import { eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { hashToken, SESSION_COOKIE } from '../src/auth/sessions'; import { sessions } from '../src/db/schema'; import { Client, makeApp, setUpAdmin, type TestApp } from './helpers'; let t: TestApp; beforeEach(async () => { t = await makeApp(); }); afterEach(() => t.cleanup()); describe('first-run setup', () => { it('asks for setup until the first person exists, then never again', async () => { const visitor = new Client(t); expect((await visitor.get('/api/v1/config')).json).toEqual({ version: '20261007.01', ideasEnabled: false, needsSetup: true, }); const admin = await setUpAdmin(t); const me = (await admin.get('/api/v1/me')).json; expect(me).toMatchObject({ username: 'chris', displayName: 'Chris', access: 'full', isAdmin: true, hasPasskey: false }); expect((await visitor.get('/api/v1/config')).json.needsSetup).toBe(false); const again = await visitor.post('/api/v1/setup', { displayName: 'Eve', username: 'eve', password: 'password123' }); expect(again.status).toBe(409); }); it('rejects weak input with a friendly message', async () => { const res = await new Client(t).post('/api/v1/setup', { displayName: 'A', username: 'a b', password: 'short' }); expect(res.status).toBe(400); expect(res.json.error.code).toBe('invalid_input'); }); }); describe('sign-in', () => { it('signs in, stays signed in, and signs out', async () => { await setUpAdmin(t); const phone = new Client(t, 'http://localhost', { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1' }); expect((await phone.get('/api/v1/me')).status).toBe(401); const login = await phone.post('/api/v1/auth/login', { username: 'CHRIS', password: 'correct-horse' }); expect(login.status).toBe(200); const cookie = login.headers.getSetCookie().find((c) => c.startsWith(`${SESSION_COOKIE}=`))!; expect(cookie).toMatch(/HttpOnly/i); expect(cookie).toMatch(/SameSite=Lax/i); expect(cookie).toMatch(/Max-Age=34560000/); // 400 days const devices = (await phone.get('/api/v1/devices')).json; expect(devices.find((d: any) => d.current).deviceLabel).toBe('iPhone ยท Safari'); expect((await phone.post('/api/v1/auth/logout')).status).toBe(200); expect((await phone.get('/api/v1/me')).status).toBe(401); }); it('refuses a wrong password, then slows down repeated guessing', async () => { await setUpAdmin(t); const guesser = new Client(t); for (let i = 0; i < 5; i++) { expect((await guesser.post('/api/v1/auth/login', { username: 'chris', password: `guess-${i}` })).status).toBe(401); } const blocked = await guesser.post('/api/v1/auth/login', { username: 'chris', password: 'correct-horse' }); expect(blocked.status).toBe(429); expect(blocked.headers.get('retry-after')).toBeTruthy(); }); it('renews an ageing session so a device in use never signs out', async () => { const admin = await setUpAdmin(t); const token = admin.cookie(SESSION_COOKIE)!; const id = hashToken(token); const soon = new Date(Date.now() + 2 * 86_400_000).toISOString(); t.ctx.db.update(sessions).set({ expiresAt: soon }).where(eq(sessions.id, id)).run(); const res = await admin.get('/api/v1/me'); expect(res.status).toBe(200); expect(res.headers.getSetCookie().some((c) => c.startsWith(`${SESSION_COOKIE}=`))).toBe(true); const renewed = t.ctx.db.select().from(sessions).where(eq(sessions.id, id)).get()!; expect(Date.parse(renewed.expiresAt)).toBeGreaterThan(Date.now() + 399 * 86_400_000); }); it('refuses password sign-in over plain HTTP from another machine', async () => { await setUpAdmin(t); const lan = new Client(t, 'http://192.168.1.20:8095'); const res = await lan.post('/api/v1/auth/login', { username: 'chris', password: 'correct-horse' }); expect(res.status).toBe(400); expect(res.json.error.code).toBe('insecure_connection'); }); it('trusts the HTTPS proxy and marks cookies Secure behind it', async () => { t.cleanup(); t = await makeApp({ TRUST_PROXY: 'true' }); const viaProxy = new Client(t, 'http://192.168.1.20:8095', { 'X-Forwarded-Proto': 'https', 'X-Forwarded-Host': 'spendlight.example.com', }); const res = await viaProxy.post('/api/v1/setup', { displayName: 'Chris', username: 'chris', password: 'correct-horse' }); expect(res.status).toBe(200); expect(res.headers.getSetCookie().find((c) => c.startsWith(SESSION_COOKIE))).toMatch(/Secure/); }); it('blocks changes sent from another website', async () => { const admin = await setUpAdmin(t); const res = await admin.patch('/api/v1/me', { theme: 'dark' }, { Origin: 'https://evil.example' }); expect(res.status).toBe(403); expect((await admin.patch('/api/v1/me', { theme: 'dark' }, { Origin: 'http://localhost' })).json.theme).toBe('dark'); }); it('changes a password and signs out other devices', async () => { const admin = await setUpAdmin(t); const laptop = new Client(t); await laptop.post('/api/v1/auth/login', { username: 'chris', password: 'correct-horse' }); const wrong = await admin.post('/api/v1/me/password', { currentPassword: 'nope', newPassword: 'battery-staple' }); expect(wrong.status).toBe(400); const ok = await admin.post('/api/v1/me/password', { currentPassword: 'correct-horse', newPassword: 'battery-staple' }); expect(ok.status).toBe(200); expect((await admin.get('/api/v1/me')).status).toBe(200); expect((await laptop.get('/api/v1/me')).status).toBe(401); }); }); describe('people and roles', () => { it('lets an admin add a read-only person who must choose a password', async () => { const admin = await setUpAdmin(t); const added = await admin.post('/api/v1/people', { displayName: 'Sam', username: 'sam', access: 'read' }); expect(added.status).toBe(200); expect(added.json.temporaryPassword).toMatch(/^[a-z2-9]{4}-[a-z2-9]{4}-[a-z2-9]{4}$/); const sam = new Client(t); const login = await sam.post('/api/v1/auth/login', { username: 'sam', password: added.json.temporaryPassword }); expect(login.json).toMatchObject({ access: 'read', isAdmin: false, mustChangePassword: true }); expect((await sam.get('/api/v1/people')).status).toBe(403); expect((await sam.post('/api/v1/backups')).status).toBe(403); const changed = await sam.post('/api/v1/me/password', { currentPassword: added.json.temporaryPassword, newPassword: 'sams-own-password', }); expect(changed.json.mustChangePassword).toBe(false); }); it('never leaves the household without an admin', async () => { const admin = await setUpAdmin(t); const me = (await admin.get('/api/v1/me')).json; expect((await admin.patch(`/api/v1/people/${me.id}`, { isAdmin: false })).json.error.code).toBe('last_admin'); expect((await admin.del(`/api/v1/people/${me.id}`)).json.error.code).toBe('cannot_remove_self'); }); it('removes a person, signs them out, and frees their username', async () => { const admin = await setUpAdmin(t); const added = await admin.post('/api/v1/people', { displayName: 'Sam', username: 'sam', access: 'full', password: 'sams-password' }); const sam = new Client(t); await sam.post('/api/v1/auth/login', { username: 'sam', password: 'sams-password' }); expect((await admin.del(`/api/v1/people/${added.json.person.id}`)).status).toBe(200); expect((await sam.get('/api/v1/me')).status).toBe(401); expect((await admin.post('/api/v1/people', { displayName: 'Sam 2', username: 'sam', access: 'read' })).status).toBe(200); }); });