import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import Database from "better-sqlite3"; import type { AppDatabase } from "../src/db/database.js"; import { migrate } from "../src/db/migrate.js"; import { createSessionRepository, createMessageRepository } from "../src/sessions/repository.js"; import { createWebhookAuditRepository } from "../src/webhooks/audit.js"; import { runWebhookAuditPurge } from "../src/webhooks/audit.js"; let db: AppDatabase; let dbPath: string; beforeEach(() => { const dir = join(tmpdir(), `sic-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(dir, { recursive: true }); dbPath = join(dir, "test.db"); db = new Database(dbPath); db.pragma("foreign_keys = ON"); migrate(db); }); afterEach(() => { db.close(); if (existsSync(dbPath)) rmSync(dbPath, { force: true }); }); describe("session isolation", () => { it("never returns sessions/messages from another user", () => { const sessions = createSessionRepository(db); const messages = createMessageRepository(db); const a = sessions.create("user-a", "A session"); const b = sessions.create("user-b", "B session"); messages.create({ sessionId: a.id, userId: "user-a", role: "user", content: "a msg" }); messages.create({ sessionId: b.id, userId: "user-b", role: "user", content: "b msg" }); // List filter expect(sessions.list("user-a").map((s) => s.id)).toEqual([a.id]); expect(sessions.list("user-b").map((s) => s.id)).toEqual([b.id]); // get() requires matching user_id expect(sessions.get("user-a", b.id)).toBeFalsy(); expect(sessions.get("user-b", a.id)).toBeFalsy(); // Messages filter by both session_id and user_id const bMessages = messages.listForSession("user-a", b.id); expect(bMessages).toEqual([]); const aMessages = messages.listForSession("user-a", a.id); expect(aMessages).toHaveLength(1); expect(aMessages[0]?.content).toBe("a msg"); }); it("delete cascades to messages", () => { const sessions = createSessionRepository(db); const messages = createMessageRepository(db); const s = sessions.create("user-a", null); const m = messages.create({ sessionId: s.id, userId: "user-a", role: "user", content: "will be cascaded", }); sessions.delete("user-a", s.id); expect(messages.listForSession("user-a", s.id)).toEqual([]); // Direct DB check that the message row is gone (not just hidden) const row = db.prepare("SELECT id FROM chat_messages WHERE id = ?").get(m.id); expect(row).toBeUndefined(); }); it("updateTitle only affects the owner's session", () => { const sessions = createSessionRepository(db); const a = sessions.create("user-a", "Old"); sessions.updateTitle("user-a", a.id, "New"); expect(sessions.get("user-a", a.id)?.title).toBe("New"); }); }); describe("webhook audit + retention", () => { it("usageForUserSince aggregates per webhook", () => { const sessions = createSessionRepository(db); const audit = createWebhookAuditRepository(db); const s1 = sessions.create("user-a", "test"); const s2 = sessions.create("user-b", "test"); const now = Date.now(); const fresh = new Date(now - 1_000).toISOString(); const old = new Date(now - 100 * 86_400_000).toISOString(); audit.create({ webhookId: "dns-flush", userId: "user-a", sessionId: s1.id, status: "success", createdAt: fresh }); audit.create({ webhookId: "dns-flush", userId: "user-a", sessionId: s1.id, status: "success", createdAt: fresh }); audit.create({ webhookId: "dns-flush", userId: "user-a", sessionId: s1.id, status: "error", createdAt: fresh }); audit.create({ webhookId: "dns-flush", userId: "user-a", sessionId: s1.id, status: "success", createdAt: old }); audit.create({ webhookId: "other-hook", userId: "user-b", sessionId: s2.id, status: "success", createdAt: fresh }); const since = new Date(now - 7 * 86_400_000).toISOString(); const usage = audit.usageForUserSince(since, "user-a"); expect(usage["dns-flush"]?.runs).toBe(3); expect(usage["dns-flush"]?.successes).toBe(2); expect(usage["dns-flush"]?.successRate).toBeCloseTo(2 / 3); expect(usage["other-hook"]).toBeUndefined(); }); it("retention purge deletes old rows but keeps recent ones", () => { const sessions = createSessionRepository(db); const audit = createWebhookAuditRepository(db); const s = sessions.create("user-a", "test"); const now = Date.now(); const fresh = new Date(now - 60_000).toISOString(); const stale = new Date(now - 100 * 86_400_000).toISOString(); audit.create({ webhookId: "w", userId: "user-a", sessionId: s.id, status: "success", createdAt: fresh }); audit.create({ webhookId: "w", userId: "user-a", sessionId: s.id, status: "success", createdAt: stale }); audit.create({ webhookId: "w", userId: "user-a", sessionId: s.id, status: "success", createdAt: stale }); const report = runWebhookAuditPurge(db, { retentionDays: 30, maxPerUser: 0 }); expect(report.deletedByAge).toBe(2); const remaining = db.prepare("SELECT COUNT(*) as n FROM webhook_runs").get() as { n: number }; expect(remaining.n).toBe(1); }); it("per-user cap keeps the most recent N", () => { const sessions = createSessionRepository(db); const audit = createWebhookAuditRepository(db); const s = sessions.create("user-a", "test"); const now = Date.now(); for (let i = 0; i < 8; i++) { const ts = new Date(now - i * 1000).toISOString(); audit.create({ webhookId: "w", userId: "user-a", sessionId: s.id, status: "success", createdAt: ts }); } const report = runWebhookAuditPurge(db, { retentionDays: 0, maxPerUser: 3 }); expect(report.deletedByCap).toBe(5); const remaining = db.prepare("SELECT COUNT(*) as n FROM webhook_runs").get() as { n: number }; expect(remaining.n).toBe(3); }); it("listForSession enforces user_id", () => { const sessions = createSessionRepository(db); const audit = createWebhookAuditRepository(db); const sa = sessions.create("user-a", "test"); const sb = sessions.create("user-b", "test"); audit.create({ webhookId: "w", userId: "user-a", sessionId: sa.id, status: "success" }); audit.create({ webhookId: "w", userId: "user-b", sessionId: sb.id, status: "success" }); expect(audit.listForSession("user-a", sa.id)).toHaveLength(1); expect(audit.listForSession("user-b", sb.id)).toHaveLength(1); expect(audit.listForSession("user-a", sa.id)[0]?.user_id).toBe("user-a"); }); });