···52525353## Tests
54545555-- `tests/health.test.ts` — service health checks (PLC, PDS, TAP, Jetstream)
5656-- `tests/ingest-roundtrip.test.ts` — publish → index roundtrip. Creates a
5757- fresh PDS account, publishes a calendar event and an RSVP, and verifies
5858- both the record and its `rsvpsGoingCount` via an in-process XRPC handler.
5959-- `tests/cursor-resume.test.ts` — regression for `runPersistent`'s durable
6060- cursor. Starts the ingester, publishes A, stops the ingester, publishes
6161- B + updates B + deletes A, restarts, and verifies the saved cursor
6262- replays the gap.
6363-6464-Each test spins up its own `runPersistent` in-process against an isolated
6565-postgres schema, so tests don't interfere with each other or with any
5555+See `tests/` for the current suite. Each test header explains its scope.
5656+Tests spin up their own `runPersistent` in-process against an isolated
5757+postgres schema, so they don't interfere with each other or with a
6658dogfooding ingester running in another terminal.
5959+6060+### Gap-probe pattern (`it.fails`)
6161+6262+Some tests use vitest's `it.fails(...)` to pin currently-known gaps in
6363+contrail behavior — they pass *because* contrail doesn't yet enforce the
6464+condition. The moment the condition is enforced, the test flips from
6565+passing to failing, forcing whoever lands the fix to update the assertion
6666+to the new correct behavior. Each `it.fails` block names the gap inline.
67676868## Teardown
6969
···11+/**
22+ * Community publishing end-to-end.
33+ *
44+ * Pins the proxy path that's unique to the community module: caller's JWT →
55+ * encrypted app-password decrypt → PDS session → `com.atproto.repo.createRecord`
66+ * with the **community** DID as `repo` → Jetstream propagation → indexer.
77+ * Four real systems on a single hot path.
88+ *
99+ * Each published record gets a 4-way check:
1010+ * 1. `community.putRecord` returns 200 with `{ uri, cid }`.
1111+ * 2. The record exists at the PDS (`com.atproto.repo.getRecord`) — proves
1212+ * the proxy actually wrote, not just contrail's local store.
1313+ * 3. The record appears in the contrail index — proves Jetstream + ingest.
1414+ * 4. The indexed `did` is the community DID, not the caller's DID.
1515+ *
1616+ * Plus: minted-community publishing returns NotSupported (no PDS to proxy
1717+ * to); a non-publisher gets 403; deleteRecord cleans both PDS and index.
1818+ *
1919+ * Prereqs: `pnpm stack:up`.
2020+ */
2121+import { describe, it, expect, beforeAll, afterAll } from "vitest";
2222+import pg from "pg";
2323+import type { Client } from "@atcute/client";
2424+import "@atcute/atproto";
2525+import { Contrail, runPersistent } from "@atmo-dev/contrail";
2626+import { createHandler } from "@atmo-dev/contrail/server";
2727+import { createPostgresDatabase } from "@atmo-dev/contrail/postgres";
2828+import { config as baseConfig } from "../config";
2929+import {
3030+ createTestAccount,
3131+ createIsolatedSchema,
3232+ createDevnetResolver,
3333+ createCaller,
3434+ createAppPasswordFor,
3535+ devnetRewriteFetch,
3636+ getRecordFromPds,
3737+ login,
3838+ CONTRAIL_SERVICE_DID,
3939+ waitFor,
4040+ type CallAs,
4141+ type TestAccount,
4242+} from "./helpers";
4343+4444+const NS = `${baseConfig.namespace}.community`;
4545+const SPACE_TYPE = "rsvp.atmo.event.space";
4646+const EVENT_NSID = "community.lexicon.calendar.event";
4747+const TEST_MASTER_KEY = new Uint8Array(32).fill(7);
4848+4949+describe("community publishing (proxy → PDS → Jetstream → index)", () => {
5050+ let alice: TestAccount; // owner of community spaces (caller)
5151+ let bob: TestAccount; // account adopted as the community
5252+ let charlie: TestAccount; // outsider — no grants on the community
5353+5454+ let aliceClient: Client;
5555+ let charlieClient: Client;
5656+ let bobAppPassword: string;
5757+5858+ let pool: pg.Pool;
5959+ let cleanupSchema: () => Promise<void>;
6060+ let handle: (req: Request) => Promise<Response>;
6161+ let callAs: CallAs;
6262+ let ingestController: AbortController;
6363+ let ingestPromise: Promise<void>;
6464+6565+ let adoptedCommunityDid: string;
6666+ let publishedUri: string;
6767+ let publishedRkey: string;
6868+6969+ beforeAll(async () => {
7070+ [alice, bob, charlie] = await Promise.all([
7171+ createTestAccount(),
7272+ createTestAccount(),
7373+ createTestAccount(),
7474+ ]);
7575+7676+ aliceClient = await login(alice);
7777+ charlieClient = await login(charlie);
7878+7979+ // Bob mints an app password — that's what gets stored encrypted in the
8080+ // credential vault and used for every proxied write.
8181+ bobAppPassword = await createAppPasswordFor(bob);
8282+8383+ const iso = await createIsolatedSchema("test_community_publishing");
8484+ pool = iso.pool;
8585+ cleanupSchema = iso.cleanup;
8686+ const db = createPostgresDatabase(pool);
8787+8888+ const contrail = new Contrail({
8989+ ...baseConfig,
9090+ db,
9191+ spaces: {
9292+ type: SPACE_TYPE,
9393+ serviceDid: CONTRAIL_SERVICE_DID,
9494+ resolver: createDevnetResolver(),
9595+ },
9696+ community: {
9797+ serviceDid: CONTRAIL_SERVICE_DID,
9898+ masterKey: TEST_MASTER_KEY,
9999+ resolver: createDevnetResolver(),
100100+ // Devnet PDS publishes "https://devnet.test" as its public endpoint
101101+ // in every DID document, which isn't reachable from the test
102102+ // process. Rewrite outgoing requests so the credential check on
103103+ // adopt and the proxied createRecord on putRecord both hit the
104104+ // host-mapped port instead.
105105+ fetch: devnetRewriteFetch,
106106+ },
107107+ });
108108+ await contrail.init();
109109+ handle = createHandler(contrail);
110110+ callAs = createCaller(handle);
111111+112112+ ingestController = new AbortController();
113113+ ingestPromise = runPersistent(db, baseConfig, {
114114+ batchSize: 50,
115115+ flushIntervalMs: 500,
116116+ signal: ingestController.signal,
117117+ });
118118+119119+ // Alice adopts Bob's account as the community. Alice becomes owner of
120120+ // both $admin and $publishers via bootstrapReservedSpaces. Pass Bob's
121121+ // DID rather than his handle — devnet handles aren't resolvable via
122122+ // the public /.well-known path that resolveIdentity falls back to.
123123+ const adopt = await callAs(aliceClient, "POST", `${NS}.adopt`, {
124124+ body: { identifier: bob.did, appPassword: bobAppPassword },
125125+ });
126126+ expect(adopt.status, await adopt.clone().text()).toBe(200);
127127+ const data = (await adopt.json()) as { communityDid: string };
128128+ adoptedCommunityDid = data.communityDid;
129129+ expect(adoptedCommunityDid).toBe(bob.did);
130130+ });
131131+132132+ afterAll(async () => {
133133+ ingestController?.abort();
134134+ await ingestPromise?.catch(() => {});
135135+ await cleanupSchema?.();
136136+ });
137137+138138+ // ----- helpers ------------------------------------------------------------
139139+140140+ /** Look up the record in the contrail index via the local XRPC handler. */
141141+ async function getIndexedRecord(uri: string): Promise<any | undefined> {
142142+ const url = `http://test/xrpc/${baseConfig.namespace}.event.getRecord?uri=${encodeURIComponent(uri)}`;
143143+ const res = await handle(new Request(url));
144144+ if (res.status === 404) return undefined;
145145+ if (!res.ok) throw new Error(`getRecord ${uri} → ${res.status}: ${await res.text()}`);
146146+ return await res.json();
147147+ }
148148+149149+ // ----- happy path: adopted community publishes a public event -------------
150150+151151+ it("publishes via community.putRecord and lands at PDS, Jetstream, and index", async () => {
152152+ const eventName = `community-published ${Date.now()}`;
153153+ const startsAt = new Date(Date.now() + 60 * 60_000).toISOString();
154154+155155+ const res = await callAs(aliceClient, "POST", `${NS}.putRecord`, {
156156+ body: {
157157+ communityDid: adoptedCommunityDid,
158158+ collection: EVENT_NSID,
159159+ record: {
160160+ $type: EVENT_NSID,
161161+ name: eventName,
162162+ createdAt: new Date().toISOString(),
163163+ startsAt,
164164+ mode: `${EVENT_NSID}#inperson`,
165165+ status: `${EVENT_NSID}#scheduled`,
166166+ },
167167+ },
168168+ });
169169+ expect(res.status, await res.clone().text()).toBe(200);
170170+ const out = (await res.json()) as { uri: string; cid: string };
171171+ publishedUri = out.uri;
172172+ publishedRkey = out.uri.split("/").pop()!;
173173+174174+ // (1) URI is rooted at the community's DID, not Alice's. `at://` is
175175+ // intentional here — this is a PDS-issued record URI, distinct from
176176+ // the `ats://` scheme used for Contrail-internal space URIs.
177177+ expect(publishedUri).toMatch(new RegExp(`^at://${adoptedCommunityDid}/${EVENT_NSID}/`));
178178+179179+ // (2) PDS actually has it — proves the proxy wrote, not just the local DB.
180180+ const pds = await getRecordFromPds(adoptedCommunityDid, EVENT_NSID, publishedRkey);
181181+ expect(pds.status).toBe(200);
182182+ expect(pds.record.name).toBe(eventName);
183183+184184+ // (3) Index has it — proves Jetstream + ingester.
185185+ // (4) Indexed `did` is the community DID, not the caller's DID.
186186+ const indexed = await waitFor(
187187+ () => getIndexedRecord(publishedUri),
188188+ { label: `index ${publishedUri}` },
189189+ );
190190+ expect(indexed.did).toBe(adoptedCommunityDid);
191191+ expect(indexed.did).not.toBe(alice.did);
192192+ expect(indexed.value.name).toBe(eventName);
193193+ });
194194+195195+ // ----- authorization: non-member can't publish on behalf of community -----
196196+197197+ it("rejects publishing from a caller not in $publishers", async () => {
198198+ const res = await callAs(charlieClient, "POST", `${NS}.putRecord`, {
199199+ body: {
200200+ communityDid: adoptedCommunityDid,
201201+ collection: EVENT_NSID,
202202+ record: {
203203+ $type: EVENT_NSID,
204204+ name: "should never land",
205205+ createdAt: new Date().toISOString(),
206206+ startsAt: new Date(Date.now() + 60 * 60_000).toISOString(),
207207+ mode: `${EVENT_NSID}#inperson`,
208208+ status: `${EVENT_NSID}#scheduled`,
209209+ },
210210+ },
211211+ });
212212+ expect(res.status).toBe(403);
213213+ const data = (await res.json()) as { reason: string };
214214+ expect(data.reason).toBe("not-in-publishers");
215215+ });
216216+217217+ // ----- delete roundtrip: PDS + index both clear --------------------------
218218+219219+ it("deletes a published record from both PDS and index", async () => {
220220+ expect(publishedRkey, "previous test must have published").toBeTruthy();
221221+222222+ const res = await callAs(aliceClient, "POST", `${NS}.deleteRecord`, {
223223+ body: {
224224+ communityDid: adoptedCommunityDid,
225225+ collection: EVENT_NSID,
226226+ rkey: publishedRkey,
227227+ },
228228+ });
229229+ expect(res.status, await res.clone().text()).toBe(200);
230230+231231+ const pds = await getRecordFromPds(adoptedCommunityDid, EVENT_NSID, publishedRkey);
232232+ expect(pds.status).toBe(400); // PDS returns 400 RecordNotFound, not 404
233233+234234+ await waitFor(
235235+ async () => ((await getIndexedRecord(publishedUri)) === undefined ? true : undefined),
236236+ { label: `index drops ${publishedUri}` },
237237+ );
238238+ });
239239+240240+ // ----- minted communities have no PDS to proxy to ------------------------
241241+ // A minted community is a contrail-controlled DID with no `atproto_pds`
242242+ // service entry, so there's no repo to write into. `community.putRecord`
243243+ // returns NotSupported. If minted publishing ever lands (e.g. by routing
244244+ // writes to a community-owned repo), update this assertion.
245245+246246+ it("minted communities cannot publish public records", async () => {
247247+ const mint = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} });
248248+ expect(mint.status).toBe(200);
249249+ const { communityDid: mintedDid } = (await mint.json()) as {
250250+ communityDid: string;
251251+ };
252252+253253+ const res = await callAs(aliceClient, "POST", `${NS}.putRecord`, {
254254+ body: {
255255+ communityDid: mintedDid,
256256+ collection: EVENT_NSID,
257257+ record: {
258258+ $type: EVENT_NSID,
259259+ name: "should never publish",
260260+ createdAt: new Date().toISOString(),
261261+ startsAt: new Date(Date.now() + 60 * 60_000).toISOString(),
262262+ mode: `${EVENT_NSID}#inperson`,
263263+ status: `${EVENT_NSID}#scheduled`,
264264+ },
265265+ },
266266+ });
267267+ expect(res.status).toBe(400);
268268+ const data = (await res.json()) as { error: string; reason: string };
269269+ expect(data.error).toBe("NotSupported");
270270+ expect(data.reason).toBe("publishing-not-supported-for-minted-communities");
271271+ });
272272+});
+114-1
apps/contrail-e2e/tests/helpers.ts
···66 * dogfooding ingester the developer might have running in another terminal.
77 */
88import pg from "pg";
99-import type { Client } from "@atcute/client";
99+import { CredentialManager, Client } from "@atcute/client";
1010import {
1111 CompositeDidDocumentResolver,
1212 PlcDidDocumentResolver,
···156156 `waitFor(${label}) timed out after ${timeoutMs}ms (${attempts} attempts)` +
157157 (lastErr ? `: ${(lastErr as Error).message}` : ""),
158158 );
159159+}
160160+161161+/**
162162+ * Log a TestAccount into the devnet PDS and return an authed atcute Client.
163163+ */
164164+export async function login(acct: TestAccount): Promise<Client> {
165165+ const creds = new CredentialManager({ service: PDS_URL });
166166+ await creds.login({ identifier: acct.handle, password: acct.password });
167167+ return new Client({ handler: creds });
168168+}
169169+170170+/**
171171+ * A `fetch` shim that rewrites the unreachable `https://devnet.test` host
172172+ * (which devnet PDSes publish in every DID document's `atproto_pds` service
173173+ * entry) to the host-mapped `PDS_URL`. Pass this as `community.fetch` so the
174174+ * credential check on adopt and the proxied createRecord on putRecord both
175175+ * land on the local container instead of failing DNS.
176176+ */
177177+export const devnetRewriteFetch: typeof fetch = (input, init) => {
178178+ const url = typeof input === "string" ? input : input.toString();
179179+ return fetch(url.replace(/^https:\/\/devnet\.test/, PDS_URL), init);
180180+};
181181+182182+/**
183183+ * Fetch a record straight from the devnet PDS via `com.atproto.repo.getRecord`.
184184+ * Used to confirm a proxied write (e.g. via `community.putRecord`) actually
185185+ * landed on the PDS and not just contrail's local index.
186186+ */
187187+export async function getRecordFromPds(
188188+ repo: string,
189189+ collection: string,
190190+ rkey: string,
191191+): Promise<{ status: number; record?: any }> {
192192+ const url =
193193+ `${PDS_URL}/xrpc/com.atproto.repo.getRecord` +
194194+ `?repo=${encodeURIComponent(repo)}` +
195195+ `&collection=${encodeURIComponent(collection)}` +
196196+ `&rkey=${encodeURIComponent(rkey)}`;
197197+ const res = await fetch(url);
198198+ if (!res.ok) return { status: res.status };
199199+ const body = (await res.json()) as { value: any };
200200+ return { status: res.status, record: body.value };
201201+}
202202+203203+/**
204204+ * Mint an app password for `acct` via the PDS. Used by tests that need to
205205+ * adopt the account as a community (the community module stores the app
206206+ * password encrypted in its credential vault).
207207+ */
208208+export async function createAppPasswordFor(acct: TestAccount): Promise<string> {
209209+ const c = await login(acct);
210210+ const res = await c.post("com.atproto.server.createAppPassword", {
211211+ input: { name: `e2e-${Date.now()}` },
212212+ });
213213+ if (!res.ok) {
214214+ throw new Error(`createAppPassword: ${JSON.stringify(res.data)}`);
215215+ }
216216+ return res.data.password;
217217+}
218218+219219+/**
220220+ * A callAs function makes an XRPC call against the in-process Contrail
221221+ * handler with a freshly minted service-auth JWT. Each call mints its own
222222+ * token so the `lxm` claim binds to that specific endpoint.
223223+ */
224224+export type CallAs = (
225225+ client: Client,
226226+ method: "GET" | "POST",
227227+ lxm: string,
228228+ opts?: { body?: unknown; query?: Record<string, string> },
229229+) => Promise<Response>;
230230+231231+/**
232232+ * Create a caller bound to a specific in-process handler. Use one per test
233233+ * file's `beforeAll` to avoid passing `handle` through every assertion.
234234+ */
235235+export function createCaller(
236236+ handle: (req: Request) => Promise<Response>,
237237+): CallAs {
238238+ return async (client, method, lxm, opts = {}) => {
239239+ const token = await mintServiceAuthJwt(client, {
240240+ aud: CONTRAIL_SERVICE_DID,
241241+ lxm,
242242+ });
243243+ const qs = opts.query
244244+ ? "?" + new URLSearchParams(opts.query).toString()
245245+ : "";
246246+ const headers: Record<string, string> = {
247247+ authorization: `Bearer ${token}`,
248248+ };
249249+ if (opts.body !== undefined) headers["content-type"] = "application/json";
250250+ return handle(
251251+ new Request(`http://test/xrpc/${lxm}${qs}`, {
252252+ method,
253253+ headers,
254254+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
255255+ }),
256256+ );
257257+ };
258258+}
259259+260260+/**
261261+ * Parse a Response body as JSON, throwing a clear error (with status + raw
262262+ * text) if the body isn't JSON. Saves a `try/catch` in every assertion that
263263+ * needs to inspect a 4xx/5xx body.
264264+ */
265265+export async function jsonOr(res: Response): Promise<any> {
266266+ const text = await res.text();
267267+ try {
268268+ return JSON.parse(text);
269269+ } catch {
270270+ throw new Error(`non-JSON response ${res.status}: ${text}`);
271271+ }
159272}