mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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
@@ -0,0 +1,128 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export function host(
|
||||
overrides: Partial<PluginHostSummary> = {},
|
||||
): PluginHostSummary {
|
||||
return {
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
name: "web",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
requestBinary: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string },
|
||||
) => Promise<{ status: number; body: ArrayBuffer; contentType: string }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
users?: string[];
|
||||
hosts?: PluginHostSummary[];
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
for (const h of options.hosts ?? [host()]) {
|
||||
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(h.id);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["session-recording.view"],
|
||||
hosts: options.hosts ?? [host()],
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async requestBinary(method, path, { user = "user-1" } = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: { "x-test-user": user },
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
body: await response.arrayBuffer(),
|
||||
contentType: response.headers.get("content-type") ?? "",
|
||||
};
|
||||
},
|
||||
async close() {
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// session_recordings as core's SQLite bootstrap created it before 2.9.0.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE session_recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
username TEXT,
|
||||
access_id INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
duration INTEGER,
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
`;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tableExists = (name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name);
|
||||
|
||||
describe("adopting session_recordings", () => {
|
||||
it("keeps every existing row when the legacy table is there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO session_recordings (host_id, user_id, username, recording_path, protocol, format) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
7,
|
||||
"user-1",
|
||||
"user-1",
|
||||
"/data/session_logs/user-1/a.cast",
|
||||
"ssh",
|
||||
"asciicast",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_session_recordings"]);
|
||||
expect(tableExists("session_recordings")).toBe(false);
|
||||
expect(tableExists("p_session_recording_session_recordings")).toBe(true);
|
||||
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT host_id, user_id, recording_path, protocol, format FROM p_session_recording_session_recordings",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
host_id: 7,
|
||||
user_id: "user-1",
|
||||
recording_path: "/data/session_logs/user-1/a.cast",
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the table fresh when there is no legacy one", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tableExists("session_recordings")).toBe(false);
|
||||
expect(tableExists("p_session_recording_session_recordings")).toBe(true);
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT * FROM p_session_recording_session_recordings")
|
||||
.all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("cascades on host delete", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
|
||||
},
|
||||
});
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_session_recording_session_recordings (host_id, user_id, protocol, format) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run(7, "user-1", "ssh", "asciicast");
|
||||
|
||||
db.sqlite.exec("DELETE FROM ssh_data WHERE id = 7");
|
||||
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT * FROM p_session_recording_session_recordings")
|
||||
.all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a row whose user was deleted, with userId cleared", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
|
||||
},
|
||||
});
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_session_recording_session_recordings (host_id, user_id, protocol, format) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run(7, "user-1", "ssh", "asciicast");
|
||||
|
||||
// userId is a plain column, not refUser(): deleting the user must not
|
||||
// cascade delete the row (the plugin clears userId itself on
|
||||
// user.deleted instead).
|
||||
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
|
||||
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare("SELECT host_id FROM p_session_recording_session_recordings")
|
||||
.all(),
|
||||
).toEqual([{ host_id: 7 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
import { createSessionRecordingRepository } from "../../src/backend/repository";
|
||||
import { sessionRecordings } from "../../src/backend/tables";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const fakeHosts = {
|
||||
list: async () => [
|
||||
{
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
name: "web",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
},
|
||||
],
|
||||
get: async () => null,
|
||||
checkAccess: async () => ({
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: false,
|
||||
}),
|
||||
create: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
update: async () => null,
|
||||
listOwned: async () => [],
|
||||
share: async () => ({ hostId: 0, shared: false }),
|
||||
listUsers: async () => [],
|
||||
listRoles: async () => [],
|
||||
trackSession: () => () => {},
|
||||
recordActivity: async () => {},
|
||||
};
|
||||
|
||||
async function setup() {
|
||||
db = await createTestDb(pluginDir);
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run("user-1", "alice");
|
||||
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(7);
|
||||
const table = await db.database.define(sessionRecordings);
|
||||
return createSessionRecordingRepository(db.database, table, fakeHosts);
|
||||
}
|
||||
|
||||
describe("session recording repository", () => {
|
||||
it("creates a row and reads it back by recordingPath", async () => {
|
||||
const repo = await setup();
|
||||
const row = await repo.create({
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
username: "alice",
|
||||
startedAt: new Date().toISOString(),
|
||||
recordingPath: "/data/session_logs/user-1/a.cast",
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
});
|
||||
|
||||
expect(row.id).toBeGreaterThan(0);
|
||||
expect(row.recordingPath).toBe("/data/session_logs/user-1/a.cast");
|
||||
expect(db!.persisted).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lists a user's recordings with host name and ip attached", async () => {
|
||||
const repo = await setup();
|
||||
await repo.create({
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
startedAt: new Date().toISOString(),
|
||||
recordingPath: "/data/session_logs/user-1/a.cast",
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
});
|
||||
|
||||
const rows = await repo.listByUserIdWithHost("user-1");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].hostName).toBe("web");
|
||||
expect(rows[0].hostIp).toBe("10.0.0.5");
|
||||
});
|
||||
|
||||
it("finds and deletes only the owner's row", async () => {
|
||||
const repo = await setup();
|
||||
const row = await repo.create({
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
startedAt: new Date().toISOString(),
|
||||
recordingPath: "/data/session_logs/user-1/a.cast",
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
});
|
||||
|
||||
expect(await repo.findByIdForUser("user-2", row.id)).toBeNull();
|
||||
expect(await repo.deleteForUser("user-2", row.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", row.id)).toBe(true);
|
||||
expect(await repo.findByIdForUser("user-1", row.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("anonymizes a user's rows instead of deleting them", async () => {
|
||||
const repo = await setup();
|
||||
const row = await repo.create({
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
startedAt: new Date().toISOString(),
|
||||
recordingPath: "/data/session_logs/user-1/a.cast",
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
});
|
||||
|
||||
await repo.anonymizeByUserId("user-1");
|
||||
|
||||
const rows = await repo.listPathsOlderThan(
|
||||
new Date(Date.now() + 60_000).toISOString(),
|
||||
);
|
||||
expect(rows.map((r) => r.id)).toContain(row.id);
|
||||
expect(await repo.findByIdForUser("user-1", row.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("lists recordings older than a cutoff for retention pruning", async () => {
|
||||
const repo = await setup();
|
||||
await repo.create({
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
startedAt: "2020-01-01T00:00:00.000Z",
|
||||
recordingPath: "/data/session_logs/user-1/old.cast",
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
});
|
||||
|
||||
const old = await repo.listPathsOlderThan("2021-01-01T00:00:00.000Z");
|
||||
expect(old).toHaveLength(1);
|
||||
|
||||
const none = await repo.listPathsOlderThan("2019-01-01T00:00:00.000Z");
|
||||
expect(none).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dataDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "session-recording-routes-"),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("session recording routes", () => {
|
||||
it("401s without an authenticated actor", async () => {
|
||||
server = await startServer();
|
||||
const res = await server.request("GET", "/", { user: "" });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("lists only the caller's own recordings", async () => {
|
||||
server = await startServer();
|
||||
|
||||
const table = await server.mock.ctx.db.client<{
|
||||
insert: (t: unknown) => {
|
||||
values: (v: unknown) => Promise<unknown>;
|
||||
};
|
||||
}>();
|
||||
void table;
|
||||
|
||||
// Seed through the repository the way activate() wired it: easiest is a
|
||||
// direct HTTP round trip isn't available for writes (only ssh-terminal
|
||||
// and remote-desktop write through the service), so seed the table
|
||||
// directly.
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO p_session_recording_session_recordings
|
||||
(host_id, user_id, recording_path, protocol, format, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
7,
|
||||
"user-1",
|
||||
"/tmp/a.cast",
|
||||
"ssh",
|
||||
"asciicast",
|
||||
new Date().toISOString(),
|
||||
);
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO p_session_recording_session_recordings
|
||||
(host_id, user_id, recording_path, protocol, format, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
7,
|
||||
"user-2",
|
||||
"/tmp/b.cast",
|
||||
"ssh",
|
||||
"asciicast",
|
||||
new Date().toISOString(),
|
||||
);
|
||||
|
||||
const res = await server.request("GET", "/", { user: "user-1" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.logs).toHaveLength(1);
|
||||
expect(res.body.logs[0].userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("404s getting another user's recording", async () => {
|
||||
server = await startServer();
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO p_session_recording_session_recordings
|
||||
(host_id, user_id, recording_path, protocol, format, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
7,
|
||||
"user-2",
|
||||
"/tmp/b.cast",
|
||||
"ssh",
|
||||
"asciicast",
|
||||
new Date().toISOString(),
|
||||
);
|
||||
|
||||
const res = await server.request("GET", "/1", { user: "user-1" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("serves file content within the plugin's data directory", async () => {
|
||||
server = await startServer();
|
||||
const filePath = path.join(dataDir, "session_logs", "user-1", "a.cast");
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, '{"version":2}\n[0,"o","hi"]\n');
|
||||
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO p_session_recording_session_recordings
|
||||
(host_id, user_id, recording_path, protocol, format, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(7, "user-1", filePath, "ssh", "asciicast", new Date().toISOString());
|
||||
|
||||
// Point ctx.files.dataDir() at the temp dir for this request.
|
||||
(
|
||||
server.mock.ctx as unknown as {
|
||||
files: { dataDir: () => Promise<string> };
|
||||
}
|
||||
).files = { dataDir: async () => dataDir };
|
||||
|
||||
const res = await server.requestBinary("GET", "/1/content", {
|
||||
user: "user-1",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.contentType).toContain("asciicast");
|
||||
});
|
||||
|
||||
it("refuses a path outside the plugin's data directory", async () => {
|
||||
server = await startServer();
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO p_session_recording_session_recordings
|
||||
(host_id, user_id, recording_path, protocol, format, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
7,
|
||||
"user-1",
|
||||
"/etc/passwd",
|
||||
"ssh",
|
||||
"asciicast",
|
||||
new Date().toISOString(),
|
||||
);
|
||||
|
||||
(
|
||||
server.mock.ctx as unknown as {
|
||||
files: { dataDir: () => Promise<string> };
|
||||
}
|
||||
).files = { dataDir: async () => dataDir };
|
||||
|
||||
const res = await server.requestBinary("GET", "/1/content", {
|
||||
user: "user-1",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("deletes only the caller's own recording", async () => {
|
||||
server = await startServer();
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO p_session_recording_session_recordings
|
||||
(host_id, user_id, recording_path, protocol, format, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
7,
|
||||
"user-1",
|
||||
"/tmp/a.cast",
|
||||
"ssh",
|
||||
"asciicast",
|
||||
new Date().toISOString(),
|
||||
);
|
||||
|
||||
const denied = await server.request("DELETE", "/1", { user: "user-2" });
|
||||
expect(denied.status).toBe(404);
|
||||
|
||||
const ok = await server.request("DELETE", "/1", { user: "user-1" });
|
||||
expect(ok.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import { pluginDir } from "./helpers";
|
||||
import { createSessionRecordingRepository } from "../../src/backend/repository";
|
||||
import { sessionRecordings } from "../../src/backend/tables";
|
||||
import { createRecordingsWriter } from "../../src/backend/service";
|
||||
|
||||
const manifest =
|
||||
manifestJson as unknown as import("@termix/plugin-sdk/manifest").PluginManifest;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-recording-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
await fs.rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function setup(): Promise<{
|
||||
mock: MockPluginContext;
|
||||
writer: ReturnType<typeof createRecordingsWriter>;
|
||||
}> {
|
||||
db = await createTestDb(pluginDir);
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run("user-1", "alice");
|
||||
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(7);
|
||||
const table = await db.database.define(sessionRecordings);
|
||||
const hosts = {
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
checkAccess: async () => ({
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: false,
|
||||
}),
|
||||
create: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
update: async () => null,
|
||||
listOwned: async () => [],
|
||||
share: async () => ({ hostId: 0, shared: false }),
|
||||
listUsers: async () => [],
|
||||
listRoles: async () => [],
|
||||
trackSession: () => () => {},
|
||||
recordActivity: async () => {},
|
||||
};
|
||||
const repo = createSessionRecordingRepository(db.database, table, hosts);
|
||||
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
});
|
||||
// Point ctx.files.dataDir() at a real temp dir for this test.
|
||||
(mock.ctx as unknown as { files: { dataDir: () => Promise<string> } }).files =
|
||||
{
|
||||
dataDir: async () => dataDir,
|
||||
};
|
||||
|
||||
const writer = createRecordingsWriter(mock.ctx, repo);
|
||||
return { mock, writer };
|
||||
}
|
||||
|
||||
describe("recordings.writer", () => {
|
||||
it("is null when the host has recording switched off", async () => {
|
||||
const { mock, writer } = await setup();
|
||||
await mock.ctx.settings.setHost(7, "enableSessionRecording", false);
|
||||
|
||||
const sink = await writer.open({
|
||||
sessionId: "s1",
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
expect(sink).toBeNull();
|
||||
});
|
||||
|
||||
it("writes the first append with writeFile and later ones with appendFile", async () => {
|
||||
const { writer } = await setup();
|
||||
const sink = await writer.open({
|
||||
sessionId: "s1",
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
expect(sink).not.toBeNull();
|
||||
|
||||
await sink!.append("chunk-one\n");
|
||||
await sink!.append("chunk-two\n");
|
||||
|
||||
const filePath = path.join(dataDir, "session_logs", "user-1", "s1.cast");
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
expect(content).toBe("chunk-one\nchunk-two\n");
|
||||
});
|
||||
|
||||
it("persist writes the summary onto the row", async () => {
|
||||
const { writer } = await setup();
|
||||
const sink = await writer.open({
|
||||
sessionId: "s1",
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
await sink!.append("x\n");
|
||||
|
||||
const endedAt = Date.now();
|
||||
await sink!.persist({
|
||||
endedAt,
|
||||
durationSeconds: 42,
|
||||
terminatedByOwner: true,
|
||||
terminationReason: null,
|
||||
});
|
||||
|
||||
// Not thrown means it wrote successfully; deeper row assertions belong
|
||||
// to repository.test.ts.
|
||||
});
|
||||
|
||||
it("discard removes the row and further writes are no-ops", async () => {
|
||||
const { writer } = await setup();
|
||||
const sink = await writer.open({
|
||||
sessionId: "s1",
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
sink!.discard();
|
||||
await sink!.append("should not write");
|
||||
|
||||
const filePath = path.join(dataDir, "session_logs", "user-1", "s1.cast");
|
||||
await expect(fs.access(filePath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("createFinished inserts a row for an already-written recording", async () => {
|
||||
const { writer } = await setup();
|
||||
const row = await writer.createFinished({
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: new Date().toISOString(),
|
||||
duration: 10,
|
||||
recordingPath: "/data/session_recordings/guacamole/x.guac",
|
||||
protocol: "rdp",
|
||||
format: "guacamole",
|
||||
});
|
||||
|
||||
expect(row.id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => ({ data: { logs: [] } })),
|
||||
post: vi.fn(async () => ({ data: {} })),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: {} })),
|
||||
...overrides,
|
||||
} as PluginApiClient;
|
||||
}
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers only views its manifest declares", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
|
||||
expect(rendered.registered.tabs()).toEqual(["session-logs"]);
|
||||
expect(rendered.registered.panels()).toEqual(["session-logs"]);
|
||||
expect(rendered.registered.railItems()).toEqual([
|
||||
expect.objectContaining({ id: "session-logs" }),
|
||||
]);
|
||||
expect(rendered.registered.slot("terminal.toolbarStatus")).toEqual([
|
||||
"session-recording.terminalStatus",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
await app.deactivate();
|
||||
expect(app.registered.tabs()).toEqual([]);
|
||||
expect(app.registered.panels()).toEqual([]);
|
||||
expect(app.registered.railItems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders the panel without crashing", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
const element = rendered.renderPanel("session-logs");
|
||||
expect(element).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAsciicast } from "../../src/frontend/asciicast";
|
||||
|
||||
describe("parseAsciicast", () => {
|
||||
it("parses terminal dimensions and timed input/output", () => {
|
||||
const recording = parseAsciicast(
|
||||
'{"version":2,"width":120,"height":30}\n[0.1,"o","hello"]\n[1.5,"i","ls\\r"]\n[2,"r","100x40"]\n',
|
||||
);
|
||||
expect(recording.width).toBe(120);
|
||||
expect(recording.height).toBe(30);
|
||||
expect(recording.duration).toBe(2);
|
||||
expect(recording.events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("rejects unsupported recording versions", () => {
|
||||
expect(() => parseAsciicast('{"version":1}\n')).toThrow(
|
||||
"Unsupported asciicast version",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user