Skip to content

Commit 215a38d

Browse files
committed
feat: add Playwright e2e test for OAuth magic link flow
Add a headless browser test that exercises the full OAuth 2.1 flow: unauthenticated authorize -> login -> magic link -> verify -> authorize -> code - Capture magic links in dev via devMagicLinks store when SENDGRID_API_KEY is not set - Expose /api/test/magic-links GET/DELETE endpoints for tests to read/clear captured links (dev-only, not available in production) - Use APIRequestContext for magic link verification so cookies are handled automatically across requests - Add e2e job to CI that runs after unit tests, using chromium-headless-shell for a smaller Playwright download
1 parent 8c0cf41 commit 215a38d

7 files changed

Lines changed: 165 additions & 34 deletions

File tree

.github/workflows/ci.yml

Lines changed: 60 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
name: ci
22

33
on:
4+
push:
5+
branches: [main]
46
pull_request:
7+
branches: [main]
58

69
env:
710
HUSKY: 0
@@ -15,28 +18,31 @@ jobs:
1518
steps:
1619
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
1720
with:
18-
fetch-depth: 0
1921
persist-credentials: false
22+
fetch-depth: 0
2023
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
2124
with:
2225
node-version-file: ".nvmrc"
23-
24-
- name: Install commitlint
25-
run: npm install -D @commitlint/cli @commitlint/config-conventional
26-
- name: Print versions
27-
run: |
28-
git --version
29-
node --version
30-
npm --version
31-
npx commitlint --version
32-
33-
- name: Validate current commit (last commit) with commitlint
26+
- run: npm ci
27+
- run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
28+
if: github.event_name == 'pull_request'
29+
- run: npx commitlint --from ${{ github.event.before }} --to ${{ github.event.after }} --verbose
3430
if: github.event_name == 'push'
35-
run: npx commitlint --last --verbose
3631

37-
- name: Validate PR commits with commitlint
38-
if: github.event_name == 'pull_request'
39-
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
32+
smoke:
33+
runs-on: ubuntu-latest
34+
steps:
35+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
36+
with:
37+
persist-credentials: false
38+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
39+
with:
40+
node-version-file: ".nvmrc"
41+
- run: npm ci
42+
- run: |
43+
npm run prettier:check
44+
npm run lint
45+
npm run fallow
4046
4147
check-workflows:
4248
permissions:
@@ -53,39 +59,51 @@ jobs:
5359
- uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
5460

5561
shellcheck:
56-
needs:
57-
- commitlint
5862
runs-on: ubuntu-latest
5963
steps:
6064
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
6165
with:
6266
persist-credentials: false
63-
- name: Run shellcheck
64-
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
65-
with:
66-
severity: warning
67+
- run: shellcheck scripts/*.sh
6768

68-
smoke:
69-
needs:
70-
- commitlint
69+
test:
7170
runs-on: ubuntu-latest
71+
72+
services:
73+
postgres:
74+
image: postgres:16
75+
env:
76+
POSTGRES_USER: authuser
77+
POSTGRES_PASSWORD: authpass
78+
POSTGRES_DB: authdb
79+
options: >-
80+
--health-cmd pg_isready
81+
--health-interval 10s
82+
--health-timeout 5s
83+
--health-retries 5
84+
ports:
85+
- 5432:5432
86+
7287
steps:
7388
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
7489
with:
75-
fetch-depth: 0
7690
persist-credentials: false
7791
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
7892
with:
7993
node-version-file: ".nvmrc"
8094
- run: npm ci
81-
- run: |
82-
npm run prettier:check
83-
npm run lint
84-
npm run fallow
95+
- run: npm run db:generate
96+
- run: npm run db:migrate
97+
- run: npm test
8598

86-
test:
99+
env:
100+
DATABASE_URL: postgres://authuser:authpass@localhost:5432/authdb
101+
GITHUB_CLIENT_ID: dummy-id-for-testing
102+
GITHUB_CLIENT_SECRET: dummy-secret-for-testing
103+
104+
e2e:
87105
needs:
88-
- commitlint
106+
- test
89107
runs-on: ubuntu-latest
90108

91109
services:
@@ -111,9 +129,18 @@ jobs:
111129
with:
112130
node-version-file: ".nvmrc"
113131
- run: npm ci
132+
- run: npx playwright install chromium-headless-shell
114133
- run: npm run db:generate
115134
- run: npm run db:migrate
116-
- run: npm test -- --disable-coverage
135+
- run: node scripts/migrate.js
136+
- run: |
137+
node src/index.js > server.log 2>&1 &
138+
for i in {1..30}; do
139+
curl -sf 'http://127.0.0.1:3001/health?type=startup' && break
140+
sleep 1
141+
done
142+
cat server.log
143+
npx playwright test
117144
118145
env:
119146
DATABASE_URL: postgres://authuser:authpass@localhost:5432/authdb

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ lerna-debug.log*
3434
# test coverage and tap
3535
coverage/
3636
.tap/
37+
test-results/
3738

3839
# worktrees
3940
.worktrees/

e2e/oauth-flow.spec.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { test, expect } from "@playwright/test";
2+
import { AUTH_DEFAULT_PORT, PLANNER_DEFAULT_PORT } from "../src/config.js";
3+
4+
const authBase = `http://localhost:${AUTH_DEFAULT_PORT}`;
5+
6+
test("OAuth 2.1 magic link flow", async ({ page, request }) => {
7+
const email = `e2e-${Date.now()}@example.com`;
8+
9+
// Clear any stale magic links
10+
await request.delete(`${authBase}/api/test/magic-links`);
11+
12+
// Start OAuth flow: unauthenticated user hits authorize endpoint with PKCE
13+
const codeChallenge = "MJk6-W6P2z_PgOvWcEvbyqeIyc-GthZov8-QX37r0Vo";
14+
const authorizeParams = new URLSearchParams({
15+
client_id: "planner",
16+
redirect_uri: `http://localhost:${PLANNER_DEFAULT_PORT}/auth/codebar/callback`,
17+
response_type: "code",
18+
state: "e2e-state-123",
19+
scope: "openid profile",
20+
code_challenge: codeChallenge,
21+
code_challenge_method: "S256",
22+
});
23+
await page.goto(
24+
`${authBase}/api/auth/oauth2/authorize?${authorizeParams.toString()}`,
25+
);
26+
27+
// OAuth provider redirects unauthenticated users to the login page
28+
await expect(page).toHaveURL(/\/login/);
29+
30+
// Click through to the magic link form
31+
await page.getByRole("button", { name: /Send Magic Link/i }).click();
32+
await expect(page).toHaveURL(/\/login\/magic-link/);
33+
34+
// Submit email
35+
await page.fill('input[name="email"]', email);
36+
await page.click('button[type="submit"]');
37+
38+
// Success page is shown
39+
await expect(page).toHaveURL(/\/login\/magic-link\?success=/);
40+
await expect(page.locator("body")).toContainText("Magic link sent");
41+
42+
// Fetch the magic link from the auth dev endpoint
43+
const linksRes = await request.get(`${authBase}/api/test/magic-links`);
44+
const links = await linksRes.json();
45+
const link = links.find((l) => l.email === email);
46+
expect(link).toBeDefined();
47+
expect(link.url).toMatch(/magic-link\/verify/);
48+
49+
// Call the magic link verify endpoint via API to establish a session.
50+
// APIRequestContext automatically stores and sends cookies across requests.
51+
const verifyRes = await request.get(link.url, { maxRedirects: 0 });
52+
expect(verifyRes.status()).toBe(302);
53+
const verifyLocation = verifyRes.headers()["location"];
54+
expect(verifyLocation).toContain("/api/auth/oauth2/authorize");
55+
56+
// Call the authorize endpoint with the session cookie (don't follow redirects)
57+
const authRes = await request.get(
58+
`${authBase}/api/auth/oauth2/authorize?${authorizeParams.toString()}`,
59+
{ maxRedirects: 0 },
60+
);
61+
62+
// Should get a 302 redirect to the planner callback with the code
63+
expect(authRes.status()).toBe(302);
64+
const location = authRes.headers()["location"];
65+
expect(location).toBeTruthy();
66+
expect(location).toContain("code=");
67+
expect(location).toContain("state=e2e-state-123");
68+
});

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"test:pg:start": "scripts/container.sh start",
2121
"test:pg:stop": "scripts/container.sh stop",
2222
"test:pg:status": "scripts/container.sh status",
23-
"test:coverage": "tap --coverage-report=html"
23+
"test:coverage": "tap --coverage-report=html",
24+
"e2e": "playwright test"
2425
},
2526
"dependencies": {
2627
"@better-auth/oauth-provider": "^1.6.20",
@@ -42,6 +43,7 @@
4243
"@commitlint/cli": "^21.0.2",
4344
"@commitlint/config-conventional": "^21.0.2",
4445
"@eslint/js": "^10.0.1",
46+
"@playwright/test": "^1.61.0",
4547
"eslint": "^10.4.1",
4648
"fallow": "^2.92.1",
4749
"globals": "^17.6.0",

playwright.config.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
export default defineConfig({
4+
testDir: "./e2e",
5+
fullyParallel: false,
6+
workers: 1,
7+
use: {
8+
baseURL: "http://127.0.0.1:3001",
9+
trace: "on-first-retry",
10+
},
11+
projects: [
12+
{
13+
name: "chromium",
14+
use: { ...devices["Desktop Chrome"], headless: true },
15+
},
16+
],
17+
});

src/app/app.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { pinoLogger } from "hono-pino";
33
import { serveStatic } from "@hono/node-server/serve-static";
44

55
import { db } from "../auth.js";
6+
import { devMagicLinks } from "../dev/magic-links.js";
67
import authHandler from "./routes/auth.js";
78
import healthHandler from "./routes/health.js";
89
import homeHandler from "./routes/home.js";
@@ -106,6 +107,15 @@ export function createApp(auth, injectedDb) {
106107

107108
app.route("/demo", demoHandler);
108109

110+
// Dev-only endpoint for Playwright tests to retrieve captured magic links
111+
if (process.env.NODE_ENV !== "production") {
112+
app.get("/api/test/magic-links", (c) => c.json(devMagicLinks));
113+
app.delete("/api/test/magic-links", (c) => {
114+
devMagicLinks.length = 0;
115+
return c.json({ cleared: true });
116+
});
117+
}
118+
109119
// serve assets, etc.
110120
app.use("/static/*", serveStatic({ root: "./" }));
111121

src/auth.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { betterAuth } from "better-auth";
33
import { admin, magicLink, jwt } from "better-auth/plugins";
44
import { oauthProvider } from "@better-auth/oauth-provider";
55
import appConfig from "./config.js";
6+
import { devMagicLinks } from "./dev/magic-links.js";
67

78
// PostgreSQL connection pool for CI/production and local dev
89
// SSL only for non-local connections (Heroku requires it; local/CI does not)
@@ -69,6 +70,11 @@ export const auth = betterAuth({
6970

7071
if (!apiKey) {
7172
console.log(`Magic Link for ${email}: ${url}`);
73+
devMagicLinks.push({
74+
email,
75+
url,
76+
createdAt: new Date().toISOString(),
77+
});
7278
return;
7379
}
7480

0 commit comments

Comments
 (0)