"""Tests for ``_validate_subdomain`` — the self-service DNS request hostname validator. Why this exists (2026-05-29 colleague feedback): the prior regex required every label to start with ``[a-zA-Z0-9]``, which rejected underscore-led labels ENTIRELY. That meant the per-label denylist below it (``_RESERVED_UNDERSCORE_LABELS``: ``_dmarc`` / ``_dkim`` / ``_acme-challenge`` / …) never actually executed — the regex rejected every underscore-led label before the denylist could inspect it. But every major cloud DNS provider (Aliyun, AWS Route 53, Cloudflare, GCP Cloud DNS) accepts underscore-led labels — they're the standard convention for service-discovery records (SRV, DKIM, DMARC) AND for domain-ownership verification (``_dnsauth`` on Aliyun, ``_-domain-verification`` etc). Rejecting at the regex layer diverged Helix's policy from what the cloud actually accepts. These tests pin down: * Underscore-led labels accepted at regex layer. * Reserved labels (``_dmarc`` / ``_acme-challenge`` / …) still rejected by the denylist after they pass the regex. * Hyphen still rejected at label first/last position (RFC 1035 — Aliyun rejects too). * Wildcard ``*`` still rejected for self-service (admin direct-write path bypasses this validator). """ from __future__ import annotations import uuid import pytest from app.core.exceptions import ValidationError from app.domains.cmdb.dns_request_service import ( _validate_subdomain, _validate_value_for_type, ) from app.domains.cmdb.schemas import ( DnsRecordCreate, DnsRecordRequestCreate, _validate_dns_value, ) # ── Underscore-led labels that should be ACCEPTED ──────────────── @pytest.mark.parametrize( "name", [ # Aliyun's domain-ownership verification TXT label — the case # that motivated this fix. "_dnsauth", # Underscore in middle of a label (already worked, regression # guard). "abc_def", # Underscore as label trailing char (per-label edge). "abc_", # Multi-label with underscore-led intermediate label. "foo._bar", # Multi-level underscore-led labels (SRV-style without the # reserved tokens). "_x._y._z", # Apex shorthand. "@", ], ) def test_validate_subdomain_accepts(name: str) -> None: _validate_subdomain(name) # ── Labels REJECTED by the regex (still illegal char/position) ─── @pytest.mark.parametrize( "name", [ # Leading hyphen — RFC 1035, also Aliyun rejects. "-bad", # Trailing hyphen. "bad-", # Whitespace. "with space", # Forward slash — outside the label charset. "a/b", # Empty string — must not pass. "", ], ) def test_validate_subdomain_rejects_illegal_chars(name: str) -> None: with pytest.raises(ValidationError, match="illegal characters"): _validate_subdomain(name) # ── Reserved underscore labels — accepted by regex, REJECTED by # the denylist. This is the security-relevant path: the prior # regex rejected these before the denylist ran, so the denylist # was dead code. Now the denylist actually does its job. @pytest.mark.parametrize( "name", [ # Bare reserved labels. "_dmarc", "_acme-challenge", "_dkim", "_domainkey", "_spf", "_mta-sts", # Reserved label as part of a multi-label hostname — denylist # checks every label, not just the first. "foo._dmarc", "selector1._domainkey", "www._acme-challenge", ], ) def test_validate_subdomain_rejects_reserved_underscore_labels(name: str) -> None: with pytest.raises( ValidationError, match="reserved for email-authentication", ): _validate_subdomain(name) # ── Wildcard — admin direct-write only ────────────────────────── def test_validate_subdomain_rejects_wildcard() -> None: with pytest.raises(ValidationError, match="Wildcard"): _validate_subdomain("*.foo") with pytest.raises(ValidationError, match="Wildcard"): _validate_subdomain("*") # ── CNAME / NS target value — underscore-led targets accepted ──── # # Cloud-issued validation CNAMEs point at underscore-led hostnames. # AWS ACM DNS validation is the motivating case: both the record name # AND the target start with ``_`` (the target looks like # ``_b69bdcc...jkddzztszm.acm-validations.aws.``). The prior # ``_HOSTNAME_RE`` required every label to start with ``[a-zA-Z0-9]``, # so it rejected the target value and the request never reached an # approver. The target is where the record points (not a name being # created), so the reserved-underscore denylist doesn't apply. @pytest.mark.parametrize( "value", [ # AWS ACM DNS validation target — the motivating case. "_b69bdcc9f178dc51e409e8f94e275894.jkddzztszm.acm-validations.aws.", # Plain CNAME target (regression guard). "target.example.com", # Trailing-dot canonical form. "target.example.com.", ], ) def test_validate_cname_value_accepts_underscore_targets(value: str) -> None: _validate_value_for_type("CNAME", value) # NS targets share the same hostname shape. _validate_value_for_type("NS", value) @pytest.mark.parametrize( "value", [ # An IP is not a hostname. "1.2.3.4", # Whitespace. "not a host", # Single bare label — no TLD. "foo", ], ) def test_validate_cname_value_rejects_non_hostnames(value: str) -> None: with pytest.raises(ValidationError, match="must be a hostname"): _validate_value_for_type("CNAME", value) # ── Schema-layer FQDN validator — underscore-led CNAME targets ──── # # Regression for the real submit failure (2026-06-10): the service-layer # ``_HOSTNAME_RE`` above already accepted underscore-led ACM targets, but # the *schema-layer* validator (``_validate_dns_value`` in # ``app.domains.cmdb.schemas``) runs FIRST at request-parse time and its # ``_FQDN_RE`` still required ``[A-Za-z0-9]``-led labels — so it rejected # the AWS ACM validation target before the request ever reached the # service layer. The two validators must agree. Note the user's actual # value had NO trailing dot, so both forms must pass. @pytest.mark.parametrize( "value", [ # AWS ACM DNS validation target — exact value from the bug report, # no trailing dot. "_b69bdcc9f178dc51e409e8f94e275894.jkddzztszm.acm-validations.aws", # Trailing-dot canonical form. "_b69bdcc9f178dc51e409e8f94e275894.jkddzztszm.acm-validations.aws.", # DKIM / DMARC underscore-led targets. "selector1._domainkey.example.com", # Plain target (regression guard). "target.example.com", ], ) def test_schema_validate_dns_value_accepts_underscore_cname_targets(value: str) -> None: # Both CNAME and NS go through the FQDN regex. assert _validate_dns_value("CNAME", value) == value assert _validate_dns_value("NS", value) == value def test_schema_dns_request_create_accepts_acm_cname() -> None: """End-to-end: the Pydantic model the employee POSTs must accept the ACM validation CNAME without a trailing dot.""" body = DnsRecordRequestCreate( action="create", integration_id=uuid.uuid4(), zone_name="pingcap.com", name="_62eaa2a6ecf7d36d6a9b0d7a0c18381e.ops", record_type="CNAME", value="_b69bdcc9f178dc51e409e8f94e275894.jkddzztszm.acm-validations.aws", ttl=600, purpose="ACM certificate DNS validation", ) assert body.value.startswith("_b69bdcc") # ── Schema-layer name normalization — FQDN paste → relative name ── # # Regression for the 2026-06-10 prod incident: a requester pasted the # full FQDN (``_24adba….ops-dev.pingcap.net``) into the host field. # Providers treat the submitted name as RELATIVE to the zone, so DNSPod # created ``_24adba….ops-dev.pingcap.net.pingcap.net`` — a double- # suffixed record. The request executed "successfully" (status = # executed, no error) while the intended name kept NXDOMAIN-ing. # ``DnsRecordRequestCreate`` must strip a redundant zone suffix at # parse time — single choke point for both the HTTP router and the # MCP tool. def _make_request_body(name: str, zone_name: str = "pingcap.net") -> DnsRecordRequestCreate: return DnsRecordRequestCreate( action="create", integration_id=uuid.uuid4(), zone_name=zone_name, name=name, record_type="CNAME", value="_ccc9ec871be8df445014a5e5d25d87d6.jkddzztszm.acm-validations.aws", ttl=600, purpose="ACM certificate DNS validation", ) @pytest.mark.parametrize( ("pasted", "expected"), [ # The exact incident shape — FQDN pasted including the zone. ( "_24adba82654ba892fb7332c3f7f02f5e.ops-dev.pingcap.net", "_24adba82654ba892fb7332c3f7f02f5e.ops-dev", ), # Trailing-dot canonical form. ("foo.pingcap.net.", "foo"), # Case-insensitive zone match. ("foo.PingCAP.NET", "foo"), # Pasting the already-double-suffixed name still converges. ("foo.pingcap.net.pingcap.net", "foo"), # Bare zone = apex shorthand. ("pingcap.net", "@"), # Already-relative names pass through untouched. ("loop-home", "loop-home"), # Zone-looking suffix that is NOT the zone stays intact. ("foo.pingcap.network", "foo.pingcap.network"), # Zone appearing in the middle is not a suffix — untouched. ("pingcap.net.foo", "pingcap.net.foo"), ], ) def test_schema_dns_request_create_normalizes_fqdn_paste( pasted: str, expected: str, ) -> None: assert _make_request_body(pasted).name == expected def test_schema_admin_dns_record_create_normalizes_fqdn_paste() -> None: """Admin direct-write shares the FQDN-paste hazard with self-service.""" body = DnsRecordCreate( integration_id=uuid.uuid4(), zone_name="pingcap.net", name="foo.ops-dev.pingcap.net", record_type="A", value="10.0.0.1", ) assert body.name == "foo.ops-dev"