Files
Charon/tests/core/data-consistency.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

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);
}
}
});
});
});