From 78d044f1afa31bceb6166f8b942857da3705dc56 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 16:30:31 +0000 Subject: [PATCH] feat: make security-journaled actions outcome truthful (Closes #497) --- README.md | 6 +- frontend/session.js | 8 +- src/main.py | 139 +++++++++---- src/security_event_store.py | 103 ++++++++- tests/test_dashboard_auth.py | 1 + tests/test_dashboard_session_frontend.py | 7 + tests/test_security_activity.py | 253 ++++++++++++++++++++++- tests/test_security_event_store.py | 34 ++- 8 files changed, 494 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 94666a5..56ff3ae 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,11 @@ closures, and pull-request merges. The separate SQLite journal retains at most 10,000 events for 90 days and stores only bounded device labels and action targets. It never stores access tokens, cookies, session/CSRF values, credential IDs, raw network addresses, request bodies, or comment content. Keep its database on the -same class of persistent, writable storage as the session registry. +same class of persistent, writable storage as the session registry. Before a +session revocation or issue closure, the journal durably reserves a pending event; +if that reservation fails, the destructive action does not begin. A successful +action remains truthfully reported even if its event cannot immediately be finalized, +and the activity sheet marks that durable record as **Outcome confirmation pending**. After token bootstrap, **Active devices → Add a passkey for this device** enrolls a WebAuthn credential with required user verification. That device can then sign in diff --git a/frontend/session.js b/frontend/session.js index a478017..0fbd474 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -105,8 +105,12 @@ title.textContent = labels[event.kind] || 'Security event'; const details = root.document.createElement('span'); details.className = 'small muted'; - const context = [event.device_label, event.method, event.target] - .filter(value => typeof value === 'string' && value).join(' · '); + const context = [ + event.device_label, + event.method, + event.target, + event.status === 'pending' ? 'Outcome confirmation pending' : null, + ].filter(value => typeof value === 'string' && value).join(' · '); details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`; row.append(title, details); activityList.append(row); diff --git a/src/main.py b/src/main.py index f57b66d..633cb10 100644 --- a/src/main.py +++ b/src/main.py @@ -1020,6 +1020,7 @@ async def list_security_events( "device_label": event.device_label, "target": event.target, "created_at": event.created_at, + "status": event.status, } for event in page.events ], @@ -1508,25 +1509,38 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch): @app.delete("/api/v1/session") async def sign_out(request: Request, response: Response): session = request.state.dashboard_session + journal = _security_event_store() try: - await asyncio.to_thread(dashboard_auth.revoke_session, session) - await asyncio.to_thread( - _security_event_store().record, + operation_id = await asyncio.to_thread( + journal.reserve, "sign_out", target="current_device", ) - except dashboard_auth.SessionStoreError: - return JSONResponse( - {"detail": "Session registry is temporarily unavailable"}, - status_code=503, - headers={"Cache-Control": "no-store"}, - ) except SecurityEventStoreError: return JSONResponse( {"detail": "Security activity is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) + try: + await asyncio.to_thread(dashboard_auth.revoke_session, session) + except dashboard_auth.SessionStoreError: + try: + await asyncio.to_thread(journal.discard, operation_id) + except SecurityEventStoreError: + pass + return JSONResponse( + {"detail": "Session registry is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + try: + await asyncio.to_thread(journal.finalize, operation_id) + except SecurityEventStoreError: + # The pending reservation is durable evidence; do not report a + # successful revocation as failed merely because completion could not + # be marked yet. + pass path = dashboard_auth.cookie_path(request) response.delete_cookie( dashboard_auth.SESSION_COOKIE, @@ -1592,30 +1606,44 @@ async def revoke_active_device( target = next( (device for device in devices if device.management_id == management_id), None ) - await asyncio.to_thread(_security_event_store().list, limit=1) - await asyncio.to_thread(_passkey_store().revoke_management_id, management_id) - revoked = await dashboard_auth.revoke_managed_session(management_id) - if target is not None and revoked: - await asyncio.to_thread( - _security_event_store().record, - "device_revoked", - device_label=target.device_label, - target="device", - ) except dashboard_auth.SessionStoreError: return JSONResponse( {"detail": "Session registry is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) + if target is None: + raise HTTPException(status_code=404, detail="Active device not found") + + journal = _security_event_store() + try: + operation_id = await asyncio.to_thread( + journal.reserve, + "device_revoked", + device_label=target.device_label, + target="device", + ) except SecurityEventStoreError: return JSONResponse( {"detail": "Security activity is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) - if target is None or not revoked: + try: + await asyncio.to_thread(_passkey_store().revoke_management_id, management_id) + revoked = await dashboard_auth.revoke_managed_session(management_id) + except dashboard_auth.SessionStoreError: + return JSONResponse( + {"detail": "Session registry is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + if not revoked: raise HTTPException(status_code=404, detail="Active device not found") + try: + await asyncio.to_thread(journal.finalize, operation_id) + except SecurityEventStoreError: + pass return {"revoked": True, "current_session": target.current} @@ -1633,26 +1661,32 @@ async def sign_out_all_devices( action="revoke_all_sessions", target="all", ) + journal = _security_event_store() try: - await asyncio.to_thread(_passkey_store().revoke_all) - await dashboard_auth.revoke_all_sessions() - await asyncio.to_thread( - _security_event_store().record, + operation_id = await asyncio.to_thread( + journal.reserve, "all_sessions_revoked", target="all_devices", ) - except dashboard_auth.SessionStoreError: - return JSONResponse( - {"detail": "Session registry is temporarily unavailable"}, - status_code=503, - headers={"Cache-Control": "no-store"}, - ) except SecurityEventStoreError: return JSONResponse( {"detail": "Security activity is temporarily unavailable"}, status_code=503, headers={"Cache-Control": "no-store"}, ) + try: + await asyncio.to_thread(_passkey_store().revoke_all) + await dashboard_auth.revoke_all_sessions() + except dashboard_auth.SessionStoreError: + return JSONResponse( + {"detail": "Session registry is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + try: + await asyncio.to_thread(journal.finalize, operation_id) + except SecurityEventStoreError: + pass path = dashboard_auth.cookie_path(request) response.delete_cookie( dashboard_auth.SESSION_COOKIE, @@ -3309,21 +3343,20 @@ async def close_assigned_issue( target=f"{repository}#{number}", ) - async def close_issue(): - if not await gitea_proxy.is_assigned_issue(repository, number): - raise HTTPException(status_code=404, detail="Assigned issue not found") - return await gitea_proxy.close_issue(repository, number) - + target = f"{repository}#{number}" try: - result = await asyncio.wait_for( - close_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS + assigned = await asyncio.wait_for( + gitea_proxy.is_assigned_issue(repository, number), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, ) - await asyncio.to_thread( - _security_event_store().record, + if not assigned: + raise HTTPException(status_code=404, detail="Assigned issue not found") + journal = _security_event_store() + operation_id = await asyncio.to_thread( + journal.reserve, "issue_closed", - target=f"{repository}#{number}", + target=target, ) - return result except HTTPException: raise except Exception: @@ -3333,6 +3366,30 @@ async def close_assigned_issue( headers={"Retry-After": "1"}, ) + try: + result = await asyncio.wait_for( + gitea_proxy.close_issue(repository, number), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except Exception: + try: + await asyncio.to_thread(journal.discard, operation_id) + except SecurityEventStoreError: + pass + return JSONResponse( + {"error": "The issue could not be closed. It remains in My Work; please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + + try: + await asyncio.to_thread(journal.finalize, operation_id) + except SecurityEventStoreError: + # The close result is authoritative; the pending reservation preserves + # evidence without telling the operator the issue remains open. + pass + return result + @app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/labels") async def assigned_issue_label_options( diff --git a/src/security_event_store.py b/src/security_event_store.py index a6f7d5b..83168d6 100644 --- a/src/security_event_store.py +++ b/src/security_event_store.py @@ -1,5 +1,6 @@ """Bounded, privacy-preserving journal of operator security activity.""" +import secrets import sqlite3 from dataclasses import dataclass from pathlib import Path @@ -18,6 +19,7 @@ class SecurityEvent: device_label: str | None target: str | None created_at: int + status: str @dataclass(frozen=True) @@ -61,20 +63,48 @@ class SecurityEventStore: method TEXT, device_label TEXT, target TEXT, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'completed', + operation_id TEXT ) """ ) + columns = { + row[1] for row in connection.execute("PRAGMA table_info(security_events)") + } + if "status" not in columns: + connection.execute( + "ALTER TABLE security_events ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'" + ) + if "operation_id" not in columns: + connection.execute( + "ALTER TABLE security_events ADD COLUMN operation_id TEXT" + ) connection.execute( "CREATE INDEX IF NOT EXISTS security_events_created " "ON security_events(created_at DESC, id DESC)" ) + connection.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation " + "ON security_events(operation_id) WHERE operation_id IS NOT NULL" + ) return connection except (OSError, sqlite3.Error) as exc: raise SecurityEventStoreError( "Security activity is temporarily unavailable" ) from exc + def _prune(self, connection: sqlite3.Connection, now: int) -> None: + connection.execute( + "DELETE FROM security_events WHERE created_at < ?", + (now - self.retention_seconds,), + ) + connection.execute( + "DELETE FROM security_events WHERE id NOT IN " + "(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)", + (self.max_events,), + ) + def record( self, kind: str, @@ -87,12 +117,8 @@ class SecurityEventStore: try: with self._connect() as connection: connection.execute( - "DELETE FROM security_events WHERE created_at < ?", - (now - self.retention_seconds,), - ) - connection.execute( - "INSERT INTO security_events(kind, method, device_label, target, created_at) " - "VALUES (?, ?, ?, ?, ?)", + "INSERT INTO security_events(kind, method, device_label, target, created_at, status) " + "VALUES (?, ?, ?, ?, ?, 'completed')", ( self._bounded(kind, 48) or "security_event", self._bounded(method, 32), @@ -101,10 +127,65 @@ class SecurityEventStore: now, ), ) + self._prune(connection, now) + except (OSError, sqlite3.Error) as exc: + raise SecurityEventStoreError( + "Security activity is temporarily unavailable" + ) from exc + + def reserve( + self, + kind: str, + *, + method: str | None = None, + device_label: str | None = None, + target: str | None = None, + ) -> str: + now = int(self.clock()) + operation_id = secrets.token_urlsafe(24) + try: + with self._connect() as connection: connection.execute( - "DELETE FROM security_events WHERE id NOT IN " - "(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)", - (self.max_events,), + "INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) " + "VALUES (?, ?, ?, ?, ?, 'pending', ?)", + ( + self._bounded(kind, 48) or "security_event", + self._bounded(method, 32), + self._bounded(device_label, 64), + self._bounded(target, 255), + now, + operation_id, + ), + ) + self._prune(connection, now) + except (OSError, sqlite3.Error) as exc: + raise SecurityEventStoreError( + "Security activity is temporarily unavailable" + ) from exc + return operation_id + + def finalize(self, operation_id: str) -> None: + try: + with self._connect() as connection: + cursor = connection.execute( + "UPDATE security_events SET status = 'completed' WHERE operation_id = ?", + (operation_id,), + ) + if cursor.rowcount != 1: + raise SecurityEventStoreError( + "Security activity reservation was not found" + ) + except (OSError, sqlite3.Error) as exc: + raise SecurityEventStoreError( + "Security activity is temporarily unavailable" + ) from exc + + def discard(self, operation_id: str) -> None: + try: + with self._connect() as connection: + connection.execute( + "DELETE FROM security_events WHERE operation_id = ? AND status = 'pending'", + (operation_id,), ) except (OSError, sqlite3.Error) as exc: raise SecurityEventStoreError( @@ -122,7 +203,7 @@ class SecurityEventStore: try: with self._connect() as connection: rows = connection.execute( - "SELECT id, kind, method, device_label, target, created_at " + "SELECT id, kind, method, device_label, target, created_at, status " f"FROM security_events {where} ORDER BY id DESC LIMIT ?", parameters, ).fetchall() diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 1cee68a..f8c913e 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -152,6 +152,7 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token( "device_label": "Phone", "target": "dashboard", "created_at": activity.json()["events"][0]["created_at"], + "status": "completed", } assert "stackchain_session=" in signed_in.headers["set-cookie"] assert "correct horse battery staple" not in signed_in.text diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 566c2a3..c50e603 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -614,6 +614,13 @@ process.stdout.write(JSON.stringify(state)); ] +def test_security_activity_explains_pending_outcome_confirmation(): + source = SESSION_JS.read_text() + + assert "Outcome confirmation pending" in source + assert "event.status === 'pending'" in source + + def test_authenticated_dashboard_load_requests_queued_delivery_resume(): result = run_session_scenario( """ diff --git a/tests/test_security_activity.py b/tests/test_security_activity.py index 90ad94d..597c31f 100644 --- a/tests/test_security_activity.py +++ b/tests/test_security_activity.py @@ -4,7 +4,7 @@ import httpx import pytest from src import main -from src.security_event_store import SecurityEventStore +from src.security_event_store import SecurityEventStore, SecurityEventStoreError @pytest.fixture @@ -50,6 +50,7 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec "device_label": "Timmy's phone", "target": "dashboard", "created_at": activity.json()["events"][0]["created_at"], + "status": "completed", } ], "next_cursor": None, @@ -121,6 +122,54 @@ async def test_revoked_device_activity_survives_live_session_removal(security_ac ] +@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 @@ -185,6 +234,104 @@ async def test_successful_protected_actions_record_only_bounded_targets( assert b"abc123" not in persisted +@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) @@ -211,6 +358,71 @@ async def test_sign_out_is_journaled_before_the_session_is_removed(security_acce ] +@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) @@ -243,3 +455,42 @@ async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_acc 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 diff --git a/tests/test_security_event_store.py b/tests/test_security_event_store.py index 1cd5ed1..51b7416 100644 --- a/tests/test_security_event_store.py +++ b/tests/test_security_event_store.py @@ -30,7 +30,10 @@ def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path) columns = { row[1] for row in sqlite3.connect(store.path).execute("PRAGMA table_info(security_events)") } - assert columns == {"id", "kind", "method", "device_label", "target", "created_at"} + assert columns == { + "id", "kind", "method", "device_label", "target", "created_at", + "status", "operation_id", + } def test_security_event_retention_prunes_age_and_count(tmp_path): @@ -52,3 +55,32 @@ def test_security_event_retention_prunes_age_and_count(tmp_path): now[0] = 20 store.record("sign_out", device_label="Current") assert [event.kind for event in store.list(limit=10).events] == ["sign_out"] + + +def test_security_event_reservation_is_durable_until_finalized(tmp_path): + store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000) + + operation_id = store.reserve("issue_closed", target="stackchain/api#7") + + pending = SecurityEventStore( + tmp_path / "security.sqlite3", clock=lambda: 1_001 + ).list(limit=10).events + assert [(event.kind, event.target, event.status) for event in pending] == [ + ("issue_closed", "stackchain/api#7", "pending") + ] + + store.finalize(operation_id) + + completed = store.list(limit=10).events + assert [(event.kind, event.target, event.status) for event in completed] == [ + ("issue_closed", "stackchain/api#7", "completed") + ] + + +def test_failed_operation_can_discard_its_pending_reservation(tmp_path): + store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000) + operation_id = store.reserve("issue_closed", target="stackchain/api#7") + + store.discard(operation_id) + + assert store.list(limit=10).events == [] -- 2.43.0