[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 #26 from tompscanlan/test/community-delete-e2e

test: e2e suite for community.delete soft-delete + cascade

authored by

Florian and committed by
GitHub
(Apr 29, 2026, 6:41 PM +0200) 175db5a7 3d0c5b27

+264
+264
apps/contrail-e2e/tests/community-delete.test.ts
··· 1 + /** 2 + * `<ns>.community.delete` end-to-end against real Postgres. 3 + * 4 + * The router endpoint (community/router.ts) and the adapter's 5 + * `softDeleteCommunity` (community/adapter.ts) have no unit-test coverage 6 + * for delete today. Beyond filling that gap, what these tests prove that 7 + * unit tests *can't*: 8 + * 9 + * - Real Postgres writes the `deleted_at` timestamp on both the 10 + * `communities` row AND every space owned by the community ($admin 11 + * plus any child spaces). Verified by direct SQL on the pool, since 12 + * that is the ground-truth representation the rest of the system 13 + * reads from. 14 + * - `<ns>.community.list` filtering is enforced by SQL 15 + * (`listCommunitiesOwningSpaces` joins on `c.deleted_at IS NULL AND 16 + * s.deleted_at IS NULL`), not in-memory bookkeeping. 17 + * - Subsequent ops on a deleted community fail at the adapter layer: 18 + * `getCommunity` returns null because of the `deleted_at IS NULL` 19 + * filter, so `<ns>.community.space.create` returns 404. 20 + * - Real service-auth JWT verifying through the PDS+PLC chain on the 21 + * `owner-required` enforcement path. 22 + * 23 + * Prereqs: `pnpm stack:up`. 24 + */ 25 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 26 + import pg from "pg"; 27 + import type { Client } from "@atcute/client"; 28 + import "@atcute/atproto"; 29 + import { Contrail } from "@atmo-dev/contrail"; 30 + import { createHandler } from "@atmo-dev/contrail/server"; 31 + import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; 32 + import { config as baseConfig } from "../config"; 33 + import { 34 + createTestAccount, 35 + createIsolatedSchema, 36 + createDevnetResolver, 37 + createCaller, 38 + login, 39 + jsonOr, 40 + CONTRAIL_SERVICE_DID, 41 + type CallAs, 42 + type TestAccount, 43 + } from "./helpers"; 44 + 45 + const NS = `${baseConfig.namespace}.community`; 46 + const SPACE_TYPE = "rsvp.atmo.event.space"; 47 + const TEST_MASTER_KEY = new Uint8Array(32).fill(7); 48 + 49 + describe("community.delete e2e (soft-delete + cascade, real DB)", () => { 50 + let alice: TestAccount; // owner / creator 51 + let bob: TestAccount; // promoted to admin in $admin (still NOT owner) 52 + 53 + let aliceClient: Client; 54 + let bobClient: Client; 55 + 56 + let pool: pg.Pool; 57 + let cleanupSchema: () => Promise<void>; 58 + let callAs: CallAs; 59 + 60 + beforeAll(async () => { 61 + [alice, bob] = await Promise.all([createTestAccount(), createTestAccount()]); 62 + [aliceClient, bobClient] = await Promise.all([login(alice), login(bob)]); 63 + 64 + const iso = await createIsolatedSchema("test_community_delete"); 65 + pool = iso.pool; 66 + cleanupSchema = iso.cleanup; 67 + const db = createPostgresDatabase(pool); 68 + 69 + const contrail = new Contrail({ 70 + ...baseConfig, 71 + db, 72 + spaces: { 73 + type: SPACE_TYPE, 74 + serviceDid: CONTRAIL_SERVICE_DID, 75 + resolver: createDevnetResolver(), 76 + }, 77 + community: { 78 + serviceDid: CONTRAIL_SERVICE_DID, 79 + masterKey: TEST_MASTER_KEY, 80 + resolver: createDevnetResolver(), 81 + }, 82 + }); 83 + await contrail.init(); 84 + callAs = createCaller(createHandler(contrail)); 85 + }); 86 + 87 + afterAll(async () => { 88 + await cleanupSchema?.(); 89 + }); 90 + 91 + // Each test mints its own community so the soft-delete in one doesn't 92 + // interfere with the next. 93 + async function mintCommunity(): Promise<string> { 94 + const res = await callAs(aliceClient, "POST", `${NS}.mint`, { body: {} }); 95 + expect(res.status, await res.clone().text()).toBe(200); 96 + return ((await res.json()) as { communityDid: string }).communityDid; 97 + } 98 + 99 + async function createChildSpace(communityDid: string, key: string): Promise<string> { 100 + const res = await callAs(aliceClient, "POST", `${NS}.space.create`, { 101 + body: { communityDid, key }, 102 + }); 103 + expect(res.status, await res.clone().text()).toBe(200); 104 + return ((await res.json()) as { space: { uri: string } }).space.uri; 105 + } 106 + 107 + // ----- happy path: owner deletes, both rows + spaces flip deleted_at ------ 108 + 109 + it("owner can soft-delete; communities + all owned spaces get deleted_at set", async () => { 110 + const communityDid = await mintCommunity(); 111 + const channelUri = await createChildSpace(communityDid, "general"); 112 + const adminUri = `ats://${communityDid}/${SPACE_TYPE}/$admin`; 113 + 114 + // Sanity: pre-delete, deleted_at is NULL on both tables. 115 + const preCommunity = await pool.query( 116 + `SELECT deleted_at FROM communities WHERE did = $1`, 117 + [communityDid], 118 + ); 119 + expect(preCommunity.rows[0]?.deleted_at).toBeNull(); 120 + // Bootstrap creates one or more reserved spaces ($admin, $publishers, …) 121 + // in addition to the explicit child space. Don't pin the full set — 122 + // assert the two we care about are present and live, and that every row 123 + // is non-deleted. 124 + const preSpaces = await pool.query( 125 + `SELECT uri, deleted_at FROM spaces WHERE owner_did = $1`, 126 + [communityDid], 127 + ); 128 + const preUris = preSpaces.rows.map((r: { uri: string }) => r.uri); 129 + expect(preUris).toContain(adminUri); 130 + expect(preUris).toContain(channelUri); 131 + for (const row of preSpaces.rows) expect(row.deleted_at).toBeNull(); 132 + 133 + // Delete. 134 + const del = await callAs(aliceClient, "POST", `${NS}.delete`, { 135 + body: { communityDid }, 136 + }); 137 + expect(del.status, await del.clone().text()).toBe(200); 138 + expect((await del.json()) as { ok: boolean }).toEqual({ ok: true }); 139 + 140 + // (1) Community row's deleted_at is now set. 141 + const postCommunity = await pool.query( 142 + `SELECT deleted_at FROM communities WHERE did = $1`, 143 + [communityDid], 144 + ); 145 + expect(postCommunity.rows[0]?.deleted_at).not.toBeNull(); 146 + 147 + // (2) Every space owned by the community has deleted_at set — the 148 + // reserved spaces ($admin etc.) and the child "general" space. 149 + const postSpaces = await pool.query( 150 + `SELECT uri, deleted_at FROM spaces WHERE owner_did = $1`, 151 + [communityDid], 152 + ); 153 + // Same query, same connection — count must match exactly. A regression 154 + // that orphans rows or creates new ones during delete would fail here. 155 + expect(postSpaces.rows.length).toBe(preSpaces.rows.length); 156 + for (const row of postSpaces.rows) expect(row.deleted_at).not.toBeNull(); 157 + }); 158 + 159 + // ----- list filtering is real --------------------------------------------- 160 + 161 + it("post-delete community is excluded from <ns>.community.list (DB-filtered)", async () => { 162 + const communityDid = await mintCommunity(); 163 + 164 + // Pre-delete: owner sees the community. 165 + const before = await callAs(aliceClient, "GET", `${NS}.list`); 166 + const beforeDids = ((await jsonOr(before)) as { 167 + communities: Array<{ did: string }>; 168 + }).communities.map((c) => c.did); 169 + expect(beforeDids).toContain(communityDid); 170 + 171 + // Delete. 172 + const del = await callAs(aliceClient, "POST", `${NS}.delete`, { 173 + body: { communityDid }, 174 + }); 175 + expect(del.status).toBe(200); 176 + 177 + // Post-delete: the same listing call no longer returns it. The filter 178 + // lives in `listCommunitiesOwningSpaces` (real SQL), so this round-trip 179 + // proves the join condition fires end-to-end, not just in unit-test 180 + // mocks. 181 + const after = await callAs(aliceClient, "GET", `${NS}.list`); 182 + const afterDids = ((await jsonOr(after)) as { 183 + communities: Array<{ did: string }>; 184 + }).communities.map((c) => c.did); 185 + expect(afterDids).not.toContain(communityDid); 186 + }); 187 + 188 + // ----- subsequent ops fail at the adapter layer --------------------------- 189 + 190 + it("post-delete: community.space.create returns 404 (getCommunity filters deleted)", async () => { 191 + const communityDid = await mintCommunity(); 192 + 193 + const del = await callAs(aliceClient, "POST", `${NS}.delete`, { 194 + body: { communityDid }, 195 + }); 196 + expect(del.status).toBe(200); 197 + 198 + // `community.space.create` calls `getCommunity` which filters 199 + // `deleted_at IS NULL`, so a deleted community looks like a missing 200 + // community to subsequent endpoints — no special "tombstone" path. 201 + const create = await callAs(aliceClient, "POST", `${NS}.space.create`, { 202 + body: { communityDid, key: "afterlife" }, 203 + }); 204 + expect(create.status).toBe(404); 205 + const data = (await jsonOr(create)) as { error: string; reason?: string }; 206 + expect(data.error).toBe("NotFound"); 207 + expect(data.reason).toBe("community-not-found"); 208 + }); 209 + 210 + // ----- owner-required enforcement ----------------------------------------- 211 + // The endpoint requires `owner` specifically (not admin+). An admin in 212 + // $admin should be rejected with `owner-required`. 213 + 214 + it("admin (not owner) cannot delete the community", async () => { 215 + const communityDid = await mintCommunity(); 216 + const adminUri = `ats://${communityDid}/${SPACE_TYPE}/$admin`; 217 + 218 + // Promote bob to admin in $admin — strictly below owner. 219 + const grant = await callAs(aliceClient, "POST", `${NS}.space.grant`, { 220 + body: { 221 + spaceUri: adminUri, 222 + subject: { did: bob.did }, 223 + accessLevel: "admin", 224 + }, 225 + }); 226 + expect(grant.status, await grant.clone().text()).toBe(200); 227 + 228 + const del = await callAs(bobClient, "POST", `${NS}.delete`, { 229 + body: { communityDid }, 230 + }); 231 + expect(del.status).toBe(403); 232 + const data = (await jsonOr(del)) as { error: string; reason: string }; 233 + expect(data.error).toBe("Forbidden"); 234 + expect(data.reason).toBe("owner-required"); 235 + 236 + // And the community is still alive — deleted_at is still NULL. 237 + const row = await pool.query( 238 + `SELECT deleted_at FROM communities WHERE did = $1`, 239 + [communityDid], 240 + ); 241 + expect(row.rows[0]?.deleted_at).toBeNull(); 242 + }); 243 + 244 + // ----- stranger gets the same 403 (not 404) -------------------------------- 245 + // A user with no role on the community still hits the level check — the 246 + // endpoint shape is "Forbidden / owner-required", not "NotFound", which 247 + // matters because it tells the caller the resource exists but they're 248 + // not the owner. Distinct from the post-delete "community-not-found" case 249 + // above. 250 + 251 + it("non-member cannot delete the community", async () => { 252 + const communityDid = await mintCommunity(); 253 + 254 + const stranger = await createTestAccount(); 255 + const strangerClient = await login(stranger); 256 + const del = await callAs(strangerClient, "POST", `${NS}.delete`, { 257 + body: { communityDid }, 258 + }); 259 + expect(del.status).toBe(403); 260 + const data = (await jsonOr(del)) as { error: string; reason: string }; 261 + expect(data.error).toBe("Forbidden"); 262 + expect(data.reason).toBe("owner-required"); 263 + }); 264 + });