Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e3a76b0
add new workshop outline
kettanaito Aug 26, 2025
435c91b
04/01 rename exercise
kettanaito Aug 26, 2025
c855e57
Merge branch 'main' into new-outline
kettanaito Nov 3, 2025
2cdf595
02/03: add passkey authentication test
kettanaito Nov 3, 2025
6763f16
use `test-passkey` for passkey tests
kettanaito Nov 3, 2025
9f5275c
add test case for failed passkey authentication
kettanaito Nov 4, 2025
347d791
refine existing exercises
kettanaito Nov 4, 2025
6183cc8
02/01: add basic auth test case
kettanaito Nov 4, 2025
1819a7f
add invalid credentials basic auth test case
kettanaito Nov 4, 2025
66876e1
add 2fa exercise skeleton
kettanaito Nov 4, 2025
dc31e9e
02/05: add exercise skeleton
kettanaito Nov 4, 2025
a6751ca
02/05: drop persona, seed user and verification directly
kettanaito Nov 4, 2025
ce3a467
drop captcha, draft 3rd-party providers
kettanaito Nov 4, 2025
4834b84
02/04: thoughts on testing 3rd-party auth
kettanaito Nov 4, 2025
d97bfcb
02/02: fix svg import build error
kettanaito Nov 6, 2025
419e7df
02/02: generate OTP from the same config
kettanaito Nov 6, 2025
9dd408d
02/04: add test cases
kettanaito Nov 7, 2025
227ab68
update `test-passkey` for proper `publicKey` type
kettanaito Nov 7, 2025
a8e7e8e
02/04: per-test app instances with `app-launcher`
kettanaito Nov 12, 2025
e5379aa
02/04: bind `prisma` to test cases
kettanaito Nov 12, 2025
dfe499e
02/04: run prod build for tests
kettanaito Nov 12, 2025
15fd192
attach database file to test artifacts
kettanaito Nov 14, 2025
a1dc5cf
add more e2e tests for performance a/b
kettanaito Nov 14, 2025
6d87ca8
remove oath exercise, add problems
kettanaito Nov 18, 2025
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
Prev Previous commit
Next Next commit
add more e2e tests for performance a/b
  • Loading branch information
kettanaito committed Nov 14, 2025
commit a1dc5cfac7821ddc0f5767e96dbc5309591692b0
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function ErrorList({
const errorsToRender = errors?.filter(Boolean)
if (!errorsToRender?.length) return null
return (
<ul id={id} className="flex flex-col gap-1">
<ul id={id} className="flex flex-col gap-1" role="alert">
{errorsToRender.map((e) => (
<li key={e} className="text-foreground-destructive text-[10px]">
{e}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default defineConfig({
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
// retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import * as fs from 'node:fs'
import { execSync } from 'node:child_process'
import * as fs from 'node:fs'
import { faker } from '@faker-js/faker'
import bcrypt from 'bcryptjs'
import { UniqueEnforcer } from 'enforce-unique'
import { prisma } from '#app/utils/db.server.ts'

const uniqueUsernameEnforcer = new UniqueEnforcer()

Expand Down Expand Up @@ -41,38 +40,6 @@ export function generateUserInfo(info?: Partial<TestUserInfo>): TestUserInfo {
}
}

export async function createPasskey(input: {
id: string
userId: string
aaguid: string
publicKey: Uint8Array<ArrayBuffer>
counter?: number
}) {
const passkey = await prisma.passkey.create({
data: {
id: input.id,
aaguid: input.aaguid,
userId: input.userId,
publicKey: input.publicKey,
backedUp: false,
webauthnUserId: input.userId,
deviceType: 'singleDevice',
counter: input.counter || 0,
},
})

return {
async [Symbol.asyncDispose]() {
await prisma.passkey.deleteMany({
where: {
id: passkey.id,
},
})
},
...passkey,
}
}

export function createPassword(password: string = faker.internet.password()) {
return {
hash: bcrypt.hashSync(password, 10),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { generateTOTP } from '@epic-web/totp'
import { test, expect } from '#tests/test-extend.ts'

test('authenticates using two-factor authentication', async ({
app,
navigate,
page,
createUser,
createVerification,
}) => {
// Create a test user and enable 2FA for them directly in the database.
await using user = await createUser()
const totp = await generateTOTP()
await using _ = await createVerification({
totp,
userId: user.id,
})

// Log in as the created user.
await navigate('/login')

await page.getByLabel('Username').fill(user.username)
await page.getByLabel('Password').fill(user.password)
await page.getByRole('button', { name: 'Log in' }).click()

await expect(
page.getByRole('heading', { name: 'Check your 2FA app' }),
).toBeVisible()

await page
.getByRole('textbox', { name: /code/i })
.fill((await generateTOTP(totp)).otp)

await page.getByRole('button', { name: 'Submit' }).click()

await expect(page.getByRole('link', { name: user.name! })).toBeVisible()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from '#tests/test-extend.ts'

test('authenticates using a email and password', async ({
app,
navigate,
page,
createUser,
}) => {
await using user = await createUser()

await navigate('/login')

await page.getByLabel('Username').fill(user.username)
await page.getByLabel('Password').fill(user.password)
await page.getByRole('button', { name: 'Log in' }).click()

await expect(page.getByText(user.name!)).toBeVisible()
})

test('displays an error message when authenticating with invalid credentials', async ({
app,
navigate,
page,
}) => {
await navigate('/login')

await page.getByLabel('Username').fill('non_existing_user')
await page.getByLabel('Password').fill('non_existing_password')
await page.getByRole('button', { name: 'Log in' }).click()

await expect(
page.getByRole('alert').getByText('Invalid username or password'),
).toBeVisible()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { type Page } from '@playwright/test'
import { createTestPasskey } from 'test-passkey'
import { test, expect } from '#tests/test-extend.ts'

async function createWebAuthnClient(page: Page) {
const client = await page.context().newCDPSession(page)
await client.send('WebAuthn.enable')

const result = await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
// Authenticator will automatically respond to the next prompt in the browser.
automaticPresenceSimulation: true,
},
})

return {
client,
authenticatorId: result.authenticatorId,
}
}

test('authenticates using an existing passkey', async ({
app,
navigate,
page,
createUser,
createPasskey,
}) => {
await navigate('/login')

// Create a test passkey.
const passkey = createTestPasskey({
rpId: new URL(page.url()).hostname,
})

// Add the passkey to the server.
await using user = await createUser()
await using _ = await createPasskey({
id: passkey.credential.credentialId,
aaguid: passkey.credential.aaguid || '',
publicKey: passkey.publicKey,
userId: user.id,
counter: passkey.credential.signCount,
})

// Add the passkey to the browser.
const { client, authenticatorId } = await createWebAuthnClient(page)
await client.send('WebAuthn.addCredential', {
authenticatorId,
credential: {
...passkey.credential,
isResidentCredential: true,
userName: user.username,
userHandle: btoa(user.id),
userDisplayName: user.name ?? user.email,
},
})

await page.getByRole('button', { name: 'Login with a passkey' }).click()

await expect(page.getByText(user.name!)).toBeVisible()
})

test('displays an error when authenticating via a passkey fails', async ({
app,
navigate,
page,
}) => {
await navigate('/login')

const { client, authenticatorId } = await createWebAuthnClient(page)
await client.send('WebAuthn.setUserVerified', {
authenticatorId,
isUserVerified: false,
})

await page.getByRole('button', { name: 'Login with a passkey' }).click()

await expect(
page.getByText(
'Failed to authenticate with passkey: The operation either timed out or was not allowed',
),
).toBeVisible()
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { type AppProcess, defineLauncher } from '@epic-web/app-launcher'
import { generateTOTP } from '@epic-web/totp'
import { test as testBase, expect } from '@playwright/test'
import { PrismaClient, type User } from '@prisma/client'
import {
PrismaClient,
type Verification,
type Passkey,
type User,
} from '@prisma/client'
import getPort from 'get-port'
import {
definePersona,
Expand All @@ -27,6 +33,17 @@ interface Fixtures {
createUser: (
info?: Partial<TestUserInfo>,
) => Promise<AsyncDisposable & User & { password: string }>
createVerification: (input: {
totp: Awaited<ReturnType<typeof generateTOTP>>
userId: string
}) => Promise<AsyncDisposable & Verification>
createPasskey: (input: {
id: string
userId: string
aaguid: string
publicKey: Uint8Array<ArrayBuffer>
counter?: number
}) => Promise<AsyncDisposable & Passkey>
}

const user = definePersona('user', {
Expand Down Expand Up @@ -78,10 +95,12 @@ const launcher = defineLauncher({

export const test = testBase.extend<Fixtures>({
async databasePath({}, use, testInfo) {
const databasePath = `./test-${testInfo.testId}.db`
await use(databasePath)
const databaseName = `test-${testInfo.testId}.db`
const databasePath = new URL(`../prisma/${databaseName}`, import.meta.url)
.pathname

await testInfo.attach(databasePath, { path: databasePath })
await use(databasePath)
await testInfo.attach(databaseName, { path: databasePath })
},
async prisma({ databasePath }, use) {
const prisma = new PrismaClient({
Expand Down Expand Up @@ -137,6 +156,54 @@ export const test = testBase.extend<Fixtures>({
}
})
},
async createPasskey({ prisma }, use) {
await use(async (input) => {
const passkey = await prisma.passkey.create({
data: {
id: input.id,
aaguid: input.aaguid,
userId: input.userId,
publicKey: input.publicKey,
backedUp: false,
webauthnUserId: input.userId,
deviceType: 'singleDevice',
counter: input.counter || 0,
},
})

return {
async [Symbol.asyncDispose]() {
await prisma.passkey.deleteMany({
where: {
id: passkey.id,
},
})
},
...passkey,
}
})
},
async createVerification({ prisma }, use) {
await use(async (input) => {
const { otp, ...totpConfig } = input.totp
const verification = await prisma.verification.create({
data: {
...totpConfig,
type: '2fa',
target: input.userId,
},
})

return {
async [Symbol.asyncDispose]() {
await prisma.verification.deleteMany({
where: { id: verification.id },
})
},
...verification,
}
})
},
})

export { expect }