import { createHash, randomUUID } from 'node:crypto'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; // These tests intentionally use production-strength scrypt while the complete // suite may also be saturating the CPU. Do not weaken password parameters. vi.setConfig({ testTimeout: 20_000, hookTimeout: 20_000 }); import { newFixture, type Fixture } from '../helpers/fixture.js'; import type { UserRole } from '../../src/lib/auth.js'; const password = 'testpass'; const replacement = 'another long secret passphrase'; const setupToken = 'ec_setup_test_0123456789abcdef0123456789abcdef'; let passwordHash: string; let replacementHash: string; let fixture: Fixture; let auth: typeof import('../../src/lib/server/auth/index.js'); let database: typeof import('../../src/lib/server/db/index.js'); // Real scrypt hashes are shared only as fixture data. Security transitions use // the production API; deterministic gates below exercise its async race checks. beforeAll(async () => { const passwords = await import('../../src/lib/server/auth/passwords.js'); passwordHash = await passwords.hashPassword(password); replacementHash = await passwords.hashPassword(replacement); }, 15_000); beforeEach(async () => { vi.resetModules(); vi.stubEnv('EC_SETUP_TOKEN', setupToken); fixture = await newFixture([]); auth = await import('../../src/lib/server/auth/index.js'); database = await import('../../src/lib/server/db/index.js'); auth.setupRequired(); }); afterEach(async () => { vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllEnvs(); await fixture.cleanup(); }); function seedUser( username = 'administrator', role: UserRole = 'admin', options: { disabled?: boolean; mustChangePassword?: boolean } = {} ) { const id = randomUUID(), now = Date.now(); database .db() .prepare( `INSERT INTO auth_users(id,username,display_name,password_hash,role,disabled,must_change_password,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)` ) .run( id, username, username, passwordHash, role, Number(options.disabled ?? false), Number(options.mustChangePassword ?? false), now, now ); return auth.getUser(id)!; } function storedUser(id: string) { return database.db().prepare('SELECT * FROM auth_users WHERE id=?').get(id) as { password_hash: string; role: UserRole; disabled: number; must_change_password: number; }; } function gate() { let release!: (value: T) => void; const promise = new Promise((resolve) => { release = resolve; }); return { promise, release }; } describe('initial administrator setup', () => { it('reserves Maxis for system actions regardless of username case', async () => { for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) { await expect( auth.bootstrapAdmin({ username, password, token: setupToken }, '192.0.2.1') ).rejects.toMatchObject({ status: 400, message: 'Maxis is reserved for system actions. Choose another username.' }); } expect(auth.setupRequired()).toBe(true); expect(auth.listUsers()).toEqual([]); expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 }); }); it('requires an owner token and never creates an account from missing or wrong credentials', async () => { const input = { username: 'Admin', password, token: setupToken }; vi.stubEnv('EC_SETUP_TOKEN', undefined); await expect(auth.bootstrapAdmin(input, '127.0.0.1')).rejects.toMatchObject({ status: 503 }); vi.stubEnv('EC_SETUP_TOKEN', 'short'); await expect(auth.bootstrapAdmin(input, '127.0.0.1')).rejects.toMatchObject({ status: 503 }); vi.stubEnv('EC_SETUP_TOKEN', setupToken); await expect( auth.bootstrapAdmin({ ...input, token: 'wrong' }, '127.0.0.1') ).rejects.toMatchObject({ status: 401 }); expect(auth.setupRequired()).toBe(true); expect(auth.listUsers()).toEqual([]); }); it('allows exactly one competing bootstrap and atomically establishes its authenticated session', async () => { const results = await Promise.allSettled([ auth.bootstrapAdmin( { username: 'FirstAdmin', displayName: ' First Admin ', password, token: setupToken, role: 'viewer' }, '192.0.2.1' ), auth.bootstrapAdmin( { username: 'SecondAdmin', password, token: setupToken }, '192.0.2.2' ) ]); const successes = results.filter((result) => result.status === 'fulfilled'); const failures = results.filter((result) => result.status === 'rejected'); expect(successes).toHaveLength(1); expect(failures).toHaveLength(1); if (failures[0].status === 'rejected') expect(failures[0].reason).toMatchObject({ status: 409 }); if (successes[0].status !== 'fulfilled') throw new Error('Bootstrap did not succeed'); const { user, session } = successes[0].value; expect(user).toMatchObject({ role: 'admin', disabled: false, mustChangePassword: false }); expect(user.username).toBe(user.username.toLowerCase()); expect(auth.readSession(session.token)?.user.id).toBe(user.id); expect(auth.listUsers()).toHaveLength(1); expect(auth.setupRequired()).toBe(false); await expect( auth.bootstrapAdmin( { username: 'ThirdAdmin', password, token: setupToken }, '192.0.2.3' ) ).rejects.toMatchObject({ status: 409 }); const persisted = JSON.stringify(database.db().prepare('SELECT * FROM auth_users').all()) + JSON.stringify(auth.listAuthAudit()); expect(persisted).not.toContain(password); expect(persisted).not.toContain(setupToken); expect(JSON.stringify(user)).not.toContain('password_hash'); }); }); describe('password storage and verification', () => { it('uses independently salted scrypt hashes and rejects wrong passwords or malformed stored hashes', async () => { const passwords = await import('../../src/lib/server/auth/passwords.js'); const anotherHash = await passwords.hashPassword(password); expect(anotherHash).toMatch(/^scrypt\$131072\$8\$1\$[a-f0-9]{64}\$[a-f0-9]{128}$/); expect(anotherHash).not.toBe(passwordHash); expect(anotherHash).not.toContain(password); expect(await passwords.verifyPassword(password, anotherHash)).toBe(true); expect(await passwords.verifyPassword(replacement, anotherHash)).toBe(false); expect(await passwords.verifyPassword(password, 'scrypt$1$8$1$weak$bad')).toBe(false); expect(await passwords.verifyPassword(undefined, anotherHash)).toBe(false); }); it('bounds password length by Unicode characters and rejects invalid inputs before hashing', async () => { const { validatePassword, hashPassword, verifyPassword } = await import('../../src/lib/server/auth/passwords.js'); for (const value of [ undefined, null, 123, '', 'a'.repeat(7), '🦊'.repeat(7), 'a'.repeat(129) ]) { await expect(hashPassword(value)).rejects.toThrow('8 and 128'); } expect(validatePassword('a'.repeat(8))).toBe('a'.repeat(8)); expect(validatePassword('🦊'.repeat(8))).toBe('🦊'.repeat(8)); expect(validatePassword('a'.repeat(128))).toHaveLength(128); expect(await verifyPassword('a'.repeat(129), passwordHash)).toBe(false); }); it('allows three simultaneous logins to complete with production-strength scrypt', async () => { const users = [seedUser('first'), seedUser('second'), seedUser('third')]; const results = await Promise.all( users.map((user, index) => auth.authenticatePassword( { username: user.username, password }, `192.0.2.${index + 1}` ) ) ); expect(results.map((result) => result.user.id)).toEqual(users.map((user) => user.id)); for (const result of results) expect(auth.readSession(result.session.token)?.user.id).toBe(result.user.id); }); it('uses a short retry for password capacity while preserving the rate-limit retry', async () => { const { PasswordServiceBusyError } = await import('../../src/lib/server/auth/passwords.js'); const { authError } = await import('../../src/lib/server/auth/http.js'); const busy = authError(new PasswordServiceBusyError()); expect(busy.status).toBe(429); expect(busy.headers.get('Retry-After')).toBe('2'); expect(busy.headers.get('Cache-Control')).toBe('no-store'); expect(await busy.json()).toEqual({ error: 'Password service is busy. Try again shortly.' }); const limited = authError( new auth.AuthError('Too many attempts. Try again in 15 minutes.', 429) ); expect(limited.status).toBe(429); expect(limited.headers.get('Retry-After')).toBe('900'); const unexpected = authError(new Error('Database is busy with private details')); expect(unexpected.status).toBe(500); expect(unexpected.headers.has('Retry-After')).toBe(false); expect(await unexpected.json()).toEqual({ error: 'The authentication request could not be completed.' }); }); it('returns the same login error for unknown, disabled and incorrect-password accounts', async () => { seedUser('enabled'); seedUser('disabled', 'viewer', { disabled: true }); for (const [username, supplied] of [ ['missing', password], ['enabled', replacement], ['disabled', password] ]) { await expect( auth.authenticatePassword({ username, password: supplied }, '192.0.2.5') ).rejects.toMatchObject({ status: 401, message: 'Invalid username or password.' }); } expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 }); expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password); expect(auth.listAuthAudit()).toHaveLength(3); }); }); describe('administrator user management', () => { it('rejects the reserved system username for every role while allowing distinct usernames', async () => { const admin = seedUser(); for (const role of ['admin', 'super_viewer', 'operator', 'moderator', 'viewer'] as const) { for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) { await expect( auth.createUser(admin.id, { username, role, password }) ).rejects.toMatchObject({ status: 400, message: 'Maxis is reserved for system actions. Choose another username.' }); } } expect(auth.listUsers()).toHaveLength(1); expect(auth.listAuthAudit()).toEqual([]); await expect( auth.createUser(admin.id, { username: 'maxis-operator', role: 'operator', password }) ).resolves.toMatchObject({ username: 'maxis-operator' }); }); it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => { const admin = seedUser(); for (const role of ['admin', 'super_viewer', 'operator', 'moderator', 'viewer'] as const) { const user = await auth.createUser(admin.id, { username: role + 'User', displayName: ' ' + role + ' user ', role, password }); expect(user).toMatchObject({ username: role + 'user', displayName: role + ' user', role, mustChangePassword: true, disabled: false }); expect(storedUser(user.id).password_hash).toMatch(/^scrypt\$/); expect(JSON.stringify(user)).not.toContain('password_hash'); } await expect( auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password }) ).rejects.toMatchObject({ status: 409 }); expect(auth.listUsers()).toHaveLength(6); expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password); }); it('rejects administrator mutations by read-only roles, operators, disabled administrators and forced-change administrators', async () => { const target = seedUser('target', 'viewer'); const actors = [ seedUser('superviewer', 'super_viewer'), seedUser('operator', 'operator'), seedUser('moderator', 'moderator'), seedUser('viewer', 'viewer'), seedUser('disabledadmin', 'admin', { disabled: true }), seedUser('temporaryadmin', 'admin', { mustChangePassword: true }) ]; const session = auth.createSession(target.id); for (const actor of actors) { await expect( auth.createUser(actor.id, { username: 'intruder', role: 'admin', password }) ).rejects.toMatchObject({ status: 403 }); await expect( auth.updateUser(actor.id, target.id, { role: 'admin' }) ).rejects.toMatchObject({ status: 403 }); expect(() => auth.revokeUserSessions(actor.id, target.id)).toThrow('Administrator'); } expect(auth.getUser(target.id)?.role).toBe('viewer'); expect(auth.readSession(session.token)).not.toBeNull(); expect(auth.listUsers().some((user) => user.username === 'intruder')).toBe(false); }); it('preserves at least one enabled administrator and permits demotion only when another remains', async () => { const first = seedUser(); seedUser('superviewer', 'super_viewer'); const session = auth.createSession(first.id); await expect( auth.updateUser(first.id, first.id, { role: 'super_viewer' }) ).rejects.toMatchObject({ status: 409 }); await expect( auth.updateUser(first.id, first.id, { role: 'operator' }) ).rejects.toMatchObject({ status: 409 }); await expect( auth.updateUser(first.id, first.id, { role: 'moderator' }) ).rejects.toMatchObject({ status: 409 }); await expect(auth.updateUser(first.id, first.id, { disabled: true })).rejects.toMatchObject( { status: 409 } ); expect(auth.readSession(session.token)?.user.role).toBe('admin'); const second = seedUser('secondadmin'); await auth.updateUser(first.id, first.id, { role: 'super_viewer' }); expect(auth.getUser(first.id)?.role).toBe('super_viewer'); expect(auth.readSession(session.token)).toBeNull(); await expect( auth.updateUser(second.id, second.id, { disabled: true }) ).rejects.toMatchObject({ status: 409 }); expect(auth.getUser(second.id)?.role).toBe('admin'); }); it('revokes sessions when moderation access is granted or removed', async () => { const admin = seedUser(); const target = seedUser('target', 'viewer'); const beforeGrant = auth.createSession(target.id, true); await auth.updateUser(admin.id, target.id, { role: 'moderator' }); expect(auth.getUser(target.id)?.role).toBe('moderator'); expect(auth.readSession(beforeGrant.token)).toBeNull(); const beforeRemoval = auth.createSession(target.id, true); await auth.updateUser(admin.id, target.id, { role: 'viewer' }); expect(auth.readSession(beforeRemoval.token)).toBeNull(); }); it.each([false, true])( 'revokes every target session on role, enabled-state and password changes (remembered=%s)', async (remembered) => { const admin = seedUser(), target = seedUser('target', 'viewer'); const administratorSession = auth.createSession(admin.id); const first = auth.createSession(target.id, remembered), second = auth.createSession(target.id); await auth.updateUser(admin.id, target.id, { role: 'super_viewer' }); expect(auth.getUser(target.id)?.role).toBe('super_viewer'); expect(auth.readSession(first.token)).toBeNull(); expect(auth.readSession(second.token)).toBeNull(); const third = auth.createSession(target.id, remembered); await auth.updateUser(admin.id, target.id, { disabled: true }); expect(auth.readSession(third.token)).toBeNull(); expect(() => auth.createSession(target.id)).toThrow('unavailable'); await auth.updateUser(admin.id, target.id, { disabled: false }); expect(auth.readSession(third.token)).toBeNull(); const fourth = auth.createSession(target.id, remembered); const reset = await auth.updateUser(admin.id, target.id, { password: replacement }); expect(reset.mustChangePassword).toBe(true); expect(auth.readSession(fourth.token)).toBeNull(); expect(auth.readSession(administratorSession.token)?.user.id).toBe(admin.id); expect(storedUser(target.id).password_hash).not.toBe(passwordHash); await expect( auth.updateUser(admin.id, admin.id, { password: replacement }) ).rejects.toThrow('current password'); } ); it.each([false, true])( 'keeps sessions for display-only edits and supports per-user and individual revocation (remembered=%s)', async (remembered) => { const admin = seedUser(), target = seedUser('target', 'viewer'); const one = auth.createSession(target.id, remembered), two = auth.createSession(target.id, remembered), adminSession = auth.createSession(admin.id); await auth.updateUser(admin.id, target.id, { displayName: 'New display name' }); expect(auth.readSession(one.token)?.user.displayName).toBe('New display name'); auth.revokeSession(one.token); expect(auth.readSession(one.token)).toBeNull(); expect(auth.readSession(two.token)).not.toBeNull(); auth.revokeUserSessions(admin.id, target.id); expect(auth.readSession(two.token)).toBeNull(); expect(auth.readSession(adminSession.token)).not.toBeNull(); } ); }); describe('session secrecy and expiration', () => { it('signs out a remembered device by deleting its cookie and server session', async () => { const user = seedUser(); const session = auth.createSession(user.id, true); const otherDevice = auth.createSession(user.id, true); const { POST } = await import('../../src/routes/api/auth/logout/+server.js'); const url = new URL('https://ec.test/api/auth/logout'); const cookies = { get: vi.fn(() => session.token), delete: vi.fn() }; const response = await POST({ cookies, url, locals: { user } } as unknown as Parameters< typeof POST >[0]); expect(response.status).toBe(200); expect(cookies.delete).toHaveBeenCalledWith('__Host-ec_session', { path: '/' }); expect(auth.readSession(session.token)).toBeNull(); expect(auth.readSession(otherDevice.token)).not.toBeNull(); }); it.each([undefined, false, true])( 'requires explicit opt-in at login and sets the cookie lifetime (rememberMe=%s)', async (rememberMe) => { const now = 2_000_000_000_000; vi.spyOn(Date, 'now').mockReturnValue(now); const user = seedUser(); const previous = auth.createSession(user.id, true); const { POST } = await import('../../src/routes/api/auth/login/+server.js'); const url = new URL('https://ec.test/api/auth/login'); const cookies = { get: vi.fn(() => previous.token), set: vi.fn() }; const response = await POST({ request: new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: user.username, password, rememberMe }) }), cookies, url, getClientAddress: () => '192.0.2.60' } as unknown as Parameters[0]); expect(response.status).toBe(200); const [name, token, options] = cookies.set.mock.calls[0]; const lifetime = rememberMe ? 30 * 24 * 60 * 60_000 : 12 * 60 * 60_000; expect(name).toBe('__Host-ec_session'); expect(options).toEqual({ path: '/', httpOnly: true, secure: true, sameSite: 'lax', expires: new Date(now + lifetime) }); expect(auth.readSession(token)).toMatchObject({ remembered: rememberMe === true, expiresAt: now + lifetime }); expect(auth.readSession(previous.token)).toBeNull(); } ); it.each(['true', 'false', 1, null, {}])( 'rejects non-boolean remember-device choices: %j', async (rememberMe) => { const user = seedUser(); await expect( auth.authenticatePassword( { username: user.username, password, rememberMe }, '192.0.2.60' ) ).rejects.toMatchObject({ status: 400 }); expect(database.db().prepare('SELECT * FROM auth_sessions').all()).toEqual([]); } ); it('keeps remembered sessions across inactivity, other logins and restarts, but never beyond 30 days', () => { let now = 2_000_000_000_000; vi.spyOn(Date, 'now').mockImplementation(() => now); const user = seedUser(); const remembered = auth.createSession(user.id, true); const short = auth.createSession(user.id); now += 2 * 24 * 60 * 60_000; auth.createSession(user.id); // Expiry cleanup must retain inactive remembered devices. database.closeDb(); expect(auth.readSession(short.token)).toBeNull(); expect(auth.readSession(remembered.token)).toMatchObject({ user: { id: user.id }, remembered: true }); now = remembered.expiresAt - 1; expect(auth.readSession(remembered.token)?.expiresAt).toBe(remembered.expiresAt); now++; expect(auth.readSession(remembered.token)).toBeNull(); auth.createSession(user.id); expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 1 }); }); it('stores only opaque session hashes, never raw cookies or password hashes in public identities', () => { const user = seedUser(); const first = auth.createSession(user.id), second = auth.createSession(user.id); expect(first.token).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(first.token).not.toBe(second.token); const sessions = JSON.stringify(database.db().prepare('SELECT * FROM auth_sessions').all()); expect(sessions).not.toContain(first.token); expect(sessions).not.toContain(second.token); expect(auth.readSession(first.token)?.user).not.toHaveProperty('passwordHash'); for (const token of [undefined, '', 'x', '!', 'x'.repeat(44)]) expect(auth.readSession(token)).toBeNull(); expect(auth.readSession('x'.repeat(43))).toBeNull(); }); it('throttles activity touches and expires sessions at the idle boundary', () => { let now = 2_000_000_000_000; vi.spyOn(Date, 'now').mockImplementation(() => now); const user = seedUser(), session = auth.createSession(user.id); now += 59_000; expect(auth.readSession(session.token)).not.toBeNull(); expect(database.db().prepare('SELECT last_seen_at FROM auth_sessions').get()).toEqual({ last_seen_at: now - 59_000 }); now += 2_000; expect(auth.readSession(session.token)).not.toBeNull(); expect(database.db().prepare('SELECT last_seen_at FROM auth_sessions').get()).toEqual({ last_seen_at: now }); const lastSeen = now; now = lastSeen + auth.SESSION_IDLE_MS - 1; expect(auth.readSession(session.token, false)).not.toBeNull(); now++; expect(auth.readSession(session.token)).toBeNull(); auth.createSession(user.id); expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 1 }); }); it('enforces absolute expiry even while the user remains recently active', () => { let now = 2_000_000_000_000; vi.spyOn(Date, 'now').mockImplementation(() => now); const session = auth.createSession(seedUser().id); now = session.expiresAt - 1; database .db() .prepare('UPDATE auth_sessions SET last_seen_at=?') .run(now - 1000); expect(auth.readSession(session.token)).not.toBeNull(); now++; expect(auth.readSession(session.token)).toBeNull(); }); }); describe('forced password changes and asynchronous authorization races', () => { it.each([false, true])( 'preserves this device choice through a required password change and revokes all old sessions (remembered=%s)', async (remembered) => { let now = 2_000_000_000_000; vi.spyOn(Date, 'now').mockImplementation(() => now); const user = seedUser('newuser', 'viewer', { mustChangePassword: true }); const login = await auth.authenticatePassword( { username: user.username, password, rememberMe: remembered }, '192.0.2.61' ); const other = auth.createSession(user.id, true); const tokenHash = auth.readSession(login.session.token)!.tokenHash; const { POST } = await import('../../src/routes/api/auth/password/+server.js'); const url = new URL('https://ec.test/api/auth/password'); const cookies = { set: vi.fn() }; const response = await POST({ request: new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // The server must preserve the stored policy, ignoring a forged client choice. body: JSON.stringify({ currentPassword: password, newPassword: replacement, rememberMe: !remembered }) }), cookies, url, locals: { user, sessionHash: tokenHash }, getClientAddress: () => '192.0.2.61' } as unknown as Parameters[0]); expect(response.status).toBe(200); const [, token, options] = cookies.set.mock.calls[0]; const lifetime = remembered ? 30 * 24 * 60 * 60_000 : 12 * 60 * 60_000; expect(options.expires).toEqual(new Date(now + lifetime)); expect(auth.readSession(login.session.token)).toBeNull(); expect(auth.readSession(other.token)).toBeNull(); expect(auth.readSession(token)).toMatchObject({ remembered, user: { mustChangePassword: false } }); now += 24 * 60 * 60_000; expect(!!auth.readSession(token)).toBe(remembered); } ); it('requires the current password, replaces old sessions atomically and clears the forced-change state', async () => { const user = seedUser('newuser', 'viewer', { mustChangePassword: true }); const login = await auth.authenticatePassword( { username: 'NEWUSER', password }, '192.0.2.10' ); expect(login.user.mustChangePassword).toBe(true); expect(auth.readSession(login.session.token)?.user.mustChangePassword).toBe(true); await expect( auth.changePassword(user.id, replacement, 'a different long passphrase') ).rejects.toThrow('Current password'); await expect(auth.changePassword(user.id, password, password)).rejects.toThrow( 'different password' ); expect(auth.readSession(login.session.token)).not.toBeNull(); const changed = await auth.changePassword(user.id, password, replacement); expect(changed.user).toMatchObject({ id: user.id, role: 'viewer', mustChangePassword: false }); expect(auth.readSession(login.session.token)).toBeNull(); expect(auth.readSession(changed.session.token)?.user.mustChangePassword).toBe(false); await expect( auth.authenticatePassword({ username: user.username, password }, '192.0.2.11') ).rejects.toMatchObject({ status: 401 }); }); it('does not mint a session when the verified password is reset during login', async () => { const admin = seedUser(), target = seedUser('target', 'viewer'); const passwords = await import('../../src/lib/server/auth/passwords.js'); const verifying = gate(); vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise); vi.spyOn(passwords, 'hashPassword').mockResolvedValueOnce(replacementHash); const pending = auth.authenticatePassword( { username: target.username, password }, '192.0.2.20' ); await auth.updateUser(admin.id, target.id, { password: replacement }); verifying.release(true); await expect(pending).rejects.toMatchObject({ status: 401 }); expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 }); }); it('rechecks a disabled account and current role after asynchronous login verification', async () => { const admin = seedUser(), target = seedUser('target', 'operator'); const passwords = await import('../../src/lib/server/auth/passwords.js'); const verifying = gate(); vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise); const pending = auth.authenticatePassword( { username: target.username, password }, '192.0.2.21' ); await auth.updateUser(admin.id, target.id, { disabled: true }); verifying.release(true); await expect(pending).rejects.toMatchObject({ status: 401 }); await auth.updateUser(admin.id, target.id, { disabled: false }); const second = gate(); vi.mocked(passwords.verifyPassword).mockImplementationOnce(() => second.promise); const nextLogin = auth.authenticatePassword( { username: target.username, password }, '192.0.2.22' ); await auth.updateUser(admin.id, target.id, { role: 'viewer' }); second.release(true); const loggedIn = await nextLogin; expect(loggedIn.user.role).toBe('viewer'); expect(auth.readSession(loggedIn.session.token)?.user.role).toBe('viewer'); }); it('rechecks the administrator before committing a user creation that was hashing during demotion', async () => { const actor = seedUser(), other = seedUser('otheradmin'); const passwords = await import('../../src/lib/server/auth/passwords.js'); const hashing = gate(); vi.spyOn(passwords, 'hashPassword').mockImplementationOnce(() => hashing.promise); const pending = auth.createUser(actor.id, { username: 'intruder', password, role: 'admin' }); await auth.updateUser(other.id, actor.id, { role: 'viewer' }); hashing.release(passwordHash); await expect(pending).rejects.toMatchObject({ status: 403 }); expect(auth.listUsers()).toHaveLength(2); }); it('does not commit a password reset if its administrator lost access while hashing', async () => { const actor = seedUser(), other = seedUser('otheradmin'), target = seedUser('target', 'viewer'); const session = auth.createSession(target.id); const passwords = await import('../../src/lib/server/auth/passwords.js'); const hashing = gate(); vi.spyOn(passwords, 'hashPassword').mockImplementationOnce(() => hashing.promise); const pending = auth.updateUser(actor.id, target.id, { password: replacement }); await auth.updateUser(other.id, actor.id, { role: 'viewer' }); hashing.release(replacementHash); await expect(pending).rejects.toMatchObject({ status: 403 }); expect(storedUser(target.id).password_hash).toBe(passwordHash); expect(auth.readSession(session.token)).not.toBeNull(); }); it('rejects a self password change whose original password was concurrently reset', async () => { const admin = seedUser(), target = seedUser('target', 'viewer'); const passwords = await import('../../src/lib/server/auth/passwords.js'); const verifying = gate(); vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise); vi.spyOn(passwords, 'hashPassword').mockResolvedValue(replacementHash); const pending = auth.changePassword(target.id, password, 'self selected new passphrase'); await auth.updateUser(admin.id, target.id, { password: replacement }); verifying.release(true); await expect(pending).rejects.toMatchObject({ status: 409 }); expect(storedUser(target.id)).toMatchObject({ password_hash: replacementHash, must_change_password: 1 }); expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 }); }); }); describe('remembered administrator account changes', () => { it.each([false, true])( 'enforces password confirmation in account creation and update endpoints (remembered=%s)', async (remembered) => { const admin = seedUser(); const login = auth.createSession(admin.id, remembered); const sessionHash = auth.readSession(login.token)!.tokenHash; const { POST } = await import('../../src/routes/api/users/+server.js'); const { PATCH } = await import('../../src/routes/api/users/[id]/+server.js'); function event(method: string, body: Record, id = '') { const url = new URL(`https://ec.test/api/users/${id}`); return { url, request: new Request(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }), locals: { user: admin, sessionHash }, params: { id }, getClientAddress: () => '192.0.2.62' }; } const input = { username: 'newviewer', role: 'viewer', password: replacement }; if (remembered) { for (const currentPassword of [undefined, replacement]) { const response = await POST( event('POST', { ...input, currentPassword }) as unknown as Parameters< typeof POST >[0] ); expect(response.status).toBe(403); expect(auth.listUsers()).toHaveLength(1); } } const response = await POST( event('POST', { ...input, ...(remembered ? { currentPassword: password } : {}) }) as unknown as Parameters[0] ); expect(response.status).toBe(201); const { user } = await response.json(); const targetSession = auth.createSession(user.id, true); const changes = { role: 'operator', password }; if (remembered) { for (const currentPassword of [undefined, replacement]) { const response = await PATCH( event( 'PATCH', { ...changes, currentPassword }, user.id ) as unknown as Parameters[0] ); expect(response.status).toBe(403); expect(auth.getUser(user.id)?.role).toBe('viewer'); expect(auth.readSession(targetSession.token)).not.toBeNull(); } } const updated = await PATCH( event( 'PATCH', { ...changes, ...(remembered ? { currentPassword: password } : {}) }, user.id ) as unknown as Parameters[0] ); expect(updated.status).toBe(200); expect(auth.getUser(user.id)?.role).toBe('operator'); expect(auth.readSession(targetSession.token)).toBeNull(); const audit = JSON.stringify(auth.listAuthAudit()); expect(audit).not.toContain(replacement); expect(audit).not.toContain('currentPassword'); } ); it('rejects an account change if the remembered session is revoked while confirming the password', async () => { const admin = seedUser(), target = seedUser('target', 'viewer'); const session = auth.createSession(admin.id, true); const tokenHash = auth.readSession(session.token)!.tokenHash; const passwords = await import('../../src/lib/server/auth/passwords.js'); const verifying = gate(); vi.spyOn(passwords, 'verifyPassword').mockImplementationOnce(() => verifying.promise); const pending = auth.updateUser( admin.id, target.id, { role: 'admin', currentPassword: password }, { tokenHash, clientAddress: '192.0.2.62' } ); auth.revokeSession(session.token); verifying.release(true); await expect(pending).rejects.toMatchObject({ status: 401 }); expect(auth.getUser(target.id)?.role).toBe('viewer'); }); it('rechecks the session after hashing a new user password', async () => { const admin = seedUser(); const session = auth.createSession(admin.id, true); const tokenHash = auth.readSession(session.token)!.tokenHash; const passwords = await import('../../src/lib/server/auth/passwords.js'); vi.spyOn(passwords, 'verifyPassword').mockResolvedValueOnce(true); const hashing = gate(); const hash = vi .spyOn(passwords, 'hashPassword') .mockImplementationOnce(() => hashing.promise); const pending = auth.createUser( admin.id, { username: 'newviewer', role: 'viewer', password: replacement, currentPassword: password }, { tokenHash, clientAddress: '192.0.2.62' } ); await vi.waitFor(() => expect(hash).toHaveBeenCalled()); auth.revokeSession(session.token); hashing.release(replacementHash); await expect(pending).rejects.toMatchObject({ status: 401 }); expect(auth.listUsers()).toHaveLength(1); }); }); describe('persistent authentication throttling', () => { it('blocks an exhausted account/address pair without locking out the account at another address', async () => { const user = seedUser('target', 'viewer'); const input = { username: user.username.toUpperCase(), password }; for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt('192.0.2.70', input.username); // A pre-upgrade account-wide bucket must not continue a global lockout. database .db() .prepare('INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,?)') .run( createHash('sha256').update(`login:account:${user.username}`).digest('hex'), Date.now(), 10 ); await expect(auth.authenticatePassword(input, '192.0.2.70')).rejects.toMatchObject({ status: 429 }); await expect(auth.authenticatePassword(input, '192.0.2.71')).resolves.toMatchObject({ user: { id: user.id } }); }); it.each(['admin', 'super_viewer'] as const)( 'does not consume login or password-change limits for an enabled %s', (role) => { const user = seedUser('unlimited', role); for (let attempt = 0; attempt < 31; attempt++) { auth.consumeAuthAttempt('192.0.2.50', user.username.toUpperCase()); auth.consumeAuthAttempt('192.0.2.50', user.id, 'password'); } expect(database.db().prepare('SELECT * FROM auth_rate_limits').all()).toEqual([]); // Initial setup cannot inherit an existing account's exemption. for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt('192.0.2.50', user.username, 'setup'); expect(() => auth.consumeAuthAttempt('192.0.2.50', user.username, 'setup')).toThrow( 'Too many attempts' ); } ); it.each(['admin', 'super_viewer'] as const)( 'allows %s login and password changes through exhausted account and address limits', async (role) => { const actor = seedUser(); const user = seedUser('promoted', 'viewer', { mustChangePassword: true }); const address = '192.0.2.51'; for (const kind of ['login', 'password'] as const) { const account = kind === 'login' ? user.username : user.id; for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt(address, account, kind); for (let attempt = 0; attempt < 20; attempt++) auth.consumeAuthAttempt(address, `missing-${attempt}`, kind); } const limits = database .db() .prepare('SELECT * FROM auth_rate_limits ORDER BY key') .all(); await auth.updateUser(actor.id, user.id, { role }); await expect( auth.authenticatePassword( { username: user.username, password: replacement }, address ) ).rejects.toMatchObject({ status: 401 }); const login = await auth.authenticatePassword( { username: user.username.toUpperCase(), password }, address ); expect(login.user).toMatchObject({ id: user.id, role, mustChangePassword: true }); expect(auth.readSession(login.session.token)?.user.id).toBe(user.id); const { POST } = await import('../../src/routes/api/auth/password/+server.js'); const url = new URL('https://ec.test/api/auth/password'); const response = await POST({ request: new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: password, newPassword: replacement }) }), cookies: { set: vi.fn() }, locals: { user: login.user, sessionHash: auth.readSession(login.session.token)!.tokenHash }, url, getClientAddress: () => address } as unknown as Parameters[0]); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ user: { id: user.id, role, mustChangePassword: false } }); expect( database.db().prepare('SELECT * FROM auth_rate_limits ORDER BY key').all() ).toEqual(limits); } ); it.each([ ['viewer', false], ['operator', false], ['admin', true], ['super_viewer', true] ] as const)('retains limits for %s with disabled=%s', async (role, disabled) => { const user = seedUser('limited', role, { disabled }); for (const kind of ['login', 'password'] as const) { const account = kind === 'login' ? user.username : user.id; for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt('192.0.2.52', account, kind); expect(() => auth.consumeAuthAttempt('192.0.2.52', account, kind)).toThrow( 'Too many attempts' ); if (kind === 'login') expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).not.toThrow(); else expect(() => auth.consumeAuthAttempt('192.0.2.53', account, kind)).toThrow( 'Too many attempts' ); } await expect( auth.authenticatePassword( { username: user.username, password, role: 'admin' }, '192.0.2.52' ) ).rejects.toMatchObject({ status: 429 }); }); it('retains case-insensitive account/address limits across database reopen and resets after the window', () => { let now = 2_000_000_000_000; vi.spyOn(Date, 'now').mockImplementation(() => now); for (let attempt = 0; attempt < 10; attempt++) auth.consumeAuthAttempt('192.0.2.30', attempt % 2 ? 'ADMIN' : 'admin'); database.closeDb(); expect(() => auth.consumeAuthAttempt('192.0.2.30', 'Admin')).toThrow('Too many attempts'); expect(() => auth.consumeAuthAttempt('192.0.2.31', 'Admin')).not.toThrow(); const persisted = JSON.stringify( database.db().prepare('SELECT * FROM auth_rate_limits').all() ); expect(persisted).not.toContain('192.0.2.30'); expect(persisted).not.toContain('admin'); now += 15 * 60_000; expect(() => auth.consumeAuthAttempt('192.0.2.30', 'Admin')).not.toThrow(); }); it('limits attempts from one address across many accounts and separates setup from login windows', () => { for (let account = 0; account < 30; account++) auth.consumeAuthAttempt('192.0.2.40', 'account-' + account); expect(() => auth.consumeAuthAttempt('192.0.2.40', 'new-account')).toThrow( 'Too many attempts' ); expect(() => auth.consumeAuthAttempt('192.0.2.41', 'new-account')).not.toThrow(); expect(() => auth.consumeAuthAttempt('192.0.2.40', 'bootstrap', 'setup')).not.toThrow(); }); });