368 lines
20 KiB
JavaScript
368 lines
20 KiB
JavaScript
import {linkShared} from './helpers/shared-modules.mjs';
|
|
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(linkShared(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('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('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('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('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];
|
|
}
|
|
}
|
|
});
|
|
|