d3ro-voice/scripts/ci/lib/portable-publish-alias.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

423 lines
18 KiB
JavaScript

// node --test scripts/ci/lib/portable-publish-alias.test.mjs
// *-latest 별칭 교체 회귀: 패키지째 지우고 부품을 다시 올리는 동안 인덱스가 404가 되던 문제,
// 중간 실패 후 CI 재실행(재빌드 바이트 → 버전 경로 abort)으로 별칭이 복구되지 않던 문제.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { withFileDeletion } from "./forgejo-generic-file-delete.mjs";
import {
hashPayloads,
planAliasFiles,
planAliasRestore,
PortablePublishError,
publishPortablePackages,
selectAliasItems,
} from "./portable-publish-policy.mjs";
const sha = (text) => createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
const payload = (name, text, contentType = "application/octet-stream") => ({
name,
bytes: Buffer.from(text, "utf8"),
contentType,
});
const runtimeJson = (version, generatedAt = "2026-09-20T00:00:00.000Z") =>
JSON.stringify({ schemaVersion: 1, version, generatedAt });
const portableJson = (version) => JSON.stringify({ version });
const runtimeIndex = (version, generatedAt) =>
payload("runtime.json", runtimeJson(version, generatedAt), "application/json");
const portableIndex = (version) => payload("portable.json", portableJson(version), "application/json");
const installer = (text = "irm") => payload("install-d3ro-voice.ps1", text, "text/plain");
/**
* Forgejo 동작을 흉내 내는 메모리 registry: 같은 이름 PUT은 409, 파일/버전 단위 삭제 지원.
* 모든 쓰기 직후 watch 경로의 존재 여부를 timeline 에 기록한다.
*/
function forgejoLikeRegistry(initial = {}, { watch = [], failUpload } = {}) {
const packages = new Map(
Object.entries(initial).map(([path, files]) => [path, new Map(Object.entries(files))]),
);
const calls = [];
const timeline = [];
const snapshot = (op) =>
timeline.push({
op,
present: Object.fromEntries(
watch.map((key) => {
const [path, name] = key.split("/");
return [key, packages.get(path)?.has(name) ?? false];
}),
),
});
return {
packages,
calls,
timeline,
async listFileHashes(versionPath) {
calls.push(["list", versionPath]);
const files = packages.get(versionPath);
return new Map([...(files ?? new Map())].map(([name, text]) => [name, sha(text)]));
},
async deleteVersion(versionPath) {
calls.push(["deleteVersion", versionPath]);
packages.delete(versionPath);
snapshot(`deleteVersion ${versionPath}`);
},
async deleteFile(versionPath, name) {
calls.push(["deleteFile", versionPath, name]);
const files = packages.get(versionPath);
files?.delete(name);
if (files && files.size === 0) packages.delete(versionPath);
snapshot(`deleteFile ${versionPath}/${name}`);
},
async uploadFile(versionPath, file) {
calls.push(["upload", versionPath, file.name]);
if (failUpload?.(versionPath, file.name)) throw new Error(`HTTP 524 ${versionPath}/${file.name}`);
const files = packages.get(versionPath) ?? new Map();
if (files.has(file.name)) throw new Error(`409 conflict ${versionPath}/${file.name}`);
files.set(file.name, Buffer.from(file.bytes).toString("utf8"));
packages.set(versionPath, files);
snapshot(`upload ${versionPath}/${file.name}`);
},
async readTextFile(versionPath, name) {
calls.push(["read", versionPath, name]);
return packages.get(versionPath)?.get(name);
},
};
}
const RUNTIME_PARTS = (tag) => [
payload("d3ro-runtime-sidecar.tar.gz.001", `sidecar-1-${tag}`),
payload("d3ro-runtime-sidecar.tar.gz.002", `sidecar-2-${tag}`),
payload("d3ro-runtime-ffmpeg.tar.gz.001", `ffmpeg-${tag}`),
];
const publishRuntime = (registry, version, runtimePayloads) =>
publishPortablePackages({
version,
registry,
log: () => {},
packages: [
{ kind: "runtime", indexName: "runtime.json", payloads: runtimePayloads, aliasNames: ["runtime.json"] },
],
});
// ── 순수 정책 ─────────────────────────────────────────────────────────────
test("selectAliasItems keeps only alias files and moves the index to the end", () => {
const items = hashPayloads([portableIndex("1.9.1"), payload("a.zip.001", "z"), payload("a.7z.001", "v"), installer()]);
const selected = selectAliasItems({
items,
indexName: "portable.json",
aliasNames: ["portable.json", "a.zip.001", "install-d3ro-voice.ps1"],
});
assert.deepEqual(
selected.map((item) => item.name),
["a.zip.001", "install-d3ro-voice.ps1", "portable.json"],
);
assert.throws(
() => selectAliasItems({ items, indexName: "portable.json", aliasNames: ["a.zip.001"] }),
/portable\.json/,
);
assert.throws(
() => selectAliasItems({ items, indexName: "portable.json", aliasNames: ["portable.json", "nope"] }),
/nope/,
);
});
test("planAliasFiles swaps only changed files and prunes stale ones", () => {
const items = hashPayloads([payload("new.zip.001", "n"), installer("same"), portableIndex("1.9.1")]);
const plan = planAliasFiles({
items,
remoteHashes: new Map([
["old.zip.001", sha("o")],
["install-d3ro-voice.ps1", sha("same")],
["portable.json", sha(portableJson("1.9.0"))],
]),
});
assert.equal(plan.action, "update");
assert.deepEqual(
plan.writes.map(({ item, replace }) => [item.name, replace]),
[["new.zip.001", false], ["portable.json", true]],
);
assert.deepEqual(plan.prunes, ["old.zip.001"]);
assert.equal(planAliasFiles({ items, remoteHashes: new Map(items.map((i) => [i.name, i.sha256])) }).action, "skip");
});
test("planAliasRestore only restores from a complete versioned package and never copies binaries", () => {
const aliasItems = hashPayloads([payload("a.zip.001", "rebuilt-zip"), runtimeIndex("1.9.1", "B")]);
const versionedHashes = new Map([
["a.zip.001", sha("zip")],
["runtime.json", sha(runtimeJson("1.9.1", "A"))],
]);
// 별칭에 같은 zip이 이미 있으면 인덱스만 옮기면 된다.
const ok = planAliasRestore({
aliasItems,
versionedHashes,
aliasHashes: new Map([["a.zip.001", sha("zip")], ["stale", sha("s")]]),
});
assert.equal(ok.restorable, true);
assert.deepEqual(ok.reads.map((read) => [read.name, read.sha256]), [["runtime.json", sha(runtimeJson("1.9.1", "A"))]]);
assert.deepEqual(ok.prunes, ["stale"]);
// 이진 부품이 어긋나 있으면 복구하지 않는다.
assert.equal(planAliasRestore({ aliasItems, versionedHashes, aliasHashes: new Map() }).restorable, false);
// 버전 경로 미완성(인덱스 없음)이면 복구하지 않는다.
assert.equal(
planAliasRestore({
aliasItems,
versionedHashes: new Map([["a.zip.001", sha("zip")]]),
aliasHashes: new Map([["a.zip.001", sha("zip")]]),
}).restorable,
false,
);
});
// ── 유스케이스: 404 구간 ──────────────────────────────────────────────────
test("a tag bump never deletes runtime-latest nor re-uploads parts; runtime.json is missing only for its own swap", async () => {
const registry = forgejoLikeRegistry(
{
"runtime-latest": {
"d3ro-runtime-sidecar.tar.gz.001": "legacy-alias-part",
"runtime.json": runtimeJson("1.9.0"),
},
},
{ watch: ["runtime-latest/runtime.json"] },
);
const result = await publishRuntime(registry, "1.9.1", [...RUNTIME_PARTS("191"), runtimeIndex("1.9.1", "2026-09-28T00:00:00.000Z")]);
assert.deepEqual(result, { versioned: { runtime: "uploaded" }, aliases: { runtime: "replaced" } });
assert.equal(registry.calls.some(([op]) => op === "deleteVersion"), false);
assert.equal(
registry.calls.some(([op, path, name]) => op === "upload" && path === "runtime-latest" && name !== "runtime.json"),
false,
"parts must not be re-uploaded to the alias — runtime.json points at runtime-<version>",
);
const gap = registry.timeline.filter((entry) => !entry.present["runtime-latest/runtime.json"]);
assert.deepEqual(
gap.map((entry) => entry.op),
["deleteFile runtime-latest/runtime.json"],
"the only write while the index is missing is the index swap itself",
);
assert.deepEqual([...registry.packages.get("runtime-latest").keys()], ["runtime.json"]);
assert.equal(
registry.packages.get("runtime-latest").get("runtime.json"),
runtimeJson("1.9.1", "2026-09-28T00:00:00.000Z"),
);
});
test("portable-latest keeps the old index and installer live while new zip parts upload, prunes after the swap", async () => {
const registry = forgejoLikeRegistry(
{
"portable-latest": {
"D3RO-Voice-1.9.0-x64-portable.7z.001": "legacy-volume",
"D3RO-Voice-1.9.0-x64-portable.zip.001": "zip190",
"install-d3ro-voice.ps1": "irm-old",
"portable.json": portableJson("1.9.0"),
},
},
{ watch: ["portable-latest/portable.json", "portable-latest/install-d3ro-voice.ps1"] },
);
await publishPortablePackages({
version: "1.9.1",
registry,
log: () => {},
packages: [
{
kind: "portable",
indexName: "portable.json",
payloads: [
payload("D3RO-Voice-1.9.1-x64-portable.7z.001", "vol191"),
payload("D3RO-Voice-1.9.1-x64-portable.zip.001", "zip191"),
installer("irm-new"),
portableIndex("1.9.1"),
],
aliasNames: ["D3RO-Voice-1.9.1-x64-portable.zip.001", "install-d3ro-voice.ps1", "portable.json"],
},
],
});
const aliasOps = registry.timeline.map((entry) => entry.op).filter((op) => op.includes("portable-latest"));
assert.deepEqual(aliasOps, [
"upload portable-latest/D3RO-Voice-1.9.1-x64-portable.zip.001",
"deleteFile portable-latest/install-d3ro-voice.ps1",
"upload portable-latest/install-d3ro-voice.ps1",
"deleteFile portable-latest/portable.json",
"upload portable-latest/portable.json",
"deleteFile portable-latest/D3RO-Voice-1.9.0-x64-portable.7z.001",
"deleteFile portable-latest/D3RO-Voice-1.9.0-x64-portable.zip.001",
]);
const aliasFiles = Object.fromEntries(registry.packages.get("portable-latest"));
assert.deepEqual(aliasFiles, {
"D3RO-Voice-1.9.1-x64-portable.zip.001": "zip191",
"install-d3ro-voice.ps1": "irm-new",
"portable.json": portableJson("1.9.1"),
});
});
test("an upload failure mid-alias leaves the previous index serving", async () => {
const registry = forgejoLikeRegistry(
{
"portable-latest": {
"D3RO-Voice-1.9.0-x64-portable.zip.001": "zip190",
"install-d3ro-voice.ps1": "irm",
"portable.json": portableJson("1.9.0"),
},
},
{ failUpload: (path, name) => path === "portable-latest" && name.endsWith(".zip.001") },
);
await assert.rejects(
publishPortablePackages({
version: "1.9.1",
registry,
log: () => {},
packages: [
{
kind: "portable",
indexName: "portable.json",
payloads: [payload("D3RO-Voice-1.9.1-x64-portable.zip.001", "zip191"), installer("irm"), portableIndex("1.9.1")],
aliasNames: ["D3RO-Voice-1.9.1-x64-portable.zip.001", "install-d3ro-voice.ps1", "portable.json"],
},
],
}),
/HTTP 524/,
);
assert.equal(registry.packages.get("portable-latest").get("portable.json"), portableJson("1.9.0"));
assert.equal(registry.packages.get("portable-latest").get("D3RO-Voice-1.9.0-x64-portable.zip.001"), "zip190");
});
// ── 유스케이스: 재실행 복구 ────────────────────────────────────────────────
test("a CI re-run with rebuilt bytes restores an alias left without its index, then still fails closed", async () => {
const publishedIndex = runtimeJson("1.9.1", "2026-09-28T00:00:00.000Z");
const registry = forgejoLikeRegistry({
"runtime-1.9.1": {
"d3ro-runtime-sidecar.tar.gz.001": "sidecar-original",
"runtime.json": publishedIndex,
},
// 이전 실행이 runtime.json DELETE 뒤 PUT에서 끊겼다.
"runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "legacy-alias-part" },
});
await assert.rejects(
publishRuntime(registry, "1.9.1", [
payload("d3ro-runtime-sidecar.tar.gz.001", "sidecar-rebuilt"),
runtimeIndex("1.9.1", "2026-09-28T01:00:00.000Z"),
]),
(error) =>
error instanceof PortablePublishError &&
/runtime-1\.9\.1/.test(error.message) &&
/runtime-latest/.test(error.message),
);
assert.equal(registry.packages.get("runtime-latest").get("runtime.json"), publishedIndex);
assert.equal(registry.packages.get("runtime-latest").has("d3ro-runtime-sidecar.tar.gz.001"), false);
assert.equal(
registry.calls.some(([op, path]) => op !== "list" && op !== "read" && path === "runtime-1.9.1"),
false,
"the immutable versioned package is never written",
);
assert.equal(registry.packages.get("runtime-1.9.1").get("d3ro-runtime-sidecar.tar.gz.001"), "sidecar-original");
});
test("re-run recovery never rolls back a newer alias nor restores from an incomplete versioned package", async () => {
const newer = runtimeJson("1.9.2");
const rolledForward = forgejoLikeRegistry({
"runtime-1.9.1": { "d3ro-runtime-sidecar.tar.gz.001": "p", "runtime.json": runtimeJson("1.9.1", "A") },
"runtime-latest": { "runtime.json": newer },
});
await assert.rejects(
publishRuntime(rolledForward, "1.9.1", [payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt"), runtimeIndex("1.9.1", "B")]),
PortablePublishError,
);
assert.equal(rolledForward.packages.get("runtime-latest").get("runtime.json"), newer);
assert.equal(rolledForward.calls.some(([op]) => op === "upload" || op.startsWith("delete")), false);
const incomplete = forgejoLikeRegistry({
"runtime-1.9.1": { "d3ro-runtime-sidecar.tar.gz.001": "p" },
"runtime-latest": { "runtime.json": runtimeJson("1.9.0") },
});
await assert.rejects(
publishRuntime(incomplete, "1.9.1", [payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt"), runtimeIndex("1.9.1", "B")]),
PortablePublishError,
);
assert.equal(incomplete.packages.get("runtime-latest").get("runtime.json"), runtimeJson("1.9.0"));
assert.equal(incomplete.calls.some(([op]) => op === "upload" || op.startsWith("delete")), false);
});
test("a conflict in one package blocks every versioned upload (no partial new version)", async () => {
const registry = forgejoLikeRegistry({
"portable-1.9.1": { "D3RO-Voice-1.9.1-x64-portable.7z.001": "scoop-pinned" },
});
await assert.rejects(
publishPortablePackages({
version: "1.9.1",
registry,
log: () => {},
packages: [
{ kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["runtime.json"] },
{ kind: "portable", indexName: "portable.json", payloads: [payload("D3RO-Voice-1.9.1-x64-portable.7z.001", "rebuilt"), portableIndex("1.9.1")] },
],
}),
/portable-1\.9\.1/,
);
assert.equal(registry.calls.some(([op]) => op === "upload"), false);
});
test("an alias set without the index is rejected before any IO", async () => {
const registry = forgejoLikeRegistry();
await assert.rejects(
publishPortablePackages({
version: "1.9.1",
registry,
log: () => {},
packages: [{ kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["p"] }],
}),
(error) => error instanceof PortablePublishError && /runtime\.json/.test(error.message),
);
assert.equal(registry.calls.length, 0);
});
test("dry run lists only alias files for the alias path", async () => {
const lines = [];
await publishPortablePackages({
version: "1.9.1",
registry: forgejoLikeRegistry(),
dryRun: true,
log: (line) => lines.push(line),
packages: [
{ kind: "runtime", indexName: "runtime.json", payloads: [payload("p", "p"), runtimeIndex("1.9.1")], aliasNames: ["runtime.json"] },
],
});
assert.deepEqual(lines.map((line) => line.split(" ")[2]), ["runtime-1.9.1/p", "runtime-1.9.1/runtime.json", "runtime-latest/runtime.json"]);
});
// ── IO 어댑터 / 조립 ──────────────────────────────────────────────────────
test("withFileDeletion deletes a single file and fails closed on non-404 errors", async () => {
const seen = [];
const statuses = { "a.json": 204, "gone.json": 404, "boom.json": 500 };
const fetchImpl = async (url, init = {}) => {
seen.push(`${init.method} ${url}`);
return new Response(null, { status: statuses[url.split("/").pop()] });
};
const base = { fileUrl: (path, name) => `https://feed.test/${path}/${name}`, marker: 1 };
const registry = withFileDeletion(base, { fetchImpl });
assert.equal(registry.marker, 1);
await registry.deleteFile("runtime-latest", "a.json");
await registry.deleteFile("runtime-latest", "gone.json");
await assert.rejects(registry.deleteFile("runtime-latest", "boom.json"), /HTTP 500/);
assert.deepEqual(seen, [
"DELETE https://feed.test/runtime-latest/a.json",
"DELETE https://feed.test/runtime-latest/gone.json",
"DELETE https://feed.test/runtime-latest/boom.json",
]);
});
test("the portable publisher swaps alias files individually and keeps parts off runtime-latest", () => {
const source = readFileSync(new URL("../publish-portable-release.mjs", import.meta.url), "utf8");
assert.match(source, /withFileDeletion\(/);
assert.match(source, /aliasNames:\s*\[\s*'runtime\.json'\s*\]/);
assert.match(source, /aliasNames:\s*portableAliasNames/);
// 인덱스가 버전 경로에서도 마지막(설치 스크립트 뒤)이어야 재실행 복구가 완성 여부를 판단할 수 있다.
assert.ok(source.indexOf("name: 'install-d3ro-voice.ps1'") < source.indexOf("name: 'portable.json'"));
assert.doesNotMatch(source, /method:\s*['"](?:PUT|DELETE)['"]/);
});