Skip to content
Open
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
2 changes: 2 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ WORKOS_API_KEY=
NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
WORKOS_COOKIE_PASSWORD=

# When using a custom auth domain
# WORKOS_API_HOSTNAME=auth.yourapp.com
3 changes: 3 additions & 0 deletions .env.test.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
TEST_BASE_URL=http://localhost:3000
[email protected]
TEST_PASSWORD=
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage
/test-results/
/playwright-report/
/blob-report/
/playwright/.auth

# next.js
/.next/
Expand All @@ -26,10 +31,15 @@ yarn-error.log*

# local env files
.env*.local
.env.test

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

# cypress
cypress/videos
cypress/screenshots
36 changes: 36 additions & 0 deletions cypress.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { defineConfig } from "cypress";
import dotenv from "dotenv";
import path from "path";
import { registerWorkOSTasks } from "./cypress/plugins/workos";

// Load environment variables from .env files
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
dotenv.config({ path: path.resolve(__dirname, ".env.test") });

export default defineConfig({
e2e: {
baseUrl: process.env.TEST_BASE_URL,
supportFile: "cypress/support/e2e.ts",
specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}",
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true,

setupNodeEvents(on, config) {
// Register WorkOS authentication tasks
registerWorkOSTasks(on, config);

// Pass through environment variables to Cypress
config.env = {
...config.env,
WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID,
WORKOS_API_KEY: process.env.WORKOS_API_KEY,
TEST_BASE_URL: process.env.TEST_BASE_URL,
TEST_EMAIL: process.env.TEST_EMAIL,
TEST_PASSWORD: process.env.TEST_PASSWORD,
};
return config;
},
},
});
87 changes: 87 additions & 0 deletions cypress/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# E2E Testing with Cypress and WorkOS AuthKit

This directory contains Cypress tests for WorkOS AuthKit authentication using programmatic authentication.

## Setup

1. **Environment Variables** (same as Playwright tests)

```bash
# WorkOS Configuration
WORKOS_CLIENT_ID=your_client_id
WORKOS_API_KEY=your_api_key
WORKOS_COOKIE_PASSWORD=your_cookie_password

# Test Configuration
TEST_BASE_URL=http://localhost:3000
```

2. **Run Tests**

```bash
npm run test:cypress # Headless
npm run test:cypress:open # Interactive
```

## Authentication

### **Custom Command**

```typescript
// Authenticate as specific user
cy.login(Cypress.env("TEST_EMAIL"), Cypress.env("TEST_PASSWORD"));
```

### **Session Caching**

- First use: API authentication + session creation
- Subsequent uses: Cached session (faster)
- Auto-validation: Ensures session is still valid

## Usage

**Authenticated Tests:**

```typescript
describe("Admin Features", () => {
beforeEach(() => {
cy.login(Cypress.env("TEST_EMAIL"), Cypress.env("TEST_PASSWORD"));
});

it("can access admin panel", () => {
cy.visit("/admin"); // Already authenticated
});
});
```

**Unauthenticated Tests:**

```typescript
describe("Public Features", () => {
// No beforeEach = unauthenticated

it("shows login page", () => {
cy.visit("/");
});
});
```

## Test Endpoint

The tests use the following API endpoint for programmatic authentication and session creation:

**Endpoint:** `POST /api/test/set-session`

- **Purpose:** Create session from authentication tokens
- **Body:** `{ user, accessToken, refreshToken }`
- **Response:** Uses WorkOS AuthKit's `saveSession` method to create encrypted session cookie

The endpoint is to be used for testing purposes only and is recommended to be disabled in production environments.

## Files

- `plugins/workos.ts` - WorkOS authentication plugin
- `support/commands.ts` - Authentication command
- `support/e2e.ts` - Test configuration
- `e2e/authenticated-flows.cy.ts` - Tests for logged-in users
- `e2e/unauthenticated-flows.cy.ts` - Tests for anonymous users
127 changes: 127 additions & 0 deletions cypress/e2e/authenticated-flows.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
describe("Authenticated User Flows", () => {
beforeEach(() => {
// Use username/password from environment variables
cy.login(Cypress.env("TEST_EMAIL"), Cypress.env("TEST_PASSWORD"));
});

it("homepage shows authenticated state", () => {
cy.visit("/");

// Should see welcome message for authenticated user
cy.contains(/welcome back/i).should("be.visible");

// Should see account navigation
cy.get("a")
.contains(/view account/i)
.should("be.visible");

// Should see sign out button
// There are multiple sign out buttons, so we need to make sure we see at least one
cy.get("button")
.contains(/sign out/i)
.first()
.should("be.visible");

// Should NOT see sign in button
cy.get("a")
.contains(/sign in/i)
.should("not.exist");
});

it("can navigate to account page", () => {
cy.visit("/");

// Click on "View account" link
cy.get("a")
.contains(/view account/i)
.click();

// Should navigate to account page
cy.url().should("include", "/account");

// Should see account details heading
cy.get("h1, h2, h3")
.contains(/account details/i)
.should("be.visible");

// Should see some user information fields
cy.contains("Email").should("be.visible");

// Should see the email address in a readonly input
cy.get("input[readonly]")
.filter(`[value="${Cypress.env("TEST_EMAIL")}"]`)
.should("exist");

// Should see user ID field
cy.contains("Id", { matchCase: false }).should("be.visible");
});

it("account page is protected - direct access works when authenticated", () => {
// Direct navigation to protected route should work when authenticated
cy.visit("/account");

// Should see account details page
cy.get("h1, h2, h3")
.contains(/account details/i)
.should("be.visible");

// Should see user information
cy.contains("Email").should("be.visible");
});

it("can sign out successfully", () => {
cy.visit("/");

// Verify we start authenticated
cy.contains(/welcome back/i).should("be.visible");

// Click sign out button
cy.get("button")
.contains(/sign out/i)
.first()
.click();

// Wait for sign out to complete and page to update
cy.url().should("eq", Cypress.config("baseUrl") + "/");

// Should see unauthenticated state
cy.get("h1, h2, h3")
.contains(/authkit authentication example/i)
.should("be.visible");
cy.get("a")
.contains(/sign in with authkit/i)
.should("be.visible");
cy.contains(/sign in to view your account details/i).should("be.visible");

// Should NOT see authenticated elements
cy.contains(/welcome back/i).should("not.exist");
cy.contains(/sign out/i).should("not.exist");
});

it("navigation between pages works correctly", () => {
// Start at home
cy.visit("/");
cy.contains(/welcome back/i).should("be.visible");

// Go to account
cy.get("a")
.contains(/view account/i)
.click();
cy.url().should("include", "/account");
cy.get("h1, h2, h3")
.contains(/account details/i)
.should("be.visible");

// Go back to home via browser navigation
cy.go("back");
cy.url().should("not.include", "/account");
cy.contains(/welcome back/i).should("be.visible");

// Go forward again
cy.go("forward");
cy.url().should("include", "/account");
cy.get("h1, h2, h3")
.contains(/account details/i)
.should("be.visible");
});
});
47 changes: 47 additions & 0 deletions cypress/e2e/unauthenticated-flows.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
describe("Unauthenticated User Flows", () => {
it("homepage shows unauthenticated state", () => {
cy.visit("/");

// Should see the unauthenticated homepage
cy.get("h1, h2, h3")
.contains(/authkit authentication example/i)
.should("be.visible");
cy.contains(/sign in to view your account details/i).should("be.visible");

// Should see sign in button
cy.get("a")
.contains(/sign in with authkit/i)
.should("be.visible");

// Should NOT see authenticated elements
cy.contains(/welcome back/i).should("not.exist");
cy.get("a")
.contains(/view account/i)
.should("not.exist");
cy.contains("button", /sign out/i).should("not.exist");
});

it("account page redirects unauthenticated users to login", () => {
// Try to access protected route directly
cy.visit("/account");

// Should be redirected to WorkOS login (the URL will change away from the app)
cy.url({ timeout: 10000 }).should("not.contain", "/account");

cy.get("input").should("be.visible");
});

it("sign in button navigates to WorkOS login", () => {
cy.visit("/");

// Click the sign in button
cy.get("a")
.contains(/sign in with authkit/i)
.click();

// URL should not be the original app URL
const baseURL = Cypress.env("TEST_BASE_URL");
cy.url().should("not.eq", baseURL + "/");
cy.url().should("not.contain", new URL(baseURL).host);
});
});
39 changes: 39 additions & 0 deletions cypress/plugins/workos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { PluginEvents, PluginConfigOptions } from "cypress";

interface AuthenticateParams {
email: string;
password: string;
workosApiKey: string;
workosClientId: string;
}

/**
* WorkOS authentication plugin for Cypress
* Provides tasks for programmatic authentication using WorkOS SDK
*/
export function registerWorkOSTasks(
on: PluginEvents,
config: PluginConfigOptions
) {
on("task", {
authenticateWithWorkOS({
email,
password,
workosApiKey,
workosClientId,
}: AuthenticateParams) {
// Import WorkOS SDK in Node.js context (can't be imported in browser context)
const { WorkOS } = require("@workos-inc/node");

const workos = new WorkOS(workosApiKey, {
apiHostname: process.env.WORKOS_API_HOSTNAME,
});

return workos.userManagement.authenticateWithPassword({
clientId: workosClientId,
email,
password,
});
},
});
}
Loading