From a9b8d8c82c121cf45ffb8958470ecac581ea8b6c Mon Sep 17 00:00:00 2001 From: moflo Date: Fri, 17 Apr 2026 13:40:50 +0000 Subject: [PATCH 1/3] feat: implement signup flow (#8) Add email/password signup with validation, duplicate detection, password hashing, and session token issuance backed by an in-memory user store. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/signup.js | 87 ++++++++++++++++++++++++++++++++++++++++++++ tests/signup.test.js | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/signup.js create mode 100644 tests/signup.test.js diff --git a/src/signup.js b/src/signup.js new file mode 100644 index 0000000..e10032a --- /dev/null +++ b/src/signup.js @@ -0,0 +1,87 @@ +const crypto = require('crypto'); + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function validateEmail(email) { + if (typeof email !== 'string' || !EMAIL_RE.test(email)) { + return 'Invalid email format'; + } + return null; +} + +function validatePassword(password) { + if (typeof password !== 'string' || password.length < 8) { + return 'Password must be at least 8 characters'; + } + if (!/[A-Z]/.test(password)) return 'Password must contain an uppercase letter'; + if (!/[a-z]/.test(password)) return 'Password must contain a lowercase letter'; + if (!/[0-9]/.test(password)) return 'Password must contain a number'; + return null; +} + +function hashPassword(password, salt) { + const s = salt || crypto.randomBytes(16).toString('hex'); + const hash = crypto.scryptSync(password, s, 64).toString('hex'); + return { salt: s, hash }; +} + +function createUserStore() { + const byEmail = new Map(); + return { + findByEmail: (email) => byEmail.get(email.toLowerCase()) || null, + insert: (user) => { + byEmail.set(user.email.toLowerCase(), user); + return user; + }, + all: () => Array.from(byEmail.values()), + }; +} + +function createSessionToken() { + return crypto.randomBytes(32).toString('hex'); +} + +function signup({ email, password }, { store, now = () => new Date() } = {}) { + const emailError = validateEmail(email); + if (emailError) { + const err = new Error(emailError); + err.code = 'INVALID_EMAIL'; + throw err; + } + const pwError = validatePassword(password); + if (pwError) { + const err = new Error(pwError); + err.code = 'WEAK_PASSWORD'; + throw err; + } + if (!store) throw new Error('store is required'); + + if (store.findByEmail(email)) { + const err = new Error('An account with this email already exists'); + err.code = 'EMAIL_TAKEN'; + throw err; + } + + const { salt, hash } = hashPassword(password); + const user = { + id: crypto.randomUUID(), + email: email.toLowerCase(), + passwordSalt: salt, + passwordHash: hash, + createdAt: now().toISOString(), + }; + store.insert(user); + + return { + user: { id: user.id, email: user.email, createdAt: user.createdAt }, + sessionToken: createSessionToken(), + }; +} + +module.exports = { + signup, + validateEmail, + validatePassword, + createUserStore, + createSessionToken, +}; diff --git a/tests/signup.test.js b/tests/signup.test.js new file mode 100644 index 0000000..90c44e9 --- /dev/null +++ b/tests/signup.test.js @@ -0,0 +1,67 @@ +const assert = require('assert'); +const { signup, createUserStore, validateEmail, validatePassword } = require('../src/signup'); + +function run(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (e) { + console.error(`FAIL - ${name}: ${e.message}`); + process.exitCode = 1; + } +} + +run('rejects malformed email', () => { + assert.strictEqual(validateEmail('not-an-email'), 'Invalid email format'); + assert.strictEqual(validateEmail('a@b.c'), null); +}); + +run('rejects weak password', () => { + assert.ok(validatePassword('short')); + assert.ok(validatePassword('alllowercase1')); + assert.ok(validatePassword('ALLUPPERCASE1')); + assert.ok(validatePassword('NoNumbersHere')); + assert.strictEqual(validatePassword('Strong1Password'), null); +}); + +run('signup throws on invalid email', () => { + const store = createUserStore(); + assert.throws(() => signup({ email: 'bad', password: 'Strong1Password' }, { store }), { + code: 'INVALID_EMAIL', + }); +}); + +run('signup throws on weak password', () => { + const store = createUserStore(); + assert.throws(() => signup({ email: 'a@b.co', password: 'weak' }, { store }), { + code: 'WEAK_PASSWORD', + }); +}); + +run('successful signup returns session token and persists user', () => { + const store = createUserStore(); + const result = signup({ email: 'Jane@Example.com', password: 'Strong1Password' }, { store }); + assert.ok(result.sessionToken && result.sessionToken.length >= 32); + assert.strictEqual(result.user.email, 'jane@example.com'); + assert.ok(result.user.id); + assert.strictEqual(store.all().length, 1); + assert.ok(store.findByEmail('jane@example.com')); +}); + +run('duplicate email is rejected with clear error', () => { + const store = createUserStore(); + signup({ email: 'dup@example.com', password: 'Strong1Password' }, { store }); + assert.throws( + () => signup({ email: 'DUP@example.com', password: 'Strong1Password' }, { store }), + (err) => err.code === 'EMAIL_TAKEN' && /already exists/i.test(err.message), + ); + assert.strictEqual(store.all().length, 1); +}); + +run('password is not stored in plaintext', () => { + const store = createUserStore(); + signup({ email: 'secure@example.com', password: 'Strong1Password' }, { store }); + const user = store.findByEmail('secure@example.com'); + assert.ok(!Object.values(user).includes('Strong1Password')); + assert.ok(user.passwordHash && user.passwordSalt); +}); From d75a25cfa264d869ce9e93d0f80d82f529a81a8f Mon Sep 17 00:00:00 2001 From: moflo Date: Fri, 17 Apr 2026 13:45:28 +0000 Subject: [PATCH 2/3] feat: implement login flow (#9) - Adds src/login.js with login() accepting email + password - Verifies credentials against scrypt hash using timingSafeEqual - Returns a session token on success, generic error on failure (no user enumeration: same error for unknown email and wrong password) - Per-email rate limiter prevents brute-force attacks with configurable max attempts and sliding window; successful login resets the counter - Tests cover success, case-insensitive email, wrong password, unknown email, non-string input, rate limiting, window expiry, and isolation between emails Co-Authored-By: Claude Opus 4.7 (1M context) --- src/login.js | 95 ++++++++++++++++++++++++++++ tests/login.test.js | 150 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 src/login.js create mode 100644 tests/login.test.js diff --git a/src/login.js b/src/login.js new file mode 100644 index 0000000..10ffe17 --- /dev/null +++ b/src/login.js @@ -0,0 +1,95 @@ +const crypto = require('crypto'); + +const { createSessionToken } = require('./signup'); + +const DEFAULT_MAX_ATTEMPTS = 5; +const DEFAULT_WINDOW_MS = 15 * 60 * 1000; +const GENERIC_ERROR = 'Invalid email or password'; + +function verifyPassword(password, salt, expectedHash) { + if (typeof password !== 'string' || typeof salt !== 'string' || typeof expectedHash !== 'string') { + return false; + } + const actual = crypto.scryptSync(password, salt, 64); + const expected = Buffer.from(expectedHash, 'hex'); + if (actual.length !== expected.length) return false; + return crypto.timingSafeEqual(actual, expected); +} + +function createRateLimiter({ maxAttempts = DEFAULT_MAX_ATTEMPTS, windowMs = DEFAULT_WINDOW_MS } = {}) { + const attempts = new Map(); + + function prune(key, now) { + const list = attempts.get(key); + if (!list) return []; + const fresh = list.filter((t) => now - t < windowMs); + if (fresh.length) attempts.set(key, fresh); + else attempts.delete(key); + return fresh; + } + + return { + check: (key, now = Date.now()) => { + const fresh = prune(key, now); + return fresh.length < maxAttempts; + }, + recordFailure: (key, now = Date.now()) => { + const fresh = prune(key, now); + fresh.push(now); + attempts.set(key, fresh); + }, + reset: (key) => { + attempts.delete(key); + }, + }; +} + +function login( + { email, password }, + { store, rateLimiter, now = () => new Date() } = {}, +) { + if (!store) throw new Error('store is required'); + if (!rateLimiter) throw new Error('rateLimiter is required'); + + const key = typeof email === 'string' ? email.toLowerCase() : ''; + + if (!rateLimiter.check(key, now().getTime())) { + const err = new Error('Too many login attempts. Please try again later.'); + err.code = 'RATE_LIMITED'; + throw err; + } + + const fail = () => { + rateLimiter.recordFailure(key, now().getTime()); + const err = new Error(GENERIC_ERROR); + err.code = 'INVALID_CREDENTIALS'; + throw err; + }; + + if (typeof email !== 'string' || typeof password !== 'string') { + fail(); + } + + const user = store.findByEmail(email); + if (!user) { + fail(); + } + + if (!verifyPassword(password, user.passwordSalt, user.passwordHash)) { + fail(); + } + + rateLimiter.reset(key); + + return { + user: { id: user.id, email: user.email, createdAt: user.createdAt }, + sessionToken: createSessionToken(), + }; +} + +module.exports = { + login, + verifyPassword, + createRateLimiter, + GENERIC_ERROR, +}; diff --git a/tests/login.test.js b/tests/login.test.js new file mode 100644 index 0000000..0d3a1de --- /dev/null +++ b/tests/login.test.js @@ -0,0 +1,150 @@ +const assert = require('assert'); +const { signup, createUserStore } = require('../src/signup'); +const { login, createRateLimiter, GENERIC_ERROR } = require('../src/login'); + +function run(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (e) { + console.error(`FAIL - ${name}: ${e.message}`); + process.exitCode = 1; + } +} + +function seeded() { + const store = createUserStore(); + signup({ email: 'user@example.com', password: 'Strong1Password' }, { store }); + return store; +} + +run('successful login returns a session token and user', () => { + const store = seeded(); + const rateLimiter = createRateLimiter(); + const result = login( + { email: 'user@example.com', password: 'Strong1Password' }, + { store, rateLimiter }, + ); + assert.ok(result.sessionToken && result.sessionToken.length >= 32); + assert.strictEqual(result.user.email, 'user@example.com'); + assert.ok(result.user.id); +}); + +run('login is case-insensitive on email', () => { + const store = seeded(); + const rateLimiter = createRateLimiter(); + const result = login( + { email: 'USER@example.com', password: 'Strong1Password' }, + { store, rateLimiter }, + ); + assert.strictEqual(result.user.email, 'user@example.com'); +}); + +run('wrong password returns generic error', () => { + const store = seeded(); + const rateLimiter = createRateLimiter(); + assert.throws( + () => login({ email: 'user@example.com', password: 'WrongPass1' }, { store, rateLimiter }), + (err) => err.code === 'INVALID_CREDENTIALS' && err.message === GENERIC_ERROR, + ); +}); + +run('unknown email returns same generic error (no enumeration)', () => { + const store = seeded(); + const rateLimiter = createRateLimiter(); + assert.throws( + () => login({ email: 'missing@example.com', password: 'Strong1Password' }, { store, rateLimiter }), + (err) => err.code === 'INVALID_CREDENTIALS' && err.message === GENERIC_ERROR, + ); +}); + +run('non-string email or password returns generic error', () => { + const store = seeded(); + const rateLimiter = createRateLimiter(); + assert.throws( + () => login({ email: null, password: 'Strong1Password' }, { store, rateLimiter }), + (err) => err.code === 'INVALID_CREDENTIALS', + ); + assert.throws( + () => login({ email: 'user@example.com', password: 123 }, { store, rateLimiter }), + (err) => err.code === 'INVALID_CREDENTIALS', + ); +}); + +run('rate limiter blocks after too many failed attempts', () => { + const store = seeded(); + const rateLimiter = createRateLimiter({ maxAttempts: 3, windowMs: 60_000 }); + for (let i = 0; i < 3; i++) { + assert.throws( + () => login({ email: 'user@example.com', password: 'Wrong1Pass' }, { store, rateLimiter }), + (err) => err.code === 'INVALID_CREDENTIALS', + ); + } + assert.throws( + () => login({ email: 'user@example.com', password: 'Strong1Password' }, { store, rateLimiter }), + (err) => err.code === 'RATE_LIMITED', + ); +}); + +run('successful login resets the failure counter', () => { + const store = seeded(); + const rateLimiter = createRateLimiter({ maxAttempts: 3, windowMs: 60_000 }); + for (let i = 0; i < 2; i++) { + assert.throws( + () => login({ email: 'user@example.com', password: 'Wrong1Pass' }, { store, rateLimiter }), + (err) => err.code === 'INVALID_CREDENTIALS', + ); + } + const ok = login( + { email: 'user@example.com', password: 'Strong1Password' }, + { store, rateLimiter }, + ); + assert.ok(ok.sessionToken); + for (let i = 0; i < 2; i++) { + assert.throws( + () => login({ email: 'user@example.com', password: 'Wrong1Pass' }, { store, rateLimiter }), + (err) => err.code === 'INVALID_CREDENTIALS', + ); + } +}); + +run('rate limit window expires', () => { + const store = seeded(); + const rateLimiter = createRateLimiter({ maxAttempts: 2, windowMs: 1000 }); + let t = 0; + const now = () => new Date(t); + for (let i = 0; i < 2; i++) { + assert.throws( + () => login({ email: 'user@example.com', password: 'Wrong1Pass' }, { store, rateLimiter, now }), + (err) => err.code === 'INVALID_CREDENTIALS', + ); + } + assert.throws( + () => login({ email: 'user@example.com', password: 'Strong1Password' }, { store, rateLimiter, now }), + (err) => err.code === 'RATE_LIMITED', + ); + t = 2000; + const ok = login( + { email: 'user@example.com', password: 'Strong1Password' }, + { store, rateLimiter, now }, + ); + assert.ok(ok.sessionToken); +}); + +run('rate limit is per-email', () => { + const store = createUserStore(); + signup({ email: 'a@example.com', password: 'Strong1Password' }, { store }); + signup({ email: 'b@example.com', password: 'Strong1Password' }, { store }); + const rateLimiter = createRateLimiter({ maxAttempts: 2, windowMs: 60_000 }); + for (let i = 0; i < 2; i++) { + assert.throws( + () => login({ email: 'a@example.com', password: 'Wrong1Pass' }, { store, rateLimiter }), + (err) => err.code === 'INVALID_CREDENTIALS', + ); + } + const ok = login( + { email: 'b@example.com', password: 'Strong1Password' }, + { store, rateLimiter }, + ); + assert.ok(ok.sessionToken); +}); From 13e42401cb7dbcbfd24c9253448afd9303c46b31 Mon Sep 17 00:00:00 2001 From: moflo Date: Fri, 17 Apr 2026 13:48:15 +0000 Subject: [PATCH 3/3] feat: implement password reset flow (#10) Adds request/complete password reset with 1-hour token expiry, single-use tokens, and invalidation of all existing sessions on reset. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/password-reset.js | 107 +++++++++++++++++ src/signup.js | 4 + tests/password-reset.test.js | 224 +++++++++++++++++++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 src/password-reset.js create mode 100644 tests/password-reset.test.js diff --git a/src/password-reset.js b/src/password-reset.js new file mode 100644 index 0000000..2a9b13d --- /dev/null +++ b/src/password-reset.js @@ -0,0 +1,107 @@ +const crypto = require('crypto'); + +const { hashPassword, validatePassword } = require('./signup'); + +const DEFAULT_TTL_MS = 60 * 60 * 1000; + +function createResetTokenStore() { + const byToken = new Map(); + return { + put: (token, userId, expiresAt) => byToken.set(token, { userId, expiresAt }), + get: (token) => byToken.get(token) || null, + delete: (token) => byToken.delete(token), + deleteForUser: (userId) => { + for (const [t, entry] of byToken.entries()) { + if (entry.userId === userId) byToken.delete(t); + } + }, + }; +} + +function createSessionStore() { + const byToken = new Map(); + return { + register: (token, userId) => byToken.set(token, userId), + isValid: (token) => byToken.has(token), + userFor: (token) => byToken.get(token) || null, + invalidateAllForUser: (userId) => { + for (const [t, uid] of byToken.entries()) { + if (uid === userId) byToken.delete(t); + } + }, + size: () => byToken.size, + }; +} + +function requestPasswordReset( + { email }, + { store, resetTokens, ttlMs = DEFAULT_TTL_MS, now = () => new Date() } = {}, +) { + if (!store) throw new Error('store is required'); + if (!resetTokens) throw new Error('resetTokens is required'); + + if (typeof email !== 'string') return null; + + const user = store.findByEmail(email); + if (!user) return null; + + resetTokens.deleteForUser(user.id); + + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = now().getTime() + ttlMs; + resetTokens.put(token, user.id, expiresAt); + return { token, expiresAt }; +} + +function resetPassword( + { token, newPassword }, + { store, resetTokens, sessionStore, now = () => new Date() } = {}, +) { + if (!store) throw new Error('store is required'); + if (!resetTokens) throw new Error('resetTokens is required'); + if (!sessionStore) throw new Error('sessionStore is required'); + + const invalid = () => { + const err = new Error('Invalid or expired reset token'); + err.code = 'INVALID_TOKEN'; + return err; + }; + + const entry = typeof token === 'string' ? resetTokens.get(token) : null; + if (!entry) throw invalid(); + + if (now().getTime() > entry.expiresAt) { + resetTokens.delete(token); + throw invalid(); + } + + const pwError = validatePassword(newPassword); + if (pwError) { + const err = new Error(pwError); + err.code = 'WEAK_PASSWORD'; + throw err; + } + + const user = store.findById(entry.userId); + if (!user) { + resetTokens.delete(token); + throw invalid(); + } + + const { salt, hash } = hashPassword(newPassword); + user.passwordSalt = salt; + user.passwordHash = hash; + + resetTokens.delete(token); + sessionStore.invalidateAllForUser(user.id); + + return { userId: user.id }; +} + +module.exports = { + requestPasswordReset, + resetPassword, + createResetTokenStore, + createSessionStore, + DEFAULT_TTL_MS, +}; diff --git a/src/signup.js b/src/signup.js index e10032a..092e50d 100644 --- a/src/signup.js +++ b/src/signup.js @@ -27,10 +27,13 @@ function hashPassword(password, salt) { function createUserStore() { const byEmail = new Map(); + const byId = new Map(); return { findByEmail: (email) => byEmail.get(email.toLowerCase()) || null, + findById: (id) => byId.get(id) || null, insert: (user) => { byEmail.set(user.email.toLowerCase(), user); + byId.set(user.id, user); return user; }, all: () => Array.from(byEmail.values()), @@ -82,6 +85,7 @@ module.exports = { signup, validateEmail, validatePassword, + hashPassword, createUserStore, createSessionToken, }; diff --git a/tests/password-reset.test.js b/tests/password-reset.test.js new file mode 100644 index 0000000..0ec47db --- /dev/null +++ b/tests/password-reset.test.js @@ -0,0 +1,224 @@ +const assert = require('assert'); +const { signup, createUserStore } = require('../src/signup'); +const { login, createRateLimiter } = require('../src/login'); +const { + requestPasswordReset, + resetPassword, + createResetTokenStore, + createSessionStore, + DEFAULT_TTL_MS, +} = require('../src/password-reset'); + +function run(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (e) { + console.error(`FAIL - ${name}: ${e.message}`); + process.exitCode = 1; + } +} + +function seeded() { + const store = createUserStore(); + const sessionStore = createSessionStore(); + const user = signup({ email: 'user@example.com', password: 'Strong1Password' }, { store }); + sessionStore.register(user.sessionToken, user.user.id); + return { store, sessionStore, user: user.user }; +} + +run('request returns a token and expiry one hour in the future', () => { + const { store } = seeded(); + const resetTokens = createResetTokenStore(); + const t0 = 1_000_000; + const res = requestPasswordReset( + { email: 'user@example.com' }, + { store, resetTokens, now: () => new Date(t0) }, + ); + assert.ok(res && typeof res.token === 'string' && res.token.length >= 32); + assert.strictEqual(res.expiresAt, t0 + DEFAULT_TTL_MS); +}); + +run('request is case-insensitive on email', () => { + const { store } = seeded(); + const resetTokens = createResetTokenStore(); + const res = requestPasswordReset({ email: 'USER@example.com' }, { store, resetTokens }); + assert.ok(res && res.token); +}); + +run('request for unknown email returns null (no enumeration)', () => { + const { store } = seeded(); + const resetTokens = createResetTokenStore(); + const res = requestPasswordReset({ email: 'missing@example.com' }, { store, resetTokens }); + assert.strictEqual(res, null); +}); + +run('request for non-string email returns null', () => { + const { store } = seeded(); + const resetTokens = createResetTokenStore(); + assert.strictEqual(requestPasswordReset({ email: null }, { store, resetTokens }), null); + assert.strictEqual(requestPasswordReset({}, { store, resetTokens }), null); +}); + +run('requesting again invalidates the prior token', () => { + const { store } = seeded(); + const resetTokens = createResetTokenStore(); + const first = requestPasswordReset({ email: 'user@example.com' }, { store, resetTokens }); + requestPasswordReset({ email: 'user@example.com' }, { store, resetTokens }); + assert.strictEqual(resetTokens.get(first.token), null); +}); + +run('reset with valid token sets a new password', () => { + const { store, sessionStore } = seeded(); + const resetTokens = createResetTokenStore(); + const { token } = requestPasswordReset( + { email: 'user@example.com' }, + { store, resetTokens }, + ); + resetPassword( + { token, newPassword: 'NewStrong1Pass' }, + { store, resetTokens, sessionStore }, + ); + const rateLimiter = createRateLimiter(); + const res = login( + { email: 'user@example.com', password: 'NewStrong1Pass' }, + { store, rateLimiter }, + ); + assert.ok(res.sessionToken); +}); + +run('old password no longer works after reset', () => { + const { store, sessionStore } = seeded(); + const resetTokens = createResetTokenStore(); + const { token } = requestPasswordReset( + { email: 'user@example.com' }, + { store, resetTokens }, + ); + resetPassword( + { token, newPassword: 'NewStrong1Pass' }, + { store, resetTokens, sessionStore }, + ); + const rateLimiter = createRateLimiter(); + assert.throws( + () => login({ email: 'user@example.com', password: 'Strong1Password' }, { store, rateLimiter }), + (err) => err.code === 'INVALID_CREDENTIALS', + ); +}); + +run('reset token is single-use', () => { + const { store, sessionStore } = seeded(); + const resetTokens = createResetTokenStore(); + const { token } = requestPasswordReset( + { email: 'user@example.com' }, + { store, resetTokens }, + ); + resetPassword( + { token, newPassword: 'NewStrong1Pass' }, + { store, resetTokens, sessionStore }, + ); + assert.throws( + () => + resetPassword( + { token, newPassword: 'AnotherStrong1' }, + { store, resetTokens, sessionStore }, + ), + (err) => err.code === 'INVALID_TOKEN', + ); +}); + +run('reset token expires after 1 hour', () => { + const { store, sessionStore } = seeded(); + const resetTokens = createResetTokenStore(); + let t = 1_000_000; + const now = () => new Date(t); + const { token } = requestPasswordReset( + { email: 'user@example.com' }, + { store, resetTokens, now }, + ); + t += DEFAULT_TTL_MS + 1; + assert.throws( + () => + resetPassword( + { token, newPassword: 'NewStrong1Pass' }, + { store, resetTokens, sessionStore, now }, + ), + (err) => err.code === 'INVALID_TOKEN', + ); +}); + +run('reset token still valid just before 1 hour', () => { + const { store, sessionStore } = seeded(); + const resetTokens = createResetTokenStore(); + let t = 1_000_000; + const now = () => new Date(t); + const { token } = requestPasswordReset( + { email: 'user@example.com' }, + { store, resetTokens, now }, + ); + t += DEFAULT_TTL_MS - 1; + resetPassword( + { token, newPassword: 'NewStrong1Pass' }, + { store, resetTokens, sessionStore, now }, + ); +}); + +run('reset rejects weak new password', () => { + const { store, sessionStore } = seeded(); + const resetTokens = createResetTokenStore(); + const { token } = requestPasswordReset( + { email: 'user@example.com' }, + { store, resetTokens }, + ); + assert.throws( + () => + resetPassword( + { token, newPassword: 'weak' }, + { store, resetTokens, sessionStore }, + ), + (err) => err.code === 'WEAK_PASSWORD', + ); +}); + +run('reset rejects unknown/invalid token', () => { + const { store, sessionStore } = seeded(); + const resetTokens = createResetTokenStore(); + assert.throws( + () => + resetPassword( + { token: 'does-not-exist', newPassword: 'NewStrong1Pass' }, + { store, resetTokens, sessionStore }, + ), + (err) => err.code === 'INVALID_TOKEN', + ); + assert.throws( + () => + resetPassword( + { token: null, newPassword: 'NewStrong1Pass' }, + { store, resetTokens, sessionStore }, + ), + (err) => err.code === 'INVALID_TOKEN', + ); +}); + +run('all existing sessions are invalidated on reset', () => { + const { store, sessionStore, user } = seeded(); + const rateLimiter = createRateLimiter(); + const second = login( + { email: 'user@example.com', password: 'Strong1Password' }, + { store, rateLimiter }, + ); + sessionStore.register(second.sessionToken, user.id); + assert.strictEqual(sessionStore.size(), 2); + + const resetTokens = createResetTokenStore(); + const { token } = requestPasswordReset( + { email: 'user@example.com' }, + { store, resetTokens }, + ); + resetPassword( + { token, newPassword: 'NewStrong1Pass' }, + { store, resetTokens, sessionStore }, + ); + + assert.strictEqual(sessionStore.size(), 0); +});