Files
cgalo5758 3bfd0f67f4 Read the Temporal connection per request, and answer for its absence
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
2026-10-23 19:11:03 -04:00

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)
}
}