Files
Termix/src/backend/tests/database/db/audit-log-user-id-nullable.test.ts
T

115 lines
3.8 KiB
TypeScript

import fs from "fs";
import os from "os";
import path from "path";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* The audit trail outlives the account it belongs to: deleting a user
* anonymises their entries by nulling `user_id` and leaving `username` behind.
*
* The Drizzle schema said so, the repository was written against it, but the
* runtime bootstrap still created `user_id TEXT NOT NULL`. A second, corrected
* `CREATE TABLE IF NOT EXISTS` further down was a no-op, the table already
* existed, so every fresh install got the old constraint and every user
* deletion (including the OIDC account-link cleanup) failed once the account
* had logged in at least once.
*/
describe("audit_logs.user_id is nullable", () => {
let dataDir: string;
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-audit-schema-"));
vi.resetModules();
process.env.DATA_DIR = dataDir;
process.env.DB_FILE_ENCRYPTION = "false";
process.env.ALLOW_EMPTY_DATA_DIR = "true";
});
afterEach(() => {
delete process.env.DATA_DIR;
delete process.env.DB_FILE_ENCRYPTION;
delete process.env.ALLOW_EMPTY_DATA_DIR;
fs.rmSync(dataDir, { recursive: true, force: true });
});
function userIdIsNotNull(sqlite: Database.Database): boolean {
const columns = sqlite.prepare("PRAGMA table_info(audit_logs)").all() as Array<{
name: string;
notnull: number;
}>;
return columns.find((col) => col.name === "user_id")?.notnull === 1;
}
it("lets a fresh install outlive the account it logged", async () => {
const db = await import("../../../database/db/index.js");
await db.initializeDatabase();
const sqlite = db.getSqlite();
expect(userIdIsNotNull(sqlite)).toBe(false);
sqlite
.prepare("INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)")
.run("user-1", "alice", "hash");
sqlite
.prepare(
`INSERT INTO audit_logs (user_id, username, action, resource_type, success)
VALUES (?, ?, ?, ?, ?)`,
)
.run("user-1", "alice", "login", "auth", 1);
expect(() => sqlite.prepare("DELETE FROM users WHERE id = ?").run("user-1")).not.toThrow();
const row = sqlite.prepare("SELECT user_id, username FROM audit_logs").get() as {
user_id: string | null;
username: string;
};
// The reference goes, the attribution stays.
expect(row.user_id).toBeNull();
expect(row.username).toBe("alice");
});
it("rebuilds an existing table that still has the constraint, keeping its rows", async () => {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
resource_name TEXT,
details TEXT,
ip_address TEXT,
user_agent TEXT,
success INTEGER NOT NULL,
error_message TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
seed
.prepare(
`INSERT INTO audit_logs (user_id, username, action, resource_type, success)
VALUES (?, ?, ?, ?, ?)`,
)
.run("user-1", "alice", "login", "auth", 1);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
const db = await import("../../../database/db/index.js");
await db.initializeDatabase();
const sqlite = db.getSqlite();
expect(userIdIsNotNull(sqlite)).toBe(false);
const row = sqlite.prepare("SELECT user_id, username FROM audit_logs").get() as {
user_id: string | null;
username: string;
};
expect(row.user_id).toBe("user-1");
expect(row.username).toBe("alice");
});
});