diff --git a/apps/contrail-e2e/tests/spaces-auth.test.ts b/apps/contrail-e2e/tests/spaces-auth.test.ts index 5db1253..7e5124a 100644 --- a/apps/contrail-e2e/tests/spaces-auth.test.ts +++ b/apps/contrail-e2e/tests/spaces-auth.test.ts @@ -96,7 +96,7 @@ describe("spaces auth (devnet PDS JWT → Contrail verifier)", () => { 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.uri).toMatch(/^ats:\/\//); expect(data.space.ownerDid).toBe(alice.did); }); -- 2.51.2 From 74336fd928c910dd9f6a84d2eb42f1a22d67b8bb Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sun, 26 Apr 2026 08:41:29 -0400 Subject: [PATCH 2/6] add e2e tests for community lifecycle and publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new test files exercising the community module end-to-end against the local devnet stack: - community-lifecycle.test.ts: mint → bootstrap reserved spaces → grant → list → setAccessLevel → revoke → ownership handoff. Pins the missing last-owner guard with two it.fails probes that flip to passing once the guard lands. - community-publishing.test.ts: caller JWT → encrypted app-password decrypt → PDS session → com.atproto.repo.createRecord with the community DID as repo → Jetstream → indexer. Each published record gets a 4-way check (200 from putRecord, exists at PDS, indexed, indexed did is the community DID). Plus authz (non-publisher 403), delete roundtrip, and minted community returns NotSupported. Shared infrastructure (login, callAs factory, jsonOr, app-password mint, devnet-rewrite fetch, getRecordFromPds) lives in helpers.ts so each new test file imports rather than copying. --- apps/contrail-e2e/README.md | 22 +- .../tests/community-lifecycle.test.ts | 307 ++++++++++++++++++ .../tests/community-publishing.test.ts | 272 ++++++++++++++++ apps/contrail-e2e/tests/helpers.ts | 115 ++++++- 4 files changed, 704 insertions(+), 12 deletions(-) create mode 100644 apps/contrail-e2e/tests/community-lifecycle.test.ts create mode 100644 apps/contrail-e2e/tests/community-publishing.test.ts diff --git a/apps/contrail-e2e/README.md b/apps/contrail-e2e/README.md index ac0a789..2f7cd09 100644 --- a/apps/contrail-e2e/README.md +++ b/apps/contrail-e2e/README.md @@ -52,19 +52,19 @@ pnpm test:e2e:watch # re-run on change ## Tests -- `tests/health.test.ts` — service health checks (PLC, PDS, TAP, Jetstream) -- `tests/ingest-roundtrip.test.ts` — publish → index roundtrip. Creates a - fresh PDS account, publishes a calendar event and an RSVP, and verifies - both the record and its `rsvpsGoingCount` via an in-process XRPC handler. -- `tests/cursor-resume.test.ts` — regression for `runPersistent`'s durable - cursor. Starts the ingester, publishes A, stops the ingester, publishes - B + updates B + deletes A, restarts, and verifies the saved cursor - replays the gap. - -Each test spins up its own `runPersistent` in-process against an isolated -postgres schema, so tests don't interfere with each other or with any +See `tests/` for the current suite. Each test header explains its scope. +Tests spin up their own `runPersistent` in-process against an isolated +postgres schema, so they don't interfere with each other or with a dogfooding ingester running in another terminal. +### Gap-probe pattern (`it.fails`) + +Some tests use vitest's `it.fails(...)` to pin currently-known gaps in +contrail behavior — they pass *because* contrail doesn't yet enforce the +condition. The moment the condition is enforced, the test flips from +passing to failing, forcing whoever lands the fix to update the assertion +to the new correct behavior. Each `it.fails` block names the gap inline. + ## Teardown ```bash diff --git a/apps/contrail-e2e/tests/community-lifecycle.test.ts b/apps/contrail-e2e/tests/community-lifecycle.test.ts new file mode 100644 index 0000000..dce6998 --- /dev/null +++ b/apps/contrail-e2e/tests/community-lifecycle.test.ts @@ -0,0 +1,307 @@ +/** + * Community lifecycle end-to-end: mint → bootstrap reserved spaces → grant → + * list → setAccessLevel → revoke → ownership handoff. + * + * The community module exposes a 4-level access ladder (member → manager → + * admin → owner) on the reserved `$admin` space; everything else (granting + * roles, listing members, transferring ownership) is operations on that + * ACL. Ownership handoff is therefore role rotation: promote a successor + * to `owner`, then demote or revoke the departing owner. The community + * DID itself never moves. + * + * Gap pinned with `it.fails`: + * + * Last-owner guard. The owner of a community can revoke themselves + * (or setAccessLevel themselves down) while they are the ONLY owner, + * leaving the community ownerless and unmanageable. The two `it.fails` + * probes flip to passing the moment Contrail enforces the guard, + * forcing whoever lands the fix to update the assertion. + * + * Each test mints its own service-auth JWT per call — same pattern as + * spaces-auth.test.ts. No mocks on the auth path; real PDS → real PLC → + * real verifier. + * + * Prereqs: `pnpm stack:up`. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import type { 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, + createCaller, + login, + jsonOr, + CONTRAIL_SERVICE_DID, + type CallAs, + type TestAccount, +} from "./helpers"; + +const NS = `${baseConfig.namespace}.community`; +const SPACE_TYPE = "rsvp.atmo.event.space"; + +// Deterministic 32-byte key for envelope-encrypting community credentials in +// tests. Production uses a KMS-sourced secret — this is fine for devnet-only. +const TEST_MASTER_KEY = new Uint8Array(32).fill(7); + +describe("community lifecycle (mint → grant → list → revoke, + gap probes)", () => { + let alice: TestAccount; // creator / owner + let bob: TestAccount; // promoted to manager → admin + let carol: TestAccount; // member + + let aliceClient: Client; + let bobClient: Client; + + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let callAs: CallAs; + + let communityDid: string; + let adminSpaceUri: string; + + beforeAll(async () => { + [alice, bob, carol] = await Promise.all([ + createTestAccount(), + createTestAccount(), + createTestAccount(), + ]); + + aliceClient = await login(alice); + bobClient = await login(bob); + + const iso = await createIsolatedSchema("test_community_lifecycle"); + 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(), + }, + community: { + serviceDid: CONTRAIL_SERVICE_DID, + masterKey: TEST_MASTER_KEY, + resolver: createDevnetResolver(), + }, + }); + await contrail.init(); + callAs = createCaller(createHandler(contrail)); + }); + + afterAll(async () => { + await cleanupSchema?.(); + }); + + // ----- create a community with a 4-level ACL ----------------------------- + + it("mints a community and returns a recovery key to the creator", async () => { + const res = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); + expect(res.status, await res.clone().text()).toBe(200); + const data = (await res.json()) as { + communityDid: string; + recoveryKey: unknown; + }; + expect(data.communityDid).toMatch(/^did:plc:/); + expect(data.recoveryKey).toBeTruthy(); + communityDid = data.communityDid; + + // bootstrapReservedSpaces creates the $admin space owned by `communityDid` + // with Alice as the initial owner access-row. + adminSpaceUri = `ats://${communityDid}/${SPACE_TYPE}/$admin`; + }); + + it("grants bob=manager and carol=member on the admin space", async () => { + for (const [subject, level] of [ + [bob.did, "manager"], + [carol.did, "member"], + ] as const) { + const res = await callAs(aliceClient, "POST", `${NS}.space.grant`, { + body: { + spaceUri: adminSpaceUri, + subject: { did: subject }, + accessLevel: level, + }, + }); + expect(res.status, `grant ${subject}=${level}: ${await res.clone().text()}`) + .toBe(200); + } + }); + + it("listMembers reflects the full ACL ladder", async () => { + const res = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { + query: { spaceUri: adminSpaceUri }, + }); + expect(res.status).toBe(200); + const data = (await jsonOr(res)) as { + rows: Array<{ subject: { did?: string }; accessLevel: string }>; + }; + const byDid = Object.fromEntries( + data.rows + .filter((r) => r.subject.did) + .map((r) => [r.subject.did, r.accessLevel]), + ); + expect(byDid[alice.did]).toBe("owner"); + expect(byDid[bob.did]).toBe("manager"); + expect(byDid[carol.did]).toBe("member"); + }); + + it("community.list returns the community for members but not strangers", async () => { + // Alice is owner — should see the community. + const aliceList = await callAs(aliceClient, "GET", `${NS}.list`); + expect(aliceList.status).toBe(200); + const { communities: aliceCommunities } = (await jsonOr(aliceList)) as { + communities: Array<{ did: string }>; + }; + expect(aliceCommunities.map((c) => c.did)).toContain(communityDid); + + // A fresh account with no grants — should see nothing. + const stranger = await createTestAccount(); + const strangerClient = await login(stranger); + const strangerList = await callAs(strangerClient, "GET", `${NS}.list`); + expect(strangerList.status).toBe(200); + const { communities: strangerCommunities } = (await jsonOr(strangerList)) as { + communities: Array<{ did: string }>; + }; + expect(strangerCommunities.map((c) => c.did)).not.toContain(communityDid); + }); + + it("promotes bob manager → admin via setAccessLevel", async () => { + const res = await callAs(aliceClient, "POST", `${NS}.space.setAccessLevel`, { + body: { + spaceUri: adminSpaceUri, + subject: { did: bob.did }, + accessLevel: "admin", + }, + }); + expect(res.status, await res.clone().text()).toBe(200); + + const list = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { + query: { spaceUri: adminSpaceUri }, + }); + const { rows } = (await jsonOr(list)) as { + rows: Array<{ subject: { did?: string }; accessLevel: string }>; + }; + expect(rows.find((r) => r.subject.did === bob.did)?.accessLevel).toBe("admin"); + }); + + it("rejects a manager trying to grant admin (cannot-grant-higher-than-self)", async () => { + // Bob is admin now; carol's still a member. Have bob try to promote carol + // to owner — should 403 with cannot-grant-higher-than-self. + const res = await callAs(bobClient, "POST", `${NS}.space.grant`, { + body: { + spaceUri: adminSpaceUri, + subject: { did: carol.did }, + accessLevel: "owner", + }, + }); + expect(res.status).toBe(403); + const data = await jsonOr(res); + expect(data.reason).toBe("cannot-grant-higher-than-self"); + }); + + it("revokes carol cleanly (happy path)", async () => { + const res = await callAs(aliceClient, "POST", `${NS}.space.revoke`, { + body: { spaceUri: adminSpaceUri, subject: { did: carol.did } }, + }); + expect(res.status, await res.clone().text()).toBe(200); + }); + + // ----- gap probes: last-owner guard missing ------------------------------- + // Today Alice (the only owner) can revoke herself, leaving the community + // unmanageable. When Contrail adds the guard, both probes flip to passing + // and force the fix to update the expected status code. + + it.fails( + "space.revoke should reject removing the last owner", + async () => { + const res = await callAs(aliceClient, "POST", `${NS}.space.revoke`, { + body: { spaceUri: adminSpaceUri, subject: { did: alice.did } }, + }); + // Expected future behavior: 4xx (e.g. 409 Conflict, reason: "last-owner"). + // Document the currently-observed behavior in the assertion message so + // the failure is useful when run before the guard lands. + expect( + res.status, + `Pre-guard Contrail returns 200 here — the community is now ownerless. ` + + `Once the last-owner guard lands, this should return 4xx.`, + ).not.toBe(200); + }, + ); + + it.fails( + "setAccessLevel should reject demoting the last owner", + async () => { + // Fresh community so the previous test's state doesn't interfere. + const mint = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); + const { communityDid: freshDid } = (await mint.json()) as { + communityDid: string; + }; + const freshAdmin = `ats://${freshDid}/${SPACE_TYPE}/$admin`; + + const res = await callAs(aliceClient, "POST", `${NS}.space.setAccessLevel`, { + body: { + spaceUri: freshAdmin, + subject: { did: alice.did }, + accessLevel: "manager", + }, + }); + expect(res.status).not.toBe(200); + }, + ); + + // ----- ownership handoff via role rotation -------------------------------- + // The community DID stays put; ownership moves by promoting a successor to + // `owner` and then demoting (or revoking) the original owner. Uses a fresh + // community so the earlier tests' mutations don't interfere. + + it("hands off ownership by promoting a successor and demoting the original owner", async () => { + const mint = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); + const { communityDid: freshDid } = (await mint.json()) as { + communityDid: string; + }; + const freshAdmin = `ats://${freshDid}/${SPACE_TYPE}/$admin`; + + // 1. Alice promotes Bob to owner (two owners now — no last-owner risk). + const promote = await callAs(aliceClient, "POST", `${NS}.space.grant`, { + body: { + spaceUri: freshAdmin, + subject: { did: bob.did }, + accessLevel: "owner", + }, + }); + expect(promote.status, await promote.clone().text()).toBe(200); + + // 2. Bob demotes Alice to manager — he is now the sole owner. + const demote = await callAs(bobClient, "POST", `${NS}.space.setAccessLevel`, { + body: { + spaceUri: freshAdmin, + subject: { did: alice.did }, + accessLevel: "manager", + }, + }); + expect(demote.status, await demote.clone().text()).toBe(200); + + // 3. Verify final state: Bob=owner, Alice=manager. + const list = await callAs(bobClient, "GET", `${NS}.space.listMembers`, { + query: { spaceUri: freshAdmin }, + }); + const { rows } = (await jsonOr(list)) as { + rows: Array<{ subject: { did?: string }; accessLevel: string }>; + }; + const byDid = Object.fromEntries( + rows.filter((r) => r.subject.did).map((r) => [r.subject.did, r.accessLevel]), + ); + expect(byDid[bob.did]).toBe("owner"); + expect(byDid[alice.did]).toBe("manager"); + }); +}); diff --git a/apps/contrail-e2e/tests/community-publishing.test.ts b/apps/contrail-e2e/tests/community-publishing.test.ts new file mode 100644 index 0000000..6087dd7 --- /dev/null +++ b/apps/contrail-e2e/tests/community-publishing.test.ts @@ -0,0 +1,272 @@ +/** + * Community publishing end-to-end. + * + * Pins the proxy path that's unique to the community module: caller's JWT → + * encrypted app-password decrypt → PDS session → `com.atproto.repo.createRecord` + * with the **community** DID as `repo` → Jetstream propagation → indexer. + * Four real systems on a single hot path. + * + * Each published record gets a 4-way check: + * 1. `community.putRecord` returns 200 with `{ uri, cid }`. + * 2. The record exists at the PDS (`com.atproto.repo.getRecord`) — proves + * the proxy actually wrote, not just contrail's local store. + * 3. The record appears in the contrail index — proves Jetstream + ingest. + * 4. The indexed `did` is the community DID, not the caller's DID. + * + * Plus: minted-community publishing returns NotSupported (no PDS to proxy + * to); a non-publisher gets 403; deleteRecord cleans both PDS and index. + * + * Prereqs: `pnpm stack:up`. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import type { Client } from "@atcute/client"; +import "@atcute/atproto"; +import { Contrail, runPersistent } 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, + createCaller, + createAppPasswordFor, + devnetRewriteFetch, + getRecordFromPds, + login, + CONTRAIL_SERVICE_DID, + waitFor, + type CallAs, + type TestAccount, +} from "./helpers"; + +const NS = `${baseConfig.namespace}.community`; +const SPACE_TYPE = "rsvp.atmo.event.space"; +const EVENT_NSID = "community.lexicon.calendar.event"; +const TEST_MASTER_KEY = new Uint8Array(32).fill(7); + +describe("community publishing (proxy → PDS → Jetstream → index)", () => { + let alice: TestAccount; // owner of community spaces (caller) + let bob: TestAccount; // account adopted as the community + let charlie: TestAccount; // outsider — no grants on the community + + let aliceClient: Client; + let charlieClient: Client; + let bobAppPassword: string; + + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let handle: (req: Request) => Promise; + let callAs: CallAs; + let ingestController: AbortController; + let ingestPromise: Promise; + + let adoptedCommunityDid: string; + let publishedUri: string; + let publishedRkey: string; + + beforeAll(async () => { + [alice, bob, charlie] = await Promise.all([ + createTestAccount(), + createTestAccount(), + createTestAccount(), + ]); + + aliceClient = await login(alice); + charlieClient = await login(charlie); + + // Bob mints an app password — that's what gets stored encrypted in the + // credential vault and used for every proxied write. + bobAppPassword = await createAppPasswordFor(bob); + + const iso = await createIsolatedSchema("test_community_publishing"); + 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(), + }, + community: { + serviceDid: CONTRAIL_SERVICE_DID, + masterKey: TEST_MASTER_KEY, + resolver: createDevnetResolver(), + // Devnet PDS publishes "https://devnet.test" as its public endpoint + // in every DID document, which isn't reachable from the test + // process. Rewrite outgoing requests so the credential check on + // adopt and the proxied createRecord on putRecord both hit the + // host-mapped port instead. + fetch: devnetRewriteFetch, + }, + }); + await contrail.init(); + handle = createHandler(contrail); + callAs = createCaller(handle); + + ingestController = new AbortController(); + ingestPromise = runPersistent(db, baseConfig, { + batchSize: 50, + flushIntervalMs: 500, + signal: ingestController.signal, + }); + + // Alice adopts Bob's account as the community. Alice becomes owner of + // both $admin and $publishers via bootstrapReservedSpaces. Pass Bob's + // DID rather than his handle — devnet handles aren't resolvable via + // the public /.well-known path that resolveIdentity falls back to. + const adopt = await callAs(aliceClient, "POST", `${NS}.adopt`, { + body: { identifier: bob.did, appPassword: bobAppPassword }, + }); + expect(adopt.status, await adopt.clone().text()).toBe(200); + const data = (await adopt.json()) as { communityDid: string }; + adoptedCommunityDid = data.communityDid; + expect(adoptedCommunityDid).toBe(bob.did); + }); + + afterAll(async () => { + ingestController?.abort(); + await ingestPromise?.catch(() => {}); + await cleanupSchema?.(); + }); + + // ----- helpers ------------------------------------------------------------ + + /** Look up the record in the contrail index via the local XRPC handler. */ + async function getIndexedRecord(uri: string): Promise { + const url = `http://test/xrpc/${baseConfig.namespace}.event.getRecord?uri=${encodeURIComponent(uri)}`; + const res = await handle(new Request(url)); + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`getRecord ${uri} → ${res.status}: ${await res.text()}`); + return await res.json(); + } + + // ----- happy path: adopted community publishes a public event ------------- + + it("publishes via community.putRecord and lands at PDS, Jetstream, and index", async () => { + const eventName = `community-published ${Date.now()}`; + const startsAt = new Date(Date.now() + 60 * 60_000).toISOString(); + + const res = await callAs(aliceClient, "POST", `${NS}.putRecord`, { + body: { + communityDid: adoptedCommunityDid, + collection: EVENT_NSID, + record: { + $type: EVENT_NSID, + name: eventName, + createdAt: new Date().toISOString(), + startsAt, + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }, + }, + }); + expect(res.status, await res.clone().text()).toBe(200); + const out = (await res.json()) as { uri: string; cid: string }; + publishedUri = out.uri; + publishedRkey = out.uri.split("/").pop()!; + + // (1) URI is rooted at the community's DID, not Alice's. `at://` is + // intentional here — this is a PDS-issued record URI, distinct from + // the `ats://` scheme used for Contrail-internal space URIs. + expect(publishedUri).toMatch(new RegExp(`^at://${adoptedCommunityDid}/${EVENT_NSID}/`)); + + // (2) PDS actually has it — proves the proxy wrote, not just the local DB. + const pds = await getRecordFromPds(adoptedCommunityDid, EVENT_NSID, publishedRkey); + expect(pds.status).toBe(200); + expect(pds.record.name).toBe(eventName); + + // (3) Index has it — proves Jetstream + ingester. + // (4) Indexed `did` is the community DID, not the caller's DID. + const indexed = await waitFor( + () => getIndexedRecord(publishedUri), + { label: `index ${publishedUri}` }, + ); + expect(indexed.did).toBe(adoptedCommunityDid); + expect(indexed.did).not.toBe(alice.did); + expect(indexed.value.name).toBe(eventName); + }); + + // ----- authorization: non-member can't publish on behalf of community ----- + + it("rejects publishing from a caller not in $publishers", async () => { + const res = await callAs(charlieClient, "POST", `${NS}.putRecord`, { + body: { + communityDid: adoptedCommunityDid, + collection: EVENT_NSID, + record: { + $type: EVENT_NSID, + name: "should never land", + createdAt: new Date().toISOString(), + startsAt: new Date(Date.now() + 60 * 60_000).toISOString(), + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }, + }, + }); + expect(res.status).toBe(403); + const data = (await res.json()) as { reason: string }; + expect(data.reason).toBe("not-in-publishers"); + }); + + // ----- delete roundtrip: PDS + index both clear -------------------------- + + it("deletes a published record from both PDS and index", async () => { + expect(publishedRkey, "previous test must have published").toBeTruthy(); + + const res = await callAs(aliceClient, "POST", `${NS}.deleteRecord`, { + body: { + communityDid: adoptedCommunityDid, + collection: EVENT_NSID, + rkey: publishedRkey, + }, + }); + expect(res.status, await res.clone().text()).toBe(200); + + const pds = await getRecordFromPds(adoptedCommunityDid, EVENT_NSID, publishedRkey); + expect(pds.status).toBe(400); // PDS returns 400 RecordNotFound, not 404 + + await waitFor( + async () => ((await getIndexedRecord(publishedUri)) === undefined ? true : undefined), + { label: `index drops ${publishedUri}` }, + ); + }); + + // ----- minted communities have no PDS to proxy to ------------------------ + // A minted community is a contrail-controlled DID with no `atproto_pds` + // service entry, so there's no repo to write into. `community.putRecord` + // returns NotSupported. If minted publishing ever lands (e.g. by routing + // writes to a community-owned repo), update this assertion. + + it("minted communities cannot publish public records", async () => { + const mint = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); + expect(mint.status).toBe(200); + const { communityDid: mintedDid } = (await mint.json()) as { + communityDid: string; + }; + + const res = await callAs(aliceClient, "POST", `${NS}.putRecord`, { + body: { + communityDid: mintedDid, + collection: EVENT_NSID, + record: { + $type: EVENT_NSID, + name: "should never publish", + createdAt: new Date().toISOString(), + startsAt: new Date(Date.now() + 60 * 60_000).toISOString(), + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }, + }, + }); + expect(res.status).toBe(400); + const data = (await res.json()) as { error: string; reason: string }; + expect(data.error).toBe("NotSupported"); + expect(data.reason).toBe("publishing-not-supported-for-minted-communities"); + }); +}); diff --git a/apps/contrail-e2e/tests/helpers.ts b/apps/contrail-e2e/tests/helpers.ts index d6e0c1d..64f493f 100644 --- a/apps/contrail-e2e/tests/helpers.ts +++ b/apps/contrail-e2e/tests/helpers.ts @@ -6,7 +6,7 @@ * dogfooding ingester the developer might have running in another terminal. */ import pg from "pg"; -import type { Client } from "@atcute/client"; +import { CredentialManager, Client } from "@atcute/client"; import { CompositeDidDocumentResolver, PlcDidDocumentResolver, @@ -157,3 +157,116 @@ export async function waitFor( (lastErr ? `: ${(lastErr as Error).message}` : ""), ); } + +/** + * Log a TestAccount into the devnet PDS and return an authed atcute Client. + */ +export async function login(acct: TestAccount): Promise { + const creds = new CredentialManager({ service: PDS_URL }); + await creds.login({ identifier: acct.handle, password: acct.password }); + return new Client({ handler: creds }); +} + +/** + * A `fetch` shim that rewrites the unreachable `https://devnet.test` host + * (which devnet PDSes publish in every DID document's `atproto_pds` service + * entry) to the host-mapped `PDS_URL`. Pass this as `community.fetch` so the + * credential check on adopt and the proxied createRecord on putRecord both + * land on the local container instead of failing DNS. + */ +export const devnetRewriteFetch: typeof fetch = (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + return fetch(url.replace(/^https:\/\/devnet\.test/, PDS_URL), init); +}; + +/** + * Fetch a record straight from the devnet PDS via `com.atproto.repo.getRecord`. + * Used to confirm a proxied write (e.g. via `community.putRecord`) actually + * landed on the PDS and not just contrail's local index. + */ +export async function getRecordFromPds( + repo: string, + collection: string, + rkey: string, +): Promise<{ status: number; record?: any }> { + const url = + `${PDS_URL}/xrpc/com.atproto.repo.getRecord` + + `?repo=${encodeURIComponent(repo)}` + + `&collection=${encodeURIComponent(collection)}` + + `&rkey=${encodeURIComponent(rkey)}`; + const res = await fetch(url); + if (!res.ok) return { status: res.status }; + const body = (await res.json()) as { value: any }; + return { status: res.status, record: body.value }; +} + +/** + * Mint an app password for `acct` via the PDS. Used by tests that need to + * adopt the account as a community (the community module stores the app + * password encrypted in its credential vault). + */ +export async function createAppPasswordFor(acct: TestAccount): Promise { + const c = await login(acct); + const res = await c.post("com.atproto.server.createAppPassword", { + input: { name: `e2e-${Date.now()}` }, + }); + if (!res.ok) { + throw new Error(`createAppPassword: ${JSON.stringify(res.data)}`); + } + return res.data.password; +} + +/** + * A callAs function makes an XRPC call against the in-process Contrail + * handler with a freshly minted service-auth JWT. Each call mints its own + * token so the `lxm` claim binds to that specific endpoint. + */ +export type CallAs = ( + client: Client, + method: "GET" | "POST", + lxm: string, + opts?: { body?: unknown; query?: Record }, +) => Promise; + +/** + * Create a caller bound to a specific in-process handler. Use one per test + * file's `beforeAll` to avoid passing `handle` through every assertion. + */ +export function createCaller( + handle: (req: Request) => Promise, +): CallAs { + return async (client, method, lxm, opts = {}) => { + const token = await mintServiceAuthJwt(client, { + aud: CONTRAIL_SERVICE_DID, + lxm, + }); + const qs = opts.query + ? "?" + new URLSearchParams(opts.query).toString() + : ""; + const headers: Record = { + authorization: `Bearer ${token}`, + }; + if (opts.body !== undefined) headers["content-type"] = "application/json"; + return handle( + new Request(`http://test/xrpc/${lxm}${qs}`, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }), + ); + }; +} + +/** + * Parse a Response body as JSON, throwing a clear error (with status + raw + * text) if the body isn't JSON. Saves a `try/catch` in every assertion that + * needs to inspect a 4xx/5xx body. + */ +export async function jsonOr(res: Response): Promise { + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + throw new Error(`non-JSON response ${res.status}: ${text}`); + } +} -- 2.51.2 From 941bef5f7febe1e72aa022baaff76fccd382153b Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sun, 26 Apr 2026 08:41:34 -0400 Subject: [PATCH 3/6] fix: update spaces-auth e2e assertion for ats:// scheme The space URI scheme moved from at:// to ats:// (Atmosphere Spaces) in the spaces module. Update the assertion so the existing test passes against current main without other changes. --- apps/contrail-e2e/tests/spaces-auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/contrail-e2e/tests/spaces-auth.test.ts b/apps/contrail-e2e/tests/spaces-auth.test.ts index 5db1253..7e5124a 100644 --- a/apps/contrail-e2e/tests/spaces-auth.test.ts +++ b/apps/contrail-e2e/tests/spaces-auth.test.ts @@ -96,7 +96,7 @@ describe("spaces auth (devnet PDS JWT → Contrail verifier)", () => { 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.uri).toMatch(/^ats:\/\//); expect(data.space.ownerDid).toBe(alice.did); }); -- 2.51.2 From c63a752e5f95fc1778b97e358bc2158f3bed09fe Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sun, 26 Apr 2026 09:20:22 -0400 Subject: [PATCH 4/6] add e2e tests for the unified invite surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router-level unit tests in invite-unified.test.ts cover dispatch and input validation against a fakeAuth middleware and mocked PDS. This file exercises what those can't: - Real service-auth JWTs minted via com.atproto.server.getServiceAuth on the devnet PDS, verified through PLC-resolved keys by the real auth middleware. - The reconcile cascade. .invite.redeem on a community-owned space fires community.grant + reconcile, and the resulting access must show up in BOTH the community ACL and spaces.access_rows. Verified by reading community.space.listMembers (ACL view) and the same endpoint with flatten=true (spaces table view). - Real Postgres enforcement of single-use (maxUses=1) and revoked invites — the redeem query is the source of truth, not in-memory. Both invite paths covered: community-owned (accessLevel) and user-owned (kind=join, no community module touched). 4 tests, ~3s. Stacked on top of the lifecycle/publishing tests so the shared helpers (login, createCaller, jsonOr) are reused without duplication. --- .../tests/community-invites.test.ts | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 apps/contrail-e2e/tests/community-invites.test.ts diff --git a/apps/contrail-e2e/tests/community-invites.test.ts b/apps/contrail-e2e/tests/community-invites.test.ts new file mode 100644 index 0000000..e3a5fd0 --- /dev/null +++ b/apps/contrail-e2e/tests/community-invites.test.ts @@ -0,0 +1,272 @@ +/** + * Invite end-to-end against a real PDS, real PLC, real Postgres. + * + * Unit tests in `packages/contrail/tests/invite-unified.test.ts` cover the + * router-level dispatch and validation (kind/accessLevel exclusivity, 403s, + * cannot-grant-higher-than-self). What unit tests can't show is: + * + * - Real service-auth JWT verifying through the PDS+PLC chain + * (unit tests use a fakeAuth middleware). + * - The reconcile cascade — community.grant fired by invite.redeem must + * update both the community ACL AND spaces.access_rows. Verified here + * by hitting `community.space.listMembers?flatten=true`, which reads + * from spaces.access_rows directly. + * - Real Postgres enforcement of single-use / revoked invites (the redeem + * query is the source of truth, not in-memory state). + * + * Both invite paths are exercised: + * + * - Community-owned space: alice (admin in $admin) creates a child space, + * mints accessLevel=member invite, bob redeems → grant + reconcile. + * - User-owned space: alice creates her own space, mints kind=join invite, + * bob redeems → addMember. No community module involvement. + * + * Prereqs: `pnpm stack:up`. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import type { 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, + createCaller, + login, + jsonOr, + CONTRAIL_SERVICE_DID, + type CallAs, + type TestAccount, +} from "./helpers"; + +const NS = baseConfig.namespace; +const SPACE_TYPE = "rsvp.atmo.event.space"; +const TEST_MASTER_KEY = new Uint8Array(32).fill(7); + +describe("invite e2e (community + user-owned, real JWT)", () => { + let alice: TestAccount; + let bob: TestAccount; + let charlie: TestAccount; + + let aliceClient: Client; + let bobClient: Client; + let charlieClient: Client; + + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let callAs: CallAs; + + let communityDid: string; + let channelUri: string; + + beforeAll(async () => { + [alice, bob, charlie] = await Promise.all([ + createTestAccount(), + createTestAccount(), + createTestAccount(), + ]); + [aliceClient, bobClient, charlieClient] = await Promise.all([ + login(alice), + login(bob), + login(charlie), + ]); + + const iso = await createIsolatedSchema("test_community_invites"); + 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(), + }, + community: { + serviceDid: CONTRAIL_SERVICE_DID, + masterKey: TEST_MASTER_KEY, + resolver: createDevnetResolver(), + }, + }); + await contrail.init(); + callAs = createCaller(createHandler(contrail)); + + // Mint a community owned by alice; alice becomes owner of $admin via + // bootstrap and can then create child spaces. + const mint = await callAs(aliceClient, "POST", `${NS}.community.mint`, { body: {} }); + expect(mint.status, await mint.clone().text()).toBe(200); + communityDid = ((await mint.json()) as { communityDid: string }).communityDid; + + // Create a child space for invite tests; alice becomes its owner. + const create = await callAs(aliceClient, "POST", `${NS}.community.space.create`, { + body: { communityDid, key: "general" }, + }); + expect(create.status, await create.clone().text()).toBe(200); + channelUri = ((await create.json()) as { space: { uri: string } }).space.uri; + }); + + afterAll(async () => { + await cleanupSchema?.(); + }); + + // ----- community happy path: grant + reconcile cascade -------------------- + + it("redeem grants in community ACL AND reconciles to spaces.access_rows", async () => { + const create = await callAs(aliceClient, "POST", `${NS}.invite.create`, { + body: { spaceUri: channelUri, accessLevel: "member" }, + }); + expect(create.status, await create.clone().text()).toBe(200); + const { token, invite } = (await create.json()) as { + token: string; + invite: { tokenHash: string; accessLevel: string }; + }; + expect(invite.accessLevel).toBe("member"); + + const redeem = await callAs(bobClient, "POST", `${NS}.invite.redeem`, { + body: { token }, + }); + expect(redeem.status, await redeem.clone().text()).toBe(200); + const redeemed = (await redeem.json()) as { + spaceUri: string; + accessLevel: string; + communityDid: string; + }; + expect(redeemed.spaceUri).toBe(channelUri); + expect(redeemed.accessLevel).toBe("member"); + expect(redeemed.communityDid).toBe(communityDid); + + // (1) Community ACL has bob at member. + const aclList = await callAs(aliceClient, "GET", `${NS}.community.space.listMembers`, { + query: { spaceUri: channelUri }, + }); + expect(aclList.status).toBe(200); + const { rows } = (await jsonOr(aclList)) as { + rows: Array<{ subject: { did?: string }; accessLevel: string }>; + }; + const aclBob = rows.find((r) => r.subject.did === bob.did); + expect(aclBob?.accessLevel).toBe("member"); + + // (2) Reconcile cascade: spaces.access_rows now has bob too. + // `flatten=true` reads from the spaces table, not the community ACL. + const flatList = await callAs(aliceClient, "GET", `${NS}.community.space.listMembers`, { + query: { spaceUri: channelUri, flatten: "true" }, + }); + expect(flatList.status).toBe(200); + const { members } = (await jsonOr(flatList)) as { + members: Array<{ did: string }>; + }; + expect(members.map((m) => m.did)).toContain(bob.did); + + // (3) Bob's whoami resolves to the granted level via the same path that + // app code would use to gate UI/API access. + const whoami = await callAs(bobClient, "GET", `${NS}.spaceExt.whoami`, { + query: { spaceUri: channelUri }, + }); + expect(whoami.status).toBe(200); + const { isMember, accessLevel } = (await jsonOr(whoami)) as { + isMember: boolean; + accessLevel: string; + }; + expect(isMember).toBe(true); + expect(accessLevel).toBe("member"); + }); + + // ----- single-use enforcement at the real-DB layer ------------------------ + + it("maxUses=1 invite rejects a second redemption (DB-enforced, not in-memory)", async () => { + const create = await callAs(aliceClient, "POST", `${NS}.invite.create`, { + body: { spaceUri: channelUri, accessLevel: "member", maxUses: 1 }, + }); + expect(create.status).toBe(200); + const { token } = (await create.json()) as { token: string }; + + // First redemption succeeds (use a fresh outsider so the prior test's + // grant doesn't mask the count). + const dave = await createTestAccount(); + const daveClient = await login(dave); + const first = await callAs(daveClient, "POST", `${NS}.invite.redeem`, { + body: { token }, + }); + expect(first.status, await first.clone().text()).toBe(200); + + // Second redemption by a different account is rejected. + const second = await callAs(charlieClient, "POST", `${NS}.invite.redeem`, { + body: { token }, + }); + expect(second.status).toBe(400); + const data = (await jsonOr(second)) as { error: string }; + expect(data.error).toBe("InvalidInvite"); + }); + + // ----- revoke roundtrip --------------------------------------------------- + + it("revoked invite cannot be redeemed", async () => { + const create = await callAs(aliceClient, "POST", `${NS}.invite.create`, { + body: { spaceUri: channelUri, accessLevel: "member" }, + }); + const { token, invite } = (await create.json()) as { + token: string; + invite: { tokenHash: string }; + }; + + const revoke = await callAs(aliceClient, "POST", `${NS}.invite.revoke`, { + body: { spaceUri: channelUri, tokenHash: invite.tokenHash }, + }); + expect(revoke.status, await revoke.clone().text()).toBe(200); + + const redeem = await callAs(charlieClient, "POST", `${NS}.invite.redeem`, { + body: { token }, + }); + expect(redeem.status).toBe(400); + const data = (await jsonOr(redeem)) as { error: string }; + expect(data.error).toBe("InvalidInvite"); + }); + + // ----- user-owned space: simpler path, no reconcile ----------------------- + // Owner creates a space they personally own; invite confers `kind=join` and + // redeem calls `addMember` — no community module touched. + + it("user-owned space: kind=join invite roundtrip adds redeemer as a member", async () => { + const createSpace = await callAs(aliceClient, "POST", `${NS}.space.createSpace`, { + body: {}, + }); + expect(createSpace.status, await createSpace.clone().text()).toBe(200); + const userSpaceUri = ( + (await createSpace.json()) as { space: { uri: string; ownerDid: string } } + ).space.uri; + + const createInvite = await callAs(aliceClient, "POST", `${NS}.invite.create`, { + body: { spaceUri: userSpaceUri, kind: "join" }, + }); + expect(createInvite.status, await createInvite.clone().text()).toBe(200); + const { token, invite } = (await createInvite.json()) as { + token: string; + invite: { kind: string }; + }; + expect(invite.kind).toBe("join"); + + const redeem = await callAs(bobClient, "POST", `${NS}.invite.redeem`, { + body: { token }, + }); + expect(redeem.status, await redeem.clone().text()).toBe(200); + const redeemed = (await redeem.json()) as { spaceUri: string; kind: string }; + expect(redeemed.spaceUri).toBe(userSpaceUri); + expect(redeemed.kind).toBe("join"); + + // Owner can listMembers; bob shows up. + const list = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { + query: { spaceUri: userSpaceUri }, + }); + expect(list.status).toBe(200); + const { members } = (await jsonOr(list)) as { + members: Array<{ did: string }>; + }; + expect(members.map((m) => m.did)).toContain(bob.did); + }); +}); -- 2.51.2 From f3bffa93574c2b23ceaeb6bd08bc1922c4c951b0 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sun, 26 Apr 2026 09:43:12 -0400 Subject: [PATCH 5/6] add e2e tests for community.delete soft-delete and cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers .community.delete against real Postgres: - Owner soft-deletes; communities.deleted_at + every owned space's deleted_at flip together (verified by direct SQL on the test pool, so the cascade is observed at the source-of-truth layer). - .community.list filtering is real SQL — the deleted community drops out of the listing without any in-memory bookkeeping. - getCommunity's deleted_at filter blocks subsequent endpoints: community.space.create returns 404 community-not-found. - owner-required is enforced — an admin in $admin (strictly below owner) gets 403, and so does a stranger; the community stays alive. The router endpoint and softDeleteCommunity adapter method had no unit-test coverage for delete; these e2e tests fill that gap and add real-DB cascade verification on top. --- .../tests/community-delete.test.ts | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 apps/contrail-e2e/tests/community-delete.test.ts diff --git a/apps/contrail-e2e/tests/community-delete.test.ts b/apps/contrail-e2e/tests/community-delete.test.ts new file mode 100644 index 0000000..4e2ffd3 --- /dev/null +++ b/apps/contrail-e2e/tests/community-delete.test.ts @@ -0,0 +1,264 @@ +/** + * `.community.delete` end-to-end against real Postgres. + * + * The router endpoint (community/router.ts) and the adapter's + * `softDeleteCommunity` (community/adapter.ts) have no unit-test coverage + * for delete today. Beyond filling that gap, what these tests prove that + * unit tests *can't*: + * + * - Real Postgres writes the `deleted_at` timestamp on both the + * `communities` row AND every space owned by the community ($admin + * plus any child spaces). Verified by direct SQL on the pool, since + * that is the ground-truth representation the rest of the system + * reads from. + * - `.community.list` filtering is enforced by SQL + * (`listCommunitiesOwningSpaces` joins on `c.deleted_at IS NULL AND + * s.deleted_at IS NULL`), not in-memory bookkeeping. + * - Subsequent ops on a deleted community fail at the adapter layer: + * `getCommunity` returns null because of the `deleted_at IS NULL` + * filter, so `.community.space.create` returns 404. + * - Real service-auth JWT verifying through the PDS+PLC chain on the + * `owner-required` enforcement path. + * + * Prereqs: `pnpm stack:up`. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import type { 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, + createCaller, + login, + jsonOr, + CONTRAIL_SERVICE_DID, + type CallAs, + type TestAccount, +} from "./helpers"; + +const NS = `${baseConfig.namespace}.community`; +const SPACE_TYPE = "rsvp.atmo.event.space"; +const TEST_MASTER_KEY = new Uint8Array(32).fill(7); + +describe("community.delete e2e (soft-delete + cascade, real DB)", () => { + let alice: TestAccount; // owner / creator + let bob: TestAccount; // promoted to admin in $admin (still NOT owner) + + let aliceClient: Client; + let bobClient: Client; + + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let callAs: CallAs; + + beforeAll(async () => { + [alice, bob] = await Promise.all([createTestAccount(), createTestAccount()]); + [aliceClient, bobClient] = await Promise.all([login(alice), login(bob)]); + + const iso = await createIsolatedSchema("test_community_delete"); + 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(), + }, + community: { + serviceDid: CONTRAIL_SERVICE_DID, + masterKey: TEST_MASTER_KEY, + resolver: createDevnetResolver(), + }, + }); + await contrail.init(); + callAs = createCaller(createHandler(contrail)); + }); + + afterAll(async () => { + await cleanupSchema?.(); + }); + + // Each test mints its own community so the soft-delete in one doesn't + // interfere with the next. + async function mintCommunity(): Promise { + const res = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); + expect(res.status, await res.clone().text()).toBe(200); + return ((await res.json()) as { communityDid: string }).communityDid; + } + + async function createChildSpace(communityDid: string, key: string): Promise { + const res = await callAs(aliceClient, "POST", `${NS}.space.create`, { + body: { communityDid, key }, + }); + expect(res.status, await res.clone().text()).toBe(200); + return ((await res.json()) as { space: { uri: string } }).space.uri; + } + + // ----- happy path: owner deletes, both rows + spaces flip deleted_at ------ + + it("owner can soft-delete; communities + all owned spaces get deleted_at set", async () => { + const communityDid = await mintCommunity(); + const channelUri = await createChildSpace(communityDid, "general"); + const adminUri = `ats://${communityDid}/${SPACE_TYPE}/$admin`; + + // Sanity: pre-delete, deleted_at is NULL on both tables. + const preCommunity = await pool.query( + `SELECT deleted_at FROM communities WHERE did = $1`, + [communityDid], + ); + expect(preCommunity.rows[0]?.deleted_at).toBeNull(); + // Bootstrap creates one or more reserved spaces ($admin, $publishers, …) + // in addition to the explicit child space. Don't pin the full set — + // assert the two we care about are present and live, and that every row + // is non-deleted. + const preSpaces = await pool.query( + `SELECT uri, deleted_at FROM spaces WHERE owner_did = $1`, + [communityDid], + ); + const preUris = preSpaces.rows.map((r: { uri: string }) => r.uri); + expect(preUris).toContain(adminUri); + expect(preUris).toContain(channelUri); + for (const row of preSpaces.rows) expect(row.deleted_at).toBeNull(); + + // Delete. + const del = await callAs(aliceClient, "POST", `${NS}.delete`, { + body: { communityDid }, + }); + expect(del.status, await del.clone().text()).toBe(200); + expect((await del.json()) as { ok: boolean }).toEqual({ ok: true }); + + // (1) Community row's deleted_at is now set. + const postCommunity = await pool.query( + `SELECT deleted_at FROM communities WHERE did = $1`, + [communityDid], + ); + expect(postCommunity.rows[0]?.deleted_at).not.toBeNull(); + + // (2) Every space owned by the community has deleted_at set — the + // reserved spaces ($admin etc.) and the child "general" space. + const postSpaces = await pool.query( + `SELECT uri, deleted_at FROM spaces WHERE owner_did = $1`, + [communityDid], + ); + // Same query, same connection — count must match exactly. A regression + // that orphans rows or creates new ones during delete would fail here. + expect(postSpaces.rows.length).toBe(preSpaces.rows.length); + for (const row of postSpaces.rows) expect(row.deleted_at).not.toBeNull(); + }); + + // ----- list filtering is real --------------------------------------------- + + it("post-delete community is excluded from .community.list (DB-filtered)", async () => { + const communityDid = await mintCommunity(); + + // Pre-delete: owner sees the community. + const before = await callAs(aliceClient, "GET", `${NS}.list`); + const beforeDids = ((await jsonOr(before)) as { + communities: Array<{ did: string }>; + }).communities.map((c) => c.did); + expect(beforeDids).toContain(communityDid); + + // Delete. + const del = await callAs(aliceClient, "POST", `${NS}.delete`, { + body: { communityDid }, + }); + expect(del.status).toBe(200); + + // Post-delete: the same listing call no longer returns it. The filter + // lives in `listCommunitiesOwningSpaces` (real SQL), so this round-trip + // proves the join condition fires end-to-end, not just in unit-test + // mocks. + const after = await callAs(aliceClient, "GET", `${NS}.list`); + const afterDids = ((await jsonOr(after)) as { + communities: Array<{ did: string }>; + }).communities.map((c) => c.did); + expect(afterDids).not.toContain(communityDid); + }); + + // ----- subsequent ops fail at the adapter layer --------------------------- + + it("post-delete: community.space.create returns 404 (getCommunity filters deleted)", async () => { + const communityDid = await mintCommunity(); + + const del = await callAs(aliceClient, "POST", `${NS}.delete`, { + body: { communityDid }, + }); + expect(del.status).toBe(200); + + // `community.space.create` calls `getCommunity` which filters + // `deleted_at IS NULL`, so a deleted community looks like a missing + // community to subsequent endpoints — no special "tombstone" path. + const create = await callAs(aliceClient, "POST", `${NS}.space.create`, { + body: { communityDid, key: "afterlife" }, + }); + expect(create.status).toBe(404); + const data = (await jsonOr(create)) as { error: string; reason?: string }; + expect(data.error).toBe("NotFound"); + expect(data.reason).toBe("community-not-found"); + }); + + // ----- owner-required enforcement ----------------------------------------- + // The endpoint requires `owner` specifically (not admin+). An admin in + // $admin should be rejected with `owner-required`. + + it("admin (not owner) cannot delete the community", async () => { + const communityDid = await mintCommunity(); + const adminUri = `ats://${communityDid}/${SPACE_TYPE}/$admin`; + + // Promote bob to admin in $admin — strictly below owner. + const grant = await callAs(aliceClient, "POST", `${NS}.space.grant`, { + body: { + spaceUri: adminUri, + subject: { did: bob.did }, + accessLevel: "admin", + }, + }); + expect(grant.status, await grant.clone().text()).toBe(200); + + const del = await callAs(bobClient, "POST", `${NS}.delete`, { + body: { communityDid }, + }); + expect(del.status).toBe(403); + const data = (await jsonOr(del)) as { error: string; reason: string }; + expect(data.error).toBe("Forbidden"); + expect(data.reason).toBe("owner-required"); + + // And the community is still alive — deleted_at is still NULL. + const row = await pool.query( + `SELECT deleted_at FROM communities WHERE did = $1`, + [communityDid], + ); + expect(row.rows[0]?.deleted_at).toBeNull(); + }); + + // ----- stranger gets the same 403 (not 404) -------------------------------- + // A user with no role on the community still hits the level check — the + // endpoint shape is "Forbidden / owner-required", not "NotFound", which + // matters because it tells the caller the resource exists but they're + // not the owner. Distinct from the post-delete "community-not-found" case + // above. + + it("non-member cannot delete the community", async () => { + const communityDid = await mintCommunity(); + + const stranger = await createTestAccount(); + const strangerClient = await login(stranger); + const del = await callAs(strangerClient, "POST", `${NS}.delete`, { + body: { communityDid }, + }); + expect(del.status).toBe(403); + const data = (await jsonOr(del)) as { error: string; reason: string }; + expect(data.error).toBe("Forbidden"); + expect(data.reason).toBe("owner-required"); + }); +}); -- 2.51.2 From 474dc2db12c816bac32f9db2bb3e3647494e9564 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:02:57 +0000 Subject: [PATCH 6/6] Version Packages --- .changeset/fluffy-camels-sniff.md | 5 ----- packages/contrail/CHANGELOG.md | 6 ++++++ packages/contrail/package.json | 2 +- packages/lexicons/CHANGELOG.md | 7 +++++++ packages/lexicons/package.json | 2 +- 5 files changed, 15 insertions(+), 7 deletions(-) delete mode 100644 .changeset/fluffy-camels-sniff.md diff --git a/.changeset/fluffy-camels-sniff.md b/.changeset/fluffy-camels-sniff.md deleted file mode 100644 index f03c5b5..0000000 --- a/.changeset/fluffy-camels-sniff.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@atmo-dev/contrail": patch ---- - -update cli diff --git a/packages/contrail/CHANGELOG.md b/packages/contrail/CHANGELOG.md index 90cf2f8..8777bd4 100644 --- a/packages/contrail/CHANGELOG.md +++ b/packages/contrail/CHANGELOG.md @@ -1,5 +1,11 @@ # @atmo-dev/contrail +## 0.4.1 + +### Patch Changes + +- 0e6ba77: update cli + ## 0.4.0 ### Minor Changes diff --git a/packages/contrail/package.json b/packages/contrail/package.json index 2adafed..f8f2cd5 100644 --- a/packages/contrail/package.json +++ b/packages/contrail/package.json @@ -1,6 +1,6 @@ { "name": "@atmo-dev/contrail", - "version": "0.4.0", + "version": "0.4.1", "description": "Index AT Protocol records with typed XRPC endpoints. Cloudflare Workers + D1, SvelteKit, Node.js.", "type": "module", "sideEffects": false, diff --git a/packages/lexicons/CHANGELOG.md b/packages/lexicons/CHANGELOG.md index 605796e..ed0fe01 100644 --- a/packages/lexicons/CHANGELOG.md +++ b/packages/lexicons/CHANGELOG.md @@ -1,5 +1,12 @@ # @atmo-dev/contrail-lexicons +## 0.4.1 + +### Patch Changes + +- Updated dependencies [0e6ba77] + - @atmo-dev/contrail@0.4.1 + ## 0.4.0 ### Minor Changes diff --git a/packages/lexicons/package.json b/packages/lexicons/package.json index 3bcab5d..a9c9954 100644 --- a/packages/lexicons/package.json +++ b/packages/lexicons/package.json @@ -1,6 +1,6 @@ { "name": "@atmo-dev/contrail-lexicons", - "version": "0.4.0", + "version": "0.4.1", "description": "Generate atproto lexicon JSON (and optionally TypeScript types via @atcute/lex-cli) from a Contrail config.", "type": "module", "files": [