2026-08-29 15:20:42 +02:00
|
|
|
import { describe, it, expect } from 'vitest'
|
2026-08-29 15:35:24 +02:00
|
|
|
import { buildTestApp, setupAdmin, getCookie, auth, loginAs } from './helpers.js'
|
2026-08-29 15:20:42 +02:00
|
|
|
|
|
|
|
|
describe('auth routes', () => {
|
|
|
|
|
it('setup creates admin when no users exist, then is closed', async () => {
|
|
|
|
|
const app = await buildTestApp()
|
|
|
|
|
const status = await app.inject({ method: 'GET', url: '/api/setup' })
|
|
|
|
|
expect(status.json()).toEqual({ needed: true })
|
|
|
|
|
|
|
|
|
|
const cookie = await setupAdmin(app)
|
|
|
|
|
|
|
|
|
|
const me = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) })
|
|
|
|
|
expect(me.statusCode).toBe(200)
|
|
|
|
|
expect(me.json()).toEqual({ user: { id: 1, username: 'admin', isAdmin: true } })
|
|
|
|
|
|
|
|
|
|
const again = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/setup',
|
|
|
|
|
payload: { username: 'x', password: 'password123' },
|
|
|
|
|
})
|
|
|
|
|
expect(again.statusCode).toBe(403)
|
|
|
|
|
await app.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('setup validates input', async () => {
|
|
|
|
|
const app = await buildTestApp()
|
|
|
|
|
const bad = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/setup',
|
|
|
|
|
payload: { username: 'ab', password: 'short' },
|
|
|
|
|
})
|
|
|
|
|
expect(bad.statusCode).toBe(400)
|
|
|
|
|
await app.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('login and logout', async () => {
|
|
|
|
|
const app = await buildTestApp()
|
|
|
|
|
await setupAdmin(app)
|
|
|
|
|
const login = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/login',
|
|
|
|
|
payload: { username: 'admin', password: 'adminpass123' },
|
|
|
|
|
})
|
|
|
|
|
expect(login.statusCode).toBe(200)
|
|
|
|
|
const cookie = getCookie(login)
|
|
|
|
|
|
|
|
|
|
const me = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) })
|
|
|
|
|
expect(me.statusCode).toBe(200)
|
|
|
|
|
|
|
|
|
|
await app.inject({ method: 'POST', url: '/api/logout', ...auth(cookie) })
|
|
|
|
|
const after = await app.inject({ method: 'GET', url: '/api/me', ...auth(cookie) })
|
|
|
|
|
expect(after.statusCode).toBe(401)
|
|
|
|
|
await app.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('login rejects wrong password', async () => {
|
|
|
|
|
const app = await buildTestApp()
|
|
|
|
|
await setupAdmin(app)
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/login',
|
|
|
|
|
payload: { username: 'admin', password: 'wrongpass123' },
|
|
|
|
|
})
|
|
|
|
|
expect(res.statusCode).toBe(401)
|
|
|
|
|
await app.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('protected route requires auth', async () => {
|
|
|
|
|
const app = await buildTestApp()
|
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/me' })
|
|
|
|
|
expect(res.statusCode).toBe(401)
|
|
|
|
|
await app.close()
|
|
|
|
|
})
|
2026-08-29 15:46:34 +02:00
|
|
|
|
|
|
|
|
it('re-login invalidates the previous session', async () => {
|
|
|
|
|
const app = await buildTestApp()
|
|
|
|
|
await setupAdmin(app)
|
|
|
|
|
const first = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/login',
|
|
|
|
|
payload: { username: 'admin', password: 'adminpass123' },
|
|
|
|
|
})
|
|
|
|
|
const firstCookie = getCookie(first)
|
|
|
|
|
|
|
|
|
|
const second = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/login',
|
|
|
|
|
...auth(firstCookie),
|
|
|
|
|
payload: { username: 'admin', password: 'adminpass123' },
|
|
|
|
|
})
|
|
|
|
|
expect(second.statusCode).toBe(200)
|
|
|
|
|
|
|
|
|
|
const oldStale = await app.inject({ method: 'GET', url: '/api/me', ...auth(firstCookie) })
|
|
|
|
|
expect(oldStale.statusCode).toBe(401)
|
|
|
|
|
|
|
|
|
|
const newCookie = getCookie(second)
|
|
|
|
|
const fresh = await app.inject({ method: 'GET', url: '/api/me', ...auth(newCookie) })
|
|
|
|
|
expect(fresh.statusCode).toBe(200)
|
|
|
|
|
await app.close()
|
|
|
|
|
})
|
2026-08-29 15:20:42 +02:00
|
|
|
})
|
2026-08-29 15:35:24 +02:00
|
|
|
|
|
|
|
|
describe('user admin', () => {
|
|
|
|
|
async function adminApp() {
|
|
|
|
|
const app = await buildTestApp()
|
|
|
|
|
const cookie = await setupAdmin(app)
|
|
|
|
|
return { app, cookie }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('admin creates a user and lists users', async () => {
|
|
|
|
|
const { app, cookie } = await adminApp()
|
|
|
|
|
const created = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/users',
|
|
|
|
|
...auth(cookie),
|
|
|
|
|
payload: { username: 'bob', password: 'bobpass123' },
|
|
|
|
|
})
|
|
|
|
|
expect(created.statusCode).toBe(200)
|
|
|
|
|
expect(created.json()).toEqual({ id: 2, username: 'bob', isAdmin: false })
|
|
|
|
|
|
|
|
|
|
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) })
|
|
|
|
|
expect(list.json().users.map((u: { username: string }) => u.username)).toEqual(['admin', 'bob'])
|
|
|
|
|
await app.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('non-admin cannot list or create users', async () => {
|
|
|
|
|
const { app, cookie } = await adminApp()
|
|
|
|
|
const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123')
|
|
|
|
|
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(bobCookie) })
|
|
|
|
|
expect(list.statusCode).toBe(403)
|
|
|
|
|
const create = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/users',
|
|
|
|
|
...auth(bobCookie),
|
|
|
|
|
payload: { username: 'eve', password: 'evepass123' },
|
|
|
|
|
})
|
|
|
|
|
expect(create.statusCode).toBe(403)
|
|
|
|
|
await app.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('cannot delete self; deleting another user works and cascades settings', async () => {
|
|
|
|
|
const { app, cookie } = await adminApp()
|
|
|
|
|
await loginAs(app, cookie, 'bob', 'bobpass123')
|
2026-08-29 15:46:34 +02:00
|
|
|
const settingsCount = () =>
|
|
|
|
|
(app.db.prepare('SELECT COUNT(*) AS n FROM settings WHERE user_id = 2').get() as { n: number }).n
|
|
|
|
|
const sessionsCount = () =>
|
|
|
|
|
(app.db.prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = 2').get() as { n: number }).n
|
|
|
|
|
expect(settingsCount()).toBe(1)
|
|
|
|
|
expect(sessionsCount()).toBe(1)
|
2026-08-29 15:35:24 +02:00
|
|
|
const selfDelete = await app.inject({ method: 'DELETE', url: '/api/users/1', ...auth(cookie) })
|
|
|
|
|
expect(selfDelete.statusCode).toBe(400)
|
|
|
|
|
|
|
|
|
|
const del = await app.inject({ method: 'DELETE', url: '/api/users/2', ...auth(cookie) })
|
|
|
|
|
expect(del.statusCode).toBe(200)
|
2026-08-29 15:46:34 +02:00
|
|
|
expect(settingsCount()).toBe(0)
|
|
|
|
|
expect(sessionsCount()).toBe(0)
|
2026-08-29 15:35:24 +02:00
|
|
|
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) })
|
|
|
|
|
expect(list.json().users).toHaveLength(1)
|
|
|
|
|
await app.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects duplicate username', async () => {
|
|
|
|
|
const { app, cookie } = await adminApp()
|
|
|
|
|
await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/users',
|
|
|
|
|
...auth(cookie),
|
|
|
|
|
payload: { username: 'bob', password: 'bobpass123' },
|
|
|
|
|
})
|
|
|
|
|
const again = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/users',
|
|
|
|
|
...auth(cookie),
|
|
|
|
|
payload: { username: 'bob', password: 'otherpass123' },
|
|
|
|
|
})
|
|
|
|
|
expect(again.statusCode).toBe(409)
|
|
|
|
|
await app.close()
|
|
|
|
|
})
|
|
|
|
|
})
|