diff --git a/README.md b/README.md index 1db7740..d832313 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,30 @@ Use JSON Schema format checking to enforce UUID and date-time fields; the schema does not compare window endpoints. The legacy CLI JSON array remains a consumer compatibility surface, separate from this `{window, misses}` view contract. +`views_schema/v1/pqc-worklist.schema.json` is the Draft 2020-12 contract for +Core's ephemeral PQC worklist. Its required wrapper fields are `product_id`, +`repository_ref_ids`, `chokepoints` and `entries`. A null ref scope means all +refs in scope; an empty array means none. Core intersects explicit ref scope +with the selected product's shipped refs. Chokepoints are caller-supplied URL +fragments, not persisted configuration. + +Entries preserve the legacy repository/ref IDs, raw repository and SHA strings, +priority tier (`chokepoint`, `crypto-cwe`, `tail`), route (`source`, `sbom-only`, +`access-request`), nested liveness, reason, nullable scan state and cursor. +Liveness preserves nullable observations and historical `pushed_at`, +`unobserved` and reason strings. Scan currency and liveness booleans distinguish +unknown null from measured false; neither establishes readiness. Objects are +strict and every serialized field is required, including nullable fields. +Use format checking for UUIDs and observation timestamps; raw repository, SHA, +scope and historical strings have no invented URL, hash or markup restrictions. + +Core owns `PqcService.worklist`, scope intersection, representative-ref choice, +ranking, routing, cursor ordering, validation and safe human-readable rendering. +Building this view uses recorded state only: it does not scan, check out code +or write storage. The legacy CLI JSON array remains separate from this canonical +wrapper, and raw untrusted strings remain data rather than authored Markdown +or HTML. + `views_schema/v1/compliance-precision.schema.json` wraps the native `DetectionQualityService.calibrate_compliance(framework, gate=...)` result as `{calibration: {framework, assessments, checks, gate}}`. Framework IDs reuse the diff --git a/tests/test_pqc_worklist_view.py b/tests/test_pqc_worklist_view.py new file mode 100644 index 0000000..61778df --- /dev/null +++ b/tests/test_pqc_worklist_view.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from jsonschema import Draft202012Validator, FormatChecker, ValidationError + +SCHEMA = Path(__file__).parents[1] / "views_schema/v1/pqc-worklist.schema.json" +REPOSITORY_ID = "00000000-0000-4000-8000-000000000001" +REF_ID = "00000000-0000-4000-8000-000000000002" +ASSESSMENT_ID = "00000000-0000-4000-8000-000000000003" +PRODUCT_ID = "00000000-0000-4000-8000-000000000004" +HOSTILE = '\n| fake | row |\n[jump](javascript:alert(1)) & \u202e' + + +@pytest.fixture +def validator() -> Draft202012Validator: + schema = json.loads(SCHEMA.read_text()) + Draft202012Validator.check_schema(schema) + return Draft202012Validator(schema, format_checker=FormatChecker()) + + +@pytest.fixture +def document() -> dict[str, Any]: + return { + "product_id": None, + "repository_ref_ids": None, + "chokepoints": [], + "entries": [ + { + "repository_id": REPOSITORY_ID, + "repository": "https://example.com/repo", + "repository_ref_id": REF_ID, + "sha": "legacy-sha", + "priority_tier": "tail", + "route": "source", + "access": { + "repository_id": REPOSITORY_ID, + "url": "https://example.com/repo", + "observed_at": "2026-11-01T09:30:00+02:00", + "archived": False, + "pushed_at": "historical date", + "dormant": False, + "unobserved": None, + "reason": None, + }, + "reason": None, + "scan": { + "repository_ref_id": REF_ID, + "assessment_id": ASSESSMENT_ID, + "basis": "recorded-basis", + "current": True, + }, + "cursor": "2|https://example.com/repo", + } + ], + } + + +def target(document: dict[str, Any], path: tuple[str | int, ...]) -> Any: + value = document + for part in path: + value = value[part] + return value + + +OBJECT_PATHS = [(), ("entries", 0), ("entries", 0, "access"), ("entries", 0, "scan")] + + +@pytest.mark.parametrize("path", OBJECT_PATHS) +def test_each_serialized_field_is_required( + validator: Draft202012Validator, document: dict[str, Any], path: tuple[str | int, ...] +) -> None: + obj = target(document, path) + for field in tuple(obj): + saved = obj.pop(field) + with pytest.raises(ValidationError): + validator.validate(document) + obj[field] = saved + + +@pytest.mark.parametrize("path", OBJECT_PATHS) +def test_every_object_rejects_unknown_fields( + validator: Draft202012Validator, document: dict[str, Any], path: tuple[str | int, ...] +) -> None: + target(document, path)["unexpected"] = "not part of the contract" + with pytest.raises(ValidationError): + validator.validate(document) + + +@pytest.mark.parametrize("product_id", [None, PRODUCT_ID]) +@pytest.mark.parametrize("refs", [None, [], [REF_ID]]) +def test_global_product_and_explicit_empty_scope_remain_distinct( + validator: Draft202012Validator, document: dict[str, Any], product_id: Any, refs: Any +) -> None: + document.update(product_id=product_id, repository_ref_ids=refs, entries=[]) + validator.validate(document) + assert json.loads(json.dumps(document))["repository_ref_ids"] == refs + + +@pytest.mark.parametrize("tier", ["chokepoint", "crypto-cwe", "tail"]) +@pytest.mark.parametrize("route", ["source", "sbom-only", "access-request"]) +def test_all_existing_routes_and_priority_tiers( + validator: Draft202012Validator, document: dict[str, Any], tier: str, route: str +) -> None: + document["entries"][0].update(priority_tier=tier, route=route) + validator.validate(document) + + +def test_unregistered_repository_preserves_null_ref_sha_and_scan( + validator: Draft202012Validator, document: dict[str, Any] +) -> None: + entry = document["entries"][0] + entry.update( + repository_ref_id=None, + sha=None, + scan=None, + route="access-request", + reason="no-registered-commit", + ) + entry["access"].update( + observed_at=None, + archived=None, + pushed_at=None, + dormant=None, + unobserved=None, + reason=None, + ) + validator.validate(document) + + +@pytest.mark.parametrize("state", [None, False, True]) +def test_unknown_and_measured_liveness_and_scan_states_are_distinct( + validator: Draft202012Validator, document: dict[str, Any], state: bool | None +) -> None: + entry = document["entries"][0] + entry["access"].update(archived=state, dormant=state) + entry["scan"].update(current=state, assessment_id=None, basis=None) + validator.validate(document) + restored = json.loads(json.dumps(document))["entries"][0] + assert restored["scan"]["current"] is state + assert restored["access"]["archived"] is state + assert restored["access"]["dormant"] is state + + +@pytest.mark.parametrize("raw", ["", "legacy-value", HOSTILE]) +def test_raw_scope_and_historical_strings_are_not_url_sha_or_markup_restricted( + validator: Draft202012Validator, document: dict[str, Any], raw: str +) -> None: + document["chokepoints"] = [raw] + entry = document["entries"][0] + entry.update(repository=raw, sha=raw, reason=raw, cursor="2|" + raw) + entry["access"].update(url=raw, pushed_at=raw, unobserved=raw, reason=raw) + entry["scan"]["basis"] = raw + validator.validate(document) + + +ID_PATHS = [ + ("product_id",), + ("repository_ref_ids", 0), + ("entries", 0, "repository_id"), + ("entries", 0, "repository_ref_id"), + ("entries", 0, "access", "repository_id"), + ("entries", 0, "scan", "repository_ref_id"), + ("entries", 0, "scan", "assessment_id"), +] + + +@pytest.mark.parametrize("path", ID_PATHS) +@pytest.mark.parametrize("invalid", ["not-a-uuid", "00000000000040008000000000000001", "", 1]) +def test_identifiers_require_uuid_strings( + validator: Draft202012Validator, + document: dict[str, Any], + path: tuple[str | int, ...], + invalid: Any, +) -> None: + document["repository_ref_ids"] = [REF_ID] + target(document, path[:-1])[path[-1]] = invalid + with pytest.raises(ValidationError): + validator.validate(document) + + +@pytest.mark.parametrize( + "invalid", ["yesterday", "2026-02-30T00:00:00Z", "2026-11-01T09:30:00", False] +) +def test_observation_time_requires_nullable_rfc3339( + validator: Draft202012Validator, document: dict[str, Any], invalid: Any +) -> None: + document["entries"][0]["access"]["observed_at"] = invalid + with pytest.raises(ValidationError): + validator.validate(document) + + +@pytest.mark.parametrize( + ("path", "invalid"), + [ + ((), None), + (("repository_ref_ids",), {}), + (("repository_ref_ids",), [None]), + (("chokepoints",), None), + (("chokepoints",), [1]), + (("entries",), None), + (("entries",), {}), + (("entries",), [None]), + (("entries", 0, "repository_id"), None), + (("entries", 0, "repository"), None), + (("entries", 0, "sha"), False), + (("entries", 0, "priority_tier"), "urgent"), + (("entries", 0, "route"), "unknown"), + (("entries", 0, "reason"), 1), + (("entries", 0, "cursor"), None), + (("entries", 0, "access"), None), + (("entries", 0, "access", "repository_id"), None), + (("entries", 0, "access", "url"), False), + (("entries", 0, "access", "archived"), 0), + (("entries", 0, "access", "dormant"), "false"), + (("entries", 0, "access", "pushed_at"), 1), + (("entries", 0, "access", "unobserved"), False), + (("entries", 0, "access", "reason"), []), + (("entries", 0, "scan"), []), + (("entries", 0, "scan", "repository_ref_id"), None), + (("entries", 0, "scan", "basis"), 1), + (("entries", 0, "scan", "current"), 0), + ], +) +def test_structured_types_and_closed_vocabularies_are_enforced( + validator: Draft202012Validator, + document: dict[str, Any], + path: tuple[str | int, ...], + invalid: Any, +) -> None: + if path: + target(document, path[:-1])[path[-1]] = invalid + else: + document = invalid + with pytest.raises(ValidationError): + validator.validate(document) diff --git a/views_schema/v1/pqc-worklist.schema.json b/views_schema/v1/pqc-worklist.schema.json new file mode 100644 index 0000000..f576d45 --- /dev/null +++ b/views_schema/v1/pqc-worklist.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/traust-contracts/views_schema/v1/pqc-worklist.schema.json", + "title": "PQC worklist view", + "description": "Ephemeral derived Insight ViewModel from PqcService.worklist, using recorded repository liveness and scan state. Not a persisted artifact or SQL projection. Scope selection, ranking and routing belong to Core; unknown state does not establish readiness.", + "type": "object", + "required": ["product_id", "repository_ref_ids", "chokepoints", "entries"], + "additionalProperties": false, + "properties": { + "product_id": {"type": ["string", "null"], "format": "uuid"}, + "repository_ref_ids": { + "description": "Null selects all refs in the product scope; an empty array selects none. Core intersects explicit refs with the product's shipped refs.", + "type": ["array", "null"], + "items": {"type": "string", "format": "uuid"} + }, + "chokepoints": { + "description": "Caller-supplied repository URL fragments, not persisted configuration. Strings remain raw untrusted data, not authored Markdown or HTML.", + "type": "array", + "items": {"type": "string"} + }, + "entries": { + "type": "array", + "items": {"$ref": "#/$defs/entry"} + } + }, + "$defs": { + "entry": { + "type": "object", + "required": [ + "repository_id", "repository", "repository_ref_id", "sha", "priority_tier", + "route", "access", "reason", "scan", "cursor" + ], + "additionalProperties": false, + "properties": { + "repository_id": {"type": "string", "format": "uuid"}, + "repository": { + "type": "string", + "description": "Raw untrusted repository value; no new URL restrictions are imposed." + }, + "repository_ref_id": {"type": ["string", "null"], "format": "uuid"}, + "sha": {"type": ["string", "null"]}, + "priority_tier": {"type": "string", "enum": ["chokepoint", "crypto-cwe", "tail"]}, + "route": {"type": "string", "enum": ["source", "sbom-only", "access-request"]}, + "access": {"$ref": "#/$defs/liveness"}, + "reason": {"type": ["string", "null"]}, + "scan": {"anyOf": [{"$ref": "#/$defs/scan_state"}, {"type": "null"}]}, + "cursor": { + "type": "string", + "description": "Producer ordering cursor, composed by Core as |; the repository portion remains raw data." + } + } + }, + "liveness": { + "type": "object", + "required": [ + "repository_id", "url", "observed_at", "archived", "pushed_at", "dormant", + "unobserved", "reason" + ], + "additionalProperties": false, + "properties": { + "repository_id": {"type": "string", "format": "uuid"}, + "url": {"type": "string"}, + "observed_at": {"type": ["string", "null"], "format": "date-time"}, + "archived": {"type": ["boolean", "null"]}, + "pushed_at": { + "type": ["string", "null"], + "description": "Raw recorded value, including historical non-RFC3339 strings." + }, + "dormant": {"type": ["boolean", "null"]}, + "unobserved": { + "type": ["string", "null"], + "description": "Recorded ComparisonStatus, preserving historical strings; null is unknown." + }, + "reason": {"type": ["string", "null"]} + } + }, + "scan_state": { + "type": "object", + "required": ["repository_ref_id", "assessment_id", "basis", "current"], + "additionalProperties": false, + "properties": { + "repository_ref_id": {"type": "string", "format": "uuid"}, + "assessment_id": {"type": ["string", "null"], "format": "uuid"}, + "basis": {"type": ["string", "null"]}, + "current": { + "type": ["boolean", "null"], + "description": "Recorded scan currency; null is unknown, distinct from measured false and never inferred readiness." + } + } + } + } +}