diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index d022b0a..b48c27f 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -315,6 +315,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, error: String(background.error || 'Message needs attention').slice(0, 240), ...(background.deliveryState ? { deliveryState: background.deliveryState } : {}), }]; + if (background?.status === 'authorization') return [{ + ...item, + status: 'authorization', + error: String(background.error || 'Fresh authorization required').slice(0, 240), + }]; return [item]; }); write(items); diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 9ea5c13..1f552fb 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -85,7 +85,8 @@ function createIssueSyncStore({ incoming.set(current.id, replacement); } if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) || - (current.status === 'attention' && replacement.status === 'attention') || + (['attention', 'authorization'].includes(current.status) && + replacement.status === current.status) || current.status === 'sent') { incoming.set(current.id, current); } @@ -153,7 +154,8 @@ function createIssueSyncStore({ return transact(async records => { const current = (await records.getAll()).find(candidate => candidate.id === item.id); if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) || - (current.status === 'attention' && item.status === 'attention') || + (['attention', 'authorization'].includes(current.status) && + item.status === current.status) || current.status === 'sent')) return current; const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) && item?.attachment?.stored && !item.attachment.data && !item.attachment.blob @@ -243,6 +245,9 @@ function createIssueSyncStore({ status: 'attention', error, ...(deliveryState ? { deliveryState } : {}), })), + authorization: (id, claimToken, error) => updateClaim( + id, claimToken, item => clearClaim(item, { status: 'authorization', error }) + ), snapshot: () => transact(async records => (await records.getAll()).filter(item => item.recordType !== 'receipt-preference')), countBlocked: ownerLogin => transact(async records => @@ -271,6 +276,9 @@ function createBackgroundIssueSync({ const failClaim = (item, error, deliveryState) => store.supportsClaimTokens ? store.fail(item.id, item.claimToken, error, deliveryState) : store.fail(item.id, error, deliveryState); + const requireAuthorization = (item, error) => store.supportsClaimTokens + ? store.authorization(item.id, item.claimToken, error) + : store.authorization(item.id, error); const checkpointClaim = (item, transform) => store.supportsClaimTokens ? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform); @@ -560,6 +568,15 @@ function createBackgroundIssueSync({ await releaseClaim(item); throw error; } + if (status === 428 && item.kind === 'pull-review') { + const message = String(error?.message || 'Fresh authorization required').slice(0, 240); + await requireAuthorization(item, message); + return { + authorization: true, + error, + receipt: receiptFor(item, 'authorization'), + }; + } if (status >= 400 && status < 500) { await failClaim( item, @@ -590,11 +607,13 @@ function createBackgroundIssueSync({ const confirmed = []; const receipts = []; let attention = 0; - if (!login) return { confirmed, blocked: 0, attention, login, receipts }; + let authorization = 0; + if (!login) return { confirmed, blocked: 0, attention, authorization, login, receipts }; const collect = result => { if (result.issue) confirmed.push(result.issue); if (result.message) confirmed.push(result.message); if (result.attention) attention += 1; + if (result.authorization) authorization += 1; if (result.receipt) receipts.push(result.receipt); }; if (store.planBatch) { @@ -655,7 +674,7 @@ function createBackgroundIssueSync({ } } const blocked = store.countBlocked ? await store.countBlocked(login) : 0; - return { confirmed, blocked, attention, login, receipts }; + return { confirmed, blocked, attention, authorization, login, receipts }; } function flush() { diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 7e7c887..a5a6f94 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1657,6 +1657,10 @@ '' + '' + '' : + reviewOutbox && item.status === 'authorization' ? + '' + + '' + + '' : reviewOutbox && item.status === 'attention' ? '' + '' + @@ -1778,11 +1782,17 @@ const item = lastDrafts[Number(button.dataset.draftIndex)]; if (!item?.outbox_id || !activeFlushLogin) return; button.disabled = true; - qs('#my-work-action-status').textContent = 'Fresh authorization required for this exact issue.'; + const reviewAuthorization = item.outbox_kind === 'pull-review'; + qs('#my-work-action-status').textContent = reviewAuthorization ? + 'Fresh authorization required for this exact review decision and head.' : + 'Fresh authorization required for this exact issue.'; const result = await authoredOutbox.retry(item.outbox_id, activeFlushLogin); applyAuthoredOutboxResult(result); qs('#my-work-action-status').textContent = result.confirmed?.length ? - 'Issue closed and queued intent cleared.' : 'Issue closure was not confirmed. The queued intent is still safe.'; + (reviewAuthorization ? 'Review submitted and queued intent cleared.' : + 'Issue closed and queued intent cleared.') : + (reviewAuthorization ? 'Review was not confirmed. The queued review and feedback are still safe.' : + 'Issue closure was not confirmed. The queued intent is still safe.'); }); }); list.querySelectorAll('.draft-copy').forEach(button => { diff --git a/frontend/drafts.js b/frontend/drafts.js index 75ec1f1..c62e9ba 100644 --- a/frontend/drafts.js +++ b/frontend/drafts.js @@ -161,11 +161,12 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat outbox_kind: item.kind, kind: 'authored-outbox', status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : - (isClosure ? 'authorization' : 'queued')), + (item.status === 'authorization' || isClosure ? 'authorization' : 'queued')), label: item.deliveryState === 'uncertain' ? 'Verify delivery' : (item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') : - (isReview ? 'Queued review' : (isClosure ? 'Awaiting authorization' : 'Queued message'))), - authorization_required: isClosure, + (isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') : + (isClosure ? 'Awaiting authorization' : 'Queued message'))), + authorization_required: isClosure || (isReview && item.status === 'authorization'), delivery_state: item.deliveryState, repository: isUpdate ? '' : item.repository, title: target, diff --git a/frontend/service-worker.js b/frontend/service-worker.js index c22262d..c1f2d3b 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v84'; +const CACHE = 'stackchain-dashboard-shell-v85'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; @@ -205,11 +205,13 @@ async function flushAndNotify() { !await issueSync.getReceiptPreference?.(result.login)) return; for (const receipt of result.receipts) { const needsAttention = receipt.status === 'attention'; - const title = needsAttention + const needsAuthorization = receipt.status === 'authorization'; + const title = needsAuthorization ? 'Queued review needs authorization' : needsAttention ? 'Queued work needs attention' : receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent'; await self.registration.showNotification(title, { - body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.', + body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' : + needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.', tag: 'stackchain-delivery-' + receipt.id, data: { route: receipt.route }, }); diff --git a/src/main.py b/src/main.py index f293ffb..986abf2 100644 --- a/src/main.py +++ b/src/main.py @@ -204,6 +204,7 @@ class FreshAuthorization(BaseModel): access_token: str = Field(min_length=1, max_length=1_024) action: Literal[ "merge_pull", + "submit_pull_review", "close_issue", "revoke_device", "revoke_all_sessions", @@ -3793,12 +3794,26 @@ async def merge_assigned_pull( @app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201) async def submit_review( submission: PullReviewSubmission, + request: Request, owner: str, repo: str, number: int, idempotency_key: str | None = Header(default=None, max_length=128), + step_up_grant: str | None = Header( + default=None, alias="X-Step-Up-Grant", max_length=128 + ), ): repository = f"{owner}/{repo}" + if submission.decision != "comment": + await _require_step_up( + request, + step_up_grant, + action="submit_pull_review", + target=( + f"{repository}#{number}@{submission.expected_head_sha}:" + f"{submission.decision}" + ), + ) async def submit_requested_review(): if not await is_requested_review(repository, number): diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py index 3e2846b..3df1173 100644 --- a/tests/test_authored_outbox.py +++ b/tests/test_authored_outbox.py @@ -480,6 +480,35 @@ outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',ope assert output["remaining"] == [] +def test_authorization_hold_can_resume_in_foreground_with_original_review_identity(): + script = f""" +const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); +const values=new Map();const sent=[]; +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const outbox=createAuthoredOutbox({{ + storage,getOwnerLogin:()=>'timmy', + backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}}, + send:async item=>{{sent.push({{operationId:item.operationId,status:item.status, + head:item.expectedHeadSha,decision:item.decision}});return{{message:{{id:91}}}};}}}}, +}}); +outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,operationId:'review-stable', + body:'Ship it',decision:'approve',expectedHeadSha:'abc123'}}); +outbox.reconcileBackground([{{id:'review-stable',kind:'pull-review',status:'authorization', + error:'Fresh authorization required'}}]); +(async()=>{{const held=outbox.list()[0];const result=await outbox.retry(held.id,'timmy'); +process.stdout.write(JSON.stringify({{held,sent,result,remaining:outbox.list()}}));}})(); +""" + output = run_node(script) + + assert output["held"]["status"] == "authorization" + assert output["sent"] == [{ + "operationId": "review-stable", "status": "authorization", + "head": "abc123", "decision": "approve", + }] + assert output["result"]["confirmed"] == [{"id": 91}] + assert output["remaining"] == [] + + @pytest.mark.anyio async def test_mobile_dashboard_loads_and_operates_authored_message_outbox(): html = await dashboard() diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index fd2ac8d..984b4b1 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -409,6 +409,41 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo }] +def test_closed_app_sync_holds_consequential_review_for_foreground_authorization(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const item={{id:'review-op',operationId:'review-op',ownerLogin:'timmy',status:'queued', + kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good', + decision:'approve',expectedHeadSha:'abc123',comments:[]}}; +const state={{authorized:[],failed:[],released:[],completed:[]}};let claimed=false; +const store={{ + claimNext:async()=>claimed?null:(claimed=true,item), + authorization:async(id,error)=>state.authorized.push({{id,error}}), + fail:async(...args)=>state.failed.push(args),release:async id=>state.released.push(id), + complete:async id=>state.completed.push(id),countBlocked:async()=>0, +}}; +const fetchJson=async url=>{{ + if(url==='api/v1/background-identity')return{{login:'timmy'}}; + const error=new Error('Fresh authorization required');error.status=428; + error.code='step_up_required';throw error; +}}; +(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush(); +process.stdout.write(JSON.stringify({{state,result}}));}})(); +""" + output = run_node(script) + + assert output["state"] == { + "authorized": [{"id": "review-op", "error": "Fresh authorization required"}], + "failed": [], "released": [], "completed": [], + } + assert output["result"]["attention"] == 0 + assert output["result"]["authorization"] == 1 + assert output["result"]["receipts"] == [{ + "id": "review-op", "status": "authorization", "kind": "message", + "route": "#/my-work/review/stackchain/web/8", + }] + + def test_closed_app_sync_leaves_issue_closure_awaiting_foreground_authorization(): records = [ { diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 4c063bd..5770c1c 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v84" in worker + assert "stackchain-dashboard-shell-v85" in worker diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 4049221..b9f37c9 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -953,6 +953,91 @@ async def test_other_high_impact_routes_require_fresh_authorization_before_mutat assert laptop_still_active.status_code == 200 +@pytest.mark.anyio +async def test_consequential_review_requires_decision_and_head_bound_fresh_authorization( + access_control, monkeypatch +): + calls = [] + + async def requested(repository, number): + calls.append(("requested", repository, number)) + return True + + async def submit(repository, number, head, decision, body): + calls.append(("submit", repository, number, head, decision, body)) + return {"id": 91, "state": "APPROVED"} + + monkeypatch.setattr(main, "is_requested_review", requested) + monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit) + transport = httpx.ASGITransport(app=main.app) + payload = { + "expected_head_sha": "abc123", + "decision": "approve", + "body": "Ready to ship.", + } + 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/repos/stackchain/api/pulls/7/review", + json=payload, + headers=headers, + ) + wrong_grant = await fresh_grant( + client, + "submit_pull_review", + "stackchain/api#7@different:approve", + ) + mismatched = await client.post( + "/api/v1/repos/stackchain/api/pulls/7/review", + json=payload, + headers={**headers, "X-Step-Up-Grant": wrong_grant}, + ) + grant = await fresh_grant( + client, + "submit_pull_review", + "stackchain/api#7@abc123:approve", + ) + approved = await client.post( + "/api/v1/repos/stackchain/api/pulls/7/review", + json=payload, + headers={**headers, "X-Step-Up-Grant": grant}, + ) + replayed = await client.post( + "/api/v1/repos/stackchain/api/pulls/7/review", + json=payload, + headers={**headers, "X-Step-Up-Grant": grant}, + ) + comment = await client.post( + "/api/v1/repos/stackchain/api/pulls/7/review", + json={**payload, "decision": "comment"}, + headers=headers, + ) + + assert [missing.status_code, mismatched.status_code] == [428, 428] + assert missing.json()["detail"] == { + "detail": "Fresh authorization required", + "code": "step_up_required", + "action": "submit_pull_review", + "target": "stackchain/api#7@abc123:approve", + } + assert approved.status_code == 201 + assert replayed.status_code == 428 + assert comment.status_code == 201 + assert calls == [ + ("requested", "stackchain/api", 7), + ("submit", "stackchain/api", 7, "abc123", "approve", "Ready to ship."), + ("requested", "stackchain/api", 7), + ("submit", "stackchain/api", 7, "abc123", "comment", "Ready to ship."), + ] + + @pytest.mark.anyio async def test_operator_can_review_and_revoke_one_remote_device(access_control): transport = httpx.ASGITransport(app=main.app) diff --git a/tests/test_drafts.py b/tests/test_drafts.py index 8d8f744..85beef0 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -142,6 +142,27 @@ process.stdout.write(JSON.stringify(item)); assert output["quarantined"] is False +def test_draft_inbox_exposes_held_review_as_awaiting_foreground_authorization(): + script = f""" +const createDraftInbox = require({json.dumps(str(DRAFTS))}); +const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{ + id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good', + decision:'approve',expectedHeadSha:'abc',comments:[],ownerLogin:'timmy', + status:'authorization',error:'Fresh authorization required',queuedAt:200 +}}]}})]]); +const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}); +process.stdout.write(JSON.stringify({{item:inbox.list()[0],partition:inbox.partition()}})); +""" + output = run_node(script) + + assert output["item"]["status"] == "authorization" + assert output["item"]["label"] == "Review awaiting authorization" + assert output["item"]["authorization_required"] is True + assert output["partition"]["counts"]["authorization"] == 1 + assert output["partition"]["retryable"] == [] + + def test_draft_inbox_exposes_queued_issue_closure_as_awaiting_authorization(): script = f""" const createDraftInbox = require({json.dumps(str(DRAFTS))}); diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 34c30f2..9422f8f 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 79e19ab..5ad880e 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v84" in worker + assert "stackchain-dashboard-shell-v85" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 6b0a573..ff3896e 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -41,7 +41,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v84" in worker + assert "stackchain-dashboard-shell-v85" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 1d80025..075e22d 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -4800,6 +4800,9 @@ async def test_review_attention_drafts_require_revision_instead_of_resending_sta assert "reviewOutbox && item.status === 'attention'" in html assert ">Open current review" in html assert ">Copy feedback" in html + assert "reviewOutbox && item.status === 'authorization'" in html + assert ">Authorize & send review" in html + assert "Review submitted and queued intent cleared." in html assert "item.kind === 'authored-outbox' && !reviewOutbox" in html diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index e96e5e9..9b13000 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 47b311a..52d7055 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/update-ownership.js'" in source @@ -328,6 +328,7 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou state.flushResult = {login:'timmy', receipts:[ {id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'}, {id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'}, + {id:'review-1',status:'authorization',kind:'message',route:'#/my-work/drafts'}, ]}; state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}]; await dispatchSync('stackchain-issue-outbox-v1'); @@ -353,6 +354,14 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou "data": {"route": "#/my-work/drafts"}, }, }, + { + "title": "Queued review needs authorization", + "options": { + "body": "Tap to authorize it in the Delivery center.", + "tag": "stackchain-delivery-review-1", + "data": {"route": "#/my-work/drafts"}, + }, + }, ] assert result["focused"] == [ "https://forge.example/dashboard/#/my-work/issue/stackchain/api/44" @@ -385,7 +394,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 5f2600f..4e6d9b5 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v84';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v85';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index f11283d..bdb51ff 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v84" in source + assert "stackchain-dashboard-shell-v85" in source assert "BASE + 'static/today-sync.js'" in source