Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions e2e/suites/interactions/admin/rate-limits.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { test, expect } from '@playwright/test';

/**
* Admin UI for rate-limit exemption management (issue #270).
*
* The page lives at /rate-limits and lets an admin view the effective rate
* limits and add/remove exemptions for users, service accounts, and CIDR
* ranges. The backend exemption-management endpoints may not be present on
* every server, so the page is designed to degrade: the config card and the
* exemptions section both render an "unavailable" state instead of crashing.
* These tests assert the UI surface and the add/remove flow, tolerating a
* backend that has not shipped the endpoints yet.
*/
test.describe('Rate limit admin', () => {
const consoleErrors: string[] = [];

test.beforeEach(async ({ page }) => {
consoleErrors.length = 0;
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
await page.goto('/rate-limits');
await page.waitForLoadState('domcontentloaded');
});

test('page loads with Rate Limits heading', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /rate limits/i }).first()
).toBeVisible({ timeout: 15000 });
});

test('shows the current rate limits and exemptions sections', async ({ page }) => {
await expect(
page.getByText(/current rate limits/i).first()
).toBeVisible({ timeout: 10000 });
await expect(
page.getByText(/exemptions/i).first()
).toBeVisible({ timeout: 10000 });
});

test('Add Exemption button opens a dialog with type, value, and note fields', async ({ page }) => {
const addBtn = page.getByRole('button', { name: /add exemption/i }).first();
await expect(addBtn).toBeVisible({ timeout: 10000 });
await addBtn.click();

const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });

// Type selector (combobox) plus value and note inputs.
await expect(dialog.getByRole('combobox').first()).toBeVisible();
await expect(dialog.locator('#exemption-value')).toBeVisible();
await expect(dialog.locator('#exemption-note')).toBeVisible();

// Cancel closes the dialog without creating anything.
await dialog.getByRole('button', { name: /cancel/i }).click();
await expect(dialog).toBeHidden({ timeout: 5000 });
});

test('invalid CIDR is rejected client-side', async ({ page }) => {
await page.getByRole('button', { name: /add exemption/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });

// Switch the type to CIDR.
await dialog.getByRole('combobox').first().click();
const cidrOption = page.getByRole('option', { name: /cidr/i });
await cidrOption.click();

await dialog.locator('#exemption-value').fill('not-a-valid-cidr');
await dialog.getByRole('button', { name: /^add exemption$/i }).click();

// A validation toast appears and the dialog stays open.
await expect(page.getByText(/valid cidr/i).first()).toBeVisible({ timeout: 8000 });
await expect(dialog).toBeVisible();

await dialog.getByRole('button', { name: /cancel/i }).click();
});

test('add then remove a username exemption (when the backend supports it)', async ({ page }) => {
const username = `e2e-exempt-${Date.now()}`;

await page.getByRole('button', { name: /add exemption/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });

// Default type is username; just fill the value and submit.
await dialog.locator('#exemption-value').fill(username);
await dialog.locator('#exemption-note').fill('e2e test exemption');
await dialog.getByRole('button', { name: /^add exemption$/i }).click();

// Either the backend accepts it (row appears) or it has no exemption
// endpoint (failure toast). Both are acceptable, but a success must round
// trip to a removable row.
const newRow = page.getByRole('row', { name: new RegExp(username) });
const created = await newRow
.isVisible({ timeout: 8000 })
.catch(() => false);

if (!created) {
test.skip(true, 'Backend does not expose rate-limit exemption management');
return;
}

// Remove it and confirm in the alert dialog.
await newRow.getByRole('button', { name: new RegExp(`remove exemption ${username}`, 'i') }).click();
const confirm = page.getByRole('alertdialog');
await expect(confirm).toBeVisible({ timeout: 8000 });
await confirm.getByRole('button', { name: /^remove$/i }).click();

await expect(newRow).toHaveCount(0, { timeout: 10000 });
});

test('no uncaught console errors on load', async ({ page }) => {
await page.waitForTimeout(1500);
const fatal = consoleErrors.filter(
(e) => !/favicon|hydrat|ResizeObserver/i.test(e)
);
expect(fatal, fatal.join('\n')).toHaveLength(0);

Check failure on line 118 in e2e/suites/interactions/admin/rate-limits.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Interactions (shard 1/3)

[interactions] › e2e/suites/interactions/admin/rate-limits.spec.ts:113:7 › Rate limit admin › no uncaught console errors on load

1) [interactions] › e2e/suites/interactions/admin/rate-limits.spec.ts:113:7 › Rate limit admin › no uncaught console errors on load Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: Failed to load resource: the server responded with a status of 404 (Not Found) Failed to load resource: the server responded with a status of 404 (Not Found) expect(received).toHaveLength(expected) Expected length: 0 Received length: 2 Received array: ["Failed to load resource: the server responded with a status of 404 (Not Found)", "Failed to load resource: the server responded with a status of 404 (Not Found)"] 116 | (e) => !/favicon|hydrat|ResizeObserver/i.test(e) 117 | ); > 118 | expect(fatal, fatal.join('\n')).toHaveLength(0); | ^ 119 | }); 120 | }); 121 | at /home/runner/work/artifact-keeper-web/artifact-keeper-web/e2e/suites/interactions/admin/rate-limits.spec.ts:118:37

Check failure on line 118 in e2e/suites/interactions/admin/rate-limits.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Interactions (shard 1/3)

[interactions] › e2e/suites/interactions/admin/rate-limits.spec.ts:113:7 › Rate limit admin › no uncaught console errors on load

1) [interactions] › e2e/suites/interactions/admin/rate-limits.spec.ts:113:7 › Rate limit admin › no uncaught console errors on load Error: Failed to load resource: the server responded with a status of 404 (Not Found) Failed to load resource: the server responded with a status of 404 (Not Found) expect(received).toHaveLength(expected) Expected length: 0 Received length: 2 Received array: ["Failed to load resource: the server responded with a status of 404 (Not Found)", "Failed to load resource: the server responded with a status of 404 (Not Found)"] 116 | (e) => !/favicon|hydrat|ResizeObserver/i.test(e) 117 | ); > 118 | expect(fatal, fatal.join('\n')).toHaveLength(0); | ^ 119 | }); 120 | }); 121 | at /home/runner/work/artifact-keeper-web/artifact-keeper-web/e2e/suites/interactions/admin/rate-limits.spec.ts:118:37
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { test, expect } from '@playwright/test';

/**
* Feature-flag gating driven by GET /api/v1/system/config (issue #271).
*
* The web app fetches the backend's public runtime configuration once and uses
* it to decide which scanner-dependent surfaces to show and to advertise the
* upload-size limit. These tests verify the contract end to end against the
* running backend, then check that the UI reflects what the backend reports.
*/
test.describe('System config feature flags', () => {
test('public system config endpoint returns the expected shape', async ({ request }) => {
const resp = await request.get('/api/v1/system/config');
expect(resp.ok(), `system config request failed: ${resp.status()}`).toBeTruthy();

const body = await resp.json();
// Top-level fields the web app relies on.
expect(typeof body.max_upload_size_bytes).toBe('number');
expect(typeof body.demo_mode).toBe('boolean');
expect(typeof body.guest_access_enabled).toBe('boolean');
expect(typeof body.search_engine).toBe('string');
expect(typeof body.storage_backend).toBe('string');

// Nested scanner / auth flag groups used for navigation gating.
expect(body.scanners).toBeTruthy();
expect(typeof body.scanners.trivy_enabled).toBe('boolean');
expect(typeof body.scanners.openscap_enabled).toBe('boolean');
expect(typeof body.scanners.dependency_track_enabled).toBe('boolean');
expect(body.auth).toBeTruthy();
expect(typeof body.auth.oidc_enabled).toBe('boolean');
expect(typeof body.auth.sso_enabled).toBe('boolean');
});

test('scanner nav entries match the backend scanner flags', async ({ page, request }) => {
const resp = await request.get('/api/v1/system/config');
expect(resp.ok()).toBeTruthy();
const config = await resp.json();

await page.goto('/');
await page.waitForLoadState('domcontentloaded');

// The sidebar only renders for an authenticated admin; confirm it is there.
const nav = page.getByRole('navigation').first();
await expect(nav).toBeVisible({ timeout: 15000 });

Check failure on line 44 in e2e/suites/interactions/admin/system-config-feature-flags.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Interactions (shard 1/3)

[interactions] › e2e/suites/interactions/admin/system-config-feature-flags.spec.ts:34:7 › System config feature flags › scanner nav entries match the backend scanner flags

2) [interactions] › e2e/suites/interactions/admin/system-config-feature-flags.spec.ts:34:7 › System config feature flags › scanner nav entries match the backend scanner flags Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByRole('navigation').first() Expected: visible Timeout: 15000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 15000ms - waiting for getByRole('navigation').first() 42 | // The sidebar only renders for an authenticated admin; confirm it is there. 43 | const nav = page.getByRole('navigation').first(); > 44 | await expect(nav).toBeVisible({ timeout: 15000 }); | ^ 45 | 46 | // "Scan Results" is gated on Trivy or OpenSCAP being configured. 47 | const scanResults = nav.getByRole('link', { name: /scan results/i }); at /home/runner/work/artifact-keeper-web/artifact-keeper-web/e2e/suites/interactions/admin/system-config-feature-flags.spec.ts:44:23

Check failure on line 44 in e2e/suites/interactions/admin/system-config-feature-flags.spec.ts

View workflow job for this annotation

GitHub Actions / E2E Interactions (shard 1/3)

[interactions] › e2e/suites/interactions/admin/system-config-feature-flags.spec.ts:34:7 › System config feature flags › scanner nav entries match the backend scanner flags

2) [interactions] › e2e/suites/interactions/admin/system-config-feature-flags.spec.ts:34:7 › System config feature flags › scanner nav entries match the backend scanner flags Error: expect(locator).toBeVisible() failed Locator: getByRole('navigation').first() Expected: visible Timeout: 15000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 15000ms - waiting for getByRole('navigation').first() 42 | // The sidebar only renders for an authenticated admin; confirm it is there. 43 | const nav = page.getByRole('navigation').first(); > 44 | await expect(nav).toBeVisible({ timeout: 15000 }); | ^ 45 | 46 | // "Scan Results" is gated on Trivy or OpenSCAP being configured. 47 | const scanResults = nav.getByRole('link', { name: /scan results/i }); at /home/runner/work/artifact-keeper-web/artifact-keeper-web/e2e/suites/interactions/admin/system-config-feature-flags.spec.ts:44:23

// "Scan Results" is gated on Trivy or OpenSCAP being configured.
const scanResults = nav.getByRole('link', { name: /scan results/i });
const scannersOn = config.scanners.trivy_enabled || config.scanners.openscap_enabled;
if (scannersOn) {
await expect(scanResults).toBeVisible({ timeout: 10000 });
} else {
await expect(scanResults).toHaveCount(0);
}

// "DT Projects" is gated on the Dependency-Track integration.
const dtProjects = nav.getByRole('link', { name: /dt projects/i });
if (config.scanners.dependency_track_enabled) {
await expect(dtProjects).toBeVisible({ timeout: 10000 });
} else {
await expect(dtProjects).toHaveCount(0);
}
});

test('upload dialog advertises the configured max upload size', async ({ page, request }) => {
const resp = await request.get('/api/v1/system/config');
expect(resp.ok()).toBeTruthy();
const config = await resp.json();

// Only meaningful when the backend advertises a non-zero limit.
test.skip(
!config.max_upload_size_bytes || config.max_upload_size_bytes === 0,
'Server advertises no upload size limit'
);

await page.goto('/repositories/e2e-maven-local');
await page.waitForLoadState('domcontentloaded');

const uploadTab = page.getByRole('tab', { name: /upload/i });
if (!(await uploadTab.isVisible({ timeout: 8000 }).catch(() => false))) {
test.skip(true, 'Upload tab not available for this repository');
return;
}
await uploadTab.click();

// The dropzone helper text includes "max <size>" derived from system config.
await expect(page.getByText(/max\s/i).first()).toBeVisible({ timeout: 10000 });
});
});
Loading
Loading