test(v2): define secure control-plane contracts
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user