diff --git a/apps/web/e2e/dev-system/global-setup.ts b/apps/web/e2e/dev-system/global-setup.ts index a011188..dfe70e6 100644 --- a/apps/web/e2e/dev-system/global-setup.ts +++ b/apps/web/e2e/dev-system/global-setup.ts @@ -1,11 +1,15 @@ /** * Playwright global setup for dev-system tests. * - * Logs in once per user role and saves browser storage state to disk. - * Tests that don't need to exercise the login flow itself can use these - * cached states via `test.use({ storageState: '...' })` to avoid - * hitting the auth rate limiter (5 attempts / 15 min / email). + * 1. Logs in once per user role and saves browser storage state to disk. + * Tests use these cached states via `test.use({ storageState })` to avoid + * hitting the auth rate limiter (5 attempts / 15 min / email). + * + * 2. Ensures reset-test@planarchy.dev exists for the password-reset E2E tests. + * If the user is missing, creates them directly in the DB via argon2id hash + * + docker exec psql (no tRPC roundtrip needed in setup). */ +import { execSync } from "node:child_process"; import { chromium, type FullConfig } from "@playwright/test"; import * as fs from "fs"; import * as path from "path"; @@ -16,6 +20,57 @@ const USERS = { viewer: { email: "viewer@planarchy.dev", password: "viewer123" }, } as const; +const RESET_TEST_USER = { + email: "reset-test@planarchy.dev", + password: "Dev123456!", +}; + +const DB_CONTAINER = "capakraken-postgres-1"; +const DB_USER = "capakraken"; +const DB_NAME = "capakraken"; + +function psqlExec(sql: string): string { + return execSync( + `docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -t -v ON_ERROR_STOP=1`, + { input: sql, encoding: "utf8" }, + ).trim(); +} + +/** + * Ensure reset-test@planarchy.dev exists in the DB. + * Creates the user directly via SQL + argon2id hash — no email / tRPC roundtrip. + */ +async function ensureResetTestUser(): Promise { + const count = psqlExec(`SELECT COUNT(*) FROM users WHERE email='${RESET_TEST_USER.email}';`); + if (parseInt(count, 10) > 0) { + console.log(`[global-setup] reset-test user already exists — skipping`); + return; + } + + console.log(`[global-setup] Creating reset-test user…`); + + const { hash } = await import("@node-rs/argon2"); + const passwordHash = await hash(RESET_TEST_USER.password); + + // argon2id hashes use base64 chars only — safe inside a SQL single-quoted string + // Column names are camelCase (Prisma default) — must be double-quoted in SQL + const sql = ` + INSERT INTO users (id, email, name, "systemRole", "passwordHash", "createdAt", "updatedAt") + VALUES ( + gen_random_uuid()::text, + '${RESET_TEST_USER.email}', + 'reset-test', + 'USER', + '${passwordHash}', + NOW(), + NOW() + ) ON CONFLICT (email) DO NOTHING; + `; + + psqlExec(sql); + console.log(`[global-setup] Created reset-test user (${RESET_TEST_USER.email})`); +} + async function globalSetup(config: FullConfig) { const baseURL = config.projects[0]?.use?.baseURL ?? "http://localhost:3100"; const authDir = path.join(__dirname, ".auth"); @@ -32,7 +87,6 @@ async function globalSetup(config: FullConfig) { await page.fill('input[type="password"]', creds.password); await page.click('button[type="submit"]'); - // Wait for successful redirect await page.waitForURL(/\/(dashboard|resources)/, { timeout: 15000 }); await context.storageState({ path: path.join(authDir, `${role}.json`) }); @@ -41,6 +95,9 @@ async function globalSetup(config: FullConfig) { } await browser.close(); + + // Ensure the dedicated password-reset test user exists + await ensureResetTestUser(); } export default globalSetup; diff --git a/apps/web/e2e/dev-system/helpers.ts b/apps/web/e2e/dev-system/helpers.ts index c4f28c9..11e4f1b 100644 --- a/apps/web/e2e/dev-system/helpers.ts +++ b/apps/web/e2e/dev-system/helpers.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import { expect, type Page } from "@playwright/test"; /** Dev-system credentials — these exist in the planarchy.dev seed data */ @@ -45,12 +46,34 @@ const MAILHOG_API = process.env["MAILHOG_API"] ?? "http://localhost:8025"; type MailhogMessage = { Content: { - Headers: { Subject?: string[]; To?: string[] }; + Headers: { Subject?: string[]; To?: string[]; "Content-Transfer-Encoding"?: string[] }; Body: string; }; - MIME: { Parts?: Array<{ Headers: { "Content-Type"?: string[] }; Body: string }> } | null; + MIME: { + Parts?: Array<{ + Headers: { "Content-Type"?: string[]; "Content-Transfer-Encoding"?: string[] }; + Body: string; + }>; + } | null; }; +/** Decode a MIME part body based on its Content-Transfer-Encoding header value. */ +function decodeMimeBody(body: string, encoding: string | undefined): string { + const enc = (encoding ?? "").toLowerCase().trim(); + if (enc === "quoted-printable") { + return body + .replace(/=\r\n/g, "") // soft line break (CRLF) + .replace(/=\n/g, "") // soft line break (LF) + .replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => + String.fromCharCode(parseInt(hex, 16)), + ); + } + if (enc === "base64") { + return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8"); + } + return body; +} + type MailhogResponse = { count: number; items: MailhogMessage[]; @@ -82,11 +105,27 @@ export async function getLatestEmailTo( if (match) { const subject = (match.Content.Headers.Subject ?? [])[0] ?? ""; + + // Decode body parts based on Content-Transfer-Encoding const htmlPart = match.MIME?.Parts?.find((p) => (p.Headers["Content-Type"]?.[0] ?? "").includes("text/html"), ); - const html = htmlPart?.Body ?? match.Content.Body; - return { subject, body: match.Content.Body, html }; + const textPart = match.MIME?.Parts?.find((p) => + (p.Headers["Content-Type"]?.[0] ?? "").includes("text/plain"), + ); + + const rootEnc = match.Content.Headers["Content-Transfer-Encoding"]?.[0]; + const htmlEnc = htmlPart?.Headers["Content-Transfer-Encoding"]?.[0]; + const textEnc = textPart?.Headers["Content-Transfer-Encoding"]?.[0]; + + const html = htmlPart + ? decodeMimeBody(htmlPart.Body, htmlEnc) + : decodeMimeBody(match.Content.Body, rootEnc); + const body = textPart + ? decodeMimeBody(textPart.Body, textEnc) + : decodeMimeBody(match.Content.Body, rootEnc); + + return { subject, body, html }; } } @@ -112,6 +151,27 @@ export function extractUrlFromEmail( return match[0]; } +/** + * Reset a user's password directly via the DB (no tRPC roundtrip needed). + * Computes an argon2id hash in Node.js, updates password_hash via docker exec psql. + * Use in afterEach teardown — not in the test flow itself. + */ +export async function resetPasswordViaApi( + _baseURL: string, + email: string, + newPassword: string, +): Promise { + const { hash } = await import("@node-rs/argon2"); + const passwordHash = await hash(newPassword); + // argon2id hashes use base64 chars only — safe inside a SQL single-quoted string + // Column name is camelCase (Prisma default) — must be double-quoted in SQL + const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`; + execSync( + `docker exec -i capakraken-postgres-1 psql -U capakraken -d capakraken`, + { input: sql, encoding: "utf8" }, + ); +} + // ── tRPC helpers ─────────────────────────────────────────────────────────────── /** diff --git a/apps/web/e2e/dev-system/invite-flow.spec.ts b/apps/web/e2e/dev-system/invite-flow.spec.ts index 7cd7d2e..a8f683c 100644 --- a/apps/web/e2e/dev-system/invite-flow.spec.ts +++ b/apps/web/e2e/dev-system/invite-flow.spec.ts @@ -4,16 +4,16 @@ * Requires: * - Dev server running on http://localhost:3100 * - Mailhog running on http://localhost:8025 - * - SMTP_HOST=mailhog (or localhost), SMTP_PORT=1025, SMTP_TLS=false configured + * - SMTP_HOST=localhost, SMTP_PORT=1025, SMTP_TLS=false configured * * Flow: * 1. Admin opens /admin/users → clicks "Invite User" * 2. Fills in a unique test email address + role USER - * 3. Waits for success toast + * 3. Waits for "Invitation sent successfully." message in modal * 4. Reads the invite email from Mailhog - * 5. Visits the invite link → sets a password - * 6. Signs in with the new credentials - * 7. Lands on the dashboard + * 5. Visits the invite link in a separate page context + * 6. Sets a password → account created + * 7. Signs in with the new credentials → lands on dashboard */ import { expect, test } from "@playwright/test"; import { STORAGE_STATE } from "../../playwright.dev.config.js"; @@ -22,9 +22,11 @@ import { clearMailhog, extractUrlFromEmail, getLatestEmailTo } from "./helpers.j test.describe("invite flow", () => { test.use({ storageState: STORAGE_STATE.admin }); - test("admin invites a new user and invited user can sign in", async ({ page }) => { + test.beforeEach(async () => { await clearMailhog(); + }); + test("admin invites a new user and invited user can sign in", async ({ page, browser }) => { const testEmail = `invite-e2e-${Date.now()}@capakraken.test`; // Step 1: Navigate to admin users page @@ -33,38 +35,40 @@ test.describe("invite flow", () => { // Step 2: Open invite modal await page.click('button:has-text("Invite User")'); - await page.waitForSelector('[role="dialog"], form:has(input[type="email"])'); + // Wait for the modal heading — AnimatedModal does not use role="dialog" + await page.waitForSelector('text=Invite User', { state: "visible" }); // Step 3: Fill in invite form await page.fill('input[type="email"]', testEmail); // Step 4: Submit - await page.click('button[type="submit"]'); + await page.click('button:has-text("Send Invite")'); - // Step 5: Wait for success (toast or modal close) - await expect(page.locator("text=Invite sent")).toBeVisible({ timeout: 10_000 }); + // Step 5: Wait for success message (exact text from InviteUserModal.tsx) + await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({ timeout: 10_000 }); - // Step 6: Read email from Mailhog + // Step 6: Read invite email from Mailhog const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 }); const inviteUrl = extractUrlFromEmail(email, "/invite/"); // Strip base URL — Playwright navigates relative to baseURL const invitePath = new URL(inviteUrl).pathname; - // Step 7: Accept invite in a new context (not logged in as admin) - const invitePage = await page.context().newPage(); - await invitePage.goto(invitePath); + // Step 7: Accept invite in a fresh unauthenticated context (no admin cookies) + const inviteContext = await browser.newContext(); + const invitePage = await inviteContext.newPage(); + await invitePage.goto(`http://localhost:3100${invitePath}`); - // Wait for password form + // Wait for the accept-invite form await expect(invitePage.locator("text=Accept invitation")).toBeVisible({ timeout: 10_000 }); - await invitePage.fill('input[type="password"]', "TestPass123!"); - // Confirm field + // Fill both password fields using consistent nth() indexing const passwordInputs = invitePage.locator('input[type="password"]'); + await passwordInputs.nth(0).fill("TestPass123!"); await passwordInputs.nth(1).fill("TestPass123!"); await invitePage.click('button[type="submit"]'); - // Account created state + // Account created confirmation await expect(invitePage.locator("text=Account created")).toBeVisible({ timeout: 15_000 }); // Step 8: Sign in with new credentials @@ -76,6 +80,6 @@ test.describe("invite flow", () => { await invitePage.click('button[type="submit"]'); await invitePage.waitForURL(/\/(dashboard|resources)/, { timeout: 15_000 }); - await invitePage.close(); + await inviteContext.close(); }); }); diff --git a/apps/web/e2e/dev-system/password-reset.spec.ts b/apps/web/e2e/dev-system/password-reset.spec.ts index 61ecf8d..628e0c8 100644 --- a/apps/web/e2e/dev-system/password-reset.spec.ts +++ b/apps/web/e2e/dev-system/password-reset.spec.ts @@ -4,45 +4,52 @@ * Requires: * - Dev server running on http://localhost:3100 * - Mailhog running on http://localhost:8025 - * - SMTP_HOST=mailhog (or localhost), SMTP_PORT=1025, SMTP_TLS=false configured + * - SMTP_HOST=localhost, SMTP_PORT=1025, SMTP_TLS=false configured * - * Uses a dedicated test user "reset-test@planarchy.dev" (Dev123456!) that - * exists in the dev seed. This avoids modifying shared admin/manager/viewer - * credentials that other E2E tests depend on. + * Uses a dedicated test user "reset-test@planarchy.dev" created by global-setup.ts. + * This avoids touching admin/manager/viewer passwords that other E2E suites depend on. * - * Flow: - * 1. Request password reset for reset-test@planarchy.dev - * 2. Read reset email from Mailhog - * 3. Visit reset link → enter new password - * 4. Sign in with new password → land on dashboard - * 5. (Cleanup) Reset the password back to Dev123456! + * Cleanup: afterEach resets the password back to Dev123456! via the reset-password + * API + a direct DB token read (no email roundtrip needed for teardown). */ import { expect, test } from "@playwright/test"; -import { clearMailhog, extractUrlFromEmail, getLatestEmailTo, signIn } from "./helpers.js"; +import { clearMailhog, extractUrlFromEmail, getLatestEmailTo, resetPasswordViaApi, signIn } from "./helpers.js"; +const BASE_URL = "http://localhost:3100"; const RESET_USER = { email: "reset-test@planarchy.dev", originalPassword: "Dev123456!" }; const NEW_PASSWORD = "ResetPass456!"; test.describe("password reset flow", () => { // No storageState — these tests exercise the unauthenticated flow - test("user can reset password via email link", async ({ page }) => { + test.beforeEach(async () => { await clearMailhog(); + }); - // Step 1: Navigate to forgot-password page + test.afterEach(async () => { + // Always restore the original password so subsequent runs start clean. + // Uses direct DB token read — no email roundtrip needed in teardown. + try { + await resetPasswordViaApi(BASE_URL, RESET_USER.email, RESET_USER.originalPassword); + } catch (err) { + console.warn("[afterEach] Password cleanup failed (may already be correct):", err); + } + }); + + test("user can reset password via email link", async ({ page }) => { + // Step 1: Request reset await page.goto("/auth/forgot-password"); await page.fill('input[type="email"]', RESET_USER.email); await page.click('button[type="submit"]'); - // Step 2: Confirm the "check your email" state is shown + // Step 2: Confirm success state (timing-safe — shown for any email) await expect(page.locator("text=Check your email")).toBeVisible({ timeout: 10_000 }); // Step 3: Read reset email from Mailhog const email = await getLatestEmailTo(RESET_USER.email, { timeoutMs: 15_000 }); expect(email.subject).toMatch(/reset/i); - const resetUrl = extractUrlFromEmail(email, "/auth/reset-password/"); - const resetPath = new URL(resetUrl).pathname; + const resetPath = new URL(extractUrlFromEmail(email, "/auth/reset-password/")).pathname; // Step 4: Visit reset link await page.goto(resetPath); @@ -54,7 +61,6 @@ test.describe("password reset flow", () => { await passwordInputs.nth(1).fill(NEW_PASSWORD); await page.click('button[type="submit"]'); - // Password updated state await expect(page.locator("text=Password updated")).toBeVisible({ timeout: 15_000 }); // Step 6: Sign in with new password @@ -63,37 +69,18 @@ test.describe("password reset flow", () => { await signIn(page, RESET_USER.email, NEW_PASSWORD); await page.waitForURL(/\/(dashboard|resources)/, { timeout: 15_000 }); - - // Step 7: Cleanup — reset password back to original so next test run works - await clearMailhog(); - await page.goto("/auth/forgot-password"); - await page.fill('input[type="email"]', RESET_USER.email); - await page.click('button[type="submit"]'); - await expect(page.locator("text=Check your email")).toBeVisible({ timeout: 10_000 }); - - const cleanupEmail = await getLatestEmailTo(RESET_USER.email, { timeoutMs: 15_000 }); - const cleanupPath = new URL(extractUrlFromEmail(cleanupEmail, "/auth/reset-password/")).pathname; - - await page.goto(cleanupPath); - await page.waitForSelector('input[type="password"]'); - const cleanupInputs = page.locator('input[type="password"]'); - await cleanupInputs.nth(0).fill(RESET_USER.originalPassword); - await cleanupInputs.nth(1).fill(RESET_USER.originalPassword); - await page.click('button[type="submit"]'); - await expect(page.locator("text=Password updated")).toBeVisible({ timeout: 15_000 }); }); test("invalid reset token shows an error message", async ({ page }) => { await page.goto("/auth/reset-password/this-token-does-not-exist"); - // Submit the form with a bad token await page.waitForSelector('input[type="password"]'); const inputs = page.locator('input[type="password"]'); await inputs.nth(0).fill("SomePass1!"); await inputs.nth(1).fill("SomePass1!"); await page.click('button[type="submit"]'); - // Should display an error message (NOT_FOUND or BAD_REQUEST from server) + // Error div with red styling should appear await expect(page.locator('[class*="red"]').first()).toBeVisible({ timeout: 10_000 }); }); }); diff --git a/docker-compose.yml b/docker-compose.yml index 46c92aa..ad613aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,12 +73,14 @@ services: AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} GEMINI_API_KEY: ${GEMINI_API_KEY:-} - # SMTP — forwarded from host .env; overrides SystemSettings DB values - SMTP_HOST: ${SMTP_HOST:-} - SMTP_PORT: ${SMTP_PORT:-} + # SMTP — inside Docker the app must reach Mailhog via the service name. + # SMTP_HOST is hardcoded to "mailhog" here; the host .env value (localhost) + # is only relevant for `pnpm dev` (non-Docker). + SMTP_HOST: mailhog + SMTP_PORT: ${SMTP_PORT:-1025} SMTP_USER: ${SMTP_USER:-} - SMTP_FROM: ${SMTP_FROM:-} - SMTP_TLS: ${SMTP_TLS:-} + SMTP_FROM: ${SMTP_FROM:-noreply@capakraken.dev} + SMTP_TLS: ${SMTP_TLS:-false} SMTP_PASSWORD: ${SMTP_PASSWORD:-} depends_on: postgres: