mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-24 04:53:17 +00:00
ef26860df9
Until now the only record of what happened was `user_activity_logs`, which stores non-GET 2xx operations with no bodies. When something failed you could see that a counter went up, never what was sent or what came back. This adds one queryable timeline covering both directions: - inbound: every API call, including GETs and including 4xx/5xx, with the user, client IP, status, duration and — redacted, size-capped — the request and response bodies. - outbound: every HTTP call the backend makes, tagged with who it went to (ACME/Let's Encrypt, Cloudflare, GoDaddy, HAProxy stats, agents, the ACME diagnostics probe). Outbound rows inherit the inbound request's id, so one operator action and the CA/DNS calls it triggered read as a single trace: opening a failed "Request Certificate" shows the exact POST /acme/new-order and the CA's 429 underneath. Implementation notes: - Capture is a pure-ASGI middleware that TEES the request and response streams rather than draining them. `await request.body()` inside a BaseHTTPMiddleware would consume the receive channel and break the raw-body agent heartbeat handler. Registered last so it is outermost: it then sees the final client-visible response and seeds correlation_id_context before the error handler reads it. - Rows are written by a batching background writer with a bounded queue, so the request path never awaits the database and a saturated logger drops rows visibly (surfaced on the page) instead of blocking. Redaction runs on the writer, off the request coroutine. - Secrets never land: headers are an allowlist with Authorization/Cookie kept only as a presence marker; body keys and value shapes are redacted (passwords, tokens, api_token, API keys, private-key PEMs, JWTs); the ACME JWS request body is never stored, because a stored protected+signature pair is a replayable credential — a summary is logged instead; DNS-provider errors record only the exception type; the ACME HTTP-01 challenge endpoint is excluded so key_authorization is never captured. - Retention is operator-configurable in Settings -> Request Log: separate day counts for successful and failed rows (7 / 30) plus a hard row cap (500k), whichever is reached first. Pruned in batches under a Postgres advisory lock, with the day counts bound as parameters, never interpolated. - New permissions requestlog.read / requestlog.manage. super_admin and security_admin get both, operator gets read, viewer gets neither. Schema: one new table (request_logs) plus its settings seed, SCHEMA_VERSION 10 -> 11, auto-migrated. No existing table altered, no agent or rendered-config change. Kill switches: REQUEST_LOG_ENABLED=false (middleware never registered) or the `enabled` toggle in Settings. Tests: 245 new (7 backend files + 1 frontend), full suite 1655 backend + 17 frontend passing.
275 lines
9.5 KiB
JavaScript
275 lines
9.5 KiB
JavaScript
/**
|
|
* v1.11.0 regression tests for the Request Log page.
|
|
*
|
|
* Three properties matter here and none of them are visible from a snapshot:
|
|
*
|
|
* 1. Pagination is SERVER-side. Every other table in this app fetches once and
|
|
* slices in the browser; this table can hold millions of rows, so changing
|
|
* the page MUST issue a new request with a new offset. A client-side slice
|
|
* would look identical on a two-row fixture and fall over in production.
|
|
* 2. The page gates itself on requestlog.read. The sidebar and the router are
|
|
* unconditional (App.js has no hook access at module level), so this
|
|
* component is the only gate — and it must not even call the API when the
|
|
* caller lacks the permission.
|
|
* 3. Redacted values are what the modal shows. A test that only asserted the
|
|
* modal opens would still pass if the UI un-redacted anything.
|
|
*/
|
|
import React from 'react';
|
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
|
import axios from 'axios';
|
|
import RequestLog from '../RequestLog';
|
|
|
|
// Rendering the antd Table + filter row + modal is slow; keep the limit here so
|
|
// plain `npm test` passes as shipped rather than needing --testTimeout.
|
|
jest.setTimeout(30000);
|
|
|
|
jest.mock('axios');
|
|
|
|
let mockPermissions = { read: true, manage: true, admin: false };
|
|
jest.mock('../../contexts/AuthContext', () => ({
|
|
useAuth: () => ({
|
|
hasPermission: (resource, action) =>
|
|
resource === 'requestlog' && Boolean(mockPermissions[action]),
|
|
isAdmin: () => mockPermissions.admin,
|
|
}),
|
|
}));
|
|
|
|
const INBOUND_ROW = {
|
|
id: 2,
|
|
request_id: 'abc123',
|
|
direction: 'inbound',
|
|
target: null,
|
|
method: 'POST',
|
|
url: '/api/letsencrypt/certificates',
|
|
path: '/api/letsencrypt/certificates',
|
|
status_code: 500,
|
|
status_class: 5,
|
|
duration_ms: 2431,
|
|
user_id: 1,
|
|
username: 'admin',
|
|
client_ip: '10.0.0.5',
|
|
error: null,
|
|
request_body_bytes: 120,
|
|
response_body_bytes: 88,
|
|
truncated: false,
|
|
created_at: '2026-08-11T09:00:00+00:00',
|
|
};
|
|
|
|
const OUTBOUND_ROW = {
|
|
id: 1,
|
|
request_id: 'abc123',
|
|
direction: 'outbound',
|
|
target: 'acme',
|
|
method: 'POST',
|
|
url: 'https://acme-v02.api.letsencrypt.org/acme/new-order',
|
|
path: '/acme/new-order',
|
|
status_code: 429,
|
|
status_class: 4,
|
|
duration_ms: 812,
|
|
user_id: null,
|
|
username: null,
|
|
client_ip: null,
|
|
error: null,
|
|
request_body_bytes: 0,
|
|
response_body_bytes: 210,
|
|
truncated: false,
|
|
created_at: '2026-08-11T09:00:01+00:00',
|
|
};
|
|
|
|
const LIST_RESPONSE = {
|
|
logs: [INBOUND_ROW, OUTBOUND_ROW],
|
|
total: 2,
|
|
total_is_estimate: false,
|
|
limit: 50,
|
|
offset: 0,
|
|
scoped_to_self: false,
|
|
};
|
|
|
|
const DETAIL_RESPONSE = {
|
|
log: {
|
|
...INBOUND_ROW,
|
|
query_params: null,
|
|
request_headers: { 'content-type': 'application/json', authorization: '***REDACTED***' },
|
|
request_body: { domains: ['example.com'], eab_hmac_key: '***REDACTED***' },
|
|
response_headers: { 'content-type': 'application/json' },
|
|
response_body: { error: { message: 'ACME rate limited' } },
|
|
},
|
|
related: [OUTBOUND_ROW],
|
|
};
|
|
|
|
const STATS_RESPONSE = {
|
|
window_hours: 24,
|
|
by_direction: [
|
|
{ direction: 'inbound', total: 120, errors: 3, avg_duration_ms: 45, max_duration_ms: 2431 },
|
|
{ direction: 'outbound', total: 18, errors: 1, avg_duration_ms: 300, max_duration_ms: 812 },
|
|
],
|
|
by_status_class: [],
|
|
by_target: [],
|
|
total_rows: 138,
|
|
oldest_at: '2026-08-04T09:00:00+00:00',
|
|
newest_at: '2026-08-11T09:00:01+00:00',
|
|
sink: { queued: 0, queue_capacity: 2000, written: 138, dropped: 0, failed_batches: 0, running: 1 },
|
|
retention: { success_retention_days: 7, error_retention_days: 30, max_rows: 500000 },
|
|
};
|
|
|
|
const listCalls = () => axios.get.mock.calls.filter((c) => c[0] === '/api/request-logs');
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockPermissions = { read: true, manage: true, admin: false };
|
|
axios.get.mockImplementation((url) => {
|
|
if (url === '/api/request-logs') return Promise.resolve({ data: LIST_RESPONSE });
|
|
if (url === '/api/request-logs/stats') return Promise.resolve({ data: STATS_RESPONSE });
|
|
if (url.startsWith('/api/request-logs/')) return Promise.resolve({ data: DETAIL_RESPONSE });
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
axios.post.mockResolvedValue({ data: { removed: { success: 1, error: 0, overflow: 0 } } });
|
|
});
|
|
|
|
async function renderPage() {
|
|
render(<RequestLog />);
|
|
await waitFor(() => expect(listCalls().length).toBeGreaterThan(0));
|
|
await act(async () => {});
|
|
}
|
|
|
|
test('renders both directions of a request from the list endpoint', async () => {
|
|
await renderPage();
|
|
|
|
expect(await screen.findByText('/api/letsencrypt/certificates')).toBeInTheDocument();
|
|
expect(screen.getByText('https://acme-v02.api.letsencrypt.org/acme/new-order')).toBeInTheDocument();
|
|
// Inbound shows the user; outbound shows the target it called.
|
|
expect(screen.getByText('admin')).toBeInTheDocument();
|
|
expect(screen.getByText('acme')).toBeInTheDocument();
|
|
expect(screen.getByText('500')).toBeInTheDocument();
|
|
expect(screen.getByText('429')).toBeInTheDocument();
|
|
});
|
|
|
|
test('a caller without requestlog.read sees a denial and the API is never called', async () => {
|
|
mockPermissions = { read: false, manage: false, admin: false };
|
|
|
|
render(<RequestLog />);
|
|
|
|
expect(await screen.findByText('Access denied')).toBeInTheDocument();
|
|
expect(axios.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('an admin without an explicit grant still gets in', async () => {
|
|
mockPermissions = { read: false, manage: false, admin: true };
|
|
await renderPage();
|
|
expect(await screen.findByText('/api/letsencrypt/certificates')).toBeInTheDocument();
|
|
});
|
|
|
|
test('the errors-only switch is sent to the server, not applied in the browser', async () => {
|
|
await renderPage();
|
|
const before = listCalls().length;
|
|
|
|
await act(async () => {
|
|
fireEvent.click(document.querySelector('.ant-switch'));
|
|
});
|
|
|
|
await waitFor(() => expect(listCalls().length).toBeGreaterThan(before));
|
|
const params = listCalls()[listCalls().length - 1][1].params;
|
|
expect(params.errors_only).toBe(true);
|
|
});
|
|
|
|
test('the direction filter is sent as a query parameter', async () => {
|
|
await renderPage();
|
|
const before = listCalls().length;
|
|
|
|
await act(async () => {
|
|
fireEvent.mouseDown(document.querySelectorAll('.ant-select-selector')[0]);
|
|
});
|
|
await act(async () => {
|
|
const option = Array.from(document.querySelectorAll('.ant-select-item-option'))
|
|
.find((el) => el.textContent.includes('Outbound'));
|
|
fireEvent.click(option);
|
|
});
|
|
|
|
await waitFor(() => expect(listCalls().length).toBeGreaterThan(before));
|
|
const params = listCalls()[listCalls().length - 1][1].params;
|
|
expect(params.direction).toBe('outbound');
|
|
});
|
|
|
|
test('the first request asks for a bounded page, not the whole table', async () => {
|
|
await renderPage();
|
|
const params = listCalls()[0][1].params;
|
|
expect(params.limit).toBe(50);
|
|
expect(params.offset).toBe(0);
|
|
});
|
|
|
|
test('opening a row fetches the detail and shows the REDACTED body', async () => {
|
|
await renderPage();
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getAllByText('Detail')[0]);
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(axios.get).toHaveBeenCalledWith(expect.stringMatching(/\/api\/request-logs\/\d+$/))
|
|
);
|
|
|
|
const modal = await waitFor(() => document.querySelector('.ant-modal-content'));
|
|
expect(modal.textContent).toContain('***REDACTED***');
|
|
// The redaction happens server-side; the UI must not attempt to show a raw value.
|
|
expect(modal.textContent).not.toContain('eab_hmac_key":"');
|
|
expect(modal.textContent).toContain('ACME rate limited');
|
|
});
|
|
|
|
test('the detail modal lists the outbound calls triggered by the same request', async () => {
|
|
await renderPage();
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getAllByText('Detail')[0]);
|
|
});
|
|
|
|
const modal = await waitFor(() => document.querySelector('.ant-modal-content'));
|
|
await waitFor(() => expect(modal.textContent).toContain('Calls triggered by this request'));
|
|
expect(modal.textContent).toContain('acme');
|
|
});
|
|
|
|
test('a self-scoped response explains why the list is narrower', async () => {
|
|
axios.get.mockImplementation((url) => {
|
|
if (url === '/api/request-logs') {
|
|
return Promise.resolve({ data: { ...LIST_RESPONSE, scoped_to_self: true } });
|
|
}
|
|
if (url === '/api/request-logs/stats') return Promise.resolve({ data: STATS_RESPONSE });
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
|
|
await renderPage();
|
|
expect(await screen.findByText('Showing your own requests only')).toBeInTheDocument();
|
|
});
|
|
|
|
test('dropped rows are surfaced as a warning', async () => {
|
|
axios.get.mockImplementation((url) => {
|
|
if (url === '/api/request-logs') return Promise.resolve({ data: LIST_RESPONSE });
|
|
if (url === '/api/request-logs/stats') {
|
|
return Promise.resolve({ data: { ...STATS_RESPONSE, sink: { ...STATS_RESPONSE.sink, dropped: 42 } } });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
|
|
await renderPage();
|
|
expect(await screen.findByText(/42 row\(s\) dropped/)).toBeInTheDocument();
|
|
});
|
|
|
|
test('a failed load surfaces the server message instead of a blank table', async () => {
|
|
axios.get.mockImplementation((url) => {
|
|
if (url === '/api/request-logs') {
|
|
return Promise.reject({
|
|
response: { status: 500, data: { error: { message: 'Failed to list request logs' } } },
|
|
});
|
|
}
|
|
return Promise.resolve({ data: STATS_RESPONSE });
|
|
});
|
|
|
|
await renderPage();
|
|
expect(await screen.findByText('Failed to list request logs')).toBeInTheDocument();
|
|
});
|
|
|
|
test('the retention button is hidden without requestlog.manage', async () => {
|
|
mockPermissions = { read: true, manage: false, admin: false };
|
|
await renderPage();
|
|
expect(screen.queryByText('Apply retention now')).not.toBeInTheDocument();
|
|
});
|