Files
TX/artifacts/api-server/tests/storage-object-authz.test.mjs
T
riyadhafraa 745e503940 Improve how users can access visible applications and files
Refactor `getVisibleAppsForUser` into a new file `appsVisibility.ts` and update existing imports. Add a new test case for app icon object authorization. Modify the `objectAuthz.ts` file to use a more precise JSONB path check for meeting attachments.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: d1d78e3b-ae14-4da2-b782-586269e0ef7e
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/jLdqQ2v
Replit-Helium-Checkpoint-Created: true
2026-05-13 10:14:35 +00:00

504 lines
20 KiB
JavaScript

// Tests for Task #524 — MR-H1 / MR-H2 / MR-M7.
//
// 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";
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}_`;
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 adminCookie;
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}%`,
]);
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;
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);
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, 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]);
}
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();
});
// ---- 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);
});
// ---- 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");
});
// ---- 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: 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");
});
// ---- 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 },
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: 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",
}),
},
);
assert.equal(res.status, 400, `expected 400, got ${res.status}`);
});
// ---- 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?date=${ARCHIVE_DATE}`,
{ headers: { cookie: viewerCookie } },
);
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(Array.isArray(body.archives), "expected archives array");
const ours = body.archives.find(
(a) => a.filePath === `print:${ARCHIVE_DATE}`,
);
assert.ok(ours, "the seeded archive row must be visible to executive_viewer");
});
// ---- 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]);
}
});
// ---- 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],
);
}
});