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.
423 lines
18 KiB
JavaScript
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)['"]/);
|
|
});
|