fix: recover stale sessions without masking ARR submission errors
This commit is contained in:
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)=>{
|
||||
|
||||
@@ -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'});
|
||||
|
||||
Reference in new issue
Block a user