Round 2 returned BLOCKED. The enumeration added in the previous commit was
itself incomplete: it omitted `ArtworkContentTypeModel`, which carries three
members outside its `required` array and is reachable from the full-replace
`PUT /channels/{id}` via `UpdateChannelRequest.logo`. It was sorted out on its
name — `...Model` reads as a response model — which is the same failure the
enumeration was written to eliminate one level up, and it closed with a false
universal negative ("the remaining schemas ... are never request bodies") sixty
lines below this document's own warning against exactly that.
Two misses from one mechanism, so the mechanism goes rather than the list
getting a third patch.
`scripts/tests/test_optional_request_members.py` derives the population every
run from `ErsatzTV/wwwroot/openapi/v1.json`: every schema with a property
outside `required` that is TRANSITIVELY reachable from a POST/PUT/PATCH request
body, asserted set-equal in both directions against a registry of per-schema
dispositions. Transitivity is load-bearing — `MultiCollectionItemRequest` and
`ArtworkContentTypeModel` are both nested, so a top-level-only check would have
reproduced both misses. Population derived, dispositions hand-written as the
reviewed SCOPE, per testing.guard-derives-population-from-source.
Witnessed red: removing the `ArtworkContentTypeModel` disposition reports it as
MISSING with its three members. That mutation is DECLARED in
`mutation_manifest.py` and re-applied every suite, so the row grades MUTATION
rather than resting on this message. Renaming the key rather than deleting the
entry keeps the module importable, so the red is the MISSING direction and not
an ImportError reddening for the wrong reason.
ArtworkContentTypeModel's real disposition is COMPUTED, not COVERED: the three
members are get-only properties derived from `Path` and are never deserialized,
so omitting them drops nothing — and `Complete<T>` must NOT be applied there,
because it would force a caller to fabricate server-computed values in an
outbound request. That caveat is now in spa-conventions §4b, where a reader
following "annotate each construction site" would otherwise have produced a
wrong payload.
Also reconciles the contradiction round 2 found between the wrapper-boundary
rationale and the snapshot: 13 of ~33 full-replace PUT wrappers carry
`Complete<>` today. The rest are stated as still contingent — the difference
from before is that the contingency is now MONITORED by the derived test rather
than assumed.
L5 the phantom denominator is ten sites, not nine (five accepted, three of those
without a spread or a local). N6 fixture field placed alphabetically. N7 the
record's three-act framing trimmed to the current statement plus the rejected
one, so a cold reader does not have to work out which is live.
Verification: 879 passed 2 skipped in scripts/tests (up from 874), typecheck 0,
eslint 0, 1200/1200 web tests, web build 0, catalog check clean, doc-narrative 0.
Decisions-Edit: yes
273 lines
13 KiB
Python
273 lines
13 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 anywhere in the request graph
|
|
fails this test until someone writes down what should happen about it.
|
|
|
|
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
|
|
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.",
|
|
),
|
|
"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 (141 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 a POST/PUT/PATCH request body, transitively.
|
|
|
|
Transitively is the point: `MultiCollectionItemRequest` and `ArtworkContentTypeModel` are both
|
|
NESTED, and a check that looked only at top-level request bodies would miss both — which is one
|
|
of the two ways the hand-written version of this went wrong.
|
|
"""
|
|
schemas = doc["components"]["schemas"]
|
|
seeds: set[str] = set()
|
|
for operations in doc["paths"].values():
|
|
for verb, operation in operations.items():
|
|
if verb.lower() not in ("post", "put", "patch"):
|
|
continue
|
|
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_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():
|
|
properties = schema.get("properties") or {}
|
|
if not properties:
|
|
continue
|
|
optional = sorted(set(properties) - set(schema.get("required") or []))
|
|
if optional:
|
|
out[name] = optional
|
|
return out
|
|
|
|
|
|
def _droppable(doc: dict) -> dict[str, list[str]]:
|
|
reachable = _request_reachable(doc)
|
|
return {n: m for n, m in _optional_members(doc).items() if n in reachable}
|
|
|
|
|
|
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'."""
|
|
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_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 POST/PUT/PATCH 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)
|
|
|
|
|
|
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"]
|
|
|
|
host = next(
|
|
(n for n in _request_reachable(doc) if isinstance(schemas.get(n), dict) and schemas[n].get("properties")),
|
|
None,
|
|
)
|
|
assert host is not None, "no request-reachable schema with properties — the walk is broken"
|
|
|
|
schemas["PlantedDroppableRequest"] = {
|
|
"type": "object",
|
|
"properties": {"plantedRequired": {"type": "string"}, "plantedOptional": {"type": "string"}},
|
|
"required": ["plantedRequired"],
|
|
}
|
|
schemas[host]["properties"]["plantedLink"] = {"$ref": "#/components/schemas/PlantedDroppableRequest"}
|
|
|
|
droppable = _droppable(doc)
|
|
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
|