84398de390
Destructive removal per user confirmation ("حذف نهائي ما يرجع").
Removed:
- API: routes/conversations.ts, schema/conversations.ts, all chat
socket handlers in src/index.ts, /admin/users/:id/dependents/
conversations+messages endpoints, conversation/message dependency
counts in users/stats routes.
- Web: pages/chat.tsx, /chat route, dock chat filter, MessageSquare
icon and messages StatCard on home, all chat-related UI in
notifications + admin (dependency badges, delete-dialog rows,
UserDependentConversations/Messages sections, count map keys).
- Locales: nav.chat, home.stats.messages, full chat.* block,
admin.deleteUser conv/msgCount, admin.users.counts.conv/msg,
admin.audit.unit.conversation_*/message_*, admin.dependents.user*.
- OpenAPI spec: tags, all /conversations/* paths, conv/msg dependent
paths, related schemas (ConversationWithDetails, MessageWithSender,
UserDependentConversation/MessageItem+Page, etc.), UserProfile and
UserDeletionConflict conv/msg fields, HomeStats.unreadMessages.
Regenerated client via orval.
- Database: dropped message_reads, messages,
conversation_participants, conversations (CASCADE); deleted
notifications with related_type='conversation' or type='chat';
deleted apps row with slug='chat'; ran drizzle push-force.
- Seed: removed chat:access permission + user-role assignment +
seeded chat app entry from scripts/src/seed.ts.
- Tests: deleted conversations-leave.test.mjs; cleaned chat refs from
list-dependency-counts, delete-force-warnings, audit-log-coverage,
and admin-inline-dependency-counts (e2e) — replaced chat dependents
with note dependents where needed for force-delete coverage.
Notes preserved: notes.tsx noConversationsYet/conversationWith refer
to NOTE THREADS (not chat) and were intentionally NOT touched.
executive-meetings.ts not modified per replit.md restriction.
Pre-existing flaky test failures in executive-meetings/group/etc
suites remain unrelated to this task.
1124 lines
36 KiB
JavaScript
1124 lines
36 KiB
JavaScript
// Verifies that every sensitive admin action that the API claims to audit
|
|
// actually writes the expected `audit_logs` row. Each test exercises a
|
|
// single endpoint, then queries the table and asserts:
|
|
// - exactly one matching row exists
|
|
// - action, actor_user_id, target_type, target_id are correct
|
|
// - the metadata blob has the documented shape
|
|
//
|
|
// The suite owns its own admin user, so `actor_user_id = adminId` is enough
|
|
// to tell our rows apart from other rows produced by parallel suites.
|
|
|
|
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!";
|
|
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)}`;
|
|
|
|
let adminId;
|
|
let adminUsername;
|
|
let adminCookie;
|
|
const createdUserIds = [];
|
|
const createdGroupIds = [];
|
|
const createdRoleIds = [];
|
|
const createdAppIds = [];
|
|
const createdServiceIds = [];
|
|
|
|
let originalSettings = null;
|
|
|
|
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");
|
|
return setCookie
|
|
.split(",")
|
|
.map((c) => c.split(";")[0].trim())
|
|
.find((c) => c.startsWith("connect.sid="));
|
|
}
|
|
|
|
function uniqueName(prefix) {
|
|
return `${prefix}_${STAMP}_${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
async function insertUser(prefix) {
|
|
const username = uniqueName(prefix).toLowerCase();
|
|
const r = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Audit Coverage', 'en', true) RETURNING id`,
|
|
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
const id = r.rows[0].id;
|
|
createdUserIds.push(id);
|
|
// Always membership in Everyone so the seed-default trigger / FK behavior matches prod.
|
|
await pool.query(
|
|
`INSERT INTO user_groups (user_id, group_id)
|
|
SELECT $1, id FROM groups WHERE name = 'Everyone'
|
|
ON CONFLICT DO NOTHING`,
|
|
[id],
|
|
);
|
|
return { id, username };
|
|
}
|
|
|
|
async function insertApp(slugPrefix) {
|
|
const slug = uniqueName(slugPrefix).toLowerCase();
|
|
const r = await pool.query(
|
|
`INSERT INTO apps (slug, name_ar, name_en, route, sort_order)
|
|
VALUES ($1, 'تطبيق تدقيق', 'Audit App', '/x', 999) RETURNING id`,
|
|
[slug],
|
|
);
|
|
const id = r.rows[0].id;
|
|
createdAppIds.push(id);
|
|
return { id, slug };
|
|
}
|
|
|
|
async function insertGroup(prefix) {
|
|
const name = uniqueName(prefix);
|
|
const r = await pool.query(
|
|
`INSERT INTO groups (name, description_en) VALUES ($1, 'audit coverage group') RETURNING id`,
|
|
[name],
|
|
);
|
|
const id = r.rows[0].id;
|
|
createdGroupIds.push(id);
|
|
return { id, name };
|
|
}
|
|
|
|
async function insertService(prefix) {
|
|
const nameEn = uniqueName(prefix);
|
|
const nameAr = `${nameEn}_ar`;
|
|
const r = await pool.query(
|
|
`INSERT INTO services (name_ar, name_en, price, is_available)
|
|
VALUES ($1, $2, 1.00, true) RETURNING id`,
|
|
[nameAr, nameEn],
|
|
);
|
|
const id = r.rows[0].id;
|
|
createdServiceIds.push(id);
|
|
return { id, nameEn, nameAr };
|
|
}
|
|
|
|
async function insertRole(prefix) {
|
|
const name = uniqueName(prefix).toLowerCase();
|
|
const r = await pool.query(
|
|
`INSERT INTO roles (name, description_en) VALUES ($1, 'audit coverage role') RETURNING id`,
|
|
[name],
|
|
);
|
|
const id = r.rows[0].id;
|
|
createdRoleIds.push(id);
|
|
return { id, name };
|
|
}
|
|
|
|
async function fetchAuditRows({ action, targetId, targetType }) {
|
|
const rows = await pool.query(
|
|
`SELECT id, actor_user_id, action, target_type, target_id, metadata, created_at
|
|
FROM audit_logs
|
|
WHERE actor_user_id = $1
|
|
AND action = $2
|
|
AND target_id = $3
|
|
AND target_type = $4
|
|
ORDER BY id`,
|
|
[adminId, action, targetId, targetType],
|
|
);
|
|
return rows.rows;
|
|
}
|
|
|
|
before(async () => {
|
|
adminUsername = `audit_cov_admin_${STAMP}`;
|
|
const a = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Audit Coverage Admin', 'en', true) RETURNING id`,
|
|
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
adminId = a.rows[0].id;
|
|
createdUserIds.push(adminId);
|
|
await pool.query(
|
|
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
|
[adminId],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO user_groups (user_id, group_id)
|
|
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
|
|
ON CONFLICT DO NOTHING`,
|
|
[adminId],
|
|
);
|
|
|
|
// Capture the current settings row so we can restore exactly what was
|
|
// there before this suite mutated it (settings.update is global state,
|
|
// shared with the rest of the app).
|
|
const s = await pool.query(`SELECT * FROM app_settings WHERE id = 1`);
|
|
if (s.rowCount === 1) {
|
|
originalSettings = s.rows[0];
|
|
}
|
|
|
|
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
|
});
|
|
|
|
after(async () => {
|
|
// Wipe audit rows we created so they don't contaminate the global table.
|
|
await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [
|
|
adminId,
|
|
]);
|
|
|
|
if (createdRoleIds.length) {
|
|
await pool.query(`DELETE FROM user_roles WHERE role_id = ANY($1::int[])`, [
|
|
createdRoleIds,
|
|
]);
|
|
await pool.query(`DELETE FROM group_roles WHERE role_id = ANY($1::int[])`, [
|
|
createdRoleIds,
|
|
]);
|
|
await pool.query(
|
|
`DELETE FROM role_permissions WHERE role_id = ANY($1::int[])`,
|
|
[createdRoleIds],
|
|
);
|
|
await pool.query(
|
|
`DELETE FROM role_permission_audit WHERE role_id = ANY($1::int[])`,
|
|
[createdRoleIds],
|
|
);
|
|
await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [
|
|
createdRoleIds,
|
|
]);
|
|
}
|
|
if (createdGroupIds.length) {
|
|
await pool.query(`DELETE FROM user_groups WHERE group_id = ANY($1::int[])`, [
|
|
createdGroupIds,
|
|
]);
|
|
await pool.query(`DELETE FROM group_apps WHERE group_id = ANY($1::int[])`, [
|
|
createdGroupIds,
|
|
]);
|
|
await pool.query(`DELETE FROM group_roles WHERE group_id = ANY($1::int[])`, [
|
|
createdGroupIds,
|
|
]);
|
|
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
|
|
createdGroupIds,
|
|
]);
|
|
}
|
|
if (createdServiceIds.length) {
|
|
await pool.query(
|
|
`DELETE FROM service_orders WHERE service_id = ANY($1::int[])`,
|
|
[createdServiceIds],
|
|
);
|
|
await pool.query(`DELETE FROM services WHERE id = ANY($1::int[])`, [
|
|
createdServiceIds,
|
|
]);
|
|
}
|
|
if (createdAppIds.length) {
|
|
await pool.query(`DELETE FROM app_opens WHERE app_id = ANY($1::int[])`, [
|
|
createdAppIds,
|
|
]);
|
|
await pool.query(
|
|
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
|
|
[createdAppIds],
|
|
);
|
|
await pool.query(`DELETE FROM group_apps WHERE app_id = ANY($1::int[])`, [
|
|
createdAppIds,
|
|
]);
|
|
await pool.query(
|
|
`DELETE FROM user_app_orders WHERE app_id = ANY($1::int[])`,
|
|
[createdAppIds],
|
|
);
|
|
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
|
|
createdAppIds,
|
|
]);
|
|
}
|
|
for (const uid of createdUserIds) {
|
|
if (uid !== undefined) {
|
|
await pool.query(`DELETE FROM password_reset_tokens WHERE user_id = $1`, [
|
|
uid,
|
|
]);
|
|
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
|
|
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
|
|
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
|
|
}
|
|
}
|
|
|
|
// Restore settings exactly as captured (clears any change rows we wrote).
|
|
if (originalSettings) {
|
|
await pool.query(
|
|
`UPDATE app_settings
|
|
SET site_name_ar = $1,
|
|
site_name_en = $2,
|
|
registration_open = $3,
|
|
footer_text_ar = $4,
|
|
footer_text_en = $5
|
|
WHERE id = 1`,
|
|
[
|
|
originalSettings.site_name_ar,
|
|
originalSettings.site_name_en,
|
|
originalSettings.registration_open,
|
|
originalSettings.footer_text_ar,
|
|
originalSettings.footer_text_en,
|
|
],
|
|
);
|
|
}
|
|
|
|
await pool.end();
|
|
});
|
|
|
|
// ---------- users ----------
|
|
|
|
test("DELETE /api/users/:id (no-force, no deps) writes one user.delete row with force=false", async () => {
|
|
const { id, username } = await insertUser("u_nf");
|
|
const email = `${username}@example.com`;
|
|
|
|
const res = await fetch(`${API_BASE}/api/users/${id}`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 204);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "user.delete",
|
|
targetId: id,
|
|
targetType: "user",
|
|
});
|
|
assert.equal(rows.length, 1, "expected exactly one user.delete audit row");
|
|
const row = rows[0];
|
|
assert.equal(row.actor_user_id, adminId);
|
|
assert.equal(row.metadata.username, username);
|
|
assert.equal(row.metadata.email, email);
|
|
assert.equal(row.metadata.displayNameEn, "Audit Coverage");
|
|
assert.equal(row.metadata.displayNameAr, null);
|
|
assert.equal(row.metadata.force, false);
|
|
// No-deps path must NOT include the dependency counts.
|
|
assert.equal(row.metadata.noteCount, undefined);
|
|
assert.equal(row.metadata.orderCount, undefined);
|
|
});
|
|
|
|
test("DELETE /api/users/:id without force on a user with deps returns 409 and writes NO audit row", async () => {
|
|
const { id } = await insertUser("u_409");
|
|
// Give the user a dependency that blocks delete.
|
|
await pool.query(
|
|
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'n', 'x')`,
|
|
[id],
|
|
);
|
|
|
|
const res = await fetch(`${API_BASE}/api/users/${id}`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 409);
|
|
|
|
const stillThere = await pool.query(`SELECT id FROM users WHERE id = $1`, [
|
|
id,
|
|
]);
|
|
assert.equal(stillThere.rowCount, 1, "user must still exist after 409");
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "user.delete",
|
|
targetId: id,
|
|
targetType: "user",
|
|
});
|
|
assert.equal(
|
|
rows.length,
|
|
0,
|
|
"denied delete (409) must NOT emit a user.delete audit row",
|
|
);
|
|
});
|
|
|
|
test("DELETE /api/users/:id?force=true writes one user.delete row with force=true and dep counts", async () => {
|
|
const { id, username } = await insertUser("u_f");
|
|
|
|
// Add a dependency so the force branch is taken meaningfully.
|
|
await pool.query(
|
|
`INSERT INTO notes (user_id, title, content) VALUES ($1, 'n', 'x')`,
|
|
[id],
|
|
);
|
|
|
|
const res = await fetch(`${API_BASE}/api/users/${id}?force=true`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 204);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "user.delete",
|
|
targetId: id,
|
|
targetType: "user",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
const row = rows[0];
|
|
assert.equal(row.actor_user_id, adminId);
|
|
assert.equal(row.metadata.username, username);
|
|
assert.equal(row.metadata.force, true);
|
|
// Force-with-deps path records the counts.
|
|
assert.equal(typeof row.metadata.noteCount, "number");
|
|
assert.ok(row.metadata.noteCount >= 1);
|
|
});
|
|
|
|
// ---------- roles ----------
|
|
|
|
test("POST /api/roles writes one role.create row", async () => {
|
|
const name = uniqueName("aud_role_c").toLowerCase();
|
|
const res = await fetch(`${API_BASE}/api/roles`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({
|
|
name,
|
|
descriptionEn: "audit role create",
|
|
descriptionAr: "تدقيق إنشاء الدور",
|
|
}),
|
|
});
|
|
assert.equal(res.status, 201);
|
|
const created = await res.json();
|
|
createdRoleIds.push(created.id);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "role.create",
|
|
targetId: created.id,
|
|
targetType: "role",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].actor_user_id, adminId);
|
|
assert.equal(rows[0].metadata.name, name);
|
|
assert.equal(rows[0].metadata.descriptionEn, "audit role create");
|
|
assert.equal(rows[0].metadata.descriptionAr, "تدقيق إنشاء الدور");
|
|
});
|
|
|
|
test("PATCH /api/roles/:id (rename) writes one role.update row marked renamed", async () => {
|
|
const role = await insertRole("aud_role_u");
|
|
const newName = uniqueName("aud_role_u_new").toLowerCase();
|
|
|
|
const res = await fetch(`${API_BASE}/api/roles/${role.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ name: newName, descriptionEn: "renamed" }),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "role.update",
|
|
targetId: role.id,
|
|
targetType: "role",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
const row = rows[0];
|
|
assert.equal(row.actor_user_id, adminId);
|
|
assert.equal(row.metadata.previousName, role.name);
|
|
assert.equal(row.metadata.name, newName);
|
|
assert.equal(row.metadata.renamed, true);
|
|
assert.equal(row.metadata.renamedFrom, role.name);
|
|
assert.equal(row.metadata.renamedTo, newName);
|
|
assert.ok(row.metadata.changes, "metadata.changes must be present");
|
|
assert.equal(row.metadata.changes.name, newName);
|
|
assert.equal(row.metadata.changes.descriptionEn, "renamed");
|
|
});
|
|
|
|
test("PATCH /api/roles/:id with no effective changes does NOT write an audit row", async () => {
|
|
const role = await insertRole("aud_role_noop");
|
|
|
|
const res = await fetch(`${API_BASE}/api/roles/${role.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ name: role.name }),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "role.update",
|
|
targetId: role.id,
|
|
targetType: "role",
|
|
});
|
|
assert.equal(rows.length, 0, "no-op patch must not emit role.update");
|
|
});
|
|
|
|
test("DELETE /api/roles/:id (no users/groups) writes one role.delete row", async () => {
|
|
const role = await insertRole("aud_role_d");
|
|
|
|
const res = await fetch(`${API_BASE}/api/roles/${role.id}`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 204);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "role.delete",
|
|
targetId: role.id,
|
|
targetType: "role",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].actor_user_id, adminId);
|
|
assert.equal(rows[0].metadata.roleName, role.name);
|
|
});
|
|
|
|
// ---------- groups ----------
|
|
|
|
test("POST /api/groups writes one group.create row with size counts", async () => {
|
|
const name = uniqueName("aud_grp_c");
|
|
const member = await insertUser("aud_grp_member");
|
|
|
|
const res = await fetch(`${API_BASE}/api/groups`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({
|
|
name,
|
|
descriptionEn: "audit group create",
|
|
userIds: [member.id],
|
|
appIds: [],
|
|
roleIds: [],
|
|
}),
|
|
});
|
|
assert.equal(res.status, 201);
|
|
const created = await res.json();
|
|
createdGroupIds.push(created.id);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "group.create",
|
|
targetId: created.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].actor_user_id, adminId);
|
|
assert.equal(rows[0].metadata.name, name);
|
|
assert.equal(rows[0].metadata.memberCount, 1);
|
|
assert.equal(rows[0].metadata.appCount, 0);
|
|
assert.equal(rows[0].metadata.roleCount, 0);
|
|
});
|
|
|
|
test("PATCH /api/groups/:id (members + apps + roles) writes one aggregated group.update row with diffs", async () => {
|
|
const group = await insertGroup("aud_grp_u");
|
|
const userOld = await insertUser("aud_grp_uOld");
|
|
const userNew = await insertUser("aud_grp_uNew");
|
|
const appOld = await insertApp("aud_grp_aOld");
|
|
const appNew = await insertApp("aud_grp_aNew");
|
|
const roleOld = await insertRole("aud_grp_rOld");
|
|
const roleNew = await insertRole("aud_grp_rNew");
|
|
|
|
// Seed the "previous" associations directly so we can predict the diff.
|
|
await pool.query(
|
|
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
|
|
[userOld.id, group.id],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
|
|
[group.id, appOld.id],
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO group_roles (group_id, role_id) VALUES ($1, $2)`,
|
|
[group.id, roleOld.id],
|
|
);
|
|
|
|
const res = await fetch(`${API_BASE}/api/groups/${group.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({
|
|
userIds: [userNew.id],
|
|
appIds: [appNew.id],
|
|
roleIds: [roleNew.id],
|
|
}),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "group.update",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(rows.length, 1, "aggregate PATCH must emit a single group.update row");
|
|
const meta = rows[0].metadata;
|
|
assert.equal(meta.name, group.name);
|
|
// Each diff is { added: number[], removed: number[] }
|
|
assert.deepEqual(meta.members.added, [userNew.id]);
|
|
assert.deepEqual(meta.members.removed, [userOld.id]);
|
|
assert.deepEqual(meta.apps.added, [appNew.id]);
|
|
assert.deepEqual(meta.apps.removed, [appOld.id]);
|
|
assert.deepEqual(meta.roles.added, [roleNew.id]);
|
|
assert.deepEqual(meta.roles.removed, [roleOld.id]);
|
|
});
|
|
|
|
test("PATCH /api/groups/:id with no diffs does NOT write group.update", async () => {
|
|
const group = await insertGroup("aud_grp_noop");
|
|
|
|
const res = await fetch(`${API_BASE}/api/groups/${group.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({}),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "group.update",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(rows.length, 0);
|
|
});
|
|
|
|
test("POST /api/groups/:id/users/:targetId writes one group.user.add row with username", async () => {
|
|
const group = await insertGroup("aud_sub_uadd");
|
|
const user = await insertUser("aud_sub_uadd_user");
|
|
|
|
const res = await fetch(
|
|
`${API_BASE}/api/groups/${group.id}/users/${user.id}`,
|
|
{ method: "POST", headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(res.status, 204);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "group.user.add",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].metadata.groupName, group.name);
|
|
assert.equal(rows[0].metadata.userId, user.id);
|
|
assert.equal(rows[0].metadata.username, user.username);
|
|
});
|
|
|
|
test("POST then DELETE /api/groups/:id/users/:targetId writes one add row and one group.user.remove row", async () => {
|
|
const group = await insertGroup("aud_sub_uremove");
|
|
const user = await insertUser("aud_sub_uremove_user");
|
|
|
|
const add = await fetch(
|
|
`${API_BASE}/api/groups/${group.id}/users/${user.id}`,
|
|
{ method: "POST", headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(add.status, 204);
|
|
|
|
const addRows = await fetchAuditRows({
|
|
action: "group.user.add",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(addRows.length, 1);
|
|
|
|
const rm = await fetch(
|
|
`${API_BASE}/api/groups/${group.id}/users/${user.id}`,
|
|
{ method: "DELETE", headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(rm.status, 204);
|
|
|
|
const rmRows = await fetchAuditRows({
|
|
action: "group.user.remove",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(rmRows.length, 1);
|
|
assert.equal(rmRows[0].actor_user_id, adminId);
|
|
assert.equal(rmRows[0].target_type, "group");
|
|
assert.equal(rmRows[0].target_id, group.id);
|
|
assert.equal(rmRows[0].metadata.groupName, group.name);
|
|
assert.equal(rmRows[0].metadata.userId, user.id);
|
|
assert.equal(rmRows[0].metadata.username, user.username);
|
|
});
|
|
|
|
test("POST then DELETE /api/groups/:id/apps/:targetId writes one add row and one remove row", async () => {
|
|
const group = await insertGroup("aud_sub_app");
|
|
const app = await insertApp("aud_sub_app_app");
|
|
|
|
const add = await fetch(`${API_BASE}/api/groups/${group.id}/apps/${app.id}`, {
|
|
method: "POST",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(add.status, 204);
|
|
|
|
const addRows = await fetchAuditRows({
|
|
action: "group.app.add",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(addRows.length, 1);
|
|
assert.equal(addRows[0].metadata.groupName, group.name);
|
|
assert.equal(addRows[0].metadata.appId, app.id);
|
|
assert.equal(addRows[0].metadata.appSlug, app.slug);
|
|
|
|
const rm = await fetch(`${API_BASE}/api/groups/${group.id}/apps/${app.id}`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(rm.status, 204);
|
|
|
|
const rmRows = await fetchAuditRows({
|
|
action: "group.app.remove",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(rmRows.length, 1);
|
|
assert.equal(rmRows[0].metadata.appId, app.id);
|
|
assert.equal(rmRows[0].metadata.appSlug, app.slug);
|
|
});
|
|
|
|
test("POST then DELETE /api/groups/:id/roles/:targetId writes one add row and one remove row", async () => {
|
|
const group = await insertGroup("aud_sub_role");
|
|
const role = await insertRole("aud_sub_role_role");
|
|
|
|
const add = await fetch(
|
|
`${API_BASE}/api/groups/${group.id}/roles/${role.id}`,
|
|
{ method: "POST", headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(add.status, 204);
|
|
|
|
const addRows = await fetchAuditRows({
|
|
action: "group.role.add",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(addRows.length, 1);
|
|
assert.equal(addRows[0].metadata.roleId, role.id);
|
|
assert.equal(addRows[0].metadata.roleName, role.name);
|
|
|
|
const rm = await fetch(
|
|
`${API_BASE}/api/groups/${group.id}/roles/${role.id}`,
|
|
{ method: "DELETE", headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(rm.status, 204);
|
|
|
|
const rmRows = await fetchAuditRows({
|
|
action: "group.role.remove",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(rmRows.length, 1);
|
|
assert.equal(rmRows[0].metadata.roleName, role.name);
|
|
});
|
|
|
|
test("DELETE /api/groups/:id (empty group, no force) writes group.delete with force=false and zero counts", async () => {
|
|
const group = await insertGroup("aud_grp_d_nf");
|
|
|
|
const res = await fetch(`${API_BASE}/api/groups/${group.id}`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 204);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "group.delete",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].actor_user_id, adminId);
|
|
assert.equal(rows[0].metadata.name, group.name);
|
|
assert.equal(rows[0].metadata.force, false);
|
|
assert.equal(rows[0].metadata.memberCount, 0);
|
|
assert.equal(rows[0].metadata.appCount, 0);
|
|
assert.equal(rows[0].metadata.roleCount, 0);
|
|
});
|
|
|
|
test("DELETE /api/groups/:id without force on a non-empty group returns 409 and writes NO audit row", async () => {
|
|
const group = await insertGroup("aud_grp_409");
|
|
const member = await insertUser("aud_grp_409_member");
|
|
await pool.query(
|
|
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
|
|
[member.id, group.id],
|
|
);
|
|
|
|
const res = await fetch(`${API_BASE}/api/groups/${group.id}`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 409);
|
|
|
|
const stillThere = await pool.query(`SELECT id FROM groups WHERE id = $1`, [
|
|
group.id,
|
|
]);
|
|
assert.equal(stillThere.rowCount, 1, "group must still exist after 409");
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "group.delete",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(
|
|
rows.length,
|
|
0,
|
|
"denied delete (409) must NOT emit a group.delete audit row",
|
|
);
|
|
});
|
|
|
|
test("DELETE /api/groups/:id?force=true (with members) writes one group.delete with force=true and counts", async () => {
|
|
const group = await insertGroup("aud_grp_d_f");
|
|
const member = await insertUser("aud_grp_d_f_member");
|
|
await pool.query(
|
|
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`,
|
|
[member.id, group.id],
|
|
);
|
|
|
|
const res = await fetch(`${API_BASE}/api/groups/${group.id}?force=true`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 204);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "group.delete",
|
|
targetId: group.id,
|
|
targetType: "group",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].metadata.force, true);
|
|
assert.equal(rows[0].metadata.memberCount, 1);
|
|
});
|
|
|
|
// ---------- apps ----------
|
|
|
|
test("POST /api/apps writes one app.create row", async () => {
|
|
const slug = uniqueName("aud_app_c").toLowerCase();
|
|
const res = await fetch(`${API_BASE}/api/apps`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({
|
|
slug,
|
|
nameAr: "تطبيق",
|
|
nameEn: "Audit Create App",
|
|
iconName: "Box",
|
|
route: "/aud-c",
|
|
color: "#fff",
|
|
}),
|
|
});
|
|
assert.equal(res.status, 201);
|
|
const created = await res.json();
|
|
createdAppIds.push(created.id);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "app.create",
|
|
targetId: created.id,
|
|
targetType: "app",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].metadata.slug, slug);
|
|
assert.equal(rows[0].metadata.nameEn, "Audit Create App");
|
|
assert.equal(rows[0].metadata.route, "/aud-c");
|
|
});
|
|
|
|
test("PATCH /api/apps/:id (real change) writes one app.update row with from/to changes", async () => {
|
|
const app = await insertApp("aud_app_u");
|
|
|
|
const res = await fetch(`${API_BASE}/api/apps/${app.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ nameEn: "Audit App Renamed" }),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "app.update",
|
|
targetId: app.id,
|
|
targetType: "app",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].metadata.slug, app.slug);
|
|
assert.equal(rows[0].metadata.changes.nameEn.from, "Audit App");
|
|
assert.equal(rows[0].metadata.changes.nameEn.to, "Audit App Renamed");
|
|
});
|
|
|
|
test("PATCH /api/apps/:id with same value does NOT write app.update", async () => {
|
|
const app = await insertApp("aud_app_noop");
|
|
|
|
const res = await fetch(`${API_BASE}/api/apps/${app.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ nameEn: "Audit App" }),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "app.update",
|
|
targetId: app.id,
|
|
targetType: "app",
|
|
});
|
|
assert.equal(rows.length, 0);
|
|
});
|
|
|
|
test("DELETE /api/apps/:id (no deps) writes app.delete with force=false and zero counts", async () => {
|
|
const app = await insertApp("aud_app_d_nf");
|
|
|
|
const res = await fetch(`${API_BASE}/api/apps/${app.id}`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 204);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "app.delete",
|
|
targetId: app.id,
|
|
targetType: "app",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].metadata.appSlug, app.slug);
|
|
assert.equal(rows[0].metadata.appNameEn, "Audit App");
|
|
assert.equal(rows[0].metadata.appNameAr, "تطبيق تدقيق");
|
|
assert.equal(rows[0].metadata.force, false);
|
|
assert.equal(rows[0].metadata.groupCount, 0);
|
|
assert.equal(rows[0].metadata.restrictionCount, 0);
|
|
assert.equal(rows[0].metadata.openCount, 0);
|
|
});
|
|
|
|
test("DELETE /api/apps/:id without force on an app with deps returns 409 and writes NO audit row", async () => {
|
|
const app = await insertApp("aud_app_409");
|
|
const group = await insertGroup("aud_app_409_grp");
|
|
await pool.query(
|
|
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
|
|
[group.id, app.id],
|
|
);
|
|
|
|
const res = await fetch(`${API_BASE}/api/apps/${app.id}`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 409);
|
|
|
|
const stillThere = await pool.query(`SELECT id FROM apps WHERE id = $1`, [
|
|
app.id,
|
|
]);
|
|
assert.equal(stillThere.rowCount, 1, "app must still exist after 409");
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "app.delete",
|
|
targetId: app.id,
|
|
targetType: "app",
|
|
});
|
|
assert.equal(
|
|
rows.length,
|
|
0,
|
|
"denied delete (409) must NOT emit an app.delete audit row",
|
|
);
|
|
});
|
|
|
|
test("DELETE /api/apps/:id?force=true (with deps) writes app.delete with force=true and dep counts", async () => {
|
|
const app = await insertApp("aud_app_d_f");
|
|
const group = await insertGroup("aud_app_d_f_grp");
|
|
await pool.query(
|
|
`INSERT INTO group_apps (group_id, app_id) VALUES ($1, $2)`,
|
|
[group.id, app.id],
|
|
);
|
|
await pool.query(`INSERT INTO app_opens (user_id, app_id) VALUES ($1, $2)`, [
|
|
adminId,
|
|
app.id,
|
|
]);
|
|
|
|
const res = await fetch(`${API_BASE}/api/apps/${app.id}?force=true`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 204);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "app.delete",
|
|
targetId: app.id,
|
|
targetType: "app",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].metadata.force, true);
|
|
assert.equal(rows[0].metadata.groupCount, 1);
|
|
assert.equal(rows[0].metadata.openCount, 1);
|
|
});
|
|
|
|
// ---------- services ----------
|
|
|
|
test("DELETE /api/services/:id (no deps, no force) writes one service.delete row with nameEn and nameAr", async () => {
|
|
// #195: a clean delete (no orders) used to leave no audit trail at all.
|
|
// It must now write a `service.delete` row carrying both names so the
|
|
// audit log can render the service name in either locale after deletion.
|
|
const svc = await insertService("aud_svc_d_nf");
|
|
|
|
const res = await fetch(`${API_BASE}/api/services/${svc.id}`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 204);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "service.delete",
|
|
targetId: svc.id,
|
|
targetType: "service",
|
|
});
|
|
assert.equal(rows.length, 1, "expected exactly one service.delete audit row");
|
|
const row = rows[0];
|
|
assert.equal(row.actor_user_id, adminId);
|
|
assert.equal(row.metadata.nameEn, svc.nameEn);
|
|
assert.equal(row.metadata.nameAr, svc.nameAr);
|
|
// No-deps path must NOT include the dependency counts that
|
|
// `service.force_delete` carries.
|
|
assert.equal(row.metadata.orderCount, undefined);
|
|
|
|
// The forced action must not be emitted on the clean path.
|
|
const forced = await fetchAuditRows({
|
|
action: "service.force_delete",
|
|
targetId: svc.id,
|
|
targetType: "service",
|
|
});
|
|
assert.equal(forced.length, 0);
|
|
});
|
|
|
|
test("DELETE /api/services/:id without force on a service with deps returns 409 and writes NO audit row", async () => {
|
|
const svc = await insertService("aud_svc_409");
|
|
await pool.query(
|
|
`INSERT INTO service_orders (user_id, service_id, status) VALUES ($1, $2, 'pending')`,
|
|
[adminId, svc.id],
|
|
);
|
|
|
|
const res = await fetch(`${API_BASE}/api/services/${svc.id}`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 409);
|
|
|
|
const stillThere = await pool.query(
|
|
`SELECT id FROM services WHERE id = $1`,
|
|
[svc.id],
|
|
);
|
|
assert.equal(stillThere.rowCount, 1, "service must still exist after 409");
|
|
|
|
const deleteRows = await fetchAuditRows({
|
|
action: "service.delete",
|
|
targetId: svc.id,
|
|
targetType: "service",
|
|
});
|
|
const forceRows = await fetchAuditRows({
|
|
action: "service.force_delete",
|
|
targetId: svc.id,
|
|
targetType: "service",
|
|
});
|
|
assert.equal(
|
|
deleteRows.length + forceRows.length,
|
|
0,
|
|
"denied delete (409) must NOT emit any service audit row",
|
|
);
|
|
});
|
|
|
|
test("DELETE /api/services/:id?force=true (with deps) writes one service.force_delete row with orderCount", async () => {
|
|
const svc = await insertService("aud_svc_d_f");
|
|
await pool.query(
|
|
`INSERT INTO service_orders (user_id, service_id, status) VALUES ($1, $2, 'pending'), ($1, $2, 'completed')`,
|
|
[adminId, svc.id],
|
|
);
|
|
|
|
const res = await fetch(`${API_BASE}/api/services/${svc.id}?force=true`, {
|
|
method: "DELETE",
|
|
headers: { Cookie: adminCookie },
|
|
});
|
|
assert.equal(res.status, 204);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "service.force_delete",
|
|
targetId: svc.id,
|
|
targetType: "service",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
const row = rows[0];
|
|
assert.equal(row.actor_user_id, adminId);
|
|
assert.equal(row.metadata.nameEn, svc.nameEn);
|
|
assert.equal(row.metadata.nameAr, svc.nameAr);
|
|
assert.equal(row.metadata.orderCount, 2);
|
|
|
|
// Forced path must not also emit the plain `service.delete` row.
|
|
const plain = await fetchAuditRows({
|
|
action: "service.delete",
|
|
targetId: svc.id,
|
|
targetType: "service",
|
|
});
|
|
assert.equal(plain.length, 0);
|
|
});
|
|
|
|
// ---------- auth: password reset link ----------
|
|
|
|
test("POST /api/auth/admin/users/:id/issue-reset-link writes one auth.issue_reset_link row", async () => {
|
|
const target = await insertUser("aud_reset");
|
|
|
|
const res = await fetch(
|
|
`${API_BASE}/api/auth/admin/users/${target.id}/issue-reset-link`,
|
|
{ method: "POST", headers: { Cookie: adminCookie } },
|
|
);
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.ok(body.resetUrl);
|
|
assert.ok(body.expiresAt);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "auth.issue_reset_link",
|
|
targetId: target.id,
|
|
targetType: "user",
|
|
});
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].actor_user_id, adminId);
|
|
assert.equal(rows[0].metadata.username, target.username);
|
|
assert.equal(rows[0].metadata.email, `${target.username}@example.com`);
|
|
assert.equal(rows[0].metadata.expiresAt, body.expiresAt);
|
|
});
|
|
|
|
// ---------- settings ----------
|
|
|
|
test("PATCH /api/settings with a real change writes one settings.update row", async () => {
|
|
// Read current value, change it, observe the audit row, then restore.
|
|
const before = await pool.query(
|
|
`SELECT site_name_en FROM app_settings WHERE id = 1`,
|
|
);
|
|
const previous = before.rows[0]?.site_name_en ?? "Tx OS";
|
|
const next = `Audit ${STAMP}`;
|
|
|
|
const res = await fetch(`${API_BASE}/api/settings`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ siteNameEn: next }),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "settings.update",
|
|
targetId: 1,
|
|
targetType: "settings",
|
|
});
|
|
assert.equal(rows.length, 1, "exactly one settings.update row for a real change");
|
|
assert.equal(rows[0].actor_user_id, adminId);
|
|
assert.equal(rows[0].metadata.changes.siteNameEn.from, previous);
|
|
assert.equal(rows[0].metadata.changes.siteNameEn.to, next);
|
|
|
|
// Clean up the audit row produced by this test so the next test starts
|
|
// from zero settings.update rows for our admin actor.
|
|
await pool.query(
|
|
`DELETE FROM audit_logs WHERE actor_user_id = $1 AND action = 'settings.update'`,
|
|
[adminId],
|
|
);
|
|
|
|
// Restore the original value via the API; that emits another audit row,
|
|
// which we also clean up so a later run is unaffected.
|
|
const restore = await fetch(`${API_BASE}/api/settings`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ siteNameEn: previous }),
|
|
});
|
|
assert.equal(restore.status, 200);
|
|
await pool.query(
|
|
`DELETE FROM audit_logs WHERE actor_user_id = $1 AND action = 'settings.update'`,
|
|
[adminId],
|
|
);
|
|
});
|
|
|
|
test("PATCH /api/settings with no effective change does NOT write settings.update", async () => {
|
|
const before = await pool.query(
|
|
`SELECT site_name_en FROM app_settings WHERE id = 1`,
|
|
);
|
|
const current = before.rows[0]?.site_name_en ?? "Tx OS";
|
|
|
|
// PATCHing with the value that's already there must be a no-op.
|
|
const res = await fetch(`${API_BASE}/api/settings`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
|
body: JSON.stringify({ siteNameEn: current }),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
|
|
const rows = await fetchAuditRows({
|
|
action: "settings.update",
|
|
targetId: 1,
|
|
targetType: "settings",
|
|
});
|
|
assert.equal(
|
|
rows.length,
|
|
0,
|
|
"no-op settings PATCH must not emit a settings.update audit row",
|
|
);
|
|
});
|