|
1 | 1 | # Two-factor authentication |
2 | 2 |
|
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. |
4 | 4 |
|
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. |
8 | 6 |
|
9 | | ---- |
| 7 | +## `createVerification()` utility |
10 | 8 |
|
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. |
19 | 10 |
|
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 | + }) |
21 | 24 |
|
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) |
0 commit comments