feat: setup/login/logout/me auth routes
This commit is contained in:
74
server/test/authRoutes.test.ts
Normal file
74
server/test/authRoutes.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildTestApp, setupAdmin, getCookie, auth } from './helpers.js'
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
69
server/test/helpers.ts
Normal file
69
server/test/helpers.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { openDatabase, type DB } from '../src/db.js'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import type { Config } from '../src/config.js'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export function testConfig(): Config {
|
||||
// artworkDir must be a real writable directory (cached images land there)
|
||||
const artworkDir = mkdtempSync(path.join(tmpdir(), 'rs-art-'))
|
||||
return {
|
||||
dataDir: ':memory:',
|
||||
artworkDir,
|
||||
dbPath: ':memory:',
|
||||
port: 0,
|
||||
sessionSecret: 'test-secret-test-secret-test-secret-1234',
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildTestApp(fetchImpl?: typeof fetch): Promise<FastifyInstance> {
|
||||
const db = openDatabase(':memory:')
|
||||
return buildApp({ db, config: testConfig(), fetchImpl })
|
||||
}
|
||||
|
||||
export async function buildTestAppWithDb(db: DB, fetchImpl?: typeof fetch): Promise<FastifyInstance> {
|
||||
return buildApp({ db, config: testConfig(), fetchImpl })
|
||||
}
|
||||
|
||||
/** Runs /api/setup to create admin 'admin' / 'adminpass123'. Returns session token value. */
|
||||
export async function setupAdmin(app: FastifyInstance): Promise<string> {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/setup',
|
||||
payload: { username: 'admin', password: 'adminpass123' },
|
||||
})
|
||||
if (res.statusCode !== 200) throw new Error(`setup failed: ${res.body}`)
|
||||
return getCookie(res)
|
||||
}
|
||||
|
||||
/** Creates and logs in a non-admin user via /api/users + /api/login. Returns session token value. */
|
||||
export async function loginAs(
|
||||
app: FastifyInstance,
|
||||
adminToken: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/users',
|
||||
...auth(adminToken),
|
||||
payload: { username, password },
|
||||
})
|
||||
if (created.statusCode !== 200) throw new Error(`user create failed: ${created.body}`)
|
||||
const login = await app.inject({ method: 'POST', url: '/api/login', payload: { username, password } })
|
||||
if (login.statusCode !== 200) throw new Error(`login failed: ${login.body}`)
|
||||
return getCookie(login)
|
||||
}
|
||||
|
||||
export function getCookie(res: { cookies: { name: string; value: string }[] }): string {
|
||||
const cookie = res.cookies.find((c) => c.name === 'rs_session')
|
||||
if (!cookie) throw new Error('no rs_session cookie in response')
|
||||
return cookie.value
|
||||
}
|
||||
|
||||
/** inject option spread for an authenticated request: `app.inject({ ..., ...auth(token) })` */
|
||||
export function auth(sessionToken: string): { cookies: Record<string, string> } {
|
||||
return { cookies: { rs_session: sessionToken } }
|
||||
}
|
||||
Reference in New Issue
Block a user