From a73b3bc7730aedf9d3660da8c3b8d94fe293a58b Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 7 Aug 2026 22:57:49 +0000 Subject: [PATCH] feat: bind offline outboxes to Gitea identity (#238) --- frontend/authored-outbox.js | 31 +++++++++++++------- frontend/drafts.js | 26 +++++++++++++---- frontend/index.html | 55 +++++++++++++++++++++++++---------- frontend/issue-outbox.js | 31 +++++++++++++------- frontend/service-worker.js | 2 +- tests/test_authored_outbox.py | 50 ++++++++++++++++++++++++------- tests/test_drafts.py | 45 ++++++++++++++++++++++++++++ tests/test_issue_outbox.py | 54 +++++++++++++++++++++++++++------- tests/test_service_worker.py | 6 ++++ 9 files changed, 237 insertions(+), 63 deletions(-) diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index e26cb36..1b3d57d 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -1,4 +1,4 @@ -function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 50 }) { +function createAuthoredOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) { const storageKey = 'stackchain.authored-outbox.v1'; const makeId = createOperationId || (() => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) @@ -9,17 +9,19 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () function read() { try { const record = JSON.parse(storage?.getItem(storageKey) || 'null'); - if (record?.version !== 1 || !Array.isArray(record.items)) return []; + if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return []; return record.items.filter(item => item && supportedKinds.has(item.kind)); } catch (_error) { return []; } } function write(items) { - storage?.setItem(storageKey, JSON.stringify({ version: 1, items })); + storage?.setItem(storageKey, JSON.stringify({ version: 2, items })); } function enqueue(message) { if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.'); + const ownerLogin = String(getOwnerLogin() || '').trim(); + if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.'); const items = read(); const requestedOperationId = String(message.operationId || '').slice(0, 128); const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId); @@ -34,6 +36,7 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () number: Number(message.number || 0), notificationId: Number(message.notificationId || 0), body: String(message.body || ''), + ownerLogin, status: 'queued', queuedAt: Number(now()), }; @@ -75,7 +78,8 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments'; } - async function sendItem(item) { + async function sendItem(item, currentLogin) { + if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; if (pending.has(item.id)) return pending.get(item.id); const request = (async () => { try { @@ -108,23 +112,30 @@ function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () finally { if (pending.get(item.id) === request) pending.delete(item.id); } } - async function flush() { + async function flush(currentLogin) { const confirmed = []; + let blocked = 0; + currentLogin = String(currentLogin || '').trim(); for (const item of read()) { if (item.status === 'attention') continue; - const outcome = await sendItem(item); + if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; } + const outcome = await sendItem(item, currentLogin); if (outcome.result) confirmed.push(outcome.result); if (outcome.transient) break; } - return { confirmed, remaining: read() }; + return { confirmed, remaining: read(), blocked }; } - async function retry(id) { + async function retry(id, currentLogin) { const item = read().find(candidate => candidate.id === id); if (!item) return { confirmed: [], remaining: read() }; + currentLogin = String(currentLogin || '').trim(); + if (!currentLogin || item.ownerLogin !== currentLogin) { + return { confirmed: [], remaining: read(), blocked: 1 }; + } const queued = item.status === 'attention' ? update(id, item) : item; - const outcome = await sendItem(queued); - return { confirmed: outcome.result ? [outcome.result] : [], remaining: read() }; + const outcome = await sendItem(queued, currentLogin); + return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 }; } return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) }; diff --git a/frontend/drafts.js b/frontend/drafts.js index e8473dd..61c7b2d 100644 --- a/frontend/drafts.js +++ b/frontend/drafts.js @@ -1,4 +1,4 @@ -function createDraftInbox({ storage, now = () => Date.now() }) { +function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Date.now() }) { const indexKey = 'stackchain.draft-index.v1'; function readIndex() { @@ -101,10 +101,14 @@ function createDraftInbox({ storage, now = () => Date.now() }) { function parseOutbox(raw) { try { const record = JSON.parse(raw); - if (record?.version !== 1 || !Array.isArray(record.items)) return []; + if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return []; + const currentLogin = String(getCurrentLogin() || '').trim(); return record.items.filter(item => item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string' - ).map(item => ({ + ).map(item => { + const ownerLogin = String(item.ownerLogin || '').trim(); + const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin; + return { id: 'stackchain.issue-outbox.v1:' + item.id, outbox_id: item.id, kind: 'issue-outbox', @@ -113,20 +117,28 @@ function createDraftInbox({ storage, now = () => Date.now() }) { repository: item.repository, title: textPreview(item.title) || 'Untitled queued issue', preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')), + copy_text: [item.title, item.body].filter(Boolean).join('\n\n'), + quarantined, + ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') + + (currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '', updated_at: Number(item.queuedAt || 0), - })); + }; + }); } catch (_error) { return []; } } function parseAuthoredOutbox(raw) { try { const record = JSON.parse(raw); - if (record?.version !== 1 || !Array.isArray(record.items)) return []; + if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return []; + const currentLogin = String(getCurrentLogin() || '').trim(); return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string') .map(item => { const isUpdate = item.kind === 'update-reply'; const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue'; const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number; + const ownerLogin = String(item.ownerLogin || '').trim(); + const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin; return { id: 'stackchain.authored-outbox.v1:' + item.id, outbox_id: item.id, @@ -136,6 +148,10 @@ function createDraftInbox({ storage, now = () => Date.now() }) { repository: isUpdate ? '' : item.repository, title: target, preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')), + copy_text: item.body, + quarantined, + ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') + + (currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '', updated_at: Number(item.queuedAt || 0), route: isUpdate ? { kind:'update', notification_id:item.notificationId } : { kind:routeKind, repository:item.repository, number:item.number }, diff --git a/frontend/index.html b/frontend/index.html index e370552..336d278 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -847,6 +847,8 @@ textarea { resize: vertical; min-height: 120px; } let bulkMarkPending = false; let reviewHandoffPending = false; let editingOutboxId = null; + let confirmedOwnerLogin = ''; + let activeFlushLogin = ''; async function fetchReviewJson(url, options) { const response = await fetch(url, options); @@ -876,8 +878,12 @@ textarea { resize: vertical; min-height: 120px; } loadMilestones: item => issueController.loadMilestones(item), }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); - const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson }); - const authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson }); + const issueOutbox = createIssueOutbox({ + storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin, + }); + const authoredOutbox = createAuthoredOutbox({ + storage: localStorage, fetchJson: fetchReviewJson, getOwnerLogin: () => confirmedOwnerLogin, + }); const shareParams = new URLSearchParams(location.search); const sharedLaunch = { title: shareParams.get('title') || '', @@ -887,7 +893,7 @@ textarea { resize: vertical; min-height: 120px; } let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ? issueCapture.stageSharedContent(sharedLaunch) : null; const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); - const draftInbox = createDraftInbox({ storage: localStorage }); + const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); const offlineWorkStore = createOfflineWorkStore({ storage: localStorage }); const findWorkController = createFindWork({ fetchJson: fetchReviewJson, @@ -1242,6 +1248,7 @@ textarea { resize: vertical; min-height: 120px; } function handleContextError(e) { console.error('context failed', e); liveMode = false; + activeFlushLogin = ''; if (!hasContextSnapshot && hydrateOfflineWork('outage')) return; setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable'); if (!hasContextSnapshot) { @@ -1301,7 +1308,10 @@ textarea { resize: vertical; min-height: 120px; } const list = qs('#my-work-list'); list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => { const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; - const outboxActions = item.kind === 'issue-outbox' ? + const outboxActions = item.quarantined ? + '' + + '' : + item.kind === 'issue-outbox' ? '' + '' + '' : @@ -1312,7 +1322,9 @@ textarea { resize: vertical; min-height: 120px; } '' + ''; const state = isOutbox ? - '' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '' : ''; + '' + (item.quarantined ? 'Identity protected' : + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '' + + (item.ownership ? '
' + escapeHtml(item.ownership) + '
' : '') : ''; return '
' + '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' + '' + escapeHtml(item.title) + '' + @@ -1344,8 +1356,16 @@ textarea { resize: vertical; min-height: 120px; } const item = lastDrafts[Number(button.dataset.draftIndex)]; if (!item?.outbox_id) return; button.disabled = true; - if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id)); - else applyOutboxResult(await issueOutbox.retry(item.outbox_id)); + if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id, activeFlushLogin)); + else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin)); + }); + }); + list.querySelectorAll('.draft-copy').forEach(button => { + button.addEventListener('click', async () => { + const item = lastDrafts[Number(button.dataset.draftIndex)]; + if (!item?.copy_text) return; + await navigator.clipboard.writeText(item.copy_text); + qs('#my-work-action-status').textContent = 'Queued content copied without sending it.'; }); }); list.querySelectorAll('.draft-discard').forEach(button => { @@ -2022,8 +2042,8 @@ textarea { resize: vertical; min-height: 120px; } } async function flushIssueOutbox() { - if (!navigator.onLine || !issueOutbox.list().length) return; - applyOutboxResult(await issueOutbox.flush()); + if (!navigator.onLine || !activeFlushLogin || !issueOutbox.list().length) return; + applyOutboxResult(await issueOutbox.flush(activeFlushLogin)); } function applyAuthoredOutboxResult(result) { @@ -2036,8 +2056,8 @@ textarea { resize: vertical; min-height: 120px; } } async function flushAuthoredOutbox() { - if (!navigator.onLine || !authoredOutbox.list().length) return; - applyAuthoredOutboxResult(await authoredOutbox.flush()); + if (!navigator.onLine || !activeFlushLogin || !authoredOutbox.list().length) return; + applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin)); } function canQueueMessage(error) { @@ -2259,6 +2279,10 @@ textarea { resize: vertical; min-height: 120px; } } if (snapshot.context) { setOfflineWorkMode(false); + const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale && + !contextFreshness?.degraded && !contextFreshness?.revalidating; + activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; + if (activeFlushLogin) confirmedOwnerLogin = activeFlushLogin; snapshot.context.notifications = lastNotifications; renderContextSnapshot(snapshot.context); if (contextFreshness?.stale) markMyWorkStale(); @@ -2271,6 +2295,8 @@ textarea { resize: vertical; min-height: 120px; } }); updateOfflineWorkControls(); } + flushIssueOutbox(); + flushAuthoredOutbox(); } else handleContextError(new Error('Context section unavailable')); if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events); if (eventsFreshness?.revalidating) { @@ -2669,7 +2695,7 @@ textarea { resize: vertical; min-height: 120px; } closeCreateIssueSheet(); refreshMyWorkView(); qs('#my-work-action-status').textContent = 'Queued for sync.'; - if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id), true); + if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id, activeFlushLogin), true); } catch (error) { qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.'; button.disabled = false; @@ -3222,6 +3248,7 @@ textarea { resize: vertical; min-height: 120px; } return true; } function showOfflineStatus() { + activeFlushLogin = ''; offlineStatus.hidden = false; setStatus('Offline'); if (!hasContextSnapshot) hydrateOfflineWork(); @@ -3248,8 +3275,6 @@ textarea { resize: vertical; min-height: 120px; } if (!navigator.onLine) showOfflineStatus(); window.addEventListener('offline', showOfflineStatus); window.addEventListener('online', reconnectLiveData); - window.addEventListener('online', flushIssueOutbox); - window.addEventListener('online', flushAuthoredOutbox); qs('#refresh').addEventListener('click', load); qs('#start-work-session').addEventListener('click', () => { @@ -3334,8 +3359,6 @@ textarea { resize: vertical; min-height: 120px; } ); } contextPoller.start(); - flushIssueOutbox(); - flushAuthoredOutbox(); document.addEventListener('visibilitychange', () => { contextPoller.setVisible(!document.hidden); }); diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index 871c8c4..44be1ff 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -1,4 +1,4 @@ -function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 20 }) { +function createIssueOutbox({ storage, fetchJson, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) { const storageKey = 'stackchain.issue-outbox.v1'; const operationId = createOperationId || (() => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) @@ -8,16 +8,18 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => function read() { try { const record = JSON.parse(storage?.getItem(storageKey) || 'null'); - if (record?.version !== 1 || !Array.isArray(record.items)) return []; + if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return []; return record.items.filter(item => item && typeof item === 'object'); } catch (_error) { return []; } } function write(items) { - storage?.setItem(storageKey, JSON.stringify({ version: 1, items })); + storage?.setItem(storageKey, JSON.stringify({ version: 2, items })); } function enqueue(draft) { + const ownerLogin = String(getOwnerLogin() || '').trim(); + if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.'); const items = read(); if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.'); const item = { @@ -27,6 +29,7 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => title: String(draft?.title || ''), body: String(draft?.body || ''), labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [], + ownerLogin, status: 'queued', queuedAt: Number(now()), }; @@ -64,7 +67,8 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => return true; } - async function sendItem(item) { + async function sendItem(item, currentLogin) { + if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; if (pending.has(item.id)) return pending.get(item.id); const repository = item.repository.split('/').map(encodeURIComponent).join('/'); const request = (async () => { @@ -98,23 +102,30 @@ function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => finally { if (pending.get(item.id) === request) pending.delete(item.id); } } - async function flush() { + async function flush(currentLogin) { const confirmed = []; + let blocked = 0; + currentLogin = String(currentLogin || '').trim(); for (const item of read()) { if (item.status === 'attention') continue; - const result = await sendItem(item); + if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; } + const result = await sendItem(item, currentLogin); if (result.issue) confirmed.push(result.issue); if (result.transient) break; } - return { confirmed, remaining: read() }; + return { confirmed, remaining: read(), blocked }; } - async function retry(id) { + async function retry(id, currentLogin) { const item = read().find(candidate => candidate.id === id); if (!item) return { confirmed: [], remaining: read() }; + currentLogin = String(currentLogin || '').trim(); + if (!currentLogin || item.ownerLogin !== currentLogin) { + return { confirmed: [], remaining: read(), blocked: 1 }; + } update(id, item); - const result = await sendItem({ ...item, status: 'queued' }); - return { confirmed: result.issue ? [result.issue] : [], remaining: read() }; + const result = await sendItem({ ...item, status: 'queued' }, currentLogin); + return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 }; } return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) }; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f1f6f19..523facc 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,4 +1,4 @@ -const CACHE = 'stackchain-dashboard-shell-v7'; +const CACHE = 'stackchain-dashboard-shell-v8'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const BASE = new URL('./', self.location.href).pathname; const SHELL = [ diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py index 98071aa..2792247 100644 --- a/tests/test_authored_outbox.py +++ b/tests/test_authored_outbox.py @@ -15,6 +15,34 @@ def run_node(script: str): return json.loads(result.stdout) +def test_authored_outbox_quarantines_legacy_and_cross_account_messages(): + script = f""" +const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:1,items:[ + {{id:'legacy',operationId:'legacy',kind:'issue-comment',repository:'o/r',number:1,body:'Old',status:'queued'}} +]}})]]); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const calls = []; +const outbox = createAuthoredOutbox({{ + storage, getOwnerLogin:()=>'timmy', + fetchJson:async url => {{calls.push(url); return {{id:1}};}}, +}}); +const bound = outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:2,body:'Mine',operationId:'mine'}}); +(async()=>{{ + const wrong = await outbox.flush('alexander'); + const right = await outbox.flush('timmy'); + process.stdout.write(JSON.stringify({{bound,wrong,right,calls,remaining:outbox.list()}})); +}})(); +""" + output = run_node(script) + + assert output["bound"]["ownerLogin"] == "timmy" + assert output["wrong"]["blocked"] == 2 + assert len(output["right"]["confirmed"]) == 1 + assert [item["id"] for item in output["remaining"]] == ["legacy"] + assert len(output["calls"]) == 1 + + def test_authored_outbox_persists_each_message_kind_and_flushes_sequentially(): script = f""" const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); @@ -22,7 +50,7 @@ const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; const calls = []; const outbox = createAuthoredOutbox({{ - storage, now:() => 1234, + storage, getOwnerLogin:()=>'timmy', now:() => 1234, fetchJson:async (url, options) => {{ calls.push({{url,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}}); return {{id:calls.length}}; @@ -32,7 +60,7 @@ outbox.enqueue({{kind:'issue-comment',repository:'stackchain/api',number:7,body: outbox.enqueue({{kind:'pull-comment',repository:'stackchain/web',number:8,body:'PR note',operationId:'pull-op'}}); outbox.enqueue({{kind:'update-reply',notificationId:9,body:'Update note',operationId:'update-op'}}); const persisted = createAuthoredOutbox({{storage,fetchJson:outbox.fetchJson}}).list(); -outbox.flush().then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}}))); +outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}}))); """ output = run_node(script) @@ -58,6 +86,7 @@ let phase = 'permanent'; const calls = []; const outbox = createAuthoredOutbox({{ storage, + getOwnerLogin:()=>'timmy', fetchJson:async (_url, options) => {{ const body = JSON.parse(options.body).body; calls.push(body); if (body === 'Bad' && phase === 'permanent') {{ const e = new Error('Reply rejected'); e.status=422; throw e; }} @@ -67,10 +96,10 @@ const outbox = createAuthoredOutbox({{ }}); const bad=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Bad',operationId:'a'}}); outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Good',operationId:'b'}}); -outbox.flush().then(async first => {{ +outbox.flush('timmy').then(async first => {{ phase='transient'; outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}}); - const second=await outbox.flush(); + const second=await outbox.flush('timmy'); process.stdout.write(JSON.stringify({{first,second,calls,remaining:outbox.list(),badId:bad.id}})); }}); """ @@ -91,12 +120,12 @@ const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; let release; const gate=new Promise(resolve=>release=resolve); let calls=0; let sequence=0; const outbox=createAuthoredOutbox({{ - storage, createOperationId:()=> 'new-' + (++sequence), + storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'new-' + (++sequence), fetchJson:async()=>{{calls++; await gate; return {{id:1}};}}, }}); const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Old',operationId:'old'}}); const edited=outbox.update(item.id,{{body:'New'}}); -const flush=outbox.flush(); const retry=outbox.retry(item.id); release(); +const flush=outbox.flush('timmy'); const retry=outbox.retry(item.id, 'timmy'); release(); Promise.all([flush,retry]).then(results=>process.stdout.write(JSON.stringify({{edited,calls,results,remaining:outbox.list()}}))); """ output = run_node(script) @@ -112,7 +141,7 @@ def test_authored_outbox_deduplicates_repeated_queue_attempts_by_operation_id(): const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; -const outbox=createAuthoredOutbox({{storage}}); +const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}}); const first=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}}); const second=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}}); process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}})); @@ -128,9 +157,10 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox(): html = await dashboard() assert '' in html - assert "createAuthoredOutbox({ storage: localStorage" in html - assert "window.addEventListener('online', flushAuthoredOutbox)" in html + assert "const authoredOutbox = createAuthoredOutbox({" in html + assert "activeFlushLogin = contextIdentityFresh ?" in html + assert "flushAuthoredOutbox();" in html assert "authoredOutbox.enqueue" in html - assert "authoredOutbox.retry(item.outbox_id)" in html + assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html assert "authoredOutbox.discard(item.outbox_id)" in html assert "if (result?.queued)" in html diff --git a/tests/test_drafts.py b/tests/test_drafts.py index 7a48ac8..dfb7e60 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -121,6 +121,32 @@ process.stdout.write(JSON.stringify(drafts)); assert output[1]["label"] == "Queued issue" +def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only(): + script = f""" +const createDraftInbox = require({json.dumps(str(DRAFTS))}); +const values = new Map([ + ['stackchain.issue-outbox.v1', JSON.stringify({{version:2,items:[ + {{id:'other',repository:'o/r',title:'Other issue',body:'Private context',ownerLogin:'alexander',status:'queued'}}, + {{id:'legacy',repository:'o/r',title:'Legacy issue',body:'Old context',status:'queued'}} + ]}})], + ['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[ + {{id:'mine',kind:'issue-comment',repository:'o/r',number:2,body:'My comment',ownerLogin:'timmy',status:'queued'}} + ]}})], +]); +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 drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list(); +process.stdout.write(JSON.stringify(drafts)); +""" + output = run_node(script) + + by_id = {item["outbox_id"]: item for item in output} + assert by_id["other"]["quarantined"] is True + assert by_id["other"]["ownership"] == "Queued by alexander — current account is timmy" + assert by_id["other"]["copy_text"] == "Other issue\n\nPrivate context" + assert by_id["legacy"]["ownership"] == "Queued by an unknown account — current account is timmy" + assert by_id["mine"]["quarantined"] is False + + @pytest.mark.anyio async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane(): html = await dashboard() @@ -134,3 +160,22 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane(): assert '.draft-actions button { min-height:44px;' in html assert 'createDraftInbox({ storage: localStorage' in html assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html + + +@pytest.mark.anyio +async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot(): + html = await dashboard() + + assert "let confirmedOwnerLogin = '';" in html + assert "let activeFlushLogin = '';" in html + assert "getOwnerLogin: () => confirmedOwnerLogin" in html + assert "getCurrentLogin: () => activeFlushLogin" in html + assert "const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&" in html + assert "!contextFreshness?.degraded && !contextFreshness?.revalidating;" in html + assert "activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';" in html + assert "issueOutbox.flush(activeFlushLogin)" in html + assert "authoredOutbox.flush(activeFlushLogin)" in html + assert "activeFlushLogin = '';" in html + assert 'class="draft-copy"' in html + assert "navigator.clipboard.writeText(item.copy_text)" in html + assert "item.quarantined" in html diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 21db5d4..518f64e 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -15,6 +15,35 @@ def run_node(script: str): return json.loads(result.stdout) +def test_issue_outbox_binds_items_and_only_flushes_for_the_matching_account(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const calls = []; +let confirmedLogin = 'timmy'; +const outbox = createIssueOutbox({{ + storage, getOwnerLogin:() => confirmedLogin, createOperationId:() => 'bound-op', + fetchJson:async url => {{ calls.push(url); return {{number:1}}; }}, +}}); +const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bound',body:'Context'}}); +confirmedLogin = 'alexander'; +(async () => {{ + const mismatch = await outbox.flush('alexander'); + const unknown = await outbox.retry(queued.id, ''); + const matched = await outbox.flush('timmy'); + process.stdout.write(JSON.stringify({{queued,mismatch,unknown,matched,calls}})); +}})(); +""" + output = run_node(script) + + assert output["queued"]["ownerLogin"] == "timmy" + assert output["mismatch"]["blocked"] == 1 + assert output["unknown"]["blocked"] == 1 + assert len(output["matched"]["confirmed"]) == 1 + assert len(output["calls"]) == 1 + + def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))}); @@ -25,7 +54,7 @@ const storage = {{ removeItem:key => values.delete(key), }}; let sequence = 0; -const outbox = createIssueOutbox({{storage, createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}}); +const outbox = createIssueOutbox({{storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}}); outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}}); outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}}); const reloaded = createIssueOutbox({{storage}}).list(); @@ -39,7 +68,7 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g ] assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"] assert all(item["status"] == "queued" for item in output["items"]) - assert output["stored"]["version"] == 1 + assert output["stored"]["version"] == 2 def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued(): @@ -51,6 +80,7 @@ let sequence = 0; const calls = []; const outbox = createIssueOutbox({{ storage, + getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-' + (++sequence), fetchJson:async (url, options) => {{ calls.push({{url, key:options.headers['Idempotency-Key']}}); @@ -60,7 +90,7 @@ const outbox = createIssueOutbox({{ }}); outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}}); outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}}); -outbox.flush().then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}}))); +outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}}))); """ output = run_node(script) @@ -82,7 +112,7 @@ const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v), let invalid = true; const calls = []; const outbox = createIssueOutbox({{ - storage, createOperationId:() => 'stable-edit', + storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit', fetchJson:async (_url, options) => {{ calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}}); if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }} @@ -90,11 +120,11 @@ const outbox = createIssueOutbox({{ }}, }}); const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}}); -outbox.flush().then(async () => {{ +outbox.flush('timmy').then(async () => {{ const attention = outbox.list()[0]; outbox.update(queued.id, {{...attention,title:'Fixed title'}}); invalid = false; - const result = await outbox.retry(queued.id); + const result = await outbox.retry(queued.id, 'timmy'); process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}})); }}); """ @@ -117,12 +147,12 @@ let calls = 0; let release; const gate = new Promise(resolve => {{ release = resolve; }}); const outbox = createIssueOutbox({{ - storage, createOperationId:() => 'single-flight', + storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'single-flight', fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }}, }}); const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}}); -const reconnect = outbox.flush(); -const sendNow = outbox.retry(queued.id); +const reconnect = outbox.flush('timmy'); +const sendNow = outbox.retry(queued.id, 'timmy'); release(); Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}}))); """ @@ -138,10 +168,12 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio html = await dashboard() assert '' in html - assert "createIssueOutbox({ storage: localStorage" in html + assert "const issueOutbox = createIssueOutbox({" in html assert "issueOutbox.enqueue(captureDraft)" in html + assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html assert "navigator.onLine" in html - assert "window.addEventListener('online', flushIssueOutbox)" in html + assert "activeFlushLogin = contextIdentityFresh ?" in html + assert "flushIssueOutbox();" in html assert 'class="draft-send"' in html assert 'class="draft-edit"' in html assert 'Queued for sync' in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 3c8b0ce..39b69ad 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -63,6 +63,12 @@ async function dispatch(name, request) {{ return json.loads(completed.stdout) +def test_identity_bound_outbox_scripts_ship_in_a_new_shell_cache(): + source = WORKER.read_text() + + assert "stackchain-dashboard-shell-v8" in source + + def test_install_precaches_complete_subpath_scoped_app_shell(): result = run_worker_scenario( """