52 lines
4.7 KiB
TypeScript
52 lines
4.7 KiB
TypeScript
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]);}
|
|
}
|