// per-recipient inbox delete + bulk delete. // A recipient can detach their note_recipients row (DELETE // /notes/received/:id and bulk variant) without touching the // underlying note or other recipients' rows. Non-recipients get 404. import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import pg from "pg"; const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run these tests"); } const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdUserIds = []; const createdNoteIds = []; function uniq() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } async function createUser(prefix) { const username = `${prefix}_${uniq()}`; const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix], ); const id = rows[0].id; createdUserIds.push(id); await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`, [id], ); return { id, username }; } async function login(username) { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password: TEST_PASSWORD }), }); assert.equal(res.status, 200, `login expected 200, got ${res.status}`); const setCookie = res.headers.get("set-cookie"); const sid = setCookie .split(",") .map((c) => c.split(";")[0].trim()) .find((c) => c.startsWith("connect.sid=")); assert.ok(sid, "expected connect.sid cookie"); return sid; } async function api(path, cookie, init = {}) { return fetch(`${API_BASE}/api${path}`, { ...init, headers: { Cookie: cookie, "Content-Type": "application/json", ...(init.headers ?? {}), }, }); } async function createNote(cookie, body) { const res = await api("/notes", cookie, { method: "POST", body: JSON.stringify({ title: "Hi", content: "hello", color: "blue", ...body, }), }); assert.ok(res.ok, `create note expected 2xx, got ${res.status}`); const json = await res.json(); createdNoteIds.push(json.id); return json; } async function sendTo(cookie, noteId, recipientUserIds) { const res = await api(`/notes/${noteId}/send`, cookie, { method: "POST", body: JSON.stringify({ recipientUserIds }), }); assert.ok(res.ok, `send expected 2xx, got ${res.status}`); return res.json(); } after(async () => { if (createdNoteIds.length > 0) { await pool.query( `DELETE FROM note_replies WHERE note_id = ANY($1::int[])`, [createdNoteIds], ); await pool.query( `DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`, [createdNoteIds], ); await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [ createdNoteIds, ]); } if (createdUserIds.length > 0) { await pool.query( `DELETE FROM notifications WHERE user_id = ANY($1::int[])`, [createdUserIds], ); await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [ createdUserIds, ]); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ createdUserIds, ]); } await pool.end(); }); test("recipient can DELETE /notes/received/:id; row is gone, note + other recipients survive", async () => { const sender = await createUser("ndel_s"); const r1 = await createUser("ndel_r1"); const r2 = await createUser("ndel_r2"); const sCookie = await login(sender.username); const r1Cookie = await login(r1.username); const r2Cookie = await login(r2.username); const note = await createNote(sCookie); await sendTo(sCookie, note.id, [r1.id, r2.id]); // r1 deletes their inbox row. const delRes = await api(`/notes/received/${note.id}`, r1Cookie, { method: "DELETE", }); assert.ok(delRes.ok, `delete expected 2xx, got ${delRes.status}`); // r1's inbox no longer shows the note. const r1Inbox = await (await api(`/notes/received`, r1Cookie)).json(); assert.ok( !r1Inbox.find((n) => n.id === note.id), "r1 must no longer see the note in their inbox", ); // The note row + r2's recipient row are intact. const { rows: noteRows } = await pool.query( `SELECT id FROM notes WHERE id = $1`, [note.id], ); assert.equal(noteRows.length, 1, "note row must still exist"); const { rows: r2Rows } = await pool.query( `SELECT id FROM note_recipients WHERE note_id = $1 AND recipient_user_id = $2`, [note.id, r2.id], ); assert.equal(r2Rows.length, 1, "r2 recipient row must be intact"); // r2 still sees the note in their inbox. const r2Inbox = await (await api(`/notes/received`, r2Cookie)).json(); assert.ok( r2Inbox.find((n) => n.id === note.id), "r2 must still see the note", ); }); test("non-recipient gets 404 on DELETE /notes/received/:id (does not leak)", async () => { const sender = await createUser("ndel_nrs"); const recipient = await createUser("ndel_nrr"); const stranger = await createUser("ndel_nrx"); const sCookie = await login(sender.username); const xCookie = await login(stranger.username); const note = await createNote(sCookie); await sendTo(sCookie, note.id, [recipient.id]); // Stranger has no recipient row → 404. const res = await api(`/notes/received/${note.id}`, xCookie, { method: "DELETE", }); assert.equal(res.status, 404, "stranger delete must be 404"); // Sender (owner, but not a recipient) also has no recipient row → 404. const res2 = await api(`/notes/received/${note.id}`, sCookie, { method: "DELETE", }); assert.equal(res2.status, 404, "sender-as-non-recipient must be 404"); // Recipient row still intact. const { rows } = await pool.query( `SELECT id FROM note_recipients WHERE note_id = $1 AND recipient_user_id = $2`, [note.id, recipient.id], ); assert.equal(rows.length, 1, "recipient row must remain after stranger 404"); }); test("bulk-delete removes only the caller's rows; mix of valid + missing is reported via notFound", async () => { const sender = await createUser("ndel_bs"); const r1 = await createUser("ndel_br1"); const other = await createUser("ndel_bo"); const sCookie = await login(sender.username); const r1Cookie = await login(r1.username); const noteA = await createNote(sCookie); const noteB = await createNote(sCookie); const noteC = await createNote(sCookie); // r1 is NOT a recipient await sendTo(sCookie, noteA.id, [r1.id, other.id]); await sendTo(sCookie, noteB.id, [r1.id]); await sendTo(sCookie, noteC.id, [other.id]); const res = await api(`/notes/received/bulk-delete`, r1Cookie, { method: "POST", body: JSON.stringify({ ids: [noteA.id, noteB.id, noteC.id, 999999999] }), }); assert.ok(res.ok, `bulk-delete expected 2xx, got ${res.status}`); const body = await res.json(); assert.deepEqual( [...body.ok].sort((a, b) => a - b), [noteA.id, noteB.id].sort((a, b) => a - b), "must report A and B as successfully deleted", ); assert.deepEqual( [...body.notFound].sort((a, b) => a - b), [noteC.id, 999999999].sort((a, b) => a - b), "non-recipient + missing ids must be reported", ); // r1 inbox no longer has A or B. const r1Inbox = await (await api(`/notes/received`, r1Cookie)).json(); assert.ok( !r1Inbox.find((n) => n.id === noteA.id), "noteA must be gone from r1 inbox", ); assert.ok( !r1Inbox.find((n) => n.id === noteB.id), "noteB must be gone from r1 inbox", ); // Other recipient on noteA still has their row. const { rows: otherRows } = await pool.query( `SELECT id FROM note_recipients WHERE note_id = $1 AND recipient_user_id = $2`, [noteA.id, other.id], ); assert.equal(otherRows.length, 1, "other recipient on noteA must survive"); // Underlying notes still exist (we only delete recipient rows). const { rows: notes } = await pool.query( `SELECT id FROM notes WHERE id = ANY($1::int[])`, [[noteA.id, noteB.id, noteC.id]], ); assert.equal(notes.length, 3, "all underlying notes must remain"); });