diff --git a/README.md b/README.md index 24a106b..7ccb063 100644 --- a/README.md +++ b/README.md @@ -47,21 +47,28 @@ authoritative. ## Architecture +This repository is a **Frontend Application Foundation**: a starter/composition +skeleton plus a reusable capability platform. Platform capabilities stay +horizontal while product business features use vertical slices. + Dependencies point inward: ```text presentation -> application -> domain adapters -----^ bootstrap composes concrete adapters -contracts own cross-cutting registries +feature use case -> feature port <- feature-owned capability binding ``` -See `docs/architecture/overview.md`, `docs/architecture/layers.md`, and +See +`docs/architecture/frontend-application-foundation.md`, +`docs/architecture/overview.md`, `docs/architecture/layers.md`, and `docs/architecture/starter-experience.md`. The removable vertical slice is under `src/features/reference-feature`; its domain, application input, HTTP -adapter, contracts, route runtime, and presentation are installed through the -feature contribution files in `src/features`. The generic starter routes -continue to typecheck, test, and build after that contribution is removed. +binding, contracts, route runtime, and presentation own their contributions. +The central installed catalogs only aggregate selected contributions. The +generic starter routes continue to typecheck, test, and build after that +feature is removed. ### Platform capability review @@ -103,6 +110,7 @@ corepack pnpm check:types:node corepack pnpm check:types:test corepack pnpm check:architecture corepack pnpm test:all +corepack pnpm test:contract corepack pnpm test:e2e corepack pnpm test:a11y corepack pnpm build @@ -115,6 +123,11 @@ corepack pnpm drill:runbooks corepack pnpm check:ci ``` +`test:all` is the ordinary product-development loop and intentionally excludes +host-level CI-runner assurance. Run `corepack pnpm test:system` only on the +compatible Linux assurance host described in +[`docs/testing/taxonomy.md`](docs/testing/taxonomy.md). + `check:types`는 source, Node scripts/config와 tests를 분리된 TypeScript project로 모두 검사한다. type/architecture/security/registry의 invalid fixture는 `config/ci/gates.json`에서 “실패해야 통과”하는 negative gate로 @@ -153,7 +166,7 @@ Live release verification additionally requires `HOSTING_BASE_URL`. ## CI and evidence -The 26-gate registry is `config/ci/gates.json`; the Gitea workflow is +The 27-gate registry is `config/ci/gates.json`; the Gitea workflow is `.gitea/workflows/quality-gates.yml`. It follows: ```text diff --git a/config/ci/gates.json b/config/ci/gates.json index b549802..aae95e8 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -142,6 +142,11 @@ "script": "test:unit", "expect": "pass" }, + { + "id": "test-contract", + "script": "test:contract", + "expect": "pass" + }, { "id": "test-coverage", "script": "test:coverage", @@ -164,6 +169,11 @@ "script": "test:integration", "expect": "pass" }, + { + "id": "test-system", + "script": "test:system", + "expect": "pass" + }, { "id": "test-http-scenario-evidence", "script": "test:http-scenario-evidence", @@ -794,6 +804,15 @@ "test-unit" ] }, + { + "id": "artifact-artifacts-tests-contract-xml", + "path": "artifacts/tests/contract.xml", + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-contract" + ] + }, { "id": "artifact-artifacts-tests-coverage-xml", "path": "artifacts/tests/coverage.xml", @@ -860,6 +879,15 @@ "test-integration" ] }, + { + "id": "artifact-artifacts-tests-system-xml", + "path": "artifacts/tests/system.xml", + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-system" + ] + }, { "id": "artifact-artifacts-tests-http-scenario-executions-json", "path": "artifacts/tests/http-scenario-executions.json", @@ -1701,12 +1729,14 @@ "name": "unit", "commandIds": [ "test-unit", + "test-contract", "test-coverage", "check-coverage-fixture" ], "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-005-txt", "evidenceArtifactIds": [ "artifact-artifacts-tests-unit-xml", + "artifact-artifacts-tests-contract-xml", "artifact-artifacts-tests-coverage-xml", "artifact-artifacts-tests-coverage-coverage-summary-json", "artifact-artifacts-quality-risk-coverage-json", @@ -1871,6 +1901,7 @@ "id": "FE-GATE-013", "name": "security", "commandIds": [ + "test-system", "verify-reproducible-build", "build-release-candidate", "verify-local-evidence", @@ -1881,6 +1912,7 @@ ], "logArtifactId": "artifact-artifacts-quality-gates-FE-GATE-013-txt", "evidenceArtifactIds": [ + "artifact-artifacts-tests-system-xml", "artifact-artifacts-security-scan-sarif", "artifact-artifacts-release-dependency-inventory-json", "artifact-artifacts-release-sbom-cdx-json", diff --git a/docs/architecture/capability-consumer-experience.md b/docs/architecture/capability-consumer-experience.md new file mode 100644 index 0000000..18cb7e4 --- /dev/null +++ b/docs/architecture/capability-consumer-experience.md @@ -0,0 +1,127 @@ +# Capability consumer experience baseline + +Correctness inside a reusable capability is not sufficient. The platform is +also evaluated by how much of that correctness a normal feature developer must +understand. + +These measurements are baselines, not score targets. A lower line count is not +automatically better if it hides business semantics or creates a universal +repository abstraction. + +## Scenario A — reference REST feature + +The existing reference feature is the executable REST consumer. + +It covers list/detail/create behavior, mapping, typed operation inputs, +application ports and an optimistic mutation path. + +Current source size: + +| Feature-owned area | LOC | +| --- | ---: | +| `application/reference-feature-api.ts` | 91 | +| `contracts/reference-mapper.ts` | 109 | +| `adapters/reference-http-gateway.ts` | 65 | +| `adapters/create-reference-feature-input.ts` | 41 | +| Total measured boundary/application source | 306 | + +The important boundary metric is not the raw total. It is what those files need +to know about the platform. + +Current result: + +- HTTP platform imports in the feature adapter layer: 2 files, +- canonical import path used by both: + `src/adapters/http/index.ts`, +- direct imports of `http-execution-v3.ts`, retry scheduler, response reader, + auth admission or effect-certainty internals: 0, +- feature gateway owns transport failure projection: 0, +- feature gateway chooses operation ID, route ID, exact input/value type and + mapper: yes, +- central installed files own feature-specific adapter/runtime wiring: no; + feature-owned contributions are aggregated centrally. + +The reusable HTTP capability now owns execution-outcome normalization through +`createFeatureHttpBinding`. A feature may still implement a custom outbound +adapter behind its application port when the reusable contract does not match +its business requirement. + +## Scenario B — IndexedDB local draft + +An executable consumer probe lives at: + +`tests/contract/consumer-experience/indexeddb-local-draft.test.ts` + +with the feature-owned fixture: + +`tests/contract/consumer-experience/fixtures/local-draft-feature.ts` + +The fixture models a small local-draft feature with save/find/remove and +optimistic revision checking. + +Measured result: + +| Metric | Result | +| --- | ---: | +| feature-owned fixture LOC | 97 | +| platform import statements | 1 | +| native IndexedDB API references | 0 | +| `src/adapters/storage/indexeddb/**` imports | 0 | +| runtime-internal IndexedDB types imported by feature | 0 | + +The feature depends only on the public application boundary: + +`src/application/ports/browser-file-storage/index.ts` + +and specifically `IndexedDbRepositoryPort`. + +The contract test rejects feature source that reaches for +`globalThis.indexedDB`, `IDBFactory`, `IDBDatabase`, `IDBTransaction`, +`IDBObjectStore` or the concrete IndexedDB adapter directory. + +## What this does and does not prove + +The probe confirms that **feature business code does not need native IndexedDB +knowledge** once an `IndexedDbRepositoryPort` has been composed. + +It does not prove that composition of `createIndexedDbRuntime` is cheap. +That constructor still owns substantial infrastructure policy: + +- dataset scope and storage policy, +- physical store governance, +- retention/idempotency stores, +- schema migrations, +- codec and query policy, +- lifecycle authority, +- durability, scheduling and observation. + +That complexity belongs at the composition/platform boundary, not in the +feature. A new `create...Repository` convenience factory should be added +only when a second real product consumer demonstrates which subset is stable +enough to become a reusable profile. Creating one now would guess at policy and +risk producing a universal storage abstraction. + +## Consumer-quality review checklist + +For each new product feature, record: + +- feature-owned adapter LOC, +- platform glue LOC, +- files changed, +- central catalog edits, +- direct imports from capability-internal modules, +- native browser/network API references, +- duplicated failure/retry/lifecycle policy. + +A healthy feature path should look like: + +1. domain type and invariant, +2. use case, +3. business port, +4. feature-owned mapper/codec and policy, +5. capability-specific binding, +6. presentation controller/page. + +The feature should not need the retry scheduler, abort ownership, +effect-certainty machinery, IndexedDB transaction lifecycle, OPFS journal, +reconnect coordinator or provider process model. diff --git a/docs/architecture/contract-ownership.md b/docs/architecture/contract-ownership.md new file mode 100644 index 0000000..fec11de --- /dev/null +++ b/docs/architecture/contract-ownership.md @@ -0,0 +1,110 @@ +# Contract ownership + +`src/contracts` is not a default destination for every shared-looking type. +A contract belongs there only when the change authority is genuinely shared +across layers, capabilities, build tooling, or runtime composition. + +## Decision rule + +For every proposed contract, ask: + +1. Which requirement can cause this type or policy to change? +2. Is there one clear capability or feature owner? +3. Does another runtime/tooling boundary consume the same semantic contract? +4. Is the contract a wire/artifact authority shared by browser runtime and + build/release tooling? + +The placement rule is: + +- one feature owner -> keep it under that feature, +- one reusable capability owner -> keep it under that adapter/capability and + export it through the capability public entry point, +- multiple independent capability/layer owners -> `src/contracts`, +- browser/build/release wire or artifact authority -> `src/contracts` even + when the browser source graph alone looks small. + +Consumer count alone is not sufficient. Scripts, generated artifacts and +release gates are semantic consumers too. + +## Audit result + +The September 2026 architecture review triggered an import-graph audit of all +39 contract files. + +### Capability-owned contract moved + +`cursor-pagination.ts` had one production owner: +`src/adapters/query-cache/cursor-pagination-runtime.ts`. + +It moved to: + +`src/adapters/query-cache/cursor-pagination-contract.ts` + +and is exported through: + +`src/adapters/query-cache/index.ts` + +Tests use that public capability entry point. Pagination vocabulary no longer +occupies the global contract bucket merely because it is reusable inside one +adapter. + +### Contracts intentionally kept global + +The following examples have multiple semantic owners and remain global: + +- `errors.ts` — application, presentation and several adapters, +- `result.ts` — common success/failure carrier below application, +- `boundary-mapper.ts` — HTTP, browser RPC, realtime and feature registries, +- `mutation-intent.ts` — application, presentation, HTTP, platform and + bootstrap, +- `exact-snapshot.ts` — HTTP, query-cache and browser-transfer, +- `rest-profiles.ts` — auth, HTTP, bootstrap and feature contract validation, +- `query-invalidation.ts` / `query-keys.ts` — query-cache, presentation, + bootstrap and features, +- `cache-invalidation.ts` — cross-context wire protocol plus query + invalidation policy, +- `storage-keys.ts` — browser storage and cross-context invalidation, +- `telemetry.ts` / `diagnostics.ts` — runtime adapters, application and + bootstrap. + +Some contracts appear to have few browser-source consumers but are still shared +authorities: + +- `env.ts` is consumed by bootstrap, runtime-schema/security tests and + registry governance, +- `deployment-admission.ts` is shared by runtime-config generation and release + admission, +- `release-tokens.ts` is shared by runtime coherence tooling and tests, +- `service-worker-static-manifest.ts` is a runtime-neutral canonical format + shared by build generation, validation and service-worker evidence. + +Moving those based only on `src/**` import counts would split one semantic +authority across processes. + +## Feature contracts + +Feature-specific contracts stay inside the vertical slice: + +`features//contracts` + +The reference feature owns routes, schemas, mapper definitions, message +catalogs and contribution identities. Central installed files aggregate those +contributions; they do not own their semantics. + +The message catalog is a deliberate special case: the compiled catalog remains +total even when a feature is build-time disabled so the typed message lookup +does not become partial. The central message file therefore aggregates compiled +message keys rather than treating runtime installation as message ownership. + +## Review rule for future additions + +A new file under `src/contracts` should be rejected during review when all of +the following are true: + +- one feature or one capability is the only semantic owner, +- no build/release/runtime wire authority needs the same definition, +- moving the definition to that owner does not create an inward dependency + violation. + +Do not move a contract merely to reduce the number 39. The goal is explicit +ownership, not a smaller directory. diff --git a/docs/architecture/frontend-application-foundation.md b/docs/architecture/frontend-application-foundation.md new file mode 100644 index 0000000..43439ca --- /dev/null +++ b/docs/architecture/frontend-application-foundation.md @@ -0,0 +1,219 @@ +# Frontend Application Foundation + +This repository is not treated as a minimal React project template and it is not +an independent general-purpose SDK. Its architectural role is: + +> Frontend Application Foundation = Starter / Composition Skeleton + Reusable Capability Platform + +The starter side owns bootstrap, routing, providers, project conventions and a +removable reference feature. The capability side owns reusable technical +problems such as HTTP execution, Server State, authentication boundaries, +IndexedDB/OPFS, Cache Storage, realtime, browser RPC, transfer and diagnostics. + +The cost of a sophisticated capability is acceptable only when product features +do not have to understand that internal sophistication. + +## Hybrid architecture + +Platform code is horizontal: + +- `application`: generic application inputs/policies/ports +- `contracts`: genuinely cross-capability shared vocabulary and registries +- `adapters`: reusable capability runtimes +- `presentation`: generic UI/routing/query integration +- `bootstrap`: concrete composition + +Product business code is vertical: + +- `features//domain` +- `features//application` +- `features//contracts` +- `features//adapters` +- `features//presentation` + +The dependency model is: + + Domain / Use case + | + | owns + v + Business port + ^ + | implements / binds + | + Feature-owned adapter binding + | + | generic type + mapper/codec + policy + v + Reusable capability runtime + | + v + Browser / network / native API + +A use case never imports a platform adapter. Generic binding happens in the +feature adapter/composition seam. + +## Capability-specific typed bindings + +Do not introduce one universal `Repository` abstraction for HTTP, +storage, realtime and transfer. Their lifecycle and failure semantics differ. + +A reusable capability boundary is composed from: + +- generic input/output types, +- feature-owned mapper or codec, +- feature-selected policy, +- one capability-specific runtime. + +The HTTP reference path is the first concrete example. +`src/adapters/http/feature-http-binding.ts` owns transport/outcome +normalization. The reference feature contributes only: + +- operation ID, +- route ID, +- exact request input type, +- exact success value type, +- wire-to-domain mapper. + +The feature gateway therefore does not reimplement timeout, cancellation, +transport failure, authentication failure or contract-violation projection. + +Storage, realtime and transfer may gain their own typed binders only after +actual feature repetition demonstrates the need. They must not be forced +through the HTTP abstraction. + +## Custom adapter escape hatch + +A product feature uses a reusable capability when the capability preserves the +business requirement. + +If a platform contract would require changing or weakening the business model, +the feature implements a custom outbound adapter behind the same application +port. The architecture boundary remains stable; platform reuse is optional. + +## Feature installation + +A feature owns its contract, runtime and adapter contributions. + +Central installed catalogs are aggregation points only: + +- `installed-product-manifest.ts`: which product features are compiled/selected +- `installed-feature-contracts.ts`: contract aggregation +- `installed-feature-runtimes.tsx`: runtime contribution aggregation +- `installed-feature-adapters.ts`: application-input contribution aggregation + +Feature-specific composition belongs under the feature itself. Central +catalogs must not grow feature-specific branching logic. + +## Presentation consumer surface + +`ApplicationProvider` remains the composition root for presentation, but new +consumers should not navigate a root `ApplicationApi` service locator. + +Use the narrow hooks in +`src/presentation/providers/application-provider.tsx`: + +- `useApplicationSession` +- `useApplicationPreferences` +- `useApplicationDiagnostics` +- `useApplicationRuntime` +- `useApplicationRecovery` +- `useApplicationFeature` + +`useApplication` exists only as a deprecated compatibility escape hatch. + +## Canonical imports + +New feature code should use capability public entry points rather than deep +runtime modules. For HTTP the canonical path is +`src/adapters/http/index.ts`. + +Compatibility re-exports may exist during a migration window, but they must be +marked as compatibility/deprecated paths and should not expand into an +unbounded public barrel. + +## Policy ownership + +Duplication is judged by ownership, not by syntax percentage. + +Small local validators can remain duplicated when locality improves auditing. +Business or concurrency policy must have one owner. For example the reference +create mutation keeps definition ID, idempotency requirement, duplicate policy +and invalidation policy in one feature-owned definition and binds only +`scope` and `execute` per usage site. + +## Runtime decomposition rule + +Large runtime files are not split by line count. + +Extract a boundary when it has its own state machine, lifecycle owner, failure +model or compensation/recovery responsibility. Candidate seams include: + +- connection/open/upgrade lifecycle, +- transaction ownership, +- migration state machine, +- reconnect/backoff and heartbeat, +- subscription ownership, +- retry/deadline/cancellation ownership, +- settlement/reconciliation/cleanup. + +A cohesive 2,000-line state machine can remain together. A 300-line file with +multiple lifecycle owners is a better extraction candidate. + +### Current runtime boundary audit + +The current large-runtime inventory was reviewed using that rule. + +- IndexedDB remains large, but connection, transaction, migration, maintenance + and failure translation already have separate owners/modules. +- resumable upload already separates runtime policy, checkpoint persistence, + HTTP control-plane transport, part execution, cancellation and mutation + locking. +- realtime already separates reconnect policy/coordinator, event codec/consumer + and stream coordination. +- OPFS is separated into browser runtime, journal, byte-store, policy and worker + protocol/runtime responsibilities. +- HTTP V3 still owned retry eligibility/backoff inside the execution state + machine, so that responsibility moved to + `src/adapters/http/http-retry-lifecycle.ts`. + +No other runtime is split merely because of its line count. + +## Consumer quality metrics + +Before adding another abstraction, implement or model multiple real feature +uses and measure: + +- feature-owned adapter LOC, +- repeated platform glue, +- number of platform-internal types exposed to the feature, +- central catalog edits, +- files changed for one normal query/command, +- whether native browser/network APIs leak into the feature. + +The target feature-development path is: + +1. domain type and invariant, +2. use case, +3. port, +4. transport/storage schema plus mapper/codec, +5. capability binding, +6. presentation controller/page. + +A product feature should not need to know the retry scheduler, abort ownership, +effect-certainty machinery, transaction leases, reconnect coordinator, OPFS +journal or provider lifecycle. + +The executable REST and IndexedDB consumer baselines are recorded in +[`capability-consumer-experience.md`](./capability-consumer-experience.md). +Contract placement and the global-vs-owner-local audit are recorded in +[`contract-ownership.md`](./contract-ownership.md). + +## Verification paths + +Product-development verification, capability verification and release assurance +are intentionally separate. See +[`docs/testing/taxonomy.md`](../testing/taxonomy.md). + +Host-level CI-runner tests belong to `tests/system`, not `tests/unit`. +Reusable capability consumer contracts belong to `tests/contract`. diff --git a/docs/reviews/2026-09-17-senior-frontend-architecture-review.md b/docs/reviews/2026-09-17-senior-frontend-architecture-review.md new file mode 100644 index 0000000..15380c6 --- /dev/null +++ b/docs/reviews/2026-09-17-senior-frontend-architecture-review.md @@ -0,0 +1,1050 @@ +# Clean Architecture Frontend Template — Senior Architecture Review + +> 검토일: 2026-09-17 +> +> 검토 기준: `develop` / `c10a709f2cdea9d58d2f4816b48b4c41d0b5268b` +> +> 검토 관점: 장기 유지보수, Clean Architecture 경계, reusable adapter/capability 설계, TypeScript 타입 안전성, 테스트 품질과 실행 신뢰성, 개발자 경험 + +## 1. 결론 + +이 저장소를 단순한 **React 프로젝트 템플릿**으로 평가하면 현재 구조는 지나치게 크고 복잡하다. 그러나 실제 목적을 반영하면 평가는 달라진다. + +이 저장소의 목표는 다음에 더 가깝다. + +> **비즈니스 use case와 domain을 기술 세부사항에서 보호하면서, HTTP·Server State·IndexedDB·OPFS·WebSocket·Upload/Download 같은 클라이언트 capability를 미리 구현해 두고, 새 제품 기능에서는 비즈니스 타입과 mapper/codec/policy를 결합해 최대한 재사용할 수 있는 Frontend Application Foundation.** + +즉 `Starter Template + Reusable Capability Platform`의 혼합형이다. + +따라서 IndexedDB runtime이 크다거나 WebSocket 구현이 복잡하다는 이유만으로 제거하거나 단순화해서는 안 된다. 이런 복잡성은 여러 feature에서 한 번 구현한 기술 문제를 재사용하기 위한 비용일 수 있다. + +진짜 판정 기준은 다음이다. + +1. feature/use case 개발자가 adapter 내부 구현을 알아야 하는가. +2. use case가 요구하는 비즈니스 타입을 TypeScript generic으로 안전하게 adapter binding에 적용할 수 있는가. +3. 기능별 차이를 mapper/codec/policy만 주입해 표현할 수 있는가. +4. 공통 capability가 요구사항에 맞지 않을 때 custom adapter를 자연스럽게 구현할 수 있는가. +5. 플랫폼 내부의 registry, lifecycle, retry, storage, browser API 복잡도가 application/domain으로 새지 않는가. +6. 플랫폼을 사용하는 비용이 플랫폼을 직접 새로 구현하는 비용보다 충분히 낮은가. + +이 기준으로 보면 현재 저장소는 **방향은 맞고 기술적 기반도 강하지만, consumer surface와 검증 구조가 플랫폼 내부 복잡도를 충분히 숨기지 못하고 있다.** + +현재 가장 중요한 문제는 “플랫폼 기능이 너무 많다”가 아니라 다음 세 가지다. + +- **공통 capability를 사용하는 feature 작성 경로가 아직 충분히 작지 않다.** +- **테스트 taxonomy와 실행 환경이 self-contained하지 않아 테스트 결과를 신뢰하기 어렵게 만든다.** +- **플랫폼 내부 구현과 CI assurance가 매우 커졌는데, 이 복잡성이 명확한 모듈 경계로 격리되지 않았다.** + +--- + +## 2. 검토 기준선 + +현재 저장소 규모는 다음과 같다. + +| 영역 | 파일 수 | LOC | +| --- | ---: | ---: | +| `src/**` | 311 | 83,947 | +| `src/adapters/**` | 129 | 59,120 | +| `src/application/**` | 41 | 3,525 | +| `src/bootstrap/**` | 13 | 1,880 | +| `src/contracts/**` | 39 | 8,144 | +| `src/features/**` | 24 | 2,096 | +| `src/presentation/**` | 65 | 9,182 | +| `tests/**` TypeScript | 308 | 72,120 | +| `scripts/**` TypeScript | 123 | 28,397 | +| `package.json` scripts | 114 | - | + +제품 dependency는 6개, dev dependency는 29개다. 외부 라이브러리 숫자를 무작정 늘린 구조는 아니다. 복잡성의 상당 부분을 저장소가 직접 소유하고 있다는 의미다. + +가장 큰 production source는 다음과 같다. + +| 파일 | LOC | +| --- | ---: | +| `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | 2,744 | +| `src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts` | 2,298 | +| `src/adapters/realtime/websocket/websocket-connection.ts` | 2,003 | +| `src/adapters/storage/opfs/opfs-worker-runtime.ts` | 1,971 | +| `src/adapters/cache-storage/public-response-cache-adapter.ts` | 1,949 | +| `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | 1,881 | +| `src/adapters/realtime/reconnect-coordinator.ts` | 1,796 | +| `src/adapters/browser-rpc/browser-rpc-runtime.ts` | 1,696 | +| `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | 1,649 | +| `src/adapters/http/http-execution-v3.ts` | 1,602 | + +큰 파일 자체를 결함으로 판정하지 않는다. 다만 이 규모에서는 내부 책임 분리가 명확하지 않으면 수정 시 regression 위험이 빠르게 증가한다. + +--- + +## 3. 현재 구조에서 잘된 부분 + +### 3.1 Clean Architecture 경계가 실제로 집행된다 + +이 저장소의 가장 강한 부분이다. + +`presentation`, `application`, feature code가 `fetch`, `IndexedDB`, `WebSocket`, browser storage 같은 native I/O를 임의로 직접 호출하는 구조가 아니며, 정적 architecture gate가 dependency 방향을 검사한다. + +검토 시점 `check:architecture`는 다음 기준선을 통과했다. + +```text +307 modules +929 dependencies +PASS +``` + +금지 dependency fixture도 실제로 거절된다. + +즉 폴더 이름만 `domain/application/adapter`로 만든 구조가 아니라 **의존 방향을 executable rule로 만든 구조**다. + +### 3.2 TypeScript generic을 이미 핵심 계약에 사용하고 있다 + +사용자가 원하는 “use case에서 결정되는 비즈니스 타입을 공통 adapter capability에 적용”할 기반은 이미 존재한다. + +예를 들어 V3 HTTP executor는 다음 형태다. + +```ts +interface ContractHttpExecutor { + execute( + operation: InstalledHttpContract, + input: Input, + context: HttpExecutionContext, + ): Promise>; +} +``` + +즉 HTTP runtime은 특정 `User`, `Order`, `ReferenceResource`를 알지 않는다. `Input`, `WireOutput`, `Problem` 타입 파라미터만 안다. + +Server State도 `BoundMutation`와 같이 feature의 command/result 타입을 generic으로 받는다. + +또 `ApplicationFeatureInputs`는 module augmentation을 이용해 concrete feature를 generic application에 직접 import하지 않고도 typed feature API를 등록한다. + +이 방향은 유지할 가치가 높다. + +### 3.3 reference feature가 vertical slice의 실제 증거 역할을 한다 + +`reference-feature`는 단순 데모 화면이 아니다. + +- domain은 외부 DTO/React를 모른다. +- application은 input/gateway 계약을 가진다. +- outbound adapter가 HTTP executor를 feature gateway로 투영한다. +- mapper가 transport payload와 domain/application value 사이를 분리한다. +- presentation은 application input을 호출하며 HTTP나 storage를 직접 알지 않는다. +- sample removal test로 feature를 제거했을 때 generic platform이 유지되는지 확인한다. + +즉 목표 아키텍처를 실제 실행 가능한 vertical slice로 증명하고 있다는 점은 좋다. + +### 3.4 테스트의 사고방식은 깊다 + +테스트는 단순 mock 호출 횟수 검증에 머무르지 않는다. + +예를 들어 query/mutation test는 cancellation, scope fence, stale data, optimistic mutation, effect certainty, reconciliation, duplicate submission을 실제 lifecycle 관점에서 검증한다. + +HTTP/storage/realtime도 정상 경로뿐 아니라 timeout, late settlement, retry, mutation effect, lifecycle cleanup 등을 검증한다. + +따라서 테스트의 핵심 문제는 **검증 깊이가 부족한 것**이 아니다. 오히려 검증 범위가 커지면서 **분류와 실행 경계가 무너진 것**이 문제다. + +--- + +## 4. 이 저장소의 정체성을 명확히 해야 한다 + +현재 문서와 코드에는 `template`, `platform`, `reference feature`, `optional capability`라는 표현이 모두 존재한다. 각각 틀린 표현은 아니지만, 최상위 모델이 명확하지 않으면 이후 리팩터링에서 서로 반대 방향의 결론을 낼 수 있다. + +이 저장소는 다음 두 축을 동시에 가진다고 정의하는 것이 가장 정확하다. + +```text +Frontend Application Foundation +│ +├── Starter / Composition Skeleton +│ ├── bootstrap +│ ├── routing +│ ├── application provider +│ ├── reference feature +│ └── project conventions +│ +└── Reusable Capability Platform + ├── HTTP + ├── Server State + ├── Auth boundary + ├── IndexedDB / OPFS + ├── Cache Storage + ├── Realtime / WebSocket + ├── Browser RPC + ├── Upload / Download + ├── Diagnostics / Telemetry + └── optional browser capabilities +``` + +중요한 점은 **둘을 물리적으로 반드시 별도 repository/package로 분리해야 한다는 의미가 아니다.** + +먼저 코드와 문서에서 책임을 명확히 해야 한다. + +- Starter는 “어떻게 조립하는가”를 보여준다. +- Feature는 “비즈니스 요구를 어떻게 표현하는가”를 보여준다. +- Capability Platform은 “기술 문제를 어떻게 재사용하는가”를 책임진다. +- CI/Assurance는 “이 계약이 깨지지 않았음을 어떻게 증명하는가”를 책임진다. + +이 네 영역이 같은 repository 안에 있어도 된다. 문제는 한 영역을 사용하기 위해 다른 세 영역의 내부 구현을 알아야 하는 경우다. + +--- + +## 5. 목표 Adapter 모델 + +### 5.1 use case가 adapter에 의존하면 안 된다 + +사용자의 표현인 “use case에서 나오는 비즈니스 타입을 adapter에 바로 적용한다”는 의도는 맞다. 다만 dependency 방향은 정확히 구분해야 한다. + +원하는 구조는 다음이다. + +```text +Domain / UseCase + │ + │ owns + ▼ +Business Port + ▲ + │ implements / binds + │ +Feature Adapter Binding + │ + │ supplies generic types + mapper + policy + ▼ +Reusable Capability Runtime +``` + +use case가 `HttpAdapter`를 직접 생성하거나 import하는 구조가 아니다. + +use case는 자신의 port만 안다. + +```ts +interface UserRepository { + findById(id: UserId): Promise>; +} +``` + +그리고 feature-owned outbound adapter/composition이 reusable HTTP capability를 구체화한다. + +```ts +createHttpBinding({ + contract, + decode: decodeUserWireDto, + map: mapUser, + mapFailure, +}); +``` + +이때 `User`는 generic type argument로 capability에 들어갈 수 있지만 capability source가 `User` domain module을 import하지는 않는다. + +### 5.2 제네릭만으로 해결하지 않는다 + +재사용 adapter의 목표를 `Repository` 하나로 모든 기술을 추상화하는 것으로 잡으면 실패한다. + +HTTP, IndexedDB, WebSocket, Upload는 failure semantics와 lifecycle 자체가 다르다. + +공통화 단위는 다음 조합이 적절하다. + +```text +Generic Type ++ Codec / Mapper ++ Policy ++ Capability Runtime +``` + +예를 들어 HTTP는 다음을 공통 runtime이 책임질 수 있다. + +- timeout/deadline +- cancellation +- retry +- auth attachment +- bounded response read +- effect certainty +- diagnostics/telemetry +- transport failure normalization + +Feature는 다음만 책임져야 한다. + +- operation identity +- request input +- wire schema +- wire → domain mapper +- feature-specific failure mapping +- 필요한 retry/idempotency policy 선택 + +IndexedDB는 HTTP와 다른 capability factory를 가져야 한다. + +- key extraction +- encode/decode +- schema/migration policy +- domain-specific repository operation + +WebSocket/realtime은 다시 다른 API가 맞다. + +- event decoder +- event authority/revision policy +- subscription ownership +- reconnect/resume policy + +즉 **“하나의 generic repository”가 아니라 “capability-specific typed factory”**를 목표로 하는 것이 맞다. + +### 5.3 custom adapter escape hatch는 반드시 남긴다 + +Reusable capability가 80~90%의 일반 요구를 해결하더라도 제품 요구가 capability contract와 맞지 않을 수 있다. + +따라서 platform API는 다음을 강제해서는 안 된다. + +> 모든 HTTP/storage/realtime 구현은 반드시 platform factory를 사용해야 한다. + +정확한 규칙은 다음이 좋다. + +> 공통 capability contract가 요구사항을 보존할 수 있으면 재사용한다. 요구사항을 왜곡해야 사용할 수 있다면 feature-owned custom adapter를 구현한다. 단 application port와 architecture boundary는 동일하게 유지한다. + +이 원칙이 있어야 platform abstraction이 business model을 끌어당기지 않는다. + +--- + +## 6. 현재 구현이 목표 모델에 근접한 부분과 남은 간극 + +### 6.1 이미 근접한 부분 + +현재 `ContractHttpExecutor`은 좋은 기반이다. + +feature에서는 `ReferenceOperationMap`을 통해 operation마다 request/value type을 묶고, raw executor 결과를 domain type으로 투영한다. + +`ApplicationFeatureInputs`도 concrete feature를 generic application에서 분리한다. + +즉 **타입을 parameterize하는 방향 자체는 이미 맞다.** + +### 6.2 아직 feature adapter boilerplate가 크다 + +`reference-http-gateway.ts`를 보면 feature가 다음을 모두 직접 작성한다. + +- operation map +- request type projection +- result type projection +- `execute` 호출 +- result guard +- mapping contract failure 생성 + +이 중 일부는 feature-owned이어야 한다. 그러나 동일한 형태가 feature마다 반복된다면 재사용 platform의 consumer API가 충분히 올라오지 않은 것이다. + +향후 실제 feature 2~3개를 추가해 다음을 측정해야 한다. + +- HTTP feature 하나 추가 시 작성해야 하는 adapter glue LOC +- 동일 패턴 반복 비율 +- platform 내부 type 이름을 feature 개발자가 알아야 하는 개수 +- 등록해야 하는 중앙 registry 수 +- 정상 query 하나를 연결하기 위해 건드리는 파일 수 + +이 지표가 platform usability를 판단하는 핵심이다. + +### 6.3 설치 지점이 여러 곳으로 나뉜다 + +현재 reference feature README가 명시하는 설치 지점은 세 곳이다. + +- `installed-feature-contracts.ts` +- `installed-feature-runtimes.tsx` +- `installed-feature-adapters.ts` + +현재 총 181 LOC라 파일 크기 자체는 크지 않다. 문제는 feature가 늘었을 때 **한 기능 설치가 여러 중앙 catalog 수정으로 확산되는 구조**라는 점이다. + +이 구조는 계약/runtime/composition을 분리한다는 장점이 있으므로 무작정 한 객체로 합치면 안 된다. + +대신 다음 목표가 필요하다. + +> feature-owned contribution을 각 boundary가 소비하되, 새 feature 개발자가 중앙 설치 파일의 내부 구조를 반복해서 편집하지 않게 한다. + +예를 들어 feature가 contract/runtime/adapter contribution을 각각 export하고, installed catalog는 단순 aggregation만 담당하는 구조가 적절하다. + +### 6.4 `ApplicationApi`는 지금은 관리 가능하지만 Service Locator로 성장할 수 있다 + +현재 `ApplicationApi`는 다음 플랫폼 기능을 직접 노출한다. + +- session +- preferences +- diagnostics +- runtime +- recovery +- features + +구조 자체는 잘못이 아니다. presentation에서 concrete adapter를 숨기는 역할을 한다. + +그러나 기능이 계속 추가되며 모든 application capability가 하나의 root object 아래 들어가면 다음과 같은 사용 패턴이 일반화될 수 있다. + +```ts +const application = useApplication(); +``` + +이후 모든 presentation 코드가 거대한 application service locator를 바라보게 된다. + +권장 방향은 root provider를 유지하더라도 consumer API를 좁히는 것이다. + +```text +useSession() +useRuntimeCapabilities() +useReferenceFeature() +``` + +즉 composition은 하나여도 presentation dependency surface는 필요한 capability만 노출한다. + +--- + +## 7. P0 — 테스트 실행 환경이 self-contained하지 않다 + +이 문제는 다른 리팩터링보다 먼저 고치는 것이 좋다. 테스트가 신뢰되지 않으면 이후 구조 변경의 안전망도 신뢰할 수 없다. + +### 7.1 Component test가 host `NODE_ENV`에 영향을 받는다 + +검토 환경의 host에는 다음이 설정되어 있었다. + +```text +NODE_ENV=production +``` + +현재 `package.json`의 `test:component`는 다음과 같이 Vitest를 바로 실행하며 test environment를 고정하지 않는다. + +```text +vitest run tests/component ... +``` + +그 결과: + +```text +pnpm test:component +→ React.act is not a function +→ 119 tests failed +``` + +반면 같은 checkout에서: + +```text +NODE_ENV=test pnpm test:component +→ 20 test files passed +→ 130 tests passed +``` + +했다. + +이것은 119개 component가 각각 잘못된 것이 아니라 **test runner가 외부 shell environment를 그대로 받아 React production test path를 로드한 것**이다. + +템플릿/플랫폼 저장소의 테스트는 개발자 머신의 우연한 환경 값에 따라 의미가 바뀌면 안 된다. + +### 권장 + +- component/unit/integration runner가 필요한 environment를 명시적으로 소유한다. +- test bootstrap 단계에서 잘못된 `NODE_ENV`를 fail-fast하거나 고정한다. +- CI에서만 성립하는 environment assumption을 일반 test script와 분리한다. + +--- + +## 8. P0 — `unit` test taxonomy가 실제 실행 특성과 맞지 않는다 + +현재 `docs/testing/taxonomy.md`는 `test:unit`을 별도 test level로 정의하지만 `tests/unit` 안에는 실제로 host-level system assurance가 포함된다. + +검토 환경에서: + +```text +pnpm test:unit +→ 99 failures +``` + +가 발생했다. + +하지만 주요 실패는 business/application logic regression이 아니었다. + +대표 원인은 다음과 같다. + +```text +provider sandbox unavailable: /usr/bin/bwrap is required +systemctl list-units failed: Failed to connect to bus +npm_config_userconfig / npm_config_prefix / npm_config_globalconfig host environment +``` + +`ci-artifact-contract.test.ts`는 다음을 검증한다. + +- bubblewrap sandbox +- systemd unit +- cgroup/resource limit +- process guardian/supervisor +- background process kill/collection +- archive/provider isolation +- host path protection + +이 테스트들은 가치가 있다. 제거할 대상이 아니다. + +그러나 **unit test가 아니다.** + +권장 taxonomy는 다음과 같다. + +```text +tests/ +├── unit/ +│ ├── domain +│ ├── application +│ └── pure-policy +│ +├── contract/ +│ └── reusable-capability contracts +│ +├── component/ +│ └── React / hook / UI behavior +│ +├── integration/ +│ ├── HTTP/MSW +│ ├── IndexedDB +│ └── browser runtime boundaries +│ +├── system/ +│ └── ci-runner/ +│ ├── sandbox +│ ├── cgroup +│ ├── process-supervision +│ └── supply-chain +│ +└── e2e/ +``` + +특히 `pnpm test:unit`은 **지원 Node 환경만 있으면 일반 개발 머신에서 deterministic하게 실행**되어야 한다. + +systemd/bwrap/cgroup이 필요하면 `test:ci-runner` 또는 `test:system`처럼 요구사항이 이름에 드러나야 한다. + +--- + +## 9. P1 — Adapter runtime은 “크기”가 아니라 내부 경계를 기준으로 재검토해야 한다 + +기존에 2,000~2,700 LOC 파일을 단순히 작게 나누는 것은 권장하지 않는다. + +이미 adapter review의 D-07이 적절한 원칙을 가지고 있다. + +> state machine과 Saga 경계로만 큰 runtime을 나누고, public capability identity/failure taxonomy/persisted schema/wire semantics를 유지한다. + +이 원칙을 그대로 적용하는 것이 맞다. + +예를 들어 `indexeddb-runtime.ts`를 다음 이유만으로 분리해서는 안 된다. + +> 2,744줄이니까 500줄씩 다섯 파일로 나누자. + +대신 다음 책임이 독립적으로 설명되고 테스트 가능한지 본다. + +- connection/open/upgrade lifecycle +- transaction ownership +- schema/migration state machine +- quota/budget policy +- serialization/codec +- mutation settlement +- cleanup/compensation +- recovery/reconciliation + +WebSocket도 같은 기준이다. + +- connection state machine +- reconnect/backoff policy +- heartbeat +- subscription ownership +- writer/lease lifecycle +- resume/gap policy +- message codec + +**하나의 변경 이유와 하나의 failure model을 공유한다면 같은 모듈에 있어도 된다.** + +반대로 독립 lifecycle을 가진 책임이 같은 2,000줄 파일에서 mutable state를 공유한다면 추출 우선순위가 높다. + +--- + +## 10. P1 — 테스트 파일도 behavior contract 단위로 분리해야 한다 + +현재 가장 큰 테스트 파일은 다음과 같다. + +| 파일 | LOC | +| --- | ---: | +| `ci-artifact-contract.test.ts` | 2,538 | +| `presigned-transfer.test.ts` | 2,518 | +| `image-cdn-runtime.test.ts` | 2,430 | +| `application-query.test.tsx` | 2,196 | +| `security-followup.test.ts` | 1,859 | +| `public-response-cache.test.ts` | 1,711 | +| `ci-workflow-generation.test.ts` | 1,635 | + +테스트 파일이 production 파일보다 커지는 것 자체는 문제가 아니다. 복잡한 state machine은 많은 테스트를 필요로 한다. + +문제는 서로 다른 behavior가 하나의 파일 안에 섞이면 실패 메시지가 ownership을 알려주지 못한다는 점이다. + +예를 들어 `application-query.test.tsx`는 사실상 다음 여러 계약을 검증한다. + +```text +query initial/background state +query scope fence +result budget +mutation scope fence +optimistic mutation +unknown effect reconciliation +concurrency / duplicate submission +cache invalidation +``` + +따라서 다음처럼 behavior 단위로 분리하는 것이 더 낫다. + +```text +application-query/ +├── query-state.test.tsx +├── query-scope-fence.test.tsx +├── query-budget.test.tsx +├── mutation-scope-fence.test.tsx +├── optimistic-mutation.test.tsx +├── mutation-reconciliation.test.tsx +├── duplicate-submission.test.tsx +└── fixtures.ts +``` + +테스트 분리의 목표도 LOC가 아니다. + +> 하나의 실패 파일명이 “어떤 계약이 깨졌는지”를 설명해야 한다. + +--- + +## 11. P1 — Capability consumer API를 실제 feature 개발 비용으로 평가해야 한다 + +현재 플랫폼은 내부 correctness에 대한 검증은 매우 강하다. 반면 앞으로는 **새 feature를 만드는 개발자의 비용**을 별도 품질 지표로 봐야 한다. + +추천하는 평가 시나리오는 실제 sample feature 2개를 추가해보는 것이다. + +### 시나리오 A — 일반 REST CRUD + +필요 조건: + +- list/detail/create/update +- pagination +- error mapping +- optimistic mutation 하나 + +측정: + +- feature-owned LOC +- platform glue LOC +- 수정 파일 수 +- 중앙 registry 수정 수 +- transport/runtime type을 직접 알아야 하는 횟수 + +### 시나리오 B — IndexedDB local draft + +필요 조건: + +- domain draft type +- save/find/remove +- codec +- migration 없는 단순 store + +측정: + +- IndexedDB native API를 feature가 알아야 하는가 +- runtime internal type을 import해야 하는가 +- `create...Repository` 계열 binding만으로 해결되는가 + +이 실험 결과가 다음 platform API 리팩터링의 근거가 되어야 한다. + +--- + +## 12. P1 — `contracts`는 계속 커지면 제2의 `shared/common`이 된다 + +현재 `src/contracts`는 39개 파일, 약 8,144 LOC다. + +`contracts`라는 이름은 편리하지만 ownership을 잃기 쉽다. + +특히 다음이 모두 한 bucket으로 들어가면 문제가 된다. + +- error vocabulary +- server-state contracts +- capability registry +- boundary mapper +- mutation intent +- runtime descriptor +- compatibility metadata + +공통 계약이 실제로 여러 capability가 공유하는 SSOT라면 `contracts`에 있어도 된다. + +그러나 **특정 feature/adapter만 소비하는 contract는 owner 쪽에 두는 것이 더 낫다.** + +판정 질문은 단순하다. + +> 이 타입을 바꾸는 이유가 어떤 모듈의 요구사항 변화 때문인가? + +답이 항상 특정 feature/storage/http라면 global contracts가 아닐 가능성이 높다. + +--- + +## 13. P1 — CI/Assurance는 가치가 있지만 product development path와 격리해야 한다 + +현재 package script는 114개, `scripts/**/*.ts`는 123개 / 약 28K LOC다. + +이는 단순 frontend build script 수준을 넘어선다. + +현재 저장소는 다음을 자체적으로 검증한다. + +- architecture graph +- registry compatibility +- artifact semantic validation +- supply-chain evidence +- reproducible build +- provider sandbox +- promotion/finalization +- archive traversal/symlink/hardlink +- release admission +- security fixtures + +이 기능을 단순화한다는 이유로 제거할 필요는 없다. 플랫폼 품질과 supply-chain assurance를 강하게 가져가겠다면 정당한 투자다. + +다만 개발자가 일반 feature를 수정할 때 이 전체 영역을 알아야 해서는 안 된다. + +권장 구조적 목표는 다음이다. + +```text +Product development path + feature → focused test → type/lint/architecture + +Platform capability path + adapter → capability contract/integration tests + +Assurance path + CI runner → supply-chain/system/promotion tests +``` + +세 경로의 명령과 실패 메시지가 명확히 분리되어야 한다. + +--- + +## 14. P2 — 실제 중복은 정책 중복부터 제거한다 + +중복 제거의 목표를 “같은 코드 한 줄도 두 번 쓰지 않는다”로 잡으면 이 프로젝트에서는 오히려 abstraction이 과해진다. + +특히 security/protocol boundary의 작은 validator는 local duplication이 가독성과 auditability에 도움이 될 수 있다. + +반대로 **정책 중복은 제거해야 한다.** + +현재 `use-reference-feature.ts`에는 `CREATE_REFERENCE_RESOURCE` mutation definition이 두 경로에 반복된다. + +반복되는 핵심 값은 다음과 같다. + +```text +definitionId +operationId +requiresIdempotencyKey +owner +duplicatePolicy +invalidate +``` + +이것은 단순 syntax 중복이 아니라 하나의 mutation policy다. + +한쪽만 수정되면 같은 feature 안에서 서로 다른 concurrency/idempotency 동작을 할 수 있다. + +따라서 feature-owned mutation definition 또는 factory로 단일화하는 것이 맞다. + +반대로 `isPlainRecord` 같은 3~5줄 helper는 모든 adapter에서 무조건 한 global utility로 합칠 필요가 없다. + +--- + +## 15. P2 — compatibility re-export는 canonical import path를 흐릴 수 있다 + +현재 일부 UI와 Result 계층에는 canonical 정의와 compatibility re-export가 동시에 존재한다. + +이런 구조가 migration window를 위해 필요하다면 괜찮다. + +하지만 템플릿 출발점에서 두 경로가 모두 정식 API처럼 보이면 새 개발자가 어떤 import를 사용해야 하는지 판단해야 한다. + +원칙은 다음이 적절하다. + +- public import path는 capability마다 하나를 canonical로 둔다. +- compatibility export는 deprecated/migration purpose임을 명시한다. +- migration 종료 조건이 충족되면 제거한다. +- 테스트 편의를 위해 barrel export를 무한히 확장하지 않는다. + +--- + +## 16. Hybrid Architecture를 공식 모델로 문서화할 필요가 있다 + +현재 저장소는 순수 horizontal Clean Architecture가 아니다. + +실제 구조는 다음 hybrid에 가깝다. + +```text +Platform horizontal layers +├── application +├── contracts +├── adapters +├── presentation +└── bootstrap + +Feature vertical slices +└── features/ + ├── domain + ├── application + ├── adapters + ├── contracts + └── presentation +``` + +이 구조는 프론트엔드에서 합리적이다. + +공통 HTTP/storage/query runtime을 모든 feature 안에 복사할 이유는 없고, feature domain/application은 vertical slice로 격리할 수 있기 때문이다. + +따라서 전역 `src/domain`이 없다는 이유로 억지로 생성할 필요도 없다. + +대신 문서에서 다음을 명시해야 한다. + +> platform은 horizontal capability layer를 사용하고, product business feature는 vertical slice를 사용한다. feature는 platform capability를 port 뒤에서 소비하며 platform은 concrete feature를 알지 않는다. + +이 문장이 공식 architecture model이 되면 “왜 여기에는 domain이 없고 feature 안에는 domain이 있는가” 같은 혼란이 줄어든다. + +--- + +## 17. 권장 리팩터링 순서 + +### Phase 0 — 테스트 신뢰성 정상화 + +먼저 구조를 바꾸지 않고 검증 기반을 고친다. + +1. test runner의 `NODE_ENV` 등 필수 environment를 deterministic하게 만든다. +2. `tests/unit`에서 systemd/bwrap/cgroup 의존 테스트를 분리한다. +3. `test:unit`, `test:component`, `test:integration`, `test:system`, `test:e2e`의 의미를 다시 고정한다. +4. Node version requirement와 local runner requirement를 명확하게 fail-fast한다. + +이 단계가 끝나야 이후 refactoring failure를 실제 regression으로 믿을 수 있다. + +### Phase 1 — reference feature를 platform consumer UX 기준으로 리팩터링 + +새 abstraction을 먼저 만들지 않는다. + +현재 reference feature를 기준으로 다음을 측정하고 줄인다. + +- HTTP gateway boilerplate +- mapper/result guard 중복 +- query/mutation definition duplication +- installed contribution 수정 지점 +- platform internal type exposure + +이 단계에서 capability API의 이상적인 최소 사용 형태를 결정한다. + +### Phase 2 — capability-specific typed factory 정리 + +반복이 확인된 경우에만 factory를 만든다. + +후보: + +```text +HTTP operation/gateway binder +IndexedDB typed repository/storage binder +Realtime event source binder +Transfer command/session binder +``` + +하나의 범용 repository/framework로 합치지 않는다. + +### Phase 3 — Application/Feature installation surface 축소 + +- root ApplicationProvider는 유지 가능 +- feature consumer hook은 narrow API 제공 +- installed catalog는 aggregation 역할로 제한 +- feature-owned contribution 정의를 강화 +- concrete feature를 generic platform에서 import하지 않는 규칙 유지 + +### Phase 4 — 거대 runtime 내부 경계 추출 + +characterization test를 먼저 고정한다. + +우선순위 후보: + +1. IndexedDB runtime +2. resumable upload +3. WebSocket connection/reconnect +4. HTTP execution V3 +5. OPFS runtime/journal + +파일 크기가 아니라 state machine/Saga/lifecycle ownership을 기준으로 추출한다. + +### Phase 5 — 거대 테스트 분해 + +production runtime extraction과 같은 behavior boundary에 맞춰 test suite도 분리한다. + +### Phase 6 — CI/Assurance 경로 분리 + +product feature 개발 loop와 release/supply-chain system assurance loop를 명령, 테스트 디렉터리, prerequisite 측면에서 명확히 구분한다. + +--- + +## 18. 명시적으로 하지 말아야 할 리팩터링 + +### 18.1 모든 adapter를 하나의 generic repository로 통합하지 않는다 + +HTTP, IndexedDB, Realtime, Upload는 lifecycle과 failure semantics가 다르다. + +타입 파라미터가 비슷하다는 이유로 하나의 abstraction으로 합치면 business 요구를 기술 abstraction에 맞추게 된다. + +### 18.2 use case가 platform adapter를 직접 import하지 않는다 + +`UseCase → HttpAdapter` 구조가 되면 Clean Architecture의 핵심 목적을 잃는다. + +Generic type binding은 feature adapter/composition에서 일어나야 한다. + +### 18.3 단순화를 이유로 hardening을 제거하지 않는다 + +현재 retry/effect certainty/bounded read/recovery/sandbox 같은 계약은 대부분 실제 failure mode를 막기 위해 존재한다. + +필요한 것은 삭제가 아니라 **internal complexity encapsulation**이다. + +### 18.4 파일 길이만 보고 나누지 않는다 + +2,000줄이어도 하나의 cohesive state machine이면 함부로 분리하지 않는다. + +반대로 300줄이어도 서로 다른 lifecycle owner가 섞여 있으면 분리 대상이다. + +### 18.5 중복률 0%를 목표로 하지 않는다 + +local protocol validator의 작은 중복보다 policy가 여러 곳에 존재하는 중복이 더 위험하다. + +--- + +## 19. 최종 목표 개발 경험 + +이 Foundation이 잘 리팩터링됐을 때 새 feature 개발자는 대략 다음만 작성하면 되어야 한다. + +```text +1. Domain type / invariant +2. UseCase +3. Port +4. Transport/storage schema와 mapper +5. Capability binding 설정 +6. Presentation controller/page +``` + +그리고 HTTP/IndexedDB/WebSocket 내부의 다음 내용을 몰라도 되어야 한다. + +```text +retry scheduler +abort ownership +effect certainty +transaction lease +reconnect coordinator +OPFS journal +provider lifecycle +telemetry delivery +``` + +개념적으로 다음 형태가 목표다. + +```text +Business Domain + ↓ +UseCase + ↓ +Port + ↓ +Feature-owned Binding + + + mapper/codec + + policy + ↓ +Reusable Capability Runtime + ↓ +Browser / Network / Native API +``` + +이 구조가 성립하면 내부 runtime이 복잡하다는 사실은 문제가 아니다. + +오히려 복잡한 기술 문제를 한 곳에서 해결했기 때문에 각 제품 feature는 더 단순해진다. + +--- + +## 20. 우선순위 요약 + +| 우선순위 | Finding | 조치 방향 | +| --- | --- | --- | +| P0 | 테스트가 host `NODE_ENV` 영향을 받음 | test environment deterministic하게 고정 | +| P0 | unit suite에 systemd/bwrap/cgroup 테스트 혼재 | system/CI-runner suite로 분리 | +| P1 | capability consumer API의 실제 개발 비용이 불명확 | reference + 추가 feature로 사용성 측정 | +| P1 | feature 설치가 여러 중앙 catalog로 확산 | feature-owned contribution + 단순 aggregation | +| P1 | 거대 runtime 내부 책임 추적 비용 | state machine/Saga/lifecycle 기준 extraction | +| P1 | 2K+ LOC test file 다수 | behavior contract별 suite 분리 | +| P1 | `contracts`가 generic bucket으로 성장 가능 | ownership 기준 재배치 | +| P1 | product dev path와 CI assurance path가 뒤섞임 | command/test prerequisite 격리 | +| P2 | `ApplicationApi`의 Service Locator 성장 위험 | narrow consumer hooks/API | +| P2 | feature mutation policy 중복 | feature-owned definition SSOT | +| P2 | compatibility/re-export 경로 | canonical public API 지정 | +| P2 | hybrid architecture 설명 부족 | platform horizontal + feature vertical 공식화 | + +--- + +## 21. 이번 검토에서 확인한 검증 결과 + +코드 변경 전 기준선에서 확인한 결과다. + +```text +check:architecture +→ PASS +→ 307 modules / 929 dependencies +``` + +앞선 검토에서 다음도 통과했다. + +```text +check:types +→ PASS + +lint +→ PASS +``` + +Component test는 host environment를 그대로 사용하면 실패했다. + +```text +pnpm test:component +→ FAIL +→ React.act is not a function +→ host NODE_ENV=production +``` + +동일 checkout에서 test environment를 명시하면 통과했다. + +```text +NODE_ENV=test pnpm test:component +→ 20 files PASS +→ 130 tests PASS +``` + +Unit 전체는 현재 Coka host에서 green이 아니다. + +```text +pnpm test:unit +→ FAIL +→ 99 failures +``` + +대표 원인은 다음이다. + +- `/usr/bin/bwrap` 미설치 +- systemd bus 사용 불가 +- host `npm_config_*` 환경 오염 + +따라서 이 결과를 application/domain regression 99건으로 해석하면 안 된다. + +또한 저장소가 요구하는 Node 범위는 `>=24.11.0 <25.0.0`인데, 현재 Coka host는 Node `22.23.2`다. 정식 full verification은 지원 Node 환경에서 다시 수행해야 한다. + +--- + +## 22. 최종 평가 + +현재 저장소는 설계가 부족해서 문제가 생긴 코드베이스가 아니다. + +오히려 반대다. + +**실제 브라우저와 네트워크에서 발생할 수 있는 많은 실패를 플랫폼이 직접 책임지려 하면서 내부 correctness와 assurance가 매우 강해졌고, 그 결과 플랫폼 사용성과 유지보수 비용이 다음 병목이 된 상태**다. + +따라서 앞으로의 리팩터링 목표는 abstraction을 더 추가하는 것이 아니다. + +다음 세 가지가 핵심이다. + +1. **비즈니스 개발자가 보는 API를 더 작게 만든다.** +2. **플랫폼 내부의 복잡성은 capability boundary 안에서 더 명확하게 격리한다.** +3. **테스트와 CI를 실제 실행 성격에 맞게 분리해 검증 결과를 신뢰할 수 있게 만든다.** + +특히 이 프로젝트의 성공 여부는 “몇 개의 capability를 구현했는가”보다 다음 질문으로 판단해야 한다. + +> 새로운 비즈니스 기능을 구현할 때, 개발자가 domain과 use case에 집중한 채 이미 구현된 capability를 타입 안전하게 조립할 수 있는가? + +그 답이 지속적으로 `예`가 되도록 만드는 것이 이후 리팩터링의 기준이 되어야 한다. diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md index 6003f36..e514f08 100644 --- a/docs/testing/frontend-platform-testing-strategy.md +++ b/docs/testing/frontend-platform-testing-strategy.md @@ -90,11 +90,19 @@ production Playwright profile은 source fixture가 아니라 `build` + `preview` #### TanStack Query의 React integration test 기반 -`tests/component/application-query.test.tsx`는 production query inbound -adapter의 query/mutation lifecycle을 검증한다. cancellation, initial terminal -failure, background stale-failure latch와 retry 복구, duplicate submit, -optimistic commit/rollback, conflict 해제와 namespace invalidation이 실제 -QueryClient 위에서 실행된다. HTTP 자동 retry가 소유자이므로 이 adapter의 +production query inbound adapter의 React integration은 behavior owner별 +component suite로 분리되어 있다. + +- `application-query-bridge.test.tsx`: initial/background state, cancellation +- `application-query-scope-fence.test.tsx`: scope commit fence와 result budget +- `application-mutation-scope-fence.test.tsx`: mutation scope fence +- `application-query.test.tsx`: unknown-effect reconciliation +- `application-mutation-admission.test.tsx`: intent/duplicate admission +- `application-mutation-optimistic-cache.test.tsx`: optimistic cache commit/rollback +- `application-query-fixture.tsx`: 공통 QueryClient/scope fixture + +각 실패 파일명이 깨진 behavior contract를 직접 드러내며, 동일한 52개 계약을 +실제 QueryClient 위에서 검증한다. HTTP 자동 retry가 소유자이므로 이 adapter의 query/mutation vendor retry는 꺼져 있다. #### Form과 route 위험 diff --git a/docs/testing/taxonomy.md b/docs/testing/taxonomy.md index 8a4b847..c808157 100644 --- a/docs/testing/taxonomy.md +++ b/docs/testing/taxonomy.md @@ -3,14 +3,68 @@ Each gate is blocking in its declared scope. Failures are not downgraded with `continue-on-error` or warning-only scripts. -| Level | Command | Evidence | -| --- | --- | --- | -| runtime schema | `pnpm test:runtime-schema` | `artifacts/tests/runtime-schema.xml` | -| unit | `pnpm test:unit` | `artifacts/tests/unit.xml` | -| component | `pnpm test:component` | `artifacts/tests/component.xml` | -| integration | `pnpm test:integration` | `artifacts/tests/integration.xml` | -| end-to-end | `pnpm test:e2e` | `artifacts/tests/e2e/` | -| accessibility | `pnpm test:a11y` | `artifacts/tests/a11y.json` | +## Executable levels + +| Level | Command | Ownership / prerequisite | Evidence | +| --- | --- | --- | --- | +| runtime schema | `pnpm test:runtime-schema` | pure runtime schema contracts | `artifacts/tests/runtime-schema.xml` | +| unit | `pnpm test:unit` | Node-only domain/application/pure policy/runtime units; no systemd/bwrap/cgroup prerequisite | `artifacts/tests/unit.xml` | +| capability contract | `pnpm test:contract` | reusable capability consumer contracts | `artifacts/tests/contract.xml` | +| component | `pnpm test:component` | React/hook/UI behavior | `artifacts/tests/component.xml` | +| integration | `pnpm test:integration` | HTTP/MSW, IndexedDB and composed browser-runtime boundaries | `artifacts/tests/integration.xml` | +| system / CI runner | `pnpm test:system` | compatible Linux host with systemd, bubblewrap, cgroup v2 and CI-provider process controls | `artifacts/tests/system.xml` | +| end-to-end | `pnpm test:e2e` | pinned browser engines | `artifacts/tests/e2e/` | +| accessibility | `pnpm test:a11y` | pinned browser engines | `artifacts/tests/a11y.json` | + +`test:all` is the normal product-development loop. It intentionally includes +runtime-schema, unit, capability-contract, component, integration, reference +feature and recipe suites, but does not include `test:system`. CI-runner and +supply-chain assurance has different host prerequisites and is invoked +explicitly in the assurance path. + +## Deterministic test process + +All Vitest package scripts launch through `scripts/run-vitest.ts`. + +That runner: + +1. rejects Node versions outside the repository-supported + `>=24.11.0 <25.0.0` range before the suite starts, +2. owns `NODE_ENV=test` rather than trusting the parent shell, +3. removes host-specific `npm_config_userconfig`, `npm_config_prefix` and + `npm_config_globalconfig` values before Vitest starts. + +`vitest.config.ts` also fixes `NODE_ENV=test` so a direct Vitest invocation +cannot accidentally select React's production behavior. + +The system suite additionally runs +`scripts/check-system-test-prerequisites.ts` and fails with one prerequisite +report when the CI-runner host does not provide its required Linux facilities. + +## Development paths + +Product feature: + +- focused feature/unit/component test +- capability contract test when a reusable boundary changes +- type/lint/architecture +- `test:all` + +Reusable capability: + +- focused unit tests +- capability contract tests +- integration tests +- type/lint/architecture + +CI / release assurance: + +- `test:system` +- supply-chain / promotion / release gates + +A host-level process/sandbox test must not be placed in `tests/unit` merely +because it uses Vitest. The classification follows the system boundary and +prerequisites, not the test framework. End-to-end and automated accessibility scenarios run on the pinned Chromium, Firefox, and WebKit engines. The responsive contract explicitly exercises @@ -30,9 +84,6 @@ Promotion is an AND graph: 3. release gates plus rollback/runbook drills 4. production promotion plus eligible field Web Vitals evidence -This file describes the currently registered taxonomy. The -[frontend platform testing strategy](./frontend-platform-testing-strategy.md) -documents the target additions: test TypeScript projects, real bootstrap -composition tests, shared MSW scenarios, query/mutation/form/router coverage, -Storybook interaction and accessibility checks, visual regression, and a -built-output Playwright profile. +The [frontend platform testing strategy](./frontend-platform-testing-strategy.md) +contains the broader testing design. This file is the executable taxonomy for +where a test belongs and which environment is allowed to run it. diff --git a/package.json b/package.json index 0c62356..5420e53 100644 --- a/package.json +++ b/package.json @@ -46,13 +46,15 @@ "check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts", "check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts", "check:types:fixture:image-resolve-signal": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts", - "test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", - "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", - "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", - "test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml", - "test:http-scenario-catalog": "vitest run tests/integration/http-scenario-catalog.test.ts --reporter=default --maxWorkers=1", + "test:runtime-schema": "node scripts/run-vitest.ts run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", + "test:unit": "node scripts/run-vitest.ts run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", + "test:contract": "node scripts/run-vitest.ts run tests/contract --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/contract.xml", + "test:system": "node scripts/check-system-test-prerequisites.ts && node scripts/run-vitest.ts run tests/system --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/system.xml", + "test:component": "node scripts/run-vitest.ts run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", + "test:integration": "node scripts/run-vitest.ts run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml", + "test:http-scenario-catalog": "node scripts/run-vitest.ts run tests/integration/http-scenario-catalog.test.ts --reporter=default --maxWorkers=1", "test:http-scenario-evidence": "node scripts/run-http-scenario-evidence.ts", - "test:recipes": "vitest run tests/recipes --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/optional-recipes.xml --passWithNoTests", + "test:recipes": "node scripts/run-vitest.ts run tests/recipes --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/optional-recipes.xml --passWithNoTests", "test:e2e": "playwright test", "test:e2e:dev": "playwright test --config playwright.dev.config.ts", "test:browser-capabilities": "playwright test --config playwright.capabilities.config.ts", @@ -74,11 +76,11 @@ "test:optional-recipe-removal": "node scripts/test-optional-recipe-removal.ts", "test:browser-file-storage-removal": "node scripts/test-browser-file-storage-runtime-removal.ts", "test:realtime-removal": "node scripts/test-realtime-runtime-removal.ts", - "test:reference-feature": "vitest run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests", + "test:reference-feature": "node scripts/run-vitest.ts run tests/features/reference-feature --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/reference-feature.xml --passWithNoTests", "check:v8-coverage-counter-semantics": "node scripts/check-v8-coverage-counter-semantics.ts", - "test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && vitest run tests/runtime-schema tests/unit tests/component tests/integration tests/features/reference-feature --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts", + "test:coverage": "corepack pnpm check:v8-coverage-counter-semantics && node scripts/run-vitest.ts run tests/runtime-schema tests/unit tests/contract tests/component tests/integration tests/features/reference-feature --coverage --maxWorkers=4 --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/coverage.xml && node scripts/check-risk-coverage.ts", "check:coverage:fixture": "node scripts/check-risk-coverage.ts --summary tests/fixtures/coverage/below-threshold.json --artifact artifacts/quality/risk-coverage-fixture.json", - "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes", + "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:contract && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes", "verify:lockfile": "corepack pnpm install --frozen-lockfile --ignore-scripts", "check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts", "generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts", diff --git a/scripts/check-compatibility.ts b/scripts/check-compatibility.ts index dc2a79a..32f3a9b 100644 --- a/scripts/check-compatibility.ts +++ b/scripts/check-compatibility.ts @@ -1,6 +1,6 @@ import { mkdir, readFile } from "node:fs/promises"; -import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.ts"; +import { classifyObjectSchemaChange } from "../src/contracts/compatibility.ts"; import { compatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; diff --git a/scripts/check-system-test-prerequisites.ts b/scripts/check-system-test-prerequisites.ts new file mode 100644 index 0000000..c43e32f --- /dev/null +++ b/scripts/check-system-test-prerequisites.ts @@ -0,0 +1,55 @@ +import { constants } from "node:fs"; +import { access, readFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; + +const failures: string[] = []; + +async function requireExecutable(path: string, label: string): Promise { + try { + await access(path, constants.X_OK); + } catch { + failures.push(`${label} is required at ${path}`); + } +} + +await Promise.all([ + requireExecutable("/usr/bin/bwrap", "bubblewrap"), + requireExecutable("/usr/bin/systemctl", "systemctl"), + requireExecutable("/usr/bin/tar", "tar"), +]); + +try { + const controllers = await readFile("/sys/fs/cgroup/cgroup.controllers", "utf8"); + if (controllers.trim().length === 0) { + failures.push("cgroup v2 controllers are unavailable"); + } +} catch { + failures.push("cgroup v2 is required at /sys/fs/cgroup/cgroup.controllers"); +} + +if (!failures.some((failure) => failure.includes("systemctl"))) { + const probe = spawnSync("/usr/bin/systemctl", ["show-environment"], { + encoding: "utf8", + timeout: 3_000, + }); + if (probe.error || probe.status !== 0) { + failures.push("a reachable systemd manager bus is required"); + } +} + +if (failures.length > 0) { + process.stderr.write( + [ + "System/CI-runner test prerequisites are unavailable:", + ...failures.map((failure) => ` - ${failure}`), + "", + "Run test:unit/test:contract/test:component/test:integration for the", + "developer loop. test:system is intentionally reserved for a compatible", + "Linux CI-runner host.", + "", + ].join("\n"), + ); + process.exit(1); +} + +process.stdout.write("System test prerequisites: PASS\n"); diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index adb7581..8abb588 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -443,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{ }>; const CANONICAL_GATE_SHAPE_SHA256 = - "4617ada21cbdeb217d118146bd572860d7c58ad222142a52d41916b26577239a"; + "372959f50b9c5a228bdc85dce33ca7968c72414ce3b6d5534c9e6eaf554f6ce4"; function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { const normalized = gates.map( @@ -477,13 +477,13 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] if (contract.gates.length !== 27) { failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`); } - if (contract.commands.length !== 82 || commandReferenceCount !== 94) { + if (contract.commands.length !== 84 || commandReferenceCount !== 96) { failures.push( - `command authority baseline must contain exactly 82 definitions and 94 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, + `command authority baseline must contain exactly 84 definitions and 96 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, ); } - if (contract.artifacts.length !== 107) { - failures.push(`artifact authority baseline must contain exactly 107 artifacts; received ${contract.artifacts.length}`); + if (contract.artifacts.length !== 109) { + failures.push(`artifact authority baseline must contain exactly 109 artifacts; received ${contract.artifacts.length}`); } if (contract.stages.length !== 5) { failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); diff --git a/scripts/drill-runbook.ts b/scripts/drill-runbook.ts index 4a967ef..1c1a5a6 100644 --- a/scripts/drill-runbook.ts +++ b/scripts/drill-runbook.ts @@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url"; import { shouldRetry } from "../src/adapters/http/retry-policy.ts"; import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.ts"; -import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.ts"; +import { verifyCompatibilityTuple } from "../src/contracts/compatibility.ts"; import type { StoragePort } from "../src/application/ports/storage-port.ts"; import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts"; import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts"; diff --git a/scripts/lib/release-runtime-coherence.ts b/scripts/lib/release-runtime-coherence.ts index 35f103f..857cf53 100644 --- a/scripts/lib/release-runtime-coherence.ts +++ b/scripts/lib/release-runtime-coherence.ts @@ -1,4 +1,4 @@ -import { isVersionCompatible } from "../../src/application/policies/compatibility.ts"; +import { isVersionCompatible } from "../../src/contracts/compatibility.ts"; import { verifyContractSet } from "../../src/contracts/contract-set.ts"; import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts"; import type { ReleaseArtifact } from "../../src/contracts/release-artifacts.ts"; diff --git a/scripts/run-vitest.ts b/scripts/run-vitest.ts new file mode 100644 index 0000000..09cd3c1 --- /dev/null +++ b/scripts/run-vitest.ts @@ -0,0 +1,67 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +function assertSupportedNode(): void { + const [majorText = "0", minorText = "0"] = process.versions.node.split("."); + const major = Number(majorText); + const minor = Number(minorText); + + if (major !== 24 || minor < 11) { + process.stderr.write( + [ + `Unsupported Node.js runtime for tests: ${process.versions.node}`, + "Required by package.json: >=24.11.0 <25.0.0", + "Use the repository-supported Node.js runtime before running a test suite.", + "", + ].join("\n"), + ); + process.exit(1); + } +} + +assertSupportedNode(); + +const vitestEntry = fileURLToPath( + new URL("../node_modules/vitest/vitest.mjs", import.meta.url), +); +if (!existsSync(vitestEntry)) { + process.stderr.write( + "Vitest is not installed. Run the repository package installation first.\n", + ); + process.exit(1); +} + +const testEnvironment: NodeJS.ProcessEnv = { + ...process.env, + NODE_ENV: "test", +}; +for (const key of [ + "npm_config_userconfig", + "npm_config_prefix", + "npm_config_globalconfig", + "NPM_CONFIG_USERCONFIG", + "NPM_CONFIG_PREFIX", + "NPM_CONFIG_GLOBALCONFIG", +]) { + delete testEnvironment[key]; +} + +const result = spawnSync( + process.execPath, + [vitestEntry, ...process.argv.slice(2)], + { + stdio: "inherit", + env: testEnvironment, + }, +); + +if (result.error) { + throw result.error; +} +if (result.signal) { + process.stderr.write(`Vitest terminated by signal ${result.signal}.\n`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts index a577ad5..54a54cb 100644 --- a/scripts/verify-release.ts +++ b/scripts/verify-release.ts @@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url"; import { verifyCompatibilityTuple, type CompatibilityTuple, -} from "../src/application/policies/compatibility.ts"; +} from "../src/contracts/compatibility.ts"; import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts"; import { parseBuildManifestArtifact, diff --git a/src/adapters/browser-rpc/browser-rpc-runtime.ts b/src/adapters/browser-rpc/browser-rpc-runtime.ts index 21b6057..1341c1d 100644 --- a/src/adapters/browser-rpc/browser-rpc-runtime.ts +++ b/src/adapters/browser-rpc/browser-rpc-runtime.ts @@ -8,7 +8,7 @@ import type { BrowserRpcUnaryPort, } from "../../application/ports/browser-rpc/index.ts"; import type { ClockPort } from "../../application/ports/clock-port.ts"; -import type { Result } from "../../application/result.ts"; +import type { Result } from "../../contracts/result.ts"; import { installBrowserRpcContractBindings, type InstalledBrowserRpcContractBindings, diff --git a/src/adapters/http/feature-http-binding.ts b/src/adapters/http/feature-http-binding.ts new file mode 100644 index 0000000..616ac94 --- /dev/null +++ b/src/adapters/http/feature-http-binding.ts @@ -0,0 +1,237 @@ +import type { Result } from "../../contracts/result.ts"; +import type { + MappingResult, +} from "../../contracts/boundary-mapper.ts"; +import { + createFailure, + kindForStatus, + type ApiFailure, + type FailureEffectCertainty, + type FailureKind, +} from "../../contracts/errors.ts"; +import type { MutationIntent } from "../../contracts/mutation-intent.ts"; +import type { HttpExecutionOutcome } from "./http-execution-v3.ts"; + +export type InstalledHttpOperationExecutor = Readonly<{ + execute( + operationId: string, + input: unknown, + context: Readonly<{ + routeId: string; + signal?: AbortSignal; + intent?: MutationIntent; + }>, + ): Promise>; +}>; + +type FeatureHttpOperationSpec = Readonly<{ + operationId: string; + routeId: string; + mapSuccess(value: unknown): MappingResult; +}>; + +export type FeatureHttpOperation = + FeatureHttpOperationSpec & + Readonly<{ + /** + * Compile-time only carrier. Feature bindings remain plain frozen objects + * at runtime while preserving each operation's input/value pair. + */ + __types?: Readonly<{ + input: Input; + value: Value; + }>; + }>; + +export function defineFeatureHttpOperation( + spec: FeatureHttpOperationSpec, +): FeatureHttpOperation { + return Object.freeze(spec) as FeatureHttpOperation; +} + +type OperationInput = + Operation extends FeatureHttpOperation ? Input : never; + +type OperationValue = + Operation extends FeatureHttpOperation ? Value : never; + +export type FeatureHttpBinding< + Operations extends Readonly< + Record> + >, +> = Readonly<{ + execute( + operationId: OperationId, + input: OperationInput, + context?: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }>, + ): Promise, ApiFailure>>; +}>; + +/** + * Capability-specific feature binding for the installed HTTP runtime. + * + * The platform owns transport/outcome normalization. A feature contributes only + * operation identity, route identity, typed input and its wire-to-domain mapper. + * This keeps HTTP lifecycle/failure semantics out of feature application code + * without collapsing storage/realtime/transfer into a universal repository. + */ +export function createFeatureHttpBinding< + const Operations extends Readonly< + Record> + >, +>( + executor: InstalledHttpOperationExecutor, + operations: Operations, +): FeatureHttpBinding { + for (const [registryId, operation] of Object.entries(operations)) { + if (registryId !== operation.operationId) { + throw new TypeError( + `Feature HTTP operation key mismatch: ${registryId} !== ${operation.operationId}`, + ); + } + } + + return Object.freeze({ + async execute( + operationId: OperationId, + input: OperationInput, + context: Readonly<{ + signal?: AbortSignal; + intent?: MutationIntent; + }> = {}, + ): Promise, ApiFailure>> { + const operation = operations[operationId]; + const outcome = await executor.execute(operation.operationId, input, { + routeId: operation.routeId, + ...(context.signal === undefined ? {} : { signal: context.signal }), + ...(context.intent === undefined ? {} : { intent: context.intent }), + }); + return projectExecutionOutcome< + OperationValue + >( + operation.operationId, + operation.mapSuccess as ( + value: unknown, + ) => MappingResult>, + outcome, + ); + }, + }); +} + +function projectExecutionOutcome( + operationId: string, + mapSuccess: (value: unknown) => MappingResult, + outcome: HttpExecutionOutcome, +): Result { + switch (outcome.kind) { + case "SUCCESS": { + const mapped = mapSuccess(outcome.value); + return mapped.ok + ? Object.freeze({ ok: true as const, value: mapped.value }) + : failure( + "MAPPING_CONTRACT_VIOLATION", + operationId, + mapped.code, + { effect: outcome.effect }, + ); + } + case "PROBLEM": + return failure( + kindForStatus(outcome.metadata.status), + operationId, + "CONTRACT_PROBLEM", + { httpStatus: outcome.metadata.status, effect: outcome.effect }, + ); + case "UNAUTHENTICATED": + return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", { + effect: outcome.effect, + }); + case "FORBIDDEN": + return failure("FORBIDDEN", operationId, "FORBIDDEN", { + effect: outcome.effect, + }); + case "RATE_LIMITED": + return failure("RATE_LIMITED", operationId, "RATE_LIMITED", { + ...(outcome.retryAfterMs === undefined + ? {} + : { retryAfterMs: outcome.retryAfterMs }), + effect: outcome.effect, + }); + case "CANCELLED": + return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", { + effect: outcome.effect, + }); + case "AUTH_INTEGRATION_FAILURE": + return failure( + "AUTH_INTEGRATION_FAILURE", + operationId, + outcome.reason, + { effect: outcome.effect }, + ); + case "TRANSPORT_FAILURE": + return failure( + outcome.failure.kind === "TIMEOUT" + ? "REQUEST_TIMEOUT" + : outcome.failure.kind === "ABORTED_BY_SCOPE" + ? "SCOPE_GENERATION_CHANGED" + : "NETWORK_UNREACHABLE", + operationId, + outcome.failure.kind, + { effect: outcome.effect }, + ); + case "CONTRACT_VIOLATION": + return failure( + failureKindForViolation(outcome.violation.kind), + operationId, + outcome.violation.kind, + { effect: outcome.effect }, + ); + } +} + +function failureKindForViolation( + violation: Extract< + HttpExecutionOutcome, + { kind: "CONTRACT_VIOLATION" } + >["violation"]["kind"], +): FailureKind { + switch (violation) { + case "CONTENT_TYPE_MISMATCH": + return "CONTENT_TYPE_MISMATCH"; + case "RESPONSE_TOO_LARGE": + return "RESPONSE_BODY_LIMIT"; + case "UTF8_INVALID": + case "JSON_INVALID": + return "MALFORMED_JSON"; + case "MAPPING_CONTRACT_VIOLATION": + return "MAPPING_CONTRACT_VIOLATION"; + case "SCOPE_FENCED": + return "SCOPE_GENERATION_CHANGED"; + case "SUCCESS_SCHEMA_INVALID": + case "PROBLEM_SCHEMA_INVALID": + case "VALIDATOR_RUNTIME_FAILURE": + return "SCHEMA_MISMATCH"; + default: + return "ENVELOPE_MISMATCH"; + } +} + +function failure( + kind: FailureKind, + operationId: string, + code: string, + details: Readonly<{ + httpStatus?: number; + retryAfterMs?: number; + effect?: FailureEffectCertainty; + }> = {}, +): Result { + return Object.freeze({ + ok: false as const, + error: createFailure(kind, operationId, 0, { code, ...details }), + }); +} diff --git a/src/adapters/http/http-execution-v3.ts b/src/adapters/http/http-execution-v3.ts index 9cb8070..74d6d3e 100644 --- a/src/adapters/http/http-execution-v3.ts +++ b/src/adapters/http/http-execution-v3.ts @@ -41,6 +41,13 @@ import { type PhysicalAttemptState, } from "./http-effect-certainty.ts"; import { parseRetryAfter } from "./retry-policy.ts"; +import { + canRetryTransport, + isRetryableHttpStatus, + isRetryableSemantics, + jitteredDelay, + retryDelayFor, +} from "./http-retry-lifecycle.ts"; /** * §7–§8. Descriptor-driven HTTP execution. @@ -299,14 +306,6 @@ export type ContractHttpExecutorDependencies = Readonly<{ observe?: (observation: HttpExecutionObservation) => void; }>; -const RETRYABLE_STATUSES: ReadonlySet = new Set([ - 408, 425, 429, 502, 503, 504, -]); - -const RETRY_BASE_DELAY_MS = 250; -const RETRY_MAX_LOCAL_DELAY_MS = 2_000; -const RETRY_AFTER_CEILING_MS = 5_000; - type MutationIntentValidation = | Readonly<{ ok: true; intent?: MutationIntent }> | Readonly<{ @@ -1347,7 +1346,7 @@ async function admitProblem( ): Promise> { const contract = operation.contract; const isCommand = contract.commandEffect !== null; - const retryable = RETRYABLE_STATUSES.has(status); + const retryable = isRetryableHttpStatus(status); const bytes = await readResponseBytes( response, @@ -1434,56 +1433,6 @@ async function admitProblem( }); } -/** - * §8.3. `SAFE` and `IDEMPOTENT` may replay the same frozen request. `KEYED` - * must not automatically retry once an attempt was dispatched and its response - * was lost; that path goes to inspect/reconciliation instead. `NEVER` is zero. - */ -function canRetryTransport( - semantics: InstalledHttpContract< - unknown, - unknown, - unknown - >["contract"]["retrySemantics"], - attemptState: PhysicalAttemptState, -): boolean { - if (semantics === "NEVER") return false; - if (semantics === "KEYED") { - return attemptState === "PREPARING" || attemptState === "READY_TO_SEND"; - } - return true; -} - -function isRetryableSemantics( - semantics: InstalledHttpContract< - unknown, - unknown, - unknown - >["contract"]["retrySemantics"], -): boolean { - return semantics === "SAFE" || semantics === "IDEMPOTENT"; -} - -/** §8.2. Full jitter over `min(2000, 250 * 2^index)`. */ -function jitteredDelay(retryIndex: number, random: () => number): number { - const ceiling = Math.min( - RETRY_MAX_LOCAL_DELAY_MS, - RETRY_BASE_DELAY_MS * 2 ** retryIndex, - ); - return Math.floor(random() * ceiling); -} - -function retryDelayFor( - retryAfterMs: number | null, - retryIndex: number, - random: () => number, -): number | null { - const local = jitteredDelay(retryIndex, random); - if (retryAfterMs === null) return local; - if (retryAfterMs > RETRY_AFTER_CEILING_MS) return null; - return Math.max(local, retryAfterMs); -} - /** §7.12. No raw header map, URL, cookie, traceparent or ETag value escapes. */ function safeMetadata( response: Response, diff --git a/src/adapters/http/http-retry-lifecycle.ts b/src/adapters/http/http-retry-lifecycle.ts new file mode 100644 index 0000000..ec7139b --- /dev/null +++ b/src/adapters/http/http-retry-lifecycle.ts @@ -0,0 +1,57 @@ +import type { RetrySemantics } from "../../contracts/external-contract-runtime.ts"; +import type { PhysicalAttemptState } from "./http-effect-certainty.ts"; + +const RETRYABLE_STATUSES: ReadonlySet = new Set([ + 408, 425, 429, 502, 503, 504, +]); +const RETRY_BASE_DELAY_MS = 250; +const RETRY_MAX_LOCAL_DELAY_MS = 2_000; +const RETRY_AFTER_CEILING_MS = 5_000; + +export function isRetryableHttpStatus(status: number): boolean { + return RETRYABLE_STATUSES.has(status); +} + +/** + * Transport replay authority. + * + * KEYED commands may retry only before a physical dispatch. Once dispatched, + * an uncertain result belongs to reconciliation rather than automatic replay. + */ +export function canRetryTransport( + semantics: RetrySemantics, + attemptState: PhysicalAttemptState, +): boolean { + if (semantics === "NEVER") return false; + if (semantics === "KEYED") { + return attemptState === "PREPARING" || attemptState === "READY_TO_SEND"; + } + return true; +} + +export function isRetryableSemantics(semantics: RetrySemantics): boolean { + return semantics === "SAFE" || semantics === "IDEMPOTENT"; +} + +/** Full jitter over min(2000, 250 * 2^retryIndex). */ +export function jitteredDelay( + retryIndex: number, + random: () => number, +): number { + const ceiling = Math.min( + RETRY_MAX_LOCAL_DELAY_MS, + RETRY_BASE_DELAY_MS * 2 ** retryIndex, + ); + return Math.floor(random() * ceiling); +} + +export function retryDelayFor( + retryAfterMs: number | null, + retryIndex: number, + random: () => number, +): number | null { + const local = jitteredDelay(retryIndex, random); + if (retryAfterMs === null) return local; + if (retryAfterMs > RETRY_AFTER_CEILING_MS) return null; + return Math.max(local, retryAfterMs); +} diff --git a/src/adapters/http/index.ts b/src/adapters/http/index.ts index e4316ae..d7af490 100644 --- a/src/adapters/http/index.ts +++ b/src/adapters/http/index.ts @@ -19,6 +19,13 @@ export { type HttpTransportFailure, type SafeResponseMetadata, } from "./http-execution-v3.ts"; +export { + createFeatureHttpBinding, + defineFeatureHttpOperation, + type FeatureHttpBinding, + type FeatureHttpOperation, + type InstalledHttpOperationExecutor, +} from "./feature-http-binding.ts"; /** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */ export type { CredentialPatchOutcome } from "./http-contract-bridge.ts"; /** diff --git a/src/contracts/cursor-pagination.ts b/src/adapters/query-cache/cursor-pagination-contract.ts similarity index 90% rename from src/contracts/cursor-pagination.ts rename to src/adapters/query-cache/cursor-pagination-contract.ts index 6e9dccb..c0aab34 100644 --- a/src/contracts/cursor-pagination.ts +++ b/src/adapters/query-cache/cursor-pagination-contract.ts @@ -1,4 +1,4 @@ -import type { Result } from "./result.ts"; +import type { Result } from "../../contracts/result.ts"; export type CursorPage = Readonly<{ items: readonly Value[]; diff --git a/src/adapters/query-cache/cursor-pagination-runtime.ts b/src/adapters/query-cache/cursor-pagination-runtime.ts index 3ab66a6..4c74b1c 100644 --- a/src/adapters/query-cache/cursor-pagination-runtime.ts +++ b/src/adapters/query-cache/cursor-pagination-runtime.ts @@ -1,9 +1,9 @@ -import type { Result } from "../../application/result.ts"; +import type { Result } from "../../contracts/result.ts"; import type { CursorPage, CursorPaginationProfile, CursorPaginationRuntime, -} from "../../contracts/cursor-pagination.ts"; +} from "./cursor-pagination-contract.ts"; import { createFailure } from "../../contracts/errors.ts"; import { snapshotExactObject } from "../../contracts/exact-snapshot.ts"; diff --git a/src/adapters/query-cache/index.ts b/src/adapters/query-cache/index.ts index 57c1c6c..3ea046e 100644 --- a/src/adapters/query-cache/index.ts +++ b/src/adapters/query-cache/index.ts @@ -4,6 +4,11 @@ export { type ConditionalValidatorStore, } from "./conditional-validator-store.ts"; export { createCursorPaginationRuntime } from "./cursor-pagination-runtime.ts"; +export type { + CursorPage, + CursorPaginationProfile, + CursorPaginationRuntime, +} from "./cursor-pagination-contract.ts"; export { createServerStateScopeRuntime, type ScopeResetParticipant, diff --git a/src/application/policies/compatibility.ts b/src/application/policies/compatibility.ts index 4bd1b68..3fa4cea 100644 --- a/src/application/policies/compatibility.ts +++ b/src/application/policies/compatibility.ts @@ -4,8 +4,10 @@ * The implementation lives in `src/contracts/compatibility.ts`: it is a pure * predicate over release tokens with no application state, and * `src/contracts/release-tokens.ts` needs it, which previously made contracts - * import the application layer. This module re-exports it for application-side - * and script-side callers. + * import the application layer. This module is retained only as a migration + * shim; internal callers use src/contracts/compatibility.ts directly. + * + * @deprecated Import compatibility contracts from ../../contracts/compatibility.ts. */ export { COMPATIBILITY_TUPLE_FIELDS, diff --git a/src/application/result.ts b/src/application/result.ts index 561ef7a..8240037 100644 --- a/src/application/result.ts +++ b/src/application/result.ts @@ -1,13 +1,9 @@ /** - * The single success/failure carrier used across application input boundaries. - * Adapters map technology-specific errors to an application failure before - * constructing this value. + * Compatibility re-export for application-side callers from older revisions. * - * The type itself lives in `src/contracts` because both layers need it and - * neither owns it: `src/contracts/server-state.ts` and - * `src/contracts/cursor-pagination.ts` reached back into the application layer - * for it, which made the ownership of the shared vocabulary ambiguous in both - * directions. Contracts is the lower of the two, so the shared shape sits there - * and this module re-exports it for every existing application-side importer. + * The canonical Result definition is src/contracts/result.ts. New code should + * import it from that module so the shared carrier has one public authority. + * + * @deprecated Import Result from ../contracts/result.ts. */ export type { Result } from "../contracts/result.ts"; diff --git a/src/features/installed-contract-contributions.ts b/src/features/installed-contract-contributions.ts index 7f1f329..5c3d44c 100644 --- a/src/features/installed-contract-contributions.ts +++ b/src/features/installed-contract-contributions.ts @@ -3,22 +3,25 @@ import { type InstalledContractContribution, type InstalledContractPackageIdentity, } from "../contracts/external-contract-runtime.ts"; -import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts"; -import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts"; import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; +import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts"; + +const COMPILED_CONTRACT_CONTRIBUTIONS = Object.freeze([ + REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, +] as const); /** - * §4.8. Static contract selection SSOT. + * Central contract installation is aggregation only. * - * A product feature adds exactly one entry per service package here and imports - * the generated package only from - * `src/features//contracts/-contract-contribution.ts`. + * Each feature owns its concrete service/package contribution. This catalog + * selects contributions whose feature is installed; it does not contain + * feature-specific branching or service wiring. */ export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = Object.freeze( - INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID) - ? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION] - : [], + COMPILED_CONTRACT_CONTRIBUTIONS.filter((contribution) => + INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId), + ), ); export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions( diff --git a/src/features/installed-feature-adapters.ts b/src/features/installed-feature-adapters.ts index ec1f1d4..ab01bb1 100644 --- a/src/features/installed-feature-adapters.ts +++ b/src/features/installed-feature-adapters.ts @@ -1,25 +1,33 @@ import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts"; -import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts"; -import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts"; +import { REFERENCE_FEATURE_ADAPTER_CONTRIBUTION } from "./reference-feature/adapters/create-reference-feature-input.ts"; import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; -/** - * §3.5. Partial on purpose: a feature the manifest did not select supplies no - * driving input, so consumers have to narrow before calling one. A total type - * here would let feature code compile against an input that is not there. - */ -type InstalledFeatureInputs = Readonly< - Partial> ->; +const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([ + REFERENCE_FEATURE_ADAPTER_CONTRIBUTION, +] as const); +type FeatureAdapterContext = Parameters< + (typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS)[number]["createInput"] +>[0]; + +type InstalledFeatureInputs = Readonly>; + +/** + * Central adapter composition only selects and aggregates feature-owned + * contributions. The feature owns how its application input is bound to + * reusable platform capabilities. + */ export function createInstalledFeatureInputs( - context: Parameters[0], + context: FeatureAdapterContext, ): InstalledFeatureInputs { - if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) { - return Object.freeze({}); - } - const referenceFeature = createReferenceFeatureInstalledInput(context); - return Object.freeze({ - [referenceFeature.featureId]: referenceFeature.input, - }); + const entries = COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS + .filter((contribution) => + INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId), + ) + .map((contribution) => { + const installed = contribution.createInput(context); + return [installed.featureId, installed.input] as const; + }); + + return Object.freeze(Object.fromEntries(entries)) as InstalledFeatureInputs; } diff --git a/src/features/installed-feature-runtimes.tsx b/src/features/installed-feature-runtimes.tsx index 46757ad..dc53a8c 100644 --- a/src/features/installed-feature-runtimes.tsx +++ b/src/features/installed-feature-runtimes.tsx @@ -1,28 +1,38 @@ import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts"; import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx"; -import { - REFERENCE_FEATURE_ROUTE_CODECS, - REFERENCE_FEATURE_ROUTE_RUNTIME, -} from "./reference-feature/presentation/reference-feature-runtime.tsx"; -import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts"; +import { REFERENCE_FEATURE_RUNTIME_CONTRIBUTION } from "./reference-feature/presentation/reference-feature-runtime.tsx"; import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; +const COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS = Object.freeze([ + REFERENCE_FEATURE_RUNTIME_CONTRIBUTION, +] as const); + +const INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS = + COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS.filter((contribution) => + INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId), + ); + /** - * §3.5. A feature the manifest did not select contributes no codec and no route - * component, so the router has nothing to mount for it. The module is still - * linked — a static import cannot be undone by a value — which is why physical - * removal is FE-GATE-020's job and this is deselection, not deletion. + * Central runtime catalogs only aggregate feature-owned contributions. + * Adding route codecs/components to a feature no longer requires duplicating + * reference-specific selection logic in this composition file. */ -const referenceSelected = INSTALLED_PRODUCT_FEATURE_IDS.includes( - REFERENCE_FEATURE_ID, +export const ROUTE_CODECS = Object.freeze( + Object.assign( + {}, + PLATFORM_ROUTE_CODECS, + ...INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS.map( + (contribution) => contribution.routeCodecs, + ), + ), ); -export const ROUTE_CODECS = Object.freeze({ - ...PLATFORM_ROUTE_CODECS, - ...(referenceSelected ? REFERENCE_FEATURE_ROUTE_CODECS : {}), -}); - -export const ROUTE_RUNTIME = Object.freeze({ - ...PLATFORM_ROUTE_RUNTIME, - ...(referenceSelected ? REFERENCE_FEATURE_ROUTE_RUNTIME : {}), -}); +export const ROUTE_RUNTIME = Object.freeze( + Object.assign( + {}, + PLATFORM_ROUTE_RUNTIME, + ...INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS.map( + (contribution) => contribution.routeRuntime, + ), + ), +); diff --git a/src/features/reference-feature/README.md b/src/features/reference-feature/README.md index 1062d0c..4dd7f7a 100644 --- a/src/features/reference-feature/README.md +++ b/src/features/reference-feature/README.md @@ -24,11 +24,12 @@ augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므 `ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인 `ApiFailure`는 같은 type의 alias다. -HTTP adapter의 operation map은 operation ID마다 허용된 route ID, request shape와 -성공 value type을 함께 묶는다. raw HTTP executor의 성공값은 Zod request/response -검증과 feature mapper를 통과한 뒤 operation별 runtime result guard에서 typed -executor로 승격된다. 따라서 gateway 메서드는 개별 응답 cast 없이 정확한 결과를 -반환하고, operation/route/request 조합 오류는 typecheck에서 차단된다. +HTTP adapter는 `src/adapters/http/feature-http-binding.ts`의 capability-specific +typed binding을 사용한다. feature는 operation ID, route ID, request type, 성공 +value type, mapper만 소유한다. timeout/cancellation/auth/transport/contract +violation을 `AppFailure`로 정규화하는 책임은 reusable HTTP capability가 소유한다. +따라서 gateway 메서드는 transport outcome이나 runtime result guard를 반복 구현하지 +않고도 정확한 결과 타입을 반환한다. route codec과 URL builder는 `src/presentation/routes/route-codecs.ts`, route type은 `route-contract.ts`, React context/provider/hook은 `route-input.tsx`가 각각 @@ -39,12 +40,16 @@ route codec과 URL builder는 `src/presentation/routes/route-codecs.ts`, route t ## 설치 지점 -- 직렬화 계약: `src/features/installed-feature-contracts.ts` -- component/codec: `src/features/installed-feature-runtimes.tsx` -- bootstrap input 조립: `src/features/installed-feature-adapters.ts` +feature가 실제 contribution을 소유한다. -새 기능도 이 세 지점에 contribution을 합성하되 feature ID를 generic application, -router나 HTTP client에 하드코딩하지 않는다. +- contract: `reference-feature-contract.ts` +- component/codec: `REFERENCE_FEATURE_RUNTIME_CONTRIBUTION` +- bootstrap input: `REFERENCE_FEATURE_ADAPTER_CONTRIBUTION` + +중앙 파일인 `installed-feature-contracts.ts`, +`installed-feature-runtimes.tsx`, `installed-feature-adapters.ts`는 선택된 +contribution을 합치는 역할만 한다. 새 기능의 내부 조립 규칙을 중앙 catalog에 +추가하지 않는다. ## 검증과 제거 diff --git a/src/features/reference-feature/adapters/create-reference-feature-input.ts b/src/features/reference-feature/adapters/create-reference-feature-input.ts index de6e1f3..38ab0e6 100644 --- a/src/features/reference-feature/adapters/create-reference-feature-input.ts +++ b/src/features/reference-feature/adapters/create-reference-feature-input.ts @@ -1,195 +1,41 @@ -import type { Result } from "../../../application/result.ts"; -import type { ApiFailure, FailureKind } from "../../../contracts/errors.ts"; -import type { FailureEffectCertainty } from "../../../contracts/errors.ts"; -import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; import { - createFailure, - kindForStatus, -} from "../../../contracts/errors.ts"; -import type { HttpExecutionOutcome } from "../../../adapters/http/index.ts"; + createFeatureHttpBinding, + type InstalledHttpOperationExecutor, +} from "../../../adapters/http/index.ts"; import { createReferenceFeatureInput } from "../application/reference-feature-api.ts"; import { REFERENCE_FEATURE_ID, } from "../contracts/reference-feature-contract.ts"; -import { mapReferenceOperation } from "../contracts/reference-mapper.ts"; import { createReferenceHttpGateway, - type RawReferenceHttpExecutor, - type ReferenceHttpRequest, - type ReferenceOperationId, + REFERENCE_HTTP_OPERATIONS, } from "./reference-http-gateway.ts"; -export type InstalledContractOperationExecutor = Readonly<{ - execute( - operationId: string, - input: unknown, - context: Readonly<{ - routeId: string; - signal?: AbortSignal; - intent?: MutationIntent; - }>, - ): Promise>; -}>; +export type InstalledContractOperationExecutor = + InstalledHttpOperationExecutor; /** - * The installed feature consumes the composed external-contract operation - * registry through one descriptor-driven executor. Legacy ApiOperation/schema - * registries are intentionally absent from this production composition seam. + * Feature-owned composition seam. + * + * The feature contributes typed operation descriptors and its application + * gateway. HTTP lifecycle/error normalization stays in the reusable capability + * binding rather than being repeated by every product feature. */ export function createReferenceFeatureInstalledInput(context: Readonly<{ - contractOperations: InstalledContractOperationExecutor; + contractOperations: InstalledHttpOperationExecutor; }>) { - const rawHttp: RawReferenceHttpExecutor = Object.freeze({ - async execute(request) { - const operationId = request.operationId; - const input = inputFor(request); - const signal = "signal" in request ? request.signal : undefined; - const intent = "intent" in request ? request.intent : undefined; - const outcome = await context.contractOperations.execute( - operationId, - input, - { - // §7.4. The gateway owns the low-cardinality route identity; losing - // it here is what made every V3 diagnostic unattributable. - routeId: request.routeId, - ...(signal === undefined ? {} : { signal }), - ...(intent === undefined ? {} : { intent }), - }, - ); - return projectExecutionOutcome(operationId, outcome); - }, - }); + const http = createFeatureHttpBinding( + context.contractOperations, + REFERENCE_HTTP_OPERATIONS, + ); return Object.freeze({ featureId: REFERENCE_FEATURE_ID, - input: createReferenceFeatureInput(createReferenceHttpGateway(rawHttp)), + input: createReferenceFeatureInput(createReferenceHttpGateway(http)), }); } -function inputFor( - request: ReferenceHttpRequest, -): unknown { - switch (request.operationId) { - case "LIST_REFERENCE_RESOURCES": - return request.searchParams; - case "CREATE_REFERENCE_RESOURCE": - return request.body; - case "GET_REFERENCE_RESOURCE": - return request.pathParams; - } -} - -function projectExecutionOutcome( - operationId: ReferenceOperationId, - outcome: HttpExecutionOutcome, -): Result { - switch (outcome.kind) { - case "SUCCESS": { - const mapped = mapReferenceOperation(operationId, outcome.value); - return mapped.ok - ? Object.freeze({ ok: true as const, value: mapped.value }) - : failure( - "MAPPING_CONTRACT_VIOLATION", - operationId, - mapped.code, - { effect: outcome.effect }, - ); - } - case "PROBLEM": - return failure( - kindForStatus(outcome.metadata.status), - operationId, - "CONTRACT_PROBLEM", - { httpStatus: outcome.metadata.status, effect: outcome.effect }, - ); - case "UNAUTHENTICATED": - return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", { - effect: outcome.effect, - }); - case "FORBIDDEN": - return failure("FORBIDDEN", operationId, "FORBIDDEN", { - effect: outcome.effect, - }); - case "RATE_LIMITED": - return failure("RATE_LIMITED", operationId, "RATE_LIMITED", { - ...(outcome.retryAfterMs === undefined - ? {} - : { retryAfterMs: outcome.retryAfterMs }), - effect: outcome.effect, - }); - case "CANCELLED": - return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", { - effect: outcome.effect, - }); - case "AUTH_INTEGRATION_FAILURE": - // §7.7. A configuration or collaborator breach, not a session state, so - // it must not drive the re-authentication surface. - return failure( - "AUTH_INTEGRATION_FAILURE", - operationId, - outcome.reason, - { effect: outcome.effect }, - ); - case "TRANSPORT_FAILURE": - return failure( - outcome.failure.kind === "TIMEOUT" - ? "REQUEST_TIMEOUT" - : outcome.failure.kind === "ABORTED_BY_SCOPE" - ? "SCOPE_GENERATION_CHANGED" - : "NETWORK_UNREACHABLE", - operationId, - outcome.failure.kind, - { effect: outcome.effect }, - ); - case "CONTRACT_VIOLATION": - return failure( - failureKindForViolation(outcome.violation.kind), - operationId, - outcome.violation.kind, - { effect: outcome.effect }, - ); - } -} - -function failureKindForViolation( - violation: Extract< - HttpExecutionOutcome, - { kind: "CONTRACT_VIOLATION" } - >["violation"]["kind"], -): FailureKind { - switch (violation) { - case "CONTENT_TYPE_MISMATCH": - return "CONTENT_TYPE_MISMATCH"; - case "RESPONSE_TOO_LARGE": - return "RESPONSE_BODY_LIMIT"; - case "UTF8_INVALID": - case "JSON_INVALID": - return "MALFORMED_JSON"; - case "MAPPING_CONTRACT_VIOLATION": - return "MAPPING_CONTRACT_VIOLATION"; - case "SCOPE_FENCED": - return "SCOPE_GENERATION_CHANGED"; - case "SUCCESS_SCHEMA_INVALID": - case "PROBLEM_SCHEMA_INVALID": - case "VALIDATOR_RUNTIME_FAILURE": - return "SCHEMA_MISMATCH"; - default: - return "ENVELOPE_MISMATCH"; - } -} - -function failure( - kind: FailureKind, - operationId: string, - code: string, - details: Readonly<{ - httpStatus?: number; - retryAfterMs?: number; - effect?: FailureEffectCertainty; - }> = {}, -): Result { - return Object.freeze({ - ok: false as const, - error: createFailure(kind, operationId, 0, { code, ...details }), - }); -} +export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION = Object.freeze({ + featureId: REFERENCE_FEATURE_ID, + createInput: createReferenceFeatureInstalledInput, +}); diff --git a/src/features/reference-feature/adapters/reference-http-gateway.ts b/src/features/reference-feature/adapters/reference-http-gateway.ts index 34e2874..553fb1a 100644 --- a/src/features/reference-feature/adapters/reference-http-gateway.ts +++ b/src/features/reference-feature/adapters/reference-http-gateway.ts @@ -1,160 +1,65 @@ -import type { Result } from "../../../application/result.ts"; import { - createFailure, - type ApiFailure, -} from "../../../contracts/errors.ts"; + defineFeatureHttpOperation, + type FeatureHttpBinding, +} from "../../../adapters/http/index.ts"; import type { ReferenceCreateCommand, ReferenceGateway, ReferenceListFilters, } from "../application/reference-feature-api.ts"; import type { ReferenceResource } from "../domain/reference-resource.ts"; -import type { MutationIntent } from "../../../contracts/mutation-intent.ts"; +import { + mapReferenceResourceListPayload, + mapReferenceResourcePayload, +} from "../contracts/reference-mapper.ts"; -type ReferenceOperationMap = Readonly<{ - LIST_REFERENCE_RESOURCES: Readonly<{ - request: Readonly<{ - operationId: "LIST_REFERENCE_RESOURCES"; - routeId: "REFERENCE_RESOURCE_LIST"; - searchParams: ReferenceListFilters; - signal?: AbortSignal; - }>; - value: readonly ReferenceResource[]; - }>; - CREATE_REFERENCE_RESOURCE: Readonly<{ - request: Readonly<{ - operationId: "CREATE_REFERENCE_RESOURCE"; - routeId: "REFERENCE_RESOURCE_LIST"; - body: ReferenceCreateCommand; - signal?: AbortSignal; - intent?: MutationIntent; - }>; - value: ReferenceResource; - }>; - GET_REFERENCE_RESOURCE: Readonly<{ - request: Readonly<{ - operationId: "GET_REFERENCE_RESOURCE"; - routeId: "REFERENCE_RESOURCE_DETAIL"; - pathParams: Readonly<{ resourceId: string }>; - signal?: AbortSignal; - }>; - value: ReferenceResource; - }>; -}>; +export const REFERENCE_HTTP_OPERATIONS = Object.freeze({ + LIST_REFERENCE_RESOURCES: defineFeatureHttpOperation< + ReferenceListFilters, + readonly ReferenceResource[] + >({ + operationId: "LIST_REFERENCE_RESOURCES", + routeId: "REFERENCE_RESOURCE_LIST", + mapSuccess: mapReferenceResourceListPayload, + }), + CREATE_REFERENCE_RESOURCE: defineFeatureHttpOperation< + ReferenceCreateCommand, + ReferenceResource + >({ + operationId: "CREATE_REFERENCE_RESOURCE", + routeId: "REFERENCE_RESOURCE_LIST", + mapSuccess: mapReferenceResourcePayload, + }), + GET_REFERENCE_RESOURCE: defineFeatureHttpOperation< + Readonly<{ resourceId: string }>, + ReferenceResource + >({ + operationId: "GET_REFERENCE_RESOURCE", + routeId: "REFERENCE_RESOURCE_DETAIL", + mapSuccess: mapReferenceResourcePayload, + }), +} as const); -export type ReferenceOperationId = keyof ReferenceOperationMap; - -export type ReferenceHttpRequest< - OperationId extends ReferenceOperationId, -> = ReferenceOperationMap[OperationId]["request"]; - -export type ReferenceHttpResult< - OperationId extends ReferenceOperationId, -> = Result; - -export type RawReferenceHttpExecutor = Readonly<{ - execute( - request: ReferenceHttpRequest, - ): Promise>; -}>; +export type ReferenceHttpBinding = FeatureHttpBinding< + typeof REFERENCE_HTTP_OPERATIONS +>; export function createReferenceHttpGateway( - http: RawReferenceHttpExecutor, + http: ReferenceHttpBinding, ): ReferenceGateway { return Object.freeze({ - async list( - filters: ReferenceListFilters, - context?: Readonly<{ signal?: AbortSignal }>, - ) { - const result = await http.execute({ - operationId: "LIST_REFERENCE_RESOURCES", - routeId: "REFERENCE_RESOURCE_LIST", - searchParams: filters, - signal: context?.signal, - }); - return projectListResult(result); + list(filters, context) { + return http.execute("LIST_REFERENCE_RESOURCES", filters, context); }, - async create( - command: ReferenceCreateCommand, - context?: Readonly<{ - signal?: AbortSignal; - intent?: MutationIntent; - }>, - ) { - const result = await http.execute({ - operationId: "CREATE_REFERENCE_RESOURCE", - routeId: "REFERENCE_RESOURCE_LIST", - body: command, - signal: context?.signal, - intent: context?.intent, - }); - return projectResourceResult("CREATE_REFERENCE_RESOURCE", result); + create(command, context) { + return http.execute("CREATE_REFERENCE_RESOURCE", command, context); }, - async get( - resourceId: string, - context?: Readonly<{ signal?: AbortSignal }>, - ) { - const result = await http.execute({ - operationId: "GET_REFERENCE_RESOURCE", - routeId: "REFERENCE_RESOURCE_DETAIL", - pathParams: { resourceId }, - signal: context?.signal, - }); - return projectResourceResult("GET_REFERENCE_RESOURCE", result); + get(resourceId, context) { + return http.execute( + "GET_REFERENCE_RESOURCE", + Object.freeze({ resourceId }), + context, + ); }, }); } - -function projectListResult( - result: Result, -): ReferenceHttpResult<"LIST_REFERENCE_RESOURCES"> { - if (!result.ok) return result; - if (isReferenceResourceList(result.value)) { - return { ok: true, value: result.value }; - } - return typedResultFailure("LIST_REFERENCE_RESOURCES"); -} - -function projectResourceResult( - operationId: - | "CREATE_REFERENCE_RESOURCE" - | "GET_REFERENCE_RESOURCE", - result: Result, -): Result { - if (!result.ok) return result; - if (isReferenceResource(result.value)) { - return { ok: true, value: result.value }; - } - return typedResultFailure(operationId); -} - -function typedResultFailure(operationId: ReferenceOperationId) { - return { - ok: false as const, - error: createFailure( - "MAPPING_CONTRACT_VIOLATION", - operationId, - 0, - { code: "BOUND_RESULT_TYPE_MISMATCH" }, - ), - }; -} - -function isReferenceResourceList( - value: unknown, -): value is readonly ReferenceResource[] { - return Array.isArray(value) && value.every(isReferenceResource); -} - -function isReferenceResource(value: unknown): value is ReferenceResource { - if (!value || typeof value !== "object") return false; - const candidate = value as Readonly>; - return ( - typeof candidate.id === "string" && - candidate.id.length > 0 && - typeof candidate.displayName === "string" && - candidate.displayName.length > 0 && - (candidate.createdAt === null || - typeof candidate.createdAt === "string") - ); -} diff --git a/src/features/reference-feature/application/reference-feature-api.ts b/src/features/reference-feature/application/reference-feature-api.ts index 4ee895e..9ea8f21 100644 --- a/src/features/reference-feature/application/reference-feature-api.ts +++ b/src/features/reference-feature/application/reference-feature-api.ts @@ -1,4 +1,4 @@ -import type { Result } from "../../../application/result.ts"; +import type { Result } from "../../../contracts/result.ts"; import type {} from "../../../application/ports/in/application-api.ts"; import { toReferenceView, diff --git a/src/features/reference-feature/contracts/reference-mapper.ts b/src/features/reference-feature/contracts/reference-mapper.ts index 11b226e..bb05aa4 100644 --- a/src/features/reference-feature/contracts/reference-mapper.ts +++ b/src/features/reference-feature/contracts/reference-mapper.ts @@ -5,8 +5,8 @@ import { import { mappingFailure, mappingSuccess, - type MappingResult, type InstalledBoundaryMapper, + type MappingResult, } from "../../../contracts/boundary-mapper.ts"; export type ReferenceResourceView = Readonly<{ @@ -16,7 +16,9 @@ export type ReferenceResourceView = Readonly<{ optimistic?: boolean; }>; -function mapReferenceDto(value: unknown): MappingResult { +export function mapReferenceResourcePayload( + value: unknown, +): MappingResult { if (!value || typeof value !== "object") { return mappingFailure("MAPPING_INVARIANT_REJECTED"); } @@ -37,28 +39,40 @@ function mapReferenceDto(value: unknown): MappingResult { } } +export function mapReferenceResourceListPayload( + payload: unknown, +): MappingResult { + if (!Array.isArray(payload)) { + return mappingFailure("MAPPING_INVARIANT_REJECTED"); + } + if (payload.length > 100) { + return mappingFailure("OUTPUT_LIMIT_EXCEEDED"); + } + const output: ReferenceResource[] = []; + for (const item of payload) { + const mapped = mapReferenceResourcePayload(item); + if (!mapped.ok) return mapped; + output.push(mapped.value); + } + return mappingSuccess(Object.freeze(output)); +} + +/** + * Registry-facing compatibility projection. Feature adapters should prefer the + * operation-specific mapper functions above so their result type is exact. + */ export function mapReferenceOperation( operationId: string, payload: unknown, ): MappingResult { if (operationId === "LIST_REFERENCE_RESOURCES") { - if (!Array.isArray(payload)) { - return mappingFailure("MAPPING_INVARIANT_REJECTED"); - } - if (payload.length > 100) return mappingFailure("OUTPUT_LIMIT_EXCEEDED"); - const output: ReferenceResource[] = []; - for (const item of payload) { - const mapped = mapReferenceDto(item); - if (!mapped.ok) return mapped; - output.push(mapped.value); - } - return mappingSuccess(Object.freeze(output)); + return mapReferenceResourceListPayload(payload); } if ( operationId === "CREATE_REFERENCE_RESOURCE" || operationId === "GET_REFERENCE_RESOURCE" ) { - return mapReferenceDto(payload); + return mapReferenceResourcePayload(payload); } return mappingFailure("MAPPING_INVARIANT_REJECTED"); } @@ -71,8 +85,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({ outputContractId: "ReferenceResourceList", owner: "feature-frontend-reference-feature-vertical-slice", maxOutputItems: 100, - map: (input: unknown) => - mapReferenceOperation("LIST_REFERENCE_RESOURCES", input), + map: mapReferenceResourceListPayload, }), ReferenceResourceMapper: Object.freeze({ mapperId: "ReferenceResourceMapper", @@ -81,8 +94,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({ outputContractId: "ReferenceResource", owner: "feature-frontend-reference-feature-vertical-slice", maxOutputItems: 1, - map: (input: unknown) => - mapReferenceOperation("GET_REFERENCE_RESOURCE", input), + map: mapReferenceResourcePayload, }), } satisfies Readonly>); diff --git a/src/features/reference-feature/presentation/reference-feature-runtime.tsx b/src/features/reference-feature/presentation/reference-feature-runtime.tsx index 5e2055c..ca38551 100644 --- a/src/features/reference-feature/presentation/reference-feature-runtime.tsx +++ b/src/features/reference-feature/presentation/reference-feature-runtime.tsx @@ -1,5 +1,6 @@ import { lazy } from "react"; +import { REFERENCE_FEATURE_ID } from "../contracts/reference-feature-contract.ts"; import { referenceResourceListQuerySchema, referenceResourceParamsSchema, @@ -28,3 +29,9 @@ export const REFERENCE_FEATURE_ROUTE_RUNTIME = { Component: lazy(() => import("./reference-resource-status-page.tsx")), }), } as const; + +export const REFERENCE_FEATURE_RUNTIME_CONTRIBUTION = Object.freeze({ + featureId: REFERENCE_FEATURE_ID, + routeCodecs: REFERENCE_FEATURE_ROUTE_CODECS, + routeRuntime: REFERENCE_FEATURE_ROUTE_RUNTIME, +}); diff --git a/src/features/reference-feature/presentation/use-reference-feature.ts b/src/features/reference-feature/presentation/use-reference-feature.ts index 64ba9e3..ca1f976 100644 --- a/src/features/reference-feature/presentation/use-reference-feature.ts +++ b/src/features/reference-feature/presentation/use-reference-feature.ts @@ -1,4 +1,4 @@ -import { useApplication } from "../../../presentation/providers/application-provider.tsx"; +import { useApplicationFeature } from "../../../presentation/providers/application-provider.tsx"; import { useApplicationMutation, useApplicationQuery, @@ -50,8 +50,32 @@ function measureResourceList( return { itemCount: views.length, estimatedBytes }; } +const REFERENCE_CREATE_MUTATION_POLICY = Object.freeze({ + definitionId: "reference-resource-create-v1", + definitionVersion: 1, + operationId: "CREATE_REFERENCE_RESOURCE", + requiresIdempotencyKey: true, + owner: REFERENCE_FEATURE_ID, + duplicatePolicy: "REJECT_WHILE_ACTIVE" as const, + invalidate: Object.freeze([REFERENCE_RESOURCE_INVALIDATION_TOPIC]), +}); + +type ReferenceCreateMutationBinding = Pick< + BoundMutation, + "scope" | "execute" +>; + +function bindReferenceCreateMutation( + binding: ReferenceCreateMutationBinding, +): BoundMutation { + return Object.freeze({ + ...REFERENCE_CREATE_MUTATION_POLICY, + ...binding, + }); +} + export function useReferenceFeatureInput(): ReferenceFeatureInput { - return useApplication().features.get(REFERENCE_FEATURE_ID); + return useApplicationFeature(REFERENCE_FEATURE_ID); } export function useReferenceDetail(resourceId: string) { @@ -82,20 +106,10 @@ export function useReferenceDetail(resourceId: string) { export function useReferenceCreate() { const input = useReferenceFeatureInput(); const scope = useServerStateScope(); - const mutation: BoundMutation< - ReferenceCreateCommand, - ReferenceResourceView - > = { - definitionId: "reference-resource-create-v1", - definitionVersion: 1, - operationId: "CREATE_REFERENCE_RESOURCE", - requiresIdempotencyKey: true, - owner: REFERENCE_FEATURE_ID, - duplicatePolicy: "REJECT_WHILE_ACTIVE", + const mutation = bindReferenceCreateMutation({ scope, execute: input.createResource, - invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC], - }; + }); return useApplicationMutation(mutation); } @@ -123,16 +137,11 @@ export function useReferenceFeature() { scope, ), ); - const mutation = useApplicationMutation({ - definitionId: "reference-resource-create-v1", - definitionVersion: 1, - operationId: "CREATE_REFERENCE_RESOURCE", - requiresIdempotencyKey: true, - owner: REFERENCE_FEATURE_ID, - duplicatePolicy: "REJECT_WHILE_ACTIVE", - scope, - execute: input.createResource, - invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC], - }); + const mutation = useApplicationMutation( + bindReferenceCreateMutation({ + scope, + execute: input.createResource, + }), + ); return Object.freeze({ filters, query, mutation }); } diff --git a/src/presentation/adapters/query/application-query.ts b/src/presentation/adapters/query/application-query.ts index 98a4432..1d5a149 100644 --- a/src/presentation/adapters/query/application-query.ts +++ b/src/presentation/adapters/query/application-query.ts @@ -17,7 +17,7 @@ import { deriveAsyncState, type AsyncState, } from "../../../application/view-models/async-state.ts"; -import type { Result } from "../../../application/result.ts"; +import type { Result } from "../../../contracts/result.ts"; import { createFailure, normalizeUnknownFailure, diff --git a/src/presentation/examples/platform-overview-page.tsx b/src/presentation/examples/platform-overview-page.tsx index e646809..bc95036 100644 --- a/src/presentation/examples/platform-overview-page.tsx +++ b/src/presentation/examples/platform-overview-page.tsx @@ -19,7 +19,7 @@ import { PageHeader, type DataTableColumn, } from "../design-system/index.ts"; -import { useApplication } from "../providers/application-provider.tsx"; +import { useApplicationRuntime } from "../providers/application-provider.tsx"; /** * Every number and row on this page is read from an installed registry at @@ -279,7 +279,7 @@ function buildOperationRows(): readonly OperationRow[] { } export default function PlatformOverviewPage() { - const { runtime } = useApplication(); + const runtime = useApplicationRuntime(); const [release, setRelease] = useState< Awaited> | null >(null); diff --git a/src/presentation/forms/form-contracts.ts b/src/presentation/forms/form-contracts.ts index 5fc7670..cdc1581 100644 --- a/src/presentation/forms/form-contracts.ts +++ b/src/presentation/forms/form-contracts.ts @@ -1,4 +1,4 @@ -import type { Result } from "../../application/result.ts"; +import type { Result } from "../../contracts/result.ts"; import type { AppFailure } from "../../contracts/errors.ts"; import { formatMessage, diff --git a/src/presentation/layouts/app-shell.tsx b/src/presentation/layouts/app-shell.tsx index 366ba56..9692166 100644 --- a/src/presentation/layouts/app-shell.tsx +++ b/src/presentation/layouts/app-shell.tsx @@ -20,7 +20,7 @@ import { useLocale, type MessageKey, } from "../i18n/index.ts"; -import { useApplication } from "../providers/application-provider.tsx"; +import { useApplicationRuntime } from "../providers/application-provider.tsx"; import { useSession } from "../providers/session-provider.tsx"; import { useTheme } from "../providers/theme-provider.tsx"; @@ -168,7 +168,7 @@ export function AppShell() { function PrimaryNavigation({ id }: Readonly<{ id: string }>) { const { resolve, message } = useLocale(); - const { runtime } = useApplication(); + const runtime = useApplicationRuntime(); // §3.5. A feature the runtime document disabled does not advertise itself. // The router refuses its routes too, so this is presentation, not the switch. const routes = NAVIGATION_ROUTES.filter((definition) => { diff --git a/src/presentation/pages/home-page.tsx b/src/presentation/pages/home-page.tsx index 4e42cff..6dfeab7 100644 --- a/src/presentation/pages/home-page.tsx +++ b/src/presentation/pages/home-page.tsx @@ -3,7 +3,7 @@ import { Link } from "react-router-dom"; import { routePath } from "../../features/installed-feature-contracts.ts"; import { PageHeader } from "../design-system/index.ts"; -import { useApplication } from "../providers/application-provider.tsx"; +import { useApplicationRuntime } from "../providers/application-provider.tsx"; const READINESS_ITEMS = Object.freeze([ { @@ -21,7 +21,7 @@ const READINESS_ITEMS = Object.freeze([ ]); export default function HomePage() { - const { runtime } = useApplication(); + const runtime = useApplicationRuntime(); const [release, setRelease] = useState< Awaited> | null >(null); diff --git a/src/presentation/providers/application-provider.tsx b/src/presentation/providers/application-provider.tsx index f70bbfc..9a74ca0 100644 --- a/src/presentation/providers/application-provider.tsx +++ b/src/presentation/providers/application-provider.tsx @@ -4,7 +4,11 @@ import { useContext, } from "react"; -import type { ApplicationApi } from "../../application/create-application.ts"; +import type { + ApplicationApi, + ApplicationFeatureId, + ApplicationFeatureInputs, +} from "../../application/ports/in/application-api.ts"; const ApplicationContext = createContext(null); @@ -22,10 +26,47 @@ export function ApplicationProvider({ ); } -export function useApplication(): ApplicationApi { +function useApplicationContext(): ApplicationApi { const application = useContext(ApplicationContext); if (!application) { throw new Error("ApplicationProvider is required"); } return application; } + +export function useApplicationSession(): ApplicationApi["session"] { + return useApplicationContext().session; +} + +export function useApplicationPreferences(): ApplicationApi["preferences"] { + return useApplicationContext().preferences; +} + +export function useApplicationDiagnostics(): ApplicationApi["diagnostics"] { + return useApplicationContext().diagnostics; +} + +export function useApplicationRuntime(): ApplicationApi["runtime"] { + return useApplicationContext().runtime; +} + +export function useApplicationRecovery(): ApplicationApi["recovery"] { + return useApplicationContext().recovery; +} + +export function useApplicationFeature< + FeatureId extends ApplicationFeatureId, +>(featureId: FeatureId): ApplicationFeatureInputs[FeatureId] { + return useApplicationContext().features.get(featureId); +} + +/** + * Compatibility escape hatch for composition-oriented presentation code. + * New consumers should use the narrow hooks above so dependencies remain + * explicit instead of growing a root-object service locator. + * + * @deprecated Prefer a capability-specific hook. + */ +export function useApplication(): ApplicationApi { + return useApplicationContext(); +} diff --git a/src/presentation/providers/session-provider.tsx b/src/presentation/providers/session-provider.tsx index 75f9ced..b87129e 100644 --- a/src/presentation/providers/session-provider.tsx +++ b/src/presentation/providers/session-provider.tsx @@ -10,7 +10,7 @@ import type { ApplicationApi, SessionState, } from "../../application/ports/in/application-api.ts"; -import { useApplication } from "./application-provider.tsx"; +import { useApplicationSession } from "./application-provider.tsx"; export type SessionContextValue = Readonly<{ sessionState: SessionState; @@ -24,7 +24,7 @@ const SessionContext = createContext(null); export function SessionProvider({ children, }: Readonly<{ children: ReactNode }>) { - const { session } = useApplication(); + const session = useApplicationSession(); const sessionState = useSyncExternalStore( session.subscribe, session.getSnapshot, diff --git a/src/presentation/providers/theme-provider.tsx b/src/presentation/providers/theme-provider.tsx index e48dd3d..1e53532 100644 --- a/src/presentation/providers/theme-provider.tsx +++ b/src/presentation/providers/theme-provider.tsx @@ -13,7 +13,7 @@ import { normalizeColorSchemePreference, resolveColorScheme, } from "../../application/policies/color-scheme.ts"; -import { useApplication } from "./application-provider.tsx"; +import { useApplicationPreferences } from "./application-provider.tsx"; export type ThemeContextValue = Readonly<{ preference: ColorSchemePreference; @@ -33,7 +33,7 @@ function systemPrefersDark(): boolean { export function ThemeProvider({ children, }: Readonly<{ children: ReactNode }>) { - const { preferences } = useApplication(); + const preferences = useApplicationPreferences(); const [preference, updatePreference] = useState( preferences.getColorScheme, ); diff --git a/src/presentation/routes/app-router.tsx b/src/presentation/routes/app-router.tsx index 2f2403c..0c505b4 100644 --- a/src/presentation/routes/app-router.tsx +++ b/src/presentation/routes/app-router.tsx @@ -30,7 +30,11 @@ import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.tsx import { Button, PageHeader } from "../design-system/index.ts"; import { LocaleProvider, useLocale } from "../i18n/index.ts"; import { AppShell } from "../layouts/app-shell.tsx"; -import { useApplication } from "../providers/application-provider.tsx"; +import { + useApplicationDiagnostics, + useApplicationRecovery, + useApplicationRuntime, +} from "../providers/application-provider.tsx"; import { SessionProvider, useSession } from "../providers/session-provider.tsx"; import { ThemeProvider } from "../providers/theme-provider.tsx"; import { @@ -126,7 +130,7 @@ function RouteLifecycle({ }) { const location = useLocation(); const { message, resolve } = useLocale(); - const { diagnostics } = useApplication(); + const diagnostics = useApplicationDiagnostics(); useEffect(() => { document.title = message("route.documentTitle", { title: resolve(`route.${definition.routeId}.title`), @@ -269,7 +273,9 @@ function RegisteredRoute({ const params = useParams(); const [search] = useSearchParams(); const location = useLocation(); - const { diagnostics, runtime: platformRuntime, recovery } = useApplication(); + const diagnostics = useApplicationDiagnostics(); + const platformRuntime = useApplicationRuntime(); + const recovery = useApplicationRecovery(); // §3.5. A feature the runtime document disabled is out of service, not // merely hidden: withdrawing it from navigation alone would leave a typed // deep link that still mounts it. diff --git a/tests/component/application-mutation-admission.test.tsx b/tests/component/application-mutation-admission.test.tsx new file mode 100644 index 0000000..afe60de --- /dev/null +++ b/tests/component/application-mutation-admission.test.tsx @@ -0,0 +1,317 @@ +// @vitest-environment jsdom + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + type ApplicationResult, + useApplicationMutation, +} from "../../src/presentation/adapters/query/application-query.ts"; +import type { MutationIntentFactory } from "../../src/application/ports/mutation-intent-factory.ts"; +import { + RESOURCE_INVALIDATION_TOPIC, + deterministicMutationIntentFactory, + queryClient, + scopeSnapshot, + wrapper, +} from "./application-query-fixture.tsx"; + +describe("application mutation intent admission", () => { + it("retains optimistic data when execute throws after dispatch begins", async () => { + const client = queryClient(); + const key = ["resource", "thrown-unknown-effect"]; + client.setQueryData(key, ["base"]); + const hook = renderHook( + () => + useApplicationMutation({ + execute: async () => { + throw new Error("private transport defect"); + }, + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome: ApplicationResult | undefined; + await act(async () => { + outcome = await hook.result.current.submit("created"); + }); + + expect(outcome).toMatchObject({ + ok: false, + error: { + effect: "MAYBE_APPLIED", + retryable: false, + action: "contact-support", + }, + }); + expect(JSON.stringify(outcome)).not.toContain("private transport defect"); + expect(client.getQueryData(key)).toEqual(["base", "created"]); + expect(hook.result.current.state.indicator).toBe( + "mutation-effect-unknown", + ); + }); + + it("creates a distinct logical intent for each independently admitted submit", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const observedIntents: unknown[] = []; + const execute = vi.fn( + async ( + input: string, + context: Readonly<{ signal: AbortSignal; intent?: unknown }>, + ) => { + observedIntents.push(context.intent); + return { ok: true as const, value: input }; + }, + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "independent-intent-v1", + definitionVersion: 1, + operationId: "CREATE_WITH_INTENT", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "ALLOW_PARALLEL", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client) }, + ); + + await act(async () => { + await hook.result.current.submit("same-input"); + await hook.result.current.submit("same-input"); + }); + + expect(observedIntents).toHaveLength(2); + expect(observedIntents[0]).toMatchObject({ + intentId: "intent-1", + operationId: "CREATE_WITH_INTENT", + idempotencyKey: "key-1", + }); + expect(observedIntents[1]).toMatchObject({ + intentId: "intent-2", + operationId: "CREATE_WITH_INTENT", + idempotencyKey: "key-2", + }); + expect(observedIntents[0]).not.toEqual(observedIntents[1]); + }); + + it("creates no second intent when JOIN_IDENTICAL shares an admitted submit", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const deterministicFactory = deterministicMutationIntentFactory(); + const createIntent = vi.fn(deterministicFactory.create); + const factory: MutationIntentFactory = Object.freeze({ + create: createIntent, + }); + let complete: (value: ApplicationResult) => void = () => {}; + const execute = vi.fn( + () => + new Promise>((resolve) => { + complete = resolve; + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "joined-intent-v1", + definitionVersion: 1, + operationId: "CREATE_JOINED", + requiresIdempotencyKey: true, + owner: "platform-test", + duplicatePolicy: "JOIN_IDENTICAL", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client, factory) }, + ); + + let first: Promise> | null = null; + let joined: Promise> | null = null; + act(() => { + first = hook.result.current.submit("same-input"); + joined = hook.result.current.submit("same-input"); + }); + + expect(first).toBe(joined); + await waitFor(() => expect(execute).toHaveBeenCalledOnce()); + expect(createIntent).toHaveBeenCalledOnce(); + expect(createIntent).toHaveBeenCalledWith({ + operationId: "CREATE_JOINED", + canonicalInputIdentity: + "scope-fingerprint-0001:joined-intent-v1:scope-identity-token-0001", + requiresIdempotencyKey: true, + }); + + complete({ ok: true, value: "same-input" }); + if (!first) throw new Error("expected admitted mutation"); + await act(() => first); + }); + + it("keeps the legacy raw mutation path outside the intent factory", async () => { + const client = queryClient(); + const createIntent = vi.fn(); + const factory: MutationIntentFactory = Object.freeze({ + create: createIntent, + }); + const execute = vi.fn(async (input: string) => ({ + ok: true as const, + value: input, + })); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-raw-path-v1", + execute, + }), + { wrapper: wrapper(client, factory) }, + ); + + await act(() => hook.result.current.submit("legacy-input")); + + expect(execute).toHaveBeenCalledOnce(); + expect(createIntent).not.toHaveBeenCalled(); + }); + + it("rejects a duplicate submit by default while one is active", async () => { + const client = queryClient(); + let complete: (value: ApplicationResult) => void = () => {}; + const execute = vi.fn( + () => + new Promise>((resolve) => { + complete = resolve; + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-duplicate-rejection-v1", + execute, + }), + { wrapper: wrapper(client) }, + ); + + let first: Promise> | null = null; + let duplicate: Promise> | null = null; + act(() => { + first = hook.result.current.submit("created"); + duplicate = hook.result.current.submit("created"); + }); + + if (!first || !duplicate) throw new Error("expected two submissions"); + await expect(duplicate).resolves.toMatchObject({ + ok: false, + error: { kind: "DUPLICATE_IN_FLIGHT" }, + }); + expect(execute).toHaveBeenCalledOnce(); + + complete({ ok: true, value: "created" }); + await act(() => first as Promise>); + }); + + it("deduplicates submit and commits one optimistic mutation", async () => { + const client = queryClient(); + const key = ["resource", "list"]; + client.setQueryData(key, ["existing"]); + let complete: (value: ApplicationResult) => void = () => {}; + const execute = vi.fn( + () => + new Promise>((resolve) => { + complete = resolve; + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + // §11.2: joining is opt-in. The default is REJECT_WHILE_ACTIVE. + duplicatePolicy: "JOIN_IDENTICAL", + execute, + invalidate: [RESOURCE_INVALIDATION_TOPIC], + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let first: Promise> | null = null; + let duplicate: Promise> | null = null; + act(() => { + first = hook.result.current.submit("created"); + duplicate = hook.result.current.submit("created"); + }); + expect(first).toBe(duplicate); + await waitFor(() => + expect(client.getQueryData(key)).toEqual(["existing", "created"]), + ); + await waitFor(() => expect(execute).toHaveBeenCalledOnce()); + await waitFor(() => + expect(hook.result.current.state.indicator).toBe("mutation-pending"), + ); + + complete({ ok: true, value: "created" }); + if (!first) throw new Error("expected pending mutation"); + await act(() => first); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + await waitFor(() => + expect(hook.result.current.state.indicator).toBeNull(), + ); + }); + + it("never joins distinct mutation inputs to the same runtime promise", async () => { + const client = queryClient(); + const resolvers = new Map< + string, + (value: ApplicationResult) => void + >(); + const execute = vi.fn( + (input: string) => + new Promise>((resolve) => { + resolvers.set(input, resolve); + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "legacy-parallel-distinct-inputs-v1", + execute, + currentData: true, + }), + { wrapper: wrapper(client) }, + ); + + let first: Promise> | undefined; + let second: Promise> | undefined; + act(() => { + first = hook.result.current.submit("first"); + second = hook.result.current.submit("second"); + }); + expect(first).not.toBe(second); + await waitFor(() => expect(execute).toHaveBeenCalledTimes(2)); + + resolvers.get("first")?.({ ok: true, value: "first" }); + resolvers.get("second")?.({ ok: true, value: "second" }); + if (!first || !second) throw new Error("expected pending mutations"); + await act(async () => { + await Promise.all([first, second]); + }); + }); + +}); diff --git a/tests/component/application-mutation-optimistic-cache.test.tsx b/tests/component/application-mutation-optimistic-cache.test.tsx new file mode 100644 index 0000000..eca908b --- /dev/null +++ b/tests/component/application-mutation-optimistic-cache.test.tsx @@ -0,0 +1,213 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + type ApplicationResult, + useApplicationMutation, +} from "../../src/presentation/adapters/query/application-query.ts"; +import { createFailure } from "../../src/contracts/errors.ts"; +import { + RESOURCE_INVALIDATION_TOPIC, + queryClient, + wrapper, +} from "./application-query-fixture.tsx"; + +describe("application mutation optimistic cache lifecycle", () => { + it("cancels an in-flight query before taking the optimistic snapshot", async () => { + const client = queryClient(); + const key = ["resource", "ordered-update"]; + client.setQueryData(key, ["existing"]); + let finishCancellation: () => void = () => {}; + const cancellation = new Promise((resolve) => { + finishCancellation = resolve; + }); + const cancelQueries = vi + .spyOn(client, "cancelQueries") + .mockImplementation(async () => cancellation); + const getQueryData = vi.spyOn(client, "getQueryData"); + const update = vi.fn((previous, input) => [ + ...(previous as string[]), + input, + ]); + const execute = vi.fn(async () => ({ + ok: true as const, + value: "created", + })); + const hook = renderHook( + () => + useApplicationMutation({ + execute, + currentData: ["existing"], + optimistic: { queryKey: key, update }, + }), + { wrapper: wrapper(client) }, + ); + + let pending: Promise> | undefined; + act(() => { + pending = hook.result.current.submit("created"); + }); + expect(cancelQueries).toHaveBeenCalledWith({ + queryKey: key, + exact: true, + }); + expect(getQueryData).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + + finishCancellation(); + if (!pending) throw new Error("expected pending mutation"); + await act(() => pending); + + expect(getQueryData).toHaveBeenCalledWith(key); + expect(update).toHaveBeenCalledWith(["existing"], "created"); + expect(execute).toHaveBeenCalledOnce(); + expect(client.getQueryData(key)).toEqual(["existing", "created"]); + }); + + it("keeps a committed optimistic update when invalidation fails", async () => { + const client = queryClient(); + const key = ["resource", "committed-update"]; + client.setQueryData(key, ["existing"]); + vi.spyOn(client, "invalidateQueries").mockRejectedValue( + new Error("cache refresh failed"), + ); + const hook = renderHook( + () => + useApplicationMutation({ + execute: async () => ({ ok: true, value: "created" }), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + currentData: ["existing"], + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome; + await act(async () => { + outcome = await hook.result.current.submit("created"); + }); + + expect(outcome).toEqual({ ok: true, value: "created" }); + expect(client.getQueryData(key)).toEqual(["existing", "created"]); + }); + + it("normalizes an optimistic preparation defect without running the command", async () => { + const client = queryClient(); + const key = ["resource", "invalid-optimistic-update"]; + client.setQueryData(key, ["existing"]); + const execute = vi.fn(async () => ({ + ok: true as const, + value: "created", + })); + const hook = renderHook( + () => + useApplicationMutation({ + execute, + currentData: ["existing"], + optimistic: { + queryKey: key, + update: () => { + throw new Error("private optimistic detail"); + }, + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome; + await act(async () => { + outcome = await hook.result.current.submit("created"); + }); + + expect(outcome).toMatchObject({ + ok: false, + error: { + kind: "UNKNOWN_FAILURE", + effect: "NOT_STARTED", + operationId: "APPLICATION_MUTATION", + userMessageKey: "error.unknown_failure", + }, + }); + expect(JSON.stringify(outcome)).not.toContain("private optimistic detail"); + expect(execute).not.toHaveBeenCalled(); + expect(client.getQueryData(key)).toEqual(["existing"]); + }); + + it("removes an optimistic cache entry when no prior data existed", async () => { + const client = queryClient(); + const key = ["resource", "new-optimistic-entry"]; + const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { + effect: "NOT_APPLIED", + }); + const hook = renderHook( + () => + useApplicationMutation({ + execute: async () => ({ ok: false, error: failure }), + currentData: true, + optimistic: { + queryKey: key, + update: (_previous, input) => [input], + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome; + await act(async () => { + outcome = await hook.result.current.submit("temporary"); + }); + + expect(outcome).toEqual({ ok: false, error: failure }); + expect(client.getQueryData(key)).toBeUndefined(); + expect(client.getQueryState(key)).toBeUndefined(); + }); + + it("rolls optimistic data back and exposes a resolvable conflict", async () => { + const client = queryClient(); + const key = ["resource", "list"]; + client.setQueryData(key, ["existing"]); + const conflict = createFailure("CONFLICT", "CREATE", 0, { + effect: "NOT_APPLIED", + }); + const hook = renderHook( + () => + useApplicationMutation({ + execute: async () => ({ ok: false, error: conflict }), + invalidate: [RESOURCE_INVALIDATION_TOPIC], + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + ...(previous as string[]), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome; + await act(async () => { + outcome = await hook.result.current.submit("conflicting"); + }); + expect(outcome).toEqual({ ok: false, error: conflict }); + expect(client.getQueryData(key)).toEqual(["existing"]); + expect(hook.result.current.state.indicator).toBe("mutation-conflict"); + expect(hook.result.current.state.overlay).toMatchObject({ + mutationPending: false, + mutationConflict: true, + }); + + await act(() => hook.result.current.resolveConflict()); + expect(hook.result.current.state.indicator).toBeNull(); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + });}); diff --git a/tests/component/application-mutation-scope-fence.test.tsx b/tests/component/application-mutation-scope-fence.test.tsx new file mode 100644 index 0000000..7588b8a --- /dev/null +++ b/tests/component/application-mutation-scope-fence.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom + +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + type ApplicationResult, + useApplicationMutation, +} from "../../src/presentation/adapters/query/application-query.ts"; +import { createFailure } from "../../src/contracts/errors.ts"; +import { + queryClient, + scopeSnapshot, + wrapper, +} from "./application-query-fixture.tsx"; + +describe("scope-bound mutation fence", () => { + it("rejects a submit whose scope is already fenced", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const execute = vi.fn(async () => ({ ok: true as const, value: "v" })); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "fenced-mutation-v1", + definitionVersion: 1, + operationId: "CREATE_FENCED", + requiresIdempotencyKey: false, + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client) }, + ); + + scope.fence(); + const outcome = await hook.result.current.submit("value"); + expect(outcome).toMatchObject({ + ok: false, + error: { + kind: "SCOPE_GENERATION_CHANGED", + effect: "NOT_STARTED", + }, + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it("discards a mutation result whose scope was fenced after dispatch", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const execute = vi.fn(async () => { + scope.fence(); + return { ok: true as const, value: "committed" }; + }); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "late-mutation-v1", + definitionVersion: 1, + operationId: "CREATE_LATE", + requiresIdempotencyKey: false, + owner: "platform-test", + duplicatePolicy: "ALLOW_PARALLEL", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client) }, + ); + + const outcome = await hook.result.current.submit("value"); + expect(outcome).toMatchObject({ + ok: false, + error: { kind: "SCOPE_GENERATION_CHANGED" }, + }); + expect(execute).toHaveBeenCalledOnce(); + }); + + it("aborts a hung mutation when its captured scope is fenced", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + let observedSignal: AbortSignal | undefined; + const execute = vi.fn( + (_input: string, context: Readonly<{ signal: AbortSignal }>) => + new Promise>((resolve) => { + observedSignal = context.signal; + context.signal.addEventListener( + "abort", + () => + resolve({ + ok: false, + error: createFailure("REQUEST_ABORTED", "CREATE_HUNG", 0), + }), + { once: true }, + ); + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + definitionId: "hung-mutation-v1", + definitionVersion: 1, + operationId: "CREATE_HUNG", + requiresIdempotencyKey: false, + owner: "platform-test", + duplicatePolicy: "REJECT_WHILE_ACTIVE", + scope, + execute, + invalidate: [], + }), + { wrapper: wrapper(client) }, + ); + + const outcome = hook.result.current.submit("value"); + await waitFor(() => expect(observedSignal).toBe(scope.signal)); + + scope.fence(); + + expect(observedSignal?.aborted).toBe(true); + await expect(outcome).resolves.toMatchObject({ + ok: false, + error: { + kind: "SCOPE_GENERATION_CHANGED", + effect: "MAYBE_APPLIED", + retryable: false, + action: "contact-support", + }, + }); + }); +}); + diff --git a/tests/component/application-query-bridge.test.tsx b/tests/component/application-query-bridge.test.tsx new file mode 100644 index 0000000..e76bed9 --- /dev/null +++ b/tests/component/application-query-bridge.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + type ApplicationResult, + useApplicationQuery, +} from "../../src/presentation/adapters/query/application-query.ts"; +import { createFailure } from "../../src/contracts/errors.ts"; +import { queryClient, wrapper } from "./application-query-fixture.tsx"; + +describe("application query inbound bridge", () => { + it("latches a background failure over stale data and clears it on retry success", async () => { + const client = queryClient(); + const responses: ApplicationResult[] = [ + { ok: true, value: ["first"] }, + { + ok: false, + error: createFailure("SERVER_FAILURE", "LIST", 0), + }, + { ok: true, value: ["recovered"] }, + ]; + const execute = vi.fn(async () => responses.shift() ?? responses[0]); + const hook = renderHook( + () => + useApplicationQuery({ + queryKey: ["resource", "list"], + execute, + }), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => expect(hook.result.current.data).toEqual(["first"])); + await act(() => hook.result.current.retry()); + await waitFor(() => + expect(hook.result.current.state.indicator).toBe("stale-degraded"), + ); + expect(hook.result.current.state.base).toBe("success"); + expect(hook.result.current.data).toEqual(["first"]); + + await act(() => hook.result.current.retry()); + await waitFor(() => + expect(hook.result.current.data).toEqual(["recovered"]), + ); + expect(hook.result.current.state.indicator).toBeNull(); + expect(execute).toHaveBeenCalledTimes(3); + }); + + it("projects an initial application failure into terminal state", async () => { + const client = queryClient(); + const failure = createFailure("FORBIDDEN", "LIST", 0); + const hook = renderHook( + () => + useApplicationQuery({ + queryKey: ["forbidden"], + execute: async () => ({ ok: false, error: failure }), + }), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => + expect(hook.result.current.state.base).toBe("terminal-error"), + ); + expect(hook.result.current.state.failure).toBe(failure); + }); + + it("normalizes an unexpected execute rejection into a safe terminal failure", async () => { + const client = queryClient(); + const hook = renderHook( + () => + useApplicationQuery({ + queryKey: ["unexpected-rejection"], + execute: async () => { + throw new Error("private upstream detail"); + }, + }), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => + expect(hook.result.current.state.base).toBe("terminal-error"), + ); + expect(hook.result.current.state.failure).toMatchObject({ + kind: "UNKNOWN_FAILURE", + operationId: "APPLICATION_QUERY", + userMessageKey: "error.unknown_failure", + action: "contact-support", + }); + expect(JSON.stringify(hook.result.current.state.failure)).not.toContain( + "private upstream detail", + ); + }); + + it("passes cancellation to the application and does not retain an unmounted error", async () => { + const client = queryClient(); + let aborted = false; + const execute = vi.fn( + ({ signal }: { signal: AbortSignal }) => + new Promise>((resolve) => { + signal.addEventListener( + "abort", + () => { + aborted = true; + resolve({ + ok: false, + error: createFailure("REQUEST_ABORTED", "LIST", 0), + }); + }, + { once: true }, + ); + }), + ); + const hook = renderHook( + () => + useApplicationQuery({ + queryKey: ["cancelled"], + execute, + }), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => expect(execute).toHaveBeenCalledOnce()); + hook.unmount(); + await waitFor(() => expect(aborted).toBe(true)); + expect(client.getQueryState(["cancelled"])?.status).not.toBe("error"); + }); +}); + diff --git a/tests/component/application-query-fixture.tsx b/tests/component/application-query-fixture.tsx new file mode 100644 index 0000000..567236f --- /dev/null +++ b/tests/component/application-query-fixture.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +import type { MutationIntentFactory } from "../../src/application/ports/mutation-intent-factory.ts"; +import { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx"; +import { MutationIntentProvider } from "../../src/presentation/adapters/query/mutation-intent-provider.tsx"; +import { + defineQueryInvalidationTopic, + type QueryInvalidationCoordinator, +} from "../../src/contracts/query-invalidation.ts"; +import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts"; +import type { QueryResultMeasure } from "../../src/contracts/server-state.ts"; +import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts"; + +export const RESOURCE_INVALIDATION_TOPIC = + defineQueryInvalidationTopic("resource"); + +export function scopeSnapshot( + generation = 1, + fingerprint = "scope-fingerprint-0001", +): CacheScopeSnapshot & { fence(): void } { + let current = true; + const lifetime = new AbortController(); + const identities = createRuntimeIdentityRegistry({ + tokenFactory: () => "scope-identity-token-0001", + }); + return { + generation, + fingerprint, + identities, + signal: lifetime.signal, + isCurrent: () => current, + fence() { + current = false; + lifetime.abort(); + }, + }; +} + +export function measureOne(): QueryResultMeasure { + return { itemCount: 1, estimatedBytes: 8 }; +} + +export function queryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: 0, gcTime: Infinity }, + mutations: { retry: false }, + }, + }); +} + +export function deterministicMutationIntentFactory(): MutationIntentFactory { + let sequence = 0; + return Object.freeze({ + create(input) { + sequence += 1; + return Object.freeze({ + intentId: `intent-${sequence}`, + operationId: input.operationId, + canonicalInputIdentity: input.canonicalInputIdentity, + ...(input.requiresIdempotencyKey + ? { idempotencyKey: `key-${sequence}` } + : {}), + createdAtMonotonicMs: sequence, + }); + }, + }); +} + +export function wrapper( + client: QueryClient, + mutationIntentFactory = deterministicMutationIntentFactory(), +) { + const coordinator: QueryInvalidationCoordinator = { + async invalidate(topics) { + for (const topic of topics) { + await client.invalidateQueries({ + queryKey: [topic], + exact: false, + refetchType: "active", + }); + } + }, + beginMutation() { + return { release: async () => {} }; + }, + async resetLocal() { + await client.cancelQueries(); + client.clear(); + }, + dispose() {}, + }; + return function QueryWrapper({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ); + }; +} diff --git a/tests/component/application-query-scope-fence.test.tsx b/tests/component/application-query-scope-fence.test.tsx new file mode 100644 index 0000000..6da1a06 --- /dev/null +++ b/tests/component/application-query-scope-fence.test.tsx @@ -0,0 +1,209 @@ +// @vitest-environment jsdom + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + type ApplicationResult, + useApplicationQuery, +} from "../../src/presentation/adapters/query/application-query.ts"; +import { + createQueryInvalidationPrefix, + defineQueryNamespaceIdentity, +} from "../../src/contracts/query-keys.ts"; +import { + bindQuery, + type QueryResultMeasure, +} from "../../src/contracts/server-state.ts"; +import { + measureOne, + queryClient, + scopeSnapshot, + wrapper, +} from "./application-query-fixture.tsx"; + +describe("scope-bound query commit fence", () => { + it("binds the namespace-first V2 query key", () => { + const scope = scopeSnapshot(); + const definition = { + definitionId: "resource-detail-v1", + definitionVersion: 1, + owner: "platform-test", + namespace: "resource", + namespaceVersion: 1, + operationId: "GET_RESOURCE", + profileId: "DETAIL_STANDARD" as const, + measureResult: measureOne, + execute: async () => ({ ok: true as const, value: "value" }), + }; + + const bound = bindQuery(definition, "resource-1", scope); + + expect(bound.queryKey).toEqual([ + "query", + 2, + "resource", + 1, + "scope-fingerprint-0001", + 1, + "scope-identity-token-0001", + ]); + expect(bound.queryKey.slice(0, 4)).toEqual( + createQueryInvalidationPrefix( + defineQueryNamespaceIdentity("resource", 1), + ), + ); + }); + + it("discards a successful result whose scope was fenced during execution", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + let complete: (value: ApplicationResult) => void = () => {}; + const hook = renderHook( + () => + useApplicationQuery( + bindQuery( + { + definitionId: "fenced-detail-v1", + definitionVersion: 1, + owner: "platform-test", + namespace: "fenced", + namespaceVersion: 1, + operationId: "GET_FENCED", + profileId: "DETAIL_STANDARD", + measureResult: measureOne, + execute: () => + new Promise>((resolve) => { + complete = resolve; + }), + }, + "input", + scope, + ), + ), + { wrapper: wrapper(client) }, + ); + + // §10.8: the scope goes stale after dispatch but before commit. + scope.fence(); + await act(async () => { + complete({ ok: true, value: "late" }); + }); + + await waitFor(() => + expect(hook.result.current.state.failure?.kind).toBe( + "SCOPE_GENERATION_CHANGED", + ), + ); + expect(hook.result.current.data).toBeUndefined(); + }); + + it("refuses to start when the captured scope is already stale", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const execute = vi.fn(async () => ({ ok: true as const, value: "v" })); + scope.fence(); + + const hook = renderHook( + () => + useApplicationQuery( + bindQuery( + { + definitionId: "stale-detail-v1", + definitionVersion: 1, + owner: "platform-test", + namespace: "stale", + namespaceVersion: 1, + operationId: "GET_STALE", + profileId: "DETAIL_STANDARD", + measureResult: measureOne, + execute, + }, + "input", + scope, + ), + ), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => + expect(hook.result.current.state.failure?.kind).toBe( + "SCOPE_GENERATION_CHANGED", + ), + ); + expect(execute).not.toHaveBeenCalled(); + }); + + it("rejects a result that exceeds the profile budget instead of caching it", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const hook = renderHook( + () => + useApplicationQuery( + bindQuery( + { + definitionId: "oversized-list-v1", + definitionVersion: 1, + owner: "platform-test", + namespace: "oversized", + namespaceVersion: 1, + operationId: "LIST_OVERSIZED", + profileId: "VOLATILE_STATUS", + // §10.4: VOLATILE_STATUS admits 1 item and 64KiB. + measureResult: (): QueryResultMeasure => ({ + itemCount: 2, + estimatedBytes: 8, + }), + execute: async () => ({ ok: true as const, value: "value" }), + }, + "input", + scope, + ), + ), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => + expect(hook.result.current.state.failure?.kind).toBe( + "RESULT_LIMIT_EXCEEDED", + ), + ); + expect(hook.result.current.data).toBeUndefined(); + }); + + it("treats a throwing measurement as a measurement failure, not a cache commit", async () => { + const client = queryClient(); + const scope = scopeSnapshot(); + const hook = renderHook( + () => + useApplicationQuery( + bindQuery( + { + definitionId: "unmeasurable-v1", + definitionVersion: 1, + owner: "platform-test", + namespace: "unmeasurable", + namespaceVersion: 1, + operationId: "GET_UNMEASURABLE", + profileId: "DETAIL_STANDARD", + measureResult: (): QueryResultMeasure => { + throw new Error("estimator defect"); + }, + execute: async () => ({ ok: true as const, value: "value" }), + }, + "input", + scope, + ), + ), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => + expect(hook.result.current.state.failure?.kind).toBe( + "RESULT_LIMIT_EXCEEDED", + ), + ); + expect(hook.result.current.data).toBeUndefined(); + }); +}); + diff --git a/tests/component/application-query.test.tsx b/tests/component/application-query.test.tsx index a82911e..e32e8ed 100644 --- a/tests/component/application-query.test.tsx +++ b/tests/component/application-query.test.tsx @@ -1,557 +1,23 @@ // @vitest-environment jsdom -import { - act, - renderHook, - waitFor, -} from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { renderToString } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; import { type ApplicationResult, useApplicationMutation, - useApplicationQuery, } from "../../src/presentation/adapters/query/application-query.ts"; -import type { MutationIntentFactory } from "../../src/application/ports/mutation-intent-factory.ts"; -import { MutationIntentProvider } from "../../src/presentation/adapters/query/mutation-intent-provider.tsx"; -import { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx"; -import { - defineQueryInvalidationTopic, - type QueryInvalidationCoordinator, -} from "../../src/contracts/query-invalidation.ts"; import { createFailure } from "../../src/contracts/errors.ts"; +import { MUTATION_COORDINATOR_BOUNDS } from "../../src/contracts/server-state.ts"; import { - createQueryInvalidationPrefix, - createRuntimeIdentityRegistry, - defineQueryNamespaceIdentity, -} from "../../src/contracts/query-keys.ts"; -import { - bindQuery, - MUTATION_COORDINATOR_BOUNDS, - type QueryResultMeasure, -} from "../../src/contracts/server-state.ts"; -import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts"; + RESOURCE_INVALIDATION_TOPIC, + queryClient, + scopeSnapshot, + wrapper, +} from "./application-query-fixture.tsx"; -const RESOURCE_INVALIDATION_TOPIC = - defineQueryInvalidationTopic("resource"); - -/** - * §24.12: the scope-bound commit fence is common runtime, so it is verified - * here with a local scope fixture rather than through the removable sample - * feature. - */ -function scopeSnapshot( - generation = 1, - fingerprint = "scope-fingerprint-0001", -): CacheScopeSnapshot & { fence(): void } { - let current = true; - const lifetime = new AbortController(); - const identities = createRuntimeIdentityRegistry({ - tokenFactory: () => "scope-identity-token-0001", - }); - return { - generation, - fingerprint, - identities, - signal: lifetime.signal, - isCurrent: () => current, - fence() { - current = false; - lifetime.abort(); - }, - }; -} - -function measureOne(): QueryResultMeasure { - return { itemCount: 1, estimatedBytes: 8 }; -} - -function queryClient() { - return new QueryClient({ - defaultOptions: { - queries: { retry: false, staleTime: 0, gcTime: Infinity }, - mutations: { retry: false }, - }, - }); -} - -function deterministicMutationIntentFactory(): MutationIntentFactory { - let sequence = 0; - return Object.freeze({ - create(input) { - sequence += 1; - return Object.freeze({ - intentId: `intent-${sequence}`, - operationId: input.operationId, - canonicalInputIdentity: input.canonicalInputIdentity, - ...(input.requiresIdempotencyKey - ? { idempotencyKey: `key-${sequence}` } - : {}), - createdAtMonotonicMs: sequence, - }); - }, - }); -} - -function wrapper( - client: QueryClient, - mutationIntentFactory = deterministicMutationIntentFactory(), -) { - const coordinator: QueryInvalidationCoordinator = { - async invalidate(topics) { - for (const topic of topics) { - await client.invalidateQueries({ - queryKey: [topic], - exact: false, - refetchType: "active", - }); - } - }, - beginMutation() { - return { release: async () => {} }; - }, - async resetLocal() { - await client.cancelQueries(); - client.clear(); - }, - dispose() {}, - }; - return function QueryWrapper({ children }: { children: ReactNode }) { - return ( - - - - {children} - - - - ); - }; -} - -describe("application query inbound bridge", () => { - it("latches a background failure over stale data and clears it on retry success", async () => { - const client = queryClient(); - const responses: ApplicationResult[] = [ - { ok: true, value: ["first"] }, - { - ok: false, - error: createFailure("SERVER_FAILURE", "LIST", 0), - }, - { ok: true, value: ["recovered"] }, - ]; - const execute = vi.fn(async () => responses.shift() ?? responses[0]); - const hook = renderHook( - () => - useApplicationQuery({ - queryKey: ["resource", "list"], - execute, - }), - { wrapper: wrapper(client) }, - ); - - await waitFor(() => expect(hook.result.current.data).toEqual(["first"])); - await act(() => hook.result.current.retry()); - await waitFor(() => - expect(hook.result.current.state.indicator).toBe("stale-degraded"), - ); - expect(hook.result.current.state.base).toBe("success"); - expect(hook.result.current.data).toEqual(["first"]); - - await act(() => hook.result.current.retry()); - await waitFor(() => - expect(hook.result.current.data).toEqual(["recovered"]), - ); - expect(hook.result.current.state.indicator).toBeNull(); - expect(execute).toHaveBeenCalledTimes(3); - }); - - it("projects an initial application failure into terminal state", async () => { - const client = queryClient(); - const failure = createFailure("FORBIDDEN", "LIST", 0); - const hook = renderHook( - () => - useApplicationQuery({ - queryKey: ["forbidden"], - execute: async () => ({ ok: false, error: failure }), - }), - { wrapper: wrapper(client) }, - ); - - await waitFor(() => - expect(hook.result.current.state.base).toBe("terminal-error"), - ); - expect(hook.result.current.state.failure).toBe(failure); - }); - - it("normalizes an unexpected execute rejection into a safe terminal failure", async () => { - const client = queryClient(); - const hook = renderHook( - () => - useApplicationQuery({ - queryKey: ["unexpected-rejection"], - execute: async () => { - throw new Error("private upstream detail"); - }, - }), - { wrapper: wrapper(client) }, - ); - - await waitFor(() => - expect(hook.result.current.state.base).toBe("terminal-error"), - ); - expect(hook.result.current.state.failure).toMatchObject({ - kind: "UNKNOWN_FAILURE", - operationId: "APPLICATION_QUERY", - userMessageKey: "error.unknown_failure", - action: "contact-support", - }); - expect(JSON.stringify(hook.result.current.state.failure)).not.toContain( - "private upstream detail", - ); - }); - - it("passes cancellation to the application and does not retain an unmounted error", async () => { - const client = queryClient(); - let aborted = false; - const execute = vi.fn( - ({ signal }: { signal: AbortSignal }) => - new Promise>((resolve) => { - signal.addEventListener( - "abort", - () => { - aborted = true; - resolve({ - ok: false, - error: createFailure("REQUEST_ABORTED", "LIST", 0), - }); - }, - { once: true }, - ); - }), - ); - const hook = renderHook( - () => - useApplicationQuery({ - queryKey: ["cancelled"], - execute, - }), - { wrapper: wrapper(client) }, - ); - - await waitFor(() => expect(execute).toHaveBeenCalledOnce()); - hook.unmount(); - await waitFor(() => expect(aborted).toBe(true)); - expect(client.getQueryState(["cancelled"])?.status).not.toBe("error"); - }); -}); - -describe("scope-bound query commit fence", () => { - it("binds the namespace-first V2 query key", () => { - const scope = scopeSnapshot(); - const definition = { - definitionId: "resource-detail-v1", - definitionVersion: 1, - owner: "platform-test", - namespace: "resource", - namespaceVersion: 1, - operationId: "GET_RESOURCE", - profileId: "DETAIL_STANDARD" as const, - measureResult: measureOne, - execute: async () => ({ ok: true as const, value: "value" }), - }; - - const bound = bindQuery(definition, "resource-1", scope); - - expect(bound.queryKey).toEqual([ - "query", - 2, - "resource", - 1, - "scope-fingerprint-0001", - 1, - "scope-identity-token-0001", - ]); - expect(bound.queryKey.slice(0, 4)).toEqual( - createQueryInvalidationPrefix( - defineQueryNamespaceIdentity("resource", 1), - ), - ); - }); - - it("discards a successful result whose scope was fenced during execution", async () => { - const client = queryClient(); - const scope = scopeSnapshot(); - let complete: (value: ApplicationResult) => void = () => {}; - const hook = renderHook( - () => - useApplicationQuery( - bindQuery( - { - definitionId: "fenced-detail-v1", - definitionVersion: 1, - owner: "platform-test", - namespace: "fenced", - namespaceVersion: 1, - operationId: "GET_FENCED", - profileId: "DETAIL_STANDARD", - measureResult: measureOne, - execute: () => - new Promise>((resolve) => { - complete = resolve; - }), - }, - "input", - scope, - ), - ), - { wrapper: wrapper(client) }, - ); - - // §10.8: the scope goes stale after dispatch but before commit. - scope.fence(); - await act(async () => { - complete({ ok: true, value: "late" }); - }); - - await waitFor(() => - expect(hook.result.current.state.failure?.kind).toBe( - "SCOPE_GENERATION_CHANGED", - ), - ); - expect(hook.result.current.data).toBeUndefined(); - }); - - it("refuses to start when the captured scope is already stale", async () => { - const client = queryClient(); - const scope = scopeSnapshot(); - const execute = vi.fn(async () => ({ ok: true as const, value: "v" })); - scope.fence(); - - const hook = renderHook( - () => - useApplicationQuery( - bindQuery( - { - definitionId: "stale-detail-v1", - definitionVersion: 1, - owner: "platform-test", - namespace: "stale", - namespaceVersion: 1, - operationId: "GET_STALE", - profileId: "DETAIL_STANDARD", - measureResult: measureOne, - execute, - }, - "input", - scope, - ), - ), - { wrapper: wrapper(client) }, - ); - - await waitFor(() => - expect(hook.result.current.state.failure?.kind).toBe( - "SCOPE_GENERATION_CHANGED", - ), - ); - expect(execute).not.toHaveBeenCalled(); - }); - - it("rejects a result that exceeds the profile budget instead of caching it", async () => { - const client = queryClient(); - const scope = scopeSnapshot(); - const hook = renderHook( - () => - useApplicationQuery( - bindQuery( - { - definitionId: "oversized-list-v1", - definitionVersion: 1, - owner: "platform-test", - namespace: "oversized", - namespaceVersion: 1, - operationId: "LIST_OVERSIZED", - profileId: "VOLATILE_STATUS", - // §10.4: VOLATILE_STATUS admits 1 item and 64KiB. - measureResult: (): QueryResultMeasure => ({ - itemCount: 2, - estimatedBytes: 8, - }), - execute: async () => ({ ok: true as const, value: "value" }), - }, - "input", - scope, - ), - ), - { wrapper: wrapper(client) }, - ); - - await waitFor(() => - expect(hook.result.current.state.failure?.kind).toBe( - "RESULT_LIMIT_EXCEEDED", - ), - ); - expect(hook.result.current.data).toBeUndefined(); - }); - - it("treats a throwing measurement as a measurement failure, not a cache commit", async () => { - const client = queryClient(); - const scope = scopeSnapshot(); - const hook = renderHook( - () => - useApplicationQuery( - bindQuery( - { - definitionId: "unmeasurable-v1", - definitionVersion: 1, - owner: "platform-test", - namespace: "unmeasurable", - namespaceVersion: 1, - operationId: "GET_UNMEASURABLE", - profileId: "DETAIL_STANDARD", - measureResult: (): QueryResultMeasure => { - throw new Error("estimator defect"); - }, - execute: async () => ({ ok: true as const, value: "value" }), - }, - "input", - scope, - ), - ), - { wrapper: wrapper(client) }, - ); - - await waitFor(() => - expect(hook.result.current.state.failure?.kind).toBe( - "RESULT_LIMIT_EXCEEDED", - ), - ); - expect(hook.result.current.data).toBeUndefined(); - }); -}); - -describe("scope-bound mutation fence", () => { - it("rejects a submit whose scope is already fenced", async () => { - const client = queryClient(); - const scope = scopeSnapshot(); - const execute = vi.fn(async () => ({ ok: true as const, value: "v" })); - const hook = renderHook( - () => - useApplicationMutation({ - definitionId: "fenced-mutation-v1", - definitionVersion: 1, - operationId: "CREATE_FENCED", - requiresIdempotencyKey: false, - owner: "platform-test", - duplicatePolicy: "REJECT_WHILE_ACTIVE", - scope, - execute, - invalidate: [], - }), - { wrapper: wrapper(client) }, - ); - - scope.fence(); - const outcome = await hook.result.current.submit("value"); - expect(outcome).toMatchObject({ - ok: false, - error: { - kind: "SCOPE_GENERATION_CHANGED", - effect: "NOT_STARTED", - }, - }); - expect(execute).not.toHaveBeenCalled(); - }); - - it("discards a mutation result whose scope was fenced after dispatch", async () => { - const client = queryClient(); - const scope = scopeSnapshot(); - const execute = vi.fn(async () => { - scope.fence(); - return { ok: true as const, value: "committed" }; - }); - const hook = renderHook( - () => - useApplicationMutation({ - definitionId: "late-mutation-v1", - definitionVersion: 1, - operationId: "CREATE_LATE", - requiresIdempotencyKey: false, - owner: "platform-test", - duplicatePolicy: "ALLOW_PARALLEL", - scope, - execute, - invalidate: [], - }), - { wrapper: wrapper(client) }, - ); - - const outcome = await hook.result.current.submit("value"); - expect(outcome).toMatchObject({ - ok: false, - error: { kind: "SCOPE_GENERATION_CHANGED" }, - }); - expect(execute).toHaveBeenCalledOnce(); - }); - - it("aborts a hung mutation when its captured scope is fenced", async () => { - const client = queryClient(); - const scope = scopeSnapshot(); - let observedSignal: AbortSignal | undefined; - const execute = vi.fn( - (_input: string, context: Readonly<{ signal: AbortSignal }>) => - new Promise>((resolve) => { - observedSignal = context.signal; - context.signal.addEventListener( - "abort", - () => - resolve({ - ok: false, - error: createFailure("REQUEST_ABORTED", "CREATE_HUNG", 0), - }), - { once: true }, - ); - }), - ); - const hook = renderHook( - () => - useApplicationMutation({ - definitionId: "hung-mutation-v1", - definitionVersion: 1, - operationId: "CREATE_HUNG", - requiresIdempotencyKey: false, - owner: "platform-test", - duplicatePolicy: "REJECT_WHILE_ACTIVE", - scope, - execute, - invalidate: [], - }), - { wrapper: wrapper(client) }, - ); - - const outcome = hook.result.current.submit("value"); - await waitFor(() => expect(observedSignal).toBe(scope.signal)); - - scope.fence(); - - expect(observedSignal?.aborted).toBe(true); - await expect(outcome).resolves.toMatchObject({ - ok: false, - error: { - kind: "SCOPE_GENERATION_CHANGED", - effect: "MAYBE_APPLIED", - retryable: false, - action: "contact-support", - }, - }); - }); -}); - -describe("application mutation inbound bridge", () => { +describe("application mutation reconciliation bridge", () => { it.each([ ["NOT_STARTED", ["existing"], 0, null], ["NOT_APPLIED", ["existing"], 0, null], @@ -1700,497 +1166,4 @@ describe("application mutation inbound bridge", () => { }, ); - it("retains optimistic data when execute throws after dispatch begins", async () => { - const client = queryClient(); - const key = ["resource", "thrown-unknown-effect"]; - client.setQueryData(key, ["base"]); - const hook = renderHook( - () => - useApplicationMutation({ - execute: async () => { - throw new Error("private transport defect"); - }, - currentData: client.getQueryData(key), - optimistic: { - queryKey: key, - update: (previous, input) => [ - ...(previous as string[]), - input, - ], - }, - }), - { wrapper: wrapper(client) }, - ); - - let outcome: ApplicationResult | undefined; - await act(async () => { - outcome = await hook.result.current.submit("created"); - }); - - expect(outcome).toMatchObject({ - ok: false, - error: { - effect: "MAYBE_APPLIED", - retryable: false, - action: "contact-support", - }, - }); - expect(JSON.stringify(outcome)).not.toContain("private transport defect"); - expect(client.getQueryData(key)).toEqual(["base", "created"]); - expect(hook.result.current.state.indicator).toBe( - "mutation-effect-unknown", - ); - }); - - it("creates a distinct logical intent for each independently admitted submit", async () => { - const client = queryClient(); - const scope = scopeSnapshot(); - const observedIntents: unknown[] = []; - const execute = vi.fn( - async ( - input: string, - context: Readonly<{ signal: AbortSignal; intent?: unknown }>, - ) => { - observedIntents.push(context.intent); - return { ok: true as const, value: input }; - }, - ); - const hook = renderHook( - () => - useApplicationMutation({ - definitionId: "independent-intent-v1", - definitionVersion: 1, - operationId: "CREATE_WITH_INTENT", - requiresIdempotencyKey: true, - owner: "platform-test", - duplicatePolicy: "ALLOW_PARALLEL", - scope, - execute, - invalidate: [], - }), - { wrapper: wrapper(client) }, - ); - - await act(async () => { - await hook.result.current.submit("same-input"); - await hook.result.current.submit("same-input"); - }); - - expect(observedIntents).toHaveLength(2); - expect(observedIntents[0]).toMatchObject({ - intentId: "intent-1", - operationId: "CREATE_WITH_INTENT", - idempotencyKey: "key-1", - }); - expect(observedIntents[1]).toMatchObject({ - intentId: "intent-2", - operationId: "CREATE_WITH_INTENT", - idempotencyKey: "key-2", - }); - expect(observedIntents[0]).not.toEqual(observedIntents[1]); - }); - - it("creates no second intent when JOIN_IDENTICAL shares an admitted submit", async () => { - const client = queryClient(); - const scope = scopeSnapshot(); - const deterministicFactory = deterministicMutationIntentFactory(); - const createIntent = vi.fn(deterministicFactory.create); - const factory: MutationIntentFactory = Object.freeze({ - create: createIntent, - }); - let complete: (value: ApplicationResult) => void = () => {}; - const execute = vi.fn( - () => - new Promise>((resolve) => { - complete = resolve; - }), - ); - const hook = renderHook( - () => - useApplicationMutation({ - definitionId: "joined-intent-v1", - definitionVersion: 1, - operationId: "CREATE_JOINED", - requiresIdempotencyKey: true, - owner: "platform-test", - duplicatePolicy: "JOIN_IDENTICAL", - scope, - execute, - invalidate: [], - }), - { wrapper: wrapper(client, factory) }, - ); - - let first: Promise> | null = null; - let joined: Promise> | null = null; - act(() => { - first = hook.result.current.submit("same-input"); - joined = hook.result.current.submit("same-input"); - }); - - expect(first).toBe(joined); - await waitFor(() => expect(execute).toHaveBeenCalledOnce()); - expect(createIntent).toHaveBeenCalledOnce(); - expect(createIntent).toHaveBeenCalledWith({ - operationId: "CREATE_JOINED", - canonicalInputIdentity: - "scope-fingerprint-0001:joined-intent-v1:scope-identity-token-0001", - requiresIdempotencyKey: true, - }); - - complete({ ok: true, value: "same-input" }); - if (!first) throw new Error("expected admitted mutation"); - await act(() => first); - }); - - it("keeps the legacy raw mutation path outside the intent factory", async () => { - const client = queryClient(); - const createIntent = vi.fn(); - const factory: MutationIntentFactory = Object.freeze({ - create: createIntent, - }); - const execute = vi.fn(async (input: string) => ({ - ok: true as const, - value: input, - })); - const hook = renderHook( - () => - useApplicationMutation({ - definitionId: "legacy-raw-path-v1", - execute, - }), - { wrapper: wrapper(client, factory) }, - ); - - await act(() => hook.result.current.submit("legacy-input")); - - expect(execute).toHaveBeenCalledOnce(); - expect(createIntent).not.toHaveBeenCalled(); - }); - - it("rejects a duplicate submit by default while one is active", async () => { - const client = queryClient(); - let complete: (value: ApplicationResult) => void = () => {}; - const execute = vi.fn( - () => - new Promise>((resolve) => { - complete = resolve; - }), - ); - const hook = renderHook( - () => - useApplicationMutation({ - definitionId: "legacy-duplicate-rejection-v1", - execute, - }), - { wrapper: wrapper(client) }, - ); - - let first: Promise> | null = null; - let duplicate: Promise> | null = null; - act(() => { - first = hook.result.current.submit("created"); - duplicate = hook.result.current.submit("created"); - }); - - if (!first || !duplicate) throw new Error("expected two submissions"); - await expect(duplicate).resolves.toMatchObject({ - ok: false, - error: { kind: "DUPLICATE_IN_FLIGHT" }, - }); - expect(execute).toHaveBeenCalledOnce(); - - complete({ ok: true, value: "created" }); - await act(() => first as Promise>); - }); - - it("deduplicates submit and commits one optimistic mutation", async () => { - const client = queryClient(); - const key = ["resource", "list"]; - client.setQueryData(key, ["existing"]); - let complete: (value: ApplicationResult) => void = () => {}; - const execute = vi.fn( - () => - new Promise>((resolve) => { - complete = resolve; - }), - ); - const hook = renderHook( - () => - useApplicationMutation({ - // §11.2: joining is opt-in. The default is REJECT_WHILE_ACTIVE. - duplicatePolicy: "JOIN_IDENTICAL", - execute, - invalidate: [RESOURCE_INVALIDATION_TOPIC], - currentData: client.getQueryData(key), - optimistic: { - queryKey: key, - update: (previous, input) => [ - ...(previous as string[]), - input, - ], - }, - }), - { wrapper: wrapper(client) }, - ); - - let first: Promise> | null = null; - let duplicate: Promise> | null = null; - act(() => { - first = hook.result.current.submit("created"); - duplicate = hook.result.current.submit("created"); - }); - expect(first).toBe(duplicate); - await waitFor(() => - expect(client.getQueryData(key)).toEqual(["existing", "created"]), - ); - await waitFor(() => expect(execute).toHaveBeenCalledOnce()); - await waitFor(() => - expect(hook.result.current.state.indicator).toBe("mutation-pending"), - ); - - complete({ ok: true, value: "created" }); - if (!first) throw new Error("expected pending mutation"); - await act(() => first); - expect(client.getQueryState(key)?.isInvalidated).toBe(true); - await waitFor(() => - expect(hook.result.current.state.indicator).toBeNull(), - ); - }); - - it("never joins distinct mutation inputs to the same runtime promise", async () => { - const client = queryClient(); - const resolvers = new Map< - string, - (value: ApplicationResult) => void - >(); - const execute = vi.fn( - (input: string) => - new Promise>((resolve) => { - resolvers.set(input, resolve); - }), - ); - const hook = renderHook( - () => - useApplicationMutation({ - definitionId: "legacy-parallel-distinct-inputs-v1", - execute, - currentData: true, - }), - { wrapper: wrapper(client) }, - ); - - let first: Promise> | undefined; - let second: Promise> | undefined; - act(() => { - first = hook.result.current.submit("first"); - second = hook.result.current.submit("second"); - }); - expect(first).not.toBe(second); - await waitFor(() => expect(execute).toHaveBeenCalledTimes(2)); - - resolvers.get("first")?.({ ok: true, value: "first" }); - resolvers.get("second")?.({ ok: true, value: "second" }); - if (!first || !second) throw new Error("expected pending mutations"); - await act(async () => { - await Promise.all([first, second]); - }); - }); - - it("cancels an in-flight query before taking the optimistic snapshot", async () => { - const client = queryClient(); - const key = ["resource", "ordered-update"]; - client.setQueryData(key, ["existing"]); - let finishCancellation: () => void = () => {}; - const cancellation = new Promise((resolve) => { - finishCancellation = resolve; - }); - const cancelQueries = vi - .spyOn(client, "cancelQueries") - .mockImplementation(async () => cancellation); - const getQueryData = vi.spyOn(client, "getQueryData"); - const update = vi.fn((previous, input) => [ - ...(previous as string[]), - input, - ]); - const execute = vi.fn(async () => ({ - ok: true as const, - value: "created", - })); - const hook = renderHook( - () => - useApplicationMutation({ - execute, - currentData: ["existing"], - optimistic: { queryKey: key, update }, - }), - { wrapper: wrapper(client) }, - ); - - let pending: Promise> | undefined; - act(() => { - pending = hook.result.current.submit("created"); - }); - expect(cancelQueries).toHaveBeenCalledWith({ - queryKey: key, - exact: true, - }); - expect(getQueryData).not.toHaveBeenCalled(); - expect(update).not.toHaveBeenCalled(); - expect(execute).not.toHaveBeenCalled(); - - finishCancellation(); - if (!pending) throw new Error("expected pending mutation"); - await act(() => pending); - - expect(getQueryData).toHaveBeenCalledWith(key); - expect(update).toHaveBeenCalledWith(["existing"], "created"); - expect(execute).toHaveBeenCalledOnce(); - expect(client.getQueryData(key)).toEqual(["existing", "created"]); - }); - - it("keeps a committed optimistic update when invalidation fails", async () => { - const client = queryClient(); - const key = ["resource", "committed-update"]; - client.setQueryData(key, ["existing"]); - vi.spyOn(client, "invalidateQueries").mockRejectedValue( - new Error("cache refresh failed"), - ); - const hook = renderHook( - () => - useApplicationMutation({ - execute: async () => ({ ok: true, value: "created" }), - invalidate: [RESOURCE_INVALIDATION_TOPIC], - currentData: ["existing"], - optimistic: { - queryKey: key, - update: (previous, input) => [ - ...(previous as string[]), - input, - ], - }, - }), - { wrapper: wrapper(client) }, - ); - - let outcome; - await act(async () => { - outcome = await hook.result.current.submit("created"); - }); - - expect(outcome).toEqual({ ok: true, value: "created" }); - expect(client.getQueryData(key)).toEqual(["existing", "created"]); - }); - - it("normalizes an optimistic preparation defect without running the command", async () => { - const client = queryClient(); - const key = ["resource", "invalid-optimistic-update"]; - client.setQueryData(key, ["existing"]); - const execute = vi.fn(async () => ({ - ok: true as const, - value: "created", - })); - const hook = renderHook( - () => - useApplicationMutation({ - execute, - currentData: ["existing"], - optimistic: { - queryKey: key, - update: () => { - throw new Error("private optimistic detail"); - }, - }, - }), - { wrapper: wrapper(client) }, - ); - - let outcome; - await act(async () => { - outcome = await hook.result.current.submit("created"); - }); - - expect(outcome).toMatchObject({ - ok: false, - error: { - kind: "UNKNOWN_FAILURE", - effect: "NOT_STARTED", - operationId: "APPLICATION_MUTATION", - userMessageKey: "error.unknown_failure", - }, - }); - expect(JSON.stringify(outcome)).not.toContain("private optimistic detail"); - expect(execute).not.toHaveBeenCalled(); - expect(client.getQueryData(key)).toEqual(["existing"]); - }); - - it("removes an optimistic cache entry when no prior data existed", async () => { - const client = queryClient(); - const key = ["resource", "new-optimistic-entry"]; - const failure = createFailure("SERVER_FAILURE", "CREATE", 0, { - effect: "NOT_APPLIED", - }); - const hook = renderHook( - () => - useApplicationMutation({ - execute: async () => ({ ok: false, error: failure }), - currentData: true, - optimistic: { - queryKey: key, - update: (_previous, input) => [input], - }, - }), - { wrapper: wrapper(client) }, - ); - - let outcome; - await act(async () => { - outcome = await hook.result.current.submit("temporary"); - }); - - expect(outcome).toEqual({ ok: false, error: failure }); - expect(client.getQueryData(key)).toBeUndefined(); - expect(client.getQueryState(key)).toBeUndefined(); - }); - - it("rolls optimistic data back and exposes a resolvable conflict", async () => { - const client = queryClient(); - const key = ["resource", "list"]; - client.setQueryData(key, ["existing"]); - const conflict = createFailure("CONFLICT", "CREATE", 0, { - effect: "NOT_APPLIED", - }); - const hook = renderHook( - () => - useApplicationMutation({ - execute: async () => ({ ok: false, error: conflict }), - invalidate: [RESOURCE_INVALIDATION_TOPIC], - currentData: client.getQueryData(key), - optimistic: { - queryKey: key, - update: (previous, input) => [ - ...(previous as string[]), - input, - ], - }, - }), - { wrapper: wrapper(client) }, - ); - - let outcome; - await act(async () => { - outcome = await hook.result.current.submit("conflicting"); - }); - expect(outcome).toEqual({ ok: false, error: conflict }); - expect(client.getQueryData(key)).toEqual(["existing"]); - expect(hook.result.current.state.indicator).toBe("mutation-conflict"); - expect(hook.result.current.state.overlay).toMatchObject({ - mutationPending: false, - mutationConflict: true, - }); - - await act(() => hook.result.current.resolveConflict()); - expect(hook.result.current.state.indicator).toBeNull(); - expect(client.getQueryState(key)?.isInvalidated).toBe(true); - }); }); diff --git a/tests/contract/consumer-experience/fixtures/local-draft-feature.ts b/tests/contract/consumer-experience/fixtures/local-draft-feature.ts new file mode 100644 index 0000000..5a78fed --- /dev/null +++ b/tests/contract/consumer-experience/fixtures/local-draft-feature.ts @@ -0,0 +1,97 @@ +import type { + BrowserDataResult, + IndexedDbRepositoryPort, +} from "../../../../src/application/ports/browser-file-storage/index.ts"; + +export type LocalDraft = Readonly<{ + draftId: string; + title: string; + body: string; +}>; + +export type SaveLocalDraftCommand = Readonly<{ + draft: LocalDraft; + expectedRevision: number | null; + idempotencyKey: string; +}>; + +export type RemoveLocalDraftCommand = Readonly<{ + draftId: string; + expectedRevision: number; + idempotencyKey: string; +}>; + +export type LocalDraftRecord = Readonly<{ + draft: LocalDraft; + revision: number; +}>; + +export interface LocalDraftStore { + save( + command: SaveLocalDraftCommand, + signal?: AbortSignal, + ): Promise>>; + find( + draftId: string, + signal?: AbortSignal, + ): Promise>; + remove( + command: RemoveLocalDraftCommand, + signal?: AbortSignal, + ): Promise>; +} + +/** + * Feature-owned binding over the technology-neutral IndexedDB application port. + * + * The feature knows its domain type and optimistic concurrency inputs. It does + * not know database names, stores, transactions, native IDB objects, codecs, + * migrations, quota handling or connection lifecycle. + */ +export function createLocalDraftStore( + repository: IndexedDbRepositoryPort, +): LocalDraftStore { + const store: LocalDraftStore = { + async save(command, signal) { + const result = await repository.compareAndSwap({ + key: command.draft.draftId, + value: command.draft, + expectedRevision: command.expectedRevision, + idempotencyKey: command.idempotencyKey, + signal, + }); + if (!result.ok) return result; + return Object.freeze({ + ok: true as const, + value: Object.freeze({ revision: result.value.revision }), + }); + }, + + async find(draftId, signal) { + const result = await repository.read(draftId, signal); + if (!result.ok) return result; + return Object.freeze({ + ok: true as const, + value: + result.value === null + ? null + : Object.freeze({ + draft: result.value.value, + revision: result.value.revision, + }), + }); + }, + + async remove(command, signal) { + const result = await repository.remove({ + key: command.draftId, + expectedRevision: command.expectedRevision, + idempotencyKey: command.idempotencyKey, + signal, + }); + if (!result.ok) return result; + return Object.freeze({ ok: true as const, value: undefined }); + }, + }; + return Object.freeze(store); +} diff --git a/tests/contract/consumer-experience/indexeddb-local-draft.test.ts b/tests/contract/consumer-experience/indexeddb-local-draft.test.ts new file mode 100644 index 0000000..f25dfae --- /dev/null +++ b/tests/contract/consumer-experience/indexeddb-local-draft.test.ts @@ -0,0 +1,161 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import type { + BrowserDataResult, + IndexedDbRepositoryPort, +} from "../../../src/application/ports/browser-file-storage/index.ts"; +import { + createLocalDraftStore, + type LocalDraft, +} from "./fixtures/local-draft-feature.ts"; + +function success(value: Value): BrowserDataResult { + return Object.freeze({ ok: true as const, value }); +} + +function repositoryFixture(): IndexedDbRepositoryPort { + let stored: Readonly<{ value: LocalDraft; revision: number }> | null = null; + + const repository: IndexedDbRepositoryPort = { + async open() { + return success(undefined); + }, + async read(key) { + if (stored === null || stored.value.draftId !== key) { + return success(null); + } + return success(stored); + }, + async query() { + return success(Object.freeze({ items: [], nextCursor: null })); + }, + async compareAndSwap(input) { + const currentRevision = stored?.revision ?? null; + if (currentRevision !== input.expectedRevision) { + return Object.freeze({ + ok: false as const, + error: Object.freeze({ + code: "CONFLICT" as const, + operation: "INDEXEDDB_WRITE" as const, + retryable: false, + recovery: "RECONCILE" as const, + }), + }); + } + const revision = (currentRevision ?? 0) + 1; + stored = Object.freeze({ value: input.value, revision }); + return success( + Object.freeze({ + key: input.key, + revision, + replayed: false, + }), + ); + }, + async remove(input) { + if (stored === null || stored.revision !== input.expectedRevision) { + return Object.freeze({ + ok: false as const, + error: Object.freeze({ + code: "CONFLICT" as const, + operation: "INDEXEDDB_WRITE" as const, + retryable: false, + recovery: "RECONCILE" as const, + }), + }); + } + stored = null; + return success( + Object.freeze({ + key: input.key, + revision: input.expectedRevision + 1, + replayed: false, + }), + ); + }, + async enforceLifecycleBatch() { + stored = null; + return success( + Object.freeze({ + state: "COMPLETE" as const, + scannedRows: 0, + deletedRows: 0, + budgetExhausted: false, + }), + ); + }, + getStatus() { + return Object.freeze({ kind: "READY" as const, schemaVersion: 1 }); + }, + subscribeStatus() { + return () => {}; + }, + close() {}, + }; + return Object.freeze(repository); +} + +describe("IndexedDB local-draft consumer experience", () => { + it("implements save/find/remove through the public application port", async () => { + const store = createLocalDraftStore(repositoryFixture()); + const draft = Object.freeze({ + draftId: "draft-1", + title: "Architecture notes", + body: "Feature code owns the draft model.", + }); + + await expect( + store.save({ + draft, + expectedRevision: null, + idempotencyKey: "draft-save-0001", + }), + ).resolves.toEqual({ ok: true, value: { revision: 1 } }); + + await expect(store.find("draft-1")).resolves.toEqual({ + ok: true, + value: { draft, revision: 1 }, + }); + + await expect( + store.remove({ + draftId: "draft-1", + expectedRevision: 1, + idempotencyKey: "draft-remove-0001", + }), + ).resolves.toEqual({ ok: true, value: undefined }); + + await expect(store.find("draft-1")).resolves.toEqual({ + ok: true, + value: null, + }); + }); + + it("keeps native IndexedDB and runtime internals out of feature-owned code", async () => { + const source = await readFile( + new URL("./fixtures/local-draft-feature.ts", import.meta.url), + "utf8", + ); + + const importLines = source + .split("\n") + .filter((line) => line.startsWith("import ")); + expect(importLines).toHaveLength(1); + expect(source).toContain( + "src/application/ports/browser-file-storage/index.ts", + ); + + for (const forbidden of [ + "src/adapters/storage/indexeddb", + "globalThis.indexedDB", + "IDBFactory", + "IDBDatabase", + "IDBTransaction", + "IDBObjectStore", + ]) { + expect(source).not.toContain(forbidden); + } + }); +}); diff --git a/tests/contract/reusable-capability/feature-http-binding.test.ts b/tests/contract/reusable-capability/feature-http-binding.test.ts new file mode 100644 index 0000000..f7ff8d4 --- /dev/null +++ b/tests/contract/reusable-capability/feature-http-binding.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createFeatureHttpBinding, + defineFeatureHttpOperation, + type InstalledHttpOperationExecutor, +} from "../../../src/adapters/http/index.ts"; +import { + mappingFailure, + mappingSuccess, +} from "../../../src/contracts/boundary-mapper.ts"; + +type Resource = Readonly<{ id: string; title: string }>; + +const OPERATIONS = Object.freeze({ + LOAD_RESOURCE: defineFeatureHttpOperation< + Readonly<{ resourceId: string }>, + Resource + >({ + operationId: "LOAD_RESOURCE", + routeId: "RESOURCE_DETAIL", + mapSuccess(value) { + if ( + !value || + typeof value !== "object" || + typeof (value as Record).id !== "string" || + typeof (value as Record).title !== "string" + ) { + return mappingFailure("MAPPING_INVARIANT_REJECTED"); + } + const candidate = value as Readonly<{ id: string; title: string }>; + return mappingSuccess( + Object.freeze({ id: candidate.id, title: candidate.title }), + ); + }, + }), +} as const); + +describe("feature HTTP binding", () => { + it("keeps typed feature input while platform owns route/context execution", async () => { + const execute = vi.fn( + async (_operationId, input, context) => { + expect(input).toEqual({ resourceId: "resource-1" }); + expect(context.routeId).toBe("RESOURCE_DETAIL"); + return Object.freeze({ + kind: "SUCCESS" as const, + value: Object.freeze({ id: "resource-1", title: "Reference" }), + metadata: Object.freeze({ status: 200 }), + effect: "NOT_APPLICABLE" as const, + }); + }, + ); + const binding = createFeatureHttpBinding( + Object.freeze({ execute }), + OPERATIONS, + ); + + const result = await binding.execute("LOAD_RESOURCE", { + resourceId: "resource-1", + }); + + expect(result).toEqual({ + ok: true, + value: { id: "resource-1", title: "Reference" }, + }); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it("normalizes transport failure before it crosses the feature gateway", async () => { + const executor: InstalledHttpOperationExecutor = Object.freeze({ + async execute() { + return Object.freeze({ + kind: "TRANSPORT_FAILURE" as const, + failure: Object.freeze({ + kind: "TIMEOUT" as const, + retryable: true, + }), + effect: "NOT_STARTED" as const, + }); + }, + }); + const binding = createFeatureHttpBinding(executor, OPERATIONS); + + const result = await binding.execute("LOAD_RESOURCE", { + resourceId: "resource-1", + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.kind).toBe("REQUEST_TIMEOUT"); + expect(result.error.operationId).toBe("LOAD_RESOURCE"); + expect(result.error.effect).toBe("NOT_STARTED"); + }); + + it("turns feature mapper rejection into the shared mapping failure", async () => { + const executor: InstalledHttpOperationExecutor = Object.freeze({ + async execute() { + return Object.freeze({ + kind: "SUCCESS" as const, + value: Object.freeze({ unexpected: true }), + metadata: Object.freeze({ status: 200 }), + effect: "NOT_APPLICABLE" as const, + }); + }, + }); + const binding = createFeatureHttpBinding(executor, OPERATIONS); + + const result = await binding.execute("LOAD_RESOURCE", { + resourceId: "resource-1", + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION"); + expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED"); + }); +}); diff --git a/tests/features/reference-feature/reference-diagnostics.test.ts b/tests/features/reference-feature/reference-diagnostics.test.ts index dcbaebb..57f5e8f 100644 --- a/tests/features/reference-feature/reference-diagnostics.test.ts +++ b/tests/features/reference-feature/reference-diagnostics.test.ts @@ -1,61 +1,68 @@ import { describe, expect, it, vi } from "vitest"; -import { createHttpClient } from "../../../src/adapters/http/client.ts"; -import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts"; -import { - createReferenceHttpGateway, -} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts"; -import { createReferenceFeatureInput } from "../../../src/features/reference-feature/application/reference-feature-api.ts"; -import { REFERENCE_FEATURE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts"; -import { mapReferenceOperation } from "../../../src/features/reference-feature/contracts/reference-mapper.ts"; -import { - validateReferencePayload, - validateReferenceRequest, -} from "../../../src/features/reference-feature/contracts/reference-schemas.ts"; +import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts"; +import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.ts"; +import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts"; +import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts"; -describe("reference feature diagnostics correlation", () => { - it("preserves route, operation and request correlation through the vertical path", async () => { +function scopeSnapshot() { + return Object.freeze({ + generation: 1, + fingerprint: "reference-scope", + identities: Object.freeze({}) as never, + signal: new AbortController().signal, + isCurrent: () => true, + }); +} + +describe("reference feature HTTP diagnostics", () => { + it("preserves route and operation identity through the installed V3 path", async () => { const record = vi.fn(); - const operations = - REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly< - Record< - string, - ReturnType< - NonNullable[0]["getOperation"]> - > - > - >; - const client = createHttpClient({ + const contractHttp = createContractHttpExecutor({ baseUrl: "https://api.test", - authSession: createDemoSessionAdapter("authenticated"), - fetcher: async () => - Response.json({ - success: true, - data: [{ id: "reference-1", name: "Reference" }], - meta: { requestId: "safe-request", traceId: "safe-trace" }, - }), - getOperation(operationId) { - const operation = operations[operationId]; - if (!operation) throw new Error("Unregistered reference operation"); - return operation; - }, - validatePayload: validateReferencePayload, - validateRequest: validateReferenceRequest, - mapPayload: mapReferenceOperation, - correlationIdFactory: () => "reference-correlation", - diagnostics: { record }, - scheduler: { - setTimeout: () => 1, - clearTimeout: () => {}, - }, + maxRetryAttempts: 0, + attachCredentials: () => ({ + kind: "READY" as const, + headers: { authorization: "Bearer diagnostics-test-token" }, + }), + fetcher: (async () => + Response.json([ + { id: "reference-1", name: "Reference" }, + ])) as unknown as typeof fetch, + observe: createHttpObservationProjector({ + diagnostics: { record }, + telemetry: { emit: vi.fn() }, + }), }); - const application = createReferenceFeatureInput( - createReferenceHttpGateway(client), + const operations = new Map( + REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.map((operation) => [ + operation.contract.operationId, + operation, + ]), ); + const installed = createReferenceFeatureInstalledInput({ + contractOperations: Object.freeze({ + async execute(operationId, input, context) { + const operation = operations.get(operationId); + if (!operation) throw new Error("Unregistered reference operation"); + return contractHttp.execute(operation, input, { + routeId: context.routeId, + scope: scopeSnapshot(), + ...(context.signal === undefined + ? {} + : { signal: context.signal }), + ...(context.intent === undefined + ? {} + : { intent: context.intent }), + }); + }, + }), + }); await expect( - application.listResources({ limit: 20 }), + installed.input.listResources({ limit: 20 }), ).resolves.toMatchObject({ ok: true }); + expect(record).toHaveBeenCalledOnce(); expect(record).toHaveBeenCalledWith({ level: "info", @@ -63,8 +70,7 @@ describe("reference feature diagnostics correlation", () => { context: expect.objectContaining({ route_id: "REFERENCE_RESOURCE_LIST", operation_id: "LIST_REFERENCE_RESOURCES", - correlation_id: "reference-correlation", - outcome: "success", + outcome: "SUCCESS", }), }); }); diff --git a/tests/features/reference-feature/reference-http-gateway.test.ts b/tests/features/reference-feature/reference-http-gateway.test.ts index 2146b22..77b46f8 100644 --- a/tests/features/reference-feature/reference-http-gateway.test.ts +++ b/tests/features/reference-feature/reference-http-gateway.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -import { createFailure } from "../../../src/contracts/errors.ts"; +import type { Result } from "../../../src/contracts/result.ts"; +import { createFailure, type ApiFailure } from "../../../src/contracts/errors.ts"; import { createReferenceHttpGateway, - type RawReferenceHttpExecutor, + type ReferenceHttpBinding, } from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts"; import type { ReferenceResource } from "../../../src/features/reference-feature/domain/reference-resource.ts"; @@ -20,14 +21,42 @@ const resources = Object.freeze({ }), }) satisfies Readonly>; +type ScriptedResult = Result< + ReferenceResource | readonly ReferenceResource[], + ApiFailure +>; + +function scriptedBinding(results: ScriptedResult[]) { + const calls: Array< + readonly [operationId: string, input: unknown, context: unknown] + > = []; + let index = 0; + const execute = (async ( + operationId: string, + input: unknown, + context?: unknown, + ) => { + calls.push([operationId, input, context]); + const result = results[index]; + index += 1; + if (!result) throw new Error("Missing scripted result"); + return result; + }) as ReferenceHttpBinding["execute"]; + + return { + binding: Object.freeze({ execute }) satisfies ReferenceHttpBinding, + calls, + }; +} + describe("reference HTTP operation gateway", () => { - it("builds the exact registered request for every gateway operation", async () => { - const execute = vi - .fn() - .mockResolvedValueOnce({ ok: true, value: [resources.first] }) - .mockResolvedValueOnce({ ok: true, value: resources.created }) - .mockResolvedValueOnce({ ok: true, value: resources.first }); - const gateway = createReferenceHttpGateway({ execute }); + it("delegates exact typed feature inputs to the capability binding", async () => { + const scripted = scriptedBinding([ + { ok: true, value: [resources.first] }, + { ok: true, value: resources.created }, + { ok: true, value: resources.first }, + ]); + const gateway = createReferenceHttpGateway(scripted.binding); const signal = new AbortController().signal; await expect( @@ -40,67 +69,37 @@ describe("reference HTTP operation gateway", () => { gateway.get("reference-1", { signal }), ).resolves.toEqual({ ok: true, value: resources.first }); - expect(execute.mock.calls).toEqual([ + expect(scripted.calls).toEqual([ [ - { - operationId: "LIST_REFERENCE_RESOURCES", - routeId: "REFERENCE_RESOURCE_LIST", - searchParams: { - cursor: "next", - limit: 20, - tags: ["active"], - }, - signal, - }, + "LIST_REFERENCE_RESOURCES", + { cursor: "next", limit: 20, tags: ["active"] }, + { signal }, ], [ - { - operationId: "CREATE_REFERENCE_RESOURCE", - routeId: "REFERENCE_RESOURCE_LIST", - body: { name: "Created", note: "safe note" }, - }, + "CREATE_REFERENCE_RESOURCE", + { name: "Created", note: "safe note" }, + undefined, ], [ - { - operationId: "GET_REFERENCE_RESOURCE", - routeId: "REFERENCE_RESOURCE_DETAIL", - pathParams: { resourceId: "reference-1" }, - signal, - }, + "GET_REFERENCE_RESOURCE", + { resourceId: "reference-1" }, + { signal }, ], ]); }); - it("preserves a validated raw failure without casting it into success", async () => { + it("preserves a capability-normalized failure across the feature gateway", async () => { const failure = createFailure( "SCHEMA_MISMATCH", "GET_REFERENCE_RESOURCE", 0, ); - const execute = vi - .fn() - .mockResolvedValue({ ok: false, error: failure }); - const gateway = createReferenceHttpGateway({ execute }); + const scripted = scriptedBinding([{ ok: false, error: failure }]); + const gateway = createReferenceHttpGateway(scripted.binding); await expect(gateway.get("invalid")).resolves.toEqual({ ok: false, error: failure, }); }); - - it("fails closed when a raw success does not match its operation result", async () => { - const execute = vi - .fn() - .mockResolvedValue({ ok: true, value: { id: "not-a-list" } }); - const gateway = createReferenceHttpGateway({ execute }); - - await expect(gateway.list({ limit: 20 })).resolves.toMatchObject({ - ok: false, - error: { - kind: "MAPPING_CONTRACT_VIOLATION", - code: "BOUND_RESULT_TYPE_MISMATCH", - operationId: "LIST_REFERENCE_RESOURCES", - }, - }); - }); }); diff --git a/tests/fixtures/typecheck/invalid-reference-operation.ts b/tests/fixtures/typecheck/invalid-reference-operation.ts index 8ad79da..cbc174d 100644 --- a/tests/fixtures/typecheck/invalid-reference-operation.ts +++ b/tests/fixtures/typecheck/invalid-reference-operation.ts @@ -1,9 +1,8 @@ -import type { RawReferenceHttpExecutor } from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts"; +import type { ReferenceHttpBinding } from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts"; -declare const http: RawReferenceHttpExecutor; +declare const http: ReferenceHttpBinding; -http.execute({ - operationId: "GET_REFERENCE_RESOURCE", - routeId: "REFERENCE_RESOURCE_LIST", - pathParams: { resourceId: "resource-1" }, -}); +const listInput = { limit: 20 }; + +// GET is bound to { resourceId: string }; list input must be rejected. +http.execute("GET_REFERENCE_RESOURCE", listInput); diff --git a/tests/fixtures/typecheck/invalid-result-narrowing.ts b/tests/fixtures/typecheck/invalid-result-narrowing.ts index 5e18fa9..5821540 100644 --- a/tests/fixtures/typecheck/invalid-result-narrowing.ts +++ b/tests/fixtures/typecheck/invalid-result-narrowing.ts @@ -1,4 +1,4 @@ -import type { Result } from "../../../src/application/result.ts"; +import type { Result } from "../../../src/contracts/result.ts"; export function invalidUnwrap(result: Result): number { return result.value; diff --git a/tests/unit/ci-artifact-contract.test.ts b/tests/system/ci-runner/ci-artifact-contract.test.ts similarity index 98% rename from tests/unit/ci-artifact-contract.test.ts rename to tests/system/ci-runner/ci-artifact-contract.test.ts index e6587f8..b094876 100644 --- a/tests/unit/ci-artifact-contract.test.ts +++ b/tests/system/ci-runner/ci-artifact-contract.test.ts @@ -11,41 +11,41 @@ import { afterAll, afterEach, describe, expect, it } from "vitest"; import type { CiGateArtifact, CiGateArtifactSchema, -} from "../../scripts/contracts/ci-gates.ts"; -import { parseCiGateContract } from "../../scripts/contracts/ci-gates.ts"; +} from "../../../scripts/contracts/ci-gates.ts"; +import { parseCiGateContract } from "../../../scripts/contracts/ci-gates.ts"; import { hasCiArtifactSemanticValidator, readBoundedRegularFile, validateCiArtifact, -} from "../../scripts/lib/ci-artifact-validator.ts"; -import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts"; -import { linkFixtureNodeModules } from "../../scripts/lib/fixture-node-modules.ts"; -import { copyReleaseEvidenceTree } from "../../scripts/lib/removal-fixture.ts"; -import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts"; +} from "../../../scripts/lib/ci-artifact-validator.ts"; +import { writeCiGateLogAtomic } from "../../../scripts/lib/ci-gate-log.ts"; +import { linkFixtureNodeModules } from "../../../scripts/lib/fixture-node-modules.ts"; +import { copyReleaseEvidenceTree } from "../../../scripts/lib/removal-fixture.ts"; +import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../../scripts/lib/ci-candidate-archive.ts"; import { CANDIDATE_ARCHIVE_USAGE, parseCandidateArchiveArguments, -} from "../../scripts/lib/ci-candidate-archive-cli.ts"; -import { validateProviderUpload } from "../../scripts/lib/provider-upload-validator.ts"; -import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts"; +} from "../../../scripts/lib/ci-candidate-archive-cli.ts"; +import { validateProviderUpload } from "../../../scripts/lib/provider-upload-validator.ts"; +import { verifyExactPromotionBundle } from "../../../scripts/lib/exact-promotion-bundle.ts"; import { cleanupFinalizedPromotion, stageVerifiedPromotion, -} from "../../scripts/lib/promotion-stager.ts"; -import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts"; +} from "../../../scripts/lib/promotion-stager.ts"; +import { PROMOTED_FILE_NAMES } from "../../../scripts/contracts/promotion-artifacts.ts"; import { providerEvidenceSignaturePayload, providerPublicKeyFingerprint, providerVerificationArtifactSchema, -} from "../../scripts/lib/provider-evidence.ts"; -import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts"; -import { readProviderTrust } from "../../scripts/lib/provider-trust.ts"; +} from "../../../scripts/lib/provider-evidence.ts"; +import { localEvidenceAssessmentArtifactSchema } from "../../../scripts/contracts/release-artifacts.ts"; +import { readProviderTrust } from "../../../scripts/lib/provider-trust.ts"; import { createReleaseCandidateManifest, LOCAL_EVIDENCE_ASSESSMENT_PATH, RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_MANIFEST_PATH, -} from "../../scripts/lib/release-candidate.ts"; +} from "../../../scripts/lib/release-candidate.ts"; /** * Budget for the provider suites specifically. They spawn a systemd scope, a diff --git a/tests/system/ci-runner/ci-evidence-freshness.test.ts b/tests/system/ci-runner/ci-evidence-freshness.test.ts new file mode 100644 index 0000000..8eb1877 --- /dev/null +++ b/tests/system/ci-runner/ci-evidence-freshness.test.ts @@ -0,0 +1,168 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { loadCiGateContract } from "../../../scripts/contracts/ci-gates.ts"; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => + rm(root, { recursive: true, force: true }), + ), + ); +}); + +describe("CI-runner command-generated evidence freshness", () => { + it("rejects stale command-generated evidence from a successful no-op producer", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-gate-stale-evidence-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + await mkdir(path.join(root, "artifacts/tests"), { recursive: true }); + const contract = JSON.parse( + JSON.stringify(await loadCiGateContract(process.cwd())), + ) as Record; + const command = contract.commands.find( + (entry: Record) => entry.id === "test-runtime-schema", + ); + command.script = "test:stale-evidence-noop"; + const evidence = contract.artifacts.find( + (entry: Record) => + entry.path === "artifacts/tests/runtime-schema.xml", + ); + const packageDocument = JSON.parse( + await readFile("package.json", "utf8"), + ) as { scripts: Record }; + packageDocument.scripts[command.script] = "true"; + await writeFile( + path.join(root, "config/ci/gates.json"), + `${JSON.stringify(contract)}\n`, + ); + await writeFile( + path.join(root, "package.json"), + `${JSON.stringify(packageDocument)}\n`, + ); + await writeFile( + path.join(root, evidence.path), + '\n', + ); + + const result = spawnSync( + process.execPath, + [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"], + { cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } }, + ); + + expect(result.status).toBe(1); + expect( + await readFile( + path.join(root, "artifacts/quality/gates/FE-GATE-004.txt"), + "utf8", + ), + ).toMatch(/not freshly produced/i); + }); + + it("accepts a fresh deterministic rewrite with identical evidence bytes", async () => { + const root = await mkdtemp( + path.join(tmpdir(), "ci-gate-identical-rewrite-"), + ); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + await mkdir(path.join(root, "artifacts/tests"), { recursive: true }); + const contract = JSON.parse( + JSON.stringify(await loadCiGateContract(process.cwd())), + ) as Record; + const command = contract.commands.find( + (entry: Record) => entry.id === "test-runtime-schema", + ); + command.script = "test:identical-evidence-rewrite"; + const evidence = contract.artifacts.find( + (entry: Record) => + entry.path === "artifacts/tests/runtime-schema.xml", + ); + const evidenceBytes = + '\n'; + const packageDocument = JSON.parse( + await readFile("package.json", "utf8"), + ) as { scripts: Record }; + packageDocument.scripts[command.script] = + `node -e 'require("node:fs").writeFileSync("${evidence.path}", Buffer.from("${Buffer.from(evidenceBytes).toString("base64")}", "base64"))'`; + await writeFile( + path.join(root, "config/ci/gates.json"), + `${JSON.stringify(contract)}\n`, + ); + await writeFile( + path.join(root, "package.json"), + `${JSON.stringify(packageDocument)}\n`, + ); + await writeFile(path.join(root, evidence.path), evidenceBytes); + + const result = spawnSync( + process.execPath, + [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"], + { cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/FE-GATE-004 runtime-schema: PASS/); + }); +}); + +describe("CI-runner gate output budget", () => { + it("caps aggregate gate output at the log schema before later commands can accumulate", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-gate-output-budget-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + const contract = JSON.parse( + JSON.stringify(await loadCiGateContract(process.cwd())), + ) as Record; + const gate = contract.gates.find( + (entry: Record) => entry.id === "FE-GATE-001", + ); + const command = contract.commands.find( + (entry: Record) => entry.id === gate.commandIds[0], + ); + command.script = "test:huge-output"; + const logArtifact = contract.artifacts.find( + (entry: Record) => entry.id === gate.logArtifactId, + ); + const logSchema = contract.artifactSchemas.find( + (entry: Record) => entry.id === logArtifact.schemaId, + ); + logSchema.maxBytes = 8_192; + const packageDocument = JSON.parse( + await readFile("package.json", "utf8"), + ) as { scripts: Record }; + packageDocument.scripts["test:huge-output"] = + "node -e \"process.stdout.write('x'.repeat(20000))\""; + await writeFile( + path.join(root, "config/ci/gates.json"), + `${JSON.stringify(contract)}\n`, + ); + await writeFile( + path.join(root, "package.json"), + `${JSON.stringify(packageDocument)}\n`, + ); + const result = spawnSync( + process.execPath, + [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-001"], + { + cwd: root, + encoding: "utf8", + env: { ...process.env, CI: "false" }, + timeout: 15_000, + }, + ); + + expect(result.status).toBe(1); + const log = await readFile(path.join(root, logArtifact.path)); + expect(log.byteLength).toBeLessThanOrEqual(8_192); + expect(log.toString("utf8")).toMatch( + /aggregate output|INFRASTRUCTURE_FAILURE/i, + ); + }, 20_000); +}); diff --git a/tests/system/ci-runner/provider-process-group.test.ts b/tests/system/ci-runner/provider-process-group.test.ts new file mode 100644 index 0000000..a6db0ac --- /dev/null +++ b/tests/system/ci-runner/provider-process-group.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { runProviderProcess } from "../../../scripts/lib/provider-process-runner.ts"; + +describe("CI-runner provider process-group lifecycle", () => { + it("kills and reaps a stubborn provider process group including its descendant", async () => { + const root = await mkdtemp(path.join(tmpdir(), "provider-process-group-")); + const descendantPidPath = path.join(root, "descendant.pid"); + try { + const source = [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + "const child = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdio: 'ignore' });", + "writeFileSync(process.env.DESCENDANT_PID_PATH, String(child.pid));", + "process.on('SIGTERM', () => {});", + "setInterval(() => {}, 1000);", + ].join("\n"); + + const running = runProviderProcess({ + executable: process.execPath, + arguments: ["-e", source], + environment: { + PATH: process.env.PATH, + DESCENDANT_PID_PATH: descendantPidPath, + }, + timeoutMs: 250, + }); + + await expect(running).rejects.toThrow(/timed out.*process close/u); + const descendantPid = Number(await readFile(descendantPidPath, "utf8")); + expect(Number.isSafeInteger(descendantPid) && descendantPid > 0).toBe(true); + expect(() => process.kill(descendantPid, 0)).toThrow(/ESRCH|no such process/u); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/browser-rpc/browser-rpc-runtime.test.ts b/tests/unit/browser-rpc/browser-rpc-runtime.test.ts index 0027308..1e68ef5 100644 --- a/tests/unit/browser-rpc/browser-rpc-runtime.test.ts +++ b/tests/unit/browser-rpc/browser-rpc-runtime.test.ts @@ -9,7 +9,7 @@ import { type BrowserRpcStreamFrame, type BrowserRpcTransport, } from "../../../src/adapters/browser-rpc/index.ts"; -import type { Result } from "../../../src/application/result.ts"; +import type { Result } from "../../../src/contracts/result.ts"; import type { AppFailure } from "../../../src/contracts/errors.ts"; import { MAPPERS, diff --git a/tests/unit/ci-workflow-generation.test.ts b/tests/unit/ci-workflow-generation.test.ts index d3dda0c..95833ae 100644 --- a/tests/unit/ci-workflow-generation.test.ts +++ b/tests/unit/ci-workflow-generation.test.ts @@ -189,11 +189,11 @@ describe("CI gate contract", () => { ), ); expect(contract.jobs).toHaveLength(9); - expect(contract.commands).toHaveLength(82); - expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(94); + 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.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(86); - expect(contract.artifacts).toHaveLength(107); + expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88); + expect(contract.artifacts).toHaveLength(109); expect(contract.stages).toHaveLength(5); expect(contract.retention.classes).toHaveLength(5); expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2); @@ -1335,106 +1335,6 @@ describe("CI gate contract", () => { ); }); - it("caps aggregate gate output at the log schema before later commands can accumulate", async () => { - const root = await mkdtemp(path.join(tmpdir(), "ci-gate-output-budget-")); - temporaryRoots.push(root); - await mkdir(path.join(root, "config/ci"), { recursive: true }); - const contract = JSON.parse(JSON.stringify(await loadCiGateContract(process.cwd()))) as Record; - const gate = contract.gates.find((entry: any) => entry.id === "FE-GATE-001"); - const command = contract.commands.find((entry: any) => entry.id === gate.commandIds[0]); - command.script = "test:huge-output"; - const logArtifact = contract.artifacts.find((entry: any) => entry.id === gate.logArtifactId); - const logSchema = contract.artifactSchemas.find((entry: any) => entry.id === logArtifact.schemaId); - logSchema.maxBytes = 8_192; - const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { scripts: Record }; - packageDocument.scripts["test:huge-output"] = "node -e \"process.stdout.write('x'.repeat(20000))\""; - await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); - await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); - const environment = { ...process.env, CI: "false" }; - const result = spawnSync(process.execPath, [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-001"], { - cwd: root, - encoding: "utf8", - env: environment, - timeout: 15_000, - }); - expect(result.status).toBe(1); - const log = await readFile(path.join(root, logArtifact.path)); - expect(log.byteLength).toBeLessThanOrEqual(8_192); - expect(log.toString("utf8")).toMatch(/aggregate output|INFRASTRUCTURE_FAILURE/i); - }, 20_000); - - it("rejects stale command-generated evidence from a successful no-op producer", async () => { - const root = await mkdtemp(path.join(tmpdir(), "ci-gate-stale-evidence-")); - temporaryRoots.push(root); - await mkdir(path.join(root, "config/ci"), { recursive: true }); - await mkdir(path.join(root, "artifacts/tests"), { recursive: true }); - const contract = JSON.parse( - JSON.stringify(await loadCiGateContract(process.cwd())), - ) as Record; - const command = contract.commands.find( - (entry: Record) => entry.id === "test-runtime-schema", - ); - command.script = "test:stale-evidence-noop"; - const evidence = contract.artifacts.find( - (entry: Record) => entry.path === "artifacts/tests/runtime-schema.xml", - ); - const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { - scripts: Record; - }; - packageDocument.scripts[command.script] = "true"; - await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); - await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); - await writeFile( - path.join(root, evidence.path), - '\n', - ); - - const result = spawnSync( - process.execPath, - [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"], - { cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } }, - ); - - expect(result.status).toBe(1); - expect( - await readFile(path.join(root, "artifacts/quality/gates/FE-GATE-004.txt"), "utf8"), - ).toMatch(/not freshly produced/i); - }); - - it("accepts a fresh deterministic rewrite with identical evidence bytes", async () => { - const root = await mkdtemp(path.join(tmpdir(), "ci-gate-identical-rewrite-")); - temporaryRoots.push(root); - await mkdir(path.join(root, "config/ci"), { recursive: true }); - await mkdir(path.join(root, "artifacts/tests"), { recursive: true }); - const contract = JSON.parse( - JSON.stringify(await loadCiGateContract(process.cwd())), - ) as Record; - const command = contract.commands.find( - (entry: Record) => entry.id === "test-runtime-schema", - ); - command.script = "test:identical-evidence-rewrite"; - const evidence = contract.artifacts.find( - (entry: Record) => entry.path === "artifacts/tests/runtime-schema.xml", - ); - const evidenceBytes = '\n'; - const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { - scripts: Record; - }; - packageDocument.scripts[command.script] = - `node -e 'require("node:fs").writeFileSync("${evidence.path}", Buffer.from("${Buffer.from(evidenceBytes).toString("base64")}", "base64"))'`; - await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); - await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); - await writeFile(path.join(root, evidence.path), evidenceBytes); - - const result = spawnSync( - process.execPath, - [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"], - { cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } }, - ); - - expect(result.status).toBe(0); - expect(result.stdout).toMatch(/FE-GATE-004 runtime-schema: PASS/); - }); }); describe("CI workflow generation", () => { diff --git a/tests/unit/compatibility.test.ts b/tests/unit/compatibility.test.ts index 660fce0..bc0654e 100644 --- a/tests/unit/compatibility.test.ts +++ b/tests/unit/compatibility.test.ts @@ -5,7 +5,7 @@ import { isVersionCompatible, parseNumericVersion, verifyCompatibilityTuple, -} from "../../src/application/policies/compatibility.ts"; +} from "../../src/contracts/compatibility.ts"; describe("contract compatibility", () => { it("uses numeric version parsing rather than lexical comparison", () => { diff --git a/tests/unit/cursor-pagination-runtime.test.ts b/tests/unit/cursor-pagination-runtime.test.ts index 9b119c5..eeb6fce 100644 --- a/tests/unit/cursor-pagination-runtime.test.ts +++ b/tests/unit/cursor-pagination-runtime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/cursor-pagination-runtime.ts"; +import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/index.ts"; const profile = { profileId: "bounded-cursor-v1", diff --git a/tests/unit/http-retry-lifecycle.test.ts b/tests/unit/http-retry-lifecycle.test.ts new file mode 100644 index 0000000..3e27f63 --- /dev/null +++ b/tests/unit/http-retry-lifecycle.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { + canRetryTransport, + isRetryableHttpStatus, + isRetryableSemantics, + jitteredDelay, + retryDelayFor, +} from "../../src/adapters/http/http-retry-lifecycle.ts"; + +describe("HTTP retry lifecycle policy", () => { + it("allows KEYED replay only before a physical dispatch", () => { + expect(canRetryTransport("KEYED", "PREPARING")).toBe(true); + expect(canRetryTransport("KEYED", "READY_TO_SEND")).toBe(true); + expect(canRetryTransport("KEYED", "DISPATCHED")).toBe(false); + expect(canRetryTransport("KEYED", "RESPONSE_HEADERS")).toBe(false); + }); + + it("keeps SAFE and IDEMPOTENT retryable while NEVER is terminal", () => { + expect(isRetryableSemantics("SAFE")).toBe(true); + expect(isRetryableSemantics("IDEMPOTENT")).toBe(true); + expect(isRetryableSemantics("KEYED")).toBe(false); + expect(isRetryableSemantics("NEVER")).toBe(false); + + expect(canRetryTransport("SAFE", "DISPATCHED")).toBe(true); + expect(canRetryTransport("IDEMPOTENT", "READING_BODY")).toBe(true); + expect(canRetryTransport("NEVER", "PREPARING")).toBe(false); + }); + + it("owns the closed retryable status vocabulary", () => { + for (const status of [408, 425, 429, 502, 503, 504]) { + expect(isRetryableHttpStatus(status)).toBe(true); + } + for (const status of [400, 401, 403, 404, 409, 500, 501]) { + expect(isRetryableHttpStatus(status)).toBe(false); + } + }); + + it("uses bounded full jitter and rejects excessive Retry-After", () => { + expect(jitteredDelay(0, () => 0)).toBe(0); + expect(jitteredDelay(0, () => 0.5)).toBe(125); + expect(jitteredDelay(8, () => 0.5)).toBe(1_000); + + expect(retryDelayFor(null, 0, () => 0.5)).toBe(125); + expect(retryDelayFor(400, 0, () => 0.5)).toBe(400); + expect(retryDelayFor(5_001, 0, () => 0.5)).toBeNull(); + }); +}); diff --git a/tests/unit/image-cdn-browser-probe.test.ts b/tests/unit/image-cdn-browser-probe.test.ts new file mode 100644 index 0000000..183c8de --- /dev/null +++ b/tests/unit/image-cdn-browser-probe.test.ts @@ -0,0 +1,652 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + ImageProbeRequest, +} from "../../src/application/ports/browser-transfer/image-cdn.ts"; +import { createBrowserImageProbe } from "../../src/adapters/browser-transfer/image-cdn/browser-image-probe.ts"; +import { + avifBytes, + jpegBytes, + manualImageProbeScheduler, + pngBytes, + publicImageHeaders, + responseAt, + webpBytes, +} from "./image-cdn-test-fixture.ts"; + +describe("browser image probe", () => { + const imageUrl = + "https://images.example.test/v1/assets/a/rev?format=png"; + const request = ( + overrides: Partial = {}, + ): ImageProbeRequest => ({ + absoluteUrl: imageUrl, + expectedMediaType: "image/png", + expectedWidth: 640, + expectedHeight: 360, + maxEncodedBytes: 1_024, + maxDecodedPixels: 230_400, + maxDecodedBytes: 921_600, + delivery: "PUBLIC_IMMUTABLE", + minimumPublicMaxAgeSeconds: 31_536_000, + referrerPolicy: "no-referrer", + signal: new AbortController().signal, + ...overrides, + }); + + it("parses all supported static headers before decode and closes each bitmap", async () => { + const samples = [ + { + mediaType: "image/png" as const, + bytes: pngBytes(640, 360), + }, + { + mediaType: "image/jpeg" as const, + bytes: jpegBytes(640, 360), + }, + { + mediaType: "image/webp" as const, + bytes: webpBytes(640, 360), + }, + { + mediaType: "image/avif" as const, + bytes: avifBytes(640, 360), + }, + ]; + const close = vi.fn(); + for (const sample of samples) { + const exactUrl = imageUrl.replace( + "format=png", + `format=${sample.mediaType.slice("image/".length)}`, + ); + const fetcher = vi.fn(async () => + responseAt(exactUrl, sample.bytes, { + status: 200, + headers: publicImageHeaders( + sample.mediaType, + sample.bytes.byteLength, + ), + }), + ); + const probe = createBrowserImageProbe({ + fetcher: fetcher as typeof fetch, + createBitmap: vi.fn(async () => ({ + width: 640, + height: 360, + close, + })), + }); + + await expect( + probe.probe( + request({ + absoluteUrl: exactUrl, + expectedMediaType: sample.mediaType, + }), + ), + ).resolves.toMatchObject({ + ok: true, + value: { + absoluteUrl: exactUrl, + mediaType: sample.mediaType, + encodedBytes: sample.bytes.byteLength, + decodedWidth: 640, + decodedHeight: 360, + }, + }); + expect(fetcher).toHaveBeenCalledWith( + exactUrl, + expect.objectContaining({ + credentials: "omit", + redirect: "error", + mode: "cors", + cache: "no-store", + referrerPolicy: "no-referrer", + }), + ); + } + expect(close).toHaveBeenCalledTimes(samples.length); + }); + + it("fails closed on duplicate/conflicting cache directives and oversized bodies", async () => { + const png = pngBytes(640, 360); + const createBitmap = vi.fn(async () => ({ + width: 640, + height: 360, + close: vi.fn(), + })); + for (const cacheControl of [ + // BT-IMG-02. Unmatched quotes must not be unwrapped into a bare number. + 'public, max-age="31536000, immutable', + 'public, max-age=31536000", immutable', + 'public, max-age="31536000\\", immutable', + "public, public, max-age=31536000, immutable", + "public, max-age=31536000, s-maxage=60, immutable", + "public, max-age=31536000, immutable, must-revalidate", + "public=1, max-age=31536000, immutable", + "public, max-age=31536000, immutable=true", + ]) { + const probe = createBrowserImageProbe({ + fetcher: (async () => + responseAt(imageUrl, png, { + status: 200, + headers: { + "cache-control": cacheControl, + "content-type": "image/png", + }, + })) as typeof fetch, + createBitmap, + }); + await expect(probe.probe(request())).resolves.toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + } + + const probe = createBrowserImageProbe({ + fetcher: (async () => + responseAt(imageUrl, new Uint8Array(2_048), { + status: 200, + headers: { + "cache-control": + "public, max-age=31536000, immutable", + "content-type": "image/png", + }, + })) as typeof fetch, + createBitmap, + }); + await expect(probe.probe(request())).resolves.toMatchObject({ + ok: false, + error: { code: "LIMIT_EXCEEDED" }, + }); + expect(createBitmap).not.toHaveBeenCalled(); + }); + + it("rejects non-identity content encoding and mismatched declared lengths", async () => { + const png = pngBytes(640, 360); + const createBitmap = vi.fn(); + const cases = [ + { + headers: { + "content-encoding": "gzip", + "content-length": String(png.byteLength), + }, + code: "POLICY_REJECTED", + }, + { + headers: { + "content-length": String(png.byteLength + 1), + }, + code: "INTEGRITY_FAILED", + }, + ]; + for (const invalid of cases) { + const headers = publicImageHeaders("image/png"); + for (const [name, value] of Object.entries(invalid.headers)) { + headers.set(name, value); + } + const probe = createBrowserImageProbe({ + fetcher: (async () => + responseAt(imageUrl, png, { + status: 200, + headers, + })) as typeof fetch, + createBitmap, + }); + await expect(probe.probe(request())).resolves.toMatchObject({ + ok: false, + error: { code: invalid.code }, + }); + } + expect(createBitmap).not.toHaveBeenCalled(); + }); + + it("rejects malicious dimensions and animated PNG/WebP before native decode", async () => { + const createBitmap = vi.fn(async () => ({ + width: 640, + height: 360, + close: vi.fn(), + })); + const cases = [ + { + bytes: pngBytes(20_000, 20_000), + mediaType: "image/png" as const, + code: "LIMIT_EXCEEDED", + }, + { + bytes: pngBytes(320, 180), + mediaType: "image/png" as const, + code: "INTEGRITY_FAILED", + }, + { + bytes: jpegBytes(320, 180), + mediaType: "image/jpeg" as const, + code: "INTEGRITY_FAILED", + }, + { + bytes: webpBytes(10_000, 10_000), + mediaType: "image/webp" as const, + code: "LIMIT_EXCEEDED", + }, + { + bytes: avifBytes(20_000, 20_000), + mediaType: "image/avif" as const, + code: "LIMIT_EXCEEDED", + }, + { + bytes: pngBytes(640, 360, true), + mediaType: "image/png" as const, + code: "INTEGRITY_FAILED", + }, + { + bytes: webpBytes(640, 360, true), + mediaType: "image/webp" as const, + code: "INTEGRITY_FAILED", + }, + { + bytes: avifBytes(640, 360, "avis"), + mediaType: "image/avif" as const, + code: "INTEGRITY_FAILED", + }, + ]; + for (const malicious of cases) { + const url = imageUrl.replace( + "format=png", + `format=${malicious.mediaType.slice("image/".length)}`, + ); + const probe = createBrowserImageProbe({ + fetcher: (async () => + responseAt(url, malicious.bytes, { + status: 200, + headers: publicImageHeaders( + malicious.mediaType, + malicious.bytes.byteLength, + ), + })) as typeof fetch, + createBitmap, + }); + await expect( + probe.probe( + request({ + absoluteUrl: url, + expectedMediaType: malicious.mediaType, + }), + ), + ).resolves.toMatchObject({ + ok: false, + error: { code: malicious.code }, + }); + } + expect(createBitmap).not.toHaveBeenCalled(); + }); + + it("enforces private no-store, omitted credentials and the exact final URL", async () => { + const png = pngBytes(640, 360); + const fetcher = vi.fn(async () => + responseAt(imageUrl, png, { + status: 200, + headers: { + // TR-RR-09. A private response carries `no-store` and nothing else + // that describes cacheability. + "cache-control": "no-store", + "content-type": "image/png", + }, + }), + ); + const probe = createBrowserImageProbe({ + fetcher: fetcher as typeof fetch, + createBitmap: async () => ({ + width: 640, + height: 360, + close: vi.fn(), + }), + }); + await expect( + probe.probe( + request({ + delivery: "PRIVATE_SIGNED", + minimumPublicMaxAgeSeconds: 0, + }), + ), + ).resolves.toMatchObject({ ok: true }); + expect(fetcher).toHaveBeenCalledWith( + imageUrl, + expect.objectContaining({ + cache: "no-store", + credentials: "omit", + redirect: "error", + }), + ); + + for (const response of [ + responseAt(imageUrl, png, { + status: 200, + headers: { + "cache-control": "private, no-store=value", + "content-type": "image/png", + }, + }), + responseAt(imageUrl, png, { + status: 200, + headers: { + "cache-control": "public, no-store", + "content-type": "image/png", + }, + }), + responseAt( + "https://images.example.test/v1/assets/other", + png, + { + status: 200, + headers: { + "cache-control": "private, no-store", + "content-type": "image/png", + }, + }, + ), + ]) { + const rejectingProbe = createBrowserImageProbe({ + fetcher: (async () => response) as typeof fetch, + createBitmap: async () => ({ + width: 640, + height: 360, + close: vi.fn(), + }), + }); + await expect( + rejectingProbe.probe( + request({ + delivery: "PRIVATE_SIGNED", + minimumPublicMaxAgeSeconds: 0, + }), + ), + ).resolves.toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + } + + await expect( + probe.probe( + request({ + expectedMediaType: "image/svg+xml", + } as unknown as Partial), + ), + ).resolves.toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + }); + + /** + * TR-RR-09. The recorded BT-IMG-02 contract for a private response is a + * fail-closed matrix. Accepting `no-store` next to a directive that describes + * cacheability lets a self-contradictory policy read as acceptable. + */ + it("applies the full private Cache-Control matrix", async () => { + const png = pngBytes(640, 360); + const probeWith = async (cacheControl: string) => { + const probe = createBrowserImageProbe({ + fetcher: (async () => + responseAt(imageUrl, png, { + status: 200, + headers: { + "cache-control": cacheControl, + "content-type": "image/png", + }, + })) as typeof fetch, + createBitmap: async () => ({ + width: 640, + height: 360, + close: vi.fn(), + }), + }); + return await probe.probe( + request({ + delivery: "PRIVATE_SIGNED", + minimumPublicMaxAgeSeconds: 0, + }), + ); + }; + + // Only `no-store`, plus a syntactically valid unknown extension. + expect(await probeWith("no-store")).toMatchObject({ ok: true }); + expect(await probeWith('no-store, x-vendor="a,b"')).toMatchObject({ + ok: true, + }); + + for (const companion of [ + "public", + "private", + "immutable", + "max-age=60", + "s-maxage=60", + "no-cache", + "must-revalidate", + "proxy-revalidate", + ]) { + expect(await probeWith(`no-store, ${companion}`)).toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + } + + for (const withoutNoStore of ["private", "no-cache", "max-age=0"]) { + expect(await probeWith(withoutNoStore)).toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + } + }); + + it("times out a stalled body, aborts the composed signal and cancels its reader", async () => { + const manual = manualImageProbeScheduler(); + const cancel = vi.fn(async () => undefined); + const releaseLock = vi.fn(); + const read = vi.fn( + () => + new Promise>( + () => undefined, + ), + ); + const response = { + body: { + getReader: () => ({ cancel, read, releaseLock }), + }, + headers: publicImageHeaders("image/png"), + ok: true, + redirected: false, + status: 200, + type: "cors", + url: imageUrl, + } as unknown as Response; + const fetcher = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + response, + ); + const probe = createBrowserImageProbe({ + fetcher: fetcher as typeof fetch, + createBitmap: vi.fn(), + timeoutMs: 1_000, + scheduler: manual.scheduler, + }); + const probeRequest = request(); + const pending = probe.probe(probeRequest); + await vi.waitFor(() => { + expect(read).toHaveBeenCalledOnce(); + }); + const composedSignal = fetcher.mock.calls[0]?.[1]?.signal as + | AbortSignal + | null + | undefined; + expect(composedSignal).not.toBe(probeRequest.signal); + manual.fire(); + + await expect(pending).resolves.toMatchObject({ + ok: false, + error: { + code: "UNAVAILABLE", + retryable: true, + recovery: "RETRY", + }, + }); + expect(cancel).toHaveBeenCalledOnce(); + expect(releaseLock).toHaveBeenCalledOnce(); + expect(composedSignal?.aborted).toBe(true); + }); + + it("times out stalled decode and closes a bitmap that resolves late", async () => { + const manual = manualImageProbeScheduler(); + const close = vi.fn(); + let finishDecode: + ((bitmap: { + width: number; + height: number; + close(): void; + }) => void) | undefined; + const createBitmap = vi.fn( + () => + new Promise<{ + width: number; + height: number; + close(): void; + }>((resolve) => { + finishDecode = resolve; + }), + ); + const png = pngBytes(640, 360); + const probe = createBrowserImageProbe({ + fetcher: (async () => + responseAt(imageUrl, png, { + status: 200, + headers: publicImageHeaders("image/png", png.byteLength), + })) as typeof fetch, + createBitmap, + timeoutMs: 1_000, + scheduler: manual.scheduler, + }); + const pending = probe.probe(request()); + await vi.waitFor(() => { + expect(createBitmap).toHaveBeenCalledOnce(); + }); + manual.fire(); + await expect(pending).resolves.toMatchObject({ + ok: false, + error: { code: "UNAVAILABLE" }, + }); + + finishDecode?.({ width: 640, height: 360, close }); + await vi.waitFor(() => { + expect(close).toHaveBeenCalledOnce(); + }); + }); + + /** + * X-AUDIT-02. `probe()` promises a `BrowserDataResult`. A scheduler that + * cannot install the probe deadline must close the probe inside that contract + * rather than rejecting it, and must not leave the caller's listener behind. + */ + describe("scheduler boundary", () => { + const trackedSignal = () => { + const controller = new AbortController(); + const added: string[] = []; + const removed: string[] = []; + const add = controller.signal.addEventListener.bind(controller.signal); + const remove = controller.signal.removeEventListener.bind( + controller.signal, + ); + Object.defineProperty(controller.signal, "addEventListener", { + configurable: true, + value: (type: string, ...rest: readonly unknown[]) => { + added.push(type); + return (add as (...args: readonly unknown[]) => unknown)( + type, + ...rest, + ); + }, + }); + Object.defineProperty(controller.signal, "removeEventListener", { + configurable: true, + value: (type: string, ...rest: readonly unknown[]) => { + removed.push(type); + return (remove as (...args: readonly unknown[]) => unknown)( + type, + ...rest, + ); + }, + }); + return { controller, added, removed }; + }; + + it("closes the probe when the scheduler cannot install the deadline", async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 200 })); + const { controller, added, removed } = trackedSignal(); + const probe = createBrowserImageProbe({ + fetcher: fetcher as unknown as typeof fetch, + createBitmap: vi.fn(), + timeoutMs: 1_000, + scheduler: { + setTimeout: () => { + throw new TypeError("image scheduler install exploded"); + }, + clearTimeout: vi.fn(), + }, + }); + + await expect( + probe.probe({ ...request(), signal: controller.signal }), + ).resolves.toMatchObject({ + ok: false, + error: { code: "UNAVAILABLE", retryable: true, recovery: "RETRY" }, + }); + expect(fetcher).not.toHaveBeenCalled(); + expect(added.filter((type) => type === "abort")).toHaveLength(1); + expect(removed.filter((type) => type === "abort")).toHaveLength(1); + }); + + it("starts no timer and no fetch for an already aborted caller", async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 200 })); + const setTimeout_ = vi.fn(() => 1); + const controller = new AbortController(); + controller.abort(); + const probe = createBrowserImageProbe({ + fetcher: fetcher as unknown as typeof fetch, + createBitmap: vi.fn(), + timeoutMs: 1_000, + scheduler: { setTimeout: setTimeout_, clearTimeout: vi.fn() }, + }); + + await expect( + probe.probe({ ...request(), signal: controller.signal }), + ).resolves.toMatchObject({ ok: false, error: { code: "ABORTED" } }); + expect(fetcher).not.toHaveBeenCalled(); + expect(setTimeout_).not.toHaveBeenCalled(); + }); + + it("keeps the classified outcome when clearing the deadline throws", async () => { + const png = pngBytes(640, 360); + const probe = createBrowserImageProbe({ + fetcher: (async () => + responseAt(imageUrl, png, { + status: 200, + headers: publicImageHeaders("image/png", png.byteLength), + })) as typeof fetch, + createBitmap: vi.fn(async () => ({ + width: 640, + height: 360, + close: vi.fn(), + })), + timeoutMs: 1_000, + scheduler: { + setTimeout: (callback: () => void, milliseconds: number) => + setTimeout(callback, milliseconds), + clearTimeout: () => { + throw new TypeError("image scheduler clear exploded"); + }, + }, + }); + + await expect(probe.probe(request())).resolves.toMatchObject({ ok: true }); + }); + }); +}); diff --git a/tests/unit/image-cdn-capability-verifier.test.ts b/tests/unit/image-cdn-capability-verifier.test.ts new file mode 100644 index 0000000..81b36cc --- /dev/null +++ b/tests/unit/image-cdn-capability-verifier.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { createP256ImageCapabilityVerifier } from "../../src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts"; +import { base64Url } from "./image-cdn-test-fixture.ts"; + +describe("P-256 image capability verifier", () => { + it("rejects an ECDSA public key on any curve other than P-256", async () => { + const generated = await globalThis.crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-384" }, + false, + ["sign", "verify"], + ); + if (!("publicKey" in generated)) { + throw new TypeError("Expected an ECDSA key pair."); + } + expect(() => + createP256ImageCapabilityVerifier({ + subtle: globalThis.crypto.subtle, + publicKeys: [ + { + keyId: "image-signing-wrong-curve", + key: generated.publicKey, + }, + ], + }), + ).toThrow(/public key binding/u); + }); + + it("verifies the exact canonical payload and rejects tampering", async () => { + const generated = await globalThis.crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign", "verify"], + ); + if (!("privateKey" in generated)) { + throw new TypeError("Expected an ECDSA key pair."); + } + const payload = new TextEncoder().encode( + '["image-cdn-capability-v1","bound"]', + ); + const payloadBuffer = new Uint8Array(payload.byteLength); + payloadBuffer.set(payload); + const signature = await globalThis.crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + generated.privateKey, + payloadBuffer.buffer, + ); + const verifier = createP256ImageCapabilityVerifier({ + subtle: globalThis.crypto.subtle, + publicKeys: [ + { + keyId: "image-signing-2026-01", + key: generated.publicKey, + }, + ], + }); + expect(verifier.acceptsKey("image-signing-2026-01")).toBe( + true, + ); + expect(verifier.acceptsKey("image-signing-unknown")).toBe( + false, + ); + const signatureBase64Url = base64Url( + new Uint8Array(signature), + ); + await expect( + verifier.verify({ + algorithm: "ECDSA_P256_SHA256", + keyId: "image-signing-2026-01", + canonicalPayload: payload, + signatureBase64Url, + }), + ).resolves.toBe(true); + const tampered = Uint8Array.from(payload); + tampered[0] ^= 1; + await expect( + verifier.verify({ + algorithm: "ECDSA_P256_SHA256", + keyId: "image-signing-2026-01", + canonicalPayload: tampered, + signatureBase64Url, + }), + ).resolves.toBe(false); + }); +}); diff --git a/tests/unit/image-cdn-runtime.test.ts b/tests/unit/image-cdn-runtime.test.ts index e81305f..29590d0 100644 --- a/tests/unit/image-cdn-runtime.test.ts +++ b/tests/unit/image-cdn-runtime.test.ts @@ -7,10 +7,6 @@ import type { ImageProbeRequest, PublicImmutableImageAsset, } from "../../src/application/ports/browser-transfer/image-cdn.ts"; -import { - createBrowserImageProbe, - type ImageProbeScheduler, -} from "../../src/adapters/browser-transfer/image-cdn/browser-image-probe.ts"; import { IMAGE_CDN_IMPLEMENTATION_CEILINGS, ImageCdnPolicyRegistry, @@ -25,7 +21,6 @@ import { MAX_IMAGE_CAPABILITY_VERIFICATION_TIMEOUT_MS, type ImageCapabilityVerificationScheduler, } from "../../src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts"; -import { createP256ImageCapabilityVerifier } from "../../src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts"; const hardLimits: ImageCdnHardLimits = Object.freeze({ maxIntrinsicWidth: 4_096, @@ -179,6 +174,8 @@ async function privateAsset( }; } +import { manualCapabilityVerificationScheduler } from "./image-cdn-test-fixture.ts"; + describe("production image CDN runtime", () => { it("builds sorted, duplicate-free responsive candidates from a named preset", async () => { const preset = imageCdnPresetReference( @@ -1409,1022 +1406,3 @@ describe("production image CDN runtime", () => { ]); }); }); - -describe("browser image probe", () => { - const imageUrl = - "https://images.example.test/v1/assets/a/rev?format=png"; - const request = ( - overrides: Partial = {}, - ): ImageProbeRequest => ({ - absoluteUrl: imageUrl, - expectedMediaType: "image/png", - expectedWidth: 640, - expectedHeight: 360, - maxEncodedBytes: 1_024, - maxDecodedPixels: 230_400, - maxDecodedBytes: 921_600, - delivery: "PUBLIC_IMMUTABLE", - minimumPublicMaxAgeSeconds: 31_536_000, - referrerPolicy: "no-referrer", - signal: new AbortController().signal, - ...overrides, - }); - - it("parses all supported static headers before decode and closes each bitmap", async () => { - const samples = [ - { - mediaType: "image/png" as const, - bytes: pngBytes(640, 360), - }, - { - mediaType: "image/jpeg" as const, - bytes: jpegBytes(640, 360), - }, - { - mediaType: "image/webp" as const, - bytes: webpBytes(640, 360), - }, - { - mediaType: "image/avif" as const, - bytes: avifBytes(640, 360), - }, - ]; - const close = vi.fn(); - for (const sample of samples) { - const exactUrl = imageUrl.replace( - "format=png", - `format=${sample.mediaType.slice("image/".length)}`, - ); - const fetcher = vi.fn(async () => - responseAt(exactUrl, sample.bytes, { - status: 200, - headers: publicImageHeaders( - sample.mediaType, - sample.bytes.byteLength, - ), - }), - ); - const probe = createBrowserImageProbe({ - fetcher: fetcher as typeof fetch, - createBitmap: vi.fn(async () => ({ - width: 640, - height: 360, - close, - })), - }); - - await expect( - probe.probe( - request({ - absoluteUrl: exactUrl, - expectedMediaType: sample.mediaType, - }), - ), - ).resolves.toMatchObject({ - ok: true, - value: { - absoluteUrl: exactUrl, - mediaType: sample.mediaType, - encodedBytes: sample.bytes.byteLength, - decodedWidth: 640, - decodedHeight: 360, - }, - }); - expect(fetcher).toHaveBeenCalledWith( - exactUrl, - expect.objectContaining({ - credentials: "omit", - redirect: "error", - mode: "cors", - cache: "no-store", - referrerPolicy: "no-referrer", - }), - ); - } - expect(close).toHaveBeenCalledTimes(samples.length); - }); - - it("fails closed on duplicate/conflicting cache directives and oversized bodies", async () => { - const png = pngBytes(640, 360); - const createBitmap = vi.fn(async () => ({ - width: 640, - height: 360, - close: vi.fn(), - })); - for (const cacheControl of [ - // BT-IMG-02. Unmatched quotes must not be unwrapped into a bare number. - 'public, max-age="31536000, immutable', - 'public, max-age=31536000", immutable', - 'public, max-age="31536000\\", immutable', - "public, public, max-age=31536000, immutable", - "public, max-age=31536000, s-maxage=60, immutable", - "public, max-age=31536000, immutable, must-revalidate", - "public=1, max-age=31536000, immutable", - "public, max-age=31536000, immutable=true", - ]) { - const probe = createBrowserImageProbe({ - fetcher: (async () => - responseAt(imageUrl, png, { - status: 200, - headers: { - "cache-control": cacheControl, - "content-type": "image/png", - }, - })) as typeof fetch, - createBitmap, - }); - await expect(probe.probe(request())).resolves.toMatchObject({ - ok: false, - error: { code: "POLICY_REJECTED" }, - }); - } - - const probe = createBrowserImageProbe({ - fetcher: (async () => - responseAt(imageUrl, new Uint8Array(2_048), { - status: 200, - headers: { - "cache-control": - "public, max-age=31536000, immutable", - "content-type": "image/png", - }, - })) as typeof fetch, - createBitmap, - }); - await expect(probe.probe(request())).resolves.toMatchObject({ - ok: false, - error: { code: "LIMIT_EXCEEDED" }, - }); - expect(createBitmap).not.toHaveBeenCalled(); - }); - - it("rejects non-identity content encoding and mismatched declared lengths", async () => { - const png = pngBytes(640, 360); - const createBitmap = vi.fn(); - const cases = [ - { - headers: { - "content-encoding": "gzip", - "content-length": String(png.byteLength), - }, - code: "POLICY_REJECTED", - }, - { - headers: { - "content-length": String(png.byteLength + 1), - }, - code: "INTEGRITY_FAILED", - }, - ]; - for (const invalid of cases) { - const headers = publicImageHeaders("image/png"); - for (const [name, value] of Object.entries(invalid.headers)) { - headers.set(name, value); - } - const probe = createBrowserImageProbe({ - fetcher: (async () => - responseAt(imageUrl, png, { - status: 200, - headers, - })) as typeof fetch, - createBitmap, - }); - await expect(probe.probe(request())).resolves.toMatchObject({ - ok: false, - error: { code: invalid.code }, - }); - } - expect(createBitmap).not.toHaveBeenCalled(); - }); - - it("rejects malicious dimensions and animated PNG/WebP before native decode", async () => { - const createBitmap = vi.fn(async () => ({ - width: 640, - height: 360, - close: vi.fn(), - })); - const cases = [ - { - bytes: pngBytes(20_000, 20_000), - mediaType: "image/png" as const, - code: "LIMIT_EXCEEDED", - }, - { - bytes: pngBytes(320, 180), - mediaType: "image/png" as const, - code: "INTEGRITY_FAILED", - }, - { - bytes: jpegBytes(320, 180), - mediaType: "image/jpeg" as const, - code: "INTEGRITY_FAILED", - }, - { - bytes: webpBytes(10_000, 10_000), - mediaType: "image/webp" as const, - code: "LIMIT_EXCEEDED", - }, - { - bytes: avifBytes(20_000, 20_000), - mediaType: "image/avif" as const, - code: "LIMIT_EXCEEDED", - }, - { - bytes: pngBytes(640, 360, true), - mediaType: "image/png" as const, - code: "INTEGRITY_FAILED", - }, - { - bytes: webpBytes(640, 360, true), - mediaType: "image/webp" as const, - code: "INTEGRITY_FAILED", - }, - { - bytes: avifBytes(640, 360, "avis"), - mediaType: "image/avif" as const, - code: "INTEGRITY_FAILED", - }, - ]; - for (const malicious of cases) { - const url = imageUrl.replace( - "format=png", - `format=${malicious.mediaType.slice("image/".length)}`, - ); - const probe = createBrowserImageProbe({ - fetcher: (async () => - responseAt(url, malicious.bytes, { - status: 200, - headers: publicImageHeaders( - malicious.mediaType, - malicious.bytes.byteLength, - ), - })) as typeof fetch, - createBitmap, - }); - await expect( - probe.probe( - request({ - absoluteUrl: url, - expectedMediaType: malicious.mediaType, - }), - ), - ).resolves.toMatchObject({ - ok: false, - error: { code: malicious.code }, - }); - } - expect(createBitmap).not.toHaveBeenCalled(); - }); - - it("enforces private no-store, omitted credentials and the exact final URL", async () => { - const png = pngBytes(640, 360); - const fetcher = vi.fn(async () => - responseAt(imageUrl, png, { - status: 200, - headers: { - // TR-RR-09. A private response carries `no-store` and nothing else - // that describes cacheability. - "cache-control": "no-store", - "content-type": "image/png", - }, - }), - ); - const probe = createBrowserImageProbe({ - fetcher: fetcher as typeof fetch, - createBitmap: async () => ({ - width: 640, - height: 360, - close: vi.fn(), - }), - }); - await expect( - probe.probe( - request({ - delivery: "PRIVATE_SIGNED", - minimumPublicMaxAgeSeconds: 0, - }), - ), - ).resolves.toMatchObject({ ok: true }); - expect(fetcher).toHaveBeenCalledWith( - imageUrl, - expect.objectContaining({ - cache: "no-store", - credentials: "omit", - redirect: "error", - }), - ); - - for (const response of [ - responseAt(imageUrl, png, { - status: 200, - headers: { - "cache-control": "private, no-store=value", - "content-type": "image/png", - }, - }), - responseAt(imageUrl, png, { - status: 200, - headers: { - "cache-control": "public, no-store", - "content-type": "image/png", - }, - }), - responseAt( - "https://images.example.test/v1/assets/other", - png, - { - status: 200, - headers: { - "cache-control": "private, no-store", - "content-type": "image/png", - }, - }, - ), - ]) { - const rejectingProbe = createBrowserImageProbe({ - fetcher: (async () => response) as typeof fetch, - createBitmap: async () => ({ - width: 640, - height: 360, - close: vi.fn(), - }), - }); - await expect( - rejectingProbe.probe( - request({ - delivery: "PRIVATE_SIGNED", - minimumPublicMaxAgeSeconds: 0, - }), - ), - ).resolves.toMatchObject({ - ok: false, - error: { code: "POLICY_REJECTED" }, - }); - } - - await expect( - probe.probe( - request({ - expectedMediaType: "image/svg+xml", - } as unknown as Partial), - ), - ).resolves.toMatchObject({ - ok: false, - error: { code: "POLICY_REJECTED" }, - }); - }); - - /** - * TR-RR-09. The recorded BT-IMG-02 contract for a private response is a - * fail-closed matrix. Accepting `no-store` next to a directive that describes - * cacheability lets a self-contradictory policy read as acceptable. - */ - it("applies the full private Cache-Control matrix", async () => { - const png = pngBytes(640, 360); - const probeWith = async (cacheControl: string) => { - const probe = createBrowserImageProbe({ - fetcher: (async () => - responseAt(imageUrl, png, { - status: 200, - headers: { - "cache-control": cacheControl, - "content-type": "image/png", - }, - })) as typeof fetch, - createBitmap: async () => ({ - width: 640, - height: 360, - close: vi.fn(), - }), - }); - return await probe.probe( - request({ - delivery: "PRIVATE_SIGNED", - minimumPublicMaxAgeSeconds: 0, - }), - ); - }; - - // Only `no-store`, plus a syntactically valid unknown extension. - expect(await probeWith("no-store")).toMatchObject({ ok: true }); - expect(await probeWith('no-store, x-vendor="a,b"')).toMatchObject({ - ok: true, - }); - - for (const companion of [ - "public", - "private", - "immutable", - "max-age=60", - "s-maxage=60", - "no-cache", - "must-revalidate", - "proxy-revalidate", - ]) { - expect(await probeWith(`no-store, ${companion}`)).toMatchObject({ - ok: false, - error: { code: "POLICY_REJECTED" }, - }); - } - - for (const withoutNoStore of ["private", "no-cache", "max-age=0"]) { - expect(await probeWith(withoutNoStore)).toMatchObject({ - ok: false, - error: { code: "POLICY_REJECTED" }, - }); - } - }); - - it("times out a stalled body, aborts the composed signal and cancels its reader", async () => { - const manual = manualImageProbeScheduler(); - const cancel = vi.fn(async () => undefined); - const releaseLock = vi.fn(); - const read = vi.fn( - () => - new Promise>( - () => undefined, - ), - ); - const response = { - body: { - getReader: () => ({ cancel, read, releaseLock }), - }, - headers: publicImageHeaders("image/png"), - ok: true, - redirected: false, - status: 200, - type: "cors", - url: imageUrl, - } as unknown as Response; - const fetcher = vi.fn( - async (_input: RequestInfo | URL, _init?: RequestInit) => - response, - ); - const probe = createBrowserImageProbe({ - fetcher: fetcher as typeof fetch, - createBitmap: vi.fn(), - timeoutMs: 1_000, - scheduler: manual.scheduler, - }); - const probeRequest = request(); - const pending = probe.probe(probeRequest); - await vi.waitFor(() => { - expect(read).toHaveBeenCalledOnce(); - }); - const composedSignal = fetcher.mock.calls[0]?.[1]?.signal as - | AbortSignal - | null - | undefined; - expect(composedSignal).not.toBe(probeRequest.signal); - manual.fire(); - - await expect(pending).resolves.toMatchObject({ - ok: false, - error: { - code: "UNAVAILABLE", - retryable: true, - recovery: "RETRY", - }, - }); - expect(cancel).toHaveBeenCalledOnce(); - expect(releaseLock).toHaveBeenCalledOnce(); - expect(composedSignal?.aborted).toBe(true); - }); - - it("times out stalled decode and closes a bitmap that resolves late", async () => { - const manual = manualImageProbeScheduler(); - const close = vi.fn(); - let finishDecode: - ((bitmap: { - width: number; - height: number; - close(): void; - }) => void) | undefined; - const createBitmap = vi.fn( - () => - new Promise<{ - width: number; - height: number; - close(): void; - }>((resolve) => { - finishDecode = resolve; - }), - ); - const png = pngBytes(640, 360); - const probe = createBrowserImageProbe({ - fetcher: (async () => - responseAt(imageUrl, png, { - status: 200, - headers: publicImageHeaders("image/png", png.byteLength), - })) as typeof fetch, - createBitmap, - timeoutMs: 1_000, - scheduler: manual.scheduler, - }); - const pending = probe.probe(request()); - await vi.waitFor(() => { - expect(createBitmap).toHaveBeenCalledOnce(); - }); - manual.fire(); - await expect(pending).resolves.toMatchObject({ - ok: false, - error: { code: "UNAVAILABLE" }, - }); - - finishDecode?.({ width: 640, height: 360, close }); - await vi.waitFor(() => { - expect(close).toHaveBeenCalledOnce(); - }); - }); - - /** - * X-AUDIT-02. `probe()` promises a `BrowserDataResult`. A scheduler that - * cannot install the probe deadline must close the probe inside that contract - * rather than rejecting it, and must not leave the caller's listener behind. - */ - describe("scheduler boundary", () => { - const trackedSignal = () => { - const controller = new AbortController(); - const added: string[] = []; - const removed: string[] = []; - const add = controller.signal.addEventListener.bind(controller.signal); - const remove = controller.signal.removeEventListener.bind( - controller.signal, - ); - Object.defineProperty(controller.signal, "addEventListener", { - configurable: true, - value: (type: string, ...rest: readonly unknown[]) => { - added.push(type); - return (add as (...args: readonly unknown[]) => unknown)( - type, - ...rest, - ); - }, - }); - Object.defineProperty(controller.signal, "removeEventListener", { - configurable: true, - value: (type: string, ...rest: readonly unknown[]) => { - removed.push(type); - return (remove as (...args: readonly unknown[]) => unknown)( - type, - ...rest, - ); - }, - }); - return { controller, added, removed }; - }; - - it("closes the probe when the scheduler cannot install the deadline", async () => { - const fetcher = vi.fn(async () => new Response(null, { status: 200 })); - const { controller, added, removed } = trackedSignal(); - const probe = createBrowserImageProbe({ - fetcher: fetcher as unknown as typeof fetch, - createBitmap: vi.fn(), - timeoutMs: 1_000, - scheduler: { - setTimeout: () => { - throw new TypeError("image scheduler install exploded"); - }, - clearTimeout: vi.fn(), - }, - }); - - await expect( - probe.probe({ ...request(), signal: controller.signal }), - ).resolves.toMatchObject({ - ok: false, - error: { code: "UNAVAILABLE", retryable: true, recovery: "RETRY" }, - }); - expect(fetcher).not.toHaveBeenCalled(); - expect(added.filter((type) => type === "abort")).toHaveLength(1); - expect(removed.filter((type) => type === "abort")).toHaveLength(1); - }); - - it("starts no timer and no fetch for an already aborted caller", async () => { - const fetcher = vi.fn(async () => new Response(null, { status: 200 })); - const setTimeout_ = vi.fn(() => 1); - const controller = new AbortController(); - controller.abort(); - const probe = createBrowserImageProbe({ - fetcher: fetcher as unknown as typeof fetch, - createBitmap: vi.fn(), - timeoutMs: 1_000, - scheduler: { setTimeout: setTimeout_, clearTimeout: vi.fn() }, - }); - - await expect( - probe.probe({ ...request(), signal: controller.signal }), - ).resolves.toMatchObject({ ok: false, error: { code: "ABORTED" } }); - expect(fetcher).not.toHaveBeenCalled(); - expect(setTimeout_).not.toHaveBeenCalled(); - }); - - it("keeps the classified outcome when clearing the deadline throws", async () => { - const png = pngBytes(640, 360); - const probe = createBrowserImageProbe({ - fetcher: (async () => - responseAt(imageUrl, png, { - status: 200, - headers: publicImageHeaders("image/png", png.byteLength), - })) as typeof fetch, - createBitmap: vi.fn(async () => ({ - width: 640, - height: 360, - close: vi.fn(), - })), - timeoutMs: 1_000, - scheduler: { - setTimeout: (callback: () => void, milliseconds: number) => - setTimeout(callback, milliseconds), - clearTimeout: () => { - throw new TypeError("image scheduler clear exploded"); - }, - }, - }); - - await expect(probe.probe(request())).resolves.toMatchObject({ ok: true }); - }); - }); -}); - -describe("P-256 image capability verifier", () => { - it("rejects an ECDSA public key on any curve other than P-256", async () => { - const generated = await globalThis.crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-384" }, - false, - ["sign", "verify"], - ); - if (!("publicKey" in generated)) { - throw new TypeError("Expected an ECDSA key pair."); - } - expect(() => - createP256ImageCapabilityVerifier({ - subtle: globalThis.crypto.subtle, - publicKeys: [ - { - keyId: "image-signing-wrong-curve", - key: generated.publicKey, - }, - ], - }), - ).toThrow(/public key binding/u); - }); - - it("verifies the exact canonical payload and rejects tampering", async () => { - const generated = await globalThis.crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign", "verify"], - ); - if (!("privateKey" in generated)) { - throw new TypeError("Expected an ECDSA key pair."); - } - const payload = new TextEncoder().encode( - '["image-cdn-capability-v1","bound"]', - ); - const payloadBuffer = new Uint8Array(payload.byteLength); - payloadBuffer.set(payload); - const signature = await globalThis.crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - generated.privateKey, - payloadBuffer.buffer, - ); - const verifier = createP256ImageCapabilityVerifier({ - subtle: globalThis.crypto.subtle, - publicKeys: [ - { - keyId: "image-signing-2026-01", - key: generated.publicKey, - }, - ], - }); - expect(verifier.acceptsKey("image-signing-2026-01")).toBe( - true, - ); - expect(verifier.acceptsKey("image-signing-unknown")).toBe( - false, - ); - const signatureBase64Url = base64Url( - new Uint8Array(signature), - ); - await expect( - verifier.verify({ - algorithm: "ECDSA_P256_SHA256", - keyId: "image-signing-2026-01", - canonicalPayload: payload, - signatureBase64Url, - }), - ).resolves.toBe(true); - const tampered = Uint8Array.from(payload); - tampered[0] ^= 1; - await expect( - verifier.verify({ - algorithm: "ECDSA_P256_SHA256", - keyId: "image-signing-2026-01", - canonicalPayload: tampered, - signatureBase64Url, - }), - ).resolves.toBe(false); - }); -}); - -function responseAt( - url: string, - body: Uint8Array, - init: ResponseInit, -): Response { - const responseBytes = new Uint8Array(body.byteLength); - responseBytes.set(body); - const response = new Response(responseBytes.buffer, init); - Object.defineProperty(response, "url", { - configurable: false, - enumerable: true, - value: url, - }); - return response; -} - -function publicImageHeaders( - mediaType: string, - contentLength?: number, -): Headers { - const headers = new Headers({ - "cache-control": - "public, max-age=31536000, s-maxage=31536000, immutable", - "content-type": mediaType, - vary: "Accept-Encoding", - }); - if (contentLength !== undefined) { - headers.set("content-length", String(contentLength)); - } - return headers; -} - -function pngBytes( - width: number, - height: number, - animated = false, -): Uint8Array { - const header = new Uint8Array(13); - const headerView = new DataView(header.buffer); - headerView.setUint32(0, width); - headerView.setUint32(4, height); - header[8] = 8; - header[9] = 6; - return concatenateBytes([ - Uint8Array.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - ]), - pngChunk("IHDR", header), - ...(animated - ? [pngChunk("acTL", new Uint8Array(8))] - : []), - pngChunk("IDAT", new Uint8Array()), - pngChunk("IEND", new Uint8Array()), - ]); -} - -function pngChunk(type: string, payload: Uint8Array): Uint8Array { - const chunk = new Uint8Array(12 + payload.byteLength); - const view = new DataView(chunk.buffer); - view.setUint32(0, payload.byteLength); - writeAscii(chunk, 4, type); - chunk.set(payload, 8); - return chunk; -} - -function jpegBytes(width: number, height: number): Uint8Array { - return Uint8Array.from([ - 0xff, - 0xd8, - 0xff, - 0xc0, - 0x00, - 0x11, - 0x08, - (height >>> 8) & 0xff, - height & 0xff, - (width >>> 8) & 0xff, - width & 0xff, - 0x03, - 0x01, - 0x11, - 0x00, - 0x02, - 0x11, - 0x00, - 0x03, - 0x11, - 0x00, - 0xff, - 0xda, - ]); -} - -function webpBytes( - width: number, - height: number, - animated = false, -): Uint8Array { - const chunkType = animated ? "VP8X" : "VP8 "; - const payload = new Uint8Array(10); - if (animated) { - payload[0] = 0x02; - writeUint24LittleEndian(payload, 4, width - 1); - writeUint24LittleEndian(payload, 7, height - 1); - } else { - payload.set([0x9d, 0x01, 0x2a], 3); - const view = new DataView(payload.buffer); - view.setUint16(6, width, true); - view.setUint16(8, height, true); - } - const chunk = concatenateBytes([ - asciiBytes(chunkType), - littleEndianUint32(payload.byteLength), - payload, - ]); - return concatenateBytes([ - asciiBytes("RIFF"), - littleEndianUint32(4 + chunk.byteLength), - asciiBytes("WEBP"), - chunk, - ]); -} - -function avifBytes( - width: number, - height: number, - brand = "avif", -): Uint8Array { - const fileType = isoBox( - "ftyp", - concatenateBytes([ - asciiBytes(brand), - new Uint8Array(4), - asciiBytes(brand), - ]), - ); - const spatialExtent = new Uint8Array(12); - const extentView = new DataView(spatialExtent.buffer); - extentView.setUint32(4, width); - extentView.setUint32(8, height); - const primaryItem = new Uint8Array(6); - new DataView(primaryItem.buffer).setUint16(4, 1); - const itemInfoEntry = new Uint8Array(13); - itemInfoEntry[0] = 2; - const itemInfoView = new DataView(itemInfoEntry.buffer); - itemInfoView.setUint16(4, 1); - writeAscii(itemInfoEntry, 8, "av01"); - const itemInfo = new Uint8Array(6); - new DataView(itemInfo.buffer).setUint16(4, 1); - const propertyAssociation = new Uint8Array(12); - const associationView = new DataView( - propertyAssociation.buffer, - ); - associationView.setUint32(4, 1); - associationView.setUint16(8, 1); - propertyAssociation[10] = 1; - propertyAssociation[11] = 0x81; - const properties = isoBox( - "iprp", - concatenateBytes([ - isoBox("ipco", isoBox("ispe", spatialExtent)), - isoBox("ipma", propertyAssociation), - ]), - ); - const metadata = isoBox( - "meta", - concatenateBytes([ - new Uint8Array(4), - isoBox("pitm", primaryItem), - isoBox( - "iinf", - concatenateBytes([ - itemInfo, - isoBox("infe", itemInfoEntry), - ]), - ), - properties, - ]), - ); - return concatenateBytes([ - fileType, - metadata, - isoBox("mdat", Uint8Array.of(0)), - ]); -} - -function isoBox(type: string, payload: Uint8Array): Uint8Array { - const box = new Uint8Array(8 + payload.byteLength); - const view = new DataView(box.buffer); - view.setUint32(0, box.byteLength); - writeAscii(box, 4, type); - box.set(payload, 8); - return box; -} - -function littleEndianUint32(value: number): Uint8Array { - const bytes = new Uint8Array(4); - new DataView(bytes.buffer).setUint32(0, value, true); - return bytes; -} - -function writeUint24LittleEndian( - bytes: Uint8Array, - offset: number, - value: number, -): void { - bytes[offset] = value & 0xff; - bytes[offset + 1] = (value >>> 8) & 0xff; - bytes[offset + 2] = (value >>> 16) & 0xff; -} - -function asciiBytes(value: string): Uint8Array { - return Uint8Array.from( - [...value].map((character) => character.charCodeAt(0)), - ); -} - -function writeAscii( - target: Uint8Array, - offset: number, - value: string, -): void { - target.set(asciiBytes(value), offset); -} - -function concatenateBytes( - chunks: readonly Uint8Array[], -): Uint8Array { - const combined = new Uint8Array( - chunks.reduce((total, chunk) => total + chunk.byteLength, 0), - ); - let offset = 0; - for (const chunk of chunks) { - combined.set(chunk, offset); - offset += chunk.byteLength; - } - return combined; -} - -function manualImageProbeScheduler(): Readonly<{ - scheduler: ImageProbeScheduler; - fire(): void; -}> { - let callback: (() => void) | undefined; - return { - scheduler: { - setTimeout(nextCallback) { - callback = nextCallback; - return 1; - }, - clearTimeout: vi.fn(), - }, - fire() { - if (!callback) { - throw new TypeError("No image probe timeout is scheduled."); - } - callback(); - }, - }; -} - -function manualCapabilityVerificationScheduler(): Readonly<{ - scheduler: ImageCapabilityVerificationScheduler; - delays: readonly number[]; - clearTimeout: ReturnType; - fire(): void; -}> { - let callback: (() => void) | undefined; - const delays: number[] = []; - const clearTimeout = vi.fn(); - return { - scheduler: { - setTimeout(nextCallback, milliseconds) { - callback = nextCallback; - delays.push(milliseconds); - return 1; - }, - clearTimeout, - }, - delays, - clearTimeout, - fire() { - if (!callback) { - throw new TypeError( - "No capability verification timeout is scheduled.", - ); - } - callback(); - }, - }; -} - -function base64Url(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary) - .replace(/\+/gu, "-") - .replace(/\//gu, "_") - .replace(/=+$/gu, ""); -} diff --git a/tests/unit/image-cdn-test-fixture.ts b/tests/unit/image-cdn-test-fixture.ts new file mode 100644 index 0000000..7999c26 --- /dev/null +++ b/tests/unit/image-cdn-test-fixture.ts @@ -0,0 +1,309 @@ +import { vi } from "vitest"; + +import type { + ImageProbeScheduler, +} from "../../src/adapters/browser-transfer/image-cdn/browser-image-probe.ts"; +import type { + ImageCapabilityVerificationScheduler, +} from "../../src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts"; + +export function responseAt( + url: string, + body: Uint8Array, + init: ResponseInit, +): Response { + const responseBytes = new Uint8Array(body.byteLength); + responseBytes.set(body); + const response = new Response(responseBytes.buffer, init); + Object.defineProperty(response, "url", { + configurable: false, + enumerable: true, + value: url, + }); + return response; +} + +export function publicImageHeaders( + mediaType: string, + contentLength?: number, +): Headers { + const headers = new Headers({ + "cache-control": + "public, max-age=31536000, s-maxage=31536000, immutable", + "content-type": mediaType, + vary: "Accept-Encoding", + }); + if (contentLength !== undefined) { + headers.set("content-length", String(contentLength)); + } + return headers; +} + +export function pngBytes( + width: number, + height: number, + animated = false, +): Uint8Array { + const header = new Uint8Array(13); + const headerView = new DataView(header.buffer); + headerView.setUint32(0, width); + headerView.setUint32(4, height); + header[8] = 8; + header[9] = 6; + return concatenateBytes([ + Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]), + pngChunk("IHDR", header), + ...(animated + ? [pngChunk("acTL", new Uint8Array(8))] + : []), + pngChunk("IDAT", new Uint8Array()), + pngChunk("IEND", new Uint8Array()), + ]); +} + +function pngChunk(type: string, payload: Uint8Array): Uint8Array { + const chunk = new Uint8Array(12 + payload.byteLength); + const view = new DataView(chunk.buffer); + view.setUint32(0, payload.byteLength); + writeAscii(chunk, 4, type); + chunk.set(payload, 8); + return chunk; +} + +export function jpegBytes(width: number, height: number): Uint8Array { + return Uint8Array.from([ + 0xff, + 0xd8, + 0xff, + 0xc0, + 0x00, + 0x11, + 0x08, + (height >>> 8) & 0xff, + height & 0xff, + (width >>> 8) & 0xff, + width & 0xff, + 0x03, + 0x01, + 0x11, + 0x00, + 0x02, + 0x11, + 0x00, + 0x03, + 0x11, + 0x00, + 0xff, + 0xda, + ]); +} + +export function webpBytes( + width: number, + height: number, + animated = false, +): Uint8Array { + const chunkType = animated ? "VP8X" : "VP8 "; + const payload = new Uint8Array(10); + if (animated) { + payload[0] = 0x02; + writeUint24LittleEndian(payload, 4, width - 1); + writeUint24LittleEndian(payload, 7, height - 1); + } else { + payload.set([0x9d, 0x01, 0x2a], 3); + const view = new DataView(payload.buffer); + view.setUint16(6, width, true); + view.setUint16(8, height, true); + } + const chunk = concatenateBytes([ + asciiBytes(chunkType), + littleEndianUint32(payload.byteLength), + payload, + ]); + return concatenateBytes([ + asciiBytes("RIFF"), + littleEndianUint32(4 + chunk.byteLength), + asciiBytes("WEBP"), + chunk, + ]); +} + +export function avifBytes( + width: number, + height: number, + brand = "avif", +): Uint8Array { + const fileType = isoBox( + "ftyp", + concatenateBytes([ + asciiBytes(brand), + new Uint8Array(4), + asciiBytes(brand), + ]), + ); + const spatialExtent = new Uint8Array(12); + const extentView = new DataView(spatialExtent.buffer); + extentView.setUint32(4, width); + extentView.setUint32(8, height); + const primaryItem = new Uint8Array(6); + new DataView(primaryItem.buffer).setUint16(4, 1); + const itemInfoEntry = new Uint8Array(13); + itemInfoEntry[0] = 2; + const itemInfoView = new DataView(itemInfoEntry.buffer); + itemInfoView.setUint16(4, 1); + writeAscii(itemInfoEntry, 8, "av01"); + const itemInfo = new Uint8Array(6); + new DataView(itemInfo.buffer).setUint16(4, 1); + const propertyAssociation = new Uint8Array(12); + const associationView = new DataView( + propertyAssociation.buffer, + ); + associationView.setUint32(4, 1); + associationView.setUint16(8, 1); + propertyAssociation[10] = 1; + propertyAssociation[11] = 0x81; + const properties = isoBox( + "iprp", + concatenateBytes([ + isoBox("ipco", isoBox("ispe", spatialExtent)), + isoBox("ipma", propertyAssociation), + ]), + ); + const metadata = isoBox( + "meta", + concatenateBytes([ + new Uint8Array(4), + isoBox("pitm", primaryItem), + isoBox( + "iinf", + concatenateBytes([ + itemInfo, + isoBox("infe", itemInfoEntry), + ]), + ), + properties, + ]), + ); + return concatenateBytes([ + fileType, + metadata, + isoBox("mdat", Uint8Array.of(0)), + ]); +} + +export function isoBox(type: string, payload: Uint8Array): Uint8Array { + const box = new Uint8Array(8 + payload.byteLength); + const view = new DataView(box.buffer); + view.setUint32(0, box.byteLength); + writeAscii(box, 4, type); + box.set(payload, 8); + return box; +} + +export function littleEndianUint32(value: number): Uint8Array { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setUint32(0, value, true); + return bytes; +} + +export function writeUint24LittleEndian( + bytes: Uint8Array, + offset: number, + value: number, +): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >>> 8) & 0xff; + bytes[offset + 2] = (value >>> 16) & 0xff; +} + +export function asciiBytes(value: string): Uint8Array { + return Uint8Array.from( + [...value].map((character) => character.charCodeAt(0)), + ); +} + +export function writeAscii( + target: Uint8Array, + offset: number, + value: string, +): void { + target.set(asciiBytes(value), offset); +} + +export function concatenateBytes( + chunks: readonly Uint8Array[], +): Uint8Array { + const combined = new Uint8Array( + chunks.reduce((total, chunk) => total + chunk.byteLength, 0), + ); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return combined; +} + +export function manualImageProbeScheduler(): Readonly<{ + scheduler: ImageProbeScheduler; + fire(): void; +}> { + let callback: (() => void) | undefined; + return { + scheduler: { + setTimeout(nextCallback) { + callback = nextCallback; + return 1; + }, + clearTimeout: vi.fn(), + }, + fire() { + if (!callback) { + throw new TypeError("No image probe timeout is scheduled."); + } + callback(); + }, + }; +} + +export function manualCapabilityVerificationScheduler(): Readonly<{ + scheduler: ImageCapabilityVerificationScheduler; + delays: readonly number[]; + clearTimeout: ReturnType; + fire(): void; +}> { + let callback: (() => void) | undefined; + const delays: number[] = []; + const clearTimeout = vi.fn(); + return { + scheduler: { + setTimeout(nextCallback, milliseconds) { + callback = nextCallback; + delays.push(milliseconds); + return 1; + }, + clearTimeout, + }, + delays, + clearTimeout, + fire() { + if (!callback) { + throw new TypeError( + "No capability verification timeout is scheduled.", + ); + } + callback(); + }, + }; +} + +export function base64Url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replace(/\+/gu, "-") + .replace(/\//gu, "_") + .replace(/=+$/gu, ""); +} diff --git a/tests/unit/legacy-and-optional-network-remediation.test.ts b/tests/unit/legacy-and-optional-network-remediation.test.ts index a66b870..5c24f2e 100644 --- a/tests/unit/legacy-and-optional-network-remediation.test.ts +++ b/tests/unit/legacy-and-optional-network-remediation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/cursor-pagination-runtime.ts"; +import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/index.ts"; import { defineMutationIntent, isValidIdempotencyKey, @@ -8,8 +8,8 @@ import { import type { CursorPage, CursorPaginationProfile, -} from "../../src/contracts/cursor-pagination.ts"; -import type { Result } from "../../src/application/result.ts"; +} from "../../src/adapters/query-cache/index.ts"; +import type { Result } from "../../src/contracts/result.ts"; const PROFILE: CursorPaginationProfile = Object.freeze({ profileId: "TEST_PAGINATION_V1", diff --git a/tests/unit/presigned-transfer-delivery.test.ts b/tests/unit/presigned-transfer-delivery.test.ts new file mode 100644 index 0000000..65d7f0d --- /dev/null +++ b/tests/unit/presigned-transfer-delivery.test.ts @@ -0,0 +1,420 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + BrowserFilePolicyRegistry, + browserFilePolicyReference, +} from "../../src/adapters/browser-files/browser-file-policy-registry.ts"; +import { + createDownloadDeliveryAdapter, + type SaveFileHandle, +} from "../../src/adapters/browser-files/download-delivery-adapter.ts"; +import { + CHECKSUM_HEADER, + CONTROL_ENDPOINT, + DATA_ORIGIN, + DIGEST_HEADER, + DOWNLOAD_HREF, + DOWNLOAD_PATH, + NOW, + POLICY_HEADER, + REQUEST_BINDING_SHA256, + UPLOAD_SESSION_ID, + collect, + createHarness, + downloadCapabilityPayload, + downloadResponse, + jsonResponse, + responseWithUrl, + uploadCapabilityPayload, +} from "./presigned-transfer-fixture.ts"; + +describe("presigned DownloadDelivery integration", () => { + it("connects an issued capability to DownloadDeliveryPort without caller digest input", async () => { + const bytes = new TextEncoder().encode("verified"); + const payload = downloadCapabilityPayload(bytes); + const fetcher = vi.fn(async (input: RequestInfo | URL) => + String(input) === CONTROL_ENDPOINT + ? jsonResponse(payload) + : downloadResponse(bytes.slice().buffer, payload), + ) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const issued = await provider.issueDownload({ + resourceId: "resource-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + + const policy = browserFilePolicyReference( + "download", + "presigned-stream", + ); + const policies = new BrowserFilePolicyRegistry({ + profiles: [ + { + reference: policy, + download: { + strategy: "PROMPT_AND_STREAM", + mediaType: "application/octet-stream", + safeExtension: ".bin", + maxTransferBytes: 64, + maxBufferedBytes: 8, + integrity: "REQUIRED", + }, + }, + ], + hardLimits: { + maxInspectionBytes: 64, + maxRetainedFileBytes: 64, + maxPreviewBytes: 64, + maxObjectUrlBytes: 64, + maxTransferBytes: 64, + }, + }); + const written: number[] = []; + const handle: SaveFileHandle = { + async createWritable() { + return new WritableStream({ + write(chunk) { + written.push(...chunk); + }, + }); + }, + }; + const downloads = createDownloadDeliveryAdapter({ + host: { handoff() {} }, + policies, + hardMaxObjectUrlBytes: 64, + hardMaxTransferBytes: 64, + browserManagedCapabilities: { + resolve() { + throw new TypeError("not used"); + }, + }, + openAuthorizedSource: + executor.downloadSources.open.bind(executor.downloadSources), + showSaveFilePicker: async () => handle, + userActivation: { isActive: true }, + now: () => NOW, + }); + const result = await downloads.deliver({ + policy, + source: { + kind: "AUTHORIZED_STREAM_RESOURCE", + resourceId: "resource-1", + capability: issued.value, + }, + suggestedFileName: "artifact.bin", + signal: new AbortController().signal, + onProgress() {}, + }); + expect(result).toMatchObject({ + ok: true, + value: { + kind: "SAVED", + integrity: "VERIFIED", + bytesWritten: bytes.byteLength, + }, + }); + expect(written).toEqual([...bytes]); + }); + + /** + * TR-RR-04. A presigned byte source owns a fetch reader and a capability + * lease, and its port requires `close()`. The delivery consumer never called + * it, so every outcome — success, validation failure, writer failure and + * abort — leaked both. + */ + it.each([ + { label: "success", mode: "SUCCESS" as const }, + { label: "writer failure", mode: "WRITER_FAILURE" as const }, + { label: "abort", mode: "ABORT" as const }, + ])("closes the presigned source exactly once on $label", async ({ mode }) => { + const bytes = new Uint8Array([1, 2, 3]); + let closes = 0; + const controller = new AbortController(); + const source = { + byteLength: bytes.byteLength, + integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const, + capability: undefined as never, + close() { + closes += 1; + }, + async *stream() { + if (mode === "ABORT") controller.abort(); + yield { ok: true as const, value: bytes }; + }, + }; + const capability = Object.freeze({ + capabilityReceipt: "capability-close-1", + method: "GET" as const, + binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, + mediaType: "application/octet-stream", + byteLength: bytes.byteLength, + maxBytes: bytes.byteLength, + expectedSha256: "a".repeat(64), + expiresAtEpochMs: NOW + 60_000, + }); + source.capability = capability as never; + + const closePolicy = browserFilePolicyReference( + "download", + "presigned-close", + ); + const policies = new BrowserFilePolicyRegistry({ + profiles: [ + { + reference: closePolicy, + download: { + strategy: "PROMPT_AND_STREAM", + mediaType: "application/octet-stream", + safeExtension: ".bin", + maxTransferBytes: 64, + maxBufferedBytes: 8, + integrity: "REQUIRED", + }, + }, + ], + hardLimits: { + maxInspectionBytes: 64, + maxRetainedFileBytes: 64, + maxPreviewBytes: 64, + maxObjectUrlBytes: 64, + maxTransferBytes: 64, + }, + }); + const handle: SaveFileHandle = { + async createWritable() { + return new WritableStream({ + write() { + if (mode === "WRITER_FAILURE") { + throw new TypeError("writer exploded"); + } + }, + }); + }, + }; + const downloads = createDownloadDeliveryAdapter({ + host: { handoff() {} }, + policies, + hardMaxObjectUrlBytes: 64, + hardMaxTransferBytes: 64, + browserManagedCapabilities: { + resolve() { + throw new TypeError("not used"); + }, + }, + openAuthorizedSource: async () => + ({ ok: true, value: source }) as never, + showSaveFilePicker: async () => handle, + userActivation: { isActive: true }, + now: () => NOW, + }); + + const deliveryResult = await downloads.deliver({ + policy: closePolicy, + source: { + kind: "AUTHORIZED_STREAM_RESOURCE", + resourceId: "resource-1", + capability: capability as never, + }, + suggestedFileName: "artifact.bin", + signal: controller.signal, + onProgress() {}, + }); + + void deliveryResult; + expect(closes).toBe(1); + }); + + /** + * TR-02. A lease that resolved after the abort already ended the delivery + * never reached the holder, so nothing closed it: the fetch reader and the + * capability lease outlived the terminal result. + */ + it("closes a source lease that arrives after the delivery was aborted", async () => { + const bytes = new Uint8Array([1, 2, 3]); + let closes = 0; + const controller = new AbortController(); + let releaseOpen: + | ((value: { ok: true; value: unknown }) => void) + | undefined; + const source = { + byteLength: bytes.byteLength, + integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const, + capability: undefined as never, + close() { + closes += 1; + }, + async *stream() { + yield { ok: true as const, value: bytes }; + }, + }; + const capability = Object.freeze({ + capabilityReceipt: "capability-late-1", + method: "GET" as const, + binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, + mediaType: "application/octet-stream", + byteLength: bytes.byteLength, + maxBytes: bytes.byteLength, + expectedSha256: "a".repeat(64), + expiresAtEpochMs: NOW + 60_000, + }); + source.capability = capability as never; + + const latePolicy = browserFilePolicyReference("download", "presigned-late"); + const policies = new BrowserFilePolicyRegistry({ + profiles: [ + { + reference: latePolicy, + download: { + strategy: "PROMPT_AND_STREAM", + mediaType: "application/octet-stream", + safeExtension: ".bin", + maxTransferBytes: 64, + maxBufferedBytes: 8, + integrity: "REQUIRED", + }, + }, + ], + hardLimits: { + maxInspectionBytes: 64, + maxRetainedFileBytes: 64, + maxPreviewBytes: 64, + maxObjectUrlBytes: 64, + maxTransferBytes: 64, + }, + }); + const downloads = createDownloadDeliveryAdapter({ + host: { handoff() {} }, + policies, + hardMaxObjectUrlBytes: 64, + hardMaxTransferBytes: 64, + browserManagedCapabilities: { + resolve() { + throw new TypeError("not used"); + }, + }, + // Ignores the signal entirely and resolves only when the test says so. + openAuthorizedSource: () => + new Promise((resolve) => { + releaseOpen = resolve as never; + }) as never, + showSaveFilePicker: async () => ({ + async createWritable() { + return new WritableStream({ write() {} }); + }, + }), + userActivation: { isActive: true }, + now: () => NOW, + }); + + const delivering = downloads.deliver({ + policy: latePolicy, + source: { + kind: "AUTHORIZED_STREAM_RESOURCE", + resourceId: "resource-1", + capability: capability as never, + }, + suggestedFileName: "artifact.bin", + signal: controller.signal, + onProgress() {}, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(); + const delivered = await delivering; + expect(delivered.ok).toBe(false); + + // The lease arrives only now, long after the terminal result. + releaseOpen?.({ ok: true, value: source }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(closes).toBe(1); + }); + + it("does not leave a late rejection unhandled after an abort", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + const controller = new AbortController(); + let rejectOpen: ((reason: unknown) => void) | undefined; + const rejectPolicy = browserFilePolicyReference( + "download", + "presigned-late-reject", + ); + const policies = new BrowserFilePolicyRegistry({ + profiles: [ + { + reference: rejectPolicy, + download: { + strategy: "PROMPT_AND_STREAM", + mediaType: "application/octet-stream", + safeExtension: ".bin", + maxTransferBytes: 64, + maxBufferedBytes: 8, + integrity: "REQUIRED", + }, + }, + ], + hardLimits: { + maxInspectionBytes: 64, + maxRetainedFileBytes: 64, + maxPreviewBytes: 64, + maxObjectUrlBytes: 64, + maxTransferBytes: 64, + }, + }); + const downloads = createDownloadDeliveryAdapter({ + host: { handoff() {} }, + policies, + hardMaxObjectUrlBytes: 64, + hardMaxTransferBytes: 64, + browserManagedCapabilities: { + resolve() { + throw new TypeError("not used"); + }, + }, + openAuthorizedSource: () => + new Promise((_resolve, reject) => { + rejectOpen = reject; + }) as never, + showSaveFilePicker: async () => ({ + async createWritable() { + return new WritableStream({ write() {} }); + }, + }), + userActivation: { isActive: true }, + now: () => NOW, + }); + + const delivering = downloads.deliver({ + policy: rejectPolicy, + source: { + kind: "AUTHORIZED_STREAM_RESOURCE", + resourceId: "resource-1", + capability: Object.freeze({ + capabilityReceipt: "capability-late-2", + method: "GET" as const, + binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, + mediaType: "application/octet-stream", + byteLength: 3, + maxBytes: 3, + expectedSha256: "a".repeat(64), + expiresAtEpochMs: NOW + 60_000, + }) as never, + }, + suggestedFileName: "artifact.bin", + signal: controller.signal, + onProgress() {}, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(); + await delivering; + + rejectOpen?.(new Error("late open failure")); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + });}); diff --git a/tests/unit/presigned-transfer-download.test.ts b/tests/unit/presigned-transfer-download.test.ts new file mode 100644 index 0000000..998897d --- /dev/null +++ b/tests/unit/presigned-transfer-download.test.ts @@ -0,0 +1,650 @@ +import { describe, expect, it, vi } from "vitest"; + +import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts"; +import { createPresignedCapabilityVault } from "../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts"; +import { sha256Hex } from "../../src/adapters/browser-transfer/presigned/incremental-sha256.ts"; +import { + CHECKSUM_HEADER, + CONTROL_ENDPOINT, + DATA_ORIGIN, + DIGEST_HEADER, + DOWNLOAD_HREF, + DOWNLOAD_PATH, + NOW, + POLICY_HEADER, + REQUEST_BINDING_SHA256, + UPLOAD_SESSION_ID, + collect, + createHarness, + downloadCapabilityPayload, + downloadResponse, + jsonResponse, + responseWithUrl, + uploadCapabilityPayload, +} from "./presigned-transfer-fixture.ts"; + +describe("presigned download stream lifecycle", () => { + describe("TR-01 the stored capability is the one that was validated", () => { + const baseRegistration = () => ({ + protocol: PRESIGNED_TRANSFER_PROTOCOL, + capabilityReceipt: "capability-snapshot-1", + method: "GET" as const, + binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, + href: `${DATA_ORIGIN}${DOWNLOAD_PATH}`, + origin: DATA_ORIGIN, + path: DOWNLOAD_PATH, + allowedQueryParameters: [], + requestHeaders: [{ name: "x-safe", value: "1" }], + requiredResponseHeaders: [], + digestRequestHeader: null, + digestResponseHeader: null, + receiptResponseHeader: null, + expectedStatus: 200, + expectedResponseByteLength: 3, + mediaType: "application/octet-stream", + byteLength: 3, + maxBytes: 3, + expectedSha256: "a".repeat(64), + expiresAtEpochMs: NOW + 60_000, + }); + + const freshVault = () => + createPresignedCapabilityVault({ + now: () => NOW, + maxActiveCapabilities: 4, + }); + + it("refuses a header row that answers differently on a second read", () => { + const vault = freshVault(); + let nameReads = 0; + const header = new Proxy( + { name: "x-safe", value: "1" }, + { + getOwnPropertyDescriptor(target, key) { + if (key === "name") { + nameReads += 1; + return { + configurable: true, + enumerable: true, + value: nameReads > 1 ? "authorization" : "x-safe", + }; + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }, + ); + + const registered = vault.register({ + ...baseRegistration(), + requestHeaders: [header], + } as never); + + if (registered.ok) { + // A single read means the value that was checked is the value stored. + const resolved = vault.resolve(registered.value); + expect(resolved.ok).toBe(true); + if (resolved.ok) { + expect(resolved.value.requestHeaders.map((row) => row.name)).toEqual([ + "x-safe", + ]); + } + } + vault.dispose(); + }); + + const hostileRegistrations: readonly (readonly [string, () => unknown])[] = [ + [ + "an accessor field", + () => + Object.defineProperty(baseRegistration(), "href", { + enumerable: true, + get: () => `${DATA_ORIGIN}${DOWNLOAD_PATH}`, + }), + ], + [ + "an inherited field", + () => Object.assign(Object.create({ injected: true }), baseRegistration()), + ], + [ + "a symbol field", + () => ({ ...baseRegistration(), [Symbol.for("injected")]: true }), + ], + [ + "a non-enumerable own field", + () => + Object.defineProperty(baseRegistration(), "injected", { + enumerable: false, + value: true, + }), + ], + [ + "a throwing ownKeys trap", + () => + new Proxy(baseRegistration(), { + ownKeys() { + throw new TypeError("hostile ownKeys trap"); + }, + }), + ], + [ + "a null header array", + () => ({ ...baseRegistration(), requestHeaders: null }), + ], + [ + "a non-iterable header array", + () => ({ ...baseRegistration(), requestHeaders: { length: 1 } }), + ], + [ + "a header row with an extra field", + () => ({ + ...baseRegistration(), + requestHeaders: [{ name: "x-safe", value: "1", injected: true }], + }), + ], + [ + "an accessor header name", + () => ({ + ...baseRegistration(), + requestHeaders: [ + Object.defineProperty({ value: "1" }, "name", { + enumerable: true, + get: () => "x-safe", + }), + ], + }), + ], + [ + "a binding with an extra field", + () => ({ + ...baseRegistration(), + binding: { kind: "DOWNLOAD", resourceId: "r", injected: true }, + }), + ], + [ + "a null binding", + () => ({ ...baseRegistration(), binding: null }), + ], + ]; + + for (const [label, build] of hostileRegistrations) { + it(`rejects ${label} as POLICY_REJECTED`, () => { + const vault = freshVault(); + expect(vault.register(build() as never)).toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + vault.dispose(); + }); + } + + it("does not observe a mutation of the issuer's object after registration", () => { + const vault = freshVault(); + const registration = baseRegistration(); + const registered = vault.register(registration as never); + expect(registered.ok).toBe(true); + if (!registered.ok) return; + + registration.requestHeaders[0]!.name = "authorization"; + registration.expiresAtEpochMs = NOW + 999_999; + + const resolved = vault.resolve(registered.value); + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(resolved.value.requestHeaders.map((row) => row.name)).toEqual([ + "x-safe", + ]); + expect(resolved.value.expiresAtEpochMs).toBe(NOW + 60_000); + vault.dispose(); + }); + }); + + it("does not fetch a presigned download until stream consumption", async () => { + const bytes = new Uint8Array([1, 2, 3]); + const responsePayload = downloadCapabilityPayload(bytes); + let downloadFetches = 0; + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === CONTROL_ENDPOINT) { + return jsonResponse(responsePayload); + } + downloadFetches += 1; + return downloadResponse(bytes.slice().buffer, responsePayload); + }) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const signal = new AbortController().signal; + const issued = await provider.issueDownload({ + resourceId: "resource-1", + signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + + const opened = await executor.downloadSources.open({ + resourceId: "resource-1", + capability: issued.value, + signal, + }); + expect(opened.ok).toBe(true); + if (!opened.ok) return; + // BT-PRE-01. open() performs no network I/O. + expect(downloadFetches).toBe(0); + + for await (const chunk of opened.value.stream(signal)) { + expect(chunk.ok).toBe(true); + } + expect(downloadFetches).toBe(1); + opened.value.close(); + }); + + it("closes an unused download source without network I/O", async () => { + const bytes = new Uint8Array([1, 2, 3]); + const responsePayload = downloadCapabilityPayload(bytes); + let downloadFetches = 0; + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === CONTROL_ENDPOINT) { + return jsonResponse(responsePayload); + } + downloadFetches += 1; + return downloadResponse(bytes.slice().buffer, responsePayload); + }) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const signal = new AbortController().signal; + const issued = await provider.issueDownload({ + resourceId: "resource-1", + signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + + const opened = await executor.downloadSources.open({ + resourceId: "resource-1", + capability: issued.value, + signal, + }); + expect(opened.ok).toBe(true); + if (!opened.ok) return; + + opened.value.close(); + // close() is idempotent and never starts the transfer. + opened.value.close(); + expect(downloadFetches).toBe(0); + + // A stream after close is one terminal conflict, still without fetching. + const results = []; + for await (const chunk of opened.value.stream(signal)) { + results.push(chunk); + } + expect(results).toMatchObject([ + { ok: false, error: { code: "CONFLICT" } }, + ]); + expect(downloadFetches).toBe(0); + }); + + it.each([ + { + name: "truncation", + body: new Uint8Array([1, 2]), + expectedCode: "INTEGRITY_FAILED", + }, + { + name: "overrun", + body: new Uint8Array([1, 2, 3, 4]), + expectedCode: "INTEGRITY_FAILED", + }, + ])("closes $name as a terminal stream failure", async ({ body, expectedCode }) => { + const declared = new Uint8Array([1, 2, 3]); + const payload = downloadCapabilityPayload(declared); + const fetcher = vi.fn(async (input: RequestInfo | URL) => + String(input) === CONTROL_ENDPOINT + ? jsonResponse(payload) + : downloadResponse(body.slice().buffer, payload), + ) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const issued = await provider.issueDownload({ + resourceId: "resource-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + const opened = await executor.downloadSources.open({ + resourceId: "resource-1", + capability: issued.value, + signal: new AbortController().signal, + }); + expect(opened.ok).toBe(true); + if (!opened.ok) return; + const results = await collect(opened.value); + expect(results.at(-1)).toMatchObject({ + ok: false, + error: { code: expectedCode }, + }); + const firstFailure = results.findIndex((result) => !result.ok); + expect(results.slice(firstFailure + 1)).toEqual([]); + }); + + it("closes native body errors without throwing across the port", async () => { + const bytes = new Uint8Array([1, 2, 3]); + const payload = downloadCapabilityPayload(bytes); + const failingBody = new ReadableStream({ + pull(controller) { + controller.error(new DOMException("secret native detail", "NetworkError")); + }, + }); + const fetcher = vi.fn(async (input: RequestInfo | URL) => + String(input) === CONTROL_ENDPOINT + ? jsonResponse(payload) + : downloadResponse(failingBody, payload), + ) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const issued = await provider.issueDownload({ + resourceId: "resource-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + const opened = await executor.downloadSources.open({ + resourceId: "resource-1", + capability: issued.value, + signal: new AbortController().signal, + }); + expect(opened.ok).toBe(true); + if (!opened.ok) return; + await expect(collect(opened.value)).resolves.toMatchObject([ + { + ok: false, + error: { + code: "NOT_READABLE", + recovery: "REISSUE_CAPABILITY", + }, + }, + ]); + }); + + it("closes active abort and timeout without leaking native rejection", async () => { + const bytes = new Uint8Array([1]); + const payload = downloadCapabilityPayload(bytes); + const neverBody = () => + new ReadableStream({ pull() {} }); + const fetcher = vi.fn(async (input: RequestInfo | URL) => + String(input) === CONTROL_ENDPOINT + ? jsonResponse(payload) + : downloadResponse(neverBody(), payload), + ) as unknown as typeof fetch; + const controller = new AbortController(); + let harness = createHarness({ fetcher }); + let issued = await harness.provider.issueDownload({ + resourceId: "resource-1", + signal: controller.signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + let opened = await harness.executor.downloadSources.open({ + resourceId: "resource-1", + capability: issued.value, + signal: controller.signal, + }); + expect(opened.ok).toBe(true); + if (!opened.ok) return; + const aborted = collect(opened.value, controller.signal); + controller.abort("user"); + expect(await aborted).toMatchObject([ + { ok: false, error: { code: "ABORTED" } }, + ]); + + let timeoutCallback: (() => void) | undefined; + const scheduler = { + setTimeout(callback: () => void) { + timeoutCallback = callback; + return 1; + }, + clearTimeout() {}, + }; + harness = createHarness({ fetcher, scheduler }); + issued = await harness.provider.issueDownload({ + resourceId: "resource-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + opened = await harness.executor.downloadSources.open({ + resourceId: "resource-1", + capability: issued.value, + signal: new AbortController().signal, + }); + expect(opened.ok).toBe(true); + if (!opened.ok) return; + const timedOut = collect(opened.value); + timeoutCallback?.(); + expect(await timedOut).toMatchObject([ + { + ok: false, + error: { + code: "UNAVAILABLE", + recovery: "REISSUE_CAPABILITY", + }, + }, + ]); + }); + + it("rejects an expired capability before data-plane fetch", async () => { + const bytes = new Uint8Array([1]); + const payload = downloadCapabilityPayload(bytes); + let current = NOW; + const fetcher = vi.fn(async (input: RequestInfo | URL) => + String(input) === CONTROL_ENDPOINT + ? jsonResponse(payload) + : downloadResponse(bytes.slice().buffer, payload), + ) as unknown as typeof fetch; + const { provider, executor } = createHarness({ + fetcher, + now: () => current, + }); + const issued = await provider.issueDownload({ + resourceId: "resource-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + current = Number(payload.expiresAtEpochMs) + 1; + expect( + await executor.downloadSources.open({ + resourceId: "resource-1", + capability: issued.value, + signal: new AbortController().signal, + }), + ).toMatchObject({ + ok: false, + error: { + code: "EXPIRED_RESOURCE", + recovery: "REISSUE_CAPABILITY", + }, + }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("rejects capabilities below the configured minimum remaining lifetime", async () => { + const bytes = new Uint8Array([1]); + const nearExpiryPayload = downloadCapabilityPayload(bytes, { + expiresAtEpochMs: NOW + 999, + }); + let fetcher = vi.fn(async () => + jsonResponse(nearExpiryPayload), + ) as unknown as typeof fetch; + let harness = createHarness({ fetcher }); + expect( + await harness.provider.issueDownload({ + resourceId: "resource-1", + signal: new AbortController().signal, + }), + ).toMatchObject({ + ok: false, + error: { + code: "EXPIRED_RESOURCE", + recovery: "REISSUE_CAPABILITY", + }, + }); + + const acceptedPayload = downloadCapabilityPayload(bytes, { + expiresAtEpochMs: NOW + 2_000, + }); + let current = NOW; + fetcher = vi.fn(async (input: RequestInfo | URL) => + String(input) === CONTROL_ENDPOINT + ? jsonResponse(acceptedPayload) + : downloadResponse(bytes.slice().buffer, acceptedPayload), + ) as unknown as typeof fetch; + harness = createHarness({ + fetcher, + now: () => current, + }); + const issued = await harness.provider.issueDownload({ + resourceId: "resource-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + current = NOW + 1_001; + expect( + await harness.executor.downloadSources.open({ + resourceId: "resource-1", + capability: issued.value, + signal: new AbortController().signal, + }), + ).toMatchObject({ + ok: false, + error: { + code: "EXPIRED_RESOURCE", + recovery: "REISSUE_CAPABILITY", + }, + }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("closes malformed AbortSignal inputs at every public boundary", async () => { + const bytes = new Uint8Array([1, 2]); + const payload = downloadCapabilityPayload(bytes); + const uploadChecksum = sha256Hex(bytes); + const uploadPayload = uploadCapabilityPayload({ + bytes, + checksum: uploadChecksum, + }); + const fetcher = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === CONTROL_ENDPOINT) { + const request = JSON.parse(String(init?.body)) as { + method: string; + }; + return jsonResponse( + request.method === "GET" ? payload : uploadPayload, + ); + } + if (String(input) === DOWNLOAD_HREF) { + return downloadResponse(bytes.slice().buffer, payload); + } + return responseWithUrl( + new Response(null, { + status: 200, + headers: { + [POLICY_HEADER]: "v1", + "Content-Length": "0", + ETag: "\"part-etag-1\"", + }, + }), + String(uploadPayload.href), + ); + }, + ) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const malformed = {} as AbortSignal; + + expect( + await provider.issueDownload({ + resourceId: "resource-1", + signal: malformed, + }), + ).toMatchObject({ + ok: false, + error: { code: "INVALID_INPUT" }, + }); + expect( + await provider.issueUploadPart({ + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: uploadChecksum, + mediaType: "application/octet-stream", + idempotencyKey: "part-attempt-1", + signal: malformed, + }), + ).toMatchObject({ + ok: false, + error: { code: "INVALID_INPUT" }, + }); + + const issuedDownload = await provider.issueDownload({ + resourceId: "resource-1", + signal: new AbortController().signal, + }); + expect(issuedDownload.ok).toBe(true); + if (!issuedDownload.ok) return; + expect( + await executor.downloadSources.open({ + resourceId: "resource-1", + capability: issuedDownload.value, + signal: malformed, + }), + ).toMatchObject({ + ok: false, + error: { code: "INVALID_INPUT" }, + }); + const opened = await executor.downloadSources.open({ + resourceId: "resource-1", + capability: issuedDownload.value, + signal: new AbortController().signal, + }); + expect(opened.ok).toBe(true); + if (!opened.ok) return; + expect(await collect(opened.value, malformed)).toMatchObject([ + { ok: false, error: { code: "INVALID_INPUT" } }, + ]); + expect(await collect(opened.value)).toMatchObject([ + { ok: false, error: { code: "CONFLICT" } }, + ]); + + const issuedUpload = await provider.issueUploadPart({ + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: uploadChecksum, + mediaType: "application/octet-stream", + idempotencyKey: "part-attempt-1", + signal: new AbortController().signal, + }); + expect(issuedUpload.ok).toBe(true); + if (!issuedUpload.ok) return; + expect( + await executor.uploadParts.put({ + capability: issuedUpload.value, + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: uploadChecksum, + idempotencyKey: "part-attempt-1", + bytes, + signal: malformed, + }), + ).toMatchObject({ + ok: false, + error: { code: "INVALID_INPUT" }, + }); + }); + +}); diff --git a/tests/unit/presigned-transfer-fixture.ts b/tests/unit/presigned-transfer-fixture.ts new file mode 100644 index 0000000..1fd63a8 --- /dev/null +++ b/tests/unit/presigned-transfer-fixture.ts @@ -0,0 +1,247 @@ +import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts"; +import type { + PresignedDownloadByteSource, +} from "../../src/application/ports/browser-transfer/presigned-transfer.ts"; +import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts"; +import type { BrowserDataObservation } from "../../src/application/ports/browser-file-storage/shared.ts"; +import { + createPresignedCapabilityVault, + createSingleUsePresignedReplayGuard, +} from "../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts"; +import { createPresignedCapabilityHttpProvider } from "../../src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts"; +import { + createPresignedTransferExecutor, + type PresignedTransferExecutorOptions, +} from "../../src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts"; +import { sha256Hex } from "../../src/adapters/browser-transfer/presigned/incremental-sha256.ts"; + +export const NOW = 1_000_000; +export const CONTROL_ENDPOINT = "https://api.example/capabilities"; +export const DATA_ORIGIN = "https://objects.example"; +export const DOWNLOAD_PATH = "/files/resource-1"; +export const DOWNLOAD_HREF = + `${DATA_ORIGIN}${DOWNLOAD_PATH}?sig=do-not-log-this`; +export const POLICY_HEADER = "x-policy-version"; +export const DIGEST_HEADER = "x-content-sha256"; +export const CHECKSUM_HEADER = "x-checksum-sha256"; +export const UPLOAD_SESSION_ID = "upload-session-1"; +export const REQUEST_BINDING_SHA256 = "c".repeat(64); + +export function downloadCapabilityPayload( + bytes: Uint8Array, + overrides: Readonly> = {}, +) { + const digest = sha256Hex(bytes); + return { + protocol: PRESIGNED_TRANSFER_PROTOCOL, + capabilityReceipt: "capability-download-1", + method: "GET", + binding: { + kind: "DOWNLOAD", + resourceId: "resource-1", + }, + href: DOWNLOAD_HREF, + origin: DATA_ORIGIN, + path: DOWNLOAD_PATH, + allowedQueryParameters: ["sig"], + requestHeaders: [ + { name: "accept", value: "application/octet-stream" }, + ], + requiredResponseHeaders: [ + { name: POLICY_HEADER, value: "v1" }, + ], + digestRequestHeader: null, + digestResponseHeader: DIGEST_HEADER, + receiptResponseHeader: null, + expectedStatus: 200, + expectedResponseByteLength: null, + mediaType: "application/octet-stream", + byteLength: bytes.byteLength, + maxBytes: 64, + expectedSha256: digest, + expiresAtEpochMs: NOW + 30_000, + singleUse: true, + ...overrides, + }; +} + +export type CapabilityPayload = ReturnType; + +export function uploadCapabilityPayload(input: Readonly<{ + bytes: Uint8Array; + checksum: string; +}>, overrides: Readonly> = {}) { + return { + protocol: PRESIGNED_TRANSFER_PROTOCOL, + capabilityReceipt: "capability-upload-1", + method: "PUT", + binding: { + kind: "UPLOAD_PART", + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + idempotencyKey: "part-attempt-1", + }, + href: `${DATA_ORIGIN}/uploads/session-1/part-1?sig=secret`, + origin: DATA_ORIGIN, + path: "/uploads/session-1/part-1", + allowedQueryParameters: ["sig"], + requestHeaders: [ + { name: "content-type", value: "application/octet-stream" }, + { name: CHECKSUM_HEADER, value: input.checksum }, + ], + requiredResponseHeaders: [ + { name: POLICY_HEADER, value: "v1" }, + ], + digestRequestHeader: CHECKSUM_HEADER, + digestResponseHeader: null, + receiptResponseHeader: "etag", + expectedStatus: 200, + expectedResponseByteLength: 0, + mediaType: "application/octet-stream", + byteLength: input.bytes.byteLength, + maxBytes: 64, + expectedSha256: input.checksum, + expiresAtEpochMs: NOW + 30_000, + singleUse: true, + ...overrides, + }; +} + +export function jsonResponse( + value: unknown, + url = CONTROL_ENDPOINT, +): Response { + return responseWithUrl( + new Response(JSON.stringify(value), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + url, + ); +} + +export function downloadResponse( + body: BodyInit | null, + payload: CapabilityPayload, + headers: Record = {}, +): Response { + return responseWithUrl( + new Response(body, { + status: payload.expectedStatus as number, + headers: { + "Content-Type": String(payload.mediaType), + "Content-Length": String(payload.byteLength), + [DIGEST_HEADER]: String(payload.expectedSha256), + [POLICY_HEADER]: "v1", + ...headers, + }, + }), + String(payload.href), + ); +} + +export function responseWithUrl(response: Response, href: string): Response { + Object.defineProperty(response, "url", { + configurable: true, + value: href, + }); + return response; +} + +export function createHarness(input: Readonly<{ + fetcher: typeof fetch; + maxActiveCapabilities?: number; + now?: () => number; + digestBytes?: PresignedTransferExecutorOptions["digestBytes"]; + scheduler?: PresignedTransferExecutorOptions["scheduler"]; + observer?: Readonly<{ + record(observation: BrowserDataObservation): void; + }>; +}>) { + const now = input.now ?? (() => NOW); + const vault = createPresignedCapabilityVault({ + maxActiveCapabilities: input.maxActiveCapabilities ?? 16, + now, + }); + const replayGuard = createSingleUsePresignedReplayGuard(); + const provider = createPresignedCapabilityHttpProvider({ + endpoint: CONTROL_ENDPOINT, + vault, + allowedDataOrigins: [DATA_ORIGIN], + allowedDataPathPrefixes: ["/files/", "/uploads/"], + allowedQueryParameters: ["sig"], + allowedRequestHeaders: [ + "accept", + "content-type", + CHECKSUM_HEADER, + ], + allowedResponseHeaders: [ + POLICY_HEADER, + DIGEST_HEADER, + "etag", + ], + hardMaxTransferBytes: 64, + hardMaxUploadResponseBytes: 16, + maxCapabilityTtlMs: 60_000, + minimumRemainingLifetimeMs: 1_000, + timeoutMs: 5_000, + fetcher: input.fetcher, + now, + scheduler: input.scheduler, + observer: input.observer, + }); + const executor = createPresignedTransferExecutor({ + vault, + replayGuard, + hardMaxTransferBytes: 64, + hardMaxChunkBytes: 2, + hardMaxUploadResponseBytes: 16, + minimumRemainingLifetimeMs: 1_000, + timeoutMs: 5_000, + fetcher: input.fetcher, + now, + scheduler: input.scheduler, + digestBytes: input.digestBytes, + observer: input.observer, + }); + return { provider, executor, vault }; +} + +export async function collect( + source: PresignedDownloadByteSource, + signal = new AbortController().signal, +) { + const results = []; + for await (const result of source.stream(signal)) { + results.push(result); + } + return results; +} + +/** + * BT-PRE-01. The download lease is lazy, so a response-shape rejection is + * observed on first consumption rather than at open(). + */ +export async function firstStreamResult( + opened: Awaited< + ReturnType< + ReturnType["executor"]["downloadSources"]["open"] + > + >, +): Promise { + if (!opened.ok) return opened; + try { + for await (const chunk of opened.value.stream( + new AbortController().signal, + )) { + if (!chunk.ok) return chunk; + } + return { ok: true }; + } finally { + opened.value.close(); + } +} diff --git a/tests/unit/presigned-transfer-upload.test.ts b/tests/unit/presigned-transfer-upload.test.ts new file mode 100644 index 0000000..58797c3 --- /dev/null +++ b/tests/unit/presigned-transfer-upload.test.ts @@ -0,0 +1,549 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { BrowserDataObservation } from "../../src/application/ports/browser-file-storage/shared.ts"; +import { sha256Hex } from "../../src/adapters/browser-transfer/presigned/incremental-sha256.ts"; +import { + CHECKSUM_HEADER, + CONTROL_ENDPOINT, + DATA_ORIGIN, + DIGEST_HEADER, + DOWNLOAD_HREF, + DOWNLOAD_PATH, + NOW, + POLICY_HEADER, + REQUEST_BINDING_SHA256, + UPLOAD_SESSION_ID, + collect, + createHarness, + downloadCapabilityPayload, + downloadResponse, + jsonResponse, + responseWithUrl, + uploadCapabilityPayload, +} from "./presigned-transfer-fixture.ts"; + +describe("presigned upload part execution", () => { + it("snapshots, verifies and uploads a PUT part with a separate response receipt", async () => { + const original = new Uint8Array([9, 8, 7]); + const checksum = sha256Hex(original); + const payload = uploadCapabilityPayload({ + bytes: original, + checksum, + }); + let releaseDigest: (() => void) | undefined; + const digestGate = new Promise((resolve) => { + releaseDigest = resolve; + }); + const sentBodies: number[][] = []; + const dataCalls: RequestInit[] = []; + const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === CONTROL_ENDPOINT) return jsonResponse(payload); + dataCalls.push(init ?? {}); + sentBodies.push([ + ...new Uint8Array(init?.body as ArrayBuffer), + ]); + return responseWithUrl( + new Response(null, { + status: 200, + headers: { + [POLICY_HEADER]: "v1", + "Content-Length": "0", + ETag: "\"part-etag-1\"", + }, + }), + String(payload.href), + ); + }) as unknown as typeof fetch; + const { provider, executor } = createHarness({ + fetcher, + digestBytes: async (bytes) => { + await digestGate; + return sha256Hex(bytes); + }, + }); + const issued = await provider.issueUploadPart({ + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: original.byteLength, + checksumSha256: checksum, + mediaType: "application/octet-stream", + idempotencyKey: "part-attempt-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + const request = { + capability: issued.value, + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: original.byteLength, + checksumSha256: checksum, + idempotencyKey: "part-attempt-1", + bytes: original, + signal: new AbortController().signal, + }; + const pending = executor.uploadParts.put(request); + original.fill(0); + request.sessionId = "mutated-session"; + request.requestBindingSha256 = "d".repeat(64); + request.checksumSha256 = "f".repeat(64); + releaseDigest?.(); + + expect(await pending).toEqual({ + ok: true, + value: { + bytesWritten: 3, + checksumSha256: checksum, + receiptToken: "part-etag-1", + }, + }); + expect(sentBodies).toEqual([[9, 8, 7]]); + expect(dataCalls[0]).toMatchObject({ + method: "PUT", + credentials: "omit", + redirect: "error", + referrerPolicy: "no-referrer", + }); + expect( + (dataCalls[0]?.headers as Headers).get(CHECKSUM_HEADER), + ).toBe(checksum); + }); + + it.each(["sessionId", "requestBindingSha256"] as const)( + "rejects an actual PUT whose %s differs from the capability", + async (field) => { + const bytes = new Uint8Array([3, 2, 1]); + const checksum = sha256Hex(bytes); + const payload = uploadCapabilityPayload({ bytes, checksum }); + const fetcher = vi.fn(async () => + jsonResponse(payload), + ) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const issued = await provider.issueUploadPart({ + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + mediaType: "application/octet-stream", + idempotencyKey: "part-attempt-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + expect( + await executor.uploadParts.put({ + capability: issued.value, + sessionId: + field === "sessionId" + ? "different-session" + : UPLOAD_SESSION_ID, + requestBindingSha256: + field === "requestBindingSha256" + ? "d".repeat(64) + : REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + idempotencyKey: "part-attempt-1", + bytes, + signal: new AbortController().signal, + }), + ).toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + expect(fetcher).toHaveBeenCalledTimes(1); + }, + ); + + it("rejects URL-shaped upload receipts", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const checksum = sha256Hex(bytes); + const payload = uploadCapabilityPayload({ bytes, checksum }); + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === CONTROL_ENDPOINT) { + return jsonResponse(payload); + } + return responseWithUrl( + new Response(null, { + status: 200, + headers: { + [POLICY_HEADER]: "v1", + "Content-Length": "0", + ETag: "\"https://objects.example/authorizing-token\"", + }, + }), + String(payload.href), + ); + }) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const issued = await provider.issueUploadPart({ + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + mediaType: "application/octet-stream", + idempotencyKey: "part-attempt-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + expect( + await executor.uploadParts.put({ + capability: issued.value, + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + idempotencyKey: "part-attempt-1", + bytes, + signal: new AbortController().signal, + }), + ).toMatchObject({ + ok: false, + error: { code: "INTEGRITY_FAILED" }, + }); + }); + + it("drains a bounded successful PUT acknowledgement without cancelling it", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const checksum = sha256Hex(bytes); + const payload = uploadCapabilityPayload( + { bytes, checksum }, + { expectedResponseByteLength: 2 }, + ); + let cancelled = false; + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === CONTROL_ENDPOINT) { + return jsonResponse(payload); + } + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([8, 9])); + controller.close(); + }, + cancel() { + cancelled = true; + }, + }); + return responseWithUrl( + new Response(body, { + status: 200, + headers: { + [POLICY_HEADER]: "v1", + "Content-Length": "2", + ETag: "\"part-etag-1\"", + }, + }), + String(payload.href), + ); + }) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const issued = await provider.issueUploadPart({ + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + mediaType: "application/octet-stream", + idempotencyKey: "part-attempt-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + expect( + await executor.uploadParts.put({ + capability: issued.value, + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + idempotencyKey: "part-attempt-1", + bytes, + signal: new AbortController().signal, + }), + ).toMatchObject({ + ok: true, + value: { receiptToken: "part-etag-1" }, + }); + expect(cancelled).toBe(false); + }); + + it("accepts an empty 204 PUT acknowledgement", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const checksum = sha256Hex(bytes); + const payload = uploadCapabilityPayload( + { bytes, checksum }, + { + expectedStatus: 204, + expectedResponseByteLength: 0, + }, + ); + const fetcher = vi.fn(async (input: RequestInfo | URL) => + String(input) === CONTROL_ENDPOINT + ? jsonResponse(payload) + : responseWithUrl( + new Response(null, { + status: 204, + headers: { + [POLICY_HEADER]: "v1", + ETag: "\"part-etag-204\"", + }, + }), + String(payload.href), + ), + ) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const issued = await provider.issueUploadPart({ + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + mediaType: "application/octet-stream", + idempotencyKey: "part-attempt-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + expect( + await executor.uploadParts.put({ + capability: issued.value, + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + idempotencyKey: "part-attempt-1", + bytes, + signal: new AbortController().signal, + }), + ).toMatchObject({ + ok: true, + value: { receiptToken: "part-etag-204" }, + }); + }); + + it("cancels a PUT acknowledgement whose declared length violates its binding", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const checksum = sha256Hex(bytes); + const payload = uploadCapabilityPayload({ bytes, checksum }); + let cancelled = false; + const fetcher = vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === CONTROL_ENDPOINT) { + return jsonResponse(payload); + } + const body = new ReadableStream({ + pull() {}, + cancel() { + cancelled = true; + }, + }); + return responseWithUrl( + new Response(body, { + status: 200, + headers: { + [POLICY_HEADER]: "v1", + "Content-Length": "1", + ETag: "\"part-etag-1\"", + }, + }), + String(payload.href), + ); + }) as unknown as typeof fetch; + const { provider, executor } = createHarness({ fetcher }); + const issued = await provider.issueUploadPart({ + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + mediaType: "application/octet-stream", + idempotencyKey: "part-attempt-1", + signal: new AbortController().signal, + }); + expect(issued.ok).toBe(true); + if (!issued.ok) return; + expect( + await executor.uploadParts.put({ + capability: issued.value, + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + idempotencyKey: "part-attempt-1", + bytes, + signal: new AbortController().signal, + }), + ).toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + expect(cancelled).toBe(true); + }); + + it("observes only safe operation, outcome, failure and byte buckets", async () => { + const bytes = new Uint8Array([7, 8, 9]); + const downloadPayload = downloadCapabilityPayload(bytes); + const checksum = sha256Hex(bytes); + const uploadPayload = uploadCapabilityPayload({ bytes, checksum }); + const observations: BrowserDataObservation[] = []; + const fetcher = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === CONTROL_ENDPOINT) { + const request = JSON.parse(String(init?.body)) as { + method: string; + }; + return jsonResponse( + request.method === "GET" + ? downloadPayload + : uploadPayload, + ); + } + if (String(input) === DOWNLOAD_HREF) { + return downloadResponse( + bytes.slice().buffer, + downloadPayload, + ); + } + return responseWithUrl( + new Response(null, { + status: 200, + headers: { + [POLICY_HEADER]: "v1", + "Content-Length": "0", + ETag: "\"part-etag-secret\"", + }, + }), + String(uploadPayload.href), + ); + }, + ) as unknown as typeof fetch; + const { provider, executor } = createHarness({ + fetcher, + observer: { + record(observation) { + observations.push(observation); + }, + }, + }); + const issuedDownload = await provider.issueDownload({ + resourceId: "resource-1", + signal: new AbortController().signal, + }); + expect(issuedDownload.ok).toBe(true); + if (!issuedDownload.ok) return; + const opened = await executor.downloadSources.open({ + resourceId: "resource-1", + capability: issuedDownload.value, + signal: new AbortController().signal, + }); + expect(opened.ok).toBe(true); + if (!opened.ok) return; + expect((await collect(opened.value)).every((result) => result.ok)).toBe( + true, + ); + + const issuedUpload = await provider.issueUploadPart({ + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + mediaType: "application/octet-stream", + idempotencyKey: "part-attempt-1", + signal: new AbortController().signal, + }); + expect(issuedUpload.ok).toBe(true); + if (!issuedUpload.ok) return; + expect( + await executor.uploadParts.put({ + capability: issuedUpload.value, + sessionId: UPLOAD_SESSION_ID, + requestBindingSha256: REQUEST_BINDING_SHA256, + uploadBindingSha256: "b".repeat(64), + partNumber: 1, + offset: 0, + byteLength: bytes.byteLength, + checksumSha256: checksum, + idempotencyKey: "part-attempt-1", + bytes, + signal: new AbortController().signal, + }), + ).toMatchObject({ ok: true }); + + expect(observations).toEqual([ + { + operation: "PRESIGNED_TRANSFER", + outcome: "SUCCEEDED", + byteBucket: "LT1MIB", + }, + { + operation: "PRESIGNED_TRANSFER", + outcome: "SUCCEEDED", + byteBucket: "LT1MIB", + }, + { + operation: "DOWNLOAD", + outcome: "SUCCEEDED", + byteBucket: "LT1MIB", + }, + { + operation: "PRESIGNED_TRANSFER", + outcome: "SUCCEEDED", + byteBucket: "LT1MIB", + }, + { + operation: "UPLOAD_PART", + outcome: "SUCCEEDED", + byteBucket: "LT1MIB", + }, + ]); + const serialized = JSON.stringify(observations); + for (const secret of [ + DOWNLOAD_HREF, + "do-not-log-this", + checksum, + "capability-download-1", + "capability-upload-1", + "part-etag-secret", + "resource-1", + ]) { + expect(serialized).not.toContain(secret); + } + }); + +}); diff --git a/tests/unit/presigned-transfer.test.ts b/tests/unit/presigned-transfer.test.ts index 982dd75..ab260fc 100644 --- a/tests/unit/presigned-transfer.test.ts +++ b/tests/unit/presigned-transfer.test.ts @@ -1,241 +1,33 @@ import { describe, expect, it, vi } from "vitest"; -import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts"; - -import type { - PresignedDownloadCapability, - PresignedDownloadByteSource, +import { + PRESIGNED_TRANSFER_PROTOCOL, + type PresignedDownloadCapability, } from "../../src/application/ports/browser-transfer/presigned-transfer.ts"; -import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts"; -import type { BrowserDataObservation } from "../../src/application/ports/browser-file-storage/shared.ts"; -import { - createPresignedCapabilityVault, - createSingleUsePresignedReplayGuard, -} from "../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts"; -import { createPresignedCapabilityHttpProvider } from "../../src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts"; -import { - createPresignedTransferExecutor, - type PresignedTransferExecutorOptions, -} from "../../src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts"; +import { createPresignedCapabilityVault } from "../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts"; import { sha256Hex } from "../../src/adapters/browser-transfer/presigned/incremental-sha256.ts"; import { - BrowserFilePolicyRegistry, - browserFilePolicyReference, -} from "../../src/adapters/browser-files/browser-file-policy-registry.ts"; -import { - createDownloadDeliveryAdapter, - type SaveFileHandle, -} from "../../src/adapters/browser-files/download-delivery-adapter.ts"; + CHECKSUM_HEADER, + CONTROL_ENDPOINT, + DATA_ORIGIN, + DIGEST_HEADER, + DOWNLOAD_HREF, + DOWNLOAD_PATH, + NOW, + POLICY_HEADER, + REQUEST_BINDING_SHA256, + UPLOAD_SESSION_ID, + collect, + createHarness, + downloadCapabilityPayload, + downloadResponse, + firstStreamResult, + jsonResponse, + responseWithUrl, + uploadCapabilityPayload, +} from "./presigned-transfer-fixture.ts"; -const NOW = 1_000_000; -const CONTROL_ENDPOINT = "https://api.example/capabilities"; -const DATA_ORIGIN = "https://objects.example"; -const DOWNLOAD_PATH = "/files/resource-1"; -const DOWNLOAD_HREF = - `${DATA_ORIGIN}${DOWNLOAD_PATH}?sig=do-not-log-this`; -const POLICY_HEADER = "x-policy-version"; -const DIGEST_HEADER = "x-content-sha256"; -const CHECKSUM_HEADER = "x-checksum-sha256"; -const UPLOAD_SESSION_ID = "upload-session-1"; -const REQUEST_BINDING_SHA256 = "c".repeat(64); - -function downloadCapabilityPayload( - bytes: Uint8Array, - overrides: Readonly> = {}, -) { - const digest = sha256Hex(bytes); - return { - // BT-PRE-02. Every capability envelope carries the top-level protocol. - protocol: PRESIGNED_TRANSFER_PROTOCOL, - capabilityReceipt: "capability-download-1", - method: "GET", - binding: { - kind: "DOWNLOAD", - resourceId: "resource-1", - }, - href: DOWNLOAD_HREF, - origin: DATA_ORIGIN, - path: DOWNLOAD_PATH, - allowedQueryParameters: ["sig"], - requestHeaders: [ - { name: "accept", value: "application/octet-stream" }, - ], - requiredResponseHeaders: [ - { name: POLICY_HEADER, value: "v1" }, - ], - digestRequestHeader: null, - digestResponseHeader: DIGEST_HEADER, - receiptResponseHeader: null, - expectedStatus: 200, - expectedResponseByteLength: null, - mediaType: "application/octet-stream", - byteLength: bytes.byteLength, - maxBytes: 64, - expectedSha256: digest, - expiresAtEpochMs: NOW + 30_000, - singleUse: true, - ...overrides, - }; -} - -type CapabilityPayload = ReturnType; - -function uploadCapabilityPayload(input: Readonly<{ - bytes: Uint8Array; - checksum: string; -}>, overrides: Readonly> = {}) { - return { - protocol: PRESIGNED_TRANSFER_PROTOCOL, - capabilityReceipt: "capability-upload-1", - method: "PUT", - binding: { - kind: "UPLOAD_PART", - protocol: RESUMABLE_UPLOAD_PROTOCOL, - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - idempotencyKey: "part-attempt-1", - }, - href: `${DATA_ORIGIN}/uploads/session-1/part-1?sig=secret`, - origin: DATA_ORIGIN, - path: "/uploads/session-1/part-1", - allowedQueryParameters: ["sig"], - requestHeaders: [ - { name: "content-type", value: "application/octet-stream" }, - { name: CHECKSUM_HEADER, value: input.checksum }, - ], - requiredResponseHeaders: [ - { name: POLICY_HEADER, value: "v1" }, - ], - digestRequestHeader: CHECKSUM_HEADER, - digestResponseHeader: null, - receiptResponseHeader: "etag", - expectedStatus: 200, - expectedResponseByteLength: 0, - mediaType: "application/octet-stream", - byteLength: input.bytes.byteLength, - maxBytes: 64, - expectedSha256: input.checksum, - expiresAtEpochMs: NOW + 30_000, - singleUse: true, - ...overrides, - }; -} - -function jsonResponse( - value: unknown, - url = CONTROL_ENDPOINT, -): Response { - return responseWithUrl( - new Response(JSON.stringify(value), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - url, - ); -} - -function downloadResponse( - body: BodyInit | null, - payload: CapabilityPayload, - headers: Record = {}, -): Response { - return responseWithUrl( - new Response(body, { - status: payload.expectedStatus as number, - headers: { - "Content-Type": String(payload.mediaType), - "Content-Length": String(payload.byteLength), - [DIGEST_HEADER]: String(payload.expectedSha256), - [POLICY_HEADER]: "v1", - ...headers, - }, - }), - String(payload.href), - ); -} - -function responseWithUrl(response: Response, href: string): Response { - Object.defineProperty(response, "url", { - configurable: true, - value: href, - }); - return response; -} - -function createHarness(input: Readonly<{ - fetcher: typeof fetch; - maxActiveCapabilities?: number; - now?: () => number; - digestBytes?: PresignedTransferExecutorOptions["digestBytes"]; - scheduler?: PresignedTransferExecutorOptions["scheduler"]; - observer?: Readonly<{ - record(observation: BrowserDataObservation): void; - }>; -}>) { - const now = input.now ?? (() => NOW); - const vault = createPresignedCapabilityVault({ - maxActiveCapabilities: input.maxActiveCapabilities ?? 16, - now, - }); - const replayGuard = createSingleUsePresignedReplayGuard(); - const provider = createPresignedCapabilityHttpProvider({ - endpoint: CONTROL_ENDPOINT, - vault, - allowedDataOrigins: [DATA_ORIGIN], - allowedDataPathPrefixes: ["/files/", "/uploads/"], - allowedQueryParameters: ["sig"], - allowedRequestHeaders: [ - "accept", - "content-type", - CHECKSUM_HEADER, - ], - allowedResponseHeaders: [ - POLICY_HEADER, - DIGEST_HEADER, - "etag", - ], - hardMaxTransferBytes: 64, - hardMaxUploadResponseBytes: 16, - maxCapabilityTtlMs: 60_000, - minimumRemainingLifetimeMs: 1_000, - timeoutMs: 5_000, - fetcher: input.fetcher, - now, - scheduler: input.scheduler, - observer: input.observer, - }); - const executor = createPresignedTransferExecutor({ - vault, - replayGuard, - hardMaxTransferBytes: 64, - hardMaxChunkBytes: 2, - hardMaxUploadResponseBytes: 16, - minimumRemainingLifetimeMs: 1_000, - timeoutMs: 5_000, - fetcher: input.fetcher, - now, - scheduler: input.scheduler, - digestBytes: input.digestBytes, - observer: input.observer, - }); - return { provider, executor, vault }; -} - -async function collect( - source: PresignedDownloadByteSource, - signal = new AbortController().signal, -) { - const results = []; - for await (const result of source.stream(signal)) { - results.push(result); - } - return results; -} - -describe("presigned transfer", () => { +describe("presigned transfer capability issuance and admission", () => { it("matches the standard SHA-256 vector", () => { expect(sha256Hex(new TextEncoder().encode("abc"))).toBe( "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", @@ -956,1563 +748,4 @@ describe("presigned transfer", () => { * header set to the forbidden-header check and hand `Authorization` to the * stored binding, so the executor sent a credential no rule had approved. */ - describe("TR-01 the stored capability is the one that was validated", () => { - const baseRegistration = () => ({ - protocol: PRESIGNED_TRANSFER_PROTOCOL, - capabilityReceipt: "capability-snapshot-1", - method: "GET" as const, - binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, - href: `${DATA_ORIGIN}${DOWNLOAD_PATH}`, - origin: DATA_ORIGIN, - path: DOWNLOAD_PATH, - allowedQueryParameters: [], - requestHeaders: [{ name: "x-safe", value: "1" }], - requiredResponseHeaders: [], - digestRequestHeader: null, - digestResponseHeader: null, - receiptResponseHeader: null, - expectedStatus: 200, - expectedResponseByteLength: 3, - mediaType: "application/octet-stream", - byteLength: 3, - maxBytes: 3, - expectedSha256: "a".repeat(64), - expiresAtEpochMs: NOW + 60_000, - }); - - const freshVault = () => - createPresignedCapabilityVault({ - now: () => NOW, - maxActiveCapabilities: 4, - }); - - it("refuses a header row that answers differently on a second read", () => { - const vault = freshVault(); - let nameReads = 0; - const header = new Proxy( - { name: "x-safe", value: "1" }, - { - getOwnPropertyDescriptor(target, key) { - if (key === "name") { - nameReads += 1; - return { - configurable: true, - enumerable: true, - value: nameReads > 1 ? "authorization" : "x-safe", - }; - } - return Reflect.getOwnPropertyDescriptor(target, key); - }, - }, - ); - - const registered = vault.register({ - ...baseRegistration(), - requestHeaders: [header], - } as never); - - if (registered.ok) { - // A single read means the value that was checked is the value stored. - const resolved = vault.resolve(registered.value); - expect(resolved.ok).toBe(true); - if (resolved.ok) { - expect(resolved.value.requestHeaders.map((row) => row.name)).toEqual([ - "x-safe", - ]); - } - } - vault.dispose(); - }); - - const hostileRegistrations: readonly (readonly [string, () => unknown])[] = [ - [ - "an accessor field", - () => - Object.defineProperty(baseRegistration(), "href", { - enumerable: true, - get: () => `${DATA_ORIGIN}${DOWNLOAD_PATH}`, - }), - ], - [ - "an inherited field", - () => Object.assign(Object.create({ injected: true }), baseRegistration()), - ], - [ - "a symbol field", - () => ({ ...baseRegistration(), [Symbol.for("injected")]: true }), - ], - [ - "a non-enumerable own field", - () => - Object.defineProperty(baseRegistration(), "injected", { - enumerable: false, - value: true, - }), - ], - [ - "a throwing ownKeys trap", - () => - new Proxy(baseRegistration(), { - ownKeys() { - throw new TypeError("hostile ownKeys trap"); - }, - }), - ], - [ - "a null header array", - () => ({ ...baseRegistration(), requestHeaders: null }), - ], - [ - "a non-iterable header array", - () => ({ ...baseRegistration(), requestHeaders: { length: 1 } }), - ], - [ - "a header row with an extra field", - () => ({ - ...baseRegistration(), - requestHeaders: [{ name: "x-safe", value: "1", injected: true }], - }), - ], - [ - "an accessor header name", - () => ({ - ...baseRegistration(), - requestHeaders: [ - Object.defineProperty({ value: "1" }, "name", { - enumerable: true, - get: () => "x-safe", - }), - ], - }), - ], - [ - "a binding with an extra field", - () => ({ - ...baseRegistration(), - binding: { kind: "DOWNLOAD", resourceId: "r", injected: true }, - }), - ], - [ - "a null binding", - () => ({ ...baseRegistration(), binding: null }), - ], - ]; - - for (const [label, build] of hostileRegistrations) { - it(`rejects ${label} as POLICY_REJECTED`, () => { - const vault = freshVault(); - expect(vault.register(build() as never)).toMatchObject({ - ok: false, - error: { code: "POLICY_REJECTED" }, - }); - vault.dispose(); - }); - } - - it("does not observe a mutation of the issuer's object after registration", () => { - const vault = freshVault(); - const registration = baseRegistration(); - const registered = vault.register(registration as never); - expect(registered.ok).toBe(true); - if (!registered.ok) return; - - registration.requestHeaders[0]!.name = "authorization"; - registration.expiresAtEpochMs = NOW + 999_999; - - const resolved = vault.resolve(registered.value); - expect(resolved.ok).toBe(true); - if (!resolved.ok) return; - expect(resolved.value.requestHeaders.map((row) => row.name)).toEqual([ - "x-safe", - ]); - expect(resolved.value.expiresAtEpochMs).toBe(NOW + 60_000); - vault.dispose(); - }); - }); - - it("does not fetch a presigned download until stream consumption", async () => { - const bytes = new Uint8Array([1, 2, 3]); - const responsePayload = downloadCapabilityPayload(bytes); - let downloadFetches = 0; - const fetcher = vi.fn(async (input: RequestInfo | URL) => { - if (String(input) === CONTROL_ENDPOINT) { - return jsonResponse(responsePayload); - } - downloadFetches += 1; - return downloadResponse(bytes.slice().buffer, responsePayload); - }) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const signal = new AbortController().signal; - const issued = await provider.issueDownload({ - resourceId: "resource-1", - signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - - const opened = await executor.downloadSources.open({ - resourceId: "resource-1", - capability: issued.value, - signal, - }); - expect(opened.ok).toBe(true); - if (!opened.ok) return; - // BT-PRE-01. open() performs no network I/O. - expect(downloadFetches).toBe(0); - - for await (const chunk of opened.value.stream(signal)) { - expect(chunk.ok).toBe(true); - } - expect(downloadFetches).toBe(1); - opened.value.close(); - }); - - it("closes an unused download source without network I/O", async () => { - const bytes = new Uint8Array([1, 2, 3]); - const responsePayload = downloadCapabilityPayload(bytes); - let downloadFetches = 0; - const fetcher = vi.fn(async (input: RequestInfo | URL) => { - if (String(input) === CONTROL_ENDPOINT) { - return jsonResponse(responsePayload); - } - downloadFetches += 1; - return downloadResponse(bytes.slice().buffer, responsePayload); - }) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const signal = new AbortController().signal; - const issued = await provider.issueDownload({ - resourceId: "resource-1", - signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - - const opened = await executor.downloadSources.open({ - resourceId: "resource-1", - capability: issued.value, - signal, - }); - expect(opened.ok).toBe(true); - if (!opened.ok) return; - - opened.value.close(); - // close() is idempotent and never starts the transfer. - opened.value.close(); - expect(downloadFetches).toBe(0); - - // A stream after close is one terminal conflict, still without fetching. - const results = []; - for await (const chunk of opened.value.stream(signal)) { - results.push(chunk); - } - expect(results).toMatchObject([ - { ok: false, error: { code: "CONFLICT" } }, - ]); - expect(downloadFetches).toBe(0); - }); - - it.each([ - { - name: "truncation", - body: new Uint8Array([1, 2]), - expectedCode: "INTEGRITY_FAILED", - }, - { - name: "overrun", - body: new Uint8Array([1, 2, 3, 4]), - expectedCode: "INTEGRITY_FAILED", - }, - ])("closes $name as a terminal stream failure", async ({ body, expectedCode }) => { - const declared = new Uint8Array([1, 2, 3]); - const payload = downloadCapabilityPayload(declared); - const fetcher = vi.fn(async (input: RequestInfo | URL) => - String(input) === CONTROL_ENDPOINT - ? jsonResponse(payload) - : downloadResponse(body.slice().buffer, payload), - ) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const issued = await provider.issueDownload({ - resourceId: "resource-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - const opened = await executor.downloadSources.open({ - resourceId: "resource-1", - capability: issued.value, - signal: new AbortController().signal, - }); - expect(opened.ok).toBe(true); - if (!opened.ok) return; - const results = await collect(opened.value); - expect(results.at(-1)).toMatchObject({ - ok: false, - error: { code: expectedCode }, - }); - const firstFailure = results.findIndex((result) => !result.ok); - expect(results.slice(firstFailure + 1)).toEqual([]); - }); - - it("closes native body errors without throwing across the port", async () => { - const bytes = new Uint8Array([1, 2, 3]); - const payload = downloadCapabilityPayload(bytes); - const failingBody = new ReadableStream({ - pull(controller) { - controller.error(new DOMException("secret native detail", "NetworkError")); - }, - }); - const fetcher = vi.fn(async (input: RequestInfo | URL) => - String(input) === CONTROL_ENDPOINT - ? jsonResponse(payload) - : downloadResponse(failingBody, payload), - ) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const issued = await provider.issueDownload({ - resourceId: "resource-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - const opened = await executor.downloadSources.open({ - resourceId: "resource-1", - capability: issued.value, - signal: new AbortController().signal, - }); - expect(opened.ok).toBe(true); - if (!opened.ok) return; - await expect(collect(opened.value)).resolves.toMatchObject([ - { - ok: false, - error: { - code: "NOT_READABLE", - recovery: "REISSUE_CAPABILITY", - }, - }, - ]); - }); - - it("closes active abort and timeout without leaking native rejection", async () => { - const bytes = new Uint8Array([1]); - const payload = downloadCapabilityPayload(bytes); - const neverBody = () => - new ReadableStream({ pull() {} }); - const fetcher = vi.fn(async (input: RequestInfo | URL) => - String(input) === CONTROL_ENDPOINT - ? jsonResponse(payload) - : downloadResponse(neverBody(), payload), - ) as unknown as typeof fetch; - const controller = new AbortController(); - let harness = createHarness({ fetcher }); - let issued = await harness.provider.issueDownload({ - resourceId: "resource-1", - signal: controller.signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - let opened = await harness.executor.downloadSources.open({ - resourceId: "resource-1", - capability: issued.value, - signal: controller.signal, - }); - expect(opened.ok).toBe(true); - if (!opened.ok) return; - const aborted = collect(opened.value, controller.signal); - controller.abort("user"); - expect(await aborted).toMatchObject([ - { ok: false, error: { code: "ABORTED" } }, - ]); - - let timeoutCallback: (() => void) | undefined; - const scheduler = { - setTimeout(callback: () => void) { - timeoutCallback = callback; - return 1; - }, - clearTimeout() {}, - }; - harness = createHarness({ fetcher, scheduler }); - issued = await harness.provider.issueDownload({ - resourceId: "resource-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - opened = await harness.executor.downloadSources.open({ - resourceId: "resource-1", - capability: issued.value, - signal: new AbortController().signal, - }); - expect(opened.ok).toBe(true); - if (!opened.ok) return; - const timedOut = collect(opened.value); - timeoutCallback?.(); - expect(await timedOut).toMatchObject([ - { - ok: false, - error: { - code: "UNAVAILABLE", - recovery: "REISSUE_CAPABILITY", - }, - }, - ]); - }); - - it("rejects an expired capability before data-plane fetch", async () => { - const bytes = new Uint8Array([1]); - const payload = downloadCapabilityPayload(bytes); - let current = NOW; - const fetcher = vi.fn(async (input: RequestInfo | URL) => - String(input) === CONTROL_ENDPOINT - ? jsonResponse(payload) - : downloadResponse(bytes.slice().buffer, payload), - ) as unknown as typeof fetch; - const { provider, executor } = createHarness({ - fetcher, - now: () => current, - }); - const issued = await provider.issueDownload({ - resourceId: "resource-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - current = Number(payload.expiresAtEpochMs) + 1; - expect( - await executor.downloadSources.open({ - resourceId: "resource-1", - capability: issued.value, - signal: new AbortController().signal, - }), - ).toMatchObject({ - ok: false, - error: { - code: "EXPIRED_RESOURCE", - recovery: "REISSUE_CAPABILITY", - }, - }); - expect(fetcher).toHaveBeenCalledTimes(1); - }); - - it("rejects capabilities below the configured minimum remaining lifetime", async () => { - const bytes = new Uint8Array([1]); - const nearExpiryPayload = downloadCapabilityPayload(bytes, { - expiresAtEpochMs: NOW + 999, - }); - let fetcher = vi.fn(async () => - jsonResponse(nearExpiryPayload), - ) as unknown as typeof fetch; - let harness = createHarness({ fetcher }); - expect( - await harness.provider.issueDownload({ - resourceId: "resource-1", - signal: new AbortController().signal, - }), - ).toMatchObject({ - ok: false, - error: { - code: "EXPIRED_RESOURCE", - recovery: "REISSUE_CAPABILITY", - }, - }); - - const acceptedPayload = downloadCapabilityPayload(bytes, { - expiresAtEpochMs: NOW + 2_000, - }); - let current = NOW; - fetcher = vi.fn(async (input: RequestInfo | URL) => - String(input) === CONTROL_ENDPOINT - ? jsonResponse(acceptedPayload) - : downloadResponse(bytes.slice().buffer, acceptedPayload), - ) as unknown as typeof fetch; - harness = createHarness({ - fetcher, - now: () => current, - }); - const issued = await harness.provider.issueDownload({ - resourceId: "resource-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - current = NOW + 1_001; - expect( - await harness.executor.downloadSources.open({ - resourceId: "resource-1", - capability: issued.value, - signal: new AbortController().signal, - }), - ).toMatchObject({ - ok: false, - error: { - code: "EXPIRED_RESOURCE", - recovery: "REISSUE_CAPABILITY", - }, - }); - expect(fetcher).toHaveBeenCalledTimes(1); - }); - - it("closes malformed AbortSignal inputs at every public boundary", async () => { - const bytes = new Uint8Array([1, 2]); - const payload = downloadCapabilityPayload(bytes); - const uploadChecksum = sha256Hex(bytes); - const uploadPayload = uploadCapabilityPayload({ - bytes, - checksum: uploadChecksum, - }); - const fetcher = vi.fn( - async (input: RequestInfo | URL, init?: RequestInit) => { - if (String(input) === CONTROL_ENDPOINT) { - const request = JSON.parse(String(init?.body)) as { - method: string; - }; - return jsonResponse( - request.method === "GET" ? payload : uploadPayload, - ); - } - if (String(input) === DOWNLOAD_HREF) { - return downloadResponse(bytes.slice().buffer, payload); - } - return responseWithUrl( - new Response(null, { - status: 200, - headers: { - [POLICY_HEADER]: "v1", - "Content-Length": "0", - ETag: "\"part-etag-1\"", - }, - }), - String(uploadPayload.href), - ); - }, - ) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const malformed = {} as AbortSignal; - - expect( - await provider.issueDownload({ - resourceId: "resource-1", - signal: malformed, - }), - ).toMatchObject({ - ok: false, - error: { code: "INVALID_INPUT" }, - }); - expect( - await provider.issueUploadPart({ - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: uploadChecksum, - mediaType: "application/octet-stream", - idempotencyKey: "part-attempt-1", - signal: malformed, - }), - ).toMatchObject({ - ok: false, - error: { code: "INVALID_INPUT" }, - }); - - const issuedDownload = await provider.issueDownload({ - resourceId: "resource-1", - signal: new AbortController().signal, - }); - expect(issuedDownload.ok).toBe(true); - if (!issuedDownload.ok) return; - expect( - await executor.downloadSources.open({ - resourceId: "resource-1", - capability: issuedDownload.value, - signal: malformed, - }), - ).toMatchObject({ - ok: false, - error: { code: "INVALID_INPUT" }, - }); - const opened = await executor.downloadSources.open({ - resourceId: "resource-1", - capability: issuedDownload.value, - signal: new AbortController().signal, - }); - expect(opened.ok).toBe(true); - if (!opened.ok) return; - expect(await collect(opened.value, malformed)).toMatchObject([ - { ok: false, error: { code: "INVALID_INPUT" } }, - ]); - expect(await collect(opened.value)).toMatchObject([ - { ok: false, error: { code: "CONFLICT" } }, - ]); - - const issuedUpload = await provider.issueUploadPart({ - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: uploadChecksum, - mediaType: "application/octet-stream", - idempotencyKey: "part-attempt-1", - signal: new AbortController().signal, - }); - expect(issuedUpload.ok).toBe(true); - if (!issuedUpload.ok) return; - expect( - await executor.uploadParts.put({ - capability: issuedUpload.value, - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: uploadChecksum, - idempotencyKey: "part-attempt-1", - bytes, - signal: malformed, - }), - ).toMatchObject({ - ok: false, - error: { code: "INVALID_INPUT" }, - }); - }); - - it("snapshots, verifies and uploads a PUT part with a separate response receipt", async () => { - const original = new Uint8Array([9, 8, 7]); - const checksum = sha256Hex(original); - const payload = uploadCapabilityPayload({ - bytes: original, - checksum, - }); - let releaseDigest: (() => void) | undefined; - const digestGate = new Promise((resolve) => { - releaseDigest = resolve; - }); - const sentBodies: number[][] = []; - const dataCalls: RequestInit[] = []; - const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - if (String(input) === CONTROL_ENDPOINT) return jsonResponse(payload); - dataCalls.push(init ?? {}); - sentBodies.push([ - ...new Uint8Array(init?.body as ArrayBuffer), - ]); - return responseWithUrl( - new Response(null, { - status: 200, - headers: { - [POLICY_HEADER]: "v1", - "Content-Length": "0", - ETag: "\"part-etag-1\"", - }, - }), - String(payload.href), - ); - }) as unknown as typeof fetch; - const { provider, executor } = createHarness({ - fetcher, - digestBytes: async (bytes) => { - await digestGate; - return sha256Hex(bytes); - }, - }); - const issued = await provider.issueUploadPart({ - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: original.byteLength, - checksumSha256: checksum, - mediaType: "application/octet-stream", - idempotencyKey: "part-attempt-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - const request = { - capability: issued.value, - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: original.byteLength, - checksumSha256: checksum, - idempotencyKey: "part-attempt-1", - bytes: original, - signal: new AbortController().signal, - }; - const pending = executor.uploadParts.put(request); - original.fill(0); - request.sessionId = "mutated-session"; - request.requestBindingSha256 = "d".repeat(64); - request.checksumSha256 = "f".repeat(64); - releaseDigest?.(); - - expect(await pending).toEqual({ - ok: true, - value: { - bytesWritten: 3, - checksumSha256: checksum, - receiptToken: "part-etag-1", - }, - }); - expect(sentBodies).toEqual([[9, 8, 7]]); - expect(dataCalls[0]).toMatchObject({ - method: "PUT", - credentials: "omit", - redirect: "error", - referrerPolicy: "no-referrer", - }); - expect( - (dataCalls[0]?.headers as Headers).get(CHECKSUM_HEADER), - ).toBe(checksum); - }); - - it.each(["sessionId", "requestBindingSha256"] as const)( - "rejects an actual PUT whose %s differs from the capability", - async (field) => { - const bytes = new Uint8Array([3, 2, 1]); - const checksum = sha256Hex(bytes); - const payload = uploadCapabilityPayload({ bytes, checksum }); - const fetcher = vi.fn(async () => - jsonResponse(payload), - ) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const issued = await provider.issueUploadPart({ - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - mediaType: "application/octet-stream", - idempotencyKey: "part-attempt-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - expect( - await executor.uploadParts.put({ - capability: issued.value, - sessionId: - field === "sessionId" - ? "different-session" - : UPLOAD_SESSION_ID, - requestBindingSha256: - field === "requestBindingSha256" - ? "d".repeat(64) - : REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - idempotencyKey: "part-attempt-1", - bytes, - signal: new AbortController().signal, - }), - ).toMatchObject({ - ok: false, - error: { code: "POLICY_REJECTED" }, - }); - expect(fetcher).toHaveBeenCalledTimes(1); - }, - ); - - it("rejects URL-shaped upload receipts", async () => { - const bytes = new Uint8Array([4, 5, 6]); - const checksum = sha256Hex(bytes); - const payload = uploadCapabilityPayload({ bytes, checksum }); - const fetcher = vi.fn(async (input: RequestInfo | URL) => { - if (String(input) === CONTROL_ENDPOINT) { - return jsonResponse(payload); - } - return responseWithUrl( - new Response(null, { - status: 200, - headers: { - [POLICY_HEADER]: "v1", - "Content-Length": "0", - ETag: "\"https://objects.example/authorizing-token\"", - }, - }), - String(payload.href), - ); - }) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const issued = await provider.issueUploadPart({ - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - mediaType: "application/octet-stream", - idempotencyKey: "part-attempt-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - expect( - await executor.uploadParts.put({ - capability: issued.value, - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - idempotencyKey: "part-attempt-1", - bytes, - signal: new AbortController().signal, - }), - ).toMatchObject({ - ok: false, - error: { code: "INTEGRITY_FAILED" }, - }); - }); - - it("drains a bounded successful PUT acknowledgement without cancelling it", async () => { - const bytes = new Uint8Array([4, 5, 6]); - const checksum = sha256Hex(bytes); - const payload = uploadCapabilityPayload( - { bytes, checksum }, - { expectedResponseByteLength: 2 }, - ); - let cancelled = false; - const fetcher = vi.fn(async (input: RequestInfo | URL) => { - if (String(input) === CONTROL_ENDPOINT) { - return jsonResponse(payload); - } - const body = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array([8, 9])); - controller.close(); - }, - cancel() { - cancelled = true; - }, - }); - return responseWithUrl( - new Response(body, { - status: 200, - headers: { - [POLICY_HEADER]: "v1", - "Content-Length": "2", - ETag: "\"part-etag-1\"", - }, - }), - String(payload.href), - ); - }) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const issued = await provider.issueUploadPart({ - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - mediaType: "application/octet-stream", - idempotencyKey: "part-attempt-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - expect( - await executor.uploadParts.put({ - capability: issued.value, - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - idempotencyKey: "part-attempt-1", - bytes, - signal: new AbortController().signal, - }), - ).toMatchObject({ - ok: true, - value: { receiptToken: "part-etag-1" }, - }); - expect(cancelled).toBe(false); - }); - - it("accepts an empty 204 PUT acknowledgement", async () => { - const bytes = new Uint8Array([4, 5, 6]); - const checksum = sha256Hex(bytes); - const payload = uploadCapabilityPayload( - { bytes, checksum }, - { - expectedStatus: 204, - expectedResponseByteLength: 0, - }, - ); - const fetcher = vi.fn(async (input: RequestInfo | URL) => - String(input) === CONTROL_ENDPOINT - ? jsonResponse(payload) - : responseWithUrl( - new Response(null, { - status: 204, - headers: { - [POLICY_HEADER]: "v1", - ETag: "\"part-etag-204\"", - }, - }), - String(payload.href), - ), - ) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const issued = await provider.issueUploadPart({ - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - mediaType: "application/octet-stream", - idempotencyKey: "part-attempt-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - expect( - await executor.uploadParts.put({ - capability: issued.value, - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - idempotencyKey: "part-attempt-1", - bytes, - signal: new AbortController().signal, - }), - ).toMatchObject({ - ok: true, - value: { receiptToken: "part-etag-204" }, - }); - }); - - it("cancels a PUT acknowledgement whose declared length violates its binding", async () => { - const bytes = new Uint8Array([4, 5, 6]); - const checksum = sha256Hex(bytes); - const payload = uploadCapabilityPayload({ bytes, checksum }); - let cancelled = false; - const fetcher = vi.fn(async (input: RequestInfo | URL) => { - if (String(input) === CONTROL_ENDPOINT) { - return jsonResponse(payload); - } - const body = new ReadableStream({ - pull() {}, - cancel() { - cancelled = true; - }, - }); - return responseWithUrl( - new Response(body, { - status: 200, - headers: { - [POLICY_HEADER]: "v1", - "Content-Length": "1", - ETag: "\"part-etag-1\"", - }, - }), - String(payload.href), - ); - }) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const issued = await provider.issueUploadPart({ - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - mediaType: "application/octet-stream", - idempotencyKey: "part-attempt-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - expect( - await executor.uploadParts.put({ - capability: issued.value, - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - idempotencyKey: "part-attempt-1", - bytes, - signal: new AbortController().signal, - }), - ).toMatchObject({ - ok: false, - error: { code: "POLICY_REJECTED" }, - }); - expect(cancelled).toBe(true); - }); - - it("observes only safe operation, outcome, failure and byte buckets", async () => { - const bytes = new Uint8Array([7, 8, 9]); - const downloadPayload = downloadCapabilityPayload(bytes); - const checksum = sha256Hex(bytes); - const uploadPayload = uploadCapabilityPayload({ bytes, checksum }); - const observations: BrowserDataObservation[] = []; - const fetcher = vi.fn( - async (input: RequestInfo | URL, init?: RequestInit) => { - if (String(input) === CONTROL_ENDPOINT) { - const request = JSON.parse(String(init?.body)) as { - method: string; - }; - return jsonResponse( - request.method === "GET" - ? downloadPayload - : uploadPayload, - ); - } - if (String(input) === DOWNLOAD_HREF) { - return downloadResponse( - bytes.slice().buffer, - downloadPayload, - ); - } - return responseWithUrl( - new Response(null, { - status: 200, - headers: { - [POLICY_HEADER]: "v1", - "Content-Length": "0", - ETag: "\"part-etag-secret\"", - }, - }), - String(uploadPayload.href), - ); - }, - ) as unknown as typeof fetch; - const { provider, executor } = createHarness({ - fetcher, - observer: { - record(observation) { - observations.push(observation); - }, - }, - }); - const issuedDownload = await provider.issueDownload({ - resourceId: "resource-1", - signal: new AbortController().signal, - }); - expect(issuedDownload.ok).toBe(true); - if (!issuedDownload.ok) return; - const opened = await executor.downloadSources.open({ - resourceId: "resource-1", - capability: issuedDownload.value, - signal: new AbortController().signal, - }); - expect(opened.ok).toBe(true); - if (!opened.ok) return; - expect((await collect(opened.value)).every((result) => result.ok)).toBe( - true, - ); - - const issuedUpload = await provider.issueUploadPart({ - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - mediaType: "application/octet-stream", - idempotencyKey: "part-attempt-1", - signal: new AbortController().signal, - }); - expect(issuedUpload.ok).toBe(true); - if (!issuedUpload.ok) return; - expect( - await executor.uploadParts.put({ - capability: issuedUpload.value, - sessionId: UPLOAD_SESSION_ID, - requestBindingSha256: REQUEST_BINDING_SHA256, - uploadBindingSha256: "b".repeat(64), - partNumber: 1, - offset: 0, - byteLength: bytes.byteLength, - checksumSha256: checksum, - idempotencyKey: "part-attempt-1", - bytes, - signal: new AbortController().signal, - }), - ).toMatchObject({ ok: true }); - - expect(observations).toEqual([ - { - operation: "PRESIGNED_TRANSFER", - outcome: "SUCCEEDED", - byteBucket: "LT1MIB", - }, - { - operation: "PRESIGNED_TRANSFER", - outcome: "SUCCEEDED", - byteBucket: "LT1MIB", - }, - { - operation: "DOWNLOAD", - outcome: "SUCCEEDED", - byteBucket: "LT1MIB", - }, - { - operation: "PRESIGNED_TRANSFER", - outcome: "SUCCEEDED", - byteBucket: "LT1MIB", - }, - { - operation: "UPLOAD_PART", - outcome: "SUCCEEDED", - byteBucket: "LT1MIB", - }, - ]); - const serialized = JSON.stringify(observations); - for (const secret of [ - DOWNLOAD_HREF, - "do-not-log-this", - checksum, - "capability-download-1", - "capability-upload-1", - "part-etag-secret", - "resource-1", - ]) { - expect(serialized).not.toContain(secret); - } - }); - - it("connects an issued capability to DownloadDeliveryPort without caller digest input", async () => { - const bytes = new TextEncoder().encode("verified"); - const payload = downloadCapabilityPayload(bytes); - const fetcher = vi.fn(async (input: RequestInfo | URL) => - String(input) === CONTROL_ENDPOINT - ? jsonResponse(payload) - : downloadResponse(bytes.slice().buffer, payload), - ) as unknown as typeof fetch; - const { provider, executor } = createHarness({ fetcher }); - const issued = await provider.issueDownload({ - resourceId: "resource-1", - signal: new AbortController().signal, - }); - expect(issued.ok).toBe(true); - if (!issued.ok) return; - - const policy = browserFilePolicyReference( - "download", - "presigned-stream", - ); - const policies = new BrowserFilePolicyRegistry({ - profiles: [ - { - reference: policy, - download: { - strategy: "PROMPT_AND_STREAM", - mediaType: "application/octet-stream", - safeExtension: ".bin", - maxTransferBytes: 64, - maxBufferedBytes: 8, - integrity: "REQUIRED", - }, - }, - ], - hardLimits: { - maxInspectionBytes: 64, - maxRetainedFileBytes: 64, - maxPreviewBytes: 64, - maxObjectUrlBytes: 64, - maxTransferBytes: 64, - }, - }); - const written: number[] = []; - const handle: SaveFileHandle = { - async createWritable() { - return new WritableStream({ - write(chunk) { - written.push(...chunk); - }, - }); - }, - }; - const downloads = createDownloadDeliveryAdapter({ - host: { handoff() {} }, - policies, - hardMaxObjectUrlBytes: 64, - hardMaxTransferBytes: 64, - browserManagedCapabilities: { - resolve() { - throw new TypeError("not used"); - }, - }, - openAuthorizedSource: - executor.downloadSources.open.bind(executor.downloadSources), - showSaveFilePicker: async () => handle, - userActivation: { isActive: true }, - now: () => NOW, - }); - const result = await downloads.deliver({ - policy, - source: { - kind: "AUTHORIZED_STREAM_RESOURCE", - resourceId: "resource-1", - capability: issued.value, - }, - suggestedFileName: "artifact.bin", - signal: new AbortController().signal, - onProgress() {}, - }); - expect(result).toMatchObject({ - ok: true, - value: { - kind: "SAVED", - integrity: "VERIFIED", - bytesWritten: bytes.byteLength, - }, - }); - expect(written).toEqual([...bytes]); - }); - - /** - * TR-RR-04. A presigned byte source owns a fetch reader and a capability - * lease, and its port requires `close()`. The delivery consumer never called - * it, so every outcome — success, validation failure, writer failure and - * abort — leaked both. - */ - it.each([ - { label: "success", mode: "SUCCESS" as const }, - { label: "writer failure", mode: "WRITER_FAILURE" as const }, - { label: "abort", mode: "ABORT" as const }, - ])("closes the presigned source exactly once on $label", async ({ mode }) => { - const bytes = new Uint8Array([1, 2, 3]); - let closes = 0; - const controller = new AbortController(); - const source = { - byteLength: bytes.byteLength, - integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const, - capability: undefined as never, - close() { - closes += 1; - }, - async *stream() { - if (mode === "ABORT") controller.abort(); - yield { ok: true as const, value: bytes }; - }, - }; - const capability = Object.freeze({ - capabilityReceipt: "capability-close-1", - method: "GET" as const, - binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, - mediaType: "application/octet-stream", - byteLength: bytes.byteLength, - maxBytes: bytes.byteLength, - expectedSha256: "a".repeat(64), - expiresAtEpochMs: NOW + 60_000, - }); - source.capability = capability as never; - - const closePolicy = browserFilePolicyReference( - "download", - "presigned-close", - ); - const policies = new BrowserFilePolicyRegistry({ - profiles: [ - { - reference: closePolicy, - download: { - strategy: "PROMPT_AND_STREAM", - mediaType: "application/octet-stream", - safeExtension: ".bin", - maxTransferBytes: 64, - maxBufferedBytes: 8, - integrity: "REQUIRED", - }, - }, - ], - hardLimits: { - maxInspectionBytes: 64, - maxRetainedFileBytes: 64, - maxPreviewBytes: 64, - maxObjectUrlBytes: 64, - maxTransferBytes: 64, - }, - }); - const handle: SaveFileHandle = { - async createWritable() { - return new WritableStream({ - write() { - if (mode === "WRITER_FAILURE") { - throw new TypeError("writer exploded"); - } - }, - }); - }, - }; - const downloads = createDownloadDeliveryAdapter({ - host: { handoff() {} }, - policies, - hardMaxObjectUrlBytes: 64, - hardMaxTransferBytes: 64, - browserManagedCapabilities: { - resolve() { - throw new TypeError("not used"); - }, - }, - openAuthorizedSource: async () => - ({ ok: true, value: source }) as never, - showSaveFilePicker: async () => handle, - userActivation: { isActive: true }, - now: () => NOW, - }); - - const deliveryResult = await downloads.deliver({ - policy: closePolicy, - source: { - kind: "AUTHORIZED_STREAM_RESOURCE", - resourceId: "resource-1", - capability: capability as never, - }, - suggestedFileName: "artifact.bin", - signal: controller.signal, - onProgress() {}, - }); - - void deliveryResult; - expect(closes).toBe(1); - }); - - /** - * TR-02. A lease that resolved after the abort already ended the delivery - * never reached the holder, so nothing closed it: the fetch reader and the - * capability lease outlived the terminal result. - */ - it("closes a source lease that arrives after the delivery was aborted", async () => { - const bytes = new Uint8Array([1, 2, 3]); - let closes = 0; - const controller = new AbortController(); - let releaseOpen: - | ((value: { ok: true; value: unknown }) => void) - | undefined; - const source = { - byteLength: bytes.byteLength, - integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const, - capability: undefined as never, - close() { - closes += 1; - }, - async *stream() { - yield { ok: true as const, value: bytes }; - }, - }; - const capability = Object.freeze({ - capabilityReceipt: "capability-late-1", - method: "GET" as const, - binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, - mediaType: "application/octet-stream", - byteLength: bytes.byteLength, - maxBytes: bytes.byteLength, - expectedSha256: "a".repeat(64), - expiresAtEpochMs: NOW + 60_000, - }); - source.capability = capability as never; - - const latePolicy = browserFilePolicyReference("download", "presigned-late"); - const policies = new BrowserFilePolicyRegistry({ - profiles: [ - { - reference: latePolicy, - download: { - strategy: "PROMPT_AND_STREAM", - mediaType: "application/octet-stream", - safeExtension: ".bin", - maxTransferBytes: 64, - maxBufferedBytes: 8, - integrity: "REQUIRED", - }, - }, - ], - hardLimits: { - maxInspectionBytes: 64, - maxRetainedFileBytes: 64, - maxPreviewBytes: 64, - maxObjectUrlBytes: 64, - maxTransferBytes: 64, - }, - }); - const downloads = createDownloadDeliveryAdapter({ - host: { handoff() {} }, - policies, - hardMaxObjectUrlBytes: 64, - hardMaxTransferBytes: 64, - browserManagedCapabilities: { - resolve() { - throw new TypeError("not used"); - }, - }, - // Ignores the signal entirely and resolves only when the test says so. - openAuthorizedSource: () => - new Promise((resolve) => { - releaseOpen = resolve as never; - }) as never, - showSaveFilePicker: async () => ({ - async createWritable() { - return new WritableStream({ write() {} }); - }, - }), - userActivation: { isActive: true }, - now: () => NOW, - }); - - const delivering = downloads.deliver({ - policy: latePolicy, - source: { - kind: "AUTHORIZED_STREAM_RESOURCE", - resourceId: "resource-1", - capability: capability as never, - }, - suggestedFileName: "artifact.bin", - signal: controller.signal, - onProgress() {}, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - controller.abort(); - const delivered = await delivering; - expect(delivered.ok).toBe(false); - - // The lease arrives only now, long after the terminal result. - releaseOpen?.({ ok: true, value: source }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(closes).toBe(1); - }); - - it("does not leave a late rejection unhandled after an abort", async () => { - const unhandled: unknown[] = []; - const onUnhandled = (reason: unknown) => unhandled.push(reason); - process.on("unhandledRejection", onUnhandled); - try { - const controller = new AbortController(); - let rejectOpen: ((reason: unknown) => void) | undefined; - const rejectPolicy = browserFilePolicyReference( - "download", - "presigned-late-reject", - ); - const policies = new BrowserFilePolicyRegistry({ - profiles: [ - { - reference: rejectPolicy, - download: { - strategy: "PROMPT_AND_STREAM", - mediaType: "application/octet-stream", - safeExtension: ".bin", - maxTransferBytes: 64, - maxBufferedBytes: 8, - integrity: "REQUIRED", - }, - }, - ], - hardLimits: { - maxInspectionBytes: 64, - maxRetainedFileBytes: 64, - maxPreviewBytes: 64, - maxObjectUrlBytes: 64, - maxTransferBytes: 64, - }, - }); - const downloads = createDownloadDeliveryAdapter({ - host: { handoff() {} }, - policies, - hardMaxObjectUrlBytes: 64, - hardMaxTransferBytes: 64, - browserManagedCapabilities: { - resolve() { - throw new TypeError("not used"); - }, - }, - openAuthorizedSource: () => - new Promise((_resolve, reject) => { - rejectOpen = reject; - }) as never, - showSaveFilePicker: async () => ({ - async createWritable() { - return new WritableStream({ write() {} }); - }, - }), - userActivation: { isActive: true }, - now: () => NOW, - }); - - const delivering = downloads.deliver({ - policy: rejectPolicy, - source: { - kind: "AUTHORIZED_STREAM_RESOURCE", - resourceId: "resource-1", - capability: Object.freeze({ - capabilityReceipt: "capability-late-2", - method: "GET" as const, - binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" }, - mediaType: "application/octet-stream", - byteLength: 3, - maxBytes: 3, - expectedSha256: "a".repeat(64), - expiresAtEpochMs: NOW + 60_000, - }) as never, - }, - suggestedFileName: "artifact.bin", - signal: controller.signal, - onProgress() {}, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - controller.abort(); - await delivering; - - rejectOpen?.(new Error("late open failure")); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(unhandled).toEqual([]); - } finally { - process.off("unhandledRejection", onUnhandled); - } - }); }); - -/** - * BT-PRE-01. The download lease is lazy, so a response-shape rejection is - * observed on first consumption rather than at `open()`. - */ -async function firstStreamResult( - opened: Awaited< - ReturnType< - ReturnType["executor"]["downloadSources"]["open"] - > - >, -): Promise { - if (!opened.ok) return opened; - try { - for await (const chunk of opened.value.stream( - new AbortController().signal, - )) { - if (!chunk.ok) return chunk; - } - return { ok: true }; - } finally { - opened.value.close(); - } -} diff --git a/tests/unit/security-followup.test.ts b/tests/unit/security-followup.test.ts index 992c96d..fb0ae71 100644 --- a/tests/unit/security-followup.test.ts +++ b/tests/unit/security-followup.test.ts @@ -965,36 +965,6 @@ describe("security follow-up contracts", () => { await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u); }); - it("kills and reaps a stubborn provider process group including its descendant", async () => { - const root = await mkdtemp(path.join(tmpdir(), "provider-process-group-")); - const descendantPidPath = path.join(root, "descendant.pid"); - try { - const source = [ - "const { spawn } = require('node:child_process');", - "const { writeFileSync } = require('node:fs');", - "const child = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdio: 'ignore' });", - "writeFileSync(process.env.DESCENDANT_PID_PATH, String(child.pid));", - "process.on('SIGTERM', () => {});", - "setInterval(() => {}, 1000);", - ].join("\n"); - const running = runProviderProcess({ - executable: process.execPath, - arguments: ["-e", source], - environment: { - PATH: process.env.PATH, - DESCENDANT_PID_PATH: descendantPidPath, - }, - timeoutMs: 250, - }); - await expect(running).rejects.toThrow(/timed out.*process close/u); - const descendantPid = Number(await readFile(descendantPidPath, "utf8")); - expect(Number.isSafeInteger(descendantPid) && descendantPid > 0).toBe(true); - expect(() => process.kill(descendantPid, 0)).toThrow(/ESRCH|no such process/u); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - it.each(["open failure", "partial write failure"])( "cleans finalized staging from memory when GITHUB_OUTPUT has a %s", async (failureKind) => { diff --git a/tests/unit/task3-selective-integration.test.ts b/tests/unit/task3-selective-integration.test.ts index 7777f07..c0370e0 100644 --- a/tests/unit/task3-selective-integration.test.ts +++ b/tests/unit/task3-selective-integration.test.ts @@ -182,9 +182,9 @@ 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(82); - expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94); - expect(canonical.artifacts).toHaveLength(107); + expect(canonical.commands).toHaveLength(84); + expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(96); + expect(canonical.artifacts).toHaveLength(109); expect(canonical.stages).toHaveLength(5); expect(canonical.retention.classes).toHaveLength(5); diff --git a/vitest.config.ts b/vitest.config.ts index 5358cb1..b46a250 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,11 @@ import { configDefaults, defineConfig } from "vitest/config"; +// Tests must not inherit a caller's production React/runtime selection. +// Package scripts also launch Vitest through scripts/run-vitest.ts, which owns +// NODE_ENV before the Vitest process starts. Keeping the config deterministic +// protects direct `vitest` invocations as well. +process.env.NODE_ENV = "test"; + export default defineConfig({ test: { globals: false,