/** * E2E tests — Playlist Edit (/playlists/:id/edit + edit dialog) * Covers: redirect, edit dialog, security, a11y, i18n, regression */ import { test, expect } from '@playwright/test'; import { loginViaAPI, CONFIG, navigateTo } from './helpers'; test.describe('Edition playlist (/playlists/:id/edit)', () => { test.beforeEach(async ({ page }) => { await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); }); test.describe('Chargement & Rendu', () => { test('redirect /edit preserves the playlist ID @critical', async ({ page }) => { // First find a valid playlist await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; const playlistId = href.replace('/playlists/', ''); // Navigate to /edit URL await page.goto(`${CONFIG.baseURL}${href}/edit`, { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle').catch(() => {}); // Wait for redirect await page.waitForTimeout(2_000); // URL should be the detail page, NOT literal /:id const url = page.url(); expect(url).toContain(playlistId); expect(url).not.toContain(':id'); // Page should show the playlist, not "Not Found" const notFound = page.getByText('Playlist Not Found'); const isNotFound = await notFound.isVisible().catch(() => false); expect(isNotFound).toBe(false); }); test('redirect /edit does not crash on invalid ID', async ({ page }) => { await page.goto(`${CONFIG.baseURL}/playlists/00000000-0000-0000-0000-000000000000/edit`, { waitUntil: 'domcontentloaded', }); await page.waitForLoadState('networkidle').catch(() => {}); await page.waitForTimeout(2_000); // Should redirect to the detail page (which shows Not Found) const url = page.url(); expect(url).toContain('00000000-0000-0000-0000-000000000000'); expect(url).not.toContain('/edit'); }); }); test.describe('Fonctionnalites', () => { test('edit button not visible for non-owner', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); // Find a playlist not owned by this user const playlistLink = page.locator('a[href^="/playlists/"]').filter({ has: page.locator('text=Curated by'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await navigateTo(page, href); // Edit button should NOT be visible (non-owner) const editButton = page.getByRole('button', { name: /edit playlist/i }); const isVisible = await editButton.isVisible({ timeout: 3_000 }).catch(() => false); expect(isVisible).toBe(false); }); test('owner can see and open edit dialog', async ({ page }) => { // Navigate to playlists list to find user's own playlist await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); // Look for a playlist without "Curated by" (owned by current user) const ownPlaylist = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('text=Curated by'), }).filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await ownPlaylist.getAttribute('href').catch(() => null); if (!href) { // No own playlist found, skip test test.skip(); return; } await navigateTo(page, href); // Edit button should be visible (owner) const editButton = page.getByRole('button', { name: /edit playlist/i }); const isVisible = await editButton.isVisible({ timeout: 5_000 }).catch(() => false); if (!isVisible) { // Permissions may not show edit for this playlist type return; } // Click edit button to open dialog await editButton.click(); // Dialog should open with title input pre-filled const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 3_000 }); }); }); test.describe('Securite', () => { test('XSS in edit redirect ID is handled safely', async ({ page }) => { const jsErrors: string[] = []; page.on('pageerror', (error) => { jsErrors.push(error.message); }); await page.goto( `${CONFIG.baseURL}/playlists/%3Cscript%3Ealert(1)%3C%2Fscript%3E/edit`, { waitUntil: 'domcontentloaded' }, ); await page.waitForLoadState('networkidle').catch(() => {}); await page.waitForTimeout(2_000); // Should not execute XSS const xssErrors = jsErrors.filter((e) => e.includes('alert')); expect(xssErrors).toHaveLength(0); }); test('no sensitive data in redirect URL', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await page.goto(`${CONFIG.baseURL}${href}/edit`, { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle').catch(() => {}); await page.waitForTimeout(2_000); const url = page.url(); expect(url).not.toMatch(/token=/i); expect(url).not.toMatch(/@.*\./); expect(url).not.toMatch(/api[_-]?key/i); }); }); test.describe('Accessibilite', () => { test('edit dialog has accessible title when opened', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const ownPlaylist = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('text=Curated by'), }).filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await ownPlaylist.getAttribute('href').catch(() => null); if (!href) { test.skip(); return; } await navigateTo(page, href); const editButton = page.getByRole('button', { name: /edit playlist/i }); const isVisible = await editButton.isVisible({ timeout: 5_000 }).catch(() => false); if (!isVisible) return; await editButton.click(); const dialog = page.getByRole('dialog'); const isDialogVisible = await dialog.isVisible({ timeout: 3_000 }).catch(() => false); if (!isDialogVisible) return; // Dialog should have a heading/title const dialogTitle = dialog.locator('h2, [role="heading"]'); const titleCount = await dialogTitle.count(); expect(titleCount).toBeGreaterThan(0); }); }); test.describe('i18n', () => { test('no raw i18n keys in edit dialog', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const ownPlaylist = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('text=Curated by'), }).filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await ownPlaylist.getAttribute('href').catch(() => null); if (!href) { test.skip(); return; } await navigateTo(page, href); const editButton = page.getByRole('button', { name: /edit playlist/i }); const isVisible = await editButton.isVisible({ timeout: 5_000 }).catch(() => false); if (!isVisible) return; await editButton.click(); const dialog = page.getByRole('dialog'); const isDialogVisible = await dialog.isVisible({ timeout: 3_000 }).catch(() => false); if (!isDialogVisible) return; const dialogText = await dialog.textContent() || ''; // No raw i18n keys should be visible expect(dialogText).not.toMatch(/playlists\.editDialog\./); expect(dialogText).not.toMatch(/playlists\.actions\./); expect(dialogText).not.toMatch(/playlists\.form\./); }); }); test.describe('Regression', () => { test('BUG #1: /edit redirect uses actual playlist ID, not literal :id', async ({ page }) => { await navigateTo(page, '/playlists'); await page.waitForLoadState('networkidle').catch(() => {}); const playlistLink = page.locator('a[href^="/playlists/"]').filter({ hasNot: page.locator('[href="/playlists/favoris"]'), }).first(); const href = await playlistLink.getAttribute('href').catch(() => null); if (!href) return; await page.goto(`${CONFIG.baseURL}${href}/edit`, { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle').catch(() => {}); await page.waitForTimeout(2_000); // CRITICAL: URL must NOT be "/playlists/:id" const url = page.url(); expect(url).not.toBe(`${CONFIG.baseURL}/playlists/:id`); expect(url).not.toContain('/:id'); // Should be the actual playlist detail URL expect(url).toContain('/playlists/'); expect(url).not.toContain('/edit'); }); }); });