test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

This commit is contained in:
LukeGus committed 2026-09-25 09:58:45 -05:00
1 parent 2ee9b3592c
commit f779d6a08e
37 files changed
+5184 -377

No files matched your search

@@ -0,0 +1,39 @@
import fs from "node:fs/promises";
import path from "node:path";
export function isInside(file: string, dir: string): boolean {
return path.resolve(file).startsWith(`${path.resolve(dir)}${path.sep}`);
}
/**
* Moves a finished recording someone else wrote (guacd's, in remote
* desktop's recordings folder) under this plugin's data folder, which is the
* only place playback and retention read from. Returns where it ended up;
* a file that is not there is left alone.
*/
export async function adoptRecordingFile(
source: string,
dataDir: string,
): Promise<string> {
if (isInside(source, dataDir)) return source;
const target = path.join(
dataDir,
"session_recordings",
path.basename(path.dirname(source)),
path.basename(source),
);
try {
await fs.access(source);
} catch {
return source;
}
await fs.mkdir(path.dirname(target), { recursive: true });
try {
await fs.rename(source, target);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
await fs.copyFile(source, target);
await fs.unlink(source);
}
return target;
}
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { SessionRecordingRepository } from "./repository.js";
import { adoptRecordingFile } from "./files.js";
/**
* recordings.writer v1, the service ssh-terminal calls to record sessions.
@@ -40,8 +41,8 @@ export interface RecordingsWriterV1 {
/**
* Inserts a row for a recording a caller already wrote to disk itself
* (remote desktop's guacd recordings), rather than streaming through
* open()/append(). The caller already decided to record; this only writes
* the row.
* open()/append(). The caller already decided to record. The file is
* moved under this plugin's data folder, where playback reads it.
*/
createFinished: (input: {
hostId: number;
@@ -117,7 +118,15 @@ export function createRecordingsWriter(
async createFinished(input) {
const username = await repository.usernameFor(input.userId);
const row = await repository.create({ ...input, username });
const recordingPath = await adoptRecordingFile(
input.recordingPath,
await ctx.files.dataDir(),
).catch(() => input.recordingPath);
const row = await repository.create({
...input,
recordingPath,
username,
});
return { id: row.id };
},
};
@@ -172,6 +172,41 @@ describe("recordings.writer", () => {
expect(row.id).toBeGreaterThan(0);
});
it("createFinished moves guacd's file under the data folder, where playback reads it", async () => {
const { mock, writer } = await setup();
const guacdDir = await fs.mkdtemp(path.join(os.tmpdir(), "guacd-"));
const source = path.join(guacdDir, "guacamole", "rdp-1.guac");
await fs.mkdir(path.dirname(source), { recursive: true });
await fs.writeFile(source, "4.size,1.0;");
const row = await writer.createFinished({
hostId: 7,
userId: "user-1",
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
duration: 10,
recordingPath: source,
protocol: "rdp",
format: "guacamole",
});
const target = path.join(
await mock.ctx.files.dataDir(),
"session_recordings",
"guacamole",
"rdp-1.guac",
);
await expect(fs.readFile(target, "utf8")).resolves.toBe("4.size,1.0;");
await expect(fs.access(source)).rejects.toThrow();
const stored = db!.sqlite
.prepare(
"SELECT recording_path FROM p_session_recording_session_recordings WHERE id = ?",
)
.get(row.id) as { recording_path: string };
expect(stored.recording_path).toBe(target);
await fs.rm(guacdDir, { recursive: true, force: true });
});
it("enabledFor follows the host's recording switch", async () => {
const { mock, writer } = await setup();
await mock.ctx.settings.setHost(7, "enableSessionRecording", true);