Files
openapi/test_publish.py
zeekay 5b4379a876 name the repository that holds the document
The receipt is a coordinate a consumer resolves -- <repo>@<ref>:<path> -- and
it named a repository whose main is a different lineage, one that does not
contain the ref. Nothing could re-derive this artifact from that coordinate.

The constant now carries a note saying why it reads the way it does, because
it is the string a prose sweep reaches for first and a comment-only edit is
how it last moved.
2026-08-27 14:00:28 -07:00

266 lines
13 KiB
Python

#!/usr/bin/env python3
"""Tests for the published artifact — the gate that makes `hanzo.yaml` OUTPUT.
Runnable standalone (`python3 test_publish.py`, non-zero exit on failure) AND
under pytest, like test_flows.py beside it.
These are the OFFLINE half. They read the committed `hanzo.yaml` and assert the
properties `publish.py` promises, so a hand edit that satisfies none of them is
caught here without a network or a hanzo-inc/cloud checkout.
The half that reaches the world is `python3 publish.py --served`, which refutes
every published operation against the document api.hanzo.ai actually serves. It
is what CI runs, because it is the only one of the three online questions that
needs no credential — see the last test in this file, which asserts it is still
WIRED. `publish.py --check` is the sharper question and needs a hanzo-inc/cloud
checkout; it runs where one exists.
None of them replaces another: `--check` needs the input, `--served` needs the
deployment, these need nothing.
"""
import os
import re
import sys
import yaml
import publish
ROOT = os.path.dirname(os.path.abspath(__file__))
METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
def load():
doc = yaml.safe_load(open(os.path.join(ROOT, "hanzo.yaml")))
ops = [(m, p, op) for p, item in doc["paths"].items()
for m, op in item.items() if m in METHODS and isinstance(op, dict)]
return doc, ops
def lock():
out = {}
for line in open(os.path.join(ROOT, ".spec-lock")):
k, _, v = line.strip().partition("=")
if k:
out[k] = v
return out
def test_the_artifact_names_the_release_it_came_from():
"""Without this the document cannot answer "which release am I?", and every
consumer pinning it is pinning a file rather than a version."""
doc, _ = load()
spec, have = doc["info"]["x-spec"], lock()
assert spec["repo"] == have["repo"] == "hanzo-inc/cloud"
assert spec["path"] == have["path"] == "openapi.yaml"
assert spec["ref"] == have["ref"], "hanzo.yaml and .spec-lock name different refs"
assert re.fullmatch(r"[0-9a-f]{64}", spec["sha256"])
assert spec["sha256"] == have["sha256"], "hanzo.yaml and .spec-lock name different digests"
def test_every_operation_is_generatable():
"""The six projection rules, checked on the OUTPUT rather than trusted from
the code that wrote it. Each failure here is a language that stops compiling:
a missing `responses` is 1012 validator errors and zero files written, two
tags is one identifier declared twice, no tag is DefaultApi, and TRACE is a
`RequestMethod` constant the JVM generator never emits."""
_, ops = load()
assert ops, "no operations"
for m, p, op in ops:
assert m != "trace", f"TRACE survived at {p}"
assert op.get("responses"), f"{m.upper()} {p} declares no responses"
assert len(op.get("tags") or []) == 1, f"{m.upper()} {p} has tags {op.get('tags')}"
assert op.get("operationId"), f"{m.upper()} {p} has no operationId"
def test_operationids_are_unique_to_a_generator():
"""Unique as STRINGS is what OpenAPI asks and is not enough: every generator
strips punctuation and camel-cases, so `x-y` and `x_y` arrive as one name and
the client declares one request type twice."""
_, ops = load()
seen = {}
for _, p, op in ops:
key = re.sub(r"[^a-z0-9]", "", op["operationId"].lower())
assert key not in seen, (f"{op['operationId']} ({p}) and {seen[key]} are one "
f"identifier to a generator")
seen[key] = f"{op['operationId']} ({p})"
def test_a_client_can_authenticate():
"""Every scheme the document REQUIRES is one the document DEFINES.
A requirement naming a scheme with no definition is dropped in silence by
the whole chain. MEASURED at openapi-generator 7.14.0 on two documents
differing only in the name inside `security`:
validate No validation issues detected. (both)
generate no warning naming the scheme (both)
python call site _auth_settings: List[str] = [] vs ['bearer']
typescript-axios operation no setBearerAuthToObject call vs one
java call site new String[] { } vs { "bearer" }
The credential survives everywhere a reader looks for it — `access_token` is
still on Configuration, `HttpBearerAuth` is still emitted and still in the
authentications map — so the client compiles, the suites pass, and a caller
sets a token the client never sends. Nothing upstream of this assert reports
it, which is why it is asserted here.
Stated over names rather than over one name: it holds whatever the emission
calls its credential, and it is the property that decides whether a generated
client authenticates at all.
"""
doc, ops = load()
defined = set(doc["components"].get("securitySchemes") or {})
required = {name for req in (doc.get("security") or []) for name in req}
for _, _, op in ops:
for req in (op.get("security") or []):
required |= set(req)
assert required, "the document asks for no credential, so every call 401s"
assert required <= defined, (
f"security requires {sorted(required - defined)}; components."
f"securitySchemes defines {sorted(defined)}. Every generator drops the "
f"unresolvable name without a word and emits a client that sends no "
f"credential.")
def test_the_projection_requires_the_credential_the_emission_declares():
"""The same invariant from the INPUT side, on both arms of the rule.
The artifact reaches only one arm per release, so the other is unguarded
wherever it is not exercised here — and the arms trade places the release
cloud's emitter starts or stops declaring a scheme, which is a change in
another repo that lands in this one with no diff to review.
A requirement written BESIDE the definitions instead of FROM them names
whatever this file happens to say and resolves against whatever the emission
happens to declare. Written from them, the two cannot disagree.
"""
thing = {"/v1/thing": {"get": {"operationId": "getThing", "tags": ["thing"],
"responses": {"200": {"description": "ok"}}}}}
groups = [{"title": "Domain", "tags": ["thing"]}]
# The emission declares its own credential — as it does today, where it also
# answers for the pk-/sk- keys, the `X-Authorization` spelling and the
# browser cookie that a scheme written here cannot know about. The projection
# defers to it, name and prose, and mints no second name for one credential.
declared, _ = publish.project(
{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "paths": thing,
"components": {"securitySchemes": {"bearer": {"type": "http", "scheme": "bearer"}}},
"security": [{"bearer": []}]}, groups)
assert set(declared["components"]["securitySchemes"]) == {"bearer"}
assert {n for req in declared["security"] for n in req} == {"bearer"}, (
f"the emission declares `bearer`; the projection requires "
f"{[n for req in declared['security'] for n in req]}")
# The emission declares none: without a scheme AND a requirement naming it,
# a generated client sends no Authorization header and every call 401s.
silent, _ = publish.project(
{"openapi": "3.1.0", "info": {"title": "t", "version": "1"}, "paths": thing}, groups)
defined = set(silent["components"]["securitySchemes"])
assert defined == {"bearerAuth"}
assert {n for req in silent["security"] for n in req} == defined
def test_every_capability_is_grouped_exactly_once():
"""The curation law, from the artifact's side. capabilities.yaml is the one
editorial file left here, and it may only ARRANGE the served API — a tag it
misses is a product no doc site files anywhere, a tag it adds is a heading
with nothing under it."""
doc, ops = load()
used = {op["tags"][0] for _, _, op in ops}
grouped = [t for g in doc["x-tagGroups"] for t in g["tags"]]
assert sorted(grouped) == sorted(set(grouped)), "a capability is in two domains"
assert set(grouped) == used, (f"ungrouped: {sorted(used - set(grouped))}; "
f"grouped but unserved: {sorted(set(grouped) - used)}")
# Schemas whose PROPERTY NAMES cannot be projected into Go, pinned as a ceiling
# rather than gated to zero. Every generator Pascal-cases a property name and
# emits `Get<F>`, `Get<F>Ok`, `Has<F>`, `Set<F>` beside it, so two spellings of
# one field collide into one struct field, and a field literally named
# `hasClusterName` collides with the accessor for `clusterName`:
#
# o11y.GettableAgentCheckIn integration_config + integrationConfig
# removed_at + removedAt
# o11y.O11yPodOnboarding hasClusterName / hasNamespaceName / hasNodeName
# o11y.PostableProfile has_existing_observability_tool
#
# MEASURED at [email protected]: `go build ./...` on the generated client fails on
# these three and on nothing else — renaming those keys in a scratch copy of the
# document and regenerating gives exit 0. They are hanzo-inc/cloud's schemas to fix
# and this repo must NOT rename a field, because a field name is the wire.
#
# A ceiling and not a zero, because holding the publish hostage to another repo's
# schema helps nobody: what matters is that a FOURTH one cannot arrive unnoticed.
GO_UNSAFE = {"o11y.GettableAgentCheckIn", "o11y.O11yPodOnboarding", "o11y.PostableProfile"}
def test_no_new_schema_becomes_ungeneratable_in_go():
doc, _ = load()
def pascal(p):
return "".join(w[:1].upper() + w[1:] for w in re.split(r"[^A-Za-z0-9]+", p) if w)
bad = set()
for name, schema in (doc["components"].get("schemas") or {}).items():
props = (schema or {}).get("properties") or {}
fields = [pascal(p) for p in props]
if len(set(fields)) != len(fields):
bad.add(name)
for f in fields:
for prefix in ("Get", "Has", "Set"):
if f.startswith(prefix) and f[len(prefix):] in fields:
bad.add(name)
assert bad <= GO_UNSAFE, (
f"schema(s) the Go client cannot be generated from, beyond the pinned "
f"three: {sorted(bad - GO_UNSAFE)}. Two spellings of one field, or a "
f"property named for its own accessor. Fix in hanzo-inc/cloud — a field "
f"name is the wire, and renaming it here would be a lie.")
def test_no_authored_spec_survives():
"""The inversion, as a fact about the tree. A `<svc>/openapi.yaml` reappearing
is a second authority reappearing, and it would reach the skills plane — which
has no liveness filter — as an instruction to call whatever it declares."""
strays = sorted(d for d in os.listdir(ROOT)
if os.path.isfile(os.path.join(ROOT, d, "openapi.yaml")))
assert not strays, (f"hand-authored spec dir(s) are back: {strays}. The API is "
f"declared in hanzo-inc/cloud; this repo publishes what it emits.")
def test_the_drift_gate_is_wired_into_the_build():
"""A gate that cannot fail a build is not a gate, and this repo proved it.
`publish.py --check` was the only thing standing between a hand edit and
seven SDKs, and it lived in a workflow no forge collected, needing a
credential no secret supplied. It exited 1 on `main` for 81 hanzo-inc/cloud
commits while every consumer regenerated happily. Nothing NOTICED, because
nothing ran it.
So the gate that replaced it is asserted from inside the suite that does
run: `served` must be a `test:` entry in hanzo.yml — the one file both
hanzoai/ci and platform.hanzo.ai read — and it must invoke `--served`.
Deleting the gate now has to delete this test, in the same diff, on purpose.
"""
cfg = yaml.safe_load(open(os.path.join(ROOT, "hanzo.yml"))) or {}
gates = {g.get("name"): g.get("run", "") for g in (cfg.get("test") or [])}
assert "served" in gates, (
f"hanzo.yml declares no `served` test gate, so nothing refutes a "
f"published operation against the running deployment. Gates: "
f"{sorted(gates)}")
assert "publish.py --served" in gates["served"], (
"the `served` gate does not run `publish.py --served`")
if __name__ == "__main__":
failures = 0
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
try:
fn()
print(f"ok {name}")
except AssertionError as e:
failures += 1
print(f"FAIL {name}: {e}")
sys.exit(1 if failures else 0)