refactor: 프론트엔드 리펙토링
This commit is contained in:
@@ -200,6 +200,53 @@ describe("bounded body reader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("abandons a pending bounded read when the operation is aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
const reader = {
|
||||
read: vi.fn(() => new Promise<never>(() => {})),
|
||||
cancel: vi.fn().mockRejectedValue(new Error("cancel ignored")),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = {
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response;
|
||||
|
||||
const pending = readBoundedBytes(response, 8, controller.signal);
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_STREAM_FAILURE",
|
||||
});
|
||||
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("abandons a pending forbidden-body probe when the operation is aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
const reader = {
|
||||
read: vi.fn(() => new Promise<never>(() => {})),
|
||||
cancel: vi.fn().mockRejectedValue(new Error("cancel ignored")),
|
||||
releaseLock: vi.fn(),
|
||||
};
|
||||
const response = {
|
||||
headers: new Headers(),
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response;
|
||||
|
||||
const pending = probeForbiddenBody(response, controller.signal);
|
||||
controller.abort();
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_STREAM_FAILURE",
|
||||
});
|
||||
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => {
|
||||
expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({
|
||||
ok: true,
|
||||
|
||||
@@ -73,12 +73,17 @@ function labelledFileInput(): HTMLInputElement {
|
||||
describe("browser file pickers", () => {
|
||||
it("treats native input cancellation as a normal dismissed outcome", async () => {
|
||||
const input = labelledFileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {
|
||||
queueMicrotask(() => input.dispatchEvent(new Event("cancel")));
|
||||
}),
|
||||
});
|
||||
const baselineShowPicker = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
configurable: true,
|
||||
value: baselineShowPicker,
|
||||
});
|
||||
const harness = createHarness();
|
||||
const picker = new NativeInputFilePicker({
|
||||
input,
|
||||
@@ -90,6 +95,7 @@ describe("browser file pickers", () => {
|
||||
ok: true,
|
||||
value: { kind: "DISMISSED" },
|
||||
});
|
||||
expect(baselineShowPicker).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets the native input and supports same-file reselection", async () => {
|
||||
@@ -102,12 +108,12 @@ describe("browser file pickers", () => {
|
||||
configurable: true,
|
||||
value: [selected],
|
||||
});
|
||||
const showPicker = vi.fn(() => {
|
||||
const click = vi.fn(() => {
|
||||
queueMicrotask(() => input.dispatchEvent(new Event("change")));
|
||||
});
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: showPicker,
|
||||
value: click,
|
||||
});
|
||||
let sequence = 0;
|
||||
const { policies, vault } = createHarness(policyDefinition, {
|
||||
@@ -130,14 +136,14 @@ describe("browser file pickers", () => {
|
||||
ok: true,
|
||||
value: { kind: "SELECTED" },
|
||||
});
|
||||
expect(showPicker).toHaveBeenCalledTimes(2);
|
||||
expect(click).toHaveBeenCalledTimes(2);
|
||||
expect(input.value).toBe("");
|
||||
expect(vault.activeReferenceCount).toBe(2);
|
||||
});
|
||||
|
||||
it("snapshots selection policy before awaiting picker events", async () => {
|
||||
const input = labelledFileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
@@ -186,7 +192,7 @@ describe("browser file pickers", () => {
|
||||
|
||||
it("honors AbortSignal while a native dialog is pending", async () => {
|
||||
const input = labelledFileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
@@ -215,7 +221,7 @@ describe("browser file pickers", () => {
|
||||
let fallback: (() => void) | undefined;
|
||||
let delay: number | undefined;
|
||||
const clearTimeout = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
@@ -261,7 +267,7 @@ describe("browser file pickers", () => {
|
||||
const input = labelledFileInput();
|
||||
let fallback: (() => void) | undefined;
|
||||
const clearTimeout = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
@@ -316,14 +322,14 @@ describe("browser file pickers", () => {
|
||||
setTimeout: originalSetTimeout,
|
||||
clearTimeout: originalClearTimeout,
|
||||
};
|
||||
const originalShowPicker = vi.fn(() => {
|
||||
const originalClick = vi.fn(() => {
|
||||
focus?.(new Event("focus"));
|
||||
});
|
||||
const replacedShowPicker = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
const replacedClick = vi.fn();
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalShowPicker,
|
||||
value: originalClick,
|
||||
});
|
||||
const harness = createHarness();
|
||||
const picker = new NativeInputFilePicker({
|
||||
@@ -338,7 +344,7 @@ describe("browser file pickers", () => {
|
||||
focusFallbackGraceMs: 0,
|
||||
});
|
||||
|
||||
input.showPicker = replacedShowPicker;
|
||||
input.click = replacedClick;
|
||||
windowHost.addEventListener = vi.fn();
|
||||
windowHost.removeEventListener = vi.fn();
|
||||
scheduler.setTimeout = vi.fn();
|
||||
@@ -348,8 +354,8 @@ describe("browser file pickers", () => {
|
||||
ok: true,
|
||||
value: { kind: "DISMISSED" },
|
||||
});
|
||||
expect(originalShowPicker).toHaveBeenCalledOnce();
|
||||
expect(replacedShowPicker).not.toHaveBeenCalled();
|
||||
expect(originalClick).toHaveBeenCalledOnce();
|
||||
expect(replacedClick).not.toHaveBeenCalled();
|
||||
expect(originalWindowAdd).toHaveBeenCalledOnce();
|
||||
expect(originalWindowRemove).toHaveBeenCalledOnce();
|
||||
expect(originalSetTimeout).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -178,7 +178,7 @@ describe("browser file runtime hard limits and disposal", () => {
|
||||
|
||||
it("aborts a pending native picker and prevents event resurrection", async () => {
|
||||
const input = fileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -189,9 +189,9 @@ describe("CI gate contract", () => {
|
||||
),
|
||||
);
|
||||
expect(contract.jobs).toHaveLength(9);
|
||||
expect(contract.commands).toHaveLength(84);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(96);
|
||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
||||
expect(contract.commands).toHaveLength(91);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(103);
|
||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(30);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88);
|
||||
expect(contract.artifacts).toHaveLength(109);
|
||||
expect(contract.stages).toHaveLength(5);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
const COMPILED = Object.freeze([
|
||||
Object.freeze({ featureId: "reference-feature" }),
|
||||
Object.freeze({ featureId: "alpha-feature" }),
|
||||
Object.freeze({ featureId: "billing" }),
|
||||
]);
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("build-time product selection", () => {
|
||||
expect(
|
||||
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
|
||||
String(declared),
|
||||
).toEqual(["reference-feature", "billing"]);
|
||||
).toEqual(["alpha-feature", "billing"]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,10 +35,10 @@ describe("build-time product selection", () => {
|
||||
selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId),
|
||||
).toEqual(["billing"]);
|
||||
expect(
|
||||
selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map(
|
||||
selectCompiledProductFeatures(COMPILED, " billing , alpha-feature ").map(
|
||||
(f) => f.featureId,
|
||||
),
|
||||
).toEqual(["reference-feature", "billing"]);
|
||||
).toEqual(["alpha-feature", "billing"]);
|
||||
});
|
||||
|
||||
it("selects nothing only when asked explicitly", () => {
|
||||
@@ -79,13 +79,13 @@ describe("build-time product selection", () => {
|
||||
describe("runtime product feature resolution", () => {
|
||||
it("reports active, disabled and not-installed distinctly", () => {
|
||||
const statuses = resolveProductFeatures(
|
||||
["reference-feature", "billing"],
|
||||
["reference-feature"],
|
||||
{ "reference-feature": "DISABLED" },
|
||||
["alpha-feature", "billing"],
|
||||
["alpha-feature"],
|
||||
{ "alpha-feature": "DISABLED" },
|
||||
);
|
||||
expect(statuses).toEqual([
|
||||
{ featureId: "alpha-feature", state: "DISABLED_BY_CONFIG" },
|
||||
{ featureId: "billing", state: "NOT_INSTALLED" },
|
||||
{ featureId: "reference-feature", state: "DISABLED_BY_CONFIG" },
|
||||
]);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual([]);
|
||||
});
|
||||
@@ -103,11 +103,11 @@ describe("runtime product feature resolution", () => {
|
||||
// A shared runtime document may cover several builds, so a stale key is
|
||||
// inert rather than fatal.
|
||||
const statuses = resolveProductFeatures(
|
||||
["reference-feature"],
|
||||
["reference-feature"],
|
||||
["alpha-feature"],
|
||||
["alpha-feature"],
|
||||
{ analytics: "DISABLED" },
|
||||
);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual(["alpha-feature"]);
|
||||
});
|
||||
|
||||
it("leaves an installed feature active without an override", () => {
|
||||
@@ -140,15 +140,15 @@ describe("runtime config carries the switch", () => {
|
||||
expect(
|
||||
runtimeConfigV2ArtifactSchema.parse({
|
||||
...base,
|
||||
FEATURE_OVERRIDES: { "reference-feature": "DISABLED" },
|
||||
FEATURE_OVERRIDES: { "alpha-feature": "DISABLED" },
|
||||
}).FEATURE_OVERRIDES,
|
||||
).toEqual({ "reference-feature": "DISABLED" });
|
||||
).toEqual({ "alpha-feature": "DISABLED" });
|
||||
// There is no "ENABLED": the vocabulary itself is what makes the rule
|
||||
// unbreakable, not a check somewhere downstream.
|
||||
expect(
|
||||
runtimeConfigV2ArtifactSchema.safeParse({
|
||||
...base,
|
||||
FEATURE_OVERRIDES: { "reference-feature": "ENABLED" },
|
||||
FEATURE_OVERRIDES: { "alpha-feature": "ENABLED" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -212,11 +212,13 @@ describe("route ownership", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("owns exactly the routes the registry received from features", () => {
|
||||
it("owns exactly routes that exist in the composed registry", () => {
|
||||
const owned = Object.keys(ROUTE_FEATURE_OWNER);
|
||||
expect(owned.length).toBeGreaterThan(0);
|
||||
for (const routeId of owned) {
|
||||
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
|
||||
}
|
||||
expect(
|
||||
owned.every((routeId) => ROUTE_FEATURE_OWNER[routeId] !== undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
PublicCacheAsset,
|
||||
PublicCacheReleaseManifest,
|
||||
} from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts";
|
||||
import {
|
||||
createDefaultPublicCachePolicy,
|
||||
type PublicCacheRuntimePolicy,
|
||||
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
|
||||
import {
|
||||
computePublicCacheManifestDigestHex,
|
||||
type PublicCacheMutationLock,
|
||||
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
|
||||
|
||||
export class MemoryCache {
|
||||
readonly responses: Array<Readonly<{
|
||||
request: Request;
|
||||
response: Response;
|
||||
}>> = [];
|
||||
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
const url =
|
||||
request instanceof Request ? request.url : new URL(String(request)).href;
|
||||
const nativeRequest =
|
||||
request instanceof Request ? request : new Request(url);
|
||||
return this.responses
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.request.url === url &&
|
||||
varyMatches(entry.request, nativeRequest, entry.response),
|
||||
)
|
||||
?.response.clone();
|
||||
}
|
||||
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
const url =
|
||||
request instanceof Request ? request.url : new URL(String(request)).href;
|
||||
const nativeRequest =
|
||||
request instanceof Request ? request.clone() : new Request(url);
|
||||
const existing = this.responses.findIndex(
|
||||
(entry) =>
|
||||
entry.request.url === url &&
|
||||
varyMatches(entry.request, nativeRequest, response),
|
||||
);
|
||||
const entry = {
|
||||
request: nativeRequest,
|
||||
response: response.clone(),
|
||||
};
|
||||
if (existing >= 0) this.responses.splice(existing, 1, entry);
|
||||
else this.responses.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function varyMatches(
|
||||
storedRequest: Request,
|
||||
incomingRequest: Request,
|
||||
response: Response,
|
||||
): boolean {
|
||||
const vary = response.headers.get("vary");
|
||||
if (!vary) return true;
|
||||
return vary
|
||||
.split(",")
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.every(
|
||||
(name) =>
|
||||
storedRequest.headers.get(name) ===
|
||||
incomingRequest.headers.get(name),
|
||||
);
|
||||
}
|
||||
|
||||
export class MemoryCacheStorage {
|
||||
readonly caches = new Map<string, MemoryCache>();
|
||||
|
||||
async open(name: string): Promise<Cache> {
|
||||
let cache = this.caches.get(name);
|
||||
if (!cache) {
|
||||
cache = new MemoryCache();
|
||||
this.caches.set(name, cache);
|
||||
}
|
||||
return cache as unknown as Cache;
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
return [...this.caches.keys()];
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return this.caches.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
export const immediateLock: PublicCacheMutationLock = {
|
||||
async run(_signal, task) {
|
||||
return await task();
|
||||
},
|
||||
};
|
||||
|
||||
export function deferred<Value>() {
|
||||
let settle: ((value: Value) => void) | undefined;
|
||||
const promise = new Promise<Value>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return Object.freeze({
|
||||
promise,
|
||||
resolve(value: Value): void {
|
||||
settle?.(value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function digestHex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
Uint8Array.from(bytes),
|
||||
);
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function manifestFor(
|
||||
releaseRegistryId: string,
|
||||
assets: readonly PublicCacheAsset[],
|
||||
policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
),
|
||||
): Promise<PublicCacheReleaseManifest> {
|
||||
return {
|
||||
releaseRegistryId,
|
||||
assets,
|
||||
manifestDigestHex: await computePublicCacheManifestDigestHex(
|
||||
globalThis.crypto,
|
||||
releaseRegistryId,
|
||||
assets,
|
||||
policy,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PublicCacheAsset } from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts";
|
||||
import { createDefaultPublicCachePolicy } from "../../src/adapters/cache-storage/public-cache-policy.ts";
|
||||
import { createPublicResponseCacheAdapter } from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
|
||||
import {
|
||||
MemoryCacheStorage,
|
||||
digestHex,
|
||||
immediateLock,
|
||||
manifestFor,
|
||||
} from "./public-response-cache-fixture.ts";
|
||||
|
||||
/**
|
||||
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
|
||||
* the last thing a repair may destroy. A transient marker read failure is not
|
||||
* evidence of damage, and a repair that has not yet fetched anything has not
|
||||
* yet earned the right to delete what still works.
|
||||
*/
|
||||
describe("public response cache repair is failure-atomic", () => {
|
||||
async function stagedRelease(releaseRegistryId: string) {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const firstBytes = new Uint8Array([1, 1, 1, 1]);
|
||||
const secondBytes = new Uint8Array([2, 2, 2, 2]);
|
||||
const assets: readonly PublicCacheAsset[] = [
|
||||
{
|
||||
absoluteUrl: "https://assets.example.test/first.js",
|
||||
expectedByteLength: firstBytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(firstBytes),
|
||||
},
|
||||
},
|
||||
{
|
||||
absoluteUrl: "https://assets.example.test/second.js",
|
||||
expectedByteLength: secondBytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(secondBytes),
|
||||
},
|
||||
},
|
||||
];
|
||||
const bodies = new Map<string, Uint8Array>([
|
||||
[assets[0]!.absoluteUrl, firstBytes],
|
||||
[assets[1]!.absoluteUrl, secondBytes],
|
||||
]);
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const fetchLog: string[] = [];
|
||||
let failFrom: string | null = null;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async (request: Request) => {
|
||||
fetchLog.push(request.url);
|
||||
if (failFrom !== null && request.url === failFrom) {
|
||||
throw new TypeError("network is down");
|
||||
}
|
||||
const body = bodies.get(request.url);
|
||||
if (!body) throw new TypeError(`unknown asset ${request.url}`);
|
||||
return new Response(Uint8Array.from(body), {
|
||||
headers: {
|
||||
"cache-control": "public",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor(releaseRegistryId, assets, policy);
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(
|
||||
await adapter.admin.activateRelease(
|
||||
manifest.releaseRegistryId,
|
||||
manifest.manifestDigestHex,
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
|
||||
name.includes(releaseRegistryId),
|
||||
);
|
||||
if (!cacheName) throw new Error("staged cache missing");
|
||||
|
||||
return {
|
||||
adapter,
|
||||
assets,
|
||||
cacheName,
|
||||
cacheStorage,
|
||||
fetchLog,
|
||||
manifest,
|
||||
setFailure(url: string | null) {
|
||||
failFrom = url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("does not delete an active candidate when the marker read fails transiently", async () => {
|
||||
const release = await stagedRelease("transient-marker");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
const realMatch = cache.match.bind(cache);
|
||||
let markerReads = 0;
|
||||
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
|
||||
cache.match = async (request: RequestInfo | URL) => {
|
||||
const url =
|
||||
request instanceof Request ? request.url : String(request);
|
||||
if (!assetUrls.has(url)) {
|
||||
markerReads += 1;
|
||||
throw new DOMException("Storage is busy", "InvalidStateError");
|
||||
}
|
||||
return await realMatch(request);
|
||||
};
|
||||
|
||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
||||
|
||||
expect(markerReads).toBeGreaterThan(0);
|
||||
expect(restaged.ok).toBe(false);
|
||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
||||
cache.match = realMatch;
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("keeps every healthy asset when one repair fetch fails", async () => {
|
||||
const release = await stagedRelease("partial-repair");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
// Corrupt only the first asset's stored bytes.
|
||||
const corrupted = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
|
||||
);
|
||||
expect(corrupted).toBeGreaterThanOrEqual(0);
|
||||
cache.responses.splice(corrupted, 1);
|
||||
|
||||
release.setFailure(release.assets[0]!.absoluteUrl);
|
||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
||||
expect(restaged.ok).toBe(false);
|
||||
|
||||
// The cache still exists and the healthy asset is still served.
|
||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("still removes a candidate this call created when staging fails", async () => {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([7, 7, 7, 7]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/fresh.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(bytes),
|
||||
},
|
||||
};
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async () => {
|
||||
throw new TypeError("network is down");
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor("fresh-release", [asset], policy);
|
||||
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
expect(await cacheStorage.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("repairs an evicted asset in place and keeps the release usable", async () => {
|
||||
const release = await stagedRelease("in-place-repair");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
const evicted = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
|
||||
);
|
||||
cache.responses.splice(evicted, 1);
|
||||
|
||||
expect(
|
||||
await release.adapter.admin.stageRelease(release.manifest),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -10,135 +10,17 @@ import {
|
||||
type PublicCacheRuntimePolicy,
|
||||
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
|
||||
import {
|
||||
computePublicCacheManifestDigestHex,
|
||||
createPublicResponseCacheAdapter,
|
||||
type PublicCacheMutationLock,
|
||||
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
|
||||
|
||||
class MemoryCache {
|
||||
readonly responses: Array<Readonly<{
|
||||
request: Request;
|
||||
response: Response;
|
||||
}>> = [];
|
||||
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
const url =
|
||||
request instanceof Request ? request.url : new URL(String(request)).href;
|
||||
const nativeRequest =
|
||||
request instanceof Request ? request : new Request(url);
|
||||
return this.responses
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.request.url === url &&
|
||||
varyMatches(entry.request, nativeRequest, entry.response),
|
||||
)
|
||||
?.response.clone();
|
||||
}
|
||||
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
const url =
|
||||
request instanceof Request ? request.url : new URL(String(request)).href;
|
||||
const nativeRequest =
|
||||
request instanceof Request ? request.clone() : new Request(url);
|
||||
const existing = this.responses.findIndex(
|
||||
(entry) =>
|
||||
entry.request.url === url &&
|
||||
varyMatches(entry.request, nativeRequest, response),
|
||||
);
|
||||
const entry = {
|
||||
request: nativeRequest,
|
||||
response: response.clone(),
|
||||
};
|
||||
if (existing >= 0) this.responses.splice(existing, 1, entry);
|
||||
else this.responses.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function varyMatches(
|
||||
storedRequest: Request,
|
||||
incomingRequest: Request,
|
||||
response: Response,
|
||||
): boolean {
|
||||
const vary = response.headers.get("vary");
|
||||
if (!vary) return true;
|
||||
return vary
|
||||
.split(",")
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.every(
|
||||
(name) =>
|
||||
storedRequest.headers.get(name) ===
|
||||
incomingRequest.headers.get(name),
|
||||
);
|
||||
}
|
||||
|
||||
class MemoryCacheStorage {
|
||||
readonly caches = new Map<string, MemoryCache>();
|
||||
|
||||
async open(name: string): Promise<Cache> {
|
||||
let cache = this.caches.get(name);
|
||||
if (!cache) {
|
||||
cache = new MemoryCache();
|
||||
this.caches.set(name, cache);
|
||||
}
|
||||
return cache as unknown as Cache;
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
return [...this.caches.keys()];
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return this.caches.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
const immediateLock: PublicCacheMutationLock = {
|
||||
async run(_signal, task) {
|
||||
return await task();
|
||||
},
|
||||
};
|
||||
|
||||
function deferred<Value>() {
|
||||
let settle: ((value: Value) => void) | undefined;
|
||||
const promise = new Promise<Value>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return Object.freeze({
|
||||
promise,
|
||||
resolve(value: Value): void {
|
||||
settle?.(value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function digestHex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
Uint8Array.from(bytes),
|
||||
);
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function manifestFor(
|
||||
releaseRegistryId: string,
|
||||
assets: readonly PublicCacheAsset[],
|
||||
policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
),
|
||||
): Promise<PublicCacheReleaseManifest> {
|
||||
return {
|
||||
releaseRegistryId,
|
||||
assets,
|
||||
manifestDigestHex: await computePublicCacheManifestDigestHex(
|
||||
globalThis.crypto,
|
||||
releaseRegistryId,
|
||||
assets,
|
||||
policy,
|
||||
),
|
||||
};
|
||||
}
|
||||
import {
|
||||
MemoryCacheStorage,
|
||||
deferred,
|
||||
digestHex,
|
||||
immediateLock,
|
||||
manifestFor,
|
||||
} from "./public-response-cache-fixture.ts";
|
||||
|
||||
describe("public response Cache Storage adapter", () => {
|
||||
it("rejects a policy that enables variants but strips Vary", () => {
|
||||
@@ -1513,199 +1395,3 @@ describe("public response Cache Storage adapter", () => {
|
||||
expect(await cacheStorage.keys()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
|
||||
* the last thing a repair may destroy. A transient marker read failure is not
|
||||
* evidence of damage, and a repair that has not yet fetched anything has not
|
||||
* yet earned the right to delete what still works.
|
||||
*/
|
||||
describe("public response cache repair is failure-atomic", () => {
|
||||
async function stagedRelease(releaseRegistryId: string) {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const firstBytes = new Uint8Array([1, 1, 1, 1]);
|
||||
const secondBytes = new Uint8Array([2, 2, 2, 2]);
|
||||
const assets: readonly PublicCacheAsset[] = [
|
||||
{
|
||||
absoluteUrl: "https://assets.example.test/first.js",
|
||||
expectedByteLength: firstBytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(firstBytes),
|
||||
},
|
||||
},
|
||||
{
|
||||
absoluteUrl: "https://assets.example.test/second.js",
|
||||
expectedByteLength: secondBytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(secondBytes),
|
||||
},
|
||||
},
|
||||
];
|
||||
const bodies = new Map<string, Uint8Array>([
|
||||
[assets[0]!.absoluteUrl, firstBytes],
|
||||
[assets[1]!.absoluteUrl, secondBytes],
|
||||
]);
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const fetchLog: string[] = [];
|
||||
let failFrom: string | null = null;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async (request: Request) => {
|
||||
fetchLog.push(request.url);
|
||||
if (failFrom !== null && request.url === failFrom) {
|
||||
throw new TypeError("network is down");
|
||||
}
|
||||
const body = bodies.get(request.url);
|
||||
if (!body) throw new TypeError(`unknown asset ${request.url}`);
|
||||
return new Response(Uint8Array.from(body), {
|
||||
headers: {
|
||||
"cache-control": "public",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor(releaseRegistryId, assets, policy);
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(
|
||||
await adapter.admin.activateRelease(
|
||||
manifest.releaseRegistryId,
|
||||
manifest.manifestDigestHex,
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
|
||||
name.includes(releaseRegistryId),
|
||||
);
|
||||
if (!cacheName) throw new Error("staged cache missing");
|
||||
|
||||
return {
|
||||
adapter,
|
||||
assets,
|
||||
cacheName,
|
||||
cacheStorage,
|
||||
fetchLog,
|
||||
manifest,
|
||||
setFailure(url: string | null) {
|
||||
failFrom = url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("does not delete an active candidate when the marker read fails transiently", async () => {
|
||||
const release = await stagedRelease("transient-marker");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
const realMatch = cache.match.bind(cache);
|
||||
let markerReads = 0;
|
||||
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
|
||||
cache.match = async (request: RequestInfo | URL) => {
|
||||
const url =
|
||||
request instanceof Request ? request.url : String(request);
|
||||
if (!assetUrls.has(url)) {
|
||||
markerReads += 1;
|
||||
throw new DOMException("Storage is busy", "InvalidStateError");
|
||||
}
|
||||
return await realMatch(request);
|
||||
};
|
||||
|
||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
||||
|
||||
expect(markerReads).toBeGreaterThan(0);
|
||||
expect(restaged.ok).toBe(false);
|
||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
||||
cache.match = realMatch;
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("keeps every healthy asset when one repair fetch fails", async () => {
|
||||
const release = await stagedRelease("partial-repair");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
// Corrupt only the first asset's stored bytes.
|
||||
const corrupted = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
|
||||
);
|
||||
expect(corrupted).toBeGreaterThanOrEqual(0);
|
||||
cache.responses.splice(corrupted, 1);
|
||||
|
||||
release.setFailure(release.assets[0]!.absoluteUrl);
|
||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
||||
expect(restaged.ok).toBe(false);
|
||||
|
||||
// The cache still exists and the healthy asset is still served.
|
||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("still removes a candidate this call created when staging fails", async () => {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([7, 7, 7, 7]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/fresh.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(bytes),
|
||||
},
|
||||
};
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async () => {
|
||||
throw new TypeError("network is down");
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor("fresh-release", [asset], policy);
|
||||
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
expect(await cacheStorage.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("repairs an evicted asset in place and keeps the release usable", async () => {
|
||||
const release = await stagedRelease("in-place-repair");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
const evicted = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
|
||||
);
|
||||
cache.responses.splice(evicted, 1);
|
||||
|
||||
expect(
|
||||
await release.adapter.admin.stageRelease(release.manifest),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
import type {
|
||||
ResumableUploadCheckpoint,
|
||||
ResumableUploadCheckpointStore,
|
||||
ResumableUploadControlPlane,
|
||||
ResumableUploadSource,
|
||||
UploadPartExecutor,
|
||||
UploadPartReceipt,
|
||||
UploadProviderResult,
|
||||
UploadSession,
|
||||
} from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type {
|
||||
BrowserDataResult,
|
||||
} from "../../src/application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../src/adapters/browser-file-storage/result.ts";
|
||||
import { resolveResumableUploadRuntimePolicy } from "../../src/adapters/browser-transfer/resumable-upload/runtime-policy.ts";
|
||||
import type {
|
||||
UploadCancellationChannel,
|
||||
UploadCancellationListener,
|
||||
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
|
||||
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
||||
|
||||
export type TestCapability = Readonly<{ id: string }>;
|
||||
|
||||
export const activeSignal = new AbortController().signal;
|
||||
export const noContentionLock: UploadMutationLock = Object.freeze({
|
||||
async run<Value>(
|
||||
_uploadKey: string,
|
||||
_signal: AbortSignal,
|
||||
task: () => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
return await task();
|
||||
},
|
||||
});
|
||||
|
||||
export function createSerialMutationLock(): UploadMutationLock {
|
||||
let tail = Promise.resolve();
|
||||
return Object.freeze({
|
||||
run<Value>(
|
||||
_uploadKey: string,
|
||||
signal: AbortSignal,
|
||||
task: () => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
const result = tail.then(async () => {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException(
|
||||
"The operation was aborted.",
|
||||
"AbortError",
|
||||
);
|
||||
}
|
||||
return await task();
|
||||
});
|
||||
tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createMemoryCancellationPair(): readonly [
|
||||
UploadCancellationChannel,
|
||||
UploadCancellationChannel,
|
||||
] {
|
||||
const listeners = [
|
||||
new Set<UploadCancellationListener>(),
|
||||
new Set<UploadCancellationListener>(),
|
||||
] as const;
|
||||
const channels = listeners.map((ownListeners, ownIndex) => {
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
publish(uploadKey: string) {
|
||||
if (closed) return false;
|
||||
for (const [index, peerListeners] of listeners.entries()) {
|
||||
if (index === ownIndex) continue;
|
||||
for (const listener of [...peerListeners]) {
|
||||
listener(uploadKey);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
subscribe(listener: UploadCancellationListener) {
|
||||
if (closed) throw new TypeError("closed");
|
||||
ownListeners.add(listener);
|
||||
return () => ownListeners.delete(listener);
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
ownListeners.clear();
|
||||
},
|
||||
});
|
||||
});
|
||||
return channels as unknown as readonly [
|
||||
UploadCancellationChannel,
|
||||
UploadCancellationChannel,
|
||||
];
|
||||
}
|
||||
|
||||
export class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
|
||||
readonly rows = new Map<string, ResumableUploadCheckpoint>();
|
||||
closed = false;
|
||||
|
||||
async read(
|
||||
uploadKey: string,
|
||||
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
|
||||
return browserDataSuccess(
|
||||
structuredClone(this.rows.get(uploadKey) ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
async compareAndSwap(
|
||||
input: Parameters<
|
||||
ResumableUploadCheckpointStore["compareAndSwap"]
|
||||
>[0],
|
||||
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
|
||||
const current = this.rows.get(input.checkpoint.uploadKey);
|
||||
if (
|
||||
(input.expectedRevision === null && current) ||
|
||||
(input.expectedRevision !== null &&
|
||||
current?.revision !== input.expectedRevision)
|
||||
) {
|
||||
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
||||
recovery: "RECONCILE",
|
||||
});
|
||||
}
|
||||
const snapshot = structuredClone(input.checkpoint);
|
||||
this.rows.set(snapshot.uploadKey, snapshot);
|
||||
return browserDataSuccess(snapshot);
|
||||
}
|
||||
|
||||
async remove(
|
||||
input: Parameters<ResumableUploadCheckpointStore["remove"]>[0],
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
const current = this.rows.get(input.uploadKey);
|
||||
if (current?.revision !== input.expectedRevision) {
|
||||
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
||||
recovery: "RECONCILE",
|
||||
});
|
||||
}
|
||||
this.rows.delete(input.uploadKey);
|
||||
return browserDataSuccess(undefined);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function rangeSource(bytes: Uint8Array): ResumableUploadSource {
|
||||
return Object.freeze({
|
||||
kind: "RANGE_READER" as const,
|
||||
reader: Object.freeze({
|
||||
byteLength: bytes.byteLength,
|
||||
async readRange(input: Readonly<{
|
||||
offset: number;
|
||||
length: number;
|
||||
signal: AbortSignal;
|
||||
}>) {
|
||||
if (input.signal.aborted) {
|
||||
return browserDataFailure("ABORTED", "FILE_READ");
|
||||
}
|
||||
return browserDataSuccess(
|
||||
bytes.slice(input.offset, input.offset + input.length),
|
||||
);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function byteStreamSource(bytes: Uint8Array): ResumableUploadSource {
|
||||
return Object.freeze({
|
||||
kind: "FILE_BYTE_SOURCE" as const,
|
||||
bytes: Object.freeze({
|
||||
byteLength: bytes.byteLength,
|
||||
async *stream(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
yield browserDataFailure("ABORTED", "FILE_READ");
|
||||
return;
|
||||
}
|
||||
yield browserDataSuccess(bytes.slice(0, 3));
|
||||
yield browserDataSuccess(bytes.slice(3));
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function runtimePolicy(
|
||||
overrides: Partial<
|
||||
Parameters<typeof resolveResumableUploadRuntimePolicy>[0]
|
||||
> = {},
|
||||
) {
|
||||
return {
|
||||
partSizeBytes: 4,
|
||||
maxFileBytes: 100,
|
||||
maxPartCount: 25,
|
||||
maxConcurrency: 3,
|
||||
maxInFlightBytes: 48,
|
||||
partBufferCopyFactor: 4,
|
||||
maxSourceChunkBytes: 8,
|
||||
maxRetries: 2,
|
||||
retryBaseDelayMs: 1,
|
||||
retryMaxDelayMs: 10,
|
||||
maxRetryAfterMs: 100,
|
||||
capabilityRefreshSkewMs: 5,
|
||||
maxSessionLifetimeMs: 10_000,
|
||||
providerAttemptTimeoutMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export type ControlHarness = Readonly<{
|
||||
control: ResumableUploadControlPlane<TestCapability>;
|
||||
accepted: Map<number, UploadPartReceipt>;
|
||||
issued: ReturnType<typeof vi.fn>;
|
||||
completedParts: UploadPartReceipt[][];
|
||||
getSession(): UploadSession | null;
|
||||
}>;
|
||||
|
||||
export function createControlHarness(options: Readonly<{
|
||||
now?: number;
|
||||
serverMaxConcurrency?: number;
|
||||
sessionId?: (createIndex: number) => string;
|
||||
statusParts?: (
|
||||
session: UploadSession,
|
||||
accepted: Map<number, UploadPartReceipt>,
|
||||
) => readonly UploadPartReceipt[];
|
||||
issueCapability?: (
|
||||
input: Parameters<
|
||||
ResumableUploadControlPlane<TestCapability>["issuePartCapability"]
|
||||
>[0],
|
||||
callIndex: number,
|
||||
) => UploadProviderResult<Readonly<{
|
||||
capability: TestCapability;
|
||||
uploadBindingSha256: string;
|
||||
expiresAtEpochMs: number;
|
||||
}>>;
|
||||
}> = {}): ControlHarness {
|
||||
const now = options.now ?? 1_000;
|
||||
const accepted = new Map<number, UploadPartReceipt>();
|
||||
const completedParts: UploadPartReceipt[][] = [];
|
||||
let session: UploadSession | null = null;
|
||||
let createCount = 0;
|
||||
let issueCount = 0;
|
||||
const issued = vi.fn();
|
||||
const control: ResumableUploadControlPlane<TestCapability> = {
|
||||
async createSession(input) {
|
||||
createCount += 1;
|
||||
session = Object.freeze({
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId:
|
||||
options.sessionId?.(createCount) ?? "session_01",
|
||||
requestBindingSha256: input.requestBindingSha256,
|
||||
fingerprint: input.fingerprint,
|
||||
partSizeBytes: input.requestedPartSizeBytes,
|
||||
partCount: input.fingerprint.partCount,
|
||||
maxConcurrency: options.serverMaxConcurrency ?? 2,
|
||||
expiresAtEpochMs: now + 5_000,
|
||||
});
|
||||
return browserDataSuccess(session);
|
||||
},
|
||||
async getStatus() {
|
||||
if (!session) {
|
||||
return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE");
|
||||
}
|
||||
const parts =
|
||||
options.statusParts?.(session, accepted) ??
|
||||
[...accepted.values()].sort(
|
||||
(left, right) => left.partNumber - right.partNumber,
|
||||
);
|
||||
return browserDataSuccess({
|
||||
state: "ACTIVE",
|
||||
session,
|
||||
acceptedParts: parts,
|
||||
});
|
||||
},
|
||||
async issuePartCapability(input) {
|
||||
issueCount += 1;
|
||||
issued(input);
|
||||
return (
|
||||
options.issueCapability?.(input, issueCount) ??
|
||||
browserDataSuccess({
|
||||
capability: Object.freeze({ id: `cap-${issueCount}` }),
|
||||
uploadBindingSha256: input.uploadBindingSha256,
|
||||
expiresAtEpochMs: now + 4_000,
|
||||
})
|
||||
);
|
||||
},
|
||||
async complete(input) {
|
||||
completedParts.push([...input.orderedParts]);
|
||||
return browserDataSuccess({
|
||||
state: "QUARANTINED",
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: input.sessionId,
|
||||
requestBindingSha256: input.requestBindingSha256,
|
||||
fingerprint: input.fingerprint,
|
||||
resourceId: "resource_01",
|
||||
});
|
||||
},
|
||||
async abort() {
|
||||
return browserDataSuccess({ state: "ABORTED" });
|
||||
},
|
||||
};
|
||||
return {
|
||||
control,
|
||||
accepted,
|
||||
issued,
|
||||
completedParts,
|
||||
getSession: () => session,
|
||||
};
|
||||
}
|
||||
|
||||
export function executorFor(
|
||||
harness: ControlHarness,
|
||||
options: Readonly<{
|
||||
delay?: () => Promise<void>;
|
||||
onActive?: (active: number) => void;
|
||||
}> = {},
|
||||
): UploadPartExecutor<TestCapability> {
|
||||
let active = 0;
|
||||
return {
|
||||
async uploadPart(input) {
|
||||
active += 1;
|
||||
options.onActive?.(active);
|
||||
await options.delay?.();
|
||||
active -= 1;
|
||||
const receipt = Object.freeze({
|
||||
...input.part,
|
||||
receiptToken: `etag-part-${input.part.partNumber}`,
|
||||
});
|
||||
harness.accepted.set(input.part.partNumber, receipt);
|
||||
return browserDataSuccess(receipt);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { browserDataFailure } from "../../src/adapters/browser-file-storage/result.ts";
|
||||
import { createResumableUploadRuntime } from "../../src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts";
|
||||
import {
|
||||
activeSignal,
|
||||
byteStreamSource,
|
||||
createControlHarness,
|
||||
executorFor,
|
||||
MemoryCheckpointStore,
|
||||
noContentionLock,
|
||||
runtimePolicy,
|
||||
} from "./resumable-upload-runtime-fixture.ts";
|
||||
|
||||
/**
|
||||
* TR-RR-06. A non-cooperative mutation lock or provider must not make teardown
|
||||
* unbounded: `dispose()` bounds its drain and reports honestly when the runtime
|
||||
* is still CLOSING, and an abort is admitted physical work it cannot step over.
|
||||
*/
|
||||
describe("TR-RR-06 bounded resumable teardown", () => {
|
||||
it("reports an unproved drain instead of waiting forever", async () => {
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const harness = createControlHarness();
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness, { delay: async () => {} }),
|
||||
checkpoints,
|
||||
// A lock that never grants: dispose must still be bounded.
|
||||
mutationLock: Object.freeze({
|
||||
async run<Value>(): Promise<Value> {
|
||||
return await new Promise<never>(() => {});
|
||||
},
|
||||
}),
|
||||
crypto,
|
||||
policy: runtimePolicy({ cleanupDeadlineMs: 20 }),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
void runtime.upload({
|
||||
uploadKey: "upload_key_hung",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed.ok).toBe(false);
|
||||
// Still CLOSING: physical work the caller must not treat as finished.
|
||||
expect(runtime.lifecycle()).toBe("CLOSING");
|
||||
expect(checkpoints.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("closes once every admitted operation settles", async () => {
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const harness = createControlHarness();
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness, { delay: async () => {} }),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({ cleanupDeadlineMs: 200 }),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed).toMatchObject({ ok: true });
|
||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||
expect(checkpoints.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TR-04. `dispose()` proved quiescence from the wrapper registry only. A
|
||||
* provider that ignored its attempt deadline let the wrapper settle first and
|
||||
* leave the set empty, so teardown reported a drained runtime — and closed the
|
||||
* checkpoint store — while the provider was still running.
|
||||
*/
|
||||
describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => {
|
||||
it("refuses to report a drained runtime while a provider is still running", async () => {
|
||||
const harness = createControlHarness();
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const closeStore = vi.spyOn(checkpoints, "close");
|
||||
let releaseProvider: (() => void) | undefined;
|
||||
// Ignores the attempt signal entirely and outlives its own deadline.
|
||||
harness.control.createSession = () =>
|
||||
new Promise((resolve) => {
|
||||
releaseProvider = () =>
|
||||
resolve(
|
||||
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({
|
||||
providerAttemptTimeoutMs: 5,
|
||||
cleanupDeadlineMs: 25,
|
||||
maxRetries: 0,
|
||||
}),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const uploading = runtime.upload({
|
||||
uploadKey: "upload_key_raw",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
||||
// The wrapper has already given up on the attempt.
|
||||
await uploading;
|
||||
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed.ok).toBe(false);
|
||||
if (!disposed.ok) {
|
||||
expect(disposed.error.code).toBe("UNAVAILABLE");
|
||||
expect(disposed.error.recovery).toBe("RESUME");
|
||||
}
|
||||
// The store stays open while something could still write a checkpoint.
|
||||
expect(closeStore).not.toHaveBeenCalled();
|
||||
|
||||
releaseProvider?.();
|
||||
});
|
||||
|
||||
it("reports a drained runtime once the raw provider settles", async () => {
|
||||
const harness = createControlHarness();
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
let releaseProvider: (() => void) | undefined;
|
||||
harness.control.createSession = () =>
|
||||
new Promise((resolve) => {
|
||||
releaseProvider = () =>
|
||||
resolve(
|
||||
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({
|
||||
providerAttemptTimeoutMs: 5,
|
||||
cleanupDeadlineMs: 1_000,
|
||||
maxRetries: 0,
|
||||
}),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const uploading = runtime.upload({
|
||||
uploadKey: "upload_key_raw_2",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
||||
await uploading;
|
||||
|
||||
const disposing = runtime.dispose();
|
||||
releaseProvider?.();
|
||||
await expect(disposing).resolves.toMatchObject({ ok: true });
|
||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||
});
|
||||
});
|
||||
@@ -32,319 +32,19 @@ import type {
|
||||
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
|
||||
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
||||
|
||||
type TestCapability = Readonly<{ id: string }>;
|
||||
|
||||
const activeSignal = new AbortController().signal;
|
||||
const noContentionLock: UploadMutationLock = Object.freeze({
|
||||
async run<Value>(
|
||||
_uploadKey: string,
|
||||
_signal: AbortSignal,
|
||||
task: () => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
return await task();
|
||||
},
|
||||
});
|
||||
|
||||
function createSerialMutationLock(): UploadMutationLock {
|
||||
let tail = Promise.resolve();
|
||||
return Object.freeze({
|
||||
run<Value>(
|
||||
_uploadKey: string,
|
||||
signal: AbortSignal,
|
||||
task: () => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
const result = tail.then(async () => {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException(
|
||||
"The operation was aborted.",
|
||||
"AbortError",
|
||||
);
|
||||
}
|
||||
return await task();
|
||||
});
|
||||
tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMemoryCancellationPair(): readonly [
|
||||
UploadCancellationChannel,
|
||||
UploadCancellationChannel,
|
||||
] {
|
||||
const listeners = [
|
||||
new Set<UploadCancellationListener>(),
|
||||
new Set<UploadCancellationListener>(),
|
||||
] as const;
|
||||
const channels = listeners.map((ownListeners, ownIndex) => {
|
||||
let closed = false;
|
||||
return Object.freeze({
|
||||
publish(uploadKey: string) {
|
||||
if (closed) return false;
|
||||
for (const [index, peerListeners] of listeners.entries()) {
|
||||
if (index === ownIndex) continue;
|
||||
for (const listener of [...peerListeners]) {
|
||||
listener(uploadKey);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
subscribe(listener: UploadCancellationListener) {
|
||||
if (closed) throw new TypeError("closed");
|
||||
ownListeners.add(listener);
|
||||
return () => ownListeners.delete(listener);
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
ownListeners.clear();
|
||||
},
|
||||
});
|
||||
});
|
||||
return channels as unknown as readonly [
|
||||
UploadCancellationChannel,
|
||||
UploadCancellationChannel,
|
||||
];
|
||||
}
|
||||
|
||||
class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
|
||||
readonly rows = new Map<string, ResumableUploadCheckpoint>();
|
||||
closed = false;
|
||||
|
||||
async read(
|
||||
uploadKey: string,
|
||||
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
|
||||
return browserDataSuccess(
|
||||
structuredClone(this.rows.get(uploadKey) ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
async compareAndSwap(
|
||||
input: Parameters<
|
||||
ResumableUploadCheckpointStore["compareAndSwap"]
|
||||
>[0],
|
||||
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
|
||||
const current = this.rows.get(input.checkpoint.uploadKey);
|
||||
if (
|
||||
(input.expectedRevision === null && current) ||
|
||||
(input.expectedRevision !== null &&
|
||||
current?.revision !== input.expectedRevision)
|
||||
) {
|
||||
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
||||
recovery: "RECONCILE",
|
||||
});
|
||||
}
|
||||
const snapshot = structuredClone(input.checkpoint);
|
||||
this.rows.set(snapshot.uploadKey, snapshot);
|
||||
return browserDataSuccess(snapshot);
|
||||
}
|
||||
|
||||
async remove(
|
||||
input: Parameters<ResumableUploadCheckpointStore["remove"]>[0],
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
const current = this.rows.get(input.uploadKey);
|
||||
if (current?.revision !== input.expectedRevision) {
|
||||
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
||||
recovery: "RECONCILE",
|
||||
});
|
||||
}
|
||||
this.rows.delete(input.uploadKey);
|
||||
return browserDataSuccess(undefined);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function rangeSource(bytes: Uint8Array): ResumableUploadSource {
|
||||
return Object.freeze({
|
||||
kind: "RANGE_READER" as const,
|
||||
reader: Object.freeze({
|
||||
byteLength: bytes.byteLength,
|
||||
async readRange(input: Readonly<{
|
||||
offset: number;
|
||||
length: number;
|
||||
signal: AbortSignal;
|
||||
}>) {
|
||||
if (input.signal.aborted) {
|
||||
return browserDataFailure("ABORTED", "FILE_READ");
|
||||
}
|
||||
return browserDataSuccess(
|
||||
bytes.slice(input.offset, input.offset + input.length),
|
||||
);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function byteStreamSource(bytes: Uint8Array): ResumableUploadSource {
|
||||
return Object.freeze({
|
||||
kind: "FILE_BYTE_SOURCE" as const,
|
||||
bytes: Object.freeze({
|
||||
byteLength: bytes.byteLength,
|
||||
async *stream(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
yield browserDataFailure("ABORTED", "FILE_READ");
|
||||
return;
|
||||
}
|
||||
yield browserDataSuccess(bytes.slice(0, 3));
|
||||
yield browserDataSuccess(bytes.slice(3));
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function runtimePolicy(
|
||||
overrides: Partial<
|
||||
Parameters<typeof resolveResumableUploadRuntimePolicy>[0]
|
||||
> = {},
|
||||
) {
|
||||
return {
|
||||
partSizeBytes: 4,
|
||||
maxFileBytes: 100,
|
||||
maxPartCount: 25,
|
||||
maxConcurrency: 3,
|
||||
maxInFlightBytes: 48,
|
||||
partBufferCopyFactor: 4,
|
||||
maxSourceChunkBytes: 8,
|
||||
maxRetries: 2,
|
||||
retryBaseDelayMs: 1,
|
||||
retryMaxDelayMs: 10,
|
||||
maxRetryAfterMs: 100,
|
||||
capabilityRefreshSkewMs: 5,
|
||||
maxSessionLifetimeMs: 10_000,
|
||||
providerAttemptTimeoutMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type ControlHarness = Readonly<{
|
||||
control: ResumableUploadControlPlane<TestCapability>;
|
||||
accepted: Map<number, UploadPartReceipt>;
|
||||
issued: ReturnType<typeof vi.fn>;
|
||||
completedParts: UploadPartReceipt[][];
|
||||
getSession(): UploadSession | null;
|
||||
}>;
|
||||
|
||||
function createControlHarness(options: Readonly<{
|
||||
now?: number;
|
||||
serverMaxConcurrency?: number;
|
||||
sessionId?: (createIndex: number) => string;
|
||||
statusParts?: (
|
||||
session: UploadSession,
|
||||
accepted: Map<number, UploadPartReceipt>,
|
||||
) => readonly UploadPartReceipt[];
|
||||
issueCapability?: (
|
||||
input: Parameters<
|
||||
ResumableUploadControlPlane<TestCapability>["issuePartCapability"]
|
||||
>[0],
|
||||
callIndex: number,
|
||||
) => UploadProviderResult<Readonly<{
|
||||
capability: TestCapability;
|
||||
uploadBindingSha256: string;
|
||||
expiresAtEpochMs: number;
|
||||
}>>;
|
||||
}> = {}): ControlHarness {
|
||||
const now = options.now ?? 1_000;
|
||||
const accepted = new Map<number, UploadPartReceipt>();
|
||||
const completedParts: UploadPartReceipt[][] = [];
|
||||
let session: UploadSession | null = null;
|
||||
let createCount = 0;
|
||||
let issueCount = 0;
|
||||
const issued = vi.fn();
|
||||
const control: ResumableUploadControlPlane<TestCapability> = {
|
||||
async createSession(input) {
|
||||
createCount += 1;
|
||||
session = Object.freeze({
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId:
|
||||
options.sessionId?.(createCount) ?? "session_01",
|
||||
requestBindingSha256: input.requestBindingSha256,
|
||||
fingerprint: input.fingerprint,
|
||||
partSizeBytes: input.requestedPartSizeBytes,
|
||||
partCount: input.fingerprint.partCount,
|
||||
maxConcurrency: options.serverMaxConcurrency ?? 2,
|
||||
expiresAtEpochMs: now + 5_000,
|
||||
});
|
||||
return browserDataSuccess(session);
|
||||
},
|
||||
async getStatus() {
|
||||
if (!session) {
|
||||
return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE");
|
||||
}
|
||||
const parts =
|
||||
options.statusParts?.(session, accepted) ??
|
||||
[...accepted.values()].sort(
|
||||
(left, right) => left.partNumber - right.partNumber,
|
||||
);
|
||||
return browserDataSuccess({
|
||||
state: "ACTIVE",
|
||||
session,
|
||||
acceptedParts: parts,
|
||||
});
|
||||
},
|
||||
async issuePartCapability(input) {
|
||||
issueCount += 1;
|
||||
issued(input);
|
||||
return (
|
||||
options.issueCapability?.(input, issueCount) ??
|
||||
browserDataSuccess({
|
||||
capability: Object.freeze({ id: `cap-${issueCount}` }),
|
||||
uploadBindingSha256: input.uploadBindingSha256,
|
||||
expiresAtEpochMs: now + 4_000,
|
||||
})
|
||||
);
|
||||
},
|
||||
async complete(input) {
|
||||
completedParts.push([...input.orderedParts]);
|
||||
return browserDataSuccess({
|
||||
state: "QUARANTINED",
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: input.sessionId,
|
||||
requestBindingSha256: input.requestBindingSha256,
|
||||
fingerprint: input.fingerprint,
|
||||
resourceId: "resource_01",
|
||||
});
|
||||
},
|
||||
async abort() {
|
||||
return browserDataSuccess({ state: "ABORTED" });
|
||||
},
|
||||
};
|
||||
return {
|
||||
control,
|
||||
accepted,
|
||||
issued,
|
||||
completedParts,
|
||||
getSession: () => session,
|
||||
};
|
||||
}
|
||||
|
||||
function executorFor(
|
||||
harness: ControlHarness,
|
||||
options: Readonly<{
|
||||
delay?: () => Promise<void>;
|
||||
onActive?: (active: number) => void;
|
||||
}> = {},
|
||||
): UploadPartExecutor<TestCapability> {
|
||||
let active = 0;
|
||||
return {
|
||||
async uploadPart(input) {
|
||||
active += 1;
|
||||
options.onActive?.(active);
|
||||
await options.delay?.();
|
||||
active -= 1;
|
||||
const receipt = Object.freeze({
|
||||
...input.part,
|
||||
receiptToken: `etag-part-${input.part.partNumber}`,
|
||||
});
|
||||
harness.accepted.set(input.part.partNumber, receipt);
|
||||
return browserDataSuccess(receipt);
|
||||
},
|
||||
};
|
||||
}
|
||||
import {
|
||||
activeSignal,
|
||||
byteStreamSource,
|
||||
createControlHarness,
|
||||
createMemoryCancellationPair,
|
||||
createSerialMutationLock,
|
||||
executorFor,
|
||||
MemoryCheckpointStore,
|
||||
noContentionLock,
|
||||
rangeSource,
|
||||
runtimePolicy,
|
||||
type TestCapability,
|
||||
} from "./resumable-upload-runtime-fixture.ts";
|
||||
|
||||
describe("production resumable upload runtime", () => {
|
||||
it("disposes through one drain that proves quiescence", async () => {
|
||||
@@ -1280,177 +980,3 @@ describe("production resumable upload runtime", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
/**
|
||||
* TR-RR-06. A non-cooperative mutation lock or provider must not make teardown
|
||||
* unbounded: `dispose()` bounds its drain and reports honestly when the runtime
|
||||
* is still CLOSING, and an abort is admitted physical work it cannot step over.
|
||||
*/
|
||||
describe("TR-RR-06 bounded resumable teardown", () => {
|
||||
it("reports an unproved drain instead of waiting forever", async () => {
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const harness = createControlHarness();
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness, { delay: async () => {} }),
|
||||
checkpoints,
|
||||
// A lock that never grants: dispose must still be bounded.
|
||||
mutationLock: Object.freeze({
|
||||
async run<Value>(): Promise<Value> {
|
||||
return await new Promise<never>(() => {});
|
||||
},
|
||||
}),
|
||||
crypto,
|
||||
policy: runtimePolicy({ cleanupDeadlineMs: 20 }),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
void runtime.upload({
|
||||
uploadKey: "upload_key_hung",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed.ok).toBe(false);
|
||||
// Still CLOSING: physical work the caller must not treat as finished.
|
||||
expect(runtime.lifecycle()).toBe("CLOSING");
|
||||
expect(checkpoints.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("closes once every admitted operation settles", async () => {
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const harness = createControlHarness();
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness, { delay: async () => {} }),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({ cleanupDeadlineMs: 200 }),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed).toMatchObject({ ok: true });
|
||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||
expect(checkpoints.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TR-04. `dispose()` proved quiescence from the wrapper registry only. A
|
||||
* provider that ignored its attempt deadline let the wrapper settle first and
|
||||
* leave the set empty, so teardown reported a drained runtime — and closed the
|
||||
* checkpoint store — while the provider was still running.
|
||||
*/
|
||||
describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => {
|
||||
it("refuses to report a drained runtime while a provider is still running", async () => {
|
||||
const harness = createControlHarness();
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const closeStore = vi.spyOn(checkpoints, "close");
|
||||
let releaseProvider: (() => void) | undefined;
|
||||
// Ignores the attempt signal entirely and outlives its own deadline.
|
||||
harness.control.createSession = () =>
|
||||
new Promise((resolve) => {
|
||||
releaseProvider = () =>
|
||||
resolve(
|
||||
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({
|
||||
providerAttemptTimeoutMs: 5,
|
||||
cleanupDeadlineMs: 25,
|
||||
maxRetries: 0,
|
||||
}),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const uploading = runtime.upload({
|
||||
uploadKey: "upload_key_raw",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
||||
// The wrapper has already given up on the attempt.
|
||||
await uploading;
|
||||
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed.ok).toBe(false);
|
||||
if (!disposed.ok) {
|
||||
expect(disposed.error.code).toBe("UNAVAILABLE");
|
||||
expect(disposed.error.recovery).toBe("RESUME");
|
||||
}
|
||||
// The store stays open while something could still write a checkpoint.
|
||||
expect(closeStore).not.toHaveBeenCalled();
|
||||
|
||||
releaseProvider?.();
|
||||
});
|
||||
|
||||
it("reports a drained runtime once the raw provider settles", async () => {
|
||||
const harness = createControlHarness();
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
let releaseProvider: (() => void) | undefined;
|
||||
harness.control.createSession = () =>
|
||||
new Promise((resolve) => {
|
||||
releaseProvider = () =>
|
||||
resolve(
|
||||
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({
|
||||
providerAttemptTimeoutMs: 5,
|
||||
cleanupDeadlineMs: 1_000,
|
||||
maxRetries: 0,
|
||||
}),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const uploading = runtime.upload({
|
||||
uploadKey: "upload_key_raw_2",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
||||
await uploading;
|
||||
|
||||
const disposing = runtime.dispose();
|
||||
releaseProvider?.();
|
||||
await expect(disposing).resolves.toMatchObject({ ok: true });
|
||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
sign,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts";
|
||||
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
|
||||
import {
|
||||
providerEvidenceSignaturePayload,
|
||||
trustPolicySha256,
|
||||
} from "../../scripts/lib/provider-evidence.ts";
|
||||
import {
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
distSha256,
|
||||
type ReleaseCandidateManifest,
|
||||
} from "../../scripts/lib/release-candidate.ts";
|
||||
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
|
||||
|
||||
export const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||
|
||||
export const digest = (value: string): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
export const digestBytes = (value: Buffer): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
|
||||
export function passingAssessment(): any {
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
artifactType: "local-evidence-assessment" as const,
|
||||
generatedAt: "2026-08-02T00:00:00.000Z",
|
||||
status: "PASS" as const,
|
||||
verifier: {
|
||||
id: "clean-architecture-frontend-template/local-evidence-verifier",
|
||||
version: "1",
|
||||
sourceSha256: digest("verifier source"),
|
||||
},
|
||||
source: {
|
||||
revision: "a".repeat(40),
|
||||
sourceSetSha256: digest("source set"),
|
||||
},
|
||||
candidate: {
|
||||
distSha256: digest("dist"),
|
||||
lockfileSha256: digest("lockfile"),
|
||||
sbomSha256: digest("sbom"),
|
||||
},
|
||||
secretScan: {
|
||||
policySha256: digest("secret policy"),
|
||||
sarifSha256: digest("secret sarif"),
|
||||
scanInputSha256: digest("secret scan input"),
|
||||
},
|
||||
policyInputs: [
|
||||
{
|
||||
path: "config/security/dependency-policy.json",
|
||||
bytes: 3,
|
||||
sha256: digest("{}\n"),
|
||||
},
|
||||
],
|
||||
evidenceInputs: [
|
||||
{ path: "pnpm-lock.yaml", bytes: 9, sha256: digest("lockfile\n") },
|
||||
],
|
||||
checks: {
|
||||
release: "PASS" as const,
|
||||
supplyChain: "PASS" as const,
|
||||
dependencyPolicy: "PASS" as const,
|
||||
licensePolicy: "PASS" as const,
|
||||
vulnerabilityPolicy: "PASS" as const,
|
||||
secretScan: "PASS" as const,
|
||||
},
|
||||
failures: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function providerExpectedContext() {
|
||||
return {
|
||||
run: { id: "run-42", attempt: 1 },
|
||||
source: { revision: "b".repeat(40), sourceSetSha256: digest("provider source") },
|
||||
candidate: {
|
||||
archiveSha256: digest("archive"),
|
||||
bundleSha256: digest("bundle"),
|
||||
distSha256: digest("provider dist"),
|
||||
lockfileSha256: digest("provider lockfile"),
|
||||
},
|
||||
secretScanAttestation: {
|
||||
status: "PASS" as const,
|
||||
localEvidenceAssessmentSha256: digest("provider assessment"),
|
||||
sourceSetSha256: digest("provider source"),
|
||||
policySha256: digest("provider secret policy"),
|
||||
sarifSha256: digest("provider secret sarif"),
|
||||
scanInputSha256: digest("provider secret input"),
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function privatePromotionFiles() {
|
||||
return PROMOTED_FILE_NAMES.map((name) => {
|
||||
const bytes = Buffer.from(`${name}\n`);
|
||||
return { name, bytes, sha256: digestBytes(bytes) };
|
||||
});
|
||||
}
|
||||
|
||||
export function syntheticSignedPromotionBundle() {
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const archiveBytes = Buffer.from("synthetic signed replay archive\n");
|
||||
const run = { id: "signed-run", attempt: 1 } as const;
|
||||
const source = {
|
||||
revision: "a".repeat(40),
|
||||
sourceSetSha256: digest("synthetic-source-set"),
|
||||
} as const;
|
||||
const candidate = {
|
||||
archiveSha256: digestBytes(archiveBytes),
|
||||
bundleSha256: digest("synthetic-bundle"),
|
||||
distSha256: digest("synthetic-dist"),
|
||||
lockfileSha256: digest("synthetic-lock"),
|
||||
} as const;
|
||||
const vulnerability = signedProviderV2(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
evidenceType: "vulnerability-report",
|
||||
provider: "synthetic-vulnerability-provider",
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { ...run, invocationNonce: "1".repeat(64) },
|
||||
source,
|
||||
candidate,
|
||||
secretScanAttestation: {
|
||||
status: "PASS",
|
||||
localEvidenceAssessmentSha256: digest("synthetic-assessment"),
|
||||
sourceSetSha256: source.sourceSetSha256,
|
||||
policySha256: digest("synthetic-policy"),
|
||||
sarifSha256: digest("synthetic-sarif"),
|
||||
scanInputSha256: digest("synthetic-scan-input"),
|
||||
},
|
||||
findings: [],
|
||||
},
|
||||
"synthetic-vulnerability",
|
||||
vulnerabilityKeys.publicKey,
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenance = signedProviderV2(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
evidenceType: "provenance-attestation",
|
||||
provider: "synthetic-provenance-provider",
|
||||
signer: "synthetic-signer",
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { ...run, invocationNonce: "2".repeat(64) },
|
||||
source,
|
||||
candidate,
|
||||
subject: { name: "dist", digest: { sha256: candidate.distSha256 } },
|
||||
},
|
||||
"synthetic-provenance",
|
||||
provenanceKeys.publicKey,
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
const vulnerabilityBytes = Buffer.from(`${JSON.stringify(vulnerability)}\n`);
|
||||
const provenanceBytes = Buffer.from(`${JSON.stringify(provenance)}\n`);
|
||||
const vulnerabilityTrust = trust("synthetic-vulnerability", vulnerabilityKeys.publicKey);
|
||||
const provenanceTrust = trust("synthetic-provenance", provenanceKeys.publicKey);
|
||||
const providerEvidence = {
|
||||
vulnerabilityReportSha256: digestBytes(vulnerabilityBytes),
|
||||
provenanceAttestationSha256: digestBytes(provenanceBytes),
|
||||
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||
provenanceInvocationNonce: "2".repeat(64),
|
||||
vulnerabilityKeyId: vulnerabilityTrust.keyId,
|
||||
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
|
||||
provenanceKeyId: provenanceTrust.keyId,
|
||||
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
|
||||
secretScanAttestation: vulnerability.secretScanAttestation,
|
||||
};
|
||||
const common = {
|
||||
schemaVersion: 3,
|
||||
verifiedAt: "2026-08-02T01:00:00.000Z",
|
||||
status: "PASS",
|
||||
verifier: {
|
||||
id: "clean-architecture-frontend-template/promotion-verifier",
|
||||
version: "3",
|
||||
},
|
||||
run,
|
||||
source,
|
||||
candidate,
|
||||
providerEvidence,
|
||||
trustPolicySha256: trustPolicySha256({ vulnerabilityTrust, provenanceTrust }),
|
||||
failures: [],
|
||||
};
|
||||
const providerBytes = Buffer.from(
|
||||
`${JSON.stringify({
|
||||
...common,
|
||||
artifactType: "provider-verification",
|
||||
vulnerabilityStatus: "PASS",
|
||||
provenanceAttestationStatus: "PASS",
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
const promotionBytes = Buffer.from(
|
||||
`${JSON.stringify({
|
||||
...common,
|
||||
artifactType: "promotion-verification",
|
||||
localEvidenceStatus: "PASS",
|
||||
localEvidenceAssessmentSha256: digest("synthetic-assessment"),
|
||||
providerVerificationSha256: digestBytes(providerBytes),
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
return {
|
||||
files: {
|
||||
"release-candidate.tar.gz": archiveBytes,
|
||||
"vulnerability-report.json": vulnerabilityBytes,
|
||||
"provenance-attestation.json": provenanceBytes,
|
||||
"provider-verification.json": providerBytes,
|
||||
"promotion-verification.json": promotionBytes,
|
||||
},
|
||||
verification: {
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||
expected: {
|
||||
run,
|
||||
sourceRevision: source.revision,
|
||||
sourceSetSha256: source.sourceSetSha256,
|
||||
archiveSha256: candidate.archiveSha256,
|
||||
bundleSha256: candidate.bundleSha256,
|
||||
distSha256: candidate.distSha256,
|
||||
lockfileSha256: candidate.lockfileSha256,
|
||||
},
|
||||
},
|
||||
vulnerabilityPem: vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }),
|
||||
provenancePem: provenanceKeys.publicKey.export({ type: "spki", format: "pem" }),
|
||||
};
|
||||
}
|
||||
|
||||
export function fingerprint(publicKey: KeyObject): string {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(publicKey.export({ type: "spki", format: "der" }))
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
export function trust(keyId: string, publicKey: KeyObject) {
|
||||
return { keyId, publicKey, publicKeyFingerprint: fingerprint(publicKey) };
|
||||
}
|
||||
|
||||
export function signedProviderV2(
|
||||
unsigned: Record<string, unknown>,
|
||||
keyId: string,
|
||||
publicKey: KeyObject,
|
||||
privateKey: KeyObject,
|
||||
fingerprintOverride?: string,
|
||||
): Record<string, any> {
|
||||
const { signature: existingSignature, ...payload } = unsigned;
|
||||
const value = {
|
||||
...payload,
|
||||
signature: {
|
||||
algorithm: "Ed25519" as const,
|
||||
keyId,
|
||||
publicKeyFingerprint:
|
||||
fingerprintOverride ??
|
||||
(existingSignature && typeof existingSignature === "object" &&
|
||||
"publicKeyFingerprint" in existingSignature
|
||||
? String(existingSignature.publicKeyFingerprint)
|
||||
: fingerprint(publicKey)),
|
||||
value: "",
|
||||
},
|
||||
};
|
||||
value.signature.value = sign(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(value),
|
||||
privateKey,
|
||||
).toString("base64");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function providerUnsigned(
|
||||
kind: "vulnerability" | "provenance",
|
||||
expected: ReturnType<typeof providerExpectedContext>,
|
||||
): Record<string, any> {
|
||||
const common = {
|
||||
source: expected.source,
|
||||
candidate: expected.candidate,
|
||||
schemaVersion: 2,
|
||||
evidenceType:
|
||||
kind === "vulnerability"
|
||||
? "vulnerability-report"
|
||||
: "provenance-attestation",
|
||||
provider: `fixture-${kind}`,
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: {
|
||||
...expected.run,
|
||||
invocationNonce: kind === "vulnerability" ? "1".repeat(64) : "2".repeat(64),
|
||||
},
|
||||
};
|
||||
return kind === "vulnerability"
|
||||
? {
|
||||
...common,
|
||||
secretScanAttestation: expected.secretScanAttestation,
|
||||
findings: [],
|
||||
}
|
||||
: {
|
||||
...common,
|
||||
signer: "fixture-workload",
|
||||
subject: {
|
||||
name: "dist",
|
||||
digest: { sha256: expected.candidate.distSha256 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createArchivedAssessmentFixture(): Promise<{
|
||||
root: string;
|
||||
manifest: ReleaseCandidateManifest;
|
||||
assessmentSha256: string;
|
||||
}> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "archived-assessment-"));
|
||||
const sourceRevision = "a".repeat(40);
|
||||
const sourceSetSha256 = digest("source set");
|
||||
const releaseManifestBytes = Buffer.from(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
appVersion: "1.0.0",
|
||||
buildId: "build-1",
|
||||
commitSha: sourceRevision,
|
||||
configSchemaVersion: "1",
|
||||
apiContractVersion: "1",
|
||||
assetManifestHash: digest("vite manifest"),
|
||||
releaseId: "release-1",
|
||||
builtAt: "2026-08-02T00:00:00.000Z",
|
||||
routeChunks: { home: "assets/home.js" },
|
||||
})}\n`,
|
||||
);
|
||||
const distInputs = [
|
||||
{ path: "dist/app.js", bytes: Buffer.byteLength("app\n"), sha256: digest("app\n"), gzipBytes: 0 },
|
||||
{
|
||||
path: "dist/release-manifest.json",
|
||||
bytes: releaseManifestBytes.byteLength,
|
||||
sha256: digestBytes(releaseManifestBytes),
|
||||
gzipBytes: 0,
|
||||
},
|
||||
];
|
||||
const candidateDist = distSha256(distInputs);
|
||||
const sbomBytes = Buffer.from(
|
||||
`${JSON.stringify({
|
||||
bomFormat: "CycloneDX",
|
||||
specVersion: "1.6",
|
||||
serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001",
|
||||
version: 1,
|
||||
metadata: {
|
||||
component: { type: "application", name: "fixture", version: "1.0.0" },
|
||||
properties: [],
|
||||
},
|
||||
components: [],
|
||||
dependencies: [],
|
||||
})}\n`,
|
||||
);
|
||||
const sbomSha256 = digestBytes(sbomBytes);
|
||||
const lockfileBytes = Buffer.from("lockfile\n");
|
||||
const lockfileDigest = digestBytes(lockfileBytes);
|
||||
const buildManifest = {
|
||||
schemaVersion: 1,
|
||||
buildId: "build-1",
|
||||
commitSha: sourceRevision,
|
||||
releaseId: "release-1",
|
||||
moduleInventoryHash: digest("module inventory"),
|
||||
generatedAt: "2026-08-02T00:00:00.000Z",
|
||||
buildContext: {
|
||||
nodeVersion: "v24.0.0",
|
||||
packageManagerVersion: "11.0.0",
|
||||
runnerImage: "linux-x64",
|
||||
sourceDateEpoch: "1785638400",
|
||||
},
|
||||
outputs: {
|
||||
directory: "dist",
|
||||
viteManifest: "dist/.vite/manifest.json",
|
||||
moduleInventory: "artifacts/quality/vite-module-inventory.json",
|
||||
routeChunks: { home: "assets/home.js" },
|
||||
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
||||
},
|
||||
};
|
||||
const provenance = {
|
||||
_type: "https://in-toto.io/Statement/v1",
|
||||
subject: [{ name: "dist", digest: { sha256: candidateDist } }],
|
||||
predicateType: "https://slsa.dev/provenance/v1",
|
||||
predicate: {
|
||||
buildDefinition: {
|
||||
buildType: "https://vite.dev/build/v1",
|
||||
externalParameters: {},
|
||||
internalParameters: {},
|
||||
resolvedDependencies: [
|
||||
{ uri: "pnpm-lock.yaml", digest: { sha256: lockfileDigest } },
|
||||
],
|
||||
},
|
||||
runDetails: {
|
||||
builder: { id: "fixture-builder" },
|
||||
metadata: { invocationId: "LOCAL_UNSIGNED" },
|
||||
},
|
||||
materials: { lockfileSha256: lockfileDigest, sourceSetSha256, sbomSha256 },
|
||||
},
|
||||
};
|
||||
const supplyVerification = {
|
||||
schemaVersion: 1,
|
||||
localStatus: "PASS",
|
||||
promotionStatus: "FAIL_UNVERIFIED",
|
||||
lockfileSha256: lockfileDigest,
|
||||
sourceSetSha256,
|
||||
distSha256: candidateDist,
|
||||
sbomSha256,
|
||||
dependencyDiff: { added: [], removed: [], changed: [], upgrades: [] },
|
||||
highRiskReview: [],
|
||||
vulnerabilityStatus: "FAIL_UNVERIFIED",
|
||||
provenanceAttestationStatus: "FAIL_UNVERIFIED",
|
||||
failures: [],
|
||||
};
|
||||
const members = new Map<string, Buffer>([
|
||||
["dist/app.js", Buffer.from("app\n")],
|
||||
["dist/release-manifest.json", releaseManifestBytes],
|
||||
["pnpm-lock.yaml", lockfileBytes],
|
||||
["artifacts/release/build-manifest.json", Buffer.from(`${JSON.stringify(buildManifest)}\n`)],
|
||||
["artifacts/release/provenance.json", Buffer.from(`${JSON.stringify(provenance)}\n`)],
|
||||
[
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
Buffer.from(`${JSON.stringify(supplyVerification)}\n`),
|
||||
],
|
||||
["artifacts/release/sbom.cdx.json", sbomBytes],
|
||||
]);
|
||||
const evidenceInputs = [...members.entries()]
|
||||
.map(([memberPath, bytes]) => ({
|
||||
path: memberPath,
|
||||
bytes: bytes.byteLength,
|
||||
sha256: digestBytes(bytes),
|
||||
}))
|
||||
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
||||
const policyPaths = [
|
||||
"config/security/dependency-baseline.approval.json",
|
||||
"config/security/dependency-baseline.json",
|
||||
"config/security/dependency-change-evidence.json",
|
||||
"config/security/dependency-policy.json",
|
||||
"config/security/secret-scan-policy.json",
|
||||
"config/security/vulnerability-exceptions.json",
|
||||
"config/security/vulnerability-policy.json",
|
||||
"schemas/artifacts/build-manifest.schema.json",
|
||||
"schemas/artifacts/dependency-inventory.schema.json",
|
||||
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||
"scripts/contracts/release-artifacts.ts",
|
||||
"scripts/create-release-candidate.ts",
|
||||
"scripts/generate-supply-chain.ts",
|
||||
"scripts/lib/build-manifest-outputs.ts",
|
||||
"scripts/lib/json-schema.ts",
|
||||
"scripts/lib/local-policy-evidence.ts",
|
||||
"scripts/lib/local-release-evidence.ts",
|
||||
"scripts/lib/release-candidate.ts",
|
||||
"scripts/lib/release-input-evidence.ts",
|
||||
"scripts/lib/release-runtime-coherence.ts",
|
||||
"scripts/lib/repository-file-inventory.ts",
|
||||
"scripts/lib/secret-scan-evaluator.ts",
|
||||
"scripts/lib/secret-scan-policy.ts",
|
||||
"scripts/lib/secret-scan.ts",
|
||||
"scripts/lib/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
"src/features/installed-contract-contributions.ts",
|
||||
"src/features/installed-feature-contracts.ts",
|
||||
];
|
||||
const sbomRow = evidenceInputs.find(
|
||||
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||
)!;
|
||||
const policyInputs = policyPaths.map((policyPath) => ({
|
||||
path: policyPath,
|
||||
bytes: 2,
|
||||
sha256: digest(`policy:${policyPath}`),
|
||||
}));
|
||||
const verifierPaths = new Set([
|
||||
"scripts/contracts/release-artifacts.ts",
|
||||
"scripts/create-release-candidate.ts",
|
||||
"scripts/generate-supply-chain.ts",
|
||||
"scripts/lib/build-manifest-outputs.ts",
|
||||
"scripts/lib/json-schema.ts",
|
||||
"scripts/lib/local-policy-evidence.ts",
|
||||
"scripts/lib/local-release-evidence.ts",
|
||||
"scripts/lib/release-candidate.ts",
|
||||
"scripts/lib/release-input-evidence.ts",
|
||||
"scripts/lib/release-runtime-coherence.ts",
|
||||
"scripts/lib/repository-file-inventory.ts",
|
||||
"scripts/lib/secret-scan-evaluator.ts",
|
||||
"scripts/lib/secret-scan-policy.ts",
|
||||
"scripts/lib/secret-scan.ts",
|
||||
"scripts/lib/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
"src/features/installed-contract-contributions.ts",
|
||||
"src/features/installed-feature-contracts.ts",
|
||||
]);
|
||||
const assessment = localEvidenceAssessmentArtifactSchema.parse({
|
||||
...passingAssessment(),
|
||||
verifier: {
|
||||
id: "clean-architecture-frontend-template/local-evidence-verifier",
|
||||
version: "1",
|
||||
sourceSha256: supplyChainDigest(
|
||||
policyInputs.filter(({ path: policyPath }) => verifierPaths.has(policyPath)),
|
||||
),
|
||||
},
|
||||
source: { revision: sourceRevision, sourceSetSha256 },
|
||||
candidate: {
|
||||
distSha256: candidateDist,
|
||||
lockfileSha256: evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml")!
|
||||
.sha256,
|
||||
sbomSha256: sbomRow.sha256,
|
||||
},
|
||||
policyInputs,
|
||||
evidenceInputs,
|
||||
});
|
||||
const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`);
|
||||
members.set(LOCAL_EVIDENCE_ASSESSMENT_PATH, assessmentBytes);
|
||||
for (const [memberPath, bytes] of members) {
|
||||
await mkdir(path.dirname(path.join(root, memberPath)), { recursive: true });
|
||||
await writeFile(path.join(root, memberPath), bytes);
|
||||
}
|
||||
const files = [...members.entries()]
|
||||
.map(([memberPath, bytes]) => ({
|
||||
path: memberPath,
|
||||
bytes: bytes.byteLength,
|
||||
sha256: digestBytes(bytes),
|
||||
}))
|
||||
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
||||
const manifest: ReleaseCandidateManifest = {
|
||||
schemaVersion: 1,
|
||||
distSha256: assessment.candidate.distSha256,
|
||||
lockfileSha256: assessment.candidate.lockfileSha256,
|
||||
bundleSha256: supplyChainDigest(files),
|
||||
files,
|
||||
};
|
||||
await mkdir(path.join(root, "artifacts/release"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(root, "artifacts/release/release-candidate.json"),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
return { root, manifest, assessmentSha256: digestBytes(assessmentBytes) };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
|
||||
import { publishPrivatePromotionStaging } from "../../scripts/lib/promotion-stager.ts";
|
||||
import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts";
|
||||
import {
|
||||
PROCESS_HEAVY_TIMEOUT_MS,
|
||||
privatePromotionFiles,
|
||||
syntheticSignedPromotionBundle,
|
||||
} from "./security-followup-fixture.ts";
|
||||
|
||||
describe("security private promotion staging contracts", () => {
|
||||
it("forces exact private staging modes in an isolated child with umask 077", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-umask-"));
|
||||
try {
|
||||
const stagerUrl = pathToFileURL(
|
||||
path.join(process.cwd(), "scripts/lib/promotion-stager.ts"),
|
||||
).href;
|
||||
const contractsUrl = pathToFileURL(
|
||||
path.join(process.cwd(), "scripts/contracts/promotion-artifacts.ts"),
|
||||
).href;
|
||||
const childPath = path.join(root, "umask-child.mjs");
|
||||
const resultPath = path.join(root, "result.json");
|
||||
await writeFile(resultPath, "{}\n", { mode: 0o600 });
|
||||
await writeFile(
|
||||
childPath,
|
||||
[
|
||||
`import { lstat, writeFile } from "node:fs/promises";`,
|
||||
`import path from "node:path";`,
|
||||
`import { createHash } from "node:crypto";`,
|
||||
`import { cleanupFinalizedPromotion, publishPrivatePromotionStaging } from ${JSON.stringify(stagerUrl)};`,
|
||||
`import { PROMOTED_FILE_NAMES } from ${JSON.stringify(contractsUrl)};`,
|
||||
`process.umask(Number.parseInt(process.argv[2], 8));`,
|
||||
`const runnerTempRoot = process.argv[3];`,
|
||||
`const files = PROMOTED_FILE_NAMES.map((name) => { const bytes = Buffer.from(name); return { name, bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; });`,
|
||||
`const finalized = await publishPrivatePromotionStaging(runnerTempRoot, { id: "umask", attempt: 1 }, files, () => Buffer.alloc(16, 1));`,
|
||||
`const directoryMode = (await lstat(finalized.stagingRoot)).mode & 0o777;`,
|
||||
`const fileModes = await Promise.all(PROMOTED_FILE_NAMES.map(async (name) => (await lstat(path.join(finalized.stagingRoot, name))).mode & 0o777));`,
|
||||
`await cleanupFinalizedPromotion({ runnerTempRoot, stagingRoot: finalized.stagingRoot, cleanupToken: finalized.cleanupToken, runnerTempIdentity: finalized.runnerTempIdentity, stagingIdentity: finalized.stagingIdentity });`,
|
||||
`await writeFile(process.argv[4], JSON.stringify({ directoryMode, fileModes }));`,
|
||||
].join("\n"),
|
||||
);
|
||||
const child = spawnSync(process.execPath, [childPath, "077", root, resultPath], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0);
|
||||
expect(JSON.parse(await readFile(resultPath, "utf8"))).toEqual({
|
||||
directoryMode: 0o700,
|
||||
fileModes: [0o400, 0o400, 0o400, 0o400, 0o400],
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a staged file unlinked and recreated after its original write", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-recreate-"));
|
||||
const files = privatePromotionFiles();
|
||||
const token = `promotion-seal-1-${"11".repeat(16)}`;
|
||||
try {
|
||||
await expect(
|
||||
publishPrivatePromotionStaging(
|
||||
root,
|
||||
{ id: "seal", attempt: 1 },
|
||||
files,
|
||||
() => Buffer.alloc(16, 0x11),
|
||||
undefined,
|
||||
async (name) => {
|
||||
if (name !== PROMOTED_FILE_NAMES.at(-1)) return;
|
||||
const first = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
||||
await rm(first);
|
||||
await writeFile(first, "replacement bytes\n", { mode: 0o400 });
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/staged.*digest|inode|seal/u);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects staged mode drift before returning the upload root", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-mode-"));
|
||||
const files = privatePromotionFiles();
|
||||
const token = `promotion-seal-1-${"12".repeat(16)}`;
|
||||
try {
|
||||
await expect(
|
||||
publishPrivatePromotionStaging(
|
||||
root,
|
||||
{ id: "seal", attempt: 1 },
|
||||
files,
|
||||
() => Buffer.alloc(16, 0x12),
|
||||
undefined,
|
||||
async (name) => {
|
||||
if (name === PROMOTED_FILE_NAMES.at(-1)) {
|
||||
await chmod(path.join(root, token, PROMOTED_FILE_NAMES[0]), 0o600);
|
||||
}
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/mode.*0400|staged.*mode|seal/u);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects staging leaf replacement between mkdir and descriptor open", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-replace-"));
|
||||
const files = privatePromotionFiles();
|
||||
const token = `promotion-preopen-1-${"13".repeat(16)}`;
|
||||
const displaced = path.join(root, `${token}-displaced`);
|
||||
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
||||
try {
|
||||
await expect(
|
||||
publishPrivatePromotionStaging(
|
||||
root,
|
||||
{ id: "preopen", attempt: 1 },
|
||||
files,
|
||||
() => Buffer.alloc(16, 0x13),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
async (stagingRoot) => {
|
||||
await rename(stagingRoot, displaced);
|
||||
await mkdir(stagingRoot, { mode: 0o700 });
|
||||
await writeFile(replacementCanary, "external replacement canary\n");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/staging leaf.*changed|mkdir.*open|identity/u);
|
||||
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
||||
"external replacement canary\n",
|
||||
);
|
||||
await expect(readdir(displaced)).resolves.toEqual([]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not scan a crowded parent to recover an unverified pre-open leaf", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-bounded-"));
|
||||
const files = privatePromotionFiles();
|
||||
const token = `promotion-preopen-bound-1-${"14".repeat(16)}`;
|
||||
const displaced = path.join(root, `${token}-displaced`);
|
||||
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
||||
try {
|
||||
for (let offset = 0; offset < 4_097; offset += 128) {
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(128, 4_097 - offset) },
|
||||
(_, index) =>
|
||||
mkdir(
|
||||
path.join(
|
||||
root,
|
||||
`noise-${String(offset + index).padStart(4, "0")}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
let failure: unknown;
|
||||
try {
|
||||
await publishPrivatePromotionStaging(
|
||||
root,
|
||||
{ id: "preopen-bound", attempt: 1 },
|
||||
files,
|
||||
() => Buffer.alloc(16, 0x14),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
async (stagingRoot) => {
|
||||
await rename(stagingRoot, displaced);
|
||||
await mkdir(stagingRoot, { mode: 0o700 });
|
||||
await writeFile(replacementCanary, "external replacement canary\n");
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure).not.toBeInstanceOf(AggregateError);
|
||||
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
|
||||
await expect(readdir(displaced)).resolves.toEqual([]);
|
||||
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
||||
"external replacement canary\n",
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("leaves a non-empty moved original untouched after pre-open mismatch", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-nonempty-"));
|
||||
const files = privatePromotionFiles();
|
||||
const token = `promotion-preopen-nonempty-1-${"15".repeat(16)}`;
|
||||
const displaced = path.join(root, `${token}-displaced`);
|
||||
const ownedResidual = path.join(displaced, "owned-residual");
|
||||
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
||||
try {
|
||||
let failure: unknown;
|
||||
try {
|
||||
await publishPrivatePromotionStaging(
|
||||
root,
|
||||
{ id: "preopen-nonempty", attempt: 1 },
|
||||
files,
|
||||
() => Buffer.alloc(16, 0x15),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
async (stagingRoot) => {
|
||||
await rename(stagingRoot, displaced);
|
||||
await writeFile(ownedResidual, "owned residual\n");
|
||||
await mkdir(stagingRoot, { mode: 0o700 });
|
||||
await writeFile(replacementCanary, "external replacement canary\n");
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure).not.toBeInstanceOf(AggregateError);
|
||||
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
|
||||
await expect(readFile(ownedResidual, "utf8")).resolves.toBe(
|
||||
"owned residual\n",
|
||||
);
|
||||
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
||||
"external replacement canary\n",
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not search outside the parent for a moved unverified original", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-missing-"));
|
||||
const outside = await mkdtemp(path.join(tmpdir(), "promotion-preopen-moved-"));
|
||||
const files = privatePromotionFiles();
|
||||
const token = `promotion-preopen-missing-1-${"16".repeat(16)}`;
|
||||
const displaced = path.join(outside, token);
|
||||
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
||||
try {
|
||||
let failure: unknown;
|
||||
try {
|
||||
await publishPrivatePromotionStaging(
|
||||
root,
|
||||
{ id: "preopen-missing", attempt: 1 },
|
||||
files,
|
||||
() => Buffer.alloc(16, 0x16),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
async (stagingRoot) => {
|
||||
await rename(stagingRoot, displaced);
|
||||
await mkdir(stagingRoot, { mode: 0o700 });
|
||||
await writeFile(replacementCanary, "external replacement canary\n");
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure).not.toBeInstanceOf(AggregateError);
|
||||
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
|
||||
await expect(lstat(displaced)).resolves.toEqual(
|
||||
expect.objectContaining({ dev: expect.any(Number), ino: expect.any(Number) }),
|
||||
);
|
||||
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
||||
"external replacement canary\n",
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a fresh signed exact-five bundle replayed under a different expected run", async () => {
|
||||
const fixture = syntheticSignedPromotionBundle();
|
||||
await expect(
|
||||
verifyExactPromotionBundle(fixture.files, {
|
||||
...fixture.verification,
|
||||
expected: {
|
||||
...fixture.verification.expected,
|
||||
run: { id: "different-run", attempt: 1 },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/external expected run.*mismatch|expected promotion run/u);
|
||||
});
|
||||
|
||||
it("requires every external expected identity variable at the exact promotion CLI", async () => {
|
||||
const fixture = syntheticSignedPromotionBundle();
|
||||
const root = await mkdtemp(path.join(tmpdir(), "promotion-replay-cli-"));
|
||||
const bundleRoot = path.join(root, "bundle");
|
||||
try {
|
||||
await mkdir(bundleRoot);
|
||||
for (const [name, bytes] of Object.entries(fixture.files)) {
|
||||
await writeFile(path.join(bundleRoot, name), bytes);
|
||||
}
|
||||
await writeFile(path.join(root, "vulnerability.pem"), fixture.vulnerabilityPem);
|
||||
await writeFile(path.join(root, "provenance.pem"), fixture.provenancePem);
|
||||
const cliPath = path.join(process.cwd(), "scripts/verify-exact-promotion-bundle.ts");
|
||||
const baseEnvironment: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
PROMOTION_BUNDLE_ROOT: bundleRoot,
|
||||
VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem",
|
||||
VULNERABILITY_KEY_ID: "synthetic-vulnerability",
|
||||
PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem",
|
||||
PROVENANCE_KEY_ID: "synthetic-provenance",
|
||||
EXPECTED_PROMOTION_RUN_ID: fixture.verification.expected.run.id,
|
||||
EXPECTED_PROMOTION_RUN_ATTEMPT: String(
|
||||
fixture.verification.expected.run.attempt,
|
||||
),
|
||||
EXPECTED_PROMOTION_SOURCE_REVISION:
|
||||
fixture.verification.expected.sourceRevision,
|
||||
EXPECTED_PROMOTION_ARCHIVE_SHA256:
|
||||
fixture.verification.expected.archiveSha256,
|
||||
};
|
||||
const requiredExpected = [
|
||||
"EXPECTED_PROMOTION_RUN_ID",
|
||||
"EXPECTED_PROMOTION_RUN_ATTEMPT",
|
||||
"EXPECTED_PROMOTION_SOURCE_REVISION",
|
||||
"EXPECTED_PROMOTION_ARCHIVE_SHA256",
|
||||
] as const;
|
||||
for (const missing of requiredExpected) {
|
||||
const environment = { ...baseEnvironment };
|
||||
delete environment[missing];
|
||||
const result = spawnSync(process.execPath, [cliPath], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: environment,
|
||||
});
|
||||
expect(result.status, missing).not.toBe(0);
|
||||
expect(result.stderr, missing).toContain(
|
||||
`exact promotion verification environment is missing ${missing}`,
|
||||
);
|
||||
}
|
||||
for (const [name, value, diagnostic] of [
|
||||
["EXPECTED_PROMOTION_RUN_ID", "different-run", /external expected run.*mismatch/u],
|
||||
["EXPECTED_PROMOTION_RUN_ATTEMPT", "2", /external expected run.*mismatch/u],
|
||||
["EXPECTED_PROMOTION_SOURCE_REVISION", "f".repeat(40), /external expected source revision.*mismatch/u],
|
||||
["EXPECTED_PROMOTION_ARCHIVE_SHA256", "0".repeat(64), /external expected archive digest.*mismatch/u],
|
||||
] as const) {
|
||||
const result = spawnSync(process.execPath, [cliPath], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: { ...baseEnvironment, [name]: value },
|
||||
});
|
||||
expect(result.status, name).not.toBe(0);
|
||||
expect(result.stderr, name).toMatch(diagnostic);
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||
});
|
||||
@@ -0,0 +1,708 @@
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
providerPublicKeyFingerprint,
|
||||
validateProviderEvidence,
|
||||
} from "../../scripts/lib/provider-evidence.ts";
|
||||
import { readProviderTrust } from "../../scripts/lib/provider-trust.ts";
|
||||
import { superviseProviderEvidence } from "../../scripts/lib/provider-supervisor.ts";
|
||||
import { runProviderProcess } from "../../scripts/lib/provider-process-runner.ts";
|
||||
import { runStageVerifiedPromotionCli } from "../../scripts/lib/stage-verified-promotion-cli.ts";
|
||||
import type { ReleaseCandidateManifest } from "../../scripts/lib/release-candidate.ts";
|
||||
import {
|
||||
digest,
|
||||
providerExpectedContext,
|
||||
providerUnsigned,
|
||||
signedProviderV2,
|
||||
trust,
|
||||
} from "./security-followup-fixture.ts";
|
||||
|
||||
describe("security provider evidence contracts", () => {
|
||||
it("accepts signed provider v2 evidence only for the exact run, source, archive, and nonce", () => {
|
||||
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const expected = providerExpectedContext();
|
||||
const vulnerability = signedProviderV2(
|
||||
{
|
||||
source: expected.source,
|
||||
candidate: expected.candidate,
|
||||
schemaVersion: 2,
|
||||
evidenceType: "vulnerability-report",
|
||||
provider: "fixture-vulnerability",
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { ...expected.run, invocationNonce: "1".repeat(64) },
|
||||
secretScanAttestation: expected.secretScanAttestation,
|
||||
findings: [],
|
||||
},
|
||||
"vulnerability-key",
|
||||
vulnerabilityKeys.publicKey,
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenance = signedProviderV2(
|
||||
{
|
||||
source: expected.source,
|
||||
candidate: expected.candidate,
|
||||
schemaVersion: 2,
|
||||
evidenceType: "provenance-attestation",
|
||||
provider: "fixture-provenance",
|
||||
signer: "fixture-workload",
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { ...expected.run, invocationNonce: "2".repeat(64) },
|
||||
subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } },
|
||||
},
|
||||
"provenance-key",
|
||||
provenanceKeys.publicKey,
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
const result = evaluatePromotionEvidence({
|
||||
expected: {
|
||||
...expected,
|
||||
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||
provenanceInvocationNonce: "2".repeat(64),
|
||||
},
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport: vulnerability,
|
||||
provenanceAttestation: provenance,
|
||||
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||
nowEpochMs: () => now,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "PASS",
|
||||
vulnerabilityStatus: "PASS",
|
||||
provenanceAttestationStatus: "PASS",
|
||||
failures: [],
|
||||
});
|
||||
const replayed = evaluatePromotionEvidence({
|
||||
expected: {
|
||||
...expected,
|
||||
run: { id: expected.run.id, attempt: 2 },
|
||||
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||
provenanceInvocationNonce: "2".repeat(64),
|
||||
},
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport: vulnerability,
|
||||
provenanceAttestation: provenance,
|
||||
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||
nowEpochMs: () => now,
|
||||
});
|
||||
expect(replayed.status).toBe("FAIL_UNVERIFIED");
|
||||
expect(replayed.failures).toEqual(
|
||||
expect.arrayContaining([
|
||||
"vulnerability report run identity mismatch",
|
||||
"provenance attestation run identity mismatch",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a signed vulnerability PASS when the captured SARIF attestation differs", () => {
|
||||
const keys = generateKeyPairSync("ed25519");
|
||||
const expected = providerExpectedContext();
|
||||
const secretScanAttestation = {
|
||||
status: "PASS" as const,
|
||||
localEvidenceAssessmentSha256: digest("assessment"),
|
||||
sourceSetSha256: expected.source.sourceSetSha256,
|
||||
policySha256: digest("secret policy"),
|
||||
sarifSha256: digest("real sarif"),
|
||||
scanInputSha256: digest("scan input"),
|
||||
};
|
||||
const report = signedProviderV2(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
evidenceType: "vulnerability-report",
|
||||
provider: "fixture-vulnerability",
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { ...expected.run, invocationNonce: "1".repeat(64) },
|
||||
source: expected.source,
|
||||
candidate: expected.candidate,
|
||||
secretScanAttestation,
|
||||
findings: [],
|
||||
},
|
||||
"vulnerability-key",
|
||||
keys.publicKey,
|
||||
keys.privateKey,
|
||||
);
|
||||
const validated = validateProviderEvidence({
|
||||
kind: "vulnerability",
|
||||
value: report,
|
||||
expected: {
|
||||
...expected,
|
||||
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||
provenanceInvocationNonce: "2".repeat(64),
|
||||
secretScanAttestation: {
|
||||
...secretScanAttestation,
|
||||
sarifSha256: digest("forged empty sarif"),
|
||||
},
|
||||
},
|
||||
trust: trust("vulnerability-key", keys.publicKey),
|
||||
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||
});
|
||||
expect(validated.status).toBe("FAIL_UNVERIFIED");
|
||||
expect(validated.failures).toContain(
|
||||
"vulnerability report secret scan attestation mismatch",
|
||||
);
|
||||
const forged = structuredClone(report);
|
||||
forged.secretScanAttestation.sarifSha256 = digest("forged empty sarif");
|
||||
const forgedValidation = validateProviderEvidence({
|
||||
kind: "vulnerability",
|
||||
value: forged,
|
||||
expected: {
|
||||
...expected,
|
||||
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||
provenanceInvocationNonce: "2".repeat(64),
|
||||
secretScanAttestation: forged.secretScanAttestation,
|
||||
},
|
||||
trust: trust("vulnerability-key", keys.publicKey),
|
||||
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||
});
|
||||
expect(forgedValidation.failures).toContain(
|
||||
"vulnerability report signature verification failed",
|
||||
);
|
||||
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const provenance = signedProviderV2(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
evidenceType: "provenance-attestation",
|
||||
provider: "fixture-provenance",
|
||||
signer: "fixture-workload",
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { ...expected.run, invocationNonce: "2".repeat(64) },
|
||||
source: expected.source,
|
||||
candidate: expected.candidate,
|
||||
subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } },
|
||||
},
|
||||
"provenance-key",
|
||||
provenanceKeys.publicKey,
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
const evaluated = evaluatePromotionEvidence({
|
||||
expected: {
|
||||
...expected,
|
||||
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||
provenanceInvocationNonce: "2".repeat(64),
|
||||
secretScanAttestation: {
|
||||
...secretScanAttestation,
|
||||
sarifSha256: digest("forged empty sarif"),
|
||||
},
|
||||
},
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport: report,
|
||||
provenanceAttestation: provenance,
|
||||
vulnerabilityTrust: trust("vulnerability-key", keys.publicKey),
|
||||
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||
});
|
||||
expect(evaluated.vulnerabilityStatus).toBe("FAIL_UNVERIFIED");
|
||||
expect(evaluated.failures).toContain(
|
||||
"vulnerability report secret scan attestation mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["vulnerability", "provenance"] as const)(
|
||||
"rejects correctly re-signed %s v2 context/time/replay drift",
|
||||
(kind) => {
|
||||
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const expected = providerExpectedContext();
|
||||
const baseVulnerability = providerUnsigned("vulnerability", expected);
|
||||
const baseProvenance = providerUnsigned("provenance", expected);
|
||||
const validVulnerability = signedProviderV2(
|
||||
baseVulnerability,
|
||||
"vulnerability-key",
|
||||
vulnerabilityKeys.publicKey,
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const validProvenance = signedProviderV2(
|
||||
baseProvenance,
|
||||
"provenance-key",
|
||||
provenanceKeys.publicKey,
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
const rawCases: Array<readonly [
|
||||
string,
|
||||
(value: Record<string, any>) => Record<string, any>,
|
||||
RegExp,
|
||||
]> = [
|
||||
["schema v1", (value) => ({ ...value, schemaVersion: 1 }), /missing or invalid/u],
|
||||
[
|
||||
"evidence type",
|
||||
(value) => ({
|
||||
...value,
|
||||
evidenceType:
|
||||
kind === "vulnerability"
|
||||
? "provenance-attestation"
|
||||
: "vulnerability-report",
|
||||
}),
|
||||
/missing or invalid/u,
|
||||
],
|
||||
...(["archiveSha256", "bundleSha256", "distSha256", "lockfileSha256"] as const).map(
|
||||
(field) => [
|
||||
`candidate ${field}`,
|
||||
(value: Record<string, any>) => ({
|
||||
...value,
|
||||
candidate: { ...value.candidate, [field]: "f".repeat(64) },
|
||||
...(kind === "provenance" && field === "distSha256"
|
||||
? {
|
||||
subject: {
|
||||
name: "dist",
|
||||
digest: { sha256: "f".repeat(64) },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
/candidate identity|subject dist/u,
|
||||
] as const,
|
||||
),
|
||||
[
|
||||
"different archive with same dist and lockfile",
|
||||
(value) => ({
|
||||
...value,
|
||||
candidate: { ...value.candidate, archiveSha256: "e".repeat(64) },
|
||||
}),
|
||||
/candidate identity/u,
|
||||
],
|
||||
[
|
||||
"source revision",
|
||||
(value) => ({ ...value, source: { ...value.source, revision: "c".repeat(40) } }),
|
||||
/source identity/u,
|
||||
],
|
||||
[
|
||||
"source set",
|
||||
(value) => ({ ...value, source: { ...value.source, sourceSetSha256: "c".repeat(64) } }),
|
||||
/source identity/u,
|
||||
],
|
||||
[
|
||||
"run id",
|
||||
(value) => ({ ...value, run: { ...value.run, id: "other-run" } }),
|
||||
/run identity/u,
|
||||
],
|
||||
[
|
||||
"run attempt replay",
|
||||
(value) => ({ ...value, run: { ...value.run, attempt: 2 } }),
|
||||
/run identity/u,
|
||||
],
|
||||
[
|
||||
"different nonce",
|
||||
(value) => ({ ...value, run: { ...value.run, invocationNonce: "3".repeat(64) } }),
|
||||
/invocation nonce/u,
|
||||
],
|
||||
[
|
||||
"missing nonce",
|
||||
(value) => {
|
||||
const run = { ...value.run };
|
||||
delete run.invocationNonce;
|
||||
return { ...value, run };
|
||||
},
|
||||
/missing or invalid/u,
|
||||
],
|
||||
[
|
||||
"uppercase nonce",
|
||||
(value) => ({ ...value, run: { ...value.run, invocationNonce: "A".repeat(64) } }),
|
||||
/missing or invalid/u,
|
||||
],
|
||||
[
|
||||
"short nonce",
|
||||
(value) => ({ ...value, run: { ...value.run, invocationNonce: "1".repeat(62) } }),
|
||||
/missing or invalid/u,
|
||||
],
|
||||
[
|
||||
"issued future boundary",
|
||||
(value) => ({ ...value, issuedAt: "2026-08-02T01:05:00.001Z" }),
|
||||
/future skew/u,
|
||||
],
|
||||
[
|
||||
"expiry equality",
|
||||
(value) => ({ ...value, expiresAt: "2026-08-02T01:00:00.000Z" }),
|
||||
/expired/u,
|
||||
],
|
||||
[
|
||||
"expiry past",
|
||||
(value) => ({ ...value, expiresAt: "2026-08-02T00:59:59.999Z" }),
|
||||
/expired/u,
|
||||
],
|
||||
[
|
||||
"zero lifetime",
|
||||
(value) => ({
|
||||
...value,
|
||||
issuedAt: "2026-08-02T01:01:00.000Z",
|
||||
expiresAt: "2026-08-02T01:01:00.000Z",
|
||||
}),
|
||||
/not positive/u,
|
||||
],
|
||||
[
|
||||
"negative lifetime",
|
||||
(value) => ({
|
||||
...value,
|
||||
issuedAt: "2026-08-02T01:02:00.000Z",
|
||||
expiresAt: "2026-08-02T01:01:59.999Z",
|
||||
}),
|
||||
/not positive/u,
|
||||
],
|
||||
[
|
||||
"lifetime above two hours",
|
||||
(value) => ({
|
||||
...value,
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T03:00:00.001Z",
|
||||
}),
|
||||
/exceeds two hours/u,
|
||||
],
|
||||
[
|
||||
"wrong fingerprint",
|
||||
(value) => ({
|
||||
...value,
|
||||
signature: {
|
||||
...value.signature,
|
||||
publicKeyFingerprint: `sha256:${"d".repeat(64)}`,
|
||||
},
|
||||
}),
|
||||
/trust identity/u,
|
||||
],
|
||||
];
|
||||
const cases = rawCases.map(([name, mutate, failure]) => ({
|
||||
name,
|
||||
mutate,
|
||||
failure,
|
||||
}));
|
||||
|
||||
for (const testCase of cases) {
|
||||
const base = kind === "vulnerability" ? baseVulnerability : baseProvenance;
|
||||
const mutated = testCase.mutate(structuredClone(base));
|
||||
const resigned = signedProviderV2(
|
||||
mutated,
|
||||
kind === "vulnerability" ? "vulnerability-key" : "provenance-key",
|
||||
kind === "vulnerability" ? vulnerabilityKeys.publicKey : provenanceKeys.publicKey,
|
||||
kind === "vulnerability" ? vulnerabilityKeys.privateKey : provenanceKeys.privateKey,
|
||||
"signature" in mutated && mutated.signature?.publicKeyFingerprint
|
||||
? mutated.signature.publicKeyFingerprint
|
||||
: undefined,
|
||||
);
|
||||
const result = evaluatePromotionEvidence({
|
||||
expected: {
|
||||
...expected,
|
||||
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||
provenanceInvocationNonce: "2".repeat(64),
|
||||
},
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport:
|
||||
kind === "vulnerability" ? resigned : validVulnerability,
|
||||
provenanceAttestation:
|
||||
kind === "provenance" ? resigned : validProvenance,
|
||||
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||
nowEpochMs: () => now,
|
||||
});
|
||||
expect(result.status, testCase.name).toBe("FAIL_UNVERIFIED");
|
||||
expect(result.failures.join("\n"), testCase.name).toMatch(testCase.failure);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("canonicalizes provider fingerprints from DER SPKI across PEM wrapping and rejects Ed448", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "provider-fingerprint-"));
|
||||
try {
|
||||
const ed25519 = generateKeyPairSync("ed25519").publicKey;
|
||||
const pem = ed25519.export({ type: "spki", format: "pem" }).toString();
|
||||
const body = pem.replace(/-----[^-]+-----|\s/gu, "");
|
||||
const wrapped = (width: number) =>
|
||||
`-----BEGIN PUBLIC KEY-----\n${body.match(new RegExp(`.{1,${width}}`, "gu"))!.join("\n")}\n-----END PUBLIC KEY-----\n`;
|
||||
await writeFile(path.join(root, "a.pem"), wrapped(64));
|
||||
await writeFile(path.join(root, "b.pem"), wrapped(32));
|
||||
const first = await readProviderTrust(root, "a.pem", "fixture-key");
|
||||
const second = await readProviderTrust(root, "b.pem", "fixture-key");
|
||||
expect(first?.publicKeyFingerprint).toBe(providerPublicKeyFingerprint(ed25519));
|
||||
expect(second?.publicKeyFingerprint).toBe(first?.publicKeyFingerprint);
|
||||
|
||||
const ed448 = generateKeyPairSync("ed448").publicKey;
|
||||
await writeFile(root + "/ed448.pem", ed448.export({ type: "spki", format: "pem" }));
|
||||
await expect(readProviderTrust(root, "ed448.pem", "fixture-key")).resolves.toBeNull();
|
||||
expect(() => providerPublicKeyFingerprint(ed448)).toThrow(/must be Ed25519/u);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("captures the downloaded archive pathname exactly once in the provider supervisor", async () => {
|
||||
const keys = generateKeyPairSync("ed25519");
|
||||
const expected = providerExpectedContext();
|
||||
let captureCount = 0;
|
||||
let receivedEnvironment: Readonly<Record<string, string>> | undefined;
|
||||
const manifest: ReleaseCandidateManifest = {
|
||||
schemaVersion: 1,
|
||||
distSha256: expected.candidate.distSha256,
|
||||
lockfileSha256: expected.candidate.lockfileSha256,
|
||||
bundleSha256: expected.candidate.bundleSha256,
|
||||
files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }],
|
||||
};
|
||||
const result = await superviseProviderEvidence(
|
||||
{
|
||||
kind: "vulnerability",
|
||||
archivePath: "/downloads/candidate.tar.gz",
|
||||
expectedArchiveSha256: expected.candidate.archiveSha256,
|
||||
expectedRun: {
|
||||
id: expected.run.id,
|
||||
attempt: expected.run.attempt,
|
||||
sourceRevision: expected.source.revision,
|
||||
},
|
||||
trust: trust("vulnerability-key", keys.publicKey),
|
||||
executeProvider: async ({ environment }) => {
|
||||
receivedEnvironment = environment;
|
||||
},
|
||||
captureReport: async () => Buffer.from("{}\n"),
|
||||
},
|
||||
{
|
||||
captureArchive: async (input) => {
|
||||
captureCount += 1;
|
||||
expect(input).toEqual({
|
||||
archivePath: "/downloads/candidate.tar.gz",
|
||||
expectedSha256: expected.candidate.archiveSha256,
|
||||
});
|
||||
return {
|
||||
bytes: Buffer.from("captured archive"),
|
||||
archiveSha256: expected.candidate.archiveSha256,
|
||||
};
|
||||
},
|
||||
withVerifiedCandidate: (async (input: any) =>
|
||||
input.verify({ extractionRoot: "/captured/extraction", manifest })) as any,
|
||||
verifyLocalEvidence: async () => ({
|
||||
status: "PASS",
|
||||
identity: {
|
||||
sourceRevision: expected.source.revision,
|
||||
sourceSetSha256: expected.source.sourceSetSha256,
|
||||
assessmentSha256: digest("assessment"),
|
||||
secretScan: {
|
||||
policySha256: digest("provider secret policy"),
|
||||
sarifSha256: digest("provider secret sarif"),
|
||||
scanInputSha256: digest("provider secret input"),
|
||||
},
|
||||
},
|
||||
failures: [],
|
||||
}),
|
||||
validateUpload: (async (input: any) => {
|
||||
expect("archivePath" in input).toBe(false);
|
||||
return { sealed: true };
|
||||
}) as any,
|
||||
randomBytes: () => Buffer.alloc(32, 0x11),
|
||||
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(captureCount).toBe(1);
|
||||
expect(receivedEnvironment).toEqual(
|
||||
expect.objectContaining({
|
||||
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
|
||||
PROVIDER_INVOCATION_NONCE: "11".repeat(32),
|
||||
PROVIDER_ISSUED_AT: "2026-08-02T01:00:00.000Z",
|
||||
PROVIDER_EXPIRES_AT: "2026-08-02T02:00:00.000Z",
|
||||
CI_RUN_ID: expected.run.id,
|
||||
CI_RUN_ATTEMPT: "1",
|
||||
SOURCE_REVISION: expected.source.revision,
|
||||
CANDIDATE_ARCHIVE_SHA256: expected.candidate.archiveSha256,
|
||||
}),
|
||||
);
|
||||
expect(result.evidence).toEqual({ sealed: true });
|
||||
});
|
||||
|
||||
it("samples provider freshness after report capture instead of reusing issuance time", async () => {
|
||||
const keys = generateKeyPairSync("ed25519");
|
||||
const expected = providerExpectedContext();
|
||||
const manifest: ReleaseCandidateManifest = {
|
||||
schemaVersion: 1,
|
||||
distSha256: expected.candidate.distSha256,
|
||||
lockfileSha256: expected.candidate.lockfileSha256,
|
||||
bundleSha256: expected.candidate.bundleSha256,
|
||||
files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }],
|
||||
};
|
||||
const issuedSample = Date.parse("2026-08-02T01:00:00.000Z");
|
||||
const validationSample = Date.parse("2026-08-02T02:00:00.001Z");
|
||||
const samples = [issuedSample, validationSample];
|
||||
let issuedAt = "";
|
||||
await expect(
|
||||
superviseProviderEvidence(
|
||||
{
|
||||
kind: "vulnerability",
|
||||
archivePath: "/downloads/candidate.tar.gz",
|
||||
expectedArchiveSha256: expected.candidate.archiveSha256,
|
||||
expectedRun: {
|
||||
id: expected.run.id,
|
||||
attempt: expected.run.attempt,
|
||||
sourceRevision: expected.source.revision,
|
||||
},
|
||||
trust: trust("vulnerability-key", keys.publicKey),
|
||||
executeProvider: async ({ environment }) => {
|
||||
issuedAt = environment.PROVIDER_ISSUED_AT!;
|
||||
},
|
||||
captureReport: async () => Buffer.from("{}\n"),
|
||||
},
|
||||
{
|
||||
captureArchive: async () => ({
|
||||
bytes: Buffer.from("captured archive"),
|
||||
archiveSha256: expected.candidate.archiveSha256,
|
||||
}),
|
||||
withVerifiedCandidate: (async (input: any) =>
|
||||
input.verify({ extractionRoot: "/captured/extraction", manifest })) as any,
|
||||
verifyLocalEvidence: async () => ({
|
||||
status: "PASS",
|
||||
identity: {
|
||||
sourceRevision: expected.source.revision,
|
||||
sourceSetSha256: expected.source.sourceSetSha256,
|
||||
assessmentSha256: digest("assessment"),
|
||||
secretScan: {
|
||||
policySha256: digest("provider secret policy"),
|
||||
sarifSha256: digest("provider secret sarif"),
|
||||
scanInputSha256: digest("provider secret input"),
|
||||
},
|
||||
},
|
||||
failures: [],
|
||||
}),
|
||||
validateUpload: (async (input: any) => {
|
||||
expect(input.nowEpochMs()).toBe(validationSample);
|
||||
throw new Error("provider report expired during execution");
|
||||
}) as any,
|
||||
randomBytes: () => Buffer.alloc(32, 0x33),
|
||||
nowEpochMs: () => samples.shift()!,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/expired during execution/u);
|
||||
expect(issuedAt).toBe("2026-08-02T01:00:00.000Z");
|
||||
});
|
||||
|
||||
it("kills a timed-out provider but settles only after the child closes", async () => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
kill(signal: NodeJS.Signals): boolean;
|
||||
};
|
||||
let killedWith: NodeJS.Signals | undefined;
|
||||
child.kill = (signal) => {
|
||||
killedWith = signal;
|
||||
return true;
|
||||
};
|
||||
let fireTimeout: (() => void) | undefined;
|
||||
let settled = false;
|
||||
const running = runProviderProcess(
|
||||
{ executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 },
|
||||
{
|
||||
spawnChild: () => child as any,
|
||||
setTimer: (callback) => {
|
||||
fireTimeout = callback;
|
||||
return 1 as any;
|
||||
},
|
||||
clearTimer: () => undefined,
|
||||
},
|
||||
).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
fireTimeout?.();
|
||||
await Promise.resolve();
|
||||
expect(killedWith).toBe("SIGKILL");
|
||||
expect(settled).toBe(false);
|
||||
child.emit("close", null, "SIGKILL");
|
||||
await expect(running).rejects.toThrow(/timed out/u);
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it("captures process-group kill errors, attempts child fallback, and settles after close", async () => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
pid: number;
|
||||
kill(signal: NodeJS.Signals): boolean;
|
||||
};
|
||||
child.pid = 12_346;
|
||||
let fallbackSignal: NodeJS.Signals | undefined;
|
||||
child.kill = (signal) => {
|
||||
fallbackSignal = signal;
|
||||
return true;
|
||||
};
|
||||
let fireTimeout: (() => void) | undefined;
|
||||
const running = runProviderProcess(
|
||||
{ executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 },
|
||||
{
|
||||
spawnChild: () => child as any,
|
||||
setTimer: (callback) => {
|
||||
fireTimeout = callback;
|
||||
return 1 as any;
|
||||
},
|
||||
clearTimer: () => undefined,
|
||||
killProcessGroup: () => {
|
||||
throw Object.assign(new Error("group kill denied"), { code: "EPERM" });
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(() => fireTimeout?.()).not.toThrow();
|
||||
expect(fallbackSignal).toBe("SIGKILL");
|
||||
child.emit("close", null, "SIGKILL");
|
||||
await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u);
|
||||
});
|
||||
|
||||
it.each(["open failure", "partial write failure"])(
|
||||
"cleans finalized staging from memory when GITHUB_OUTPUT has a %s",
|
||||
async (failureKind) => {
|
||||
const finalized = {
|
||||
stagingRoot: "/runner/promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
cleanupToken: "promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
runnerTempIdentity: { dev: 10, ino: 20 },
|
||||
stagingIdentity: { dev: 30, ino: 40 },
|
||||
files: [],
|
||||
} as const;
|
||||
let cleanupInput: unknown;
|
||||
let appendCalls = 0;
|
||||
const environment = {
|
||||
CANDIDATE_ARCHIVE_PATH: "candidate.tar.gz",
|
||||
CANDIDATE_ARCHIVE_SHA256: "a".repeat(64),
|
||||
VULNERABILITY_REPORT_PATH: "vulnerability.json",
|
||||
PROVENANCE_ATTESTATION_PATH: "provenance.json",
|
||||
VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem",
|
||||
VULNERABILITY_KEY_ID: "vulnerability-key",
|
||||
PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem",
|
||||
PROVENANCE_KEY_ID: "provenance-key",
|
||||
CI_RUN_ID: "run",
|
||||
CI_RUN_ATTEMPT: "1",
|
||||
VITE_COMMIT_SHA: "b".repeat(40),
|
||||
VULNERABILITY_INVOCATION_NONCE: "c".repeat(64),
|
||||
PROVENANCE_INVOCATION_NONCE: "d".repeat(64),
|
||||
RUNNER_TEMP: "/runner",
|
||||
GITHUB_OUTPUT: "/runner/github-output",
|
||||
};
|
||||
await expect(
|
||||
runStageVerifiedPromotionCli(environment, {
|
||||
cwd: () => "/workspace",
|
||||
finalize: async () => finalized as any,
|
||||
appendOutput: async () => {
|
||||
appendCalls += 1;
|
||||
if (failureKind === "partial write failure") {
|
||||
// The output sink accepted an unspecified prefix before rejecting.
|
||||
}
|
||||
throw new Error(failureKind);
|
||||
},
|
||||
cleanup: async (input) => {
|
||||
cleanupInput = input;
|
||||
},
|
||||
writeStdout: () => undefined,
|
||||
}),
|
||||
).rejects.toThrow(new RegExp(failureKind, "u"));
|
||||
expect(appendCalls).toBe(1);
|
||||
expect(cleanupInput).toEqual({
|
||||
runnerTempRoot: "/runner",
|
||||
stagingRoot: finalized.stagingRoot,
|
||||
cleanupToken: finalized.cleanupToken,
|
||||
runnerTempIdentity: finalized.runnerTempIdentity,
|
||||
stagingIdentity: finalized.stagingIdentity,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
});
|
||||
@@ -182,8 +182,8 @@ describe("selective Task 3 contract closure", () => {
|
||||
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
|
||||
const canonical = await loadCiGateContract(process.cwd());
|
||||
expect(canonical.gates).toHaveLength(27);
|
||||
expect(canonical.commands).toHaveLength(84);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(96);
|
||||
expect(canonical.commands).toHaveLength(91);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(103);
|
||||
expect(canonical.artifacts).toHaveLength(109);
|
||||
expect(canonical.stages).toHaveLength(5);
|
||||
expect(canonical.retention.classes).toHaveLength(5);
|
||||
|
||||
Reference in New Issue
Block a user