feat: add Audit Logs page with filtering and exporting capabilities

- Implemented Audit Logs page with a detailed view for each log entry.
- Added API functions for fetching and exporting audit logs in CSV format.
- Created hooks for managing audit log data fetching and state.
- Integrated filtering options for audit logs based on various criteria.
- Added unit tests for the Audit Logs page to ensure functionality and correctness.
- Updated Security page to include a link to the Audit Logs page.
This commit is contained in:
GitHub Actions
2026-01-03 22:26:16 +00:00
parent 697ef6d200
commit b09f8f78a9
17 changed files with 4475 additions and 21 deletions
@@ -0,0 +1,400 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import AuditLogs from '../AuditLogs'
import * as auditLogsApi from '../../api/auditLogs'
import { toast } from '../../utils/toast'
vi.mock('../../api/auditLogs')
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('<AuditLogs />', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
const mockAuditLogs = [
{
id: 1,
uuid: '123e4567-e89b-12d3-a456-426614174000',
actor: 'admin@example.com',
action: 'dns_provider_create' as const,
event_category: 'dns_provider' as const,
resource_id: 1,
resource_uuid: 'res-123',
details: '{"name":"Cloudflare","type":"cloudflare"}',
ip_address: '192.168.1.1',
user_agent: 'Mozilla/5.0',
created_at: '2026-01-03T10:00:00Z',
},
{
id: 2,
uuid: '223e4567-e89b-12d3-a456-426614174001',
actor: 'user@example.com',
action: 'credential_test' as const,
event_category: 'dns_provider' as const,
resource_uuid: 'res-456',
details: '{"test_result":"success"}',
ip_address: '192.168.1.2',
created_at: '2026-01-03T11:00:00Z',
},
]
const renderWithProviders = (ui: React.ReactNode) =>
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
)
beforeEach(() => {
vi.restoreAllMocks()
queryClient.clear()
})
it('renders page title and description', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: [],
total: 0,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
await waitFor(() => {
expect(screen.getByText('Audit Logs')).toBeInTheDocument()
expect(screen.getByText('View and filter security audit events')).toBeInTheDocument()
})
})
it('displays audit logs in table', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: mockAuditLogs,
total: 2,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
await waitFor(() => {
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
expect(screen.getByText('user@example.com')).toBeInTheDocument()
expect(screen.getByText('dns_provider_create')).toBeInTheDocument()
expect(screen.getByText('credential_test')).toBeInTheDocument()
})
})
it('shows loading state', () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockImplementation(
() => new Promise(() => {}) // Never resolves
)
renderWithProviders(<AuditLogs />)
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('shows empty state when no logs', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: [],
total: 0,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
await waitFor(() => {
expect(screen.getByText('No audit logs found')).toBeInTheDocument()
})
})
it('toggles filter panel', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: [],
total: 0,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
await waitFor(() => {
expect(screen.getByRole('button', { name: /Filters/i })).toBeInTheDocument()
})
// Initially filters are not shown
expect(screen.queryByText('Start Date')).not.toBeInTheDocument()
// Click to show filters
const filterButton = screen.getByRole('button', { name: /Filters/i })
fireEvent.click(filterButton)
await waitFor(() => {
expect(screen.getByText('Start Date')).toBeInTheDocument()
})
})
it('applies category filter', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: [],
total: 0,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
// Open filters
fireEvent.click(screen.getByRole('button', { name: /Filters/i }))
await waitFor(() => {
expect(screen.getByText('Start Date')).toBeInTheDocument()
})
// Verify all filter inputs are present
expect(screen.getByPlaceholderText('Filter by actor...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Filter by action...')).toBeInTheDocument()
})
it('clears all filters', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: [],
total: 0,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
// Open filters
fireEvent.click(screen.getByRole('button', { name: /Filters/i }))
await waitFor(() => {
expect(screen.getByText('Start Date')).toBeInTheDocument()
})
const clearButton = screen.getByRole('button', { name: /Clear All/i })
fireEvent.click(clearButton)
// Filters should still be visible after clearing
await waitFor(() => {
expect(screen.getByText('Start Date')).toBeInTheDocument()
})
})
it('opens detail modal when row is clicked', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: mockAuditLogs,
total: 2,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
await waitFor(() => {
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
})
// Click first row
const firstRow = screen.getByText('admin@example.com').closest('tr')
if (firstRow) {
fireEvent.click(firstRow)
}
await waitFor(() => {
expect(screen.getByText('Audit Log Details')).toBeInTheDocument()
expect(screen.getByText('123e4567-e89b-12d3-a456-426614174000')).toBeInTheDocument()
})
})
it('closes detail modal', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: mockAuditLogs,
total: 2,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
await waitFor(() => {
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
})
// Click first row to open modal
const firstRow = screen.getByText('admin@example.com').closest('tr')
if (firstRow) {
fireEvent.click(firstRow)
}
await waitFor(() => {
expect(screen.getByText('Audit Log Details')).toBeInTheDocument()
})
// Close modal using the "Close" button in footer
const closeButtons = screen.getAllByRole('button', { name: /Close/i })
const footerCloseButton = closeButtons.find(btn => btn.textContent === 'Close')
if (footerCloseButton) {
fireEvent.click(footerCloseButton)
}
await waitFor(() => {
expect(screen.queryByText('Audit Log Details')).not.toBeInTheDocument()
})
})
it('handles pagination', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: mockAuditLogs,
total: 100,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
await waitFor(() => {
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
})
// Check pagination is present
const nextButton = screen.getByRole('button', { name: /Next/i })
expect(nextButton).not.toBeDisabled()
const prevButton = screen.getByRole('button', { name: /Previous/i })
expect(prevButton).toBeDisabled()
// Check page indicator
expect(screen.getByText(/Page 1 of 2/i)).toBeInTheDocument()
})
it('exports to CSV', async () => {
const mockCSV = 'timestamp,actor,action\n2026-01-03,admin,create'
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: mockAuditLogs,
total: 2,
page: 1,
limit: 50,
})
vi.spyOn(auditLogsApi, 'exportAuditLogsCSV').mockResolvedValue(mockCSV)
// Mock toast
const toastSuccessSpy = vi.spyOn(toast, 'success')
// Mock URL APIs
const mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
const mockRevokeObjectURL = vi.fn()
window.URL.createObjectURL = mockCreateObjectURL
window.URL.revokeObjectURL = mockRevokeObjectURL
renderWithProviders(<AuditLogs />)
await waitFor(() => {
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
})
const exportButton = screen.getByRole('button', { name: /Export CSV/i })
fireEvent.click(exportButton)
await waitFor(() => {
expect(auditLogsApi.exportAuditLogsCSV).toHaveBeenCalled()
expect(toastSuccessSpy).toHaveBeenCalledWith('Audit logs exported successfully')
})
})
it('handles export error', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: mockAuditLogs,
total: 2,
page: 1,
limit: 50,
})
vi.spyOn(auditLogsApi, 'exportAuditLogsCSV').mockRejectedValue(
new Error('Export failed')
)
const toastErrorSpy = vi.spyOn(toast, 'error')
renderWithProviders(<AuditLogs />)
await waitFor(() => {
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
})
const exportButton = screen.getByRole('button', { name: /Export CSV/i })
fireEvent.click(exportButton)
await waitFor(() => {
expect(toastErrorSpy).toHaveBeenCalledWith('Failed to export audit logs')
})
})
it('displays parsed JSON details in modal', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: mockAuditLogs,
total: 2,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
await waitFor(() => {
expect(screen.getByText('admin@example.com')).toBeInTheDocument()
})
// Click first row to open modal
const firstRow = screen.getByText('admin@example.com').closest('tr')
if (firstRow) {
fireEvent.click(firstRow)
}
await waitFor(() => {
expect(screen.getByText('Audit Log Details')).toBeInTheDocument()
// Check that JSON is displayed
expect(screen.getByText(/"name"/)).toBeInTheDocument()
expect(screen.getByText(/"Cloudflare"/)).toBeInTheDocument()
})
})
it('shows filter count badge', async () => {
vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({
logs: [],
total: 0,
page: 1,
limit: 50,
})
renderWithProviders(<AuditLogs />)
// Open filters
const filterButton = screen.getByRole('button', { name: /Filters/i })
fireEvent.click(filterButton)
await waitFor(() => {
expect(screen.getByText('Start Date')).toBeInTheDocument()
})
// Add a filter by typing in the actor input
const actorInput = screen.getByPlaceholderText('Filter by actor...')
fireEvent.change(actorInput, { target: { value: 'admin' } })
// The badge should show 1 active filter
await waitFor(() => {
const badge = filterButton.querySelector('.bg-brand-500')
expect(badge).toBeInTheDocument()
expect(badge?.textContent).toBe('1')
})
})
})