diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..40285b6 --- /dev/null +++ b/.env.test @@ -0,0 +1,21 @@ +# E2E test environment — used by the dev server during Playwright runs. +# Never touches trips.db. + +AUTH_URL=http://127.0.0.1:5173/trips/auth +AUTH_SECRET=e2e-test-secret-do-not-use-in-production-32b + +LOCAL_AUTH_ENABLED=true +LOCAL_AUTH_ARGON2_MEMORY_KB=8192 +LOCAL_AUTH_ARGON2_TIME_COST=2 +LOCAL_AUTH_ARGON2_PARALLELISM=1 + +# e2e_admin username is matched by isAdminUser() via DB lookup +ADMIN_USER_IDS=e2e_admin + +# Separate test database — never touches trips.db +DATABASE_URL=file:trips.test.db + +# Synology OIDC — not exercised in E2E tests but must be present to avoid startup errors +SYNOLOGY_ISSUER=https://cloud.campbellwireless.net/auth/webman/sso +SYNOLOGY_CLIENT_ID=e2e-placeholder +SYNOLOGY_CLIENT_SECRET=e2e-placeholder diff --git a/AGENTS.md b/AGENTS.md index 5093b9f..f6633a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,14 @@ - Framework: Vitest with `node` environment. - Test file pattern: `src/**/*.test.ts`. - Keep tests near the code they cover; use `src/tests/stubs` for runtime stubbing. +- For every user-facing feature or behavior change, add/update Playwright e2e coverage under `e2e/`. +- When work is tied to a Gitea issue, record an e2e run video and upload it to that issue. + - One-off video run: `PW_VIDEO_MODE=on PW_TRACE_MODE=on bunx playwright test ` + - Prefer comment-level attachments: post a comment first, then upload the video to that comment. + - Create comment: `tea comment -l -r / ""` + - Upload with `curl` (example, `tea api` in this repo's toolchain does not send multipart correctly): + - `TOKEN=$(awk '/- name: cloud.campbellwireless.net/{f=1} f && $1=="token:"{print $2; exit}' "$HOME/Library/Application Support/tea/config.yml")` + - `curl -fsS -X POST "https://cloud.campbellwireless.net/git/api/v1/repos/{owner}/{repo}/issues/comments/{comment_id}/assets" -H "Authorization: token $TOKEN" -F "name=" -F "attachment=@"` ## Commit & Pull Request Guidelines - Commit messages currently follow a light “scope) message” pattern, e.g., diff --git a/CLAUDE.md b/CLAUDE.md index 05baf03..264a904 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,18 @@ After making changes, always verify: 1. `bun run lint` — must exit with 0 errors (warnings are acceptable) 2. `bun run test` — all tests must pass +For user-facing feature work, also add/update e2e coverage and validate it: +3. `bunx playwright test ` + +When work maps to a Gitea issue, upload an e2e run video to the issue: +- Generate one-off video artifacts with: + - `PW_VIDEO_MODE=on PW_TRACE_MODE=on bunx playwright test ` +- Prefer comment-level attachments: create a comment first, then attach video to that comment. + - `tea comment -l -r / ""` +- Upload with `curl` (the current `tea api` build here does not send multipart/form-data correctly for attachments): + - `TOKEN=$(awk '/- name: cloud.campbellwireless.net/{f=1} f && $1=="token:"{print $2; exit}' "$HOME/Library/Application Support/tea/config.yml")` + - `curl -fsS -X POST "https://cloud.campbellwireless.net/git/api/v1/repos/{owner}/{repo}/issues/comments/{comment_id}/assets" -H "Authorization: token $TOKEN" -F "name=" -F "attachment=@"` + ### Write unit tests after every major feature When adding or significantly modifying server-side business logic (files under `src/lib/server/`), write corresponding unit tests in a `.test.ts` file alongside the module (e.g. `src/lib/server/lodgings.test.ts`). diff --git a/bun.lock b/bun.lock index 65e4524..4fa3f80 100644 --- a/bun.lock +++ b/bun.lock @@ -11,12 +11,14 @@ "devDependencies": { "@biomejs/biome": "^2.4.4", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.58.2", "@sveltejs/adapter-node": "^5.5.3", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.0", "@vitest/coverage-v8": "^4.0.18", "bun-types": "^1.3.9", + "dotenv": "^17.3.1", "eslint": "^10.0.0", "eslint-plugin-svelte": "^3.15.0", "globals": "^17.3.0", @@ -156,6 +158,8 @@ "@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="], @@ -358,6 +362,8 @@ "devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="], + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], @@ -540,6 +546,10 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], @@ -674,6 +684,8 @@ "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], diff --git a/e2e/auth.test.ts b/e2e/auth.test.ts new file mode 100644 index 0000000..1f3c3d5 --- /dev/null +++ b/e2e/auth.test.ts @@ -0,0 +1,56 @@ +import { test, expect, type Page } from '@playwright/test'; +import { TEST_USERS } from './setup/test-users.js'; + +const LOGIN_URL = '/trips/login'; +const DASHBOARD_URL = '/trips/dashboard'; +const ADMIN_USERS_URL = '/trips/admin/users'; + +async function loginAsLocalUser(page: Page, username: string, password: string): Promise { + await page.goto(LOGIN_URL); + await page.fill('input[name="identifier"]', username); + await page.fill('input[name="password"]', password); + await page.click('button[type="submit"]:has-text("Sign in locally")'); + await page.waitForURL(`**${DASHBOARD_URL}`, { timeout: 15_000 }); +} + +test.beforeEach(async ({ context }) => { + await context.clearCookies(); +}); + +test.describe('regular user', () => { + test('can log in and lands on dashboard', async ({ page }) => { + await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password); + await expect(page).toHaveURL(/\/trips\/dashboard/); + }); + + test('cannot access admin — redirected to dashboard', async ({ page }) => { + await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password); + await page.goto(ADMIN_USERS_URL); + await expect(page).toHaveURL(/\/trips\/dashboard/); + }); +}); + +test.describe('admin user', () => { + test('can log in and lands on dashboard', async ({ page }) => { + await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password); + await expect(page).toHaveURL(/\/trips\/dashboard/); + }); + + test('can access /admin/users', async ({ page }) => { + await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password); + await page.goto(ADMIN_USERS_URL); + await expect(page).toHaveURL(/\/trips\/admin\/users/); + await expect(page.locator('h1')).toContainText('Users'); + }); +}); + +test.describe('error handling', () => { + test('bad password shows error on login page', async ({ page }) => { + await page.goto(LOGIN_URL); + await page.fill('input[name="identifier"]', TEST_USERS.regular.username); + await page.fill('input[name="password"]', 'wrong-password-long-enough'); + await page.click('button[type="submit"]:has-text("Sign in locally")'); + await page.waitForURL(/error=CredentialsSignin/, { timeout: 10_000 }); + await expect(page.locator('.text-rose-700')).toContainText('Invalid credentials'); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts new file mode 100644 index 0000000..56d003e --- /dev/null +++ b/e2e/setup/global-setup.ts @@ -0,0 +1,20 @@ +import type { FullConfig } from '@playwright/test'; +import { spawnSync } from 'child_process'; +import { resolve } from 'path'; +import { config as dotenvConfig } from 'dotenv'; + +dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true }); + +async function globalSetup(_config: FullConfig): Promise { + console.log('[e2e] Seeding test database via Bun...'); + const result = spawnSync('bun', ['run', 'e2e/setup/seed-db.ts'], { + env: { ...process.env }, + stdio: 'inherit', + cwd: process.cwd() + }); + if (result.status !== 0) { + throw new Error(`[e2e] DB seed script failed with exit code ${result.status}`); + } +} + +export default globalSetup; diff --git a/e2e/setup/global-teardown.ts b/e2e/setup/global-teardown.ts new file mode 100644 index 0000000..b360351 --- /dev/null +++ b/e2e/setup/global-teardown.ts @@ -0,0 +1,17 @@ +import type { FullConfig } from '@playwright/test'; +import { existsSync, unlinkSync } from 'fs'; +import { resolve } from 'path'; + +const DB_PATH = resolve(process.cwd(), 'trips.test.db'); + +async function globalTeardown(_config: FullConfig): Promise { + for (const ext of ['', '-shm', '-wal']) { + const p = DB_PATH + ext; + if (existsSync(p)) { + unlinkSync(p); + } + } + console.log('[e2e] Removed trips.test.db'); +} + +export default globalTeardown; diff --git a/e2e/setup/seed-db.ts b/e2e/setup/seed-db.ts new file mode 100644 index 0000000..69244af --- /dev/null +++ b/e2e/setup/seed-db.ts @@ -0,0 +1,77 @@ +// Bun script — runs with `bun run e2e/setup/seed-db.ts`. +// Uses bun:sqlite and argon2 directly; must NOT be imported from Node context. +import { Database as BunSqlite } from 'bun:sqlite'; +import { existsSync, unlinkSync } from 'fs'; +import { resolve } from 'path'; +import argon2 from 'argon2'; +import { randomUUID } from 'crypto'; +import { config as dotenvConfig } from 'dotenv'; +import { TEST_USERS } from './test-users.js'; + +dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true }); + +const DB_PATH = resolve(process.cwd(), 'trips.test.db'); + +// Delete any existing test DB so we start clean each run +for (const ext of ['', '-shm', '-wal']) { + const p = DB_PATH + ext; + if (existsSync(p)) { + unlinkSync(p); + } +} + +const db = new BunSqlite(DB_PATH); +db.exec('PRAGMA foreign_keys = ON'); + +// Wrap bun:sqlite to match the Database interface expected by runMigrations +const dbWrapper = { + run(sql: string, params: unknown[] = []) { + db.query(sql).run(...(params as [unknown?, ...unknown[]])); + }, + get>(sql: string, params: unknown[] = []): T | undefined { + const row = db.query(sql).get(...(params as [unknown?, ...unknown[]])); + return (row === null ? undefined : row) as T | undefined; + }, + all>(sql: string, params: unknown[] = []): T[] { + return db.query(sql).all(...(params as [unknown?, ...unknown[]])) as T[]; + }, + close() { + db.close(); + } +}; + +const { runMigrations } = await import('../../src/lib/server/db/migrations.js'); +runMigrations(dbWrapper); +console.log('[e2e] Migrations complete.'); + +async function hashPassword(password: string): Promise { + return argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 8192, + timeCost: 2, + parallelism: 1 + }); +} + +const regularId = randomUUID(); +dbWrapper.run( + `INSERT INTO users (id, username, full_name, email, auth_source) VALUES (?, ?, ?, ?, ?)`, + [regularId, TEST_USERS.regular.username, TEST_USERS.regular.fullName, TEST_USERS.regular.email, 'Local'] +); +dbWrapper.run(`INSERT INTO local_credentials (user_id, password_hash) VALUES (?, ?)`, [ + regularId, + await hashPassword(TEST_USERS.regular.password) +]); + +const adminId = randomUUID(); +dbWrapper.run( + `INSERT INTO users (id, username, full_name, email, auth_source) VALUES (?, ?, ?, ?, ?)`, + [adminId, TEST_USERS.admin.username, TEST_USERS.admin.fullName, TEST_USERS.admin.email, 'Local'] +); +dbWrapper.run(`INSERT INTO local_credentials (user_id, password_hash) VALUES (?, ?)`, [ + adminId, + await hashPassword(TEST_USERS.admin.password) +]); + +db.close(); +console.log(`[e2e] Seeded users: ${TEST_USERS.regular.username}, ${TEST_USERS.admin.username}`); diff --git a/e2e/setup/test-users.ts b/e2e/setup/test-users.ts new file mode 100644 index 0000000..fe857fc --- /dev/null +++ b/e2e/setup/test-users.ts @@ -0,0 +1,15 @@ +// Shared test user definitions — no runtime deps, importable from both Node and Bun contexts. +export const TEST_USERS = { + regular: { + username: 'e2e_user', + fullName: 'E2E Regular User', + email: 'e2e_user@test.local', + password: 'e2e-regular-password123' + }, + admin: { + username: 'e2e_admin', + fullName: 'E2E Admin User', + email: 'e2e_admin@test.local', + password: 'e2e-admin-password123' + } +} as const; diff --git a/e2e/trip-planning.test.ts b/e2e/trip-planning.test.ts new file mode 100644 index 0000000..bc2f09f --- /dev/null +++ b/e2e/trip-planning.test.ts @@ -0,0 +1,186 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; +import { TEST_USERS } from './setup/test-users.js'; + +const LOGIN_URL = '/trips/login'; +const PROFILE_URL = '/trips/profile'; + +function formatDate(offsetDays: number): string { + const date = new Date(); + date.setDate(date.getDate() + offsetDays); + return date.toISOString().slice(0, 10); +} + +async function loginAsLocalUser(page: Page, username: string, password: string): Promise { + await page.goto(LOGIN_URL); + await page.fill('input[name="identifier"]', username); + await page.fill('input[name="password"]', password); + await page.click('button[type="submit"]:has-text("Sign in locally")'); + await page.waitForURL('**/trips/dashboard', { timeout: 15_000 }); +} + +async function ensureSelfProfile(page: Page): Promise { + await page.goto(PROFILE_URL); + await page.fill('#first_name', 'E2E'); + await page.fill('#last_name', 'User'); + await page.fill('#email', TEST_USERS.regular.email); + await page.getByRole('button', { name: 'Save profile' }).click(); + await expect(page.getByRole('heading', { name: 'My Profile' })).toBeVisible(); +} + +async function openAddToTripMenuItem(page: Page, label: string): Promise { + await page.getByRole('button', { name: 'Add to trip' }).click(); + await page.getByRole('button', { name: label, exact: true }).click(); +} + +async function addLoggedInTraveller(page: Page): Promise { + await page.getByRole('button', { name: "Who's travelling?" }).click(); + const dialog = page.getByRole('dialog', { name: 'Add traveller' }); + await expect(dialog).toBeVisible(); + + const selfButton = dialog.getByRole('button', { name: /You/ }).first(); + if (await selfButton.isVisible().catch(() => false)) { + await selfButton.click(); + } else { + const firstNameInput = dialog.getByLabel('First name *'); + if (!(await firstNameInput.isVisible().catch(() => false))) { + await dialog.getByRole('button', { name: 'Add someone new' }).click(); + } + await dialog.getByLabel('First name *').fill('E2E'); + await dialog.getByLabel('Last name *').fill('User'); + await dialog.getByLabel(/Email/).fill(TEST_USERS.regular.email); + await dialog.getByRole('button', { name: 'Add traveller' }).click(); + } + + await expect(dialog).toBeHidden(); +} + +async function addNewTraveller( + page: Page, + firstName: string, + lastName: string, + email: string +): Promise { + await openAddToTripMenuItem(page, 'Travellers'); + const dialog = page.getByRole('dialog', { name: 'Add traveller' }); + await expect(dialog).toBeVisible(); + + const firstNameInput = dialog.getByLabel('First name *'); + if (!(await firstNameInput.isVisible().catch(() => false))) { + await dialog.getByRole('button', { name: 'Add someone new' }).click(); + } + + await dialog.getByLabel('First name *').fill(firstName); + await dialog.getByLabel('Last name *').fill(lastName); + await dialog.getByLabel(/Email/).fill(email); + await dialog.getByRole('button', { name: 'Add traveller' }).click(); + await expect(dialog).toBeHidden(); +} + +async function addDestination(page: Page, cityQuery: string, startDate: string): Promise { + await openAddToTripMenuItem(page, 'Destinations'); + const dialog = page.getByRole('dialog', { name: 'Add destination' }); + await expect(dialog).toBeVisible(); + + await dialog.getByLabel('City *').fill(cityQuery); + const cityOption = dialog.locator('ul button').filter({ hasText: cityQuery }).first(); + await expect(cityOption).toBeVisible(); + await cityOption.click(); + + await dialog.getByLabel('Arrival').fill(startDate); + await dialog.getByRole('button', { name: 'Add to trip' }).click(); + await expect(dialog).toBeHidden(); +} + +async function addFlight(page: Page, departureDate: string): Promise { + await openAddToTripMenuItem(page, 'Transportation'); + const transportDialog = page.getByRole('dialog', { name: 'Add transportation' }); + await expect(transportDialog).toBeVisible(); + + await transportDialog.getByRole('button', { name: /Flight/ }).click(); + const form = transportDialog.locator('form'); + await expect(form.locator('input[name="segments[0][departure_date]"]')).toBeVisible(); + + await form.locator('input[name="segments[0][departure_date]"]').fill(departureDate); + await form.getByPlaceholder('Search airline or enter code').fill('UA'); + await form.locator('input[name="segments[0][flight_number]"]').fill('1001'); + await form.getByPlaceholder('Code or search').nth(0).fill('SFO'); + await form.getByPlaceholder('Code or search').nth(1).fill('LAX'); + await expect(form.getByRole('button', { name: 'Add transportation' })).toBeEnabled(); + await form.getByRole('button', { name: 'Add transportation' }).click(); + await expect(transportDialog).toBeHidden(); +} + +async function addLodging(page: Page, lodgingName: string): Promise { + await openAddToTripMenuItem(page, 'Lodgings'); + const dialog = page.getByRole('dialog', { name: 'Add lodging' }); + await expect(dialog).toBeVisible(); + + await dialog.getByLabel('Name *').fill(lodgingName); + await dialog.getByRole('button', { name: 'Add lodging' }).click(); + await expect(dialog).toBeHidden(); +} + +async function expectPlanSectionsAndDetails( + page: Page, + tripName: string, + destinationQuery: string, + flightText: RegExp, + lodgingName: string, + travellerLocators: Locator[] +): Promise { + await page.getByRole('link', { name: 'Upcoming Trips' }).click(); + await expect(page.getByRole('heading', { name: 'Upcoming Trips' })).toBeVisible(); + await expect(page.getByRole('link', { name: tripName })).toBeVisible(); + + await page.getByRole('link', { name: tripName }).click(); + await expect(page.getByRole('heading', { name: tripName })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Destinations' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Transportation' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Lodgings' })).toBeVisible(); + + await expect(page.getByText(destinationQuery, { exact: false })).toBeVisible(); + await expect(page.getByText(flightText)).toBeVisible(); + await expect(page.getByText(lodgingName, { exact: true })).toBeVisible(); + for (const traveller of travellerLocators) { + await expect(traveller).toBeVisible(); + } +} + +test.beforeEach(async ({ context }) => { + await context.clearCookies(); +}); + +test('regular user can plan a detailed future trip and see it in upcoming', async ({ page }) => { + const startDate = formatDate(30); + const tripName = `E2E Future Trip ${Date.now()}`; + const tripDescription = 'Future trip created in Playwright e2e scenario'; + const destinationQuery = 'Tokyo'; + const lodgingName = `E2E Hotel ${Date.now()}`; + const newTravellerFirstName = 'Jamie'; + const newTravellerLastName = 'Companion'; + const newTravellerEmail = `jamie+${Date.now()}@test.local`; + + await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password); + await ensureSelfProfile(page); + + await page.getByRole('link', { name: 'Plan New Trip' }).click(); + await expect(page.getByRole('heading', { name: 'Plan New Trip' })).toBeVisible(); + await page.getByLabel('Trip name *').fill(tripName); + await page.getByLabel('Start date').fill(startDate); + await page.getByLabel('End date').fill(''); + await page.getByLabel('Description').fill(tripDescription); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForURL('**/trips/trips/*', { timeout: 15_000 }); + await expect(page.getByRole('heading', { name: tripName })).toBeVisible(); + + await addLoggedInTraveller(page); + await addNewTraveller(page, newTravellerFirstName, newTravellerLastName, newTravellerEmail); + await addDestination(page, destinationQuery, startDate); + await addFlight(page, startDate); + await addLodging(page, lodgingName); + + await expectPlanSectionsAndDetails(page, tripName, destinationQuery, /UA\s*1001/, lodgingName, [ + page.getByText('E2E User', { exact: false }), + page.getByText(`${newTravellerFirstName} ${newTravellerLastName}`, { exact: false }) + ]); +}); diff --git a/package.json b/package.json index eb7a9e2..12f2cb4 100644 --- a/package.json +++ b/package.json @@ -14,17 +14,22 @@ "format": "prettier --write .", "test": "bunx --bun svelte-kit sync && bunx --bun vitest run", "test:watch": "bunx --bun svelte-kit sync && bunx --bun vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:e2e": "bunx playwright test", + "test:e2e:ui": "bunx playwright test --ui", + "test:e2e:debug": "bunx playwright test --debug" }, "devDependencies": { "@biomejs/biome": "^2.4.4", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.58.2", "@sveltejs/adapter-node": "^5.5.3", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.0", "@vitest/coverage-v8": "^4.0.18", "bun-types": "^1.3.9", + "dotenv": "^17.3.1", "eslint": "^10.0.0", "eslint-plugin-svelte": "^3.15.0", "globals": "^17.3.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..1fefc0c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,53 @@ +import { defineConfig, devices } from '@playwright/test'; +import { config as dotenvConfig } from 'dotenv'; +import { resolve } from 'path'; + +// Load .env.test so both this process and the webServer child process see the values. +dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true }); + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: 'html', + + use: { + baseURL: 'http://127.0.0.1:5173', + trace: process.env.PW_TRACE_MODE ?? 'on-first-retry', + video: process.env.PW_VIDEO_MODE ?? 'off' + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ], + + globalTeardown: './e2e/setup/global-teardown.ts', + + webServer: { + // Playwright starts webServer before globalSetup. Seed first so the app + // process opens the final DB file and never sees it replaced underneath. + command: 'bun run e2e/setup/seed-db.ts && bunx --bun vite dev --host 127.0.0.1', + url: 'http://127.0.0.1:5173/trips', + // Always start a fresh server to ensure it uses trips.test.db + reuseExistingServer: false, + timeout: 120_000, + env: { + AUTH_URL: process.env.AUTH_URL!, + AUTH_SECRET: process.env.AUTH_SECRET!, + LOCAL_AUTH_ENABLED: process.env.LOCAL_AUTH_ENABLED!, + LOCAL_AUTH_ARGON2_MEMORY_KB: process.env.LOCAL_AUTH_ARGON2_MEMORY_KB!, + LOCAL_AUTH_ARGON2_TIME_COST: process.env.LOCAL_AUTH_ARGON2_TIME_COST!, + LOCAL_AUTH_ARGON2_PARALLELISM: process.env.LOCAL_AUTH_ARGON2_PARALLELISM!, + ADMIN_USER_IDS: process.env.ADMIN_USER_IDS!, + DATABASE_URL: process.env.DATABASE_URL!, + SYNOLOGY_ISSUER: process.env.SYNOLOGY_ISSUER!, + SYNOLOGY_CLIENT_ID: process.env.SYNOLOGY_CLIENT_ID!, + SYNOLOGY_CLIENT_SECRET: process.env.SYNOLOGY_CLIENT_SECRET! + } + } +}); diff --git a/src/auth.ts b/src/auth.ts index 4fb449b..06f2534 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -47,6 +47,9 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ }) ], trustHost: true, + pages: { + signIn: '/trips/login' + }, callbacks: { jwt({ token, profile, user }) { const details = profile as @@ -56,7 +59,7 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ email?: string; username?: string; preferred_username?: string; - } + } | undefined; if (details?.sub) { token.sub = details.sub as string; diff --git a/src/lib/server/users.test.ts b/src/lib/server/users.test.ts index 263792a..3be7e60 100644 --- a/src/lib/server/users.test.ts +++ b/src/lib/server/users.test.ts @@ -2,8 +2,10 @@ import { beforeEach, afterEach, describe, expect, it } from 'vitest'; import { setupTestDb } from '../../tests/helpers.js'; import type { Database } from './db/types.js'; import { + createLocalUser, deleteUser, listUsers, + setLocalPasswordForUser, updateUser, upsertUserFromAuth, upsertUserProfile @@ -100,3 +102,50 @@ describe('deleteUser', () => { expect(listUsers().find((item) => item.id === 'u4')).toBeUndefined(); }); }); + +describe('createLocalUser', () => { + it('creates a local user with credentials', async () => { + const created = await createLocalUser({ + username: 'local-user', + fullName: 'Local User', + email: 'local@example.com', + password: 'averysecurepassword' + }); + expect(created.username).toBe('local-user'); + expect(created.auth_source).toBe('Local'); + const row = database.get<{ user_id: string; password_hash: string }>( + 'SELECT user_id, password_hash FROM local_credentials WHERE user_id = ?', + [created.id] + ); + expect(row?.user_id).toBe(created.id); + expect(row?.password_hash).toBeTruthy(); + }); + + it('rejects duplicate usernames', async () => { + await createLocalUser({ + username: 'dup-user', + fullName: 'Dup User', + password: 'averysecurepassword' + }); + await expect( + createLocalUser({ + username: 'dup-user', + fullName: 'Dup User Two', + password: 'averysecurepassword' + }) + ).rejects.toThrow('Username already exists'); + }); +}); + +describe('setLocalPasswordForUser', () => { + it('sets local credentials for an existing user', async () => { + upsertUserFromAuth({ id: 'u5', username: 'sarah', fullName: 'Sarah Lee' }); + await setLocalPasswordForUser('u5', 'averysecurepassword'); + const row = database.get<{ user_id: string; password_hash: string }>( + 'SELECT user_id, password_hash FROM local_credentials WHERE user_id = ?', + ['u5'] + ); + expect(row?.user_id).toBe('u5'); + expect(row?.password_hash).toBeTruthy(); + }); +}); diff --git a/src/lib/server/users.ts b/src/lib/server/users.ts index d22508f..108bb59 100644 --- a/src/lib/server/users.ts +++ b/src/lib/server/users.ts @@ -1,4 +1,6 @@ +import { randomUUID } from 'crypto'; import { db } from './db/index.js'; +import { setLocalCredentialPassword } from './local-credentials.js'; export interface AppUser { id: string; @@ -11,6 +13,7 @@ export interface AppUser { } const DEFAULT_AUTH_SOURCE = 'OIDC - Synology'; +const LOCAL_AUTH_SOURCE = 'Local'; const normalizeOptional = (value?: string | null): string | undefined => { const trimmed = value?.trim(); @@ -24,12 +27,16 @@ const normalizeEmail = (value?: string | null): string | null | undefined => { return trimmed ? trimmed : null; }; -export function listUsers(): AppUser[] { - return db.all( - `SELECT id, username, full_name, email, auth_source, created_at, updated_at - FROM users - ORDER BY username COLLATE NOCASE` - ); +export function listUsers(): (AppUser & { has_local_credentials: boolean })[] { + return db + .all( + `SELECT u.id, u.username, u.full_name, u.email, u.auth_source, u.created_at, u.updated_at, + CASE WHEN lc.user_id IS NOT NULL THEN 1 ELSE 0 END AS has_local_credentials + FROM users u + LEFT JOIN local_credentials lc ON lc.user_id = u.id + ORDER BY u.username COLLATE NOCASE` + ) + .map((row) => ({ ...row, has_local_credentials: row.has_local_credentials === 1 })); } export function upsertUserFromAuth(input: { @@ -112,6 +119,56 @@ export function deleteUser(id: string): void { db.run('DELETE FROM users WHERE id = ?', [id]); } +export async function createLocalUser(input: { + username: string; + fullName: string; + email?: string | null; + password: string; +}): Promise { + const username = input.username?.trim() ?? ''; + const fullName = input.fullName?.trim() ?? ''; + if (!username || !fullName) { + throw new Error('Username and full name are required'); + } + const email = normalizeEmail(input.email); + const existingUsername = db.get<{ id: string }>( + 'SELECT id FROM users WHERE lower(username) = lower(?)', + [username] + ); + if (existingUsername) { + throw new Error('Username already exists'); + } + if (email) { + const existingEmail = db.get<{ id: string }>( + 'SELECT id FROM users WHERE lower(email) = lower(?)', + [email] + ); + if (existingEmail) { + throw new Error('Email already exists'); + } + } + const id = randomUUID(); + db.run( + `INSERT INTO users (id, username, full_name, email, auth_source) + VALUES (?, ?, ?, ?, ?)`, + [id, username, fullName, email ?? null, LOCAL_AUTH_SOURCE] + ); + await setLocalCredentialPassword(id, input.password); + return db.get('SELECT * FROM users WHERE id = ?', [id])!; +} + +export async function setLocalPasswordForUser(userId: string, password: string): Promise { + const id = userId?.trim() ?? ''; + if (!id) { + throw new Error('User id required'); + } + const existing = db.get<{ id: string }>('SELECT id FROM users WHERE id = ?', [id]); + if (!existing) { + throw new Error('User not found'); + } + await setLocalCredentialPassword(id, password); +} + function syncSelfProfile(userId: string, fullName: string, email?: string | null): void { const profile = db.get<{ id: string; first_name: string; last_name: string; email: string | null }>( `SELECT id, first_name, last_name, email FROM people WHERE user_id = ? AND is_self = 1 LIMIT 1`, diff --git a/src/routes/(protected)/admin/api/users/+server.ts b/src/routes/(protected)/admin/api/users/+server.ts index 378fb6b..c977c96 100644 --- a/src/routes/(protected)/admin/api/users/+server.ts +++ b/src/routes/(protected)/admin/api/users/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { requireAdmin } from '$lib/server/admin/auth.js'; -import { deleteUser, listUsers, updateUser } from '$lib/server/users.js'; +import { createLocalUser, deleteUser, listUsers, updateUser } from '$lib/server/users.js'; export const GET: RequestHandler = async (event) => { requireAdmin((await event.locals.auth())?.user); @@ -24,6 +24,34 @@ export const PATCH: RequestHandler = async (event) => { return json({ ok: true }); }; +export const POST: RequestHandler = async (event) => { + requireAdmin((await event.locals.auth())?.user); + const body = await event.request.json(); + const { username, full_name, email, password } = body as { + username?: string; + full_name?: string; + email?: string | null; + password?: string; + }; + if (!username?.trim() || !full_name?.trim() || !password?.trim()) { + return json({ error: 'username, full_name and password required' }, { status: 400 }); + } + try { + const user = await createLocalUser({ + username: username.trim(), + fullName: full_name.trim(), + email, + password + }); + return json(user); + } catch (error) { + return json( + { error: error instanceof Error ? error.message : 'Failed to create user' }, + { status: 400 } + ); + } +}; + export const DELETE: RequestHandler = async (event) => { requireAdmin((await event.locals.auth())?.user); const id = event.url.searchParams.get('id')?.trim(); diff --git a/src/routes/(protected)/admin/api/users/local-credentials/+server.ts b/src/routes/(protected)/admin/api/users/local-credentials/+server.ts new file mode 100644 index 0000000..cab2b16 --- /dev/null +++ b/src/routes/(protected)/admin/api/users/local-credentials/+server.ts @@ -0,0 +1,22 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAdmin } from '$lib/server/admin/auth.js'; +import { setLocalPasswordForUser } from '$lib/server/users.js'; + +export const POST: RequestHandler = async (event) => { + requireAdmin((await event.locals.auth())?.user); + const body = await event.request.json(); + const { id, password } = body as { id?: string; password?: string }; + if (!id?.trim() || !password?.trim()) { + return json({ error: 'id and password required' }, { status: 400 }); + } + try { + await setLocalPasswordForUser(id.trim(), password); + return json({ ok: true }); + } catch (error) { + return json( + { error: error instanceof Error ? error.message : 'Failed to set password' }, + { status: 400 } + ); + } +}; diff --git a/src/routes/(protected)/admin/users/+page.svelte b/src/routes/(protected)/admin/users/+page.svelte index fceb4c6..c23416b 100644 --- a/src/routes/(protected)/admin/users/+page.svelte +++ b/src/routes/(protected)/admin/users/+page.svelte @@ -8,6 +8,7 @@ full_name: string; email: string | null; auth_source: string; + has_local_credentials: boolean; } let users = $state([]); @@ -17,6 +18,19 @@ let editUsername = $state(''); let editFullName = $state(''); let editEmail = $state(''); + let addDrawerOpen = $state(false); + let addUsername = $state(''); + let addFullName = $state(''); + let addEmail = $state(''); + let addPassword = $state(''); + let addPasswordConfirm = $state(''); + let addError = $state(''); + let addSubmitting = $state(false); + let passwordDrawerUser = $state(null); + let localPassword = $state(''); + let localPasswordConfirm = $state(''); + let localPasswordError = $state(''); + let localPasswordSubmitting = $state(false); async function loadUsers() { loading = true; @@ -50,6 +64,113 @@ error = ''; } + function openAddDrawer() { + addDrawerOpen = true; + addError = ''; + } + + function closeAddDrawer() { + addDrawerOpen = false; + addUsername = ''; + addFullName = ''; + addEmail = ''; + addPassword = ''; + addPasswordConfirm = ''; + addError = ''; + } + + async function submitAddUser() { + if (!addUsername.trim() || !addFullName.trim() || !addPassword.trim()) { + addError = 'Username, full name, and password are required'; + return; + } + if (addPassword.trim().length < 12) { + addError = 'Password must be at least 12 characters'; + return; + } + if (addPassword.trim() !== addPasswordConfirm.trim()) { + addError = 'Passwords do not match'; + return; + } + addSubmitting = true; + addError = ''; + try { + const res = await fetch(`${base}/admin/api/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: addUsername.trim(), + full_name: addFullName.trim(), + email: addEmail.trim() || null, + password: addPassword + }) + }); + if (!res.ok) { + const data = await res.json(); + addError = data.error ?? 'Failed to create user'; + return; + } + closeAddDrawer(); + loadUsers(); + } catch (e) { + addError = e instanceof Error ? e.message : 'Failed to create user'; + } finally { + addSubmitting = false; + } + } + + function openPasswordDrawer(user: User) { + passwordDrawerUser = user; + localPassword = ''; + localPasswordConfirm = ''; + localPasswordError = ''; + } + + function closePasswordDrawer() { + passwordDrawerUser = null; + localPassword = ''; + localPasswordConfirm = ''; + localPasswordError = ''; + } + + async function submitLocalPassword() { + if (!passwordDrawerUser) return; + if (!localPassword.trim()) { + localPasswordError = 'Password is required'; + return; + } + if (localPassword.trim().length < 12) { + localPasswordError = 'Password must be at least 12 characters'; + return; + } + if (localPassword.trim() !== localPasswordConfirm.trim()) { + localPasswordError = 'Passwords do not match'; + return; + } + localPasswordSubmitting = true; + localPasswordError = ''; + try { + const res = await fetch(`${base}/admin/api/users/local-credentials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: passwordDrawerUser.id, + password: localPassword + }) + }); + if (!res.ok) { + const data = await res.json(); + localPasswordError = data.error ?? 'Failed to set password'; + return; + } + closePasswordDrawer(); + } catch (e) { + localPasswordError = e instanceof Error ? e.message : 'Failed to set password'; + } finally { + localPasswordSubmitting = false; + } + } + async function submitEdit() { if (!editing) return; if (!editUsername.trim() || !editFullName.trim()) { @@ -105,9 +226,17 @@
-
-

Users

-

Manage application user accounts and access.

+
+
+

Users

+

Manage application user accounts and access.

+
+
@@ -212,12 +341,37 @@ {user.email || '—'} - {user.auth_source} +
+ {user.auth_source} + {#if user.has_local_credentials && user.auth_source !== 'Local'} + Local + {/if} +
+
+ +{#if addDrawerOpen} +
e.key === 'Escape' && closeAddDrawer()} + >
+ +{/if} + +{#if passwordDrawerUser} +
e.key === 'Escape' && closePasswordDrawer()} + >
+ +{/if} diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index f846970..59cb130 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -10,10 +10,12 @@ const resolveErrorMessage = (value: string | null): string | null => { export const load: PageServerLoad = async ({ url }) => { const authUrl = process.env.AUTH_URL ?? env.AUTH_URL ?? ''; const localAuthEnabled = (process.env.LOCAL_AUTH_ENABLED ?? env.LOCAL_AUTH_ENABLED) === 'true'; + const appRoot = authUrl.replace(/\/auth$/, ''); return { signinUrl: `${authUrl}/signin/synology`, - localSigninUrl: `${authUrl}/signin/local`, + localSigninUrl: `${authUrl}/callback/local`, + callbackUrl: appRoot, localAuthEnabled, error: resolveErrorMessage(url.searchParams.get('error')) }; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index e89c471..72e2fa7 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -45,6 +45,7 @@ {#if data.localAuthEnabled}
+