[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 the unified invite surface

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

Tom Scanlan (Apr 26, 2026, 3:37 PM EDT) c63a752e 941bef5f

+272
+272
apps/contrail-e2e/tests/community-invites.test.ts
··· 1 + /** 2 + * Invite end-to-end against a real PDS, real PLC, real Postgres. 3 + * 4 + * Unit tests in `packages/contrail/tests/invite-unified.test.ts` cover the 5 + * router-level dispatch and validation (kind/accessLevel exclusivity, 403s, 6 + * cannot-grant-higher-than-self). What unit tests can't show is: 7 + * 8 + * - Real service-auth JWT verifying through the PDS+PLC chain 9 + * (unit tests use a fakeAuth middleware). 10 + * - The reconcile cascade — community.grant fired by invite.redeem must 11 + * update both the community ACL AND spaces.access_rows. Verified here 12 + * by hitting `community.space.listMembers?flatten=true`, which reads 13 + * from spaces.access_rows directly. 14 + * - Real Postgres enforcement of single-use / revoked invites (the redeem 15 + * query is the source of truth, not in-memory state). 16 + * 17 + * Both invite paths are exercised: 18 + * 19 + * - Community-owned space: alice (admin in $admin) creates a child space, 20 + * mints accessLevel=member invite, bob redeems → grant + reconcile. 21 + * - User-owned space: alice creates her own space, mints kind=join invite, 22 + * bob redeems → addMember. No community module involvement. 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; 47 + const SPACE_TYPE = "rsvp.atmo.event.space"; 48 + const TEST_MASTER_KEY = new Uint8Array(32).fill(7); 49 + 50 + describe("invite e2e (community + user-owned, real JWT)", () => { 51 + let alice: TestAccount; 52 + let bob: TestAccount; 53 + let charlie: TestAccount; 54 + 55 + let aliceClient: Client; 56 + let bobClient: Client; 57 + let charlieClient: Client; 58 + 59 + let pool: pg.Pool; 60 + let cleanupSchema: () => Promise<void>; 61 + let callAs: CallAs; 62 + 63 + let communityDid: string; 64 + let channelUri: string; 65 + 66 + beforeAll(async () => { 67 + [alice, bob, charlie] = await Promise.all([ 68 + createTestAccount(), 69 + createTestAccount(), 70 + createTestAccount(), 71 + ]); 72 + [aliceClient, bobClient, charlieClient] = await Promise.all([ 73 + login(alice), 74 + login(bob), 75 + login(charlie), 76 + ]); 77 + 78 + const iso = await createIsolatedSchema("test_community_invites"); 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 + // Mint a community owned by alice; alice becomes owner of $admin via 101 + // bootstrap and can then create child spaces. 102 + const mint = await callAs(aliceClient, "POST", `${NS}.community.mint`, { body: {} }); 103 + expect(mint.status, await mint.clone().text()).toBe(200); 104 + communityDid = ((await mint.json()) as { communityDid: string }).communityDid; 105 + 106 + // Create a child space for invite tests; alice becomes its owner. 107 + const create = await callAs(aliceClient, "POST", `${NS}.community.space.create`, { 108 + body: { communityDid, key: "general" }, 109 + }); 110 + expect(create.status, await create.clone().text()).toBe(200); 111 + channelUri = ((await create.json()) as { space: { uri: string } }).space.uri; 112 + }); 113 + 114 + afterAll(async () => { 115 + await cleanupSchema?.(); 116 + }); 117 + 118 + // ----- community happy path: grant + reconcile cascade -------------------- 119 + 120 + it("redeem grants in community ACL AND reconciles to spaces.access_rows", async () => { 121 + const create = await callAs(aliceClient, "POST", `${NS}.invite.create`, { 122 + body: { spaceUri: channelUri, accessLevel: "member" }, 123 + }); 124 + expect(create.status, await create.clone().text()).toBe(200); 125 + const { token, invite } = (await create.json()) as { 126 + token: string; 127 + invite: { tokenHash: string; accessLevel: string }; 128 + }; 129 + expect(invite.accessLevel).toBe("member"); 130 + 131 + const redeem = await callAs(bobClient, "POST", `${NS}.invite.redeem`, { 132 + body: { token }, 133 + }); 134 + expect(redeem.status, await redeem.clone().text()).toBe(200); 135 + const redeemed = (await redeem.json()) as { 136 + spaceUri: string; 137 + accessLevel: string; 138 + communityDid: string; 139 + }; 140 + expect(redeemed.spaceUri).toBe(channelUri); 141 + expect(redeemed.accessLevel).toBe("member"); 142 + expect(redeemed.communityDid).toBe(communityDid); 143 + 144 + // (1) Community ACL has bob at member. 145 + const aclList = await callAs(aliceClient, "GET", `${NS}.community.space.listMembers`, { 146 + query: { spaceUri: channelUri }, 147 + }); 148 + expect(aclList.status).toBe(200); 149 + const { rows } = (await jsonOr(aclList)) as { 150 + rows: Array<{ subject: { did?: string }; accessLevel: string }>; 151 + }; 152 + const aclBob = rows.find((r) => r.subject.did === bob.did); 153 + expect(aclBob?.accessLevel).toBe("member"); 154 + 155 + // (2) Reconcile cascade: spaces.access_rows now has bob too. 156 + // `flatten=true` reads from the spaces table, not the community ACL. 157 + const flatList = await callAs(aliceClient, "GET", `${NS}.community.space.listMembers`, { 158 + query: { spaceUri: channelUri, flatten: "true" }, 159 + }); 160 + expect(flatList.status).toBe(200); 161 + const { members } = (await jsonOr(flatList)) as { 162 + members: Array<{ did: string }>; 163 + }; 164 + expect(members.map((m) => m.did)).toContain(bob.did); 165 + 166 + // (3) Bob's whoami resolves to the granted level via the same path that 167 + // app code would use to gate UI/API access. 168 + const whoami = await callAs(bobClient, "GET", `${NS}.spaceExt.whoami`, { 169 + query: { spaceUri: channelUri }, 170 + }); 171 + expect(whoami.status).toBe(200); 172 + const { isMember, accessLevel } = (await jsonOr(whoami)) as { 173 + isMember: boolean; 174 + accessLevel: string; 175 + }; 176 + expect(isMember).toBe(true); 177 + expect(accessLevel).toBe("member"); 178 + }); 179 + 180 + // ----- single-use enforcement at the real-DB layer ------------------------ 181 + 182 + it("maxUses=1 invite rejects a second redemption (DB-enforced, not in-memory)", async () => { 183 + const create = await callAs(aliceClient, "POST", `${NS}.invite.create`, { 184 + body: { spaceUri: channelUri, accessLevel: "member", maxUses: 1 }, 185 + }); 186 + expect(create.status).toBe(200); 187 + const { token } = (await create.json()) as { token: string }; 188 + 189 + // First redemption succeeds (use a fresh outsider so the prior test's 190 + // grant doesn't mask the count). 191 + const dave = await createTestAccount(); 192 + const daveClient = await login(dave); 193 + const first = await callAs(daveClient, "POST", `${NS}.invite.redeem`, { 194 + body: { token }, 195 + }); 196 + expect(first.status, await first.clone().text()).toBe(200); 197 + 198 + // Second redemption by a different account is rejected. 199 + const second = await callAs(charlieClient, "POST", `${NS}.invite.redeem`, { 200 + body: { token }, 201 + }); 202 + expect(second.status).toBe(400); 203 + const data = (await jsonOr(second)) as { error: string }; 204 + expect(data.error).toBe("InvalidInvite"); 205 + }); 206 + 207 + // ----- revoke roundtrip --------------------------------------------------- 208 + 209 + it("revoked invite cannot be redeemed", async () => { 210 + const create = await callAs(aliceClient, "POST", `${NS}.invite.create`, { 211 + body: { spaceUri: channelUri, accessLevel: "member" }, 212 + }); 213 + const { token, invite } = (await create.json()) as { 214 + token: string; 215 + invite: { tokenHash: string }; 216 + }; 217 + 218 + const revoke = await callAs(aliceClient, "POST", `${NS}.invite.revoke`, { 219 + body: { spaceUri: channelUri, tokenHash: invite.tokenHash }, 220 + }); 221 + expect(revoke.status, await revoke.clone().text()).toBe(200); 222 + 223 + const redeem = await callAs(charlieClient, "POST", `${NS}.invite.redeem`, { 224 + body: { token }, 225 + }); 226 + expect(redeem.status).toBe(400); 227 + const data = (await jsonOr(redeem)) as { error: string }; 228 + expect(data.error).toBe("InvalidInvite"); 229 + }); 230 + 231 + // ----- user-owned space: simpler path, no reconcile ----------------------- 232 + // Owner creates a space they personally own; invite confers `kind=join` and 233 + // redeem calls `addMember` — no community module touched. 234 + 235 + it("user-owned space: kind=join invite roundtrip adds redeemer as a member", async () => { 236 + const createSpace = await callAs(aliceClient, "POST", `${NS}.space.createSpace`, { 237 + body: {}, 238 + }); 239 + expect(createSpace.status, await createSpace.clone().text()).toBe(200); 240 + const userSpaceUri = ( 241 + (await createSpace.json()) as { space: { uri: string; ownerDid: string } } 242 + ).space.uri; 243 + 244 + const createInvite = await callAs(aliceClient, "POST", `${NS}.invite.create`, { 245 + body: { spaceUri: userSpaceUri, kind: "join" }, 246 + }); 247 + expect(createInvite.status, await createInvite.clone().text()).toBe(200); 248 + const { token, invite } = (await createInvite.json()) as { 249 + token: string; 250 + invite: { kind: string }; 251 + }; 252 + expect(invite.kind).toBe("join"); 253 + 254 + const redeem = await callAs(bobClient, "POST", `${NS}.invite.redeem`, { 255 + body: { token }, 256 + }); 257 + expect(redeem.status, await redeem.clone().text()).toBe(200); 258 + const redeemed = (await redeem.json()) as { spaceUri: string; kind: string }; 259 + expect(redeemed.spaceUri).toBe(userSpaceUri); 260 + expect(redeemed.kind).toBe("join"); 261 + 262 + // Owner can listMembers; bob shows up. 263 + const list = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { 264 + query: { spaceUri: userSpaceUri }, 265 + }); 266 + expect(list.status).toBe(200); 267 + const { members } = (await jsonOr(list)) as { 268 + members: Array<{ did: string }>; 269 + }; 270 + expect(members.map((m) => m.did)).toContain(bob.did); 271 + }); 272 + });