mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 15:21:08 +00:00
86513fda0d
On a managed install the operator runs place search and routing, the plugin runtime, the integrations and the documentation, so the admin and the users no longer see switches and guides for them: - The place search and routing card, the transit provider and the search log are gone from the admin settings (desktop and mobile). places_provider is a managed-locked setting, and the transit provider, search log and wanderer routes refuse writes there. Share place details stays the admin's, as a card of its own, hidden when TREK_PLACES_CONTRIBUTE switches it off. - File types and trip reminders move to the right column, so the columns stay balanced. - Plugins disappear from the admin and the user settings, wanderer from Tours. - The Help & Docs page and its links are not offered. The help center stays and leaves out guides about what the operator runs (keys, OIDC, backups, storage, releases, plugins, document sync) and about integrations that are off. - Mapbox GL is offered only with the operator's token, Amap tiles only where the operator set Amap up. - The mobile admin no longer shows OIDC and the WebAuthn relying party fields, matching the desktop tab. For every install: - Notification matrices list only events the install can raise: no plugin row without the plugin runtime, no Vacay, collection, photo, Synology, wanderer or packing rows while those are off. On a managed install the release and replica alerts are left out. - The journey entry editor offers External photos only where a photo provider is switched on, as the phone already did. - app-config is asked again after a failed answer. A page opened while the server restarted kept every default for the session, which on a managed install brought back the operator's settings.
625 lines
26 KiB
TypeScript
625 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 { MANAGED_FORBIDDEN } from '../../../src/nest/common/managed';
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('AdminController on a managed install', () => {
|
|
it('ADMIN-CTRL-MANAGED-001: the transit provider and the place search log are operator-owned', () => {
|
|
// Place search and routing come with a managed instance; the admin UI hides
|
|
// both, and the routes refuse them so a direct call cannot set them either.
|
|
for (const name of ['updateTransitProvider', 'updatePlaceShadow', 'updateToursWanderer'] as const) {
|
|
const handler = AdminController.prototype[name];
|
|
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, handler)?.reason, name).toBeTruthy();
|
|
}
|
|
// Reading stays open: the search still needs to know what it runs on.
|
|
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, AdminController.prototype.getTransitProvider)).toBeUndefined();
|
|
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, AdminController.prototype.getPlaceShadow)).toBeUndefined();
|
|
});
|
|
});
|