mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
Stabilize Playwright E2E isolation
Merged Playwright E2E isolation fixes for issue #568 after local and CI verification.
This commit is contained in:
parent
5b4948f34f
commit
93b8eec64a
12 changed files with 123 additions and 107 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { bypassAuth, cleanupRoutes } from './helpers/auth';
|
||||
|
||||
test.describe('Governance dashboard widgets', () => {
|
||||
test.describe('Board sidebar widgets', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await bypassAuth(page);
|
||||
});
|
||||
|
|
@ -10,25 +10,17 @@ test.describe('Governance dashboard widgets', () => {
|
|||
await cleanupRoutes(page);
|
||||
});
|
||||
|
||||
test('renders dashboard widgets on the board view', async ({ page }) => {
|
||||
test('renders operational widgets on the board view', async ({ page }) => {
|
||||
await page.goto('/', { timeout: 15_000 });
|
||||
|
||||
await expect(page.getByText('Loading dashboard…'))
|
||||
.not.toBeVisible({ timeout: 15_000 })
|
||||
.catch(() => {});
|
||||
await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /dashboard|governance dashboard/i }).first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await expect(
|
||||
page.locator('text=/Filter|Filters|Time Range|Agent|Status/i').first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('text=/Success Rate|Token Usage|Average Run Duration|Monthly Budget|Health/i')
|
||||
.first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Agent Registry' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Recent Status Changes' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Audit Trail' })).toBeVisible();
|
||||
await expect(page.getByText('Monthly Budget')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { bypassAuth, cleanupRoutes } from './helpers/auth';
|
||||
|
||||
test.describe('Feedback panel', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await bypassAuth(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await cleanupRoutes(page);
|
||||
});
|
||||
|
||||
test('renders feedback browse state and filters', async ({ page }) => {
|
||||
await page.goto('/', { timeout: 15_000 });
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'User Feedback' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByRole('tab', { name: 'Browse' }).click();
|
||||
|
||||
await expect(page.getByPlaceholder('Filter by agent')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Sentiment|All sentiments/i })
|
||||
.first()
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Category|All categories/i })
|
||||
.first()
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Status|All statuses|Unresolved|Resolved/i })
|
||||
.first()
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('text=/No feedback found\.|Loading…|Resolved|Unresolved/i').first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
|
@ -22,10 +22,10 @@ test.describe('Health Check', () => {
|
|||
await page.goto('/');
|
||||
|
||||
// Wait for columns to load — data fetches via API, so allow time for loading
|
||||
const todoColumn = page.getByRole('region', { name: /To Do column/ });
|
||||
const inProgressColumn = page.getByRole('region', { name: /In Progress column/ });
|
||||
const blockedColumn = page.getByRole('region', { name: /Blocked column/ });
|
||||
const doneColumn = page.getByRole('region', { name: /Done column/ });
|
||||
const todoColumn = page.getByRole('region', { name: 'To Do' });
|
||||
const inProgressColumn = page.getByRole('region', { name: 'In Progress' });
|
||||
const blockedColumn = page.getByRole('region', { name: 'Blocked' });
|
||||
const doneColumn = page.getByRole('region', { name: 'Done' });
|
||||
|
||||
await expect(todoColumn).toBeVisible({ timeout: 15_000 });
|
||||
await expect(inProgressColumn).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,29 @@ const ADMIN_KEY = process.env.VERITAS_ADMIN_KEY || 'dev-admin-key';
|
|||
* Vite proxy issues (IPv4/IPv6 binding differences on macOS).
|
||||
*/
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://127.0.0.1:3001';
|
||||
const AGENT_STATUS_PATH = /\/api\/agent\/status(?:\?.*)?$/;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export function unwrapApiData<T = unknown>(body: unknown): T {
|
||||
if (isRecord(body) && 'data' in body) {
|
||||
return body.data as T;
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export function unwrapTaskList<T = { id: string; title?: string }>(body: unknown): T[] {
|
||||
const data = unwrapApiData<unknown>(body);
|
||||
|
||||
if (Array.isArray(data)) return data as T[];
|
||||
if (isRecord(data) && Array.isArray(data.tasks)) return data.tasks as T[];
|
||||
if (isRecord(body) && Array.isArray(body.tasks)) return body.tasks as T[];
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass authentication for E2E tests.
|
||||
|
|
@ -38,14 +61,34 @@ export async function bypassAuth(page: Page): Promise<void> {
|
|||
})
|
||||
);
|
||||
|
||||
// Keep browser E2E focused on user flows instead of live agent-status polling.
|
||||
await page.route(AGENT_STATUS_PATH, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
status: 'idle',
|
||||
subAgentCount: 0,
|
||||
activeAgents: [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Add 429 retry interceptor for all API calls from the browser.
|
||||
// The dev server has rate limiting which E2E tests can exceed.
|
||||
await page.route('**/api/**', async (route: Route) => {
|
||||
const url = route.request().url();
|
||||
// Skip the auth/status route (already handled by the specific handler above).
|
||||
// Skip routes already handled by specific handlers above.
|
||||
// Use route.fallback() so Playwright passes the request to the next matching
|
||||
// handler instead of hanging (routes are matched LIFO).
|
||||
if (url.includes('/api/auth/status')) {
|
||||
if (url.includes('/api/auth/status') || AGENT_STATUS_PATH.test(url)) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
|
@ -138,7 +181,7 @@ export async function seedTestTask(
|
|||
}
|
||||
|
||||
const createdBody = await response.json();
|
||||
const task = (createdBody as { data?: Record<string, unknown> }).data ?? createdBody;
|
||||
const task = unwrapApiData<Record<string, unknown>>(createdBody);
|
||||
|
||||
// The API creates tasks as 'todo' and only accepts git/worktree data on PATCH.
|
||||
const targetStatus = desiredStatus ?? 'todo';
|
||||
|
|
@ -163,7 +206,7 @@ export async function seedTestTask(
|
|||
);
|
||||
}
|
||||
const patchedBody = await patchResponse.json();
|
||||
return (patchedBody as { data?: Record<string, unknown> }).data ?? patchedBody;
|
||||
return unwrapApiData<Record<string, unknown>>(patchedBody);
|
||||
}
|
||||
|
||||
return task;
|
||||
|
|
|
|||
|
|
@ -503,7 +503,7 @@ test.describe('v5 Mantine migration QA gate', () => {
|
|||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click();
|
||||
const settingsDialog = page.getByRole('dialog', { name: 'Settings' });
|
||||
await expect(settingsDialog).toBeVisible({ timeout: 5_000 });
|
||||
await settingsDialog.getByRole('tab', { name: 'Board' }).click();
|
||||
|
|
@ -516,7 +516,7 @@ test.describe('v5 Mantine migration QA gate', () => {
|
|||
|
||||
await page.getByRole('button', { name: 'Search' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByRole('textbox', { name: 'Search tasks and docs' })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Search Veritas' })).toBeVisible();
|
||||
await assertNoLegacyPrimitiveSlots(page);
|
||||
await assertVisibleInteractiveControlsHaveNames(page);
|
||||
await assertFocusRemainsInsideDialog(page, 'Search');
|
||||
|
|
@ -561,6 +561,7 @@ test.describe('v5 Mantine migration QA gate', () => {
|
|||
|
||||
await page.setViewportSize(mobileViewport);
|
||||
await page.goto('/', { timeout: 15_000 });
|
||||
await page.getByRole('button', { name: 'Mobile board' }).click();
|
||||
await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
|
@ -580,7 +581,7 @@ test.describe('v5 Mantine migration QA gate', () => {
|
|||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
|
||||
await assertNoHorizontalOverflow(page);
|
||||
await assertMobileTouchTargets(page);
|
||||
|
|
|
|||
|
|
@ -107,7 +107,9 @@ test.describe('mobile responsive flows', () => {
|
|||
await detail.getByRole('tab', { name: 'Details' }).click();
|
||||
await detail.getByPlaceholder(/Add a comment/).fill('Mobile comment submitted.');
|
||||
await detail.getByRole('button', { name: 'Add Comment' }).click();
|
||||
await expect(detail.getByText('Mobile comment submitted.')).toBeVisible();
|
||||
await expect(
|
||||
detail.locator('p', { hasText: 'Mobile comment submitted.' }).first()
|
||||
).toBeVisible();
|
||||
|
||||
await detail.getByRole('button', { name: 'Close task details' }).click();
|
||||
await expect(detail).not.toBeVisible();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { bypassAuth, cleanupRoutes } from './helpers/auth';
|
||||
|
||||
test.describe('Prompt registry', () => {
|
||||
test.describe('Template registry', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await bypassAuth(page);
|
||||
});
|
||||
|
|
@ -14,11 +14,16 @@ test.describe('Prompt registry', () => {
|
|||
await page.goto('/templates', { timeout: 15_000 });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Templates' })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('Prompt Templates')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('heading', { name: 'Task Templates' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole('button', { name: /new template/i })).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Search templates...')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('text=/No description|Variables:|Loading templates|Prompt Templates/i').first()
|
||||
page
|
||||
.locator('text=/No templates yet|No templates match|Loading templates|Task Templates/i')
|
||||
.first()
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { bypassAuth, deleteTask, cleanupRoutes } from './helpers/auth';
|
||||
import {
|
||||
bypassAuth,
|
||||
deleteTask,
|
||||
cleanupRoutes,
|
||||
unwrapApiData,
|
||||
unwrapTaskList,
|
||||
} from './helpers/auth';
|
||||
|
||||
test.describe('Task Creation', () => {
|
||||
const createdTaskIds: string[] = [];
|
||||
|
|
@ -38,9 +44,21 @@ test.describe('Task Creation', () => {
|
|||
const descInput = dialog.locator('#description');
|
||||
await descInput.fill('This task was created by an E2E test');
|
||||
|
||||
// Submit the form
|
||||
// Submit the form and capture the created task ID before UI assertions.
|
||||
const submitBtn = dialog.locator('button[type="submit"]', { hasText: /Create/ });
|
||||
await submitBtn.click();
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/tasks') && resp.request().method() === 'POST',
|
||||
{ timeout: 10_000 }
|
||||
),
|
||||
submitBtn.click(),
|
||||
]);
|
||||
expect(createResponse.status()).toBeLessThan(400);
|
||||
|
||||
const createdFromResponse = unwrapApiData<{ id?: string }>(await createResponse.json());
|
||||
if (createdFromResponse.id) {
|
||||
createdTaskIds.push(createdFromResponse.id);
|
||||
}
|
||||
|
||||
// Dialog should close after the API call completes
|
||||
await expect(dialog).not.toBeVisible({ timeout: 15_000 });
|
||||
|
|
@ -53,11 +71,9 @@ test.describe('Task Creation', () => {
|
|||
const response = await page.request.get('/api/tasks', {
|
||||
headers: { 'X-API-Key': process.env.VERITAS_ADMIN_KEY || 'dev-admin-key' },
|
||||
});
|
||||
const tasks = await response.json();
|
||||
const created = (tasks as { id: string; title: string }[]).find(
|
||||
(t) => t.title === 'E2E Created Task'
|
||||
);
|
||||
if (created) {
|
||||
const tasks = unwrapTaskList<{ id: string; title: string }>(await response.json());
|
||||
const created = tasks.find((t) => t.title === 'E2E Created Task');
|
||||
if (created && !createdTaskIds.includes(created.id)) {
|
||||
createdTaskIds.push(created.id);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ test.describe('Task List', () => {
|
|||
await page.goto('/');
|
||||
|
||||
// The kanban board should show the seeded task
|
||||
const taskCard = page.locator(`text=E2E Visible Task`);
|
||||
const taskCard = page.getByRole('heading', { name: 'E2E Visible Task' });
|
||||
await expect(taskCard).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ test.describe('Task List', () => {
|
|||
await page.goto('/');
|
||||
|
||||
// Find the in-progress column and verify the task is inside it
|
||||
const inProgressCol = page.getByRole('region', { name: /In Progress column/ });
|
||||
const inProgressCol = page.getByRole('region', { name: 'In Progress' });
|
||||
await expect(inProgressCol).toBeVisible({ timeout: 15_000 });
|
||||
await expect(inProgressCol.locator('text=E2E Column Check Task')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
|
|
@ -54,7 +54,7 @@ test.describe('Task List', () => {
|
|||
await page.goto('/');
|
||||
|
||||
// Wait for the board to load
|
||||
await expect(page.getByRole('region', { name: /To Do column/ })).toBeVisible({
|
||||
await expect(page.getByRole('region', { name: 'To Do' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ test.describe('Task Status Change', () => {
|
|||
await page.goto('/');
|
||||
|
||||
// Verify the task is in the To Do column
|
||||
const todoColumn = page.getByRole('region', { name: /To Do column/ });
|
||||
const todoColumn = page.getByRole('region', { name: 'To Do' });
|
||||
await expect(todoColumn.locator(`text=${uniqueTitle}`)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
|
@ -40,10 +40,7 @@ test.describe('Task Status Change', () => {
|
|||
const detailPanel = page.locator('[role="dialog"]');
|
||||
await expect(detailPanel).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// The metadata section has a grid: Status | Type | Priority
|
||||
// Status is the first Select in the grid
|
||||
const statusSection = detailPanel.locator('label:has-text("Status")').locator('..');
|
||||
const statusTrigger = statusSection.locator('button[role="combobox"]');
|
||||
const statusTrigger = detailPanel.getByRole('combobox', { name: 'Status' });
|
||||
await expect(statusTrigger).toBeVisible();
|
||||
await statusTrigger.click();
|
||||
|
||||
|
|
@ -67,7 +64,7 @@ test.describe('Task Status Change', () => {
|
|||
await expect(detailPanel).not.toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// Wait for the task to move to the In Progress column
|
||||
const inProgressColumn = page.getByRole('region', { name: /In Progress column/ });
|
||||
const inProgressColumn = page.getByRole('region', { name: 'In Progress' });
|
||||
await expect(inProgressColumn.locator(`text=${uniqueTitle}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
|
@ -89,7 +86,7 @@ test.describe('Task Status Change', () => {
|
|||
await page.goto('/');
|
||||
|
||||
// Verify the task starts in In Progress
|
||||
const inProgressCol = page.getByRole('region', { name: /In Progress column/ });
|
||||
const inProgressCol = page.getByRole('region', { name: 'In Progress' });
|
||||
await expect(inProgressCol.locator(`text=${uniqueTitle}`)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Open the detail panel
|
||||
|
|
@ -98,9 +95,7 @@ test.describe('Task Status Change', () => {
|
|||
const detailPanel = page.locator('[role="dialog"]');
|
||||
await expect(detailPanel).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Find the Status dropdown
|
||||
const statusSection = detailPanel.locator('label:has-text("Status")').locator('..');
|
||||
const statusTrigger = statusSection.locator('button[role="combobox"]');
|
||||
const statusTrigger = detailPanel.getByRole('combobox', { name: 'Status' });
|
||||
await expect(statusTrigger).toBeVisible();
|
||||
await statusTrigger.click();
|
||||
|
||||
|
|
@ -122,7 +117,7 @@ test.describe('Task Status Change', () => {
|
|||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify the task moved to Done
|
||||
const doneCol = page.getByRole('region', { name: /Done column/ });
|
||||
const doneCol = page.getByRole('region', { name: 'Done' });
|
||||
await expect(doneCol.locator(`text=${uniqueTitle}`)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { mkdtempSync, readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// Load VERITAS_ADMIN_KEY from server/.env so E2E tests use the same key as the server
|
||||
|
|
@ -15,6 +16,10 @@ if (!process.env.VERITAS_ADMIN_KEY) {
|
|||
}
|
||||
}
|
||||
|
||||
const e2eDataDir =
|
||||
process.env.VERITAS_DATA_DIR ?? mkdtempSync(resolve(tmpdir(), 'veritas-kanban-e2e-'));
|
||||
process.env.VERITAS_DATA_DIR = e2eDataDir;
|
||||
|
||||
/**
|
||||
* Playwright E2E test configuration for Veritas Kanban.
|
||||
*
|
||||
|
|
@ -70,9 +75,11 @@ export default defineConfig({
|
|||
timeout: 30_000,
|
||||
env: {
|
||||
VERITAS_ADMIN_KEY: process.env.VERITAS_ADMIN_KEY || 'dev-admin-key',
|
||||
VERITAS_DATA_DIR: e2eDataDir,
|
||||
VERITAS_DISABLE_WATCHERS: '1',
|
||||
VERITAS_AUTH_LOCALHOST_BYPASS: 'true',
|
||||
VERITAS_AUTH_LOCALHOST_ROLE: 'admin',
|
||||
RATE_LIMIT_MAX: '10000',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import type { Request } from 'express';
|
|||
* - authRateLimit — 10 req / 15 min (login, token refresh)
|
||||
* - uploadRateLimit — 20 req / min (file uploads)
|
||||
* - writeRateLimit — 60 req / min (POST, PUT, PATCH, DELETE)
|
||||
* - readRateLimit — 300 req / min (GET requests)
|
||||
* - apiRateLimit — 300 req / min (global fallback, localhost exempt)
|
||||
* - readRateLimit — 300 req / min (GET requests, configurable with RATE_LIMIT_MAX)
|
||||
* - apiRateLimit — 300 req / min (global fallback, configurable with RATE_LIMIT_MAX, localhost exempt)
|
||||
*/
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -25,7 +25,7 @@ import type { Request } from 'express';
|
|||
/** Default rate limit (requests per minute) for general API access. */
|
||||
const DEFAULT_API_LIMIT = 300;
|
||||
|
||||
/** Read override from environment, falling back to the default. */
|
||||
/** API/read override from environment, falling back to the default. */
|
||||
const API_LIMIT: number = (() => {
|
||||
const env = process.env.RATE_LIMIT_MAX;
|
||||
if (env) {
|
||||
|
|
@ -37,7 +37,6 @@ const API_LIMIT: number = (() => {
|
|||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true when the request originates from localhost / loopback. */
|
||||
/** Returns true when the request originates from localhost / loopback. */
|
||||
function isLocalhost(req: Request): boolean {
|
||||
// In production, never exempt localhost to avoid bypassing limits behind proxies.
|
||||
|
|
@ -120,12 +119,13 @@ export const writeRateLimit = rateLimit({
|
|||
});
|
||||
|
||||
/**
|
||||
* Generous rate limiter for read operations: 300 req / min per IP.
|
||||
* Generous rate limiter for read operations: 300 req / min per IP by default.
|
||||
* Applied to: GET requests on resource endpoints.
|
||||
* Override with RATE_LIMIT_MAX for high-volume test/dev scenarios.
|
||||
* Localhost is NOT exempt — consistent with other tiered limiters.
|
||||
*/
|
||||
export const readRateLimit = rateLimit({
|
||||
limit: 300,
|
||||
limit: API_LIMIT,
|
||||
windowMs: 60_000,
|
||||
message: 'Too many read requests. Please slow down.',
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue