a14b589006
Record an audit trail every time a role's permissions change and surface recent history inside the role edit dialog. Schema (lib/db): - New `role_permission_audit` table: id, roleId (FK roles), actorUserId (FK users, nullable on delete), previousPermissionIds int[], newPermissionIds int[], createdAt. Created via raw SQL because `drizzle push` currently fails on a pre-existing app_permissions duplicate (followup #148 tracks fixing this). API (artifacts/api-server): - PUT /api/roles/:id/permissions now wraps the permission delete/insert, the legacy audit_logs row, and the new role_permission_audit row in a *single* transaction so the permission set and its audit trail always commit (or roll back) together. Previous IDs are also read inside the transaction to avoid races. - A role_permission_audit row is written on EVERY PUT call (per task spec), including no-op saves; the GET handler computes addedPermissionIds/removedPermissionIds so the History UI can distinguish meaningful changes from no-op saves. - New GET /api/roles/:id/audit (admin-only, ?limit=1..50, default 10) returns recent entries newest-first with computed added/removedPermissionIds and joined actor info. - New tests in tests/role-permission-audit.test.mjs cover write, every-call write (incl. no-op), GET ordering+diff+actor, no-op diff reporting, 404, and limit. Drive-by fixes: - Fixed pre-existing syntax corruption in tests/apps-open.test.mjs (stray `ccaPassa,` line from a prior bad merge that prevented the whole api-server test workflow from running). - Made tests/roles-crud.test.mjs "rejects an invalid name" robust to parallel test execution by scoping the leak check to the bad name instead of relying on a global row count. API spec / codegen (lib/api-spec, lib/api-client-react): - Added `getRolePermissionAudit` operation and `RolePermissionAuditEntry` schema; ran orval codegen. Frontend (artifacts/tx-os): - New RolePermissionHistory component renders inside the role edit dialog (between permissions and Save/Cancel) using useGetRolePermissionAudit. Shows timestamp, actor, added/removed permission names (resolved via permissionsById), and total count. - Save invalidates the audit query so the new entry appears immediately on the next open. - Bilingual i18n strings (en + ar with full Arabic plural variants: zero/one/two/few/many/other) under admin.roles.history*. Verification: - tx-os typecheck passes. - All 5 new audit tests + 13 existing roles tests pass. - E2E (testing skill) verified the full flow: empty state on a fresh role, save adds a permission, reopened dialog shows the new entry with actor name in Arabic. Docs: - replit.md updated to list `role_permission_audit` in Database Tables. Follow-ups proposed: #146 paginate/filter history, #147 audit other admin permission changes, #148 fix drizzle push duplicate. Replit-Task-Id: 9b8886a2-6e76-4072-b345-a772421fa161
180 lines
6.2 KiB
JavaScript
180 lines
6.2 KiB
JavaScript
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import http from "node:http";
|
|
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 });
|
|
|
|
let testUserId;
|
|
let testUsername;
|
|
let validAppId;
|
|
let validAppRoute;
|
|
let badAppId;
|
|
let sessionCookie;
|
|
|
|
async function loginAndGetCookie(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, "expected Set-Cookie header from login");
|
|
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 countOpens({ userId, appId } = {}) {
|
|
let query = "SELECT COUNT(*)::int AS c FROM app_opens WHERE 1=1";
|
|
const params = [];
|
|
if (userId !== undefined) {
|
|
params.push(userId);
|
|
query += ` AND user_id = $${params.length}`;
|
|
}
|
|
if (appId !== undefined) {
|
|
params.push(appId);
|
|
query += ` AND app_id = $${params.length}`;
|
|
}
|
|
const { rows } = await pool.query(query, params);
|
|
return rows[0].c;
|
|
}
|
|
|
|
before(async () => {
|
|
testUsername = `open_test_${Date.now().toString(36)}_${Math.random()
|
|
.toString(36)
|
|
.slice(2, 6)}`;
|
|
const userRes = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Open Test', 'en', true) RETURNING id`,
|
|
[testUsername, `${testUsername}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
testUserId = userRes.rows[0].id;
|
|
|
|
await pool.query(
|
|
`INSERT INTO user_roles (user_id, role_id)
|
|
SELECT $1, id FROM roles WHERE name = 'user'`,
|
|
[testUserId],
|
|
);
|
|
|
|
const appRes = await pool.query(
|
|
`SELECT id, route FROM apps WHERE is_active = true ORDER BY id ASC LIMIT 1`,
|
|
);
|
|
assert.ok(appRes.rows[0], "no active app found to test against");
|
|
validAppId = appRes.rows[0].id;
|
|
validAppRoute = appRes.rows[0].route;
|
|
|
|
const maxRes = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`);
|
|
badAppId = Number(maxRes.rows[0].m) + 100000;
|
|
|
|
sessionCookie = await loginAndGetCookie(testUsername, TEST_PASSWORD);
|
|
});
|
|
|
|
after(async () => {
|
|
if (testUserId !== undefined) {
|
|
await pool.query(`DELETE FROM app_opens WHERE user_id = $1`, [testUserId]);
|
|
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [testUserId]);
|
|
await pool.query(`DELETE FROM users WHERE id = $1`, [testUserId]);
|
|
}
|
|
await pool.end();
|
|
});
|
|
|
|
test("happy path: authenticated POST inserts a row and returns 204", async () => {
|
|
const before = await countOpens({ userId: testUserId, appId: validAppId });
|
|
const res = await fetch(`${API_BASE}/api/apps/${validAppId}/open`, {
|
|
method: "POST",
|
|
headers: { Cookie: sessionCookie },
|
|
});
|
|
assert.equal(res.status, 204);
|
|
const after = await countOpens({ userId: testUserId, appId: validAppId });
|
|
assert.equal(after, before + 1, "app_opens row should be inserted");
|
|
});
|
|
|
|
test("unauthenticated POST returns 401 and inserts no row", async () => {
|
|
const before = await countOpens({ appId: validAppId });
|
|
const res = await fetch(`${API_BASE}/api/apps/${validAppId}/open`, {
|
|
method: "POST",
|
|
});
|
|
assert.equal(res.status, 401);
|
|
const after = await countOpens({ appId: validAppId });
|
|
assert.equal(after, before, "no row should be inserted on 401");
|
|
});
|
|
|
|
test("authenticated POST to non-existent app id returns 404 and inserts no row", async () => {
|
|
const before = await countOpens({ appId: badAppId });
|
|
const res = await fetch(`${API_BASE}/api/apps/${badAppId}/open`, {
|
|
method: "POST",
|
|
headers: { Cookie: sessionCookie },
|
|
});
|
|
assert.equal(res.status, 404);
|
|
const after = await countOpens({ appId: badAppId });
|
|
assert.equal(after, before);
|
|
assert.equal(after, 0);
|
|
});
|
|
|
|
// Simulates a slow network where the client navigates away (and the response
|
|
// is never consumed) before the server finishes. The fire-and-forget POST in
|
|
// home.tsx uses keepalive so the request reaches the server; the server-side
|
|
// handler awaits the DB insert before responding, so the row must still be
|
|
// recorded even if the client aborts before reading the response.
|
|
test("slow network: row is still inserted when the client aborts before reading the response", async () => {
|
|
const before = await countOpens({ userId: testUserId, appId: validAppId });
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const url = new URL(`${API_BASE}/api/apps/${validAppId}/open`);
|
|
const req = http.request(
|
|
{
|
|
method: "POST",
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname,
|
|
headers: {
|
|
Cookie: sessionCookie,
|
|
"Content-Length": "0",
|
|
},
|
|
},
|
|
(res) => {
|
|
// If we get a response back quickly, just consume and resolve.
|
|
res.resume();
|
|
res.on("end", resolve);
|
|
},
|
|
);
|
|
req.on("error", (err) => {
|
|
// ECONNRESET from our destroy() below is expected.
|
|
if (err && (err.code === "ECONNRESET" || err.code === "ECONNABORTED")) {
|
|
resolve();
|
|
} else {
|
|
reject(err);
|
|
}
|
|
});
|
|
req.end();
|
|
// Abort the connection ~50ms after sending so the client never reads the
|
|
// response, mimicking a navigation-aborted keepalive request.
|
|
setTimeout(() => req.destroy(), 50);
|
|
});
|
|
|
|
// Give the server time to finish its insert even though we abandoned the
|
|
// socket on the client side.
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
|
|
const after = await countOpens({ userId: testUserId, appId: validAppId });
|
|
assert.equal(
|
|
after,
|
|
before + 1,
|
|
"row should be inserted server-side even when the client aborts before reading the response",
|
|
);
|
|
});
|