| 1 | import assert from "node:assert/strict"; |
| 2 | import { readFileSync, readdirSync } from "node:fs"; |
| 3 | import { DatabaseSync } from "node:sqlite"; |
| 4 | import { test } from "node:test"; |
| 5 | import { build } from "esbuild"; |
| 6 | |
| 7 | // Execute the real service code against SQLite, with only the Cloudflare host replaced. |
| 8 | async function service(path) { |
| 9 | const bundle = await build({ |
| 10 | entryPoints: [path], |
| 11 | bundle: true, |
| 12 | write: false, |
| 13 | format: "esm", |
| 14 | platform: "node", |
| 15 | plugins: [ |
| 16 | { |
| 17 | name: "worker-host", |
| 18 | setup(build) { |
| 19 | build.onResolve({ filter: /^yjs$/ }, () => ({ path: import.meta.resolve("yjs"), external: true })); |
| 20 | build.onResolve({ filter: /^cloudflare:workers$/ }, () => ({ path: "host", namespace: "host" })); |
| 21 | build.onLoad({ filter: /.*/, namespace: "host" }, () => ({ |
| 22 | contents: "export class DurableObject { constructor(ctx, env) { this.ctx = ctx; this.env = env; } }", |
| 23 | })); |
| 24 | }, |
| 25 | }, |
| 26 | ], |
| 27 | }); |
| 28 | return import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`); |
| 29 | } |
| 30 | |
| 31 | function database(service) { |
| 32 | const raw = new DatabaseSync(":memory:"); |
| 33 | const dir = new URL(`../services/${service}/migrations/`, import.meta.url); |
| 34 | for (const name of readdirSync(dir).sort()) raw.exec(readFileSync(new URL(name, dir), "utf8")); |
| 35 | raw.exec("PRAGMA foreign_keys = ON"); |
| 36 | const run = (s) => ({ meta: { changes: Number(raw.prepare(s.sql).run(...s.params).changes) } }); |
| 37 | const prepare = (sql, params = []) => ({ |
| 38 | sql, |
| 39 | params, |
| 40 | bind: (...p) => prepare(sql, p), |
| 41 | first: async () => raw.prepare(sql).get(...params) ?? null, |
| 42 | all: async () => ({ results: raw.prepare(sql).all(...params) }), |
| 43 | run: async () => run({ sql, params }), |
| 44 | }); |
| 45 | return { |
| 46 | raw, |
| 47 | prepare, |
| 48 | batch: async (statements) => { |
| 49 | raw.exec("BEGIN"); |
| 50 | try { |
| 51 | const result = statements.map(run); |
| 52 | raw.exec("COMMIT"); |
| 53 | return result; |
| 54 | } catch (e) { |
| 55 | raw.exec("ROLLBACK"); |
| 56 | throw e; |
| 57 | } |
| 58 | }, |
| 59 | }; |
| 60 | } |
| 61 | const namespace = (get) => ({ idFromName: (id) => id, get }); |
| 62 | const AT = "2026-10-14T00:00:00.000Z"; |
| 63 | |
| 64 | test("identity purge work is atomic, idempotent and a prior restore prevents scheduling", () => { |
| 65 | const db = database("identity").raw; |
| 66 | const enqueue = db.prepare(readFileSync(new URL("../services/identity/src/purge_job.sql", import.meta.url), "utf8")); |
| 67 | db.prepare("INSERT INTO users (id, username, password_hash, created_at, deleted_at, purge_after) VALUES ('usr_fixture', 'fixture', 'fixture', ?, ?, ?)").run( |
| 68 | AT, |
| 69 | AT, |
| 70 | AT, |
| 71 | ); |
| 72 | const args = { "?1": "usr_fixture", "?2": "user", "?3": "CHAT", "?4": "{}", "?5": AT }; |
| 73 | db.exec("BEGIN"); |
| 74 | enqueue.run(args); |
| 75 | db.exec("DELETE FROM users WHERE id = 'usr_fixture'"); |
| 76 | db.exec("ROLLBACK"); |
| 77 | assert.equal(db.prepare("SELECT count(*) n FROM purge_jobs").get().n, 0); |
| 78 | assert.ok(db.prepare("SELECT 1 FROM users WHERE id = 'usr_fixture'").get()); |
| 79 | db.exec("UPDATE users SET deleted_at = NULL, purge_after = NULL WHERE id = 'usr_fixture'"); |
| 80 | enqueue.run(args); |
| 81 | assert.equal(db.prepare("SELECT count(*) n FROM purge_jobs").get().n, 0); |
| 82 | db.prepare("UPDATE users SET deleted_at = ?, purge_after = ? WHERE id = 'usr_fixture'").run(AT, AT); |
| 83 | db.exec("BEGIN"); |
| 84 | enqueue.run(args); |
| 85 | enqueue.run(args); |
| 86 | db.exec("DELETE FROM users WHERE id = 'usr_fixture'; COMMIT"); |
| 87 | assert.equal(db.prepare("SELECT count(*) n FROM purge_jobs").get().n, 1); |
| 88 | assert.equal(db.prepare("SELECT completed_at FROM purge_jobs").get().completed_at, null); |
| 89 | }); |
| 90 | |
| 91 | test("chat purges the private AI conversation, preserves other tenants, retries failed rooms and rejects late writes", async () => { |
| 92 | const { purgePersonalData } = await service("services/chat/src/erasure.ts"); |
| 93 | const db = database("chat"); |
| 94 | const { G1T_KEY } = await service("packages/contracts/src/chat.ts"); |
| 95 | for (const [id, person] of [ |
| 96 | ["mine", "usr_a"], |
| 97 | ["other", "usr_b"], |
| 98 | ]) { |
| 99 | db.raw.prepare("INSERT INTO channels (id, workspace_id, kind, created_by, created_at) VALUES (?, ?, 'dm', ?, ?)").run(id, id, `user:${person}`, AT); |
| 100 | for (const key of [`user:${person}`, G1T_KEY]) |
| 101 | db.raw.prepare("INSERT INTO channel_members (channel_id, principal, joined_at) VALUES (?, ?, ?)").run(id, key, AT); |
| 102 | db.raw.prepare("INSERT INTO messages (id, channel_id, author, body, created_at) VALUES (?, ?, ?, 'private prompt', ?)").run(id, id, `user:${person}`, AT); |
| 103 | } |
| 104 | let fail = true; |
| 105 | const env = { |
| 106 | DB: db, |
| 107 | ROOMS: namespace(() => ({ |
| 108 | erase: async () => { |
| 109 | if (fail) throw Error("unavailable"); |
| 110 | }, |
| 111 | })), |
| 112 | AVATARS: { delete: async () => {} }, |
| 113 | }; |
| 114 | await assert.rejects(purgePersonalData(env, { kind: "user", id: "usr_a" })); |
| 115 | assert.equal(db.raw.prepare("SELECT count(*) n FROM messages").get().n, 2); |
| 116 | fail = false; |
| 117 | assert.deepEqual(await purgePersonalData(env, { kind: "user", id: "usr_a" }), { ok: true }); |
| 118 | assert.deepEqual(await purgePersonalData(env, { kind: "user", id: "usr_a" }), { ok: true }); |
| 119 | assert.equal(db.raw.prepare("SELECT count(*) n FROM messages").get().n, 1); |
| 120 | assert.equal(db.raw.prepare("SELECT body FROM messages WHERE id = 'other'").get().body, "private prompt"); |
| 121 | db.raw.prepare("INSERT INTO messages (id, channel_id, author, body, created_at) VALUES ('late', 'mine', 'user:usr_a', 'late', ?)").run(AT); |
| 122 | assert.equal(db.raw.prepare("SELECT count(*) n FROM messages").get().n, 1); |
| 123 | }); |
| 124 | |
| 125 | test("artifacts keep retry references when file deletion fails, then remove private state with FK enforcement", async () => { |
| 126 | const { purgePersonalData } = await service("services/artifacts/src/erasure.ts"); |
| 127 | const db = database("artifacts"); |
| 128 | for (const [id, owner] of [ |
| 129 | ["mine", "usr_a"], |
| 130 | ["other", "usr_b"], |
| 131 | ]) { |
| 132 | db.raw |
| 133 | .prepare( |
| 134 | "INSERT INTO folios (id, workspace_id, kind, owner, position, acl_root, path, created_by, created_at, updated_at, edited_at) VALUES (?, ?, 'doc', ?, 1, ?, ?, ?, ?, ?, ?)", |
| 135 | ) |
| 136 | .run(id, id, `user:${owner}`, id, `/${id}/`, `user:${owner}`, AT, AT, AT); |
| 137 | db.raw |
| 138 | .prepare( |
| 139 | "INSERT INTO folio_files (id, workspace_id, folio_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, 'private.txt', 'text/plain', 10, ?, ?)", |
| 140 | ) |
| 141 | .run(id, id, id, id, `user:${owner}`, AT); |
| 142 | } |
| 143 | let fail = true; |
| 144 | const deleted = [], |
| 145 | rooms = []; |
| 146 | const env = { |
| 147 | DB: db, |
| 148 | FOLIOS: namespace((id) => ({ |
| 149 | forgetPerson: async () => {}, |
| 150 | destroy: async () => { |
| 151 | rooms.push(id); |
| 152 | }, |
| 153 | })), |
| 154 | FILES: { |
| 155 | delete: async (key) => { |
| 156 | if (fail) throw Error("storage down"); |
| 157 | deleted.push(key); |
| 158 | }, |
| 159 | }, |
| 160 | }; |
| 161 | await assert.rejects(purgePersonalData(env, { kind: "user", id: "usr_a" })); |
| 162 | assert.equal(db.raw.prepare("SELECT count(*) n FROM folio_files").get().n, 2); |
| 163 | fail = false; |
| 164 | assert.deepEqual(await purgePersonalData(env, { kind: "user", id: "usr_a" }), { ok: true }); |
| 165 | assert.deepEqual(await purgePersonalData(env, { kind: "user", id: "usr_a" }), { ok: true }); |
| 166 | assert.deepEqual(deleted, ["docs/mine"]); |
| 167 | assert.equal(db.raw.prepare("SELECT count(*) n FROM folios").get().n, 1); |
| 168 | assert.equal(db.raw.prepare("SELECT id FROM folio_files").get().id, "other"); |
| 169 | assert.ok(rooms.every((id) => id === "mine")); |
| 170 | assert.deepEqual(await purgePersonalData(env, { kind: "workspace", id: "other" }), { ok: true }); |
| 171 | assert.equal(db.raw.prepare("SELECT count(*) n FROM folios").get().n, 0); |
| 172 | }); |
| 173 | |
| 174 | function objectState() { |
| 175 | const raw = new DatabaseSync(":memory:"); |
| 176 | const kv = new Map(); |
| 177 | const sql = { |
| 178 | exec(query, ...params) { |
| 179 | const stmt = raw.prepare(query); |
| 180 | if (stmt.columns().length) { |
| 181 | const rows = stmt.all(...params); |
| 182 | return { toArray: () => rows, one: () => rows[0], [Symbol.iterator]: () => rows[Symbol.iterator]() }; |
| 183 | } |
| 184 | const result = stmt.run(...params); |
| 185 | return { toArray: () => [], rowsWritten: Number(result.changes) }; |
| 186 | }, |
| 187 | }; |
| 188 | return { |
| 189 | raw, |
| 190 | blockConcurrencyWhile: (fn) => fn(), |
| 191 | storage: { |
| 192 | sql, |
| 193 | get: async (key) => kv.get(key), |
| 194 | put: async (key, value) => { |
| 195 | kv.set(key, value); |
| 196 | }, |
| 197 | delete: async (key) => kv.delete(key), |
| 198 | deleteAlarm: async () => {}, |
| 199 | transactionSync(fn) { |
| 200 | raw.exec("BEGIN"); |
| 201 | try { |
| 202 | fn(); |
| 203 | raw.exec("COMMIT"); |
| 204 | } catch (e) { |
| 205 | raw.exec("ROLLBACK"); |
| 206 | throw e; |
| 207 | } |
| 208 | }, |
| 209 | }, |
| 210 | getWebSockets: () => [], |
| 211 | setWebSocketAutoResponse: () => {}, |
| 212 | waitUntil: () => {}, |
| 213 | }; |
| 214 | } |
| 215 | |
| 216 | test("notification erasure retries presence cleanup, clears credentials and permanently blocks late delivery", async () => { |
| 217 | globalThis.WebSocketRequestResponsePair = class {}; |
| 218 | const { Feed } = await service("services/notify/src/feed.ts"); |
| 219 | const state = objectState(); |
| 220 | let fail = true, |
| 221 | calls = 0; |
| 222 | const feed = new Feed(state, { |
| 223 | ROOMS: namespace(() => ({ |
| 224 | forget: async () => { |
| 225 | calls++; |
| 226 | if (fail) throw Error("offline"); |
| 227 | }, |
| 228 | })), |
| 229 | }); |
| 230 | state.raw.exec( |
| 231 | `INSERT INTO meta VALUES ('workspaces', '["acme"]'); INSERT INTO devices VALUES ('token', 'ios', 'phone', 1); INSERT INTO notifications VALUES ('n', 1, '{}');`, |
| 232 | ); |
| 233 | await assert.rejects(feed.erase("usr_a")); |
| 234 | // A late delivery is dropped quietly, never refused: senders would log and retry it. |
| 235 | assert.deepEqual(await feed.deliver([{ workspace: "acme", notification: { id: "late", created_at: AT } }]), { ok: true }); |
| 236 | assert.deepEqual(await feed.notify({ id: "late", kind: "mention", workspace: "acme", created_at: AT }), { ok: true }); |
| 237 | fail = false; |
| 238 | assert.deepEqual(await feed.erase("usr_a"), { ok: true }); |
| 239 | assert.equal(calls, 2); |
| 240 | for (const table of ["devices", "notifications", "counts", "subscriptions", "visits", "watching"]) |
| 241 | assert.equal(state.raw.prepare(`SELECT count(*) n FROM ${table}`).get().n, 0); |
| 242 | assert.deepEqual( |
| 243 | state.raw |
| 244 | .prepare("SELECT key FROM meta") |
| 245 | .all() |
| 246 | .map((r) => r.key), |
| 247 | ["erased"], |
| 248 | ); |
| 249 | await assert.rejects(feed.registerDevice({ token: "late", platform: "ios" }), /Account erased/); |
| 250 | assert.equal((await feed.fetch(new Request("https://test/live"))).status, 410); |
| 251 | assert.deepEqual(await feed.erase("usr_a"), { ok: true }); |
| 252 | }); |
| 253 | |
| 254 | test("destroyed document rooms reject late ensure calls and keep only a tombstone", async () => { |
| 255 | globalThis.WebSocketRequestResponsePair = class {}; |
| 256 | for (const [file, name, init] of [ |
| 257 | ["services/artifacts/src/room.ts", "PageRoom", { page_id: "p", workspace_slug: "acme", markdown: "private" }], |
| 258 | ["services/artifacts/src/folios/room.ts", "FolioRoom", { folio_id: "f", kind: "doc", workspace_slug: "acme", text: "private" }], |
| 259 | ]) { |
| 260 | const module = await service(file), |
| 261 | state = objectState(); |
| 262 | const room = new module[name](state, {}); |
| 263 | state.raw.exec("INSERT INTO meta VALUES ('workspace_slug', '\"acme\"'); INSERT INTO snapshot VALUES (1, x'01');"); |
| 264 | await room.destroy(); |
| 265 | await room.destroy(); |
| 266 | assert.equal(state.raw.prepare("SELECT count(*) n FROM snapshot").get().n, 0); |
| 267 | assert.deepEqual( |
| 268 | state.raw |
| 269 | .prepare("SELECT key FROM meta") |
| 270 | .all() |
| 271 | .map((r) => r.key), |
| 272 | ["erased"], |
| 273 | ); |
| 274 | await assert.rejects(room.ensure(init), /Document erased/); |
| 275 | assert.equal((await room.fetch(new Request("https://test/live"))).status, 410); |
| 276 | assert.deepEqual( |
| 277 | state.raw |
| 278 | .prepare("SELECT key FROM meta") |
| 279 | .all() |
| 280 | .map((r) => r.key), |
| 281 | ["erased"], |
| 282 | ); |
| 283 | } |
| 284 | }); |
| 285 | |
| 286 | test("erasing one member of a shared channel leaves everyone else's conversation alone", async () => { |
| 287 | const { purgePersonalData } = await service("services/chat/src/erasure.ts"); |
| 288 | const db = database("chat"); |
| 289 | db.raw |
| 290 | .prepare("INSERT INTO channels (id, workspace_id, kind, name, created_by, created_at) VALUES ('general', 'ws', 'channel', 'general', 'user:usr_a', ?)") |
| 291 | .run(AT); |
| 292 | for (const person of ["usr_a", "usr_b"]) |
| 293 | db.raw.prepare("INSERT INTO channel_members (channel_id, principal, joined_at) VALUES ('general', ?, ?)").run(`user:${person}`, AT); |
| 294 | db.raw |
| 295 | .prepare( |
| 296 | "INSERT INTO messages (id, channel_id, author, body, created_at) VALUES ('m1', 'general', 'user:usr_a', 'from a', ?), ('m2', 'general', 'user:usr_b', 'from b', ?)", |
| 297 | ) |
| 298 | .run(AT, AT); |
| 299 | db.raw |
| 300 | .prepare("INSERT INTO reactions (message_id, principal, emoji, created_at) VALUES ('m2', 'user:usr_a', 'ok', ?), ('m2', 'user:usr_b', 'ok', ?)") |
| 301 | .run(AT, AT); |
| 302 | const erased = []; |
| 303 | const env = { |
| 304 | DB: db, |
| 305 | ROOMS: namespace(() => ({ |
| 306 | erase: async (id, principal) => { |
| 307 | erased.push([id, principal]); |
| 308 | }, |
| 309 | })), |
| 310 | AVATARS: { delete: async () => {} }, |
| 311 | }; |
| 312 | assert.deepEqual(await purgePersonalData(env, { kind: "user", id: "usr_a" }), { ok: true }); |
| 313 | // The room shuts out only the erased person; it is never cleared for everyone. |
| 314 | assert.deepEqual(erased, [["general", "user:usr_a"]]); |
| 315 | assert.deepEqual( |
| 316 | db.raw |
| 317 | .prepare("SELECT id, author, body FROM messages ORDER BY id") |
| 318 | .all() |
| 319 | .map((r) => ({ ...r })), |
| 320 | [ |
| 321 | { id: "m1", author: "user:usr_ghost", body: "from a" }, |
| 322 | { id: "m2", author: "user:usr_b", body: "from b" }, |
| 323 | ], |
| 324 | ); |
| 325 | assert.deepEqual( |
| 326 | db.raw |
| 327 | .prepare("SELECT principal FROM channel_members") |
| 328 | .all() |
| 329 | .map((r) => r.principal), |
| 330 | ["user:usr_b"], |
| 331 | ); |
| 332 | assert.deepEqual( |
| 333 | db.raw |
| 334 | .prepare("SELECT principal FROM reactions") |
| 335 | .all() |
| 336 | .map((r) => r.principal), |
| 337 | ["user:usr_b"], |
| 338 | ); |
| 339 | assert.equal(db.raw.prepare("SELECT created_by FROM channels").get().created_by, "user:usr_ghost"); |
| 340 | }); |
| 341 | |
| 342 | test("a shared channel's room shuts out only the erased member and tells nobody it was cleared", async () => { |
| 343 | globalThis.WebSocketRequestResponsePair = class {}; |
| 344 | const { ChannelRoom } = await service("services/chat/src/room.ts"); |
| 345 | const state = objectState(); |
| 346 | const socket = (who) => ({ |
| 347 | who, |
| 348 | sent: [], |
| 349 | closed: null, |
| 350 | send(text) { |
| 351 | this.sent.push(text); |
| 352 | }, |
| 353 | close(code) { |
| 354 | this.closed = code; |
| 355 | }, |
| 356 | }); |
| 357 | const sockets = [socket("user:usr_a"), socket("user:usr_b")]; |
| 358 | state.getWebSockets = (tag) => sockets.filter((s) => !tag || s.who === tag); |
| 359 | await state.storage.put("job", { message: { id: "answer_for_b" } }); |
| 360 | const deletes = []; |
| 361 | const room = new ChannelRoom(state, { |
| 362 | DB: { |
| 363 | prepare: () => ({ |
| 364 | bind: (id) => ({ |
| 365 | run: async () => { |
| 366 | deletes.push(id); |
| 367 | }, |
| 368 | }), |
| 369 | }), |
| 370 | }, |
| 371 | }); |
| 372 | await room.erase("general", "user:usr_a"); |
| 373 | assert.equal(sockets[0].closed, 4403); |
| 374 | assert.equal(sockets[1].closed, null); |
| 375 | assert.deepEqual(sockets[1].sent, []); |
| 376 | // Someone else's pending answer carries on. |
| 377 | assert.deepEqual(await state.storage.get("job"), { message: { id: "answer_for_b" } }); |
| 378 | assert.deepEqual(deletes, []); |
| 379 | assert.equal((await room.fetch(new Request("https://test/live"))).status, 426); |
| 380 | }); |
| 381 | |
| 382 | test("a workspace's large channel is deleted in chunks across calls, and nothing of it comes back", async () => { |
| 383 | const { purgePersonalData } = await service("services/chat/src/erasure.ts"); |
| 384 | const db = database("chat"); |
| 385 | db.raw |
| 386 | .prepare( |
| 387 | "INSERT INTO channels (id, workspace_id, kind, name, created_by, created_at) VALUES ('big', 'ws', 'channel', 'big', 'user:usr_a', ?), ('kept', 'other', 'channel', 'kept', 'user:usr_b', ?)", |
| 388 | ) |
| 389 | .run(AT, AT); |
| 390 | const insert = db.raw.prepare("INSERT INTO messages (id, channel_id, author, body, created_at) VALUES (?, ?, 'user:usr_a', 'x', ?)"); |
| 391 | for (let i = 0; i < 5600; i++) insert.run(`m${String(i).padStart(5, "0")}`, "big", AT); |
| 392 | insert.run("other", "kept", AT); |
| 393 | const env = { DB: db, ROOMS: namespace(() => ({ erase: async () => {} })), AVATARS: { delete: async () => {} } }; |
| 394 | assert.deepEqual(await purgePersonalData(env, { kind: "workspace", id: "ws" }), { ok: false, pending: true }); |
| 395 | assert.ok(db.raw.prepare("SELECT count(*) n FROM messages WHERE channel_id = 'big'").get().n > 0); |
| 396 | assert.deepEqual(await purgePersonalData(env, { kind: "workspace", id: "ws" }), { ok: true }); |
| 397 | assert.equal(db.raw.prepare("SELECT count(*) n FROM messages WHERE channel_id = 'big'").get().n, 0); |
| 398 | assert.equal(db.raw.prepare("SELECT count(*) n FROM channels WHERE workspace_id = 'ws'").get().n, 0); |
| 399 | assert.equal(db.raw.prepare("SELECT body FROM messages WHERE id = 'other'").get().body, "x"); |
| 400 | db.raw |
| 401 | .prepare("INSERT INTO channels (id, workspace_id, kind, name, created_by, created_at) VALUES ('late', 'ws', 'channel', 'late', 'user:usr_b', ?)") |
| 402 | .run(AT); |
| 403 | db.raw.prepare("INSERT INTO chat_settings (workspace_id) VALUES ('ws')").run(); |
| 404 | assert.equal(db.raw.prepare("SELECT count(*) n FROM channels WHERE workspace_id = 'ws'").get().n, 0); |
| 405 | assert.equal(db.raw.prepare("SELECT count(*) n FROM chat_settings").get().n, 0); |
| 406 | }); |
| 407 | |
| 408 | test("a purged workspace's slug can be made again: only what was said before the purge is dropped", async () => { |
| 409 | globalThis.WebSocketRequestResponsePair = class {}; |
| 410 | const { Feed } = await service("services/notify/src/feed.ts"); |
| 411 | const { Room } = await service("services/notify/src/room.ts"); |
| 412 | const state = objectState(); |
| 413 | const feed = new Feed(state, {}); |
| 414 | const before = new Date(Date.now() - 60_000).toISOString(); |
| 415 | state.raw.prepare("INSERT INTO notifications VALUES ('old', 1, ?)").run(JSON.stringify({ id: "old", workspace: "alice", created_at: before })); |
| 416 | state.raw.prepare("INSERT INTO meta VALUES ('complete:alice', '1'), ('forgot:alice:ch_1', ?), ('forgot:alicex:ch_1', ?)").run(before, before); |
| 417 | assert.deepEqual(await feed.eraseWorkspace("alice"), { ok: true }); |
| 418 | assert.equal(state.raw.prepare("SELECT count(*) n FROM notifications").get().n, 0); |
| 419 | assert.deepEqual( |
| 420 | state.raw |
| 421 | .prepare("SELECT key FROM meta WHERE key LIKE 'forgot:%' OR key LIKE 'complete:%'") |
| 422 | .all() |
| 423 | .map((r) => r.key), |
| 424 | ["forgot:alicex:ch_1"], |
| 425 | ); |
| 426 | // A late notification from before the purge is dropped; one from the workspace made again is kept. |
| 427 | await feed.notify({ id: "late", kind: "mention", workspace: "alice", title: "t", url: "/alice", created_at: before }); |
| 428 | await feed.notify({ id: "new", kind: "mention", workspace: "alice", title: "t", url: "/alice", created_at: new Date(Date.now() + 1000).toISOString() }); |
| 429 | assert.deepEqual( |
| 430 | state.raw |
| 431 | .prepare("SELECT id FROM notifications") |
| 432 | .all() |
| 433 | .map((r) => r.id), |
| 434 | ["new"], |
| 435 | ); |
| 436 | |
| 437 | const room = new Room(objectState(), {}); |
| 438 | await room.report("alice", { user_id: "usr_b", at: 1, presence: "online" }); |
| 439 | assert.deepEqual(await room.eraseMembers(), ["usr_b"]); |
| 440 | await room.dropPerson("usr_b"); |
| 441 | assert.equal(await room.report("alice", { user_id: "usr_c", at: 2, presence: "online" }), null); |
| 442 | await room.report("alice", { user_id: "usr_b", at: Date.now() + 1000, presence: "online" }); |
| 443 | assert.deepEqual( |
| 444 | (await room.people()).map((p) => p.user_id), |
| 445 | ["usr_b"], |
| 446 | ); |
| 447 | }); |
| 448 | |
| 449 | test("vectors written while a document was being erased are taken back", async () => { |
| 450 | const { dropIfErased, erasedSource } = await service("services/artifacts/src/indexer.ts"); |
| 451 | const db = database("artifacts"); |
| 452 | const deleted = []; |
| 453 | const store = { |
| 454 | delete: async (ids) => { |
| 455 | deleted.push(...ids); |
| 456 | }, |
| 457 | }; |
| 458 | await dropIfErased(db, store, "folio", "f1", "ws", ["f1:0"]); |
| 459 | assert.deepEqual(deleted, []); |
| 460 | db.raw.prepare("INSERT INTO erased_documents (id, kind) VALUES ('f1', 'folio')").run(); |
| 461 | assert.equal(await erasedSource(db, "folio", "f1", "ws"), true); |
| 462 | assert.equal(await erasedSource(db, "page", "f1", "ws"), false); |
| 463 | await dropIfErased(db, store, "folio", "f1", "ws", ["f1:0", "f1:1"]); |
| 464 | assert.deepEqual(deleted, ["f1:0", "f1:1"]); |
| 465 | db.raw.prepare("INSERT INTO erased_subjects VALUES ('ws2', 'workspace')").run(); |
| 466 | assert.equal(await erasedSource(db, null, "repo_file", "ws2"), true); |
| 467 | }); |
| 468 | |
| 469 | test("a workspace's uploads are swept a page at a time, resuming after a failure", async () => { |
| 470 | const { purgePersonalData } = await service("services/artifacts/src/erasure.ts"); |
| 471 | const db = database("artifacts"); |
| 472 | const insert = db.raw.prepare( |
| 473 | "INSERT INTO files (id, workspace_id, page_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, NULL, ?, 'f', 'text/plain', 1, 'user:usr_a', ?)", |
| 474 | ); |
| 475 | for (let i = 0; i < 2100; i++) insert.run(`f${i}`, "ws", `k${i}`, AT); |
| 476 | insert.run("kept", "other", "kept", AT); |
| 477 | let fail = 3; |
| 478 | const deleted = new Set(); |
| 479 | const env = { |
| 480 | DB: db, |
| 481 | FILES: { |
| 482 | delete: async (key) => { |
| 483 | if (key === "docs/k5" && fail-- > 0) throw Error("storage down"); |
| 484 | deleted.add(key); |
| 485 | }, |
| 486 | }, |
| 487 | }; |
| 488 | await assert.rejects(purgePersonalData(env, { kind: "workspace", id: "ws" })); |
| 489 | let result; |
| 490 | for (let i = 0; i < 6 && !result?.ok; i++) result = await purgePersonalData(env, { kind: "workspace", id: "ws" }).catch(() => null); |
| 491 | assert.deepEqual(result, { ok: true }); |
| 492 | assert.equal(deleted.size, 2100); |
| 493 | assert.ok(!deleted.has("docs/kept")); |
| 494 | assert.equal(db.raw.prepare("SELECT id FROM files").get().id, "kept"); |
| 495 | }); |