feat: bind offline outboxes to Gitea identity (#238)
This commit is contained in:
1 parent
d155f0081f
commit
a73b3bc773
9 files changed
+237
-63
No files matched your search
@@ -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 '<script src="static/authored-outbox.js"></script>' 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
|
||||
@@ -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
|
||||
+43
-11
@@ -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 '<script src="static/issue-outbox.js"></script>' 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
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user