test(server): cover returnToken body opt-in and CORS/Swagger env parsers

Close the two "[test coverage]" review gaps on PR #116 (mobile bootstrap):

- auth.controller.spec.ts: unit-test AuthController.login() returnToken
  branches via direct instantiation. returnToken:true returns exactly
  { authToken } alongside the httpOnly cookie; omitted/explicit-false return
  strictly undefined (the token must never leak into the response body for
  web clients) while the cookie is still set.
- environment.service.spec.ts: table-driven tests for getCorsAllowedOrigins()
  (split/trim/filter of CORS_ALLOWED_ORIGINS) and isSwaggerEnabled()
  (case-insensitive SWAGGER_ENABLED === 'true'), the two parsers feeding the
  CORS allowlist and Swagger exposure trust boundaries.

Tests only; no production code changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-21 17:05:35 +03:00
parent 9319bc7356
commit 92d03f1ff6
2 changed files with 154 additions and 0 deletions

View File

@@ -20,3 +20,92 @@ describe('AuthController', () => {
expect(controller).toBeDefined();
});
});
// The EE MFA module is absent in this build, so login() always falls through to
// the bottom path: it mints the token, sets the httpOnly cookie, and ONLY echoes
// the token in the response body when loginInput.returnToken is truthy. Web
// clients omit returnToken, so the token must never leak into the body for them.
describe('login (returnToken body opt-in)', () => {
const TOKEN = 'access-token-123';
let controller: AuthController;
let authService: { login: jest.Mock };
let environmentService: { getCookieExpiresIn: jest.Mock; isHttps: jest.Mock };
let res: { setCookie: jest.Mock };
let workspace: any;
beforeEach(() => {
// Fresh stubs per test so setCookie/login call counts are isolated.
authService = { login: jest.fn().mockResolvedValue(TOKEN) };
environmentService = {
getCookieExpiresIn: jest.fn().mockReturnValue(new Date(0)),
isHttps: jest.fn().mockReturnValue(false),
};
res = { setCookie: jest.fn() };
workspace = { id: 'workspace-1' }; // no enforceSso -> SSO check passes
controller = new AuthController(
authService as any, // authService
{} as any, // sessionService
environmentService as any, // environmentService
{} as any, // moduleRef (MFA module absent -> never used)
{} as any, // auditService
);
});
it('returns { authToken } in the body and sets the cookie when returnToken is true', async () => {
const loginInput = {
email: 'a@b.com',
password: 'pw',
returnToken: true,
} as any;
const result = await controller.login(workspace, res as any, loginInput);
// Body token is emitted alongside the cookie for native (Bearer) clients.
expect(result).toEqual({ authToken: TOKEN });
expect(authService.login).toHaveBeenCalledWith(loginInput, workspace.id);
expect(res.setCookie).toHaveBeenCalledTimes(1);
expect(res.setCookie).toHaveBeenCalledWith(
'authToken',
TOKEN,
expect.objectContaining({ httpOnly: true, sameSite: 'lax', path: '/' }),
);
});
it('returns undefined (token only in cookie) when returnToken is omitted', async () => {
// Web client: returnToken is not present on the body at all.
const loginInput = { email: 'a@b.com', password: 'pw' } as any;
const result = await controller.login(workspace, res as any, loginInput);
// Security-critical: the token must NOT leak into the response body.
expect(result).toBeUndefined();
expect(res.setCookie).toHaveBeenCalledTimes(1);
expect(res.setCookie).toHaveBeenCalledWith(
'authToken',
TOKEN,
expect.objectContaining({ httpOnly: true, sameSite: 'lax', path: '/' }),
);
});
it('returns undefined when returnToken is explicitly false', async () => {
// Guards against an `!== undefined` style bug: explicit false must behave
// exactly like the omitted case.
const loginInput = {
email: 'a@b.com',
password: 'pw',
returnToken: false,
} as any;
const result = await controller.login(workspace, res as any, loginInput);
expect(result).toBeUndefined();
expect(res.setCookie).toHaveBeenCalledTimes(1);
expect(res.setCookie).toHaveBeenCalledWith(
'authToken',
TOKEN,
expect.objectContaining({ httpOnly: true, sameSite: 'lax', path: '/' }),
);
});
});

View File

@@ -15,3 +15,68 @@ describe('EnvironmentService', () => {
expect(service).toBeDefined();
});
});
// Fake ConfigService that honors the (key, default) signature: returns the
// mapped value when the key is present, otherwise the provided default. This
// matters because getCorsAllowedOrigins/isSwaggerEnabled call .split/.toLowerCase
// on the result, so an unset key MUST fall back to the default string.
const makeConfig = (map: Record<string, string>) =>
new EnvironmentService({
get: (key: string, def?: string) => (key in map ? map[key] : def),
} as any);
// These two parsers feed trust boundaries (CORS allowlist + Swagger exposure),
// so a parsing bug has a direct security effect. Style mirrors
// trust-proxy.util.spec.ts.
describe('EnvironmentService.getCorsAllowedOrigins', () => {
it.each([
['key unset -> empty list', {}, []],
['empty string -> empty list', { CORS_ALLOWED_ORIGINS: '' }, []],
[
'single origin',
{ CORS_ALLOWED_ORIGINS: 'https://app.example' },
['https://app.example'],
],
[
'multiple origins',
{ CORS_ALLOWED_ORIGINS: 'https://a.example,https://b.example' },
['https://a.example', 'https://b.example'],
],
[
'trims surrounding and inner spaces',
{ CORS_ALLOWED_ORIGINS: ' https://a.example , https://b.example ' },
['https://a.example', 'https://b.example'],
],
[
'double/trailing commas produce no empty strings',
{ CORS_ALLOWED_ORIGINS: 'a,, ,b' },
['a', 'b'],
],
[
'leading/trailing commas with spaces',
{ CORS_ALLOWED_ORIGINS: ' , a , , b ' },
['a', 'b'],
],
])('%s', (_label, map, expected) => {
expect(makeConfig(map as Record<string, string>).getCorsAllowedOrigins()).toEqual(
expected,
);
});
});
describe('EnvironmentService.isSwaggerEnabled', () => {
it.each([
["'true' -> true", { SWAGGER_ENABLED: 'true' }, true],
["'TRUE' -> true", { SWAGGER_ENABLED: 'TRUE' }, true],
["'True' -> true", { SWAGGER_ENABLED: 'True' }, true],
['key unset -> false', {}, false],
["'false' -> false", { SWAGGER_ENABLED: 'false' }, false],
["empty string -> false", { SWAGGER_ENABLED: '' }, false],
["'yes' -> false", { SWAGGER_ENABLED: 'yes' }, false],
["'1' -> false", { SWAGGER_ENABLED: '1' }, false],
])('%s', (_label, map, expected) => {
expect(makeConfig(map as Record<string, string>).isSwaggerEnabled()).toBe(
expected,
);
});
});