"""Unit: the branch-name validator (phase 135, task 02). ``validate_branch_name`` is the single choke point for every branch value that enters the API (POST create + PATCH set/change legs — ASSUMPTION B2). The contract, checked in the documented order (trim → empty → length → charset → ``..``/``//`` → leading → trailing → HEAD): * TRIM first — the returned name is the trimmed input, so a schema-trimmed value is a fixed point; * empty (or whitespace-only) → ``ValueError``; * > 200 chars → ``ValueError`` (exactly 200 passes — the schema's ``max_length=200`` is the first line, the validator the choke point a future caller must not bypass); * outside the safe charset ``[A-Za-z0-9._/-]+`` → ``ValueError`` (whitespace, ``@``, ``~``, ``^``, ``:``, ``?``, ``*``, ``[``, backslash — all out); * contains ``..`` or ``//`` → ``ValueError`` (path-traversal-shaped names, even though the charset would allow the pieces); * leading ``-`` / ``.`` / ``/`` → ``ValueError``; * trailing ``/`` or ``.`` → ``ValueError``; * ``HEAD`` (any case) → ``ValueError`` (git's refname — a branch literally named ``head`` is a documented cost of the simple rule). The raised ``ValueError`` messages are FIXED reason strings — the API maps them to the fixed-detail 422 and the raw input is never echoed (the house credential-safety discipline, applied for consistency): the tests pin that no message repeats the rejected input. """ from __future__ import annotations import pytest from app.rag.git_sources import validate_branch_name # --------------------------------------------------------------------------- # Accept # --------------------------------------------------------------------------- @pytest.mark.parametrize( "raw", [ "main", "release/1.2", "feature_x", "a.b/c", "v2-rc.1", ], ) def test_valid_names_pass_through_trimmed(raw: str) -> None: """The accepted shapes — plain, slashed, dotted, dashed, underscored — return exactly the trimmed input.""" assert validate_branch_name(raw) == raw def test_trim_applied_before_validation_and_returned() -> None: """Surrounding whitespace is stripped: the returned value is the trimmed name (``" main "`` → ``main``).""" assert validate_branch_name(" main ") == "main" assert validate_branch_name("\trelease/1.2\n") == "release/1.2" def test_exactly_200_chars_passes() -> None: """The length boundary is > 200: a 200-char name is valid (the charset allows it — a long but well-formed branch path).""" name = "a" * 196 + "/x.1" assert len(name) == 200 assert validate_branch_name(name) == name # --------------------------------------------------------------------------- # Reject # --------------------------------------------------------------------------- @pytest.mark.parametrize( "raw", [ "", " ", "a b", # whitespace inside "a@b", "a~b", "a:b", "a?b", "a*b", "a[b", "a\\b", "a^b", ], ) def test_empty_and_charset_violations_rejected(raw: str) -> None: """Empty/whitespace-only and non-charset characters all raise.""" with pytest.raises(ValueError): validate_branch_name(raw) def test_201_chars_rejected() -> None: """One past the length limit raises (even though the charset allows every character).""" with pytest.raises(ValueError): validate_branch_name("a" * 201) @pytest.mark.parametrize("raw", ["a..b", "a//b", "..", "//"]) def test_path_sequences_rejected(raw: str) -> None: """``..`` and ``//`` are disallowed — traversal-shaped names, including the two-character bare forms.""" with pytest.raises(ValueError): validate_branch_name(raw) @pytest.mark.parametrize("raw", ["-x", ".x", "/x"]) def test_leading_characters_rejected(raw: str) -> None: """A leading ``-`` (git would read it as an option), ``.``, or ``/`` raises.""" with pytest.raises(ValueError): validate_branch_name(raw) @pytest.mark.parametrize("raw", ["x/", "x."]) def test_trailing_characters_rejected(raw: str) -> None: """A trailing ``/`` or ``.`` raises.""" with pytest.raises(ValueError): validate_branch_name(raw) @pytest.mark.parametrize("raw", ["HEAD", "head", "HeAd", " hEaD "]) def test_head_rejected_case_insensitively(raw: str) -> None: """``HEAD`` in any case is git's refname — rejected (the trim runs first, so even `` hEaD `` is the reserved name).""" with pytest.raises(ValueError): validate_branch_name(raw) def test_reject_messages_are_fixed_and_input_free() -> None: """The ``ValueError`` reasons are fixed strings — none of them echoes the rejected input (the API maps to the fixed-detail 422 on the same discipline; a secret-ish name typed into the field must not surface in a log line either).""" for raw in ("a b", "a..b", "x/", "HEAD", "a" * 201, ""): try: validate_branch_name(raw) except ValueError as e: if raw: assert raw not in str(e) else: pytest.fail(f"{raw!r} must be rejected")