Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip; SSO handoff и revoke refresh-сессий; улучшены audit-карточки. Co-authored-by: Cursor <cursoragent@cursor.com>
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { appendAudit, listAudit, purgeAuditOlderThan } from '@authportal/db'
|
|
import { buildApp } from '../src/app.js'
|
|
import { loadConfig } from '../src/config.js'
|
|
|
|
async function buildTestApp() {
|
|
const config = loadConfig({
|
|
...process.env,
|
|
JWT_SECRET: 'test-secret-at-least-8',
|
|
ADMIN_EMAIL: 'admin@test.local',
|
|
ADMIN_PASSWORD: 'adminpass',
|
|
DATABASE_URL: 'sqlite::memory:',
|
|
NODE_ENV: 'test',
|
|
AUDIT_INGEST_SECRET: 'dev-audit-ingest-secret',
|
|
})
|
|
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
|
}
|
|
|
|
async function adminToken(app: Awaited<ReturnType<typeof buildTestApp>>) {
|
|
const login = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { email: 'admin@test.local', password: 'adminpass' },
|
|
})
|
|
expect(login.statusCode).toBe(200)
|
|
return (login.json() as { access_token: string }).access_token
|
|
}
|
|
|
|
describe('audit log API', () => {
|
|
it('ingests external events with secret and dedupes by event_id', async () => {
|
|
const app = await buildTestApp()
|
|
const denied = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/ingest/audit',
|
|
payload: { events: [] },
|
|
})
|
|
expect(denied.statusCode).toBe(401)
|
|
|
|
const eventId = 'evt-test-1'
|
|
const ok = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/ingest/audit',
|
|
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
|
payload: {
|
|
events: [
|
|
{
|
|
event_id: eventId,
|
|
source_app: 'vps',
|
|
action: 'vps.vps.create',
|
|
summary: 'Создан VPS',
|
|
actor_email: 'ops@test.local',
|
|
},
|
|
],
|
|
},
|
|
})
|
|
expect(ok.statusCode).toBe(200)
|
|
expect(ok.json()).toMatchObject({ accepted: 1, duplicates: 0 })
|
|
|
|
const dup = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/ingest/audit',
|
|
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
|
payload: {
|
|
events: [
|
|
{
|
|
event_id: eventId,
|
|
source_app: 'vps',
|
|
action: 'vps.vps.create',
|
|
summary: 'Создан VPS',
|
|
},
|
|
],
|
|
},
|
|
})
|
|
expect(dup.json()).toMatchObject({ accepted: 0, duplicates: 1 })
|
|
|
|
const token = await adminToken(app)
|
|
const list = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/audit?source_app=vps',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(list.statusCode).toBe(200)
|
|
const entries = list.json() as { source_app: string; action: string }[]
|
|
expect(entries.some((e) => e.action === 'vps.vps.create')).toBe(true)
|
|
|
|
await app.close()
|
|
})
|
|
|
|
it('records login and lists for admin', async () => {
|
|
const app = await buildTestApp()
|
|
const token = await adminToken(app)
|
|
|
|
const list = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/audit',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(list.statusCode).toBe(200)
|
|
const entries = list.json() as { action: string }[]
|
|
expect(entries.some((e) => e.action === 'auth.login')).toBe(true)
|
|
|
|
const denied = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/audit',
|
|
})
|
|
expect(denied.statusCode).toBe(401)
|
|
|
|
await app.close()
|
|
})
|
|
|
|
it('updates retention and purges old rows', async () => {
|
|
const app = await buildTestApp()
|
|
const token = await adminToken(app)
|
|
|
|
appendAudit(app.db, {
|
|
action: 'user.create',
|
|
summary: 'old event',
|
|
})
|
|
// Backdate the last inserted row
|
|
app.sqlite
|
|
.prepare(
|
|
`UPDATE audit_log SET created_at = ? WHERE summary = 'old event'`,
|
|
)
|
|
.run(new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString())
|
|
|
|
const settings = await app.inject({
|
|
method: 'PUT',
|
|
url: '/api/v1/admin/audit/settings',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { retention_days: 30 },
|
|
})
|
|
expect(settings.statusCode).toBe(200)
|
|
expect(settings.json()).toEqual({ retention_days: 30 })
|
|
|
|
const before = listAudit(app.db, { limit: 500 })
|
|
expect(before.some((e) => e.summary === 'old event')).toBe(true)
|
|
|
|
const deleted = purgeAuditOlderThan(app.db, 30)
|
|
expect(deleted).toBeGreaterThanOrEqual(1)
|
|
expect(listAudit(app.db, { limit: 500 }).some((e) => e.summary === 'old event')).toBe(
|
|
false,
|
|
)
|
|
|
|
const purge = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/admin/audit/purge',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(purge.statusCode).toBe(200)
|
|
expect((purge.json() as { retention_days: number }).retention_days).toBe(30)
|
|
|
|
await app.close()
|
|
})
|
|
|
|
it('records user.create from admin mutation', async () => {
|
|
const app = await buildTestApp()
|
|
const token = await adminToken(app)
|
|
|
|
const create = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/admin/users',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: {
|
|
email: 'new@test.local',
|
|
name: 'New User',
|
|
password: 'secret12',
|
|
is_admin: false,
|
|
apps: ['cfdm'],
|
|
permissions: [],
|
|
},
|
|
})
|
|
expect(create.statusCode).toBe(201)
|
|
|
|
const list = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/audit?action=user.create',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(list.statusCode).toBe(200)
|
|
const entries = list.json() as { action: string; summary: string }[]
|
|
expect(entries.length).toBeGreaterThanOrEqual(1)
|
|
expect(entries[0]?.summary).toContain('new@test.local')
|
|
|
|
await app.close()
|
|
})
|
|
|
|
it('records login with IP, UA and last_login_ip', async () => {
|
|
const app = await buildTestApp()
|
|
const login = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
headers: {
|
|
'x-forwarded-for': '203.0.113.10',
|
|
'user-agent': 'VitestBrowser/1.0',
|
|
},
|
|
payload: {
|
|
email: 'admin@test.local',
|
|
password: 'adminpass',
|
|
return_to: 'https://vps.example.test/dashboard',
|
|
},
|
|
})
|
|
expect(login.statusCode).toBe(200)
|
|
const token = (login.json() as { access_token: string }).access_token
|
|
|
|
const users = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/users',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
const admin = (
|
|
users.json() as { email: string; last_login_ip: string | null }[]
|
|
).find((u) => u.email === 'admin@test.local')
|
|
expect(admin?.last_login_ip).toBe('203.0.113.10')
|
|
|
|
const list = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/audit?kind=logins',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
const entries = list.json() as {
|
|
action: string
|
|
ip: string | null
|
|
details: Record<string, unknown> | null
|
|
}[]
|
|
const loginEvt = entries.find((e) => e.action === 'auth.login')
|
|
expect(loginEvt?.ip).toBe('203.0.113.10')
|
|
expect(loginEvt?.details?.user_agent).toBe('VitestBrowser/1.0')
|
|
expect(loginEvt?.details?.target_app).toBe('vps')
|
|
|
|
await app.close()
|
|
})
|
|
|
|
it('records failed login with email and IP', async () => {
|
|
const app = await buildTestApp()
|
|
const fail = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
headers: { 'x-forwarded-for': '198.51.100.7' },
|
|
payload: { email: 'nobody@test.local', password: 'wrong' },
|
|
})
|
|
expect(fail.statusCode).toBe(401)
|
|
|
|
const token = await adminToken(app)
|
|
const list = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/audit?kind=logins&actor_email=nobody@test.local',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
const entries = list.json() as {
|
|
action: string
|
|
actor_email: string | null
|
|
ip: string | null
|
|
}[]
|
|
expect(entries.some((e) => e.action === 'auth.login_failed')).toBe(true)
|
|
expect(entries[0]?.actor_email).toBe('nobody@test.local')
|
|
expect(entries[0]?.ip).toBe('198.51.100.7')
|
|
|
|
await app.close()
|
|
})
|
|
|
|
it('records sso-access and separates kind=changes', async () => {
|
|
const app = await buildTestApp()
|
|
const token = await adminToken(app)
|
|
|
|
const sso = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/sso-access',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { return_to: 'https://bgp.example.test/' },
|
|
})
|
|
expect(sso.statusCode).toBe(200)
|
|
expect((sso.json() as { target_app: string }).target_app).toBe('bgp')
|
|
|
|
const logins = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/audit?kind=logins',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
const loginEntries = logins.json() as { action: string }[]
|
|
expect(loginEntries.every((e) => e.action.startsWith('auth.'))).toBe(true)
|
|
expect(loginEntries.some((e) => e.action === 'auth.sso_handoff')).toBe(true)
|
|
|
|
const changes = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/audit?kind=changes',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
const changeEntries = changes.json() as { action: string }[]
|
|
expect(changeEntries.every((e) => !e.action.startsWith('auth.'))).toBe(true)
|
|
|
|
await app.close()
|
|
})
|
|
|
|
it('lists and revokes refresh sessions', async () => {
|
|
const app = await buildTestApp()
|
|
const token = await adminToken(app)
|
|
|
|
const sessions = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/sessions',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(sessions.statusCode).toBe(200)
|
|
const rows = sessions.json() as { id: string; user_id: string }[]
|
|
expect(rows.length).toBeGreaterThanOrEqual(1)
|
|
const sessionId = rows[0]!.id
|
|
|
|
const revoke = await app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/admin/sessions/${sessionId}`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(revoke.statusCode).toBe(200)
|
|
|
|
const after = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/sessions',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(
|
|
(after.json() as { id: string }[]).some((s) => s.id === sessionId),
|
|
).toBe(false)
|
|
|
|
await app.close()
|
|
})
|
|
})
|