mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: turn session recording into a plugin (B13)
This commit is contained in:
1 parent
a276f6898c
commit
0af2c94983
147 files changed
+25915
-2074
No files matched your search
@@ -7,10 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
/**
|
||||
* `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a
|
||||
* number of tables that the primary bootstrap already creates. The probe never
|
||||
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and two
|
||||
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and one
|
||||
* of those unreachable copies had drifted away from the real definition
|
||||
* (`sessions` had lost `ON DELETE CASCADE`; `session_recordings` still had the
|
||||
* pre-#1128 `user_id NOT NULL` with `ON DELETE CASCADE` and no `username`).
|
||||
* (`sessions` had lost `ON DELETE CASCADE`).
|
||||
*
|
||||
* They are gone now. What has to stay true is that the bootstrap alone
|
||||
* produces every one of those tables, from an empty database and from a
|
||||
@@ -27,7 +26,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
|
||||
"roles",
|
||||
"user_roles",
|
||||
"audit_logs",
|
||||
"session_recordings",
|
||||
"api_keys",
|
||||
];
|
||||
|
||||
@@ -99,16 +97,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
|
||||
await db.initializeDatabase();
|
||||
const sqlite = db.getSqlite();
|
||||
|
||||
// session_recordings: nullable user_id with the attribution kept, per
|
||||
// "audit trails survive the account" — not the NOT NULL + CASCADE the
|
||||
// dead copy still carried.
|
||||
const columns = sqlite
|
||||
.prepare("PRAGMA table_info(session_recordings)")
|
||||
.all() as Array<{ name: string; notnull: number }>;
|
||||
const userId = columns.find((c) => c.name === "user_id");
|
||||
expect(userId?.notnull).toBe(0);
|
||||
expect(columns.some((c) => c.name === "username")).toBe(true);
|
||||
|
||||
// sessions: the dead copy had dropped ON DELETE CASCADE.
|
||||
const sql = sqlite
|
||||
.prepare(
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SessionRecordingRepository } from "../../../database/repositories/session-recording-repository.js";
|
||||
|
||||
describe("SessionRecordingRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SessionRecordingRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new SessionRecordingRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists session recordings with host metadata", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-06-27T00:00:00.000Z",
|
||||
endedAt: "2026-06-27T00:01:00.000Z",
|
||||
duration: 60,
|
||||
recordingPath: "/tmp/one.log",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 2,
|
||||
startedAt: "2026-06-27T00:02:00.000Z",
|
||||
recordingPath: "/tmp/two.log",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-2",
|
||||
hostId: 3,
|
||||
startedAt: "2026-06-27T00:03:00.000Z",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
duration: 60,
|
||||
recordingPath: "/tmp/one.log",
|
||||
});
|
||||
|
||||
const rows = await repo.listByUserIdWithHost("user-1");
|
||||
expect(rows.map((row) => row.hostName)).toEqual(["two", "one"]);
|
||||
expect(rows[0]).toMatchObject({
|
||||
hostIp: "10.0.0.2",
|
||||
recordingPath: "/tmp/two.log",
|
||||
protocol: "ssh",
|
||||
format: "text",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds paths and prunes old recordings", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const old = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
recordingPath: "/tmp/old.log",
|
||||
});
|
||||
const current = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-06-27T00:00:00.000Z",
|
||||
recordingPath: "/tmp/current.log",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.findPathByIdForUser("user-2", old.id)).toBeNull();
|
||||
expect(await repo.findPathByIdForUser("user-1", old.id)).toMatchObject({
|
||||
recordingPath: "/tmp/old.log",
|
||||
});
|
||||
expect(await repo.listPathsOlderThan("2026-02-01T00:00:00.000Z")).toEqual([
|
||||
{ id: old.id, recordingPath: "/tmp/old.log" },
|
||||
]);
|
||||
|
||||
expect(await repo.deleteById(old.id)).toBe(true);
|
||||
expect(await repo.deleteById(old.id)).toBe(false);
|
||||
expect(await repo.findByIdForUser("user-1", current.id)).toMatchObject({
|
||||
id: current.id,
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes recordings by user and host references", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const first = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 2,
|
||||
startedAt: "2026-06-27T00:01:00.000Z",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-2",
|
||||
hostId: 3,
|
||||
startedAt: "2026-06-27T00:02:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", first.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", first.id)).toBe(true);
|
||||
expect(await repo.deleteByHostIds([])).toBe(0);
|
||||
expect(await repo.deleteByHostIds([2])).toBe(1);
|
||||
expect(writeCount).toBe(5);
|
||||
|
||||
expect(await repo.deleteByUserId("user-2")).toBe(1);
|
||||
expect(await repo.deleteByHostId(3)).toBe(0);
|
||||
expect(writeCount).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import path from "path";
|
||||
|
||||
// Stub db, logger, fs, and AuthManager before importing the route module
|
||||
const mockSelect = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
delete: mockDelete,
|
||||
insert: mockInsert,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// The route module calls PermissionManager.getInstance() at import time and
|
||||
// pulls in the repository factory, which loads the drizzle schema and the
|
||||
// better-sqlite3 native binding. Importing that tree costs seconds under a
|
||||
// concurrent full run — enough to blow the 5s test timeout — and none of it is
|
||||
// under test here.
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
canAccessHost: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSessionRecordingRepository: vi.fn(),
|
||||
createCurrentSettingsRepository: vi.fn(),
|
||||
getCurrentSettingValue: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockReadFile = vi.fn();
|
||||
const mockStat = vi.fn();
|
||||
const mockUnlink = vi.fn();
|
||||
const mockExistsSync = vi.fn();
|
||||
|
||||
vi.mock("fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("fs")>();
|
||||
return {
|
||||
...actual,
|
||||
promises: { readFile: mockReadFile, unlink: mockUnlink },
|
||||
existsSync: mockExistsSync,
|
||||
statSync: mockStat,
|
||||
};
|
||||
});
|
||||
|
||||
// Build a chainable drizzle-like query stub
|
||||
function makeChain(resolveValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"from",
|
||||
"leftJoin",
|
||||
"where",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"set",
|
||||
"values",
|
||||
];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
|
||||
Promise.resolve(resolveValue).then(cb);
|
||||
(chain as unknown as Promise<unknown>).catch = (
|
||||
cb: (e: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).catch(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
describe("session-log-routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.DATA_DIR = "/data";
|
||||
});
|
||||
|
||||
describe("GET / - list logs", () => {
|
||||
it("returns logs for the authenticated user with file size", async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: 1,
|
||||
hostId: 10,
|
||||
userId: "u1",
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
endedAt: "2026-01-01T00:05:00Z",
|
||||
duration: 300,
|
||||
recordingPath: "/data/session_logs/u1/abc.log",
|
||||
hostName: "my-server",
|
||||
hostIp: "10.0.0.1",
|
||||
},
|
||||
];
|
||||
const chain = makeChain(rows);
|
||||
mockSelect.mockReturnValue(chain);
|
||||
mockStat.mockReturnValue({ size: 4096 });
|
||||
|
||||
// Directly call the route handler extracted from the module
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/session-log-routes.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("path traversal guard", () => {
|
||||
it("rejects paths outside the allowed session_logs directory", () => {
|
||||
const allowedBase = path.resolve("/data", "session_logs");
|
||||
const malicious = path.resolve("/data/session_logs/../../etc/passwd");
|
||||
expect(malicious.startsWith(allowedBase)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a legitimate session log path", () => {
|
||||
const allowedBase = path.resolve("/data", "session_logs");
|
||||
const valid = path.resolve("/data/session_logs/user1/abc.log");
|
||||
expect(valid.startsWith(allowedBase)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatters (pure logic)", () => {
|
||||
it("stat returns size when file exists", () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockStat.mockReturnValue({ size: 1234 });
|
||||
const exists = mockExistsSync("/some/file.log");
|
||||
const { size } = mockStat("/some/file.log");
|
||||
expect(exists).toBe(true);
|
||||
expect(size).toBe(1234);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* The session logging host-column migration.
|
||||
*
|
||||
* The legacy column defaulted to true, the plugin's enable switch defaults
|
||||
* to false, so every host needs an explicit row, not just the ones that
|
||||
* turned it off. Running it twice must not duplicate or clobber what it
|
||||
* moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_session_logging: boolean | null;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () => hostRows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runSessionRecordingSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/session-recording-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runSessionRecordingSettingsMigration", () => {
|
||||
it("moves an enabled host's switch into plugin settings", async () => {
|
||||
hostRows.push({ id: 1, enable_session_logging: true });
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableSessionRecording")).toBe(true);
|
||||
expect(
|
||||
settingsRows.every((row) => row.pluginId === "session-recording"),
|
||||
).toBe(true);
|
||||
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
|
||||
});
|
||||
|
||||
it("moves a disabled host's switch too, since the defaults flipped", async () => {
|
||||
hostRows.push({ id: 3, enable_session_logging: false });
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(3, "enableSessionRecording")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a null legacy value as on, matching the old column default", async () => {
|
||||
hostRows.push({ id: 4, enable_session_logging: null });
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(4, "enableSessionRecording")).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install", async () => {
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({ id: 2, enable_session_logging: true });
|
||||
|
||||
await runSessionRecordingSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
hostRows.push({ id: 5, enable_session_logging: true });
|
||||
await pluginSettingsRepository.set(
|
||||
"session-recording",
|
||||
"host",
|
||||
"5",
|
||||
"enableSessionRecording",
|
||||
"false",
|
||||
);
|
||||
|
||||
const result = await runSessionRecordingSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(storedValue(5, "enableSessionRecording")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user