303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""Integration: the admin git-sources CRUD API (phase 35, task 02).
|
|
|
|
Real Postgres (``podman compose up -d db``); the ``BOR_GIT_SOURCES``
|
|
fallback is exercised deterministically by monkeypatching the router's
|
|
``get_settings`` with a fresh ``Settings(_env_file=None, git_sources=…)``
|
|
(same pattern as ``test_sync_api.py`` — the dev ``.env`` never leaks in).
|
|
|
|
Contract under test:
|
|
|
|
* anonymous → 403 ``{"detail": "admin only"}`` on GET, POST, and DELETE
|
|
(phase 16 pattern, same as ``/api/sync``);
|
|
* GET — empty table + env set → the env rows with ``from_env: true`` and
|
|
null ``id``/``added_at``; empty table + empty env → ``sources: []``
|
|
with ``from_env: true``; any DB rows → ``from_env: false`` and the env
|
|
var is ignored (the phase's locked decision); DB rows ordered by
|
|
``(added_at, id)``;
|
|
* POST — 201 stored trimmed; duplicate (even with different surrounding
|
|
whitespace) → 409 with a generic detail that never echoes the URL
|
|
(credential safety), including when only the DB unique index catches
|
|
it; bad shape / blank / >500 chars → 422, also input-free;
|
|
* DELETE — 204 and gone; an emptied table falls back to the env list
|
|
again; unknown id → 404.
|
|
|
|
``git_sources`` is global state: truncated around every test.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select, text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api import git_sources as git_sources_api
|
|
from app.config import Settings
|
|
from app.models import GitSource
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_git_sources(db: Session) -> Iterator[None]:
|
|
"""The stored list is global state: reset around every test."""
|
|
db.execute(text("TRUNCATE git_sources"))
|
|
db.commit()
|
|
yield
|
|
db.execute(text("TRUNCATE git_sources"))
|
|
db.commit()
|
|
|
|
|
|
def _settings(git_sources: str = "") -> Settings:
|
|
"""Fresh settings with the ``.env`` file ignored; the explicit kwarg
|
|
beats any process env leaks (test_sync_api pattern)."""
|
|
return Settings(_env_file=None, git_sources=git_sources) # pyright: ignore[reportCallIssue]
|
|
|
|
|
|
# --- anonymous -------------------------------------------------------------
|
|
|
|
|
|
def test_anonymous_gets_403_on_all_routes(client: TestClient, db: Session) -> None:
|
|
r = client.get("/api/git-sources")
|
|
assert r.status_code == 403
|
|
assert r.json() == {"detail": "admin only"}
|
|
r = client.post("/api/git-sources", json={"url": "https://anon.example.com/x.git"})
|
|
assert r.status_code == 403
|
|
assert r.json() == {"detail": "admin only"}
|
|
r = client.delete(f"/api/git-sources/{uuid.uuid4()}")
|
|
assert r.status_code == 403
|
|
assert r.json() == {"detail": "admin only"}
|
|
# Nothing landed in the table.
|
|
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
|
|
|
|
|
# --- GET: env fallback -----------------------------------------------------
|
|
|
|
|
|
def test_get_empty_table_with_env_returns_env_rows(
|
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
git_sources_api,
|
|
"get_settings",
|
|
lambda: _settings("https://a.example.com/one.git, git@b.example.com:two.git ,"),
|
|
)
|
|
|
|
r = admin_client.get("/api/git-sources")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["from_env"] is True
|
|
# Whitespace-trimmed, empty entries dropped, order preserved; null ids.
|
|
assert body["sources"] == [
|
|
{"id": None, "url": "https://a.example.com/one.git", "added_at": None},
|
|
{"id": None, "url": "git@b.example.com:two.git", "added_at": None},
|
|
]
|
|
|
|
|
|
def test_get_empty_table_with_empty_env_returns_empty_list(
|
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(git_sources_api, "get_settings", lambda: _settings())
|
|
|
|
r = admin_client.get("/api/git-sources")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"sources": [], "from_env": True}
|
|
|
|
|
|
def test_get_orders_db_rows_by_added_at_then_id(admin_client: TestClient, db: Session) -> None:
|
|
base = datetime.now(UTC) - timedelta(hours=3)
|
|
db.add_all(
|
|
[
|
|
GitSource(url="https://example.com/oldest.git", added_at=base),
|
|
GitSource(url="https://example.com/second.git", added_at=base + timedelta(hours=1)),
|
|
GitSource(url="https://example.com/newest.git", added_at=base + timedelta(hours=2)),
|
|
# Same transaction → identical server-stamped added_at (≈ now,
|
|
# after the explicit rows): the id tie-break decides their order.
|
|
GitSource(url="https://example.com/tie-a.git"),
|
|
GitSource(url="https://example.com/tie-b.git"),
|
|
]
|
|
)
|
|
db.commit()
|
|
tie_rows = db.scalars(
|
|
select(GitSource).where(GitSource.url.like("https://example.com/tie-%"))
|
|
).all()
|
|
tie_ids = sorted(row.id for row in tie_rows)
|
|
|
|
body = admin_client.get("/api/git-sources").json()
|
|
assert body["from_env"] is False
|
|
assert [s["url"] for s in body["sources"][:3]] == [
|
|
"https://example.com/oldest.git",
|
|
"https://example.com/second.git",
|
|
"https://example.com/newest.git",
|
|
]
|
|
assert [s["id"] for s in body["sources"][3:]] == [str(i) for i in tie_ids]
|
|
# DB rows carry real ids + timestamps (the env shape has neither).
|
|
for s in body["sources"]:
|
|
assert s["id"] is not None
|
|
assert s["added_at"] is not None
|
|
|
|
|
|
# --- POST: create ----------------------------------------------------------
|
|
|
|
|
|
def test_post_creates_trimmed_and_list_stops_using_env(
|
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
git_sources_api,
|
|
"get_settings",
|
|
lambda: _settings("https://env.example.com/env.git"),
|
|
)
|
|
|
|
r = admin_client.post("/api/git-sources", json={"url": " https://new.example.com/repo.git "})
|
|
assert r.status_code == 201
|
|
body = r.json()
|
|
assert body["url"] == "https://new.example.com/repo.git" # trimmed
|
|
uuid.UUID(body["id"])
|
|
assert body["added_at"] is not None
|
|
assert set(body) == {"id", "url", "added_at"}
|
|
|
|
# The DB row now wins: from_env False, the env URL is gone from the list.
|
|
body = admin_client.get("/api/git-sources").json()
|
|
assert body["from_env"] is False
|
|
assert [s["url"] for s in body["sources"]] == ["https://new.example.com/repo.git"]
|
|
|
|
|
|
def test_post_accepts_accepted_url_shapes(admin_client: TestClient) -> None:
|
|
for url in (
|
|
"https://github.com/owner/repo.git",
|
|
"http://git.local/repo.git",
|
|
"ssh://git@example.com/repo.git",
|
|
"git@github.com:owner/repo.git",
|
|
):
|
|
r = admin_client.post("/api/git-sources", json={"url": url})
|
|
assert r.status_code == 201, f"{url} must be accepted: {r.text}"
|
|
assert r.json()["url"] == url
|
|
|
|
|
|
def test_post_duplicate_url_returns_409_without_echoing_url(
|
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The 409 detail is a fixed generic string — URLs may embed
|
|
``user:pass@`` credentials (phase 32's masking discipline)."""
|
|
monkeypatch.setattr(git_sources_api, "get_settings", lambda: _settings())
|
|
url = "https://user:secret@example.com/creds.git"
|
|
|
|
assert admin_client.post("/api/git-sources", json={"url": url}).status_code == 201
|
|
# Same URL with different surrounding whitespace: the trim makes it a
|
|
# duplicate too.
|
|
r = admin_client.post("/api/git-sources", json={"url": f" {url}\t"})
|
|
assert r.status_code == 409
|
|
detail = r.json()["detail"]
|
|
assert detail == "a git source with this URL already exists"
|
|
assert url not in detail
|
|
assert "user:secret" not in detail
|
|
# Exactly one row stored.
|
|
assert len(admin_client.get("/api/git-sources").json()["sources"]) == 1
|
|
|
|
|
|
def test_post_concurrent_insert_backstop_still_409(
|
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""If the duplicate pre-check misses (a concurrent insert lands
|
|
between the check and the commit), the DB unique index still yields
|
|
the generic 409 — never a 500."""
|
|
monkeypatch.setattr(git_sources_api, "get_settings", lambda: _settings())
|
|
url = "https://example.com/backstop.git"
|
|
assert admin_client.post("/api/git-sources", json={"url": url}).status_code == 201
|
|
|
|
real_select = git_sources_api.select
|
|
|
|
def blind_select(*args: Any, **kwargs: Any) -> Any:
|
|
if args and args[0] is GitSource: # the duplicate pre-check
|
|
# …now never matches — only the unique index can catch it.
|
|
return real_select(GitSource).where(GitSource.url == "zz-never-matches")
|
|
return real_select(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(git_sources_api, "select", blind_select)
|
|
r = admin_client.post("/api/git-sources", json={"url": url})
|
|
assert r.status_code == 409
|
|
detail = r.json()["detail"]
|
|
assert detail == "a git source with this URL already exists"
|
|
assert url not in detail
|
|
|
|
|
|
def test_post_rejects_invalid_shapes_without_echoing_input(
|
|
admin_client: TestClient, db: Session
|
|
) -> None:
|
|
"""Bad shapes are 422 with a detail that never repeats the submitted
|
|
value."""
|
|
for bad in ("not a url", "host:repo", "ftp://example.com/x.git"):
|
|
r = admin_client.post("/api/git-sources", json={"url": bad})
|
|
assert r.status_code == 422, f"{bad!r} must be rejected"
|
|
assert bad not in r.text, f"422 detail must not echo the input ({bad!r})"
|
|
# Nothing stored.
|
|
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
|
|
|
|
|
def test_post_rejects_blank_and_oversized_urls(admin_client: TestClient, db: Session) -> None:
|
|
"""Whitespace-only (trim → empty) and >500-char URLs are 422; the
|
|
500-char boundary passes."""
|
|
assert admin_client.post("/api/git-sources", json={"url": " \t\n "}).status_code == 422
|
|
assert admin_client.post("/api/git-sources", json={"url": "x" * 501}).status_code == 422
|
|
boundary = "https://" + "x" * 492 # exactly 500 chars
|
|
assert len(boundary) == 500
|
|
assert admin_client.post("/api/git-sources", json={"url": boundary}).status_code == 201
|
|
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 1
|
|
|
|
|
|
# --- DB rows win over env ---------------------------------------------------
|
|
|
|
|
|
def test_db_rows_win_over_env(admin_client: TestClient, db: Session, monkeypatch) -> None:
|
|
"""Seed a row AND set the env: the GET returns only the DB rows and
|
|
``from_env: false`` — the env var is ignored once the table has rows."""
|
|
monkeypatch.setattr(
|
|
git_sources_api, "get_settings", lambda: _settings("https://env.example.com/env.git")
|
|
)
|
|
db.add(GitSource(url="https://db.example.com/db.git"))
|
|
db.commit()
|
|
|
|
body = admin_client.get("/api/git-sources").json()
|
|
assert body["from_env"] is False
|
|
assert [s["url"] for s in body["sources"]] == ["https://db.example.com/db.git"]
|
|
for s in body["sources"]:
|
|
assert s["id"] is not None
|
|
assert s["added_at"] is not None
|
|
|
|
|
|
# --- DELETE -----------------------------------------------------------------
|
|
|
|
|
|
def test_delete_removes_row_and_falls_back_to_env(
|
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
git_sources_api,
|
|
"get_settings",
|
|
lambda: _settings("https://env.example.com/env.git"),
|
|
)
|
|
created = admin_client.post("/api/git-sources", json={"url": "https://new.example.com/x.git"})
|
|
assert created.status_code == 201
|
|
|
|
assert admin_client.delete(f"/api/git-sources/{created.json()['id']}").status_code == 204
|
|
|
|
# The table is empty again → the env fallback is live once more.
|
|
body = admin_client.get("/api/git-sources").json()
|
|
assert body["from_env"] is True
|
|
assert body["sources"] == [
|
|
{"id": None, "url": "https://env.example.com/env.git", "added_at": None}
|
|
]
|
|
|
|
|
|
def test_delete_unknown_id_returns_404(admin_client: TestClient) -> None:
|
|
r = admin_client.delete(f"/api/git-sources/{uuid.uuid4()}")
|
|
assert r.status_code == 404
|
|
assert r.json() == {"detail": "git source not found"}
|
|
|
|
|
|
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
|
|
assert admin_client.delete("/api/git-sources/not-a-uuid").status_code == 422
|