mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
233 lines
8.3 KiB
TypeScript
233 lines
8.3 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
describe("local media path policy", () => {
|
|
let tempRoot: string;
|
|
let appDataPath: string;
|
|
let userDataPath: string;
|
|
let tempPath: string;
|
|
let appPath: string;
|
|
|
|
beforeEach(async () => {
|
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-media-policy-"));
|
|
appDataPath = path.join(tempRoot, "AppData");
|
|
userDataPath = path.join(tempRoot, "UserData");
|
|
tempPath = path.join(tempRoot, "Temp");
|
|
appPath = path.join(tempRoot, "App");
|
|
|
|
await Promise.all(
|
|
[appDataPath, userDataPath, tempPath, appPath].map((dirPath) =>
|
|
fs.mkdir(dirPath, { recursive: true }),
|
|
),
|
|
);
|
|
|
|
vi.resetModules();
|
|
vi.doMock("electron", () => ({
|
|
app: {
|
|
isPackaged: false,
|
|
getAppPath: () => appPath,
|
|
getPath: (name: string) => {
|
|
if (name === "appData") return appDataPath;
|
|
if (name === "userData") return userDataPath;
|
|
if (name === "temp") return tempPath;
|
|
return tempRoot;
|
|
},
|
|
setPath: () => undefined,
|
|
},
|
|
}));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.resetModules();
|
|
vi.doUnmock("electron");
|
|
if (tempRoot) {
|
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects existing media files outside allowed directories until they are approved", async () => {
|
|
const downloadsPath = path.join(tempRoot, "Downloads");
|
|
const exportPath = path.join(downloadsPath, "export-test.mp4");
|
|
await fs.mkdir(downloadsPath, { recursive: true });
|
|
await fs.writeFile(exportPath, "test-video");
|
|
|
|
const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import(
|
|
"./manager"
|
|
);
|
|
|
|
await expect(isAllowedLocalMediaPath(exportPath)).resolves.toBe(false);
|
|
|
|
await rememberApprovedLocalReadPath(exportPath);
|
|
|
|
await expect(isAllowedLocalMediaPath(exportPath)).resolves.toBe(true);
|
|
});
|
|
|
|
it("rejects missing media files outside the allowed directories", async () => {
|
|
const missingPath = path.join(tempRoot, "Downloads", "missing.mp4");
|
|
const { isAllowedLocalMediaPath } = await import("./manager");
|
|
|
|
await expect(isAllowedLocalMediaPath(missingPath)).resolves.toBe(false);
|
|
});
|
|
|
|
it("allows approved media paths before the file exists", async () => {
|
|
const pendingExportPath = path.join(tempRoot, "Downloads", "pending-export.mp4");
|
|
const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import(
|
|
"./manager"
|
|
);
|
|
|
|
await rememberApprovedLocalReadPath(pendingExportPath);
|
|
|
|
await expect(isAllowedLocalMediaPath(pendingExportPath)).resolves.toBe(true);
|
|
});
|
|
|
|
it("approves media-server access for approved external files resolved through the URL policy", async () => {
|
|
const downloadsPath = path.join(tempRoot, "Downloads");
|
|
const videoPath = path.join(downloadsPath, "external-video.mp4");
|
|
await fs.mkdir(downloadsPath, { recursive: true });
|
|
await fs.writeFile(videoPath, "test-video");
|
|
const resolvedVideoPath = await fs.realpath(videoPath);
|
|
|
|
const { resolveApprovedLocalMediaPath, rememberApprovedLocalReadPath } = await import(
|
|
"./manager"
|
|
);
|
|
const { isAllowedMediaPath } = await import("../../mediaServer");
|
|
|
|
// Unapproved external paths are rejected before they ever reach the media server.
|
|
expect(isAllowedMediaPath(videoPath)).toBe(false);
|
|
await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBeNull();
|
|
|
|
// Once the user opts in (via dialog/export/etc.) the path is approved.
|
|
await rememberApprovedLocalReadPath(videoPath);
|
|
|
|
await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(resolvedVideoPath);
|
|
expect(isAllowedMediaPath(videoPath)).toBe(true);
|
|
});
|
|
|
|
it("rejects existing non-media files when resolving local media URLs", async () => {
|
|
const downloadsPath = path.join(tempRoot, "Downloads");
|
|
const textPath = path.join(downloadsPath, "notes.txt");
|
|
await fs.mkdir(downloadsPath, { recursive: true });
|
|
await fs.writeFile(textPath, "not media");
|
|
|
|
const { resolveApprovedLocalMediaPath } = await import("./manager");
|
|
const { isAllowedMediaPath } = await import("../../mediaServer");
|
|
|
|
await expect(resolveApprovedLocalMediaPath(textPath)).resolves.toBeNull();
|
|
expect(isAllowedMediaPath(textPath)).toBe(false);
|
|
});
|
|
|
|
it("rejects symlinks under allowed prefixes that point outside the allowlist", async () => {
|
|
const outsideTarget = path.join(tempRoot, "outside-secret.mp4");
|
|
const symlinkInsideUserData = path.join(userDataPath, "shortcut-to-secret.mp4");
|
|
await fs.writeFile(outsideTarget, "secret-bytes");
|
|
|
|
try {
|
|
await fs.symlink(outsideTarget, symlinkInsideUserData);
|
|
} catch (error) {
|
|
// Windows requires Developer Mode or admin to create file symlinks. If
|
|
// we can't create one, the bypass we're guarding against also can't be
|
|
// crafted on this machine, so skipping is safe.
|
|
if ((error as NodeJS.ErrnoException).code === "EPERM") {
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const { isAllowedLocalMediaPath, resolveApprovedLocalMediaPath } = await import(
|
|
"./manager"
|
|
);
|
|
|
|
await expect(isAllowedLocalMediaPath(symlinkInsideUserData)).resolves.toBe(false);
|
|
await expect(resolveApprovedLocalMediaPath(symlinkInsideUserData)).resolves.toBeNull();
|
|
});
|
|
|
|
it("preserves an existing project thumbnail when no replacement is provided", async () => {
|
|
const projectPath = path.join(tempRoot, "Projects", "demo.recordly");
|
|
const thumbnailDataUrl = `data:image/png;base64,${Buffer.from("png-thumbnail").toString("base64")}`;
|
|
await fs.mkdir(path.dirname(projectPath), { recursive: true });
|
|
|
|
const { getProjectThumbnailPath, saveProjectThumbnail } = await import("./manager");
|
|
const thumbnailPath = getProjectThumbnailPath(projectPath);
|
|
|
|
await saveProjectThumbnail(projectPath, thumbnailDataUrl);
|
|
await saveProjectThumbnail(projectPath, undefined);
|
|
|
|
await expect(fs.readFile(thumbnailPath, "utf8")).resolves.toBe("png-thumbnail");
|
|
});
|
|
|
|
it("loads project files that start with a UTF-8 byte order mark", async () => {
|
|
const videoPath = path.join(tempPath, "recording.mp4");
|
|
const projectPath = path.join(tempPath, "recording.recordly");
|
|
await fs.writeFile(videoPath, "test-video");
|
|
await fs.writeFile(
|
|
projectPath,
|
|
`\uFEFF${JSON.stringify({
|
|
version: 1,
|
|
videoPath,
|
|
editor: {},
|
|
})}`,
|
|
"utf-8",
|
|
);
|
|
|
|
const { loadProjectFromPath } = await import("./manager");
|
|
|
|
const result = await loadProjectFromPath(projectPath);
|
|
expect(result.success).toBe(true);
|
|
expect(result.path).toBe(projectPath);
|
|
expect(result.project).toMatchObject({ videoPath });
|
|
});
|
|
|
|
it("rejects invalid project payloads before approving media paths", async () => {
|
|
const downloadsPath = path.join(tempRoot, "Downloads");
|
|
const videoPath = path.join(downloadsPath, "recording.mp4");
|
|
const projectPath = path.join(tempPath, "invalid.recordly");
|
|
await fs.mkdir(downloadsPath, { recursive: true });
|
|
await fs.writeFile(videoPath, "test-video");
|
|
await fs.writeFile(
|
|
projectPath,
|
|
JSON.stringify({
|
|
videoPath,
|
|
editor: {},
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
const { loadProjectFromPath, resolveApprovedLocalMediaPath } = await import("./manager");
|
|
|
|
const result = await loadProjectFromPath(projectPath);
|
|
expect(result.success).toBe(false);
|
|
expect(result.message).toBe("Invalid project file format");
|
|
await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBeNull();
|
|
});
|
|
|
|
it("approves editor audioRegions audioPath entries when loading a project", async () => {
|
|
const downloadsPath = path.join(tempRoot, "Downloads");
|
|
const videoPath = path.join(tempPath, "recording.mp4");
|
|
const audioPath = path.join(downloadsPath, "music.ogg");
|
|
const projectPath = path.join(tempPath, "recording.recordly");
|
|
await fs.mkdir(downloadsPath, { recursive: true });
|
|
await fs.writeFile(videoPath, "test-video");
|
|
await fs.writeFile(audioPath, "test-audio");
|
|
await fs.writeFile(
|
|
projectPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
videoPath,
|
|
editor: {
|
|
audioRegions: [{ id: "a1", startMs: 0, endMs: 1000, audioPath, volume: 1 }],
|
|
},
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
const { loadProjectFromPath, resolveApprovedLocalMediaPath } = await import("./manager");
|
|
const resolvedAudioPath = await fs.realpath(audioPath);
|
|
|
|
const result = await loadProjectFromPath(projectPath);
|
|
expect(result.success).toBe(true);
|
|
await expect(resolveApprovedLocalMediaPath(audioPath)).resolves.toBe(resolvedAudioPath);
|
|
});
|
|
});
|