mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +08:00
171 lines
7.8 KiB
TypeScript
171 lines
7.8 KiB
TypeScript
import { sql } from 'drizzle-orm'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { authedHeaders, createTestApp } from '../test/setup.js'
|
|
|
|
// Admin user management moved off our own /api/users/* routes onto better-auth's
|
|
// admin plugin (/api/auth/admin/*), which the frontend admin client now calls.
|
|
// These tests prove that contract holds in OUR wiring: the admin() plugin is
|
|
// mounted, the first signup is promoted to admin, and an admin session can list,
|
|
// ban/unban and remove users through better-auth directly.
|
|
|
|
async function adminCookie(app: ReturnType<typeof import('../app')['createApp']>) {
|
|
await authedHeaders(app, 'admin@example.com', 'password123456')
|
|
const signIn = await app.request('/api/auth/sign-in/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'admin@example.com', password: 'password123456' }),
|
|
})
|
|
// Origin matches the test app's baseURL — better-auth's CSRF check requires it
|
|
// on state-changing admin POSTs (ban/unban/remove), exactly as a browser sends.
|
|
return { Cookie: signIn.headers.getSetCookie().join('; '), Origin: 'http://localhost:3000' }
|
|
}
|
|
|
|
describe('better-auth admin user endpoints (migration target)', () => {
|
|
it('GET /api/auth/admin/list-users returns users for an admin session [spec: users/list]', async () => {
|
|
const { app } = await createTestApp()
|
|
const headers = await adminCookie(app)
|
|
await authedHeaders(app, 'member@example.com', 'password123456')
|
|
|
|
const res = await app.request('/api/auth/admin/list-users?limit=50&offset=0', { headers })
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { users: Array<{ email: string }>; total: number }
|
|
expect(body.total).toBeGreaterThanOrEqual(2)
|
|
expect(body.users.map((u) => u.email)).toEqual(expect.arrayContaining(['admin@example.com', 'member@example.com']))
|
|
})
|
|
|
|
it('rejects list-users for a non-admin session [spec: users/admin-only]', async () => {
|
|
const { app } = await createTestApp()
|
|
await authedHeaders(app, 'admin@example.com', 'password123456')
|
|
const memberHeaders = await authedHeaders(app, 'member@example.com', 'password123456')
|
|
|
|
const res = await app.request('/api/auth/admin/list-users?limit=50&offset=0', { headers: memberHeaders })
|
|
expect(res.status).toBe(403)
|
|
})
|
|
|
|
it('POST /api/auth/admin/ban-user sets banned and revokes the Better Auth session [spec: users/disable] [spec: users/disabled-session-rejected]', async () => {
|
|
const { app, db, auth } = await createTestApp()
|
|
const headers = await adminCookie(app)
|
|
const memberHeaders = await authedHeaders(app, 'ban-me@example.com', 'password123456')
|
|
const rows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'ban-me@example.com'`)
|
|
const userId = rows[0].id
|
|
|
|
const adminRows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'admin@example.com'`)
|
|
const adminId = adminRows[0].id
|
|
|
|
const ban = await app.request('/api/auth/admin/ban-user', {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userId }),
|
|
})
|
|
expect(ban.status).toBe(200)
|
|
|
|
const banned = await db.all<{ banned: number }>(sql`SELECT banned FROM user WHERE id = ${userId}`)
|
|
expect(banned[0].banned).toBe(1)
|
|
|
|
// The disable is audited with the acting admin as the actor.
|
|
const disableEvt = await db.all<{ user_id: string; target_id: string }>(
|
|
sql`SELECT user_id, target_id FROM audit_events WHERE action = 'user_disable' AND target_id = ${userId}`,
|
|
)
|
|
expect(disableEvt).toEqual([{ user_id: adminId, target_id: userId }])
|
|
|
|
// Better Auth's signed cookie cache has a bounded revocation window, so
|
|
// ordinary routes can still use this one-minute cached session.
|
|
const cached = await app.request('/api/quotas/me', { headers: memberHeaders })
|
|
expect(cached.status).toBe(200)
|
|
|
|
// Bypassing the cookie cache proves that Better Auth deleted the session.
|
|
const revoked = await auth.api.getSession({
|
|
headers: new Headers(memberHeaders),
|
|
query: { disableCookieCache: true },
|
|
})
|
|
expect(revoked).toBeNull()
|
|
|
|
// Unban restores access and is audited as user_enable.
|
|
const unban = await app.request('/api/auth/admin/unban-user', {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userId }),
|
|
})
|
|
expect(unban.status).toBe(200)
|
|
const restored = await db.all<{ banned: number }>(sql`SELECT banned FROM user WHERE id = ${userId}`)
|
|
expect(restored[0].banned).toBe(0)
|
|
const enableEvt = await db.all<{ user_id: string }>(
|
|
sql`SELECT user_id FROM audit_events WHERE action = 'user_enable' AND target_id = ${userId}`,
|
|
)
|
|
expect(enableEvt).toEqual([{ user_id: adminId }])
|
|
})
|
|
|
|
it('does NOT audit a failed admin action (ban of a nonexistent user) [spec: users/patch-missing]', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminCookie(app)
|
|
|
|
const res = await app.request('/api/auth/admin/ban-user', {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userId: 'does-not-exist' }),
|
|
})
|
|
expect(res.status).toBe(404)
|
|
|
|
const evts = await db.all(sql`SELECT 1 FROM audit_events WHERE action = 'user_disable'`)
|
|
expect(evts).toHaveLength(0)
|
|
})
|
|
|
|
it('POST /api/auth/admin/remove-user deletes the user but preserves immutable registration history [spec: users/delete]', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await adminCookie(app)
|
|
await authedHeaders(app, 'delete-me@example.com', 'password123456')
|
|
const rows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'delete-me@example.com'`)
|
|
const userId = rows[0].id
|
|
const adminRows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'admin@example.com'`)
|
|
const adminId = adminRows[0].id
|
|
|
|
const registrationBefore = await db.all<{ provider: string }>(sql`
|
|
SELECT json_extract(metadata, '$.provider') AS provider
|
|
FROM audit_events
|
|
WHERE id = ${`event:user_register:${userId}`}
|
|
`)
|
|
expect(registrationBefore).toEqual([{ provider: 'credential' }])
|
|
|
|
const res = await app.request('/api/auth/admin/remove-user', {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userId }),
|
|
})
|
|
expect(res.status).toBe(200)
|
|
|
|
const remaining = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE id = ${userId}`)
|
|
expect(remaining).toHaveLength(0)
|
|
|
|
const deleteEvents = await db.all<{ user_id: string; target_id: string }>(sql`
|
|
SELECT user_id, target_id
|
|
FROM audit_events
|
|
WHERE action = 'user_delete' AND target_id = ${userId}
|
|
`)
|
|
expect(deleteEvents).toEqual([{ user_id: adminId, target_id: userId }])
|
|
|
|
const registrationAfter = await db.all<{ provider: string }>(sql`
|
|
SELECT json_extract(metadata, '$.provider') AS provider
|
|
FROM audit_events
|
|
WHERE id = ${`event:user_register:${userId}`}
|
|
`)
|
|
expect(registrationAfter).toEqual(registrationBefore)
|
|
})
|
|
|
|
it('blocks self-service account deletion', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const headers = await authedHeaders(app, 'self-delete@example.com', 'password123456')
|
|
const [{ id: userId }] = await db.all<{ id: string }>(
|
|
sql`SELECT id FROM user WHERE email = 'self-delete@example.com'`,
|
|
)
|
|
|
|
const res = await app.request('/api/auth/delete-user', {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json', Origin: 'http://localhost:3000' },
|
|
body: JSON.stringify({ callbackURL: '/' }),
|
|
})
|
|
|
|
expect(res.status).toBe(403)
|
|
expect(await db.all(sql`SELECT id FROM user WHERE id = ${userId}`)).toEqual([{ id: userId }])
|
|
})
|
|
})
|