Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ jobs:
OIDC_ISSUER_URL: http://localhost:4000
OIDC_CLIENT_ID: test-only-not-a-real-id
OIDC_CLIENT_SECRET: test-only-not-a-real-secret
NEXT_PUBLIC_OIDC_PROVIDER_ID: oidc
OIDC_PROVIDER_ID: oidc
BETTER_AUTH_URL: http://localhost:3000
BETTER_AUTH_SECRET: test-only-not-a-real-better-auth-secret
USE_OLLAMA: "true"
OLLAMA_MODEL: qwen2.5:1.5b
USE_E2E_MODEL: "true"
E2E_MODEL_NAME: qwen2.5:1.5b
OLLAMA_BASE_URL: http://localhost:11434
steps:
- name: Checkout
Expand Down Expand Up @@ -64,6 +64,13 @@ jobs:
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium

- name: Build production app
run: pnpm build
env:
# These env vars must match the runtime values for token encryption/decryption to work
BETTER_AUTH_SECRET: test-only-not-a-real-better-auth-secret
OIDC_PROVIDER_ID: oidc

- name: Run Playwright tests
run: pnpm test:e2e

Expand Down
11 changes: 5 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,12 @@ pnpm generate-client:nofetch # Regenerate without fetching

### E2E Tests (Playwright)

- End-to-end tests live under `tests/e2e` and run against a live dev stack.
- End-to-end tests live under `tests/e2e` and run against a **production build**.
- Commands:
- `pnpm dev` – starts Next.js (3000), mock OIDC (4000), and MSW mock API (9090)
- `pnpm run test:e2e` – runs Playwright tests (headless)
- `pnpm run test:e2e:ui` – opens Playwright UI mode for interactive debugging
- `pnpm run test:e2e:debug` – runs with Playwright Inspector
- CI runs E2E tests via `.github/workflows/bdd.yml` and installs Playwright browsers.
- `pnpm test:e2e` – builds the app and runs E2E tests
- `pnpm test:e2e:ui` – builds and opens Playwright UI mode for interactive debugging
- `pnpm test:e2e:debug` – builds and runs with Playwright Inspector
- CI runs E2E tests via `.github/workflows/e2e.yml` (builds first, then tests)
- Install browsers locally once: `pnpm exec playwright install`

Tests use custom fixtures for authentication. The `authenticatedPage` fixture handles login automatically.
Expand Down
13 changes: 8 additions & 5 deletions dev-auth/oidc-provider.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
import { config } from "dotenv";
import Provider from "oidc-provider";

config();
config({ path: ".env.local" });
// Load env files but don't override existing env vars (e.g., from playwright)
config({ override: false });
config({ path: ".env.local", override: false });

const ISSUER = process.env.OIDC_ISSUER_URL || "http://localhost:4000";
const PORT = new URL(ISSUER).port || 4000;
Expand Down Expand Up @@ -106,9 +107,12 @@ const configuration = {
profile: ["name"],
},
ttl: {
// Short-lived access tokens to force refresh during dev
AccessToken: 15, // seconds
AccessToken: 15, // seconds - short-lived to exercise refresh flow
RefreshToken: 86400 * 30, // 30 days
Interaction: 3600, // 1 hour
Session: 86400 * 14, // 14 days
Grant: 86400 * 14, // 14 days
IdToken: 3600, // 1 hour
},
// Dev-only: always issue refresh tokens to make the flow reliable locally
issueRefreshToken: async () => true,
Expand All @@ -119,7 +123,6 @@ const oidc = new Provider(ISSUER, configuration);
// Simple interaction endpoint for dev - auto-login as test-user
oidc.use(async (ctx, next) => {
if (ctx.path.startsWith("/interaction/")) {
const _uid = ctx.path.split("/")[2];
const interaction = await oidc.interactionDetails(ctx.req, ctx.res);

if (interaction.prompt.name === "login") {
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
"lint": "biome check",
"format": "biome format --write",
"test": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e": "BETTER_AUTH_SECRET=e2e-test-secret-at-least-32-chars-long OIDC_PROVIDER_ID=okta BETTER_AUTH_RATE_LIMIT=100 pnpm build && playwright test",
"test:e2e:ui": "BETTER_AUTH_SECRET=e2e-test-secret-at-least-32-chars-long OIDC_PROVIDER_ID=okta BETTER_AUTH_RATE_LIMIT=100 pnpm build && playwright test --ui",
"test:e2e:debug": "BETTER_AUTH_SECRET=e2e-test-secret-at-least-32-chars-long OIDC_PROVIDER_ID=okta BETTER_AUTH_RATE_LIMIT=100 pnpm build && playwright test --debug",
"start:e2e": "concurrently -n \"OIDC,Mock,Next\" -c \"blue,magenta,green\" \"pnpm oidc\" \"pnpm mock:server\" \"pnpm start\"",
"test:coverage": "vitest run --coverage",
"type-check": "tsc --noEmit",
"prepare": "husky",
Expand Down
10 changes: 7 additions & 3 deletions playwright.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export default defineConfig({
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
timeout: 30_000,
use: {
Expand All @@ -35,7 +34,8 @@ export default defineConfig({
webServer: serverAlreadyRunning
? undefined
: {
command: "pnpm dev",
// Run against production build - requires `pnpm build` to be run first
command: "pnpm start:e2e",
url: BASE_URL,
timeout: 120_000,
stdout: "pipe",
Expand All @@ -45,9 +45,13 @@ export default defineConfig({
OIDC_ISSUER_URL: "http://localhost:4000",
OIDC_CLIENT_ID: "better-auth-dev",
OIDC_CLIENT_SECRET: "dev-secret-change-in-production",
NEXT_PUBLIC_OIDC_PROVIDER_ID: "okta",
OIDC_PROVIDER_ID: "okta",
BETTER_AUTH_URL: "http://localhost:3000",
BETTER_AUTH_SECRET: "e2e-test-secret-at-least-32-chars-long",
// Better Auth rate limits sign-in to 3 requests per 10 seconds by default.
// E2E tests with multiple authenticatedPage fixtures exceed this limit,
// causing 429 errors. Set to 100 to allow rapid sequential logins.
BETTER_AUTH_RATE_LIMIT: "100",
// Always use testing model for E2E tests to avoid needing OpenRouter API keys
USE_E2E_MODEL: "true",
E2E_MODEL_NAME: process.env.E2E_MODEL_NAME ?? "qwen2.5:1.5b",
Expand Down
16 changes: 16 additions & 0 deletions src/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type Auth, type BetterAuthOptions, betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import {
BASE_URL,
BETTER_AUTH_RATE_LIMIT,
BETTER_AUTH_SECRET,
IS_PRODUCTION,
OIDC_CLIENT_ID,
Expand Down Expand Up @@ -162,6 +163,21 @@ export const auth: Auth<BetterAuthOptions> = betterAuth({
secret: BETTER_AUTH_SECRET,
baseURL: BASE_URL,
...(pool && { database: pool }),
// Rate limit override for E2E tests.
// Better Auth's default rate limit for /sign-in/* is 3 requests per 10 seconds.
// This is too restrictive for E2E tests where multiple tests authenticate in
// quick succession. We use customRules because the default special rules for
// sign-in paths take precedence over the global max setting.
...(BETTER_AUTH_RATE_LIMIT && {
rateLimit: {
customRules: {
"/sign-in/*": {
max: BETTER_AUTH_RATE_LIMIT,
window: 10,
},
},
},
}),
account: {
storeStateStrategy: pool ? "database" : "cookie",
storeAccountCookie: !pool,
Expand Down
13 changes: 13 additions & 0 deletions src/lib/auth/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ export const COOKIE_SECURE =
// Database configuration (optional - enables database mode for large OIDC tokens)
export const DATABASE_URL = process.env.DATABASE_URL;

// Rate limiting configuration
//
// Better Auth has a default rate limit of 3 requests per 10 seconds for sign-in
// endpoints. This causes E2E test failures when multiple tests authenticate in
// quick succession (e.g., 3 tests using authenticatedPage fixture followed by
// a login test = 4 sign-ins, triggering 429 Too Many Requests).
//
// Set BETTER_AUTH_RATE_LIMIT to a higher value (e.g., 100) for E2E tests.
// See: node_modules/better-auth/dist/api/rate-limiter/index.mjs
export const BETTER_AUTH_RATE_LIMIT = process.env.BETTER_AUTH_RATE_LIMIT
? Number.parseInt(process.env.BETTER_AUTH_RATE_LIMIT, 10)
: undefined;

// Trusted origins for Better Auth
const trustedOriginsFromEnv = process.env.TRUSTED_ORIGINS
? process.env.TRUSTED_ORIGINS.split(",").map((s) => s.trim())
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export const test = base.extend<{ authenticatedPage: Page }>({
});

export { expect } from "@playwright/test";
export { login };
5 changes: 2 additions & 3 deletions tests/e2e/login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { expect, test } from "./fixtures";
import { expect, login, test } from "./fixtures";

test.describe("Login flow", () => {
test("sign in and land on Catalog", async ({ page }) => {
await page.goto("/signin");
await page.getByRole("button", { name: /oidc|okta/i }).click();
await login(page);
await expect(page).toHaveURL(/\/catalog$/);
await expect(
page.getByRole("heading", { name: "MCP Server Catalog" }),
Expand Down
Loading