feat: sync mobile recent work across devices (Closes #1475)
This commit is contained in:
1 parent
12a7a3c4b0
commit
19732ea5a6
11 files changed
+507
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": []}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in new issue
Block a user