feat: add evidence-based compatibility dimensions

This commit is contained in:
Sergey Antropoff
2026-07-13 01:04:13 +03:00
parent 6175c724d9
commit 636f42ce9d
11 changed files with 464 additions and 6 deletions
+1
View File
@@ -26,6 +26,7 @@ RUN groupadd --system --gid 10001 simulator \
&& useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator
COPY --from=builder /opt/venv /opt/venv
COPY contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json /app/contracts/pve-9.2.3.json
COPY evidence/pve-9.2.3-0.1.0.json /app/evidence/pve-9.2.3-0.1.0.json
WORKDIR /app
USER 10001:10001
EXPOSE 8006
+5
View File
@@ -60,6 +60,11 @@ Run the external-client smoke flow against the Compose network with
`compatibility`. It covers login, reads, CSRF-protected mutation, and UPID task
completion.
Machine-readable evidence is served at `/admin/compatibility`; deterministic
Markdown and HTML variants use `/admin/compatibility.md` and
`/admin/compatibility.html`. Scores are separated across all 13 contract,
response, state, task, error, and permission dimensions.
Database migrations are ordered SQL files applied transactionally and recorded
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
applied migration is rejected instead of silently drifting the schema.
+165
View File
@@ -2,13 +2,90 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
from html import escape
from pathlib import Path
from types import MappingProxyType
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from app.contracts.model import Snapshot
MethodKey = tuple[str, str]
class CompatibilityDimension(StrEnum):
ROUTE_METHOD = "route_method"
INPUT_PARAMETERS = "input_parameters"
PARAMETER_REQUIREDNESS = "parameter_requiredness"
TYPES_CONSTRAINTS = "types_constraints"
HTTP_STATUS = "http_status"
JSON_STRUCTURE = "json_structure"
RESPONSE_FIELD_TYPES = "response_field_types"
RESPONSE_REQUIRED_FIELDS = "response_required_fields"
HEADERS_COOKIES = "headers_cookies"
STATE_SEMANTICS = "state_semantics"
LONG_TASK_BEHAVIOR = "long_task_behavior"
ERRORS_PROHIBITIONS = "errors_prohibitions"
PERMISSIONS = "permissions"
EMPTY_DIMENSION_EVIDENCE: Mapping[CompatibilityDimension, frozenset[MethodKey]] = MappingProxyType(
{dimension: frozenset() for dimension in CompatibilityDimension}
)
class MethodEvidence(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
path: str
verb: str
dimensions: tuple[CompatibilityDimension, ...]
sources: tuple[str, ...]
@field_validator("sources")
@classmethod
def require_sources(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if not value:
raise ValueError("evidence record requires at least one source")
return value
class EvidenceManifest(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
format_version: int = 1
profile: str
source_version: str
records: tuple[MethodEvidence, ...]
@model_validator(mode="after")
def reject_duplicate_methods(self) -> EvidenceManifest:
keys = [(record.path, record.verb) for record in self.records]
if len(keys) != len(set(keys)):
raise ValueError("evidence manifest contains duplicate methods")
return self
def dimension_map(self) -> Mapping[CompatibilityDimension, frozenset[MethodKey]]:
evidence: dict[CompatibilityDimension, set[MethodKey]] = {
dimension: set() for dimension in CompatibilityDimension
}
for record in self.records:
key = (record.path, record.verb)
for dimension in record.dimensions:
evidence[dimension].add(key)
return MappingProxyType(
{dimension: frozenset(methods) for dimension, methods in evidence.items()}
)
def load_evidence_manifest(path: Path) -> EvidenceManifest:
return EvidenceManifest.model_validate_json(path.read_bytes())
@dataclass(frozen=True, slots=True)
class CompatibilityReport:
source_version: str
@@ -17,6 +94,9 @@ class CompatibilityReport:
implemented: frozenset[MethodKey]
observed: frozenset[MethodKey]
verified: frozenset[MethodKey]
dimensions: Mapping[CompatibilityDimension, frozenset[MethodKey]]
incompatible: frozenset[MethodKey]
regressions: frozenset[MethodKey]
def as_json(self) -> dict[str, object]:
levels = {
@@ -27,6 +107,13 @@ class CompatibilityReport:
"verified": self.verified,
}
total = len(self.declared)
dimension_sets = tuple(self.dimensions.values())
fully_evidenced = (
dimension_sets[0].intersection(*dimension_sets[1:]) if dimension_sets else frozenset()
)
evidenced = frozenset().union(*dimension_sets)
fully_compatible = fully_evidenced & self.implemented
partially_compatible = (evidenced & self.implemented) - fully_compatible - self.incompatible
return {
"source_version": self.source_version,
"total_declared": total,
@@ -39,8 +126,31 @@ class CompatibilityReport:
for name, methods in levels.items()
},
"groups": self._groups(),
"dimension_groups": self._dimension_groups(),
"classifications": {
"fully_compatible": self._method_names(fully_compatible),
"partially_compatible": self._method_names(partially_compatible),
"incompatible": self._method_names(self.incompatible),
"regressions": self._method_names(self.regressions),
"unsupported": self._method_names(self.schema_only),
},
"dimensions": {
dimension.value: {
"count": len(methods),
"score": len(methods) / total if total else 1.0,
"methods": [f"{verb} {path}" for path, verb in sorted(methods)],
}
for dimension, methods in self.dimensions.items()
},
}
@staticmethod
def _method_names(methods: frozenset[MethodKey]) -> list[str]:
return [f"{verb} {path}" for path, verb in sorted(methods)]
def canonical_json(self) -> str:
return json.dumps(self.as_json(), ensure_ascii=False, separators=(",", ":"), sort_keys=True)
def _groups(self) -> dict[str, dict[str, int]]:
groups: dict[str, dict[str, int]] = {}
for path, verb in self.declared:
@@ -51,6 +161,17 @@ class CompatibilityReport:
counters["verified"] += int((path, verb) in self.verified)
return dict(sorted(groups.items()))
def _dimension_groups(self) -> dict[str, dict[str, int]]:
groups: dict[str, dict[str, int]] = {}
for dimension, methods in self.dimensions.items():
for path, _verb in methods:
group = path.strip("/").split("/", 1)[0] or "root"
counters = groups.setdefault(
group, {item.value: 0 for item in CompatibilityDimension}
)
counters[dimension.value] += 1
return dict(sorted(groups.items()))
def as_markdown(self) -> str:
levels = {
"declared": self.declared,
@@ -69,8 +190,37 @@ class CompatibilityReport:
for name, methods in levels.items():
score = len(methods) / total if total else 1.0
lines.append(f"| {name} | {len(methods)} | {score:.2%} |")
lines.extend(
[
"",
"## Compatibility dimensions",
"",
"| Dimension | Verified methods | Score |",
"|---|---:|---:|",
]
)
for dimension, methods in self.dimensions.items():
score = len(methods) / total if total else 1.0
lines.append(f"| {dimension.value} | {len(methods)} | {score:.2%} |")
return "\n".join(lines)
def as_html(self) -> str:
rows = "".join(
"<tr>"
f"<td>{escape(dimension.value)}</td>"
f"<td>{len(methods)}</td>"
f"<td>{(len(methods) / len(self.declared) if self.declared else 1.0):.2%}</td>"
"</tr>"
for dimension, methods in self.dimensions.items()
)
return (
'<!doctype html><html lang="en"><meta charset="utf-8">'
"<title>Compatibility report</title><body>"
f"<h1>PVE {escape(self.source_version)} compatibility</h1>"
"<table><thead><tr><th>Dimension</th><th>Verified methods</th>"
f"<th>Score</th></tr></thead><tbody>{rows}</tbody></table></body></html>"
)
def build_report(
snapshot: Snapshot,
@@ -78,6 +228,9 @@ def build_report(
implemented: frozenset[MethodKey] = frozenset(),
observed: frozenset[MethodKey] = frozenset(),
verified: frozenset[MethodKey] = frozenset(),
dimensions: Mapping[CompatibilityDimension, frozenset[MethodKey]] = EMPTY_DIMENSION_EVIDENCE,
incompatible: frozenset[MethodKey] = frozenset(),
regressions: frozenset[MethodKey] = frozenset(),
) -> CompatibilityReport:
declared = frozenset(
(path.path, method.verb) for path in snapshot.paths for method in path.methods
@@ -86,9 +239,18 @@ def build_report(
"implemented": implemented,
"observed": observed,
"verified": verified,
"incompatible": incompatible,
"regressions": regressions,
}.items():
if not evidence <= declared:
raise ValueError(f"{name} evidence references undeclared methods")
resolved_dimensions = {
dimension: frozenset(dimensions.get(dimension, frozenset()))
for dimension in CompatibilityDimension
}
for dimension, evidence in resolved_dimensions.items():
if not evidence <= declared:
raise ValueError(f"{dimension.value} evidence references undeclared methods")
return CompatibilityReport(
source_version=snapshot.source_version,
declared=declared,
@@ -96,4 +258,7 @@ def build_report(
implemented=implemented,
observed=observed,
verified=verified,
dimensions=MappingProxyType(resolved_dimensions),
incompatible=incompatible,
regressions=regressions,
)
+1
View File
@@ -33,6 +33,7 @@ class Settings(BaseSettings):
log_level: str = "INFO"
request_id_header: str = "X-Request-ID"
contract_snapshot: Path | None = None
compatibility_evidence: Path | None = None
contract_fallback: Literal["error", "schema-default", "fixture"] = "error"
ticket_signing_key: SecretStr = SecretStr("development-only-signing-key-change-me")
task_worker_concurrency: int = Field(default=2, ge=1, le=32)
+22 -3
View File
@@ -4,12 +4,12 @@ from __future__ import annotations
from typing import cast
from fastapi import FastAPI
from fastapi import FastAPI, Response
from app.api.errors import ApiError, api_error_handler, unhandled_exception_handler
from app.api.middleware import RequestContextMiddleware
from app.api.registry import HandlerRegistry, register_contract_routes
from app.compatibility import build_report
from app.compatibility import CompatibilityDimension, build_report, load_evidence_manifest
from app.config import Settings, get_settings
from app.contracts.model import Snapshot
from app.db.pool import AsyncpgDatabase, Database
@@ -71,12 +71,31 @@ def create_app(
declared = frozenset(
(path.path, method.verb) for path in snapshot.paths for method in path.methods
)
report = build_report(snapshot, implemented=resolved_handlers.keys() & declared)
dimensions = {CompatibilityDimension.ROUTE_METHOD: declared}
if resolved.compatibility_evidence is not None:
evidence = load_evidence_manifest(resolved.compatibility_evidence)
if evidence.source_version != snapshot.source_version:
raise ValueError("compatibility evidence version does not match contract")
dimensions.update(evidence.dimension_map())
dimensions[CompatibilityDimension.ROUTE_METHOD] = declared
report = build_report(
snapshot,
implemented=resolved_handlers.keys() & declared,
dimensions=dimensions,
)
@app.get("/admin/compatibility", include_in_schema=False)
async def compatibility_report() -> dict[str, object]:
return report.as_json()
@app.get("/admin/compatibility.md", include_in_schema=False)
async def compatibility_report_markdown() -> Response:
return Response(report.as_markdown(), media_type="text/markdown")
@app.get("/admin/compatibility.html", include_in_schema=False)
async def compatibility_report_html() -> Response:
return Response(report.as_html(), media_type="text/html")
return app
+1
View File
@@ -25,6 +25,7 @@ services:
environment:
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
CONTRACT_SNAPSHOT: /app/contracts/pve-9.2.3.json
COMPATIBILITY_EVIDENCE: /app/evidence/pve-9.2.3-0.1.0.json
depends_on:
postgres:
condition: service_healthy
+8
View File
@@ -43,3 +43,11 @@ worker tests; a single smoke run chooses the transition valid for current state.
The live `/admin/compatibility` endpoint is the machine-readable source for
declared and implemented counts. Unsupported methods remain failures by default
so the simulator cannot silently overstate compatibility.
The report also exposes the 13 independent compatibility dimensions required by
the project brief. Evidence is loaded from the immutable
`evidence/pve-9.2.3-0.1.0.json` manifest, where every method/dimension claim cites
the tests that support it. Dynamic route registration itself proves only the
route/method dimension; it does not imply semantic compatibility. Markdown and
HTML renderings are available at `/admin/compatibility.md` and
`/admin/compatibility.html`.
+147
View File
@@ -0,0 +1,147 @@
# Original prompt gap plan
This is the executable completion checklist for the original 1,985-line project
brief. It starts after release 0.1.0 and supersedes the short “subsequent
releases” list as the source of delivery status. A box is closed only when code,
tests, documentation, container acceptance, and a focused commit exist.
Status at audit time: 0.1.0 is operational, but the overall project is not done.
## G1 — measurable compatibility evidence
- [x] Model all 13 requested compatibility dimensions independently: route and
method, inputs, requiredness, types and constraints, HTTP status, JSON shape,
response field types, required response fields, headers and cookies, state
semantics, long-task behavior, errors and prohibitions, permissions.
- [x] Store test-derived evidence per method/profile instead of deriving strong
claims merely from handler presence.
- [x] Generate deterministic JSON, Markdown, and HTML reports with totals and
breakdowns by API group and PVE version.
- [x] Mark declared, schema-only, implemented, observed, partially compatible,
incompatible, and regression states without claiming unsupported semantics.
Exit: a test fixture can prove different scores for every dimension and the
live admin report renders the same evidence deterministically.
## G2 — persistence model and deterministic datasets
- [ ] Expand normalized tables/repositories for storages and contents, QEMU,
LXC, disks, NICs, snapshots, backups, pools, users/groups/roles/ACLs/tokens and
observed contracts. Preserve version, metadata, timestamps, and cluster-wide
VMID uniqueness.
- [ ] Make migration readiness explicit so workers cannot become permanently
unhealthy before schema creation.
- [ ] Match the required `small` profile (one node, two QEMU, one LXC, two
storages, administrator, completed tasks).
- [ ] Implement deterministic `medium`, configurable batch-insert `large`,
`ha-demo`, and `broken-storage` profiles.
Exit: clean migration plus every seed profile has a stable logical snapshot;
large seeding proves bounded batch operations rather than row-at-a-time inserts.
## G3 — authentication and authorization surface
- [ ] Expose API-token lifecycle and authenticate
`PVEAPIToken=USER@REALM!TOKENID=SECRET` without CSRF.
- [ ] Complete pam, pve, and test realm behavior, ticket skew/expiry and
credential redaction.
- [ ] Wire users, groups, roles, ACL propagation, route-derived permissions and
token privilege separation into every semantic handler.
- [ ] Test root, audit-only, VM operator, storage user, separated token,
inheritance, denial, and existence-hiding behavior.
Exit: the complete credential/permission matrix passes through HTTP and no
plaintext password, ticket, CSRF token, or token secret reaches storage/logs.
## G4 — QEMU 0.2 verticals
- [ ] Create, update, delete, shutdown, reboot, reset, suspend and resume.
- [ ] Snapshots and rollback, clone, local/remote migration, resize and move
disk, selected agent endpoints, pending/status data.
- [ ] Persist normalized CPU/memory/common fields plus unknown PVE parameters in
JSONB; simulate usage, uptime, PID, IO/network, lock, template, QMP, HA and
guest-agent availability.
- [ ] Cover concurrent start/delete, migrate/snapshot, optimistic conflict,
idempotency and restart recovery.
Exit: each operation is a complete contract/auth/permission/persistence/task/
state/error/test vertical and release 0.2.0 has a generated limitation matrix.
## G5 — LXC, storage, pools, backup and cluster 0.3
- [ ] LXC create/config/lifecycle/clone/migrate/snapshot/resize/delete.
- [ ] Storage list/status/content metadata, allocation/free, upload metadata,
ISO/template/backup listing and content deletion without large default blobs.
- [ ] Pools and membership, backup metadata/tasks, cluster status/nextid/options/
tasks/replication, and initial HA model/status.
Exit: release 0.3.0 passes client-level flows for every listed resource family.
## G6 — simulator administration, faults and virtual time 0.4
- [ ] Protected, disableable `/_simulator` API for state, reset, scenarios,
faults, compatibility and manual clock advancement.
- [ ] Deterministic rules filtered by route, method, principal, node, VMID, call
count, probability, time interval and scenario.
- [ ] Implement the specified node/storage/task/permission/migration/snapshot/
backup/HTTP/malformed/agent/lock/quorum/HA failures.
- [ ] Ensure simulation services use injected real, accelerated or manual clocks;
only lease internals use real monotonic time.
Exit: seeded scenario tests reproduce the same failures and durations across
runs; admin endpoints cannot overlap or weaken the PVE API boundary.
## G7 — safe recorder and differential laboratory 0.5
- [ ] Opt-in async passthrough/record fallbacks with official-lab allowlist,
production denylist, verified TLS, read-only default and explicit mutation
authorization.
- [ ] Sanitize credentials, cookies, tickets, CSRF, tokens, people, hosts and IPs
before fixtures can be persisted.
- [ ] Record request/response/latency/version/time/scenario metadata.
- [ ] Run identical lab/simulator requests using declarative normalization for
timestamps, UPIDs, PIDs, tokens, node values, uptime and resource usage.
- [ ] Produce JSON/Markdown/HTML reports with compatibility classes, regressions,
groups and versions.
Exit: secret-scanning fixtures and offline replay tests pass; normal startup has
no dependency on or route to a real Proxmox.
## G8 — profiles, observability and operational packaging
- [ ] Central `CompatibilityCapabilities` profiles for pve-8.4, pve-9.0,
pve-9.2 and custom; no scattered version-prefix conditions.
- [ ] Prometheus metrics requested by the brief, with bounded-cardinality labels,
plus optional OpenTelemetry tracing and optional Compose Prometheus/Grafana.
- [ ] Enrich safe structured logs with route template, principal, task/resource
context and stable error code.
- [ ] Add Docker labels/SBOM-friendly metadata and verify read-only/non-root
runtime, signals, one uvicorn process and multi-replica task leasing.
- [ ] Add Helm/Kubernetes Deployment, Service, ConfigMap, Secret, probes, PDB,
NetworkPolicy, hardened security context, resources, topology spread, separate
migration/seed Jobs and external PostgreSQL production configuration.
Exit: observability tests reject high-cardinality labels; chart lint/render and
multi-replica acceptance pass.
## G9 — client certification, security and 1.0 governance
- [ ] Add contract coverage for every imported route and critical-module
coverage of at least 90% while maintaining project coverage at least 80%.
- [ ] Certify documented versions of proxmoxer, HTTPX, Terraform provider and
Ansible modules only for the surfaces their flows exercise.
- [ ] Publish stable compatibility/migration policy and run dependency,
container, recorder, secret-handling and threat-model review.
- [ ] Expand README with architecture, version/profile choice, every seed,
scenarios, reports, recorder security, Kubernetes, commands and roadmap.
Exit: release 1.0.0 is reproducible from a clean checkout and all published
claims point to immutable machine-readable evidence.
## Global gate for every G-stage
Run formatting, Ruff, strict mypy, unit/integration/contract/compatibility tests,
coverage, `git diff --check`, relevant clean-container acceptance, documentation,
and a focused commit. No `pass`, TODO, `NotImplementedError`, sync network/DB IO,
`time.sleep`, plaintext secrets, silent error swallowing, or unsupported
compatibility claims may be introduced.
+55
View File
@@ -0,0 +1,55 @@
{
"format_version": 1,
"profile": "pve-9.2",
"source_version": "9.2.3",
"records": [
{
"path": "/version",
"verb": "GET",
"dimensions": ["http_status", "json_structure", "response_field_types", "response_required_fields"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_core_handlers.py"]
},
{
"path": "/access/ticket",
"verb": "POST",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "response_field_types", "response_required_fields", "headers_cookies", "errors_prohibitions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_auth.py", "tests/unit/test_dynamic_routes.py"]
},
{
"path": "/nodes",
"verb": "GET",
"dimensions": ["http_status", "json_structure", "response_field_types", "response_required_fields", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_core_handlers.py", "tests/unit/test_acl.py"]
},
{
"path": "/nodes/{node}/qemu",
"verb": "GET",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "response_field_types", "response_required_fields", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_core_handlers.py", "tests/unit/test_acl.py"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/status/current",
"verb": "GET",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "response_field_types", "response_required_fields", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_core_handlers.py"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/status/start",
"verb": "POST",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "headers_cookies", "state_semantics", "long_task_behavior", "errors_prohibitions", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_qemu_task.py", "tests/unit/test_transitions.py"]
},
{
"path": "/nodes/{node}/qemu/{vmid}/status/stop",
"verb": "POST",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "headers_cookies", "state_semantics", "long_task_behavior", "errors_prohibitions", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_qemu_task.py", "tests/unit/test_transitions.py"]
},
{
"path": "/nodes/{node}/tasks/{upid}/status",
"verb": "GET",
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "response_field_types", "response_required_fields", "long_task_behavior", "permissions"],
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_task_worker.py", "tests/unit/test_upid.py"]
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
"""Unmodified proxmoxer HTTPS smoke flow."""
import os
import time
from threading import Event
import pytest
from proxmoxer import ProxmoxAPI # type: ignore[import-untyped]
@@ -34,6 +34,6 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
task = proxmox.nodes("pve1").tasks(upid).status.get()
if task["status"] == "stopped":
break
time.sleep(0.05)
Event().wait(0.05)
assert task["status"] == "stopped"
assert task["exitstatus"] == "OK"
+57 -1
View File
@@ -1,10 +1,11 @@
"""Compatibility accounting tests."""
from datetime import UTC, datetime
from typing import cast
import pytest
from app.compatibility import build_report
from app.compatibility import CompatibilityDimension, EvidenceManifest, build_report
from app.contracts.model import Method, PathContract, Schema, Snapshot
@@ -54,3 +55,58 @@ def test_report_scores_levels_and_groups_independently() -> None:
def test_report_rejects_unbound_evidence() -> None:
with pytest.raises(ValueError, match="undeclared"):
build_report(snapshot(), verified=frozenset({("/missing", "GET")}))
def test_all_thirteen_dimensions_have_independent_evidence_and_renderers() -> None:
method = frozenset({("/nodes/{node}", "GET")})
report = build_report(
snapshot(),
implemented=method,
dimensions={dimension: method for dimension in CompatibilityDimension},
)
payload = report.as_json()
dimensions = cast(dict[str, dict[str, object]], payload["dimensions"])
assert list(dimensions) == [dimension.value for dimension in CompatibilityDimension]
assert len(dimensions) == 13
assert all(item["count"] == 1 for item in dimensions.values())
assert payload["dimension_groups"]
classifications = cast(dict[str, list[str]], payload["classifications"])
assert classifications["fully_compatible"] == ["GET /nodes/{node}"]
assert not classifications["partially_compatible"]
assert "| permissions | 1 |" in report.as_markdown()
assert "<td>long_task_behavior</td><td>1</td>" in report.as_html()
assert report.canonical_json() == report.canonical_json()
def test_dimension_evidence_must_reference_declared_method() -> None:
with pytest.raises(ValueError, match="permissions evidence"):
build_report(
snapshot(),
dimensions={CompatibilityDimension.PERMISSIONS: frozenset({("/missing", "GET")})},
)
def test_evidence_manifest_requires_provenance_and_unique_methods() -> None:
manifest = EvidenceManifest.model_validate(
{
"profile": "pve-9.2",
"source_version": "9.2.3",
"records": [
{
"path": "/nodes/{node}",
"verb": "GET",
"dimensions": ["http_status", "json_structure"],
"sources": ["tests/compatibility/test_proxmoxer.py"],
}
],
}
)
evidence = manifest.dimension_map()
assert evidence[CompatibilityDimension.HTTP_STATUS] == frozenset({("/nodes/{node}", "GET")})
assert not evidence[CompatibilityDimension.PERMISSIONS]
duplicate = manifest.model_dump(mode="json")
duplicate["records"].append(duplicate["records"][0])
with pytest.raises(ValueError, match="duplicate methods"):
EvidenceManifest.model_validate(duplicate)