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
516 lines
17 KiB
TypeScript
Executable File
516 lines
17 KiB
TypeScript
Executable File
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
|
|
import { waitForDialog, waitForLoadingComplete } from '../utils/wait-helpers';
|
|
|
|
async function getAuthToken(page: import('@playwright/test').Page): Promise<string> {
|
|
return await page.evaluate(() => {
|
|
const authRaw = localStorage.getItem('auth');
|
|
if (authRaw) {
|
|
try {
|
|
const parsed = JSON.parse(authRaw) as { token?: string };
|
|
if (parsed?.token) {
|
|
return parsed.token;
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
return (
|
|
localStorage.getItem('token') ||
|
|
localStorage.getItem('charon_auth_token') ||
|
|
''
|
|
);
|
|
});
|
|
}
|
|
|
|
function buildAuthHeaders(token: string): Record<string, string> | undefined {
|
|
return token ? { Authorization: `Bearer ${token}` } : undefined;
|
|
}
|
|
|
|
async function createUserViaApi(
|
|
page: import('@playwright/test').Page,
|
|
user: { email: string; name: string; password: string; role: 'admin' | 'user' | 'guest' }
|
|
): Promise<{ id: string | number; email: string }> {
|
|
const token = await getAuthToken(page);
|
|
const response = await page.request.post('/api/v1/users', {
|
|
data: user,
|
|
headers: buildAuthHeaders(token),
|
|
});
|
|
|
|
expect(response.ok()).toBe(true);
|
|
const payload = await response.json();
|
|
expect(payload).toEqual(expect.objectContaining({
|
|
id: expect.anything(),
|
|
email: user.email,
|
|
}));
|
|
|
|
return { id: payload.id, email: payload.email };
|
|
}
|
|
|
|
async function openInviteUserForm(page: import('@playwright/test').Page): Promise<void> {
|
|
await page.goto('/users');
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
const inviteButton = page.getByRole('button', { name: /invite.*user|add user|create user/i }).first();
|
|
await expect(inviteButton).toBeVisible({ timeout: 15000 });
|
|
await inviteButton.click();
|
|
await waitForDialog(page, { timeout: 15000 });
|
|
}
|
|
|
|
/**
|
|
* Integration: Data Consistency (UI ↔ API)
|
|
*
|
|
* Purpose: Validate data consistency between UI operations and API responses
|
|
* Scenarios: UI create/API read, API modify/UI display, concurrent operations
|
|
* Success: UI and API always show same data, no data loss or corruption
|
|
*/
|
|
|
|
test.describe('Data Consistency', () => {
|
|
let testUser = {
|
|
email: '',
|
|
name: 'Consistency Test User',
|
|
password: 'ConsPass123!',
|
|
};
|
|
|
|
let testProxy = {
|
|
domain: '',
|
|
target: 'http://localhost:3001',
|
|
description: 'Data consistency test proxy',
|
|
};
|
|
|
|
test.beforeEach(async ({ page, adminUser }) => {
|
|
const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
testUser = {
|
|
email: `consistency-${suffix}@test.local`,
|
|
name: `Consistency User ${suffix}`,
|
|
password: 'ConsPass123!',
|
|
};
|
|
|
|
testProxy = {
|
|
domain: `consistency-${suffix}.local`,
|
|
target: 'http://localhost:3001',
|
|
description: 'Data consistency test proxy',
|
|
};
|
|
|
|
await loginUser(page, adminUser);
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
await expect(page.getByRole('main')).toBeVisible({ timeout: 15000 });
|
|
});
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
try {
|
|
// Cleanup user
|
|
await page.goto('/users');
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
const userRow = page.getByText(testUser.email);
|
|
if (await userRow.isVisible().catch(() => false)) {
|
|
const row = userRow.locator('xpath=ancestor::tr');
|
|
const deleteButton = row.getByRole('button', { name: /delete/i });
|
|
|
|
page.once('dialog', async (dialog) => {
|
|
await dialog.accept();
|
|
});
|
|
|
|
await deleteButton.click();
|
|
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
}
|
|
|
|
// Cleanup proxy
|
|
await page.goto('/proxy-hosts');
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
const proxyRow = page.getByText(testProxy.domain);
|
|
if (await proxyRow.isVisible().catch(() => false)) {
|
|
const row = proxyRow.locator('xpath=ancestor::tr');
|
|
const deleteButton = row.getByRole('button', { name: /delete/i });
|
|
await deleteButton.click();
|
|
|
|
const confirmButton = page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i });
|
|
if (await confirmButton.isVisible().catch(() => false)) {
|
|
await confirmButton.click();
|
|
}
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
}
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
// UI create → API read returns same data
|
|
test('Data created via UI is properly stored and readable via API', async ({ page }) => {
|
|
await test.step('Create user via UI', async () => {
|
|
await createUserViaApi(page, { ...testUser, role: 'user' });
|
|
});
|
|
|
|
await test.step('Verify API returns created user data', async () => {
|
|
const token = await getAuthToken(page);
|
|
|
|
const response = await page.request.get(
|
|
'/api/v1/users',
|
|
{
|
|
headers: buildAuthHeaders(token),
|
|
ignoreHTTPSErrors: true,
|
|
}
|
|
);
|
|
|
|
expect(response.ok()).toBe(true);
|
|
|
|
const users = await response.json();
|
|
expect(Array.isArray(users)).toBe(true);
|
|
const foundUser = Array.isArray(users)
|
|
? users.find((u: any) => u.email === testUser.email)
|
|
: null;
|
|
|
|
expect(foundUser).toBeTruthy();
|
|
if (foundUser) {
|
|
expect(foundUser.email).toBe(testUser.email);
|
|
}
|
|
});
|
|
});
|
|
|
|
// API modify → UI displays updated data
|
|
test('Data modified via API is reflected in UI', async ({ page }) => {
|
|
const updatedName = 'Updated User Name';
|
|
|
|
await test.step('Create user', async () => {
|
|
await createUserViaApi(page, { ...testUser, role: 'user' });
|
|
});
|
|
|
|
await test.step('Modify user via API', async () => {
|
|
const token = await getAuthToken(page);
|
|
|
|
const usersResponse = await page.request.get(
|
|
'/api/v1/users',
|
|
{
|
|
headers: buildAuthHeaders(token),
|
|
ignoreHTTPSErrors: true,
|
|
}
|
|
);
|
|
expect(usersResponse.ok()).toBe(true);
|
|
|
|
const users = await usersResponse.json();
|
|
expect(Array.isArray(users)).toBe(true);
|
|
const user = Array.isArray(users)
|
|
? users.find((u: any) => u.email === testUser.email)
|
|
: null;
|
|
|
|
expect(user).toBeTruthy();
|
|
|
|
const updateResponse = await page.request.put(
|
|
`/api/v1/users/${user.id}`,
|
|
{
|
|
data: { name: updatedName },
|
|
headers: buildAuthHeaders(token),
|
|
ignoreHTTPSErrors: true,
|
|
}
|
|
);
|
|
|
|
expect(updateResponse.ok()).toBe(true);
|
|
const updateBody = await updateResponse.json();
|
|
expect(updateBody).toEqual(expect.objectContaining({
|
|
message: expect.stringMatching(/updated/i),
|
|
}));
|
|
});
|
|
|
|
await test.step('Reload UI and verify updated data', async () => {
|
|
await page.goto('/users');
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
await page.reload();
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
|
|
const updatedElement = page.getByText(updatedName).first();
|
|
await expect(updatedElement).toBeVisible({ timeout: 15000 });
|
|
});
|
|
});
|
|
|
|
// Delete via UI → API no longer returns
|
|
test('Data deleted via UI is removed from API', async ({ page }) => {
|
|
await test.step('Create user', async () => {
|
|
await createUserViaApi(page, { ...testUser, role: 'user' });
|
|
await page.goto('/users');
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
});
|
|
|
|
await test.step('Delete user via UI', async () => {
|
|
const userRow = page.getByText(testUser.email);
|
|
await expect(userRow.first()).toBeVisible({ timeout: 15000 });
|
|
const row = userRow.locator('xpath=ancestor::tr');
|
|
const deleteButton = row.getByRole('button', { name: /delete/i });
|
|
|
|
const deleteResponsePromise = page.waitForResponse(
|
|
(response) => response.url().includes('/api/v1/users/') && response.request().method() === 'DELETE',
|
|
{ timeout: 15000 }
|
|
);
|
|
|
|
page.once('dialog', async (dialog) => {
|
|
await dialog.accept();
|
|
});
|
|
|
|
await deleteButton.click();
|
|
const deleteResponse = await deleteResponsePromise;
|
|
expect(deleteResponse.ok()).toBe(true);
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
});
|
|
|
|
await test.step('Verify API no longer returns user', async () => {
|
|
const token = await getAuthToken(page);
|
|
|
|
const response = await page.request.get(
|
|
'/api/v1/users',
|
|
{
|
|
headers: buildAuthHeaders(token),
|
|
ignoreHTTPSErrors: true,
|
|
}
|
|
);
|
|
|
|
expect(response.ok()).toBe(true);
|
|
|
|
const users = await response.json();
|
|
expect(Array.isArray(users)).toBe(true);
|
|
if (Array.isArray(users)) {
|
|
const foundUser = users.find((u: any) => u.email === testUser.email);
|
|
expect(foundUser).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Concurrent modifications resolved correctly
|
|
test('Concurrent modifications do not cause data corruption', async ({ page }) => {
|
|
await test.step('Create initial user', async () => {
|
|
await createUserViaApi(page, { ...testUser, role: 'user' });
|
|
});
|
|
|
|
await test.step('Trigger concurrent modifications', async () => {
|
|
const token = await getAuthToken(page);
|
|
|
|
const usersResponse = await page.request.get(
|
|
'/api/v1/users',
|
|
{
|
|
headers: buildAuthHeaders(token),
|
|
ignoreHTTPSErrors: true,
|
|
}
|
|
);
|
|
expect(usersResponse.ok()).toBe(true);
|
|
|
|
const users = await usersResponse.json();
|
|
expect(Array.isArray(users)).toBe(true);
|
|
const user = Array.isArray(users)
|
|
? users.find((u: any) => u.email === testUser.email)
|
|
: null;
|
|
|
|
expect(user).toBeTruthy();
|
|
|
|
const update1 = page.request.put(
|
|
`/api/v1/users/${user.id}`,
|
|
{
|
|
data: { name: 'Update One' },
|
|
headers: buildAuthHeaders(token),
|
|
ignoreHTTPSErrors: true,
|
|
}
|
|
);
|
|
|
|
const update2 = page.request.put(
|
|
`/api/v1/users/${user.id}`,
|
|
{
|
|
data: { name: 'Update Two' },
|
|
headers: buildAuthHeaders(token),
|
|
ignoreHTTPSErrors: true,
|
|
}
|
|
);
|
|
|
|
const [resp1, resp2] = await Promise.all([update1, update2]);
|
|
expect([200, 409]).toContain(resp1.status());
|
|
expect([200, 409]).toContain(resp2.status());
|
|
expect(resp1.ok() || resp2.ok()).toBe(true);
|
|
});
|
|
|
|
await test.step('Verify final state is consistent', async () => {
|
|
await page.goto('/users');
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
|
|
const userElement = page.getByText(testUser.email);
|
|
await expect(userElement).toBeVisible();
|
|
|
|
const nameOne = page.getByText('Update One').first();
|
|
const nameTwo = page.getByText('Update Two').first();
|
|
await expect(nameOne.or(nameTwo)).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// Transaction rollback prevents partial updates
|
|
test('Failed transaction prevents partial data updates', async ({ page }) => {
|
|
let createdProxyUUID = '';
|
|
|
|
await test.step('Create proxy', async () => {
|
|
const token = await getAuthToken(page);
|
|
const createResponse = await page.request.post('/api/v1/proxy-hosts', {
|
|
data: {
|
|
domain_names: testProxy.domain,
|
|
forward_scheme: 'http',
|
|
forward_host: 'localhost',
|
|
forward_port: 3001,
|
|
enabled: true,
|
|
},
|
|
headers: buildAuthHeaders(token),
|
|
});
|
|
expect(createResponse.ok()).toBe(true);
|
|
const createdProxy = await createResponse.json();
|
|
expect(createdProxy).toEqual(expect.objectContaining({
|
|
uuid: expect.any(String),
|
|
}));
|
|
createdProxyUUID = createdProxy.uuid;
|
|
});
|
|
|
|
await test.step('Attempt invalid modification', async () => {
|
|
const token = await getAuthToken(page);
|
|
|
|
// Try to modify with invalid data that should fail validation
|
|
const response = await page.request.put(
|
|
`/api/v1/proxy-hosts/${createdProxyUUID}`,
|
|
{
|
|
data: { domain_names: '' },
|
|
headers: buildAuthHeaders(token),
|
|
ignoreHTTPSErrors: true,
|
|
}
|
|
);
|
|
|
|
expect(response.ok()).toBe(false);
|
|
expect([400, 422]).toContain(response.status());
|
|
});
|
|
|
|
await test.step('Verify original data unchanged', async () => {
|
|
await page.goto('/proxy-hosts');
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
|
|
const token = await getAuthToken(page);
|
|
await expect.poll(async () => {
|
|
const detailResponse = await page.request.get(`/api/v1/proxy-hosts/${createdProxyUUID}`, {
|
|
headers: buildAuthHeaders(token),
|
|
});
|
|
|
|
if (!detailResponse.ok()) {
|
|
return `status:${detailResponse.status()}`;
|
|
}
|
|
|
|
const proxy = await detailResponse.json();
|
|
return proxy.domain_names || proxy.domainNames || '';
|
|
}, {
|
|
timeout: 15000,
|
|
message: `Expected proxy ${createdProxyUUID} domain to remain unchanged after failed update`,
|
|
}).toBe(testProxy.domain);
|
|
});
|
|
});
|
|
|
|
// Database constraints enforced (unique, foreign key)
|
|
test('Database constraints prevent invalid data', async ({ page }) => {
|
|
await test.step('Create first user', async () => {
|
|
await createUserViaApi(page, { ...testUser, role: 'user' });
|
|
});
|
|
|
|
await test.step('Attempt to create duplicate user with same email', async () => {
|
|
const token = await getAuthToken(page);
|
|
const duplicateResponse = await page.request.post('/api/v1/users', {
|
|
data: { email: testUser.email, name: 'Different Name', password: 'DiffPass123!', role: 'user' },
|
|
headers: buildAuthHeaders(token),
|
|
});
|
|
expect([400, 409]).toContain(duplicateResponse.status());
|
|
});
|
|
|
|
await test.step('Verify duplicate prevented by error message', async () => {
|
|
const token = await getAuthToken(page);
|
|
const usersResponse = await page.request.get('/api/v1/users', {
|
|
headers: buildAuthHeaders(token),
|
|
});
|
|
expect(usersResponse.ok()).toBe(true);
|
|
const users = await usersResponse.json();
|
|
expect(Array.isArray(users)).toBe(true);
|
|
const matchingUsers = users.filter((u: any) => u.email === testUser.email);
|
|
expect(matchingUsers).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// UI form validation matches backend validation
|
|
test('Client-side and server-side validation consistent', async ({ page }) => {
|
|
await test.step('Test email format validation on create form', async () => {
|
|
await openInviteUserForm(page);
|
|
|
|
// Try invalid email
|
|
const emailInput = page.getByPlaceholder(/user@example/i);
|
|
await emailInput.fill('not-an-email');
|
|
|
|
// Check for client validation error
|
|
const invalidFeedback = page.getByText(/invalid|format|email/i).first();
|
|
if (await invalidFeedback.isVisible()) {
|
|
await expect(invalidFeedback).toBeVisible();
|
|
}
|
|
|
|
// Try submitting anyway
|
|
const submitButton = page.getByRole('dialog')
|
|
.getByRole('button', { name: /send.*invite|create|submit/i })
|
|
.first();
|
|
if (!(await submitButton.isDisabled())) {
|
|
await submitButton.click();
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
|
|
// Server should also reject
|
|
const serverError = page.getByText(/invalid|error|failed/i).first();
|
|
if (await serverError.isVisible()) {
|
|
await expect(serverError).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Long dataset pagination consistent across loads
|
|
test('Pagination and sorting produce consistent results', async ({ page }) => {
|
|
const createdEmails: string[] = [];
|
|
|
|
await test.step('Create multiple users for pagination test', async () => {
|
|
await page.goto('/users');
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const uniqueEmail = `user${i}-${Date.now()}-${Math.floor(Math.random() * 10000)}@test.local`;
|
|
createdEmails.push(uniqueEmail);
|
|
await createUserViaApi(page, {
|
|
email: uniqueEmail,
|
|
name: `User ${i}`,
|
|
password: `Pass${i}123!`,
|
|
role: 'user',
|
|
});
|
|
}
|
|
|
|
await page.goto('/users');
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
});
|
|
|
|
await test.step('Navigate and verify pagination consistency', async () => {
|
|
await page.goto('/users');
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
|
|
for (const email of createdEmails) {
|
|
await expect(page.getByText(email).first()).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
// Navigate to page 2 if pagination exists
|
|
const nextButton = page.getByRole('button', { name: /next|>/ }).first();
|
|
if (await nextButton.isVisible() && !(await nextButton.isDisabled())) {
|
|
const page1Items = await page.locator('table tbody tr').count();
|
|
await nextButton.click();
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
|
|
const page2Items = await page.locator('table tbody tr').count();
|
|
expect(page2Items).toBeGreaterThanOrEqual(0);
|
|
|
|
// Go back to page 1
|
|
const prevButton = page.getByRole('button', { name: /prev|</ }).first();
|
|
if (await prevButton.isVisible()) {
|
|
await prevButton.click();
|
|
await waitForLoadingComplete(page, { timeout: 15000 });
|
|
|
|
const backPage1Items = await page.locator('table tbody tr').count();
|
|
expect(backPage1Items).toBe(page1Items);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|