253 lines
9.3 KiB
TypeScript
253 lines
9.3 KiB
TypeScript
|
|
/**
|
||
|
|
* 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');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|