feat: authorize and audit recap time logging (Closes #589)
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 20:26:10 +00:00
1 parent 53bf89bd47
commit 379f2b8203
4 files changed
+242 -5

No files matched your search

+6
View File
@@ -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</button>' 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
+184 -4
View File
@@ -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"] == [{