Skip to content

Commit dade94f

Browse files
committed
feat: magic link tests
1 parent bbfbbe5 commit dade94f

3 files changed

Lines changed: 227 additions & 10 deletions

File tree

src/controllers/magicLinks.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export async function verifyMagicLink(req: Request, res: Response) {
8686
const { token } = req.params;
8787

8888
if (!token) {
89-
return res.status(400).json({ message: 'Missing verification token' });
89+
return res.status(400).json({ error: 'Missing verification token' });
9090
}
9191
const tokenHash = hashSha256(token);
9292

@@ -96,17 +96,17 @@ export async function verifyMagicLink(req: Request, res: Response) {
9696

9797
if (!record) {
9898
logger.warn(`No magic link found for token: ${token}`);
99-
return res.status(400).json({ message: 'Invalid verification token' });
99+
return res.status(400).json({ error: 'Invalid verification token' });
100100
}
101101

102102
if (record.used_at) {
103103
logger.warn(`Magic link token is already used ${token}`);
104-
return res.status(400).json({ message: 'Invalid verification token' });
104+
return res.status(400).json({ error: 'Invalid verification token' });
105105
}
106106

107107
if (record.expires_at < new Date()) {
108108
logger.warn(`Magic link token expired: ${token}`);
109-
return res.status(400).json({ message: 'Invalid verification token' });
109+
return res.status(400).json({ error: 'Invalid verification token' });
110110
}
111111

112112
// Atomic consume
@@ -124,7 +124,7 @@ export async function verifyMagicLink(req: Request, res: Response) {
124124

125125
if (!updated) {
126126
logger.error(`Magic link token was not consumted: ${token}`);
127-
return res.status(500).json({ message: 'Failed to use token' });
127+
return res.status(500).json({ error: 'Failed to use token' });
128128
}
129129

130130
await AuthEventService.log({
@@ -156,7 +156,7 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) {
156156

157157
if (!user) {
158158
return res.status(400).json({
159-
message: 'Failed',
159+
error: 'Failed',
160160
});
161161
}
162162

@@ -165,19 +165,19 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) {
165165
});
166166

167167
if (!record) {
168-
console.log('No magic link token');
169-
return res.status(500).json({ message: 'Invalid request' });
168+
logger.warn('No magic link token');
169+
return res.status(500).json({ error: 'Invalid request' });
170170
}
171171

172172
// Device binding check
173173
const { ip_hash, user_agent_hash } = hashDeviceFingerprint(req.ip, req.headers['user-agent']);
174174

175175
if (record.ip_hash && record.ip_hash !== ip_hash) {
176-
return res.status(500).json({ message: 'Invalid request' });
176+
return res.status(500).json({ error: 'Invalid request' });
177177
}
178178

179179
if (record.user_agent_hash && record.user_agent_hash !== user_agent_hash) {
180-
return res.status(500).json({ message: 'Invalid request' });
180+
return res.status(500).json({ error: 'Invalid request' });
181181
}
182182

183183
if (record.used_at && record.expires_at > new Date()) {
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import request from 'supertest';
2+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { Application } from 'express';
4+
5+
import { User } from '../../../src/models/users.js';
6+
import { MagicLinkToken } from '../../../src/models/magicLinks.js';
7+
import { Session } from '../../../src/models/sessions.js';
8+
9+
import { createApp } from '../../../src/app.js';
10+
import { getSystemConfig } from '../../../src/config/getSystemConfig.js';
11+
import { generateRefreshToken, hashRefreshToken, signAccessToken } from '../../../src/lib/token.js';
12+
13+
let app: Application;
14+
15+
function buildMagicLink(overrides: any = {}) {
16+
return {
17+
id: 'link-1',
18+
user_id: 'user-1',
19+
token_hash: 'hash',
20+
used_at: null,
21+
expires_at: new Date(Date.now() + 100000),
22+
ip_hash: 'ip',
23+
user_agent_hash: 'ua',
24+
...overrides,
25+
};
26+
}
27+
28+
beforeAll(async () => {
29+
app = await createApp();
30+
});
31+
32+
beforeEach(() => {
33+
(User.findOne as any).mockResolvedValue({
34+
id: 'user-1',
35+
email: 'test@example.com',
36+
});
37+
38+
(getSystemConfig as any).mockResolvedValue({
39+
available_roles: ['user', 'admin'],
40+
default_roles: ['user'],
41+
access_token_ttl: '15m',
42+
refresh_token_ttl: '1h',
43+
origins: ['http://localhost:5174'],
44+
});
45+
});
46+
47+
describe('GET /magic-link', () => {
48+
it('returns success message even if user not found', async () => {
49+
(User.findOne as any).mockResolvedValue(null);
50+
51+
const res = await request(app).get('/magic-link');
52+
53+
expect(res.status).toBe(200);
54+
expect(res.body.message).toContain('If an account exists');
55+
});
56+
57+
it('creates magic link when user exists', async () => {
58+
(User.findOne as any).mockResolvedValue({
59+
id: 'user-1',
60+
email: 'test@example.com',
61+
});
62+
63+
(MagicLinkToken.update as any).mockResolvedValue([1]);
64+
(MagicLinkToken.create as any).mockResolvedValue({});
65+
66+
const res = await request(app).get('/magic-link');
67+
68+
expect(res.status).toBe(200);
69+
expect(MagicLinkToken.create).toHaveBeenCalled();
70+
});
71+
});
72+
73+
describe('GET /magic-link/verify/:token', () => {
74+
it('rejects missing token', async () => {
75+
const res = await request(app).get('/magic-link/verify/');
76+
77+
expect(res.status).toBe(404); // route mismatch
78+
});
79+
80+
it('rejects invalid token', async () => {
81+
(MagicLinkToken.findOne as any).mockResolvedValue(null);
82+
83+
const res = await request(app).get('/magic-link/verify/bad');
84+
85+
expect(res.status).toBe(400);
86+
});
87+
88+
it('rejects used token', async () => {
89+
(MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink({ used_at: new Date() }));
90+
91+
const res = await request(app).get('/magic-link/verify/token');
92+
93+
expect(res.status).toBe(400);
94+
});
95+
96+
it('rejects expired token', async () => {
97+
(MagicLinkToken.findOne as any).mockResolvedValue(
98+
buildMagicLink({ expires_at: new Date(Date.now() - 1000) }),
99+
);
100+
101+
const res = await request(app).get('/magic-link/verify/token');
102+
103+
expect(res.status).toBe(400);
104+
});
105+
106+
it('accepts valid token', async () => {
107+
(MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink());
108+
109+
(MagicLinkToken.update as any).mockResolvedValue([1]);
110+
111+
const res = await request(app).get('/magic-link/verify/token');
112+
113+
expect(res.status).toBe(200);
114+
});
115+
});
116+
117+
describe('GET /magic-link/check', () => {
118+
it('returns 400 when user not found', async () => {
119+
(User.findOne as any).mockResolvedValue(null);
120+
121+
const res = await request(app).get('/magic-link/check');
122+
123+
expect(res.status).toBe(400);
124+
});
125+
126+
it('returns 500 when no token found', async () => {
127+
(User.findOne as any).mockResolvedValue({ id: 'user-1', email: 'test@example.com' });
128+
(MagicLinkToken.findOne as any).mockResolvedValue(null);
129+
130+
const res = await request(app).get('/magic-link/check');
131+
132+
expect(res.status).toBe(500);
133+
});
134+
135+
it('returns 204 when not yet verified', async () => {
136+
(User.findOne as any).mockResolvedValue({ id: 'user-1', email: 'test@example.com' });
137+
138+
(MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink({ used_at: null }));
139+
140+
const res = await request(app).get('/magic-link/check');
141+
142+
expect(res.status).toBe(204);
143+
});
144+
});
145+
146+
it('creates session when magic link completed', async () => {
147+
const user = {
148+
id: 'user-1',
149+
email: 'test@example.com',
150+
roles: ['user'],
151+
save: vi.fn(),
152+
};
153+
154+
(User.findOne as any).mockResolvedValue(user);
155+
156+
(MagicLinkToken.findOne as any).mockResolvedValue(
157+
buildMagicLink({
158+
used_at: new Date(),
159+
expires_at: new Date(Date.now() + 100000),
160+
ip_hash: 'ip',
161+
user_agent_hash: 'ua',
162+
}),
163+
);
164+
165+
(Session.create as any).mockResolvedValue({ id: 'session-1' });
166+
167+
(generateRefreshToken as any).mockReturnValue('refresh-token');
168+
(hashRefreshToken as any).mockResolvedValue('hashed-refresh');
169+
(signAccessToken as any).mockResolvedValue('access-token');
170+
171+
const res = await request(app).get('/magic-link/check');
172+
173+
expect(res.status).toBe(200);
174+
});

tests/setup/mocks.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ vi.mock('../../src/middleware/requireAdmin.js', () => ({
9595
},
9696
}));
9797

98+
vi.mock('../../src/middleware/rateLimit.js', () => ({
99+
magicLinkIpLimiter: (_req: any, _res: any, next: any) => next(),
100+
magicLinkEmailLimiter: (_req: any, _res: any, next: any) => next(),
101+
dynamicRateLimit: (_req: any, _res: any, next: any) => next(),
102+
dynamicSlowDown: (_req: any, _res: any, next: any) => next(),
103+
}));
104+
98105
vi.mock('../../src/utils/otp.js', () => ({
99106
generatePhoneOTP: vi.fn(),
100107
generateEmailOTP: vi.fn(),
@@ -125,3 +132,39 @@ vi.mock('../../src/services/authEventService.js', () => ({
125132
serviceTokenInvalid: vi.fn(),
126133
},
127134
}));
135+
136+
vi.mock('../../src/models/magicLinks.js', () => ({
137+
MagicLinkToken: {
138+
create: vi.fn(),
139+
findOne: vi.fn(),
140+
update: vi.fn(),
141+
},
142+
}));
143+
144+
vi.mock('../../src/services/messagingService.js', () => ({
145+
sendMagicLinkEmail: vi.fn(),
146+
}));
147+
148+
vi.mock('crypto', async () => {
149+
const actual = await vi.importActual<typeof import('crypto')>('crypto');
150+
return {
151+
...actual,
152+
randomBytes: vi.fn(() => ({
153+
toString: () => 'mock-token',
154+
})),
155+
};
156+
});
157+
158+
vi.mock('../../src/utils/utils.js', async () => {
159+
const actual = await vi.importActual<typeof import('../../src/utils/utils.js')>(
160+
'../../src/utils/utils.js',
161+
);
162+
163+
return {
164+
...actual,
165+
hashDeviceFingerprint: vi.fn(() => ({
166+
ip_hash: 'ip',
167+
user_agent_hash: 'ua',
168+
})),
169+
};
170+
});

0 commit comments

Comments
 (0)