HTTP end-to-end tests
Read this when you add or change an operation's HTTP golden cases, fixture tokens or relationships, or anything that touches the shared identity fixtures. The general test principles are in Testing.
HTTP E2E suite
- Each Bazel shard's
TestMaincreates its own disposable database, applies migrations and constructs the production HTTP router once, with real JWT verification, ban checks and PostgreSQL-backed permissions. There is no second bypass router or injected administrator identity. Handlers run in process, without HTTP listeners. - The production router, profile-service resets, alternate scoring/dependency
routers and real worker journeys all use the restricted
AppPool. Fixture reset and verification SQL use the ownerPool; an owner-pool application router would bypass the policies the suite must exercise. - Bazel uses native rules_go process sharding. Each process owns its database,
application role, router, JWT verification clock and leased Valkey logical
database; each golden case and journey owns its business clock and UUID
stream. Serial golden cases and journeys run one after another within a
process, each after
reset, never between dependent requests. Cases and journeys that run as scenarios own private tenants instead of a reset, and run in parallel after every serial test has finished. - The router uses the default deployed API request budget of 30 seconds,
including cost-12 password hashing. The complete E2E target uses an explicit
timeout = "long"(900-second) budget so race instrumentation can cover real bcrypt work without reducing production-cost controls or skipping throttle requests. Provider fixtures retain their own bounded timeouts; request-deadline behavior is tested at the transport boundary. resetrunsinternal/testpostgres/cleanup.sql, which explicitly lists the mutable tables to truncate withrestart identity. Add newly tested mutable tables to that one file. Do not discover tables automatically or usecascade.- Migration-seeded static tables and
schema_migrationsare preserved. When a preserved table's foreign key makes PostgreSQL rejecttruncate, usedeleteinstead and document the exception incleanup.sql. - Cases load their starting data from their own SQL and relationship files; fixtures are not generated in Go.
- Reset and seeding commit before requests run, so requests use normal
application transactions and real commits. There is no outer rollback
transaction, and tests do not change
RunInTransaction. - Pool-failure, cancellation and routing checks are separate Go tests; they exercise dependency behavior rather than response cases.
- Request-body byte limits are proved the same way, in
services/tadoku-api/e2e/request_body_limit_test.go. It builds oversized and allowed bodies in code against existing fixture tokens, so no oversized request fixture is checked in. - Lifecycle tests stay focused on startup and shutdown; keep endpoint assertions out of them.
- The email gateway fixture checks cookie and bearer routing with real local providers; it supplements the live branch-host gate.
- Unrelated gateway infrastructure stays outside these in-process tests.
Tenant feature-flag isolation
services/tadoku-api/e2e/flipt_tenant_test.go compares a complete sequence of
public decision HTTP responses under testdata/FliptTenantIsolation/ for the
same account in canonical and test tenants. The test sets up a test grant, a
canonical revoke and a test revoke through the real Flipt management adapter
against the provider fixture; the application has no management endpoint.
It freezes the normal fixture clock, uses signed tenant JWTs, real permission
relationships and the non-owner application pool, and preserves the opposite
tenant's decisions.
The Flipt fixture is an explicit provider simulation: it stores independent
members and revisions per environment/namespace and uses the production
Targets mapping. SeedTenant provisions another parsed test tenant for a
journey after reset. Separate Flipt SDK integration and operator verification
exercise the provider HTTP boundary.
HTTP golden cases
For every operation, each golden case runs SQL and relationship setup plus a signed HTTP request through the production router and compares the complete response with its golden.
Case table
- Each operation's HTTP tests use one explicit Go golden-case table. Do not add separate generated-ID, persistence-readback or hand-decoded response tests beside it; persistence assertions belong in repository tests.
- Each row declares
description []stringandwantas an HTTP status constant.APITestName(operation, want, description...)names both the subtest and its fixture directory, so there is no separate fixture-name field to keep in sync. - Add a case by adding a descriptive table row and its request and golden files. Do not discover cases from directories.
- Cover each operation's response mapping, input handling, business rules and relevant boundaries.
Fixture layout
Case files are language-neutral and reviewable:
e2e/testdata/<operation>/
setup.sql # optional operation-level seed
setup.<role>.sql # optional seed for a scenario role's tenant
relationships.json # optional operation-level permission relationships
<status>_<description>/
setup.sql # optional
finalize.sql # optional final state after the account cast
relationships.json # optional permission relationship array
request.http
golden.http
- Every case has
request.httpandgolden.http. Request bodies are JSON only; do not add non-JSON request fixtures. golden.httpcontains the request label and the complete expected response.
Seed resolution
Seed files (setup.sql, finalize.sql, relationships.json) resolve per file name, one level
deep:
- A non-empty case file is the case's only seed for that name. It replaces the operation file rather than extending it.
- A zero-byte case file opts the case out: it loads no seed for that name, even when the operation has one. Keep these files; they are intentional.
- A missing case file inherits
testdata/<operation>/<name>. There is no fallback beyond the operation directory.
Put seed data shared by most cases in the operation file, add a case file only
when a case needs different data, and add a zero-byte case file when a case must
start without the operation's data. Shared PostgreSQL cleanup, including
permission_relationships, always runs, even without setup. A resolved seed
file that does not exist is skipped; other read, SQL, decode and provider errors
fail the scenario.
Relationship files
relationships.json is an array of permission relationships; unknown fields
fail decoding:
[
{
"tenant": "tadoku/prod",
"object": "app:root",
"relation": "admins",
"subject": "user:11111111-1111-4111-8111-111111111111"
}
]
The serial harness runs the resolved setup.sql, testdata/accounts.sql and the
resolved finalize.sql, then replaces every stored relationship with the
production baseline and the
resolved file's rows. A row's tenant must be registered by then: for a test
tenant, insert its registry row in the case's setup.sql with
on conflict (key) do nothing. A scenario case seeds the same files into its
own tenants instead, giving each row to the tenant that stands in for the row's
tenant. Its SQL for a test tenant goes in the operation's setup.<role>.sql
instead of setup.sql; see
Golden cases and journeys as scenarios.
Comparison
- Tests parse
request.httpwithnet/http, run the production handler at the operation's minimum required access level, check the status againstwant, and compare the entire response, including status, headers and body, with the golden. - Compare directly; do not decode the response into the generated types the handler uses.
- Explicit seed IDs, a per-case UUID source and frozen business time make responses deterministic; see Time in tests. Never rewrite SQL inside a test connector or rewrite response fields. The one exception is a scenario case's tenant alias, which names each generated test tenant by its role.
- Buffered response goldens are serialized with
httputil.DumpResponse. WhenContentLengthis unknown, fill it from the recorder body's byte length before serialization. Preserve known declared lengths and explicitly setConnectionheaders. - This framing treats an omitted
Content-Lengthand an explicitly correct one as equivalent. Apart from completing the unknown length, only HTTP line endings are normalized. - Missing or changed goldens fail the test.
Regenerating goldens
Tests never rewrite goldens by default. To regenerate existing goldens intentionally, run this uncached command from the repository root:
TADOKU_GOLDEN_SOURCE_ROOT="$PWD/services/tadoku-api/e2e/testdata" \
bazel test //services/tadoku-api/e2e:e2e_test \
--test_env=TADOKU_GOLDEN_SOURCE_ROOT \
--test_arg=-update-goldens \
--sandbox_writable_path="$PWD/services/tadoku-api/e2e/testdata" \
--cache_test_results=no \
--test_output=all
- The explicit source root makes the command write the checked-in fixtures instead of runfiles copies. The actual response is recorded into each existing golden file.
-update-goldensrewrites only golden files. It never creates, removes or changes seed files, never creates a missing golden and never records a response with an unexpected status. It exits before starting dependencies whenCIis set.- Review every rewritten path and the complete Git diff, then explain the intentional contract change in the PR body.
Authentication in E2Es
- Endpoint E2Es run through the production JWT and ban middleware and the real
permission checker on the restricted
AppPool. Never substitute passthrough middleware, inject an identity or administrator claim, or install an always-allow permission checker. - Put synthetic signed tokens in
request.httpand required role relationships in the case'srelationships.json. Administrator scenarios seed explicitadminsrelationships; an absent relationship file grants no roles. Public scenarios use signedguesttokens, as the gateway does. - Keep the full invalid-credential, role, ban and permission-store matrix at
the shared middleware boundary instead of repeating it for every endpoint.
Simulate a permission-store outage with
withPermissionsPoolinservices/tadoku-api/e2e/router_options_test.go, which gives the checker a closed pool. Role reads and ban writes use the router's transaction pool; make their writes fail inside the database instead. - Test authentication goldens at the middleware boundary, not on a business
endpoint. They register
GET /test/authenticationon the same production router with a test-only success handler, and test-only identity headers prove downstream identity propagation.X-Test-Tenantproves the parsed signed tenant also reached the handler.AuthenticationBranchgoldens construct the same production router for one scenario, deployed for its primary tenant, and prove the own-tenant success and other-tenant421boundary. The successful case seeds no relationships: admission needs no grant on any tenant. Ban-policy scenarios registerGET /test/bannedthe same way and cover guests, ungranted users and bans on both canonical and test tenants. Permission-store fail-open behavior and deadlines are tested at that boundary. No test endpoint is added to production. - The transport router test proves that every registered application route inherits the shared gates.
- The suite serves a synthetic, checked-in public JWKS
(
e2e/testdata/authentication.jwks.json) locally, plus the public key of the per-process scenario fixture signing key. No checked-in private keys or live identity providers are needed.
Time in tests
TestMainfixesjwt.TimeFuncat the signed fixtures' verification instant,2026-09-12T12:00:00Z(1789214400), before any test runs. Tests do not write it, except one whose tokens are signed at another time, such as the Oathkeeper-minted tokens of the email gateway fixture; it restores the fixture instant in cleanup.- The golden runner gives each case its own
timex.Clockat the case's business time and its own UUID stream, and attaches both to the request context withtimex.WithClockanduuidx.WithSource. The default stream starts with sixteen0x11bytes; a case table may set a seed instead. Business time moves independently of token expiry, and production, which attaches neither, keeps real time and random IDs. - A test outside the runners that needs fixed business time either attaches a
clock to its requests' contexts or wraps them in
timex.TheWorld(atFixtureInstantfor the fixture instant), never the whole suite inTestMain.TheWorldapplies only to contexts without a clock; a test may use separate, non-nested scopes. - To let business time pass while a request is in flight, call
Advanceon its clock, ortimex.Advanceinside aTheWorldscope. Release whatever the request waits on and wait for it to finish before the scope returns, on failure paths too. - Tests that use
timex.TheWorldoruuid.SetRandor overridejwt.TimeFunc, and their parent tests, must not callt.Parallel.
Latency-padding checks use testing/synctest with controlled processing delays
below and above the floor. Keep the real router, restricted database role,
admission store, rendering and transactional writes. Virtual time checks timer
policy, not production I/O latency; retain real-clock minimum-delay and
production-cost password checks. Short wall-clock sample means cannot establish
constant-time behavior under host stalls. Minimum padding does not conceal work
that exceeds its floor.
Create and close bubble clients and pools inside that bubble. Use synchronous
Valkey (DisableAutoPipelining): background socket reads prevent virtual time
from advancing. Finish the local JWKS exchange and close its server before
measuring timers; finish the scenario before key refresh is due. Never reuse HTTP
keep-alive connections across bubbles.
Signing a new fixture token
e2e/testdata/TenantIsolation/ contains synthetic signed canonical and
e2e/isolation-0123abcd tokens for the same seeded account subject. Its test
registry entry exists only in the suite's disposable database. A case that
needs an administrator or a ban on the test tenant adds a relationship for that
tenant. Changing a tenant never grants an administrator identity or bypasses
the JWT and ban gates. Seed rows
name their tenant explicitly, and owner teardown removes only the fixture's
test entry. Do not insert test tenants in the shared development base or
production database.
Reuse an existing signed request when only its relationships change.
When a scenario needs different JWT claims, generate a new synthetic key and
token locally with Node's built-in cryptography, then append the printed public
JWK to the existing keys array without removing any checked-in keys. Run this
from the repository root with Node.js and jq installed:
fixture_output=$(mktemp)
node <<'NODE' > "$fixture_output"
const { generateKeyPairSync, randomUUID, sign } = require("node:crypto");
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const kid = `tadoku-fixture-${randomUUID()}`;
const publicJWK = {
...publicKey.export({ format: "jwk" }),
kid,
use: "sig",
alg: "RS256",
};
const claims = {
iss: "http://oathkeeper-api/",
sub: "22222222-2222-4222-8222-222222222222",
iat: 1789214400,
nbf: 1789214400,
exp: 1789218000,
type: "user",
tenant: "tadoku/prod",
session: {
identity: {
traits: {
display_name: "Fixture User",
email: "fixture@example.test",
},
},
},
};
const encode = value => Buffer.from(JSON.stringify(value)).toString("base64url");
const signingInput = `${encode({ alg: "RS256", typ: "JWT", kid })}.${encode(claims)}`;
const signature = sign("RSA-SHA256", Buffer.from(signingInput), privateKey).toString("base64url");
process.stdout.write(JSON.stringify({ publicJWK, token: `${signingInput}.${signature}` }, null, 2));
NODE
jq --slurpfile fixture "$fixture_output" \
'.keys += [$fixture[0].publicJWK]' \
services/tadoku-api/e2e/testdata/authentication.jwks.json \
> services/tadoku-api/e2e/testdata/authentication.jwks.json.new
jq -r .token "$fixture_output"
- Review the
.newJWKS before replacing the fixture, and paste the printed token into the newrequest.http. - The private key exists only inside that Node process. Editing claims in a token by hand invalidates its signature, so rerun the recipe instead.
- Never use production signing keys or tokens in fixtures.
Local account fixtures
e2e/testdata/accounts.sql seeds the fixed five-user cast in tadoku/prod and
e2e/alpha-0000000a, matching the signed cast's tenants and subjects. Its
synthetic bcrypt cost-4 hash keeps fixture login cheap; production registration
and the dedicated password-policy workflows retain cost 12. Sessions come from
real API login.
Ordinary native-identity fixtures also use cost-4 hashes. The login timing and throttle control explicitly restores cost-12 reader hashes and checks their parsed bcrypt cost before exercising known and unknown accounts.
The IP-throttle fixture seeds wrong-password cost-4 accounts for repeated login attempts, while keeping an unknown-account cost-12 dummy comparison in each tenant and the cost-12 successful fail-open login. All throttle requests still use the real router, PostgreSQL and Valkey. Registration, credential-storage, password-policy and real-clock timing controls keep production cost 12.
Ordinary golden and journey resets load case SQL before the account seed.
Explicit user/account primary-key conflicts preserve existing rows, including
case-specific names, timestamps, inactive accounts and deleted or locked users.
Repeating the seed is safe; other constraint failures fail setup. Optional
finalize.sql uses the same case/operation resolution and runs after the cast
in the reset transaction. Put final absence mutations there so the default cast
cannot resurrect a user that a scenario deliberately removes. Focused scenarios
that define a different account population reset PostgreSQL with their own
explicit seed.
Profile, account-age and role-update requests use the production identity Reader and restricted application pool. The signed cast workflow verifies each unique tenant/subject pair through the real JWT router, rejects a foreign session under the target tenant's cookie name, and checks unavailable states and reset. Roles remain real permission relationships.
Email gateway fixture
HTTP email gateway fixture covers cookie and bearer routing through checksum-pinned Oathkeeper, including local checks of rendered production gateway rules.