test(v2): define secure control-plane contracts

This commit is contained in:
2026-07-27 19:56:01 +02:00
parent 8b831fc985
commit cb864bcac5
12 changed files with 499 additions and 0 deletions
+24
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
"""Backup Tool v2 test suite."""
+48
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Contract tests."""
+150
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
"""Integration tests."""
+133
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Security tests."""
+75
View File
@@ -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
+26
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Unit tests."""
+38
View File
@@ -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")