1020 lines
37 KiB
Python
1020 lines
37 KiB
Python
import sqlite3
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from src import main
|
|
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
|
|
|
|
|
@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"))
|
|
|
|
async def upstream_user():
|
|
return {"id": 42, "login": "timmy"}
|
|
|
|
monkeypatch.setattr(main, "current_user", upstream_user)
|
|
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"],
|
|
"status": "completed",
|
|
}
|
|
],
|
|
"authentication_alerts": [],
|
|
"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_tampered_security_activity_returns_no_partial_history(security_access):
|
|
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
|
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",
|
|
},
|
|
)
|
|
with sqlite3.connect(security_access / "security.sqlite3") as connection:
|
|
payload = connection.execute(
|
|
"SELECT payload FROM security_events WHERE id = 1"
|
|
).fetchone()[0]
|
|
replacement = "A" if payload[-1] != "A" else "B"
|
|
connection.execute(
|
|
"UPDATE security_events SET payload = ? WHERE id = 1",
|
|
(payload[:-1] + replacement,),
|
|
)
|
|
|
|
activity = await client.get("/api/v1/security-events")
|
|
|
|
assert activity.status_code == 503
|
|
assert activity.json() == {
|
|
"detail": "Security activity is temporarily unavailable"
|
|
}
|
|
|
|
|
|
@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_passkey_enrollment_reservation_failure_preserves_registry(
|
|
security_access, monkeypatch
|
|
):
|
|
class VerifiedRegistration:
|
|
credential_id = b"phone-credential"
|
|
credential_public_key = b"credential-public-key"
|
|
sign_count = 0
|
|
|
|
class UnavailableJournal:
|
|
def reserve(self, *_args, **_kwargs):
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(
|
|
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
|
|
)
|
|
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
|
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"},
|
|
)
|
|
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": "enroll_passkey",
|
|
"target": "current_device",
|
|
},
|
|
headers=headers,
|
|
)
|
|
options = await client.post(
|
|
"/api/v1/passkeys/registration/options",
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
|
enrolled = await client.post(
|
|
"/api/v1/passkeys/registration/verify",
|
|
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
|
|
headers=headers,
|
|
)
|
|
|
|
assert enrolled.status_code == 503
|
|
assert enrolled.json() == {"detail": "Security activity is temporarily unavailable"}
|
|
assert main._passkey_store().all(principal_id=42) == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_passkey_enrollment_registry_failure_discards_reserved_event(
|
|
security_access, monkeypatch
|
|
):
|
|
class VerifiedRegistration:
|
|
credential_id = b"phone-credential"
|
|
credential_public_key = b"credential-public-key"
|
|
sign_count = 0
|
|
|
|
monkeypatch.setattr(
|
|
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
|
|
)
|
|
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
|
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"},
|
|
)
|
|
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": "enroll_passkey",
|
|
"target": "current_device",
|
|
},
|
|
headers=headers,
|
|
)
|
|
options = await client.post(
|
|
"/api/v1/passkeys/registration/options",
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
store = main._passkey_store()
|
|
|
|
def fail_registration(**_kwargs):
|
|
raise main.dashboard_auth.SessionStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(store, "register", fail_registration)
|
|
monkeypatch.setattr(main, "_passkey_store", lambda: store)
|
|
enrolled = await client.post(
|
|
"/api/v1/passkeys/registration/verify",
|
|
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
|
|
headers=headers,
|
|
)
|
|
|
|
events = SecurityEventStore(
|
|
security_access / "security.sqlite3", clock=lambda: 0
|
|
).list(limit=10).events
|
|
assert enrolled.status_code == 503
|
|
assert enrolled.json() == {"detail": "Passkey registry is temporarily unavailable"}
|
|
assert all(event.kind != "passkey_enrolled" for event in events)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_passkey_enrollment_finalization_failure_retains_pending_event(
|
|
security_access, monkeypatch
|
|
):
|
|
class VerifiedRegistration:
|
|
credential_id = b"phone-credential"
|
|
credential_public_key = b"credential-public-key"
|
|
sign_count = 0
|
|
|
|
monkeypatch.setattr(
|
|
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
|
|
)
|
|
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
|
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"},
|
|
)
|
|
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": "enroll_passkey",
|
|
"target": "current_device",
|
|
},
|
|
headers=headers,
|
|
)
|
|
options = await client.post(
|
|
"/api/v1/passkeys/registration/options",
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
journal = main._security_event_store()
|
|
|
|
def fail_finalization(_operation_id):
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(journal, "finalize", fail_finalization)
|
|
monkeypatch.setattr(main, "_security_event_store", lambda: journal)
|
|
enrolled = await client.post(
|
|
"/api/v1/passkeys/registration/verify",
|
|
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
|
|
headers=headers,
|
|
)
|
|
activity = await client.get("/api/v1/security-events")
|
|
|
|
event = activity.json()["events"][0]
|
|
assert enrolled.status_code == 201
|
|
assert enrolled.json() == {"enrolled": True}
|
|
assert event["kind"] == "passkey_enrolled"
|
|
assert event["status"] == "pending"
|
|
assert event["device_label"] == "Phone"
|
|
assert event["method"] == "passkey"
|
|
assert event["target"] == "passkey"
|
|
|
|
|
|
@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_remote_revocation_reservation_failure_preserves_the_device(
|
|
security_access, monkeypatch
|
|
):
|
|
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"]
|
|
target = next(device for device in devices if device["device_label"] == "Phone")
|
|
headers = {
|
|
"Origin": "https://test",
|
|
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
|
}
|
|
grant = await laptop.post(
|
|
"/api/v1/fresh-authorization",
|
|
json={
|
|
"access_token": "correct horse battery staple",
|
|
"action": "revoke_device",
|
|
"target": target["management_id"],
|
|
},
|
|
headers=headers,
|
|
)
|
|
|
|
class UnavailableJournal:
|
|
def reserve(self, *_args, **_kwargs):
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
|
response = await laptop.delete(
|
|
f"/api/v1/sessions/{target['management_id']}",
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
remaining = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
|
|
|
assert response.status_code == 503
|
|
assert {device["device_label"] for device in remaining} == {"Phone", "Laptop"}
|
|
|
|
|
|
@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
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[
|
|
"/api/v1/repos/stackchain/api/issues/7/comments/42",
|
|
"/api/v1/repos/stackchain/api/pulls/7/comments/42",
|
|
"/api/v1/notifications/91/comments/42",
|
|
],
|
|
)
|
|
async def test_comment_deletion_requires_exact_fresh_authorization_before_gitea(
|
|
security_access, monkeypatch, path
|
|
):
|
|
calls = []
|
|
|
|
async def assigned(*_args):
|
|
return True
|
|
|
|
async def notification_target(thread_id):
|
|
assert thread_id == 91
|
|
return "stackchain/api", 7
|
|
|
|
async def delete(*args):
|
|
calls.append(args)
|
|
return {"id": 42, "deleted": True}
|
|
|
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
|
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_target", notification_target)
|
|
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
|
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"},
|
|
)
|
|
headers = {
|
|
"Origin": "https://test",
|
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
|
}
|
|
response = await client.delete(path, headers=headers)
|
|
|
|
assert response.status_code == 428
|
|
assert response.json()["detail"] == {
|
|
"detail": "Fresh authorization required",
|
|
"code": "step_up_required",
|
|
"action": "delete_comment",
|
|
"target": "stackchain/api#7:42",
|
|
}
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_authorized_comment_deletion_records_only_privacy_safe_target(
|
|
security_access, monkeypatch
|
|
):
|
|
async def assigned(*_args):
|
|
return True
|
|
|
|
async def delete(*_args):
|
|
return {"id": 42, "deleted": True}
|
|
|
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
|
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
|
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"},
|
|
)
|
|
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": "delete_comment",
|
|
"target": "stackchain/api#7:42",
|
|
},
|
|
headers=headers,
|
|
)
|
|
response = await client.delete(
|
|
"/api/v1/repos/stackchain/api/issues/7/comments/42",
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
events = (await client.get("/api/v1/security-events")).json()["events"]
|
|
|
|
assert response.status_code == 200
|
|
assert (events[0]["kind"], events[0]["target"], events[0]["status"]) == (
|
|
"comment_deleted", "stackchain/api#7:42", "completed"
|
|
)
|
|
persisted = (security_access / "security.sqlite3").read_bytes()
|
|
assert b"private comment body" not in persisted
|
|
assert b"correct horse battery staple" not in persisted
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_comment_deletion_reservation_failure_prevents_gitea_mutation(
|
|
security_access, monkeypatch
|
|
):
|
|
calls = []
|
|
|
|
async def assigned(*_args):
|
|
return True
|
|
|
|
async def delete(*args):
|
|
calls.append(args)
|
|
return {"id": 42, "deleted": True}
|
|
|
|
class UnavailableJournal:
|
|
def reserve(self, *_args, **_kwargs):
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
|
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
|
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"},
|
|
)
|
|
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": "delete_comment",
|
|
"target": "stackchain/api#7:42",
|
|
},
|
|
headers=headers,
|
|
)
|
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
|
response = await client.delete(
|
|
"/api/v1/repos/stackchain/api/issues/7/comments/42",
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
|
|
assert response.status_code == 503
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("finalize_fails", [False, True])
|
|
async def test_comment_deletion_journal_tracks_failed_and_confirmed_outcomes(
|
|
security_access, monkeypatch, finalize_fails
|
|
):
|
|
journal_calls = []
|
|
|
|
async def assigned(*_args):
|
|
return True
|
|
|
|
async def delete(*_args):
|
|
if not finalize_fails:
|
|
raise RuntimeError("upstream rejected deletion")
|
|
return {"id": 42, "deleted": True}
|
|
|
|
class Journal:
|
|
def reserve(self, kind, *, target):
|
|
journal_calls.append(("reserve", kind, target))
|
|
return "operation-42"
|
|
|
|
def discard(self, operation_id):
|
|
journal_calls.append(("discard", operation_id))
|
|
|
|
def finalize(self, operation_id):
|
|
journal_calls.append(("finalize", operation_id))
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
|
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
|
|
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"},
|
|
)
|
|
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": "delete_comment",
|
|
"target": "stackchain/api#7:42",
|
|
},
|
|
headers=headers,
|
|
)
|
|
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
|
|
response = await client.delete(
|
|
"/api/v1/repos/stackchain/api/issues/7/comments/42",
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
|
|
assert journal_calls[0] == (
|
|
"reserve", "comment_deleted", "stackchain/api#7:42"
|
|
)
|
|
if finalize_fails:
|
|
assert response.status_code == 200
|
|
assert response.json() == {"id": 42, "deleted": True}
|
|
assert journal_calls[1] == ("finalize", "operation-42")
|
|
else:
|
|
assert response.status_code == 503
|
|
assert journal_calls[1] == ("discard", "operation-42")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_approved_pull_review_records_a_privacy_safe_completed_event(
|
|
security_access, monkeypatch
|
|
):
|
|
async def requested(*_args):
|
|
return True
|
|
|
|
async def submit(*_args):
|
|
return {"id": 91, "state": "APPROVED"}
|
|
|
|
monkeypatch.setattr(main, "is_requested_review", requested)
|
|
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
|
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"},
|
|
)
|
|
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": "submit_pull_review",
|
|
"target": "stackchain/api#7@abc123:approve",
|
|
},
|
|
headers=headers,
|
|
)
|
|
response = await client.post(
|
|
"/api/v1/repos/stackchain/api/pulls/7/review",
|
|
json={
|
|
"expected_head_sha": "abc123",
|
|
"decision": "approve",
|
|
"body": "Ready to ship with private review notes.",
|
|
},
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
events = (await client.get("/api/v1/security-events")).json()["events"]
|
|
|
|
assert response.status_code == 201
|
|
assert events[0] == {
|
|
"id": events[0]["id"],
|
|
"kind": "pull_review_approved",
|
|
"method": None,
|
|
"device_label": None,
|
|
"target": "stackchain/api#7",
|
|
"created_at": events[0]["created_at"],
|
|
"status": "completed",
|
|
}
|
|
persisted = (security_access / "security.sqlite3").read_bytes()
|
|
assert b"abc123" not in persisted
|
|
assert b"private review notes" not in persisted
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_review_reservation_failure_prevents_the_gitea_mutation(
|
|
security_access, monkeypatch
|
|
):
|
|
submit_calls = []
|
|
|
|
async def requested(*_args):
|
|
return True
|
|
|
|
async def submit(*args):
|
|
submit_calls.append(args)
|
|
return {"id": 91, "state": "APPROVED"}
|
|
|
|
class UnavailableJournal:
|
|
def reserve(self, *_args, **_kwargs):
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(main, "is_requested_review", requested)
|
|
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
|
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"},
|
|
)
|
|
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": "submit_pull_review",
|
|
"target": "stackchain/api#7@abc123:approve",
|
|
},
|
|
headers=headers,
|
|
)
|
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
|
response = await client.post(
|
|
"/api/v1/repos/stackchain/api/pulls/7/review",
|
|
json={
|
|
"expected_head_sha": "abc123",
|
|
"decision": "approve",
|
|
"body": "Ready.",
|
|
},
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
|
|
assert response.status_code == 503
|
|
assert response.json()["detail"] == "Security activity is temporarily unavailable"
|
|
assert submit_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_issue_close_reservation_failure_prevents_the_gitea_mutation(
|
|
security_access, monkeypatch
|
|
):
|
|
close_calls = []
|
|
|
|
async def yes(*_args):
|
|
return True
|
|
|
|
async def close(*args):
|
|
close_calls.append(args)
|
|
return {"number": 7, "state": "closed"}
|
|
|
|
class UnavailableJournal:
|
|
def reserve(self, *_args, **_kwargs):
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
|
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
|
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"},
|
|
)
|
|
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": "close_issue",
|
|
"target": "stackchain/api#7",
|
|
},
|
|
headers=headers,
|
|
)
|
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
|
response = await client.patch(
|
|
"/api/v1/repos/stackchain/api/issues/7/close",
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
|
|
assert response.status_code == 503
|
|
assert close_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_issue_close_finalization_failure_still_reports_the_closed_issue(
|
|
security_access, monkeypatch
|
|
):
|
|
async def yes(*_args):
|
|
return True
|
|
|
|
async def close(*_args):
|
|
return {"number": 7, "state": "closed"}
|
|
|
|
class FinalizationUnavailable:
|
|
def reserve(self, *_args, **_kwargs):
|
|
return "durable-operation"
|
|
|
|
def finalize(self, _operation_id):
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
|
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
|
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"},
|
|
)
|
|
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": "close_issue",
|
|
"target": "stackchain/api#7",
|
|
},
|
|
headers=headers,
|
|
)
|
|
monkeypatch.setattr(
|
|
main, "_security_event_store", lambda: FinalizationUnavailable()
|
|
)
|
|
response = await client.patch(
|
|
"/api/v1/repos/stackchain/api/issues/7/close",
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"number": 7, "state": "closed"}
|
|
|
|
|
|
@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_reservation_failure_preserves_the_active_session(
|
|
security_access, monkeypatch
|
|
):
|
|
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"},
|
|
)
|
|
|
|
class UnavailableJournal:
|
|
def reserve(self, *_args, **_kwargs):
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
|
response = await client.delete(
|
|
"/api/v1/session",
|
|
headers={
|
|
"Origin": "https://test",
|
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
|
},
|
|
)
|
|
still_authenticated = await client.get("/api/v1/sessions")
|
|
|
|
assert response.status_code == 503
|
|
assert still_authenticated.status_code == 200
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sign_out_finalization_failure_reports_success_and_clears_cookies(
|
|
security_access, monkeypatch
|
|
):
|
|
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"},
|
|
)
|
|
|
|
class FinalizationUnavailable:
|
|
def reserve(self, *_args, **_kwargs):
|
|
return "durable-operation"
|
|
|
|
def finalize(self, _operation_id):
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(
|
|
main, "_security_event_store", lambda: FinalizationUnavailable()
|
|
)
|
|
response = await client.delete(
|
|
"/api/v1/session",
|
|
headers={
|
|
"Origin": "https://test",
|
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
|
},
|
|
)
|
|
rejected = await client.get("/api/v1/sessions")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["authenticated"] is False
|
|
assert "stackchain_session=" in response.headers["set-cookie"]
|
|
assert rejected.status_code == 401
|
|
|
|
|
|
@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"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sign_out_all_reservation_failure_preserves_the_active_session(
|
|
security_access, monkeypatch
|
|
):
|
|
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"},
|
|
)
|
|
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=headers,
|
|
)
|
|
|
|
class UnavailableJournal:
|
|
def reserve(self, *_args, **_kwargs):
|
|
raise SecurityEventStoreError("unavailable")
|
|
|
|
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
|
response = await client.delete(
|
|
"/api/v1/sessions",
|
|
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
|
)
|
|
still_authenticated = await client.get("/api/v1/sessions")
|
|
|
|
assert response.status_code == 503
|
|
assert still_authenticated.status_code == 200
|