// API tests for the in-app messaging flow built on top of personal notes. // Covers POST /api/notes/:id/send, GET /api/notes/sent, GET /api/notes/received, // GET /api/notes/:id/thread, POST /api/notes/:id/read, POST /api/notes/:id/reply, // and the access-control rules around them. 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, role = "user") { 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 = $2`, [id, role], ); 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 world", color: "blue", ...body, }), }); assert.ok( res.status === 200 || res.status === 201, `create note expected 2xx, got ${res.status}`, ); const json = await res.json(); createdNoteIds.push(json.id); return json; } const createdFolderIds = []; 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 (createdFolderIds.length > 0) { await pool.query( `DELETE FROM note_folder_shares WHERE folder_id = ANY($1::int[])`, [createdFolderIds], ); await pool.query(`DELETE FROM note_folders WHERE id = ANY($1::int[])`, [ createdFolderIds, ]); } 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("send → received → read → reply produces independent sender / recipient views", async () => { const sender = await createUser("notes_sender"); const r1 = await createUser("notes_r1"); const r2 = await createUser("notes_r2"); const sCookie = await login(sender.username); const r1Cookie = await login(r1.username); const r2Cookie = await login(r2.username); const note = await createNote(sCookie); // Send to both recipients (and try to include sender's own id to confirm it // gets filtered out). const sendRes = await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [r1.id, r2.id, sender.id] }), }); assert.equal(sendRes.status, 200); const sendBody = await sendRes.json(); assert.equal(sendBody.success, true); assert.equal(sendBody.sent, 2, "self-recipient must be excluded from the send"); // Sender's "Sent" view shows the note with two unread recipients. const sentRes = await api(`/notes/sent`, sCookie); assert.equal(sentRes.status, 200); const sent = await sentRes.json(); const sentNote = sent.find((n) => n.id === note.id); assert.ok(sentNote, "sent note must appear in /notes/sent"); assert.equal(sentNote.recipients.length, 2); assert.ok( sentNote.recipients.every((r) => r.status === "unread"), "all recipients should start as unread", ); // r1 sees the note in their inbox. const r1Inbox = await (await api(`/notes/received`, r1Cookie)).json(); const r1Note = r1Inbox.find((n) => n.id === note.id); assert.ok(r1Note, "r1 must see the note in their inbox"); assert.equal(r1Note.status, "unread"); assert.equal(r1Note.sender.id, sender.id); // r1 marks read → status flips for that recipient row only. const readRes = await api(`/notes/${note.id}/read`, r1Cookie, { method: "POST", }); assert.equal(readRes.status, 200); const sentAfterRead = await (await api(`/notes/sent`, sCookie)).json(); const sentNote2 = sentAfterRead.find((n) => n.id === note.id); const r1Row = sentNote2.recipients.find((r) => r.recipientUserId === r1.id); const r2Row = sentNote2.recipients.find((r) => r.recipientUserId === r2.id); assert.equal(r1Row.status, "read"); assert.equal(r2Row.status, "unread", "r2 must remain unread"); // r1 replies → status becomes 'replied' for r1 only, reply is visible. const replyRes = await api(`/notes/${note.id}/reply`, r1Cookie, { method: "POST", body: JSON.stringify({ content: "got it, thanks" }), }); assert.ok( replyRes.status === 200 || replyRes.status === 201, `reply expected 2xx, got ${replyRes.status}`, ); const threadSender = await ( await api(`/notes/${note.id}/thread`, sCookie) ).json(); assert.equal(threadSender.isOwner, true); assert.equal(threadSender.replies.length, 1); assert.equal(threadSender.replies[0].content, "got it, thanks"); const r1RowAfter = threadSender.recipients.find( (r) => r.recipientUserId === r1.id, ); assert.equal(r1RowAfter.status, "replied"); // r2's recipient view must NOT show r1's reply (recipient sees only their // own thread of replies). const threadR2 = await ( await api(`/notes/${note.id}/thread`, r2Cookie) ).json(); assert.equal(threadR2.isOwner, false); assert.equal(threadR2.myStatus, "unread"); assert.equal( threadR2.replies.length, 0, "r2 must not see replies authored by r1", ); }); test("non-participant gets 403 on /notes/:id/thread", async () => { const sender = await createUser("notes_p_sender"); const recipient = await createUser("notes_p_recipient"); const stranger = await createUser("notes_p_stranger"); const sCookie = await login(sender.username); const strangerCookie = await login(stranger.username); const note = await createNote(sCookie); await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); const res = await api(`/notes/${note.id}/thread`, strangerCookie); assert.equal(res.status, 403, "stranger must not access the thread"); }); test("recipient copy is independent: sender edit/delete does not mutate it", async () => { const sender = await createUser("notes_indep_sender"); const recipient = await createUser("notes_indep_recipient"); const sCookie = await login(sender.username); const rCookie = await login(recipient.username); const note = await createNote(sCookie, { title: "Original title", content: "Original content", color: "blue", }); await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); // Sender edits + deletes the note. const editRes = await api(`/notes/${note.id}`, sCookie, { method: "PATCH", body: JSON.stringify({ title: "EDITED", content: "EDITED body", color: "red" }), }); assert.ok(editRes.ok, `edit expected 2xx, got ${editRes.status}`); const inboxAfterEdit = await (await api(`/notes/received`, rCookie)).json(); const r1 = inboxAfterEdit.find((n) => n.id === note.id); assert.ok(r1, "recipient must still see the delivered copy after sender edit"); assert.equal(r1.title, "Original title", "recipient title must be the snapshot"); assert.equal(r1.content, "Original content", "recipient content must be the snapshot"); assert.equal(r1.color, "blue", "recipient color must be the snapshot"); // Now sender deletes their copy. const delRes = await api(`/notes/${note.id}`, sCookie, { method: "DELETE" }); assert.ok(delRes.ok, `delete expected 2xx, got ${delRes.status}`); const inboxAfterDelete = await (await api(`/notes/received`, rCookie)).json(); const r2 = inboxAfterDelete.find( (n) => n.recipientRowId === r1.recipientRowId, ); assert.ok( r2, "recipient must still see the delivered copy after sender deletion", ); assert.equal(r2.title, "Original title"); assert.equal(r2.content, "Original content"); // Recipient can still open the thread + reply even after sender deletion. const threadRes = await api(`/notes/${note.id}/thread`, rCookie); assert.equal(threadRes.status, 200, "thread still readable after sender delete"); const thread = await threadRes.json(); assert.equal(thread.title, "Original title"); assert.equal(thread.content, "Original content"); const replyRes = await api(`/notes/${note.id}/reply`, rCookie, { method: "POST", body: JSON.stringify({ content: "still here" }), }); assert.ok( replyRes.ok, `reply after sender delete expected 2xx, got ${replyRes.status}`, ); }); test("reply on an archived recipient row clears archivedAt and bumps to replied", async () => { const sender = await createUser("notes_arch_sender"); const recipient = await createUser("notes_arch_recipient"); const sCookie = await login(sender.username); const rCookie = await login(recipient.username); const note = await createNote(sCookie); await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); const archRes = await api(`/notes/${note.id}/archive`, rCookie, { method: "POST", body: JSON.stringify({ archived: true }), }); assert.ok(archRes.ok); const replyRes = await api(`/notes/${note.id}/reply`, rCookie, { method: "POST", body: JSON.stringify({ content: "back from archive" }), }); assert.ok(replyRes.ok, `reply expected 2xx, got ${replyRes.status}`); const { rows } = await pool.query( `SELECT status, archived_at FROM note_recipients WHERE note_id = $1 AND recipient_user_id = $2`, [note.id, recipient.id], ); assert.equal(rows[0].status, "replied"); assert.equal(rows[0].archived_at, null, "archivedAt must be cleared on reply"); }); test("owner can reply on their own note; recipient status is preserved", async () => { const sender = await createUser("notes_oreply_sender"); const recipient = await createUser("notes_oreply_recipient"); const sCookie = await login(sender.username); const rCookie = await login(recipient.username); const note = await createNote(sCookie); await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); // Recipient leaves the note unread, then the owner replies. Owner reply // must succeed (not 403) and must NOT flip the recipient's status. const ownerReply = await api(`/notes/${note.id}/reply`, sCookie, { method: "POST", body: JSON.stringify({ content: "owner says hi" }), }); assert.equal( ownerReply.status, 201, `owner reply expected 201, got ${ownerReply.status}`, ); const { rows } = await pool.query( `SELECT status FROM note_recipients WHERE note_id = $1 AND recipient_user_id = $2`, [note.id, recipient.id], ); assert.equal(rows[0].status, "unread", "owner reply must not flip recipient status"); // Recipient sees the owner reply in their thread. const thread = await (await api(`/notes/${note.id}/thread`, rCookie)).json(); assert.ok( thread.replies.some((r) => r.content === "owner says hi"), "recipient must see the owner's reply in the thread", ); }); test("GET /notes/:id is the same payload as /notes/:id/thread", async () => { const sender = await createUser("notes_alias_sender"); const recipient = await createUser("notes_alias_recipient"); const sCookie = await login(sender.username); const rCookie = await login(recipient.username); const note = await createNote(sCookie); await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); const a = await (await api(`/notes/${note.id}`, rCookie)).json(); const b = await (await api(`/notes/${note.id}/thread`, rCookie)).json(); assert.equal(a.id, b.id); assert.equal(a.title, b.title); assert.equal(a.content, b.content); assert.equal(a.color, b.color); assert.equal(a.myStatus, b.myStatus); }); test("stranger is forbidden from /read, /archive, and /reply on someone else's note", async () => { const sender = await createUser("notes_strg_sender"); const recipient = await createUser("notes_strg_recipient"); const stranger = await createUser("notes_strg_stranger"); const sCookie = await login(sender.username); const xCookie = await login(stranger.username); const note = await createNote(sCookie); await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); const readRes = await api(`/notes/${note.id}/read`, xCookie, { method: "POST" }); assert.equal(readRes.status, 403, "stranger /read must be 403"); const archiveRes = await api(`/notes/${note.id}/archive`, xCookie, { method: "POST", body: JSON.stringify({ archived: true }), }); assert.equal(archiveRes.status, 403, "stranger /archive must be 403"); const replyRes = await api(`/notes/${note.id}/reply`, xCookie, { method: "POST", body: JSON.stringify({ content: "I shouldn't be here" }), }); assert.equal(replyRes.status, 403, "stranger /reply must be 403"); const detailRes = await api(`/notes/${note.id}`, xCookie); assert.equal(detailRes.status, 403, "stranger GET /notes/:id must be 403"); }); test("recipient cannot PATCH the sender's note body", async () => { const sender = await createUser("notes_patchdeny_sender"); const recipient = await createUser("notes_patchdeny_recipient"); const sCookie = await login(sender.username); const rCookie = await login(recipient.username); const note = await createNote(sCookie, { title: "Untouched" }); await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); const res = await api(`/notes/${note.id}`, rCookie, { method: "PATCH", body: JSON.stringify({ title: "HACKED" }), }); // The notes PATCH route uses a two-step lookup so non-owners get a // precise 403 (instead of an ambiguous 404). A recipient already // knows the note exists — they received it — so hiding existence // would leak nothing. 403 is the correct deny status here. assert.equal(res.status, 403, "recipient PATCH must be denied"); const { rows } = await pool.query(`SELECT title FROM notes WHERE id = $1`, [note.id]); assert.equal(rows[0].title, "Untouched", "sender's note body must be unchanged"); }); test("admin can read any note via GET /notes/:id and sees full thread", async () => { const sender = await createUser("notes_adm_sender"); const recipient = await createUser("notes_adm_recipient"); const adminUser = await createUser("notes_adm_admin", "admin"); const sCookie = await login(sender.username); const rCookie = await login(recipient.username); const aCookie = await login(adminUser.username); const note = await createNote(sCookie, { title: "Confidential" }); await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); await api(`/notes/${note.id}/reply`, rCookie, { method: "POST", body: JSON.stringify({ content: "recipient says hi" }), }); const res = await api(`/notes/${note.id}`, aCookie); assert.equal(res.status, 200, "admin must be able to read any note"); const body = await res.json(); assert.equal(body.isAdmin, true); assert.equal(body.title, "Confidential"); assert.ok(body.recipients.length >= 1, "admin sees full recipient list"); assert.ok( body.replies.some((r) => r.content === "recipient says hi"), "admin sees full reply thread", ); }); test("admin can read a note whose sender deleted their copy (recipient snapshot remains)", async () => { const sender = await createUser("notes_admdel_sender"); const recipient = await createUser("notes_admdel_recipient"); const adminUser = await createUser("notes_admdel_admin", "admin"); const sCookie = await login(sender.username); const aCookie = await login(adminUser.username); const note = await createNote(sCookie, { title: "WillDelete" }); await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); // Sender deletes the live note row; recipient snapshot must remain. await pool.query(`DELETE FROM notes WHERE id = $1`, [note.id]); const res = await api(`/notes/${note.id}`, aCookie); assert.equal(res.status, 200, "admin must still read the thread after sender delete"); const body = await res.json(); assert.equal(body.isAdmin, true); assert.equal(body.title, "WillDelete", "admin sees recipient snapshot title"); assert.ok(body.recipients.length >= 1, "admin sees recipient list"); }); test("GET /notes/my returns the same payload as GET /notes (alias)", async () => { const owner = await createUser("notes_my_owner"); const cookie = await login(owner.username); await createNote(cookie, { title: "from /notes/my" }); const a = await (await api(`/notes`, cookie)).json(); const b = await (await api(`/notes/my`, cookie)).json(); assert.equal(a.length, b.length); assert.deepEqual( a.map((n) => n.id).sort(), b.map((n) => n.id).sort(), ); }); // per-recipient folder permission (view vs edit). test("folder share permissions: view-only is rejected on writes; edit can mutate; permission roundtrip works", async () => { const owner = await createUser("notes_perm_owner"); const viewer = await createUser("notes_perm_viewer"); const editor = await createUser("notes_perm_editor"); const oCookie = await login(owner.username); const vCookie = await login(viewer.username); const eCookie = await login(editor.username); // Owner creates a folder + a note inside it. const fRes = await api(`/note-folders`, oCookie, { method: "POST", body: JSON.stringify({ name: "perm-folder", color: "blue" }), }); assert.ok(fRes.status === 200 || fRes.status === 201, `folder create expected 2xx, got ${fRes.status}`); const folder = await fRes.json(); createdFolderIds.push(folder.id); const note = await createNote(oCookie, { folderId: folder.id, title: "T" }); // Owner shares: viewer = view, editor = edit (new recipients[] payload). const shareRes = await api(`/note-folders/${folder.id}/shares`, oCookie, { method: "PUT", body: JSON.stringify({ recipients: [ { userId: viewer.id, permission: "view" }, { userId: editor.id, permission: "edit" }, ], }), }); assert.equal(shareRes.status, 200); const shareList = await shareRes.json(); const viewerRow = shareList.find((r) => r.userId === viewer.id); const editorRow = shareList.find((r) => r.userId === editor.id); assert.equal(viewerRow.permission, "view"); assert.equal(editorRow.permission, "edit"); // Recipients see correct myPermission via shared-with-me. const vSwm = await (await api(`/note-folders/shared-with-me`, vCookie)).json(); assert.equal(vSwm.find((f) => f.id === folder.id).myPermission, "view"); const eSwm = await (await api(`/note-folders/shared-with-me`, eCookie)).json(); assert.equal(eSwm.find((f) => f.id === folder.id).myPermission, "edit"); // Viewer cannot patch a note in the shared folder. const vPatch = await api(`/notes/${note.id}`, vCookie, { method: "PATCH", body: JSON.stringify({ title: "hacked" }), }); assert.equal(vPatch.status, 403, "view-only must not patch"); // Editor can patch the note. const ePatch = await api(`/notes/${note.id}`, eCookie, { method: "PATCH", body: JSON.stringify({ title: "edited by editor" }), }); assert.equal(ePatch.status, 200, "editor must patch"); // Editor cannot unfile (folderId=null) — would steal the note from owner. const eUnfile = await api(`/notes/${note.id}`, eCookie, { method: "PATCH", body: JSON.stringify({ folderId: null }), }); assert.equal(eUnfile.status, 403, "editor must not unfile"); // Editor can create a new note in the folder; it is stamped to owner. const eCreate = await api(`/notes`, eCookie, { method: "POST", body: JSON.stringify({ title: "by editor", content: "x", color: "blue", folderId: folder.id, }), }); assert.ok(eCreate.status === 200 || eCreate.status === 201, `editor create expected 2xx, got ${eCreate.status}`); const eCreated = await eCreate.json(); createdNoteIds.push(eCreated.id); const { rows: ownerRows } = await pool.query( `SELECT user_id FROM notes WHERE id = $1`, [eCreated.id], ); assert.equal(ownerRows[0].user_id, owner.id, "editor-created note belongs to owner"); // Permission flip: downgrade editor to view, upgrade viewer to edit. const flipRes = await api(`/note-folders/${folder.id}/shares`, oCookie, { method: "PUT", body: JSON.stringify({ recipients: [ { userId: viewer.id, permission: "edit" }, { userId: editor.id, permission: "view" }, ], }), }); assert.equal(flipRes.status, 200); // Former editor (now view) can no longer patch. const ePatch2 = await api(`/notes/${note.id}`, eCookie, { method: "PATCH", body: JSON.stringify({ title: "after-downgrade" }), }); assert.equal(ePatch2.status, 403, "downgraded editor must lose write access"); // Former viewer (now edit) can patch. const vPatch2 = await api(`/notes/${note.id}`, vCookie, { method: "PATCH", body: JSON.stringify({ title: "after-upgrade" }), }); assert.equal(vPatch2.status, 200, "upgraded viewer must gain write access"); }); test("re-sending to the same recipient does not duplicate the recipient row", async () => { const sender = await createUser("notes_dup_sender"); const recipient = await createUser("notes_dup_recipient"); const sCookie = await login(sender.username); const note = await createNote(sCookie); await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); const second = await api(`/notes/${note.id}/send`, sCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); assert.equal(second.status, 200); const { rows } = await pool.query( `SELECT count(*)::int AS c FROM note_recipients WHERE note_id = $1`, [note.id], ); assert.equal(rows[0].c, 1, "recipient row must not be duplicated"); });