2026-05-13 09:54:35 +00:00
|
|
|
// Tests for Task #524 — MR-H1 / MR-H2 / MR-M7.
|
|
|
|
|
//
|
2026-05-13 10:08:12 +00:00
|
|
|
// 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).
|
2026-05-13 09:54:35 +00:00
|
|
|
|
|
|
|
|
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!";
|
|
|
|
|
// bcrypt cost-10 hash of TEST_PASSWORD (matches the value used by the
|
|
|
|
|
// other executive-meetings test fixtures so we don't pay bcrypt cost
|
|
|
|
|
// per test seed).
|
|
|
|
|
const TEST_PASSWORD_HASH =
|
|
|
|
|
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
|
|
|
|
|
|
|
|
|
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}_`;
|
2026-05-13 10:08:12 +00:00
|
|
|
const ARCHIVE_DATE = "2099-12-31"; // far-future, cleanup key
|
|
|
|
|
const ORPHAN_ARCHIVE_PATH = `/objects/orphan-archive-${STAMP}`;
|
2026-05-13 09:54:35 +00:00
|
|
|
|
|
|
|
|
let receiverUserId; // order_receiver only — no executive role
|
|
|
|
|
let viewerUserId; // executive_viewer — read-only inside executive module
|
|
|
|
|
let mutatorUserId; // executive_office_manager — full mutator
|
2026-05-13 10:08:12 +00:00
|
|
|
let adminUserId; // admin — bypasses entity rules
|
2026-05-13 09:54:35 +00:00
|
|
|
let receiverCookie;
|
|
|
|
|
let viewerCookie;
|
|
|
|
|
let mutatorCookie;
|
2026-05-13 10:08:12 +00:00
|
|
|
let adminCookie;
|
2026-05-13 09:54:35 +00:00
|
|
|
|
|
|
|
|
async function login(username, password) {
|
|
|
|
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ username, password }),
|
|
|
|
|
});
|
|
|
|
|
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
|
|
|
|
const setCookie = res.headers.get("set-cookie");
|
|
|
|
|
assert.ok(setCookie, "login response missing set-cookie header");
|
|
|
|
|
const cookie = setCookie
|
|
|
|
|
.split(",")
|
|
|
|
|
.map((c) => c.split(";")[0].trim())
|
|
|
|
|
.find((c) => c.startsWith("connect.sid="));
|
|
|
|
|
assert.ok(cookie, "no connect.sid cookie returned by login");
|
|
|
|
|
return cookie;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function seedUser(suffix, roleName) {
|
|
|
|
|
const username = `${USERNAME_PREFIX}${suffix}`;
|
|
|
|
|
const u = await pool.query(
|
|
|
|
|
`INSERT INTO users (username, email, password_hash, display_name_en,
|
|
|
|
|
preferred_language, is_active)
|
|
|
|
|
VALUES ($1, $2, $3, 'ObjAuthz Test', 'en', true)
|
|
|
|
|
RETURNING id`,
|
|
|
|
|
[username, `${username}@ex.com`, TEST_PASSWORD_HASH],
|
|
|
|
|
);
|
|
|
|
|
const userId = u.rows[0].id;
|
|
|
|
|
await pool.query(
|
|
|
|
|
`INSERT INTO user_roles (user_id, role_id)
|
|
|
|
|
SELECT $1, id FROM roles WHERE name = $2
|
|
|
|
|
ON CONFLICT DO NOTHING`,
|
|
|
|
|
[userId, roleName],
|
|
|
|
|
);
|
|
|
|
|
return { userId, username };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
before(async () => {
|
|
|
|
|
await pool.query(`DELETE FROM users WHERE username LIKE $1`, [
|
|
|
|
|
`${USERNAME_PREFIX}%`,
|
|
|
|
|
]);
|
2026-05-13 10:08:12 +00:00
|
|
|
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],
|
|
|
|
|
);
|
2026-05-13 09:54:35 +00:00
|
|
|
|
|
|
|
|
const r = await seedUser("receiver", "order_receiver");
|
|
|
|
|
receiverUserId = r.userId;
|
|
|
|
|
receiverCookie = await login(r.username, TEST_PASSWORD);
|
|
|
|
|
|
|
|
|
|
const v = await seedUser("viewer", "executive_viewer");
|
|
|
|
|
viewerUserId = v.userId;
|
|
|
|
|
viewerCookie = await login(v.username, TEST_PASSWORD);
|
|
|
|
|
|
|
|
|
|
const m = await seedUser("mutator", "executive_office_manager");
|
|
|
|
|
mutatorUserId = m.userId;
|
|
|
|
|
mutatorCookie = await login(m.username, TEST_PASSWORD);
|
2026-05-13 10:08:12 +00:00
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
);
|
2026-05-13 09:54:35 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
after(async () => {
|
2026-05-13 10:08:12 +00:00
|
|
|
for (const id of [receiverUserId, viewerUserId, mutatorUserId, adminUserId]) {
|
2026-05-13 09:54:35 +00:00
|
|
|
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]);
|
|
|
|
|
}
|
2026-05-13 10:08:12 +00:00
|
|
|
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]) {
|
2026-05-13 09:54:35 +00:00
|
|
|
if (!id) continue;
|
|
|
|
|
await pool.query(`DELETE FROM users WHERE id = $1`, [id]);
|
|
|
|
|
}
|
|
|
|
|
await pool.end();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 10:08:12 +00:00
|
|
|
// ---- A: unauthenticated boundary ----------------------------------
|
|
|
|
|
test("A: unauthenticated GET /api/storage/objects/* returns 401", async () => {
|
2026-05-13 09:54:35 +00:00
|
|
|
const res = await fetch(`${API_BASE}/api/storage/objects/anything-uuid`);
|
2026-05-13 10:08:12 +00:00
|
|
|
assert.equal(res.status, 401);
|
2026-05-13 09:54:35 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-13 10:08:12 +00:00
|
|
|
// ---- 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 },
|
|
|
|
|
});
|
2026-05-13 09:54:35 +00:00
|
|
|
assert.equal(res.status, 404, `expected 404, got ${res.status}`);
|
2026-05-13 10:08:12 +00:00
|
|
|
// 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.
|
2026-05-13 09:54:35 +00:00
|
|
|
const body = await res.json().catch(() => ({}));
|
|
|
|
|
assert.equal(body.error, "Object not found");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 10:08:12 +00:00
|
|
|
// ---- 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/");
|
|
|
|
|
|
2026-05-13 14:12:40 +00:00
|
|
|
// 2. PUT a known body to the presigned URL (this hits the active
|
|
|
|
|
// storage backend — local-FS driver in dev, S3/MinIO in production).
|
2026-05-13 10:08:12 +00:00
|
|
|
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 () => {
|
2026-05-13 09:54:35 +00:00
|
|
|
const res = await fetch(
|
|
|
|
|
`${API_BASE}/api/executive-meetings/pdf-archives`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
2026-05-13 10:08:12 +00:00
|
|
|
headers: { "Content-Type": "application/json", cookie: viewerCookie },
|
|
|
|
|
body: JSON.stringify({ archiveDate: ARCHIVE_DATE }),
|
2026-05-13 09:54:35 +00:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert.equal(res.status, 403, `expected 403, got ${res.status}`);
|
|
|
|
|
const body = await res.json().catch(() => ({}));
|
|
|
|
|
assert.equal(body.code, "forbidden");
|
2026-05-13 10:08:12 +00:00
|
|
|
|
|
|
|
|
// 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");
|
2026-05-13 09:54:35 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-13 10:08:12 +00:00
|
|
|
// ---- 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 () => {
|
2026-05-13 09:54:35 +00:00
|
|
|
const res = await fetch(
|
|
|
|
|
`${API_BASE}/api/executive-meetings/pdf-archives`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
2026-05-13 10:08:12 +00:00
|
|
|
headers: { "Content-Type": "application/json", cookie: mutatorCookie },
|
|
|
|
|
body: JSON.stringify({ archiveDate: ARCHIVE_DATE }),
|
2026-05-13 09:54:35 +00:00
|
|
|
},
|
|
|
|
|
);
|
2026-05-13 10:08:12 +00:00
|
|
|
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}`);
|
2026-05-13 09:54:35 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-13 10:08:12 +00:00
|
|
|
// ---- 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 () => {
|
2026-05-13 09:54:35 +00:00
|
|
|
const res = await fetch(
|
|
|
|
|
`${API_BASE}/api/executive-meetings/pdf-archives`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
2026-05-13 10:08:12 +00:00
|
|
|
headers: { "Content-Type": "application/json", cookie: mutatorCookie },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
archiveDate: ARCHIVE_DATE,
|
|
|
|
|
// MR-H2: any caller-supplied filePath — even one that matches
|
|
|
|
|
// /objects/<id> regex — must be rejected so executive writers
|
|
|
|
|
// cannot point archive rows at arbitrary observed object paths.
|
|
|
|
|
filePath: "/objects/some-other-uuid",
|
|
|
|
|
}),
|
2026-05-13 09:54:35 +00:00
|
|
|
},
|
|
|
|
|
);
|
2026-05-13 10:08:12 +00:00
|
|
|
assert.equal(res.status, 400, `expected 400, got ${res.status}`);
|
2026-05-13 09:54:35 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-13 10:08:12 +00:00
|
|
|
// ---- J: regression — read endpoint still works for read-only role -
|
|
|
|
|
test("J: GET /executive-meetings/pdf-archives by executive_viewer → 200", async () => {
|
2026-05-13 09:54:35 +00:00
|
|
|
const res = await fetch(
|
2026-05-13 10:08:12 +00:00
|
|
|
`${API_BASE}/api/executive-meetings/pdf-archives?date=${ARCHIVE_DATE}`,
|
2026-05-13 09:54:35 +00:00
|
|
|
{ headers: { cookie: viewerCookie } },
|
|
|
|
|
);
|
2026-05-13 10:08:12 +00:00
|
|
|
assert.equal(res.status, 200);
|
2026-05-13 09:54:35 +00:00
|
|
|
const body = await res.json();
|
|
|
|
|
assert.ok(Array.isArray(body.archives), "expected archives array");
|
|
|
|
|
const ours = body.archives.find(
|
2026-05-13 10:08:12 +00:00
|
|
|
(a) => a.filePath === `print:${ARCHIVE_DATE}`,
|
2026-05-13 09:54:35 +00:00
|
|
|
);
|
2026-05-13 10:08:12 +00:00
|
|
|
assert.ok(ours, "the seeded archive row must be visible to executive_viewer");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 10:14:35 +00:00
|
|
|
// ---- L: app-icon authz mirrors launcher RBAC ----------------------
|
|
|
|
|
test("L: app-icon object: user with app permission streams body, restricted user 404", async () => {
|
|
|
|
|
// Upload a real icon, attach it to a freshly-created app, then
|
|
|
|
|
// restrict the app via a unique permission. Only users granted
|
|
|
|
|
// that permission (here: admin) should see the icon stream.
|
|
|
|
|
const presignRes = await fetch(
|
|
|
|
|
`${API_BASE}/api/storage/uploads/request-url`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json", cookie: adminCookie },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
name: "app-icon.bin",
|
|
|
|
|
size: 8,
|
|
|
|
|
contentType: "application/octet-stream",
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert.equal(presignRes.status, 200);
|
|
|
|
|
const { uploadURL, objectPath } = await presignRes.json();
|
|
|
|
|
const payload = "iconbyt";
|
|
|
|
|
const putRes = await fetch(uploadURL, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
|
|
|
body: payload,
|
|
|
|
|
});
|
|
|
|
|
assert.ok(putRes.status >= 200 && putRes.status < 300);
|
|
|
|
|
|
|
|
|
|
const appSlug = `obj_authz_app_${STAMP}`;
|
|
|
|
|
const permName = `obj_authz_perm_${STAMP}`;
|
|
|
|
|
const appRow = await pool.query(
|
|
|
|
|
`INSERT INTO apps (slug, name_en, name_ar, route, image_url)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
|
|
|
RETURNING id`,
|
|
|
|
|
[appSlug, appSlug, appSlug, `/${appSlug}`, objectPath],
|
|
|
|
|
);
|
|
|
|
|
const appId = appRow.rows[0].id;
|
|
|
|
|
const permRow = await pool.query(
|
|
|
|
|
`INSERT INTO permissions (name) VALUES ($1) RETURNING id`,
|
|
|
|
|
[permName],
|
|
|
|
|
);
|
|
|
|
|
const permId = permRow.rows[0].id;
|
|
|
|
|
await pool.query(
|
|
|
|
|
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
|
|
|
|
|
[appId, permId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const tail = objectPath.replace("/objects/", "");
|
|
|
|
|
|
|
|
|
|
// Admin — sees every app via getVisibleAppsForUser short-circuit.
|
|
|
|
|
const allow = await fetch(`${API_BASE}/api/storage/objects/${tail}`, {
|
|
|
|
|
headers: { cookie: adminCookie },
|
|
|
|
|
});
|
|
|
|
|
assert.equal(allow.status, 200, `admin expected 200, got ${allow.status}`);
|
|
|
|
|
assert.equal(await allow.text(), payload, "admin should stream the icon bytes");
|
|
|
|
|
|
|
|
|
|
// Receiver — has no role granting permName, no group_apps row.
|
|
|
|
|
// Must be denied at authz → 404.
|
|
|
|
|
const deny = await fetch(`${API_BASE}/api/storage/objects/${tail}`, {
|
|
|
|
|
headers: { cookie: receiverCookie },
|
|
|
|
|
});
|
|
|
|
|
assert.equal(deny.status, 404, `receiver expected 404, got ${deny.status}`);
|
|
|
|
|
} finally {
|
|
|
|
|
await pool.query(`DELETE FROM app_permissions WHERE app_id = $1`, [appId]);
|
|
|
|
|
await pool.query(`DELETE FROM permissions WHERE id = $1`, [permId]);
|
|
|
|
|
await pool.query(`DELETE FROM apps WHERE id = $1`, [appId]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 10:08:12 +00:00
|
|
|
// ---- 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],
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-13 09:54:35 +00:00
|
|
|
});
|