Skip to content

Commit fc6f441

Browse files
committed
feat(auth): add e2e session endpoint for Cypress integration
- Introduced a new POST /e2e/session endpoint in AuthController for development use, allowing Cypress to obtain JWTs without UI login. - Added validation to ensure the endpoint is only accessible in development mode with a valid E2E_AUTH_SECRET. - Updated AuthService to issue session tokens for existing users based on email or userId. - Enhanced unit tests to cover the new e2eSession functionality and its edge cases. - Added E2E_AUTH_HEADER constant for consistent header usage across the application.
1 parent 0ee293f commit fc6f441

7 files changed

Lines changed: 247 additions & 2 deletions

File tree

apps/backend/.env.development.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
NODE_ENV=
22

3+
# Optional: non-empty value enables `POST /v1/auth/e2e/session` (dev only) for Cypress —
4+
# same value as Cypress env `E2E_AUTH_SECRET` / `CYPRESS_E2E_AUTH_SECRET`.
5+
E2E_AUTH_SECRET=
6+
37
GITHUB_CLIENT_ID=
48
GITHUB_CLIENT_SECRET=
59

apps/backend/src/auth/auth.controller.spec.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import { BadRequestException, NotFoundException } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
13
import { Test, TestingModule } from '@nestjs/testing';
24
import type { Request, Response } from 'express';
35

6+
import { UserService } from '@server/user/user.service';
7+
48
import { AuthController } from './auth.controller';
59
import { AuthService } from './auth.service';
610
import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy';
@@ -11,6 +15,16 @@ const mockAuthService = {
1115
discordLogin: jest.fn(),
1216
verifyToken: jest.fn(),
1317
loginWithEmail: jest.fn(),
18+
issueSessionTokensForUser: jest.fn(),
19+
};
20+
21+
const mockUserService = {
22+
findByEmail: jest.fn(),
23+
findByID: jest.fn(),
24+
};
25+
26+
const mockConfigService = {
27+
get: jest.fn(),
1428
};
1529

1630
const mockMagicLinkEmailStrategy = {
@@ -36,6 +50,8 @@ describe('AuthController', () => {
3650
provide: MagicLinkEmailStrategy,
3751
useValue: mockMagicLinkEmailStrategy,
3852
},
53+
{ provide: UserService, useValue: mockUserService },
54+
{ provide: ConfigService, useValue: mockConfigService },
3955
],
4056
}).compile();
4157

@@ -192,4 +208,120 @@ describe('AuthController', () => {
192208
expect(authService.verifyToken).toHaveBeenCalledWith(req, res);
193209
});
194210
});
211+
212+
describe('e2eSession', () => {
213+
it('returns 404 when not in development', async () => {
214+
mockConfigService.get.mockImplementation((key: string) => {
215+
if (key === 'NODE_ENV') return 'production';
216+
if (key === 'E2E_AUTH_SECRET') return 'secret';
217+
return undefined;
218+
});
219+
220+
await expect(
221+
controller.e2eSession('secret', { email: '[email protected]' }),
222+
).rejects.toBeInstanceOf(NotFoundException);
223+
});
224+
225+
it('returns 404 when E2E_AUTH_SECRET is empty', async () => {
226+
mockConfigService.get.mockImplementation((key: string) => {
227+
if (key === 'NODE_ENV') return 'development';
228+
if (key === 'E2E_AUTH_SECRET') return '';
229+
return undefined;
230+
});
231+
232+
await expect(
233+
controller.e2eSession('secret', { email: '[email protected]' }),
234+
).rejects.toBeInstanceOf(NotFoundException);
235+
});
236+
237+
it('returns 404 when header secret is wrong', async () => {
238+
mockConfigService.get.mockImplementation((key: string) => {
239+
if (key === 'NODE_ENV') return 'development';
240+
if (key === 'E2E_AUTH_SECRET') return 'good';
241+
return undefined;
242+
});
243+
244+
await expect(
245+
controller.e2eSession('bad', { email: '[email protected]' }),
246+
).rejects.toBeInstanceOf(NotFoundException);
247+
});
248+
249+
it('returns 400 when both email and userId are provided', async () => {
250+
mockConfigService.get.mockImplementation((key: string) => {
251+
if (key === 'NODE_ENV') return 'development';
252+
if (key === 'E2E_AUTH_SECRET') return 's';
253+
return undefined;
254+
});
255+
256+
await expect(
257+
controller.e2eSession('s', { email: '[email protected]', userId: 'id' }),
258+
).rejects.toBeInstanceOf(BadRequestException);
259+
});
260+
261+
it('returns 400 when neither email nor userId', async () => {
262+
mockConfigService.get.mockImplementation((key: string) => {
263+
if (key === 'NODE_ENV') return 'development';
264+
if (key === 'E2E_AUTH_SECRET') return 's';
265+
return undefined;
266+
});
267+
268+
await expect(controller.e2eSession('s', {})).rejects.toBeInstanceOf(
269+
BadRequestException,
270+
);
271+
});
272+
273+
it('returns tokens for existing user by email', async () => {
274+
mockConfigService.get.mockImplementation((key: string) => {
275+
if (key === 'NODE_ENV') return 'development';
276+
if (key === 'E2E_AUTH_SECRET') return 's';
277+
return undefined;
278+
});
279+
const user = { _id: 'u1', email: '[email protected]', username: 'u' };
280+
mockUserService.findByEmail.mockResolvedValueOnce(user);
281+
mockAuthService.issueSessionTokensForUser.mockResolvedValueOnce({
282+
access_token: 'a',
283+
refresh_token: 'r',
284+
});
285+
286+
const out = await controller.e2eSession('s', { email: '[email protected]' });
287+
288+
expect(out).toEqual({ access_token: 'a', refresh_token: 'r' });
289+
expect(mockUserService.findByEmail).toHaveBeenCalledWith('[email protected]');
290+
expect(mockAuthService.issueSessionTokensForUser).toHaveBeenCalledWith(
291+
user,
292+
);
293+
});
294+
295+
it('returns tokens for existing user by userId', async () => {
296+
mockConfigService.get.mockImplementation((key: string) => {
297+
if (key === 'NODE_ENV') return 'development';
298+
if (key === 'E2E_AUTH_SECRET') return 's';
299+
return undefined;
300+
});
301+
const user = { _id: 'u1', email: '[email protected]', username: 'u' };
302+
mockUserService.findByID.mockResolvedValueOnce(user);
303+
mockAuthService.issueSessionTokensForUser.mockResolvedValueOnce({
304+
access_token: 'a',
305+
refresh_token: 'r',
306+
});
307+
308+
const out = await controller.e2eSession('s', { userId: 'abc' });
309+
310+
expect(out).toEqual({ access_token: 'a', refresh_token: 'r' });
311+
expect(mockUserService.findByID).toHaveBeenCalledWith('abc');
312+
});
313+
314+
it('returns 404 when user is not found', async () => {
315+
mockConfigService.get.mockImplementation((key: string) => {
316+
if (key === 'NODE_ENV') return 'development';
317+
if (key === 'E2E_AUTH_SECRET') return 's';
318+
return undefined;
319+
});
320+
mockUserService.findByEmail.mockResolvedValueOnce(null);
321+
322+
await expect(
323+
controller.e2eSession('s', { email: '[email protected]' }),
324+
).rejects.toBeInstanceOf(NotFoundException);
325+
});
326+
});
195327
});

apps/backend/src/auth/auth.controller.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,34 @@
11
import {
2+
BadRequestException,
3+
Body,
24
Controller,
35
Get,
6+
Headers,
7+
HttpCode,
48
HttpException,
59
HttpStatus,
610
Inject,
711
Logger,
12+
NotFoundException,
813
Post,
914
Req,
1015
Res,
1116
UseGuards,
1217
} from '@nestjs/common';
18+
import { ConfigService } from '@nestjs/config';
1319
import { AuthGuard } from '@nestjs/passport';
14-
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
15-
import { Throttle } from '@nestjs/throttler';
20+
import {
21+
ApiExcludeEndpoint,
22+
ApiOperation,
23+
ApiResponse,
24+
ApiTags,
25+
} from '@nestjs/swagger';
26+
import { SkipThrottle, Throttle } from '@nestjs/throttler';
1627
import type { Request, Response } from 'express';
1728

29+
import { E2E_AUTH_HEADER } from '@nbw/config';
30+
import { UserService } from '@server/user/user.service';
31+
1832
import { AuthService } from './auth.service';
1933
import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy';
2034

@@ -27,6 +41,9 @@ export class AuthController {
2741
private readonly authService: AuthService,
2842
@Inject(MagicLinkEmailStrategy)
2943
private readonly magicLinkEmailStrategy: MagicLinkEmailStrategy,
44+
@Inject(UserService)
45+
private readonly userService: UserService,
46+
private readonly configService: ConfigService,
3047
) {}
3148

3249
@Throttle({
@@ -119,6 +136,50 @@ export class AuthController {
119136
return this.authService.discordLogin(req, res);
120137
}
121138

139+
/**
140+
* Development-only: mint JWT pair for an existing user so Cypress can `setCookie`
141+
* without UI login. Disabled unless `NODE_ENV === 'development'` and
142+
* `E2E_AUTH_SECRET` is non-empty; wrong/missing secret returns 404 to avoid
143+
* advertising the route.
144+
*/
145+
@Post('e2e/session')
146+
@HttpCode(200)
147+
@SkipThrottle()
148+
@ApiExcludeEndpoint()
149+
public async e2eSession(
150+
@Headers(E2E_AUTH_HEADER) secret: string | undefined,
151+
@Body() body: { email?: string; userId?: string },
152+
): Promise<{ access_token: string; refresh_token: string }> {
153+
const nodeEnv = this.configService.get<string>('NODE_ENV');
154+
const expectedSecret = this.configService.get<string>('E2E_AUTH_SECRET');
155+
if (
156+
nodeEnv !== 'development' ||
157+
!expectedSecret ||
158+
expectedSecret.length === 0
159+
) {
160+
throw new NotFoundException();
161+
}
162+
if (!secret || secret !== expectedSecret) {
163+
throw new NotFoundException();
164+
}
165+
166+
const email = body?.email?.trim();
167+
const userId = body?.userId?.trim();
168+
if ((email ? 1 : 0) + (userId ? 1 : 0) !== 1) {
169+
throw new BadRequestException('Provide exactly one of email or userId');
170+
}
171+
172+
const user = email
173+
? await this.userService.findByEmail(email)
174+
: await this.userService.findByID(userId!);
175+
176+
if (!user) {
177+
throw new NotFoundException();
178+
}
179+
180+
return this.authService.issueSessionTokensForUser(user);
181+
}
182+
122183
@Get('verify')
123184
@ApiOperation({ summary: 'Verify user token' })
124185
@ApiResponse({ status: 200, description: 'User token verified' })

apps/backend/src/auth/auth.service.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,33 @@ describe('AuthService', () => {
185185
});
186186
});
187187

188+
describe('issueSessionTokensForUser', () => {
189+
it('should delegate to createJwtPayload with user fields', async () => {
190+
const user = {
191+
_id: { toString: () => 'oid-1' },
192+
193+
username: 'user1',
194+
} as unknown as UserDocument;
195+
196+
spyOn(authService as any, 'createJwtPayload').mockResolvedValueOnce({
197+
access_token: 'a',
198+
refresh_token: 'r',
199+
});
200+
201+
const tokens = await authService.issueSessionTokensForUser(user);
202+
203+
expect(tokens).toEqual({
204+
access_token: 'a',
205+
refresh_token: 'r',
206+
});
207+
expect((authService as any).createJwtPayload).toHaveBeenCalledWith({
208+
id: 'oid-1',
209+
210+
username: 'user1',
211+
});
212+
});
213+
});
214+
188215
describe('createJwtPayload', () => {
189216
it('should create access and refresh tokens', async () => {
190217
const payload = { id: 'user-id', username: 'testuser' };

apps/backend/src/auth/auth.service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,17 @@ export class AuthService {
169169
return this.GenTokenRedirect(user, res);
170170
}
171171

172+
/** Mint access + refresh JWTs for an existing user (same payload shape as OAuth callbacks). */
173+
public issueSessionTokensForUser(
174+
user_registered: UserDocument,
175+
): Promise<Tokens> {
176+
return this.createJwtPayload({
177+
id: user_registered._id.toString(),
178+
email: user_registered.email,
179+
username: user_registered.username,
180+
});
181+
}
182+
172183
public async createJwtPayload(payload: TokenPayload): Promise<Tokens> {
173184
const [accessToken, refreshToken] = await Promise.all([
174185
this.jwtService.signAsync<TokenPayload>(payload, {

packages/configs/src/e2e.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Request header for `POST /v1/auth/e2e/session` (Nest, development only).
3+
* Value must match backend env `E2E_AUTH_SECRET`.
4+
*/
5+
export const E2E_AUTH_HEADER = 'x-nbw-e2e-auth';
6+
7+
/** First deterministic seed user (`deterministicSeedEmail(0)` in seed service). */
8+
export const DEFAULT_E2E_SEED_USER_EMAIL =
9+

packages/configs/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './colors';
2+
export * from './e2e';
23
export * from './seed';
34
export * from './song';
45
export * from './user';

0 commit comments

Comments
 (0)