import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { db } from '../src/lib/db'; import { hashPassword,newToken,tokenDigest } from '../src/lib/account-crypto'; import { resetPassword,loginAccount,verifyAccount,currentAccount,logoutAccount,rateLimit } from '../src/lib/account'; export async function verifyEmailAccounts(base: string) { const id=randomUUID(), email=id+'@example.com', password='a secure test password'; const request=(token:string)=>new Request('https://wiki.example',{headers:{cookie:'wiki_account='+token}}); try { await db().query('INSERT INTO account_users(id,email,password_hash) VALUES ($1,$2,$3)',[id,email,await hashPassword(password)]); assert.equal(await loginAccount(email,password),null,'unverified accounts cannot log in'); const verification=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'verify',now()+interval '1 minute')",[tokenDigest(verification),id]); const results=await Promise.all([verifyAccount(verification),verifyAccount(verification)]);assert.deepEqual(results.sort(),[false,true]); assert.equal(await loginAccount(email,'incorrect password'),null); const token=await loginAccount(email,password);assert.ok(token); assert.equal((await currentAccount(request(token))).id,id); await db().query("UPDATE account_tokens SET expires_at=now()-interval '1 second' WHERE digest=$1",[tokenDigest(token)]); assert.equal(await currentAccount(request(token)),null,'expired sessions rejected'); const fresh=await loginAccount(email,password);assert.ok(fresh);await logoutAccount(request(fresh));assert.equal(await currentAccount(request(fresh)),null,'logout revokes token'); const oldSession=await loginAccount(email,password);assert.ok(oldSession); const resets=[newToken(),newToken()],replacement='a new secure test password'; for(const value of resets)await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()+interval '30 minutes')",[tokenDigest(value),id]); const resetResults=await Promise.all(resets.map(value=>resetPassword(value,replacement))); assert.deepEqual(resetResults.sort(),[false,true],'Only one concurrent reset may succeed for one account'); assert.equal(await currentAccount(request(oldSession)),null,'Reset revokes existing sessions'); assert.equal(await loginAccount(email,password),null,'Original password is no longer accepted'); assert.ok(await loginAccount(email,replacement)); assert.equal(await resetPassword(resets[0],password),false,'Reset links are single-use, including sibling links'); const expired=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()-interval '1 minute')",[tokenDigest(expired),id]); assert.equal(await resetPassword(expired,password),false); const wrongKind=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'verify',now()+interval '30 minutes')",[tokenDigest(wrongKind),id]); assert.equal(await resetPassword(wrongKind,password),false,'Email verification cannot reset a password'); assert.equal(await resetPassword(newToken(),'short'),false); const post=(action:string,body:object,origin=process.env.ACCOUNT_PUBLIC_ORIGIN||base)=>fetch(base+'/api/account/'+action,{method:'POST',headers:{'Content-Type':'application/json',Origin:origin},body:JSON.stringify(body)}); assert.equal((await post('forgot',{email},'https://untrusted.invalid')).status,403,'Cross-origin recovery is rejected'); const unknown=await post('forgot',{email:randomUUID()+'@example.com'}); assert.equal(unknown.status,200,'Unknown email receives the same non-enumerating response without sending mail'); assert.match((await unknown.json()).message,/若该邮箱/); const httpToken=newToken(),httpPassword='http replacement secure password'; const priorHttpSession=await loginAccount(email,replacement);assert.ok(priorHttpSession); await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()+interval '30 minutes')",[tokenDigest(httpToken),id]); const changed=await post('reset',{token:httpToken,password:httpPassword}); assert.equal(changed.status,200); assert.match(changed.headers.get('set-cookie')||'',/Max-Age=0/); assert.equal(await currentAccount(request(priorHttpSession)),null); assert.ok(await loginAccount(email,httpPassword),'HTTP recovery changes the stored password'); assert.equal((await post('reset',{token:httpToken,password:replacement})).status,400,'HTTP recovery rejects a consumed token'); const key=randomUUID();assert.deepEqual(await Promise.all(Array.from({length:5},()=>rateLimit(key,3))).then(a=>a.sort()),[false,false,true,true,true]); await db().query('DELETE FROM account_limits WHERE key=$1',[tokenDigest(key)]); } finally {await db().query('DELETE FROM account_users WHERE id=$1',[id]);} }