Files
TREK/server/tests/unit/nest/admin.controller.test.ts
T
Maurice c8eb510bd4 feat(ski-routing): plan ski tours on the piste graphs of TREK Places
A new Nest domain plans the legs of a ski and winter tour on the runs,
lifts, cross-country trails, ski touring and winter paths the Places host
builds from OpenStreetMap, one SGR1 graph per ski area. TREK fetches the
index once, downloads an area by its id when a planner first plans in it,
checks it against the index's SHA-256 and keeps it in data/ski; no
clicked point ever leaves the server. A leg whose area is slower than two
seconds answers loading, and an older copy stands in while the host is
down.

The reader decodes the writer's golden file byte for byte, including the
two edge flags it added (area_has_run, type_unknown). The search is A* in
a worker thread over a profile per discipline (alpine, snowboard,
cross-country by technique, touring, snowshoe, winter hike) with the
times and penalties of the cost model, up to five snap candidates per
point, lift stations as entry points, legs that go on from each other,
pieces of a split network stitched by their boundary nodes, other ways by
the penalty method, and a failed leg explained by relaxing one rule at a
time (difficulty, lift class, technique, riding down, uphill) without ever
handing out the relaxed line.

POST /api/trips/:tripId/ski/route, GET .../ski/area-near and
GET .../ski/network/:areaId sit behind Tours and the trip, with
plan_ski_route as their MCP twin. A daily job looks for a new build and
sweeps areas unused for 90 days. The admin switch ski_routing_enabled is
on wherever TREK Places is.
2026-10-20 21:13:48 +02:00

614 lines
26 KiB
TypeScript

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> = {}): 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 })),
getToursHikingMap: vi.fn(() => ({ enabled: true })),
updateToursHikingMap: vi.fn((enabled: boolean) => ({ enabled })),
getToursCycleMap: vi.fn(() => ({ enabled: true })),
updateToursCycleMap: vi.fn((enabled: boolean) => ({ enabled })),
getToursSkiRouting: vi.fn(() => ({ enabled: true })),
updateToursSkiRouting: 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<TokenService> = {},
invites: Partial<RegistrationInvitesService> = {},
oauth: Partial<OauthService> = {},
kitinerary: Partial<KitineraryExtractorService> = {},
) =>
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<unknown>): 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<AdminService>)).listUsers(),
).toEqual({ users: [{ id: 1 }] });
expect(
await thrown(() =>
adminCtl(
svc({ createUser: vi.fn().mockRejectedValue(new DomainError(409, 'Email taken')) } 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(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<AdminService>),
).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<AdminService>),
).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<AdminService>),
).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<AdminService>),
);
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<AdminService>),
);
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<AdminService>),
).saveDemoBaseline(user, req),
),
).toEqual({ status: 400, body: { error: 'not demo' } });
expect(
await 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', 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<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('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<AdminService>),
);
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<AdminService>),
).rotateJwtSecret(user, req),
),
).toEqual({ status: 409, body: { error: 'locked' } });
expect(
await adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).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<AdminService>),
).resetUserPasskeys(user, '4', req),
).toEqual({ success: true, deleted: 2 });
expect(
await adminCtl(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial<AdminService>)).stats(),
).toEqual({ users: 3 });
expect(
await adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions(),
).toEqual({ a: 1 });
expect(
await adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog(
{},
),
).toEqual({ entries: [] });
await expect(
adminCtl(
svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>),
).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<AdminService>),
).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<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', 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<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', async () => {
const addons = addonsStub();
const c = adminCtl(svc(), undefined, addons);
const cases: Array<[() => Promise<unknown>, string, keyof AddonsService]> = [
[() => c.updateBagTracking(user, { enabled: true }, req), 'admin.bag_tracking', 'updateBagTracking'],
[() => c.updateToursWanderer(user, { enabled: true }, req), 'admin.tours_wanderer', 'updateToursWanderer'],
[() => c.updateToursHikingMap(user, { enabled: true }, req), 'admin.tours_hiking_map', 'updateToursHikingMap'],
[() => c.updateToursCycleMap(user, { enabled: true }, req), 'admin.tours_cycle_map', 'updateToursCycleMap'],
[
() => c.updateToursSkiRouting(user, { enabled: true }, req),
'admin.tours_ski_routing',
'updateToursSkiRouting',
],
[() => 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.getToursHikingMap()).toEqual({ enabled: true });
expect(await c.getToursCycleMap()).toEqual({ enabled: true });
expect(await c.getToursSkiRouting()).toEqual({ enabled: true });
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<AdminService>));
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<AdminService>), 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();
});
});