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")) 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_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() == [] @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