Put the fedwiki chain (init, render, farm, caddy) behind a fedwiki compose profile symmetric with discourse's; the default composition is neither, selected via COMPOSE_PROFILES in test/.env, so a default stack no longer binds host 443. Guard every script and walkthrough on service presence: shared skipUnlessIntegrationEndpointReachable helper, seed-stack presence checks (also repairing its unsourced .env and container-native render invocation), generic root-owned testdata reclaim in teardown, discourse coverage in verify-stack-isolation, and fedwiki's 8090 base in the port probe. Update stack docs and finalize status bookkeeping for all three changes; archives the test-stack-integration-profiles change.
14 KiB
test/ — instructions for LLM agents
If you are an automated agent working in this repository, read this before starting Docker. The test stack uses fixed default host ports; multiple worktrees running it concurrently will collide unless each generates its own isolated configuration.
All commands in this doc assume your working directory is test/ —
that's where compose, the env file, and the binary's expected secret
paths all resolve from.
All credentials in the test stack are throwaway test-only values — the Keycloak
admin/admin, seeded client secrets, database passwords, and generated.envsecrets exist only for local development. Never reuse any of them in a real deployment.
Required first step in any worktree
cd test
./bootstrap-stack.sh
The script:
- Derives a slug from this worktree's directory name (override with
./test/bootstrap-stack.sh my-slug). - Allocates a deterministic port slot via
FNV-32(slug) % 200. Probes for collisions and advances if any candidate port is already bound on the host. - Copies
test/secrets/from the main worktree if missing locally (this directory is gitignored, so fresh worktrees do not inherit it). - Writes
test/.envwithCOMPOSE_PROJECT_NAME,COMPOSE_PROFILES, host port assignments, andMC_*env vars that overridetest/mc-config.yamlvia Viper. - Prints the URL/port table the developer should use, and which integration profiles are active.
- Idempotent: re-running with
test/.envalready in place is a no-op that reprints the summary.
Which integrations the stack composes
The stack ships core only: Postgres, Valkey, Keycloak, Temporal. Every
integration service is behind a compose profile named after it, and
COMPOSE_PROFILES in test/.env is the knob that selects them:
| Value | Composes |
|---|---|
| (empty, the default) | core services only |
fedwiki |
the wiki farm chain (fedwiki-init, fedwiki-render, fedwiki) and the Caddy TLS proxy on 443 |
discourse |
the live forum, its Postgres, and its Redis |
fedwiki,discourse |
a full demo composition |
Docker Compose reads the variable natively, so up, ps, and down all
resolve the same composition without flags, and seed-stack.sh /
teardown-stack.sh follow along automatically.
Composing is not configuring. Turning a profile on starts containers;
the app still ignores that integration until you uncomment its MC_* block in
the same test/.env. Both halves are needed for a FedWiki or Discourse flow.
Bake a composition in at generation time when you know you want it:
COMPOSE_PROFILES=fedwiki ./bootstrap-stack.sh
To change it later, edit the one COMPOSE_PROFILES line in test/.env and
re-run docker compose up -d. This is the only hand-edit test/.env
sanctions (see Constraints): re-running bootstrap would not pick up a change,
and a teardown would drop the database.
After bootstrap
docker compose up -d
set -a; . .env; set +a
go run .. start --config mc-config.yaml
keycloak-seed (creates realm users) runs automatically as a one-shot on
up. With the fedwiki profile selected, fedwiki-render does too
(it resolves real KC UUIDs and templates seed/fedwiki/*.tpl into
testdata/fedwiki/), and fedwiki waits for both to complete before
starting. Keycloak silently regenerates the id field on POST /users,
so FedWiki identity cannot be pinned at commit time — it's rendered
after each realm seed.
The member-console binary must run with test/ as its working directory
because mc-config.yaml references secrets/stripe-* via relative paths.
Compose auto-loads .env from the current directory, so no
-f/--env-file flags are needed.
Use the URLs printed by bootstrap, not the defaults from
test/mc-config.yaml — the YAML still says port: 8081, but the running
process listens on whatever MC_PORT is in test/.env.
Walking the operator UI with data
The default workflow produces a clean, empty environment — good for fresh-user
flows. To populate the operator panel (products, plan ladder, entitlement set,
one active grant) for UX walkthroughs or operator-panel exploration, run the
opt-in demo seeder from the repo root after bootstrap-stack.sh:
./test/seed-demo.sh
Idempotent — re-running is a no-op. Person rows for bob/carlos/diana are not
seeded (Keycloak doesn't honor pinned sub IDs); log in once as each demo
user to populate them. See
seed/member-console-demo/README.md for
the catalog and rationale.
Running the Go test suite
make test # from the repo root, with the stack up
That target runs test/reset-test-db.sh and then go test -count=1 -p 1 ./... with test/.env sourced. The reset drops, recreates, and migrates the
tests' own databases, so every run starts from migrations only. -p 1 runs
packages one at a time: several of them commit rows others read (an org
type's default plan ladder, a provider's group mappings), and interleaving
those makes roughly half of parallel runs fail somewhere. -count=1 is there
because Go's test cache keys on code and environment, not database state, so
after a reset a cached ok would describe a database that no longer exists.
The whole suite takes about 26 seconds.
The stack's Postgres carries three databases:
| Database | Who writes it | Env var |
|---|---|---|
member_console |
the running app, seeders, browser walkthroughs | MC_DB_DSN |
member_console_test |
every DB-backed package test | TEST_DATABASE_URL |
member_console_e2e |
test/e2e/plan-management |
E2E_DATABASE_URL |
The split is what makes the suite trustworthy, and each boundary earns its
place. Tests are off the app database because a running member-console start executes boot reconcilers (domains backfill, orphaned-placement
sweep, entitlement materialization) that adopt leftover test fixtures. The
in-process e2e suite is off the package-test database because it commits an
org type's default plan ladder, which internal/provisioning asserts the
absence of. Both were live failure modes, not hypotheticals.
Running go test ./... directly still works, but skips both the reset and
the serialization, so it is flaky by construction. Prefer make test, and
reach for a bare go test ./<pkg> only when iterating on one package.
Migrating during the reset is deliberate. Each package calls
db.RunMigrations itself and goose takes no advisory lock, so against a
genuinely empty database a dozen parallel packages would race to create the
same schemas. Pre-migrating makes each of those calls a no-op.
One caveat: ./... includes test/e2e/operator-walkthroughs, which drives a
browser against the live app. Those skip wholesale when no app is listening
(the common case for make test), but with an app up they run against the
app database and need it seeded — see the next section. A walkthrough
failure there is about that database's contents, not about the isolation the
reset provides.
If reset-test-db.sh reports a missing key, test/.env predates the split:
cd test && rm .env && ./bootstrap-stack.sh. Containers and volumes survive.
Browser walkthroughs (e2e) and stack age
test/e2e/operator-walkthroughs/ runs against whatever state the stack DB
has accumulated, and adapts rather than assumes:
- Walkthroughs skip (never fail) when their environment or subject is missing — app down, a profile-gated integration service unreachable, no org with the state they need. A skip message always names the missing precondition, including which profile would supply it.
- State-dependent walkthroughs (e.g. grant-extend) scan for a valid subject instead of trusting listing order, so orgs left behind by other walkthroughs or purchase-flow testing can't poison a run.
Skips are honest but they are still coverage gaps: for a full-exercise run,
respin first (make stack-fresh from the repo root, then boot the app and
run ./test/seed-demo.sh). These walkthroughs drive the live app over a
browser, so they legitimately read the app database and stay subject to
its accumulated state; a respin is their freshness mechanism. The Go suite
no longer needs one — make test resets its own databases.
Tearing down
./teardown-stack.sh
Brings the compose stack down with volumes, removes test/.env,
test/secrets/, and test/testdata/. Returns the worktree to a clean
state for the next experiment. It enumerates the profiles compose.yaml
declares rather than reading COMPOSE_PROFILES, so gated services come down
even if the knob was turned off after they started, and it reclaims ownership
of root-owned runtime data (Postgres, Temporal, Caddy, Discourse's Postgres)
through a short-lived container before deleting it.
Keycloak user seeding
seed/keycloak/seed-keycloak.sh creates users via POST /admin/realms/{realm}/partialImport, not POST /admin/realms/{realm}/users. Keycloak 26.x silently drops the id field on the latter (server-side ID assignment for least-privilege admin operations); partialImport preserves pinned IDs and is the supported escape hatch. See OpenSpec change 2026-05-11-keycloak-id-pinning-fix for the empirical confirmation and design rationale.
Temporal namespace
The compose stack sets SKIP_DEFAULT_NAMESPACE_CREATION=true on the
temporal service, so a one-shot temporal-seed service registers the
default namespace via the internal frontend on first up. If
member-console fails with Namespace default is not found, the seed
service didn't run yet — docker compose ... up -d temporal-seed and
re-check docker compose ... logs temporal-seed.
Stripe webhook forwarding (local activation)
Stripe cannot POST to localhost, so subscription activation (the
checkout.session.completed / customer.subscription.created →
pool-provision path) only fires locally if you forward events:
stripe login # one-time, interactive
stripe listen --forward-to localhost:$MC_PORT/webhooks/stripe
stripe listen prints a signing secret; test/secrets/stripe-webhook-secret
must equal it. The app's restricted key (rk_) cannot run stripe listen
(it lacks rak_stripecli_session_write), hence the stripe login session.
Replay a past event with stripe events resend <evt_id>. Forwarding is
account-global — see the parallel-stack constraint below.
The app's restricted key (rk_) must include read scope for Subscriptions
(plus Customers and Prices). Subscription fulfillment refetches authoritative
state from the Stripe API rather than trusting the webhook payload (OpenSpec
change stripe-fulfillment-reconcile), so a missing read scope surfaces as a
403 during reconcile — not at checkout.
Constraints
- Do not run Stripe webhook scenarios in parallel across stacks. All
stacks share the same Stripe test account via
test/secrets/stripe-*. Webhook delivery to one stack is global to that account; running two stacks through webhook flows simultaneously gives undefined results. - Do not edit
test/.envby hand, with one exception: theCOMPOSE_PROFILESline and the commented-outMC_FEDWIKI_*/MC_DISCOURSE_*opt-in blocks are meant to be edited in place. For anything else — ports above all — re-run bootstrap after deleting the file. - Do not point
TEST_DATABASE_URLorE2E_DATABASE_URLat the app database. That reinstates every pollution vector the split closes, andreset-test-db.shrefuses to dropmember_consoleprecisely so a mistake here fails loudly instead of destroying the running stack's data. - Do not remove the
${VAR:-default}fallbacks fromcompose.yaml. They preserve the single-stack default workflow when no.envis present. - The HTTPS farm (Caddy) binds host
443, so only one stack at a time can serve it — but only among stacks that select thefedwikiprofile; a core-only stack binds nothing there. Browsers must reach FedWiki sites over HTTPS becausewiki-security-socialsets Secure cookies (see project_fedwiki_security_stack); member-console's admin API still uses plain HTTP onFEDWIKI_PORT(bearer token, no cookies), so provisioning is unaffected and parallel stacks still work for everything except browser login. Caddy serves a wildcard cert from its internal CA — for manual browsing, trust the root once:docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt. Override the port withFEDWIKI_HTTPS_PORTonly if you also give member-console a matching site port (buildSiteURLemits no port). - FedWiki OAuth needs Keycloak reachable at the same host:port from both
the browser and the fedwiki container.
config.json'soauth2_discoveryUrlis rendered to${KC_HOSTNAME}:${KEYCLOAK_PORT}(browser-facing), and the fedwiki service getsextra_hosts: ${KC_HOSTNAME}:host-gatewayso the container resolves that name to the host. Don't drop either half.
What bootstrap-stack.sh writes to test/.env
COMPOSE_PROJECT_NAME=<slug>
SLOT=<0..199>
COMPOSE_PROFILES= # composition knob; empty = core stack only
POSTGRES_PORT, VALKEY_PORT, KEYCLOAK_PORT,
TEMPORAL_PORT, TEMPORAL_UI_PORT, FEDWIKI_PORT, DISCOURSE_PORT
KC_HOSTNAME, DISCOURSE_HOSTNAME, MC_BASE_URL, TEMPORAL_UI_URL
MC_PORT, MC_DB_DSN, MC_VALKEY_ADDR,
MC_OIDC_IDP_ISSUER_URL, MC_TEMPORAL_HOST,
MC_TEMPORAL_OAUTH_TOKEN_URL
#MC_FEDWIKI_* # commented-out opt-in, see below
#MC_DISCOURSE_* # commented-out opt-in, see below
TEST_DATABASE_URL, E2E_DATABASE_URL, POSTGRES_ADMIN_DSN
Compose reads the first three blocks. Viper reads the MC_* block via the
existing SetEnvPrefix("MC") + AutomaticEnv() configuration in
cmd/root.go. The last block is read by test/reset-test-db.sh and the Go
test suites; POSTGRES_ADMIN_DSN points at the cluster's maintenance
database, the only place a DROP DATABASE can run from.
Both integrations' MC_* blocks ship commented out. Each is a
required-together config group (farm URL + admin token; forum base URL + API
key file), so uncommenting half of one fails boot validation on purpose. Port
assignments for both are always written, whether or not their profile is on —
they are harmless when nothing consumes them, and it keeps flipping the knob a
one-line edit.