/** * Unit tests for the settings MCP surface (SettingsMcp): * get_display_settings and update_display_settings. * * The security half carries the weight here. `settings` is one flat key/value * table that holds display preferences next to encrypted credentials, and * SettingsService.getUserSettings hands several of those back decrypted, so * these tests pin that the allow-list is the only thing either tool can see or * touch: a stored mapbox_access_token must not appear in a read, and neither * that key nor llm_api_key nor an unknown name may be written. */ import { db as testDb } from '../../../src/db/database'; import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; import { Settings } from '../../../src/db/entities/Settings.entity'; import { encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto'; import { MANAGED_LOCKED_SETTING_KEYS } from '../../../src/nest/common/managed'; import { DISPLAY_PREFERENCE_KEYS } from '../../../src/nest/settings/settings.mcp'; import { isAdminOnlyEndpointSetting, ENCRYPTED_SETTING_KEYS, MASKED_SETTING_KEYS, } from '../../../src/nest/settings/settings.service'; import { createUser } from '../../helpers/factories'; import { countRows, findRow, insertRow } from '../../helpers/factories/rows'; import { setUserSetting } from '../../helpers/factories/settings'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { MASKED_SETTING_VALUE } from '@trek/shared'; import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); beforeEach(() => { resetTestDb(testDb); delete process.env.DEMO_MODE; }); let orm: TestOrm; beforeAll(async () => { orm = await createTestOrm(testDb); }); afterAll(async () => { await orm.close(); testDb.close(); }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function setSetting(userId: number, key: string, value: string): Promise { await setUserSetting(orm, userId, key, value); } async function readSetting(userId: number, key: string): Promise { const row = await findRow(orm, Settings, { user: userId, key }); return row?.value; } function countSettings(userId: number): Promise { return countRows(orm, Settings, { user: userId }); } async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { const h = await createMcpHarness({ userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } async function withScopedHarness(userId: number, scopes: string[] | null, fn: (h: McpHarness) => Promise) { const h = await createMcpHarness({ userId, withResources: false, scopes }); try { await fn(h); } finally { await h.cleanup(); } } async function update(h: McpHarness, settings: Record) { return h.client.callTool({ name: 'update_display_settings', arguments: { settings } }); } /** The message of an isError result, for asserting the wording a model reads. */ function errorText(result: any): string { return String(result.content?.[0]?.text ?? ''); } // --------------------------------------------------------------------------- // get_display_settings // --------------------------------------------------------------------------- describe('Tool: get_display_settings', () => { it('returns an empty object for a user who has set nothing', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); const data = parseToolResult(result) as any; expect(data.settings).toEqual({}); }); }); it('returns the display preferences the user has stored', async () => { const { user } = createUser(testDb); await setSetting(user.id, 'temperature_unit', '"fahrenheit"'); await setSetting(user.id, 'distance_unit', '"imperial"'); await setSetting(user.id, 'time_format', '"12h"'); await setSetting(user.id, 'language', '"de"'); await setSetting(user.id, 'default_currency', '"USD"'); await setSetting(user.id, 'start_page', '"active_trip"'); await setSetting(user.id, 'blur_booking_codes', 'true'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); const data = parseToolResult(result) as any; expect(data.settings).toEqual({ start_page: 'active_trip', default_currency: 'USD', language: 'de', temperature_unit: 'fahrenheit', distance_unit: 'imperial', time_format: '12h', blur_booking_codes: true, }); }); }); it('falls back to the admin-set instance default for a key the user has not set', async () => { const { user } = createUser(testDb); await insertRow(orm, AppSettings, { key: 'default_user_setting_temperature_unit', value: '"fahrenheit"' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); const data = parseToolResult(result) as any; expect(data.settings.temperature_unit).toBe('fahrenheit'); }); }); it('does not return stored credentials, not even to a settings:read token', async () => { const { user } = createUser(testDb); // mapbox_access_token and carto_api_key are encrypted-but-NOT-masked, so // getUserSettings hands them back in cleartext. That is the leak this // surface must not have: store them the way the real write path does and // assert they are nowhere in the result. await setSetting(user.id, 'mapbox_access_token', String(encrypt_api_key('pk.super-secret-token'))); await setSetting(user.id, 'carto_api_key', String(encrypt_api_key('carto-secret'))); await setSetting(user.id, 'llm_api_key', String(encrypt_api_key('sk-secret'))); await setSetting(user.id, 'ntfy_token', String(encrypt_api_key('ntfy-secret'))); await setSetting(user.id, 'webhook_url', String(encrypt_api_key('https://hook.example/secret'))); await setSetting(user.id, 'temperature_unit', '"celsius"'); await withScopedHarness(user.id, ['settings:read'], async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); const data = parseToolResult(result) as any; expect(data.settings).toEqual({ temperature_unit: 'celsius' }); const serialized = JSON.stringify(data); expect(serialized).not.toContain('pk.super-secret-token'); expect(serialized).not.toContain('carto-secret'); expect(serialized).not.toContain('sk-secret'); expect(serialized).not.toContain('ntfy-secret'); expect(serialized).not.toContain('hook.example'); for (const key of ['mapbox_access_token', 'carto_api_key', 'llm_api_key', 'ntfy_token', 'webhook_url']) { expect(Object.keys(data.settings)).not.toContain(key); } }); }); it('leaves out settings that are neither display preferences nor credentials', async () => { const { user } = createUser(testDb); await setSetting(user.id, 'map_tile_url', '"https://tiles.example/{z}/{x}/{y}.png"'); await setSetting(user.id, 'dashboard_fx_from', '"EUR"'); await setSetting(user.id, 'time_format', '"24h"'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); const data = parseToolResult(result) as any; expect(data.settings).toEqual({ time_format: '24h' }); }); }); it("does not leak another user's preferences", async () => { const { user: mine } = createUser(testDb); const { user: theirs } = createUser(testDb); await setSetting(theirs.id, 'temperature_unit', '"fahrenheit"'); await withHarness(mine.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); const data = parseToolResult(result) as any; expect(data.settings).toEqual({}); }); }); }); // --------------------------------------------------------------------------- // update_display_settings: happy paths // --------------------------------------------------------------------------- describe('Tool: update_display_settings', () => { it('writes several preferences at once and reports what changed', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { temperature_unit: 'fahrenheit', distance_unit: 'imperial' }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); expect(data.updated).toBe(2); expect(data.settings).toEqual({ temperature_unit: 'fahrenheit', distance_unit: 'imperial' }); expect(await readSetting(user.id, 'temperature_unit')).toBe('fahrenheit'); expect(await readSetting(user.id, 'distance_unit')).toBe('imperial'); }); }); it('leaves untouched keys alone', async () => { const { user } = createUser(testDb); await setSetting(user.id, 'time_format', '"12h"'); await withHarness(user.id, async (h) => { await update(h, { temperature_unit: 'celsius' }); expect(await readSetting(user.id, 'time_format')).toBe('"12h"'); }); }); it('accepts every non-boolean display preference in one call', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { start_page: 'active_trip', start_trip_tab: 'finanzplan', default_currency: 'JPY', language: 'ja', temperature_unit: 'celsius', distance_unit: 'metric', time_format: '24h', dark_mode: 'auto', }); const data = parseToolResult(result) as any; expect(data.updated).toBe(8); expect(data.settings.start_trip_tab).toBe('finanzplan'); expect(data.settings.dark_mode).toBe('auto'); expect(data.settings.language).toBe('ja'); }); }); it('writes place_language, clears it with "", and refuses a language TREK does not offer (#1799)', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const set = parseToolResult(await update(h, { place_language: 'en' })) as any; expect(set.settings.place_language).toBe('en'); const refused = await update(h, { place_language: 'klingon' }); expect(refused.isError).toBe(true); expect(await readSetting(user.id, 'place_language')).toBe('en'); const cleared = parseToolResult(await update(h, { place_language: '' })) as any; expect(cleared.settings.place_language).toBe(''); }); }); it('writes week_start and refuses a day outside the three the app offers (#2029)', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const ok = parseToolResult(await update(h, { week_start: 'sunday' })) as any; expect(ok.settings.week_start).toBe('sunday'); const refused = await update(h, { week_start: 'friday' }); expect(refused.isError).toBe(true); expect(await readSetting(user.id, 'week_start')).toBe('sunday'); }); }); it('accepts every boolean display preference', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { map_booking_labels: true, map_always_show_routes: false, map_poi_pill_enabled: true, blur_booking_codes: false, optimize_from_accommodation: true, }); const data = parseToolResult(result) as any; expect(data.updated).toBe(5); expect(data.settings).toEqual({ map_booking_labels: true, map_always_show_routes: false, map_poi_pill_enabled: true, blur_booking_codes: false, optimize_from_accommodation: true, }); }); }); it('accepts a boolean dark_mode, the pre-auto form still sitting in older rows', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { dark_mode: false }); const data = parseToolResult(result) as any; expect(data.settings.dark_mode).toBe(false); expect(await readSetting(user.id, 'dark_mode')).toBe('false'); }); }); it("accepts an empty default_currency, which falls back to each trip's own", async () => { const { user } = createUser(testDb); await setSetting(user.id, 'default_currency', '"USD"'); await withHarness(user.id, async (h) => { const result = await update(h, { default_currency: '' }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); expect(await readSetting(user.id, 'default_currency')).toBe(''); }); }); it('reads back the admin default rather than echoing the input', async () => { const { user } = createUser(testDb); await insertRow(orm, AppSettings, { key: 'default_user_setting_distance_unit', value: '"imperial"' }); await withHarness(user.id, async (h) => { const result = await update(h, { temperature_unit: 'celsius' }); const data = parseToolResult(result) as any; expect(data.settings.distance_unit).toBe('imperial'); }); }); }); // --------------------------------------------------------------------------- // update_display_settings: refusals // --------------------------------------------------------------------------- describe('Tool: update_display_settings, refusals', () => { it('refuses a credential key and stores nothing', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { mapbox_access_token: 'pk.attacker-token' }); expect(result.isError).toBe(true); expect(errorText(result)).toContain('mapbox_access_token'); expect(await countSettings(user.id)).toBe(0); }); }); it('refuses llm_api_key and stores nothing, even for a settings:write token', async () => { const { user } = createUser(testDb); await withScopedHarness(user.id, ['settings:write'], async (h) => { const result = await update(h, { llm_api_key: 'sk-attacker' }); expect(result.isError).toBe(true); expect(await countSettings(user.id)).toBe(0); }); }); it('refuses the two admin-only LLM endpoint keys the REST route answers 403 for', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { for (const settings of [{ llm_base_url: 'http://127.0.0.1:11434' }, { llm_provider: 'local' }]) { const result = await update(h, settings); expect(result.isError).toBe(true); } expect(await countSettings(user.id)).toBe(0); }); }); it('refuses a key nobody has heard of rather than storing it', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { made_up_preference: 'whatever' }); expect(result.isError).toBe(true); expect(errorText(result)).toContain('made_up_preference'); expect(await countSettings(user.id)).toBe(0); expect(await readSetting(user.id, 'made_up_preference')).toBeUndefined(); }); }); it('refuses an inherited Object property name, which a plain `in` check would wave through', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { toString: 'gotcha' }); expect(result.isError).toBe(true); expect(await countSettings(user.id)).toBe(0); }); }); it('writes nothing at all when one key of a multi-key call is refused', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { temperature_unit: 'fahrenheit', llm_api_key: 'sk-attacker' }); expect(result.isError).toBe(true); expect(await countSettings(user.id)).toBe(0); }); }); it('writes nothing when one value of a multi-key call is invalid', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { temperature_unit: 'fahrenheit', time_format: '36h' }); expect(result.isError).toBe(true); expect(await countSettings(user.id)).toBe(0); }); }); it('refuses a value outside the allowed set and names the key', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { temperature_unit: 'kelvin' }); expect(result.isError).toBe(true); expect(errorText(result)).toContain('temperature_unit'); expect(await readSetting(user.id, 'temperature_unit')).toBeUndefined(); }); }); it('refuses an unsupported language code', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const bad = await update(h, { language: 'xx' }); expect(bad.isError).toBe(true); const good = await update(h, { language: 'fr' }); expect(good.isError).toBeFalsy(); expect(await readSetting(user.id, 'language')).toBe('fr'); }); }); it('refuses a currency that is not a three-letter uppercase code', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { for (const value of ['eur', 'EUROS', 'E']) { const result = await update(h, { default_currency: value }); expect(result.isError).toBe(true); } expect(await countSettings(user.id)).toBe(0); }); }); it('refuses a non-boolean for a boolean preference', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { blur_booking_codes: 'yes' }); expect(result.isError).toBe(true); expect(await countSettings(user.id)).toBe(0); }); }); it('refuses a start_trip_tab that is empty or absurdly long', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { expect((await update(h, { start_trip_tab: '' })).isError).toBe(true); expect((await update(h, { start_trip_tab: 'x'.repeat(65) })).isError).toBe(true); expect(await countSettings(user.id)).toBe(0); }); }); it('refuses the redaction placeholder instead of silently skipping it', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, { start_trip_tab: MASKED_SETTING_VALUE }); expect(result.isError).toBe(true); expect(errorText(result)).toContain('start_trip_tab'); expect(await countSettings(user.id)).toBe(0); }); }); it('refuses an empty settings object and says what it takes', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await update(h, {}); expect(result.isError).toBe(true); expect(errorText(result)).toContain('temperature_unit'); expect(await countSettings(user.id)).toBe(0); }); }); it('blocks the demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@trek.app' }); await withHarness(user.id, async (h) => { const result = await update(h, { temperature_unit: 'fahrenheit' }); expect(result.isError).toBe(true); expect(await countSettings(user.id)).toBe(0); }); }); it('lets the demo user read', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@trek.app' }); await setSetting(user.id, 'temperature_unit', '"celsius"'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); const data = parseToolResult(result) as any; expect(data.settings.temperature_unit).toBe('celsius'); }); }); }); // --------------------------------------------------------------------------- // The allow-list itself // --------------------------------------------------------------------------- describe('Display-preference allow-list', () => { it('holds no key the operator owns on a managed install', () => { // The REST route needs isManagedLockedKey because it takes any key at all. // Here the two lists must simply not overlap, which is what makes the // managed lock unreachable rather than merely duplicated. const overlap = DISPLAY_PREFERENCE_KEYS.filter((key) => (MANAGED_LOCKED_SETTING_KEYS as readonly string[]).includes(key), ); expect(overlap).toEqual([]); }); it('holds no key assertMayWriteInstanceEndpoint would have to refuse', () => { // isAdminOnlyEndpointSetting is value-dependent, so probe it with the values // that trip it rather than matching on the key name. const offenders = DISPLAY_PREFERENCE_KEYS.filter( (key) => isAdminOnlyEndpointSetting(key, 'http://127.0.0.1:11434') || isAdminOnlyEndpointSetting(key, 'local'), ); expect(offenders).toEqual([]); }); // Against the live sets, not a copy of them: a sixth encrypted key added to // the service has to fail here, which is the whole point of the allow-list. it('holds no key that is encrypted at rest', () => { const overlap = [...ENCRYPTED_SETTING_KEYS].filter((key) => (DISPLAY_PREFERENCE_KEYS as readonly string[]).includes(key), ); expect(overlap).toEqual([]); }); it('holds no key that is masked on the way out', () => { const overlap = [...MASKED_SETTING_KEYS].filter((key) => (DISPLAY_PREFERENCE_KEYS as readonly string[]).includes(key), ); expect(overlap).toEqual([]); }); }); // --------------------------------------------------------------------------- // Scope gating // --------------------------------------------------------------------------- describe('Settings tools, scope gating', () => { async function listToolNames(userId: number, scopes: string[] | null): Promise { const h = await createMcpHarness({ userId, withResources: false, scopes }); try { return (await h.client.listTools()).tools.map((t) => t.name); } finally { await h.cleanup(); } } it('registers both tools with null scopes (full access)', async () => { const { user } = createUser(testDb); const names = await listToolNames(user.id, null); expect(names).toContain('get_display_settings'); expect(names).toContain('update_display_settings'); }); it('registers only the read tool with settings:read', async () => { const { user } = createUser(testDb); const names = await listToolNames(user.id, ['settings:read']); expect(names).toContain('get_display_settings'); expect(names).not.toContain('update_display_settings'); }); it('registers both with settings:write, since write implies read', async () => { const { user } = createUser(testDb); const names = await listToolNames(user.id, ['settings:write']); expect(names).toContain('get_display_settings'); expect(names).toContain('update_display_settings'); }); it('registers neither for a token without a settings scope', async () => { const { user } = createUser(testDb); const names = await listToolNames(user.id, ['trips:read']); expect(names).not.toContain('get_display_settings'); expect(names).not.toContain('update_display_settings'); }); });