mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
489ba93c0c
dashboard_timezones joins the display-preference allow-list as a list of IANA zone ids, each checked with Intl like the widget does, without repeats and capped at 24. The write goes through saveMany, so open tabs refresh, and both tool descriptions name the widget's fallback zones.
656 lines
28 KiB
TypeScript
656 lines
28 KiB
TypeScript
/**
|
|
* 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 { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import { DISPLAY_PREFERENCE_KEYS, MAX_DASHBOARD_TIMEZONES } 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 type { FakeRealtimeService } from '../../helpers/fake-realtime';
|
|
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
import { bootTestApp } from '../../helpers/test-app';
|
|
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<void> {
|
|
await setUserSetting(orm, userId, key, value);
|
|
}
|
|
|
|
async function readSetting(userId: number, key: string): Promise<string | null | undefined> {
|
|
const row = await findRow(orm, Settings, { user: userId, key });
|
|
return row?.value;
|
|
}
|
|
|
|
function countSettings(userId: number): Promise<number> {
|
|
return countRows(orm, Settings, { user: userId });
|
|
}
|
|
|
|
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
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<void>) {
|
|
const h = await createMcpHarness({ userId, withResources: false, scopes });
|
|
try {
|
|
await fn(h);
|
|
} finally {
|
|
await h.cleanup();
|
|
}
|
|
}
|
|
|
|
async function update(h: McpHarness, settings: Record<string, unknown>) {
|
|
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 date_format and refuses a format the app does not offer (#2971)', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const ok = parseToolResult(await update(h, { date_format: 'DD.MM.YYYY' })) as {
|
|
settings: Record<string, unknown>;
|
|
};
|
|
expect(ok.settings.date_format).toBe('DD.MM.YYYY');
|
|
const refused = await update(h, { date_format: 'dd-mm' });
|
|
expect(refused.isError).toBe(true);
|
|
expect(await readSetting(user.id, 'date_format')).toBe('DD.MM.YYYY');
|
|
});
|
|
});
|
|
|
|
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('writes dashboard_timezones as the stored list and reads it back (#3044)', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const data = parseToolResult(await update(h, { dashboard_timezones: ['Europe/Madrid', 'Africa/Cairo'] })) as {
|
|
settings: Record<string, unknown>;
|
|
};
|
|
expect(data.settings.dashboard_timezones).toEqual(['Europe/Madrid', 'Africa/Cairo']);
|
|
expect(await readSetting(user.id, 'dashboard_timezones')).toBe('["Europe/Madrid","Africa/Cairo"]');
|
|
});
|
|
});
|
|
|
|
it('accepts an empty dashboard_timezones list, which shows no clocks (#3044)', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const data = parseToolResult(await update(h, { dashboard_timezones: [] })) as {
|
|
settings: Record<string, unknown>;
|
|
};
|
|
expect(data.settings.dashboard_timezones).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('refuses dashboard_timezones that are unknown, repeated, not a list or too many (#3044)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const tooMany = Intl.supportedValuesOf('timeZone').slice(0, MAX_DASHBOARD_TIMEZONES + 1);
|
|
await withHarness(user.id, async (h) => {
|
|
const unknown = await update(h, { dashboard_timezones: ['Europe/Madrid', 'Mars/Olympus_Mons'] });
|
|
expect(unknown.isError).toBe(true);
|
|
expect(errorText(unknown)).toContain('IANA');
|
|
expect((await update(h, { dashboard_timezones: [''] })).isError).toBe(true);
|
|
expect((await update(h, { dashboard_timezones: ['Asia/Tokyo', 'Asia/Tokyo'] })).isError).toBe(true);
|
|
expect((await update(h, { dashboard_timezones: 'Europe/Madrid' })).isError).toBe(true);
|
|
const capped = await update(h, { dashboard_timezones: tooMany });
|
|
expect(capped.isError).toBe(true);
|
|
expect(errorText(capped)).toContain(`at most ${MAX_DASHBOARD_TIMEZONES}`);
|
|
expect(await countSettings(user.id)).toBe(0);
|
|
});
|
|
});
|
|
|
|
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<string[]> {
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('update_display_settings reaches every tab', () => {
|
|
it('MCP-SETTINGS-001: tells every tab of the user which keys changed, never their values', async () => {
|
|
const { user } = createUser(testDb);
|
|
// The fake the harness container was booted with, which records every user message.
|
|
const realtime = (await bootTestApp()).get(RealtimeService) as FakeRealtimeService;
|
|
realtime.broadcastToUserMock.mockClear();
|
|
await withHarness(user.id, async (h) => {
|
|
await update(h, { temperature_unit: 'fahrenheit', week_start: 'sunday' });
|
|
});
|
|
expect(realtime.broadcastToUserMock.mock.calls).toEqual([
|
|
[user.id, { type: 'settings:updated', keys: ['temperature_unit', 'week_start'] }, undefined],
|
|
]);
|
|
});
|
|
|
|
it('MCP-SETTINGS-002: a refused change sends nothing', async () => {
|
|
const { user } = createUser(testDb);
|
|
const realtime = (await bootTestApp()).get(RealtimeService) as FakeRealtimeService;
|
|
realtime.broadcastToUserMock.mockClear();
|
|
await withHarness(user.id, async (h) => {
|
|
expect((await update(h, { temperature_unit: 'kelvin' })).isError).toBe(true);
|
|
});
|
|
expect(realtime.broadcastToUserMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|