zappier-edge/tests/accounts.test.ts

114 lines
3.6 KiB
TypeScript

import {
base32Decode,
base32Encode,
generateTotpSecret,
hashPassword,
hotp,
InMemorySessionRepo,
totp,
totpUri,
verifyPassword,
verifyTotp,
} from '../src/accounts';
describe('password hashing (scrypt)', () => {
it('round-trips a correct password', () => {
const stored = hashPassword('correct horse battery staple');
expect(verifyPassword('correct horse battery staple', stored)).toBe(true);
});
it('rejects a wrong password', () => {
const stored = hashPassword('correct horse battery staple');
expect(verifyPassword('wrong', stored)).toBe(false);
});
it('uses a random salt per hash', () => {
expect(hashPassword('same')).not.toBe(hashPassword('same'));
});
it('rejects malformed stored hashes', () => {
expect(verifyPassword('x', 'not-a-hash')).toBe(false);
expect(verifyPassword('x', '')).toBe(false);
});
});
describe('base32', () => {
it('round-trips bytes', () => {
const buf = Buffer.from('hello world, this is base32');
expect(base32Decode(base32Encode(buf)).equals(buf)).toBe(true);
});
it('encodes without padding', () => {
expect(base32Encode(Buffer.from('f'))).toBe('MY');
expect(base32Encode(Buffer.from('fo'))).toBe('MZXQ');
});
});
describe('TOTP (RFC 6238)', () => {
// RFC 6238 SHA-1 seed, ASCII "12345678901234567890", base32-encoded.
const RFC_SEED_B32 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
it.each([
[59_000, '287082'],
[1_111_111_109_000, '081804'],
[1_234_567_890_000, '005924'],
])('produces the RFC 6-digit truncation at %ims', (atMs, expected) => {
expect(totp(RFC_SEED_B32, atMs)).toBe(expected);
});
it('hotp pads short codes to 6 digits', () => {
expect(hotp(RFC_SEED_B32, Math.floor(1_234_567_890 / 30))).toBe('005924');
});
it('verifyTotp accepts codes within the window and rejects outside', () => {
const at = 1_111_111_109_000;
const code = totp(RFC_SEED_B32, at);
expect(verifyTotp(RFC_SEED_B32, code, at)).toBe(true);
expect(verifyTotp(RFC_SEED_B32, code, at + 30_000)).toBe(true); // one step later, window 1
expect(verifyTotp(RFC_SEED_B32, code, at + 90_000)).toBe(false); // three steps later
});
it('verifyTotp rejects malformed codes', () => {
expect(verifyTotp(RFC_SEED_B32, '12345', 59_000)).toBe(false);
expect(verifyTotp(RFC_SEED_B32, 'abcdef', 59_000)).toBe(false);
});
it('generateTotpSecret returns a decodable 160-bit base32 secret', () => {
const secret = generateTotpSecret();
expect(secret).toMatch(/^[A-Z2-7]{32}$/);
expect(base32Decode(secret).length).toBe(20);
});
it('totpUri builds an otpauth URI', () => {
const uri = totpUri('ABC234', 'ada@example.com', 'Zappier');
expect(uri).toBe(
'otpauth://totp/Zappier:ada%40example.com?secret=ABC234&issuer=Zappier',
);
});
});
describe('InMemorySessionRepo', () => {
it('creates and retrieves sessions within the TTL', () => {
const repo = new InMemorySessionRepo();
const s = repo.create('cust_1', 60_000);
expect(s.customerId).toBe('cust_1');
expect(repo.get(s.token, s.createdMs + 30_000)?.customerId).toBe('cust_1');
});
it('expires sessions after the TTL', () => {
const repo = new InMemorySessionRepo();
const s = repo.create('cust_1', 60_000);
expect(repo.get(s.token, s.createdMs + 61_000)).toBeUndefined();
});
it('deletes sessions (logout)', () => {
const repo = new InMemorySessionRepo();
const s = repo.create('cust_1', 60_000);
repo.delete(s.token);
expect(repo.get(s.token, s.createdMs)).toBeUndefined();
});
it('returns undefined for unknown tokens', () => {
expect(new InMemorySessionRepo().get('nope')).toBeUndefined();
});
});