diff --git a/frontend/session.js b/frontend/session.js index 37053d1..f6564f8 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -166,6 +166,7 @@ pull_merged: 'Pull request merged', pull_review_approved: 'Pull request approved', pull_review_changes_requested: 'Changes requested', + gitea_time_logged: 'Gitea time logged', }; page.events.forEach(event => { const row = root.document.createElement('article'); diff --git a/src/main.py b/src/main.py index 0ce3aa6..7bd7d53 100644 --- a/src/main.py +++ b/src/main.py @@ -279,6 +279,7 @@ StepUpAction = Literal[ "merge_pull", "submit_pull_review", "close_issue", + "log_recap_time", "revoke_device", "revoke_all_sessions", "enroll_passkey", @@ -430,6 +431,19 @@ class TodayRecapTimeLog(TodayRecap): log_identities: list[str] = Field(min_length=1, max_length=20) +def _recap_time_log_target(payload: TodayRecapTimeLog) -> str: + selected_minutes = { + item.identity: item.actual_minutes + for item in payload.items + if item.identity in payload.log_identities + } + material = "\0".join( + [payload.session_id] + + [f"{identity}\0{selected_minutes.get(identity, '')}" for identity in sorted(payload.log_identities)] + ) + return f"today-recap:{hashlib.sha256(material.encode()).hexdigest()[:32]}" + + class LaterOperation(BaseModel): operation_id: str = Field(min_length=1, max_length=100) action: Literal["defer", "restore"] @@ -1921,7 +1935,14 @@ async def save_today_recap(payload: TodayRecap, response: Response): @app.post("/api/v1/today/recaps/log-time") -async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Response): +async def save_today_recap_and_log_time( + payload: TodayRecapTimeLog, + request: Request, + response: Response, + step_up_grant: str | None = Header( + default=None, alias="X-Step-Up-Grant", max_length=128 + ), +): login = await _confirmed_login() if len(set(payload.log_identities)) != len(payload.log_identities): raise HTTPException(status_code=422, detail="time log targets must be unique") @@ -1936,6 +1957,12 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re if requested.actual_minutes <= 0: raise ValueError("only non-zero recap time can be logged") selected.append(requested) + await _require_step_up( + request, + step_up_grant, + action="log_recap_time", + target=_recap_time_log_target(payload), + ) recap = await asyncio.to_thread( _today_store().save_recap, login, @@ -1959,6 +1986,21 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re _today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes ) if state == "claimed": + repository, number = gitea_proxy.issue_time_target(item.identity) + journal = _security_event_store() + try: + operation_id = await asyncio.to_thread( + journal.reserve, + "gitea_time_logged", + target=f"{repository}#{number}", + ) + except SecurityEventStoreError: + await asyncio.to_thread( + _today_store().finish_time_log, + login, payload.session_id, item.identity, succeeded=False, + ) + results.append({"identity": item.identity, "status": "retry"}) + continue try: await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60) except Exception as error: @@ -1967,6 +2009,10 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re _today_store().finish_time_log, login, payload.session_id, item.identity, succeeded=False, ) + try: + await asyncio.to_thread(journal.discard, operation_id) + except SecurityEventStoreError: + pass status = "retry" else: status = "verify" @@ -1976,6 +2022,10 @@ async def save_today_recap_and_log_time(payload: TodayRecapTimeLog, response: Re _today_store().finish_time_log, login, payload.session_id, item.identity, succeeded=True, ) + try: + await asyncio.to_thread(journal.finalize, operation_id) + except SecurityEventStoreError: + pass state = "logged" results.append({ "identity": item.identity, diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index b1d9665..6ac51b8 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -815,3 +815,9 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out(): assert 'id="security-activity-list"' in html assert '>Load older activity' in html assert '#sign-out-all { min-height:44px; }' in html + + +def test_security_activity_names_confirmed_gitea_time_logging(): + source = SESSION_JS.read_text() + + assert "gitea_time_logged: 'Gitea time logged'" in source diff --git a/tests/test_today_recap.py b/tests/test_today_recap.py index 40702b2..b9bcee9 100644 --- a/tests/test_today_recap.py +++ b/tests/test_today_recap.py @@ -13,6 +13,24 @@ ROOT = Path(__file__).parents[1] TODAY_RECAP = ROOT / "frontend" / "today-recap.js" +async def recap_time_grant(client, headers, payload): + challenge = await client.post( + "/api/v1/today/recaps/log-time", json=payload, headers=headers + ) + assert challenge.status_code == 428 + authorization = await client.post( + "/api/v1/fresh-authorization", + json={ + "access_token": "correct horse battery staple", + "action": "log_recap_time", + "target": challenge.json()["detail"]["target"], + }, + headers=headers, + ) + assert authorization.status_code == 201 + return authorization.json()["grant"] + + def test_mobile_recap_time_logging_controls_are_explicit_accessible_and_opt_in(): html = (ROOT / "frontend" / "index.html").read_text() source = TODAY_RECAP.read_text() @@ -99,6 +117,148 @@ async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store" +@pytest.mark.anyio +async def test_recap_time_logging_requires_one_exact_single_use_fresh_authorization( + 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 / "login.sqlite3")) + monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3")) + monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3")) + calls = [] + + async def user(): + return {"id": 1, "login": "Timmy"} + + async def log_time(identity, seconds): + calls.append((identity, seconds)) + + monkeypatch.setattr(main, "current_user", user) + monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time) + payload = { + "session_id": "authorization-session", + "items": [{ + "identity": "issue:stackchain/dashboard:589:", + "estimate_minutes": 30, + "actual_minutes": 42, + }], + "log_identities": ["issue:stackchain/dashboard:589:"], + } + 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"]} + missing = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers) + history_before = await client.get("/api/v1/today/recaps") + target = missing.json()["detail"]["target"] + authorization = await client.post( + "/api/v1/fresh-authorization", + json={ + "access_token": "correct horse battery staple", + "action": "log_recap_time", + "target": target, + }, + headers=headers, + ) + grant = authorization.json()["grant"] + changed = {**payload, "items": [{**payload["items"][0], "actual_minutes": 43}]} + mismatched = await client.post( + "/api/v1/today/recaps/log-time", + json=changed, + headers={**headers, "X-Step-Up-Grant": grant}, + ) + accepted = await client.post( + "/api/v1/today/recaps/log-time", + json=payload, + headers={**headers, "X-Step-Up-Grant": grant}, + ) + replay = await client.post( + "/api/v1/today/recaps/log-time", + json=payload, + headers={**headers, "X-Step-Up-Grant": grant}, + ) + + assert missing.status_code == mismatched.status_code == replay.status_code == 428 + assert missing.json()["detail"]["action"] == "log_recap_time" + assert target.startswith("today-recap:") and len(target) == 44 + assert history_before.json()["recaps"] == [] + assert accepted.status_code == 200 + assert calls == [("issue:stackchain/dashboard:589:", 42 * 60)] + + +@pytest.mark.anyio +async def test_recap_time_logging_journals_confirmed_ambiguous_and_retryable_outcomes( + 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 / "login.sqlite3")) + monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3")) + security_db = tmp_path / "security.sqlite3" + monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(security_db)) + + async def user(): + return {"id": 1, "login": "Timmy"} + + async def log_time(identity, _seconds): + if ":590:" in identity: + raise httpx.ReadTimeout("response was lost after sending") + if ":591:" in identity: + raise httpx.ConnectError("request was not sent") + + monkeypatch.setattr(main, "current_user", user) + monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time) + payload = { + "session_id": "private-recap-session", + "items": [ + {"identity": f"issue:stackchain/dashboard:{number}:", "estimate_minutes": 10, "actual_minutes": minutes} + for number, minutes in [(589, 11), (590, 12), (591, 13)] + ], + "log_identities": [ + f"issue:stackchain/dashboard:{number}:" for number in (589, 590, 591) + ], + } + 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"]} + challenge = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers) + authorization = await client.post( + "/api/v1/fresh-authorization", + json={ + "access_token": "correct horse battery staple", + "action": "log_recap_time", + "target": challenge.json()["detail"]["target"], + }, + headers=headers, + ) + response = await client.post( + "/api/v1/today/recaps/log-time", + json=payload, + headers={**headers, "X-Step-Up-Grant": authorization.json()["grant"]}, + ) + events = (await client.get("/api/v1/security-events")).json()["events"] + + assert response.json()["time_logs"] == [ + {"identity": "issue:stackchain/dashboard:589:", "status": "logged"}, + {"identity": "issue:stackchain/dashboard:590:", "status": "verify"}, + {"identity": "issue:stackchain/dashboard:591:", "status": "retry"}, + ] + time_events = [event for event in events if event["kind"] == "gitea_time_logged"] + assert [(event["target"], event["status"]) for event in time_events] == [ + ("stackchain/dashboard#590", "pending"), + ("stackchain/dashboard#589", "completed"), + ] + persisted = security_db.read_bytes() + assert b"private-recap-session" not in persisted + assert challenge.json()["detail"]["target"].encode() not in persisted + + @pytest.mark.anyio async def test_explicit_recap_time_logging_posts_corrected_seconds_once(monkeypatch, tmp_path): monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator") @@ -129,8 +289,18 @@ async def test_explicit_recap_time_logging_posts_corrected_seconds_once(monkeypa 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"]} - created = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers) - replay = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers) + first_grant = await recap_time_grant(client, headers, payload) + created = await client.post( + "/api/v1/today/recaps/log-time", + json=payload, + headers={**headers, "X-Step-Up-Grant": first_grant}, + ) + replay_grant = await recap_time_grant(client, headers, payload) + replay = await client.post( + "/api/v1/today/recaps/log-time", + json=payload, + headers={**headers, "X-Step-Up-Grant": replay_grant}, + ) assert created.status_code == replay.status_code == 200 assert calls == [("issue:stackchain/dashboard:587:", 42 * 60)] @@ -172,8 +342,18 @@ async def test_ambiguous_time_log_failure_is_not_reposted_automatically(monkeypa 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"]} - first = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers) - replay = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers) + first_grant = await recap_time_grant(client, headers, payload) + first = await client.post( + "/api/v1/today/recaps/log-time", + json=payload, + headers={**headers, "X-Step-Up-Grant": first_grant}, + ) + replay_grant = await recap_time_grant(client, headers, payload) + replay = await client.post( + "/api/v1/today/recaps/log-time", + json=payload, + headers={**headers, "X-Step-Up-Grant": replay_grant}, + ) assert calls == 1 assert first.json()["time_logs"] == replay.json()["time_logs"] == [{