diff --git a/frontend/.gitignore b/frontend/.gitignore index a8bbfe6..bc2264a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -20,3 +20,9 @@ dist-ssr/ # Build cache *.tsbuildinfo .eslintcache + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..ea9764d --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '@playwright/test' +import { + ODM_USERNAME, + ODM_PASSWORD, + registerUser, + waitForLoadingDone, +} from './odm-fixtures' + +// ── Auth / Login Tests ──────────────────────────────────────────────────── +// Verifies the login page renders correctly, rejects bad credentials, and +// allows a registered user to sign in and reach the workspaces page. + +test.describe('Authentication', () => { + test.beforeAll(async () => { + await registerUser() + }) + + test('login page renders with expected form fields', async ({ page }) => { + await page.goto('/login') + + await expect(page.locator('h1')).toContainText('OpenDataMask') + await expect(page.locator('#username')).toBeVisible() + await expect(page.locator('#password')).toBeVisible() + await expect(page.locator("button[type='submit']")).toBeVisible() + await expect(page.locator("button[type='submit']")).toContainText('Sign In') + }) + + test('register page renders with expected form fields', async ({ page }) => { + await page.goto('/register') + + await expect(page.locator('h1')).toContainText('OpenDataMask') + await expect(page.locator('#username')).toBeVisible() + await expect(page.locator('#email')).toBeVisible() + await expect(page.locator('#password')).toBeVisible() + await expect(page.locator("button[type='submit']")).toBeVisible() + }) + + test('login with invalid credentials shows error', async ({ page }) => { + await page.goto('/login') + await page.fill('#username', 'nonexistent_user') + await page.fill('#password', 'wrong_password') + await page.click("button[type='submit']") + + await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10_000 }) + }) + + test('login with empty fields shows validation message', async ({ page }) => { + await page.goto('/login') + + // Click submit without filling anything + await page.click("button[type='submit']") + + // The form should show a validation error or not navigate away + await expect(page).toHaveURL(/\/login/) + }) + + test('successful login redirects to workspaces', async ({ page }) => { + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + await waitForLoadingDone(page) + + await expect(page.locator('h1')).toContainText('Workspaces') + }) + + test('authenticated user accessing /login is redirected to /workspaces', async ({ page }) => { + // First login + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + // Now navigate to /login — should redirect back + await page.goto('/login') + await expect(page).toHaveURL(/\/workspaces/) + }) + + test('unauthenticated user accessing protected route is redirected to /login', async ({ page }) => { + // Clear any stored credentials + await page.goto('/login') + await page.evaluate(() => { + localStorage.removeItem('token') + localStorage.removeItem('user') + }) + + await page.goto('/workspaces') + await expect(page).toHaveURL(/\/login/) + }) +}) diff --git a/frontend/e2e/connections.spec.ts b/frontend/e2e/connections.spec.ts new file mode 100644 index 0000000..afe44e9 --- /dev/null +++ b/frontend/e2e/connections.spec.ts @@ -0,0 +1,315 @@ +import { test, expect } from './odm-fixtures' +import { + SOURCE_DB, + TARGET_DB, + loginViaApi, + registerUser, + apiCall, + waitForPageHeading, + waitForLoadingDone, +} from './odm-fixtures' + +// ── Source & Destination Connection Tests ────────────────────────────────── +// Verifies that database connections can be created, displayed, tested, and +// managed through the frontend UI. Mirrors the source/target connection +// setup performed by verification/run_verification.sh. + +test.describe('Database Connections', () => { + let workspaceId: number + let token: string + + test.beforeAll(async () => { + await registerUser() + token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'Connections Test Workspace', description: 'E2E connection tests' }, + token, + }) + workspaceId = ws.id as number + }) + + test('connections page shows empty state when no connections exist', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await expect(page.locator('.empty-state')).toBeVisible() + await expect(page.locator('h3')).toContainText('No connections yet') + await expect(page.locator("button:has-text('Add Connection')")).toBeVisible() + }) + + test('add connection modal opens and contains expected fields', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Verify modal title + await expect(page.locator("[role='dialog']")).toContainText('Add Connection') + + // Verify essential form fields exist + await expect(page.locator("[role='dialog'] input[placeholder='Production DB']")).toBeVisible() + await expect(page.locator("[role='dialog'] select.form-control")).toBeVisible() + await expect(page.locator("[role='dialog'] input[placeholder='localhost']")).toBeVisible() + await expect(page.locator("[role='dialog'] input[placeholder='mydb']")).toBeVisible() + await expect(page.locator("[role='dialog'] input[placeholder='admin']")).toBeVisible() + + // Verify Source and Destination checkboxes + await expect(page.locator("label:has-text('Source')")).toBeVisible() + await expect(page.locator("label:has-text('Destination')")).toBeVisible() + + // Verify action buttons + await expect(page.locator("[role='dialog'] button:has-text('Add Connection')")).toBeVisible() + await expect(page.locator("[role='dialog'] button:has-text('Cancel')")).toBeVisible() + }) + + test('form validation rejects empty required fields', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Clear any default values and try to submit + await page.fill("[role='dialog'] input[placeholder='Production DB']", '') + await page.click("[role='dialog'] button:has-text('Add Connection')") + + // Should show validation error + await expect(page.locator("[role='dialog'] .alert-error")).toBeVisible({ timeout: 5_000 }) + }) + + test('form validation requires at least one role (Source or Destination)', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Fill all fields but leave both checkboxes unchecked + await page.fill("[role='dialog'] input[placeholder='Production DB']", 'test-conn') + await page.fill("[role='dialog'] input[placeholder='localhost']", 'db-host') + await page.fill("[role='dialog'] input[placeholder='mydb']", 'test_db') + await page.fill("[role='dialog'] input[placeholder='admin']", 'test_user') + await page.fill("[role='dialog'] input[type='password']", 'test_pass') + + await page.click("[role='dialog'] button:has-text('Add Connection')") + + await expect(page.locator("[role='dialog'] .alert-error")).toContainText('role', { ignoreCase: true }) + }) + + test('create source database connection via UI', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await page.fill("[role='dialog'] input[placeholder='Production DB']", SOURCE_DB.name) + await page.fill("[role='dialog'] input[placeholder='localhost']", SOURCE_DB.host) + await page.fill("[role='dialog'] input[placeholder='mydb']", SOURCE_DB.database) + await page.fill("[role='dialog'] input[placeholder='admin']", SOURCE_DB.username) + await page.fill("[role='dialog'] input[type='password']", SOURCE_DB.password) + + // Check Source role + const sourceCheckbox = page.locator("label:has-text('Source') input[type='checkbox']") + if (!(await sourceCheckbox.isChecked())) { + await sourceCheckbox.click() + } + + await page.click("[role='dialog'] button:has-text('Add Connection')") + + // Modal should close and connection should appear in the table + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + // Verify connection appears in the list + await expect(page.locator(`td:has-text("${SOURCE_DB.name}")`)).toBeVisible() + await expect(page.locator('td .badge-blue')).toContainText('Source') + }) + + test('create destination database connection via UI', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await page.fill("[role='dialog'] input[placeholder='Production DB']", TARGET_DB.name) + await page.fill("[role='dialog'] input[placeholder='localhost']", TARGET_DB.host) + await page.fill("[role='dialog'] input[placeholder='mydb']", TARGET_DB.database) + await page.fill("[role='dialog'] input[placeholder='admin']", TARGET_DB.username) + await page.fill("[role='dialog'] input[type='password']", TARGET_DB.password) + + // Check Destination role + const destCheckbox = page.locator("label:has-text('Destination') input[type='checkbox']") + if (!(await destCheckbox.isChecked())) { + await destCheckbox.click() + } + + await page.click("[role='dialog'] button:has-text('Add Connection')") + + // Modal should close + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + // Verify both connections now exist + await expect(page.locator(`td:has-text("${TARGET_DB.name}")`)).toBeVisible() + await expect(page.locator('td .badge-green')).toContainText('Destination') + }) + + test('connections list shows correct columns', async ({ authenticatedPage: page }) => { + // Ensure connections exist via API first + try { + await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: 'list-test-source', + type: 'POSTGRESQL', + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + } catch { + // May already exist + } + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Verify table headers + const headers = page.locator('thead th') + await expect(headers.nth(0)).toContainText('Name') + await expect(headers.nth(1)).toContainText('Type') + await expect(headers.nth(2)).toContainText('Host') + await expect(headers.nth(3)).toContainText('Database') + await expect(headers.nth(4)).toContainText('Roles') + await expect(headers.nth(5)).toContainText('Status') + await expect(headers.nth(6)).toContainText('Actions') + }) + + test('test connection button triggers connection test', async ({ authenticatedPage: page }) => { + // Seed a connection via API + try { + await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: 'test-btn-conn', + type: 'POSTGRESQL', + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + } catch { + // May already exist — just view the page + } + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Click the first Test button + const testBtn = page.locator("button:has-text('Test')").first() + await expect(testBtn).toBeVisible() + await testBtn.click() + + // Wait for test result badge to appear + await expect( + page.locator('.badge-green:has-text("OK"), .badge-red:has-text("Error")') + ).toBeVisible({ timeout: 30_000 }) + }) + + test('edit connection modal pre-fills existing data', async ({ authenticatedPage: page }) => { + // Seed a connection via API + await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: 'edit-test-conn', + type: 'POSTGRESQL', + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Find the row for our connection and click Edit + const row = page.locator(`tr:has-text("edit-test-conn")`) + await row.locator("button:has-text('Edit')").click() + + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Modal should say "Edit Connection" + await expect(page.locator("[role='dialog']")).toContainText('Edit Connection') + + // Name should be pre-filled + const nameInput = page.locator("[role='dialog'] input[placeholder='Production DB']") + await expect(nameInput).toHaveValue('edit-test-conn') + }) + + test('delete connection removes it from the list', async ({ authenticatedPage: page }) => { + // Seed a connection to delete + await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: 'delete-me-conn', + type: 'POSTGRESQL', + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Confirm the connection exists + await expect(page.locator('td:has-text("delete-me-conn")')).toBeVisible() + + // Set up dialog handler to accept the confirmation + page.once('dialog', (dialog) => dialog.accept()) + + // Click Delete + const row = page.locator('tr:has-text("delete-me-conn")') + await row.locator("button:has-text('Delete')").click() + + // Connection should disappear + await expect(page.locator('td:has-text("delete-me-conn")')).toBeHidden({ timeout: 10_000 }) + }) + + test('connection type selector includes PostgreSQL, MongoDB, Azure SQL, MySQL', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + const typeSelect = page.locator("[role='dialog'] select.form-control") + const options = await typeSelect.locator('option').allTextContents() + + expect(options).toContain('PostgreSQL') + expect(options).toContain('MongoDB') + expect(options).toContain('Azure SQL') + expect(options).toContain('MySQL') + }) +}) diff --git a/frontend/e2e/full-workflow.spec.ts b/frontend/e2e/full-workflow.spec.ts new file mode 100644 index 0000000..be99249 --- /dev/null +++ b/frontend/e2e/full-workflow.spec.ts @@ -0,0 +1,342 @@ +import { test, expect } from './odm-fixtures' +import { + ODM_USERNAME, + ODM_PASSWORD, + SOURCE_DB, + TARGET_DB, + registerUser, + loginViaApi, + apiCall, + waitForPageHeading, + waitForLoadingDone, + waitForJobCompletion, + type IdResponse, + type WorkspaceResponse, + type ConnectionResponse, + type JobResponse, + type JobLogEntry, + type ConnectionTestResponse, +} from './odm-fixtures' + +// ── Full E2E Workflow Test ──────────────────────────────────────────────── +// This spec mirrors the exact sequence performed by +// verification/run_verification.sh and verification/verify.py: +// +// 1. Register user → Login +// 2. Create workspace +// 3. Create source connection (PostgreSQL → source_db) +// 4. Create destination connection (PostgreSQL → target_db) +// 5. Configure table masking (users table, MASK mode) +// 6. Add column generators (full_name, email, phone, dob, salary) +// 7. Run masking job +// 8. Verify job completes successfully +// 9. Verify record integrity (row count = 50) +// 10. Verify masking effectiveness (all 50 rows processed) +// +// The test uses the UI for the interactive steps and the API for seeding +// and verification, ensuring the frontend correctly drives the backend. + +test.describe('Full Verification Workflow', () => { + test.describe.configure({ mode: 'serial' }) + + let workspaceId: number + let token: string + let sourceConnectionId: number + let targetConnectionId: number + let tableConfigId: number + let jobId: number + + test('step 1 — register and login via UI', async ({ page }) => { + await registerUser() + + await page.goto('/login') + await expect(page.locator('h1')).toContainText('OpenDataMask') + + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + await waitForLoadingDone(page) + + await expect(page.locator('h1')).toContainText('Workspaces') + + // Store token for API calls in subsequent steps + token = await loginViaApi() + }) + + test('step 2 — create workspace via UI', async ({ page }) => { + // Login first + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + await waitForLoadingDone(page) + + // Create workspace + await page.click("button:has-text('New Workspace')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + const nameInput = page.locator("[role='dialog'] input.form-control").first() + await nameInput.fill('Full Verification Workspace') + + // Look for description input if exists + const inputs = page.locator("[role='dialog'] input.form-control, [role='dialog'] textarea.form-control") + if ((await inputs.count()) > 1) { + await inputs.nth(1).fill('End-to-end verification test') + } + + const createBtn = page.locator("[role='dialog'] button:has-text('Create'), [role='dialog'] button:has-text('Save')") + await createBtn.first().click() + + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + // Verify workspace appears + await expect(page.locator('text=Full Verification Workspace')).toBeVisible({ timeout: 10_000 }) + + // Get workspace ID via API for subsequent steps + const workspaces = await apiCall('/api/workspaces', { token }) + const ws = workspaces.find((w) => w.name === 'Full Verification Workspace') + expect(ws).toBeDefined() + workspaceId = ws!.id + }) + + test('step 3 — create source database connection', async ({ page }) => { + // Login and navigate + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await page.fill("[role='dialog'] input[placeholder='Production DB']", 'verification-source') + await page.fill("[role='dialog'] input[placeholder='localhost']", SOURCE_DB.host) + await page.fill("[role='dialog'] input[placeholder='mydb']", SOURCE_DB.database) + await page.fill("[role='dialog'] input[placeholder='admin']", SOURCE_DB.username) + await page.fill("[role='dialog'] input[type='password']", SOURCE_DB.password) + + const sourceCheckbox = page.locator("label:has-text('Source') input[type='checkbox']") + if (!(await sourceCheckbox.isChecked())) { + await sourceCheckbox.click() + } + + await page.click("[role='dialog'] button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + await expect(page.locator('td:has-text("verification-source")')).toBeVisible() + + // Get connection ID via API + const conns = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + token, + }) + const srcConn = conns.find((c) => c.name === 'verification-source') + expect(srcConn).toBeDefined() + sourceConnectionId = srcConn!.id + }) + + test('step 4 — create destination database connection', async ({ page }) => { + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + + await page.click("button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await page.fill("[role='dialog'] input[placeholder='Production DB']", 'verification-target') + await page.fill("[role='dialog'] input[placeholder='localhost']", TARGET_DB.host) + await page.fill("[role='dialog'] input[placeholder='mydb']", TARGET_DB.database) + await page.fill("[role='dialog'] input[placeholder='admin']", TARGET_DB.username) + await page.fill("[role='dialog'] input[type='password']", TARGET_DB.password) + + const destCheckbox = page.locator("label:has-text('Destination') input[type='checkbox']") + if (!(await destCheckbox.isChecked())) { + await destCheckbox.click() + } + + await page.click("[role='dialog'] button:has-text('Add Connection')") + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + await expect(page.locator('td:has-text("verification-target")')).toBeVisible() + + // Get connection ID via API + const conns = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + token, + }) + const tgtConn = conns.find((c) => c.name === 'verification-target') + expect(tgtConn).toBeDefined() + targetConnectionId = tgtConn!.id + }) + + test('step 5 — configure table masking for users table', async ({ page }) => { + // Use API for table config (matching verification script's approach) + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + tableConfigId = tbl.id + + // Verify via UI + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + await expect(page.locator('text=users')).toBeVisible() + await expect(page.locator('text=MASK')).toBeVisible() + }) + + test('step 6 — add column generators matching verification script', async ({ page }) => { + // Add generators via API (matching run_verification.sh exactly) + const generators = [ + { columnName: 'full_name', generatorType: 'FULL_NAME' }, + { columnName: 'email', generatorType: 'EMAIL' }, + { columnName: 'phone_number', generatorType: 'PHONE' }, + { columnName: 'date_of_birth', generatorType: 'BIRTH_DATE' }, + { columnName: 'salary', generatorType: 'RANDOM_INT', generatorParams: JSON.stringify({ min: '30000', max: '200000' }) }, + ] + + for (const gen of generators) { + await apiCall(`/api/workspaces/${workspaceId}/tables/${tableConfigId}/generators`, { + method: 'POST', + body: gen, + token, + }) + } + + // Verify via UI + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + // Expand to see generators + const expandBtn = page.locator("button:has-text('Columns')").first() + if (await expandBtn.isVisible()) { + await expandBtn.click() + await page.waitForTimeout(500) + + await expect(page.locator('text=full_name')).toBeVisible() + await expect(page.locator('text=email')).toBeVisible() + await expect(page.locator('text=phone_number')).toBeVisible() + await expect(page.locator('text=date_of_birth')).toBeVisible() + await expect(page.locator('text=salary')).toBeVisible() + } + }) + + test('step 7 — run masking job and wait for completion', async ({ page }) => { + // Trigger job via API + jobId = ( + await apiCall(`/api/workspaces/${workspaceId}/jobs`, { + method: 'POST', + body: { + name: 'Full Verification Masking Job', + sourceConnectionId, + targetConnectionId, + }, + token, + }) + ).id + + // Wait for completion + const status = await waitForJobCompletion(token, workspaceId, jobId, 180_000) + expect(status).toBe('COMPLETED') + + // Verify in UI + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + await expect(page.locator('text=COMPLETED')).toBeVisible({ timeout: 15_000 }) + }) + + test('step 8 — verify record integrity (50 rows processed)', async () => { + // Mirrors verification/verify.py check_record_integrity + const job = await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}`, { + token, + }) + + expect(job.status).toBe('COMPLETED') + expect(job.rowsProcessed).toBe(50) + expect(job.tablesProcessed).toBe(1) + }) + + test('step 9 — verify job logs contain masking activity', async () => { + // Mirrors verification/verify.py's log inspection + const logs = await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}/logs`, { + token, + }) + + expect(logs.length).toBeGreaterThan(0) + + // Logs should contain INFO-level entries about the masking process + const infoLogs = logs.filter((l) => l.level === 'INFO') + expect(infoLogs.length).toBeGreaterThan(0) + }) + + test('step 10 — verify connections remain healthy after job', async ({ page }) => { + // Test both connections via API + const srcTest = await apiCall( + `/api/workspaces/${workspaceId}/connections/${sourceConnectionId}/test`, + { method: 'POST', token } + ) + expect(srcTest.success).toBe(true) + + const tgtTest = await apiCall( + `/api/workspaces/${workspaceId}/connections/${targetConnectionId}/test`, + { method: 'POST', token } + ) + expect(tgtTest.success).toBe(true) + + // Also verify in UI + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) + + await page.goto(`/workspaces/${workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Both connections should be listed + await expect(page.locator('td:has-text("verification-source")')).toBeVisible() + await expect(page.locator('td:has-text("verification-target")')).toBeVisible() + }) +}) diff --git a/frontend/e2e/job-execution.spec.ts b/frontend/e2e/job-execution.spec.ts new file mode 100644 index 0000000..a148a3f --- /dev/null +++ b/frontend/e2e/job-execution.spec.ts @@ -0,0 +1,388 @@ +import { test, expect } from './odm-fixtures' +import { + SOURCE_DB, + TARGET_DB, + loginViaApi, + registerUser, + apiCall, + seedVerificationData, + runMaskingJobViaApi, + waitForJobCompletion, + waitForPageHeading, + waitForLoadingDone, + type JobResponse, + type JobLogEntry, +} from './odm-fixtures' + +// ── Job Execution & Destination Transfer Verification ───────────────────── +// Verifies job creation, monitoring, completion, and validates that masked +// data is actually transferred to the destination database — mirroring the +// checks performed by verification/verify.py. + +test.describe('Job Execution', () => { + let workspaceId: number + let token: string + let sourceConnectionId: number + let targetConnectionId: number + + test.beforeAll(async () => { + await registerUser() + token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'Job Exec Test Workspace', description: 'E2E job tests' }, + token, + }) + workspaceId = ws.id as number + + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: SOURCE_DB.name, + type: SOURCE_DB.type, + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + sourceConnectionId = src.id as number + + const tgt = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: TARGET_DB.name, + type: TARGET_DB.type, + connectionString: `jdbc:postgresql://${TARGET_DB.host}:${TARGET_DB.port}/${TARGET_DB.database}`, + username: TARGET_DB.username, + password: TARGET_DB.password, + isSource: false, + isDestination: true, + }, + token, + }) + targetConnectionId = tgt.id as number + }) + + test('jobs page shows empty state when no jobs exist', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await expect(page.locator('.empty-state, .job-card')).toBeVisible({ timeout: 10_000 }) + + const emptyState = page.locator('.empty-state') + if (await emptyState.isVisible()) { + await expect(emptyState).toContainText('No jobs yet') + await expect(page.locator("button:has-text('Run New Job')")).toBeVisible() + } + }) + + test('run new job modal opens with connection selectors', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await page.click("button:has-text('Run New Job')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await expect(page.locator("[role='dialog']")).toContainText('Run New Masking Job') + + // Job name field + await expect(page.locator("[role='dialog'] input[placeholder*='Mask']")).toBeVisible() + + // Source and target connection selectors + const selects = page.locator("[role='dialog'] select.form-control") + await expect(selects.first()).toBeVisible() + + // Run Job button + await expect(page.locator("[role='dialog'] button:has-text('Run Job')")).toBeVisible() + }) + + test('job creation validation requires all fields', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await page.click("button:has-text('Run New Job')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Clear the name field + const nameInput = page.locator("[role='dialog'] input[placeholder*='Mask']") + await nameInput.fill('') + + // Try to submit + await page.click("[role='dialog'] button:has-text('Run Job')") + + // Should show validation error + await expect(page.locator("[role='dialog'] .alert-error")).toBeVisible({ timeout: 5_000 }) + }) + + test('create and submit a masking job via UI', async ({ authenticatedPage: page }) => { + // Seed table config + generators so the job has something to process + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + const tableId = tbl.id as number + + const generators = [ + { columnName: 'full_name', generatorType: 'FULL_NAME' }, + { columnName: 'email', generatorType: 'EMAIL' }, + { columnName: 'phone_number', generatorType: 'PHONE' }, + { columnName: 'date_of_birth', generatorType: 'BIRTH_DATE' }, + { columnName: 'salary', generatorType: 'RANDOM_INT', generatorParams: JSON.stringify({ min: '30000', max: '200000' }) }, + ] + for (const gen of generators) { + await apiCall(`/api/workspaces/${workspaceId}/tables/${tableId}/generators`, { + method: 'POST', + body: gen, + token, + }) + } + + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await page.click("button:has-text('Run New Job')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Fill job name + await page.fill("[role='dialog'] input[placeholder*='Mask']", 'E2E Test Masking Job') + + // Select source connection + const selects = page.locator("[role='dialog'] select.form-control") + if (await selects.nth(0).isVisible()) { + await selects.nth(0).selectOption({ index: 0 }) + } + if (await selects.nth(1).isVisible()) { + await selects.nth(1).selectOption({ index: 1 }) + } + + // Submit + await page.click("[role='dialog'] button:has-text('Run Job')") + + // Modal should close and job should appear + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await expect(page.locator('.job-card')).toBeVisible({ timeout: 10_000 }) + }) + + test('job card shows status badge', async ({ authenticatedPage: page }) => { + // Create a job via API + const jobId = await runMaskingJobViaApi(token, workspaceId, sourceConnectionId, targetConnectionId) + + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // Should display a status badge (PENDING, RUNNING, or COMPLETED) + const statusBadge = page.locator('.job-card .badge, .job-card [class*="status"]').first() + await expect(statusBadge).toBeVisible() + }) + + test('view logs button expands log section', async ({ authenticatedPage: page }) => { + // Create and wait for a job to complete via API + const jobId = await runMaskingJobViaApi(token, workspaceId, sourceConnectionId, targetConnectionId) + await waitForJobCompletion(token, workspaceId, jobId) + + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // Click View Logs + const viewLogsBtn = page.locator("button:has-text('View Logs')").first() + if (await viewLogsBtn.isVisible({ timeout: 5_000 })) { + await viewLogsBtn.click() + + // Logs section should appear + await expect(page.locator('.logs-section')).toBeVisible({ timeout: 10_000 }) + } + }) + + test('completed job shows stats (tables and rows processed)', async ({ authenticatedPage: page }) => { + // Create and wait for a job to complete + const jobId = await runMaskingJobViaApi(token, workspaceId, sourceConnectionId, targetConnectionId) + const status = await waitForJobCompletion(token, workspaceId, jobId) + + if (status === 'COMPLETED') { + await page.goto(`/workspaces/${workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + + // Wait for the jobs to load and auto-refresh + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // Reload to see completed status + await page.reload() + await waitForLoadingDone(page) + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // Completed jobs should show stat chips with tables and rows + await expect(page.locator('.stat-chip, text=tables, text=rows')).toBeVisible({ timeout: 15_000 }) + } + }) +}) + +test.describe('Destination Database Transfer Verification', () => { + let seed: Awaited> + + test.beforeAll(async () => { + seed = await seedVerificationData() + + // Run a masking job and wait for completion + const jobId = await runMaskingJobViaApi( + seed.token, + seed.workspaceId, + seed.sourceConnectionId, + seed.targetConnectionId + ) + const status = await waitForJobCompletion(seed.token, seed.workspaceId, jobId) + if (status !== 'COMPLETED') { + throw new Error(`Masking job did not complete successfully: status=${status}`) + } + }) + + test('completed job is visible in jobs list with COMPLETED status', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${seed.workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // At least one job should show COMPLETED + await expect(page.locator('text=COMPLETED')).toBeVisible({ timeout: 15_000 }) + }) + + test('completed job shows non-zero tables processed', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${seed.workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // Check via API that tablesProcessed > 0 + const jobs = await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + token: seed.token, + }) + + const completedJob = jobs.find((j) => j.status === 'COMPLETED') + expect(completedJob).toBeDefined() + expect(completedJob!.tablesProcessed).toBeGreaterThan(0) + }) + + test('completed job shows non-zero rows processed', async ({ authenticatedPage: page }) => { + // Verify via API + const jobs = await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + token: seed.token, + }) + + const completedJob = jobs.find((j) => j.status === 'COMPLETED') + expect(completedJob).toBeDefined() + expect(completedJob!.rowsProcessed).toBeGreaterThan(0) + + // Also verify in UI that stats show rows + await page.goto(`/workspaces/${seed.workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + // The stat chip should show the row count + await expect(page.locator('.stat-chip')).toBeVisible({ timeout: 10_000 }) + }) + + test('job logs show masking activity entries', async ({ authenticatedPage: page }) => { + // Fetch job ID + const jobs = await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + token: seed.token, + }) + + const completedJob = jobs.find((j) => j.status === 'COMPLETED') + expect(completedJob).toBeDefined() + + // Fetch logs via API + const logs = await apiCall( + `/api/workspaces/${seed.workspaceId}/jobs/${completedJob!.id}/logs`, + { token: seed.token } + ) + + expect(logs.length).toBeGreaterThan(0) + + // View logs in UI + await page.goto(`/workspaces/${seed.workspaceId}/jobs`) + await waitForPageHeading(page, 'Jobs') + await page.waitForSelector('.job-card', { timeout: 10_000 }) + + const viewLogsBtn = page.locator("button:has-text('View Logs')").first() + if (await viewLogsBtn.isVisible({ timeout: 5_000 })) { + await viewLogsBtn.click() + await expect(page.locator('.logs-section')).toBeVisible({ timeout: 10_000 }) + + // Log entries should be visible + await expect(page.locator('.log-line')).toHaveCount(logs.length, { timeout: 10_000 }) + } + }) + + test('record integrity — source and target row counts match (API verification)', async () => { + // This test verifies data transfer by checking the job's reported row count + // against the known source data (50 users in source_db.sql). + const jobs = await apiCall(`/api/workspaces/${seed.workspaceId}/jobs`, { + token: seed.token, + }) + + const completedJob = jobs.find((j) => j.status === 'COMPLETED') + expect(completedJob).toBeDefined() + + // The source database has 50 user records (per verification/init/source_db.sql) + expect(completedJob!.rowsProcessed).toBe(50) + expect(completedJob!.tablesProcessed).toBe(1) + }) + + test('workspace overview shows job history after completion', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${seed.workspaceId}`) + await waitForLoadingDone(page) + + // The workspace detail page should reflect completed job data + const pageContent = await page.textContent('body') + expect(pageContent).toBeTruthy() + }) + + test('source connection still test-passes after job', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${seed.workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Find the source connection row + const sourceRow = page.locator(`tr:has-text("${SOURCE_DB.name}")`).first() + if (await sourceRow.isVisible({ timeout: 5_000 })) { + await sourceRow.locator("button:has-text('Test')").click() + + // Wait for test result + await expect( + page.locator('.badge-green:has-text("OK"), .badge-red:has-text("Error")') + ).toBeVisible({ timeout: 30_000 }) + + // Should succeed + await expect(sourceRow.locator('.badge-green:has-text("OK")')).toBeVisible({ timeout: 5_000 }) + } + }) + + test('destination connection still test-passes after job', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${seed.workspaceId}/connections`) + await waitForPageHeading(page, 'Data Connections') + await waitForLoadingDone(page) + + // Find the target connection row + const targetRow = page.locator(`tr:has-text("${TARGET_DB.name}")`).first() + if (await targetRow.isVisible({ timeout: 5_000 })) { + await targetRow.locator("button:has-text('Test')").click() + + await expect( + page.locator('.badge-green:has-text("OK"), .badge-red:has-text("Error")') + ).toBeVisible({ timeout: 30_000 }) + + await expect(targetRow.locator('.badge-green:has-text("OK")')).toBeVisible({ timeout: 5_000 }) + } + }) +}) diff --git a/frontend/e2e/masking-definition.spec.ts b/frontend/e2e/masking-definition.spec.ts new file mode 100644 index 0000000..0fdfbff --- /dev/null +++ b/frontend/e2e/masking-definition.spec.ts @@ -0,0 +1,467 @@ +import { test, expect } from './odm-fixtures' +import { + SOURCE_DB, + TARGET_DB, + loginViaApi, + registerUser, + apiCall, + waitForPageHeading, + waitForLoadingDone, + type TableConfigResponse, +} from './odm-fixtures' + +// ── Masking Definition Tests ────────────────────────────────────────────── +// Verifies the table configuration, column generator assignment, and custom +// data mapping wizard through the frontend UI. Mirrors the table/generator +// setup in verification/run_verification.sh. + +test.describe('Masking Definition — Table Configurations', () => { + let workspaceId: number + let token: string + let sourceConnectionId: number + + test.beforeAll(async () => { + await registerUser() + token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'Masking Def Test Workspace', description: 'E2E masking definition tests' }, + token, + }) + workspaceId = ws.id as number + + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: SOURCE_DB.name, + type: SOURCE_DB.type, + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + sourceConnectionId = src.id as number + + await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: TARGET_DB.name, + type: TARGET_DB.type, + connectionString: `jdbc:postgresql://${TARGET_DB.host}:${TARGET_DB.port}/${TARGET_DB.database}`, + username: TARGET_DB.username, + password: TARGET_DB.password, + isSource: false, + isDestination: true, + }, + token, + }) + }) + + test('tables page shows empty state when no tables configured', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + + // Should show empty state or table list + const content = await page.textContent('body') + expect(content).toBeTruthy() + }) + + test('add table modal opens with expected fields', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + + await page.click("button:has-text('Add Table')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + await expect(page.locator("[role='dialog']")).toContainText('Table') + + // Verify form fields: connection selector, table name, mode selector + await expect(page.locator("[role='dialog'] select.form-control").first()).toBeVisible() + await expect(page.locator("[role='dialog'] input[placeholder='users']")).toBeVisible() + }) + + test('create table configuration in MASK mode via UI', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + + await page.click("button:has-text('Add Table')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Fill table name + await page.fill("[role='dialog'] input[placeholder='users']", 'users') + + // Select MASK mode + const modeSelect = page.locator("[role='dialog'] select.form-control").last() + await modeSelect.selectOption('MASK') + + // Submit + const saveBtn = page.locator("[role='dialog'] button:has-text('Add'), [role='dialog'] button:has-text('Save'), [role='dialog'] button:has-text('Create')") + await saveBtn.first().click() + + // Modal should close + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + await waitForLoadingDone(page) + + // Verify the table appears in the list + await expect(page.locator('text=users')).toBeVisible() + await expect(page.locator('text=MASK')).toBeVisible() + }) + + test('expand table to view column generators section', async ({ authenticatedPage: page }) => { + // Seed a table config via API + await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users_expand_test', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + // Click expand/columns button + const expandBtn = page.locator("button:has-text('Columns')").first() + if (await expandBtn.isVisible()) { + await expandBtn.click() + await page.waitForTimeout(500) + + // Column generators section should be visible + const columnsSection = page.locator("button:has-text('Add Column'), button:has-text('Add Generator')") + await expect(columnsSection.first()).toBeVisible({ timeout: 5_000 }) + } + }) + + test('add column generator to table via UI', async ({ authenticatedPage: page }) => { + // Seed a table config via API + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users_gen_test', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + const tableId = tbl.id as number + + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + // Expand the table + const expandBtn = page.locator("button:has-text('Columns')").first() + if (await expandBtn.isVisible()) { + await expandBtn.click() + await page.waitForTimeout(500) + } + + // Click Add Column/Generator + const addBtn = page.locator("button:has-text('Add Column'), button:has-text('Add Generator')") + if (await addBtn.first().isVisible()) { + await addBtn.first().click() + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + // Fill column name and select generator type + const columnInput = page.locator("[role='dialog'] input").first() + await columnInput.fill('email') + + const genSelect = page.locator("[role='dialog'] select.form-control").first() + await genSelect.selectOption('EMAIL') + + // Save + const saveBtn = page.locator("[role='dialog'] button:has-text('Add'), [role='dialog'] button:has-text('Save')") + await saveBtn.first().click() + + await page.waitForSelector("[role='dialog']", { state: 'hidden', timeout: 10_000 }) + } + }) + + test('table configuration shows all mode options', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + + await page.click("button:has-text('Add Table')") + await page.waitForSelector("[role='dialog']", { timeout: 5_000 }) + + const modeSelect = page.locator("[role='dialog'] select.form-control").last() + const options = await modeSelect.locator('option').allTextContents() + + expect(options).toContain('PASSTHROUGH') + expect(options).toContain('MASK') + expect(options).toContain('GENERATE') + expect(options).toContain('SUBSET') + expect(options).toContain('SKIP') + }) +}) + +test.describe('Masking Definition — Data Mapping Wizard', () => { + let workspaceId: number + let token: string + let sourceConnectionId: number + + test.beforeAll(async () => { + await registerUser() + token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'Mapping Wizard Test Workspace', description: 'E2E data mapping tests' }, + token, + }) + workspaceId = ws.id as number + + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: SOURCE_DB.name, + type: SOURCE_DB.type, + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + sourceConnectionId = src.id as number + }) + + test('data mapping wizard renders step 1 — connection selection', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + + // Step 1 should show connection cards + await expect(page.locator('text=Step 1')).toBeVisible({ timeout: 10_000 }) + await expect(page.locator('button.conn-card')).toBeVisible({ timeout: 10_000 }) + }) + + test('selecting a connection advances to step 2 — table selection', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + + // Click the first connection card + const connCard = page.locator('button.conn-card').first() + await connCard.click() + + // Should advance to step 2 + await waitForLoadingDone(page) + await expect(page.locator('text=Step 2')).toBeVisible({ timeout: 10_000 }) + }) + + test('selecting a table advances to step 3 — column mapping', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + + // Step 1: select connection + const connCard = page.locator('button.conn-card').first() + await connCard.click() + await waitForLoadingDone(page) + + // Step 2: select table + const tableCard = page.locator('button.table-card').first() + if (await tableCard.isVisible({ timeout: 10_000 })) { + await tableCard.click() + await waitForLoadingDone(page) + + // Step 3 should show column mapping UI + await expect(page.locator('text=Step 3')).toBeVisible({ timeout: 10_000 }) + } + }) + + test('column mapping shows action dropdown with MIGRATE_AS_IS and MASK options', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + + // Navigate to step 3 + const connCard = page.locator('button.conn-card').first() + await connCard.click() + await waitForLoadingDone(page) + + const tableCard = page.locator('button.table-card').first() + if (await tableCard.isVisible({ timeout: 10_000 })) { + await tableCard.click() + await waitForLoadingDone(page) + + // Check that action dropdowns exist with expected options + const actionSelect = page.locator('select').first() + if (await actionSelect.isVisible({ timeout: 5_000 })) { + const options = await actionSelect.locator('option').allTextContents() + const optionsLower = options.map((o) => o.toLowerCase()) + expect(optionsLower.some((o) => o.includes('migrate') || o.includes('as_is') || o.includes('as-is'))).toBeTruthy() + expect(optionsLower.some((o) => o.includes('mask'))).toBeTruthy() + } + } + }) + + test('selecting MASK action reveals masking strategy options', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + + // Navigate to step 3 + const connCard = page.locator('button.conn-card').first() + await connCard.click() + await waitForLoadingDone(page) + + const tableCard = page.locator('button.table-card').first() + if (await tableCard.isVisible({ timeout: 10_000 })) { + await tableCard.click() + await waitForLoadingDone(page) + + // Select MASK action for first column + const actionSelect = page.locator('select').first() + if (await actionSelect.isVisible({ timeout: 5_000 })) { + await actionSelect.selectOption('MASK') + await page.waitForTimeout(300) + + // A strategy dropdown should appear + const selects = page.locator('select') + const count = await selects.count() + // There should be more selects now (strategy + potentially generator type) + expect(count).toBeGreaterThanOrEqual(2) + } + } + }) + + test('saved mappings appear in the mappings list section', async ({ authenticatedPage: page }) => { + // Seed a mapping via API + await apiCall(`/api/workspaces/${workspaceId}/mappings/bulk`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users', + columnMappings: [ + { columnName: 'id', action: 'MIGRATE_AS_IS' }, + { columnName: 'email', action: 'MASK', maskingStrategy: 'FAKE', fakeGeneratorType: 'EMAIL' }, + { columnName: 'full_name', action: 'MASK', maskingStrategy: 'FAKE', fakeGeneratorType: 'FULL_NAME' }, + ], + }, + token, + }) + + await page.goto(`/workspaces/${workspaceId}/mappings`) + await waitForPageHeading(page, 'Custom Data Mapping') + await waitForLoadingDone(page) + + // The saved mappings section should list the saved table mapping + await expect(page.locator('text=users')).toBeVisible({ timeout: 10_000 }) + }) +}) + +test.describe('Masking Definition — Verification of Column Generators', () => { + let workspaceId: number + let token: string + let sourceConnectionId: number + let tableConfigId: number + + test.beforeAll(async () => { + await registerUser() + token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'Generator Verification Workspace', description: 'E2E generator verification' }, + token, + }) + workspaceId = ws.id as number + + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: SOURCE_DB.name, + type: SOURCE_DB.type, + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + sourceConnectionId = src.id as number + + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + tableConfigId = tbl.id as number + + // Add column generators matching verification/run_verification.sh + const generators = [ + { columnName: 'full_name', generatorType: 'FULL_NAME' }, + { columnName: 'email', generatorType: 'EMAIL' }, + { columnName: 'phone_number', generatorType: 'PHONE' }, + { columnName: 'date_of_birth', generatorType: 'BIRTH_DATE' }, + { columnName: 'salary', generatorType: 'RANDOM_INT', generatorParams: JSON.stringify({ min: '30000', max: '200000' }) }, + ] + for (const gen of generators) { + await apiCall(`/api/workspaces/${workspaceId}/tables/${tableConfigId}/generators`, { + method: 'POST', + body: gen, + token, + }) + } + }) + + test('tables page displays configured table with MASK mode', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + await expect(page.locator('text=users')).toBeVisible() + await expect(page.locator('text=MASK')).toBeVisible() + }) + + test('expanding table shows all 5 configured column generators', async ({ authenticatedPage: page }) => { + await page.goto(`/workspaces/${workspaceId}/tables`) + await waitForPageHeading(page, 'Table Configurations') + await waitForLoadingDone(page) + + // Expand column generators + const expandBtn = page.locator("button:has-text('Columns')").first() + if (await expandBtn.isVisible()) { + await expandBtn.click() + await page.waitForTimeout(500) + + // Verify all generator names are visible + await expect(page.locator('text=full_name')).toBeVisible() + await expect(page.locator('text=email')).toBeVisible() + await expect(page.locator('text=phone_number')).toBeVisible() + await expect(page.locator('text=date_of_birth')).toBeVisible() + await expect(page.locator('text=salary')).toBeVisible() + + // Verify generator types + await expect(page.locator('text=FULL_NAME')).toBeVisible() + await expect(page.locator('text=EMAIL')).toBeVisible() + await expect(page.locator('text=PHONE')).toBeVisible() + await expect(page.locator('text=BIRTH_DATE')).toBeVisible() + await expect(page.locator('text=RANDOM_INT')).toBeVisible() + } + }) + + test('column generator count matches verification script configuration', async ({ authenticatedPage: page }) => { + // Verify via API that exactly 5 generators exist + const tableConfig = await apiCall( + `/api/workspaces/${workspaceId}/tables/${tableConfigId}`, + { token } + ) + + const generators = tableConfig.columnGenerators ?? [] + expect(generators.length).toBe(5) + }) +}) diff --git a/frontend/e2e/odm-fixtures.ts b/frontend/e2e/odm-fixtures.ts new file mode 100644 index 0000000..fe6eba7 --- /dev/null +++ b/frontend/e2e/odm-fixtures.ts @@ -0,0 +1,297 @@ +import { test as base, expect, type Page } from '@playwright/test' + +// ── Verification-sandbox defaults ───────────────────────────────────────── +// These match the credentials used by verification/run_verification.sh +// and verification/docker-compose.yml so the tests work out-of-the-box +// against the sandboxed environment without any extra configuration. + +export const ODM_USERNAME = process.env.ODM_USERNAME ?? 'e2e_test_user' +export const ODM_PASSWORD = process.env.ODM_PASSWORD ?? 'E2eTest!Pass123' +export const ODM_EMAIL = process.env.ODM_EMAIL ?? 'e2e@odm-test.local' +export const API_BASE = process.env.ODM_API ?? 'http://localhost:8080' + +export const SOURCE_DB = { + name: 'e2e-source-db', + type: 'POSTGRESQL' as const, + host: process.env.SOURCE_DB_HOST ?? 'source_db', + port: 5432, + database: process.env.SOURCE_DB_NAME ?? 'source_db', + username: process.env.SOURCE_DB_USER ?? 'source_user', + password: process.env.SOURCE_DB_PASS ?? 'source_pass', +} + +export const TARGET_DB = { + name: 'e2e-target-db', + type: 'POSTGRESQL' as const, + host: process.env.TARGET_DB_HOST ?? 'target_db', + port: 5432, + database: process.env.TARGET_DB_NAME ?? 'target_db', + username: process.env.TARGET_DB_USER ?? 'target_user', + password: process.env.TARGET_DB_PASS ?? 'target_pass', +} + +// ── REST API helpers ────────────────────────────────────────────────────── +// These mirror verification/run_verification.sh — we use the API to seed +// state and read results while keeping tests focused on the frontend. + +// ── API response types ──────────────────────────────────────────────────── + +interface ApiOptions { + method?: string + body?: Record + token?: string +} + +export interface IdResponse { + id: number +} + +export interface AuthTokenResponse { + token: string +} + +export interface ConnectionTestResponse { + success: boolean + message: string +} + +export interface JobResponse { + id: number + status: string + rowsProcessed: number + tablesProcessed: number + name: string +} + +export interface JobLogEntry { + level: string + message: string +} + +export interface WorkspaceResponse { + id: number + name: string +} + +export interface ConnectionResponse { + id: number + name: string + type: string + isSource: boolean + isDestination: boolean +} + +export interface TableConfigResponse { + id: number + columnGenerators: Array<{ columnName: string; generatorType: string }> +} + +export async function apiCall( + path: string, + { method = 'GET', body, token }: ApiOptions = {} +): Promise { + const headers: Record = { 'Content-Type': 'application/json' } + if (token) headers['Authorization'] = `Bearer ${token}` + + const res = await fetch(`${API_BASE}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`API ${method} ${path} → ${res.status}: ${text}`) + } + + const text = await res.text() + return text ? (JSON.parse(text) as T) : (undefined as unknown as T) +} + +function isAlreadyRegisteredError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const message = error.message.toLowerCase() + return ( + message.includes('→ 409:') || + message.includes('already exists') || + message.includes('already registered') + ) +} + +export async function registerUser(): Promise { + try { + await apiCall('/api/auth/register', { + method: 'POST', + body: { username: ODM_USERNAME, email: ODM_EMAIL, password: ODM_PASSWORD }, + }) + } catch (error) { + if (!isAlreadyRegisteredError(error)) { + throw error + } + } +} + +export async function loginViaApi(): Promise { + const resp = await apiCall('/api/auth/login', { + method: 'POST', + body: { username: ODM_USERNAME, password: ODM_PASSWORD }, + }) + return resp.token +} + +// ── Browser helpers ─────────────────────────────────────────────────────── + +export async function loginViaUi(page: Page): Promise { + await page.goto('/login') + await page.fill('#username', ODM_USERNAME) + await page.fill('#password', ODM_PASSWORD) + await page.click("button[type='submit']") + await page.waitForURL(/\/workspaces/, { timeout: 15_000 }) +} + +export async function waitForLoadingDone(page: Page, timeout = 10_000): Promise { + try { + await page.waitForSelector('.loading-overlay', { state: 'hidden', timeout }) + } catch { + // Overlay may never appear — safe to continue. + } +} + +export async function waitForPageHeading(page: Page, text: string, timeout = 15_000): Promise { + await page.waitForSelector(`h1:has-text("${text}")`, { timeout }) + await waitForLoadingDone(page) +} + +// ── Seed helpers (API-driven, matching run_verification.sh) ──────────────── + +export interface SeedResult { + token: string + workspaceId: number + sourceConnectionId: number + targetConnectionId: number + tableConfigId: number +} + +export async function seedVerificationData(): Promise { + await registerUser() + const token = await loginViaApi() + + const ws = await apiCall('/api/workspaces', { + method: 'POST', + body: { name: 'E2E Verification Workspace', description: 'Playwright E2E test workspace' }, + token, + }) + const workspaceId = ws.id + + const src = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: SOURCE_DB.name, + type: SOURCE_DB.type, + connectionString: `jdbc:postgresql://${SOURCE_DB.host}:${SOURCE_DB.port}/${SOURCE_DB.database}`, + username: SOURCE_DB.username, + password: SOURCE_DB.password, + isSource: true, + isDestination: false, + }, + token, + }) + const sourceConnectionId = src.id + + const tgt = await apiCall(`/api/workspaces/${workspaceId}/connections`, { + method: 'POST', + body: { + name: TARGET_DB.name, + type: TARGET_DB.type, + connectionString: `jdbc:postgresql://${TARGET_DB.host}:${TARGET_DB.port}/${TARGET_DB.database}`, + username: TARGET_DB.username, + password: TARGET_DB.password, + isSource: false, + isDestination: true, + }, + token, + }) + const targetConnectionId = tgt.id + + const tbl = await apiCall(`/api/workspaces/${workspaceId}/tables`, { + method: 'POST', + body: { + connectionId: sourceConnectionId, + tableName: 'users', + schemaName: 'public', + mode: 'MASK', + }, + token, + }) + const tableConfigId = tbl.id + + const generators = [ + { columnName: 'full_name', generatorType: 'FULL_NAME' }, + { columnName: 'email', generatorType: 'EMAIL' }, + { columnName: 'phone_number', generatorType: 'PHONE' }, + { columnName: 'date_of_birth', generatorType: 'BIRTH_DATE' }, + { columnName: 'salary', generatorType: 'RANDOM_INT', generatorParams: JSON.stringify({ min: '30000', max: '200000' }) }, + ] + + for (const gen of generators) { + await apiCall(`/api/workspaces/${workspaceId}/tables/${tableConfigId}/generators`, { + method: 'POST', + body: gen, + token, + }) + } + + return { token, workspaceId, sourceConnectionId, targetConnectionId, tableConfigId } +} + +export async function runMaskingJobViaApi( + token: string, + workspaceId: number, + sourceConnectionId: number, + targetConnectionId: number +): Promise { + const job = await apiCall(`/api/workspaces/${workspaceId}/jobs`, { + method: 'POST', + body: { + name: 'E2E Verification Job', + sourceConnectionId, + targetConnectionId, + }, + token, + }) + return job.id +} + +export async function waitForJobCompletion( + token: string, + workspaceId: number, + jobId: number, + timeoutMs = 120_000 +): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const resp = await apiCall(`/api/workspaces/${workspaceId}/jobs/${jobId}`, { token }) + const status = resp.status + if (status === 'COMPLETED' || status === 'FAILED' || status === 'CANCELLED') { + return status + } + await new Promise((r) => setTimeout(r, 3_000)) + } + return 'TIMEOUT' +} + +// ── Custom test fixture ─────────────────────────────────────────────────── + +export type OdmFixtures = { + authenticatedPage: Page +} + +export const test = base.extend({ + authenticatedPage: async ({ page }, use) => { + await registerUser() + await loginViaUi(page) + await use(page) + }, +}) + +export { expect } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eeaf161..ca0c83e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tsconfig/node20": "^20.1.9", "@types/node": "^20.10.0", "@typescript-eslint/eslint-plugin": "^7.18.0", @@ -545,6 +546,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -3423,6 +3440,53 @@ } } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 67f5e2a..4300380 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@tsconfig/node20": "^20.1.9", "@types/node": "^20.10.0", "@typescript-eslint/eslint-plugin": "^7.18.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..387cb0f --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Playwright E2E test configuration for OpenDataMask. + * + * These tests run against the verification sandbox environment started by + * `verification/docker-compose.yml`. The frontend is served at http://localhost + * and the backend API at http://localhost:8080. + * + * Usage: + * cd frontend + * npx playwright test + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: 'html', + timeout: 60_000, + + use: { + baseURL: process.env.ODM_URL ?? 'http://localhost', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + actionTimeout: 15_000, + navigationTimeout: 30_000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6039470..4d7521d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' +import { configDefaults } from 'vitest/config' export default defineConfig({ plugins: [vue()], @@ -18,6 +19,7 @@ export default defineConfig({ } }, test: { - environment: 'node' + environment: 'node', + exclude: [...configDefaults.exclude, 'e2e/**'] } })