d3ro-voice/scripts/ci/lib/runtime-feed-gate.test.mjs
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

129 lines
4.6 KiB
JavaScript

// node --test scripts/ci/lib/runtime-feed-gate.test.mjs
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { fileURLToPath, URL } from "node:url";
import {
RUNTIME_MIN_VERSION_SOURCE,
WINDOWS_X64_TARGET,
assertRuntimeFeedCompatible,
evaluateRuntimeFeed,
parseRuntimeMinVersions,
} from "./runtime-feed-gate.mjs";
import { buildRuntimeIndex } from "./runtime-index-builder.mjs";
const SHA = "a".repeat(64);
const MIN = { sidecar: "1.7.0", ffmpeg: null };
function index({ version = "1.9.0", platform = "win32", arch = "x64", components = ["sidecar", "ffmpeg"] } = {}) {
return buildRuntimeIndex({
version,
generatedAt: "2026-09-28T00:00:00.000Z",
platform,
arch,
partBaseUrl: `https://feed.test/runtime-${version}`,
components: Object.fromEntries(
components.map((name) => [
name,
{
archive: `d3ro-runtime-${name}.tar.gz`,
sha256: SHA,
totalSize: 10,
parts: [{ name: `d3ro-runtime-${name}.tar.gz.001`, size: 10, sha256: SHA }],
},
]),
),
});
}
function jsonFetch(body, status = 200) {
const calls = [];
const fetchImpl = async (url) => {
calls.push(url);
return new Response(JSON.stringify(body), { status });
};
return { fetchImpl, calls };
}
test("parseRuntimeMinVersions reads the app's real RUNTIME_MIN_VERSION (drift guard)", () => {
const source = readFileSync(fileURLToPath(new URL(`../../../${RUNTIME_MIN_VERSION_SOURCE}`, import.meta.url)), "utf8");
const parsed = parseRuntimeMinVersions(source);
assert.deepEqual(Object.keys(parsed).sort(), ["ffmpeg", "sidecar"]);
assert.match(parsed.sidecar, /^\d+\.\d+\.\d+$/);
assert.equal(parsed.ffmpeg, null);
});
test("parseRuntimeMinVersions fails loudly when the constant cannot be read", () => {
assert.throws(() => parseRuntimeMinVersions("export const OTHER = {}"), /RUNTIME_MIN_VERSION not found/);
assert.throws(
() => parseRuntimeMinVersions("export const RUNTIME_MIN_VERSION = {\n sidecar: '1.7',\n}"),
/not semver/,
);
});
test("a runtime feed that satisfies the minimum and target passes", () => {
assert.deepEqual(evaluateRuntimeFeed({ index: index(), minVersions: MIN, target: WINDOWS_X64_TARGET }), []);
});
test("runtime-latest older than this build's minimum blocks latest.yml (regression)", () => {
const problems = evaluateRuntimeFeed({
index: index({ version: "1.6.0" }),
minVersions: MIN,
target: WINDOWS_X64_TARGET,
});
assert.equal(problems.length, 1);
assert.match(problems[0], /1\.6\.0 is below .*sidecar 1\.7\.0/);
});
test("a legacy index without platform/arch or for another platform is rejected", () => {
const legacy = index();
delete legacy.platform;
delete legacy.arch;
assert.match(
evaluateRuntimeFeed({ index: legacy, minVersions: MIN, target: WINDOWS_X64_TARGET }).join("\n"),
/does not declare platform\/arch/,
);
assert.match(
evaluateRuntimeFeed({ index: index({ platform: "darwin", arch: "arm64" }), minVersions: MIN, target: WINDOWS_X64_TARGET }).join("\n"),
/targets darwin-arm64/,
);
});
test("a missing component is rejected even without a minimum version", () => {
const problems = evaluateRuntimeFeed({
index: index({ components: ["sidecar"] }),
minVersions: MIN,
target: WINDOWS_X64_TARGET,
});
assert.deepEqual(problems, ["runtime.json has no ffmpeg component"]);
});
test("assertRuntimeFeedCompatible fails closed when the feed is unreadable", async () => {
const missing = jsonFetch({}, 404);
await assert.rejects(
assertRuntimeFeedCompatible({ url: "https://feed.test/runtime-latest/runtime.json", fetchImpl: missing.fetchImpl, minVersions: MIN }),
/cannot read .*HTTP 404/,
);
const failing = async () => {
throw new Error("offline");
};
await assert.rejects(
assertRuntimeFeedCompatible({ url: "https://feed.test/x", fetchImpl: failing, minVersions: MIN }),
/cannot read .*offline/,
);
});
test("assertRuntimeFeedCompatible returns the feed version when compatible and throws when stale", async () => {
const ok = jsonFetch(index({ version: "1.9.0" }));
assert.deepEqual(
await assertRuntimeFeedCompatible({ url: "https://feed.test/runtime.json", fetchImpl: ok.fetchImpl, minVersions: MIN }),
{ version: "1.9.0" },
);
assert.deepEqual(ok.calls, ["https://feed.test/runtime.json"]);
const stale = jsonFetch(index({ version: "1.6.0" }));
await assert.rejects(
assertRuntimeFeedCompatible({ url: "https://feed.test/runtime.json", fetchImpl: stale.fetchImpl, minVersions: MIN }),
/refusing to publish latest\.yml[\s\S]*1\.6\.0 is below/,
);
});