diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..90d2772 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +test-results/ +playwright-report/ +blob-report/ diff --git a/e2e/docker-compose.test.yml b/e2e/docker-compose.test.yml new file mode 100644 index 0000000..f419676 --- /dev/null +++ b/e2e/docker-compose.test.yml @@ -0,0 +1,56 @@ +services: + app: + build: + context: ../backend + dockerfile: Dockerfile + container_name: ai-synth-test + restart: "no" + environment: + DATABASE_URL: postgres://ai_synth_test:testpassword@db:5432/ai_synth_test + MASTER_ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000001" + SESSION_SECRET: "e2e-test-session-secret-at-least-64-characters-long-for-security-purposes-here" + APP_URL: http://localhost:8080 + PORT: "8080" + RUST_LOG: "info,ai_synth_backend=debug" + RESEND_API_KEY: "re_test_bypass_no_send" + EMAIL_FROM: "AI Weekly Synth " + TURNSTILE_SECRET_KEY: "test-turnstile-secret-always-pass" + TURNSTILE_SITE_KEY: "1x00000000000000000000AA" + STATIC_DIR: "./static" + depends_on: + db: + condition: service_healthy + networks: + - test-net + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health"] + interval: 10s + timeout: 5s + start_period: 30s + retries: 5 + + db: + image: postgres:17-alpine + container_name: ai-synth-test-db + restart: "no" + environment: + POSTGRES_USER: ai_synth_test + POSTGRES_PASSWORD: testpassword + POSTGRES_DB: ai_synth_test + networks: + - test-net + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ai_synth_test -d ai_synth_test"] + interval: 5s + timeout: 3s + start_period: 5s + retries: 5 + shm_size: 128mb + +networks: + test-net: + driver: bridge diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..b90214b --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,145 @@ +/** + * Authentication helpers for E2E tests. + * + * Provides functions to inject session cookies and to perform a full + * registration-and-verify flow through the UI + direct DB access. + */ + +import type { Page } from '@playwright/test'; +import pg from 'pg'; +import { createHash } from 'node:crypto'; + +const { Client } = pg; + +// --------------------------------------------------------------------------- +// Known test tokens — must match seed.ts +// --------------------------------------------------------------------------- +export const ADMIN_SESSION_TOKEN = 'e2e-admin-session-token-for-testing-only'; +export const USER_SESSION_TOKEN = 'e2e-user-session-token-for-testing-only'; +export const SESSION_COOKIE_NAME = 'ai_synth_session'; + +// --------------------------------------------------------------------------- +// Cookie injection helpers +// --------------------------------------------------------------------------- + +/** + * Set the session cookie for the admin test user. + * The page must already be navigated to (or about to navigate to) + * the app domain so the cookie is scoped correctly. + */ +export async function loginAsAdmin(page: Page): Promise { + await page.context().addCookies([ + { + name: SESSION_COOKIE_NAME, + value: ADMIN_SESSION_TOKEN, + domain: 'localhost', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ]); +} + +/** + * Set the session cookie for the regular test user. + */ +export async function loginAsUser(page: Page): Promise { + await page.context().addCookies([ + { + name: SESSION_COOKIE_NAME, + value: USER_SESSION_TOKEN, + domain: 'localhost', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ]); +} + +/** + * Perform a full registration flow for a new email address: + * 1. Navigate to /register + * 2. Fill out the form and submit + * 3. Extract the raw magic link token from the DB + * 4. Navigate to the verify URL with that token + * + * This works because: + * - Turnstile is in test mode (auto-passes with the test site key) + * - Resend is in test mode (no email actually sent) + * - We read the token hash from magic_tokens and reverse-lookup is not needed + * because we query by email and find the most recent unused token + * + * NOTE: We cannot reverse-lookup the raw token from its hash. Instead, + * we query the magic_tokens table for the most recent token_hash for + * this email and use the backend's GET /api/v1/auth/verify endpoint + * which sets the cookie via redirect. But since we only have the hash, + * we instead need a workaround: we query the DB for the token_hash, + * then use the POST verify endpoint. + * + * Actually, the cleanest approach is to create a magic link via the + * register API, then query the DB for the token_hash. Since we cannot + * reverse the hash, we insert a known token ourselves after registration. + */ +export async function registerAndVerify( + page: Page, + email: string, +): Promise { + const databaseUrl = + process.env.DATABASE_URL ?? + 'postgres://ai_synth_test:testpassword@localhost:5433/ai_synth_test'; + + // Navigate to register page and fill the form + await page.goto('/register'); + await page.locator('#email').fill(email); + + // Wait for Turnstile to auto-verify (test key always passes) + // The Turnstile widget will call the callback automatically with the test site key. + // We need to wait a moment for the async script to load and render. + await page.waitForTimeout(2000); + + // Submit the form + await page.locator('button[type="submit"]').click(); + + // Wait for the "check inbox" message to appear (means registration succeeded) + await page.waitForSelector('text=Verifiez votre boite de reception', { + timeout: 10_000, + }); + + // Now extract the magic token from the database. + // The token_hash is stored, but we don't have the raw token. + // We'll insert a known magic token for this email directly. + const knownRawToken = `e2e-magic-token-${Date.now()}`; + const knownTokenHash = createHash('sha256') + .update(knownRawToken) + .digest('hex'); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); + + const client = new Client({ connectionString: databaseUrl }); + await client.connect(); + try { + await client.query( + `INSERT INTO magic_tokens (email, token_hash, expires_at, used) + VALUES ($1, $2, $3, false)`, + [email.toLowerCase(), knownTokenHash, expiresAt], + ); + } finally { + await client.end(); + } + + // Navigate to the verify URL (GET endpoint that sets cookie and redirects) + await page.goto(`/api/v1/auth/verify?token=${knownRawToken}`); + + // The GET verify endpoint redirects to APP_URL (http://localhost:8080) + // Wait for the redirect to complete and the home page to load + await page.waitForURL('**/', { timeout: 10_000 }); +} + +/** + * Connect directly to the test database and return a pg Client. + */ +export function createDbClient(): InstanceType { + const databaseUrl = + process.env.DATABASE_URL ?? + 'postgres://ai_synth_test:testpassword@localhost:5433/ai_synth_test'; + return new Client({ connectionString: databaseUrl }); +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..d830792 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,810 @@ +{ + "name": "ai-synth-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-synth-e2e", + "devDependencies": { + "@playwright/test": "^1.50.0", + "@types/pg": "^8.11.0", + "pg": "^8.13.0", + "tsx": "^4.21.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "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/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..bc537d0 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "ai-synth-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "seed": "tsx seed.ts" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "pg": "^8.13.0", + "tsx": "^4.21.0", + "@types/pg": "^8.11.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..368ff79 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from '@playwright/test'; + +/** + * Playwright configuration for AI Weekly Synth E2E tests. + * + * Tests run against the Docker-composed stack on http://localhost:8080. + * A single worker is used to avoid parallel DB state mutations. + */ +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + retries: 1, + workers: 1, + + use: { + baseURL: 'http://localhost:8080', + screenshot: 'only-on-failure', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { + browserName: 'chromium', + }, + }, + ], +}); diff --git a/e2e/seed.ts b/e2e/seed.ts new file mode 100644 index 0000000..ec9d592 --- /dev/null +++ b/e2e/seed.ts @@ -0,0 +1,102 @@ +/** + * E2E test database seeder. + * + * Creates known test users and sessions in the test Postgres database. + * Designed to be idempotent: uses INSERT ... ON CONFLICT DO NOTHING. + * + * Usage: npx tsx seed.ts + */ + +import pg from 'pg'; +import { createHash } from 'node:crypto'; + +const { Client } = pg; + +// --------------------------------------------------------------------------- +// Known test tokens (raw values that go into cookies / URLs). +// Their SHA-256 hashes are stored in the DB. +// --------------------------------------------------------------------------- +export const ADMIN_SESSION_TOKEN = 'e2e-admin-session-token-for-testing-only'; +export const USER_SESSION_TOKEN = 'e2e-user-session-token-for-testing-only'; + +function sha256(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +async function seed() { + const databaseUrl = + process.env.DATABASE_URL ?? + 'postgres://ai_synth_test:testpassword@localhost:5433/ai_synth_test'; + + const client = new Client({ connectionString: databaseUrl }); + await client.connect(); + + try { + // ----------------------------------------------------------------------- + // 1. Create admin user + // ----------------------------------------------------------------------- + const adminId = '00000000-0000-4000-a000-000000000001'; + await client.query( + `INSERT INTO users (id, email, display_name, role, created_at, updated_at) + VALUES ($1, 'admin@test.local', 'Test Admin', 'admin', now(), now()) + ON CONFLICT (id) DO NOTHING`, + [adminId], + ); + + // ----------------------------------------------------------------------- + // 2. Create regular user + // ----------------------------------------------------------------------- + const userId = '00000000-0000-4000-a000-000000000002'; + await client.query( + `INSERT INTO users (id, email, display_name, role, created_at, updated_at) + VALUES ($1, 'user@test.local', 'Test User', 'user', now(), now()) + ON CONFLICT (id) DO NOTHING`, + [userId], + ); + + // ----------------------------------------------------------------------- + // 3. Create sessions (hash tokens with SHA-256 before inserting) + // ----------------------------------------------------------------------- + const adminSessionHash = sha256(ADMIN_SESSION_TOKEN); + const userSessionHash = sha256(USER_SESSION_TOKEN); + + const expiresAt = new Date( + Date.now() + 30 * 24 * 60 * 60 * 1000, + ).toISOString(); // 30 days + + await client.query( + `INSERT INTO sessions (session_hash, user_id, created_at, expires_at, last_active_at) + VALUES ($1, $2, now(), $3, now()) + ON CONFLICT (session_hash) DO NOTHING`, + [adminSessionHash, adminId, expiresAt], + ); + + await client.query( + `INSERT INTO sessions (session_hash, user_id, created_at, expires_at, last_active_at) + VALUES ($1, $2, now(), $3, now()) + ON CONFLICT (session_hash) DO NOTHING`, + [userSessionHash, userId, expiresAt], + ); + + // ----------------------------------------------------------------------- + // 4. Ensure Gemini provider exists and is enabled + // (The migration seeds it, but make sure is_enabled = true) + // ----------------------------------------------------------------------- + await client.query( + `UPDATE admin_providers SET is_enabled = true WHERE provider_name = 'gemini'`, + ); + + console.log('Seed completed successfully.'); + console.log(` Admin: admin@test.local (id=${adminId})`); + console.log(` User: user@test.local (id=${userId})`); + console.log(` Admin session hash: ${adminSessionHash.slice(0, 16)}...`); + console.log(` User session hash: ${userSessionHash.slice(0, 16)}...`); + } finally { + await client.end(); + } +} + +seed().catch((err) => { + console.error('Seed failed:', err); + process.exit(1); +}); diff --git a/e2e/tests/admin-providers.spec.ts b/e2e/tests/admin-providers.spec.ts new file mode 100644 index 0000000..c93fc73 --- /dev/null +++ b/e2e/tests/admin-providers.spec.ts @@ -0,0 +1,83 @@ +/** + * E2E test: Admin providers management. + * + * Validates that an admin user can: + * 1. Navigate to /admin/providers + * 2. See the Gemini provider card + * 3. Toggle enable if needed + * 4. Save changes + * 5. Verify the provider appears in the settings dropdown + */ + +import { test, expect } from '@playwright/test'; +import { loginAsAdmin } from '../helpers/auth'; + +test.describe('Admin providers', () => { + test('should see Gemini provider and verify it appears in settings', async ({ + page, + }) => { + // Step 1: Login as admin via cookie injection + await loginAsAdmin(page); + + // Step 2: Navigate to admin providers page + await page.goto('/admin/providers'); + + // Step 3: Wait for the page to load and verify the Gemini provider card is visible + await expect( + page.locator('h2', { hasText: 'Google Gemini' }), + ).toBeVisible({ timeout: 10_000 }); + + // Step 4: Find the Gemini card. Check if the enable toggle shows "Active" + const geminiCard = page + .locator('.bg-white.shadow-sm.border') + .filter({ hasText: 'Google Gemini' }); + await expect(geminiCard).toBeVisible(); + + // Check the toggle state. If it shows "Desactive", click it to enable. + const statusText = geminiCard.locator('span', { hasText: /Active|Desactive/ }); + const currentStatus = await statusText.textContent(); + + if (currentStatus?.trim() === 'Desactive') { + // Click the toggle switch (role="switch") within the Gemini card + await geminiCard.locator('button[role="switch"]').click(); + // Verify it now shows "Active" + await expect( + geminiCard.locator('span', { hasText: 'Active' }), + ).toBeVisible(); + } + + // Step 5: Click save on the Gemini card + const saveButton = geminiCard.locator('button', { + hasText: 'Enregistrer', + }); + await saveButton.click(); + + // Wait for save confirmation (toast or page update) + // The save triggers a toast; just wait briefly for the API call + await page.waitForTimeout(1000); + + // Step 6: Navigate to settings to verify the provider appears in dropdown + await page.goto('/settings'); + + // Wait for settings page to load + await expect( + page.locator('h1', { hasText: 'Parametres de generation' }), + ).toBeVisible({ timeout: 10_000 }); + + // The provider should appear in the model dropdown. + // When there's a single enabled provider, it's auto-selected and the + // provider dropdown may be hidden. Check for the model dropdown instead. + // The research model dropdown (#aiModel) should have Gemini model options. + const modelDropdown = page.locator('#aiModel'); + await expect(modelDropdown).toBeVisible({ timeout: 5_000 }); + + // Verify Gemini models are available in the dropdown + const options = modelDropdown.locator('option'); + const optionCount = await options.count(); + expect(optionCount).toBeGreaterThan(0); + + // Check that at least one option mentions a Gemini model + const allText = await modelDropdown.textContent(); + expect(allText).toContain('Gemini'); + }); +}); diff --git a/e2e/tests/registration.spec.ts b/e2e/tests/registration.spec.ts new file mode 100644 index 0000000..2ca8eba --- /dev/null +++ b/e2e/tests/registration.spec.ts @@ -0,0 +1,78 @@ +/** + * E2E test: Registration flow. + * + * Validates the full magic-link registration process: + * 1. Navigate to /register + * 2. Fill and submit the form (Turnstile auto-passes with test site key) + * 3. Extract magic link token from DB + * 4. Navigate to verify URL + * 5. Assert redirect to home with user email visible + */ + +import { test, expect } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { createDbClient } from '../helpers/auth'; + +test.describe('Registration flow', () => { + test('should register a new user and verify via magic link', async ({ + page, + }) => { + const uniqueEmail = `e2e-reg-${Date.now()}@test.local`; + + // Step 1: Navigate to the registration page + await page.goto('/register'); + + // Verify we see the registration form + await expect( + page.locator('h2', { hasText: 'Creer un compte' }), + ).toBeVisible(); + + // Step 2: Fill in the email field + await page.locator('#email').fill(uniqueEmail); + + // Optionally fill display name + await page.locator('#displayName').fill('E2E Test User'); + + // Wait for Turnstile widget to auto-verify with the test site key. + // The Cloudflare test key (1x00000000000000000000AA) always passes immediately. + await page.waitForTimeout(3000); + + // Step 3: Submit the registration form + await page.locator('button[type="submit"]').click(); + + // Wait for success message (form shows "check inbox" screen) + await expect( + page.getByText('Verifiez votre boite de reception'), + ).toBeVisible({ timeout: 10_000 }); + + // Step 4: Create a known magic token in the DB for verification + const knownRawToken = `e2e-reg-token-${Date.now()}`; + const knownTokenHash = createHash('sha256') + .update(knownRawToken) + .digest('hex'); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); + + const client = createDbClient(); + await client.connect(); + try { + await client.query( + `INSERT INTO magic_tokens (email, token_hash, expires_at, used) + VALUES ($1, $2, $3, false)`, + [uniqueEmail.toLowerCase(), knownTokenHash, expiresAt], + ); + } finally { + await client.end(); + } + + // Step 5: Navigate to the GET verify endpoint with the raw token. + // This endpoint verifies the token, creates a session, sets the cookie, + // and redirects to APP_URL. + await page.goto(`/api/v1/auth/verify?token=${knownRawToken}`); + + // The verify endpoint redirects to the app URL. Wait for the home page. + await page.waitForURL('**/', { timeout: 15_000 }); + + // Step 6: Assert the user email is visible in the navbar + await expect(page.getByText(uniqueEmail)).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/e2e/tests/settings-export.spec.ts b/e2e/tests/settings-export.spec.ts new file mode 100644 index 0000000..ac409b7 --- /dev/null +++ b/e2e/tests/settings-export.spec.ts @@ -0,0 +1,97 @@ +/** + * E2E test: Settings export/import flow. + * + * Validates: + * 1. Set theme to "Export Test" and save + * 2. Export settings (triggers download) + * 3. Change theme to "Changed" and save + * 4. Import the previously exported file + * 5. Assert theme shows "Export Test" again + */ + +import { test, expect } from '@playwright/test'; +import { loginAsUser } from '../helpers/auth'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +test.describe('Settings export/import', () => { + test('should export and re-import settings correctly', async ({ page }) => { + // Step 1: Login as regular user via cookie injection + await loginAsUser(page); + + // Step 2: Navigate to settings + await page.goto('/settings'); + + // Wait for the settings page to load + await expect( + page.locator('h1', { hasText: 'Parametres de generation' }), + ).toBeVisible({ timeout: 10_000 }); + + // Step 3: Set theme to "Export Test" + const themeInput = page.locator('#theme'); + await themeInput.fill('Export Test'); + + // Save + await page + .locator('button', { hasText: 'Enregistrer les parametres' }) + .click(); + + // Wait for save confirmation + await expect( + page.getByText('Parametres enregistres avec succes'), + ).toBeVisible({ timeout: 5_000 }); + + // Step 4: Click the export button and capture the download + const downloadPromise = page.waitForEvent('download'); + + await page.locator('button', { hasText: 'Exporter' }).first().click(); + + const download = await downloadPromise; + + // Save the downloaded file to a temp location + const downloadPath = path.join( + '/tmp', + `e2e-settings-export-${Date.now()}.json`, + ); + await download.saveAs(downloadPath); + + // Verify the downloaded file contains our theme + const fileContent = fs.readFileSync(downloadPath, 'utf-8'); + const parsed = JSON.parse(fileContent); + expect(parsed.theme).toBe('Export Test'); + + // Step 5: Change theme to "Changed" and save + await themeInput.fill('Changed'); + await page + .locator('button', { hasText: 'Enregistrer les parametres' }) + .click(); + + await expect( + page.getByText('Parametres enregistres avec succes'), + ).toBeVisible({ timeout: 5_000 }); + + // Verify it changed + await expect(themeInput).toHaveValue('Changed'); + + // Step 6: Import the previously downloaded file + // The import button triggers a hidden file input. We need to set the file + // on the hidden input element directly. + const fileInput = page.locator('input[type="file"][accept=".json"]'); + await fileInput.setInputFiles(downloadPath); + + // Wait for import success message + await expect( + page.getByText("Configuration importee avec succes"), + ).toBeVisible({ timeout: 5_000 }); + + // Step 7: Assert the theme input shows "Export Test" again + await expect(themeInput).toHaveValue('Export Test'); + + // Clean up temp file + try { + fs.unlinkSync(downloadPath); + } catch { + // Ignore cleanup errors + } + }); +}); diff --git a/e2e/tests/settings.spec.ts b/e2e/tests/settings.spec.ts new file mode 100644 index 0000000..7b49a79 --- /dev/null +++ b/e2e/tests/settings.spec.ts @@ -0,0 +1,62 @@ +/** + * E2E test: User settings persistence. + * + * Validates that a user can: + * 1. Navigate to /settings + * 2. Change the theme input + * 3. Save + * 4. Reload the page + * 5. Assert the theme value persisted + */ + +import { test, expect } from '@playwright/test'; +import { loginAsUser } from '../helpers/auth'; + +test.describe('Settings', () => { + test('should save and persist theme setting across page reloads', async ({ + page, + }) => { + // Step 1: Login as regular user via cookie injection + await loginAsUser(page); + + // Step 2: Navigate to settings + await page.goto('/settings'); + + // Wait for the settings page to load + await expect( + page.locator('h1', { hasText: 'Parametres de generation' }), + ).toBeVisible({ timeout: 10_000 }); + + // Step 3: Change the theme input to "Cybersecurite" + const themeInput = page.locator('#theme'); + await expect(themeInput).toBeVisible(); + + // Clear existing value and type new one + await themeInput.fill('Cybersecurite'); + + // Verify the input has the new value + await expect(themeInput).toHaveValue('Cybersecurite'); + + // Step 4: Click the save button + const saveButton = page.locator('button', { + hasText: 'Enregistrer les parametres', + }); + await saveButton.click(); + + // Wait for save confirmation message + await expect( + page.getByText('Parametres enregistres avec succes'), + ).toBeVisible({ timeout: 5_000 }); + + // Step 5: Reload the page + await page.reload(); + + // Wait for the settings page to reload + await expect( + page.locator('h1', { hasText: 'Parametres de generation' }), + ).toBeVisible({ timeout: 10_000 }); + + // Step 6: Assert the theme input value is "Cybersecurite" + await expect(page.locator('#theme')).toHaveValue('Cybersecurite'); + }); +}); diff --git a/e2e/tests/sources.spec.ts b/e2e/tests/sources.spec.ts new file mode 100644 index 0000000..8af3c02 --- /dev/null +++ b/e2e/tests/sources.spec.ts @@ -0,0 +1,87 @@ +/** + * E2E test: Sources CRUD. + * + * Validates: + * 1. Adding a single source + * 2. Bulk-importing a source via textarea + * 3. Deleting a source (two-click confirm) + */ + +import { test, expect } from '@playwright/test'; +import { loginAsUser } from '../helpers/auth'; + +test.describe('Sources management', () => { + test('should add, bulk-import, and delete sources', async ({ page }) => { + // Step 1: Login as regular user via cookie injection + await loginAsUser(page); + + // Step 2: Navigate to sources + await page.goto('/sources'); + + // Wait for the sources page to load + await expect( + page.locator('h1', { hasText: 'Sources Personnalisees' }), + ).toBeVisible({ timeout: 10_000 }); + + // Step 3: Add a single source + await page.locator('#source-title').fill('Test Blog'); + await page.locator('#source-url').fill('https://test.example.com/blog'); + + // Click the "Ajouter" submit button + await page.locator('button[type="submit"]', { hasText: 'Ajouter' }).click(); + + // Wait for the source to appear in the list + await expect(page.getByText('Test Blog')).toBeVisible({ timeout: 5_000 }); + await expect( + page.getByText('https://test.example.com/blog'), + ).toBeVisible(); + + // Step 4: Bulk import another source via textarea + const bulkTextarea = page.locator('#bulk-import'); + await bulkTextarea.fill('News Site;https://news.example.com'); + + // Click the bulk import button + await page + .locator('button[type="submit"]', { hasText: 'Importer les sources' }) + .click(); + + // Wait for the second source to appear + await expect(page.getByText('News Site')).toBeVisible({ timeout: 5_000 }); + + // Verify we have 2 sources in the list + // Each source is an
  • in the sources list + const sourceItems = page.locator('ul > li').filter({ + has: page.locator('.text-indigo-600'), + }); + await expect(sourceItems).toHaveCount(2); + + // Step 5: Delete the first source (Test Blog) + // Find the source item containing "Test Blog" and click its delete button + const testBlogItem = page.locator('li').filter({ hasText: 'Test Blog' }); + + // First click: enter confirm state + await testBlogItem.locator('button').last().click(); + + // The button text should change to "Confirmer ?" + await expect( + testBlogItem.getByText('Confirmer ?'), + ).toBeVisible({ timeout: 3_000 }); + + // Second click: confirm delete + await testBlogItem.getByText('Confirmer ?').click(); + + // Wait for the source to be removed from the list + await expect(page.getByText('Test Blog')).not.toBeVisible({ + timeout: 5_000, + }); + + // Step 6: Verify only 1 source remains + const remainingItems = page.locator('ul > li').filter({ + has: page.locator('.text-indigo-600'), + }); + await expect(remainingItems).toHaveCount(1); + + // The remaining source should be "News Site" + await expect(page.getByText('News Site')).toBeVisible(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..b045ec9 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "." + }, + "include": ["*.ts", "tests/**/*.ts", "helpers/**/*.ts"] +}