Files

256 lines
8.4 KiB
JavaScript
Raw Permalink Normal View History

// 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");
});