diff --git a/CHANGELOG.md b/CHANGELOG.md index 18aada75..e589831a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,9 @@ All notable product and architecture changes are recorded here. Releases use imm ## Unreleased ### Added -- Active PR #248 adds PostgreSQL 18 durability for immutable normalized longitudinal observation evidence: exact source identity, separate validity/recorded/received/ingested clocks, explicit membership shares whose deferred transaction invariant totals 10,000 basis points, exact replay, tenant isolation, and fail-closed rebinding/immutability. This entry is active-PR evidence only and is not a protected-main or release claim. -- Active PR #287 rejects padded or otherwise noncanonical published narrative, style-mapping, interpretation-unit, and approved-selection references before deterministic rendering. Numeric score authority and the protected-main narrative boundary are unchanged; this is active-PR evidence only and not a release claim. +- Active PR #165 adds public in-process instrument catalog HTTP: `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` return only published locale-specific releases so a purchaser can copy `release_ref` and `locale` into `POST /v1/sessions`. Draft, suspended, and retired rows stay hidden. OpenAPI 3.2 is limited to those two operations. +- Merged #248 adds PostgreSQL 18 durability for immutable normalized longitudinal observation evidence: exact source identity, separate validity/recorded/received/ingested clocks, explicit membership shares whose deferred transaction invariant totals 10,000 basis points, exact replay, tenant isolation, and fail-closed rebinding/immutability. +- Merged #287 rejects padded or otherwise noncanonical published narrative, style-mapping, interpretation-unit, and approved-selection references before deterministic rendering. Numeric score authority and the protected-main narrative boundary are unchanged. - Personal result export delivery authorization (`authorize_result_export_read`) reuses the stored-record `ReadOwnResult` check on the product-owned participant and immutable result, then requires the export's result snapshot reference and copied participant reference to match that exact snapshot. A cross-tenant caller fails closed with the ordinary result-authorization denial before export-binding details are evaluated, so a mismatched export is not an existence oracle. No new permission or persistence is introduced; authorized HTTP transport ships through merged `src/result_export_http.rs` and `openapi/result-exports.yaml`. This is the post-#231 export-delivery guard (ADR-0010 provenance; ADR-0003 tenant-bound authorization). - Personal result export copies one immutable snapshot into JSON and a human-readable report so a purchaser can archive the same Extraversion estimate, standard error, and version provenance they were shown. The owner participant reference stays in both artifacts. Abstained or failed constructs keep their disposition and do not receive an invented score. Approved limitation text is required. - Product and technical gap baseline records the exact protected-main snapshot, current participant-visible gaps, all open repository PRs, issue #260 dependency, and the next executable delivery loop. diff --git a/README.md b/README.md index 681d8f41..386b6ab5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ Measure -> Understand -> Reflect -> Observe Over Time -> Contribute to Science This repository owns the hosted product runtime and integration composition. It consumes reusable measurement contracts and numerical capabilities from `ContextualWisdomLab/fast-mlsirm`; identity and federation from Keyverse; temporal/event analysis from TEPP; and research release/catalog capabilities from `semantic-data-portal`. +## Start with a published instrument + +When the instrument catalog HTTP family is running in this process: + +1. Call `GET /v1/instruments` to list startable published locale-specific releases. +2. Copy one `release_ref` and its exact `locale`. +3. Call `POST /v1/sessions` with those values when the session family is available. + +Draft, suspended, and retired releases are omitted. The as-built contract is [`openapi/instruments.yaml`](openapi/instruments.yaml). + ## Architecture boundary Psychometrics Commons owns product APIs, instrument publication, participant/session lifecycle, response events, consent and data-rights workflows, scoring dispatch, immutable result snapshots, product persistence, resource authorization, reference-client composition, deployment profiles, research-contribution handoff, observability, and service integration. diff --git a/docs/DOCUMENTATION_ASSESSMENT.md b/docs/DOCUMENTATION_ASSESSMENT.md index de683848..6d7c0dad 100644 --- a/docs/DOCUMENTATION_ASSESSMENT.md +++ b/docs/DOCUMENTATION_ASSESSMENT.md @@ -31,7 +31,7 @@ This reconciliation closes those architecture-definition gaps without promoting | Quality / risk / compliance readiness | **Sufficient as assurance baseline** | Evidence scenarios and risk state are explicit; readiness is not certification. | | Traceability | **Repaired in this reconciliation** | Baseline now names exact protected-main `4499d9c…`, reconciles merged result/session/anonymous-authorization/scoring-adapter slices, and isolates still-open PRs from shipped truth. Must be updated after every material merge. | | Roadmap / agent guidance / changelog | **Sufficient for continued delivery** | Must remain code-current; documentation completion is not a terminal condition for the execution loop. | -| Machine-readable OpenAPI / AsyncAPI | **Not yet applicable as as-built evidence** | Add and validate with the first implemented HTTP/event transport. Do not publish aspirational operations as deployed. | +| Machine-readable OpenAPI / AsyncAPI | **Active PR** #165 instrument catalog HTTP (`openapi/instruments.yaml`) is as-built for `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` only; remaining families and AsyncAPI stay unimplemented | Do not list session, response, consent, or event operations as deployed until those transports exist. | | Physical schema / as-built topology | **Partial / implementation-gated** | Logical ERD is authoritative target semantics. Actual migrations/topology/rollback/restore evidence must be compared to it as those artifacts land. | | Instrument-release evidence bundles | **Target** | Every publishable consumer instrument needs immutable rights, locale/translation, scoring/calibration/norm, DIF/invariance/linking where claimed, scoreability, intended-use and narrative-rule evidence. | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2985a8ec..5bb56277 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -27,15 +27,15 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Immutable response snapshot before scoring | PRD §9.3 | TRD §5–8 | ADR-0005, ADR-0010 | **Implemented** domain semantics in `src/response.rs` | | Version-pinned scoring | PRD §9.4, §10 | TRD §8 | ADR-0004, ADR-0010 | **Implemented** reusable product-side scoring dispatch contract in `src/scoring.rs` with canonical SHA-256 engine-artifact digest provenance, `migrations/0011_scoring_request.sql` / `src/postgres_scoring_request.rs` request-identity persistence, and protected-main request-bound external adapter `src/scoring_engine.rs`; live fast-mlsirm execution remains Target | | Bounded asynchronous scoring retry/quarantine with stale-worker fencing | PRD §9.4, §10 | TRD §8; ADR-0015 transaction boundary | ADR-0004, ADR-0010, ADR-0015 | **Implemented** product lifecycle plus PostgreSQL enqueue, claim, retry, completion, expiry recovery, and cancellation without transferring a fence; live fast-mlsirm execution remains Target | -| Immutable result provenance | PRD §3.1, §9.4 | TRD §9 | ADR-0004, ADR-0010 | **Implemented** in `src/result.rs`; authorized result-read transport is active PR #257 and not protected-main truth | -| Authorized immutable personal result read | PRD §3.1, §9.4 | TRD §18 `GET /v1/results/{result_ref}`; result authorization boundary | ADR-0003, ADR-0010 | **Active PR** #257 exposes an authorized immutable result read that checks server-owned participant/result bindings before route identity; it is not protected-main truth | +| Immutable result provenance | PRD §3.1, §9.4 | TRD §9 | ADR-0004, ADR-0010 | **Implemented** in `src/result.rs`; authorized result-read transport shipped through merged #257 (`src/result_http.rs`) | +| Authorized immutable personal result read | PRD §3.1, §9.4 | TRD §18 `GET /v1/results/{result_ref}`; result authorization boundary | ADR-0003, ADR-0010 | **Implemented** through merged #257 (`src/result_http.rs`): an authorized immutable result read checks server-owned participant/result bindings before route identity | | Personal JSON and human-readable result export | PRD §3.1, §9.4 | TRD §18 `POST /v1/results/{result_ref}/exports`; ADR-0010 export provenance | ADR-0010 | **Implemented** domain copy through merged #231, delivery guard through merged #249 (`src/result_export_authorization.rs`), and authorized HTTP transport through merged #256 | | Immutable result provenance | PRD §3.1, §9.4 | TRD §9 | ADR-0004, ADR-0010 | **Implemented** in `src/result.rs`; result-serving transport is Target | | Personal JSON and human-readable result export | PRD §3.1, §9.4 | TRD §18 `POST /v1/results/{result_ref}/exports`; ADR-0010 export provenance | ADR-0010 | **Implemented** domain copy through merged #231, delivery guard through merged #249 (`src/result_export_authorization.rs`), and authorized HTTP transport through merged #256 (`src/result_export_http.rs`, `openapi/result-exports.yaml`) | | Deterministic narrative fallback | PRD §3.2, §9.5 | TRD §17; Architecture narrative view | ADR-0009, ADR-0010, ADR-0018 | **Implemented** through merged #287 (`src/deterministic_narrative.rs`, `src/style_mapping.rs`): published narrative/rule references must already use canonical opaque spelling before deterministic rendering; numeric score authority is unchanged | | Continuous scores remain source of truth; Personality Style is presentation | PRD §3.2 | Measurement Governance; AI Governance | ADR-0018 | Target product narrative mapping; numeric source remains External fast-mlsirm contract | | Immutable instrument release/version lifecycle | PRD §6, §9 | TRD §7; UML publication state | ADR-0005, ADR-0010 | **Implemented** in `src/instrument.rs` plus `migrations/0006_instrument_release.sql` and `src/postgres_instrument_release.rs`: immutable release manifest, exact version/digest/locale/item set, fail-closed Draft/Review/Published/Suspended/Retired lifecycle, idempotent publication events, and new-session eligibility | -| Quick and Deep assessment paths | PRD §3.1, §9 | TRD §5–7; immutable release/item-delivery boundary | ADR-0005, ADR-0010 | **Active PR #261** binds ordered Quick/Deep item subsets to one immutable release and copies release locale/provenance; path persistence, item-delivery transport, conversion, and scoring integration remain Target | +| Quick and Deep assessment paths | PRD §3.1, §9 | TRD §5–7; immutable release/item-delivery boundary | ADR-0005, ADR-0010 | **Implemented** through merged #261, which binds ordered Quick/Deep item subsets to one immutable release and copies release locale/provenance; path persistence, item-delivery transport, conversion, and scoring integration remain Target | | Instrument publication requires intended-use scientific/right/locale evidence | PRD §6, §9, §10 | Measurement Governance; publication evidence gate | ADR-0004, ADR-0013, ADR-0019 | **Implemented** policy gate and immutable evidence provenance in `src/instrument.rs`; each real instrument still requires its own rights/locale/scientific evidence artifacts before publication | | Optional Keyverse account linking | PRD §3.1, §9.7 | TRD §10; UML identity-link lifecycle | ADR-0003, ADR-0020 | **Partially implemented**: issuer-scoped first-link fail-closed domain primitive in `src/participant.rs`; append-only unlink/relink/recovery history, persistence, audit, and transport remain Target | | Cross-cutting tenant/task authorization | PRD §7, §9 | TRD §11; Security/Data | ADR-0001, ADR-0003 | **Implemented** fail-closed domain gate in `src/authorization.rs` binds consent operations to participant-owned `ConsentLedger` / `ManageOwnConsent`; persistence/policy-adapter/public-transport integration remains Target | @@ -48,9 +48,9 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Operation-scoped capability health | PRD §7, §13 | `docs/OPERABILITY.md` §3–4; Deployment/Operations | ADR-0011, ADR-0017 | **Implemented** domain health/readiness contract in `src/health.rs` plus `src/postgres_health.rs` PostgreSQL major/write-readiness and caller-declared relation presence; HTTP probes, measured thresholds, and deployment evidence remain Target | | Korean/English exact locale versions | PRD §3.1, §9.9 | TRD §28; instrument release + locale governance | ADR-0013, ADR-0019 | **Partially implemented**: locale is pinned/validated by `src/instrument.rs`; merged #259 ships protected-main exact `ko-KR`/`en-US` participant report labels that copy immutable scores and provenance; real form content, rights, translation, invariance, HTTP delivery, and accessible reference-client serving remain Target | | WCAG 2.2 AA supported reference client | PRD §9.10 | TRD §27; Quality Attributes | ADR-0002, ADR-0013 | Target; no reference client implementation on evaluated main | -| EMA/ESM longitudinal flow | PRD §4 | TRD §16; UML longitudinal sequence; logical ERD extension | ADR-0008 | External Gyeot/TEPP dependencies + Target Commons enrollment/orchestration adapter; `src/longitudinal_observation.rs` records validity, recorded, received, and ingested clocks with explicit membership shares. **Active PR #248 / IMPLEMENTED_ON_ACTIVE_PR** adds PostgreSQL 18 persistence for immutable normalized observation records and membership shares via `migrations/0031_longitudinal_observation.sql` and `src/postgres_longitudinal_observation.rs`; enrollment persistence, HTTP, Gyeot collection, and TEPP kernels remain Target | +| EMA/ESM longitudinal flow | PRD §4 | TRD §16; UML longitudinal sequence; logical ERD extension | ADR-0008 | External Gyeot/TEPP dependencies + Target Commons enrollment/orchestration adapter; `src/longitudinal_observation.rs` records validity, recorded, received, and ingested clocks with explicit membership shares. **Implemented** through merged #248, which adds PostgreSQL 18 persistence for immutable normalized observation records and membership shares via `migrations/0031_longitudinal_observation.sql` and `src/postgres_longitudinal_observation.rs`; enrollment persistence, HTTP, Gyeot collection, and TEPP kernels remain Target | | Measurement Workbench | PRD §6 | C4/component view; UML publication-evidence sequence; Measurement Governance | ADR-0001, ADR-0002, ADR-0004, ADR-0019 | Target; fast-mlsirm/Inkspan/RankWeave are External dependencies | -| Headless replaceable clients | PRD §7 | TRD §1, §18; C4 | ADR-0001, ADR-0002 | Architecture established; public transport is Target | +| Headless replaceable clients | PRD §7 | TRD §1, §18; C4 | ADR-0001, ADR-0002 | Architecture established; Active PR instrument catalog HTTP lists startable published releases; remaining public families are Target | | Community/Hosted/Enterprise profiles | PRD §7, §13 | TRD deployment sections; Deployment/Operations | ADR-0011, ADR-0017 | Target deployment packaging/evidence | ## 3. Technical invariant traceability @@ -67,7 +67,7 @@ An active PR, architecture document, conversation decision, or scheduler plan is | Stale scoring worker cannot complete a newer attempt | TRD §8; ADR-0015 | `src/scoring_job.rs` uses monotonically increasing fencing tokens and rejects stale/expired completion or failure evidence; `src/postgres_scoring_job.rs` persists enqueue, named claim, claim-next poll, retry, terminal outcomes, expired-lease recovery, and cancellation without transferring a fence | live adapter evidence | | Scientific failure is typed, no invented score | TRD §8; Measurement Governance | scoring contract tests plus `src/scoring_engine.rs` typed engine/request-mismatch errors | cross-process failure injection and live provider evidence | | Historical result does not mutate | TRD §9 | `src/result.rs` snapshot semantics | persistence and API supersession tests | -| Authorized result read does not expose cross-tenant existence | TRD §11, §18; Security/Data | Active PR #257 `src/result_http.rs` authorizes server-owned participant/result records before comparing the requested reference and uses safe RFC 9457-style problem responses | protected-main transport, PostgreSQL resource loading, and cross-tenant HTTP E2E | +| Authorized result read does not expose cross-tenant existence | TRD §11, §18; Security/Data | Merged #257 `src/result_http.rs` authorizes server-owned participant/result records before comparing the requested reference and uses safe RFC 9457-style problem responses | PostgreSQL resource loading and cross-tenant HTTP E2E | | Result export includes machine-readable provenance and the same scores | ADR-0010; PRD §3.1 | Protected-main `src/result_export.rs` copies snapshot scores, standard errors, dispositions, owner identity, and version provenance into JSON and a human-readable report; authorized HTTP transport shipped through merged #256 | contract tests on `tests/result_export_http.rs` | | Result export includes machine-readable provenance and the same scores | ADR-0010; PRD §3.1 | Protected-main `src/result_export.rs` copies snapshot scores, standard errors, dispositions, owner identity, and version provenance into JSON and a human-readable report | authorized HTTP transport shipped through merged #256 | | Personal export delivery is authorized from stored participant/result records | ADR-0010; ADR-0003; TRD §11 | Protected-main `src/result_export_authorization.rs` (merged #249) reuses stored-record `ReadOwnResult` and fail-closes cross-tenant before export-binding details | HTTP export contract tests on merged `tests/result_export_http.rs` | @@ -114,7 +114,7 @@ src/lib.rs ├── deterministic_narrative.rs # deterministic AI-independent approved style narrative fallback ├── health.rs # operation-scoped liveness/readiness and capability-state contract ├── instrument.rs # immutable release manifest + scientific publication-evidence gate -├── assessment_path.rs # Active PR #261 release-bound Quick/Deep item subset contract +├── assessment_path.rs # release-bound Quick/Deep item subset contract (merged #261) ├── api_problem.rs # safe RFC 9457 problem-details primitive ├── localized_result_report.rs # exact-locale report presentation over immutable exports (merged #259) ├── integration.rs # outbox/inbox/retry/quarantine domain contracts @@ -162,11 +162,10 @@ Still-Target logical modules/adapters include remaining product aggregate persis ### Active implementation work that is not protected-main truth -Merged #249 `authorize_result_export_read` is protected-main delivery-guard evidence in `src/result_export_authorization.rs`: it authorizes the stored participant/result with existing `ReadOwnResult` (ADR-0010 export provenance; ADR-0003 tenant-bound authorization) and then requires the export's `result_snapshot_ref` and copied `participant_ref` to match that exact immutable snapshot. Cross-tenant callers fail closed with the ordinary result-authorization denial before export-binding details are evaluated. No new permission or persistence was introduced; authorized HTTP transport ships through merged #256. -Merged #231 personal result export is protected-main domain evidence. `ResultExport::from_snapshot` copies the stored construct scores, standard errors, dispositions, owner `participant_ref`, and version provenance into a JSON document and a human-readable report. Approved limitation text is required so the report cannot imply diagnosis, employment fitness, or a type score. Padded export aliases are rejected at this boundary without rewriting shared reference trimming used by consent and other domains. The snapshot is not mutated. Do not fold unrelated persistence into this domain slice. -**Active PR** #257 authorized immutable personal result reads are not protected-main truth until an unchanged reviewed/check-clean head is integrated. `src/result_http.rs` accepts only exact `GET /v1/results/{result_ref}` targets, checks server-owned participant/result authorization before identity comparison, returns no result existence oracle to an unauthorized actor, and serializes the stored immutable provenance without recomputation. PostgreSQL result loading and cross-tenant HTTP E2E remain required before this becomes a complete product path. +**Active PR** #165 instrument catalog HTTP is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` return only published locale-specific releases from an in-process catalog so a purchaser can copy `release_ref` and `locale` into `POST /v1/sessions`. Draft, suspended, and retired rows stay hidden. Do not mix onto protected-main persist-backed session HTTP, open #150 research persistence, or protected-main exact published-release instrument load. Session create/reload, persistence, and other public families remain outside this slice. +Merged #257 authorized immutable personal result reads are protected-main transport evidence. `src/result_http.rs` accepts only exact `GET /v1/results/{result_ref}` targets, checks server-owned participant/result authorization before identity comparison, returns no result existence oracle to an unauthorized actor, and serializes the stored immutable provenance without recomputation. PostgreSQL result loading and cross-tenant HTTP E2E remain required before this becomes a complete product path. **Active PR** #284 durable accepted response-event persistence is not protected-main truth until an unchanged reviewed/check-clean head is integrated. `migrations/0020_response_event.sql` and `src/postgres_response_event.rs` keep the accepted mid-session ledger prefix durable across process restart with exact replay classification, contiguous sequence recovery, immutable provenance, and fail-closed migration/reference contracts. Public response HTTP transport and completed snapshot reload remain separate slices. -**Active PR** #301 consolidated public-release identity privacy gate is not protected-main truth until an unchanged reviewed/check-clean head is integrated. It consolidates the identity-column denylist, cell-value scanner, separator/prefix hardening, structured-value fail-closed behavior, and the `IdentityInventoryUnavailable` fail-closed inventory contract required by issue #260. A missing or blank effective restricted-identity inventory must fail closed before public fixture approval rather than being read as "nothing to match". +Closed-unmerged #301 consolidated public-release identity privacy gate is no longer an active lane; its fail-closed inventory requirement remains tracked by issue #260. It consolidates the identity-column denylist, cell-value scanner, separator/prefix hardening, structured-value fail-closed behavior, and the `IdentityInventoryUnavailable` fail-closed inventory contract required by issue #260. A missing or blank effective restricted-identity inventory must fail closed before public fixture approval rather than being read as "nothing to match". Merged #249 `authorize_result_export_read` is protected-main delivery-guard evidence in `src/result_export_authorization.rs`: it authorizes the stored participant/result with existing `ReadOwnResult` (ADR-0010 export provenance; ADR-0003 tenant-bound authorization) and then requires the export's `result_snapshot_ref` and copied `participant_ref` to match that exact immutable snapshot. Cross-tenant callers fail closed with the ordinary result-authorization denial before export-binding details are evaluated. No new permission or persistence was introduced. Merged #256 authorized personal result export HTTP is protected-main transport evidence: `src/result_export_http.rs` and `openapi/result-exports.yaml` bind the stored result to the authenticated participant/resource scope and preserve the immutable export provenance from `src/result_export.rs`. diff --git a/docs/adr/0014-api-and-event-contract-representation.md b/docs/adr/0014-api-and-event-contract-representation.md index 2fcf3de1..7eebd8b5 100644 --- a/docs/adr/0014-api-and-event-contract-representation.md +++ b/docs/adr/0014-api-and-event-contract-representation.md @@ -6,7 +6,7 @@ - Scope: Psychometrics Commons public/admin HTTP APIs, product-owned durable domain events, errors, schema/version negotiation - Supersedes: none - Superseded by: none -- Current/as-built status: persist-backed session create/reload HTTP (`POST /v1/sessions`, `GET /v1/sessions/{session_ref}`, `openapi/sessions.yaml`) exists on Active PR #232 and is not protected-main truth; remaining public/admin families and durable external event transport are still unimplemented on protected main +- Current/as-built status: persist-backed session create/reload HTTP (`POST /v1/sessions`, `GET /v1/sessions/{session_ref}`, `openapi/sessions.yaml`) exists on Active PR #232 and is not protected-main truth; Active PR #165 instrument catalog HTTP adds as-built OpenAPI 3.2.0 for `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` only and is not protected-main truth until an unchanged reviewed/check-clean head is integrated; remaining public/admin families and durable external event transport are still unimplemented on protected main - Target status: every implemented HTTP/event surface has an exact versioned machine-readable as-built contract and deterministic integrity/idempotency semantics - Migration status: no deployed HTTP/event transport requires migration yet; the first implementation must introduce the contract in the same or prerequisite PR @@ -165,7 +165,7 @@ Rollback must restore an application version that still understands any messages - `docs/architecture/UML.md` must not model receipt as equivalent to externally visible side-effect completion. - `docs/architecture/SECURITY_AND_DATA.md` must preserve tenant/purpose boundaries for event payloads and quarantine. - `docs/architecture/DEPLOYMENT_AND_OPERATIONS.md` must include replay/quarantine/recovery evidence when event transport is implemented. -- `docs/TRACEABILITY.md` remains target until as-built OpenAPI/AsyncAPI and transport tests exist. +- `docs/TRACEABILITY.md` remains target for remaining families until as-built OpenAPI/AsyncAPI and transport tests exist. Active PR instrument catalog HTTP names `openapi/instruments.yaml` as the as-built contract for the two GET operations only. ## Validation and release evidence diff --git a/docs/doctoring/standards-and-evidence.md b/docs/doctoring/standards-and-evidence.md index c9094961..7d37305a 100644 --- a/docs/doctoring/standards-and-evidence.md +++ b/docs/doctoring/standards-and-evidence.md @@ -137,6 +137,16 @@ Product consequences: - missing sessions return 404 that tells the buyer to POST the same Idempotency-Key. +## Public HTTP contracts + +Implemented public HTTP families are described by an exact OpenAPI 3.2.0 as-built document and return RFC 9457 problem details. HTTP semantics, including method and target parsing, follow RFC 9110. A catalog or session operation that is not in the as-built document is not available to a purchaser. + +Product consequences: + +- `GET /v1/instruments` and `GET /v1/instruments/{instrument_ref}` are the first as-built catalog family; use a listed `release_ref` and `locale` with `POST /v1/sessions` when that family is available; +- problem details use stable URNs and never echo raw request bodies, SQL, or provider text; +- unpublished instrument rows stay hidden so a purchaser cannot discover draft work through the public catalog. + ## Evidence maintenance rules 1. Review this baseline when a referenced standard is revised, withdrawn, superseded, or materially amended. diff --git a/openapi/instruments.yaml b/openapi/instruments.yaml new file mode 100644 index 00000000..8c260da0 --- /dev/null +++ b/openapi/instruments.yaml @@ -0,0 +1,151 @@ +openapi: 3.2.0 +info: + title: Psychometrics Commons public instrument catalog API + version: 0.1.0 + description: > + As-built public catalog of startable published instrument releases. A + purchaser lists exact locale-specific releases, then uses release_ref and + locale with POST /v1/sessions. Draft, suspended, and retired releases are + not listed. This family does not create sessions or persist catalog rows. +paths: + /v1/instruments: + get: + operationId: listStartableInstruments + summary: List startable published releases + description: > + Returns published locale-specific releases that currently accept new + sessions, ordered by instrument_ref, locale, then release_ref. Use a + listed release_ref and locale when calling POST /v1/sessions. + responses: + "200": + description: Startable published releases in this process catalog. + content: + application/json: + schema: + $ref: "#/components/schemas/InstrumentReleaseList" + "400": + $ref: "#/components/responses/Problem" + "404": + $ref: "#/components/responses/Problem" + "405": + $ref: "#/components/responses/MethodNotAllowed" + /v1/instruments/{instrument_ref}: + get: + operationId: getStartableInstrumentFamily + summary: List startable releases for one instrument family + description: > + Returns published releases for one opaque instrument family. Families + that exist only as draft, suspended, or retired rows return 404 so + unpublished work stays hidden. Use a listed release_ref and locale + with POST /v1/sessions. + parameters: + - name: instrument_ref + in: path + required: true + schema: + type: string + description: Opaque instrument family reference. + responses: + "200": + description: Published releases for this family. + content: + application/json: + schema: + $ref: "#/components/schemas/InstrumentFamily" + "400": + $ref: "#/components/responses/Problem" + "404": + $ref: "#/components/responses/Problem" + "405": + $ref: "#/components/responses/MethodNotAllowed" +components: + schemas: + InstrumentReleaseList: + type: object + additionalProperties: false + required: + - releases + properties: + releases: + type: array + items: + $ref: "#/components/schemas/StartableRelease" + InstrumentFamily: + type: object + additionalProperties: false + required: + - instrument_ref + - releases + properties: + instrument_ref: + type: string + releases: + type: array + items: + $ref: "#/components/schemas/StartableRelease" + StartableRelease: + type: object + additionalProperties: false + required: + - instrument_ref + - release_ref + - instrument_version_ref + - locale + - construct_ref + - content_digest + - intended_use_ref + - limitations_ref + - state + properties: + instrument_ref: + type: string + release_ref: + type: string + instrument_version_ref: + type: string + locale: + type: string + construct_ref: + type: string + content_digest: + type: string + intended_use_ref: + type: string + limitations_ref: + type: string + state: + type: string + enum: + - published + Problem: + type: object + additionalProperties: false + required: [type, title, status, detail] + properties: + type: + type: string + title: + type: string + status: + type: integer + detail: + type: string + responses: + Problem: + description: Safe RFC 9457 problem details without raw request or store errors. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + MethodNotAllowed: + description: The public instrument catalog only implements GET. + headers: + Allow: + description: RFC 9110 method set accepted by this resource. + schema: + type: string + const: GET + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" diff --git a/src/instrument_http.rs b/src/instrument_http.rs new file mode 100644 index 00000000..01fcb2eb --- /dev/null +++ b/src/instrument_http.rs @@ -0,0 +1,487 @@ +//! Public HTTP transport for listing startable published instrument releases. +//! +//! This slice exposes `GET /v1/instruments` and +//! `GET /v1/instruments/{instrument_ref}` over HTTP/1.1. Only releases whose +//! publication state currently accepts new sessions are visible. Draft, +//! suspended, and retired rows stay hidden so unpublished catalog work cannot +//! be discovered here. A purchaser uses the returned `release_ref` and `locale` +//! with `POST /v1/sessions`. Persistence and session HTTP remain other slices. +//! Errors use RFC 9457 problem details and never echo raw request bodies. + +use crate::instrument::{InstrumentRelease, PublicationState}; +use crate::reference::normalized_reference; +use std::fmt::Write; +use std::io::{self, Read, Write as IoWrite}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::time::Duration; + +/// Public collection path for startable instrument releases. +pub const INSTRUMENT_COLLECTION_PATH: &str = "/v1/instruments"; +/// Bounded read/write timeout for one accepted instrument HTTP connection. +pub const INSTRUMENT_HTTP_IO_TIMEOUT: Duration = Duration::from_secs(2); +/// Maximum accepted instrument HTTP request size, including headers. +pub const INSTRUMENT_HTTP_MAX_REQUEST_BYTES: usize = 4_096; + +/// In-process catalog of instrument releases the handler may disclose. +pub struct InstrumentHttpRuntime { + releases: Vec, +} + +impl InstrumentHttpRuntime { + /// Create a runtime from the exact catalog the handler may inspect. + /// + /// Unpublished releases may be supplied for operator tests; the handler + /// still hides them from public list and family responses. + #[must_use] + pub fn new(releases: Vec) -> Self { + Self { releases } + } + + /// Return how many catalog rows this process currently holds, including hidden ones. + #[must_use] + pub fn catalog_count(&self) -> usize { + self.releases.len() + } +} + +/// HTTP response produced by a public instrument catalog request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstrumentHttpResponse { + status: u16, + content_type: &'static str, + allow: Option<&'static str>, + body: String, +} + +impl InstrumentHttpResponse { + fn json(status: u16, body: String) -> Self { + Self { + status, + content_type: "application/json", + allow: None, + body, + } + } + + fn problem(status: u16, type_uri: &str, title: &str, detail: &str) -> Self { + Self { + status, + content_type: "application/problem+json", + allow: None, + body: format!( + "{{\"type\":{},\"title\":{},\"status\":{status},\"detail\":{}}}", + json_string(type_uri), + json_string(title), + json_string(detail) + ), + } + } + + /// Return the HTTP status code. + #[must_use] + pub const fn status(&self) -> u16 { + self.status + } + + /// Return the response content type. + #[must_use] + pub const fn content_type(&self) -> &'static str { + self.content_type + } + + /// Return the RFC 9110 `Allow` field when the request method is rejected. + #[must_use] + pub const fn allow(&self) -> Option<&'static str> { + self.allow + } + + /// Return the response body. + #[must_use] + pub fn body(&self) -> &str { + &self.body + } +} + +/// Translate one raw HTTP/1.1 request into a catalog list or family response. +/// +/// Unknown methods, malformed/unsafe encoded references, numeric references, +/// and families with no startable published release fail closed with RFC 9457 +/// problem details. Valid UTF-8 percent encoding is decoded exactly once so +/// visible multilingual opaque references remain addressable over HTTP. +#[must_use] +pub fn handle_instrument_http_request( + request: &str, + runtime: &InstrumentHttpRuntime, +) -> InstrumentHttpResponse { + let Some((method, target)) = parse_request_line(request) else { + return InstrumentHttpResponse::problem( + 400, + "urn:psychometrics-commons:problem:bad-request", + "Bad Request", + "instrument request must include an HTTP method and target", + ); + }; + let path = split_target(target).0; + match (method, path) { + ("GET", INSTRUMENT_COLLECTION_PATH) => handle_list(runtime), + ("GET", path) => handle_family(path, runtime), + (_, INSTRUMENT_COLLECTION_PATH) => method_not_allowed(), + (_, path) if path.starts_with("/v1/instruments/") => method_not_allowed(), + _ => InstrumentHttpResponse::problem( + 404, + "urn:psychometrics-commons:problem:not-found", + "Not Found", + "instrument routes accept GET /v1/instruments and GET /v1/instruments/{instrument_ref} only", + ), + } +} + +/// Bind a blocking TCP listener for public instrument HTTP. +/// +/// Tests and local operators typically bind `127.0.0.1:0`. Hosted processes bind +/// `0.0.0.0:$PORT`. This function does not start accepting connections. +/// +/// # Errors +/// +/// Returns the I/O error if the operating system cannot bind the address. +pub fn bind_instrument_http(addr: SocketAddr) -> io::Result { + TcpListener::bind(addr) +} + +/// Accept one TCP connection and serve a single instrument HTTP request. +/// +/// The connection is closed after the response. Keep-alive, TLS, and other +/// public families are outside this slice. +/// +/// # Errors +/// +/// Returns the I/O error if accept, read, or write fails. +pub fn accept_one_instrument_http( + listener: &TcpListener, + runtime: &InstrumentHttpRuntime, +) -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(INSTRUMENT_HTTP_IO_TIMEOUT))?; + stream.set_write_timeout(Some(INSTRUMENT_HTTP_IO_TIMEOUT))?; + let request = read_http_request(&mut stream)?; + let response = handle_instrument_http_request(&request, runtime); + write_http_response(&mut stream, &response) +} + +fn method_not_allowed() -> InstrumentHttpResponse { + let mut response = InstrumentHttpResponse::problem( + 405, + "urn:psychometrics-commons:problem:method-not-allowed", + "Method Not Allowed", + "instrument routes accept GET /v1/instruments and GET /v1/instruments/{instrument_ref} only", + ); + response.allow = Some("GET"); + response +} + +fn handle_list(runtime: &InstrumentHttpRuntime) -> InstrumentHttpResponse { + InstrumentHttpResponse::json(200, releases_body(&published_releases(runtime))) +} + +fn handle_family(path: &str, runtime: &InstrumentHttpRuntime) -> InstrumentHttpResponse { + let Some(encoded_instrument_ref) = path + .strip_prefix("/v1/instruments/") + .filter(|value| !value.is_empty() && !value.contains('/')) + else { + return InstrumentHttpResponse::problem( + 404, + "urn:psychometrics-commons:problem:not-found", + "Not Found", + "instrument routes accept GET /v1/instruments and GET /v1/instruments/{instrument_ref} only", + ); + }; + let Some(instrument_ref) = decode_path_segment(encoded_instrument_ref) else { + return invalid_instrument_reference(); + }; + if instrument_ref.contains('/') + || instrument_ref.chars().any(char::is_whitespace) + || normalized_reference(&instrument_ref) != Some(instrument_ref.as_str()) + { + return invalid_instrument_reference(); + } + let releases: Vec<&InstrumentRelease> = published_releases(runtime) + .into_iter() + .filter(|release| release.manifest().instrument_ref() == instrument_ref) + .collect(); + if releases.is_empty() { + return InstrumentHttpResponse::problem( + 404, + "urn:psychometrics-commons:problem:instrument-not-found", + "Instrument Not Found", + "Use GET /v1/instruments to list startable published releases, then POST /v1/sessions with a listed release_ref and locale", + ); + } + InstrumentHttpResponse::json( + 200, + format!( + "{{\"instrument_ref\":{},\"releases\":{}}}", + json_string(&instrument_ref), + release_array(&releases) + ), + ) +} + +fn invalid_instrument_reference() -> InstrumentHttpResponse { + InstrumentHttpResponse::problem( + 400, + "urn:psychometrics-commons:problem:bad-request", + "Bad Request", + "instrument_ref must be an opaque non-numeric family identity", + ) +} + +fn decode_path_segment(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let high = hex_value(*bytes.get(index + 1)?)?; + let low = hex_value(*bytes.get(index + 2)?)?; + decoded.push((high << 4) | low); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +const fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn published_releases(runtime: &InstrumentHttpRuntime) -> Vec<&InstrumentRelease> { + let mut releases: Vec<&InstrumentRelease> = runtime + .releases + .iter() + .filter(|release| release.state() == PublicationState::Published) + .collect(); + releases.sort_by(|left, right| { + let left_manifest = left.manifest(); + let right_manifest = right.manifest(); + left_manifest + .instrument_ref() + .cmp(right_manifest.instrument_ref()) + .then(left_manifest.locale().cmp(right_manifest.locale())) + .then( + left_manifest + .release_ref() + .cmp(right_manifest.release_ref()), + ) + }); + releases +} + +fn releases_body(releases: &[&InstrumentRelease]) -> String { + format!("{{\"releases\":{}}}", release_array(releases)) +} + +fn release_array(releases: &[&InstrumentRelease]) -> String { + let mut body = String::from("["); + for (index, release) in releases.iter().enumerate() { + if index > 0 { + body.push(','); + } + body.push_str(&release_object(release)); + } + body.push(']'); + body +} + +fn release_object(release: &InstrumentRelease) -> String { + let manifest = release.manifest(); + format!( + "{{\"instrument_ref\":{},\"release_ref\":{},\"instrument_version_ref\":{},\"locale\":{},\"construct_ref\":{},\"content_digest\":{},\"intended_use_ref\":{},\"limitations_ref\":{},\"state\":\"published\"}}", + json_string(manifest.instrument_ref()), + json_string(manifest.release_ref()), + json_string(manifest.instrument_version_ref()), + json_string(manifest.locale()), + json_string(manifest.construct_ref()), + json_string(manifest.content_digest()), + json_string(manifest.intended_use_ref()), + json_string(manifest.limitations_ref()) + ) +} + +fn parse_request_line(request: &str) -> Option<(&str, &str)> { + let line = request.lines().next()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?; + let target = parts.next()?; + let version = parts.next()?; + if !version.starts_with("HTTP/") || parts.next().is_some() { + return None; + } + Some((method, target)) +} + +fn split_target(target: &str) -> (&str, &str) { + match target.split_once('?') { + Some((path, query)) => (path, query), + None => (target, ""), + } +} + +fn json_string(value: &str) -> String { + let mut encoded = String::from("\""); + for character in value.chars() { + match character { + '"' => encoded.push_str("\\\""), + '\\' => encoded.push_str("\\\\"), + '\n' => encoded.push_str("\\n"), + '\r' => encoded.push_str("\\r"), + '\t' => encoded.push_str("\\t"), + character if character.is_control() => { + let _ = write!(encoded, "\\u{:04x}", u32::from(character)); + } + character => encoded.push(character), + } + } + encoded.push('"'); + encoded +} + +fn reason_phrase(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 405 => "Method Not Allowed", + _ => "Error", + } +} + +fn read_http_request(stream: &mut TcpStream) -> io::Result { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 512]; + loop { + let read_result = stream.read(&mut chunk); + match apply_request_read(&mut buffer, &chunk, read_result)? { + RequestReadProgress::Continue => {} + RequestReadProgress::Complete => break, + } + } + if buffer.len() > INSTRUMENT_HTTP_MAX_REQUEST_BYTES + || !buffer.windows(4).any(|window| window == b"\r\n\r\n") + { + return Ok(String::new()); + } + Ok(String::from_utf8_lossy(&buffer).into_owned()) +} + +#[derive(Debug)] +enum RequestReadProgress { + Continue, + Complete, +} + +fn apply_request_read( + buffer: &mut Vec, + chunk: &[u8], + read_result: io::Result, +) -> io::Result { + match read_result { + Ok(0) => Ok(RequestReadProgress::Complete), + Ok(read) => { + buffer.extend_from_slice(&chunk[..read]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") + || buffer.len() > INSTRUMENT_HTTP_MAX_REQUEST_BYTES + { + Ok(RequestReadProgress::Complete) + } else { + Ok(RequestReadProgress::Continue) + } + } + Err(error) => Err(error), + } +} + +fn write_http_response( + stream: &mut TcpStream, + response: &InstrumentHttpResponse, +) -> io::Result<()> { + let allow_header = response + .allow + .map_or_else(String::new, |value| format!("Allow: {value}\r\n")); + let payload = format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\n{}Content-Length: {}\r\nConnection: close\r\n\r\n{}", + response.status, + reason_phrase(response.status), + response.content_type, + allow_header, + response.body.len(), + response.body + ); + stream.write_all(payload.as_bytes()) +} + +#[cfg(test)] +mod unit_tests { + use super::{ + apply_request_read, json_string, parse_request_line, reason_phrase, split_target, + RequestReadProgress, INSTRUMENT_HTTP_MAX_REQUEST_BYTES, + }; + use std::io::{self, ErrorKind}; + + #[test] + fn helpers_cover_escapes_status_and_read_progress() { + assert_eq!(reason_phrase(200), "OK"); + assert_eq!(reason_phrase(400), "Bad Request"); + assert_eq!(reason_phrase(404), "Not Found"); + assert_eq!(reason_phrase(405), "Method Not Allowed"); + assert_eq!(reason_phrase(418), "Error"); + assert_eq!(json_string("a\"b\\c"), "\"a\\\"b\\\\c\""); + assert_eq!(json_string("a\n\r\t"), "\"a\\n\\r\\t\""); + assert_eq!(json_string("\u{0001}"), "\"\\u0001\""); + assert_eq!( + split_target("/v1/instruments?x=1"), + ("/v1/instruments", "x=1") + ); + assert_eq!(split_target("/v1/instruments"), ("/v1/instruments", "")); + let mut buffer = Vec::new(); + assert!(matches!( + apply_request_read(&mut buffer, b"", Ok(0)).unwrap(), + RequestReadProgress::Complete + )); + assert!(matches!( + apply_request_read(&mut Vec::new(), b"GET", Ok(3)).unwrap(), + RequestReadProgress::Continue + )); + let mut oversized = vec![b'x'; INSTRUMENT_HTTP_MAX_REQUEST_BYTES]; + assert!(matches!( + apply_request_read(&mut oversized, b"y", Ok(1)).unwrap(), + RequestReadProgress::Complete + )); + let timeout = apply_request_read( + &mut Vec::new(), + b"", + Err(io::Error::new(ErrorKind::TimedOut, "timeout")), + ) + .expect_err("read timeout must remain an I/O timeout"); + assert_eq!(timeout.kind(), ErrorKind::TimedOut); + let would_block = apply_request_read( + &mut Vec::new(), + b"", + Err(io::Error::new(ErrorKind::WouldBlock, "block")), + ) + .expect_err("would-block must remain an I/O error"); + assert_eq!(would_block.kind(), ErrorKind::WouldBlock); + assert!(apply_request_read(&mut Vec::new(), b"", Err(io::Error::other("boom"))).is_err()); + assert!(parse_request_line("GET /v1/instruments SMTP/1.0\r\n\r\n").is_none()); + assert!(parse_request_line("GET /v1/instruments SMTP/1.0 extra\r\n\r\n").is_none()); + assert!(parse_request_line("GET /v1/instruments HTTP/1.1\r\n\r\n").is_some()); + assert!(parse_request_line("GET /v1/instruments HTTP/1.1 leftover\r\n\r\n").is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8af59b3a..e75941a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod data_rights_authorization; pub mod deterministic_narrative; pub mod health; pub mod instrument; +pub mod instrument_http; pub mod integration; pub mod integration_delivery; pub mod integration_publisher; diff --git a/tests/documentation_architecture_contract.rs b/tests/documentation_architecture_contract.rs index 780df804..0f5cfbba 100644 --- a/tests/documentation_architecture_contract.rs +++ b/tests/documentation_architecture_contract.rs @@ -336,6 +336,37 @@ fn erd_covers_current_delivery_identity_and_longitudinal_boundaries() { } } +#[test] +fn instrument_catalog_http_contract_is_mapped() { + let root = repository_root(); + let openapi = read_required(&root.join("openapi/instruments.yaml")); + let traceability = read_required(&root.join("docs/TRACEABILITY.md")); + let changelog = read_required(&root.join("CHANGELOG.md")); + + for marker in [ + "openapi: 3.2.0", + "/v1/instruments", + "/v1/instruments/{instrument_ref}", + "listStartableInstruments", + "getStartableInstrumentFamily", + "application/problem+json", + ] { + assert!( + openapi.contains(marker), + "instrument OpenAPI must name as-built marker {marker}" + ); + } + assert!( + traceability.contains("GET /v1/instruments") + && traceability.contains("not protected-main truth"), + "traceability must keep instrument catalog HTTP off protected main until integrated" + ); + assert!( + changelog.contains("GET /v1/instruments"), + "changelog must tell a purchaser the catalog list path" + ); +} + #[test] fn anonymous_command_docs_do_not_claim_store_load() { let root = repository_root(); diff --git a/tests/instrument_http_allow_contract.rs b/tests/instrument_http_allow_contract.rs new file mode 100644 index 00000000..48411348 --- /dev/null +++ b/tests/instrument_http_allow_contract.rs @@ -0,0 +1,59 @@ +//! RFC 9110 method-rejection contract for the public instrument catalog. +//! +//! A 405 response must expose the allowed method to both in-process embedding +//! hosts and the HTTP/1.1 wire serializer. Ordinary responses must not invent an +//! `Allow` field. + +use psychometrics_commons_runtime::instrument_http::{ + accept_one_instrument_http, bind_instrument_http, handle_instrument_http_request, + InstrumentHttpRuntime, +}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +#[test] +fn method_rejection_exposes_get_as_the_allowed_method() { + let runtime = InstrumentHttpRuntime::new(Vec::new()); + + let rejected = handle_instrument_http_request( + "POST /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(rejected.status(), 405); + assert_eq!(rejected.allow(), Some("GET")); + + let listed = handle_instrument_http_request( + "GET /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(listed.status(), 200); + assert_eq!(listed.allow(), None); +} + +#[test] +fn socket_serialization_emits_allow_only_for_405() { + let listener = bind_instrument_http(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let address = listener.local_addr().unwrap(); + let runtime = InstrumentHttpRuntime::new(Vec::new()); + let server = std::thread::spawn(move || accept_one_instrument_http(&listener, &runtime)); + + let mut client = TcpStream::connect(address).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .write_all(b"POST /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + client.shutdown(std::net::Shutdown::Write).unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + server.join().unwrap().unwrap(); + + assert!(response.starts_with("HTTP/1.1 405 Method Not Allowed")); + assert!(response.contains("\r\nAllow: GET\r\n"), "{response}"); +} diff --git a/tests/instrument_http_contract.rs b/tests/instrument_http_contract.rs new file mode 100644 index 00000000..dba37667 --- /dev/null +++ b/tests/instrument_http_contract.rs @@ -0,0 +1,431 @@ +//! Public instrument catalog HTTP contract for startable published releases. +//! +//! A purchaser lists the exact locale-specific releases that may begin a new +//! session, then uses `release_ref` and `locale` with `POST /v1/sessions`. +//! Draft, suspended, and retired releases stay hidden so unpublished catalog +//! rows cannot be discovered through this family. + +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::instrument_http::{ + accept_one_instrument_http, bind_instrument_http, handle_instrument_http_request, + InstrumentHttpRuntime, INSTRUMENT_COLLECTION_PATH, INSTRUMENT_HTTP_MAX_REQUEST_BYTES, +}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +const VALID_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const ENGLISH_DIGEST: &str = + "sha256:2222222222222222222222222222222222222222222222222222222222222222"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +const ENGLISH_EVIDENCE_DIGEST: &str = + "sha256:3333333333333333333333333333333333333333333333333333333333333333"; + +fn manifest( + release_ref: &str, + instrument_ref: &str, + locale: &str, + digest: &str, +) -> InstrumentReleaseManifest { + InstrumentReleaseManifest::new( + release_ref, + instrument_ref, + "instrument_version_big_five_v1", + "construct_big_five", + &["item_version_001", "item_version_002"], + locale, + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + digest, + ) + .unwrap() +} + +fn approved_evidence( + evidence_ref: &str, + release_ref: &str, + locale: &str, + digest: &str, + evidence_digest: &str, +) -> PublicationEvidenceRecord { + PublicationEvidenceRecord::new( + evidence_ref, + "evidence_policy_self_reflection_v1", + release_ref, + "instrument_version_big_five_v1", + &["item_version_001", "item_version_002"], + digest, + locale, + "intended_use_self_reflection_v1", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + evidence_digest, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_big_five_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_ipip_big_five_v1"], + &["recovery_big_five_v1"], + &["approval_psychometrics_big_five_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap() +} + +fn publish( + mut release: InstrumentRelease, + evidence: PublicationEvidenceRecord, + reviewed_at_unix_ms: u64, + published_at_unix_ms: u64, +) -> InstrumentRelease { + release + .apply_command( + "publication_review_catalog", + PublicationCommand::SubmitReview, + reviewed_at_unix_ms, + ) + .unwrap(); + release.bind_publication_evidence(evidence).unwrap(); + release + .apply_command( + "publication_publish_catalog", + PublicationCommand::Publish, + published_at_unix_ms, + ) + .unwrap(); + release +} + +fn published_korean() -> InstrumentRelease { + publish( + InstrumentRelease::new( + manifest( + "release_big_five_ko_v1", + "instrument_big_five", + "ko-KR", + VALID_DIGEST, + ), + 10_000, + ) + .unwrap(), + approved_evidence( + "publication_evidence_big_five_ko_v1", + "release_big_five_ko_v1", + "ko-KR", + VALID_DIGEST, + EVIDENCE_DIGEST, + ), + 10_100, + 10_200, + ) +} + +fn published_english() -> InstrumentRelease { + publish( + InstrumentRelease::new( + manifest( + "release_big_five_en_v1", + "instrument_big_five", + "en-US", + ENGLISH_DIGEST, + ), + 11_000, + ) + .unwrap(), + approved_evidence( + "publication_evidence_big_five_en_v1", + "release_big_five_en_v1", + "en-US", + ENGLISH_DIGEST, + ENGLISH_EVIDENCE_DIGEST, + ), + 11_100, + 11_200, + ) +} + +fn draft_korean() -> InstrumentRelease { + InstrumentRelease::new( + manifest( + "release_big_five_ko_draft", + "instrument_big_five", + "ko-KR", + VALID_DIGEST, + ), + 10_000, + ) + .unwrap() +} + +fn get_request(path: &str) -> String { + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n") +} + +#[test] +fn list_returns_only_published_releases_in_stable_order() { + let runtime = InstrumentHttpRuntime::new(vec![ + draft_korean(), + published_english(), + published_korean(), + ]); + let response = + handle_instrument_http_request(&get_request(INSTRUMENT_COLLECTION_PATH), &runtime); + + assert_eq!(response.status(), 200); + assert_eq!(response.content_type(), "application/json"); + let body = response.body(); + assert!(body.contains("\"release_ref\":\"release_big_five_en_v1\"")); + assert!(body.contains("\"release_ref\":\"release_big_five_ko_v1\"")); + assert!(body.contains("\"locale\":\"en-US\"")); + assert!(body.contains("\"locale\":\"ko-KR\"")); + assert!(body.contains("\"instrument_ref\":\"instrument_big_five\"")); + assert!(body.contains(&format!("\"content_digest\":\"{VALID_DIGEST}\""))); + assert!(body.contains("\"state\":\"published\"")); + assert!( + !body.contains("release_big_five_ko_draft"), + "unpublished releases must stay hidden from the public catalog" + ); + let english = body + .find("release_big_five_en_v1") + .expect("english release"); + let korean = body.find("release_big_five_ko_v1").expect("korean release"); + assert!( + english < korean, + "catalog order must be locale then release_ref so a purchaser can pick ko-KR or en-US predictably" + ); +} + +#[test] +fn get_family_returns_published_releases_and_hides_unknown_or_draft_only_families() { + let runtime = InstrumentHttpRuntime::new(vec![published_korean(), draft_korean()]); + let found = handle_instrument_http_request( + &get_request("/v1/instruments/instrument_big_five"), + &runtime, + ); + assert_eq!(found.status(), 200); + assert!(found + .body() + .contains("\"instrument_ref\":\"instrument_big_five\"")); + assert!(found + .body() + .contains("\"release_ref\":\"release_big_five_ko_v1\"")); + assert!(!found.body().contains("release_big_five_ko_draft")); + + let missing = handle_instrument_http_request( + &get_request("/v1/instruments/instrument_missing_family"), + &runtime, + ); + assert_eq!(missing.status(), 404); + assert_eq!(missing.content_type(), "application/problem+json"); + assert!(missing + .body() + .contains("urn:psychometrics-commons:problem:instrument-not-found")); + assert!(missing + .body() + .contains("Use GET /v1/instruments to list startable published releases")); +} + +#[test] +fn invalid_paths_and_methods_fail_closed_without_leaking_catalog_rows() { + let runtime = InstrumentHttpRuntime::new(vec![published_korean()]); + + let numeric = handle_instrument_http_request(&get_request("/v1/instruments/42"), &runtime); + assert_eq!(numeric.status(), 400); + assert!(numeric + .body() + .contains("urn:psychometrics-commons:problem:bad-request")); + + let padded = handle_instrument_http_request( + &get_request("/v1/instruments/%20instrument_big_five"), + &runtime, + ); + assert_eq!(padded.status(), 400); + + let post = handle_instrument_http_request( + "POST /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(post.status(), 405); + assert!(post + .body() + .contains("urn:psychometrics-commons:problem:method-not-allowed")); + + let other = handle_instrument_http_request(&get_request("/v1/sessions"), &runtime); + assert_eq!(other.status(), 404); + + let nested = handle_instrument_http_request( + &get_request("/v1/instruments/instrument_big_five/extra"), + &runtime, + ); + assert_eq!(nested.status(), 404); + + let missing_family = handle_instrument_http_request(&get_request("/v1/instruments/"), &runtime); + assert_eq!(missing_family.status(), 404); + assert_eq!(missing_family.content_type(), "application/problem+json"); + assert!(missing_family + .body() + .contains("urn:psychometrics-commons:problem:not-found")); + assert!(!missing_family.body().contains("release_big_five_ko_v1")); + + let put_family = handle_instrument_http_request( + "PUT /v1/instruments/instrument_big_five HTTP/1.1\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(put_family.status(), 405); + + let malformed = handle_instrument_http_request("not-an-http-request", &runtime); + assert_eq!(malformed.status(), 400); + + let truncated = + handle_instrument_http_request("GET /v1/instruments HTTP/1.1\r\n\r\n", &runtime); + assert_eq!(truncated.status(), 200); + + let missing_version = handle_instrument_http_request("GET /v1/instruments\r\n\r\n", &runtime); + assert_eq!(missing_version.status(), 400); + + assert_eq!(runtime.catalog_count(), 1); +} + +#[test] +fn empty_catalog_and_unrelated_methods_stay_safe() { + let empty = InstrumentHttpRuntime::new(Vec::new()); + let listed = handle_instrument_http_request(&get_request(INSTRUMENT_COLLECTION_PATH), &empty); + assert_eq!(listed.status(), 200); + assert_eq!(listed.body(), "{\"releases\":[]}"); + + let queried = + handle_instrument_http_request(&get_request("/v1/instruments?locale=ko-KR"), &empty); + assert_eq!(queried.status(), 200); + assert_eq!(queried.body(), "{\"releases\":[]}"); + + let unrelated = handle_instrument_http_request( + "POST /v1/sessions HTTP/1.1\r\nHost: localhost\r\n\r\n", + &empty, + ); + assert_eq!(unrelated.status(), 404); +} + +#[test] +fn listener_serves_one_published_catalog_request() { + let runtime = InstrumentHttpRuntime::new(vec![published_korean()]); + let listener = bind_instrument_http(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || accept_one_instrument_http(&listener, &runtime)); + + let mut client = TcpStream::connect(address).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .write_all(b"GET /v1/instruments HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + let mut body = String::new(); + client.read_to_string(&mut body).unwrap(); + server.join().unwrap().unwrap(); + + assert!(body.starts_with("HTTP/1.1 200 OK")); + assert!(body.contains("release_big_five_ko_v1")); + assert!(body.contains("application/json")); +} + +#[test] +fn listener_rejects_oversized_unterminated_request_without_catalog_rows() { + let runtime = InstrumentHttpRuntime::new(vec![published_korean()]); + let listener = bind_instrument_http(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || accept_one_instrument_http(&listener, &runtime)); + + let mut client = TcpStream::connect(address).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + client + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = b"GET /v1/instruments HTTP/1.1\r\nHost: localhost\r\nX-Padding: ".to_vec(); + request.resize(INSTRUMENT_HTTP_MAX_REQUEST_BYTES + 1, b'x'); + client.write_all(&request).unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + server.join().unwrap().unwrap(); + + assert!( + response.starts_with("HTTP/1.1 400 Bad Request"), + "{response}" + ); + assert!(response.contains("application/problem+json")); + assert!(response.contains("urn:psychometrics-commons:problem:bad-request")); + assert!(!response.contains("release_big_five_ko_v1")); +} + +#[test] +fn listener_fails_closed_for_truncated_and_extra_token_requests() { + let runtime = InstrumentHttpRuntime::new(vec![published_korean()]); + let listener = bind_instrument_http(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn({ + let runtime = InstrumentHttpRuntime::new(vec![published_korean()]); + move || { + accept_one_instrument_http(&listener, &runtime).unwrap(); + accept_one_instrument_http(&listener, &runtime).unwrap(); + } + }); + + let mut truncated = TcpStream::connect(address).unwrap(); + truncated + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + truncated.write_all(b"GET /v1/instruments").unwrap(); + truncated.shutdown(std::net::Shutdown::Write).unwrap(); + let mut truncated_body = String::new(); + truncated.read_to_string(&mut truncated_body).unwrap(); + assert!( + truncated_body.starts_with("HTTP/1.1 400 Bad Request"), + "{truncated_body}" + ); + + let extra = handle_instrument_http_request( + "GET /v1/instruments HTTP/1.1 leftover\r\nHost: localhost\r\n\r\n", + &runtime, + ); + assert_eq!(extra.status(), 400); + + let mut extra_wire = TcpStream::connect(address).unwrap(); + extra_wire + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + extra_wire + .write_all(b"GET /v1/instruments HTTP/1.1 leftover\r\nHost: localhost\r\n\r\n") + .unwrap(); + extra_wire.shutdown(std::net::Shutdown::Write).unwrap(); + let mut extra_body = String::new(); + extra_wire.read_to_string(&mut extra_body).unwrap(); + assert!( + extra_body.starts_with("HTTP/1.1 400 Bad Request"), + "{extra_body}" + ); + + server.join().unwrap(); +} diff --git a/tests/instrument_http_percent_encoded_reference.rs b/tests/instrument_http_percent_encoded_reference.rs new file mode 100644 index 00000000..5f272e94 --- /dev/null +++ b/tests/instrument_http_percent_encoded_reference.rs @@ -0,0 +1,132 @@ +//! HTTP path regression for visible multilingual opaque instrument references. +//! +//! Product references preserve visible multilingual material. The HTTP path +//! representation may percent-encode those UTF-8 bytes; transport decoding must +//! recover the exact opaque reference without accepting encoded path separators, +//! malformed escapes, whitespace aliases, or numeric-like identities. + +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::instrument_http::{ + handle_instrument_http_request, InstrumentHttpRuntime, +}; + +const CONTENT_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +const INSTRUMENT_REF: &str = "instrument_ref_가나다_東京_éclair"; +const ENCODED_INSTRUMENT_REF: &str = + "instrument_ref_%EA%B0%80%EB%82%98%EB%8B%A4_%E6%9D%B1%E4%BA%AC_%C3%A9clair"; +const MIXED_CASE_ENCODED_INSTRUMENT_REF: &str = + "instrument_ref_%EA%B0%80%EB%82%98%EB%8B%A4_%E6%9D%B1%E4%BA%AC_%c3%a9clair"; + +fn published_multilingual_release() -> InstrumentRelease { + let manifest = InstrumentReleaseManifest::new( + "release_multilingual_catalog_v1", + INSTRUMENT_REF, + "instrument_version_multilingual_v1", + "construct_multilingual_v1", + &["item_version_multilingual_001"], + "ko-KR", + "assessment_spec_multilingual_v1", + "scoring_version_multilingual_v1", + "calibration_multilingual_v1", + Some("norm_version_multilingual_v1"), + "narrative_version_multilingual_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + CONTENT_DIGEST, + ) + .unwrap(); + let evidence = PublicationEvidenceRecord::new( + "publication_evidence_multilingual_v1", + "evidence_policy_self_reflection_v1", + "release_multilingual_catalog_v1", + "instrument_version_multilingual_v1", + &["item_version_multilingual_001"], + CONTENT_DIGEST, + "ko-KR", + "intended_use_self_reflection_v1", + "assessment_spec_multilingual_v1", + "scoring_version_multilingual_v1", + "calibration_multilingual_v1", + Some("norm_version_multilingual_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + EVIDENCE_DIGEST, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_multilingual_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_multilingual_v1"], + &["recovery_multilingual_v1"], + &["approval_psychometrics_multilingual_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap(); + let mut release = InstrumentRelease::new(manifest, 10_000).unwrap(); + release + .apply_command( + "publication_review_multilingual_v1", + PublicationCommand::SubmitReview, + 10_100, + ) + .unwrap(); + release.bind_publication_evidence(evidence).unwrap(); + release + .apply_command( + "publication_publish_multilingual_v1", + PublicationCommand::Publish, + 10_200, + ) + .unwrap(); + release +} + +fn request(path: &str) -> String { + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n") +} + +#[test] +fn percent_encoded_utf8_family_path_resolves_the_exact_multilingual_reference() { + let runtime = InstrumentHttpRuntime::new(vec![published_multilingual_release()]); + + for encoded_reference in [ENCODED_INSTRUMENT_REF, MIXED_CASE_ENCODED_INSTRUMENT_REF] { + let response = handle_instrument_http_request( + &request(&format!("/v1/instruments/{encoded_reference}")), + &runtime, + ); + + assert_eq!(response.status(), 200, "{encoded_reference}"); + assert!(response + .body() + .contains(&format!("\"instrument_ref\":\"{INSTRUMENT_REF}\""))); + assert!(response.body().contains("release_multilingual_catalog_v1")); + } +} + +#[test] +fn malformed_or_separator_percent_encoding_fails_closed() { + let runtime = InstrumentHttpRuntime::new(vec![published_multilingual_release()]); + + for path in [ + "/v1/instruments/instrument_ref_%", + "/v1/instruments/instrument_ref_%GG", + "/v1/instruments/instrument_ref_%E3%81", + "/v1/instruments/instrument_ref_%2Fhidden", + "/v1/instruments/%20instrument_ref_%EA%B0%80", + ] { + let response = handle_instrument_http_request(&request(path), &runtime); + assert_eq!(response.status(), 400, "{path}"); + assert!(response + .body() + .contains("urn:psychometrics-commons:problem:bad-request")); + } +} diff --git a/tests/instrument_http_percent_encoding.rs b/tests/instrument_http_percent_encoding.rs new file mode 100644 index 00000000..b5cf1d15 --- /dev/null +++ b/tests/instrument_http_percent_encoding.rs @@ -0,0 +1,132 @@ +//! Regression coverage for percent-encoded multilingual instrument references. +//! +//! URI path segments are transmitted as percent-encoded UTF-8 by ordinary HTTP +//! clients. The public catalog must decode that transport spelling exactly once, +//! validate the decoded opaque product identity, and reject encoded separators or +//! malformed encodings instead of making multilingual families unreachable. + +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::instrument_http::{ + handle_instrument_http_request, InstrumentHttpRuntime, +}; + +const CONTENT_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + +fn published_multilingual() -> InstrumentRelease { + let release_ref = "release_multilingual_family_v1"; + let instrument_ref = "instrument_성격_東京"; + let mut release = InstrumentRelease::new( + InstrumentReleaseManifest::new( + release_ref, + instrument_ref, + "instrument_version_multilingual_v1", + "construct_big_five", + &["item_version_001", "item_version_002"], + "ko-KR", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + CONTENT_DIGEST, + ) + .unwrap(), + 10_000, + ) + .unwrap(); + release + .apply_command( + "publication_review_multilingual", + PublicationCommand::SubmitReview, + 10_100, + ) + .unwrap(); + release + .bind_publication_evidence( + PublicationEvidenceRecord::new( + "publication_evidence_multilingual_v1", + "evidence_policy_multilingual_v1", + release_ref, + "instrument_version_multilingual_v1", + &["item_version_001", "item_version_002"], + CONTENT_DIGEST, + "ko-KR", + "intended_use_self_reflection_v1", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + EVIDENCE_DIGEST, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_big_five_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_multilingual_v1"], + &["recovery_multilingual_v1"], + &["approval_multilingual_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap(), + ) + .unwrap(); + release + .apply_command( + "publication_publish_multilingual", + PublicationCommand::Publish, + 10_200, + ) + .unwrap(); + release +} + +fn get(path: &str) -> String { + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n") +} + +#[test] +fn percent_encoded_utf8_family_ref_resolves_to_exact_stored_identity() { + let runtime = InstrumentHttpRuntime::new(vec![published_multilingual()]); + + for path in [ + "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%AC", + "/v1/instruments/instrument_%ec%84%b1%ea%b2%a9_%e6%9d%b1%e4%ba%ac", + ] { + let response = handle_instrument_http_request(&get(path), &runtime); + assert_eq!(response.status(), 200, "{path}"); + assert!(response + .body() + .contains("\"instrument_ref\":\"instrument_성격_東京\"")); + } +} + +#[test] +fn encoded_separator_and_malformed_percent_escape_fail_closed() { + let runtime = InstrumentHttpRuntime::new(vec![published_multilingual()]); + + for path in [ + "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%AC%2Fextra", + "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%", + "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%A", + "/v1/instruments/instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%ZZ", + "/v1/instruments/instrument_%FF", + "/v1/instruments/%20instrument_%EC%84%B1%EA%B2%A9_%E6%9D%B1%E4%BA%AC", + ] { + let response = handle_instrument_http_request(&get(path), &runtime); + assert_eq!(response.status(), 400, "{path}"); + assert_eq!(response.content_type(), "application/problem+json"); + } +} diff --git a/tests/instrument_http_publication_visibility.rs b/tests/instrument_http_publication_visibility.rs new file mode 100644 index 00000000..195439c5 --- /dev/null +++ b/tests/instrument_http_publication_visibility.rs @@ -0,0 +1,210 @@ +//! Regression coverage for public instrument-catalog publication visibility. +//! +//! The public catalog is an authorization boundary for starting assessments: +//! only exactly `Published` releases may be discoverable. Draft, review, +//! suspended, and retired families must remain indistinguishable from a missing +//! family. + +use psychometrics_commons_runtime::instrument::{ + InstrumentRelease, InstrumentReleaseManifest, PublicationCommand, + PublicationEvidenceProvenance, PublicationEvidenceRecord, PublicationEvidenceStatus, +}; +use psychometrics_commons_runtime::instrument_http::{ + handle_instrument_http_request, InstrumentHttpRuntime, INSTRUMENT_COLLECTION_PATH, +}; + +const CONTENT_DIGEST: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EVIDENCE_DIGEST: &str = + "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + +fn manifest(release_ref: &str, instrument_ref: &str) -> InstrumentReleaseManifest { + InstrumentReleaseManifest::new( + release_ref, + instrument_ref, + "instrument_version_catalog_visibility_v1", + "construct_big_five", + &["item_version_001", "item_version_002"], + "ko-KR", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "narrative_version_big_five_v1", + &["consent_service_v1"], + "intended_use_self_reflection_v1", + "limitations_nonclinical_v1", + CONTENT_DIGEST, + ) + .unwrap() +} + +fn approved_evidence(release_ref: &str, evidence_ref: &str) -> PublicationEvidenceRecord { + PublicationEvidenceRecord::new( + evidence_ref, + "evidence_policy_catalog_visibility_v1", + release_ref, + "instrument_version_catalog_visibility_v1", + &["item_version_001", "item_version_002"], + CONTENT_DIGEST, + "ko-KR", + "intended_use_self_reflection_v1", + "assessment_spec_big_five_v1", + "scoring_version_big_five_v1", + "calibration_big_five_v1", + Some("norm_version_big_five_v1"), + "limitations_nonclinical_v1", + PublicationEvidenceProvenance::new( + EVIDENCE_DIGEST, + "population_general_adult_v1", + "administration_web_self_report_v1", + "measurement_model_big_five_v1", + 10_050, + None, + ) + .unwrap(), + &["rights_ipip_big_five_v1"], + &["recovery_big_five_v1"], + &["approval_psychometrics_big_five_v1"], + PublicationEvidenceStatus::Approved, + ) + .unwrap() +} + +fn review_release(release_ref: &str, instrument_ref: &str) -> InstrumentRelease { + let mut release = + InstrumentRelease::new(manifest(release_ref, instrument_ref), 10_000).unwrap(); + release + .apply_command( + &format!("publication_review_{release_ref}"), + PublicationCommand::SubmitReview, + 10_100, + ) + .unwrap(); + release +} + +fn published_release( + release_ref: &str, + instrument_ref: &str, + evidence_ref: &str, +) -> InstrumentRelease { + let mut release = review_release(release_ref, instrument_ref); + release + .bind_publication_evidence(approved_evidence(release_ref, evidence_ref)) + .unwrap(); + release + .apply_command( + &format!("publication_publish_{release_ref}"), + PublicationCommand::Publish, + 10_200, + ) + .unwrap(); + release +} + +fn get_request(path: &str) -> String { + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n") +} + +#[test] +fn collection_exposes_only_exactly_published_releases() { + let visible = published_release( + "release_visible_published", + "instrument_visible_family", + "publication_evidence_visible", + ); + let review = review_release("release_hidden_review", "instrument_review_family"); + let mut suspended = published_release( + "release_hidden_suspended", + "instrument_suspended_family", + "publication_evidence_suspended", + ); + suspended + .apply_command( + "publication_suspend_hidden", + PublicationCommand::Suspend, + 10_300, + ) + .unwrap(); + let mut retired = published_release( + "release_hidden_retired", + "instrument_retired_family", + "publication_evidence_retired", + ); + retired + .apply_command( + "publication_retire_hidden", + PublicationCommand::Retire, + 10_300, + ) + .unwrap(); + let draft = InstrumentRelease::new( + manifest("release_hidden_draft", "instrument_draft_family"), + 10_000, + ) + .unwrap(); + + let runtime = InstrumentHttpRuntime::new(vec![draft, review, suspended, retired, visible]); + let response = + handle_instrument_http_request(&get_request(INSTRUMENT_COLLECTION_PATH), &runtime); + + assert_eq!(response.status(), 200); + assert!(response.body().contains("release_visible_published")); + assert!(!response.body().contains("release_hidden_draft")); + assert!(!response.body().contains("release_hidden_review")); + assert!(!response.body().contains("release_hidden_suspended")); + assert!(!response.body().contains("release_hidden_retired")); +} + +#[test] +fn unpublished_only_families_are_indistinguishable_from_missing_families() { + let review = review_release("release_hidden_review", "instrument_review_family"); + let mut suspended = published_release( + "release_hidden_suspended", + "instrument_suspended_family", + "publication_evidence_suspended", + ); + suspended + .apply_command( + "publication_suspend_hidden", + PublicationCommand::Suspend, + 10_300, + ) + .unwrap(); + let mut retired = published_release( + "release_hidden_retired", + "instrument_retired_family", + "publication_evidence_retired", + ); + retired + .apply_command( + "publication_retire_hidden", + PublicationCommand::Retire, + 10_300, + ) + .unwrap(); + let draft = InstrumentRelease::new( + manifest("release_hidden_draft", "instrument_draft_family"), + 10_000, + ) + .unwrap(); + let runtime = InstrumentHttpRuntime::new(vec![draft, review, suspended, retired]); + + for family in [ + "instrument_draft_family", + "instrument_review_family", + "instrument_suspended_family", + "instrument_retired_family", + ] { + let response = handle_instrument_http_request( + &get_request(&format!("/v1/instruments/{family}")), + &runtime, + ); + assert_eq!(response.status(), 404, "{family} must stay undiscoverable"); + assert_eq!(response.content_type(), "application/problem+json"); + assert!(response + .body() + .contains("urn:psychometrics-commons:problem:instrument-not-found")); + } +}