mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-24 11:26:29 +00:00
02b1cb2bca
Closes #13, Closes #14. This release squashes the v1.4.0 → v1.5.0 development line. v1.4.0 shipped the ACME stability & enterprise audit (Issues #10/#11/#12). v1.5.0 builds on that foundation with two co-equal headline features plus a 22-round audit campaign hardening the prior configuration surface. License remains MIT for v1.5.0 (relicense to AGPL-3.0 lands in v1.5.2). ------------------------------------------------------------------ HEADLINE FEATURE A — ACME Diagnostic Panel (Issue #13) ------------------------------------------------------------------ A live pre-flight + post-failure diagnostic surface for every ACME order, reachable from the ACME Automation page. The panel exists to make ACME failures legible to operators who do NOT have shell access to the API host. Endpoints (`backend/routers/acme_diagnostics.py`): POST /api/letsencrypt/orders/{order_id}/diagnostics Run the full 5-check suite (DNS / port-80 / routing / account / agents) and humanize the order's `error_detail` (>=11 RFC-8555 problem types, backwards compatible with legacy plain-string failures). POST /api/letsencrypt/orders/{order_id}/diagnostics/ {check_id}/rerun Re-run a single check in place — used by the "Re-run" button on every row of the modal's pre-flight table. GET /api/letsencrypt/orders/{order_id}/events Merged event timeline combining the typed `acme_order_events` rows with correlated `user_activity_logs` entries (resource_type = 'letsencrypt_order' AND resource_id = order_id). The diagnostic modal auto-tails this timeline every 5 seconds while open. Service-level checks (`backend/services/acme_diagnostics.py`): * DNS resolution via stdlib socket.gethostbyname_ex through run_in_executor (intentionally avoiding an aiodns runtime dep for v1.5.0). * Port-80 HEAD probe, target locked to the order's domains, success on HTTP 200 OR 404, warns on egress timeout (corp egress policies routinely blackhole outbound 80 — fail-hard would be too noisy). * SSRF guard: probe refuses non-public IPs and surfaces the skip in the diagnostic result; IPv4-mapped IPv6 normalisation closes the `::ffff:169.254.169.254` cloud-metadata vector. * HAProxy routing presence check: matches the order's cluster_ids to a port-80 HTTP frontend. * ACME account validity check against `letsencrypt_accounts`. * Agent presence check (>=1 active agent in target cluster). * Every sub-check wrapped in a wall-clock timeout to bound impact on the API event loop. RBAC: ssl.read for run, ssl.read for events. Per-user 5/min rate limit on both run and rerun, backed by the (user_id, action, created_at DESC) composite index. Frontend (`frontend/src/components/ACMEAutomation.js`): * "Diagnose" button on every order row + the existing "stuck order" warning row. * Modal with two tabs: - Pre-flight Checks (Antd Table with status pills + Re-run buttons + humanized error banner) - Event Log (Antd Timeline with auto-tail polling, scroll- to-bottom, pause-on-hover) * Correlation IDs surfaced in error banners and individual check fail details for backend-log lookup. ------------------------------------------------------------------ HEADLINE FEATURE B — Site Setup Wizard (Issue #14) ------------------------------------------------------------------ A single guided flow that creates a Backend + Servers + HTTP Frontend (and optional HTTPS Frontend) in one atomic transaction. Endpoints (`backend/routers/site_wizard.py`): POST /api/site-wizard/preview — diff-preview the changeset POST /api/site-wizard/create — atomic execute POST /api/site-wizard/reject — clean rollback (including any wizard_staged ACME orders) GET /api/site-wizard/drafts — draft persistence PUT /api/site-wizard/drafts/{id} — save/update DELETE /api/site-wizard/drafts/{id} Feature surface: * One screen captures both backend (mode + servers) AND frontend (http + optional https + SSL mode) inputs. * SSL modes: ACME (new order, HTTP-01 only for v1.5.0), Upload (existing PEM), Existing (link to a stored cert), or None. * ACME-staged path: wizard_staged_until watermark on the `letsencrypt_orders` row defers finalisation until agent confirmation; per-mode reject cleanly cancels and rolls back the staged order. * Live diff preview against the cluster's current generated config (renderer-evolution noise stripped — track-sc<N> dedup, per-server cookie strip, defaults-cookie inheritance, listen-block flattening). * Draft persistence with PEM stripped at save time (private keys never round-trip through the drafts table). * Per-cluster multi-tenancy: drafts and wizard_staged orders are isolated to the creating user's cluster scope. Frontend (`frontend/src/components/SiteWizard.js`): * 4-step Antd Steps flow: Backend → Frontend → SSL → Review. * Render the live diff preview inline before commit. * Antd Form-level validation mirrors backend Pydantic validators (numeric bounds, HAProxy reserved keywords, ALPN consistency, IPv6 scope-id, domain regex, server name dedup). ------------------------------------------------------------------ AUDIT CAMPAIGN — Rounds 1 → 22 (Bulgu #1 → #82) ------------------------------------------------------------------ v1.5.0 includes 22 adversarial review passes. Each round produced its own commit set in the corporate development line; this squash collapses those into the v1.5.0 release artefact. Highlights: Round 1-4 Site Wizard core: dry-run parity, single-line value injection guard, ACL -f pattern-file block, SSL parity, timeout regex, form-state pin. Round 5-7 defaults-cookie inheritance, server-named-cookie guard, fe/be mode mismatch, duplicate server names, health_check_uri + server_address validators. Round 8-10 cookie_name / cookie_options newline-injection guard, dry-run parity (round 9), TCP-mode HTTP-only feature blockers. Round 11 SSL name path traversal + health-check >= 1. Round 12-13 SSL & ACME deep dive (Bulgu #23-#32). Round 14 single-line value injection (Bulgu #33). Round 15-17 ACME multi-tenant UX, numeric bounds, HAProxy reserved keywords, ALPN/TLS consistency, all-backup, multi-domain & multi-user enterprise edges, drain/HSTS/post-completion (Bulgu #34-#53). Round 18-21 concurrency, agent state, TCP-mode HTTP-only, list size caps, IPv6 scope-id, preview account validation, TCP backend + balance uri reject (Bulgu #54-#61). Round 22 FE error visibility + 3x stale-data lockouts, referential integrity + cascade safety, authentication & authorization, multi-cluster isolation, apply_pending_changes concurrency, script injection + bulk import multi-tenancy, prefix-stripped signature comparison (Bulgu #62-#82). ------------------------------------------------------------------ NO CORPORATE-SPECIFIC ARTIFACTS ------------------------------------------------------------------ This squash deliberately sanitises corporate hostnames, container registry references, and TLS secret names into generic placeholders (`your-registry.example.com/your-org`, `haproxy-openmanager*.example.com`, `wildcard-tls`, `taylanbakircioglu/haproxy-openmanager-*`) so the public artefact contains no internal infrastructure detail. Pilot / development history that retained those values stays in the corporate fork and is NOT part of this commit.
538 lines
24 KiB
Python
538 lines
24 KiB
Python
"""Phase K — Site Wizard validation hardening (Phase A pins).
|
|
|
|
These pins lock the contract / safety / cross-field decisions made in
|
|
Phase A of the Site Wizard validation hardening + UX simplification
|
|
plan. They are deliberately model-level (not endpoint-level) so the
|
|
Pydantic invariant survives any future router refactor without an HTTP
|
|
client harness.
|
|
|
|
Background
|
|
----------
|
|
|
|
Before Phase A:
|
|
* `FrontendStep.acl_rules` was typed `List[dict]` while
|
|
`frontend/src/components/ACLRuleBuilder.js::serializeAclRule`
|
|
emitted `string`. Every wizard POST containing a single ACL rule
|
|
failed Pydantic validation with
|
|
body -> frontend -> acl_rules -> 0:
|
|
Input should be a valid dictionary
|
|
Same for `use_backend_rules`. The tool was effectively unusable
|
|
for any non-trivial site.
|
|
* `FrontendStep` did not catch `mode='tcp' + https_redirect=true`.
|
|
The renderer would silently emit an HTTP-only directive into a
|
|
TCP frontend and the agent's `haproxy -c` would reject the
|
|
config only at apply time.
|
|
* `SSLChoice` did not catch `ssl_min_ver > ssl_max_ver`. HAProxy
|
|
accepted the syntax but every TLS handshake failed at runtime.
|
|
|
|
After Phase A:
|
|
* `acl_rules: List[str]`, `use_backend_rules: List[str]` (manual API
|
|
parity at `backend/models/frontend.py::validate_acl_rules`).
|
|
* `redirect_rules: List[Union[str, dict]]` — kept heterogeneous on
|
|
purpose so the structured-redirect path
|
|
(`services/haproxy_config.py::_format_redirect_rule`) keeps
|
|
working for any historical caller.
|
|
* Every rule string is bounded (4 KB), newline-free, danger-pattern
|
|
free, non-empty after `.strip()`. Mirrors the manual frontend
|
|
API's pre-existing security posture.
|
|
* `FrontendStep.reject_tcp_mode_with_https_redirect` and
|
|
`SSLChoice.reject_inverted_tls_versions` close two silent-bug
|
|
gaps surfaced by the audit.
|
|
"""
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from models.site_wizard import FrontendStep, SSLChoice
|
|
|
|
|
|
def _frontend_kwargs(**overrides):
|
|
"""Minimal valid FrontendStep kwargs."""
|
|
base = dict(name="fe_http", mode="http", bind_port=80)
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Phase K-A1 — acl_rules contract + safety validators
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_phase_k_frontend_step_acl_rules_is_list_str_with_safety_validators():
|
|
"""Phase A: acl_rules accepts string list + rejects every known
|
|
unsafe shape (dict, newline injection, danger pattern, oversize)."""
|
|
fe = FrontendStep(**_frontend_kwargs(acl_rules=["is_api path_beg /api"]))
|
|
assert fe.acl_rules == ["is_api path_beg /api"]
|
|
|
|
with pytest.raises(ValidationError, match="must be HAProxy directive strings"):
|
|
FrontendStep(**_frontend_kwargs(acl_rules=[{"raw": "is_api path_beg /api"}]))
|
|
|
|
with pytest.raises(ValidationError, match="line breaks"):
|
|
FrontendStep(
|
|
**_frontend_kwargs(acl_rules=["is_api path_beg /api\nglobal\n daemon"])
|
|
)
|
|
|
|
with pytest.raises(ValidationError, match="dangerous content"):
|
|
FrontendStep(
|
|
**_frontend_kwargs(acl_rules=["is_evil path_beg $(rm -rf /)"])
|
|
)
|
|
|
|
with pytest.raises(ValidationError, match="exceeds 4096 characters"):
|
|
FrontendStep(**_frontend_kwargs(acl_rules=["a " * 3000]))
|
|
|
|
|
|
def test_phase_k_frontend_step_use_backend_rules_is_list_str_with_safety_validators():
|
|
"""Same matrix as ACL — use_backend_rules has the same contract
|
|
because `routers/backend.py:1281-1298` substring-matches against
|
|
these strings to clean up after a backend deletion."""
|
|
fe = FrontendStep(
|
|
**_frontend_kwargs(use_backend_rules=["api_be if is_api"])
|
|
)
|
|
assert fe.use_backend_rules == ["api_be if is_api"]
|
|
|
|
with pytest.raises(ValidationError, match="must be HAProxy directive strings"):
|
|
FrontendStep(
|
|
**_frontend_kwargs(use_backend_rules=[{"backend": "api_be"}])
|
|
)
|
|
|
|
with pytest.raises(ValidationError, match="line breaks"):
|
|
FrontendStep(
|
|
**_frontend_kwargs(use_backend_rules=["api_be if is_api\nbackend foo"])
|
|
)
|
|
|
|
with pytest.raises(ValidationError, match="dangerous content"):
|
|
FrontendStep(
|
|
**_frontend_kwargs(use_backend_rules=["api_be if `eval x`"])
|
|
)
|
|
|
|
with pytest.raises(ValidationError, match="exceeds 4096 characters"):
|
|
FrontendStep(**_frontend_kwargs(use_backend_rules=["x " * 3000]))
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Phase K-A2 — redirect_rules deliberate heterogeneity
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_phase_k_frontend_step_redirect_rules_accepts_string_or_dict():
|
|
"""`redirect_rules` stays `List[Union[str, dict]]` because the
|
|
renderer at `services/haproxy_config.py::_format_redirect_rule`
|
|
deliberately accepts both shapes. Forcing string-only would
|
|
silently break the structured-redirect path."""
|
|
fe_str = FrontendStep(
|
|
**_frontend_kwargs(redirect_rules=["scheme https if !{ ssl_fc }"])
|
|
)
|
|
assert fe_str.redirect_rules == ["scheme https if !{ ssl_fc }"]
|
|
|
|
fe_dict = FrontendStep(
|
|
**_frontend_kwargs(redirect_rules=[{"scheme": "https", "code": 301}])
|
|
)
|
|
assert fe_dict.redirect_rules == [{"scheme": "https", "code": 301}]
|
|
|
|
fe_mixed = FrontendStep(
|
|
**_frontend_kwargs(
|
|
redirect_rules=[
|
|
"scheme https if !{ ssl_fc }",
|
|
{"scheme": "https", "code": 301},
|
|
]
|
|
)
|
|
)
|
|
assert len(fe_mixed.redirect_rules) == 2
|
|
|
|
with pytest.raises(ValidationError, match="HAProxy directive strings or"):
|
|
FrontendStep(**_frontend_kwargs(redirect_rules=[42]))
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Phase K-A3 — empty / whitespace rule reject (would render as
|
|
# `acl ` / `use_backend ` and fail haproxy -c)
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_phase_k_frontend_step_rejects_empty_string_rule_element():
|
|
"""Empty / whitespace-only rule strings are rejected at validation
|
|
time on every rule field — they would otherwise render as bare
|
|
`acl ` / `use_backend ` lines that fail `haproxy -c`."""
|
|
for field in ("acl_rules", "use_backend_rules"):
|
|
with pytest.raises(ValidationError, match="empty / whitespace-only"):
|
|
FrontendStep(**_frontend_kwargs(**{field: [""]}))
|
|
with pytest.raises(ValidationError, match="empty / whitespace-only"):
|
|
FrontendStep(**_frontend_kwargs(**{field: [" "]}))
|
|
|
|
with pytest.raises(ValidationError, match="empty / whitespace-only"):
|
|
FrontendStep(**_frontend_kwargs(redirect_rules=[""]))
|
|
with pytest.raises(ValidationError, match="empty / whitespace-only"):
|
|
FrontendStep(**_frontend_kwargs(redirect_rules=[" "]))
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Phase K-A4 — TCP-mode + https_redirect cross-field validator
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_phase_k_frontend_step_rejects_tcp_mode_with_https_redirect():
|
|
"""`mode='tcp' + https_redirect=true` was a silent bug — the
|
|
renderer emits an HTTP-only directive into a TCP frontend and
|
|
the agent's `haproxy -c` rejects it only at apply time. Phase A
|
|
rejects it at the wizard boundary."""
|
|
with pytest.raises(
|
|
ValidationError,
|
|
match="frontend.mode='tcp' is incompatible with frontend.https_redirect",
|
|
):
|
|
FrontendStep(**_frontend_kwargs(mode="tcp", https_redirect=True))
|
|
|
|
fe_http = FrontendStep(**_frontend_kwargs(mode="http", https_redirect=True))
|
|
assert fe_http.https_redirect is True
|
|
fe_tcp = FrontendStep(**_frontend_kwargs(mode="tcp", https_redirect=False))
|
|
assert fe_tcp.https_redirect is False
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Phase K-A5 — TLS min/max inversion validator on SSLChoice
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_phase_k_sslchoice_rejects_inverted_tls_versions():
|
|
"""`ssl_min_ver > ssl_max_ver` produces a bind that accepts no TLS
|
|
handshakes at runtime — surface the contradiction at the wizard
|
|
boundary instead of in production."""
|
|
with pytest.raises(
|
|
ValidationError,
|
|
match="cannot be greater than ssl.ssl_max_ver",
|
|
):
|
|
SSLChoice(mode="none", ssl_min_ver="TLSv1.3", ssl_max_ver="TLSv1.2")
|
|
|
|
ssl_ok = SSLChoice(mode="none", ssl_min_ver="TLSv1.2", ssl_max_ver="TLSv1.3")
|
|
assert ssl_ok.ssl_min_ver == "TLSv1.2"
|
|
assert ssl_ok.ssl_max_ver == "TLSv1.3"
|
|
|
|
ssl_equal = SSLChoice(mode="none", ssl_min_ver="TLSv1.2", ssl_max_ver="TLSv1.2")
|
|
assert ssl_equal.ssl_min_ver == "TLSv1.2"
|
|
assert ssl_equal.ssl_max_ver == "TLSv1.2"
|
|
|
|
ssl_one_set = SSLChoice(mode="none", ssl_min_ver="TLSv1.2")
|
|
assert ssl_one_set.ssl_min_ver == "TLSv1.2"
|
|
assert ssl_one_set.ssl_max_ver is None
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Phase K-C — shared synthesis helper + dry-run preview behaviour
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _site_payload(**overrides):
|
|
"""Minimal valid SiteCreate payload for synthesis tests."""
|
|
from models.site_wizard import SiteCreate
|
|
base = {
|
|
"cluster_id": 1,
|
|
"domains": ["www.example.com"],
|
|
"backend": {"name": "be"},
|
|
"servers": [{
|
|
"server_name": "s1",
|
|
"server_address": "10.0.0.1",
|
|
"server_port": 80,
|
|
}],
|
|
"frontend": {"name": "fe", "bind_port": 80},
|
|
"ssl": {"mode": "none"},
|
|
}
|
|
base.update(overrides)
|
|
return SiteCreate(**base)
|
|
|
|
|
|
def test_phase_k_create_site_and_preview_use_same_synthesis_helper():
|
|
"""Phase K Phase C — both `create_site` and the dry-run preview
|
|
path must route through `_synthesize_candidate_haproxy_config`.
|
|
A static-source pin keeps a future refactor from quietly
|
|
inlining the call on one side and breaking parity between the
|
|
apply gate and the dry-run gate.
|
|
|
|
The helper is intentionally two-mode:
|
|
* `entities_already_inserted=True` for `create_site` (the
|
|
wizard's INSERTs are already in the active transaction so
|
|
the renderer sees them).
|
|
* `entities_already_inserted=False` for the preview dry-run
|
|
(no DB writes happen — we render the current cluster + a
|
|
candidate fragment).
|
|
"""
|
|
from pathlib import Path
|
|
src = Path(__file__).resolve().parent.parent / "routers" / "site_wizard.py"
|
|
body = src.read_text()
|
|
assert "_synthesize_candidate_haproxy_config" in body, (
|
|
"Phase K Phase C regression: routers/site_wizard.py no "
|
|
"longer defines `_synthesize_candidate_haproxy_config`."
|
|
)
|
|
assert body.count("_synthesize_candidate_haproxy_config(") >= 3, (
|
|
"Phase K Phase C regression: the helper is no longer "
|
|
"called from BOTH create_site (entities_already_inserted=True) "
|
|
"and preview_create (entities_already_inserted=False). "
|
|
"Without two callsites the dry-run gate and the apply gate "
|
|
"can desync silently."
|
|
)
|
|
assert "entities_already_inserted=True" in body, (
|
|
"Phase K Phase C regression: create_site no longer passes "
|
|
"entities_already_inserted=True. Without it the helper "
|
|
"would re-render the candidate fragment on top of the "
|
|
"post-insert config, producing duplicate frontend / "
|
|
"backend sections in the persisted version."
|
|
)
|
|
assert "entities_already_inserted=False" in body, (
|
|
"Phase K Phase C regression: the preview dry-run no longer "
|
|
"passes entities_already_inserted=False. Without the "
|
|
"candidate-fragment append, the validator would only see "
|
|
"the cluster's CURRENT config and miss every wizard-input "
|
|
"induced error."
|
|
)
|
|
|
|
|
|
def test_phase_k_synthesis_helper_acme_mode_omits_https_frontend():
|
|
"""Phase K Phase C — when `ssl.mode='acme'` the synthesizer
|
|
must NOT emit a `bind … ssl crt …` directive in the candidate
|
|
fragment. ACME's HTTPS frontend is created post-completion
|
|
inside the LE callback (see `routers/letsencrypt.py:1012-1014`)
|
|
— surfacing a synthetic HTTPS bind here would produce false-
|
|
positive errors about a missing crt path.
|
|
"""
|
|
from routers.site_wizard import _build_candidate_fragment
|
|
acme_payload = _site_payload(
|
|
ssl={"mode": "acme"},
|
|
apply_immediately=True,
|
|
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
|
|
)
|
|
fragment = _build_candidate_fragment(acme_payload)
|
|
assert "frontend fe" in fragment, "HTTP frontend must still render in ACME mode"
|
|
assert "backend be" in fragment, "backend must still render in ACME mode"
|
|
assert "ssl crt" not in fragment, (
|
|
"Phase K Phase C regression: ACME mode synthesizer is now "
|
|
"emitting `ssl crt …` for an HTTPS frontend that does not "
|
|
"exist yet — this would surface a false positive error in "
|
|
"the dry-run gate when the operator is on Step 4 with a "
|
|
"valid ACME draft."
|
|
)
|
|
|
|
upload_payload = _site_payload(
|
|
ssl={
|
|
"mode": "upload",
|
|
"name": "test-cert",
|
|
"certificate_content": "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----",
|
|
"private_key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
|
|
},
|
|
)
|
|
upload_fragment = _build_candidate_fragment(upload_payload)
|
|
assert "ssl crt" in upload_fragment, (
|
|
"Phase K Phase C regression: upload mode no longer renders "
|
|
"a `bind … ssl crt …` directive — the dry-run would miss "
|
|
"every TLS-bind related error class."
|
|
)
|
|
|
|
|
|
def test_phase_k_preview_dry_run_query_param_is_optional():
|
|
"""Phase K Phase C — the `validate_haproxy_config` flag must be
|
|
OPTIONAL on `POST /api/sites/preview` (default false). Existing
|
|
callers (e.g. `SiteDrafts.handlePreview`) pass no flag and
|
|
must keep their existing behaviour. Static-source pin.
|
|
"""
|
|
from pathlib import Path
|
|
src = Path(__file__).resolve().parent.parent / "routers" / "site_wizard.py"
|
|
body = src.read_text()
|
|
assert "validate_haproxy_config: bool = False" in body, (
|
|
"Phase K Phase C regression: the preview endpoint no "
|
|
"longer accepts `validate_haproxy_config` as an OPTIONAL "
|
|
"flag (default False). A non-default would break legacy "
|
|
"callers that never pass it."
|
|
)
|
|
|
|
|
|
def test_phase_k_preview_dry_run_validator_crash_is_non_fatal():
|
|
"""Phase K Phase C — when the validator itself raises, the
|
|
preview must return a `validation` block with `is_valid: null`
|
|
+ `validator_error`. HTTP status stays 200. Mirrors the
|
|
existing create_site contract at
|
|
`routers/site_wizard.py:1118-1124` (validator-crash-is-non-fatal).
|
|
"""
|
|
from pathlib import Path
|
|
src = Path(__file__).resolve().parent.parent / "routers" / "site_wizard.py"
|
|
body = src.read_text()
|
|
# The crash branch must (a) emit a logger.warning, (b) wrap the
|
|
# response with is_valid=None, (c) NEVER raise an HTTPException.
|
|
assert '"is_valid": None' in body, (
|
|
"Phase K Phase C regression: the dry-run validator-crash "
|
|
"branch no longer returns `is_valid: None`. The frontend "
|
|
"uses None as a tri-state to render the `unavailable` UX "
|
|
"(orange 'validation could not be performed' Alert)."
|
|
)
|
|
assert "validator_error" in body, (
|
|
"Phase K Phase C regression: the dry-run validator-crash "
|
|
"branch no longer surfaces `validator_error` to the "
|
|
"operator — without it operators cannot tell why the "
|
|
"validation became unavailable."
|
|
)
|
|
|
|
|
|
def test_phase_k_preview_dry_run_rate_limit_only_on_dry_run_path():
|
|
"""Phase K Phase C — `_enforce_rate_limit` must run ONLY when
|
|
`validate_haproxy_config=true`. Legacy preview callers
|
|
(`SiteDrafts.handlePreview`) keep their unrestricted budget.
|
|
Static-source pin.
|
|
"""
|
|
from pathlib import Path
|
|
src = Path(__file__).resolve().parent.parent / "routers" / "site_wizard.py"
|
|
body = src.read_text()
|
|
# Find the preview_create function body — Header(None) has nested
|
|
# parens in the signature so we anchor on the def line and the
|
|
# next `async def `/`def `/`@router.` instead of a parenthesised
|
|
# signature regex.
|
|
start = body.find("async def preview_create(")
|
|
assert start >= 0, "preview_create function not found"
|
|
# End at the next top-level def or router decorator after `start`.
|
|
next_async = body.find("\nasync def ", start + 1)
|
|
next_def = body.find("\ndef ", start + 1)
|
|
next_router = body.find("\n@router.", start + 1)
|
|
candidates = [x for x in (next_async, next_def, next_router) if x >= 0]
|
|
end = min(candidates) if candidates else len(body)
|
|
fn_body = body[start:end]
|
|
assert "if validate_haproxy_config:" in fn_body, (
|
|
"Phase K Phase C regression: the rate-limit path is no "
|
|
"longer gated by the dry-run flag — every legacy preview "
|
|
"caller would be rate-limited too, breaking SiteDrafts."
|
|
)
|
|
assert '_enforce_rate_limit(conn, current_user["id"], "site_previewed")' in fn_body, (
|
|
"Phase K Phase C regression: the dry-run preview path no "
|
|
"longer rate-limits at 5/min. The Step 4 auto-fire could "
|
|
"spam the validator on every keystroke if the wizard's "
|
|
"invalidate-on-change effect ever loops."
|
|
)
|
|
|
|
|
|
def test_phase_k_preview_dry_run_returns_severity_buckets():
|
|
"""Phase K Phase C — the dry-run path must bucket validator
|
|
results by severity (errors / warnings / infos). Static-source
|
|
pin on the `validation` envelope shape.
|
|
"""
|
|
from pathlib import Path
|
|
src = Path(__file__).resolve().parent.parent / "routers" / "site_wizard.py"
|
|
body = src.read_text()
|
|
for key in ('"errors"', '"warnings"', '"infos"', '"is_valid"',
|
|
'"error_count"', '"warning_count"'):
|
|
assert key in body, (
|
|
f"Phase K Phase C regression: the dry-run `validation` "
|
|
f"envelope no longer surfaces {key} — the wizard "
|
|
"frontend's severity-aware UI relies on this exact "
|
|
"shape to render the green / yellow / red Alerts."
|
|
)
|
|
|
|
|
|
def test_phase_k_preview_dry_run_does_not_persist_via_helper():
|
|
"""Phase K Phase C — `_synthesize_candidate_haproxy_config`
|
|
only calls the read-only `generate_haproxy_config_for_cluster`
|
|
plus a pure-string `_build_candidate_fragment`. Static-source
|
|
pin guards against a refactor that calls a `create_*_row`
|
|
helper from inside the preview branch.
|
|
"""
|
|
from pathlib import Path
|
|
src = Path(__file__).resolve().parent.parent / "routers" / "site_wizard.py"
|
|
body = src.read_text()
|
|
start = body.find("async def _synthesize_candidate_haproxy_config(")
|
|
assert start >= 0, "_synthesize_candidate_haproxy_config function body not found"
|
|
next_async = body.find("\nasync def ", start + 1)
|
|
next_def = body.find("\ndef ", start + 1)
|
|
next_router = body.find("\n@router.", start + 1)
|
|
candidates = [x for x in (next_async, next_def, next_router) if x >= 0]
|
|
end = min(candidates) if candidates else len(body)
|
|
fn_body = body[start:end]
|
|
for forbidden in (
|
|
"create_backend_row(",
|
|
"create_server_row(",
|
|
"create_frontend_row(",
|
|
"create_cert_row(",
|
|
):
|
|
assert forbidden not in fn_body, (
|
|
"Phase K Phase C regression: the synthesizer now "
|
|
f"calls `{forbidden}…` — the dry-run helper must "
|
|
"stay write-free; persistence belongs in `create_site`."
|
|
)
|
|
|
|
|
|
def test_phase_k_preview_dry_run_telemetry_emits_log_lines():
|
|
"""Phase K Phase C — each dry-run must emit two structured log
|
|
lines (ENTER + EXIT) so operators can correlate "Create button
|
|
is disabled" with backend telemetry. Static-source pin.
|
|
"""
|
|
from pathlib import Path
|
|
src = Path(__file__).resolve().parent.parent / "routers" / "site_wizard.py"
|
|
body = src.read_text()
|
|
assert "WIZARD: dry-run /preview ENTER" in body, (
|
|
"Phase K Phase C regression: the dry-run ENTER log line is "
|
|
"no longer emitted. Operators debugging \"why is Create "
|
|
"disabled\" rely on this line to confirm the request "
|
|
"reached the backend."
|
|
)
|
|
assert "WIZARD: dry-run /preview EXIT" in body, (
|
|
"Phase K Phase C regression: the dry-run EXIT log line is "
|
|
"no longer emitted with error_count / warning_count / "
|
|
"duration_ms. Operators rely on this to correlate slow "
|
|
"validations with cluster-side issues."
|
|
)
|
|
|
|
|
|
def test_phase_k_phase_d_sitedrafts_renders_cluster_name_not_raw_id():
|
|
"""Phase K Phase D follow-up (operator feedback, round 4) —
|
|
the Site Drafts table previously rendered the raw `cluster_id`
|
|
integer in its "Cluster" column. Operators think in cluster
|
|
names, not surrogate keys: "1" or "2" is meaningless without a
|
|
legend mapping. The page now consumes `useCluster()` and renders
|
|
the cluster's `name`, falling back to a `#id` tag with an
|
|
explanatory tooltip when the cluster is missing from the
|
|
context (deleted / list still loading).
|
|
|
|
Static-source pin so a future refactor that switches back to
|
|
`String(cid)` is caught immediately. Pin is skipped in the
|
|
backend-only build container where `frontend/` is not shipped
|
|
(matches the convention used by sibling tests, see e.g.
|
|
`test_site_wizard_form.py`).
|
|
"""
|
|
from pathlib import Path
|
|
src = (
|
|
Path(__file__).resolve().parent.parent.parent
|
|
/ "frontend"
|
|
/ "src"
|
|
/ "components"
|
|
/ "SiteDrafts.js"
|
|
)
|
|
if not src.exists():
|
|
pytest.skip(
|
|
f"frontend not present at {src}; running in backend-only "
|
|
"container is expected — skip JS source pin"
|
|
)
|
|
body = src.read_text()
|
|
|
|
# The drafts page must import `useCluster` so the column renderer
|
|
# has a cluster list to resolve names against.
|
|
assert "useCluster" in body, (
|
|
"SiteDrafts must import `useCluster` to translate cluster_id "
|
|
"values into cluster names."
|
|
)
|
|
|
|
# The helper that translates id → display label must exist and
|
|
# be reused by both the table column AND the preview modal so the
|
|
# two stay in sync.
|
|
assert "renderClusterLabel" in body, (
|
|
"SiteDrafts must define a `renderClusterLabel` helper that "
|
|
"maps cluster_id → cluster.name (with id fallback) and is "
|
|
"shared by the table column and the Preview modal."
|
|
)
|
|
|
|
# The previous broken rendering branch must be gone.
|
|
assert "String(cid)" not in body, (
|
|
"SiteDrafts regression: the Cluster column is back to "
|
|
"rendering the raw `String(cid)` integer. Use "
|
|
"`renderClusterLabel(cid)` instead."
|
|
)
|
|
|
|
# The Preview modal's Descriptions item must use the new helper
|
|
# and the human-friendly label, not "Cluster ID".
|
|
assert 'label="Cluster ID"' not in body, (
|
|
"SiteDrafts Preview modal regression: the cluster row is "
|
|
"back to the operator-hostile `label=\"Cluster ID\"` and "
|
|
"raw integer rendering."
|
|
)
|