303 lines
15 KiB
JavaScript
303 lines
15 KiB
JavaScript
// Execute the actual Files client and Core HTTP transport. Only browser I/O
|
|
// (fetch, XHR, timers, document cookies) and temporal context are substituted.
|
|
// No source slicing, regex assertions, live credentials, or server is involved.
|
|
import assert from "node:assert/strict";
|
|
import { randomUUID } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import vm from "node:vm";
|
|
|
|
const coreRoot = new URL("../../../govoplan-core/webui/", import.meta.url);
|
|
const require = createRequire(new URL("package.json", coreRoot));
|
|
const { transformSync } = require("esbuild");
|
|
const compile = (url) => transformSync(readFileSync(url, "utf8"), {
|
|
loader: "ts", format: "cjs", target: "es2022", sourcefile: url.pathname,
|
|
define: { "import.meta.env": "{}" }
|
|
}).code;
|
|
const coreCode = compile(new URL("src/api/client.ts", coreRoot));
|
|
const filesCode = compile(new URL("../src/api/files.ts", import.meta.url));
|
|
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
|
const plain = (value) => JSON.parse(JSON.stringify(value));
|
|
const settings = { apiBaseUrl: "https://fixture.invalid", accessToken: "", apiKey: "" };
|
|
const destination = { owner_type: "user", owner_id: "fixture-user", path: "imports" };
|
|
const previewResponse = (changes = {}) => ({
|
|
preview_token: "preview-token", staged_upload_id: "stage-one", expires_at: "2099-01-01T00:00:00Z",
|
|
entries: [], file_count: 1, directory_count: 0, archive_format: "zip",
|
|
requires_password: false, password_verified: true, compressed_size_bytes: 100,
|
|
expanded_size_bytes: 10, ...changes
|
|
});
|
|
const json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
status, headers: { "content-type": "application/json" }
|
|
});
|
|
const deferred = () => {
|
|
let resolve;
|
|
const promise = new Promise((complete) => { resolve = complete; });
|
|
return { promise, resolve };
|
|
};
|
|
|
|
function harness() {
|
|
const requests = [];
|
|
const xhrRequests = [];
|
|
const timers = new Map();
|
|
let timerId = 0;
|
|
let now = Date.parse("2026-09-07T10:00:00Z");
|
|
let onFetch = () => { throw new Error("Unexpected fetch"); };
|
|
let onXhr = () => { throw new Error("Unexpected XHR"); };
|
|
class ClockDate extends Date { static now() { return now; } }
|
|
class FakeXhr {
|
|
upload = {};
|
|
headers = new Headers();
|
|
open(method, url) { this.method = method; this.url = url; }
|
|
setRequestHeader(name, value) { this.headers.set(name, value); }
|
|
getResponseHeader(name) { return name.toLowerCase() === "content-type" ? "application/json" : null; }
|
|
send(body) { this.body = body; xhrRequests.push(this); onXhr(this); }
|
|
progress(loaded, total, lengthComputable = true) { this.upload.onprogress?.({ loaded, total, lengthComputable }); }
|
|
respond(body, status = 200) { this.status = status; this.statusText = status === 200 ? "OK" : "Error"; this.responseText = JSON.stringify(body); this.onload?.(); }
|
|
}
|
|
const context = vm.createContext({
|
|
File, Blob, FormData, Headers, Response, URL, URLSearchParams, AbortController,
|
|
Date: ClockDate, crypto: { randomUUID }, XMLHttpRequest: FakeXhr,
|
|
document: { cookie: "" }, console,
|
|
fetch: async (url, init) => {
|
|
const request = { url, path: new URL(url).pathname, method: init?.method ?? "GET", ...init };
|
|
requests.push(request);
|
|
return onFetch(request);
|
|
},
|
|
setTimeout: (callback, delay) => { const id = ++timerId; timers.set(id, { callback, at: now + delay }); return id; },
|
|
clearTimeout: (id) => timers.delete(id)
|
|
});
|
|
function load(code, dependencies) {
|
|
context.module = { exports: {} };
|
|
context.exports = context.module.exports;
|
|
context.require = (name) => {
|
|
assert.ok(name in dependencies, `Unexpected client import: ${name}`);
|
|
return dependencies[name];
|
|
};
|
|
vm.runInContext(`(function(module, exports, require) {\n${code}\n})(module, exports, require);`, context);
|
|
return context.module.exports;
|
|
}
|
|
const core = load(coreCode, { "../platform/temporal": { temporalRequestHeaders: () => ({}) } });
|
|
const api = load(filesCode, { "@govoplan/core-webui": core });
|
|
return {
|
|
api, core, requests, xhrRequests, timers,
|
|
fetch(handler) { onFetch = handler; },
|
|
xhr(handler) { onXhr = handler; },
|
|
async advance(milliseconds) {
|
|
const target = now + milliseconds;
|
|
while (true) {
|
|
const next = [...timers.entries()].filter(([, timer]) => timer.at <= target).sort((left, right) => left[1].at - right[1].at)[0];
|
|
if (!next) break;
|
|
now = next[1].at;
|
|
timers.delete(next[0]);
|
|
next[1].callback();
|
|
await settle();
|
|
}
|
|
now = target;
|
|
await settle();
|
|
}
|
|
};
|
|
}
|
|
|
|
let passed = 0;
|
|
async function test(name, run) {
|
|
await run();
|
|
passed += 1;
|
|
console.log(`PASS ${name}`);
|
|
}
|
|
|
|
await test("locally expired stage is released and preview reuploads the original file", async () => {
|
|
const env = harness();
|
|
const file = new File(["archive fixture"], "fixture.zip");
|
|
let previews = 0;
|
|
env.fetch((request) => {
|
|
if (request.method === "DELETE") return new Response(null, { status: 204 });
|
|
assert.equal(request.path, "/api/v1/files/archive-preview");
|
|
assert.equal(request.body.get("file"), file);
|
|
assert.equal(request.body.has("staged_upload_id"), false);
|
|
return json(previewResponse(++previews === 1 ? { expires_at: "2026-09-07T10:00:01Z" } : { staged_upload_id: "stage-two" }));
|
|
});
|
|
await env.api.previewArchiveUpload(settings, file, destination);
|
|
await env.advance(1100);
|
|
const result = await env.api.previewArchiveUpload(settings, file, destination);
|
|
assert.equal(previews, 2);
|
|
assert.equal(result.staged_upload_id, "stage-two");
|
|
assert.equal(env.requests.filter((request) => request.method === "DELETE").length, 1);
|
|
assert.equal(env.requests.filter((request) => request.path.endsWith("archive-confirm")).length, 0);
|
|
});
|
|
|
|
await test("server-expired cached preview retries only the read-only preview once with file bytes", async () => {
|
|
const env = harness();
|
|
const file = new File(["archive fixture"], "fixture.zip");
|
|
let calls = 0;
|
|
env.fetch((request) => {
|
|
assert.equal(request.path, "/api/v1/files/archive-preview");
|
|
calls += 1;
|
|
if (calls === 2) {
|
|
assert.equal(request.body.get("staged_upload_id"), "stage-one");
|
|
assert.equal(request.body.get("preview_token"), "preview-token");
|
|
assert.equal(request.body.has("file"), false);
|
|
return json({ detail: "Archive preview expired" }, 410);
|
|
}
|
|
assert.equal(request.body.get("file"), file);
|
|
assert.equal(request.body.has("staged_upload_id"), false);
|
|
return json(previewResponse({ staged_upload_id: calls === 1 ? "stage-one" : "stage-two" }));
|
|
});
|
|
await env.api.previewArchiveUpload(settings, file, destination);
|
|
assert.equal((await env.api.previewArchiveUpload(settings, file, destination)).staged_upload_id, "stage-two");
|
|
assert.equal(calls, 3, "one initial preview, one expired-stage attempt, one safe reupload");
|
|
});
|
|
|
|
await test("a failed fresh reupload cannot recurse into repeated preview attempts", async () => {
|
|
const env = harness();
|
|
const file = new File(["archive fixture"], "fixture.zip");
|
|
let calls = 0;
|
|
env.fetch(() => ++calls === 1 ? json(previewResponse()) : json({ detail: "Expired" }, 410));
|
|
await env.api.previewArchiveUpload(settings, file, destination);
|
|
await assert.rejects(env.api.previewArchiveUpload(settings, file, destination), (error) => error instanceof env.core.ApiError && error.status === 410);
|
|
assert.equal(calls, 3);
|
|
});
|
|
|
|
await test("failed confirmation never automatically retries or falls back to a file reupload", async () => {
|
|
for (const status of [400, 410, 500]) {
|
|
const env = harness();
|
|
const file = new File(["archive fixture"], "fixture.zip");
|
|
env.fetch((request) => request.path.endsWith("archive-preview") ? json(previewResponse()) : json({ detail: "Confirmation failed" }, status));
|
|
const preview = await env.api.previewArchiveUpload(settings, file, destination);
|
|
await assert.rejects(env.api.confirmArchiveUpload(settings, file, { ...destination, preview_token: preview.preview_token, selected_paths: ["one.txt"] }), (error) => error instanceof env.core.ApiError && error.status === status);
|
|
const confirmations = env.requests.filter((request) => request.path.endsWith("archive-confirm"));
|
|
assert.equal(confirmations.length, 1);
|
|
assert.equal(confirmations[0].body.get("staged_upload_id"), "stage-one");
|
|
assert.equal(confirmations[0].body.has("file"), false);
|
|
assert.equal(env.requests.length, 2);
|
|
}
|
|
});
|
|
|
|
await test("progress polling failures neither reject nor retry the authoritative confirmation POST", async () => {
|
|
for (const failure of [404, 500, "network"]) {
|
|
const env = harness();
|
|
const pending = deferred();
|
|
const events = [];
|
|
env.fetch((request) => {
|
|
if (request.method === "POST") return pending.promise;
|
|
if (failure === "network") throw new TypeError("Offline telemetry");
|
|
return json({ detail: "Telemetry unavailable" }, failure);
|
|
});
|
|
const confirmation = env.api.confirmManagedArchive(settings, "managed-file", {
|
|
...destination, source_version_id: "version-one", preview_token: "token", selected_paths: ["one.txt"],
|
|
onArchiveProgress: (event) => events.push(event)
|
|
});
|
|
let settled = false;
|
|
void confirmation.then(() => { settled = true; });
|
|
await env.advance(1200);
|
|
assert.equal(settled, false);
|
|
assert.equal(events.length, 0);
|
|
assert.equal(env.requests.filter((request) => request.method === "POST").length, 1);
|
|
const polls = env.requests.filter((request) => request.method === "GET");
|
|
assert.ok(polls.length >= 2);
|
|
assert.ok(polls.every((request) => request.cache === "no-store"));
|
|
pending.resolve(json({ files: [{ id: "one", size_bytes: 12345 }, { id: "two", size_bytes: 678 }] }));
|
|
const result = await confirmation;
|
|
assert.equal(result.files.length, 2);
|
|
assert.deepEqual(plain(events.at(-1)), { phase: "complete", status: "complete", completed_files: 2, total_files: 2, completed_bytes: 13023, total_bytes: 13023 });
|
|
assert.equal(env.timers.size, 0);
|
|
assert.ok(polls.every((request) => request.signal.aborted));
|
|
await env.advance(2000);
|
|
assert.equal(env.requests.filter((request) => request.method === "POST").length, 1);
|
|
assert.equal(env.requests.filter((request) => request.method === "GET").length, polls.length);
|
|
}
|
|
});
|
|
|
|
await test("confirmation errors stop polling without fabricating a successful completion", async () => {
|
|
const env = harness();
|
|
const pending = deferred();
|
|
const events = [];
|
|
env.fetch((request) => request.method === "POST" ? pending.promise : json({ detail: "Not yet available" }, 404));
|
|
const confirmation = env.api.confirmManagedArchive(settings, "managed-file", {
|
|
...destination, source_version_id: "version-one", preview_token: "token", selected_paths: ["one.txt"],
|
|
onArchiveProgress: (event) => events.push(event)
|
|
});
|
|
const rejected = assert.rejects(confirmation, (error) => error instanceof env.core.ApiError && error.status === 409);
|
|
await env.advance(200);
|
|
pending.resolve(json({ detail: "Destination conflict" }, 409));
|
|
await rejected;
|
|
assert.equal(events.length, 0);
|
|
assert.equal(env.timers.size, 0);
|
|
assert.equal(env.requests.filter((request) => request.method === "POST").length, 1);
|
|
});
|
|
|
|
await test("a late in-flight poll cannot replace the authoritative successful result", async () => {
|
|
const env = harness();
|
|
const post = deferred();
|
|
const poll = deferred();
|
|
const events = [];
|
|
env.fetch((request) => request.method === "POST" ? post.promise : poll.promise);
|
|
const confirmation = env.api.confirmManagedArchive(settings, "managed-file", {
|
|
...destination, source_version_id: "version-one", preview_token: "token", selected_paths: ["one.txt"],
|
|
onArchiveProgress: (event) => events.push(event)
|
|
});
|
|
await env.advance(200);
|
|
assert.equal(env.requests.filter((request) => request.method === "GET").length, 1);
|
|
post.resolve(json({ files: [{ id: "one", size_bytes: 12345 }] }));
|
|
await confirmation;
|
|
poll.resolve(json({ phase: "extracting", status: "running", completed_files: 0, total_files: 1, completed_bytes: 10, total_bytes: 12345 }));
|
|
await settle();
|
|
assert.equal(events.length, 1);
|
|
assert.equal(events[0].status, "complete");
|
|
assert.equal(events[0].completed_bytes, 12345);
|
|
assert.equal(env.timers.size, 0);
|
|
});
|
|
|
|
await test("XHR preview reports measured byte counters and preserves them at transfer completion", async () => {
|
|
const env = harness();
|
|
const file = new File(["archive fixture"], "fixture.zip");
|
|
const events = [];
|
|
env.xhr((xhr) => {
|
|
assert.equal(xhr.body.get("file"), file);
|
|
assert.equal(xhr.withCredentials, true);
|
|
xhr.progress(1578, 4096);
|
|
xhr.progress(4096, 4096);
|
|
xhr.respond(previewResponse());
|
|
});
|
|
await env.api.previewArchiveUpload(settings, file, { ...destination, onProgress: (event) => events.push(event) });
|
|
assert.deepEqual(plain(events.slice(1)), [
|
|
{ loaded: 1578, total: 4096, percentage: 39 },
|
|
{ loaded: 4096, total: 4096, percentage: 100 },
|
|
{ loaded: 4096, total: 4096, percentage: 100 }
|
|
]);
|
|
assert.equal(env.xhrRequests.length, 1);
|
|
assert.equal(env.requests.length, 0);
|
|
});
|
|
|
|
await test("unknown-length XHR uploads never fabricate total bytes", async () => {
|
|
const env = harness();
|
|
const events = [];
|
|
env.xhr((xhr) => { xhr.progress(3917, 0, false); xhr.respond(previewResponse()); });
|
|
await env.api.previewArchiveUpload(settings, new File(["fixture"], "fixture.zip"), { ...destination, onProgress: (event) => events.push(event) });
|
|
assert.equal(events.at(-2).percentage, null);
|
|
assert.equal(events.at(-1).loaded, 3917);
|
|
assert.equal(events.at(-1).total, undefined);
|
|
assert.equal(events.at(-1).percentage, 100);
|
|
});
|
|
|
|
await test("password repreview and confirmation reuse staging without another XHR transfer", async () => {
|
|
const env = harness();
|
|
const file = new File(["archive fixture"], "fixture.zip");
|
|
env.xhr((xhr) => { xhr.progress(1024, 1024); xhr.respond(previewResponse()); });
|
|
env.fetch((request) => {
|
|
assert.equal(request.body.get("staged_upload_id"), "stage-one");
|
|
assert.equal(request.body.has("file"), false);
|
|
assert.equal(request.body.get("password"), "fixture-only-password");
|
|
return request.path.endsWith("archive-preview") ? json(previewResponse({ preview_token: "verified-token" })) : json({ files: [] });
|
|
});
|
|
await env.api.previewArchiveUpload(settings, file, { ...destination, onProgress: () => {} });
|
|
const verified = await env.api.previewArchiveUpload(settings, file, { ...destination, password: "fixture-only-password", onProgress: () => assert.fail("Staged repreview must not claim another upload") });
|
|
await env.api.confirmArchiveUpload(settings, file, {
|
|
...destination, password: "fixture-only-password", preview_token: verified.preview_token, selected_paths: ["one.txt"],
|
|
onProgress: () => assert.fail("Staged confirmation must not claim another upload")
|
|
});
|
|
assert.equal(env.xhrRequests.length, 1);
|
|
assert.equal(env.requests.length, 2);
|
|
});
|
|
|
|
console.log(`Archive client behavior: ${passed} tests passed using real Files and Core transport code.`);
|