[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.

add e2e tests for community lifecycle and publishing

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.

Tom Scanlan (Apr 26, 2026, 3:29 PM EDT) 74336fd9 37c03e1a

+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 }