Skip to content

Commit a1dc5cf

Browse files
committed
add more e2e tests for performance a/b
1 parent 15fd192 commit a1dc5cf

File tree

8 files changed

+234
-40
lines changed

8 files changed

+234
-40
lines changed

exercises/02.authentication/04.solution.third-party-providers/app/components/forms.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function ErrorList({
2424
const errorsToRender = errors?.filter(Boolean)
2525
if (!errorsToRender?.length) return null
2626
return (
27-
<ul id={id} className="flex flex-col gap-1">
27+
<ul id={id} className="flex flex-col gap-1" role="alert">
2828
{errorsToRender.map((e) => (
2929
<li key={e} className="text-foreground-destructive text-[10px]">
3030
{e}

exercises/02.authentication/04.solution.third-party-providers/playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export default defineConfig({
1717
},
1818
fullyParallel: true,
1919
forbidOnly: !!process.env.CI,
20-
retries: process.env.CI ? 2 : 0,
20+
// retries: process.env.CI ? 2 : 0,
2121
workers: process.env.CI ? 1 : undefined,
2222
reporter: 'html',
2323
use: {
Binary file not shown.

exercises/02.authentication/04.solution.third-party-providers/tests/db-utils.ts

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import * as fs from 'node:fs'
21
import { execSync } from 'node:child_process'
2+
import * as fs from 'node:fs'
33
import { faker } from '@faker-js/faker'
44
import bcrypt from 'bcryptjs'
55
import { UniqueEnforcer } from 'enforce-unique'
6-
import { prisma } from '#app/utils/db.server.ts'
76

87
const uniqueUsernameEnforcer = new UniqueEnforcer()
98

@@ -41,38 +40,6 @@ export function generateUserInfo(info?: Partial<TestUserInfo>): TestUserInfo {
4140
}
4241
}
4342

44-
export async function createPasskey(input: {
45-
id: string
46-
userId: string
47-
aaguid: string
48-
publicKey: Uint8Array<ArrayBuffer>
49-
counter?: number
50-
}) {
51-
const passkey = await prisma.passkey.create({
52-
data: {
53-
id: input.id,
54-
aaguid: input.aaguid,
55-
userId: input.userId,
56-
publicKey: input.publicKey,
57-
backedUp: false,
58-
webauthnUserId: input.userId,
59-
deviceType: 'singleDevice',
60-
counter: input.counter || 0,
61-
},
62-
})
63-
64-
return {
65-
async [Symbol.asyncDispose]() {
66-
await prisma.passkey.deleteMany({
67-
where: {
68-
id: passkey.id,
69-
},
70-
})
71-
},
72-
...passkey,
73-
}
74-
}
75-
7643
export function createPassword(password: string = faker.internet.password()) {
7744
return {
7845
hash: bcrypt.hashSync(password, 10),
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { generateTOTP } from '@epic-web/totp'
2+
import { test, expect } from '#tests/test-extend.ts'
3+
4+
test('authenticates using two-factor authentication', async ({
5+
app,
6+
navigate,
7+
page,
8+
createUser,
9+
createVerification,
10+
}) => {
11+
// Create a test user and enable 2FA for them directly in the database.
12+
await using user = await createUser()
13+
const totp = await generateTOTP()
14+
await using _ = await createVerification({
15+
totp,
16+
userId: user.id,
17+
})
18+
19+
// Log in as the created user.
20+
await navigate('/login')
21+
22+
await page.getByLabel('Username').fill(user.username)
23+
await page.getByLabel('Password').fill(user.password)
24+
await page.getByRole('button', { name: 'Log in' }).click()
25+
26+
await expect(
27+
page.getByRole('heading', { name: 'Check your 2FA app' }),
28+
).toBeVisible()
29+
30+
await page
31+
.getByRole('textbox', { name: /code/i })
32+
.fill((await generateTOTP(totp)).otp)
33+
34+
await page.getByRole('button', { name: 'Submit' }).click()
35+
36+
await expect(page.getByRole('link', { name: user.name! })).toBeVisible()
37+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { test, expect } from '#tests/test-extend.ts'
2+
3+
test('authenticates using a email and password', async ({
4+
app,
5+
navigate,
6+
page,
7+
createUser,
8+
}) => {
9+
await using user = await createUser()
10+
11+
await navigate('/login')
12+
13+
await page.getByLabel('Username').fill(user.username)
14+
await page.getByLabel('Password').fill(user.password)
15+
await page.getByRole('button', { name: 'Log in' }).click()
16+
17+
await expect(page.getByText(user.name!)).toBeVisible()
18+
})
19+
20+
test('displays an error message when authenticating with invalid credentials', async ({
21+
app,
22+
navigate,
23+
page,
24+
}) => {
25+
await navigate('/login')
26+
27+
await page.getByLabel('Username').fill('non_existing_user')
28+
await page.getByLabel('Password').fill('non_existing_password')
29+
await page.getByRole('button', { name: 'Log in' }).click()
30+
31+
await expect(
32+
page.getByRole('alert').getByText('Invalid username or password'),
33+
).toBeVisible()
34+
})
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { type Page } from '@playwright/test'
2+
import { createTestPasskey } from 'test-passkey'
3+
import { test, expect } from '#tests/test-extend.ts'
4+
5+
async function createWebAuthnClient(page: Page) {
6+
const client = await page.context().newCDPSession(page)
7+
await client.send('WebAuthn.enable')
8+
9+
const result = await client.send('WebAuthn.addVirtualAuthenticator', {
10+
options: {
11+
protocol: 'ctap2',
12+
transport: 'internal',
13+
hasResidentKey: true,
14+
hasUserVerification: true,
15+
isUserVerified: true,
16+
// Authenticator will automatically respond to the next prompt in the browser.
17+
automaticPresenceSimulation: true,
18+
},
19+
})
20+
21+
return {
22+
client,
23+
authenticatorId: result.authenticatorId,
24+
}
25+
}
26+
27+
test('authenticates using an existing passkey', async ({
28+
app,
29+
navigate,
30+
page,
31+
createUser,
32+
createPasskey,
33+
}) => {
34+
await navigate('/login')
35+
36+
// Create a test passkey.
37+
const passkey = createTestPasskey({
38+
rpId: new URL(page.url()).hostname,
39+
})
40+
41+
// Add the passkey to the server.
42+
await using user = await createUser()
43+
await using _ = await createPasskey({
44+
id: passkey.credential.credentialId,
45+
aaguid: passkey.credential.aaguid || '',
46+
publicKey: passkey.publicKey,
47+
userId: user.id,
48+
counter: passkey.credential.signCount,
49+
})
50+
51+
// Add the passkey to the browser.
52+
const { client, authenticatorId } = await createWebAuthnClient(page)
53+
await client.send('WebAuthn.addCredential', {
54+
authenticatorId,
55+
credential: {
56+
...passkey.credential,
57+
isResidentCredential: true,
58+
userName: user.username,
59+
userHandle: btoa(user.id),
60+
userDisplayName: user.name ?? user.email,
61+
},
62+
})
63+
64+
await page.getByRole('button', { name: 'Login with a passkey' }).click()
65+
66+
await expect(page.getByText(user.name!)).toBeVisible()
67+
})
68+
69+
test('displays an error when authenticating via a passkey fails', async ({
70+
app,
71+
navigate,
72+
page,
73+
}) => {
74+
await navigate('/login')
75+
76+
const { client, authenticatorId } = await createWebAuthnClient(page)
77+
await client.send('WebAuthn.setUserVerified', {
78+
authenticatorId,
79+
isUserVerified: false,
80+
})
81+
82+
await page.getByRole('button', { name: 'Login with a passkey' }).click()
83+
84+
await expect(
85+
page.getByText(
86+
'Failed to authenticate with passkey: The operation either timed out or was not allowed',
87+
),
88+
).toBeVisible()
89+
})

exercises/02.authentication/04.solution.third-party-providers/tests/test-extend.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { type AppProcess, defineLauncher } from '@epic-web/app-launcher'
2+
import { generateTOTP } from '@epic-web/totp'
23
import { test as testBase, expect } from '@playwright/test'
3-
import { PrismaClient, type User } from '@prisma/client'
4+
import {
5+
PrismaClient,
6+
type Verification,
7+
type Passkey,
8+
type User,
9+
} from '@prisma/client'
410
import getPort from 'get-port'
511
import {
612
definePersona,
@@ -27,6 +33,17 @@ interface Fixtures {
2733
createUser: (
2834
info?: Partial<TestUserInfo>,
2935
) => Promise<AsyncDisposable & User & { password: string }>
36+
createVerification: (input: {
37+
totp: Awaited<ReturnType<typeof generateTOTP>>
38+
userId: string
39+
}) => Promise<AsyncDisposable & Verification>
40+
createPasskey: (input: {
41+
id: string
42+
userId: string
43+
aaguid: string
44+
publicKey: Uint8Array<ArrayBuffer>
45+
counter?: number
46+
}) => Promise<AsyncDisposable & Passkey>
3047
}
3148

3249
const user = definePersona('user', {
@@ -78,10 +95,12 @@ const launcher = defineLauncher({
7895

7996
export const test = testBase.extend<Fixtures>({
8097
async databasePath({}, use, testInfo) {
81-
const databasePath = `./test-${testInfo.testId}.db`
82-
await use(databasePath)
98+
const databaseName = `test-${testInfo.testId}.db`
99+
const databasePath = new URL(`../prisma/${databaseName}`, import.meta.url)
100+
.pathname
83101

84-
await testInfo.attach(databasePath, { path: databasePath })
102+
await use(databasePath)
103+
await testInfo.attach(databaseName, { path: databasePath })
85104
},
86105
async prisma({ databasePath }, use) {
87106
const prisma = new PrismaClient({
@@ -137,6 +156,54 @@ export const test = testBase.extend<Fixtures>({
137156
}
138157
})
139158
},
159+
async createPasskey({ prisma }, use) {
160+
await use(async (input) => {
161+
const passkey = await prisma.passkey.create({
162+
data: {
163+
id: input.id,
164+
aaguid: input.aaguid,
165+
userId: input.userId,
166+
publicKey: input.publicKey,
167+
backedUp: false,
168+
webauthnUserId: input.userId,
169+
deviceType: 'singleDevice',
170+
counter: input.counter || 0,
171+
},
172+
})
173+
174+
return {
175+
async [Symbol.asyncDispose]() {
176+
await prisma.passkey.deleteMany({
177+
where: {
178+
id: passkey.id,
179+
},
180+
})
181+
},
182+
...passkey,
183+
}
184+
})
185+
},
186+
async createVerification({ prisma }, use) {
187+
await use(async (input) => {
188+
const { otp, ...totpConfig } = input.totp
189+
const verification = await prisma.verification.create({
190+
data: {
191+
...totpConfig,
192+
type: '2fa',
193+
target: input.userId,
194+
},
195+
})
196+
197+
return {
198+
async [Symbol.asyncDispose]() {
199+
await prisma.verification.deleteMany({
200+
where: { id: verification.id },
201+
})
202+
},
203+
...verification,
204+
}
205+
})
206+
},
140207
})
141208

142209
export { expect }

0 commit comments

Comments
 (0)