Skip to content

Commit d30bb53

Browse files
committed
feat(tests): add end-to-end tests for authentication and todos functionality
1 parent b4b6a90 commit d30bb53

4 files changed

Lines changed: 144 additions & 15 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { expect, test, type Page } from '@playwright/test'
2+
3+
type Credentials = {
4+
email: string
5+
password: string
6+
}
7+
8+
const createCredentials = (): Credentials => {
9+
const nonce = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
10+
return {
11+
email: `playwright-${nonce}@example.com`,
12+
password: 'Playwright123!',
13+
}
14+
}
15+
16+
const signUp = async (page: Page, credentials: Credentials) => {
17+
await page.goto('/auth')
18+
await page.getByRole('button', { name: 'Sign up' }).click()
19+
await page.getByLabel('Email address').fill(credentials.email)
20+
await page.getByLabel('Password').fill(credentials.password)
21+
await page.getByRole('button', { name: 'Create account' }).click()
22+
await expect(page).toHaveURL(/\/app\/todos$/)
23+
}
24+
25+
const login = async (page: Page, credentials: Credentials) => {
26+
await page.goto('/auth')
27+
await page.getByLabel('Email address').fill(credentials.email)
28+
await page.getByLabel('Password').fill(credentials.password)
29+
await page.getByRole('button', { name: 'Sign in' }).click()
30+
await expect(page).toHaveURL(/\/app\/todos$/)
31+
}
32+
33+
test.describe('auth and todos', () => {
34+
test('supports sign up and sign in', async ({ page }) => {
35+
const credentials = createCredentials()
36+
37+
await signUp(page, credentials)
38+
await page.getByRole('button', { name: 'Sign out' }).click()
39+
await expect(page).toHaveURL(/\/auth$/)
40+
41+
await login(page, credentials)
42+
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible()
43+
})
44+
45+
test('supports todo create, toggle, edit and delete', async ({ page }) => {
46+
const credentials = createCredentials()
47+
const initialTitle = `todo-${Date.now()}`
48+
const updatedTitle = `${initialTitle}-updated`
49+
50+
await signUp(page, credentials)
51+
52+
const input = page.getByPlaceholder('Add a new task')
53+
await input.fill(initialTitle)
54+
await page.getByRole('button', { name: 'Add todo' }).click()
55+
56+
const item = page.locator('li', { hasText: initialTitle })
57+
await expect(item).toBeVisible()
58+
59+
await item.getByRole('button', { name: 'Mark as done' }).click()
60+
await expect(item.getByRole('button', { name: 'Mark as not done' })).toBeVisible()
61+
62+
await item.getByRole('button', { name: 'Edit' }).click()
63+
await item.getByRole('textbox').fill(updatedTitle)
64+
await item.getByRole('button', { name: 'Save' }).click()
65+
await expect(page.locator('li', { hasText: updatedTitle })).toBeVisible()
66+
67+
const updatedItem = page.locator('li', { hasText: updatedTitle })
68+
await updatedItem.getByRole('button', { name: 'Delete' }).click()
69+
await expect(updatedItem).toHaveCount(0)
70+
})
71+
})

packages/api/src/auth.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { jwtVerify } from 'jose'
1+
import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } from 'jose'
22

33
type RuntimeEnv = Record<string, string | undefined>
44
const getRuntimeEnv = (): RuntimeEnv =>
@@ -14,7 +14,11 @@ const getJwtSecret = () => {
1414

1515
const getJwtIssuer = () => {
1616
const issuer = getRuntimeEnv().SUPABASE_JWT_ISS
17-
return issuer && issuer.length > 0 ? issuer : undefined
17+
if (issuer && issuer.length > 0) {
18+
return issuer
19+
}
20+
21+
return null;
1822
}
1923

2024
export type AuthContext = {
@@ -41,11 +45,57 @@ export const getBearerToken = (request: Request): string | null => {
4145
return token
4246
}
4347

44-
export const verifySupabaseJwt = async (token: string): Promise<AuthContext> => {
48+
let remoteJwks: ReturnType<typeof createRemoteJWKSet> | null = null
49+
50+
const getJwksUrl = () => {
51+
const explicit = getRuntimeEnv().SUPABASE_JWKS_URL
52+
if (explicit && explicit.length > 0) {
53+
return explicit
54+
}
55+
4556
const issuer = getJwtIssuer()
46-
const { payload } = await jwtVerify(token, getJwtSecret(),
47-
issuer ? { issuer } : undefined,
48-
)
57+
if (!issuer) {
58+
return undefined
59+
}
60+
61+
return `${issuer.replace(/\/$/, '')}/.well-known/jwks.json`
62+
}
63+
64+
const getRemoteJwks = () => {
65+
if (remoteJwks) {
66+
return remoteJwks
67+
}
68+
69+
const jwksUrl = getJwksUrl()
70+
if (!jwksUrl) {
71+
throw new Error('SUPABASE_JWKS_URL or SUPABASE_JWT_ISS is required')
72+
}
73+
74+
remoteJwks = createRemoteJWKSet(new URL(jwksUrl))
75+
return remoteJwks
76+
}
77+
78+
const verifyWithSharedSecret = async (token: string) => {
79+
const issuer = getJwtIssuer()
80+
return jwtVerify(token, getJwtSecret(), issuer ? { issuer } : undefined)
81+
}
82+
83+
const verifyWithJwks = async (token: string) => {
84+
const issuer = getJwtIssuer()
85+
return jwtVerify(token, getRemoteJwks(), issuer ? { issuer } : undefined)
86+
}
87+
88+
export const verifySupabaseJwt = async (token: string): Promise<AuthContext> => {
89+
const { alg } = decodeProtectedHeader(token)
90+
91+
if (!alg) {
92+
throw new Error('Token algorithm is missing')
93+
}
94+
95+
const isHmac = alg.toUpperCase().startsWith('HS')
96+
const { payload } = isHmac
97+
? await verifyWithSharedSecret(token)
98+
: await verifyWithJwks(token)
4999

50100
const userId = typeof payload.sub === 'string' ? payload.sub : null
51101
const role = typeof payload.role === 'string' ? payload.role : null
@@ -63,4 +113,4 @@ export const verifySupabaseJwt = async (token: string): Promise<AuthContext> =>
63113
role: 'authenticated',
64114
token,
65115
}
66-
}
116+
}

packages/api/src/plugins/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export type AuthContext = SupabaseAuthContext
1010

1111
const authPlugin = new Elysia()
1212
.derive(
13-
{ as: 'scoped' },
13+
{ as: 'global' },
1414
async ({ request }): Promise<{ auth: AuthContext | null }> => {
1515
const token = getBearerToken(request)
1616
if (!token) {

playwright.config.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default defineConfig({
2626
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
2727
use: {
2828
/* Base URL to use in actions like `await page.goto('')`. */
29-
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:5173',
29+
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:8878',
3030
locale: 'en-US',
3131

3232
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
@@ -74,10 +74,18 @@ export default defineConfig({
7474
],
7575

7676
/* Run your local dev server before starting the tests */
77-
webServer: {
78-
command: 'pnpm --filter app dev',
79-
url: 'http://127.0.0.1:8878',
80-
reuseExistingServer: !process.env.CI,
81-
timeout: 120 * 1000,
82-
},
77+
webServer: [
78+
{
79+
command: 'pnpm --dir packages/api run dev',
80+
url: 'http://127.0.0.1:54545/health',
81+
reuseExistingServer: !process.env.CI,
82+
timeout: 120 * 1000,
83+
},
84+
{
85+
command: 'pnpm --dir apps/app run dev',
86+
url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:8878',
87+
reuseExistingServer: !process.env.CI,
88+
timeout: 120 * 1000,
89+
},
90+
],
8391
});

0 commit comments

Comments
 (0)