From cb864bcac500fea45c8bfb40c33a643b3f248ebd Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 27 Jul 2026 19:56:01 +0200 Subject: [PATCH] test(v2): define secure control-plane contracts --- docs/release/m2-evidence.md | 24 ++++ tests/__init__.py | 1 + tests/conftest.py | 48 ++++++++ tests/contract/__init__.py | 1 + tests/contract/test_api_conventions.py | 150 +++++++++++++++++++++++++ tests/integration/__init__.py | 1 + tests/integration/test_auth.py | 133 ++++++++++++++++++++++ tests/security/__init__.py | 1 + tests/security/test_auth.py | 75 +++++++++++++ tests/security/test_leakage_scan.py | 26 +++++ tests/unit/__init__.py | 1 + tests/unit/test_redaction.py | 38 +++++++ 12 files changed, 499 insertions(+) create mode 100644 docs/release/m2-evidence.md create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/contract/__init__.py create mode 100644 tests/contract/test_api_conventions.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_auth.py create mode 100644 tests/security/__init__.py create mode 100644 tests/security/test_auth.py create mode 100644 tests/security/test_leakage_scan.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_redaction.py diff --git a/docs/release/m2-evidence.md b/docs/release/m2-evidence.md new file mode 100644 index 0000000..045e2fe --- /dev/null +++ b/docs/release/m2-evidence.md @@ -0,0 +1,24 @@ +# M2 TDD Evidence + +## RED — behavioral contracts absent + +Commit command: + +```bash +.venv/bin/python -m pytest tests/unit/test_redaction.py \ + tests/contract/test_api_conventions.py tests/integration/test_auth.py \ + tests/security/test_auth.py tests/security/test_leakage_scan.py -q +``` + +Observed before implementation on 2026-07-27: + +- Exit: `1` +- Result: `4 failed, 13 errors` +- Intended causes: `backup_tool.api`, `backup_tool.security`, and + `tools/leakage_scan.py` did not exist. Tests described setup, authentication, + CSRF, token, secret, audit, pagination, ETag, idempotency, readiness, and + leakage behavior through public interfaces. + +## GREEN + +Pending implementation. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e5556b0 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Backup Tool v2 test suite.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0222057 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import importlib +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +import pytest_asyncio +from alembic import command +from httpx import ASGITransport, AsyncClient + +cli = importlib.import_module("backup_tool.cli") +config = importlib.import_module("backup_tool.config") +Settings = config.Settings + + +def make_settings(tmp_path: Path) -> Any: + key = tmp_path / "master.key" + key.write_bytes(b"m2-test-master-key-material-32-bytes-minimum") + key.chmod(0o600) + data = tmp_path / "data" + repositories = tmp_path / "repositories" + sources = tmp_path / "sources" + restores = tmp_path / "restores" + for path in (data, repositories, sources, restores): + path.mkdir() + return Settings( + data_dir=data, + database_url=f"sqlite+aiosqlite:///{data / 'metadata.db'}", + repository_roots=(repositories,), + local_source_roots=(sources,), + restore_roots=(restores,), + master_key_file=key, + public_base_url="https://testserver", + ) + + +@pytest_asyncio.fixture +async def app_client(tmp_path: Path) -> AsyncIterator[tuple[AsyncClient, Any]]: + settings = make_settings(tmp_path) + command.upgrade(cli.build_alembic_config(settings), "head") + app_module = importlib.import_module("backup_tool.api.app") + app = app_module.create_app(settings) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="https://testserver" + ) as client: + yield client, settings + await app.state.engine.dispose() diff --git a/tests/contract/__init__.py b/tests/contract/__init__.py new file mode 100644 index 0000000..14deaa5 --- /dev/null +++ b/tests/contract/__init__.py @@ -0,0 +1 @@ +"""Contract tests.""" diff --git a/tests/contract/test_api_conventions.py b/tests/contract/test_api_conventions.py new file mode 100644 index 0000000..a40a4e8 --- /dev/null +++ b/tests/contract/test_api_conventions.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import importlib +from datetime import UTC, datetime, timedelta +from uuid import UUID + +import pytest +from httpx import ASGITransport, AsyncClient + +from tests.conftest import make_settings + + +async def setup_admin( + client, username: str = "admin", password: str = "correct horse battery staple" +): + return await client.post("/api/v2/setup", json={"username": username, "password": password}) + + +@pytest.mark.asyncio +async def test_livez_is_public_and_request_ids_are_returned(app_client) -> None: + client, _ = app_client + response = await client.get("/livez") + assert response.status_code == 200 + assert response.json() == {"status": "alive"} + assert UUID(response.headers["X-Request-ID"]).version == 7 + + +@pytest.mark.asyncio +async def test_readyz_rejects_unmigrated_database(tmp_path) -> None: + settings = make_settings(tmp_path) + app = importlib.import_module("backup_tool.api.app").create_app(settings) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="https://testserver" + ) as client: + response = await client.get("/readyz") + await app.state.engine.dispose() + assert response.status_code == 503 + assert response.json()["code"] == "schema_not_current" + + +@pytest.mark.asyncio +async def test_readyz_is_truthful_before_and_after_setup(app_client) -> None: + client, _ = app_client + before = await client.get("/readyz") + assert before.status_code == 503 + assert before.headers["content-type"].startswith("application/problem+json") + assert before.json()["code"] == "setup_required" + + assert (await setup_admin(client)).status_code == 201 + after = await client.get("/readyz") + assert after.status_code == 200 + assert after.json()["status"] == "ready" + + +@pytest.mark.asyncio +async def test_protected_endpoint_uses_rfc9457_problem(app_client) -> None: + client, _ = app_client + response = await client.get("/api/v2/audit") + assert response.status_code == 401 + body = response.json() + assert response.headers["content-type"].startswith("application/problem+json") + assert {"type", "title", "status", "detail", "instance", "code"} <= body.keys() + assert body["code"] == "authentication_required" + + +@pytest.mark.asyncio +async def test_cursor_pagination_is_stable(app_client) -> None: + client, _ = app_client + assert (await setup_admin(client)).status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + headers = {"X-CSRF-Token": csrf} + for purpose in ("one", "two", "three"): + response = await client.post( + "/api/v2/admin/secrets", + json={"purpose": purpose, "value": f"secret-{purpose}"}, + headers=headers, + ) + assert response.status_code == 201 + + first = await client.get("/api/v2/audit", params={"limit": 2}) + assert first.status_code == 200 + first_body = first.json() + assert len(first_body["items"]) == 2 + assert first_body["next_cursor"] + + second = await client.get( + "/api/v2/audit", params={"limit": 2, "cursor": first_body["next_cursor"]} + ) + assert second.status_code == 200 + assert not ( + {item["id"] for item in first_body["items"]} + & {item["id"] for item in second.json()["items"]} + ) + + +@pytest.mark.asyncio +async def test_etag_conflict_is_rejected(app_client) -> None: + client, _ = app_client + created = await setup_admin(client) + user_id = created.json()["id"] + csrf = client.cookies["backup_tool_csrf"] + current = await client.get(f"/api/v2/admin/users/{user_id}") + assert current.status_code == 200 + etag = current.headers["ETag"] + + updated = await client.patch( + f"/api/v2/admin/users/{user_id}", + json={"state": "active"}, + headers={"If-Match": etag, "X-CSRF-Token": csrf}, + ) + assert updated.status_code == 200 + + stale = await client.patch( + f"/api/v2/admin/users/{user_id}", + json={"state": "active"}, + headers={"If-Match": etag, "X-CSRF-Token": csrf}, + ) + assert stale.status_code == 412 + assert stale.json()["code"] == "etag_mismatch" + + +@pytest.mark.asyncio +async def test_idempotency_key_is_bound_to_request_digest(app_client) -> None: + client, _ = app_client + assert (await setup_admin(client)).status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + headers = {"X-CSRF-Token": csrf, "Idempotency-Key": "stable-key"} + expires = (datetime.now(UTC) + timedelta(hours=1)).isoformat() + first = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["audit:read"], "expires_at": expires}, + headers=headers, + ) + assert first.status_code == 201 + replay = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["audit:read"], "expires_at": expires}, + headers=headers, + ) + assert replay.status_code == 200 + assert replay.json()["id"] == first.json()["id"] + assert replay.json()["token"] is None + + mismatch = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["admin:write"], "expires_at": expires}, + headers=headers, + ) + assert mismatch.status_code == 409 + assert mismatch.json()["code"] == "idempotency_mismatch" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..c210fac --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests.""" diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py new file mode 100644 index 0000000..8be751c --- /dev/null +++ b/tests/integration/test_auth.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest + +PASSWORD = "correct horse battery staple" + + +async def setup_admin(client, username: str = "admin"): + return await client.post("/api/v2/setup", json={"username": username, "password": PASSWORD}) + + +@pytest.mark.asyncio +async def test_setup_race_creates_exactly_one_admin(app_client) -> None: + client, _ = app_client + responses = await asyncio.gather(setup_admin(client, "admin-a"), setup_admin(client, "admin-b")) + assert sorted(response.status_code for response in responses) == [201, 409] + + +@pytest.mark.asyncio +async def test_setup_sets_secure_session_and_csrf_cookies(app_client) -> None: + client, _ = app_client + response = await setup_admin(client) + assert response.status_code == 201 + cookies = response.headers.get_list("set-cookie") + session = next(value for value in cookies if value.startswith("backup_tool_session=")) + csrf = next(value for value in cookies if value.startswith("backup_tool_csrf=")) + assert "HttpOnly" in session and "Secure" in session and "SameSite=strict" in session + assert "HttpOnly" not in csrf and "Secure" in csrf and "SameSite=strict" in csrf + assert "password" not in response.text.lower() + + +@pytest.mark.asyncio +async def test_csrf_is_required_for_cookie_authenticated_mutation(app_client) -> None: + client, _ = app_client + assert (await setup_admin(client)).status_code == 201 + denied = await client.post( + "/api/v2/admin/secrets", json={"purpose": "ssh", "value": "top-secret"} + ) + assert denied.status_code == 403 + assert denied.json()["code"] == "csrf_failed" + + allowed = await client.post( + "/api/v2/admin/secrets", + json={"purpose": "ssh", "value": "top-secret"}, + headers={"X-CSRF-Token": client.cookies["backup_tool_csrf"]}, + ) + assert allowed.status_code == 201 + assert "top-secret" not in allowed.text + + +@pytest.mark.asyncio +async def test_login_rejects_wrong_password_and_restores_session(app_client) -> None: + client, _ = app_client + assert (await setup_admin(client)).status_code == 201 + await client.post( + "/api/v2/auth/logout", headers={"X-CSRF-Token": client.cookies["backup_tool_csrf"]} + ) + wrong = await client.post( + "/api/v2/auth/login", json={"username": "admin", "password": "wrong-password"} + ) + assert wrong.status_code == 401 + logged_in = await client.post( + "/api/v2/auth/login", json={"username": "admin", "password": PASSWORD} + ) + assert logged_in.status_code == 200 + session = await client.get("/api/v2/auth/session") + assert session.status_code == 200 + assert session.json()["username"] == "admin" + + +@pytest.mark.asyncio +async def test_api_token_scope_revocation_and_expiry(app_client) -> None: + client, _ = app_client + assert (await setup_admin(client)).status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + expiry = (datetime.now(UTC) + timedelta(hours=1)).isoformat() + created = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["audit:read"], "expires_at": expiry}, + headers={"X-CSRF-Token": csrf, "Idempotency-Key": "audit-token"}, + ) + assert created.status_code == 201 + token = created.json()["token"] + client.cookies.clear() + allowed = await client.get("/api/v2/audit", headers={"Authorization": f"Bearer {token}"}) + assert allowed.status_code == 200 + denied = await client.post( + "/api/v2/admin/secrets", + json={"purpose": "ssh", "value": "x"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert denied.status_code == 403 + assert denied.json()["code"] == "insufficient_scope" + + # Log back in to revoke it. + assert ( + await client.post("/api/v2/auth/login", json={"username": "admin", "password": PASSWORD}) + ).status_code == 200 + csrf = client.cookies["backup_tool_csrf"] + assert ( + await client.delete( + f"/api/v2/auth/tokens/{created.json()['id']}", + headers={"X-CSRF-Token": csrf}, + ) + ).status_code == 204 + client.cookies.clear() + revoked = await client.get("/api/v2/audit", headers={"Authorization": f"Bearer {token}"}) + assert revoked.status_code == 401 + + # Expired token is accepted at creation but never at authentication. + assert ( + await client.post("/api/v2/auth/login", json={"username": "admin", "password": PASSWORD}) + ).status_code == 200 + expired = await client.post( + "/api/v2/auth/tokens", + json={ + "scopes": ["audit:read"], + "expires_at": (datetime.now(UTC) - timedelta(seconds=1)).isoformat(), + }, + headers={ + "X-CSRF-Token": client.cookies["backup_tool_csrf"], + "Idempotency-Key": "expired-token", + }, + ) + assert expired.status_code == 201 + expired_token = expired.json()["token"] + client.cookies.clear() + assert ( + await client.get("/api/v2/audit", headers={"Authorization": f"Bearer {expired_token}"}) + ).status_code == 401 diff --git a/tests/security/__init__.py b/tests/security/__init__.py new file mode 100644 index 0000000..ec33a68 --- /dev/null +++ b/tests/security/__init__.py @@ -0,0 +1 @@ +"""Security tests.""" diff --git a/tests/security/test_auth.py b/tests/security/test_auth.py new file mode 100644 index 0000000..b28dfe7 --- /dev/null +++ b/tests/security/test_auth.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import importlib + +import pytest + +CANARY_PASSWORD = "password-canary-4dd52a" +CANARY_SECRET = "secret-canary-9a73ce" + + +@pytest.mark.asyncio +async def test_password_hash_secret_and_token_are_never_disclosed(app_client) -> None: + client, settings = app_client + setup = await client.post( + "/api/v2/setup", json={"username": "admin", "password": CANARY_PASSWORD} + ) + assert setup.status_code == 201 + csrf = client.cookies["backup_tool_csrf"] + secret = await client.post( + "/api/v2/admin/secrets", + json={"purpose": "database", "value": CANARY_SECRET}, + headers={"X-CSRF-Token": csrf}, + ) + assert secret.status_code == 201 + token_response = await client.post( + "/api/v2/auth/tokens", + json={"scopes": ["audit:read"], "expires_at": None}, + headers={"X-CSRF-Token": csrf, "Idempotency-Key": "canary-token"}, + ) + token = token_response.json()["token"] + + responses = [ + setup, + secret, + await client.get("/api/v2/auth/session"), + await client.get("/api/v2/admin/secrets"), + await client.get("/api/v2/audit", params={"limit": 100}), + await client.get("/openapi.json"), + ] + combined = "\n".join(response.text for response in responses) + assert CANARY_PASSWORD not in combined + assert CANARY_SECRET not in combined + assert token not in combined + + models = importlib.import_module("backup_tool.db.models") + engine_module = importlib.import_module("backup_tool.db.engine") + sqlalchemy = importlib.import_module("sqlalchemy") + async_sessionmaker = importlib.import_module("sqlalchemy.ext.asyncio").async_sessionmaker + engine = engine_module.create_engine(settings) + sessions = async_sessionmaker(engine, expire_on_commit=False) + try: + async with sessions() as session: + user = await session.scalar(sqlalchemy.select(models.User)) + stored_secret = await session.scalar(sqlalchemy.select(models.Secret)) + stored_token = await session.scalar(sqlalchemy.select(models.ApiToken)) + audits = list((await session.scalars(sqlalchemy.select(models.AuditEvent))).all()) + assert user is not None and user.password_hash != CANARY_PASSWORD + assert user.password_hash.startswith("$argon2id$") + assert stored_secret is not None and CANARY_SECRET.encode() not in stored_secret.ciphertext + assert stored_token is not None and stored_token.token_hash != token + assert all(CANARY_PASSWORD not in str(audit.details) for audit in audits) + assert all(CANARY_SECRET not in str(audit.details) for audit in audits) + assert all(token not in str(audit.details) for audit in audits) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_master_key_file_never_appears_in_problem_detail(app_client) -> None: + client, settings = app_client + response = await client.post( + "/api/v2/auth/login", json={"username": "missing", "password": "bad"} + ) + assert response.status_code == 401 + assert str(settings.master_key_file) not in response.text diff --git a/tests/security/test_leakage_scan.py b/tests/security/test_leakage_scan.py new file mode 100644 index 0000000..0121b51 --- /dev/null +++ b/tests/security/test_leakage_scan.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import subprocess +import sys + + +def test_leakage_scan_rejects_canary_and_accepts_clean_file(tmp_path) -> None: + path = tmp_path / "output.txt" + path.write_text("safe output", encoding="utf-8") + clean = subprocess.run( + [sys.executable, "tools/leakage_scan.py", "--canary", "secret-canary", str(path)], + capture_output=True, + text=True, + check=False, + ) + assert clean.returncode == 0 + + path.write_text("oops secret-canary escaped", encoding="utf-8") + leaked = subprocess.run( + [sys.executable, "tools/leakage_scan.py", "--canary", "secret-canary", str(path)], + capture_output=True, + text=True, + check=False, + ) + assert leaked.returncode == 1 + assert "output.txt" in leaked.stdout diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e0310a0 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests.""" diff --git a/tests/unit/test_redaction.py b/tests/unit/test_redaction.py new file mode 100644 index 0000000..363c79b --- /dev/null +++ b/tests/unit/test_redaction.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import importlib + + +def test_redaction_removes_nested_secret_fields_and_canaries() -> None: + redaction = importlib.import_module("backup_tool.security.redaction") + canary = "canary-a96f" + value = { + "username": "operator", + "password": canary, + "nested": {"token": canary, "safe": "visible"}, + "items": [{"secret": canary}, canary], + } + result = redaction.redact(value, canaries=(canary,)) + rendered = repr(result) + assert canary not in rendered + assert result["username"] == "operator" + assert result["nested"]["safe"] == "visible" + assert result["password"] == "[REDACTED]" + + +def test_envelope_cipher_round_trips_with_purpose_binding(tmp_path) -> None: + secrets = importlib.import_module("backup_tool.security.secrets") + key_path = tmp_path / "master.key" + key_path.write_bytes(b"x" * 32) + cipher = secrets.EnvelopeCipher.from_file(key_path) + ciphertext, key_id = cipher.encrypt("sensitive", purpose="ssh", version=1) + assert b"sensitive" not in ciphertext + assert cipher.decrypt(ciphertext, purpose="ssh", version=1) == "sensitive" + assert key_id + + try: + cipher.decrypt(ciphertext, purpose="database", version=1) + except Exception as error: + assert error.__class__.__name__ == "InvalidTag" + else: # pragma: no cover - required safety assertion + raise AssertionError("ciphertext accepted under a different purpose")