Skip to content

Commit 02fafb6

Browse files
committed
02/02: add exercise texts
1 parent b66598d commit 02fafb6

5 files changed

Lines changed: 162 additions & 32 deletions

File tree

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
# Two-factor authentication
22

3+
Two-factor authentication (2FA) is a common addition to any authentication flow. It exists as a separate, additional layer of identity verification not only to supplement authentication but also act as a guard for sensitive and destructive actions of already authenticated users. You've likely seen it in action in both of those ways.
4+
5+
And now you're going to test it yourself.
6+
37
## Your task
48

5-
- Write the `tests/e2e/authentication-2fa.test.ts`.
6-
- Implement a `createVerification()` utility.
7-
- Use `@epic-web/totp` to generate the OTP in the test case.
9+
👨‍💼 In this one, follow the instructions in <InlineFile filename="./tests/e2e/authentication-2fa.test.ts">`tests/e2e/authentication-2fa.test.ts`</InlineFile> to write an end-to-end test for the two-factor authentication flow in Epic Stack.
10+
11+
🐨 Then, in addition to the `createUser()` utility you've prepared earlier, create another utility called `createVerification()` that would collocate a given user with a One-Time Password (OTP), storing that relationship in the database.
12+
13+
🐨 And, finally, log in with the test user and fill in the OTP input that will appear now as a part of the authentication attempt. As always, have the test passing.

exercises/02.authentication/02.problem.2fa/tests/db-utils.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,21 +59,20 @@ export async function createVerification(input: {
5959
userId: string
6060
}) {
6161
const { otp, ...totpConfig } = input.totp
62-
const verification = await prisma.verification.create({
63-
data: {
64-
...totpConfig,
65-
type: '2fa',
66-
target: input.userId,
67-
},
68-
})
62+
63+
// 🐨 Create a new 2FA verification record in the database
64+
// and store it in a variable called "verification".
65+
// 💰 await prisma.verification.create({
66+
// data: { ...totpConfig, type: '2fa', target: input.userId }
67+
// })
6968

7069
return {
7170
async [Symbol.asyncDispose]() {
72-
await prisma.verification.deleteMany({
73-
where: { id: verification.id },
74-
})
71+
// 🐨 Delete the previously created verification record fr om
72+
// 💰 await prisma.verification.deleteMany({ where: { ... } })
7573
},
76-
...verification,
74+
// 🐨 Return the verification object by spreading it here.
75+
// 💰 ...verification
7776
}
7877
}
7978

exercises/02.authentication/02.problem.2fa/tests/e2e/authentication-2fa.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,38 @@ test('authenticates using two-factor authentication', async ({
66
navigate,
77
page,
88
}) => {
9-
/** @todo Instructions */
9+
// 🐨 Create a test user by calling "createUser()" and storing
10+
// the returned disposable object in a variable called "user".
11+
// 💰 await using user = await fn()
12+
13+
// 🐨 Generate a Time-based One-Time Password (TOPT) using the
14+
// "generateTOTP" function from the "@epic-web/totp" package and
15+
// store it in a variable called "totp".
16+
// 💰 const totp = await generateTOTP()
17+
18+
// 🐨 Create a verification for the user by calling "createVerification()"
19+
// with an object containing the "totp" and the "userId" (which is the
20+
// "id" property of the created user). Store the returned disposable
21+
// object in placeholder variable "_".
22+
// 💰 await using _ = await fn({ totp, userId: user.id })
23+
24+
await navigate('/login')
25+
26+
// 🐨 Fill in the "user.username" into the field with the label "Username".
27+
// 💰 await page.getByLabel(label).fill(value)
28+
29+
// 🐨 Fill in the "user.password" into the field with the label "Password".
30+
// 🐨 Submit the login form.
31+
32+
// 🐨 Write an assertion that expects a heading element with the text
33+
// "Check your 2FA app" to be visible on the page.
34+
// 💰 await expect(page.getByRole(role, { name: name })).toBeVisible()
35+
36+
// 🐨 Next, generate and enter a new TOTP into the textbox with an
37+
// accessible name matching /code/i.
38+
// 💰 await page.getByRole(role, { name: name }).fill((await generateTOTP(totp)).otp)
39+
40+
// 🐨 Finally, add an assertion that expects the link element
41+
// with the user's name to be visible on the page.
42+
// 💰 await expect(page.getByRole(role, { name: user.name })).toBeVisible()
1043
})
Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,116 @@
11
# Two-factor authentication
22

3-
## Summary
3+
The 2FA flow introduces a new element to authentication: One-Time Password. Unlike regular passwords, OTP doesn't get stored anywhere as that would defeat the point of the "One-Time" part. Instead, the server stores a _verification_ record associating a user with a _secret_ that can be used to check whether any OTP actually belongs to that user.
44

5-
1. Install `@epic-web/totp`. We will use it to generate verification codes for our 2FA test.
6-
1. In `test/db-utils.ts`, create a new function called `createVerification`. Go through its implementation step-by-step.
7-
1. Add an `asyncDispose` symbol to auto-cleanup the test data when the test closure it garbage-collected.
5+
Our test setup has to mirror this specifics if we want to achieve reliable test coverage.
86

9-
---
7+
## `createVerification()` utility
108

11-
1. Create a new test at `tests/e2e/authentication-2fa.test.ts`.
12-
1. Use the `createUser` utility to create a random user.
13-
1. Use the `generatTOTP` from `@epic-web/totp` to generate an OTP.
14-
1. Use the new `createVerification` utility to generate a verification code with the given OTP for the given test user.
15-
1. Go to the login page, log in using the credentials.
16-
1. Wait until the 2FA verification prompt becomes visible.
17-
1. Enter the generated verification code.
18-
1. Verify that the auth was successful by the visibility of the user profile link.
9+
Just like creating test users with `createUser()`, the OTP verification record is one more server-side resource we need to seed for the test. To actually create OTP tokens, we are going to use a library called [`@epic-web/totp`](https://github.com/epicweb-dev/totp). This is _not_ a testing library. In fact, if you examine the authentication code for Epic Stack, you can quickly spot that we are using it to back up the actual authentication. This kind of reusage is most welcome in tests.
1910

20-
---
11+
```ts filename=test/db-utils.ts add=6-12,16-18
12+
export async function createVerification(input: {
13+
totp: Awaited<ReturnType<typeof generateTOTP>>
14+
userId: string
15+
}) {
16+
const { otp, ...totpConfig } = input.totp
17+
const verification = await prisma.verification.create({
18+
data: {
19+
...totpConfig,
20+
type: '2fa',
21+
target: input.userId,
22+
},
23+
})
2124

22-
1. `npm run test:e2e`. to confirm.
25+
return {
26+
async [Symbol.asyncDispose]() {
27+
await prisma.verification.deleteMany({
28+
where: { id: verification.id },
29+
})
30+
},
31+
...verification,
32+
}
33+
}
34+
```
35+
36+
> The exact shape of the verification record (`type`, `target`, etc.) is, once again, dictated by our application's architecture. Make sure your test setup reflects <u>**your**</u> application.
37+
38+
## Testing Two-factor authentication
39+
40+
With `createVerification()` ready, we've got all the pieces we need to put our application into the right state for testing the 2FA flow.
41+
42+
```ts filename=tests/e2e/authentication-2fa.test.ts
43+
import { generateTOTP } from '@epic-web/totp'
44+
import { createUser, createVerification } from '#tests/db-utils.ts'
45+
import { test, expect } from '#tests/test-extend.ts'
46+
47+
test('authenticates using two-factor authentication', async ({
48+
navigate,
49+
page,
50+
}) => {
51+
// Setup.
52+
await using user = await createUser()
53+
const totp = await generateTOTP()
54+
await using _ = await createVerification({
55+
totp,
56+
userId: user.id,
57+
})
58+
59+
await navigate('/login')
60+
61+
// Actions.
62+
await page.getByLabel('Username').fill(user.username)
63+
await page.getByLabel('Password').fill(user.password)
64+
await page.getByRole('button', { name: 'Log in' }).click()
65+
66+
// Assertions.
67+
await expect(
68+
page.getByRole('heading', { name: 'Check your 2FA app' }),
69+
).toBeVisible()
70+
71+
// Actions.
72+
await page
73+
.getByRole('textbox', { name: /code/i })
74+
.fill((await generateTOTP(totp)).otp)
75+
76+
await page.getByRole('button', { name: 'Submit' }).click()
77+
78+
// Assertions.
79+
await expect(page.getByRole('link', { name: user.name! })).toBeVisible()
80+
})
81+
```
82+
83+
Let's take a moment to appreciate how genuinely straightforward this test is. Despite tackling a rather complex flow, the test case itself reads not unlike a regular component test. **This is not a coincidence but a direct result of managing complexity through the test setup.**
84+
85+
`createUser()` and `generateTOTP()` and `createVerification()` are complex functions doing complex things, but they aren't leaking any of that complexity into the test case. Instead, they encapsulate it in clean and understandable steps:
86+
87+
1. Create a test user;
88+
1. Genereate a One-Time Password;
89+
1. Create a verification for this user and this OTP.
90+
91+
They expose control while offloading the implementation details and the complexity behind them to the backstage—to the test setup—where it belongs. And they give you a nice eagle-eye overview of what this test case involves, which is also nice!
92+
93+
The key part here is that once we arrive at the "Check your 2FA app" page, we enter a new TOTP the same way a user would open their password manager and copy-paste a new token into our app:
94+
95+
```ts
96+
await expect(
97+
page.getByRole('heading', { name: 'Check your 2FA app' }),
98+
).toBeVisible()
99+
100+
await page
101+
.getByRole('textbox', { name: /code/i })
102+
.fill((await generateTOTP(totp)).otp)
103+
```
104+
105+
The rest of this test case interacts with the application and asserts on the UI the same way we did in the previous tests and requires no further explanation.
106+
107+
## Running tests
108+
109+
```
110+
npm run test:e2e
111+
```
112+
113+
## Related materials
114+
115+
- [(Video) Dissecting Complexity in Tests](https://www.youtube.com/watch?v=nUozijHdgMM&t=419s)
116+
- [`@epic-web/totp`](https://github.com/epicweb-dev/totp)

exercises/02.authentication/02.solution.2fa/tests/e2e/authentication-2fa.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,13 @@ test('authenticates using two-factor authentication', async ({
66
navigate,
77
page,
88
}) => {
9-
// Create a test user and enable 2FA for them directly in the database.
109
await using user = await createUser()
1110
const totp = await generateTOTP()
1211
await using _ = await createVerification({
1312
totp,
1413
userId: user.id,
1514
})
1615

17-
// Log in as the created user.
1816
await navigate('/login')
1917

2018
await page.getByLabel('Username').fill(user.username)

0 commit comments

Comments
 (0)