fix: recover stale sessions without masking ARR submission errors

This commit is contained in:
Wyndham ARR committed 2026-10-08 21:04:34 +08:00
1 parent 5acedc02e0
commit 9487634f83
5 files changed
+140 -7

No files matched your search

@@ -27,6 +27,102 @@ test('old not_received intent is released only after the explicit source-date re
assert.equal(h.element('#arr-download-date').disabled,false);
});
test('stale session token refreshes for the same user and retries the exact submission once',async()=>{
let posts=0;
const h=harness((url,options)=>{
if(url==='/api/session') return response(200,{username:'operator',csrf_token:'fresh-fixture'});
assert.equal(url,'/api/arr-downloads');
if(++posts===1) return response(403,null,'SESSION_INVALID');
return response(202,{...JSON.parse(options.body),status:'queued',job_id:null,can_retry:false});
});
await h.submit();
assert.equal(h.calls.length,3);
assert.equal(h.calls[1].url,'/api/session');
assert.equal(h.calls[1].method,'GET');
assert.equal(h.calls[0].body,h.calls[2].body);
assert.equal(h.calls[0].csrf,'fixture');
assert.equal(h.calls[2].csrf,'fresh-fixture');
assert.equal(h.state.arrDownloadTask.status,'queued');
assert.equal(h.state.arrDownloadSubmissionError,null);
assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(h.calls[0].body).request_id);
});
test('a still-invalid session stops after one retry and keeps the rejection reason and identity',async()=>{
const h=harness((url,options)=>{
if(url==='/api/session') return response(200,{username:'operator',csrf_token:'fresh-fixture'});
return options.method==='POST' ? response(403,null,'SESSION_INVALID') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND');
});
await h.submit();
const posts=h.calls.filter(c=>c.method==='POST');
assert.equal(posts.length,2);
assert.equal(posts[0].body,posts[1].body);
assert.equal(h.calls.filter(c=>c.url==='/api/session').length,1);
assert.equal(h.state.arrDownloadTask.status,'not_received');
assert.equal(h.state.arrDownloadTask.submission_error,'SESSION_INVALID');
assert.match(h.element('#arr-download-status').textContent,/arr_download.submission_rejected/);
assert.match(h.element('#arr-download-feedback').className,/is-error/);
assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(posts[0].body).request_id);
});
for(const [description,sessionResponse] of [
['requires login',()=>response(401,null,'SESSION_INVALID')],
['switches user',()=>response(200,{username:'different-user',csrf_token:'fresh-fixture'})],
['has no valid token',()=>response(200,{username:'operator',csrf_token:''})],
]) {
test(`session refresh that ${description} cannot resend the mutation`,async()=>{
const h=harness((url,options)=>{
if(url==='/api/session') return sessionResponse();
return options.method==='POST' ? response(403,null,'SESSION_INVALID') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND');
});
await h.submit();
assert.equal(h.calls.filter(c=>c.method==='POST').length,1);
assert.equal(h.state.csrf,'fixture');
assert.equal(h.state.arrDownloadTask.submission_error,'SESSION_INVALID');
assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(h.calls[0].body).request_id);
});
}
test('a different forbidden response is displayed without refreshing or resubmitting',async()=>{
const h=harness((url,options)=>options.method==='POST'
? response(403,null,'REPLAY_ORIGIN_REJECTED') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND'));
await h.submit();
assert.equal(h.calls.length,2);
assert.equal(h.calls.some(c=>c.url==='/api/session'),false);
assert.equal(h.state.arrDownloadTask.submission_error,'REPLAY_ORIGIN_REJECTED');
assert.equal(h.state.arrDownloadTask.status,'not_received');
});
test('session recovery preserves a price decision body, extra headers and full response envelope',async()=>{
let posts=0;
const payload={case_id:'fixture-case',revision:7,real_price:'1500'};
const envelope={ok:true,data:{revision:8},trace:'fixture-trace'};
const h=harness((url,options)=>{
if(url==='/api/session') return response(200,{username:'operator',csrf_token:'fresh-fixture'});
assert.equal(url,'/api/jobs/fixture-job/review/items/1');
assert.equal(options.headers.get('X-Fixture'),'retained');
if(++posts===1) return response(403,null,'SESSION_INVALID');
return {status:200,ok:true,json:async()=>envelope};
});
const result=await h.api('/api/jobs/fixture-job/review/items/1',{
method:'POST',headers:{'Content-Type':'application/json','X-Fixture':'retained'},
body:JSON.stringify(payload),returnEnvelope:true,
});
assert.deepEqual(result,envelope);
assert.equal(h.calls[0].body,h.calls[2].body);
assert.deepEqual(JSON.parse(h.calls[2].body),payload);
assert.equal(posts,2);
});
test('a server failure remains uncertain without automatic session or mutation retry',async()=>{
const h=harness((url,options)=>options.method==='POST'
? response(500,null,'INTERNAL_ERROR') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND'));
await h.submit();
assert.equal(h.calls.length,2);
assert.equal(h.state.arrDownloadSubmissionError,null);
assert.equal(h.state.arrDownloadTask.submission_error,undefined);
assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(h.calls[0].body).request_id);
});
test('lost response and 404 keep original identity for explicit resubmission',async()=>{
let posts=0;
const h=harness((url,options)=>{
+2 -2
View File
@@ -20,9 +20,9 @@ function harness(respond) {
window:{ARRI18n:{t:key=>key,text:value=>value,errorMessage:code=>code},crypto:webcrypto,
setTimeout(){return 1;},clearTimeout(){},location:{replace(){}}},
localStorage:{setItem:(k,v)=>storage.set(k,v),getItem:k=>{storageReads.push(k);return storage.get(k);},removeItem:k=>storage.delete(k)},
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body});return respond(url,options,calls);},
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body,csrf:options.headers.get('X-ARR-CSRF')});return respond(url,options,calls);},
});
const exports='state, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview';
const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview';
vm.runInContext(source.replace(boot,` globalThis.subject = {${exports}};\n})();`),context);
const subject=context.subject;
Object.assign(subject.state,{arrDownloadReady:true,arrDownloadLoaded:true,arrDownloadStorageKey:'test',arrDownloadUsername:'operator',csrf:'fixture'});