Files
Charon/tests/tasks/logs-viewing.spec.ts
akanealw eec8c28fb3
Some checks are pending
Go Benchmark / Performance Regression Check (push) Waiting to run
Cerberus Integration / Cerberus Security Stack Integration (push) Waiting to run
Upload Coverage to Codecov / Backend Codecov Upload (push) Waiting to run
Upload Coverage to Codecov / Frontend Codecov Upload (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (go) (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (javascript-typescript) (push) Waiting to run
CrowdSec Integration / CrowdSec Bouncer Integration (push) Waiting to run
Docker Build, Publish & Test / build-and-push (push) Waiting to run
Docker Build, Publish & Test / Security Scan PR Image (push) Blocked by required conditions
Quality Checks / Auth Route Protection Contract (push) Waiting to run
Quality Checks / Codecov Trigger/Comment Parity Guard (push) Waiting to run
Quality Checks / Backend (Go) (push) Waiting to run
Quality Checks / Frontend (React) (push) Waiting to run
Rate Limit integration / Rate Limiting Integration (push) Waiting to run
Security Scan (PR) / Trivy Binary Scan (push) Waiting to run
Supply Chain Verification (PR) / Verify Supply Chain (push) Waiting to run
WAF integration / Coraza WAF Integration (push) Waiting to run
changed perms
2026-04-22 18:19:14 +00:00

710 lines
24 KiB
TypeScript
Executable File

/**
* Logs Page - Static Log File Viewing E2E Tests
*
* Tests for log file listing, content display, filtering, pagination, and download.
* Covers 12 test scenarios optimized for WebKit and cross-browser compatibility.
*
* Test Categories:
* - Page Layout (3 tests): heading, file list, filter section
* - Log File List (2 tests): display files with metadata, select file
* - Log Content Display (2 tests): show columns, highlight error entries
* - Pagination (3 tests): navigate pages, page info, button states
* - Search/Filter (2 tests): text search, level filter
* - Download (2 tests): download file, error handling
*
* Route: /tasks/logs
* Component: Logs.tsx
* Updated: 2024-02-10 for full WebKit support
*/
import { test, expect, loginUser, TEST_PASSWORD } from '../fixtures/auth-fixtures';
import { waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers';
import type { Page } from '@playwright/test';
/**
* Mock log files for testing
*/
const mockLogFiles = [
{ name: 'access.log', size: 1048576, modified: '2024-01-15T12:00:00Z' },
{ name: 'error.log', size: 256000, modified: '2024-01-15T11:30:00Z' },
{ name: 'caddy.log', size: 512000, modified: '2024-01-14T10:00:00Z' },
];
/**
* Mock log entries for content display testing
*/
const mockLogEntries = [
{
level: 'info',
ts: Math.floor(Date.now() / 1000),
logger: 'http.log.access',
msg: 'handled request',
request: {
remote_ip: '192.168.1.100',
method: 'GET',
host: 'api.example.com',
uri: '/api/v1/users',
proto: 'HTTP/2',
},
status: 200,
duration: 0.045,
size: 1234,
},
{
level: 'error',
ts: Math.floor(Date.now() / 1000) - 60,
logger: 'http.log.access',
msg: 'connection refused',
request: {
remote_ip: '192.168.1.101',
method: 'POST',
host: 'api.example.com',
uri: '/api/v1/auth/login',
proto: 'HTTP/2',
},
status: 502,
duration: 5.023,
size: 0,
},
{
level: 'warn',
ts: Math.floor(Date.now() / 1000) - 120,
logger: 'http.log.access',
msg: 'rate limit exceeded',
request: {
remote_ip: '10.0.0.50',
method: 'GET',
host: 'web.example.com',
uri: '/dashboard',
proto: 'HTTP/1.1',
},
status: 429,
duration: 0.001,
size: 256,
},
];
/**
* Generate mock log entries for pagination testing
*/
function generateMockEntries(count: number, startOffset: number = 0) {
return Array.from({ length: count }, (_, i) => ({
level: i % 3 === 0 ? 'error' : i % 3 === 1 ? 'warn' : 'info',
ts: Math.floor(Date.now() / 1000) - i * 10,
logger: 'http.log.access',
msg: `request ${startOffset + i}`,
request: {
remote_ip: `192.168.1.${100 + (i % 100)}`,
method: ['GET', 'POST', 'PUT'][i % 3],
host: 'example.com',
uri: `/api/endpoint-${i}`,
proto: 'HTTP/2',
},
status: 200 + (i % 4) * 100,
duration: Math.random() * 1,
size: Math.random() * 1000,
}));
}
/**
* Selectors and helpers for WebKit compatibility
*/
function getLogFileButton(page: Page, fileName: string) {
return page.getByTestId(`log-file-${fileName}`);
}
function getPrevButton(page: Page) {
return page.getByTestId('prev-page-button');
}
function getNextButton(page: Page) {
return page.getByTestId('next-page-button');
}
/**
* Helper to set up log API mocks
*/
async function setupLogMocks(
page: Page,
files = mockLogFiles,
entries = mockLogEntries,
totalCount?: number
) {
// Mock log files list endpoint
await page.route('**/api/v1/logs', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: files });
} else {
await route.continue();
}
});
// Mock log content endpoint for each file
for (const file of files) {
await page.route(`**/api/v1/logs/${file.name}*`, async (route) => {
const url = new URL(route.request().url());
const offset = parseInt(url.searchParams.get('offset') || '0');
const limit = parseInt(url.searchParams.get('limit') || '50');
const search = url.searchParams.get('search') || '';
const level = url.searchParams.get('level') || '';
// Apply filters
let filtered = [...entries];
if (search) {
filtered = filtered.filter(
(e) =>
e.msg.toLowerCase().includes(search.toLowerCase()) ||
e.request.uri.toLowerCase().includes(search.toLowerCase())
);
}
if (level) {
filtered = filtered.filter((e) => e.level.toLowerCase() === level.toLowerCase());
}
const paginated = filtered.slice(offset, offset + limit);
const total = totalCount || filtered.length;
await route.fulfill({
status: 200,
json: {
filename: file.name,
logs: paginated,
total,
limit,
offset,
},
});
});
}
}
test.describe('Logs Page - WebKit Compatible Tests', () => {
test.describe('Page Layout', () => {
test('should display logs page with file selector', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Verify page title contains "logs"
await expect(page.getByRole('heading', { level: 1 })).toContainText(/logs/i);
// Verify file list sidebar is visible
await expect(page.getByTestId('log-file-list')).toBeVisible();
});
test('should show list of available log files', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
const logFilesPromise = waitForAPIResponse(page, '/api/v1/logs', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await logFilesPromise;
// Verify all log files are displayed in the list
await expect(page.getByText('access.log')).toBeVisible();
await expect(page.getByText('error.log')).toBeVisible();
await expect(page.getByText('caddy.log')).toBeVisible();
});
test('should display log filters section', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Wait for filters (appear when first log file is auto-selected)
await expect(page.getByTestId('search-input')).toBeVisible({ timeout: 5000 });
// Verify filter controls are present
await expect(page.getByTestId('refresh-button')).toBeVisible();
await expect(page.getByTestId('download-button')).toBeVisible();
});
});
test.describe('Log File List', () => {
test('should list all available log files with metadata', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Verify files are listed with size information (in MB format)
await expect(page.getByText('access.log')).toBeVisible();
await expect(page.getByText('1.00 MB')).toBeVisible();
await expect(page.getByText('error.log')).toBeVisible();
await expect(page.getByText('0.24 MB')).toBeVisible();
});
test('should load log content when file selected', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// The first file (access.log) is auto-selected - wait for content
await initialContentPromise;
// Verify log table is displayed
await expect(page.getByTestId('log-table')).toBeVisible();
});
});
test.describe('Log Content Display', () => {
test('should display log entries in table format', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Wait for auto-selected log content to load
await initialContentPromise;
// Verify table structure
const logTable = page.getByTestId('log-table');
await expect(logTable).toBeVisible();
// Verify table has expected columns
await expect(page.getByRole('columnheader', { name: /time/i })).toBeVisible();
await expect(page.getByRole('columnheader', { name: /status/i })).toBeVisible();
await expect(page.getByRole('columnheader', { name: /method/i })).toBeVisible();
});
test('should show timestamp, level, method, uri, status', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Wait for content to load
await initialContentPromise;
// Verify log entry content is displayed
// The mock data includes 192.168.1.100 as remote_ip in first entry
const entryRow = page.getByRole('row').filter({ hasText: '192.168.1.100' }).first();
await expect(entryRow).toBeVisible();
await expect(entryRow.getByRole('cell', { name: 'GET' })).toBeVisible();
await expect(entryRow.getByText('/api/v1/users')).toBeVisible();
await expect(entryRow.getByTestId('status-200')).toBeVisible();
});
test('should highlight error entries with distinct styling', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Find the 502 error status badge - should have red styling class
const errorStatus = page.getByTestId('status-502');
await expect(errorStatus).toBeVisible();
// Verify error has red styling (bg-red or similar)
await expect(errorStatus).toHaveClass(/red/);
});
});
test.describe('Pagination', () => {
test('should paginate large log files', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
// Generate 150 mock entries for pagination testing
const largeEntrySet = generateMockEntries(150);
let capturedOffset = 0;
await page.route('**/api/v1/logs', async (route) => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
const url = new URL(route.request().url());
capturedOffset = parseInt(url.searchParams.get('offset') || '0');
const limit = parseInt(url.searchParams.get('limit') || '50');
await route.fulfill({
status: 200,
json: {
filename: 'access.log',
logs: largeEntrySet.slice(capturedOffset, capturedOffset + limit),
total: 150,
limit,
offset: capturedOffset,
},
});
});
// Set up the response wait BEFORE navigation to avoid missing fast mocked responses.
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Initial state - page 1
expect(capturedOffset).toBe(0);
// Click next page button
const nextButton = getNextButton(page);
await expect(nextButton).toBeEnabled();
// Set up listener BEFORE clicking to capture the request
await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/v1/logs/access.log') && resp.status() === 200),
nextButton.click(),
]);
// Should have requested offset 50 (second page)
expect(capturedOffset).toBe(50);
});
test('should display page info correctly', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
const largeEntrySet = generateMockEntries(150);
await page.route('**/api/v1/logs', async (route) => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
const url = new URL(route.request().url());
const offset = parseInt(url.searchParams.get('offset') || '0');
const limit = parseInt(url.searchParams.get('limit') || '50');
await route.fulfill({
status: 200,
json: {
filename: 'access.log',
logs: largeEntrySet.slice(offset, offset + limit),
total: 150,
limit,
offset,
},
});
});
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Verify page info displays correctly
const pageInfo = page.getByTestId('page-info');
await expect(pageInfo).toBeVisible();
// Should show "Showing 1 - 50 of 150" or similar format
await expect(pageInfo).toContainText(/1.*50.*150/);
});
test('should disable prev button on first page and next on last', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
const entries = generateMockEntries(75); // 2 pages (50 + 25)
await page.route('**/api/v1/logs', async (route) => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
const url = new URL(route.request().url());
const offset = parseInt(url.searchParams.get('offset') || '0');
const limit = parseInt(url.searchParams.get('limit') || '50');
await route.fulfill({
status: 200,
json: {
filename: 'access.log',
logs: entries.slice(offset, offset + limit),
total: 75,
limit,
offset,
},
});
});
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
const prevButton = getPrevButton(page);
const nextButton = getNextButton(page);
// On first page, prev should be disabled
await expect(prevButton).toBeDisabled();
await expect(nextButton).toBeEnabled();
// Navigate to last page
await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/v1/logs/access.log')),
nextButton.click(),
]);
// On last page, next should be disabled
await expect(prevButton).toBeEnabled();
await expect(nextButton).toBeDisabled();
});
});
test.describe('Search and Filter', () => {
test('should filter logs by search text', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
let capturedSearch = '';
await page.route('**/api/v1/logs', async (route) => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
const url = new URL(route.request().url());
capturedSearch = url.searchParams.get('search') || '';
// Filter mock entries based on search
const filtered = capturedSearch
? mockLogEntries.filter(
(e) =>
e.msg.toLowerCase().includes(capturedSearch.toLowerCase()) ||
e.request.uri.toLowerCase().includes(capturedSearch.toLowerCase())
)
: mockLogEntries;
await route.fulfill({
status: 200,
json: {
filename: 'access.log',
logs: filtered,
total: filtered.length,
limit: 50,
offset: 0,
},
});
});
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Type in search input
const searchInput = page.getByTestId('search-input');
// Set up response listener BEFORE typing to catch the debounced request
const searchResponsePromise = page.waitForResponse((resp) =>
resp.url().includes('/api/v1/logs/access.log')
);
await searchInput.fill('users');
// Wait for debounced search request
await searchResponsePromise;
// Verify search parameter was sent
expect(capturedSearch).toBe('users');
});
test('should filter logs by log level', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
let capturedLevel = '';
await page.route('**/api/v1/logs', async (route) => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
const url = new URL(route.request().url());
capturedLevel = url.searchParams.get('level') || '';
// Filter mock entries based on level
const filtered = capturedLevel
? mockLogEntries.filter((e) => e.level.toLowerCase() === capturedLevel.toLowerCase())
: mockLogEntries;
await route.fulfill({
status: 200,
json: {
filename: 'access.log',
logs: filtered,
total: filtered.length,
limit: 50,
offset: 0,
},
});
});
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Select Error level from dropdown using data-testid
const levelSelect = page.getByTestId('level-select');
await expect(levelSelect).toBeVisible();
// Set up response listener BEFORE selecting
const filterResponsePromise = page.waitForResponse((resp) =>
resp.url().includes('/api/v1/logs/access.log')
);
await levelSelect.selectOption('ERROR');
await filterResponsePromise;
// Verify level parameter was sent
expect(capturedLevel.toLowerCase()).toBe('error');
});
});
test.describe('Download', () => {
test('should download log file successfully', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Verify download button is visible and enabled
const downloadButton = page.getByTestId('download-button');
await expect(downloadButton).toBeVisible();
await expect(downloadButton).toBeEnabled();
// The download button clicking will use window.location.href for download
// Verify the button state is correct for a successful download
await expect(downloadButton).not.toHaveAttribute('disabled');
});
test('should handle download error gracefully', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await page.route('**/api/v1/logs', async (route) => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
if (!route.request().url().includes('/download')) {
await route.fulfill({
status: 200,
json: {
filename: 'access.log',
logs: mockLogEntries,
total: mockLogEntries.length,
limit: 50,
offset: 0,
},
});
} else {
await route.continue();
}
});
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Verify download button is present and properly rendered
const downloadButton = page.getByTestId('download-button');
await expect(downloadButton).toBeVisible();
// Button should be in a clickable state even if download endpoint fails
await expect(downloadButton).toBeEnabled();
});
});
test.describe('Edge Cases', () => {
test('should handle empty log content gracefully', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await page.route('**/api/v1/logs', async (route) => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
await route.fulfill({
status: 200,
json: {
filename: 'access.log',
logs: [],
total: 0,
limit: 50,
offset: 0,
},
});
});
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Should show "No logs found" or similar message
await expect(page.getByText(/no logs found|no.*matching/i)).toBeVisible();
});
test('should reset to first page when changing log file', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
const largeEntrySet = generateMockEntries(150);
let lastOffset = 0;
await page.route('**/api/v1/logs', async (route) => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/*', async (route) => {
const url = new URL(route.request().url());
lastOffset = parseInt(url.searchParams.get('offset') || '0');
const limit = parseInt(url.searchParams.get('limit') || '50');
await route.fulfill({
status: 200,
json: {
filename: 'test.log',
logs: largeEntrySet.slice(lastOffset, lastOffset + limit),
total: 150,
limit,
offset: lastOffset,
},
});
});
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Navigate to page 2
const nextButton = getNextButton(page);
await nextButton.click();
// Wait briefly for state update
await page.waitForTimeout(500);
expect(lastOffset).toBe(50);
// Switch to different log file using the new data-testid
const errorLogButton = getLogFileButton(page, 'error.log');
await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/v1/logs/')),
errorLogButton.click(),
]);
// Should reset to offset 0 when switching files
expect(lastOffset).toBe(0);
});
});
});