mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
9aab9e7513
A session token was a signed JWT and nothing else. Logout only cleared the cookie, a 30-day "remember me" token stayed valid wherever a copy of it had ended up, and nobody could sign out one device short of changing the password or rotating the JWT secret for everyone. Every session token now carries an id (jti) naming a row in the new user_sessions table (id, user_id, created_at, last_seen_at, expires_at, revoked_at, user_agent cut to 256 characters; no address is kept). The rows live behind UserSessionsRepository and the new sessions domain, whose SessionsService is the one issuer: AuthService.generateToken and the OIDC sign-in both go through it, and sliding renewal re-signs a tracked token under the same id and moves its expiry. verifyJwtAndLoadUser takes the repository and refuses a token whose session is revoked, expired or gone, refreshing last_seen_at at most every five minutes. A token without a jti was issued before this change; it passes until its own expiry (or a password change), so the upgrade signs nobody out, and renewal turns it into a tracked session. Sessions end on logout (the one the request names, verified even when expired), on a password change (all, the device gets a new one), a password reset, an admin setting a password, reset-admin.js, an MFA disable (all but the current one), an admin clearing a user's MFA, and with the account. GET /api/auth/sessions lists the active sessions, DELETE /api/auth/sessions/:id ends one and POST /api/auth/sessions/revoke-others ends all but the current, with their contracts in @trek/shared and two new audit actions. There is no UI for them yet. MCP exposes no account or session tools, so there is no parallel tool to change; an MCP call made with a session JWT as bearer goes through the same check. The token in the login, register, demo-login, MFA and passkey bodies stays for API clients but is marked deprecated in the API docs and in the client type, which never read it. A nightly session-purge job on CronRegistrarService deletes the expired and revoked rows. The migration is 042500. The sessions domain gets its own coverage entry at the 80 floor until a full run measures it.
500 lines
36 KiB
TypeScript
500 lines
36 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { Request, Response } from 'express';
|
|
|
|
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() }));
|
|
vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
|
|
|
import { AuthPublicController } from '../../../src/nest/auth/auth-public.controller';
|
|
import { AuthController } from '../../../src/nest/auth/auth.controller';
|
|
import type { TokenService } from '../../../src/nest/tokens/token.service';
|
|
import type { UserProfileService } from '../../../src/nest/auth/user-profile.service';
|
|
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
|
|
import type { AuthService } from '../../../src/nest/auth/auth.service';
|
|
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
|
import { isDemoEmail } from '../../../src/nest/common/demo';
|
|
import type { User } from '../../../src/types';
|
|
import { anyBody } from '../../helpers/dto';
|
|
import type { ForgotPasswordDto } from '../../../src/nest/auth/auth.dto';
|
|
import type { SessionsService } from '../../../src/nest/sessions/sessions.service';
|
|
import { JWT_SECRET } from '../../../src/config';
|
|
import jwt from 'jsonwebtoken';
|
|
|
|
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
|
const req = { ip: '9.9.9.9', headers: {} } as Request;
|
|
const res = {} as Response;
|
|
|
|
function asvc(o: Partial<AuthService> = {}): AuthService {
|
|
return { setAuthCookie: vi.fn(), clearAuthCookie: vi.fn(), getAppUrl: vi.fn(() => 'https://x'), sendPasswordResetEmail: vi.fn(), ...o } as unknown as AuthService;
|
|
}
|
|
function rl(): RateLimitService { return new RateLimitService(); }
|
|
|
|
// AuditService is constructor-injected since the auditLog DI migration; the
|
|
// wrappers keep the historical construction sites positional.
|
|
const writeAudit = vi.fn();
|
|
const audit = { writeAudit } as unknown as AuditService;
|
|
// Logout ends the session through SessionsService, the controller's fourth argument.
|
|
const endSession = vi.fn().mockResolvedValue(true);
|
|
const sessionsStub = { endSession } as unknown as SessionsService;
|
|
const apc = (a: AuthService, limiter: RateLimitService) => new AuthPublicController(a, limiter, audit, sessionsStub);
|
|
// Tokens moved to TokenService; the controller takes it second. Stubbed via a
|
|
// third, optional argument so every non-token call site stays as it was.
|
|
const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as import('../../../src/nest/storage/storage.service').StorageService;
|
|
const ac = (a: AuthService, limiter: RateLimitService, t: Partial<TokenService> = {}, pr: Partial<UserProfileService> = {}) =>
|
|
new AuthController(a, pr as UserProfileService, t as TokenService, limiter, audit, new RuntimeEnvService(), storageStub);
|
|
|
|
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());
|
|
afterEach(() => { delete process.env.DEMO_MODE; });
|
|
|
|
describe('RateLimitService', () => {
|
|
it('allows up to max then blocks within the window; buckets are isolated', async () => {
|
|
const s = rl();
|
|
expect(await s.check('login', 'ip', 2, 1000, 0)).toBe(true);
|
|
expect(await s.check('login', 'ip', 2, 1000, 10)).toBe(true);
|
|
expect(await s.check('login', 'ip', 2, 1000, 20)).toBe(false); // 3rd within window
|
|
expect(await s.check('mfa', 'ip', 2, 1000, 20)).toBe(true); // different bucket
|
|
expect(await s.check('login', 'ip', 2, 1000, 2000)).toBe(true); // window elapsed -> reset
|
|
});
|
|
|
|
it('reset clears a single named bucket, and reset() clears all of them', async () => {
|
|
const s = rl();
|
|
await s.check('login', 'ip', 1, 1000, 0); // login bucket now at its cap
|
|
await s.check('mfa', 'ip', 1, 1000, 0); // mfa bucket now at its cap
|
|
expect(await s.check('login', 'ip', 1, 1000, 0)).toBe(false);
|
|
await s.reset('login'); // only the login bucket
|
|
expect(await s.check('login', 'ip', 1, 1000, 0)).toBe(true);
|
|
expect(await s.check('mfa', 'ip', 1, 1000, 0)).toBe(false); // mfa untouched
|
|
await s.reset(); // everything
|
|
expect(await s.check('mfa', 'ip', 1, 1000, 0)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('AuthPublicController', () => {
|
|
it('demo-login maps error, else sets the cookie + returns token/user', async () => {
|
|
expect(await thrownAsync(() => apc(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial<AuthService>), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } });
|
|
const setAuthCookie = vi.fn();
|
|
const c = apc(asvc({ demoLogin: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial<AuthService>), rl());
|
|
expect(await c.demoLogin(req, res)).toEqual({ token: 'tk', user });
|
|
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req);
|
|
});
|
|
|
|
it('register audits + sets cookie; maps error', async () => {
|
|
expect(await thrownAsync(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AuthService>), rl()).register(anyBody(), req, res))).toEqual({ status: 409, body: { error: 'Email taken' } });
|
|
const setAuthCookie = vi.fn();
|
|
const c = apc(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial<AuthService>), rl());
|
|
expect(await c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user });
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.register' }));
|
|
expect(setAuthCookie).toHaveBeenCalled();
|
|
});
|
|
|
|
it('invite 429 when rate-limited', async () => {
|
|
const s = rl();
|
|
await s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, Date.now()); // not exhausted yet
|
|
const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }) } as Partial<AuthService>), s);
|
|
expect(await c.invite('tok', req)).toEqual({ valid: true, max_uses: 1, used_count: 0, expires_at: null });
|
|
});
|
|
|
|
it('login: mfa branch, success cookie, error mapping', async () => {
|
|
const setAuthCookie = vi.fn();
|
|
const mfa = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial<AuthService>), rl());
|
|
expect(await mfa.login(anyBody(), req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
|
|
const ok = apc(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial<AuthService>), rl());
|
|
expect(await ok.login(anyBody(), req, res)).toEqual({ token: 'tk', user });
|
|
// The "remember me" flag from the service rides through to the cookie service.
|
|
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req, true);
|
|
const bad = apc(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial<AuthService>), rl());
|
|
expect(await thrownAsync(() => bad.login(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } });
|
|
}, 10000);
|
|
|
|
it('forgot-password issues a reset email then returns the generic ok', async () => {
|
|
const sendPasswordResetEmail = vi.fn().mockResolvedValue({ delivered: true });
|
|
const c = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail } as Partial<AuthService>), rl());
|
|
expect(await c.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true });
|
|
expect(sendPasswordResetEmail).toHaveBeenCalledWith('a@b.c', 'https://x/reset-password?token=rt', 1);
|
|
}, 10000);
|
|
|
|
it('reset-password: error audits a fail, mfa branch, success', async () => {
|
|
expect(await thrownAsync(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req))).toEqual({ status: 400, body: { error: 'Invalid token' } });
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' }));
|
|
expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req)).toEqual({ mfa_required: true });
|
|
expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req)).toEqual({ success: true });
|
|
});
|
|
|
|
it('app-config forwards the optional user (present and absent)', async () => {
|
|
const getAppConfig = vi.fn().mockReturnValue({ version: '3' });
|
|
const c = apc(asvc({ getAppConfig } as Partial<AuthService>), rl());
|
|
expect(await c.appConfig({ user } as unknown as Request)).toEqual({ version: '3' });
|
|
expect(getAppConfig).toHaveBeenLastCalledWith(user);
|
|
expect(await c.appConfig({} as Request)).toEqual({ version: '3' });
|
|
expect(getAppConfig).toHaveBeenLastCalledWith(undefined);
|
|
});
|
|
|
|
it('invite maps a service error', async () => {
|
|
const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial<AuthService>), rl());
|
|
expect(await thrownAsync(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } });
|
|
});
|
|
|
|
it('login takes the mfa-required branch and never sets a cookie', async () => {
|
|
const setAuthCookie = vi.fn();
|
|
const c = apc(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial<AuthService>), rl());
|
|
expect(await c.login(anyBody(), req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
|
|
expect(setAuthCookie).not.toHaveBeenCalled();
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_mfa' }));
|
|
}, 10000);
|
|
|
|
it('forgot-password: non-issued reason and a delivery failure both still return ok', async () => {
|
|
// Non-issued (unknown email / throttled): audits the reason, no email sent.
|
|
const sendNever = vi.fn();
|
|
const skip = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'not_found', userId: null }), sendPasswordResetEmail: sendNever } as Partial<AuthService>), rl());
|
|
expect(await skip.forgotPassword({ email: 'x@y.z' }, req)).toEqual({ ok: true });
|
|
expect(sendNever).not.toHaveBeenCalled();
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_request', details: { reason: 'not_found' } }));
|
|
// Issued but the mailer throws: swallowed, audited as failed, still ok.
|
|
const boom = vi.fn().mockRejectedValue(new Error('smtp'));
|
|
const fail = apc(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail: boom } as Partial<AuthService>), rl());
|
|
expect(await fail.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true });
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ details: { delivered: 'failed' } }));
|
|
}, 10000);
|
|
|
|
it('forgot-password ignores a non-string email body', async () => {
|
|
const requestPasswordReset = vi.fn().mockReturnValue({ reason: 'not_found', userId: null });
|
|
const c = apc(asvc({ requestPasswordReset } as Partial<AuthService>), rl());
|
|
expect(await c.forgotPassword(anyBody<ForgotPasswordDto>({ email: 42 as unknown as string }), req)).toEqual({ ok: true });
|
|
expect(requestPasswordReset).toHaveBeenCalledWith('', expect.any(String));
|
|
}, 10000);
|
|
|
|
it('reset-password 429 once the dedicated reset bucket is exhausted', async () => {
|
|
const s = rl();
|
|
const now = Date.now();
|
|
for (let i = 0; i < 5; i++) await s.check('reset', '9.9.9.9', 5, 15 * 60 * 1000, now);
|
|
const c = apc(asvc({ resetPassword: vi.fn() } as Partial<AuthService>), s);
|
|
expect(await thrownAsync(() => c.resetPassword(anyBody(), req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
|
});
|
|
|
|
it('mfa/verify-login maps a service error', async () => {
|
|
const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial<AuthService>), rl());
|
|
expect(await thrownAsync(() => c.verifyMfaLogin(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad code' } });
|
|
});
|
|
|
|
it('demo-login + register + invite throw 429 when the login bucket is exhausted', async () => {
|
|
const s = rl();
|
|
const now = Date.now();
|
|
for (let i = 0; i < 10; i++) await s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now);
|
|
const c = apc(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial<AuthService>), s);
|
|
expect(await thrownAsync(() => c.register(anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
|
expect(await thrownAsync(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
|
});
|
|
|
|
it('mfa/verify-login sets cookie + audits; logout clears cookie', async () => {
|
|
const setAuthCookie = vi.fn();
|
|
const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial<AuthService>), rl());
|
|
expect(await c.verifyMfaLogin(anyBody(), req, res)).toEqual({ token: 'tk', user });
|
|
expect(setAuthCookie).toHaveBeenCalled();
|
|
const clearAuthCookie = vi.fn();
|
|
expect(await apc(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(req, res)).toEqual({ success: true });
|
|
expect(clearAuthCookie).toHaveBeenCalledWith(res, req);
|
|
// No token: nothing to end, the cookie still goes.
|
|
expect(endSession).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it('logout ends the session a well-signed token names, expired or not', async () => {
|
|
const expired = jwt.sign({ id: 1, pv: 0, exp: Math.floor(Date.now() / 1000) - 60 }, JWT_SECRET, { algorithm: 'HS256', jwtid: 'sid-1' });
|
|
const withCookie = { ip: '9.9.9.9', headers: {}, cookies: { trek_session: expired } } as unknown as Request;
|
|
const clearAuthCookie = vi.fn();
|
|
expect(await apc(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(withCookie, res)).toEqual({ success: true });
|
|
expect(endSession).toHaveBeenCalledWith(expect.objectContaining({ id: 1, jti: 'sid-1' }));
|
|
expect(clearAuthCookie).toHaveBeenCalledWith(res, withCookie);
|
|
});
|
|
|
|
it('logout with a forged token only clears the cookie', async () => {
|
|
const forged = jwt.sign({ id: 1, pv: 0 }, 'not-the-secret', { algorithm: 'HS256', jwtid: 'sid-1' });
|
|
const withHeader = { ip: '9.9.9.9', headers: { authorization: `Bearer ${forged}` } } as unknown as Request;
|
|
const clearAuthCookie = vi.fn();
|
|
expect(await apc(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(withHeader, res)).toEqual({ success: true });
|
|
expect(endSession).toHaveBeenCalledWith(null);
|
|
expect(clearAuthCookie).toHaveBeenCalled();
|
|
});
|
|
|
|
it('login, register and MFA login hand the device to the service', async () => {
|
|
const withAgent = { ip: '9.9.9.9', headers: { 'user-agent': 'Firefox/130' } } as unknown as Request;
|
|
const loginUser = vi.fn().mockReturnValue({ token: 'tk', user });
|
|
const registerUser = vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 });
|
|
const verifyMfaLogin = vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 });
|
|
const c = apc(asvc({ loginUser, registerUser, verifyMfaLogin } as Partial<AuthService>), rl());
|
|
await c.login(anyBody(), withAgent, res);
|
|
await c.register(anyBody(), withAgent, res);
|
|
await c.verifyMfaLogin(anyBody(), withAgent, res);
|
|
expect(loginUser).toHaveBeenCalledWith(anyBody(), { userAgent: 'Firefox/130' });
|
|
expect(registerUser).toHaveBeenCalledWith(anyBody(), { userAgent: 'Firefox/130' });
|
|
expect(verifyMfaLogin).toHaveBeenCalledWith(anyBody(), { userAgent: 'Firefox/130' });
|
|
}, 10000);
|
|
});
|
|
|
|
describe('AuthController (authenticated)', () => {
|
|
it('GET /me 404 when missing, else returns the loaded user', async () => {
|
|
expect(await thrownAsync(() => ac(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial<AuthService>), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } });
|
|
expect(await ac(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial<AuthService>), rl()).me(user)).toEqual({ user: { id: 1 } });
|
|
});
|
|
|
|
it('change-password maps error, else audits', async () => {
|
|
expect(await thrownAsync(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial<AuthService>), rl()).changePassword(user, anyBody(), req, res))).toEqual({ status: 400, body: { error: 'Wrong' } });
|
|
expect(await ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).changePassword(user, anyBody(), req, res)).toEqual({ success: true });
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' }));
|
|
});
|
|
|
|
it('avatar 403 in demo mode, 400 without a file, else saves', async () => {
|
|
process.env.DEMO_MODE = 'true';
|
|
vi.mocked(isDemoEmail).mockReturnValue(true);
|
|
expect(await thrownAsync(() => ac(asvc(), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } });
|
|
vi.mocked(isDemoEmail).mockReturnValue(false);
|
|
delete process.env.DEMO_MODE;
|
|
expect(await thrownAsync(() => ac(asvc(), rl()).avatar(user, undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } });
|
|
const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/a.jpg' });
|
|
expect(await ac(asvc({}), rl(), {}, { saveAvatar }).avatar(user, { filename: 'a.jpg' } as Express.Multer.File)).toEqual({ avatar: '/a.jpg' });
|
|
});
|
|
|
|
it('mfa/setup awaits the QR promise, maps a generation failure to 500', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const ok = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.resolve('<svg>') }) } as Partial<AuthService>), rl());
|
|
expect(await ok.mfaSetup(user)).toEqual({ secret: 's', otpauth_url: 'o', qr_svg: '<svg>' });
|
|
const fail = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.reject(new Error('x')) }) } as Partial<AuthService>), rl());
|
|
expect(await thrownAsync(() => fail.mfaSetup(user))).toEqual({ status: 500, body: { error: 'Could not generate QR code' } });
|
|
});
|
|
|
|
it('mfa/enable audits + returns backup codes; mcp-tokens create 201', async () => {
|
|
const enable = ac(asvc({ enableMfa: vi.fn().mockReturnValue({ mfa_enabled: true, backup_codes: ['a', 'b'] }) } as Partial<AuthService>), rl());
|
|
expect(await enable.mfaEnable(user, { code: '123456' }, req)).toEqual({ success: true, mfa_enabled: true, backup_codes: ['a', 'b'] });
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_enable' }));
|
|
const tok = ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) });
|
|
expect(await tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' });
|
|
});
|
|
|
|
it('resource-token 503 when unavailable, else returns the token payload', async () => {
|
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue(null) }).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } });
|
|
expect(await ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) }).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' });
|
|
});
|
|
|
|
it('ws/resource tokens throttle on their own buckets, so a mint loop cannot drain the store or block login', async () => {
|
|
const s = rl();
|
|
const now = Date.now();
|
|
for (let i = 0; i < 120; i++) await s.check('ws_token', String(user.id), 120, 15 * 60 * 1000, now);
|
|
expect(await thrownAsync(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)))
|
|
.toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
|
// The login bucket is untouched: an exhausted socket loop must not lock the account out.
|
|
expect(await s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now)).toBe(true);
|
|
|
|
const s2 = rl();
|
|
for (let i = 0; i < 120; i++) await s2.check('resource_token', String(user.id), 120, 15 * 60 * 1000, now);
|
|
expect(await thrownAsync(() => ac(asvc({}), s2, { createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) }).resourceToken(user, {})))
|
|
.toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
|
});
|
|
|
|
it('the ws/resource ceilings are per account, not per address: one heavy user cannot 429 the office', async () => {
|
|
const s = rl();
|
|
const now = Date.now();
|
|
const other = { ...user, id: 2 } as User;
|
|
// Both users sit behind the same NAT address, and the first one burns its
|
|
// whole ceiling.
|
|
for (let i = 0; i < 120; i++) {
|
|
await ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user);
|
|
}
|
|
expect(await thrownAsync(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)))
|
|
.toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
|
|
|
expect(await ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws2' }) }).wsToken(other)).toEqual({ token: 'ws2' });
|
|
expect(await ac(asvc({}), s, { createResourceToken: vi.fn().mockReturnValue({ token: 'rt2' }) }).resourceToken(other, {})).toEqual({ token: 'rt2' });
|
|
});
|
|
|
|
it('rate-limited account ops throw 429 once the bucket is exhausted', async () => {
|
|
const s = rl();
|
|
const now = Date.now();
|
|
// exhaust the shared 'login' bucket for this ip (max 5)
|
|
for (let i = 0; i < 5; i++) await s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now);
|
|
const c = ac(asvc({ changePassword: vi.fn() } as Partial<AuthService>), s);
|
|
expect(await thrownAsync(() => c.changePassword(user, anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
|
});
|
|
|
|
it('change-password refreshes this device cookie when the service returns a token', async () => {
|
|
const setAuthCookie = vi.fn();
|
|
const c = ac(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial<AuthService>), rl());
|
|
expect(await c.changePassword(user, anyBody(), req, res)).toEqual({ success: true });
|
|
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req, undefined);
|
|
});
|
|
|
|
it('change-password carries the session cookie remember claim into the service and the re-issued cookie (#1927)', async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const jwt = require('jsonwebtoken');
|
|
const remembered = jwt.sign({ id: 1, pv: 0, remember: true }, 'any-secret');
|
|
const reqCookie = { ip: '9.9.9.9', headers: {}, cookies: { trek_session: remembered } } as unknown as Request;
|
|
const setAuthCookie = vi.fn();
|
|
const changePassword = vi.fn().mockReturnValue({ token: 'tk3' });
|
|
const c = ac(asvc({ changePassword, setAuthCookie } as Partial<AuthService>), rl());
|
|
expect(await c.changePassword(user, anyBody(), reqCookie, res)).toEqual({ success: true });
|
|
expect(changePassword).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), true, { userAgent: null });
|
|
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk3', reqCookie, true);
|
|
});
|
|
|
|
it('delete-account maps error, else audits and succeeds', async () => {
|
|
expect(await thrownAsync(() => ac(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial<AuthService>), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } });
|
|
expect(await ac(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteAccount(user, req)).toEqual({ success: true });
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' }));
|
|
});
|
|
|
|
it('maps-key + api-keys pass straight through to the service', async () => {
|
|
const updateMapsKey = vi.fn().mockReturnValue({ success: true });
|
|
expect(await ac(asvc({}), rl(), {}, { updateMapsKey }).mapsKey(user, { maps_api_key: 'k' }, req)).toEqual({ success: true });
|
|
expect(updateMapsKey).toHaveBeenCalledWith(1, 'k');
|
|
const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 });
|
|
expect(await ac(asvc({}), rl(), {}, { updateApiKeys }).apiKeys(user, anyBody({ a: 1 } as never), req)).toEqual({ ok: 1 });
|
|
// No changedKeys from the service (and none in the body): nothing to audit.
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('api-keys audits one settings.api_keys_update with the changed names only (#1939)', async () => {
|
|
const updateApiKeys = vi.fn().mockReturnValue({ success: true, user: { id: 1 }, changedKeys: ['maps_api_key'] });
|
|
const c = ac(asvc({}), rl(), {}, { updateApiKeys });
|
|
// changedKeys is stripped off the response — the client body is unchanged.
|
|
expect(await c.apiKeys(user, anyBody({ maps_api_key: 'AIza-super-secret' } as never), req)).toEqual({ success: true, user: { id: 1 } });
|
|
expect(writeAudit).toHaveBeenCalledTimes(1);
|
|
expect(writeAudit).toHaveBeenCalledWith({
|
|
userId: 1,
|
|
action: 'settings.api_keys_update',
|
|
resource: 'api_keys',
|
|
ip: '1.2.3.4',
|
|
details: { changed: ['maps_api_key'] },
|
|
});
|
|
// Never the value, not even truncated: details is rendered raw in the panel.
|
|
expect(JSON.stringify(writeAudit.mock.calls)).not.toContain('AIza-super-secret');
|
|
});
|
|
|
|
it('maps-key and settings audit the same action; an unchanged save writes nothing', async () => {
|
|
const mapsC = ac(asvc({}), rl(), {}, { updateMapsKey: vi.fn().mockReturnValue({ success: true, changedKeys: ['maps_api_key'] }) });
|
|
await mapsC.mapsKey(user, { maps_api_key: 'k' }, req);
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.api_keys_update', details: { changed: ['maps_api_key'] } }));
|
|
|
|
writeAudit.mockClear();
|
|
const setC = ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 }, changedKeys: ['unsplash_api_key'] }) });
|
|
expect(await setC.updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } });
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.api_keys_update', details: { changed: ['unsplash_api_key'] } }));
|
|
|
|
// The test button in the panel saves before every click — an identical value
|
|
// must not cost a log line.
|
|
writeAudit.mockClear();
|
|
await ac(asvc({}), rl(), {}, { updateApiKeys: vi.fn().mockReturnValue({ success: true, changedKeys: [] }) })
|
|
.apiKeys(user, anyBody({ maps_api_key: 'same' } as never), req);
|
|
expect(writeAudit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('update-settings + get-settings map errors, else return their payloads', async () => {
|
|
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) }).updateSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'Bad' } });
|
|
expect(await ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) }).updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } });
|
|
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) }).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
|
|
expect(await ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) }).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
|
|
});
|
|
|
|
// travel-stats left with getTravelStats; it is covered by
|
|
// atlas.controller.test.ts (TravelStatsController).
|
|
it('delete-avatar + users delegate to the service', async () => {
|
|
const deleteAvatar = vi.fn().mockResolvedValue({ removed: true });
|
|
expect(await ac(asvc({}), rl(), {}, { deleteAvatar }).deleteAvatar(user)).toEqual({ removed: true });
|
|
const listUsers = vi.fn().mockReturnValue([{ id: 1 }]);
|
|
expect(await ac(asvc({}), rl(), {}, { listUsers }).users(user)).toEqual({ users: [{ id: 1 }] });
|
|
expect(listUsers).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('validate-keys maps error, else returns the maps/weather payload', async () => {
|
|
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { validateKeys: vi.fn().mockResolvedValue({ error: 'fail', status: 502 }) }).validateKeys(user))).toEqual({ status: 502, body: { error: 'fail' } });
|
|
const ok = ac(asvc({}), rl(), {}, { validateKeys: vi.fn().mockResolvedValue({ maps: true, weather: false, maps_details: { ok: 1 } }) });
|
|
expect(await ok.validateKeys(user)).toEqual({ maps: true, weather: false, maps_details: { ok: 1 } });
|
|
});
|
|
|
|
it('app-settings get maps error, else returns data; put maps error, else audits', async () => {
|
|
expect(await thrownAsync(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
|
|
expect(await ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
|
|
expect(await thrownAsync(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
|
|
expect(await ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true });
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' }));
|
|
});
|
|
|
|
it('mfa/setup maps a service error before ever awaiting the QR promise', async () => {
|
|
const c = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ error: 'already on', status: 409 }) } as Partial<AuthService>), rl());
|
|
expect(await thrownAsync(() => c.mfaSetup(user))).toEqual({ status: 409, body: { error: 'already on' } });
|
|
});
|
|
|
|
it('mfa/enable + mfa/disable map errors', async () => {
|
|
expect(await thrownAsync(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial<AuthService>), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } });
|
|
expect(await thrownAsync(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial<AuthService>), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } });
|
|
const disableMfa = vi.fn().mockReturnValue({ mfa_enabled: false });
|
|
const ok = ac(asvc({ disableMfa } as Partial<AuthService>), rl());
|
|
expect(await ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false });
|
|
// No session id on this request's token, so no session is kept back.
|
|
expect(disableMfa).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), undefined);
|
|
const tracked = jwt.sign({ id: 1, pv: 0 }, JWT_SECRET, { algorithm: 'HS256', jwtid: 'sid-here' });
|
|
const reqTracked = { ip: '9.9.9.9', headers: {}, cookies: { trek_session: tracked } } as unknown as Request;
|
|
await ok.mfaDisable(user, anyBody(), reqTracked);
|
|
expect(disableMfa).toHaveBeenLastCalledWith(1, 'u@example.test', anyBody(), 'sid-here');
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' }));
|
|
});
|
|
|
|
it('mcp-tokens list + create error + delete error/success', async () => {
|
|
expect(await ac(asvc({}), rl(), { listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) }).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
|
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
|
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
|
expect(await ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, 'tid')).toEqual({ success: true });
|
|
});
|
|
|
|
// Same four paths as the MCP block above, against the other token kind. They are
|
|
// separate routes on purpose — an API key and an MCP token open different doors —
|
|
// so nothing here is implied by the MCP tests passing.
|
|
it('api-tokens list + create success/error + delete error/success', async () => {
|
|
expect(await ac(asvc({}), rl(), { listApiTokens: vi.fn().mockReturnValue([{ id: 'a' }]) }).listApiTokens(user)).toEqual({ tokens: [{ id: 'a' }] });
|
|
expect(await ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ token: 'trek_x' }) }).createApiToken(user, { name: 'Homepage' }, req)).toEqual({ token: 'trek_x' });
|
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createApiToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
|
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteApiToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
|
expect(await ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({}) }).deleteApiToken(user, 'tid')).toEqual({ success: true });
|
|
});
|
|
|
|
// The create route shares the 'login' limiter bucket with the MCP one, at 5/window.
|
|
// Unlike MCP tokens it is NOT refused on a managed instance, so the limiter is the
|
|
// only thing standing between a scripted caller and an unbounded key list.
|
|
it('api-tokens create is rate limited after 5 in the window', async () => {
|
|
const limiter = rl();
|
|
const createApiToken = vi.fn().mockReturnValue({ token: 'trek_x' });
|
|
const ctl = ac(asvc({}), limiter, { createApiToken });
|
|
for (let i = 0; i < 5; i++) expect(await ctl.createApiToken(user, { name: `k${i}` }, req)).toEqual({ token: 'trek_x' });
|
|
expect((await thrownAsync(() => ctl.createApiToken(user, { name: 'k5' }, req))).status).toBe(429);
|
|
expect(createApiToken).toHaveBeenCalledTimes(5);
|
|
});
|
|
|
|
it('ws-token maps error, else returns the token', async () => {
|
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) }).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
|
|
expect(await ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)).toEqual({ token: 'ws' });
|
|
});
|
|
|
|
it('avatar saves when not in demo mode (env present but email is not a demo email)', async () => {
|
|
process.env.DEMO_MODE = 'true';
|
|
vi.mocked(isDemoEmail).mockReturnValue(false);
|
|
const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/b.png' });
|
|
expect(await ac(asvc({}), rl(), {}, { saveAvatar }).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' });
|
|
});
|
|
});
|