Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite), local sign-in with long-lived sessions and passkeys, roles (read-only, full access, admin), nightly and pre-upgrade backups, light and dark themes, date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED. Money in: accounts with balances, CSV import with bank detection, a column mapping pop-up for unknown layouts, duplicate checks, transfer pairing and undo; one-tap review with learned rules; activity with search, splits, tags, notes and cash entries. Statement files are blocked from commits by a pre-commit hook; tests use synthetic fixtures only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
7.6 KiB
TypeScript
162 lines
7.6 KiB
TypeScript
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);
|
|
});
|
|
});
|