import { test, expect } from '@playwright/test'; import { execSync } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; const urlMatch = baseUrl.match(/:(\d+)$/); const PORT = urlMatch ? urlMatch[1] : '4173'; const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; const ADMIN_PASSWORD = 'BlockSession123'; const TARGET_PASSWORD = 'TargetSession123'; test.describe('User Blocking Mid-Session [P1]', () => { test.describe.configure({ mode: 'serial' }); // Per-browser unique emails to avoid cross-project race conditions // (parallel browser projects share the same database) let ADMIN_EMAIL: string; let TARGET_EMAIL: string; // eslint-disable-next-line no-empty-pattern test.beforeAll(async ({}, testInfo) => { const browser = testInfo.project.name; ADMIN_EMAIL = `e2e-block-session-admin-${browser}@example.com`; TARGET_EMAIL = `e2e-block-session-target-${browser}@example.com`; const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); // Create admin user execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, { encoding: 'utf-8' } ); // Create target user to be blocked mid-session execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TARGET_EMAIL} --password=${TARGET_PASSWORD} --role=ROLE_PROF 2>&1`, { encoding: 'utf-8' } ); // Ensure target user is unblocked before tests start resetTargetUser(); }); /** * Resets the target user to active state via SQL. * Called at the start of tests that need the user unblocked, * so that Playwright retries don't fail because a previous * attempt already blocked the user server-side. */ function resetTargetUser() { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "UPDATE users SET statut = 'active', blocked_at = NULL, blocked_reason = NULL WHERE email = '${TARGET_EMAIL}'" 2>&1`, { encoding: 'utf-8' } ); } catch { // Ignore cleanup errors } } async function loginAsAdmin(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 60000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } async function loginAsTarget(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(TARGET_EMAIL); await page.locator('#password').fill(TARGET_PASSWORD); await Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 60000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } async function blockUserViaAdmin(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/admin/users`); await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); // Search for the target user (pagination may hide them beyond page 1) const searchInput = page.locator('input[type="search"]'); await searchInput.fill(TARGET_EMAIL); await page.waitForTimeout(500); await page.waitForLoadState('networkidle'); const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); await expect(targetRow).toBeVisible(); // Click "Bloquer" and wait for modal (retry handles hydration timing) await expect(async () => { await targetRow.getByRole('button', { name: /bloquer/i }).click(); await expect(page.locator('#block-modal-title')).toBeVisible({ timeout: 2000 }); }).toPass({ timeout: 10000 }); // Fill in the reason await page.locator('#block-reason').fill('Blocage mid-session E2E test'); // Confirm the block await page.getByRole('button', { name: /confirmer le blocage/i }).click(); // Wait for the success message await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); } async function unblockUserViaAdmin(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/admin/users`); await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); // Search for the target user (pagination may hide them beyond page 1) const searchInput = page.locator('input[type="search"]'); await searchInput.fill(TARGET_EMAIL); await page.waitForTimeout(500); await page.waitForLoadState('networkidle'); const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); await expect(targetRow).toBeVisible(); const unblockButton = targetRow.getByRole('button', { name: /débloquer/i }); await expect(unblockButton).toBeVisible(); await unblockButton.click(); await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); } // ============================================================================ // AC1: Admin blocks a user mid-session // ============================================================================ test('[P1] admin blocks user mid-session - blocked user next request results in redirect', async ({ browser }) => { // Reset target user state so retries work (a previous attempt may have blocked the user server-side) resetTargetUser(); // Use two separate browser contexts to simulate two concurrent sessions const adminContext = await browser.newContext(); const targetContext = await browser.newContext(); const adminPage = await adminContext.newPage(); const targetPage = await targetContext.newPage(); try { // Step 1: Target user logs in and is on the dashboard await loginAsTarget(targetPage); await expect(targetPage).toHaveURL(/\/dashboard/); // Step 2: Admin logs in and blocks the target user await loginAsAdmin(adminPage); await blockUserViaAdmin(adminPage); // Step 3: The blocked user tries to navigate or make an API call // Navigating to a protected page should result in redirect to login await targetPage.goto(`${ALPHA_URL}/settings/sessions`); // The blocked user should be redirected to login (API returns 401/403) await expect(targetPage).toHaveURL(/\/login/, { timeout: 10000 }); } finally { await adminContext.close().catch(() => {}); await targetContext.close().catch(() => {}); } }); // ============================================================================ // AC2: Blocked user cannot log in // ============================================================================ test('[P1] blocked user cannot log in and sees suspended error', async ({ page }) => { // The user was blocked in the previous test; attempt to log in await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(TARGET_EMAIL); await page.locator('#password').fill(TARGET_PASSWORD); await page.getByRole('button', { name: /se connecter/i }).click(); // Should see a suspended account error, not the generic credentials error const errorBanner = page.locator('.error-banner.account-suspended'); await expect(errorBanner).toBeVisible({ timeout: 5000 }); await expect(errorBanner).toContainText(/suspendu|contactez/i); }); // ============================================================================ // AC3: Admin unblocks user - user can log in again // ============================================================================ test('[P1] admin unblocks user and user can log in again', async ({ browser }) => { const adminContext = await browser.newContext(); const adminPage = await adminContext.newPage(); try { // Admin unblocks the user await loginAsAdmin(adminPage); await unblockUserViaAdmin(adminPage); // Verify the status changed back to "Actif" const updatedRow = adminPage.locator('tr', { has: adminPage.locator(`text=${TARGET_EMAIL}`) }); await expect(updatedRow.locator('.status-active')).toContainText('Actif'); } finally { await adminContext.close().catch(() => {}); } // Now the user should be able to log in again (use a new context) const userContext = await browser.newContext(); const userPage = await userContext.newPage(); try { await userPage.goto(`${ALPHA_URL}/login`); await userPage.locator('#email').fill(TARGET_EMAIL); await userPage.locator('#password').fill(TARGET_PASSWORD); await userPage.getByRole('button', { name: /se connecter/i }).click(); // Should redirect to dashboard (successful login) await expect(userPage).toHaveURL(/\/dashboard/, { timeout: 30000 }); } finally { await userContext.close().catch(() => {}); } }); // ============================================================================ // AC4: Admin cannot block themselves // ============================================================================ test('[P1] admin cannot block themselves from users page', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/users`); await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); // Search for the admin user (pagination may hide them beyond page 1) const searchInput = page.locator('input[type="search"]'); await searchInput.fill(ADMIN_EMAIL); await page.waitForTimeout(500); await page.waitForLoadState('networkidle'); // Find the admin's own row const adminRow = page.locator('tr', { has: page.locator(`text=${ADMIN_EMAIL}`) }); await expect(adminRow).toBeVisible(); // "Bloquer" button should NOT be present on the admin's own row await expect(adminRow.getByRole('button', { name: /^bloquer$/i })).not.toBeVisible(); }); });