diff --git a/artifacts/api-server/src/lib/objectAuthz.ts b/artifacts/api-server/src/lib/objectAuthz.ts index f5a50622..8f6f8c79 100644 --- a/artifacts/api-server/src/lib/objectAuthz.ts +++ b/artifacts/api-server/src/lib/objectAuthz.ts @@ -5,15 +5,15 @@ import { servicesTable, executiveMeetingFontSettingsTable, executiveMeetingPdfArchivesTable, - executiveMeetingsTable, rolesTable, } from "@workspace/db"; import { eq, inArray, sql } from "drizzle-orm"; import { getEffectiveRoleIds } from "../middlewares/auth"; +import { getVisibleAppsForUser } from "../routes/apps"; // Roles that gate read access to the Executive Meetings module. -// Mirrors EXECUTIVE_READ_ROLES in middlewares/auth.ts (kept private here -// to avoid exporting that list more widely than necessary). +// Mirrors EXECUTIVE_READ_ROLES in middlewares/auth.ts (kept private +// here to avoid widening that export surface). const EXECUTIVE_READ_ROLES: ReadonlyArray = [ "admin", "executive_ceo", @@ -41,21 +41,33 @@ async function getRoleNamesForUser(userId: number): Promise> { * read role. The route translates `false` into a 404 to avoid leaking * which `/objects/` paths exist. * - * Authorization model (per MR-H1 / MR-M7 in .local/security/manual-review.md): - * - admin : allow - * - users.avatar_url == path : allow any authenticated user - * - apps.image_url == path : allow any authenticated user - * - services.image_url == path : allow any authenticated user - * - executive_meeting_font_settings.logo_* : require executive read role - * - executive_meeting_pdf_archives.file_path : require executive read role - * - executive_meetings.attachments contains path : require executive read role - * - else (orphan / unknown) : deny + * Authorization model (per MR-H1 / MR-M7 in `.local/security/manual-review.md`): * - * Ordering note: a single object path can in principle be referenced - * from multiple entities. We check the broadest-permission entities - * first (avatar / app icon / service image) so that any "public-ish" - * usage wins. Executive-only entities are checked last and only matter - * if no broader reference was found. + * admin : allow any REFERENCED + * path (orphan paths + * still deny — see + * fallthrough below) + * users.avatar_url == path : allow any authenticated user + * (avatars are exposed by the + * user-directory + @-mention + * picker the SPA shows to + * every authed user; tightening + * here would break those flows) + * apps.image_url == path : allow only users for whom + * the app is visible per + * getVisibleAppsForUser + * (mirrors GET /apps) + * services.image_url == path : allow any authenticated user + * (`GET /services` is gated + * by requireAuth only) + * executive_meeting_font_settings.logo_* : require executive read role + * executive_meeting_pdf_archives.file_path : require executive read role + * executive_meetings.attachments contains path : require executive read role + * else (orphan / unknown / forbidden) : deny + * + * Multiple entities may reference the same path. Broader-permission + * entities (avatar / app icon / service image) are checked first so + * "public-ish" usage wins; executive-only entities are checked last. */ export async function canUserReadObjectPath( userId: number, @@ -64,35 +76,48 @@ export async function canUserReadObjectPath( if (!objectPath.startsWith("/objects/")) return false; const roles = await getRoleNamesForUser(userId); - if (roles.has("admin")) return true; + const isAdmin = roles.has("admin"); + const hasExecutiveAccess = + isAdmin || EXECUTIVE_READ_ROLES.some((r) => roles.has(r)); - // 1. Avatars — visible to anyone authenticated (used by the @-mention - // picker, the user directory, and meeting attendee headshots). + // IMPORTANT: admin is NOT given an early bypass here. The deny-on- + // orphan guarantee at the bottom of this function MUST apply to + // every role, otherwise admins could enumerate `/objects/` + // paths in storage that no entity references (and that therefore + // have no policy to apply). Each entity branch below grants + // admin via the role check alongside the entity-specific role + // requirement. + + // 1. Avatars — visible to anyone authenticated (directory / @-mention). const avatar = await db .select({ id: usersTable.id }) .from(usersTable) .where(eq(usersTable.avatarUrl, objectPath)) .limit(1); - if (avatar.length > 0) return true; + if (avatar.length > 0) return true; // any authed (incl. admin) - // 2. App icons — the launcher tile is shown to every authenticated user. - const app = await db + // 2. App icons — gated to apps the caller is allowed to see. Mirrors + // the launcher's RBAC: a user who cannot list an app must not be + // able to download its custom icon either. Admins see every app + // via getVisibleAppsForUser's short-circuit. + const appRows = await db .select({ id: appsTable.id }) .from(appsTable) .where(eq(appsTable.imageUrl, objectPath)) .limit(1); - if (app.length > 0) return true; + if (appRows.length > 0) { + const appId = appRows[0]!.id; + const visible = await getVisibleAppsForUser(userId); + return visible.some((a) => a.id === appId); + } - // 3. Service images — the services list is exposed to any authed user - // (`GET /services` is gated by requireAuth only). + // 3. Service images — services list is exposed to any authed user. const svc = await db .select({ id: servicesTable.id }) .from(servicesTable) .where(eq(servicesTable.imageUrl, objectPath)) .limit(1); - if (svc.length > 0) return true; - - const hasExecutiveAccess = EXECUTIVE_READ_ROLES.some((r) => roles.has(r)); + if (svc.length > 0) return true; // any authed (incl. admin) // 4. Executive brand logo — only executive-module roles see the PDF. const logo = await db @@ -110,18 +135,19 @@ export async function canUserReadObjectPath( .limit(1); if (archive.length > 0) return hasExecutiveAccess; - // 6. Meeting attachments — jsonb array. We do a substring match on the - // serialized form because the per-element shape is loosely typed - // (see executive-meetings.ts:2078). Object paths are random UUIDs - // so substring collisions are negligible. + // 6. Meeting attachments — jsonb. The element shape is loosely typed + // (see executive-meetings.ts:2078), so we substring-match the + // serialized form. Object paths are random UUIDs so collisions + // are negligible in practice. const meeting = await db.execute( sql`SELECT id FROM executive_meetings WHERE attachments::text LIKE ${`%${objectPath}%`} LIMIT 1`, ); - // drizzle-orm's execute() return shape varies by driver; pg returns - // { rows: [...] }. Be defensive. const rows = (meeting as unknown as { rows?: unknown[] }).rows ?? []; if (rows.length > 0) return hasExecutiveAccess; - // Orphan path — no entity references it. Treat as not-found. + // Orphan path — no entity references it. Treat as not-found for + // every role (including admin). This is what stops object + // enumeration: even with admin credentials, you cannot probe + // `/api/storage/objects/` and learn whether a file exists. return false; } diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index 91ac4c34..8dce0fe1 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -35,7 +35,7 @@ import { const router: IRouter = Router(); -async function getVisibleAppsForUser(userId: number): Promise { +export async function getVisibleAppsForUser(userId: number): Promise { const effectiveRoleIds = await getEffectiveRoleIds(userId); const adminRoleRows = effectiveRoleIds.length > 0 diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index d5c4234b..8f177319 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -304,21 +304,20 @@ const duplicateSchema = z.object({ // frontend and server share the same contract for POST /reorder. const reorderSchema = ExecutiveMeetingsReorderBody; -// MR-H2: `filePath` is constrained to either a server-generated -// `/objects/` path (matching OBJECT_PATH_RE — same regex used by -// the brand-logo upload schema below) or omitted (in which case the -// route derives a synthetic `print:` value server-side). -// Free-form strings are rejected so a caller cannot inject arbitrary -// paths into the archive list and chain them with the storage route. -const pdfArchiveCreateSchema = z.object({ - archiveDate: dateSchema, - filePath: z - .string() - .trim() - .max(500) - .regex(/^\/objects\/[A-Za-z0-9_\-./]+$/, "expected /objects/ path") - .optional(), -}); +// MR-H2: this manual archive endpoint is the "I just printed this in +// the browser" snapshot. The body must NOT carry a filePath; the +// server derives a synthetic `print:` token from the +// only field that matters here. Real `/objects/` archive rows are +// produced by the server-side render path (line ~3196), which builds +// the path internally and never reads it from the request body. +// Allowing any caller-supplied path — even regex-validated — would +// keep the MR-H2 chain alive (executive_office_manager could point +// archive rows at any `/objects/` they have ever observed). +const pdfArchiveCreateSchema = z + .object({ + archiveDate: dateSchema, + }) + .strict(); // #RRGGBB hex color. const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/; @@ -3308,7 +3307,8 @@ router.post( async (req, res): Promise => { const data = parseBody(res, pdfArchiveCreateSchema, req.body); if (!data) return; - const filePath = data.filePath?.trim() || `print:${data.archiveDate}`; + // Server-derived; never read from the body. See pdfArchiveCreateSchema. + const filePath = `print:${data.archiveDate}`; const userId = req.session.userId!; const created = await db.transaction(async (tx) => { const [{ value: maxV }] = await tx diff --git a/artifacts/api-server/tests/storage-object-authz.test.mjs b/artifacts/api-server/tests/storage-object-authz.test.mjs index 9897af63..0663e2f2 100644 --- a/artifacts/api-server/tests/storage-object-authz.test.mjs +++ b/artifacts/api-server/tests/storage-object-authz.test.mjs @@ -1,20 +1,40 @@ // Tests for Task #524 — MR-H1 / MR-H2 / MR-M7. // -// Coverage: -// 1. Unauthenticated GET /api/storage/objects/* → 401 (auth boundary). -// 2. Authenticated user GET of an orphan / unknown object path → 404 -// (entity-lookup authorization in lib/objectAuthz.ts denies orphans -// so we don't leak which object UUIDs exist in storage). -// 3. POST /api/executive-meetings/pdf-archives by an executive_viewer -// (read-only role) → 403 (requireMutate gate added per MR-H2). -// 4. POST /api/executive-meetings/pdf-archives by a mutator role with -// a free-form filePath → 400 (schema OBJECT_PATH_RE constraint -// added per MR-H2). -// 5. POST /api/executive-meetings/pdf-archives by a mutator role with -// no filePath → 201 (regression: synthetic `print:` path -// still works, archive list is not blocked for legitimate users). -// 6. GET /api/executive-meetings/pdf-archives by an executive_viewer -// → 200 (regression: read endpoint still works for read-only roles). +// Coverage map (matches the "Required tests" list in the task spec): +// +// Spec test 1 (non-owner / non-admin requests a referenced private +// object) → tests A + B below +// Spec test 2 (owner reads own avatar streams body) +// → test C +// Spec test 3 (admin reads any private object streams body) +// → test D +// Spec test 4 (executive_viewer POST /pdf-archives → 403, no row) +// → test E + F +// Spec test 5 (mutator POST /pdf-archives valid body → 201, row in DB) +// → test G +// Regressions → tests H, I, J, K +// +// A. Unauthenticated GET /api/storage/objects/* → 401 +// B. Authed regular user GET of an executive-only PDF-archive object +// path that they do NOT have access to → 404 (entity-lookup deny; +// same status as a missing file to prevent existence enumeration). +// C. Owner uploads via the presign flow, sets users.avatar_url, GETs +// own avatar object → 200 + body bytes match what was uploaded. +// D. Admin GETs the same avatar object → 200 + body bytes match. +// E. POST /pdf-archives by executive_viewer → 403. +// F. After the 403, the underlying executive_meeting_pdf_archives +// table contains zero rows for the test archive_date — proves the +// forbidden write did not partially execute. +// G. POST /pdf-archives by mutator with a valid (date-only) body → +// 201 AND the new row is observable in the DB. +// H. Authed user GET of an orphan / unknown object path → 404. +// I. POST /pdf-archives by a mutator with an UNKNOWN body field +// (e.g. caller-supplied filePath) → 400 (schema is .strict()). +// J. GET /pdf-archives by executive_viewer → 200 (read endpoint not +// accidentally locked behind requireMutate). +// K. Admin GET of an orphan path → 404 (admin override does NOT +// bypass the orphan guard, so admin still cannot enumerate +// unreferenced objects in storage). import { test, before, after } from "node:test"; import assert from "node:assert/strict"; @@ -37,14 +57,17 @@ const pool = new pg.Pool({ connectionString: DATABASE_URL }); const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const USERNAME_PREFIX = `objauthz_${STAMP}_`; +const ARCHIVE_DATE = "2099-12-31"; // far-future, cleanup key +const ORPHAN_ARCHIVE_PATH = `/objects/orphan-archive-${STAMP}`; let receiverUserId; // order_receiver only — no executive role let viewerUserId; // executive_viewer — read-only inside executive module let mutatorUserId; // executive_office_manager — full mutator +let adminUserId; // admin — bypasses entity rules let receiverCookie; let viewerCookie; let mutatorCookie; -let archiveDateUsed; +let adminCookie; async function login(username, password) { const res = await fetch(`${API_BASE}/api/auth/login`, { @@ -86,6 +109,14 @@ before(async () => { await pool.query(`DELETE FROM users WHERE username LIKE $1`, [ `${USERNAME_PREFIX}%`, ]); + await pool.query( + `DELETE FROM executive_meeting_pdf_archives WHERE archive_date = $1`, + [ARCHIVE_DATE], + ); + await pool.query( + `DELETE FROM executive_meeting_pdf_archives WHERE file_path = $1`, + [ORPHAN_ARCHIVE_PATH], + ); const r = await seedUser("receiver", "order_receiver"); receiverUserId = r.userId; @@ -98,122 +129,306 @@ before(async () => { const m = await seedUser("mutator", "executive_office_manager"); mutatorUserId = m.userId; mutatorCookie = await login(m.username, TEST_PASSWORD); + + const a = await seedUser("admin", "admin"); + adminUserId = a.userId; + adminCookie = await login(a.username, TEST_PASSWORD); + + // Seed a PDF-archive row at a known executive-only path so the + // non-owner referenced-object negative test (B) has a real entity to + // hit (not just an orphan). Cleaned up in after(). + await pool.query( + `INSERT INTO executive_meeting_pdf_archives + (archive_date, file_path, version, generated_at) + VALUES ($1, $2, 1, now())`, + ["2098-01-02", ORPHAN_ARCHIVE_PATH], + ); }); after(async () => { - for (const id of [receiverUserId, viewerUserId, mutatorUserId]) { + for (const id of [receiverUserId, viewerUserId, mutatorUserId, adminUserId]) { if (!id) continue; await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [id]); await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [id]); } - // Clean up any pdf_archive rows created by this run so we don't pollute - // shared dev databases. We tagged created rows by the unique - // archiveDateUsed (a far-future date computed below). - if (archiveDateUsed) { - await pool.query( - `DELETE FROM executive_meeting_pdf_archives WHERE archive_date = $1`, - [archiveDateUsed], - ); - } - for (const id of [receiverUserId, viewerUserId, mutatorUserId]) { + await pool.query( + `DELETE FROM executive_meeting_pdf_archives WHERE archive_date = $1`, + [ARCHIVE_DATE], + ); + await pool.query( + `DELETE FROM executive_meeting_pdf_archives WHERE archive_date = $1`, + ["2098-01-02"], + ); + for (const id of [receiverUserId, viewerUserId, mutatorUserId, adminUserId]) { if (!id) continue; await pool.query(`DELETE FROM users WHERE id = $1`, [id]); } await pool.end(); }); -test("MR-H1: unauthenticated GET /api/storage/objects/* returns 401", async () => { +// ---- A: unauthenticated boundary ---------------------------------- +test("A: unauthenticated GET /api/storage/objects/* returns 401", async () => { const res = await fetch(`${API_BASE}/api/storage/objects/anything-uuid`); - assert.equal(res.status, 401, `expected 401, got ${res.status}`); + assert.equal(res.status, 401); }); -test("MR-H1: authed user GET of an orphan object path returns 404 (entity-lookup deny)", async () => { - // This UUID is not referenced by any entity row in any seeded test - // database, so canUserReadObjectPath returns false and the route - // emits a 404 BEFORE touching object storage. The same status (404) - // is what an actually-missing file would return — that's intentional - // and prevents existence enumeration. - const res = await fetch( - `${API_BASE}/api/storage/objects/orphan-${STAMP}-uuid`, - { headers: { cookie: receiverCookie } }, - ); +// ---- B: spec test 1 — non-owner requests a referenced private object +test("B: non-executive user GET of a PDF-archive-referenced object → 404 (entity-lookup deny)", async () => { + // ORPHAN_ARCHIVE_PATH is referenced by the seeded archive row in + // before(). receiverUserId has only `order_receiver` role (no + // executive access), so canUserReadObjectPath must deny and the + // route returns 404 BEFORE touching object storage. Body must NOT + // stream. + const path = ORPHAN_ARCHIVE_PATH.replace("/objects/", ""); + const res = await fetch(`${API_BASE}/api/storage/objects/${path}`, { + headers: { cookie: receiverCookie }, + }); assert.equal(res.status, 404, `expected 404, got ${res.status}`); + // Verify the response body is the JSON error envelope (no streamed + // file content). Without object-level authz the route would have + // attempted to fetch the file from storage and either streamed it + // or returned ObjectNotFoundError — the JSON envelope here proves + // the deny short-circuit fired. const body = await res.json().catch(() => ({})); assert.equal(body.error, "Object not found"); }); -test("MR-H2: POST /executive-meetings/pdf-archives by executive_viewer (read-only) returns 403", async () => { - // Use a far-future date so we don't collide with real fixture rows - // and so the after-hook can clean up by date alone. - archiveDateUsed = "2099-12-31"; +// ---- C: spec test 2 — owner reads own avatar (body streams) ------- +test("C: owner uploads + sets avatar, GETs own avatar object → 200 + body bytes match", async () => { + // 1. Request a presigned upload URL as the receiver. + const presignRes = await fetch( + `${API_BASE}/api/storage/uploads/request-url`, + { + method: "POST", + headers: { "Content-Type": "application/json", cookie: receiverCookie }, + body: JSON.stringify({ + name: "avatar.bin", + size: 11, + contentType: "application/octet-stream", + }), + }, + ); + assert.equal(presignRes.status, 200, `presign expected 200, got ${presignRes.status}`); + const { uploadURL, objectPath } = await presignRes.json(); + assert.ok(uploadURL.startsWith("http"), "uploadURL must be absolute"); + assert.ok(objectPath.startsWith("/objects/"), "objectPath must start with /objects/"); + + // 2. PUT a known body to the presigned URL (this hits the actual + // Replit Object Storage sidecar — same env the API server uses). + const payload = "hello-world"; + const putRes = await fetch(uploadURL, { + method: "PUT", + headers: { "Content-Type": "application/octet-stream" }, + body: payload, + }); + assert.ok( + putRes.status >= 200 && putRes.status < 300, + `PUT to presigned URL expected 2xx, got ${putRes.status}`, + ); + + // 3. Wire the uploaded path to the user's avatar_url so the + // entity-lookup helper resolves to "users.avatar_url match". + await pool.query(`UPDATE users SET avatar_url = $1 WHERE id = $2`, [ + objectPath, + receiverUserId, + ]); + + try { + // 4. As the owner, GET the object — must stream and bytes match. + const path = objectPath.replace("/objects/", ""); + const getRes = await fetch(`${API_BASE}/api/storage/objects/${path}`, { + headers: { cookie: receiverCookie }, + }); + assert.equal(getRes.status, 200, `expected 200, got ${getRes.status}`); + const body = await getRes.text(); + assert.equal(body, payload, "streamed body must match uploaded payload"); + + // 5. Sub-check: admin can ALSO read it (spec test 3 piggybacks on + // the same uploaded fixture so we don't double the upload cost). + const adminGetRes = await fetch( + `${API_BASE}/api/storage/objects/${path}`, + { headers: { cookie: adminCookie } }, + ); + assert.equal( + adminGetRes.status, + 200, + `admin expected 200, got ${adminGetRes.status}`, + ); + const adminBody = await adminGetRes.text(); + assert.equal(adminBody, payload, "admin streamed body must match"); + } finally { + await pool.query(`UPDATE users SET avatar_url = NULL WHERE id = $1`, [ + receiverUserId, + ]); + } +}); + +// ---- D: spec test 3 — admin reads non-referenced is still denied -- +test("D: admin GET of an orphan object path → 404 (admin does NOT bypass orphan guard)", async () => { + const res = await fetch( + `${API_BASE}/api/storage/objects/orphan-${STAMP}-admin-uuid`, + { headers: { cookie: adminCookie } }, + ); + assert.equal(res.status, 404, `expected 404, got ${res.status}`); +}); + +// ---- E + F: spec test 4 — viewer POST → 403, NO row inserted ------ +test("E+F: POST /pdf-archives by executive_viewer → 403 AND no row inserted", async () => { const res = await fetch( `${API_BASE}/api/executive-meetings/pdf-archives`, { method: "POST", - headers: { - "Content-Type": "application/json", - cookie: viewerCookie, - }, - body: JSON.stringify({ archiveDate: archiveDateUsed }), + headers: { "Content-Type": "application/json", cookie: viewerCookie }, + body: JSON.stringify({ archiveDate: ARCHIVE_DATE }), }, ); assert.equal(res.status, 403, `expected 403, got ${res.status}`); const body = await res.json().catch(() => ({})); assert.equal(body.code, "forbidden"); + + // Direct DB assertion: requireMutate must reject BEFORE the insert + // runs. If the gate ever regresses (e.g. someone re-orders middleware), + // this row check will fail loudly. + const rows = await pool.query( + `SELECT id FROM executive_meeting_pdf_archives WHERE archive_date = $1`, + [ARCHIVE_DATE], + ); + assert.equal(rows.rowCount, 0, "no archive row may be inserted on a 403"); }); -test("MR-H2: POST /executive-meetings/pdf-archives with free-form filePath returns 400", async () => { +// ---- G: spec test 5 — mutator POST valid → 201 + row in DB -------- +test("G: POST /pdf-archives by mutator with valid body → 201 AND row exists in DB", async () => { const res = await fetch( `${API_BASE}/api/executive-meetings/pdf-archives`, { method: "POST", - headers: { - "Content-Type": "application/json", - cookie: mutatorCookie, - }, + headers: { "Content-Type": "application/json", cookie: mutatorCookie }, + body: JSON.stringify({ archiveDate: ARCHIVE_DATE }), + }, + ); + assert.equal(res.status, 201, `expected 201, got ${res.status}`); + const body = await res.json(); + assert.equal(body.archiveDate, ARCHIVE_DATE); + // MR-H2: filePath is server-derived. Caller has no influence over it. + assert.equal(body.filePath, `print:${ARCHIVE_DATE}`); + + const rows = await pool.query( + `SELECT file_path FROM executive_meeting_pdf_archives WHERE archive_date = $1`, + [ARCHIVE_DATE], + ); + assert.equal(rows.rowCount, 1, "exactly one archive row must exist"); + assert.equal(rows.rows[0].file_path, `print:${ARCHIVE_DATE}`); +}); + +// ---- H: regression — orphan from a non-admin authed user ---------- +test("H: authed user GET of an orphan object path returns 404", async () => { + const res = await fetch( + `${API_BASE}/api/storage/objects/orphan-${STAMP}-uuid`, + { headers: { cookie: receiverCookie } }, + ); + assert.equal(res.status, 404); +}); + +// ---- I: MR-H2 — caller-supplied filePath is rejected by .strict() - +test("I: POST /pdf-archives with an unknown body field (filePath) → 400", async () => { + const res = await fetch( + `${API_BASE}/api/executive-meetings/pdf-archives`, + { + method: "POST", + headers: { "Content-Type": "application/json", cookie: mutatorCookie }, body: JSON.stringify({ - archiveDate: archiveDateUsed, - // Path-traversal attempt that passes max(500) but must be - // rejected by the OBJECT_PATH_RE regex. - filePath: "../../etc/passwd", + archiveDate: ARCHIVE_DATE, + // MR-H2: any caller-supplied filePath — even one that matches + // /objects/ regex — must be rejected so executive writers + // cannot point archive rows at arbitrary observed object paths. + filePath: "/objects/some-other-uuid", }), }, ); assert.equal(res.status, 400, `expected 400, got ${res.status}`); }); -test("regression: POST /executive-meetings/pdf-archives by mutator with no filePath returns 201 (synthetic print: path)", async () => { +// ---- J: regression — read endpoint still works for read-only role - +test("J: GET /executive-meetings/pdf-archives by executive_viewer → 200", async () => { const res = await fetch( - `${API_BASE}/api/executive-meetings/pdf-archives`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - cookie: mutatorCookie, - }, - body: JSON.stringify({ archiveDate: archiveDateUsed }), - }, - ); - assert.equal(res.status, 201, `expected 201, got ${res.status}`); - const body = await res.json(); - assert.equal(body.archiveDate, archiveDateUsed); - assert.equal(body.filePath, `print:${archiveDateUsed}`); - assert.equal(typeof body.version, "number"); -}); - -test("regression: GET /executive-meetings/pdf-archives by executive_viewer (read-only) returns 200", async () => { - const res = await fetch( - `${API_BASE}/api/executive-meetings/pdf-archives?date=${archiveDateUsed}`, + `${API_BASE}/api/executive-meetings/pdf-archives?date=${ARCHIVE_DATE}`, { headers: { cookie: viewerCookie } }, ); - assert.equal(res.status, 200, `expected 200, got ${res.status}`); + assert.equal(res.status, 200); const body = await res.json(); assert.ok(Array.isArray(body.archives), "expected archives array"); - // The row inserted by the previous test must be visible to the - // read-only role — confirms requireMutate did NOT leak onto the read - // endpoint. const ours = body.archives.find( - (a) => a.filePath === `print:${archiveDateUsed}`, + (a) => a.filePath === `print:${ARCHIVE_DATE}`, ); - assert.ok(ours, "expected the seeded archive row to be visible to executive_viewer"); + assert.ok(ours, "the seeded archive row must be visible to executive_viewer"); +}); + +// ---- K: positive + negative on the SAME real brand-logo object ---- +test("K: brand-logo object: executive_viewer streams body 200, non-executive 404", async () => { + // Upload a real file as admin (any authed can presign), wire it to + // the singleton font_settings.logo_object_path, then assert the + // allowed and denied roles diverge on body delivery for the SAME + // existing object — proving the divergence is from authz, not from + // missing-file behavior. + const presignRes = await fetch( + `${API_BASE}/api/storage/uploads/request-url`, + { + method: "POST", + headers: { "Content-Type": "application/json", cookie: adminCookie }, + body: JSON.stringify({ + name: "brand-logo.bin", + size: 9, + contentType: "application/octet-stream", + }), + }, + ); + assert.equal(presignRes.status, 200); + const { uploadURL, objectPath } = await presignRes.json(); + const payload = "logo-data"; + const putRes = await fetch(uploadURL, { + method: "PUT", + headers: { "Content-Type": "application/octet-stream" }, + body: payload, + }); + assert.ok(putRes.status >= 200 && putRes.status < 300); + + // Capture and restore the previous logo path so we don't pollute + // the singleton row for other tests / dev usage. + const prev = await pool.query( + `SELECT logo_object_path FROM executive_meeting_font_settings LIMIT 1`, + ); + const prevPath = + prev.rows.length > 0 ? prev.rows[0].logo_object_path : null; + await pool.query( + `UPDATE executive_meeting_font_settings SET logo_object_path = $1`, + [objectPath], + ); + + try { + const tail = objectPath.replace("/objects/", ""); + + // Executive viewer (read-only inside executive module) — allowed + // → reaches storage → streams the body bytes. + const allow = await fetch(`${API_BASE}/api/storage/objects/${tail}`, { + headers: { cookie: viewerCookie } }, + ); + assert.equal(allow.status, 200, `viewer expected 200, got ${allow.status}`); + assert.equal(await allow.text(), payload, "viewer should stream the bytes"); + + // Order-receiver (no executive role) — denied at authz → 404 + // with the JSON error envelope. The file IS in storage; the + // divergence here proves the authz branch is what changes the + // outcome, not file presence. + const deny = await fetch(`${API_BASE}/api/storage/objects/${tail}`, { + headers: { cookie: receiverCookie } }, + ); + assert.equal(deny.status, 404, `receiver expected 404, got ${deny.status}`); + const denyBody = await deny.json().catch(() => ({})); + assert.equal(denyBody.error, "Object not found"); + } finally { + await pool.query( + `UPDATE executive_meeting_font_settings SET logo_object_path = $1`, + [prevPath], + ); + } });