Fifth section of #141. internal/workflows/clientholder holds the Temporal client: start builds an empty holder, hands it to the server and the integrations, and publishes the client once, after the worker starts and before the first Startup hook; a second Publish panics. Every handler and integration that starts a workflow reads the holder when a request arrives, so a client that connects after the listener opens reaches them without a restart; a test scans the handler configurations and fails on any field that still holds a client.Client. While the holder is empty: - POST /webhooks/stripe stores the event as received, then answers 503 with Retry-After: 5 and the body "Temporal is not connected", and logs that it answered 503 so Stripe redelivers it. A redelivery after Temporal connects starts the event's workflow, and the processing guard keeps a finished event from running twice. - A Discourse event is recorded as failed with "start reconcile: Temporal is not connected"; the sweep restores what it missed. - Issuing or managing a grant with an end date is refused under the end-date field with "An end date needs the expiration scheduler, which is not connected.", after the date itself is checked, and writes nothing. The operator page's "no scheduler" warnings, which saved the grant and left it without an expiry, are gone. - FedWiki's site actions and the domain claims answer that they are not available, as they did with no Temporal configured. The operator test harnesses and the plan-management end-to-end harness publish a holder over a fake client whose ExecuteWorkflow succeeds, so every bounded save keeps its result. Full suite on reset databases: 4485 pass, 15 skip, none failed. The console booted on the test stack and connected to Temporal. go test -race -p 1 over clientholder, internal/server and the three integrations after reset-test-db.sh: every package passes, no data race. make lint passes. Breaks, each caught: - 5.10 member domains handler reads the client at construction - 5.10 FedWiki site action handler reads the client at construction - 5.10 Stripe environment-check handler reads the client at construction - 5.10 the holder is a plain field in place of an atomic.Pointer (-race) - 5.10 a client.Client field added back to MemberDomainsConfig - 5.10 the scan ignores import names, so a temporalsdk.Client field would pass - 5.10 Stripe StartProcessing reads the holder at registration - 5.10 the webhook answers 500 for ErrNotConnected - 5.10 the webhook answers 503 before the insert - 5.10 Discourse StartReconcile calls the nil client - 5.10 the issuance refusal after ConferGrant - 5.10 the management refusal removed - 5.10 the issuance refusal applied to a grant without an end date - 5.10 the management refusal applied to a save that clears the end date - 5.10 Publish stores a second client without panicking - 5.10 a temporalsdk.Client field added back to a FedWiki handler configuration - 5.10 Discourse StartReconcile reads the holder at registration - 5.10 the issuance refusal before the parse - 5.10 the management refusal before the parse - 5.10 the issuance refusal is a form error, not under the end date
532 lines
20 KiB
Go
532 lines
20 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
// The router NewHandler builds, characterized without a database. Every
|
|
// pattern the source registers (lint.Routes over this package and
|
|
// internal/auth) and the patterns that scan cannot see (the static mounts and
|
|
// a fake integration's routes) gets one row: the pattern the mux resolves a
|
|
// probe path to, and the answer the whole middleware stack gives an anonymous
|
|
// request, a cross-site request for a method that changes state, and a
|
|
// signed-in member where the handler reads nothing before it answers.
|
|
// testdata/routes.golden holds the answers; -update rewrites it.
|
|
//
|
|
// The scan reads literal HandleFunc patterns only. Not in it, so listed by
|
|
// hand below: the two Handle registrations of static files and the routes a
|
|
// mount registers. The real integrations' routes (FedWiki's 17, the Stripe
|
|
// webhook's 1 and Discourse's 5 in a fully configured deployment, 23 in all)
|
|
// mount through cfg.RouteMounts from cmd/start.go and are not probed here; the
|
|
// fake mount stands in for them.
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/spf13/viper"
|
|
"go.temporal.io/sdk/client"
|
|
"golang.org/x/oauth2"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
|
|
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/lint"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/authtest"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/golden"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/clientholder"
|
|
)
|
|
|
|
const (
|
|
routesBaseURL = "https://console.test"
|
|
routesElsewhere = "https://elsewhere.test"
|
|
routesZeroID = "00000000-0000-0000-0000-000000000000"
|
|
)
|
|
|
|
// routeEnv is a router built from a Config whose queriers have no database
|
|
// behind them, with a member session a probe can carry.
|
|
type routeEnv struct {
|
|
cfg Config
|
|
auth *auth.Config
|
|
rt *router
|
|
sm *scs.SessionManager
|
|
member *http.Cookie
|
|
deps Deps // what the fake mount was handed
|
|
}
|
|
|
|
// fakeMount stands in for an integration: a webhook exempt from both
|
|
// cross-origin protection and session auth, a route that panics, and a
|
|
// fragment a dashboard card composes. It keeps the Deps it is handed.
|
|
func fakeMount(kept *Deps) RouteMount {
|
|
return RouteMount{
|
|
Register: func(mux *http.ServeMux, deps Deps) error {
|
|
*kept = deps
|
|
mux.HandleFunc("POST /hooks/test", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
mux.HandleFunc("GET /hooks/panic", func(http.ResponseWriter, *http.Request) {
|
|
panic("the fake mount panics")
|
|
})
|
|
mux.HandleFunc("GET /hooks/card", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(w, `<p id="fake-card">fake card</p>`)
|
|
})
|
|
return nil
|
|
},
|
|
CSRFExemptPaths: []string{"/hooks/test"},
|
|
}
|
|
}
|
|
|
|
// setViper sets key for the test and puts the previous value back after it.
|
|
func setViper(t *testing.T, key string, value any) {
|
|
t.Helper()
|
|
prev := viper.Get(key)
|
|
viper.Set(key, value)
|
|
t.Cleanup(func() { viper.Set(key, prev) })
|
|
}
|
|
|
|
// routeConfig is the configured profile: a Stripe key and webhook secret,
|
|
// one dashboard card, the fake mount, a mount with static files and one
|
|
// without, and one integration configuration.
|
|
func routeConfig(kept *Deps) Config {
|
|
return Config{
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
IdentityQ: identity.New(nil),
|
|
OrgQ: organization.New(nil),
|
|
EntitlementsQ: entitlements.New(nil),
|
|
BillingQ: billing.New(nil),
|
|
StripeQ: stripedb.New(nil),
|
|
DomainsQ: domains.New(nil),
|
|
StripeAPIKey: "sk_test_routes",
|
|
StripeWebhookSecret: "whsec_routes",
|
|
StripeMode: "test",
|
|
StripeKeyFingerprint: "fingerprint-routes",
|
|
BaseURL: routesBaseURL,
|
|
RouteMounts: []RouteMount{fakeMount(kept)},
|
|
UIMounts: []UIMount{
|
|
{Key: "fake", Static: fstest.MapFS{"app.js": {Data: []byte("// the fake mount's script\n")}}},
|
|
{Key: "nostatic"},
|
|
},
|
|
DashboardCards: []DashboardCard{{Title: "Fake", PartialPath: "/hooks/card"}},
|
|
IntegrationConfigs: []IntegrationConfigInfo{{Key: "x", DisplayName: "Sentinel integration"}},
|
|
}
|
|
}
|
|
|
|
// routeAuth is an auth configuration that dials nothing: an in-memory session
|
|
// manager and an identity provider whose endpoints are never fetched. With no
|
|
// IdentityQ and no Verifier, the auth middleware passes any session that
|
|
// names a person.
|
|
func routeAuth() *auth.Config {
|
|
return &auth.Config{
|
|
SessionManager: authtest.NewSessionManager(),
|
|
OAuthConfig: &oauth2.Config{
|
|
ClientID: "console",
|
|
RedirectURL: routesBaseURL + "/callback",
|
|
Endpoint: oauth2.Endpoint{AuthURL: "https://idp.test/auth", TokenURL: "https://idp.test/token"},
|
|
},
|
|
EndSessionEndpoint: "https://idp.test/logout",
|
|
}
|
|
}
|
|
|
|
// newRouteEnv builds the router from the configured profile, after edit
|
|
// changes it. The viper keys the probed handlers read are fixed for the test.
|
|
func newRouteEnv(t *testing.T, edit func(*Config)) *routeEnv {
|
|
t.Helper()
|
|
return newRouteEnvOver(t, edit, routeAuth())
|
|
}
|
|
|
|
// newRouteEnvOver is newRouteEnv over authConfig, which a test that wants the
|
|
// console's discovery state gives in place of routeAuth's hand-built one.
|
|
func newRouteEnvOver(t *testing.T, edit func(*Config), authConfig *auth.Config) *routeEnv {
|
|
t.Helper()
|
|
setViper(t, "base-url", routesBaseURL)
|
|
setViper(t, "oidc-idp-issuer-url", "https://idp.test/realms/console")
|
|
setViper(t, "oidc-sp-client-id", "console")
|
|
env := &routeEnv{}
|
|
cfg := routeConfig(&env.deps)
|
|
if edit != nil {
|
|
edit(&cfg)
|
|
}
|
|
rt, err := newRouter(cfg, authConfig)
|
|
if err != nil {
|
|
t.Fatalf("newRouter: %v", err)
|
|
}
|
|
env.cfg, env.auth, env.rt, env.sm = cfg, authConfig, rt, authConfig.SessionManager
|
|
ctx := authtest.SignIn(t, env.sm, auth.SignIn{
|
|
PersonID: "person-routes", OrgID: "org-routes", WorkspaceID: "workspace-routes", Name: "Member",
|
|
})
|
|
env.member = authtest.Cookie(t, env.sm, ctx)
|
|
return env
|
|
}
|
|
|
|
// serve sends req through the whole handler, as the server does.
|
|
func (e *routeEnv) serve(req *http.Request) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
e.rt.handler.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// routeRow is one registered pattern and the path its probes request.
|
|
type routeRow struct {
|
|
key string // "METHOD /path", or "/path" for a method-less pattern
|
|
method string // the probes' method
|
|
probe string
|
|
member bool // a member's probe answers before any read
|
|
auth bool // registered by internal/auth: the sign-in flow
|
|
}
|
|
|
|
var pathParam = regexp.MustCompile(`\{([A-Za-z]+)(\.\.\.)?\}`)
|
|
|
|
// probePath fills a pattern's wildcards: an id with the zero UUID, any other
|
|
// name with "x". A method-less subtree pattern gets "x" after its slash.
|
|
func probePath(pattern string) string {
|
|
p := pathParam.ReplaceAllStringFunc(pattern, func(m string) string {
|
|
if strings.HasSuffix(pathParam.FindStringSubmatch(m)[1], "ID") {
|
|
return routesZeroID
|
|
}
|
|
return "x"
|
|
})
|
|
if p != "/" && strings.HasSuffix(p, "/") {
|
|
p += "x"
|
|
}
|
|
return p
|
|
}
|
|
|
|
// readsNothing are the rows outside the operator surface whose handler
|
|
// answers a member without a read.
|
|
var readsNothing = map[string]bool{
|
|
"GET /domains": true, "/partials/": true, "/static/": true, "/static/fake/": true,
|
|
"POST /hooks/test": true, "GET /hooks/panic": true, "GET /hooks/card": true,
|
|
}
|
|
|
|
func newRouteRow(method, pattern, probe string) routeRow {
|
|
key := strings.TrimSpace(method + " " + pattern)
|
|
r := routeRow{key: key, method: method, probe: probe}
|
|
if r.method == "" {
|
|
r.method = http.MethodGet
|
|
}
|
|
r.member = readsNothing[key] || strings.HasPrefix(pattern, "/operator") || strings.HasPrefix(pattern, "/partials/operator")
|
|
return r
|
|
}
|
|
|
|
// routeRows is every row, sorted by key: the scan's, then the hand-listed.
|
|
func routeRows(t *testing.T) []routeRow {
|
|
t.Helper()
|
|
var rows []routeRow
|
|
for _, dir := range []string{".", "../auth"} {
|
|
found, err := lint.Routes(dir)
|
|
if err != nil {
|
|
t.Fatalf("scan %s: %v", dir, err)
|
|
}
|
|
for _, r := range found {
|
|
row := newRouteRow(r.Method, r.Path, probePath(r.Path))
|
|
row.auth = dir == "../auth"
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
rows = append(rows,
|
|
newRouteRow("", "/static/", "/static/app.css"),
|
|
newRouteRow("", "/static/fake/", "/static/fake/app.js"),
|
|
newRouteRow("POST", "/hooks/test", "/hooks/test"),
|
|
newRouteRow("GET", "/hooks/panic", "/hooks/panic"),
|
|
newRouteRow("GET", "/hooks/card", "/hooks/card"),
|
|
)
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].key < rows[j].key })
|
|
return rows
|
|
}
|
|
|
|
// probe is one request: a native browser navigation unless edit says
|
|
// otherwise.
|
|
func probe(method, path string, edit func(*http.Request)) *http.Request {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
if edit != nil {
|
|
edit(req)
|
|
}
|
|
return req
|
|
}
|
|
|
|
func crossSite(req *http.Request) {
|
|
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
|
req.Header.Set("Origin", routesElsewhere)
|
|
}
|
|
|
|
// answer is a response as one line: the status, where it sends the browser
|
|
// (query dropped: the identity provider's carries a fresh state), the page's
|
|
// heading and lead or a plain body's first line, and the headers the stack
|
|
// adds.
|
|
func answer(t *testing.T, rec *httptest.ResponseRecorder) string {
|
|
t.Helper()
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%d", rec.Code)
|
|
if loc := rec.Header().Get("Location"); loc != "" {
|
|
loc, _, _ = strings.Cut(loc, "?")
|
|
fmt.Fprintf(&b, " loc=%s", loc)
|
|
}
|
|
if hx := rec.Header().Get("HX-Redirect"); hx != "" {
|
|
fmt.Fprintf(&b, " hx=%s", hx)
|
|
}
|
|
if retry := rec.Header().Get("Retry-After"); retry != "" {
|
|
fmt.Fprintf(&b, " retry-after=%s", retry)
|
|
}
|
|
b.WriteString(bodyFacts(t, rec))
|
|
b.WriteString(headerFacts(rec.Header()))
|
|
return b.String()
|
|
}
|
|
|
|
func bodyFacts(t *testing.T, rec *httptest.ResponseRecorder) string {
|
|
t.Helper()
|
|
if rec.Body.Len() == 0 || rec.Header().Get("Content-Encoding") != "" {
|
|
return ""
|
|
}
|
|
ctype, _, _ := mime.ParseMediaType(rec.Header().Get("Content-Type"))
|
|
out := " type=" + ctype
|
|
switch ctype {
|
|
case "text/plain":
|
|
line, _ := bufio.NewReader(strings.NewReader(rec.Body.String())).ReadString('\n')
|
|
out += " text=" + strings.TrimSpace(line)
|
|
case "text/html":
|
|
doc := htmltest.Parse(t, rec.Body.String())
|
|
if h := doc.Texts("main h1"); len(h) > 0 {
|
|
out += " h1=" + h[0]
|
|
}
|
|
if p := doc.Texts("main h1 + p"); len(p) > 0 {
|
|
out += " lead=" + p[0]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func headerFacts(h http.Header) string {
|
|
var flags []string
|
|
if h.Get("X-Content-Type-Options") == "nosniff" {
|
|
flags = append(flags, "nosniff")
|
|
}
|
|
if h.Get("X-Request-ID") != "" {
|
|
flags = append(flags, "rid")
|
|
}
|
|
if enc := h.Get("Content-Encoding"); enc != "" {
|
|
flags = append(flags, enc)
|
|
}
|
|
if acao := h.Get("Access-Control-Allow-Origin"); acao != "" {
|
|
flags = append(flags, "acao="+acao)
|
|
}
|
|
return " h=" + strings.Join(flags, ",")
|
|
}
|
|
|
|
// rowAnswers is a row's block in the golden file.
|
|
func (e *routeEnv) rowAnswers(t *testing.T, r routeRow) string {
|
|
t.Helper()
|
|
var b strings.Builder
|
|
_, pattern := e.rt.mux.Handler(probe(r.method, r.probe, nil))
|
|
fmt.Fprintf(&b, "%s probe=%s pattern=%s\n", r.key, r.probe, pattern)
|
|
fmt.Fprintf(&b, " anon: %s\n", answer(t, e.serve(probe(r.method, r.probe, nil))))
|
|
if r.method != http.MethodGet && r.method != http.MethodHead {
|
|
fmt.Fprintf(&b, " xsite: %s\n", answer(t, e.serve(probe(r.method, r.probe, crossSite))))
|
|
}
|
|
if r.member {
|
|
withMember := func(req *http.Request) { req.AddCookie(e.member) }
|
|
fmt.Fprintf(&b, " member: %s\n", answer(t, e.serve(probe(r.method, r.probe, withMember))))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// extraProbe is a request no row makes: a path no route matches, a wrong
|
|
// method, a body over the limit, a cross-origin read, a compressed answer.
|
|
type extraProbe struct {
|
|
name string
|
|
method string
|
|
path string
|
|
edit func(*http.Request)
|
|
}
|
|
|
|
var extraProbes = []extraProbe{
|
|
{"an unknown path", http.MethodGet, "/no-such-page", nil},
|
|
{"an unknown path from htmx", http.MethodGet, "/no-such-page", func(r *http.Request) { r.Header.Set("HX-Request", "true") }},
|
|
{"an unknown path, compressed", http.MethodGet, "/no-such-page", func(r *http.Request) { r.Header.Set("Accept-Encoding", "gzip") }},
|
|
{"a POST to a GET route", http.MethodPost, "/domains/ask", nil},
|
|
{"a GET to a POST route", http.MethodGet, "/billing/checkout", nil},
|
|
{"a body one byte over 1 MiB", http.MethodPost, "/partials/workspaces", func(r *http.Request) {
|
|
r.Body = io.NopCloser(strings.NewReader(strings.Repeat("x", 1<<20+1)))
|
|
r.ContentLength = 1<<20 + 1
|
|
}},
|
|
{"a mount without static files", http.MethodGet, "/static/nostatic/app.js", nil},
|
|
{"a cross-origin read", http.MethodGet, "/static/app.css", func(r *http.Request) { r.Header.Set("Origin", routesElsewhere) }},
|
|
}
|
|
|
|
// routeTable is every row's answers, the extra probes' and the bare
|
|
// configuration's, as the golden files hold them.
|
|
func routeTable(t *testing.T, env *routeEnv) string {
|
|
t.Helper()
|
|
var b strings.Builder
|
|
for _, r := range routeRows(t) {
|
|
b.WriteString(env.rowAnswers(t, r))
|
|
}
|
|
for _, x := range extraProbes {
|
|
req := probe(x.method, x.path, x.edit)
|
|
_, pattern := env.rt.mux.Handler(req)
|
|
fmt.Fprintf(&b, "extra: %s %s %s pattern=%s\n anon: %s\n", x.name, x.method, x.path, pattern, answer(t, env.serve(req)))
|
|
}
|
|
bare := newRouteEnvOver(t, func(c *Config) { c.StripeAPIKey = "" }, env.auth)
|
|
req := probe(http.MethodPost, "/billing/checkout", nil)
|
|
_, pattern := bare.rt.mux.Handler(req)
|
|
fmt.Fprintf(&b, "bare: POST /billing/checkout pattern=%s\n anon: %s\n", pattern, answer(t, bare.serve(req)))
|
|
return b.String()
|
|
}
|
|
|
|
// TestNewHandlerRouteTable holds every route's pattern and answers to
|
|
// testdata/routes.golden, with the extra probes and the one row a
|
|
// configuration without a Stripe key answers differently.
|
|
func TestNewHandlerRouteTable(t *testing.T) {
|
|
golden.Compare(t, "testdata/routes.golden", routeTable(t, newRouteEnv(t, nil)))
|
|
}
|
|
|
|
// TestNewHandlerRouteTableNotReady is the same table over the console's own
|
|
// authentication configuration before the identity provider's discovery
|
|
// document is published (auth.NewAwaitingDiscovery) and before Temporal
|
|
// connects (an empty clientholder.Holder). Both tables run without a Temporal
|
|
// client (routeConfig sets no holder), so its diff from routes.golden is the
|
|
// sign-in rows.
|
|
func TestNewHandlerRouteTableNotReady(t *testing.T) {
|
|
env := newRouteEnvOver(t, func(c *Config) { c.Temporal = clientholder.New() },
|
|
auth.NewAwaitingDiscovery(authtest.NewSessionManager()))
|
|
golden.Compare(t, "testdata/routes-not-ready.golden", routeTable(t, env))
|
|
}
|
|
|
|
// TestNewHandlerUnavailableSignInIsStyled: a browser's /login before the
|
|
// discovery document is published renders the styled page with the heading
|
|
// "Sign-in unavailable" and one action, "Try again", back to /login (copy
|
|
// row 1).
|
|
func TestNewHandlerUnavailableSignInIsStyled(t *testing.T) {
|
|
env := newRouteEnvOver(t, nil, auth.NewAwaitingDiscovery(authtest.NewSessionManager()))
|
|
rec := env.serve(probe(http.MethodGet, "/login", nil))
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("/login before discovery = %d, want 503", rec.Code)
|
|
}
|
|
doc := htmltest.Parse(t, rec.Body.String())
|
|
if got := doc.One(t, "main h1").Text(); got != "Sign-in unavailable" {
|
|
t.Errorf("heading = %q, want Sign-in unavailable", got)
|
|
}
|
|
if got := doc.One(t, `main a[href="/login"]`).Text(); got != "Try again" {
|
|
t.Errorf("action = %q, want Try again", got)
|
|
}
|
|
if got := len(doc.Texts("main a")); got != 1 {
|
|
t.Errorf("the page has %d actions, want one", got)
|
|
}
|
|
}
|
|
|
|
// TestNewHandlerTrustsTheBaseURLOrigin: a cross-site POST from the base URL's
|
|
// origin passes cross-origin protection (and meets the sign-in bounce),
|
|
// and one from any other origin is refused.
|
|
func TestNewHandlerTrustsTheBaseURLOrigin(t *testing.T) {
|
|
env := newRouteEnv(t, nil)
|
|
post := func(origin string) *httptest.ResponseRecorder {
|
|
return env.serve(probe(http.MethodPost, "/partials/workspaces", func(r *http.Request) {
|
|
r.Header.Set("Sec-Fetch-Site", "cross-site")
|
|
r.Header.Set("Origin", origin)
|
|
}))
|
|
}
|
|
if rec := post(routesBaseURL); rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
|
|
t.Errorf("POST from %s = %d to %q, want the sign-in bounce", routesBaseURL, rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
if rec := post(routesElsewhere); rec.Code != http.StatusForbidden {
|
|
t.Errorf("POST from %s = %d, want 403", routesElsewhere, rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestNewHandlerRefusedSignInIsStyled: a callback whose state is not the one
|
|
// /login stored answers the styled page naming the failure, with the way
|
|
// back to sign-in (spec error-pages, "A refused sign-in renders the styled
|
|
// page").
|
|
func TestNewHandlerRefusedSignInIsStyled(t *testing.T) {
|
|
env := newRouteEnv(t, nil)
|
|
login := env.serve(probe(http.MethodGet, "/login", nil))
|
|
cookies := login.Result().Cookies()
|
|
if len(cookies) == 0 {
|
|
t.Fatal("/login set no session cookie")
|
|
}
|
|
rec := env.serve(probe(http.MethodGet, "/callback?state=wrong", func(r *http.Request) { r.AddCookie(cookies[0]) }))
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("callback = %d, want 400", rec.Code)
|
|
}
|
|
doc := htmltest.Parse(t, rec.Body.String())
|
|
if got := doc.One(t, "main h1").Text(); got != "Sign-in expired" {
|
|
t.Errorf("heading = %q, want Sign-in expired", got)
|
|
}
|
|
if got := doc.One(t, `main a[href="/login"]`).Text(); got != "Sign in again" {
|
|
t.Errorf("action = %q, want Sign in again", got)
|
|
}
|
|
}
|
|
|
|
// TestNewHandlerRefusesAMountTemplateOutsideItsKey: a mount's member
|
|
// templates join the root set, so a file without the mount's key prefix
|
|
// stops the build (UIMount's contract).
|
|
func TestNewHandlerRefusesAMountTemplateOutsideItsKey(t *testing.T) {
|
|
setViper(t, "base-url", routesBaseURL)
|
|
var kept Deps
|
|
cfg := routeConfig(&kept)
|
|
cfg.UIMounts = append(cfg.UIMounts, UIMount{Key: "fake", Templates: fstest.MapFS{
|
|
"other.html": {Data: []byte(`{{ define "other" }}{{ end }}`)},
|
|
}})
|
|
defer func() {
|
|
if p := recover(); p == nil || !strings.Contains(fmt.Sprint(p), `without the required "fake" key prefix`) {
|
|
t.Errorf("newRouter panic = %v, want the key-prefix refusal", p)
|
|
}
|
|
}()
|
|
_, _ = newRouter(cfg, routeAuth())
|
|
}
|
|
|
|
// sentinelTemporal is a Temporal client no call reaches; only its identity
|
|
// is compared.
|
|
type sentinelTemporal struct{ client.Client }
|
|
|
|
// TestNewHandlerPassesDepsToMounts: a mount is handed core's configuration,
|
|
// the auth configuration and an Include that dispatches through the router.
|
|
func TestNewHandlerPassesDepsToMounts(t *testing.T) {
|
|
env := newRouteEnv(t, func(c *Config) {
|
|
c.Database, c.Temporal = new(sql.DB), holderOver(&sentinelTemporal{})
|
|
c.DomainsPolicy = domains.Policy{PendingCap: 7}
|
|
c.DomainsConnectTarget = "connect.test"
|
|
})
|
|
d, c := env.deps, env.cfg
|
|
for field, same := range map[string]bool{
|
|
"Database": d.Database == c.Database,
|
|
"IdentityQ": d.IdentityQ == c.IdentityQ,
|
|
"OrgQ": d.OrgQ == c.OrgQ,
|
|
"EntitlementsQ": d.EntitlementsQ == c.EntitlementsQ,
|
|
"BillingQ": d.BillingQ == c.BillingQ,
|
|
"Temporal": d.Temporal == c.Temporal,
|
|
"AuthConfig": d.AuthConfig == env.auth,
|
|
"Logger": d.Logger == c.Logger,
|
|
"DomainsPolicy": d.DomainsPolicy == c.DomainsPolicy,
|
|
"DomainsConnectTarget": d.DomainsConnectTarget == c.DomainsConnectTarget,
|
|
} {
|
|
if !same {
|
|
t.Errorf("Deps.%s is not the configured value", field)
|
|
}
|
|
}
|
|
if d.Include == nil {
|
|
t.Fatal("Deps.Include is nil")
|
|
}
|
|
body, err := d.Include(httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil), "/hooks/card")
|
|
if err != nil || string(body) != `<p id="fake-card">fake card</p>` {
|
|
t.Errorf("Include(/hooks/card) = %q, %v, want the fake card", body, err)
|
|
}
|
|
}
|