From 19732ea5a6851e999df47240592ea1e3b1f1b338 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 27 Aug 2026 17:32:46 +0000 Subject: [PATCH] feat: sync mobile recent work across devices (Closes #1475) --- README.md | 10 +- frontend/dashboard.js | 4 + frontend/index.html | 1 + frontend/mobile-recent-work.js | 164 +++++++++++++++++++++++++---- scripts/rotate_private_state.py | 3 + src/main.py | 49 ++++++++- src/recent_work_store.py | 131 +++++++++++++++++++++++ tests/test_mobile_recent_work.py | 36 +++++++ tests/test_recent_work_api.py | 53 ++++++++++ tests/test_recent_work_store.py | 47 +++++++++ tests/test_rotate_private_state.py | 31 ++++++ 11 files changed, 507 insertions(+), 22 deletions(-) create mode 100644 src/recent_work_store.py create mode 100644 tests/test_recent_work_api.py create mode 100644 tests/test_recent_work_store.py diff --git a/README.md b/README.md index a3a589f..78b1efa 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,14 @@ losing either order. Account changes discard stale responses and retry work. Res canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable. Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path. +Mobile **Recent work** is also portable across signed-in devices. Opening an issue, pull request, +review, Filed item, or update records its canonical detail route locally before navigation and marks +the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks +merge the server list without duplicate routes, while each confirmed account remains bounded to its +five most recent items. Titles, repositories, and routes are encrypted at rest with the shared +private-state key; stale responses from a prior account are discarded. Set +`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`. + Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate. The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only @@ -310,7 +318,7 @@ each envelope to its operation key and field purpose so rows and fields cannot b Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows -likewise migrate on first read. Synchronized Saved Search collections and completed Filed review +likewise migrate on first read. Synchronized Saved Search collections, mobile Recent work, and completed Filed review receipts use the private-state key and authenticate each envelope to its normalized account, preventing rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their revision; existing completed Filed receipts migrate transactionally at diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 9959cce..c75af83 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -435,9 +435,11 @@ mobileRecentWork = createMobileRecentWork({ storage:localStorage, getLogin:() => confirmedOwnerLogin, + fetchJson:fetchReviewJson, document, section:qs('#mobile-recent-work'), list:qs('#mobile-recent-work-list'), + status:qs('#mobile-recent-work-status'), openRoute:fragment => { const sheet = qs('#mobile-queue-sheet'); if (sheet.open) sheet.close(); @@ -445,6 +447,7 @@ workRoute.sync(); }, }); + mobileRecentWork.startLifecycle({window, document}); mobileQueuePriority = createMobileQueuePriority({ storage: localStorage, getLogin: () => confirmedOwnerLogin, @@ -5665,6 +5668,7 @@ String(snapshot.context.user.id) + ':' + activeFlushLogin : ''; mobileQueuePriority.render(); renderMobileQueuePresentation(); + void mobileRecentWork.load(); void mobileQueuePriority.load(); void refreshPhotoDraftInbox(); timerView.restore(todaySync.flush()); diff --git a/frontend/index.html b/frontend/index.html index f320300..9444ef8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2185,6 +2185,7 @@
diff --git a/frontend/mobile-recent-work.js b/frontend/mobile-recent-work.js index dc5c7c1..ccab3c2 100644 --- a/frontend/mobile-recent-work.js +++ b/frontend/mobile-recent-work.js @@ -6,10 +6,17 @@ const storage = options.storage; const getLogin = options.getLogin; + const fetchJson = options.fetchJson; const limit = Math.max(1, Number(options.limit) || 5); const prefix = 'stackchain.mobile-recent-work.v1.'; const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']); + const setTimer = options.setTimeout || setTimeout; + const clearTimer = options.clearTimeout || clearTimeout; + const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150; + let syncFlight = null; + let syncAccount = ''; + let debounceTimer = null; function login() { return String(getLogin?.() || '').trim().toLowerCase(); @@ -35,40 +42,157 @@ } const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180); if (!title) return null; - return { - kind, - ...(repository ? { repository } : {}), - number, - title, - route, - }; + return {kind, ...(repository ? {repository} : {}), number, title, route}; } - function items() { + function normalizeList(value) { + if (!Array.isArray(value)) return []; + const unique = []; + for (const candidate of value) { + const item = normalize(candidate); + if (item && !unique.some(existing => existing.route === item.route)) unique.push(item); + if (unique.length === limit) break; + } + return unique; + } + + function read() { const storageKey = key(); - if (!storageKey) return []; + if (!storageKey) return {items:[], pending:[]}; try { - const parsed = JSON.parse(storage.getItem(storageKey) || '[]'); - if (!Array.isArray(parsed)) return []; - return parsed.map(normalize).filter(Boolean).slice(0, limit); + const parsed = JSON.parse(storage.getItem(storageKey) || 'null'); + if (Array.isArray(parsed)) return {items:normalizeList(parsed), pending:[]}; + return { + items:normalizeList(parsed?.items), + pending:normalizeList(parsed?.pending), + }; } catch (_) { - return []; + return {items:[], pending:[]}; } } - function record(item) { - const storageKey = key(); - const normalized = normalize(item); - if (!storageKey || !normalized) return false; - const next = [normalized, ...items().filter(existing => existing.route !== normalized.route)].slice(0, limit); + function persist(value, accountKey = key()) { + if (!accountKey || accountKey !== key()) return false; try { - storage.setItem(storageKey, JSON.stringify(next)); + storage.setItem(accountKey, JSON.stringify({ + items:normalizeList(value.items), pending:normalizeList(value.pending), + })); return true; } catch (_) { return false; } } + function announce(value = read(), status = null) { + if (!options.status) return; + options.status.textContent = status || (value.pending.length ? 'Sync pending.' : ''); + } + + function items() { + return read().items; + } + + function state() { + const value = read(); + return {pending:value.pending.length > 0, pendingCount:value.pending.length}; + } + + function scheduleSync() { + if (!fetchJson || !key()) return false; + if (debounceTimer) clearTimer(debounceTimer); + debounceTimer = setTimer(() => { + debounceTimer = null; + void sync(); + }, debounceMs); + return true; + } + + function record(item) { + const accountKey = key(); + const normalized = normalize(item); + if (!accountKey || !normalized) return false; + const current = read(); + current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit); + current.pending = [normalized, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit); + if (!persist(current, accountKey)) return false; + announce(current); + render(); + scheduleSync(); + return true; + } + + function adopt(snapshot, accountKey, pending = []) { + if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false; + const remote = normalizeList(snapshot.items); + const unsent = normalizeList(pending); + const value = { + items:normalizeList([...unsent, ...remote]), + pending:unsent, + }; + persist(value, accountKey); + announce(value); + render(); + return true; + } + + async function drain(accountKey) { + while (key() === accountKey) { + const current = read(); + if (!current.pending.length) return current; + const sending = current.pending[current.pending.length - 1]; + announce(current, 'Syncing recent work…'); + try { + const snapshot = await fetchJson('api/v1/recent-work', { + method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending), + }); + if (key() !== accountKey) return read(); + const latest = read(); + const pending = latest.pending.filter(item => item.route !== sending.route); + if (!adopt(snapshot, accountKey, pending)) throw new Error('Recent work response is invalid.'); + } catch (_error) { + if (key() === accountKey) announce(read()); + return read(); + } + } + return read(); + } + + function sync() { + if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; } + const accountKey = key(); + if (!fetchJson || !accountKey || !read().pending.length) return Promise.resolve(read()); + if (syncFlight && syncAccount === accountKey) return syncFlight; + syncAccount = accountKey; + syncFlight = drain(accountKey).finally(() => { + if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; } + }); + return syncFlight; + } + + async function load() { + const accountKey = key(); + if (!fetchJson || !accountKey) return read(); + try { + const snapshot = await fetchJson('api/v1/recent-work'); + if (key() !== accountKey) return read(); + const current = read(); + adopt(snapshot, accountKey, current.pending); + return current.pending.length ? sync() : read(); + } catch (_error) { + if (key() === accountKey) announce(read(), read().pending.length ? null : 'Recent work could not sync.'); + return read(); + } + } + + function startLifecycle(lifecycle = {}) { + const reconcile = () => read().pending.length ? sync() : load(); + lifecycle.window?.addEventListener?.('online', () => { void reconcile(); }); + lifecycle.document?.addEventListener?.('visibilitychange', () => { + if (!lifecycle.document.hidden) void reconcile(); + }); + return reconcile; + } + function render() { const recent = items(); const list = options.list; @@ -98,5 +222,5 @@ return rows.length; } - return { items, record, render }; + return {items, record, render, load, sync, startLifecycle, state}; }); diff --git a/scripts/rotate_private_state.py b/scripts/rotate_private_state.py index 80d37ba..99a436b 100755 --- a/scripts/rotate_private_state.py +++ b/scripts/rotate_private_state.py @@ -81,6 +81,9 @@ STORES = ( Store("queue-priority", "queue-priority", "STACKCHAIN_QUEUE_PRIORITY_DB", "queue-priority.sqlite3", ( Table("queue_priorities", ("login",), (Field("queue_order", "order:{login}"),)), )), + Store("recent-work", "recent-work", "STACKCHAIN_RECENT_WORK_DB", "recent-work.sqlite3", ( + Table("recent_work", ("login",), (Field("items", "items:{login}"),)), + )), Store( "completed-filed-reviews", "completed-filed-reviews", diff --git a/src/main.py b/src/main.py index 12bc6c9..42315ef 100644 --- a/src/main.py +++ b/src/main.py @@ -72,6 +72,7 @@ from src.push_subscription_store import build_push_subscription_store from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.saved_search_store import SavedSearchConflict, SavedSearchStore from src.queue_priority_store import QueuePriorityConflict, QueuePriorityStore +from src.recent_work_store import RecentWorkStore from src.following_store import FollowingStore from src.unfiled_draft_store import ( UnfiledDraftConflict, @@ -971,6 +972,14 @@ class QueuePriorityCollection(BaseModel): order: list[str] = Field(min_length=9, max_length=9) +class RecentWorkItem(BaseModel): + kind: Literal["issue", "filed", "pull", "review", "update"] + repository: str = Field(default="", max_length=200) + number: PositiveInt + title: str = Field(min_length=1, max_length=180) + route: str = Field(min_length=1, max_length=300) + + class CompletedFiledReviewReceipt(BaseModel): repository: str = Field( min_length=3, @@ -1790,7 +1799,7 @@ async def require_operator_session(request: Request, call_next): async def prevent_live_api_caching(request, call_next): response = await call_next(request) path = dashboard_auth.application_path(request) - if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/queue-priority", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or ( + if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/queue-priority", "/api/v1/recent-work", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or ( path.startswith("/api/v1/repos/") and path.endswith("/review") ) or path.startswith("/api/v1/notifications") or ( @@ -3114,6 +3123,12 @@ def _queue_priority_store() -> QueuePriorityStore: ) +def _recent_work_store() -> RecentWorkStore: + return RecentWorkStore( + os.getenv("STACKCHAIN_RECENT_WORK_DB", str(_state_dir / "recent-work.sqlite3")) + ) + + def _following_store() -> FollowingStore: return FollowingStore( os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3")) @@ -3421,6 +3436,38 @@ async def replace_queue_priority(payload: QueuePriorityCollection): ) +@app.get("/api/v1/recent-work") +async def get_recent_work(response: Response): + login = await _confirmed_login() + try: + snapshot = await asyncio.to_thread(_recent_work_store().get, login) + except (OSError, sqlite3.Error, PrivateStateEncryptionError): + raise HTTPException( + status_code=503, + detail="Recent work synchronization is unavailable", + headers={"Retry-After": "1"}, + ) + response.headers["Cache-Control"] = "no-store" + return snapshot + + +@app.post("/api/v1/recent-work") +async def record_recent_work(payload: RecentWorkItem): + login = await _confirmed_login() + try: + return await asyncio.to_thread( + _recent_work_store().record, login, payload.model_dump() + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + except (OSError, sqlite3.Error, PrivateStateEncryptionError): + raise HTTPException( + status_code=503, + detail="Recent work synchronization is unavailable", + headers={"Retry-After": "1"}, + ) + + @app.get("/api/v1/unfiled-drafts") async def get_unfiled_drafts(response: Response): login = await _confirmed_login() diff --git a/src/recent_work_store.py b/src/recent_work_store.py new file mode 100644 index 0000000..36a1663 --- /dev/null +++ b/src/recent_work_store.py @@ -0,0 +1,131 @@ +"""Encrypted, account-scoped recent work shared by signed-in devices.""" + +import sqlite3 +from pathlib import Path + +from src.private_state import connect_private_sqlite +from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config + + +KINDS = frozenset({"issue", "filed", "pull", "review", "update"}) + + +class RecentWorkStore: + def __init__( + self, + path: str | Path, + *, + timeout: float = 1.0, + encryption_key: bytes | None = None, + limit: int = 5, + ): + self.path = Path(path) + self.timeout = timeout + self.limit = max(1, int(limit)) + self._cipher = PrivateStateCipher( + encryption_key if encryption_key is not None else private_state_encryption_config(), + store="recent-work", + ) + with self._connect() as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute( + "CREATE TABLE IF NOT EXISTS recent_work (" + "login TEXT PRIMARY KEY, items TEXT NOT NULL)" + ) + + def _connect(self) -> sqlite3.Connection: + return connect_private_sqlite(self.path, timeout=self.timeout) + + @staticmethod + def _login(login: str) -> str: + normalized = login.strip().lower() + if not normalized: + raise ValueError("login is required") + return normalized + + @staticmethod + def _normalize(item: dict) -> dict: + if not isinstance(item, dict): + raise ValueError("recent work item is invalid") + kind = item.get("kind") + number = item.get("number") + title = item.get("title") + repository = item.get("repository", "") + if ( + kind not in KINDS + or not isinstance(number, int) + or isinstance(number, bool) + or number < 1 + or not isinstance(title, str) + or not title.strip() + ): + raise ValueError("recent work item is invalid") + title = title.strip()[:180] + if kind == "update": + if repository: + raise ValueError("recent work item is invalid") + route = f"#/my-work/update/{number}" + normalized = {"kind": kind, "number": number, "title": title, "route": route} + else: + if ( + not isinstance(repository, str) + or repository.count("/") != 1 + or any(not part or not all(character.isalnum() or character in "_.-" for character in part) + for part in repository.split("/")) + ): + raise ValueError("recent work item is invalid") + route = f"#/my-work/{kind}/{repository}/{number}" + normalized = { + "kind": kind, + "repository": repository, + "number": number, + "title": title, + "route": route, + } + if item.get("route", route) != route: + raise ValueError("recent work item is invalid") + return normalized + + def _items(self, row, login: str) -> tuple[list[dict], bool]: + if row is None: + return [], False + payload, legacy = self._cipher.open(row[0], binding=f"items:{login}") + if not isinstance(payload, list): + raise PrivateStateEncryptionError("private state could not be decrypted") + try: + return [self._normalize(item) for item in payload][: self.limit], legacy + except ValueError as error: + raise PrivateStateEncryptionError("private state could not be decrypted") from error + + def get(self, login: str) -> dict: + login = self._login(login) + with self._connect() as connection: + row = connection.execute( + "SELECT items FROM recent_work WHERE login = ?", (login,) + ).fetchone() + items, legacy = self._items(row, login) + if row is not None and legacy: + connection.execute( + "UPDATE recent_work SET items = ? WHERE login = ? AND items = ?", + (self._cipher.seal(items, binding=f"items:{login}"), login, row[0]), + ) + return {"items": items} + + def record(self, login: str, item: dict) -> dict: + login = self._login(login) + normalized = self._normalize(item) + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT items FROM recent_work WHERE login = ?", (login,) + ).fetchone() + current, _legacy = self._items(row, login) + items = [normalized, *(entry for entry in current if entry["route"] != normalized["route"])] + items = items[: self.limit] + sealed = self._cipher.seal(items, binding=f"items:{login}") + connection.execute( + "INSERT INTO recent_work(login, items) VALUES (?, ?) " + "ON CONFLICT(login) DO UPDATE SET items=excluded.items", + (login, sealed), + ) + return {"items": items} diff --git a/tests/test_mobile_recent_work.py b/tests/test_mobile_recent_work.py index f33ac2c..6f57795 100644 --- a/tests/test_mobile_recent_work.py +++ b/tests/test_mobile_recent_work.py @@ -125,6 +125,37 @@ process.stdout.write(JSON.stringify({{ } +def test_recent_work_records_offline_first_then_merges_the_server_snapshot(): + script = f""" +const createRecentWork = require({json.dumps(str(RECENT_WORK))}); +(async()=>{{ +const values = new Map(); const calls=[]; const status={{textContent:''}}; +const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}}; +const remote={{kind:'pull',repository:'stackchain/api',number:9,title:'Ship API',route:'#/my-work/pull/stackchain/api/9'}}; +const local={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix queue',route:'#/my-work/issue/stackchain/dashboard/7'}}; +const recent=createRecentWork({{ + storage,getLogin:()=>'alice',status,debounceMs:99999, + fetchJson:async (url, options={{}})=>{{ + calls.push([url,options.method||'GET']); + return options.method==='POST' ? {{items:[local,remote]}} : {{items:[remote]}}; + }}, +}}); +recent.record(local); +const immediate={{items:recent.items(),status:status.textContent,state:recent.state()}}; +await recent.sync(); +process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:status.textContent,calls}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + payload = run_node(script) + + assert payload["immediate"]["items"][0]["number"] == 7 + assert payload["immediate"]["status"] == "Sync pending." + assert payload["immediate"]["state"]["pending"] is True + assert [item["number"] for item in payload["settled"]] == [7, 9] + assert payload["status"] == "" + assert payload["calls"] == [["api/v1/recent-work", "POST"]] + + def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes(): html = INDEX.read_text() dashboard = DASHBOARD.read_text() @@ -132,8 +163,13 @@ def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes(): assert 'id="mobile-recent-work"' in html assert 'id="mobile-recent-work-list"' in html + assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html assert '' in html assert "createMobileRecentWork({" in dashboard + assert "fetchJson:fetchReviewJson" in dashboard + assert "status:qs('#mobile-recent-work-status')" in dashboard + assert "mobileRecentWork.startLifecycle({window, document})" in dashboard + assert "void mobileRecentWork.load();" in dashboard assert "mobileRecentWork.record(item)" in dashboard assert "mobileRecentWork.render()" in dashboard assert "workRoute.sync()" in dashboard diff --git a/tests/test_recent_work_api.py b/tests/test_recent_work_api.py new file mode 100644 index 0000000..5241d14 --- /dev/null +++ b/tests/test_recent_work_api.py @@ -0,0 +1,53 @@ +import httpx +import pytest + +from src import main + + +@pytest.mark.anyio +async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_account_scoped( + 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_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3")) + active_login = "Timmy" + + async def user(): + return {"id": 1, "login": active_login} + + monkeypatch.setattr(main, "current_user", user) + entry = { + "kind": "issue", + "repository": "stackchain/dashboard", + "number": 1475, + "title": "Sync recent work", + "route": "#/my-work/issue/stackchain/dashboard/1475", + } + 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"} + ) + forbidden = await client.post("/api/v1/recent-work", json=entry) + headers = { + "Origin": "https://test", + "X-CSRF-Token": client.cookies["stackchain_csrf"], + } + saved = await client.post("/api/v1/recent-work", json=entry, headers=headers) + fetched = await client.get("/api/v1/recent-work") + active_login = "Alexander" + other_account = await client.get("/api/v1/recent-work") + + assert forbidden.status_code == 403 + assert saved.status_code == 200 + assert saved.json() == {"items": [entry]} + assert fetched.json() == saved.json() + assert fetched.headers["cache-control"] == "no-store" + assert other_account.json() == {"items": []} diff --git a/tests/test_recent_work_store.py b/tests/test_recent_work_store.py new file mode 100644 index 0000000..bdac945 --- /dev/null +++ b/tests/test_recent_work_store.py @@ -0,0 +1,47 @@ +import sqlite3 + +from src.recent_work_store import RecentWorkStore + + +def item(number: int, *, title: str | None = None) -> dict: + return { + "kind": "issue", + "repository": "stackchain/dashboard", + "number": number, + "title": title or f"Issue {number}", + "route": f"#/my-work/issue/stackchain/dashboard/{number}", + } + + +def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_path): + database = tmp_path / "recent-work.sqlite3" + store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5) + + for number in range(1, 7): + store.record(" Timmy ", item(number)) + expected = store.record("timmy", item(3, title="Issue 3 updated")) + + assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2] + assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected + assert store.get("alexander") == {"items": []} + with sqlite3.connect(database) as connection: + payload = connection.execute( + "SELECT items FROM recent_work WHERE login = 'timmy'" + ).fetchone()[0] + assert payload.startswith("v1:") + assert "Issue 3 updated" not in payload + assert "#/my-work/issue" not in payload + + +def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path): + store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32) + + invalid = item(1) + invalid["route"] = "https://attacker.example/" + + try: + store.record("timmy", invalid) + except ValueError as error: + assert str(error) == "recent work item is invalid" + else: + raise AssertionError("invalid route was accepted") diff --git a/tests/test_rotate_private_state.py b/tests/test_rotate_private_state.py index 60e9545..f3fca9e 100644 --- a/tests/test_rotate_private_state.py +++ b/tests/test_rotate_private_state.py @@ -8,6 +8,7 @@ from pathlib import Path from src.completed_filed_review_store import CompletedFiledReviewStore from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore +from src.recent_work_store import RecentWorkStore from src.saved_search_store import SavedSearchStore @@ -138,3 +139,33 @@ def test_rotation_command_rewraps_mobile_queue_priority(tmp_path): assert QueuePriorityStore( path, encryption_key=({"next": b"n" * 32}, "next") ).get("timmy") == expected + + +def test_rotation_command_rewraps_recent_work_without_printing_titles(tmp_path): + state = tmp_path / "state" + path = state / "recent-work.sqlite3" + private_item = { + "kind": "issue", + "repository": "private/canary", + "number": 1475, + "title": "Secret release investigation", + "route": "#/my-work/issue/private/canary/1475", + } + expected = RecentWorkStore(path, encryption_key=b"o" * 32).record("timmy", private_item) + + completed = run_rotation(state) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout)["recent-work"] == { + "current": 0, "failed": 0, "migrated": 1, "total": 1 + } + assert private_item["title"] not in completed.stdout + assert "timmy" not in completed.stdout + with sqlite3.connect(path) as connection: + payload = connection.execute( + "SELECT items FROM recent_work WHERE login = 'timmy'" + ).fetchone()[0] + assert payload.startswith("v2:next:") + assert RecentWorkStore( + path, encryption_key=({"next": b"n" * 32}, "next") + ).get("timmy") == expected -- 2.43.0