import { randomUUID } from 'node:crypto'; import { encode } from '@auth/core/jwt'; import { test as base, type Page } from '@playwright/test'; import { Pool } from 'pg'; import { generateDeviceToken, hashToken } from '../lib/device/auth'; import { E2E_AUTH_SECRET } from './constants'; /** * Database access for the end-to-end suite, over plain SQL. * * Deliberately not the Prisma client: it is generated as a TypeScript module * whose format Playwright's loader and Next's bundler disagree about, and the * suite only needs to empty a handful of tables and read a few rows back. * Raw SQL costs a few lines and removes the argument entirely. */ let pool: Pool | undefined; function db(): Pool { pool ??= new Pool({ connectionString: process.env.TEST_DATABASE_URL }); return pool; } export async function query>( text: string, values: unknown[] = [], ): Promise { const result = await db().query(text, values); return result.rows as T[]; } /** * Signing in without Authentik. * * Mints the session cookie Auth.js would have issued after a successful round * trip. What is under test is the application's behaviour for a given role; * the OIDC handshake is verified against the live provider separately, and * standing up an identity provider per test run would trade a lot of * machinery for coverage of somebody else's code. */ const COOKIE_NAME = 'authjs.session-token'; export type Role = 'admin' | 'viewer'; export async function signIn(page: Page, role: Role = 'admin'): Promise { const token = await encode({ secret: E2E_AUTH_SECRET, // Auth.js derives its encryption key from the cookie name. salt: COOKIE_NAME, maxAge: 3600, token: { sub: `e2e-${role}`, name: role === 'admin' ? 'Admin E2E' : 'Viewer E2E', email: `${role}@ita-ito.test`, role, groups: role === 'admin' ? ['horaires-admins'] : ['autre-groupe'], }, }); await page.context().addCookies([ { name: COOKIE_NAME, value: token, domain: '127.0.0.1', path: '/', httpOnly: true }, ]); } const WEEK: [number, boolean, string][] = [ [0, true, '[]'], [1, true, '[]'], [2, false, '[{"open":"10:00","close":"18:30"}]'], [3, false, '[{"open":"10:00","close":"18:30"}]'], [4, false, '[{"open":"10:00","close":"18:30"}]'], [5, false, '[{"open":"10:00","close":"18:30"}]'], [6, false, '[{"open":"10:00","close":"18:30"}]'], ]; /** Empties everything and restores a known week. */ export async function resetDatabase(): Promise { await query(` TRUNCATE device_logs, devices, screen_images, schedule_exceptions, vacation_periods, public_holidays, messages, translation_cache, audit_logs, weekly_schedules, settings, sync_states RESTART IDENTITY CASCADE `); await query(`INSERT INTO settings (id, "updatedAt") VALUES ('singleton', now())`); for (const [dayOfWeek, isClosed, slots] of WEEK) { await query( `INSERT INTO weekly_schedules (id, "dayOfWeek", "isClosed", slots) VALUES ($1, $2, $3, $4::jsonb)`, [randomUUID(), dayOfWeek, isClosed, slots], ); } } /** Pairs a panel and hands back its token, so the device API can be called. */ export async function pairDevice(): Promise { const token = generateDeviceToken(); await query( `INSERT INTO devices (id, "macAddress", "friendlyId", "apiKeyHash", "updatedAt") VALUES ($1, $2, $3, $4, now())`, [randomUUID(), 'E2:E0:00:00:00:01', 'E2E001', hashToken(token)], ); return token; } /** Today as the shop reads it, which is not necessarily as the runner does. */ export function today(offsetDays = 0): string { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Zurich' }).format( new Date(Date.now() + offsetDays * 86_400_000), ); } export const test = base; export { expect } from '@playwright/test';