feat(admin): журнал аудита с ротацией и апгрейд таблицы пользователей
Добавлен audit log (solution-users-6) с retention N дней и hourly purge; таблица пользователей приведена к DNA solution-users-1 (Filters, avatar, sorting). Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -16,7 +16,12 @@ describe('app-switcher API', () => {
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
|
||||
expect(body.menuLabel).toBeTruthy()
|
||||
expect(body.apps.map((a) => a.id).sort()).toEqual(['bgp', 'cfdm', 'vps'])
|
||||
expect(body.apps.map((a) => a.id).sort()).toEqual([
|
||||
'bgp',
|
||||
'cfdm',
|
||||
'fw',
|
||||
'vps',
|
||||
])
|
||||
await app.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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: '[email protected]',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
NODE_ENV: 'test',
|
||||
})
|
||||
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: '[email protected]', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
return (login.json() as { access_token: string }).access_token
|
||||
}
|
||||
|
||||
describe('audit log API', () => {
|
||||
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: '[email protected]',
|
||||
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('[email protected]')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user