diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c06aa2a..318d7d5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -380,8 +380,66 @@ jobs: run: pnpm typecheck - name: Test + env: + # The "Verify the SystemConfig repair path" step ends with a fresh + # `migrate deploy`, so this database has every table the gated + # real-Postgres tests need. + OUTPOST_TEST_DATABASE_URL: 'postgresql://ci:ci@127.0.0.1:5432/ci' + # Fail, never skip, the *-db tests if the URL above is ever lost. + OUTPOST_REQUIRE_DB_TESTS: '1' run: pnpm test + # The Test step passes when a *-db file skips. Both variables above reach + # vitest only through turbo.json's `test.env` list (strict env mode strips + # everything else). If that list loses them, or a package-scoped + # `@copilotkit/outpost-web#test` task replaces the base one without it, every + # *-db file reports "skipped" and the run still exits 0. + # + # This step reruns those files through the same turbo task with a JSON + # reporter, then fails unless each file ran at least one test and every test + # passed. The check reads the report, not the environment, so it does not + # depend on the passthrough it is checking. --force stops a cached replay; + # a replay writes no report, so it would fail here rather than pass. + - name: Verify the real-Postgres tests ran + env: + OUTPOST_TEST_DATABASE_URL: 'postgresql://ci:ci@127.0.0.1:5432/ci' + OUTPOST_REQUIRE_DB_TESTS: '1' + run: | + set -euo pipefail + shopt -s nullglob + files=(apps/web/src/__tests__/*-db.test.ts) + if [ "${#files[@]}" -eq 0 ]; then + echo "FATAL: no apps/web/src/__tests__/*-db.test.ts files found." + exit 1 + fi + report="$RUNNER_TEMP/db-tests.json" + rm -f "$report" + pnpm exec turbo run test --filter=@copilotkit/outpost-web --force -- \ + --reporter=default --reporter=json --outputFile.json="$report" \ + "${files[@]#apps/web/}" + if [ ! -s "$report" ]; then + echo "FATAL: vitest wrote no JSON report to $report." + exit 1 + fi + failed=0 + for f in "${files[@]}"; do + # A command substitution, so `set -e` stops the step if jq fails. + counts=$(jq -r --arg f "/$f" \ + '[.testResults[] | select(.name | endswith($f)) | .assertionResults[].status] + | "\(length) \(map(select(. == "passed")) | length)"' "$report") + read -r total passed <<<"$counts" + if [ "$total" -eq 0 ] || [ "$passed" -ne "$total" ]; then + echo "FATAL: $f: $passed of $total tests passed; the rest were skipped or did not run." + failed=1 + else + echo " $f: $passed/$total passed" + fi + done + if [ "$failed" -ne 0 ]; then + echo "The real-Postgres tests did not run. Check turbo.json's test.env list." + exit 1 + fi + # Railway auto-deploys on push to main via GitHub integration. # No explicit deploy step needed — Railway watches the repo directly. # diff --git a/.gitignore b/.gitignore index d87c787a..dc3ed6e9 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,7 @@ yarn.lock # Local AI review pipeline session state (create-review / review-changes) .chalk/ + +# Playwright output (apps/web/e2e) +apps/web/test-results/ +apps/web/playwright-report/ diff --git a/apps/web/e2e/docker-compose.e2e.yml b/apps/web/e2e/docker-compose.e2e.yml new file mode 100644 index 00000000..aa094879 --- /dev/null +++ b/apps/web/e2e/docker-compose.e2e.yml @@ -0,0 +1,44 @@ +# Used only by apps/web/e2e (Playwright globalSetup), as Compose project outpost-e2e. +# Standalone on purpose: it is not layered on ../../../docker-compose.yml, sets no +# container_name, and publishes its own host ports (web 3100, Mailpit API 18025), so an +# e2e run never collides with or stops a developer's outpost-postgres / outpost-web. +# Runs a pre-built image, so the same spec can be pointed at an image built from main +# (RED) or from a branch (GREEN). +services: + postgres: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_USER: outpost + POSTGRES_PASSWORD: outpost + POSTGRES_DB: outpost + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U outpost'] + interval: 5s + timeout: 5s + retries: 5 + + mailpit: + image: axllent/mailpit:latest + ports: + - '18025:8025' + + web: + image: ${OUTPOST_E2E_IMAGE:?set OUTPOST_E2E_IMAGE to a locally built apps/web image} + pull_policy: never + ports: + - '3100:3000' + environment: + DATABASE_URL: postgresql://outpost:outpost@postgres:5432/outpost + NEXTAUTH_SECRET: e2e-only-secret + NEXTAUTH_URL: http://localhost:3100 + AUTH_PROVIDER: credentials + NEXT_PUBLIC_AUTH_PROVIDER: credentials + NODE_ENV: production + PORT: 3000 + SMTP_HOST: mailpit + SMTP_PORT: 1025 + depends_on: + postgres: + condition: service_healthy + mailpit: + condition: service_started diff --git a/apps/web/e2e/global-setup.ts b/apps/web/e2e/global-setup.ts new file mode 100644 index 00000000..ffcc56f1 --- /dev/null +++ b/apps/web/e2e/global-setup.ts @@ -0,0 +1,102 @@ +import { execFileSync } from 'node:child_process'; +import { request } from '@playwright/test'; +import { + ADMIN, + BASE_URL, + MEMBER_PASSWORD, + ORG_NAME, + PENDING_INVITE, + USERS, + composeArgs, +} from './support'; + +async function expectOk( + res: { ok(): boolean; status(): number; text(): Promise }, + what: string, +) { + if (!res.ok()) throw new Error(`${what} failed: ${res.status()} ${await res.text()}`); +} + +export default async function globalSetup(): Promise { + if (!process.env.OUTPOST_E2E_IMAGE) { + throw new Error( + 'Set OUTPOST_E2E_IMAGE to a locally built apps/web image tag (see e2e/support.ts).', + ); + } + execFileSync('docker', [...composeArgs, 'down', '-v', '--remove-orphans'], { + stdio: 'inherit', + }); + execFileSync( + 'docker', + [ + ...composeArgs, + 'up', + '-d', + '--no-build', + '--wait', + '--wait-timeout', + '180', + 'postgres', + 'mailpit', + 'web', + ], + { stdio: 'inherit' }, + ); + + const api = await request.newContext({ baseURL: BASE_URL }); + + await expectOk( + await api.post('/api/setup', { + data: { + orgName: ORG_NAME, + orgEmail: 'org@e2e.test', + name: 'E2E Admin', + email: ADMIN.email, + password: ADMIN.password, + confirmPassword: ADMIN.password, + }, + }), + 'setup', + ); + + // Sign the admin in through NextAuth's own credentials endpoint. + const { csrfToken } = (await (await api.get('/api/auth/csrf')).json()) as { csrfToken: string }; + await expectOk( + await api.post('/api/auth/callback/credentials', { + form: { + csrfToken, + email: ADMIN.email, + password: ADMIN.password, + json: 'true', + callbackUrl: `${BASE_URL}/dashboard`, + }, + }), + 'admin sign-in', + ); + await api.get('/dashboard'); // the middleware sets the csrf cookie on this response + const csrf = (await api.storageState()).cookies.find((c) => c.name === 'csrf')?.value; + if (!csrf) throw new Error('No csrf cookie after loading /dashboard'); + + for (const email of [...Object.values(USERS), PENDING_INVITE]) { + const invite = await api.post('/api/team/invite', { + headers: { 'X-CSRF-Token': csrf }, + data: { email, role: 'MEMBER' }, + }); + await expectOk(invite, `invite ${email}`); + if (email === PENDING_INVITE) continue; + const { inviteUrl } = (await invite.json()) as { inviteUrl: string }; + const token = new URL(inviteUrl).searchParams.get('token'); + await expectOk( + await api.post('/api/team/invite/accept', { + data: { + token, + name: email.split('@')[0], + password: MEMBER_PASSWORD, + confirmPassword: MEMBER_PASSWORD, + }, + }), + `accept ${email}`, + ); + } + await api.dispose(); +} diff --git a/apps/web/e2e/global-teardown.ts b/apps/web/e2e/global-teardown.ts new file mode 100644 index 00000000..00b2d0b1 --- /dev/null +++ b/apps/web/e2e/global-teardown.ts @@ -0,0 +1,9 @@ +import { execFileSync } from 'node:child_process'; +import { composeArgs } from './support'; + +export default async function globalTeardown(): Promise { + if (process.env.E2E_KEEP === '1') return; + execFileSync('docker', [...composeArgs, 'down', '-v', '--remove-orphans'], { + stdio: 'inherit', + }); +} diff --git a/apps/web/e2e/password-reset.spec.ts b/apps/web/e2e/password-reset.spec.ts new file mode 100644 index 00000000..b7abd89e --- /dev/null +++ b/apps/web/e2e/password-reset.spec.ts @@ -0,0 +1,224 @@ +import { test, expect, type Browser } from '@playwright/test'; +import { + ADMIN, + MEMBER_PASSWORD, + ORG_NAME, + PENDING_INVITE, + USERS, + newContext, + messagesTo, + profileStatus, + resetLinkFrom, + signIn, + sql, + waitForMail, +} from './support'; + +const NEUTRAL = "If an account exists for that address, we've sent a link to reset your password."; +const EXPIRED = 'This reset link is invalid or has expired.'; +const RESET_SUBJECT = `Reset your ${ORG_NAME} Outpost password`; +const CHANGED_SUBJECT = `Your ${ORG_NAME} Outpost password was changed`; +const BUTTON_TITLE = 'Send password reset link'; + +async function requestReset(browser: Browser, email: string) { + const context = await newContext(browser); + const page = await context.newPage(); + await page.goto('/forgot-password'); + await expect(page).toHaveURL(/\/forgot-password$/); + await page.fill('#email', email); + await page.getByRole('button', { name: 'Send reset link' }).click(); + await expect(page.getByText(NEUTRAL)).toBeVisible(); + await context.close(); +} + +async function setPasswordFromLink(browser: Browser, link: string, password: string) { + const context = await newContext(browser); + const page = await context.newPage(); + await page.goto(link); + await expect(page.getByLabel('New password', { exact: true })).toBeVisible(); + expect(page.url()).not.toContain('#token'); + await page.getByLabel('New password', { exact: true }).fill(password); + await page.getByLabel('Confirm new password', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Set new password' }).click(); + await page.waitForURL('**/login?reset=1'); + await expect( + page.getByText('Your password has been reset. Sign in with your new password.'), + ).toBeVisible(); + await context.close(); +} + +async function expectSignInFails(browser: Browser, email: string, password: string) { + const context = await newContext(browser); + const page = await context.newPage(); + await page.goto('/login'); + await page.fill('#email', email); + await page.fill('#password', password); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForURL(/error=CredentialsSignin/); + await context.close(); +} + +test('0: the built image sends email over SMTP', async () => { + // globalSetup invited every member; on a working image the invite reached Mailpit. + const mail = await waitForMail(USERS.selfService, "You've been invited"); + expect(mail.Text).toContain('/invite/accept?token='); +}); + +test('RED 2: /forgot-password is public', async ({ browser }) => { + const context = await newContext(browser); + const page = await context.newPage(); + await page.goto('/forgot-password'); + await expect(page).toHaveURL(/\/forgot-password$/); + await expect(page.getByRole('heading', { name: 'Reset your password' })).toBeVisible(); + await context.close(); +}); + +test('RED 1 / GREEN 1-9: self-service reset, single use, all sessions revoked', async ({ + browser, +}) => { + const email = USERS.selfService; + const contextA = await signIn(browser, email, MEMBER_PASSWORD); + expect(await profileStatus(contextA)).toBe(200); + + const contextB = await newContext(browser); + const b = await contextB.newPage(); + await b.goto('/login'); + await b.getByRole('link', { name: 'Forgot password?' }).click(); + await b.waitForURL('**/forgot-password**'); + await b.fill('#email', email); + await b.getByRole('button', { name: 'Send reset link' }).click(); + await expect(b.getByText(NEUTRAL)).toBeVisible(); + + await requestReset(browser, 'nobody@e2e.test'); + + const link = resetLinkFrom(await waitForMail(email, RESET_SUBJECT)); + expect(link).toMatch(/^http:\/\/localhost:3100\/reset-password#token=[0-9a-f]{64}$/); + // The unknown request's after() started after the member's, so it has had less time. + // Wait 2 s more so that a mistaken send to the unknown address can arrive before we check. + await new Promise((resolve) => setTimeout(resolve, 2000)); + expect(await messagesTo('nobody@e2e.test')).toHaveLength(0); + + const newPassword = 'self-pass-2'; + await setPasswordFromLink(browser, link, newPassword); + + await (await signIn(browser, email, newPassword)).close(); + await expectSignInFails(browser, email, MEMBER_PASSWORD); + + const reopen = await newContext(browser); + const reopenPage = await reopen.newPage(); + await reopenPage.goto(link); + await expect(reopenPage.getByText(EXPIRED)).toBeVisible(); + await reopen.close(); + + expect(await profileStatus(contextA)).toBe(401); + const session = await contextA.request.get('/api/auth/session'); + expect(await session.json()).toEqual({}); + const a = await contextA.newPage(); + await a.goto('/dashboard'); + await a.waitForURL('**/login**'); + + await waitForMail(email, CHANGED_SUBJECT); + await contextA.close(); + await contextB.close(); +}); + +test('GREEN 10: an expired link shows the expired state', async ({ browser }) => { + await requestReset(browser, USERS.expiry); + const link = resetLinkFrom(await waitForMail(USERS.expiry, RESET_SUBJECT)); + expect( + sql( + `UPDATE "PasswordResetToken" SET "expiresAt" = now() - interval '1 minute' ` + + `WHERE "usedAt" IS NULL AND "memberId" = (SELECT "id" FROM "TeamMember" WHERE "email" = '${USERS.expiry}')`, + ), + ).toBe('UPDATE 1'); + + const context = await newContext(browser); + const page = await context.newPage(); + await page.goto(link); + await expect(page.getByText(EXPIRED)).toBeVisible(); + await expect(page.getByRole('link', { name: 'Request a new link' })).toBeVisible(); + await context.close(); +}); + +test('RED 3 / GREEN 11: profile change keeps this session and revokes the others', async ({ + browser, +}) => { + const email = USERS.profile; + const contextA = await signIn(browser, email, MEMBER_PASSWORD); + const contextB = await signIn(browser, email, MEMBER_PASSWORD); + const newPassword = 'profile-pass-2'; + + const a = await contextA.newPage(); + await a.goto('/settings/profile'); + await a.fill('#currentPassword', MEMBER_PASSWORD); + await a.fill('#newPassword', newPassword); + await a.fill('#confirmNewPassword', newPassword); + await a.getByRole('button', { name: /Save Changes/ }).click(); + // Either message, so on main the test reaches the revocation assertion below. + await expect( + a.getByText( + /Password changed\. Your other sessions have been signed out\.|Profile updated successfully\./, + ), + ).toBeVisible(); + + expect(await profileStatus(contextB)).toBe(401); + await expect( + a.getByText('Password changed. Your other sessions have been signed out.'), + ).toBeVisible(); + expect(await profileStatus(contextA)).toBe(200); + + const a2 = await contextA.newPage(); + await a2.goto('/dashboard'); + await expect(a2).toHaveURL(/\/dashboard/); + const b2 = await contextB.newPage(); + await b2.goto('/dashboard'); + await b2.waitForURL('**/login**'); + + await (await signIn(browser, email, newPassword)).close(); + await contextA.close(); + await contextB.close(); +}); + +test('RED 4 / GREEN 12: admin sends a reset link; sending revokes nothing, using it does', async ({ + browser, +}) => { + const email = USERS.adminSend; + const contextC = await signIn(browser, email, MEMBER_PASSWORD); + const admin = await signIn(browser, ADMIN.email, ADMIN.password); + const page = await admin.newPage(); + await page.goto('/settings/team'); + + const memberRow = page.locator('tr', { hasText: email }); + await expect(memberRow.getByTitle(BUTTON_TITLE)).toBeVisible(); + await expect(page.locator('tr', { hasText: ADMIN.email }).getByTitle(BUTTON_TITLE)).toHaveCount( + 0, + ); + await expect( + page.locator('tr', { hasText: PENDING_INVITE }).getByTitle(BUTTON_TITLE), + ).toHaveCount(0); + + page.once('dialog', (dialog) => dialog.accept()); + const responsePromise = page.waitForResponse( + (r) => r.url().endsWith('/password-reset') && r.request().method() === 'POST', + ); + await memberRow.getByTitle(BUTTON_TITLE).click(); + const response = await responsePromise; + expect(response.status()).toBe(200); + expect(await response.json()).toEqual({ ok: true, emailSent: true }); + await expect( + page.getByText(`Password reset link sent to ${email}. It expires in 30 minutes.`), + ).toBeVisible(); + + const mail = await waitForMail(email, RESET_SUBJECT); + expect((await messagesTo(email)).filter((m) => m.Subject.includes(RESET_SUBJECT))).toHaveLength( + 1, + ); + expect(await profileStatus(contextC)).toBe(200); + + await setPasswordFromLink(browser, resetLinkFrom(mail), 'adminsend-pass-2'); + expect(await profileStatus(contextC)).toBe(401); + expect(await profileStatus(admin)).toBe(200); + + await contextC.close(); + await admin.close(); +}); diff --git a/apps/web/e2e/support.ts b/apps/web/e2e/support.ts new file mode 100644 index 00000000..1864e6dc --- /dev/null +++ b/apps/web/e2e/support.ts @@ -0,0 +1,145 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Browser, BrowserContext } from '@playwright/test'; + +/** + * Build the image under test from the repo root, then run the spec with its tag: + * + * docker build -f apps/web/Dockerfile --build-arg NEXT_PUBLIC_AUTH_PROVIDER=credentials -t outpost-web:e2e-branch . + * (cd apps/web && OUTPOST_E2E_IMAGE=outpost-web:e2e-branch pnpm e2e) + * + * E2E_KEEP=1 leaves the Compose stack running after the run, for debugging. + */ +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '../../..'); + +// Own ports and project, never the dev stack's (see docker-compose.e2e.yml). +export const BASE_URL = 'http://localhost:3100'; +const MAILPIT = 'http://localhost:18025'; + +export const composeArgs = [ + 'compose', + '-p', + 'outpost-e2e', + '--project-directory', + repoRoot, + '-f', + path.join(here, 'docker-compose.e2e.yml'), +]; + +export const ADMIN = { email: 'admin@e2e.test', password: 'admin-pass-1' }; +export const MEMBER_PASSWORD = 'member-pass-1'; +/** One member per test, so a failed test cannot change another test's starting state. */ +export const USERS = { + selfService: 'self@e2e.test', + expiry: 'expiry@e2e.test', + profile: 'profile@e2e.test', + adminSend: 'adminsend@e2e.test', +} as const; +export const PENDING_INVITE = 'pending@e2e.test'; +export const ORG_NAME = 'E2E Org'; + +export interface MailSummary { + ID: string; + Subject: string; + To: Array<{ Address: string }>; +} + +export interface MailMessage { + ID: string; + Subject: string; + Text: string; + HTML: string; +} + +export function newContext(browser: Browser): Promise { + return browser.newContext({ baseURL: BASE_URL }); +} + +export async function messagesTo(address: string): Promise { + const res = await fetch(`${MAILPIT}/api/v1/messages?limit=500`); + const body = (await res.json()) as { messages: MailSummary[] }; + return body.messages.filter((m) => + m.To.some((t) => t.Address.toLowerCase() === address.toLowerCase()), + ); +} + +/** Poll Mailpit, bounded, for a message to `address` whose subject contains `subject`. */ +export async function waitForMail( + address: string, + subject: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const match = (await messagesTo(address)).find((m) => m.Subject.includes(subject)); + if (match) { + return (await ( + await fetch(`${MAILPIT}/api/v1/message/${match.ID}`) + ).json()) as MailMessage; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error( + `No email to ${address} with subject containing "${subject}" within ${timeoutMs} ms`, + ); +} + +export function resetLinkFrom(mail: MailMessage): string { + const match = mail.Text.match(/http:\/\/localhost:3100\/reset-password#token=[0-9a-f]+/); + if (!match) throw new Error('No reset link in the email text'); + return match[0]; +} + +/** Run one SQL statement in the e2e Postgres container and return its output. */ +export function sql(query: string): string { + return execFileSync( + 'docker', + [ + ...composeArgs, + 'exec', + '-T', + 'postgres', + 'psql', + '-U', + 'outpost', + '-d', + 'outpost', + '-tAc', + query, + ], + { encoding: 'utf8' }, + ).trim(); +} + +export async function signIn( + browser: Browser, + email: string, + password: string, +): Promise { + const context = await newContext(browser); + const page = await context.newPage(); + await page.goto('/login'); + await page.fill('#email', email); + await page.fill('#password', password); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForURL('**/dashboard'); + await page.close(); + return context; +} + +/** + * Status of GET /api/profile for this context's session. Redirects are not followed: with no + * session the middleware answers 307 to /login, and following it would read /login's 200. + */ +export async function profileStatus(context: BrowserContext): Promise { + return (await context.request.get('/api/profile', { maxRedirects: 0 })).status(); +} + +/** The double-submit CSRF header; the middleware sets the `csrf` cookie on any page load. */ +export async function csrfHeader(context: BrowserContext): Promise> { + const value = (await context.cookies()).find((c) => c.name === 'csrf')?.value; + if (!value) throw new Error('No csrf cookie: load a page in this context first'); + return { 'X-CSRF-Token': value }; +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 970e9652..e5dd3e95 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -22,6 +22,14 @@ const nextConfig: NextConfig = { }, ], }, + { + // Defense in depth for the reset page: the token is in the fragment, which + // never reaches Referer, but nothing on this page should leak a referrer. + // Must come AFTER the global entry: for the same header key, the later + // matching entry wins. No Cache-Control here: Next overwrites it for pages. + source: '/reset-password', + headers: [{ key: 'Referrer-Policy', value: 'no-referrer' }], + }, ]; }, webpack: (config, { isServer }) => { @@ -39,11 +47,10 @@ const nextConfig: NextConfig = { 'utf-8-validate', '@slack/web-api', '@slack/bolt', - // nodemailer stays nulled: it is not installed, and the SMTP transport - // only matters for self-hosted setups. postmark is NOT here: sendEmail - // (transpiled from @copilotkit/outpost) loads it in this server bundle, - // and nulling it made every Postmark send fail (outpost#253). - 'nodemailer', + // postmark and nodemailer are NOT here: sendEmail (transpiled from + // @copilotkit/outpost) loads them in this server bundle. Nulling postmark + // made every Postmark send fail (outpost#253); nulling nodemailer made every + // SMTP send fail (outpost#206). nodemailer is a dependency of @copilotkit/outpost. ]; for (const pkg of nullPkgs) { config.resolve.alias[pkg] = false; diff --git a/apps/web/package.json b/apps/web/package.json index 230220a2..15939849 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,8 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "e2e": "playwright test" }, "dependencies": { "@copilotkit/outpost": "workspace:*", @@ -27,6 +28,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^22.10.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 00000000..21f40a34 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test'; + +/** + * One end-to-end spec against the production web image (outpost#206). Not run in CI; + * see apps/web/e2e/support.ts for how to build and select the image. + */ +export default defineConfig({ + testDir: './e2e', + // Tests share one database and one mail sink, so they run one at a time, in order. + workers: 1, + fullyParallel: false, + retries: 0, + timeout: 60_000, + reporter: [['list']], + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', + use: { + baseURL: 'http://localhost:3100', // e2e web port (e2e/docker-compose.e2e.yml) + trace: 'retain-on-failure', + }, +}); diff --git a/apps/web/src/__tests__/admin-reset-api.test.ts b/apps/web/src/__tests__/admin-reset-api.test.ts new file mode 100644 index 00000000..6e065ab6 --- /dev/null +++ b/apps/web/src/__tests__/admin-reset-api.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockGetServerSession = vi.fn(); +vi.mock('next-auth', () => ({ + getServerSession: (...args: unknown[]) => mockGetServerSession(...args), +})); + +const mockOrgFindFirst = vi.fn(); +const mockMemberFindUnique = vi.fn(); +const mockTxCreate = vi.fn(); +const mockTxExecute = vi.fn(); + +vi.mock('@copilotkit/outpost/db', () => ({ + prisma: { + organization: { findFirst: (...a: unknown[]) => mockOrgFindFirst(...a) }, + teamMember: { findUnique: (...a: unknown[]) => mockMemberFindUnique(...a) }, + $transaction: async (fn: (tx: unknown) => Promise) => + fn({ + $executeRawUnsafe: (...a: unknown[]) => mockTxExecute(...a), + teamMember: { create: (...a: unknown[]) => mockTxCreate(...a) }, + }), + }, +})); + +vi.mock('@copilotkit/outpost/shared', () => ({ verifyPassword: vi.fn() })); + +import { POST } from '@/app/api/admin/reset/route'; +import { authOptions } from '@/lib/auth'; + +const adminRow = { + id: 'admin-1', + name: 'Admin', + email: 'admin@x.io', + role: 'ADMIN', + status: 'ACTIVE', + avatarUrl: null, + passwordHash: 'h', + joinedAt: new Date('2026-01-01T00:00:00Z'), + sessionVersion: 3, +}; + +describe('POST /api/admin/reset', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetServerSession.mockResolvedValue({ user: { role: 'ADMIN', memberId: 'admin-1' } }); + mockOrgFindFirst.mockResolvedValue(null); + mockMemberFindUnique.mockResolvedValue(adminRow); + }); + + it("recreates the admin with the saved sessionVersion, so the admin's own token still passes", async () => { + const res = await POST( + new Request('http://localhost:3000/api/admin/reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ confirmation: 'RESET' }), + }), + ); + expect(res.status).toBe(200); + + const created = mockTxCreate.mock.calls[0][0].data as Record; + expect(created.sessionVersion).toBe(3); + + // Replay the row as the database now holds it (Prisma default 0 if omitted). + mockMemberFindUnique.mockResolvedValue({ + sessionVersion: (created.sessionVersion as number | undefined) ?? 0, + status: created.status, + }); + await expect( + authOptions.callbacks!.jwt!({ token: { memberId: 'admin-1', sv: 3 } } as never), + ).resolves.toMatchObject({ memberId: 'admin-1' }); + }); +}); diff --git a/apps/web/src/__tests__/app-shell.test.tsx b/apps/web/src/__tests__/app-shell.test.tsx new file mode 100644 index 00000000..41487839 --- /dev/null +++ b/apps/web/src/__tests__/app-shell.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AppShell } from '@/components/app-shell'; + +const mockPathname = vi.fn().mockReturnValue('/dashboard'); +vi.mock('next/navigation', () => ({ + usePathname: () => mockPathname(), +})); + +// The shell's own logic is only which paths get the sidebar; stand in for the chrome. +vi.mock('@/components/sidebar', () => ({ + Sidebar: () =>