Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 14s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 30s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m9s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m49s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 15s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
822 lines
42 KiB
Python
822 lines
42 KiB
Python
"""#807 guard: every schema that can SILENTLY DROP a member on a SPA write has a stated disposition.
|
|
|
|
WHAT THIS BLOCKS. A request-body property that is absent from its schema's `required` array emits
|
|
into `web/src/api/generated/v1.d.ts` as an OPTIONAL member (`"weight"?: number`). A SPA builder may
|
|
then omit it, `tsc` says nothing — optional means omittable, by design — and on a FULL-REPLACE write
|
|
the server stores the field's default. That is #754's mechanism and it is what #807 found live in
|
|
`MultiCollectionItemRequest.weight` and `UpdateFFmpegProfileRequest.qsvPreferNativeDecoder`.
|
|
|
|
WHY IT IS A DERIVED GUARD AND NOT A TABLE IN A DOC. #807 shipped the disposition list by hand
|
|
TWICE and got it wrong BOTH times, each time by sorting a schema on its NAME rather than on what its
|
|
endpoint does:
|
|
|
|
round 1 a prose sentence exempted "create/update" — `updateMultiCollection` and
|
|
`updateFFmpegProfile` are full replaces, and both were live silent drops.
|
|
round 2 a hand-written table replaced that sentence and omitted `ArtworkContentTypeModel`,
|
|
because `…Model` reads as a response model. It is reachable from `PUT /channels/{id}`.
|
|
|
|
Two misses from one mechanism, so the mechanism goes rather than the list getting a third patch.
|
|
`testing.guard-derives-population-from-source` is explicit that a hand-written list is "a filter
|
|
frozen at authoring time, correct on the day it was written and unable to report the day it stopped
|
|
being" — and unlike #820's population (sites in code, which needs compiler-API tooling), THIS
|
|
population has an authoritative machine-readable source: the OpenAPI document.
|
|
|
|
SCOPE vs POPULATION, per that same record. The POPULATION — which schemas can drop a member on a
|
|
write — is DERIVED here, every run, from `ErsatzTV/wwwroot/openapi/v1.json`. The DISPOSITIONS below
|
|
are the SCOPE: a reviewed policy choice per schema, legitimately hand-written, and each one is
|
|
FORCED to exist by the set-equality assertion. A new optional member in a named component schema
|
|
reachable from a request body, or in an inline request body, fails this test until someone writes
|
|
down what should happen about it — that is the reach, bounded by what `_resolve`
|
|
resolves. That resolver walks `allOf`, `oneOf`, `anyOf`, `if`/`then`/`else`, `dependentSchemas`,
|
|
`items`/`prefixItems` and inline objects under `properties`, and deliberately contributes nothing
|
|
for `additionalProperties`/`patternProperties` (which name no fixed members) — each pinned by a
|
|
case in `test_composition_is_resolved_the_way_JSON_Schema_means_it`. It does NOT follow `$ref`;
|
|
that is the component walk's job. An earlier draft said "anywhere in the request graph"; inline bodies were invisible at
|
|
the time, so the universal was false the day it was written.
|
|
|
|
Set equality is asserted in BOTH directions and reported separately, because they are opposite
|
|
defects: `missing` is a schema the API can drop and nobody has ruled on (the #807 defect), `phantom`
|
|
is a disposition for a schema that no longer has an optional member reachable from a request body
|
|
(the registry claiming coverage of something gone).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
OPENAPI = REPO_ROOT / "ErsatzTV" / "wwwroot" / "openapi" / "v1.json"
|
|
|
|
# --- the closed disposition vocabulary -------------------------------------------------------
|
|
#
|
|
# COVERED the SPA builds this body and the builder is annotated `Complete<T>`, so omitting a
|
|
# member is a typecheck error (`web/src/api/completeRequest.ts`).
|
|
# CREATE a POST that creates a new entity, where an omitted member correctly means "use the
|
|
# default". Annotating it would be a BUG, not coverage — see the from-lineup note.
|
|
# TRIGGER the body parameterises an ACTION and replaces no stored entity, so there is nothing to
|
|
# drop.
|
|
# COMPUTED the optional members are get-only computed properties on the C# record. System.Text.Json
|
|
# never deserializes them, so the client cannot drop a stored value by omitting them —
|
|
# and `Complete<T>` must NOT be applied here, because it would force a caller to invent
|
|
# server-computed values in an outbound request.
|
|
COVERED = "COVERED"
|
|
CREATE = "CREATE"
|
|
TRIGGER = "TRIGGER"
|
|
COMPUTED = "COMPUTED"
|
|
|
|
DISPOSITIONS: dict[str, tuple[str, str]] = {
|
|
"UpdateFFmpegProfileRequest": (
|
|
COVERED,
|
|
"PUT /ffmpeg/profiles/{id} is a full replace. `qsvPreferNativeDecoder` is a defaulted ctor "
|
|
"param so ASP.NET drops it from `required`. Was a LIVE silent drop before #807.",
|
|
),
|
|
"CreateFFmpegProfileRequest": (
|
|
COVERED,
|
|
"FFmpegProfilesScreen's `Draft` feeds BOTH the POST and the full-replace PUT, so the draft "
|
|
"type itself is `Complete<…>` rather than only the update wrapper.",
|
|
),
|
|
"MultiCollectionItemRequest": (
|
|
COVERED,
|
|
"Nested in the full-replace PUT /multi-collections/{id}. `weight` is a defaulted ctor param "
|
|
"(`CreateMultiCollectionRequest.cs`). Was a LIVE silent drop before #807: the screen carried "
|
|
"a prose comment warning that dropping it resets every weight to 1, and a comment is not a "
|
|
"check.",
|
|
),
|
|
"ArtworkContentTypeModel": (
|
|
COMPUTED,
|
|
"Reachable from the full-replace PUT /channels/{id} (via `UpdateChannelRequest.logo`), so "
|
|
"the endpoint test alone would put it in COVERED. It is not: `IsExternalUrl`, "
|
|
"`HasContentType` and `UrlWithContentType` are computed get-only properties on the record "
|
|
"`ArtworkContentTypeModel(string Path, string ContentType)`, never deserialized, so a "
|
|
"client omitting them drops nothing. Annotating the SPA site `Complete<…>` would force it "
|
|
"to fabricate server-computed values. This row exists because #807's hand-written table "
|
|
"omitted this schema on the strength of its `…Model` name.",
|
|
),
|
|
"AutoTunedChannelRequest": (
|
|
CREATE,
|
|
"POST /channels/auto-tune. `CreateChannelFromLineupHandler` does `Channels.Add(...)` and "
|
|
"rejects a duplicate number; it never overwrites an existing channel.",
|
|
),
|
|
"CreateChannelFromLineupAdvancedOptionsRequest": (
|
|
CREATE,
|
|
"POST /channels/from-lineup. Omission is LOAD-BEARING here: `CreateChannelFromLineupClearField` "
|
|
"documents that for the template-inheritable fields a null/omitted override means INHERIT the "
|
|
"template value, with a separate explicit `clear` list to force NONE. `Complete<T>` would "
|
|
"collapse that third state into explicit-null.",
|
|
),
|
|
"AutoTuneSourceWeightRequest": (
|
|
CREATE,
|
|
"Nested in the auto-tune POST body; same create-time semantics as its parent.",
|
|
),
|
|
"POST /api/v1/artwork/uploads (multipart/form-data inline body)": (
|
|
TRIGGER,
|
|
"A multipart upload. Its body is declared INLINE rather than as a named schema, which is why "
|
|
"it needs a key of this shape at all. `file` and `target` sit outside `required`, but the "
|
|
"endpoint stores an uploaded blob and replaces no entity, so there is no stored value for an "
|
|
"omitted member to overwrite; `web/src/api/artwork.ts` builds it as hand-rolled `FormData` "
|
|
"with no generated type involved.",
|
|
),
|
|
"ScanShowRequest": (
|
|
TRIGGER,
|
|
"POST /libraries/{id}/scan-show starts a scan. `deepScan` parameterises the action; no entity is replaced.",
|
|
),
|
|
}
|
|
|
|
# Anti-vacuity floors. A completeness check whose population came back empty must not report that it
|
|
# proved everything (`testing.guard-ships-with-mutation-proof`). These are LOWER bounds on the
|
|
# derived intermediates, deliberately well under today's values (142 reachable, 13 with optional
|
|
# members) so ordinary schema churn does not trip them — they exist to catch a broken parse or a
|
|
# `$ref` walk that reached nothing, not to pin the corpus.
|
|
MIN_REQUEST_REACHABLE_SCHEMAS = 40
|
|
MIN_SCHEMAS_WITH_OPTIONAL_MEMBERS = 5
|
|
|
|
|
|
def _load() -> dict:
|
|
if not OPENAPI.is_file():
|
|
pytest.fail(f"{OPENAPI.relative_to(REPO_ROOT)} is missing — run scripts/update-openapi.sh")
|
|
return json.loads(OPENAPI.read_text())
|
|
|
|
|
|
def _schema_refs(node: object, out: set[str]) -> None:
|
|
"""Collect every `#/components/schemas/X` name anywhere under `node`."""
|
|
if isinstance(node, dict):
|
|
ref = node.get("$ref")
|
|
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
|
|
out.add(ref.rsplit("/", 1)[1])
|
|
for value in node.values():
|
|
_schema_refs(value, out)
|
|
elif isinstance(node, list):
|
|
for value in node:
|
|
_schema_refs(value, out)
|
|
|
|
|
|
def _request_reachable(doc: dict) -> set[str]:
|
|
"""Every schema reachable from ANY operation's request body, transitively.
|
|
|
|
Two deliberate non-restrictions, both because this population has now been drawn by hand wrongly
|
|
twice and every hand-drawn edge is a place to be wrong again:
|
|
|
|
NO VERB ALLOW-LIST. An earlier draft scanned POST/PUT/PATCH, which reads as obviously right and
|
|
already had an exception: `DELETE /api/v1/media-items` carries a request body. Rather than argue
|
|
that a DELETE body cannot cause a full-replace drop — probably true, and exactly the kind of
|
|
"probably" that produced this record's two live misses — every operation carrying a request body
|
|
seeds the walk, and anything it surfaces must acquire a stated disposition.
|
|
|
|
TRANSITIVE, and that is load-bearing rather than thorough: `MultiCollectionItemRequest` and
|
|
`ArtworkContentTypeModel` are both NESTED, so a check reading only top-level request bodies
|
|
would have missed both of the schemas this guard exists because of. `_schema_refs` walks every
|
|
value of every dict and every list element, so `oneOf` (23 occurrences today), `items`,
|
|
`additionalProperties` and any future composition keyword are covered without naming them.
|
|
"""
|
|
schemas = doc["components"]["schemas"]
|
|
seeds: set[str] = set()
|
|
for operations in doc["paths"].values():
|
|
for operation in operations.values():
|
|
if isinstance(operation, dict) and operation.get("requestBody"):
|
|
_schema_refs(operation["requestBody"], seeds)
|
|
|
|
seen: set[str] = set()
|
|
stack = list(seeds)
|
|
while stack:
|
|
name = stack.pop()
|
|
if name in seen or name not in schemas:
|
|
continue
|
|
seen.add(name)
|
|
nested: set[str] = set()
|
|
_schema_refs(schemas[name], nested)
|
|
stack.extend(nested - seen)
|
|
return seen
|
|
|
|
|
|
def _optional_of(schema: object) -> set[str]:
|
|
"""The members of `schema` a client may omit, nested inline objects included.
|
|
|
|
This is the function to call. `_resolve` below is its recursive half and returns three sets;
|
|
the split matters and is the fix for a real defect, so it is stated here rather than in a
|
|
comment further down.
|
|
|
|
A nested inline object lives in its OWN namespace. Qualifying its members as `parent.child`
|
|
BEFORE subtracting `required` merges the two namespaces, and a literal member named
|
|
`parent.child` then collides with the nested one — masking it entirely when the literal is
|
|
required, so a droppable member vanishes from the population with nothing failing (measured
|
|
2026-08-23). Subtracting inside each namespace first and qualifying only the survivors means
|
|
the two sets are never mixed, so the collision cannot arise and there is no separator to
|
|
defend. An earlier version instead ASSERTED that no property name contains a dot, which is a
|
|
guard where a restructure was available.
|
|
|
|
Residual, stated because it is real: if a literal `a.b` and a nested `a` -> `b` are BOTH
|
|
optional they conflate into one reported string. That is a diagnostic ambiguity, not a miss —
|
|
the schema still enters the population and still forces a disposition.
|
|
"""
|
|
properties, required, nested_optional = _resolve(schema)
|
|
return (properties - required) | nested_optional
|
|
|
|
|
|
def _resolve(schema: object) -> tuple[set[str], set[str], set[str]]:
|
|
"""(properties, required, already-resolved nested optional members) for one schema node.
|
|
|
|
The third set is carried through every composition site rather than merged into the first two,
|
|
for the namespace reason in `_optional_of`. Do NOT collapse this back to a 2-tuple to spare the
|
|
callers: folding `nested_optional` into `properties` re-creates the mask at the merge boundary
|
|
and the whole suite stays green while it does.
|
|
|
|
NESTING. Handling `allOf` one level deep misses an `allOf` inside an `allOf` — a property there
|
|
reported as no properties at all. Composition nests, so the resolution has to recurse.
|
|
|
|
`$ref` IS DELIBERATELY NOT RESOLVED HERE, and that is not a hole: an arm that is a `$ref` names
|
|
a component schema, which `_request_reachable` already seeds on (`_schema_refs` finds a `$ref`
|
|
anywhere, arms included) and `_optional_members` already walks, so its optional members surface
|
|
under their OWN key rather than being merged into the inline one. Verified by construction in
|
|
`test_a_ref_ARM_is_covered_by_the_component_walk_not_by_this_one`. Resolving refs here as well
|
|
would report the same member twice under two keys, which is worse than either.
|
|
|
|
CONJUNCTION vs DISJUNCTION. `allOf` arms ALL apply, so their `required` sets UNION. `oneOf` and
|
|
`anyOf` arms are ALTERNATIVES, so a member is only genuinely required when EVERY alternative
|
|
requires it — the `required` sets INTERSECT. Unioning them instead (the first version) marks a
|
|
member required because one arm requires it, hiding the arm that lets a client omit it. That is
|
|
the drop this whole guard exists to catch, so getting it backwards is not a detail.
|
|
|
|
No cycle guard, deliberately. This resolver never follows `$ref`, so the only way to recurse
|
|
forever is a schema that contains ITSELF by object identity — which `json.load` cannot produce,
|
|
since JSON has no back-references. Verified 2026-08-23: a hand-built self-referential dict does
|
|
raise `RecursionError`, a LOUD red rather than a silent wrong answer. The `$ref` cycle a real
|
|
document CAN express is handled by `_request_reachable`'s `seen` set, also verified.
|
|
"""
|
|
if not isinstance(schema, dict):
|
|
return set(), set(), set()
|
|
|
|
properties = set(schema.get("properties") or {})
|
|
required = set(schema.get("required") or [])
|
|
nested_optional: set[str] = set()
|
|
|
|
# A property may itself be an INLINE OBJECT rather than a `$ref`, and the generator recurses
|
|
# into it (`objectTypeFromSchema` -> `typeFromSchema`), so a member outside that nested
|
|
# `required` really does emit `?:` and really is droppable. Resolved in its own namespace and
|
|
# qualified afterwards — see `_optional_of`.
|
|
for name, value in (schema.get("properties") or {}).items():
|
|
if not isinstance(value, dict) or "$ref" in value:
|
|
continue
|
|
nested_optional |= {f"{name}.{child}" for child in _optional_of(value)}
|
|
|
|
# CONJUNCTIVE: every `allOf` arm applies, so both sets union.
|
|
for branch in schema.get("allOf") or []:
|
|
branch_properties, branch_required, branch_nested = _resolve(branch)
|
|
properties |= branch_properties
|
|
required |= branch_required
|
|
nested_optional |= branch_nested
|
|
|
|
# CONDITIONAL keywords — `if`, `then`, `else`, `dependentSchemas`. All four are treated the same
|
|
# way: collect their properties (a client may send them) and DISCARD their `required` (it binds
|
|
# only on a branch that may not be taken, so the member is omittable).
|
|
#
|
|
# An earlier version put `then`/`else` in the conjunctive list above, unioning their `required`.
|
|
# That is the polarity error this function warns about above, committed in the same block:
|
|
# `then` and `else` are MUTUALLY EXCLUSIVE, so a member required only under `then` is omittable
|
|
# whenever `if` does not match, and the guard reported it as required — the silent-miss
|
|
# direction, which is the one this whole file exists to catch. `if`'s `required` is discarded
|
|
# for a different reason (it selects a branch rather than obliging anyone), and
|
|
# `dependentSchemas` for a third (it binds only when its trigger key is present), but the
|
|
# resulting rule is identical, so they share one loop rather than three arguments.
|
|
conditional: list[object] = []
|
|
for keyword in ("if", "then", "else"):
|
|
value = schema.get(keyword)
|
|
conditional.extend(value if isinstance(value, list) else ([value] if isinstance(value, dict) else []))
|
|
conditional.extend((schema.get("dependentSchemas") or {}).values())
|
|
for branch in conditional:
|
|
branch_properties, _, branch_nested = _resolve(branch)
|
|
properties |= branch_properties
|
|
nested_optional |= branch_nested
|
|
|
|
# DISJUNCTIVE keywords, handled SEPARATELY rather than concatenated: `oneOf` and `anyOf` are
|
|
# conjunctive WITH EACH OTHER (a body satisfying both must satisfy one arm of each), so the
|
|
# correct required set is the intersection within each keyword, unioned across them. Merging
|
|
# the two lists first intersects across keywords and under-reports required.
|
|
for keyword in ("oneOf", "anyOf"):
|
|
alternatives = schema.get(keyword) or []
|
|
shared_required: set[str] | None = None
|
|
for branch in alternatives:
|
|
branch_properties, branch_required, branch_nested = _resolve(branch)
|
|
properties |= branch_properties
|
|
nested_optional |= branch_nested
|
|
shared_required = branch_required if shared_required is None else (shared_required & branch_required)
|
|
if shared_required:
|
|
required |= shared_required
|
|
|
|
# An inline body may be an ARRAY of inline objects; the members live on `items`, and a member
|
|
# droppable there is droppable in the request. `items` is a SCHEMA in OpenAPI 3.1 / JSON Schema
|
|
# 2020-12 and may be a LIST in the 3.0 tuple form, so both shapes are walked — and `prefixItems`
|
|
# is the 2020-12 spelling of that tuple.
|
|
for keyword in ("items", "prefixItems"):
|
|
value = schema.get(keyword)
|
|
branches = value if isinstance(value, list) else ([value] if isinstance(value, dict) else [])
|
|
for branch in branches:
|
|
branch_properties, branch_required, branch_nested = _resolve(branch)
|
|
properties |= branch_properties
|
|
required |= branch_required
|
|
nested_optional |= branch_nested
|
|
|
|
return properties, required, nested_optional
|
|
|
|
|
|
def _optional_members(doc: dict) -> dict[str, list[str]]:
|
|
"""Schema -> its properties that sit OUTSIDE `required`, i.e. the ones that emit `?:`."""
|
|
out: dict[str, list[str]] = {}
|
|
for name, schema in doc["components"]["schemas"].items():
|
|
optional = sorted(_optional_of(schema))
|
|
if optional:
|
|
out[name] = optional
|
|
return out
|
|
|
|
|
|
def _inline_body_members(doc: dict) -> dict[str, list[str]]:
|
|
"""Optional members of request bodies declared INLINE, i.e. with no `$ref` to a named schema.
|
|
|
|
`_optional_members` iterates `components.schemas`, and `_request_reachable` seeds from `$ref`s,
|
|
so between them an inline body is invisible in BOTH directions. That was not hypothetical: the
|
|
document declares one today (`POST /api/v1/artwork/uploads`, multipart), whose `file` and
|
|
`target` sit outside any `required` array — a member already present and outside the guard's
|
|
reach while its docstring claimed to cover the whole request graph.
|
|
|
|
Keyed by `"<VERB> <path> (<media type> inline body)"` rather than by a schema name, because there is no name
|
|
to use — which is exactly why the component-schema walk cannot see it.
|
|
|
|
Composition is resolved by `_optional_of`/`_resolve`, which recurse and treat `allOf` as
|
|
conjunction and `oneOf`/`anyOf` as alternatives — this body splits its properties across `allOf`
|
|
arms and would otherwise report none.
|
|
"""
|
|
out: dict[str, list[str]] = {}
|
|
for path, operations in doc["paths"].items():
|
|
for verb, operation in operations.items():
|
|
if not isinstance(operation, dict) or not operation.get("requestBody"):
|
|
continue
|
|
for content_type, media in (operation["requestBody"].get("content") or {}).items():
|
|
schema = media.get("schema") or {}
|
|
if "$ref" in schema:
|
|
# Belt-and-braces, not load-bearing: `_optional_of` returns an empty
|
|
# sets for a bare `$ref` node anyway, so deleting this line changes no result
|
|
# today. It stays because a body that names a component schema is that schema's
|
|
# business — `_optional_members` already covers it — and skipping it here keeps
|
|
# that division explicit rather than accidental.
|
|
continue
|
|
optional = sorted(_optional_of(schema))
|
|
if optional:
|
|
# Keyed by MEDIA TYPE as well as verb and path. An operation may declare more
|
|
# than one inline body (a second `[Consumes]` is all it takes), and keying on
|
|
# verb+path alone made the later one overwrite the earlier — a droppable member
|
|
# silently disappearing from the population rather than failing.
|
|
out[f"{verb.upper()} {path} ({content_type} inline body)"] = optional
|
|
return out
|
|
|
|
|
|
def _droppable(doc: dict) -> dict[str, list[str]]:
|
|
reachable = _request_reachable(doc)
|
|
droppable = {n: m for n, m in _optional_members(doc).items() if n in reachable}
|
|
droppable.update(_inline_body_members(doc))
|
|
return droppable
|
|
|
|
|
|
def test_the_derivation_reached_a_real_population() -> None:
|
|
"""Anti-vacuity: a broken `$ref` walk or parse must not read as 'nothing to rule on'.
|
|
|
|
What this test does NOT do, measured 2026-08-22 rather than assumed: it does not catch a
|
|
PARTIALLY broken walk. Deleting the transitive step from `_request_reachable` — so only the
|
|
schemas named directly on a request body resolve — leaves both floors satisfied and this test
|
|
GREEN. What reddens is `test_MUTATION_a_planted_optional_member_is_reported_as_MISSING`, whose
|
|
planted schema is reached through a nested `$ref` precisely so that it can, plus the disposition
|
|
test (the nested rows vanish and report as PHANTOM). So the floors below are the crude backstop
|
|
against a parse that reached nothing at all; the planted-member test is what actually holds the
|
|
walk honest, and it should be the one kept working if these two ever conflict.
|
|
"""
|
|
doc = _load()
|
|
reachable = _request_reachable(doc)
|
|
optional = _optional_members(doc)
|
|
assert len(reachable) >= MIN_REQUEST_REACHABLE_SCHEMAS, (
|
|
f"only {len(reachable)} schemas reachable from a request body — the $ref walk is broken, not the API"
|
|
)
|
|
assert len(optional) >= MIN_SCHEMAS_WITH_OPTIONAL_MEMBERS, (
|
|
f"only {len(optional)} schemas have a property outside `required` — suspect the parse"
|
|
)
|
|
|
|
|
|
def test_the_walks_ASSUMPTIONS_about_the_document_still_hold() -> None:
|
|
"""The derivation's scope mirrors two properties of the OpenAPI document. Check them.
|
|
|
|
`testing.guard-derives-population-from-source`: "When the scope itself MIRRORS an authoritative
|
|
source, the mirror needs its own equality check or a dated staleness marker, or the guard is
|
|
complete within a scope that has silently gone stale." Two such assumptions are baked into
|
|
`_request_reachable`, and both are true of the document today (2026-08-22) rather than
|
|
guaranteed by anything:
|
|
|
|
1. Request bodies are declared INLINE on the operation. If ASP.NET ever emits a
|
|
`components.requestBodies` bucket and operations `$ref` into it, the seed walk still finds
|
|
the `$ref` — but only because `_schema_refs` collects `#/components/schemas/...` names, so a
|
|
body referencing `#/components/requestBodies/X` would seed NOTHING and the schemas under it
|
|
would drop out of the population silently.
|
|
2. Every `$ref` in the document points into `#/components/schemas/`. `_schema_refs` matches on
|
|
that prefix, so a ref into any other bucket is invisible to it.
|
|
|
|
Both are cheap to assert and neither is asserted anywhere else, so a change in the emitter
|
|
would otherwise shrink this guard's population without failing anything.
|
|
|
|
A THIRD assumption used to sit here unstated and was already violated: that every request body
|
|
`$ref`s a named component schema. `POST /api/v1/artwork/uploads` declares its body inline, so
|
|
both `_optional_members` (which iterates `components.schemas`) and `_request_reachable` (which
|
|
seeds from `$ref`s) were blind to it. That one is not an assumption any more —
|
|
`_inline_body_members` handles it — which is why it is described here rather than asserted.
|
|
"""
|
|
doc = _load()
|
|
buckets = set(doc.get("components", {}))
|
|
assert "requestBodies" not in buckets, (
|
|
"the OpenAPI document now declares components.requestBodies — `_request_reachable` seeds "
|
|
"only from inline operation bodies and `_schema_refs` only follows #/components/schemas/, "
|
|
"so schemas behind a shared request body are now INVISIBLE to this guard. Teach the walk "
|
|
"to resolve that bucket before deleting this assertion."
|
|
)
|
|
ref_buckets = set(re.findall(r'"#/components/([^/"]+)/', json.dumps(doc)))
|
|
assert ref_buckets <= {"schemas"}, (
|
|
f"$refs now point into {sorted(ref_buckets - {'schemas'})} as well as schemas; "
|
|
"`_schema_refs` matches only the schemas prefix and silently ignores the rest"
|
|
)
|
|
|
|
|
|
def test_every_droppable_request_schema_has_a_stated_disposition() -> None:
|
|
"""Set equality, both directions, accumulated into ONE message.
|
|
|
|
Failing fast on the first mismatch hands back one schema at a time and invites fixing them one
|
|
at a time, which is how #754's twin stayed hidden.
|
|
"""
|
|
droppable = _droppable(_load())
|
|
|
|
unruled = sorted(set(droppable) - set(DISPOSITIONS))
|
|
phantom = sorted(set(DISPOSITIONS) - set(droppable))
|
|
|
|
problems: list[str] = []
|
|
if unruled:
|
|
problems.append(
|
|
"MISSING — reachable from a request body with a member outside `required`, and "
|
|
"no disposition written down. Decide what happens to each and add a row:\n"
|
|
+ "\n".join(f" {n}: optional members {droppable[n]}" for n in unruled)
|
|
)
|
|
if phantom:
|
|
problems.append(
|
|
"PHANTOM — a disposition for a schema that is no longer request-reachable with an "
|
|
"optional member. Delete the row rather than leaving it claiming coverage:\n"
|
|
+ "\n".join(f" {n}" for n in phantom)
|
|
)
|
|
assert not problems, "\n\n".join(problems)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("case", "schema", "expected"),
|
|
[
|
|
(
|
|
"a flat schema reports the properties outside `required`",
|
|
{"type": "object", "properties": {"a": {}, "b": {}}, "required": ["a"]},
|
|
["b"],
|
|
),
|
|
(
|
|
"allOf arms are conjunctive: each arm's `required` applies",
|
|
{"allOf": [{"properties": {"a": {}}}, {"properties": {"b": {}}, "required": ["b"]}]},
|
|
["a"],
|
|
),
|
|
(
|
|
"allOf NESTED inside allOf is reached — a one-level walk reported nothing here",
|
|
{"allOf": [{"allOf": [{"properties": {"deep": {}}}]}]},
|
|
["deep"],
|
|
),
|
|
(
|
|
"oneOf arms are ALTERNATIVES: required in one arm only means a client may omit it",
|
|
{"oneOf": [{"properties": {"x": {}}, "required": ["x"]}, {"properties": {"x": {}}}]},
|
|
["x"],
|
|
),
|
|
(
|
|
"oneOf where EVERY arm requires it is genuinely required",
|
|
{
|
|
"oneOf": [
|
|
{"properties": {"x": {}}, "required": ["x"]},
|
|
{"properties": {"x": {}}, "required": ["x"]},
|
|
]
|
|
},
|
|
[],
|
|
),
|
|
(
|
|
"anyOf arms are alternatives too — required in one arm only means droppable",
|
|
{"anyOf": [{"properties": {"y": {}}, "required": ["y"]}, {"properties": {"y": {}}}]},
|
|
["y"],
|
|
),
|
|
(
|
|
"anyOf where EVERY arm requires it is genuinely required",
|
|
{
|
|
"anyOf": [
|
|
{"properties": {"y": {}}, "required": ["y"]},
|
|
{"properties": {"y": {}}, "required": ["y"]},
|
|
]
|
|
},
|
|
[],
|
|
),
|
|
(
|
|
"oneOf and anyOf on ONE node are conjunctive with EACH OTHER, not one alternative list",
|
|
{
|
|
"oneOf": [{"properties": {"x": {}}, "required": ["x"]}, {"properties": {"x": {}}, "required": ["x"]}],
|
|
"anyOf": [{"properties": {"y": {}}, "required": ["y"]}, {"properties": {"y": {}}, "required": ["y"]}],
|
|
},
|
|
[],
|
|
),
|
|
(
|
|
"composition NESTED inside an alternative arm is reached",
|
|
{"oneOf": [{"allOf": [{"properties": {"nestedInArm": {}}}]}]},
|
|
["nestedInArm"],
|
|
),
|
|
(
|
|
"properties in arms 2..n are collected, not just the first arm's",
|
|
{"oneOf": [{"properties": {"first": {}}}, {"properties": {"second": {}}}]},
|
|
["first", "second"],
|
|
),
|
|
(
|
|
"a top-level `required` still applies when alternatives are present",
|
|
{"properties": {"top": {}, "other": {}}, "required": ["top"], "oneOf": [{"properties": {"arm": {}}}]},
|
|
["arm", "other"],
|
|
),
|
|
(
|
|
"an inline body that is an ARRAY of objects exposes its item members",
|
|
{"type": "array", "items": {"properties": {"itemReq": {}, "itemOpt": {}}, "required": ["itemReq"]}},
|
|
["itemOpt"],
|
|
),
|
|
(
|
|
"`items` in the 3.0 TUPLE form (a list) is walked, not just the schema form",
|
|
{"type": "array", "items": [{"properties": {"tupleReq": {}, "tupleOpt": {}}, "required": ["tupleReq"]}]},
|
|
["tupleOpt"],
|
|
),
|
|
(
|
|
"`prefixItems`, the 2020-12 spelling of a tuple, is walked",
|
|
{"type": "array", "prefixItems": [{"properties": {"prefixOpt": {}}}]},
|
|
["prefixOpt"],
|
|
),
|
|
(
|
|
"nested arrays are followed to the object at the bottom",
|
|
{"type": "array", "items": {"type": "array", "items": {"properties": {"deepOpt": {}}}}},
|
|
["deepOpt"],
|
|
),
|
|
(
|
|
"`dependentSchemas` members are collectable and its conditional `required` is discarded",
|
|
{"dependentSchemas": {"trigger": {"properties": {"depOpt": {}}, "required": ["depOpt"]}}},
|
|
["depOpt"],
|
|
),
|
|
(
|
|
"a `required` under `then` does NOT make a member required — the branch may not be taken",
|
|
{
|
|
"if": {"properties": {"k": {}}, "required": ["k"]},
|
|
"then": {"properties": {"m": {}}, "required": ["m"]},
|
|
},
|
|
["k", "m"],
|
|
),
|
|
(
|
|
"a `required` under `else` does not either, and `then`/`else` are mutually exclusive",
|
|
{
|
|
"if": {"properties": {"k": {}}},
|
|
"then": {"properties": {"m": {}}, "required": ["m"]},
|
|
"else": {"properties": {"m": {}}},
|
|
},
|
|
["k", "m"],
|
|
),
|
|
(
|
|
"a `required` under `if` selects a branch, it does not oblige the client",
|
|
{"if": {"properties": {"k": {}}, "required": ["k"]}, "then": {"properties": {"m": {}}}},
|
|
["k", "m"],
|
|
),
|
|
(
|
|
"an INLINE OBJECT under `properties` is recursed into, reported with a dotted path",
|
|
{
|
|
"properties": {
|
|
"top": {},
|
|
"nested": {"properties": {"a": {}, "b": {}}, "required": ["a"]},
|
|
},
|
|
"required": ["top", "nested"],
|
|
},
|
|
["nested.b"],
|
|
),
|
|
(
|
|
"a REQUIRED literal `a.b` cannot mask a nested `a` -> `b` — the namespaces never merge",
|
|
{"properties": {"a.b": {}, "a": {"properties": {"b": {}}}}, "required": ["a.b", "a"]},
|
|
["a.b"],
|
|
),
|
|
(
|
|
"`prefixItems` positions are conjunctive, so a `required` there really does bind",
|
|
{
|
|
"type": "array",
|
|
"prefixItems": [{"properties": {"pReq": {}, "pOpt": {}}, "required": ["pReq"]}],
|
|
},
|
|
["pOpt"],
|
|
),
|
|
(
|
|
"a dotted nested member cannot be confused with a top-level member of the same name",
|
|
{
|
|
"properties": {"b": {}, "nested": {"properties": {"b": {}}}},
|
|
"required": ["b", "nested"],
|
|
},
|
|
["nested.b"],
|
|
),
|
|
(
|
|
"`patternProperties` names no FIXED members, so it contributes none — same as additionalProperties",
|
|
{"properties": {"named": {}}, "patternProperties": {"^x-": {"properties": {"notAMember": {}}}}},
|
|
["named"],
|
|
),
|
|
(
|
|
"`additionalProperties` names no members, so it contributes none",
|
|
{"properties": {"named": {}}, "additionalProperties": {"properties": {"notAMember": {}}}},
|
|
["named"],
|
|
),
|
|
(
|
|
"`not` cannot make a member required",
|
|
{"properties": {"a": {}}, "not": {"required": ["a"]}},
|
|
["a"],
|
|
),
|
|
(
|
|
"properties declared under `else` count for the same reason `then` does",
|
|
{"if": {}, "else": {"properties": {"elseOpt": {}}}},
|
|
["elseOpt"],
|
|
),
|
|
(
|
|
"properties declared under `then` are reachable on some branch, so they count",
|
|
{"if": {"properties": {"kind": {}}}, "then": {"properties": {"conditional": {}}}},
|
|
["conditional", "kind"],
|
|
),
|
|
(
|
|
"top-level properties and an allOf arm are merged, not either/or",
|
|
{"properties": {"top": {}}, "required": ["top"], "allOf": [{"properties": {"inner": {}}}]},
|
|
["inner"],
|
|
),
|
|
],
|
|
)
|
|
def test_composition_is_resolved_the_way_JSON_Schema_means_it(case: str, schema: dict, expected: list[str]) -> None:
|
|
"""Pin `_optional_of`/`_resolve` against constructed schemas, not against today's document.
|
|
|
|
The document exercises exactly one shape (a two-arm `allOf` in the artwork upload body), so
|
|
every other branch of this resolver would otherwise be unexercised prose. Both of the first
|
|
version's defects are here as cases: the nested `allOf` it could not reach, and the `oneOf`
|
|
whose `required` it unioned instead of intersecting — which marked a member required because
|
|
ONE arm required it, hiding the arm that lets a client drop it.
|
|
"""
|
|
assert sorted(_optional_of(schema)) == expected, case
|
|
|
|
|
|
_NESTED_PROBE = {
|
|
"properties": {"outer": {"properties": {"req": {}, "opt": {}}, "required": ["req"]}},
|
|
"required": ["outer"],
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("site", "schema"),
|
|
[
|
|
("top level", _NESTED_PROBE),
|
|
("allOf arm", {"allOf": [_NESTED_PROBE]}),
|
|
("if", {"if": _NESTED_PROBE}),
|
|
("then", {"then": _NESTED_PROBE}),
|
|
("else", {"else": _NESTED_PROBE}),
|
|
("dependentSchemas", {"dependentSchemas": {"trigger": _NESTED_PROBE}}),
|
|
("oneOf arm", {"oneOf": [_NESTED_PROBE]}),
|
|
("anyOf arm", {"anyOf": [_NESTED_PROBE]}),
|
|
("items", {"type": "array", "items": _NESTED_PROBE}),
|
|
("prefixItems", {"type": "array", "prefixItems": [_NESTED_PROBE]}),
|
|
],
|
|
)
|
|
def test_a_nested_inline_object_survives_EVERY_composition_site(site: str, schema: dict) -> None:
|
|
"""`_resolve` returns three sets, and the third has to be threaded through every branch.
|
|
|
|
A composition site that unions `properties` and `required` but forgets `nested_optional` loses
|
|
the nested member silently — no error, just a smaller population — and each site is a separate
|
|
opportunity to forget. Enumerating the sites here is what makes "threaded through every branch"
|
|
a checked property instead of a claim in a docstring; a NEW composition keyword must be added
|
|
to this list, and if it is not, the omission is at least visible in one place rather than
|
|
spread across the resolver.
|
|
"""
|
|
assert "outer.opt" in _optional_of(schema), (
|
|
f"a nested inline object under `{site}` lost its optional member — `nested_optional` is "
|
|
"not threaded through that branch of `_resolve`"
|
|
)
|
|
|
|
|
|
def test_a_ref_ARM_is_covered_by_the_component_walk_not_by_this_one() -> None:
|
|
"""The two walks COMPOSE; neither alone covers an inline body with a `$ref` arm.
|
|
|
|
`_resolve` does not resolve `$ref`, so an `allOf` arm that is a `$ref`
|
|
contributes nothing to the inline key. That looks like a gap and is not: the referenced schema
|
|
is a named component, so it is seeded by `_request_reachable` and walked by `_optional_members`,
|
|
and its optional members surface under their own key. Pinned here because the obvious "fix" —
|
|
resolving refs in this resolver too — would report the same member under two keys, and because
|
|
a reader checking only one of the two walks would reasonably conclude the case is uncovered.
|
|
"""
|
|
doc = _load()
|
|
doc["components"]["schemas"]["RefArmProbeSchema"] = {
|
|
"type": "object",
|
|
"properties": {"probeRequired": {"type": "string"}, "probeOptional": {"type": "string"}},
|
|
"required": ["probeRequired"],
|
|
}
|
|
doc["paths"]["/probe-ref-arm"] = {
|
|
"post": {
|
|
"requestBody": {
|
|
"content": {
|
|
"application/json": {
|
|
"schema": {
|
|
"allOf": [
|
|
{"$ref": "#/components/schemas/RefArmProbeSchema"},
|
|
{"properties": {"probeInline": {}}},
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
droppable = _droppable(doc)
|
|
assert droppable.get("RefArmProbeSchema") == ["probeOptional"], (
|
|
"a $ref arm's target must still surface via the component walk — if this is empty, an "
|
|
"inline body can hide a droppable member behind a $ref"
|
|
)
|
|
assert droppable.get("POST /probe-ref-arm (application/json inline body)") == ["probeInline"]
|
|
|
|
|
|
def test_every_disposition_uses_the_closed_vocabulary() -> None:
|
|
"""A free-text disposition would let a row read as considered while saying nothing."""
|
|
allowed = {COVERED, CREATE, TRIGGER, COMPUTED}
|
|
for name, (disposition, why) in DISPOSITIONS.items():
|
|
assert disposition in allowed, f"{name}: {disposition!r} is not one of {sorted(allowed)}"
|
|
assert len(why.strip()) >= 40, f"{name}: the reason is too thin to be a decision"
|
|
|
|
|
|
def test_MUTATION_a_planted_optional_member_is_reported_as_MISSING() -> None:
|
|
"""The checker-guard mutation proof (`testing.guard-ships-with-mutation-proof`).
|
|
|
|
Disarming a checker makes it ABSENT rather than red, so the mutation goes into the guarded
|
|
ARTIFACT: plant a request-reachable schema carrying a member outside `required` and require the
|
|
derivation to surface it. Mutating the checker's own population instead would be the trap that
|
|
record names — a shrunken population makes every real row report PHANTOM, so the proof would go
|
|
red on a false positive while saying nothing about the MISSING detection this row claims.
|
|
|
|
The planted schema is reached through a real request body, so this also exercises the
|
|
transitive `$ref` walk that the hand-written list twice failed to do by eye.
|
|
"""
|
|
doc = _load()
|
|
schemas = doc["components"]["schemas"]
|
|
|
|
# Deterministic, because `set` iteration order over strings varies per process: picking the
|
|
# host with `next(iter(...))` would silently vary WHICH walk depth the proof exercises from run
|
|
# to run, so a green run would not mean the same thing twice.
|
|
candidates = sorted(
|
|
n for n in _request_reachable(doc) if isinstance(schemas.get(n), dict) and schemas[n].get("properties")
|
|
)
|
|
assert candidates, "no request-reachable schema with properties — the walk is broken"
|
|
host = candidates[0]
|
|
|
|
schemas["PlantedDroppableRequest"] = {
|
|
"type": "object",
|
|
"properties": {"plantedRequired": {"type": "string"}, "plantedOptional": {"type": "string"}},
|
|
"required": ["plantedRequired"],
|
|
}
|
|
# Planted through a `oneOf` LIST rather than a bare dict `$ref`, because `_schema_refs` has two
|
|
# descent branches and only the dict one was exercised: deleting its list descent — which makes
|
|
# every `oneOf` reference invisible, 23 of them in the document today — left the whole file
|
|
# GREEN when measured 2026-08-22. A proof that cannot see half its own walk is the "green for
|
|
# the wrong reason" shape this repo keeps recording.
|
|
schemas[host]["properties"]["plantedLink"] = {
|
|
"oneOf": [{"type": "null"}, {"$ref": "#/components/schemas/PlantedDroppableRequest"}]
|
|
}
|
|
|
|
# Plant into the INLINE request body too, so `_inline_body_members` is exercised rather than
|
|
# merely present. Without this the whole inline branch could be deleted and every test here
|
|
# would stay green — the exact shape the `oneOf` list branch was in before it was planted
|
|
# through.
|
|
inline_host = next(
|
|
(
|
|
media["schema"]
|
|
for operations in doc["paths"].values()
|
|
for operation in operations.values()
|
|
if isinstance(operation, dict) and operation.get("requestBody")
|
|
for media in (operation["requestBody"].get("content") or {}).values()
|
|
if isinstance(media.get("schema"), dict) and "$ref" not in media["schema"]
|
|
),
|
|
None,
|
|
)
|
|
assert inline_host is not None, "no inline request body in the document — re-target this plant"
|
|
inline_host.setdefault("properties", {})["plantedInlineOptional"] = {"type": "string"}
|
|
|
|
droppable = _droppable(doc)
|
|
planted_inline = [k for k, v in droppable.items() if "plantedInlineOptional" in v]
|
|
assert planted_inline, (
|
|
"the member planted in an INLINE request body was NOT surfaced — `_inline_body_members` is "
|
|
"not reaching inline bodies, so a body declared without a $ref is invisible to this guard"
|
|
)
|
|
assert "PlantedDroppableRequest" in droppable, (
|
|
"the planted schema was NOT surfaced — the derivation cannot see a droppable member, so a "
|
|
"green run of this file proves nothing"
|
|
)
|
|
assert droppable["PlantedDroppableRequest"] == ["plantedOptional"]
|
|
assert "PlantedDroppableRequest" not in DISPOSITIONS
|