All verification complete. No edits were needed by this pass — the phase work and docs were already consistent and green. **Phase 135 final verification pass — report** - Verified all 5 tasks' work present & correct: migration 0024 (reversible), `GitSource.branch` + 4 schema shapes, `validate_branch_name` (full check order per design), `clone_or_pull(…, branch)` (HEAD guard first, `--branch` clone, fetch+ff merge), both call sites pass `row.branch`, UI (add-form field, editor field, row tag), `file://` URL transport. - Completion criteria: (1) branch-mirrored through Sync + CLI — **pass** (E2E + integration); (2) `branch=None` byte-identical — **pass** (`test_branch_none_argv_is_byte_identical_pre_phase_sequence` green); (3) fixed 422s / local-row 422 / tri-state — **pass**; (4) mismatch fails loudly with remove-and-re-add — **pass** (E2E); (5) coverage 99% >90%, E2E isolated green — **pass**; (6) no regressions — **pass**. - Tests: `uv run pytest --cov=app --cov-report=term-missing` → 3223 passed, app/ 99%; `uv run pytest tests/e2e/test_git_source_branch.py -v --no-cov` → 4 passed (16.4 s); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings. - Documentation: README.md reviewed in full — phase-135 branch section accurate; the phase's README diff also correctly fixed a pre-existing stale line (shallow `--depth 1` clone vs full-history, phase 107). Routes, env defaults, feature lists verified against code. AGENTS.md — all 10 rules + quick-reference verified (referenced tests, PLAN.md, phases dirs, commands all accurate). **Verified consistent — no changes needed.** - No live-infrastructure changes (dev DB already running and healthy; no live steps in this phase). - Next pending phase: `.agents/phases/todo/136_force_push_reset`.
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
"""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")
|