246 lines
9.3 KiB
Python
246 lines
9.3 KiB
Python
import sqlite3
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from src import main
|
|
from src.security_event_store import SecurityEventStore
|
|
|
|
|
|
@pytest.fixture
|
|
def security_access(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
|
monkeypatch.setenv(
|
|
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
|
"a-separate-session-signing-secret-with-enough-entropy",
|
|
)
|
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "attempts.sqlite3"))
|
|
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
|
|
return tmp_path
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_authenticated_security_activity_lists_private_sign_in_history(security_access):
|
|
transport = httpx.ASGITransport(app=main.app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as anonymous:
|
|
rejected = await anonymous.get("/api/v1/security-events")
|
|
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
|
signed_in = await client.post(
|
|
"/api/v1/session",
|
|
json={
|
|
"access_token": "correct horse battery staple",
|
|
"device_label": "Timmy's phone",
|
|
},
|
|
)
|
|
activity = await client.get("/api/v1/security-events", params={"limit": 25})
|
|
|
|
assert rejected.status_code == 401
|
|
assert signed_in.status_code == 200
|
|
assert activity.status_code == 200
|
|
assert activity.headers["cache-control"] == "no-store"
|
|
assert activity.json() == {
|
|
"events": [
|
|
{
|
|
"id": 1,
|
|
"kind": "sign_in",
|
|
"method": "token",
|
|
"device_label": "Timmy's phone",
|
|
"target": "dashboard",
|
|
"created_at": activity.json()["events"][0]["created_at"],
|
|
}
|
|
],
|
|
"next_cursor": None,
|
|
}
|
|
persisted = (security_access / "security.sqlite3").read_bytes()
|
|
assert b"correct horse battery staple" not in persisted
|
|
assert b"stackchain_session" not in persisted
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_security_activity_limit_is_validated(security_access):
|
|
transport = httpx.ASGITransport(app=main.app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
|
await client.post(
|
|
"/api/v1/session",
|
|
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
|
)
|
|
too_large = await client.get("/api/v1/security-events", params={"limit": 101})
|
|
invalid_cursor = await client.get("/api/v1/security-events", params={"cursor": 0})
|
|
|
|
assert too_large.status_code == 422
|
|
assert invalid_cursor.status_code == 422
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_revoked_device_activity_survives_live_session_removal(security_access):
|
|
transport = httpx.ASGITransport(app=main.app)
|
|
async with (
|
|
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
|
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
|
):
|
|
await phone.post(
|
|
"/api/v1/session",
|
|
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
|
)
|
|
await laptop.post(
|
|
"/api/v1/session",
|
|
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
|
)
|
|
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
|
phone_device = next(device for device in devices if device["device_label"] == "Phone")
|
|
csrf = laptop.cookies["stackchain_csrf"]
|
|
grant = await laptop.post(
|
|
"/api/v1/fresh-authorization",
|
|
json={
|
|
"access_token": "correct horse battery staple",
|
|
"action": "revoke_device",
|
|
"target": phone_device["management_id"],
|
|
},
|
|
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
|
)
|
|
revoked = await laptop.delete(
|
|
f"/api/v1/sessions/{phone_device['management_id']}",
|
|
headers={
|
|
"Origin": "https://test",
|
|
"X-CSRF-Token": csrf,
|
|
"X-Step-Up-Grant": grant.json()["grant"],
|
|
},
|
|
)
|
|
active = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
|
history = (await laptop.get("/api/v1/security-events")).json()["events"]
|
|
|
|
assert revoked.status_code == 200
|
|
assert [device["device_label"] for device in active] == ["Laptop"]
|
|
assert [(event["kind"], event["device_label"]) for event in history] == [
|
|
("device_revoked", "Phone"),
|
|
("sign_in", "Laptop"),
|
|
("sign_in", "Phone"),
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_successful_protected_actions_record_only_bounded_targets(
|
|
security_access, monkeypatch
|
|
):
|
|
async def yes(*_args):
|
|
return True
|
|
|
|
async def close(*_args):
|
|
return {"number": 7, "state": "closed"}
|
|
|
|
async def merge(*_args):
|
|
return {"number": 9, "merged": True, "state": "closed"}
|
|
|
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
|
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", yes)
|
|
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
|
|
|
transport = httpx.ASGITransport(app=main.app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
|
await client.post(
|
|
"/api/v1/session",
|
|
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
|
)
|
|
csrf_headers = {
|
|
"Origin": "https://test",
|
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
|
}
|
|
|
|
async def grant(action, target):
|
|
response = await client.post(
|
|
"/api/v1/fresh-authorization",
|
|
json={
|
|
"access_token": "correct horse battery staple",
|
|
"action": action,
|
|
"target": target,
|
|
},
|
|
headers=csrf_headers,
|
|
)
|
|
return response.json()["grant"]
|
|
|
|
issue_grant = await grant("close_issue", "stackchain/api#7")
|
|
closed = await client.patch(
|
|
"/api/v1/repos/stackchain/api/issues/7/close",
|
|
headers={**csrf_headers, "X-Step-Up-Grant": issue_grant},
|
|
)
|
|
pull_grant = await grant("merge_pull", "stackchain/app#9")
|
|
merged = await client.post(
|
|
"/api/v1/repos/stackchain/app/pulls/9/merge",
|
|
json={"expected_head_sha": "abc123"},
|
|
headers={**csrf_headers, "X-Step-Up-Grant": pull_grant},
|
|
)
|
|
events = (await client.get("/api/v1/security-events")).json()["events"]
|
|
|
|
assert closed.status_code == 200
|
|
assert merged.status_code == 200
|
|
assert [(event["kind"], event["target"]) for event in events[:2]] == [
|
|
("pull_merged", "stackchain/app#9"),
|
|
("issue_closed", "stackchain/api#7"),
|
|
]
|
|
persisted = (security_access / "security.sqlite3").read_bytes()
|
|
assert b"abc123" not in persisted
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sign_out_is_journaled_before_the_session_is_removed(security_access):
|
|
transport = httpx.ASGITransport(app=main.app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
|
await client.post(
|
|
"/api/v1/session",
|
|
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
|
)
|
|
signed_out = await client.delete(
|
|
"/api/v1/session",
|
|
headers={
|
|
"Origin": "https://test",
|
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
|
},
|
|
)
|
|
|
|
events = SecurityEventStore(
|
|
security_access / "security.sqlite3", clock=lambda: 0
|
|
).list(limit=10).events
|
|
assert signed_out.status_code == 200
|
|
assert [(event.kind, event.device_label) for event in events[:2]] == [
|
|
("sign_out", None),
|
|
("sign_in", "Phone"),
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_access):
|
|
transport = httpx.ASGITransport(app=main.app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
|
await client.post(
|
|
"/api/v1/session",
|
|
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
|
)
|
|
csrf_headers = {
|
|
"Origin": "https://test",
|
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
|
}
|
|
grant = await client.post(
|
|
"/api/v1/fresh-authorization",
|
|
json={
|
|
"access_token": "correct horse battery staple",
|
|
"action": "revoke_all_sessions",
|
|
"target": "all",
|
|
},
|
|
headers=csrf_headers,
|
|
)
|
|
response = await client.delete(
|
|
"/api/v1/sessions",
|
|
headers={**csrf_headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
|
|
events = SecurityEventStore(
|
|
security_access / "security.sqlite3", clock=lambda: 0
|
|
).list(limit=10).events
|
|
assert response.status_code == 200
|
|
assert events[0].kind == "all_sessions_revoked"
|
|
assert events[0].target == "all_devices"
|