Files
ersatztv/scripts/tests/test_optional_request_members.py
T
timothyandtimothy 3e2c07b42f
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
fix(807): SPA full-replace bodies are built as Complete<T>, with a derived guard over droppable request members (#827)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-23 00:58:19 +00:00

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