feat: sync mobile recent work across devices (Closes #1475)
All checks were successful
CI / lint (pull_request) Successful in 3m47s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m29s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-27 17:32:46 +00:00
parent 12a7a3c4b0
commit 19732ea5a6
11 changed files with 507 additions and 22 deletions

View File

@ -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

View File

@ -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());

View File

@ -2185,6 +2185,7 @@
</section>
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
<h3 id="mobile-recent-work-heading">Recent work</h3>
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
</section>
<details class="mobile-queue-priority" id="mobile-queue-priority">

View File

@ -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};
}
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 {items:[], pending:[]};
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (Array.isArray(parsed)) return {items:normalizeList(parsed), pending:[]};
return {
kind,
...(repository ? { repository } : {}),
number,
title,
route,
items:normalizeList(parsed?.items),
pending:normalizeList(parsed?.pending),
};
}
function items() {
const storageKey = key();
if (!storageKey) return [];
try {
const parsed = JSON.parse(storage.getItem(storageKey) || '[]');
if (!Array.isArray(parsed)) return [];
return parsed.map(normalize).filter(Boolean).slice(0, limit);
} 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};
});

View File

@ -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",

View File

@ -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()

131
src/recent_work_store.py Normal file
View File

@ -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}

View File

@ -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 '<script src="static/mobile-recent-work.js"></script>' 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

View File

@ -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": []}

View File

@ -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")

View File

@ -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