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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,10 @@ PUBLIC_ORIGIN=
SSE_SESSION_RECHECK_SECONDS=15

LOG_LEVEL=info

# Used by `npm run e2e:docker` — credentials for a real backend user the e2e
# auth fixture logs in as, and docker-compose.e2e.yml's gateway admin
# password — must be 22+ chars (privileged-account minimum) and not contain
# the email's local part. See e2e/README.md.
E2E_TEST_EMAIL=
E2E_TEST_PASSWORD=
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ COPY --chown=1001:0 openapi.json orval.config.ts index.html vite.config.ts build
COPY --chown=1001:0 tsconfig.json tsconfig.app.json tsconfig.node.json ./
COPY --chown=1001:0 public ./public
COPY --chown=1001:0 src ./src
# Off by default; docker-compose.e2e.yml passes --build-arg true for e2e:docker. Must be ARG — Vite bakes it in at build time.
ARG VITE_ENABLE_TOOL_PREVIEW=false
ENV VITE_ENABLE_TOOL_PREVIEW=${VITE_ENABLE_TOOL_PREVIEW}
RUN npm run build

# ---- BFF dependencies ----
Expand Down
60 changes: 60 additions & 0 deletions docker-compose.e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Standalone stack for `npm run e2e:docker` — own app/redis, independent of
# docker-compose.yml, so a plain `docker compose down` can't touch either one.
name: e2e

services:
app:
build:
context: .
dockerfile: Dockerfile
args:
VITE_ENABLE_TOOL_PREVIEW: "true"
ports:
- "${PORT:-3000}:${PORT:-3000}"
env_file:
- .env
environment:
REDIS_URL: redis://redis:6379/0
CONTEXTFORGE_URL: http://gateway:4444
depends_on:
redis:
condition: service_healthy
restart: "no"

redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
restart: "no"
volumes:
- redis-data:/data

gateway:
# Keep in sync with openapi.json's `info.version` — the API surface this repo's BFF proxies to.
image: ghcr.io/ibm/mcp-context-forge:v1.0.8
environment:
HOST: 0.0.0.0
DATABASE_URL: sqlite:///./data/mcp.db
# `:-` not `-`: E2E_TEST_EMAIL/PASSWORD ship blank in .env.example, so
# fall back even when set-but-empty.
PLATFORM_ADMIN_EMAIL: ${E2E_TEST_EMAIL:-test@example.com}
# Throwaway first-boot password. e2e/seed/seed.ts changes it to
# E2E_TEST_PASSWORD before tests run — real logins never use this one.
PLATFORM_ADMIN_PASSWORD: ${E2E_BOOTSTRAP_PASSWORD:-changeme-e2e-bootstrap-pwd1}
PLATFORM_ADMIN_FULL_NAME: E2E Test User
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-e2e-testing-secret-key-not-for-prod}
AUTH_ENCRYPTION_SECRET: ${AUTH_ENCRYPTION_SECRET:-e2e-testing-secret-not-for-prod-32ch}
MCPGATEWAY_UI_ENABLED: "true"
MCPGATEWAY_ADMIN_API_ENABLED: "true"
AUTH_REQUIRED: "true"
# Parallel workers each logging in for real blow past the login rate limit and lock the account.
RATE_LIMITING_ENABLED: "false"
ports:
- "4444:4444"
restart: "no"

volumes:
redis-data:
24 changes: 24 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ existing server instead:
PLAYWRIGHT_BASE_URL=http://localhost:4444 PLAYWRIGHT_SKIP_WEBSERVER=1 npm run e2e
```

### Real-backend mode (`e2e:docker`)

`npm run e2e:docker` runs the same suite against a real backend instead of
`page.route()` stubs: `docker-compose.e2e.yml` is a standalone stack (`app`,
`redis`, and a real gateway — official `ghcr.io/ibm/mcp-context-forge`
image), entirely independent of `docker-compose.yml` and under its own
`e2e` Compose project, so it can never collide with a plain dev stack's
containers or volumes either way. It seeds a login user, runs the suite
against the containers (`E2E_REAL_API=true`), then tears the stack down.
The `apiMock`/`auth` fixtures skip stubbing for the success path in this
mode; the auth fixture logs in for real using
`E2E_TEST_EMAIL`/`E2E_TEST_PASSWORD`.

```bash
npm run e2e:docker
```

Requires `E2E_TEST_EMAIL`/`E2E_TEST_PASSWORD` set in `.env` — the seed
script bootstraps that user as the gateway's admin (22+ chars, no email
local-part — see `.env.example`).

The `gateway` image tag in `docker-compose.e2e.yml` is pinned to match
`openapi.json`'s `info.version` — bump both together.

## Writing a new test

Import the `test` and `expect` helpers from the fixture that matches your needs:
Expand Down
12 changes: 9 additions & 3 deletions e2e/auth/login-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { test, expect } from "../fixtures/api-mock";
import { APP, TOKEN_STORAGE_KEY } from "../utils/paths";

const IS_REAL_API = process.env.E2E_REAL_API === "true";
// Only the "successful login" test needs a user that really exists.
const VALID_EMAIL = IS_REAL_API ? (process.env.E2E_TEST_EMAIL ?? "") : "test@example.com";
const VALID_PASSWORD = IS_REAL_API ? (process.env.E2E_TEST_PASSWORD ?? "") : "password123";

test.describe("Login flow", () => {
test.beforeEach(async ({ page, apiMock }) => {
await apiMock.mockSession({ authenticated: false });
Expand All @@ -13,12 +18,13 @@ test.describe("Login flow", () => {
await apiMock.mockLogin();

await page.goto(APP.LOGIN);
await page.getByLabel(/email address/i).fill("test@example.com");
await page.getByLabel(/password/i).fill("password123");
await page.getByLabel(/email address/i).fill(VALID_EMAIL);
await page.getByLabel(/password/i).fill(VALID_PASSWORD);
await page.getByRole("button", { name: /^sign in$/i }).click();

await expect(page).toHaveURL(new RegExp(`${APP.ROOT}$`));
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
// Not a heading match — that text is data-dependent. Home nav is the stable "landed, not bounced to /login" signal.
await expect(page.getByRole("button", { name: "Home" })).toBeVisible();

const token = await page.evaluate(
(key) => window.sessionStorage.getItem(key),
Expand Down
11 changes: 10 additions & 1 deletion e2e/auth/password-change-required.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { test, expect } from "../fixtures/api-mock";
import { realLogin } from "../fixtures/real-login";
import { APP, TOKEN_STORAGE_KEY } from "../utils/paths";

const IS_REAL_API = process.env.E2E_REAL_API === "true";

test.describe("Password change required flow", () => {
test.beforeEach(async ({ page, apiMock }) => {
await apiMock.mockSession({ authenticated: false });
Expand All @@ -14,17 +17,23 @@ test.describe("Password change required flow", () => {
apiMock,
}) => {
await apiMock.mockChangePasswordRequired();
await apiMock.mockPermissions();

await page.goto(`${APP.CHANGE_PASSWORD_REQUIRED}?email=test%40example.com`);
await expect(page.getByLabel(/email address/i)).toHaveValue("test@example.com");

// The mocked change-password-required response never touches the real
// backend, so log in for real too or other unmocked calls 401 and bounce us to login.
if (IS_REAL_API) await realLogin(page);

await page.getByLabel(/current password/i).fill("old-password");
await page.getByLabel(/^new password/i).fill("New-password1");
await page.getByLabel(/confirm new password/i).fill("New-password1");
await page.getByRole("button", { name: /change password/i }).click();

await expect(page).toHaveURL(new RegExp(`${APP.ROOT}$`));
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
// Not a heading match — that text is data-dependent. Home nav is the stable "landed, not bounced to /login" signal.
await expect(page.getByRole("button", { name: "Home" })).toBeVisible();
});

test("password changed but auto sign-in failed shows a fallback screen back to login", async ({
Expand Down
3 changes: 2 additions & 1 deletion e2e/auth/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { APP, TOKEN_STORAGE_KEY } from "../utils/paths";
test.describe("Authenticated session", () => {
test("cookie session check lets a user reach the dashboard", async ({ page }) => {
await page.goto(APP.ROOT);
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
// Not a heading match — that text is data-dependent. Home nav is the stable "landed, not bounced to /login" signal.
await expect(page.getByRole("button", { name: "Home" })).toBeVisible();
// Polled rather than read once: Vite can trigger a full reload on this page
// (dep pre-bundling), which destroys the execution context mid-evaluate.
await expect
Expand Down
31 changes: 31 additions & 0 deletions e2e/fixtures/api-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@
* Uses `page.route()` so tests run without a live backend. The payload
* shapes mirror the BFF's auth routes (client/server/src/routes/auth/) and
* `client/src/auth/AuthContext.tsx` (`User`, `LoginResponse`, `SessionResponse`).
*
* When E2E_REAL_API=true, methods testing the success path (default
* `mockSession`, `mockLogin({status: 200})`) do a real login instead of
* stubbing; error-status stubs stay mocked either way.
*/

import { test as base, expect, type Page } from "@playwright/test";
import { realLogin } from "./real-login";

const IS_REAL_API = process.env.E2E_REAL_API === "true";

export interface MockUser {
email: string;
Expand Down Expand Up @@ -49,6 +56,8 @@ export interface ApiMock {
*/
mockPermissions(options?: { permissions?: string[] }): Promise<void>;
mockUnauthorized(urlPattern: string | RegExp): Promise<void>;
/** Real csrfToken from this test's real login — compare against this instead of MOCK_CSRF_TOKEN when IS_REAL_API. */
getRealCsrfToken(): Promise<string | undefined>;
/**
* Mocks POST /auth/change-password-required, the BFF's route used by
* PasswordChangeRequired.tsx (client/src/pages/) after a "password change
Expand All @@ -64,12 +73,17 @@ export interface ApiMock {
}

export function createApiMock(page: Page): ApiMock {
let realSessionResponse: Promise<{ csrfToken?: string }> | undefined;
return {
async mockLogin({
user = DEFAULT_TEST_USER,
status = 200,
detail = "Invalid credentials",
} = {}) {
// Only the success path skips stubbing (real login must hit the real
// backend); explicit error statuses test client rendering and stay
// mocked either way.
if (IS_REAL_API && status === 200) return;
await page.route("**/auth/login", async (route) => {
if (status === 200) {
await route.fulfill({
Expand All @@ -91,6 +105,18 @@ export function createApiMock(page: Page): ApiMock {
},

async mockSession({ user = DEFAULT_TEST_USER, authenticated = true } = {}) {
if (IS_REAL_API) {
// Real login when authenticated, no-op when false, so specs testing
// the logged-out state stay logged out.
if (authenticated) {
// AuthContext's own /auth/session call sets ITS token, not realLogin()'s — arm before it fires.
realSessionResponse = page
.waitForResponse((response) => /\/auth\/session(?:\?|$)/.test(response.url()))
.then((response) => response.json() as Promise<{ csrfToken?: string }>);
await realLogin(page);
}
return;
}
await page.route("**/auth/session", async (route) => {
await route.fulfill({
status: 200,
Expand Down Expand Up @@ -137,6 +163,7 @@ export function createApiMock(page: Page): ApiMock {
},

async mockUnauthorized(urlPattern) {
if (IS_REAL_API) return;
await page.route(urlPattern, async (route) => {
await route.fulfill({
status: 401,
Expand All @@ -145,6 +172,10 @@ export function createApiMock(page: Page): ApiMock {
});
});
},

async getRealCsrfToken() {
return (await realSessionResponse)?.csrfToken;
},
};
}

Expand Down
31 changes: 23 additions & 8 deletions e2e/fixtures/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,42 @@
*
* Tests import from here when they need to skip the login form and land
* directly on an authenticated route.
*
* When E2E_REAL_API=true (npm run e2e:docker), a real login is performed
* against the live BFF instead of stubbing, and the session cookie carries into `page`.
*/

import { test as base } from "@playwright/test";
import { test as base, type Page } from "@playwright/test";
import { createApiMock, type ApiMock } from "./api-mock";

// Keyed by page (not fixture-to-fixture — Playwright rejects that as a cycle) so either fixture triggers setup once.
const setupByPage = new WeakMap<Page, Promise<ApiMock>>();

function setupOnce(page: Page): Promise<ApiMock> {
let setup = setupByPage.get(page);
if (!setup) {
setup = (async () => {
const mock = createApiMock(page);
await mock.mockSession();
await mock.mockLogin();
return mock;
})();
setupByPage.set(page, setup);
}
return setup;
}

type AuthFixtures = {
apiMock: ApiMock;
};

export const test = base.extend<AuthFixtures>({
page: async ({ page }, use) => {
const mock = createApiMock(page);
await mock.mockSession();
await mock.mockLogin();
await setupOnce(page);
await use(page);
},
apiMock: async ({ page }, use) => {
const mock = createApiMock(page);
await mock.mockSession();
await mock.mockLogin();
await use(mock);
await use(await setupOnce(page));
},
});

Expand Down
24 changes: 24 additions & 0 deletions e2e/fixtures/real-login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Real-backend login, shared by api-mock.ts and auth.ts. Logs in against
* the live BFF with E2E_TEST_EMAIL/PASSWORD (E2E_REAL_API=true only).
*/

import type { Page } from "@playwright/test";

// Returns the real csrfToken the BFF issued, for tests that assert on it.
export async function realLogin(page: Page): Promise<string> {
const email = process.env.E2E_TEST_EMAIL;
const password = process.env.E2E_TEST_PASSWORD;
if (!email || !password) {
throw new Error(
"E2E_REAL_API=true requires E2E_TEST_EMAIL and E2E_TEST_PASSWORD (see .env.example).",
);
}
// page.request shares page's cookie jar, so the session cookie carries into page.goto().
const response = await page.request.post("/auth/login", { data: { email, password } });
if (!response.ok()) {
throw new Error(`Real login failed: ${response.status()} ${await response.text()}`);
}
const { csrfToken } = (await response.json()) as { csrfToken: string };
return csrfToken;
}
Loading
Loading