import {routingModuleUrl} from './helpers/routing.mjs'; import {test} from 'node:test'; import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import ts from 'typescript'; async function moduleUrl(file,replace=code=>code){ const filename=new URL(file,import.meta.url); const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}); return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64'); } const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts'); const authUrl=await moduleUrl('../src/lib/client/auth.ts'); const {authFormPayload}=await import(authUrl); let sequence=0; async function client(){ const url=await moduleUrl('../src/lib/client/api.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./auth"',JSON.stringify(authUrl+'#'+sequence++))); return new (await import(url)).ApiClient(); } const guest={userId:7,type:0,isRegister:false}; const member={userId:8,type:1,isRegister:true}; const response=(data,code=200,headers={})=>new Response(JSON.stringify({code,data,msg:code===200?'ok':'session expired'}),{status:code===1000?401:200,headers:{'Content-Type':'application/json',...headers}}); const deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return {promise,resolve};}; async function withFetch(fetcher,work){const old=globalThis.fetch;globalThis.fetch=fetcher;try{await work();}finally{globalThis.fetch=old;}} test('login accepts account/email, trims identifiers, and preserves password bytes',()=>{ assert.deepEqual(authFormPayload('login',{account:' test ',password:' 1234 '}),{account:'test',password:' 1234 '}); assert.deepEqual(authFormPayload('login',{account:'me@example.com',password:'1234'}),{account:'me@example.com',password:'1234'}); assert.throws(()=>authFormPayload('login',{account:'bad@',password:'1234'}),/有效邮箱/); assert.throws(()=>authFormPayload('login',{account:'test',password:'123'}),/密码/); const account='abcdefghijklmnopqrst',password='SafePassword12345678'; assert.equal(authFormPayload('login',{account,password}).account,account); }); test('guest binding validates confirmation and agreement without requiring an email',()=>{ const values={account:' player01 ',password:'Secure123',passwordConfirm:'Secure123',agree:'on'}; assert.deepEqual(authFormPayload('register',values),{accountNo:'player01',password:'Secure123'}); for(const change of [{account:'a_b'},{password:'abcdefgh',passwordConfirm:'abcdefgh'},{passwordConfirm:'Wrong123'},{agree:''}])assert.throws(()=>authFormPayload('register',{...values,...change})); assert.deepEqual(authFormPayload('resetPassword',{account:' player01 ',email:' me@example.com ',password:'NewPass123',passwordConfirm:'NewPass123'}),{accountNo:'player01',email:'me@example.com',newPassword:'NewPass123'}); }); test('device identity, first channel and invite survive page navigation without persisting credentials',async()=>{ const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)])); const storage=new Map([['uuid','old-device']]); Object.defineProperty(globalThis,'document',{configurable:true,value:{cookie:'drama_device=cookie-device'}}); Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key),setItem:(key,value)=>storage.set(key,value)}}); Object.defineProperty(globalThis,'location',{configurable:true,value:{search:'?dc=first&inviteCode=INV123'}}); try{ const {authMetadata}=await import(authUrl+'#attribution'); assert.deepEqual(authMetadata(),{devID:'cookie-device',deviceId:'cookie-device',channel:'first',agentInviteCode:'INV123'}); globalThis.location.search='?dc=later'; assert.equal(authMetadata().channel,'first');assert.equal(authMetadata().agentInviteCode,'INV123'); assert.deepEqual([...storage.keys()].sort(),['channel','uuid']); globalThis.localStorage.getItem=()=>{throw new Error('blocked');}; globalThis.localStorage.setItem=()=>{throw new Error('blocked');}; globalThis.document.cookie=''; const fallback=(await import(authUrl+'#blocked')).authMetadata; assert.equal(fallback().devID,fallback().devID); }finally{for(const [key,descriptor] of Object.entries(originals)){if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}} }); test('concurrent private requests share one guest initialization and public data needs no session',async()=>{ const api=await client(),gate=deferred(),calls=[]; await withFetch(async(url,options)=>{ calls.push({url,body:options.body&&JSON.parse(options.body)}); if(url.endsWith('/auth/guest')){await gate.promise;return response(guest);} return response({}); },async()=>{ const first=api.get('user/center/data'),second=api.get('playlet/collect/list'); await Promise.resolve();assert.equal(calls.length,1);gate.resolve();await Promise.all([first,second]); assert.equal(calls.filter(call=>call.url.endsWith('/auth/guest')).length,1); assert.ok(calls[0].body.devID);assert.equal(api.user.userId,7); const anonymous=await client();await anonymous.get('playlet/home');assert.equal(anonymous.user,null); }); }); test('history restoration revalidates current cookies and cannot overwrite a newer login',async()=>{ const api=await client();api.seedSession(guest); await withFetch(async(_url,options)=>{assert.equal(JSON.parse(options.body).automatic,true);return response(null);},async()=>{ await api.revalidateSession();assert.equal(api.user,null);assert.equal(await api.initializeSession(),null); }); const gate=deferred();api.seedSession(guest); await withFetch(async()=>{await gate.promise;return response(guest);},async()=>{ const refresh=api.revalidateSession();await Promise.resolve();api.setUser(member);gate.resolve();await refresh;assert.equal(api.user.userId,member.userId); }); }); test('search and suggestions wait for a shared guest session before requesting protected search endpoints',async()=>{ const api=await client(),gate=deferred(),calls=[]; await withFetch(async url=>{ calls.push(url); if(url.endsWith('/auth/guest')){await gate.promise;return response(guest);} return response({list:[],totalCount:0}); },async()=>{ const search=api.get('playlet/search',{keyword:'都市',pageNum:2,pageSize:25}); const suggestions=api.get('playlet/search/suggest',{keyword:'都市'}); await Promise.resolve();assert.deepEqual(calls,['/api/app/auth/guest']); gate.resolve();await Promise.all([search,suggestions]); assert.equal(calls.length,3);assert.ok(calls[1].includes('playlet/search?'));assert.ok(calls[1].includes('pageSize=25'));assert.ok(calls[2].includes('playlet/search/suggest?')); }); }); test('slow guest restoration finishes before login and binding waits for a guest identity',async()=>{ const api=await client(),gate=deferred(),calls=[]; await withFetch(async(url,options)=>{ calls.push({url,body:JSON.parse(options.body)}); if(url.endsWith('/auth/guest')){await gate.promise;return response(guest);} return response(member); },async()=>{ const restore=api.initializeSession(),login=api.post('auth/login',{account:'player',password:'Secure123'}); await Promise.resolve();assert.equal(calls.length,1);gate.resolve();await Promise.all([restore,login]); assert.equal(calls[1].body.devID,calls[0].body.devID);assert.equal(api.user.userId,8); const binding=await client();await binding.post('auth/register',{accountNo:'player',password:'Secure123'}); assert.deepEqual(calls.slice(2).map(call=>call.url),['/api/app/auth/guest','/api/app/auth/register']); assert.equal(binding.user.userId,8); }); }); test('invalid credentials preserve the current identity; late expired requests cannot clear a new login',async()=>{ const api=await client();api.setUser(guest); const gate=deferred();let invalid=true; await withFetch(async url=>{ if(url.endsWith('/auth/login'))return invalid?response(null,1000):response(member); await gate.promise;return response(null,1000); },async()=>{ await assert.rejects(api.post('auth/login',{account:'player',password:'bad'}));assert.equal(api.user.userId,7); const pending=api.get('user/center/data');await Promise.resolve();invalid=false; await api.post('auth/login',{account:'player',password:'Secure123'});gate.resolve();await assert.rejects(pending); assert.equal(api.user.userId,8); }); }); test('registered expiry prompts once and blocks automatic guest downgrade',async()=>{ const api=await client();api.setUser(member);let prompts=0,calls=0;api.addEventListener('expired',()=>prompts++); await withFetch(async()=>{calls++;return response(null,1000,{'X-Auth-State':'login-required'});},async()=>{ await assert.rejects(api.get('user/center/data')); await new Promise(resolve=>setImmediate(resolve)); assert.equal(api.user,null);assert.equal(prompts,1); assert.equal(await api.initializeSession(),null); await assert.rejects(api.ensureSession());await assert.rejects(api.ensureSession()); assert.equal(calls,2);assert.equal(prompts,1); }); }); test('a stale expired action keeps the signed-in user when the account cookie is still valid',async()=>{ const api=await client();api.setUser(member);const calls=[]; await withFetch(async url=>{ calls.push(url); return url.endsWith('/auth/getInfo')?response(member):response(null,1000,{'X-Auth-State':'login-required'}); },async()=>{ await assert.rejects(api.get('user/center/data')); await new Promise(resolve=>setImmediate(resolve)); assert.equal(api.user.userId,member.userId); assert.ok(calls[0].endsWith('/user/center/data'));assert.ok(calls[1].endsWith('/auth/getInfo')); }); }); test('optional account-menu stats never confirm session with getInfo',async()=>{ const api=await client();api.setUser(guest);const calls=[]; await withFetch(async url=>{ calls.push(url); return response(null,1000); },async()=>{ assert.equal(await api.tryGet('user/center/data'),undefined); await new Promise(resolve=>setImmediate(resolve)); assert.equal(api.user.userId,guest.userId); assert.deepEqual(calls,['/api/app/user/center/data']); }); }); test('restoring an expired registered cookie prompts for login even before a user is loaded',async()=>{ const api=await client();let prompts=0;api.addEventListener('expired',()=>prompts++); await withFetch(async()=>response(null,1000,{'X-Auth-State':'login-required'}),async()=>{ await assert.rejects(api.initializeSession());await assert.rejects(api.ensureSession()); assert.equal(prompts,1);assert.equal(api.user,null); }); }); test('auth dialog renders binding confirmation, blocks duplicate submission, and clears revealed passwords on close',async()=>{ const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl))); const trackingUrl=await moduleUrl('../src/lib/tracking.ts'); const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl))); const {bindAccountUI,openAuth}=await import(accountUrl); const oldDocument=globalThis.document,OldFormData=globalThis.FormData; const listeners={},error={textContent:''},button={textContent:'绑定账号',disabled:false}; const password={value:'Secure123',type:'password'},confirmation={value:'Secure123',type:'password'}; const form={dataset:{mode:'register'},isConnected:true,reportValidity:()=>true, querySelector:selector=>selector==='#auth-error'?error:button,querySelectorAll:()=>[password,confirmation]}; const dialog={id:'member-auth-dialog',innerHTML:'',open:false,classList:{toggle(){}},querySelector:selector=>selector==='#auth-account'?{focus(){}}:selector==='#auth-password'?password:null, querySelectorAll:()=>[password,confirmation],addEventListener:(name,handler)=>listeners[name]=handler,showModal(){this.open=true;},close(){this.open=false;listeners.close();}}; const gate=deferred();let submitted=0,routed=0,refreshed=0;const bindState={textContent:'未绑定'}; const app={api:{post:async(path,payload)=>{submitted++;assert.equal(path,'auth/register');assert.equal(payload.accountNo,'player01');await gate.promise;},refreshUser:async()=>{refreshed++;}},notify(){},async route(){routed++;}}; globalThis.document={getElementById:id=>id==='member-auth-dialog'?dialog:null,querySelector:selector=>selector==='[data-bind-state]'?bindState:null}; globalThis.FormData=class extends OldFormData{constructor(){super();for(const [key,value] of Object.entries({account:'player01',password:'Secure123',passwordConfirm:'Secure123',agree:'on'}))this.append(key,value);}}; try{ bindAccountUI(app);openAuth('bind');assert.ok(dialog.open); assert.ok(dialog.innerHTML.includes('name="passwordConfirm"'));assert.ok(!dialog.innerHTML.includes('name="email"')); const toggle={dataset:{authPasswordToggle:'auth-password'},setAttribute(){}}; listeners.click({target:{closest:()=>toggle}});assert.equal(password.type,'text'); const event={target:form,preventDefault(){}};listeners.submit(event);listeners.submit(event); assert.equal(submitted,1);assert.equal(button.disabled,true); gate.resolve();await new Promise(resolve=>setImmediate(resolve)); // Bind stays on the settings page — no full reload that would replay a stale drama_session. assert.equal(routed,0);assert.equal(refreshed,1);assert.equal(button.disabled,false);assert.equal(password.value,'');assert.equal(confirmation.value,'');assert.equal(bindState.textContent,'已绑定'); openAuth('switch');assert.ok(dialog.innerHTML.includes('切换账号')); assert.ok(dialog.innerHTML.includes('data-mode="login"'));assert.ok(dialog.innerHTML.includes('data-auth-switch="1"')); form.dataset.mode='login';form.dataset.authSwitch='1'; const current={userId:7};app.api.user=current; app.api.post=async(path,payload)=>{assert.equal(path,'auth/login');assert.equal(payload.switchAccount,true);assert.equal(payload.account,'player01');return {userId:9};}; listeners.submit(event);await new Promise(resolve=>setImmediate(resolve)); assert.equal(routed,1);assert.equal(refreshed,2); app.api.post=async()=>current; listeners.submit(event);await new Promise(resolve=>setImmediate(resolve)); assert.equal(routed,1);assert.equal(error.textContent,'切换失败,请确认账号和密码'); }finally{globalThis.document=oldDocument;globalThis.FormData=OldFormData;} }); test('a server-prepared session is reused without another guest handshake and a sign-in snapshot blocks automatic downgrade',async()=>{ await withFetch(()=>assert.fail('prepared sessions must not refetch auth'),async()=>{ const ready=await client();ready.seedSession(member);assert.equal((await ready.initializeSession()).userId,member.userId); const signedOut=await client();signedOut.seedSession(null);assert.equal(await signedOut.initializeSession(),null); }); }); test('an unavailable SSR snapshot still performs guest login on initializeSession',async()=>{ const api=await client();const calls=[]; await withFetch(async url=>{ calls.push(url); assert.ok(url.endsWith('/auth/guest')); return response(guest); },async()=>{ assert.equal((await api.initializeSession()).userId,guest.userId); assert.deepEqual(calls,['/api/app/auth/guest']); }); }); test('bootstrapGuestSession guests then soft-refreshes getInfo like jha-1048 enter-site auth',async()=>{ const api=await client();const calls=[]; const refreshed={...guest,nickName:'游客昵称',recharge:12}; await withFetch(async url=>{ calls.push(url.pathname||url); if(String(url).endsWith('/auth/guest'))return response(guest); assert.ok(String(url).endsWith('/auth/getInfo')); return response(refreshed); },async()=>{ assert.equal((await api.bootstrapGuestSession()).nickName,'游客昵称'); assert.equal(api.user.recharge,12); assert.deepEqual(calls.map(url=>String(url).replace(/^.*\/api\/app\//,'')),['auth/guest','auth/getInfo']); }); }); test('bootstrapGuestSession guests once when SSR seeded a display identity without a client bearer',async()=>{ const api=await client();api.seedSession(guest);const calls=[]; await withFetch(async(url,options)=>{ calls.push({url:String(url),authorization:options.headers?.Authorization||options.headers?.authorization}); if(String(url).endsWith('/auth/guest'))return response({...guest,token:'seed-jwt'}); assert.ok(String(url).endsWith('/auth/getInfo')); return response({...guest,nickName:'刷新后'}); },async()=>{ assert.equal((await api.bootstrapGuestSession()).nickName,'刷新后'); // No drama_bearer yet → same as jha-1048 first visit: guest then soft getInfo. assert.deepEqual(calls.map(call=>call.url.replace(/^.*\/api\/app\//,'')),['auth/guest','auth/getInfo']); assert.equal(calls[1].authorization,'seed-jwt'); }); }); test('bootstrapGuestSession keeps the guest seed when soft getInfo fails without prompting login',async()=>{ const api=await client();const calls=[];let prompts=0;api.addEventListener('expired',()=>prompts++); await withFetch(async url=>{ calls.push(String(url)); if(String(url).endsWith('/auth/guest'))return response({...guest,token:'guest-jwt'}); assert.ok(String(url).endsWith('/auth/getInfo')); return response(null,1000); },async()=>{ assert.equal((await api.bootstrapGuestSession()).userId,guest.userId); assert.equal(calls.filter(url=>url.endsWith('/auth/guest')).length,1); assert.ok(calls.some(url=>url.endsWith('/auth/getInfo'))); assert.equal(prompts,0); }); }); test('valid guest bearer with SSR seed only soft-refreshes getInfo and does not re-guest',async()=>{ const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)])); const storage=new Map([['drama_bearer','live-guest-jwt'],['uuid','guest-device']]); let cookieJar='drama_device=guest-device; drama_bearer=live-guest-jwt'; Object.defineProperty(globalThis,'document',{configurable:true,value:{ get cookie(){return cookieJar;}, set cookie(value){ const raw=String(value); const [pair]=raw.split(';'); const sep=pair.indexOf('='); const name=pair.slice(0,sep); const body=pair.slice(sep+1); const parts=cookieJar?cookieJar.split('; ').filter(item=>!item.startsWith(name+'=')):[]; if(body&&!raw.includes('Max-Age=0'))parts.push(`${name}=${body}`); cookieJar=parts.join('; '); }, }}); Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}}); Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}}); try{ const api=await client();api.seedSession(guest);const calls=[]; await withFetch(async(url,options)=>{ const path=String(url); calls.push({path,authorization:options.headers?.Authorization||options.headers?.authorization}); assert.ok(path.endsWith('/auth/getInfo')); assert.equal(options.headers?.Authorization||options.headers?.authorization,'live-guest-jwt'); return response({...guest,nickName:'缓存游客',token:''}); },async()=>{ assert.equal((await api.bootstrapGuestSession()).nickName,'缓存游客'); assert.deepEqual(calls.map(call=>call.path.replace(/^.*\/api\/app\//,'')),['auth/getInfo']); assert.equal(storage.get('drama_bearer'),'live-guest-jwt'); }); }finally{ for(const [key,descriptor] of Object.entries(originals)){ if(descriptor)Object.defineProperty(globalThis,key,descriptor); else delete globalThis[key]; } } }); test('stale guest bearer is treated as logged out then guests once like jha-1048',async()=>{ const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)])); const storage=new Map([['drama_bearer','stale-guest-jwt'],['uuid','guest-device']]); let cookieJar='drama_device=guest-device; drama_bearer=stale-guest-jwt'; Object.defineProperty(globalThis,'document',{configurable:true,value:{ get cookie(){return cookieJar;}, set cookie(value){ const raw=String(value); const [pair]=raw.split(';'); const sep=pair.indexOf('='); const name=pair.slice(0,sep); const body=pair.slice(sep+1); const parts=cookieJar?cookieJar.split('; ').filter(item=>!item.startsWith(name+'=')):[]; if(body&&!raw.includes('Max-Age=0'))parts.push(`${name}=${body}`); cookieJar=parts.join('; '); }, }}); Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}}); Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}}); try{ const api=await client();api.seedSession(guest);const calls=[]; await withFetch(async(url,options)=>{ const path=String(url); const authorization=options.headers?.Authorization||options.headers?.authorization; calls.push({path,authorization}); if(path.endsWith('/auth/getInfo')){ if(authorization==='stale-guest-jwt')return response(null,1000); assert.equal(authorization,'fresh-guest-jwt'); return response({...guest,nickName:'新游客',token:''}); } if(path.endsWith('/auth/guest')){ assert.equal(authorization,undefined); return response({...guest,nickName:'新游客',token:'fresh-guest-jwt'}); } return response({}); },async()=>{ assert.equal((await api.bootstrapGuestSession()).nickName,'新游客'); assert.deepEqual(calls.map(call=>call.path.replace(/^.*\/api\/app\//,'')),['auth/getInfo','auth/guest','auth/getInfo']); assert.equal(storage.get('drama_bearer'),'fresh-guest-jwt'); }); }finally{ for(const [key,descriptor] of Object.entries(originals)){ if(descriptor)Object.defineProperty(globalThis,key,descriptor); else delete globalThis[key]; } } }); test('bootstrapGuestSession does not downgrade a login-required snapshot',async()=>{ await withFetch(()=>assert.fail('login-required must not call auth'),async()=>{ const api=await client();api.seedSession(null); assert.equal(await api.bootstrapGuestSession(),null); }); }); test('guest token is kept as Authorization and stripped from the public user snapshot',async()=>{ const api=await client();const calls=[]; await withFetch(async(url,options)=>{ calls.push({url,authorization:options.headers?.Authorization||options.headers?.authorization}); if(String(url).endsWith('/auth/guest'))return response({...guest,token:'guest-jwt'}); if(String(url).endsWith('/auth/getInfo'))return response({...guest,nickName:'游客',token:''}); return response({ok:true}); },async()=>{ assert.equal((await api.bootstrapGuestSession()).nickName,'游客'); assert.equal(api.user.token,undefined); await api.post('playlet/like/set',{id:1,isLike:true}); const like=calls.find(call=>String(call.url).includes('playlet/like/set')); assert.equal(like.authorization,'guest-jwt'); }); }); test('expired guest actions recover once with Authorization when the BFF cookie path is unavailable',async()=>{ const api=await client();api.seedSession(guest);const calls=[]; await withFetch(async(url,options)=>{ const path=String(url); calls.push({path,authorization:options.headers?.Authorization||options.headers?.authorization}); if(path.endsWith('/auth/guest'))return response({...guest,token:'recovered-jwt'}); if(path.includes('playlet/like/set')){ if(!options.headers?.Authorization&&!options.headers?.authorization)return response(null,1000); return response({isLike:1}); } return response(null,1000); },async()=>{ assert.deepEqual(await api.post('playlet/like/set',{id:9,isLike:true}),{isLike:1}); assert.ok(calls.some(call=>call.path.endsWith('/auth/guest'))); assert.ok(calls.some(call=>call.path.includes('playlet/like/set')&&call.authorization==='recovered-jwt')); }); }); test('registered expiry still never recovers as a guest',async()=>{ const api=await client();api.setUser(member);const calls=[]; await withFetch(async url=>{ calls.push(String(url)); return response(null,1000,{'X-Auth-State':'login-required'}); },async()=>{ await assert.rejects(api.post('playlet/like/set',{id:9,isLike:true})); await new Promise(resolve=>setImmediate(resolve)); assert.ok(!calls.some(url=>url.endsWith('/auth/guest'))); }); }); test('binding replaces the guest bearer with the new register token and getInfo uses it',async()=>{ const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)])); const storage=new Map([['uuid','bind-device']]); let cookieJar='drama_device=bind-device'; Object.defineProperty(globalThis,'document',{configurable:true,value:{ get cookie(){return cookieJar;}, set cookie(value){ const [pair]=String(value).split(';'); const sep=pair.indexOf('='); const name=pair.slice(0,sep); const body=pair.slice(sep+1); const parts=cookieJar?cookieJar.split('; ').filter(item=>!item.startsWith(name+'=')):[]; if(body&&!String(value).includes('Max-Age=0'))parts.push(`${name}=${body}`); cookieJar=parts.join('; '); }, }}); Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}}); Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}}); try{ const api=await client();const calls=[]; await withFetch(async(url,options)=>{ const path=String(url); const authorization=options.headers?.Authorization||options.headers?.authorization; calls.push({path,authorization}); if(path.endsWith('/auth/guest'))return response({...guest,token:'guest-before-bind'}); if(path.endsWith('/auth/register')){ assert.equal(authorization,'guest-before-bind'); // Upstream invalidates the guest JWT and returns a new registered token. return response({...member,token:'bound-after-register',accountNo:'player01'}); } if(path.endsWith('/auth/getInfo')){ // Bootstrap uses the guest token; post-bind refresh must use the new register token. if(authorization==='guest-before-bind')return response({...guest,token:''}); assert.equal(authorization,'bound-after-register'); return response({...member,accountNo:'player01',token:''}); } return response({}); },async()=>{ await api.bootstrapGuestSession(); await api.post('auth/register',{accountNo:'player01',password:'Secure123'}); await api.refreshUser(); assert.equal(api.user.accountNo,'player01'); assert.equal(api.user.type,1); assert.equal(storage.get('drama_bearer'),'bound-after-register'); assert.ok(calls.some(call=>call.path.endsWith('/auth/register')&&call.authorization==='guest-before-bind')); assert.ok(calls.some(call=>call.path.endsWith('/auth/getInfo')&&call.authorization==='bound-after-register')); }); }finally{ for(const [key,descriptor] of Object.entries(originals)){ if(descriptor)Object.defineProperty(globalThis,key,descriptor); else delete globalThis[key]; } } }); test('persisted bearer restores getInfo after reload without opening the login dialog',async()=>{ const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)])); const storage=new Map([['drama_bearer','bound-jwt'],['uuid','bind-device']]); let cookieJar='drama_device=bind-device'; let prompts=0; Object.defineProperty(globalThis,'document',{configurable:true,value:{get cookie(){return cookieJar;},set cookie(value){cookieJar=String(value);}}}); Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}}); Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}}); try{ const api=await client();api.addEventListener('expired',()=>prompts++); const calls=[]; await withFetch(async(url,options)=>{ calls.push({path:String(url),authorization:options.headers?.Authorization||options.headers?.authorization}); assert.ok(String(url).endsWith('/auth/getInfo')); assert.equal(options.headers?.Authorization||options.headers?.authorization,'bound-jwt'); return response({...member,accountNo:'player01',token:''}); },async()=>{ assert.equal((await api.bootstrapGuestSession()).accountNo,'player01'); assert.equal(prompts,0); assert.deepEqual(calls.map(call=>call.path.replace(/^.*\/api\/app\//,'')),['auth/getInfo']); }); }finally{ for(const [key,descriptor] of Object.entries(originals)){ if(descriptor)Object.defineProperty(globalThis,key,descriptor); else delete globalThis[key]; } } }); test('soft bootstrap getInfo failure after bind never opens the login dialog',async()=>{ const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)])); const storage=new Map([['drama_bearer','stale-bound-jwt']]); Object.defineProperty(globalThis,'document',{configurable:true,value:{cookie:''}}); Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}}); Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}}); try{ const api=await client();api.seedSession(member);let prompts=0;api.addEventListener('expired',()=>prompts++); await withFetch(async()=>response(null,1000),async()=>{ assert.equal((await api.bootstrapGuestSession())?.userId,member.userId); assert.equal(prompts,0); assert.equal(api.user.userId,member.userId); assert.equal(storage.has('drama_bearer'),false); }); }finally{ for(const [key,descriptor] of Object.entries(originals)){ if(descriptor)Object.defineProperty(globalThis,key,descriptor); else delete globalThis[key]; } } });