Write integration tests that cover the full authentication lifecycle: registration, login, JWT issuance, protected route access, token refresh, and logout with cookie invalidation.
Authentication is the most security-critical part of any application, and it is also the part where integration tests add the most value over unit tests. A unit test can verify that bcrypt.compare returns the right boolean, but only an integration test can verify that a login request returns a correctly set HttpOnly cookie, that the cookie is sent on subsequent requests, that the JWT contains the right claims, and that a logout request actually clears it.
This lesson builds a complete authentication test suite covering the registration-to-logout lifecycle. Every test sends real HTTP requests through the full middleware stack and asserts on the observable security behaviour: headers, cookies, response codes, and database state.
Registration and login tests verify the critical security properties: password is never returned, token is set correctly, and invalid credentials are rejected.
1import request from 'supertest';
2import { app } from '../app';
3import { getTestPrisma } from '../test/database';
4
5beforeEach(async () => {
6 await getTestPrisma().$executeRaw`TRUNCATE "User", "Session" CASCADE`;
7});
8
9describe('POST /auth/register', () => {
10 const validPayload = {
11 name: 'Alice Smith',
12 email: '[email protected]',
13 password: 'SecurePass1!',
14 };
15
16 it('returns 201 with user id and email', async () => {
17 const res = await request(app).post('/auth/register').send(validPayload);
18 expect(res.status).toBe(201);
19 expect(res.body.user.id).toBeDefined();
20 expect(res.body.user.email).toBe('[email protected]');
21 });
22
23 it('never returns the password or hash in the response', async () => {
24 const res = await request(app).post('/auth/register').send(validPayload);
25 expect(res.body.user.password).toBeUndefined();
26 expect(res.body.user.passwordHash).toBeUndefined();
27 });
28
29 it('sets an HttpOnly access token cookie', async () => {
30 const res = await request(app).post('/auth/register').send(validPayload);
31 const setCookie = res.headers['set-cookie'] as string[] | string;
32 const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
33 const accessCookie = cookies.find(c => c.startsWith('accessToken='));
34 expect(accessCookie).toBeDefined();
35 expect(accessCookie).toContain('HttpOnly');
36 });
37
38 it('returns 409 when email is already registered', async () => {
39 await request(app).post('/auth/register').send(validPayload);
40 const res = await request(app).post('/auth/register').send(validPayload);
41 expect(res.status).toBe(409);
42 expect(res.body.error).toContain('already registered');
43 });
44
45 it('returns 422 for password shorter than 8 characters', async () => {
46 const res = await request(app)
47 .post('/auth/register')
48 .send({ ...validPayload, password: 'short' });
49 expect(res.status).toBe(422);
50 });
51});
52
53describe('POST /auth/login', () => {
54 beforeEach(async () => {
55 // Seed a user to log in as
56 await request(app).post('/auth/register').send({
57 name: 'Alice',
58 email: '[email protected]',
59 password: 'SecurePass1!',
60 });
61 });
62
63 it('returns 200 with tokens for valid credentials', async () => {
64 const res = await request(app)
65 .post('/auth/login')
66 .send({ email: '[email protected]', password: 'SecurePass1!' });
67 expect(res.status).toBe(200);
68 expect(res.body.accessToken).toBeDefined();
69 });
70
71 it('returns 401 for wrong password', async () => {
72 const res = await request(app)
73 .post('/auth/login')
74 .send({ email: '[email protected]', password: 'WrongPassword' });
75 expect(res.status).toBe(401);
76 expect(res.body.error).not.toContain('hash'); // never reveal internal details
77 });
78
79 it('returns 401 for unregistered email', async () => {
80 const res = await request(app)
81 .post('/auth/login')
82 .send({ email: '[email protected]', password: 'SecurePass1!' });
83 expect(res.status).toBe(401);
84 // Same error message as wrong password — prevents user enumeration
85 expect(res.body.error).toBe('Invalid credentials');
86 });
87});Protected route tests verify the middleware chain: no token → 401, wrong role → 403, valid token → 200.
1import jwt from 'jsonwebtoken';
2
3const JWT_SECRET = process.env['JWT_SECRET'] ?? 'test-secret';
4
5function makeToken(payload: object, expiresIn = '1h'): string {
6 return jwt.sign(payload, JWT_SECRET, { expiresIn });
7}
8
9describe('Protected routes', () => {
10 describe('GET /api/profile (any authenticated user)', () => {
11 it('returns 401 when no Authorization header', async () => {
12 const res = await request(app).get('/api/profile');
13 expect(res.status).toBe(401);
14 });
15
16 it('returns 401 for malformed Bearer token', async () => {
17 const res = await request(app)
18 .get('/api/profile')
19 .set('Authorization', 'Bearer not.a.real.jwt');
20 expect(res.status).toBe(401);
21 });
22
23 it('returns 401 for expired token', async () => {
24 const expired = makeToken({ userId: 'u1', role: 'student' }, '-1s');
25 const res = await request(app)
26 .get('/api/profile')
27 .set('Authorization', `Bearer ${expired}`);
28 expect(res.status).toBe(401);
29 });
30
31 it('returns 200 for valid student token', async () => {
32 const token = makeToken({ userId: 'u1', role: 'student' });
33 const res = await request(app)
34 .get('/api/profile')
35 .set('Authorization', `Bearer ${token}`);
36 expect(res.status).toBe(200);
37 });
38 });
39
40 describe('GET /api/admin/users (admin only)', () => {
41 it('returns 403 when authenticated as student', async () => {
42 const token = makeToken({ userId: 'u1', role: 'student' });
43 const res = await request(app)
44 .get('/api/admin/users')
45 .set('Authorization', `Bearer ${token}`);
46 expect(res.status).toBe(403);
47 expect(res.body.error).toContain('Insufficient permissions');
48 });
49
50 it('returns 200 when authenticated as admin', async () => {
51 const token = makeToken({ userId: 'admin1', role: 'admin' });
52 const res = await request(app)
53 .get('/api/admin/users')
54 .set('Authorization', `Bearer ${token}`);
55 expect(res.status).toBe(200);
56 });
57 });
58});Refresh token rotation is security-critical: when a refresh token is used, the old one must be invalidated and a new one issued. Tests verify this contract.
1describe('POST /auth/refresh', () => {
2 let refreshToken: string;
3 let agent: ReturnType<typeof request.agent>;
4
5 beforeEach(async () => {
6 agent = request.agent(app);
7 // Register and login to get a refresh token cookie
8 await agent.post('/auth/register').send({
9 name: 'Bob', email: '[email protected]', password: 'SecurePass1!',
10 });
11 const loginRes = await agent.post('/auth/login').send({
12 email: '[email protected]', password: 'SecurePass1!',
13 });
14 refreshToken = loginRes.body.refreshToken;
15 });
16
17 it('issues a new access token when refresh token is valid', async () => {
18 const res = await request(app)
19 .post('/auth/refresh')
20 .send({ refreshToken });
21
22 expect(res.status).toBe(200);
23 expect(res.body.accessToken).toBeDefined();
24 expect(res.body.accessToken).not.toBe(refreshToken); // new token issued
25 });
26
27 it('issues a new refresh token (rotation)', async () => {
28 const res = await request(app)
29 .post('/auth/refresh')
30 .send({ refreshToken });
31
32 expect(res.body.refreshToken).toBeDefined();
33 expect(res.body.refreshToken).not.toBe(refreshToken); // rotated
34 });
35
36 it('returns 401 when the refresh token is reused after rotation', async () => {
37 // Use the token once
38 await request(app).post('/auth/refresh').send({ refreshToken });
39
40 // Try to use it again — must be rejected (prevents token theft)
41 const res = await request(app)
42 .post('/auth/refresh')
43 .send({ refreshToken });
44
45 expect(res.status).toBe(401);
46 expect(res.body.error).toContain('invalid');
47 });
48
49 it('returns 401 for an expired refresh token', async () => {
50 const expiredToken = jwt.sign(
51 { userId: 'u1', type: 'refresh' },
52 JWT_SECRET,
53 { expiresIn: '-1s' }
54 );
55 const res = await request(app)
56 .post('/auth/refresh')
57 .send({ refreshToken: expiredToken });
58 expect(res.status).toBe(401);
59 });
60});Logout must clear the authentication cookie and invalidate the server-side session or refresh token record.
1describe('POST /auth/logout', () => {
2 let agent: ReturnType<typeof request.agent>;
3 let accessToken: string;
4
5 beforeEach(async () => {
6 agent = request.agent(app);
7 await agent.post('/auth/register').send({
8 name: 'Carol', email: '[email protected]', password: 'SecurePass1!',
9 });
10 const loginRes = await agent.post('/auth/login').send({
11 email: '[email protected]', password: 'SecurePass1!',
12 });
13 accessToken = loginRes.body.accessToken;
14 });
15
16 it('returns 200 and clears the access token cookie', async () => {
17 const res = await agent
18 .post('/auth/logout')
19 .set('Authorization', `Bearer ${accessToken}`);
20
21 expect(res.status).toBe(200);
22
23 const setCookie = res.headers['set-cookie'] as string[] | string;
24 const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
25 const accessCookie = cookies.find(c => c.startsWith('accessToken='));
26 // Cookie should be cleared: expires in the past or Max-Age=0
27 expect(accessCookie).toMatch(/Max-Age=0|expires=Thu, 01 Jan 1970/);
28 });
29
30 it('invalidates the refresh token in the database', async () => {
31 const loginRes = await agent.post('/auth/login').send({
32 email: '[email protected]', password: 'SecurePass1!',
33 });
34 const refreshToken = loginRes.body.refreshToken;
35
36 await agent
37 .post('/auth/logout')
38 .set('Authorization', `Bearer ${accessToken}`);
39
40 // Attempting to use the refresh token after logout should fail
41 const refreshRes = await request(app)
42 .post('/auth/refresh')
43 .send({ refreshToken });
44 expect(refreshRes.status).toBe(401);
45 });
46
47 it('protected routes reject requests after logout', async () => {
48 await agent
49 .post('/auth/logout')
50 .set('Authorization', `Bearer ${accessToken}`);
51
52 // Access token is still structurally valid but server-side invalidated
53 const res = await agent.get('/api/profile');
54 expect(res.status).toBe(401);
55 });
56});Multi-factor authentication and email verification add additional states to the authentication flow. Integration tests ensure these state transitions work correctly.
1describe('Email verification flow', () => {
2 it('allows login but blocks protected resources until email is verified', async () => {
3 // Register — email not verified yet
4 const regRes = await request(app).post('/auth/register').send({
5 name: 'Dave', email: '[email protected]', password: 'SecurePass1!',
6 });
7 const unverifiedToken = regRes.body.accessToken;
8
9 // Can still get a token (limited scope)
10 expect(unverifiedToken).toBeDefined();
11
12 // But cannot access resources requiring verified email
13 const res = await request(app)
14 .get('/api/courses')
15 .set('Authorization', `Bearer ${unverifiedToken}`);
16 expect(res.status).toBe(403);
17 expect(res.body.error).toContain('email not verified');
18 });
19
20 it('grants full access after email verification token is used', async () => {
21 await request(app).post('/auth/register').send({
22 name: 'Eve', email: '[email protected]', password: 'SecurePass1!',
23 });
24
25 // Retrieve the verification token from the database directly
26 const prisma = getTestPrisma();
27 const record = await prisma.verificationToken.findFirst({
28 where: { email: '[email protected]' },
29 });
30 expect(record).toBeDefined();
31
32 // Use the token to verify
33 const verifyRes = await request(app)
34 .post('/auth/verify-email')
35 .send({ token: record!.token });
36 expect(verifyRes.status).toBe(200);
37
38 // Now login and access protected resource
39 const loginRes = await request(app).post('/auth/login').send({
40 email: '[email protected]', password: 'SecurePass1!',
41 });
42 const verifiedToken = loginRes.body.accessToken;
43
44 const res = await request(app)
45 .get('/api/courses')
46 .set('Authorization', `Bearer ${verifiedToken}`);
47 expect(res.status).toBe(200);
48 });
49});End-to-end authentication integration tests covering registration, login, protected access, token refresh rotation, and logout cookie clearing using Supertest agent.
1// src/auth/auth.integration.test.ts
2import request from 'supertest';
3import { app } from '../app';
4import { getTestPrisma } from '../test/database';
5
6const prisma = getTestPrisma();
7
8beforeEach(async () => {
9 await prisma.$executeRaw`TRUNCATE "User", "Session", "RefreshToken", "VerificationToken" CASCADE`;
10});
11
12// Helper to register + login and get both tokens
13async function registerAndLogin(email = '[email protected]', password = 'SecurePass1!') {
14 await request(app).post('/auth/register').send({ name: 'Test User', email, password });
15 const res = await request(app).post('/auth/login').send({ email, password });
16 return {
17 accessToken: res.body.accessToken as string,
18 refreshToken: res.body.refreshToken as string,
19 userId: res.body.user.id as string,
20 };
21}
22
23describe('Authentication Integration', () => {
24 describe('Full registration → login → access → logout cycle', () => {
25 it('completes the full auth lifecycle without errors', async () => {
26 // 1. Register
27 const regRes = await request(app).post('/auth/register').send({
28 name: 'Full Cycle User',
29 email: '[email protected]',
30 password: 'SecurePass1!',
31 });
32 expect(regRes.status).toBe(201);
33
34 // 2. Login
35 const loginRes = await request(app).post('/auth/login').send({
36 email: '[email protected]',
37 password: 'SecurePass1!',
38 });
39 expect(loginRes.status).toBe(200);
40 const { accessToken, refreshToken } = loginRes.body;
41
42 // 3. Access protected resource
43 const profileRes = await request(app)
44 .get('/api/profile')
45 .set('Authorization', `Bearer ${accessToken}`);
46 expect(profileRes.status).toBe(200);
47
48 // 4. Refresh tokens
49 const refreshRes = await request(app)
50 .post('/auth/refresh')
51 .send({ refreshToken });
52 expect(refreshRes.status).toBe(200);
53 const newAccessToken = refreshRes.body.accessToken;
54 expect(newAccessToken).not.toBe(accessToken);
55
56 // 5. Access with new token
57 const newProfileRes = await request(app)
58 .get('/api/profile')
59 .set('Authorization', `Bearer ${newAccessToken}`);
60 expect(newProfileRes.status).toBe(200);
61
62 // 6. Logout
63 const logoutRes = await request(app)
64 .post('/auth/logout')
65 .set('Authorization', `Bearer ${newAccessToken}`);
66 expect(logoutRes.status).toBe(200);
67
68 // 7. Verify access is denied after logout
69 const afterLogout = await request(app)
70 .get('/api/profile')
71 .set('Authorization', `Bearer ${newAccessToken}`);
72 expect(afterLogout.status).toBe(401);
73 });
74 });
75
76 describe('Brute force protection', () => {
77 it('returns 429 after 5 failed login attempts', async () => {
78 await request(app).post('/auth/register').send({
79 name: 'Rate Limited', email: '[email protected]', password: 'SecurePass1!',
80 });
81
82 // Attempt 5 wrong password logins
83 for (let i = 0; i < 5; i++) {
84 await request(app).post('/auth/login').send({
85 email: '[email protected]', password: 'WrongPassword',
86 });
87 }
88
89 // 6th attempt should be rate limited
90 const res = await request(app).post('/auth/login').send({
91 email: '[email protected]', password: 'WrongPassword',
92 });
93 expect(res.status).toBe(429);
94 expect(res.headers['retry-after']).toBeDefined();
95 });
96 });
97
98 describe('Role-based access control', () => {
99 it('enforces role separation between student and instructor routes', async () => {
100 const { accessToken: studentToken } = await registerAndLogin('[email protected]');
101 const { accessToken: instructorToken } = await registerAndLogin('[email protected]');
102
103 // Promote second user to instructor (direct DB operation in test)
104 await prisma.user.update({
105 where: { email: '[email protected]' },
106 data: { role: 'INSTRUCTOR' },
107 });
108
109 // Re-login to get token with updated role
110 const instrLoginRes = await request(app).post('/auth/login').send({
111 email: '[email protected]', password: 'SecurePass1!',
112 });
113 const instrToken = instrLoginRes.body.accessToken;
114
115 // Student cannot access instructor routes
116 const studentAccess = await request(app)
117 .get('/api/instructor/courses')
118 .set('Authorization', `Bearer ${studentToken}`);
119 expect(studentAccess.status).toBe(403);
120
121 // Instructor can access instructor routes
122 const instrAccess = await request(app)
123 .get('/api/instructor/courses')
124 .set('Authorization', `Bearer ${instrToken}`);
125 expect(instrAccess.status).toBe(200);
126 });
127 });
128});The full lifecycle test in a single it block is intentional here — it documents the expected happy-path auth flow as a sequential narrative. All other tests are properly isolated to a single behaviour. Notice registerAndLogin() is a helper that keeps repeated setup out of the tests themselves. The brute force test sends multiple requests in a loop to trigger rate limiting — only possible in an integration test where the real rate limiter middleware is active.
The next lesson covers testing file upload endpoints: multipart form data with Supertest, file size and MIME type validation, and asserting on storage side effects.
Your progress is saved in this browser, on this device only.