mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
115 lines
3.8 KiB
TypeScript
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");
|
|
});
|
|
});
|