mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
782e613506
- GPX files drop onto the map or a day; the import lands on that day - wanderer connection per user in Integrations; the workspace offers the user's own trails only, behind an admin switch under the Tours addon - wanderer difficulty is stored on the SAC scale (T1 to T3), backfilled - a GPX or wanderer import can be planned as an editable tour, either in place (the recorded track is kept and can be restored) or as a new tour that remembers the import it came from - rails, rows and detail dialog follow the plan sidebar's cards and badges - admin addon shelves with more than one row fold, collapsed by default - reverse-geocoded waypoint names are cached on the device
598 lines
25 KiB
TypeScript
598 lines
25 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 })),
|
|
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.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<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();
|
|
});
|
|
});
|