feat(admin): журналы входов и изменений с IP/UA и сессиями
Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip; SSO handoff и revoke refresh-сессий; улучшены audit-карточки. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -183,4 +183,144 @@ describe('audit log API', () => {
|
||||
|
||||
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: '[email protected]',
|
||||
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 === '[email protected]')
|
||||
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: '[email protected]', 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&[email protected]',
|
||||
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('[email protected]')
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user