// Full-stack proof for anonymousAccess: handlers that allow roles=["anonymous"] // must be reachable WITHOUT a JWT, while the rest of /api/* still requires // authentication. Covers the resolution chain (defaultTenantId, X-Tenant // header, kumiko_tenant cookie, custom resolver) plus the rejection paths // (no tenant, unknown tenant, openToAll-protected). // // Bun.SQL-only setup. KEIN postgres-js, KEIN setupTestStack. import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { z } from "zod"; import type { AnonymousAccessConfig } from "../api/auth-middleware"; import { createEventStoreExecutor } from "../db/event-store-executor"; import { asRawClient, selectMany } from "../db/query"; import { buildEntityTable } from "../db/table-builder"; import { ANONYMOUS_USER_ID, createEntity, createTextField, defineFeature, type TenantId, } from "../engine"; import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../stack"; const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId; const OTHER_TENANT_ID = "00000000-0000-4000-8000-000000000002" as TenantId; // --- Feature --- const productEntity = createEntity({ table: "anon_products", fields: { name: createTextField({ required: true }), }, }); const productTable = buildEntityTable("product", productEntity); const orderEntity = createEntity({ table: "anon_orders", fields: { productName: createTextField({ required: true }), placedBy: createTextField({ default: "" }), }, }); const orderTable = buildEntityTable("order", orderEntity); const shopFeature = defineFeature("anonshop", (r) => { r.entity("product", productEntity); r.entity("order", orderEntity); // Public listing — anonymous + authenticated customers see it. r.queryHandler( "product:list", z.object({}), async (_event, ctx) => { const rows = await selectMany(ctx.db, productTable); return rows; }, { access: { roles: ["anonymous", "User", "Admin"] } }, ); // Authenticated-only listing — confirms openToAll still rejects anonymous. r.queryHandler( "product:list-auth-only", z.object({}), async (_event, ctx) => { const rows = await selectMany(ctx.db, productTable); return rows; }, { access: { openToAll: true } }, ); // Anonymous can place a guest order. r.writeHandler( "order:guest-checkout", z.object({ productName: z.string().min(1) }), async (event, ctx) => { const crud = createEventStoreExecutor(orderTable, orderEntity, { entityName: "order" }); return crud.create( { productName: event.payload.productName, placedBy: event.user.id }, event.user, ctx.db, ); }, { access: { roles: ["anonymous", "User"] } }, ); // Admin-only — confirms role-gated handlers still reject anonymous. r.writeHandler( "product:create", z.object({ name: z.string().min(1) }), async (event, ctx) => { const crud = createEventStoreExecutor(productTable, productEntity, { entityName: "product", }); return crud.create({ name: event.payload.name }, event.user, ctx.db); }, { access: { roles: ["Admin"] } }, ); }); // --- Suite --- describe("anonymous access — single-tenant default", () => { let stack: TestStack; beforeAll(async () => { stack = await setupTestStack({ features: [shopFeature], anonymousAccess: { defaultTenantId: TENANT_ID }, }); await unsafeCreateEntityTable(stack.db, productEntity); await unsafeCreateEntityTable(stack.db, orderEntity); }); afterAll(() => stack.cleanup()); beforeEach(async () => { await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`); await asRawClient(stack.db).unsafe(`DELETE FROM "${orderTable.tableName}"`); }); test("anonymous query succeeds without any auth headers", async () => { // Seed a product with the admin user so the query has data to return. await stack.http.writeOk( "anonshop:write:product:create", { name: "Espresso Beans" }, TestUsers.admin, ); const res = await stack.http.raw("POST", "/api/query", { type: "anonshop:query:product:list", payload: {}, }); expect(res.status).toBe(200); const body = (await res.json()) as { data: Array<{ name: string }> }; expect(body.data).toHaveLength(1); expect(body.data[0]?.name).toBe("Espresso Beans"); }); test("anonymous write succeeds and records actor=anonymous", async () => { const res = await stack.http.raw("POST", "/api/write", { type: "anonshop:write:order:guest-checkout", payload: { productName: "Espresso Beans" }, }); expect(res.status).toBe(200); const body = (await res.json()) as { isSuccess: boolean }; expect(body.isSuccess).toBe(true); // Verify the row landed with placedBy=anonymous in the DB. Confirms the // synthesised SessionUser actually flows through to the handler. const rows = await selectMany(stack.db, orderTable); expect(rows).toHaveLength(1); expect(rows[0]?.["placedBy"]).toBe(ANONYMOUS_USER_ID); }); test("openToAll handler rejects anonymous (regression guard)", async () => { // The advisor-flagged regression: enabling anonymousAccess must NOT // silently expose every existing openToAll endpoint. hasAccess refuses // anonymous on openToAll, so the dispatcher returns AccessDenied. const res = await stack.http.raw("POST", "/api/query", { type: "anonshop:query:product:list-auth-only", payload: {}, }); expect(res.status).toBe(403); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("access_denied"); }); test("role-gated handler still rejects anonymous", async () => { const res = await stack.http.raw("POST", "/api/write", { type: "anonshop:write:product:create", payload: { name: "Tea Set" }, }); expect(res.status).toBe(403); }); test("authenticated user with JWT bypasses anonymous path entirely", async () => { const res = await stack.http.query("anonshop:query:product:list", {}, TestUsers.admin); expect(res.status).toBe(200); }); test("X-Tenant header that disagrees with default → 400 tenant_mismatch", async () => { // Single-tenant mode is locked: a client cannot override defaultTenantId // by sending a different X-Tenant. Silent acceptance would let a // confused client write into the wrong tenant of a single-tenant // deployment that happened to have data for OTHER_TENANT_ID. const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { "X-Tenant": OTHER_TENANT_ID }, ); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("tenant_mismatch"); }); test("X-Tenant header matching default → accepted", async () => { // Same default, redundant header — fine. const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { "X-Tenant": TENANT_ID }, ); expect(res.status).toBe(200); }); }); describe("anonymous access — header-supplied tenant", () => { let stack: TestStack; beforeAll(async () => { stack = await setupTestStack({ features: [shopFeature], anonymousAccess: { // No defaultTenantId — every anonymous request must declare its tenant. tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID, }, }); await unsafeCreateEntityTable(stack.db, productEntity); await unsafeCreateEntityTable(stack.db, orderEntity); }); afterAll(() => stack.cleanup()); test("X-Tenant header resolves the tenant", async () => { const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { "X-Tenant": TENANT_ID }, ); expect(res.status).toBe(200); }); test("malformed X-Tenant header → 400 invalid_tenant_format", async () => { // Junk strings (SQL fragments, path traversals, plain typos) must never // reach the pipeline as a TenantId. parseTenantId rejects anything that // isn't a UUID-shape, the middleware turns that into a 400 here. const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { "X-Tenant": "not-a-uuid" }, ); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string; details: { source: string } }; }; expect(body.error.code).toBe("invalid_tenant_format"); expect(body.error.details.source).toBe("X-Tenant header"); }); test("missing tenant → 400 tenant_required", async () => { const res = await stack.http.raw("POST", "/api/query", { type: "anonshop:query:product:list", payload: {}, }); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string; i18nKey: string } }; expect(body.error.code).toBe("tenant_required"); expect(body.error.i18nKey).toBe("auth.errors.tenantRequired"); }); test("/api/auth/* without JWT → 401 missing_token (not 400 tenant_required)", async () => { // Auth-routes (tenants, switch-tenant, logout) brauchen einen JWT, // aber keinen Tenant-Resolve. Vor dem Fix fielen sie in handleAnonymous, // das beim resolveTenant ohne X-Tenant 400 tenant_required wirft — // falsche Diagnose, der Caller ist unauthenticated, nicht ohne Tenant. // Login bleibt davon unberührt (in PUBLIC_API_PATHS, skipped vor auth). const res = await stack.http.raw("GET", "/api/auth/tenants"); expect(res.status).toBe(401); const body = (await res.json()) as { error: { code: string; i18nKey: string } }; expect(body.error.code).toBe("missing_token"); expect(body.error.i18nKey).toBe("auth.errors.missingToken"); }); test("authenticated request with conflicting X-Tenant header → 400 tenant_mismatch", async () => { // JWT carries tenantId=TENANT_ID, but the client sends X-Tenant for a // different tenant. Silent ignore would let the client think it's // hitting OTHER_TENANT_ID while it's actually on TENANT_ID — defensive // reject, same shape as ambiguous_auth. const token = await stack.jwt.sign(TestUsers.admin); const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { Authorization: `Bearer ${token}`, "X-Tenant": OTHER_TENANT_ID }, ); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("tenant_mismatch"); }); test("unknown tenant → 404 tenant_not_found", async () => { const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { "X-Tenant": "00000000-0000-4000-8000-deadbeefdead" }, ); expect(res.status).toBe(404); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("tenant_not_found"); }); }); describe("anonymous access — resolverTrust: authoritative", () => { let stack: TestStack; beforeAll(async () => { // Simulates a subdomain resolver: TENANT_ID is "known" (the resolver // recognises it), everything else is an unrecognised host (null). stack = await setupTestStack({ features: [shopFeature], anonymousAccess: { tenantResolver: () => TENANT_ID, resolverTrust: "authoritative", tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID, }, }); await unsafeCreateEntityTable(stack.db, productEntity); await unsafeCreateEntityTable(stack.db, orderEntity); }); afterAll(() => stack.cleanup()); test("no client tenant → resolver wins", async () => { const res = await stack.http.raw("POST", "/api/query", { type: "anonshop:query:product:list", payload: {}, }); expect(res.status).toBe(200); }); test("X-Tenant header agreeing with the resolver → accepted", async () => { const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { "X-Tenant": TENANT_ID }, ); expect(res.status).toBe(200); }); test("X-Tenant header disagreeing with the resolver → 400 tenant_mismatch, resolver's tenant is NOT overridden", async () => { // The core #51 regression: a header claiming a different (real, active) // tenant than the one the resolver derived must never win — that would // let a guest on tenantA's subdomain write into tenantB by forging a // header. Reject, don't silently prefer either side. const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { "X-Tenant": OTHER_TENANT_ID }, ); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("tenant_mismatch"); }); // pr-review kumiko-framework #1052/3 — resolveTenant treats header and // cookie identically, but every case above only exercised the header. // These two lock in that the cookie path behaves the same way. test("kumiko_tenant cookie agreeing with the resolver → accepted", async () => { const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { Cookie: `kumiko_tenant=${TENANT_ID}` }, ); expect(res.status).toBe(200); }); test("kumiko_tenant cookie disagreeing with the resolver → 400 tenant_mismatch, same as a disagreeing header", async () => { const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { Cookie: `kumiko_tenant=${OTHER_TENANT_ID}` }, ); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("tenant_mismatch"); }); }); describe("anonymous access — resolverTrust: authoritative, resolver returns null", () => { let stack: TestStack; beforeAll(async () => { // Resolver never recognises the host (e.g. an unmapped/unknown // subdomain) — always returns null. stack = await setupTestStack({ features: [shopFeature], anonymousAccess: { tenantResolver: () => null, resolverTrust: "authoritative", tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID, }, }); await unsafeCreateEntityTable(stack.db, productEntity); await unsafeCreateEntityTable(stack.db, orderEntity); }); afterAll(() => stack.cleanup()); test("resolver null + X-Tenant header present → 400 tenant_required, header does NOT pick the tenant", async () => { // If this fell back to the client header, an unrecognised-host request // could still choose its own tenant via X-Tenant — the same override // "authoritative" exists to prevent, just reached via "unknown host" // instead of "known host, disagreeing header". const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { "X-Tenant": TENANT_ID }, ); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("tenant_required"); }); test("resolver null + no client tenant → 400 tenant_required", async () => { const res = await stack.http.raw("POST", "/api/query", { type: "anonshop:query:product:list", payload: {}, }); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe("tenant_required"); }); }); describe("anonymous access — resolverTrust: fallback-only", () => { let stack: TestStack; beforeAll(async () => { stack = await setupTestStack({ features: [shopFeature], anonymousAccess: { tenantResolver: () => TENANT_ID, resolverTrust: "fallback-only", tenantExists: async (id: TenantId) => id === TENANT_ID || id === OTHER_TENANT_ID, }, }); await unsafeCreateEntityTable(stack.db, productEntity); await unsafeCreateEntityTable(stack.db, orderEntity); }); afterAll(() => stack.cleanup()); test("X-Tenant header disagreeing with the resolver → header wins (documented convenience-fallback behaviour)", async () => { // Opposite of "authoritative": a client tenant that disagrees with the // resolver is accepted, not rejected — this mode is for resolvers that // carry no more trust than the client's own claim. const res = await stack.http.raw( "POST", "/api/query", { type: "anonshop:query:product:list", payload: {} }, { "X-Tenant": OTHER_TENANT_ID }, ); expect(res.status).toBe(200); }); test("no client tenant → resolver runs as fallback", async () => { const res = await stack.http.raw("POST", "/api/query", { type: "anonshop:query:product:list", payload: {}, }); expect(res.status).toBe(200); }); }); describe("anonymous access — tenantResolver without resolverTrust", () => { test("authMiddleware throws at boot, not silently at request time", async () => { await expect( setupTestStack({ features: [shopFeature], // Simulates a caller that bypasses the compiler (JS consumer, an // `as any` cast) — the discriminated union stops well-typed callers, // this proves the runtime guard behind it also fires. anonymousAccess: { tenantResolver: () => TENANT_ID, resolverTrust: undefined, } as unknown as AnonymousAccessConfig, }), ).rejects.toThrow(/resolverTrust/); }); }); describe("anonymous access — disabled by default", () => { let stack: TestStack; beforeAll(async () => { stack = await setupTestStack({ features: [shopFeature] }); await unsafeCreateEntityTable(stack.db, productEntity); await unsafeCreateEntityTable(stack.db, orderEntity); }); afterAll(() => stack.cleanup()); test("missing JWT → 401 (no anonymous fall-through)", async () => { const res = await stack.http.raw("POST", "/api/query", { type: "anonshop:query:product:list", payload: {}, }); expect(res.status).toBe(401); }); });