Files
TREK/server/tests/unit/nest/auth-guard.test.ts
T
Maurice 9814480a96 refactor(server): move the JWT verify into nest/auth, delete four dead middlewares (#1828)
middleware/auth.ts exported seven things. Two of them — extractToken and
verifyJwtAndLoadUser — are the canonical session check every auth surface goes
through. The other five (authenticate, requireCookieAuth, optionalAuth,
adminOnly, demoUploadBlock) are Express middlewares with no production caller
left; the guards in nest/auth replaced them and only comments still mention
them.

The two live functions move to nest/auth/jwt-verify.ts and the file goes, along
with middleware/validate.ts (nothing but its own test imported it) and
middleware/idempotency.ts (its only caller was `authenticate`, and
IdempotencyInterceptor has replaced it). AuthRequest and OptionalAuthRequest go
with them — src/typings/express.d.ts is what types req.user now.

Eight importers repoint: the three guards, auth/files/plugins-proxy services,
platform.routes and the global MFA policy.

Two deliberate departures from the plan for this package.

JWT_SECRET does not move to app-config. It is not an env value: it is generated
into data/.jwt_secret, and the admin panel rotates it at runtime through
updateJwtSecret. `export let` is precisely what makes a rotation take effect
in-process — a registerAs token would freeze the boot value and silently break
rotation.

The verify also keeps reading the db singleton rather than DatabaseService. That
change is not a one-line swap: it gives the three guards a constructor
dependency, which means registering them as providers everywhere they are used.
Measured: 38 directories use those guards and 21 of them do not import
AuthModule, on top of 34 e2e tests that each build a container around a single
domain module. That is its own PR, not a rider on one that deletes dead files.

tests/unit/middleware/auth.test.ts covered real behaviour, so it is not simply
deleted. Its extractToken cases and the five verify cases it drove through
`authenticate` come back as tests/unit/nest/auth/jwt-verify.test.ts, now hitting
verifyJwtAndLoadUser directly. Four cases are new: the password_version gate —
the whole reason this function exists — had no direct coverage at all. Its three
adminOnly cases are dropped because AdminGuard, their successor, is covered by
three e2e suites.
2026-08-07 18:01:24 +02:00

276 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
vi.mock('../../../src/nest/common/cookie', () => ({ setAuthCookie: vi.fn() }));
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import { CookieAuthGuard } from '../../../src/nest/auth/cookie-auth.guard';
import { OptionalJwtGuard } from '../../../src/nest/auth/optional-jwt.guard';
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
import { PasskeyEnabledGuard } from '../../../src/nest/auth/passkey-enabled.guard';
import { PasskeyController } from '../../../src/nest/auth/passkey.controller';
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
// AuditService is constructor-injected since the auditLog DI migration; the
// wrapper keeps the historical construction sites positional.
const writeAudit = vi.fn();
const audit = { writeAudit } as unknown as AuditService;
// PasskeyService is constructor-injected since the passkey DI fold; a stub
// object replaces the old services/passkeyService path mock.
const passkey = {
passkeyRegisterOptions: vi.fn(),
passkeyRegisterVerify: vi.fn(),
passkeyLoginOptions: vi.fn(),
passkeyLoginVerify: vi.fn(),
listPasskeys: vi.fn(),
renamePasskey: vi.fn(),
deletePasskey: vi.fn(),
};
const pc = (limiter: RateLimitService) => new PasskeyController(limiter, audit, passkey as unknown as PasskeyService);
import { CurrentUser } from '../../../src/nest/auth/current-user.decorator';
import { extractToken, verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify';
import type { AuthService } from '../../../src/nest/auth/auth.service';
import type { PasskeyService } from '../../../src/nest/auth/passkey.service';
import { setAuthCookie } from '../../../src/nest/common/cookie';
import type { AuditService } from '../../../src/nest/audit/audit.service';
import type { User } from '../../../src/types';
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
function context(req: unknown) {
return { switchToHttp: () => ({ getRequest: () => req }) } as never;
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try { await fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
beforeEach(() => vi.clearAllMocks());
describe('JwtAuthGuard', () => {
const guard = new JwtAuthGuard();
it('rejects with the legacy 401 { error, code } when no token is present', () => {
vi.mocked(extractToken).mockReturnValue(null);
expect(thrown(() => guard.canActivate(context({ headers: {}, cookies: {} })))).toEqual({
status: 401,
body: { error: 'Access token required', code: 'AUTH_REQUIRED' },
});
});
it('rejects an invalid/expired token (verify returns null)', () => {
vi.mocked(extractToken).mockReturnValue('tok');
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
expect(thrown(() => guard.canActivate(context({ headers: {} })))).toEqual({
status: 401,
body: { error: 'Invalid or expired token', code: 'AUTH_REQUIRED' },
});
});
it('attaches the loaded user and allows a valid token through', () => {
const req: Record<string, unknown> = { headers: {} };
vi.mocked(extractToken).mockReturnValue('tok');
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
expect(guard.canActivate(context(req))).toBe(true);
expect(req.user).toBe(user);
});
});
describe('CookieAuthGuard', () => {
const guard = new CookieAuthGuard();
it('401s when the trek_session cookie is missing', () => {
expect(thrown(() => guard.canActivate(context({ cookies: {} })))).toEqual({
status: 401,
body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
});
// and when there is no cookies object at all
expect(thrown(() => guard.canActivate(context({})))).toEqual({
status: 401,
body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
});
});
it('401s when the cookie token fails verification', () => {
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
expect(thrown(() => guard.canActivate(context({ cookies: { trek_session: 'tok' } })))).toEqual({
status: 401,
body: { error: 'Invalid or expired session', code: 'AUTH_REQUIRED' },
});
});
it('attaches the user and allows a valid cookie session through', () => {
const req: Record<string, unknown> = { cookies: { trek_session: 'tok' } };
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
expect(guard.canActivate(context(req))).toBe(true);
expect(req.user).toBe(user);
});
});
describe('OptionalJwtGuard', () => {
const guard = new OptionalJwtGuard();
it('always allows; sets req.user to null when no token', () => {
const req: Record<string, unknown> = { headers: {} };
vi.mocked(extractToken).mockReturnValue(null);
expect(guard.canActivate(context(req))).toBe(true);
expect(req.user).toBeNull();
expect(verifyJwtAndLoadUser).not.toHaveBeenCalled();
});
it('sets req.user to null when a token verifies to nothing', () => {
const req: Record<string, unknown> = { headers: {} };
vi.mocked(extractToken).mockReturnValue('tok');
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
expect(guard.canActivate(context(req))).toBe(true);
expect(req.user).toBeNull();
});
it('populates req.user from a valid token', () => {
const req: Record<string, unknown> = { headers: {} };
vi.mocked(extractToken).mockReturnValue('tok');
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
expect(guard.canActivate(context(req))).toBe(true);
expect(req.user).toBe(user);
});
});
describe('AdminGuard', () => {
const guard = new AdminGuard();
it('403s for anonymous and for a non-admin role', () => {
expect(thrown(() => guard.canActivate(context({})))).toEqual({ status: 403, body: { error: 'Admin access required' } });
expect(thrown(() => guard.canActivate(context({ user: { role: 'user' } })))).toEqual({ status: 403, body: { error: 'Admin access required' } });
});
it('allows an admin through', () => {
expect(guard.canActivate(context({ user: { role: 'admin' } }))).toBe(true);
});
});
describe('PasskeyEnabledGuard', () => {
// The guard injects AuthService since the auth DI fold; a resolveAuthToggles
// stub replaces the old services/authService path mock.
const resolveAuthToggles = vi.fn();
const guard = new PasskeyEnabledGuard({ resolveAuthToggles } as unknown as AuthService);
it('404s when passkey_login is off', () => {
resolveAuthToggles.mockReturnValue({ passkey_login: false });
expect(thrown(() => guard.canActivate())).toEqual({ status: 404, body: { error: 'Passkey login is not enabled' } });
});
it('allows when passkey_login is on', () => {
resolveAuthToggles.mockReturnValue({ passkey_login: true });
expect(guard.canActivate()).toBe(true);
});
});
describe('CurrentUser decorator', () => {
// Apply the decorator to a throwaway handler so Nest stores the param factory in
// route metadata, then invoke that factory exactly as the framework would.
function paramFactory(): (data: unknown, ctx: unknown) => User | undefined {
class Target { handler(_u: User) {} }
(CurrentUser() as ParameterDecorator)(Target.prototype, 'handler', 0);
const meta = Reflect.getMetadata('__routeArguments__', Target, 'handler') as Record<string, { factory: (data: unknown, ctx: unknown) => User | undefined }>;
return Object.values(meta)[0].factory;
}
it('resolves the authenticated user from the request', () => {
expect(paramFactory()(undefined, context({ user }))).toBe(user);
});
it('returns undefined when no user is attached', () => {
expect(paramFactory()(undefined, context({}))).toBeUndefined();
});
});
describe('PasskeyController', () => {
const req = { ip: '9.9.9.9' } as Request;
const res = {} as never;
function rl(): RateLimitService { return new RateLimitService(); }
it('register/options maps a service error, else returns the options', async () => {
passkey.passkeyRegisterOptions.mockResolvedValue({ error: 'Incorrect password', status: 401 });
expect(await thrownAsync(() => pc(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } });
passkey.passkeyRegisterOptions.mockResolvedValue({ options: { challenge: 'c' } as never });
expect(await pc(rl()).registerOptions(user, { password: 'p' }, req)).toEqual({ challenge: 'c' });
});
it('register/verify maps a service error, else audits and returns the credential', async () => {
passkey.passkeyRegisterVerify.mockResolvedValue({ error: 'Verification failed', status: 400 } as never);
expect(await thrownAsync(() => pc(rl()).registerVerify(user, {}, req))).toEqual({ status: 400, body: { error: 'Verification failed' } });
passkey.passkeyRegisterVerify.mockResolvedValue({ credential: { id: 'cr' } } as never);
expect(await pc(rl()).registerVerify(user, {}, req)).toEqual({ success: true, credential: { id: 'cr' } });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_register' }));
});
it('login/options maps a service error, else returns the options', async () => {
passkey.passkeyLoginOptions.mockResolvedValue({ error: 'Not configured', status: 503 } as never);
expect(await thrownAsync(() => pc(rl()).loginOptions(req))).toEqual({ status: 503, body: { error: 'Not configured' } });
passkey.passkeyLoginOptions.mockResolvedValue({ options: { challenge: 'd' } } as never);
expect(await pc(rl()).loginOptions(req)).toEqual({ challenge: 'd' });
});
it('login/verify audits a failure then maps the error, padding latency', async () => {
passkey.passkeyLoginVerify.mockResolvedValue({ error: 'No match', status: 401, auditAction: 'user.login_fail', auditUserId: null } as never);
expect(await thrownAsync(() => pc(rl()).loginVerify({}, req, res))).toEqual({ status: 401, body: { error: 'No match' } });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_fail' }));
}, 10000);
it('login/verify sets the session cookie and audits login on success', async () => {
passkey.passkeyLoginVerify.mockResolvedValue({ token: 'tk', user, auditUserId: 1 } as never);
expect(await pc(rl()).loginVerify({}, req, res)).toEqual({ token: 'tk', user });
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req);
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login', details: { method: 'passkey' } }));
}, 10000);
it('credentials: list, rename (error + success), delete (error + success)', () => {
passkey.listPasskeys.mockReturnValue([{ id: 'a' }]);
expect(pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] });
passkey.renamePasskey.mockReturnValue({ error: 'Not found', status: 404 });
expect(thrown(() => pc(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } });
passkey.renamePasskey.mockReturnValue({ success: true });
expect(pc(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true });
passkey.deletePasskey.mockReturnValue({ error: 'Incorrect password', status: 401 });
expect(thrown(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } });
passkey.deletePasskey.mockReturnValue({ success: true });
expect(pc(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' }));
});
it('throttles registration and login ceremonies once the bucket is exhausted', async () => {
const s = new RateLimitService();
const now = Date.now();
for (let i = 0; i < 5; i++) s.check('mfa', '9.9.9.9', 5, 15 * 60 * 1000, now);
expect(await thrownAsync(() => pc(s).registerOptions(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
const s2 = new RateLimitService();
for (let i = 0; i < 10; i++) s2.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now);
expect(await thrownAsync(() => pc(s2).loginOptions(req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
});
it('falls back to the "unknown" rate-limit key when req.ip is absent', async () => {
passkey.passkeyLoginOptions.mockResolvedValue({ options: { challenge: 'z' } } as never);
const noIp = {} as Request;
expect(await pc(rl()).loginOptions(noIp)).toEqual({ challenge: 'z' });
});
});