cameras/src/app/api/recordings/clip/route.test.ts
Michael Mainguy 4fc68cacf1 Add a Recordings page to browse, play and delete motion clips
- /recordings lists clips newest first with camera, time and size, plays
  them in the browser and deletes after a confirmation.
- /api/recordings lists clips; /api/recordings/clip serves one with Range
  support so seeking works, and deletes it. A clip is named by folder and
  file name, both matched against fixed patterns and resolved inside the
  recordings folder, so no request can reach another file.
- Each camera's page links to its own clips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 17:16:14 -05:00

84 lines
3.3 KiB
TypeScript

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null }));
const { GET, DELETE } = await import("./route");
const ID = "11111111-2222-3333-4444-555555555555";
const NAME = "2026-09-19_17-06-18-101298.mp4";
const CLIP = `cam-${ID}-sub-rec/${NAME}`;
const BODY = "0123456789";
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(os.tmpdir(), "clip-route-"));
vi.stubEnv("RECORDINGS_DIR", dir);
const folder = path.join(dir, `cam-${ID}-sub-rec`);
await (await import("node:fs/promises")).mkdir(folder, { recursive: true });
await writeFile(path.join(folder, NAME), BODY);
});
afterEach(() => rm(dir, { recursive: true, force: true }));
const url = (clip = CLIP) => `http://localhost/api/recordings/clip?clip=${encodeURIComponent(clip)}`;
const get = (clip?: string, headers?: HeadersInit) => GET(new Request(url(clip), { headers }));
const text = async (res: Response) => Buffer.from(await res.arrayBuffer()).toString();
describe("GET /api/recordings/clip", () => {
it("serves the whole clip as seekable video", async () => {
const res = await get();
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("video/mp4");
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
expect(res.headers.get("Content-Length")).toBe(String(BODY.length));
expect(res.headers.get("Cache-Control")).toBe("no-store");
expect(await text(res)).toBe(BODY);
});
it("serves a byte range so the player can seek", async () => {
const res = await get(CLIP, { range: "bytes=2-5" });
expect(res.status).toBe(206);
expect(res.headers.get("Content-Range")).toBe(`bytes 2-5/${BODY.length}`);
expect(res.headers.get("Content-Length")).toBe("4");
expect(await text(res)).toBe("2345");
});
it("serves an open-ended range and a suffix range", async () => {
expect(await text(await get(CLIP, { range: "bytes=7-" }))).toBe("789");
expect(await text(await get(CLIP, { range: "bytes=-3" }))).toBe("789");
});
it("refuses a range past the end", async () => {
const res = await get(CLIP, { range: "bytes=50-60" });
expect(res.status).toBe(416);
expect(res.headers.get("Content-Range")).toBe(`bytes */${BODY.length}`);
});
it.each([
["a traversal", "../../etc/passwd"],
["a folder that isn't ours", `secrets/${NAME}`],
["a name that isn't a clip", `cam-${ID}-sub-rec/passwd`],
["a clip that doesn't exist", `cam-${ID}-main-rec/${NAME}`],
["nothing", ""],
])("is not found for %s", async (_l, clip) => {
expect((await get(clip)).status).toBe(404);
});
});
describe("DELETE /api/recordings/clip", () => {
it("deletes the clip", async () => {
expect((await DELETE(new Request(url(), { method: "DELETE" }))).status).toBe(204);
expect((await get()).status).toBe(404);
});
it.each([["../../etc/passwd"], [""], [`cam-${ID}-sub-rec/2026-01-01_00-00-00-000000.mp4`]])(
"is not found for %j",
async (clip) => {
expect((await DELETE(new Request(url(clip), { method: "DELETE" }))).status).toBe(404);
},
);
});