import type { AddonsService } from '../../../src/nest/addons/addons.service'; import { AdminController } from '../../../src/nest/admin/admin.controller'; import type { AdminService } from '../../../src/nest/admin/admin.service'; import type { AuditService } from '../../../src/nest/audit/audit.service'; import type { RegistrationInvitesService } from '../../../src/nest/auth/registration-invites.service'; import { KitineraryExtractorService } from '../../../src/nest/booking-import/kitinerary-extractor.service'; import { DomainError } from '../../../src/nest/common/domain-error'; import type { NotificationsService } from '../../../src/nest/notifications/notifications.service'; import type { OauthService } from '../../../src/nest/oauth/oauth.service'; import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; import type { TokenService } from '../../../src/nest/tokens/token.service'; import type { User } from '../../../src/types'; import { HttpException, NotFoundException } from '@nestjs/common'; import type { Request } from 'express'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 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(), })); const user = { id: 1, role: 'admin', email: 'admin@example.test' } as User; const req = { headers: {} } as Request; function svc(o: Partial = {}): 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 })), getToursWanderer: vi.fn(() => ({ enabled: false })), updateToursWanderer: 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 })), // Fail-closed with nothing to backfill — see AddonsService.getPlaceShadow. getPlaceShadow: vi.fn(() => ({ enabled: false })), updatePlaceShadow: vi.fn((enabled: boolean) => ({ enabled })), // Fail-open, unlike its three neighbours — see AddonsService.getPlacesEnrich. getPlacesEnrich: vi.fn(() => ({ enabled: true })), updatePlacesEnrich: vi.fn((enabled: boolean) => ({ enabled })), // Fail-closed with nothing to backfill, like the shadow log. getPlacesGoogleOnly: vi.fn(() => ({ enabled: false })), updatePlacesGoogleOnly: vi.fn((enabled: boolean) => ({ enabled })), // Fail-open too, and the only switch here about egress rather than spending. getCollabFeatures: vi.fn(() => ({ chat: false })), updateCollabFeatures: vi.fn(() => ({ features: { chat: true }, changed: true })), getTransitProvider: vi.fn(() => ({ provider: 'transitous', googleKeySource: null })), updateTransitProvider: vi.fn((provider: string) => ({ provider, googleKeySource: null })), }) 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 = {}, invites: Partial = {}, oauth: Partial = {}, kitinerary: Partial = {}, ) => new AdminController( s, addons, rt as unknown as PluginRuntimeService, audit, notifications, tokens as TokenService, invites as RegistrationInvitesService, oauth as OauthService, kitinerary as KitineraryExtractorService, ); // #2261 — the extractor's version is what tells a stale binary from a provider // nobody wrote a script for; both come back empty otherwise. describe('AdminController system-info', () => { it('ADM-SYSINFO-001: hands the extractor description through unchanged', () => { const describeFn = vi.fn(() => ({ available: true, path: '/usr/local/bin/kitinerary-extractor', version: '6.3.3', configuredPath: null, })); const res = adminCtl( {} as AdminService, undefined, addonsStub(), {}, {}, {}, { describe: describeFn }, ).systemInfo(); expect(res).toEqual({ kitinerary: { available: true, path: '/usr/local/bin/kitinerary-extractor', version: '6.3.3', configuredPath: null, }, }); }); }); async function thrown(fn: () => Promise): Promise<{ status: number; body: unknown }> { try { await 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', async () => { expect( await adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial)).listUsers(), ).toEqual({ users: [{ id: 1 }] }); expect( await thrown(() => adminCtl( svc({ createUser: vi.fn().mockRejectedValue(new DomainError(409, 'Email taken')) } as Partial), ).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), ); expect(await 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', async () => { expect( await adminCtl( svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }), } as Partial), ).updateUser(user, '2', {}, req), ).toEqual({ user: { id: 2 } }); expect( await thrown(() => adminCtl( svc({ deleteUser: vi.fn().mockRejectedValue(new DomainError(400, 'Cannot delete self')), } as Partial), ).deleteUser(user, '1', req), ), ).toEqual({ status: 400, body: { error: 'Cannot delete self' } }); expect( await adminCtl( svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial), ).deleteUser(user, '2', req), ).toEqual({ success: true }); }); }); describe('AdminController permissions + oidc + misc', () => { it('permissions: saves + audits', async () => { const c = adminCtl( svc({ savePermissions: vi.fn().mockReturnValue({ permissions: { x: 1 }, skipped: [] }), } as Partial), ); expect(await c.savePermissions(user, { permissions: { x: 1 } }, req)).toEqual({ success: true, permissions: { x: 1 }, }); }); it('permissions: includes skipped when present', async () => { const c = adminCtl( svc({ savePermissions: vi.fn().mockReturnValue({ permissions: {}, skipped: ['bad'] }) } as Partial), ); expect(await c.savePermissions(user, { permissions: {} }, req)).toEqual({ success: true, permissions: {}, skipped: ['bad'], }); }); it('save-demo-baseline maps error, else returns message', async () => { expect( await thrown(() => adminCtl( svc({ saveDemoBaseline: vi.fn().mockRejectedValue(new DomainError(400, 'not demo')), } as Partial), ).saveDemoBaseline(user, req), ), ).toEqual({ status: 400, body: { error: 'not demo' } }); expect( await adminCtl( svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial), ).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', async () => { const c = adminCtl( svc(), undefined, undefined, {}, { createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) }, ); expect(await c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' })); expect( await thrown(() => adminCtl( svc(), undefined, undefined, {}, { deleteInvite: vi.fn().mockRejectedValue(new DomainError(404, 'not found')) }, ).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), 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), 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), runtime2, ); await disable.updateAddon(user, 'budget', { enabled: false }, req); expect(runtime2.deactivateForDisabledAddon).toHaveBeenCalledWith('budget'); }); it('addon update maps the journey-dependency refusal to its status, before any audit', async () => { const c = adminCtl( svc({ updateAddon: vi.fn().mockRejectedValue(new DomainError(409, 'Enable the Journey addon first')), } as Partial), ); const err = await c.updateAddon(user, 'immich', { enabled: true }, req).then( () => null, (e: unknown) => e as HttpException, ); expect(err).toBeInstanceOf(HttpException); expect(err!.getStatus()).toBe(409); expect(err!.getResponse()).toEqual({ error: 'Enable the Journey addon first' }); expect(writeAudit).not.toHaveBeenCalled(); }); it('oauth-sessions revoke audits; rotate-jwt maps error', async () => { expect( await adminCtl( svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({}) }, ).revokeOAuthSession(user, '3', req), ).toEqual({ success: true }); expect( await thrown(() => adminCtl( svc({ rotateJwtSecret: vi.fn(() => { throw new DomainError(409, 'locked'); }), } as Partial), ).rotateJwtSecret(user, req), ), ).toEqual({ status: 409, body: { error: 'locked' } }); expect( await adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial)).rotateJwtSecret( user, req, ), ).toEqual({ success: true }); }); }); describe('AdminController read-only getters', () => { it('return service values verbatim', async () => { expect( await adminCtl( svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial), ).resetUserPasskeys(user, '4', req), ).toEqual({ success: true, deleted: 2 }); expect( await adminCtl(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial)).stats(), ).toEqual({ users: 3 }); expect( await adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial)).permissions(), ).toEqual({ a: 1 }); expect( await adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial)).auditLog( {}, ), ).toEqual({ entries: [] }); await expect( adminCtl( svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial), ).versionCheck(), ).resolves.toEqual({ current: '1' }); expect( await adminCtl( svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }, ).listInvites(), ).toEqual({ invites: [{ id: 1 }] }); expect( await adminCtl( svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial), ).listAddons(), ).toEqual({ addons: [{ id: 'mcp' }] }); expect( await adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]), }).listMcpTokens(), ).toEqual({ tokens: [{ id: 1 }] }); expect( await adminCtl( svc(), undefined, undefined, {}, {}, { listAllOAuthSessions: vi.fn().mockResolvedValue([{ id: 1 }]) }, ).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)); 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', async () => { expect( await adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken( user, '2', req, ), ).toEqual({ success: true }); expect( await thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockRejectedValue(new DomainError(404, 'no token')), }).deleteMcpToken(user, '9', req), ), ).toEqual({ status: 404, body: { error: 'no token' } }); expect( await thrown(() => adminCtl( svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockRejectedValue(new DomainError(404, 'no session')) }, ).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).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).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', async () => { const addons = addonsStub(); const c = adminCtl(svc(), undefined, addons); const cases: Array<[() => Promise, string, keyof AddonsService]> = [ [() => c.updateBagTracking(user, { enabled: true }, req), 'admin.bag_tracking', 'updateBagTracking'], [() => c.updateToursWanderer(user, { enabled: true }, req), 'admin.tours_wanderer', 'updateToursWanderer'], [() => 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'], [() => c.updatePlaceShadow(user, { enabled: true }, req), 'admin.place_shadow', 'updatePlaceShadow'], ]; for (const [run, action, method] of cases) { writeAudit.mockClear(); expect(await 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', async () => { const c = adminCtl(svc()); expect(await c.getBagTracking()).toEqual({ enabled: false }); expect(await c.getToursWanderer()).toEqual({ enabled: false }); expect(await c.getPlacesPhotos()).toEqual({ enabled: false }); expect(await c.getPlacesAutocomplete()).toEqual({ enabled: false }); expect(await c.getPlacesDetails()).toEqual({ enabled: false }); expect(await c.getPlaceShadow()).toEqual({ enabled: false }); expect(await c.getPlacesEnrich()).toEqual({ enabled: true }); expect(await c.getPlacesGoogleOnly()).toEqual({ enabled: false }); expect(await c.getCollabFeatures()).toEqual({ chat: false }); }); it('ADMIN-TOGGLE-002d places-google-only updates through the addons domain and is audited', async () => { const c = adminCtl(svc()); expect(await c.updatePlacesGoogleOnly(user, { enabled: true }, req)).toEqual({ enabled: true }); expect(writeAudit).toHaveBeenCalledWith( expect.objectContaining({ action: 'admin.places_google_only', details: { enabled: true } }), ); }); it('ADMIN-TOGGLE-002b places-enrich updates through the addons domain and is audited', async () => { const c = adminCtl(svc()); expect(await c.updatePlacesEnrich(user, { enabled: false }, req)).toEqual({ enabled: false }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.places_enrich' })); }); it('ADMIN-TOGGLE-002c transit-provider reads and writes through the addons domain and is audited (#1699)', async () => { const addons = addonsStub(); const c = adminCtl(svc(), undefined, addons); expect(await c.getTransitProvider(user)).toEqual({ provider: 'transitous', googleKeySource: null }); expect(await c.updateTransitProvider(user, { provider: 'google' }, req)).toEqual({ provider: 'google', googleKeySource: null, }); // The acting admin's id drives key resolution — the panel warns about the // key THEY would search with. expect(addons.getTransitProvider).toHaveBeenCalledWith(user.id); expect(addons.updateTransitProvider).toHaveBeenCalledWith('google', user.id); expect(writeAudit).toHaveBeenCalledWith( expect.objectContaining({ action: 'admin.transit_provider', details: { provider: 'google' } }), ); }); it('ADMIN-TOGGLE-004 place-shadow forwards the body value and audits the stored one', async () => { // The handler audits result.enabled. A service that declines the flip must // not leave behind an audit row claiming the shadow log was switched on. const updatePlaceShadow = vi.fn(() => ({ enabled: false })); const c = adminCtl(svc(), undefined, { ...addonsStub(), updatePlaceShadow } as unknown as AddonsService); expect(await c.updatePlaceShadow(user, { enabled: true }, req)).toEqual({ enabled: false }); expect(updatePlaceShadow).toHaveBeenCalledWith(true); expect(writeAudit).toHaveBeenCalledWith({ userId: user.id, action: 'admin.place_shadow', ip: '1.2.3.4', details: { enabled: false }, }); // Off is carried too: the table above only ever asks for true, so a handler // that hardcoded the argument would pass it. await c.updatePlaceShadow(user, { enabled: false }, req); expect(updatePlaceShadow).toHaveBeenLastCalledWith(false); }); it('ADMIN-TOGGLE-004b place-shadow reads through to AddonsService instead of a controller-side default', async () => { const getPlaceShadow = vi.fn(() => ({ enabled: true })); const c = adminCtl(svc(), undefined, { ...addonsStub(), getPlaceShadow } as unknown as AddonsService); expect(await c.getPlaceShadow()).toEqual({ enabled: true }); expect(getPlaceShadow).toHaveBeenCalledTimes(1); expect(writeAudit).not.toHaveBeenCalled(); }); it('ADMIN-TOGGLE-003 collab-features invalidates MCP sessions only when a flag flipped (#1414)', async () => { const invalidateMcpSessions = vi.fn(); const c = adminCtl(svc({ invalidateMcpSessions } as Partial)); expect(await c.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true }); expect(invalidateMcpSessions).toHaveBeenCalled(); const noopInvalidate = vi.fn(); const noop = adminCtl(svc({ invalidateMcpSessions: noopInvalidate } as Partial), undefined, { ...addonsStub(), updateCollabFeatures: vi.fn(() => ({ features: { chat: true }, changed: false })), } as unknown as AddonsService); expect(await noop.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true }); expect(noopInvalidate).not.toHaveBeenCalled(); }); });