orgstate / tests /test_infra_api_pydantic_validation.py
Legal-i's picture
Initial OrgState deploy via Stage 150 free-tier stack
d2d1903 verified
"""
Tests for Stage 78 β€” Pydantic request body validation.
Before Stage 78, every POST/PUT route did ad-hoc dict.get() checks
in the handler. Wrong-TYPE fields hit the handler and either
crashed with 500 or coerced silently. Now bodies validate at the
API boundary with proper 422 + field-level errors.
This file tests the VALIDATION layer specifically β€” that 422
fires on the right shapes, with the right field paths in the
detail. The happy-path behavior is covered by the existing
per-route test files (test_infra_api_webhooks.py, schedules,
admin_actions, etc.).
The contract (FastAPI default):
- 422 status code
- body shape: {"detail": [{"loc": [...], "msg": ..., "type": ...}, ...]}
- "loc" path includes "body" then the field name
"""
import pytest
pytest.importorskip("fastapi")
pytest.importorskip("httpx")
from fastapi.testclient import TestClient
from infra import OrgStateService
from infra.api import create_app
def _bootstrap(tmp_path):
dbfile = str(tmp_path / "pyd.sqlite3")
svc = OrgStateService(dbfile)
try:
svc.register_tenant("acme", "ACME")
keys = {
"admin": svc.create_admin_key().raw,
"acme_op": svc.create_api_key("acme", role="operator").raw,
"acme_admin": svc.create_api_key("acme", role="admin").raw,
}
finally:
svc.close()
return dbfile, keys
def _client(dbfile):
return TestClient(create_app(dbfile))
def _auth(k):
return {"Authorization": f"Bearer {k}"}
def _field_in_errors(body: dict, field: str) -> bool:
"""Helper: does any error in body['detail'] reference `field`?"""
return any(field in err.get("loc", []) for err in body.get("detail", []))
# --- TenantRegisterBody ---------------------------------------------
def test_tenants_post_missing_tenant_id_422(tmp_path):
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post("/tenants", headers=_auth(keys["admin"]),
json={"name": "X"})
assert r.status_code == 422
assert _field_in_errors(r.json(), "tenant_id")
def test_tenants_post_empty_tenant_id_422(tmp_path):
"""min_length=1 catches empty string."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post("/tenants", headers=_auth(keys["admin"]),
json={"tenant_id": "", "name": "X"})
assert r.status_code == 422
def test_tenants_post_wrong_type_422(tmp_path):
"""tenant_id must be str, not int."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post("/tenants", headers=_auth(keys["admin"]),
json={"tenant_id": 12345, "name": "X"})
# pydantic strict mode would 422; lax mode coerces. We're in
# default mode which coerces int->str. Either is acceptable
# here β€” what we DO want is no crash and a 2xx or 422.
assert r.status_code in (201, 422)
def test_tenants_post_default_vertical_filled(tmp_path):
"""Omitting `vertical` shouldn't reject β€” schema has default
'logistics'."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post("/tenants", headers=_auth(keys["admin"]),
json={"tenant_id": "new", "name": "New"})
assert r.status_code == 201
# --- ScheduleCreateBody ---------------------------------------------
def test_schedule_bad_frequency_caught_by_model(tmp_path):
"""model_post_init validates frequency ∈ {hourly,daily,weekly}."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/tenants/acme/schedules",
headers=_auth(keys["acme_op"]),
json={"vertical": "logistics", "entity_type": "warehouse",
"connector_type": "csv_folder", "frequency": "centennial"},
)
assert r.status_code == 422
def test_schedule_enabled_must_be_bool(tmp_path):
"""`enabled` is typed bool; a string '1' is rejected (str-coerce
is disabled for bool fields in Pydantic v2 by default for safety)."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/tenants/acme/schedules",
headers=_auth(keys["acme_op"]),
json={"vertical": "logistics", "entity_type": "warehouse",
"connector_type": "csv_folder",
"enabled": "yes-please"},
)
assert r.status_code == 422
assert _field_in_errors(r.json(), "enabled")
def test_schedule_connector_config_must_be_object(tmp_path):
"""connector_config: Dict[str, Any] β€” a list is rejected."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/tenants/acme/schedules",
headers=_auth(keys["acme_op"]),
json={"vertical": "logistics", "entity_type": "warehouse",
"connector_type": "csv_folder",
"connector_config": ["this", "should", "be", "an", "object"]},
)
assert r.status_code == 422
# --- ApiKeyMintBody --------------------------------------------------
def test_api_key_invalid_role_422(tmp_path):
"""model_post_init validates role ∈ {admin,operator,readonly}."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/tenants/acme/api_keys",
headers=_auth(keys["acme_admin"]),
json={"role": "superuser"},
)
assert r.status_code == 422
def test_api_key_typo_in_role_caught(tmp_path):
"""The user-facing win: 'oprator' (typo) β†’ 422, not silently
minting an admin key (the old default)."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/tenants/acme/api_keys",
headers=_auth(keys["acme_admin"]),
json={"role": "oprator"},
)
assert r.status_code == 422
def test_api_key_defaults_when_body_empty(tmp_path):
"""Empty body β†’ default role='admin', name=''. No validation
error β€” these defaults exist for the bootstrap workflow."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/tenants/acme/api_keys",
headers=_auth(keys["acme_admin"]),
json={},
)
assert r.status_code == 201
assert r.json()["role"] == "admin"
# --- WebhookCreateBody ----------------------------------------------
def test_webhook_url_required_422(tmp_path):
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post("/tenants/acme/webhooks",
headers=_auth(keys["acme_op"]), json={})
assert r.status_code == 422
assert _field_in_errors(r.json(), "url")
def test_webhook_secret_optional(tmp_path):
"""Omitting secret is valid β€” webhook fires unsigned."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post("/tenants/acme/webhooks",
headers=_auth(keys["acme_op"]),
json={"url": "https://hooks.slack.com/x"})
assert r.status_code == 201
def test_webhook_secret_must_be_str_not_dict(tmp_path):
"""A common copy-paste mistake."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post("/tenants/acme/webhooks",
headers=_auth(keys["acme_op"]),
json={"url": "https://hooks.slack.com/x",
"secret": {"value": "shh"}})
assert r.status_code == 422
# --- CalibrateBody / RunAnalysisBody --------------------------------
def test_calibrate_observations_empty_list_422(tmp_path):
"""min_length=1 on observations β€” empty list isn't calibratable."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/tenants/acme/calibrations",
headers=_auth(keys["acme_op"]),
json={"vertical": "logistics", "entity_type": "warehouse",
"observations": []},
)
assert r.status_code == 422
def test_calibrate_observation_missing_entity_id_422(tmp_path):
"""ObservationModel.entity_id required."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/tenants/acme/calibrations",
headers=_auth(keys["acme_op"]),
json={"vertical": "logistics", "entity_type": "warehouse",
"observations": [{"day": "2026-05-17",
"values": {"orders": 100}}]},
)
assert r.status_code == 422
def test_calibrate_observation_values_must_be_object_not_list(tmp_path):
"""values: Dict[str, Any] β€” list rejected."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/tenants/acme/calibrations",
headers=_auth(keys["acme_op"]),
json={"vertical": "logistics", "entity_type": "warehouse",
"observations": [{"entity_id": "w1", "day": "2026-05-17",
"values": [1, 2, 3]}]},
)
assert r.status_code == 422
# --- TenantOverridesBody --------------------------------------------
def test_overrides_severity_must_be_object(tmp_path):
"""Already covered in overrides test file, replicated here
for symmetry with the Stage 78 grouping."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.put(
"/admin/tenants/acme/overrides/warehouse",
headers=_auth(keys["admin"]),
json={"severity_thresholds": 42},
)
assert r.status_code == 422
def test_overrides_drift_weights_must_be_numbers(tmp_path):
"""Dict[str, float] catches string values."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.put(
"/admin/tenants/acme/overrides/warehouse",
headers=_auth(keys["admin"]),
json={"drift_weights": {"delta": "not-a-number"}},
)
assert r.status_code == 422
def test_overrides_all_optional_empty_body_ok(tmp_path):
"""Both fields optional β†’ empty body should be accepted by
Pydantic. Service-layer may complain but not the schema."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.put(
"/admin/tenants/acme/overrides/warehouse",
headers=_auth(keys["admin"]),
json={},
)
# service-layer may still 400 (e.g. "no overrides to apply"),
# but NOT a Pydantic 422
assert r.status_code != 422
# --- AdminRecalibrateBody -------------------------------------------
def test_recalibrate_min_observations_must_be_positive(tmp_path):
"""ge=1 on min_observations."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/admin/tenants/acme/recalibrate",
headers=_auth(keys["admin"]),
json={"entity_type": "warehouse", "min_observations": 0},
)
assert r.status_code == 422
def test_recalibrate_min_observations_default_used(tmp_path):
"""Default 14 applied if omitted; not a validation error."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/admin/tenants/acme/recalibrate",
headers=_auth(keys["admin"]),
json={"entity_type": "warehouse"},
)
# service may 400 (no observations to recalibrate from) but
# NOT 422 β€” defaults applied successfully
assert r.status_code != 422
# --- error response shape --------------------------------------------
def test_422_response_shape_has_loc_and_msg(tmp_path):
"""Verify the field-level error contract: every error has a
`loc` path and a `msg`. This is what enables a customer
dashboard to display 'url is required' next to the right
input field."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post("/tenants/acme/webhooks",
headers=_auth(keys["acme_op"]), json={})
assert r.status_code == 422
body = r.json()
assert "detail" in body
assert isinstance(body["detail"], list)
assert len(body["detail"]) >= 1
err = body["detail"][0]
assert "loc" in err
assert "msg" in err
# loc should mention the field path so UIs can route the error
assert any("url" in part for part in err["loc"] if isinstance(part, str))
def test_422_field_level_errors_multiple(tmp_path):
"""Two missing fields β†’ two errors, one per field. Customer
can fix everything in one round-trip instead of one-at-a-time."""
dbfile, keys = _bootstrap(tmp_path)
client = _client(dbfile)
r = client.post(
"/tenants/acme/schedules",
headers=_auth(keys["acme_op"]),
json={},
)
assert r.status_code == 422
body = r.json()
field_paths = [err["loc"] for err in body["detail"]]
# vertical + entity_type + connector_type all missing β†’ at
# least 3 errors
assert len(body["detail"]) >= 3
flat = [p for loc in field_paths for p in loc]
for required in ("vertical", "entity_type", "connector_type"):
assert required in flat, f"expected error for {required}"