From d02c0c68287e736f68c8269a7e6754e3cf863c0e Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Fri, 24 Apr 2026 19:04:06 -0400 Subject: [PATCH] add e2e tests for spaces auth path and privacy invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new tests in apps/contrail-e2e, each running in-process against a real devnet PDS + PLC + Postgres with spaces enabled: - spaces-auth.test.ts: real service-auth JWT minted via com.atproto.server.getServiceAuth is accepted; wrong aud and wrong lxm binding are rejected. Exercises the full verifier path (PDS signs with the user's PLC-published key, Contrail resolves the key from devnet PLC, @atcute/xrpc-server verifies the signature) — the existing package-level spaces tests all use a fakeAuth header and never touch this path. - spaces-firehose-invisibility.test.ts: records written via {ns}.space.putRecord do not appear on the ATProto firehose. Uses a direct-to-PDS createRecord as a positive control so a silently broken subscriber can't make the negative assertion vacuous. - spaces-table-isolation.test.ts: a space putRecord lands in spaces_records_ with the public records_ table empty for the same caller — proves the store-level separation that the spaces privacy model depends on. helpers.ts gains CONTRAIL_SERVICE_DID, createDevnetResolver (PlcDid- DocumentResolver pointed at devnet PLC), and mintServiceAuthJwt. Adds @atcute/identity-resolver, @atcute/jetstream, and @atcute/lexicons as direct devDependencies. --- apps/contrail-e2e/package.json | 3 + apps/contrail-e2e/tests/helpers.ts | 52 +++++ apps/contrail-e2e/tests/spaces-auth.test.ts | 127 +++++++++++ .../spaces-firehose-invisibility.test.ts | 201 ++++++++++++++++++ .../tests/spaces-table-isolation.test.ts | 137 ++++++++++++ pnpm-lock.yaml | 9 + 6 files changed, 529 insertions(+) create mode 100644 apps/contrail-e2e/tests/spaces-auth.test.ts create mode 100644 apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts create mode 100644 apps/contrail-e2e/tests/spaces-table-isolation.test.ts diff --git a/apps/contrail-e2e/package.json b/apps/contrail-e2e/package.json index 2e42c0b..ed43978 100644 --- a/apps/contrail-e2e/package.json +++ b/apps/contrail-e2e/package.json @@ -16,6 +16,9 @@ "devDependencies": { "@atcute/atproto": "^3.1.10", "@atcute/client": "^4.2.1", + "@atcute/identity-resolver": "^1.2.2", + "@atcute/jetstream": "^1.1.2", + "@atcute/lexicons": "^1.3.0", "@types/pg": "^8.20.0", "typescript": "^5.9.3", "vitest": "^4.1.0" diff --git a/apps/contrail-e2e/tests/helpers.ts b/apps/contrail-e2e/tests/helpers.ts index f3965ef..d6e0c1d 100644 --- a/apps/contrail-e2e/tests/helpers.ts +++ b/apps/contrail-e2e/tests/helpers.ts @@ -6,16 +6,68 @@ * dogfooding ingester the developer might have running in another terminal. */ import pg from "pg"; +import type { Client } from "@atcute/client"; +import { + CompositeDidDocumentResolver, + PlcDidDocumentResolver, +} from "@atcute/identity-resolver"; +import type { Did as AtDid, Nsid } from "@atcute/lexicons"; export type Did = `did:${string}:${string}`; export const PDS_PORT = Number(process.env.DEVNET_PDS_PORT ?? 4000); export const PDS_URL = `http://localhost:${PDS_PORT}`; +export const PLC_PORT = Number(process.env.DEVNET_PLC_PORT ?? 2582); +export const PLC_URL = `http://localhost:${PLC_PORT}`; export const HANDLE_DOMAIN = process.env.DEVNET_HANDLE_DOMAIN ?? ".devnet.test"; export const PDS_ADMIN_PASSWORD = process.env.DEVNET_PDS_ADMIN_PASSWORD ?? "devnet-admin-password"; export const DATABASE_URL = process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5433/contrail"; +/** + * Arbitrary service DID used for the test Contrail deployment. The only + * requirements: (a) the JWTs we mint via getServiceAuth use this as their + * `aud` claim, and (b) Contrail's SpacesConfig.serviceDid matches. The DID + * itself doesn't need to be resolvable — the verifier only resolves issuers + * (users), not the audience. + */ +export const CONTRAIL_SERVICE_DID = "did:web:contrail-test.devnet.test"; + +/** + * Resolver that points the PLC method at the local devnet PLC on :2582. + * Without this, the default resolver hits plc.directory and 404s on every + * devnet DID. + */ +export function createDevnetResolver() { + return new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver({ apiUrl: PLC_URL }), + }, + }); +} + +/** + * Mint an atproto service-auth JWT via the PDS's getServiceAuth endpoint. + * Requires the client to already be authed for a user. Returns the raw JWT + * string suitable for `Authorization: Bearer `. + */ +export async function mintServiceAuthJwt( + client: Client, + opts: { aud: string; lxm?: string; expSeconds?: number }, +): Promise { + const params: { aud: AtDid; lxm?: Nsid; exp?: number } = { + aud: opts.aud as AtDid, + }; + if (opts.lxm) params.lxm = opts.lxm as Nsid; + if (opts.expSeconds) params.exp = Math.floor(Date.now() / 1000) + opts.expSeconds; + + const res = await client.get("com.atproto.server.getServiceAuth", { params }); + if (!res.ok) { + throw new Error(`getServiceAuth → ${res.status}: ${JSON.stringify(res.data)}`); + } + return res.data.token; +} + export type TestAccount = { handle: string; password: string; did: Did }; export async function createTestAccount(): Promise { diff --git a/apps/contrail-e2e/tests/spaces-auth.test.ts b/apps/contrail-e2e/tests/spaces-auth.test.ts new file mode 100644 index 0000000..5db1253 --- /dev/null +++ b/apps/contrail-e2e/tests/spaces-auth.test.ts @@ -0,0 +1,127 @@ +/** + * Service-auth JWT end-to-end against spaces XRPCs. + * + * 1. Alice mints a JWT via com.atproto.server.getServiceAuth, calls + * {ns}.space.createSpace → verifier accepts, space is created. + * 2. JWT with wrong audience → verifier rejects, 401. + * 3. JWT with lxm bound to one method, used on a different method → 401. + * + * The full auth path is exercised: PDS signs with Alice's PLC-published + * key, Contrail's resolver reads that key from devnet PLC, real verifier + * checks the signature. No mocks on the auth path. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import { CredentialManager, Client } from "@atcute/client"; +import "@atcute/atproto"; +import { Contrail } from "@atmo-dev/contrail"; +import { createHandler } from "@atmo-dev/contrail/server"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; +import { config as baseConfig } from "../config"; +import { + createTestAccount, + createIsolatedSchema, + createDevnetResolver, + mintServiceAuthJwt, + CONTRAIL_SERVICE_DID, + PDS_URL, + type TestAccount, +} from "./helpers"; + +const SPACE_TYPE = "rsvp.atmo.event.space"; + +describe("spaces auth (devnet PDS JWT → Contrail verifier)", () => { + let alice: TestAccount; + let aliceClient: Client; + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let handle: (req: Request) => Promise; + + beforeAll(async () => { + alice = await createTestAccount(); + const creds = new CredentialManager({ service: PDS_URL }); + await creds.login({ identifier: alice.handle, password: alice.password }); + aliceClient = new Client({ handler: creds }); + + const iso = await createIsolatedSchema("test_spaces_auth"); + pool = iso.pool; + cleanupSchema = iso.cleanup; + const db = createPostgresDatabase(pool); + + const contrail = new Contrail({ + ...baseConfig, + db, + spaces: { + type: SPACE_TYPE, + serviceDid: CONTRAIL_SERVICE_DID, + resolver: createDevnetResolver(), + }, + }); + await contrail.init(); + handle = createHandler(contrail); + }); + + afterAll(async () => { + await cleanupSchema?.(); + }); + + async function callXrpc( + method: "GET" | "POST", + path: string, + opts: { token?: string; body?: unknown } = {}, + ): Promise { + const headers: Record = {}; + if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; + if (opts.body !== undefined) headers["content-type"] = "application/json"; + return handle( + new Request(`http://test${path}`, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }), + ); + } + + it("accepts a real service-auth JWT and creates a space", async () => { + const token = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.createSpace", + }); + + const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { + token, + body: {}, + }); + const text = await res.clone().text().catch(() => ""); + expect(res.status, `createSpace → ${res.status}: ${text}`).toBe(200); + + const data = (await res.json()) as { space: { uri: string; ownerDid: string } }; + expect(data.space.uri).toMatch(/^at:\/\//); + expect(data.space.ownerDid).toBe(alice.did); + }); + + it("rejects a JWT minted with the wrong audience", async () => { + const token = await mintServiceAuthJwt(aliceClient, { + aud: "did:web:not-contrail.devnet.test", + }); + + const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { + token, + body: {}, + }); + expect(res.status).toBe(401); + }); + + it("rejects a JWT whose lxm binding mismatches the route", async () => { + const token = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.listSpaces", + }); + + const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { + token, + body: {}, + }); + expect(res.status).toBe(401); + }); +}); diff --git a/apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts b/apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts new file mode 100644 index 0000000..07f7827 --- /dev/null +++ b/apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts @@ -0,0 +1,201 @@ +/** + * Records put into a private space must not appear on the ATProto firehose. + * + * Protocol: + * 1. Subscribe to Jetstream filtered to Alice's DID + the event collection. + * 2. Alice publishes a control record directly to her PDS → MUST appear + * on the firehose (proves the subscriber works). + * 3. Alice puts a record into a space via {ns}.space.putRecord → MUST NOT + * appear on the firehose. + * + * Without (2) as a positive control, a silently broken subscriber would + * make (3) vacuously true. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import { CredentialManager, Client } from "@atcute/client"; +import "@atcute/atproto"; +import { JetstreamSubscription, type JetstreamEvent } from "@atcute/jetstream"; +import type { Did as AtDid } from "@atcute/lexicons"; +import { Contrail } from "@atmo-dev/contrail"; +import { createHandler } from "@atmo-dev/contrail/server"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; +import { config as baseConfig } from "../config"; +import { + createTestAccount, + createIsolatedSchema, + createDevnetResolver, + mintServiceAuthJwt, + CONTRAIL_SERVICE_DID, + PDS_URL, + type TestAccount, +} from "./helpers"; + +const EVENT_NSID = "community.lexicon.calendar.event"; +const SPACE_TYPE = "rsvp.atmo.event.space"; +const JETSTREAM_URL = process.env.JETSTREAM_URL ?? "ws://localhost:6008/subscribe"; +const PROPAGATION_MS = 2_500; + +describe("spaces firehose invisibility", () => { + let alice: TestAccount; + let aliceClient: Client; + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let handle: (req: Request) => Promise; + + beforeAll(async () => { + alice = await createTestAccount(); + const creds = new CredentialManager({ service: PDS_URL }); + await creds.login({ identifier: alice.handle, password: alice.password }); + aliceClient = new Client({ handler: creds }); + + const iso = await createIsolatedSchema("test_firehose_invisibility"); + pool = iso.pool; + cleanupSchema = iso.cleanup; + const db = createPostgresDatabase(pool); + + const contrail = new Contrail({ + ...baseConfig, + db, + spaces: { + type: SPACE_TYPE, + serviceDid: CONTRAIL_SERVICE_DID, + resolver: createDevnetResolver(), + }, + }); + await contrail.init(); + handle = createHandler(contrail); + }); + + afterAll(async () => { + await cleanupSchema?.(); + }); + + async function callXrpc( + method: "GET" | "POST", + path: string, + opts: { token?: string; body?: unknown } = {}, + ): Promise { + const headers: Record = {}; + if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; + if (opts.body !== undefined) headers["content-type"] = "application/json"; + return handle( + new Request(`http://test${path}`, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }), + ); + } + + it("publishes PDS writes to firehose; space writes stay invisible", async () => { + const observed = new Set(); + const ac = new AbortController(); + const opened = deferred(); + + const sub = new JetstreamSubscription({ + url: JETSTREAM_URL, + wantedCollections: [EVENT_NSID], + wantedDids: [alice.did as unknown as AtDid], + onConnectionOpen: () => opened.resolve(), + }); + + const collector = (async () => { + const iterator = sub[Symbol.asyncIterator](); + try { + while (!ac.signal.aborted) { + const result = await Promise.race([ + iterator.next(), + new Promise>((resolve) => { + ac.signal.addEventListener( + "abort", + () => resolve({ value: undefined, done: true }), + { once: true }, + ); + }), + ]); + if (result.done) break; + const ev = result.value; + if (ev.kind === "commit" && ev.did === alice.did) { + observed.add(ev.commit.rkey); + } + } + } finally { + await iterator.return?.(); + } + })(); + + await opened.promise; + + // Control: direct PDS write must land on the firehose. + const controlRes = await aliceClient.post("com.atproto.repo.createRecord", { + input: { + repo: alice.did, + collection: EVENT_NSID as never, + record: eventRecord("firehose-control"), + }, + }); + expect(controlRes.ok, `control createRecord: ${JSON.stringify(controlRes.data)}`).toBe(true); + if (!controlRes.ok) throw new Error("unreachable"); + const controlRkey = controlRes.data.uri.split("/").pop()!; + + // Private: space write must not. + const createToken = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.createSpace", + }); + const createRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { + token: createToken, + body: {}, + }); + expect(createRes.status).toBe(200); + const { space } = (await createRes.json()) as { space: { uri: string } }; + + const putToken = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.putRecord", + }); + const putRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.putRecord", { + token: putToken, + body: { + spaceUri: space.uri, + collection: EVENT_NSID, + record: eventRecord("private-in-space"), + }, + }); + const putText = await putRes.clone().text().catch(() => ""); + expect(putRes.status, `space.putRecord → ${putRes.status}: ${putText}`).toBe(200); + const { rkey: spaceRkey } = (await putRes.json()) as { rkey: string }; + + await new Promise((r) => setTimeout(r, PROPAGATION_MS)); + + ac.abort(); + await collector; + + expect( + observed.has(controlRkey), + `control PDS record ${controlRkey} must appear on firehose; observed: ${[...observed].join(",") || "(none)"}`, + ).toBe(true); + expect( + observed.has(spaceRkey), + `space record ${spaceRkey} must NOT appear on firehose`, + ).toBe(false); + }); +}); + +function eventRecord(name: string) { + return { + $type: EVENT_NSID, + name, + createdAt: new Date().toISOString(), + startsAt: new Date(Date.now() + 60_000).toISOString(), + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} diff --git a/apps/contrail-e2e/tests/spaces-table-isolation.test.ts b/apps/contrail-e2e/tests/spaces-table-isolation.test.ts new file mode 100644 index 0000000..9a3c8c8 --- /dev/null +++ b/apps/contrail-e2e/tests/spaces-table-isolation.test.ts @@ -0,0 +1,137 @@ +/** + * Records written via {ns}.space.putRecord land in `spaces_records_`, + * not `records_`. The public table must stay empty for the caller. + * + * 1. Alice creates a space and puts an event into it. + * 2. Query postgres directly: + * - records_event → 0 rows for alice.did + * - spaces_records_event → 1 row with matching rkey and space_uri + * + * A leak between these tables would silently expose private records to any + * public {ns}.event.getRecord / listRecords call. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import { CredentialManager, Client } from "@atcute/client"; +import "@atcute/atproto"; +import { Contrail } from "@atmo-dev/contrail"; +import { createHandler } from "@atmo-dev/contrail/server"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; +import { config as baseConfig } from "../config"; +import { + createTestAccount, + createIsolatedSchema, + createDevnetResolver, + mintServiceAuthJwt, + CONTRAIL_SERVICE_DID, + PDS_URL, + type TestAccount, +} from "./helpers"; + +const EVENT_NSID = "community.lexicon.calendar.event"; +const SPACE_TYPE = "rsvp.atmo.event.space"; + +describe("spaces table isolation", () => { + let alice: TestAccount; + let aliceClient: Client; + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let handle: (req: Request) => Promise; + + beforeAll(async () => { + alice = await createTestAccount(); + const creds = new CredentialManager({ service: PDS_URL }); + await creds.login({ identifier: alice.handle, password: alice.password }); + aliceClient = new Client({ handler: creds }); + + const iso = await createIsolatedSchema("test_table_isolation"); + pool = iso.pool; + cleanupSchema = iso.cleanup; + const db = createPostgresDatabase(pool); + + const contrail = new Contrail({ + ...baseConfig, + db, + spaces: { + type: SPACE_TYPE, + serviceDid: CONTRAIL_SERVICE_DID, + resolver: createDevnetResolver(), + }, + }); + await contrail.init(); + handle = createHandler(contrail); + }); + + afterAll(async () => { + await cleanupSchema?.(); + }); + + async function callXrpc( + method: "GET" | "POST", + path: string, + opts: { token?: string; body?: unknown } = {}, + ): Promise { + const headers: Record = {}; + if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; + if (opts.body !== undefined) headers["content-type"] = "application/json"; + return handle( + new Request(`http://test${path}`, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }), + ); + } + + it("space records live in spaces_records_event, not records_event", async () => { + const createToken = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.createSpace", + }); + const createRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { + token: createToken, + body: {}, + }); + expect(createRes.status).toBe(200); + const { space } = (await createRes.json()) as { space: { uri: string } }; + + const putToken = await mintServiceAuthJwt(aliceClient, { + aud: CONTRAIL_SERVICE_DID, + lxm: "rsvp.atmo.space.putRecord", + }); + const putRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.putRecord", { + token: putToken, + body: { + spaceUri: space.uri, + collection: EVENT_NSID, + record: { + $type: EVENT_NSID, + name: "isolation-target", + createdAt: new Date().toISOString(), + startsAt: new Date(Date.now() + 60_000).toISOString(), + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }, + }, + }); + expect(putRes.status, await putRes.clone().text().catch(() => "")).toBe(200); + const { rkey } = (await putRes.json()) as { rkey: string }; + + const publicRows = await pool.query( + "SELECT COUNT(*)::int AS n FROM records_event WHERE did = $1", + [alice.did], + ); + expect(publicRows.rows[0].n, "records_event must not contain the space record").toBe(0); + + const spaceRows = await pool.query( + "SELECT rkey, space_uri, did FROM spaces_records_event WHERE did = $1", + [alice.did], + ); + expect(spaceRows.rows).toHaveLength(1); + expect(spaceRows.rows[0]).toMatchObject({ + rkey, + space_uri: space.uri, + did: alice.did, + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a51e70..f3c8172 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,15 @@ importers: '@atcute/client': specifier: ^4.2.1 version: 4.2.1 + '@atcute/identity-resolver': + specifier: ^1.2.2 + version: 1.2.2(@atcute/identity@1.1.4) + '@atcute/jetstream': + specifier: ^1.1.2 + version: 1.1.2 + '@atcute/lexicons': + specifier: ^1.3.0 + version: 1.3.0 '@types/pg': specifier: ^8.20.0 version: 8.20.0 -- 2.51.2