[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #24 from tompscanlan/test/community-lifecycle-e2e

test: e2e suite for community lifecycle + publishing

authored by

Florian and committed by
GitHub
(Apr 29, 2026, 6:40 PM +0200) e8386eef 5847a13a

+704 -12
+11 -11
apps/contrail-e2e/README.md
··· 52 52 53 53 ## Tests 54 54 55 - - `tests/health.test.ts` — service health checks (PLC, PDS, TAP, Jetstream) 56 - - `tests/ingest-roundtrip.test.ts` — publish → index roundtrip. Creates a 57 - fresh PDS account, publishes a calendar event and an RSVP, and verifies 58 - both the record and its `rsvpsGoingCount` via an in-process XRPC handler. 59 - - `tests/cursor-resume.test.ts` — regression for `runPersistent`'s durable 60 - cursor. Starts the ingester, publishes A, stops the ingester, publishes 61 - B + updates B + deletes A, restarts, and verifies the saved cursor 62 - replays the gap. 63 - 64 - Each test spins up its own `runPersistent` in-process against an isolated 65 - postgres schema, so tests don't interfere with each other or with any 55 + See `tests/` for the current suite. Each test header explains its scope. 56 + Tests spin up their own `runPersistent` in-process against an isolated 57 + postgres schema, so they don't interfere with each other or with a 66 58 dogfooding ingester running in another terminal. 59 + 60 + ### Gap-probe pattern (`it.fails`) 61 + 62 + Some tests use vitest's `it.fails(...)` to pin currently-known gaps in 63 + contrail behavior — they pass *because* contrail doesn't yet enforce the 64 + condition. The moment the condition is enforced, the test flips from 65 + passing to failing, forcing whoever lands the fix to update the assertion 66 + to the new correct behavior. Each `it.fails` block names the gap inline. 67 67 68 68 ## Teardown 69 69
+307
apps/contrail-e2e/tests/community-lifecycle.test.ts
··· 1 + /** 2 + * Community lifecycle end-to-end: mint → bootstrap reserved spaces → grant → 3 + * list → setAccessLevel → revoke → ownership handoff. 4 + * 5 + * The community module exposes a 4-level access ladder (member → manager → 6 + * admin → owner) on the reserved `$admin` space; everything else (granting 7 + * roles, listing members, transferring ownership) is operations on that 8 + * ACL. Ownership handoff is therefore role rotation: promote a successor 9 + * to `owner`, then demote or revoke the departing owner. The community 10 + * DID itself never moves. 11 + * 12 + * Gap pinned with `it.fails`: 13 + * 14 + * Last-owner guard. The owner of a community can revoke themselves 15 + * (or setAccessLevel themselves down) while they are the ONLY owner, 16 + * leaving the community ownerless and unmanageable. The two `it.fails` 17 + * probes flip to passing the moment Contrail enforces the guard, 18 + * forcing whoever lands the fix to update the assertion. 19 + * 20 + * Each test mints its own service-auth JWT per call — same pattern as 21 + * spaces-auth.test.ts. No mocks on the auth path; real PDS → real PLC → 22 + * real verifier. 23 + * 24 + * Prereqs: `pnpm stack:up`. 25 + */ 26 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 27 + import pg from "pg"; 28 + import type { Client } from "@atcute/client"; 29 + import "@atcute/atproto"; 30 + import { Contrail } from "@atmo-dev/contrail"; 31 + import { createHandler } from "@atmo-dev/contrail/server"; 32 + import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; 33 + import { config as baseConfig } from "../config"; 34 + import { 35 + createTestAccount, 36 + createIsolatedSchema, 37 + createDevnetResolver, 38 + createCaller, 39 + login, 40 + jsonOr, 41 + CONTRAIL_SERVICE_DID, 42 + type CallAs, 43 + type TestAccount, 44 + } from "./helpers"; 45 + 46 + const NS = `${baseConfig.namespace}.community`; 47 + const SPACE_TYPE = "rsvp.atmo.event.space"; 48 + 49 + // Deterministic 32-byte key for envelope-encrypting community credentials in 50 + // tests. Production uses a KMS-sourced secret — this is fine for devnet-only. 51 + const TEST_MASTER_KEY = new Uint8Array(32).fill(7); 52 + 53 + describe("community lifecycle (mint → grant → list → revoke, + gap probes)", () => { 54 + let alice: TestAccount; // creator / owner 55 + let bob: TestAccount; // promoted to manager → admin 56 + let carol: TestAccount; // member 57 + 58 + let aliceClient: Client; 59 + let bobClient: Client; 60 + 61 + let pool: pg.Pool; 62 + let cleanupSchema: () => Promise<void>; 63 + let callAs: CallAs; 64 + 65 + let communityDid: string; 66 + let adminSpaceUri: string; 67 + 68 + beforeAll(async () => { 69 + [alice, bob, carol] = await Promise.all([ 70 + createTestAccount(), 71 + createTestAccount(), 72 + createTestAccount(), 73 + ]); 74 + 75 + aliceClient = await login(alice); 76 + bobClient = await login(bob); 77 + 78 + const iso = await createIsolatedSchema("test_community_lifecycle"); 79 + pool = iso.pool; 80 + cleanupSchema = iso.cleanup; 81 + const db = createPostgresDatabase(pool); 82 + 83 + const contrail = new Contrail({ 84 + ...baseConfig, 85 + db, 86 + spaces: { 87 + type: SPACE_TYPE, 88 + serviceDid: CONTRAIL_SERVICE_DID, 89 + resolver: createDevnetResolver(), 90 + }, 91 + community: { 92 + serviceDid: CONTRAIL_SERVICE_DID, 93 + masterKey: TEST_MASTER_KEY, 94 + resolver: createDevnetResolver(), 95 + }, 96 + }); 97 + await contrail.init(); 98 + callAs = createCaller(createHandler(contrail)); 99 + }); 100 + 101 + afterAll(async () => { 102 + await cleanupSchema?.(); 103 + }); 104 + 105 + // ----- create a community with a 4-level ACL ----------------------------- 106 + 107 + it("mints a community and returns a recovery key to the creator", async () => { 108 + const res = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); 109 + expect(res.status, await res.clone().text()).toBe(200); 110 + const data = (await res.json()) as { 111 + communityDid: string; 112 + recoveryKey: unknown; 113 + }; 114 + expect(data.communityDid).toMatch(/^did:plc:/); 115 + expect(data.recoveryKey).toBeTruthy(); 116 + communityDid = data.communityDid; 117 + 118 + // bootstrapReservedSpaces creates the $admin space owned by `communityDid` 119 + // with Alice as the initial owner access-row. 120 + adminSpaceUri = `ats://${communityDid}/${SPACE_TYPE}/$admin`; 121 + }); 122 + 123 + it("grants bob=manager and carol=member on the admin space", async () => { 124 + for (const [subject, level] of [ 125 + [bob.did, "manager"], 126 + [carol.did, "member"], 127 + ] as const) { 128 + const res = await callAs(aliceClient, "POST", `${NS}.space.grant`, { 129 + body: { 130 + spaceUri: adminSpaceUri, 131 + subject: { did: subject }, 132 + accessLevel: level, 133 + }, 134 + }); 135 + expect(res.status, `grant ${subject}=${level}: ${await res.clone().text()}`) 136 + .toBe(200); 137 + } 138 + }); 139 + 140 + it("listMembers reflects the full ACL ladder", async () => { 141 + const res = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { 142 + query: { spaceUri: adminSpaceUri }, 143 + }); 144 + expect(res.status).toBe(200); 145 + const data = (await jsonOr(res)) as { 146 + rows: Array<{ subject: { did?: string }; accessLevel: string }>; 147 + }; 148 + const byDid = Object.fromEntries( 149 + data.rows 150 + .filter((r) => r.subject.did) 151 + .map((r) => [r.subject.did, r.accessLevel]), 152 + ); 153 + expect(byDid[alice.did]).toBe("owner"); 154 + expect(byDid[bob.did]).toBe("manager"); 155 + expect(byDid[carol.did]).toBe("member"); 156 + }); 157 + 158 + it("community.list returns the community for members but not strangers", async () => { 159 + // Alice is owner — should see the community. 160 + const aliceList = await callAs(aliceClient, "GET", `${NS}.list`); 161 + expect(aliceList.status).toBe(200); 162 + const { communities: aliceCommunities } = (await jsonOr(aliceList)) as { 163 + communities: Array<{ did: string }>; 164 + }; 165 + expect(aliceCommunities.map((c) => c.did)).toContain(communityDid); 166 + 167 + // A fresh account with no grants — should see nothing. 168 + const stranger = await createTestAccount(); 169 + const strangerClient = await login(stranger); 170 + const strangerList = await callAs(strangerClient, "GET", `${NS}.list`); 171 + expect(strangerList.status).toBe(200); 172 + const { communities: strangerCommunities } = (await jsonOr(strangerList)) as { 173 + communities: Array<{ did: string }>; 174 + }; 175 + expect(strangerCommunities.map((c) => c.did)).not.toContain(communityDid); 176 + }); 177 + 178 + it("promotes bob manager → admin via setAccessLevel", async () => { 179 + const res = await callAs(aliceClient, "POST", `${NS}.space.setAccessLevel`, { 180 + body: { 181 + spaceUri: adminSpaceUri, 182 + subject: { did: bob.did }, 183 + accessLevel: "admin", 184 + }, 185 + }); 186 + expect(res.status, await res.clone().text()).toBe(200); 187 + 188 + const list = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { 189 + query: { spaceUri: adminSpaceUri }, 190 + }); 191 + const { rows } = (await jsonOr(list)) as { 192 + rows: Array<{ subject: { did?: string }; accessLevel: string }>; 193 + }; 194 + expect(rows.find((r) => r.subject.did === bob.did)?.accessLevel).toBe("admin"); 195 + }); 196 + 197 + it("rejects a manager trying to grant admin (cannot-grant-higher-than-self)", async () => { 198 + // Bob is admin now; carol's still a member. Have bob try to promote carol 199 + // to owner — should 403 with cannot-grant-higher-than-self. 200 + const res = await callAs(bobClient, "POST", `${NS}.space.grant`, { 201 + body: { 202 + spaceUri: adminSpaceUri, 203 + subject: { did: carol.did }, 204 + accessLevel: "owner", 205 + }, 206 + }); 207 + expect(res.status).toBe(403); 208 + const data = await jsonOr(res); 209 + expect(data.reason).toBe("cannot-grant-higher-than-self"); 210 + }); 211 + 212 + it("revokes carol cleanly (happy path)", async () => { 213 + const res = await callAs(aliceClient, "POST", `${NS}.space.revoke`, { 214 + body: { spaceUri: adminSpaceUri, subject: { did: carol.did } }, 215 + }); 216 + expect(res.status, await res.clone().text()).toBe(200); 217 + }); 218 + 219 + // ----- gap probes: last-owner guard missing ------------------------------- 220 + // Today Alice (the only owner) can revoke herself, leaving the community 221 + // unmanageable. When Contrail adds the guard, both probes flip to passing 222 + // and force the fix to update the expected status code. 223 + 224 + it.fails( 225 + "space.revoke should reject removing the last owner", 226 + async () => { 227 + const res = await callAs(aliceClient, "POST", `${NS}.space.revoke`, { 228 + body: { spaceUri: adminSpaceUri, subject: { did: alice.did } }, 229 + }); 230 + // Expected future behavior: 4xx (e.g. 409 Conflict, reason: "last-owner"). 231 + // Document the currently-observed behavior in the assertion message so 232 + // the failure is useful when run before the guard lands. 233 + expect( 234 + res.status, 235 + `Pre-guard Contrail returns 200 here — the community is now ownerless. ` + 236 + `Once the last-owner guard lands, this should return 4xx.`, 237 + ).not.toBe(200); 238 + }, 239 + ); 240 + 241 + it.fails( 242 + "setAccessLevel should reject demoting the last owner", 243 + async () => { 244 + // Fresh community so the previous test's state doesn't interfere. 245 + const mint = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); 246 + const { communityDid: freshDid } = (await mint.json()) as { 247 + communityDid: string; 248 + }; 249 + const freshAdmin = `ats://${freshDid}/${SPACE_TYPE}/$admin`; 250 + 251 + const res = await callAs(aliceClient, "POST", `${NS}.space.setAccessLevel`, { 252 + body: { 253 + spaceUri: freshAdmin, 254 + subject: { did: alice.did }, 255 + accessLevel: "manager", 256 + }, 257 + }); 258 + expect(res.status).not.toBe(200); 259 + }, 260 + ); 261 + 262 + // ----- ownership handoff via role rotation -------------------------------- 263 + // The community DID stays put; ownership moves by promoting a successor to 264 + // `owner` and then demoting (or revoking) the original owner. Uses a fresh 265 + // community so the earlier tests' mutations don't interfere. 266 + 267 + it("hands off ownership by promoting a successor and demoting the original owner", async () => { 268 + const mint = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); 269 + const { communityDid: freshDid } = (await mint.json()) as { 270 + communityDid: string; 271 + }; 272 + const freshAdmin = `ats://${freshDid}/${SPACE_TYPE}/$admin`; 273 + 274 + // 1. Alice promotes Bob to owner (two owners now — no last-owner risk). 275 + const promote = await callAs(aliceClient, "POST", `${NS}.space.grant`, { 276 + body: { 277 + spaceUri: freshAdmin, 278 + subject: { did: bob.did }, 279 + accessLevel: "owner", 280 + }, 281 + }); 282 + expect(promote.status, await promote.clone().text()).toBe(200); 283 + 284 + // 2. Bob demotes Alice to manager — he is now the sole owner. 285 + const demote = await callAs(bobClient, "POST", `${NS}.space.setAccessLevel`, { 286 + body: { 287 + spaceUri: freshAdmin, 288 + subject: { did: alice.did }, 289 + accessLevel: "manager", 290 + }, 291 + }); 292 + expect(demote.status, await demote.clone().text()).toBe(200); 293 + 294 + // 3. Verify final state: Bob=owner, Alice=manager. 295 + const list = await callAs(bobClient, "GET", `${NS}.space.listMembers`, { 296 + query: { spaceUri: freshAdmin }, 297 + }); 298 + const { rows } = (await jsonOr(list)) as { 299 + rows: Array<{ subject: { did?: string }; accessLevel: string }>; 300 + }; 301 + const byDid = Object.fromEntries( 302 + rows.filter((r) => r.subject.did).map((r) => [r.subject.did, r.accessLevel]), 303 + ); 304 + expect(byDid[bob.did]).toBe("owner"); 305 + expect(byDid[alice.did]).toBe("manager"); 306 + }); 307 + });
+272
apps/contrail-e2e/tests/community-publishing.test.ts
··· 1 + /** 2 + * Community publishing end-to-end. 3 + * 4 + * Pins the proxy path that's unique to the community module: caller's JWT → 5 + * encrypted app-password decrypt → PDS session → `com.atproto.repo.createRecord` 6 + * with the **community** DID as `repo` → Jetstream propagation → indexer. 7 + * Four real systems on a single hot path. 8 + * 9 + * Each published record gets a 4-way check: 10 + * 1. `community.putRecord` returns 200 with `{ uri, cid }`. 11 + * 2. The record exists at the PDS (`com.atproto.repo.getRecord`) — proves 12 + * the proxy actually wrote, not just contrail's local store. 13 + * 3. The record appears in the contrail index — proves Jetstream + ingest. 14 + * 4. The indexed `did` is the community DID, not the caller's DID. 15 + * 16 + * Plus: minted-community publishing returns NotSupported (no PDS to proxy 17 + * to); a non-publisher gets 403; deleteRecord cleans both PDS and index. 18 + * 19 + * Prereqs: `pnpm stack:up`. 20 + */ 21 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 22 + import pg from "pg"; 23 + import type { Client } from "@atcute/client"; 24 + import "@atcute/atproto"; 25 + import { Contrail, runPersistent } from "@atmo-dev/contrail"; 26 + import { createHandler } from "@atmo-dev/contrail/server"; 27 + import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; 28 + import { config as baseConfig } from "../config"; 29 + import { 30 + createTestAccount, 31 + createIsolatedSchema, 32 + createDevnetResolver, 33 + createCaller, 34 + createAppPasswordFor, 35 + devnetRewriteFetch, 36 + getRecordFromPds, 37 + login, 38 + CONTRAIL_SERVICE_DID, 39 + waitFor, 40 + type CallAs, 41 + type TestAccount, 42 + } from "./helpers"; 43 + 44 + const NS = `${baseConfig.namespace}.community`; 45 + const SPACE_TYPE = "rsvp.atmo.event.space"; 46 + const EVENT_NSID = "community.lexicon.calendar.event"; 47 + const TEST_MASTER_KEY = new Uint8Array(32).fill(7); 48 + 49 + describe("community publishing (proxy → PDS → Jetstream → index)", () => { 50 + let alice: TestAccount; // owner of community spaces (caller) 51 + let bob: TestAccount; // account adopted as the community 52 + let charlie: TestAccount; // outsider — no grants on the community 53 + 54 + let aliceClient: Client; 55 + let charlieClient: Client; 56 + let bobAppPassword: string; 57 + 58 + let pool: pg.Pool; 59 + let cleanupSchema: () => Promise<void>; 60 + let handle: (req: Request) => Promise<Response>; 61 + let callAs: CallAs; 62 + let ingestController: AbortController; 63 + let ingestPromise: Promise<void>; 64 + 65 + let adoptedCommunityDid: string; 66 + let publishedUri: string; 67 + let publishedRkey: string; 68 + 69 + beforeAll(async () => { 70 + [alice, bob, charlie] = await Promise.all([ 71 + createTestAccount(), 72 + createTestAccount(), 73 + createTestAccount(), 74 + ]); 75 + 76 + aliceClient = await login(alice); 77 + charlieClient = await login(charlie); 78 + 79 + // Bob mints an app password — that's what gets stored encrypted in the 80 + // credential vault and used for every proxied write. 81 + bobAppPassword = await createAppPasswordFor(bob); 82 + 83 + const iso = await createIsolatedSchema("test_community_publishing"); 84 + pool = iso.pool; 85 + cleanupSchema = iso.cleanup; 86 + const db = createPostgresDatabase(pool); 87 + 88 + const contrail = new Contrail({ 89 + ...baseConfig, 90 + db, 91 + spaces: { 92 + type: SPACE_TYPE, 93 + serviceDid: CONTRAIL_SERVICE_DID, 94 + resolver: createDevnetResolver(), 95 + }, 96 + community: { 97 + serviceDid: CONTRAIL_SERVICE_DID, 98 + masterKey: TEST_MASTER_KEY, 99 + resolver: createDevnetResolver(), 100 + // Devnet PDS publishes "https://devnet.test" as its public endpoint 101 + // in every DID document, which isn't reachable from the test 102 + // process. Rewrite outgoing requests so the credential check on 103 + // adopt and the proxied createRecord on putRecord both hit the 104 + // host-mapped port instead. 105 + fetch: devnetRewriteFetch, 106 + }, 107 + }); 108 + await contrail.init(); 109 + handle = createHandler(contrail); 110 + callAs = createCaller(handle); 111 + 112 + ingestController = new AbortController(); 113 + ingestPromise = runPersistent(db, baseConfig, { 114 + batchSize: 50, 115 + flushIntervalMs: 500, 116 + signal: ingestController.signal, 117 + }); 118 + 119 + // Alice adopts Bob's account as the community. Alice becomes owner of 120 + // both $admin and $publishers via bootstrapReservedSpaces. Pass Bob's 121 + // DID rather than his handle — devnet handles aren't resolvable via 122 + // the public /.well-known path that resolveIdentity falls back to. 123 + const adopt = await callAs(aliceClient, "POST", `${NS}.adopt`, { 124 + body: { identifier: bob.did, appPassword: bobAppPassword }, 125 + }); 126 + expect(adopt.status, await adopt.clone().text()).toBe(200); 127 + const data = (await adopt.json()) as { communityDid: string }; 128 + adoptedCommunityDid = data.communityDid; 129 + expect(adoptedCommunityDid).toBe(bob.did); 130 + }); 131 + 132 + afterAll(async () => { 133 + ingestController?.abort(); 134 + await ingestPromise?.catch(() => {}); 135 + await cleanupSchema?.(); 136 + }); 137 + 138 + // ----- helpers ------------------------------------------------------------ 139 + 140 + /** Look up the record in the contrail index via the local XRPC handler. */ 141 + async function getIndexedRecord(uri: string): Promise<any | undefined> { 142 + const url = `http://test/xrpc/${baseConfig.namespace}.event.getRecord?uri=${encodeURIComponent(uri)}`; 143 + const res = await handle(new Request(url)); 144 + if (res.status === 404) return undefined; 145 + if (!res.ok) throw new Error(`getRecord ${uri} → ${res.status}: ${await res.text()}`); 146 + return await res.json(); 147 + } 148 + 149 + // ----- happy path: adopted community publishes a public event ------------- 150 + 151 + it("publishes via community.putRecord and lands at PDS, Jetstream, and index", async () => { 152 + const eventName = `community-published ${Date.now()}`; 153 + const startsAt = new Date(Date.now() + 60 * 60_000).toISOString(); 154 + 155 + const res = await callAs(aliceClient, "POST", `${NS}.putRecord`, { 156 + body: { 157 + communityDid: adoptedCommunityDid, 158 + collection: EVENT_NSID, 159 + record: { 160 + $type: EVENT_NSID, 161 + name: eventName, 162 + createdAt: new Date().toISOString(), 163 + startsAt, 164 + mode: `${EVENT_NSID}#inperson`, 165 + status: `${EVENT_NSID}#scheduled`, 166 + }, 167 + }, 168 + }); 169 + expect(res.status, await res.clone().text()).toBe(200); 170 + const out = (await res.json()) as { uri: string; cid: string }; 171 + publishedUri = out.uri; 172 + publishedRkey = out.uri.split("/").pop()!; 173 + 174 + // (1) URI is rooted at the community's DID, not Alice's. `at://` is 175 + // intentional here — this is a PDS-issued record URI, distinct from 176 + // the `ats://` scheme used for Contrail-internal space URIs. 177 + expect(publishedUri).toMatch(new RegExp(`^at://${adoptedCommunityDid}/${EVENT_NSID}/`)); 178 + 179 + // (2) PDS actually has it — proves the proxy wrote, not just the local DB. 180 + const pds = await getRecordFromPds(adoptedCommunityDid, EVENT_NSID, publishedRkey); 181 + expect(pds.status).toBe(200); 182 + expect(pds.record.name).toBe(eventName); 183 + 184 + // (3) Index has it — proves Jetstream + ingester. 185 + // (4) Indexed `did` is the community DID, not the caller's DID. 186 + const indexed = await waitFor( 187 + () => getIndexedRecord(publishedUri), 188 + { label: `index ${publishedUri}` }, 189 + ); 190 + expect(indexed.did).toBe(adoptedCommunityDid); 191 + expect(indexed.did).not.toBe(alice.did); 192 + expect(indexed.value.name).toBe(eventName); 193 + }); 194 + 195 + // ----- authorization: non-member can't publish on behalf of community ----- 196 + 197 + it("rejects publishing from a caller not in $publishers", async () => { 198 + const res = await callAs(charlieClient, "POST", `${NS}.putRecord`, { 199 + body: { 200 + communityDid: adoptedCommunityDid, 201 + collection: EVENT_NSID, 202 + record: { 203 + $type: EVENT_NSID, 204 + name: "should never land", 205 + createdAt: new Date().toISOString(), 206 + startsAt: new Date(Date.now() + 60 * 60_000).toISOString(), 207 + mode: `${EVENT_NSID}#inperson`, 208 + status: `${EVENT_NSID}#scheduled`, 209 + }, 210 + }, 211 + }); 212 + expect(res.status).toBe(403); 213 + const data = (await res.json()) as { reason: string }; 214 + expect(data.reason).toBe("not-in-publishers"); 215 + }); 216 + 217 + // ----- delete roundtrip: PDS + index both clear -------------------------- 218 + 219 + it("deletes a published record from both PDS and index", async () => { 220 + expect(publishedRkey, "previous test must have published").toBeTruthy(); 221 + 222 + const res = await callAs(aliceClient, "POST", `${NS}.deleteRecord`, { 223 + body: { 224 + communityDid: adoptedCommunityDid, 225 + collection: EVENT_NSID, 226 + rkey: publishedRkey, 227 + }, 228 + }); 229 + expect(res.status, await res.clone().text()).toBe(200); 230 + 231 + const pds = await getRecordFromPds(adoptedCommunityDid, EVENT_NSID, publishedRkey); 232 + expect(pds.status).toBe(400); // PDS returns 400 RecordNotFound, not 404 233 + 234 + await waitFor( 235 + async () => ((await getIndexedRecord(publishedUri)) === undefined ? true : undefined), 236 + { label: `index drops ${publishedUri}` }, 237 + ); 238 + }); 239 + 240 + // ----- minted communities have no PDS to proxy to ------------------------ 241 + // A minted community is a contrail-controlled DID with no `atproto_pds` 242 + // service entry, so there's no repo to write into. `community.putRecord` 243 + // returns NotSupported. If minted publishing ever lands (e.g. by routing 244 + // writes to a community-owned repo), update this assertion. 245 + 246 + it("minted communities cannot publish public records", async () => { 247 + const mint = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); 248 + expect(mint.status).toBe(200); 249 + const { communityDid: mintedDid } = (await mint.json()) as { 250 + communityDid: string; 251 + }; 252 + 253 + const res = await callAs(aliceClient, "POST", `${NS}.putRecord`, { 254 + body: { 255 + communityDid: mintedDid, 256 + collection: EVENT_NSID, 257 + record: { 258 + $type: EVENT_NSID, 259 + name: "should never publish", 260 + createdAt: new Date().toISOString(), 261 + startsAt: new Date(Date.now() + 60 * 60_000).toISOString(), 262 + mode: `${EVENT_NSID}#inperson`, 263 + status: `${EVENT_NSID}#scheduled`, 264 + }, 265 + }, 266 + }); 267 + expect(res.status).toBe(400); 268 + const data = (await res.json()) as { error: string; reason: string }; 269 + expect(data.error).toBe("NotSupported"); 270 + expect(data.reason).toBe("publishing-not-supported-for-minted-communities"); 271 + }); 272 + });
+114 -1
apps/contrail-e2e/tests/helpers.ts
··· 6 6 * dogfooding ingester the developer might have running in another terminal. 7 7 */ 8 8 import pg from "pg"; 9 - import type { Client } from "@atcute/client"; 9 + import { CredentialManager, Client } from "@atcute/client"; 10 10 import { 11 11 CompositeDidDocumentResolver, 12 12 PlcDidDocumentResolver, ··· 156 156 `waitFor(${label}) timed out after ${timeoutMs}ms (${attempts} attempts)` + 157 157 (lastErr ? `: ${(lastErr as Error).message}` : ""), 158 158 ); 159 + } 160 + 161 + /** 162 + * Log a TestAccount into the devnet PDS and return an authed atcute Client. 163 + */ 164 + export async function login(acct: TestAccount): Promise<Client> { 165 + const creds = new CredentialManager({ service: PDS_URL }); 166 + await creds.login({ identifier: acct.handle, password: acct.password }); 167 + return new Client({ handler: creds }); 168 + } 169 + 170 + /** 171 + * A `fetch` shim that rewrites the unreachable `https://devnet.test` host 172 + * (which devnet PDSes publish in every DID document's `atproto_pds` service 173 + * entry) to the host-mapped `PDS_URL`. Pass this as `community.fetch` so the 174 + * credential check on adopt and the proxied createRecord on putRecord both 175 + * land on the local container instead of failing DNS. 176 + */ 177 + export const devnetRewriteFetch: typeof fetch = (input, init) => { 178 + const url = typeof input === "string" ? input : input.toString(); 179 + return fetch(url.replace(/^https:\/\/devnet\.test/, PDS_URL), init); 180 + }; 181 + 182 + /** 183 + * Fetch a record straight from the devnet PDS via `com.atproto.repo.getRecord`. 184 + * Used to confirm a proxied write (e.g. via `community.putRecord`) actually 185 + * landed on the PDS and not just contrail's local index. 186 + */ 187 + export async function getRecordFromPds( 188 + repo: string, 189 + collection: string, 190 + rkey: string, 191 + ): Promise<{ status: number; record?: any }> { 192 + const url = 193 + `${PDS_URL}/xrpc/com.atproto.repo.getRecord` + 194 + `?repo=${encodeURIComponent(repo)}` + 195 + `&collection=${encodeURIComponent(collection)}` + 196 + `&rkey=${encodeURIComponent(rkey)}`; 197 + const res = await fetch(url); 198 + if (!res.ok) return { status: res.status }; 199 + const body = (await res.json()) as { value: any }; 200 + return { status: res.status, record: body.value }; 201 + } 202 + 203 + /** 204 + * Mint an app password for `acct` via the PDS. Used by tests that need to 205 + * adopt the account as a community (the community module stores the app 206 + * password encrypted in its credential vault). 207 + */ 208 + export async function createAppPasswordFor(acct: TestAccount): Promise<string> { 209 + const c = await login(acct); 210 + const res = await c.post("com.atproto.server.createAppPassword", { 211 + input: { name: `e2e-${Date.now()}` }, 212 + }); 213 + if (!res.ok) { 214 + throw new Error(`createAppPassword: ${JSON.stringify(res.data)}`); 215 + } 216 + return res.data.password; 217 + } 218 + 219 + /** 220 + * A callAs function makes an XRPC call against the in-process Contrail 221 + * handler with a freshly minted service-auth JWT. Each call mints its own 222 + * token so the `lxm` claim binds to that specific endpoint. 223 + */ 224 + export type CallAs = ( 225 + client: Client, 226 + method: "GET" | "POST", 227 + lxm: string, 228 + opts?: { body?: unknown; query?: Record<string, string> }, 229 + ) => Promise<Response>; 230 + 231 + /** 232 + * Create a caller bound to a specific in-process handler. Use one per test 233 + * file's `beforeAll` to avoid passing `handle` through every assertion. 234 + */ 235 + export function createCaller( 236 + handle: (req: Request) => Promise<Response>, 237 + ): CallAs { 238 + return async (client, method, lxm, opts = {}) => { 239 + const token = await mintServiceAuthJwt(client, { 240 + aud: CONTRAIL_SERVICE_DID, 241 + lxm, 242 + }); 243 + const qs = opts.query 244 + ? "?" + new URLSearchParams(opts.query).toString() 245 + : ""; 246 + const headers: Record<string, string> = { 247 + authorization: `Bearer ${token}`, 248 + }; 249 + if (opts.body !== undefined) headers["content-type"] = "application/json"; 250 + return handle( 251 + new Request(`http://test/xrpc/${lxm}${qs}`, { 252 + method, 253 + headers, 254 + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, 255 + }), 256 + ); 257 + }; 258 + } 259 + 260 + /** 261 + * Parse a Response body as JSON, throwing a clear error (with status + raw 262 + * text) if the body isn't JSON. Saves a `try/catch` in every assertion that 263 + * needs to inspect a 4xx/5xx body. 264 + */ 265 + export async function jsonOr(res: Response): Promise<any> { 266 + const text = await res.text(); 267 + try { 268 + return JSON.parse(text); 269 + } catch { 270 + throw new Error(`non-JSON response ${res.status}: ${text}`); 271 + } 159 272 }