diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 72770224..2fd8a7e0 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -210,12 +210,20 @@ async function loadOne(noteId: number, userId: number) { return [{ ...note, labelIds: assignments.map((a) => a.labelId) }]; } -router.get("/notes", requireAuth, async (req, res): Promise => { +async function handleListMyNotes( + req: import("express").Request, + res: import("express").Response, +): Promise { const userId = req.session.userId!; const archived = req.query.archived === "true"; const notes = await loadNotesWithLabels(userId, archived); res.json(notes); -}); +} + +// Backward-compatible: GET /notes lists the caller's personal notes. +router.get("/notes", requireAuth, handleListMyNotes); +// Explicit alias requested by the messaging spec. +router.get("/notes/my", requireAuth, handleListMyNotes); router.post("/notes", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; @@ -312,7 +320,11 @@ router.get("/notes/sent", requireAuth, async (req, res): Promise => { .from(notesTable) .where(and(eq(notesTable.userId, userId), inArray(notesTable.id, ids))) .orderBy(desc(notesTable.updatedAt)); - const out = [] as any[]; + type SentNoteOut = (typeof notes)[number] & { + recipients: Awaited>; + replyCount: number; + }; + const out: SentNoteOut[] = []; for (const n of notes) { const recipients = await loadRecipientsForNote(n.id); const replyCount = await db diff --git a/artifacts/api-server/tests/notes-share.test.mjs b/artifacts/api-server/tests/notes-share.test.mjs index bca4e0a9..11e14f90 100644 --- a/artifacts/api-server/tests/notes-share.test.mjs +++ b/artifacts/api-server/tests/notes-share.test.mjs @@ -25,7 +25,7 @@ function uniq() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } -async function createUser(prefix) { +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) @@ -36,8 +36,8 @@ async function createUser(prefix) { createdUserIds.push(id); await pool.query( `INSERT INTO user_roles (user_id, role_id) - SELECT $1, id FROM roles WHERE name = 'user'`, - [id], + SELECT $1, id FROM roles WHERE name = $2`, + [id, role], ); return { id, username }; } @@ -376,6 +376,103 @@ test("GET /notes/:id is the same payload as /notes/:id/thread", async () => { 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" }), + }); + assert.equal(res.status, 404, "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("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(), + ); +}); + 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");