d0e98424c1
- Arbitrary file write (P0): validate import keys in /api/library/import via a now-exported safeFilename(); a ../../ key is skipped, not written out of the scope dir. - SSRF (P0): guard downloadPodcastAudio — reject non-HTTP(S) schemes, block IP-literal and DNS-resolved private/link-local/loopback/reserved/multicast and embedded-IPv4 IPv6 targets (closes DNS rebinding), cap + resolve redirects. - ESM require (P1): top-level import of randomBytes in license-purchase.js (the inner require threw on the anon purchase-settle path). - Concurrency lock (P1): skip the process-global free-tier slot in multi-mode so it no longer serializes every cloud tenant onto one job. - X-Forwarded-For bypass (P1): set Express trust proxy from RECAP_TRUSTED_PROXY_HOPS (default 1); getClientIp now reads req.ip instead of a client-spoofable XFF entry. Tests added for safeFilename, the SSRF guard, and getClientIp (119 pass). Registry blockers deferred (ROADMAP); leaked-key history purge queued.
162 lines
5.7 KiB
JavaScript
162 lines
5.7 KiB
JavaScript
// Tests for server/history.js — file-backed session storage + meta.
|
|
//
|
|
// Each test mounts an isolated tmp dir as DATA_DIR and re-inits the
|
|
// history module. We don't import the routes layer here — those need
|
|
// an Express app and are covered better by an integration test later.
|
|
|
|
import { test, describe, before, after } from "node:test";
|
|
import { strict as assert } from "node:assert";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
let tmpDir;
|
|
let historyDir;
|
|
let history;
|
|
|
|
before(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "recap-history-test-"));
|
|
historyDir = path.join(tmpDir, "history");
|
|
history = await import("../history.js");
|
|
await history.initHistory({ dataDir: tmpDir });
|
|
});
|
|
|
|
after(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
describe("initHistory + getHistoryDir", () => {
|
|
test("creates the history directory", async () => {
|
|
const stat = await fs.stat(historyDir);
|
|
assert.ok(stat.isDirectory());
|
|
});
|
|
|
|
test("getHistoryDir returns the configured path", () => {
|
|
assert.equal(history.getHistoryDir(), historyDir);
|
|
});
|
|
|
|
test("init is idempotent", async () => {
|
|
// Should not throw or wipe data.
|
|
await history.initHistory({ dataDir: tmpDir });
|
|
await history.initHistory({ dataDir: tmpDir });
|
|
});
|
|
});
|
|
|
|
describe("saveToHistory", () => {
|
|
// saveToHistory is scope-aware: it takes a `scope` first and writes under
|
|
// history/<scope>/. Tests use the "owner" scope (single-mode / operator).
|
|
const SCOPE = "owner";
|
|
const scopeFile = (id) => path.join(historyDir, SCOPE, `${id}.json`);
|
|
|
|
test("returns an id and writes a file with the expected shape", async () => {
|
|
const id = await history.saveToHistory(
|
|
SCOPE,
|
|
"videoId123",
|
|
"https://youtu.be/videoId123",
|
|
"My title",
|
|
[{ title: "Topic 1" }],
|
|
[{ offset: 0, text: "hello" }],
|
|
[{ message: "started" }],
|
|
"20260101",
|
|
"youtube"
|
|
);
|
|
assert.match(id, /^\d+-videoId123$/);
|
|
|
|
const raw = await fs.readFile(scopeFile(id), "utf-8");
|
|
const record = JSON.parse(raw);
|
|
assert.equal(record.id, id);
|
|
assert.equal(record.videoId, "videoId123");
|
|
assert.equal(record.url, "https://youtu.be/videoId123");
|
|
assert.equal(record.title, "My title");
|
|
assert.equal(record.type, "youtube");
|
|
assert.equal(record.topicCount, 1);
|
|
assert.equal(record.segmentCount, 1);
|
|
assert.equal(record.uploadDate, "20260101");
|
|
assert.deepEqual(record.chunks, [{ title: "Topic 1" }]);
|
|
assert.deepEqual(record.entries, [{ offset: 0, text: "hello" }]);
|
|
assert.deepEqual(record.logs, [{ message: "started" }]);
|
|
assert.ok(record.createdAt);
|
|
});
|
|
|
|
test("falls back to 'Untitled' when title is empty", async () => {
|
|
const id = await history.saveToHistory(
|
|
SCOPE,
|
|
"noTitleX",
|
|
"url",
|
|
"",
|
|
[],
|
|
[],
|
|
[],
|
|
"",
|
|
"youtube"
|
|
);
|
|
const raw = await fs.readFile(scopeFile(id), "utf-8");
|
|
const record = JSON.parse(raw);
|
|
assert.equal(record.title, "Untitled");
|
|
});
|
|
|
|
test("defaults type to 'youtube' when not specified", async () => {
|
|
const id = await history.saveToHistory(SCOPE, "vid", "url", "t", [], [], [], "", null);
|
|
const raw = await fs.readFile(scopeFile(id), "utf-8");
|
|
assert.equal(JSON.parse(raw).type, "youtube");
|
|
});
|
|
|
|
test("encodes long podcast guids into a base64-truncated id suffix", async () => {
|
|
const longGuid = "https://example.com/podcasts/feed.xml#episode-uuid-very-long-string";
|
|
const id = await history.saveToHistory(SCOPE, longGuid, longGuid, "ep", [], [], [], "", "podcast");
|
|
// suffix should be 16 base64 chars, not the raw URL
|
|
assert.ok(!id.includes("https"));
|
|
assert.match(id, /^\d+-[A-Za-z0-9_-]{16}$/);
|
|
});
|
|
});
|
|
|
|
describe("loadMeta + saveMeta", () => {
|
|
// loadMeta/saveMeta are scope-aware (history/<scope>/_meta.json).
|
|
test("loadMeta returns default empty shape when file missing", async () => {
|
|
// A scope that has never been written → default shape.
|
|
const meta = await history.loadMeta("never-written-scope");
|
|
assert.ok(Array.isArray(meta.folders));
|
|
assert.ok(Array.isArray(meta.uncategorized));
|
|
});
|
|
|
|
test("saveMeta + loadMeta round-trips", async () => {
|
|
const original = {
|
|
folders: [{ id: "f1", name: "Bitcoin podcasts", collapsed: false, items: ["s1", "s2"] }],
|
|
uncategorized: ["s3"],
|
|
};
|
|
await history.saveMeta("owner", original);
|
|
const loaded = await history.loadMeta("owner");
|
|
assert.deepEqual(loaded, original);
|
|
});
|
|
|
|
test("loadMeta returns default when _meta.json is corrupt", async () => {
|
|
const dir = path.join(historyDir, "corrupt-scope");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(path.join(dir, "_meta.json"), "{ this is not json");
|
|
const loaded = await history.loadMeta("corrupt-scope");
|
|
assert.deepEqual(loaded.folders, []);
|
|
assert.deepEqual(loaded.uncategorized, []);
|
|
});
|
|
});
|
|
|
|
describe("safeFilename", () => {
|
|
// Exported so callers writing user content to disk (e.g. library import)
|
|
// can share the one guard instead of rolling their own.
|
|
test("returns valid ids unchanged", () => {
|
|
for (const id of ["abc123", "a_b-c", "VIDEOid123", "0"]) {
|
|
assert.equal(history.safeFilename(id), id);
|
|
}
|
|
});
|
|
|
|
test("rejects traversal, separators, and other unsafe chars", () => {
|
|
for (const bad of ["../../evil", "..", "a/b", "a\\b", "a.json", "foo bar", "", "a:b"]) {
|
|
assert.throws(() => history.safeFilename(bad), /invalid_session_id/);
|
|
}
|
|
});
|
|
|
|
test("rejects non-strings", () => {
|
|
assert.throws(() => history.safeFilename(123), /invalid_session_id/);
|
|
assert.throws(() => history.safeFilename(null), /invalid_session_id/);
|
|
});
|
|
});
|