diff --git a/scripts/test-performance.ts b/scripts/test-performance.ts index eac262f..2eb3e7a 100644 --- a/scripts/test-performance.ts +++ b/scripts/test-performance.ts @@ -90,7 +90,13 @@ try { throw new Error("Performance route must be present in navigation."); } const interactionStarted = performance.now(); - await page.getByRole("link", { name: targetLabel }).click(); + // Playwright matches accessible names by substring, so the navigation entry + // "플랫폼 구성" also matched the home page's "플랫폼 구성 보기" call to + // action and the locator resolved to two links. That is a strict-mode + // violation before the first measurement is taken, so no lab performance + // evidence could be produced at all — the run failed for an ambiguous + // selector rather than for anything about performance. + await page.getByRole("link", { name: targetLabel, exact: true }).click(); await page.getByRole("heading", { name: target.title }).waitFor(); const namedInteractionMs = performance.now() - interactionStarted; const paint = await page.evaluate( diff --git a/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts b/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts index e8fd973..4f4ec61 100644 --- a/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts +++ b/src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts @@ -671,7 +671,14 @@ function validateCapabilityPayload( }), ); } catch { - return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER"); + // BT-PRE-04. A capability document this adapter refuses is not a dead end + // for the caller: the only way forward is to ask the issuer for a new one. + // `NONE` said the opposite — that nothing could be done — and disagreed + // with both the design record for an unsupported protocol and the vault, + // which already answers `REISSUE_CAPABILITY` for the same class of refusal. + return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", { + recovery: "REISSUE_CAPABILITY", + }); } } diff --git a/tests/browser-capabilities/presigned-streaming.spec.ts b/tests/browser-capabilities/presigned-streaming.spec.ts index 3a2e41e..9a4b5ac 100644 --- a/tests/browser-capabilities/presigned-streaming.spec.ts +++ b/tests/browser-capabilities/presigned-streaming.spec.ts @@ -32,6 +32,10 @@ test("issues an opaque capability and streams a verified object into the writabl status: 200, contentType: "application/json", body: JSON.stringify({ + // The wire envelope names the protocol it speaks. Without it the + // capability is refused before any object request is made, so the whole + // download path below was asserting on an empty transcript. + protocol: "PRESIGNED_TRANSFER_V1", capabilityReceipt: "browser-download-capability-1", method: "GET", binding: { @@ -307,6 +311,11 @@ test("issues an opaque capability and streams a verified object into the writabl expect(bffRequests).toEqual([ { + // The capability request names the protocol it is asking for. Leaving it + // out of this expectation meant the fixture stopped describing the + // request the adapter actually sends when PRESIGNED_TRANSFER_V1 was + // hardened, and the object GET path stopped being exercised at all. + protocol: "PRESIGNED_TRANSFER_V1", method: "GET", binding: { kind: "DOWNLOAD", diff --git a/tests/browser-capabilities/resumable-upload.spec.ts b/tests/browser-capabilities/resumable-upload.spec.ts index 59ae5f7..3d84d0b 100644 --- a/tests/browser-capabilities/resumable-upload.spec.ts +++ b/tests/browser-capabilities/resumable-upload.spec.ts @@ -129,6 +129,10 @@ test("uploads three presigned parts through native IndexedDB, Web Locks and fetc const objectPath = `/uploads/${SESSION_ID}/parts/${String(partNumber)}`; await fulfillJson(route, 200, { + // The part capability travels in a presigned envelope, so it names the + // transfer protocol even though its binding names the upload one. + // Without it every part was refused before any object PUT was made. + protocol: "PRESIGNED_TRANSFER_V1", capabilityReceipt: `browser-part-capability-${String(partNumber)}`, method: "PUT", binding, @@ -483,9 +487,12 @@ test("uploads three presigned parts through native IndexedDB, Web Locks and fetc }, }); expect(result.checkpoint).toEqual({ ok: true, value: null }); + // The deletion reports the physical effect it observed, not only the state it + // reached, so a caller can tell a delete that happened from one that found + // nothing to do. The fixture asserts it rather than ignoring it. expect(result.deletion).toEqual({ ok: true, - value: { state: "DELETED" }, + value: { state: "DELETED", effect: "APPLIED" }, }); expect( uploadedBodies diff --git a/tests/unit/presigned-transfer.test.ts b/tests/unit/presigned-transfer.test.ts index b060660..982dd75 100644 --- a/tests/unit/presigned-transfer.test.ts +++ b/tests/unit/presigned-transfer.test.ts @@ -242,6 +242,42 @@ describe("presigned transfer", () => { ); }); + it("answers a refused capability envelope with a re-issuable recovery", async () => { + // BT-PRE-04. A capability document the adapter will not accept is closed as + // `POLICY_REJECTED`, and the caller's only way forward is a new capability. + // `NONE` said there was nothing to be done, which contradicted both the + // design record for an unsupported protocol and the vault, which already + // answers `REISSUE_CAPABILITY` for the same class of refusal. + for (const [label, overrides] of [ + ["unknown protocol", { protocol: "PRESIGNED_TRANSFER_V2" }], + ["missing protocol", { protocol: undefined }], + ] as const) { + const bytes = new Uint8Array([1, 2, 3]); + const payload: Record = { + ...downloadCapabilityPayload(bytes), + ...overrides, + }; + if (overrides.protocol === undefined) delete payload["protocol"]; + const fetcher = vi.fn(async () => jsonResponse(payload)) as unknown as typeof fetch; + const { provider } = createHarness({ fetcher }); + + expect( + await provider.issueDownload({ + resourceId: "resource-1", + signal: new AbortController().signal, + }), + label, + ).toMatchObject({ + ok: false, + error: { + code: "POLICY_REJECTED", + retryable: false, + recovery: "REISSUE_CAPABILITY", + }, + }); + } + }); + it("keeps URL and headers adapter-private and streams bounded verified chunks", async () => { const bytes = new Uint8Array([1, 2, 3, 4, 5]); const payload = downloadCapabilityPayload(bytes); diff --git a/tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/platform-overview-light-chromium-visual-linux.png b/tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/platform-overview-light-chromium-visual-linux.png index b874f27..3f95e24 100644 Binary files a/tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/platform-overview-light-chromium-visual-linux.png and b/tests/visual/__snapshots__/platform.visual.spec.ts-snapshots/platform-overview-light-chromium-visual-linux.png differ