mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
03fbced20b
AuthService carried identity, profile, settings and tokens at once. Tokens are the cleanest of those to cut loose: they touch one table plus the ephemeral-token store, and nothing in them needs to know how a password is hashed or how a session is established. TokenService takes the six user-facing methods plus verifyMcpToken, and the two admin-side ones that only lived on AdminService because the admin route did. The pairs are genuinely different queries rather than duplicates, so both survive under names that say which is which: the user-facing ones scope every statement by user_id and treat the session sweep as best-effort, the admin ones do neither. Left behind on purpose: verifyJwtToken, which is login identity and belongs next to the cookie logic, and isDemoUser, which is a demo gate that happens to read the users table. TokensModule is a leaf with nothing but DatabaseService, because auth, admin and the MCP transport all import it and anything added here lands in three graphs at once. Both bridges and both controller test harnesses construct these classes positionally and had to be pulled along; the new argument goes in an optional slot so untouched call sites stay untouched.
262 lines
16 KiB
TypeScript
262 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { HttpException, NotFoundException } from '@nestjs/common';
|
|
import type { Request } 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() }));
|
|
|
|
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import { AdminController } from '../../../src/nest/admin/admin.controller';
|
|
import type { TokenService } from '../../../src/nest/tokens/token.service';
|
|
import type { AdminService } from '../../../src/nest/admin/admin.service';
|
|
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
|
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
|
import type { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
|
import type { User } from '../../../src/types';
|
|
|
|
const user = { id: 1, role: 'admin', email: 'admin@example.test' } as User;
|
|
const req = { headers: {} } as Request;
|
|
|
|
function svc(o: Partial<AdminService> = {}): AdminService {
|
|
return { invalidateMcpSessions: vi.fn(), ...o } as unknown as AdminService;
|
|
}
|
|
|
|
// 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;
|
|
// Notifications are a constructor-injected stub since the notifications fold
|
|
// (same behavior as the old services/notificationService path mock).
|
|
const sendNotification = vi.fn().mockResolvedValue(undefined);
|
|
const notifications = { send: sendNotification } as unknown as NotificationsService;
|
|
/** The flags live on AddonsService now; the toggle routes reach it directly. */
|
|
const addonsStub = () => ({
|
|
getBagTracking: vi.fn(() => ({ enabled: false })),
|
|
updateBagTracking: vi.fn((enabled: boolean) => ({ enabled })),
|
|
getPlacesPhotos: vi.fn(() => ({ enabled: false })),
|
|
updatePlacesPhotos: vi.fn((enabled: boolean) => ({ enabled })),
|
|
getPlacesAutocomplete: vi.fn(() => ({ enabled: false })),
|
|
updatePlacesAutocomplete: vi.fn((enabled: boolean) => ({ enabled })),
|
|
getPlacesDetails: vi.fn(() => ({ enabled: false })),
|
|
updatePlacesDetails: vi.fn((enabled: boolean) => ({ enabled })),
|
|
getCollabFeatures: vi.fn(() => ({ chat: false })),
|
|
updateCollabFeatures: vi.fn(() => ({ features: { chat: true }, changed: true })),
|
|
}) as unknown as AddonsService;
|
|
|
|
// The MCP-token routes read TokenService now, not AdminService. Stubbed via a
|
|
// fourth, optional argument so every existing call site stays as it was.
|
|
const adminCtl = (s: AdminService, rt?: PluginRuntimeService, addons: AddonsService = addonsStub(), tokens: Partial<TokenService> = {}) =>
|
|
new AdminController(s, addons, rt as unknown as PluginRuntimeService, audit, notifications, tokens as TokenService);
|
|
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
|
try { fn(); } catch (err) {
|
|
if (err instanceof NotFoundException) return { status: 404, body: err.getResponse() };
|
|
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.NODE_ENV; });
|
|
|
|
describe('AdminController users', () => {
|
|
it('lists, creates (201 + audit), maps an error', () => {
|
|
expect(adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listUsers()).toEqual({ users: [{ id: 1 }] });
|
|
expect(thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } });
|
|
const c = adminCtl(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial<AdminService>));
|
|
expect(c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } });
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.user_create' }));
|
|
});
|
|
|
|
it('update + delete audit and map errors', () => {
|
|
expect(adminCtl(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial<AdminService>)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } });
|
|
expect(thrown(() => adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial<AdminService>)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } });
|
|
expect(adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial<AdminService>)).deleteUser(user, '2', req)).toEqual({ success: true });
|
|
});
|
|
});
|
|
|
|
describe('AdminController permissions + oidc + misc', () => {
|
|
it('permissions: saves + audits', () => {
|
|
const c = adminCtl(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: { x: 1 }, skipped: [] }) } as Partial<AdminService>));
|
|
expect(c.savePermissions(user, { permissions: { x: 1 } }, req)).toEqual({ success: true, permissions: { x: 1 } });
|
|
});
|
|
|
|
it('permissions: includes skipped when present', () => {
|
|
const c = adminCtl(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: {}, skipped: ['bad'] }) } as Partial<AdminService>));
|
|
expect(c.savePermissions(user, { permissions: {} }, req)).toEqual({ success: true, permissions: {}, skipped: ['bad'] });
|
|
});
|
|
|
|
|
|
it('save-demo-baseline maps error, else returns message', () => {
|
|
expect(thrown(() => adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial<AdminService>)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } });
|
|
expect(adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial<AdminService>)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' });
|
|
});
|
|
});
|
|
|
|
// The feature toggles and the packing-template CRUD moved to the addons and packing
|
|
// domains; their cases live in admin-feature-toggles.controller.test.ts and
|
|
// admin-packing-templates.controller.test.ts.
|
|
describe('AdminController invites', () => {
|
|
it('invites: create 201 + audit, delete maps error', () => {
|
|
const c = adminCtl(svc({ createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) } as Partial<AdminService>));
|
|
expect(c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } });
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' }));
|
|
expect(thrown(() => adminCtl(svc({ deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial<AdminService>)).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } });
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
describe('AdminController addons + sessions + jwt + defaults', () => {
|
|
it('addon update audits + invalidates MCP sessions only when the MCP surface changed (#1414)', async () => {
|
|
const invalidateMcpSessions = vi.fn();
|
|
const runtime = { deactivateForDisabledAddon: vi.fn() } as unknown as PluginRuntimeService;
|
|
const c = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'mcp', enabled: true }, mcpAffected: true, auditDetails: {} }), invalidateMcpSessions } as Partial<AdminService>), runtime);
|
|
expect(await c.updateAddon(user, 'mcp', { enabled: true }, req)).toEqual({ addon: { id: 'mcp', enabled: true } });
|
|
expect(invalidateMcpSessions).toHaveBeenCalled();
|
|
expect(runtime.deactivateForDisabledAddon).not.toHaveBeenCalled();
|
|
|
|
// Config-only saves / MCP-irrelevant addons keep live sessions alive.
|
|
const noopInvalidate = vi.fn();
|
|
const noop = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'llm_parsing', enabled: true }, mcpAffected: false, auditDetails: {} }), invalidateMcpSessions: noopInvalidate } as Partial<AdminService>), runtime);
|
|
expect(await noop.updateAddon(user, 'llm_parsing', { config: { model: 'x' } }, req)).toEqual({ addon: { id: 'llm_parsing', enabled: true } });
|
|
expect(noopInvalidate).not.toHaveBeenCalled();
|
|
|
|
// Disabling an addon cascades to the plugin runtime.
|
|
const runtime2 = { deactivateForDisabledAddon: vi.fn().mockResolvedValue(['p1']) } as unknown as PluginRuntimeService;
|
|
const disable = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'budget', enabled: false }, mcpAffected: true, auditDetails: {} }), invalidateMcpSessions: vi.fn() } as Partial<AdminService>), runtime2);
|
|
await disable.updateAddon(user, 'budget', { enabled: false }, req);
|
|
expect(runtime2.deactivateForDisabledAddon).toHaveBeenCalledWith('budget');
|
|
});
|
|
|
|
it('oauth-sessions revoke audits; rotate-jwt maps error', () => {
|
|
expect(adminCtl(svc({ revokeOAuthSession: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).revokeOAuthSession(user, '3', req)).toEqual({ success: true });
|
|
expect(thrown(() => adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial<AdminService>)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } });
|
|
expect(adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).rotateJwtSecret(user, req)).toEqual({ success: true });
|
|
});
|
|
|
|
});
|
|
|
|
describe('AdminController error envelope fallbacks', () => {
|
|
it('ok() defaults to 400 when the error envelope omits a status', () => {
|
|
expect(thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } });
|
|
});
|
|
|
|
|
|
});
|
|
|
|
describe('AdminController read-only getters', () => {
|
|
it('return service values verbatim', () => {
|
|
expect(adminCtl(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial<AdminService>)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 });
|
|
expect(adminCtl(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial<AdminService>)).stats()).toEqual({ users: 3 });
|
|
expect(adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions()).toEqual({ a: 1 });
|
|
expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
|
|
expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
|
|
expect(adminCtl(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listInvites()).toEqual({ invites: [{ id: 1 }] });
|
|
expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
|
|
expect(adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
|
|
expect(adminCtl(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
|
|
});
|
|
|
|
|
|
it('githubReleases falls back to default paging when no query is given', async () => {
|
|
const getGithubReleases = vi.fn().mockResolvedValue([{ tag: 'v1' }]);
|
|
const c = adminCtl(svc({ getGithubReleases } as Partial<AdminService>));
|
|
await expect(c.githubReleases()).resolves.toEqual([{ tag: 'v1' }]);
|
|
expect(getGithubReleases).toHaveBeenCalledWith('10', '1');
|
|
await c.githubReleases('5', '2');
|
|
expect(getGithubReleases).toHaveBeenLastCalledWith('5', '2');
|
|
});
|
|
});
|
|
|
|
|
|
|
|
describe('AdminController tokens + sessions', () => {
|
|
it('mcp token + oauth session deletes return success and map errors', () => {
|
|
expect(adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken('2')).toEqual({ success: true });
|
|
expect(thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } });
|
|
expect(thrown(() => adminCtl(svc({ revokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) } as Partial<AdminService>)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } });
|
|
});
|
|
});
|
|
|
|
|
|
describe('AdminController dev test-notification', () => {
|
|
it('404 outside development', async () => {
|
|
delete process.env.NODE_ENV;
|
|
await expect(adminCtl(svc()).devTestNotification(user, {})).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('sends in development', async () => {
|
|
process.env.NODE_ENV = 'development';
|
|
const res = await adminCtl(svc()).devTestNotification(user, { event: 'trip_reminder' });
|
|
expect(res).toEqual({ success: true });
|
|
});
|
|
|
|
it('applies notification defaults when the body is empty', async () => {
|
|
process.env.NODE_ENV = 'development';
|
|
const res = await adminCtl(svc()).devTestNotification(user, {});
|
|
expect(res).toEqual({ success: true });
|
|
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ event: 'trip_reminder', scope: 'user', targetId: user.id }));
|
|
});
|
|
|
|
it('maps an Error from the notification service to 400', async () => {
|
|
process.env.NODE_ENV = 'development';
|
|
(sendNotification as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('send failed'));
|
|
await expect(adminCtl(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'send failed' } });
|
|
});
|
|
|
|
it('stringifies a non-Error notification failure to 400', async () => {
|
|
process.env.NODE_ENV = 'development';
|
|
(sendNotification as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce('weird');
|
|
await expect(adminCtl(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'weird' } });
|
|
});
|
|
});
|
|
|
|
// The feature toggles now go straight to AddonsService — these cases used to assert
|
|
// the same thing through AdminService pass-throughs that no longer exist.
|
|
describe('AdminController feature toggles', () => {
|
|
it('ADMIN-TOGGLE-001 each toggle forwards to AddonsService and writes its own audit action', () => {
|
|
const addons = addonsStub();
|
|
const c = adminCtl(svc(), undefined, addons);
|
|
const cases: Array<[() => unknown, string, keyof AddonsService]> = [
|
|
[() => c.updateBagTracking(user, { enabled: true }, req), 'admin.bag_tracking', 'updateBagTracking'],
|
|
[() => c.updatePlacesPhotos(user, { enabled: true }, req), 'admin.places_photos', 'updatePlacesPhotos'],
|
|
[() => c.updatePlacesAutocomplete(user, { enabled: true }, req), 'admin.places_autocomplete', 'updatePlacesAutocomplete'],
|
|
[() => c.updatePlacesDetails(user, { enabled: true }, req), 'admin.places_details', 'updatePlacesDetails'],
|
|
];
|
|
for (const [run, action, method] of cases) {
|
|
writeAudit.mockClear();
|
|
expect(run()).toEqual({ enabled: true });
|
|
expect(addons[method]).toHaveBeenCalledWith(true);
|
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action, details: { enabled: true } }));
|
|
}
|
|
});
|
|
|
|
it('ADMIN-TOGGLE-002 the getters return the AddonsService value verbatim', () => {
|
|
const c = adminCtl(svc());
|
|
expect(c.getBagTracking()).toEqual({ enabled: false });
|
|
expect(c.getPlacesPhotos()).toEqual({ enabled: false });
|
|
expect(c.getPlacesAutocomplete()).toEqual({ enabled: false });
|
|
expect(c.getPlacesDetails()).toEqual({ enabled: false });
|
|
expect(c.getCollabFeatures()).toEqual({ chat: false });
|
|
});
|
|
|
|
it('ADMIN-TOGGLE-003 collab-features invalidates MCP sessions only when a flag flipped (#1414)', () => {
|
|
const invalidateMcpSessions = vi.fn();
|
|
const c = adminCtl(svc({ invalidateMcpSessions } as Partial<AdminService>));
|
|
expect(c.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true });
|
|
expect(invalidateMcpSessions).toHaveBeenCalled();
|
|
|
|
const noopInvalidate = vi.fn();
|
|
const noop = adminCtl(
|
|
svc({ invalidateMcpSessions: noopInvalidate } as Partial<AdminService>),
|
|
undefined,
|
|
{ ...addonsStub(), updateCollabFeatures: vi.fn(() => ({ features: { chat: true }, changed: false })) } as unknown as AddonsService,
|
|
);
|
|
expect(noop.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true });
|
|
expect(noopInvalidate).not.toHaveBeenCalled();
|
|
});
|
|
});
|