test(v2): define secure control-plane contracts
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Backup Tool v2 test suite."""
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Contract tests."""
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests."""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Security tests."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests."""
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user