This commit is contained in:
谢宇宁
2026-11-06 00:13:27 +07:00
parent f65d10e998
commit 37411fcada
98 changed files with 958 additions and 4598 deletions
+2 -163
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
@@ -7,7 +8,7 @@ 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');
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');
@@ -23,22 +24,6 @@ const response=(data,code=200,headers={})=>new Response(JSON.stringify({code,dat
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']]);
@@ -100,36 +85,6 @@ test('search and suggestions wait for a shared guest session before requesting p
});
});
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()=>{
@@ -176,46 +131,6 @@ test('restoring an expired registered cookie prompts for login even before a use
});
});
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);
@@ -421,61 +336,6 @@ test('registered expiry still never recovers as a guest',async()=>{
});
});
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']]);
@@ -505,24 +365,3 @@ test('persisted bearer restores getInfo after reload without opening the login d
}
});
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];
}
}
});