mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
e3982a8e2a
Converts the raw prepare() fixtures and reads in the next set of specs to the ORM factories and readers: the password reset, webauthn, dawarich, budget, to-do and vacay repository specs, the journey, settings, addons and share MCP specs, the plugin POI and operator egress specs, the feeds, storage stats and query helper services, and the packing, collections, day reorder, leg mode, upload context and trip access integration suites. The notifications and google quota e2e suites now seed their users through makeUser with pinned ids, and the notifications suite moves from hand-rolled tables to the migrated snapshot. Legacy statements that serve as a parity oracle and the raw rows the primary-relation spike holds toObject() against stay raw, each with an allow marker naming why.
567 lines
24 KiB
TypeScript
567 lines
24 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 { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
import { db as testDb } from '../../../src/db/database';
|
|
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
|
|
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
|
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
import { createUser } from '../../helpers/factories';
|
|
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
import { countRows, findRow, insertRow } from '../../helpers/factories/rows';
|
|
import { setUserSetting } from '../../helpers/factories/settings';
|
|
import { Settings } from '../../../src/db/entities/Settings.entity';
|
|
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
|
import { encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto';
|
|
import { MASKED_SETTING_VALUE } from '@trek/shared';
|
|
import { DISPLAY_PREFERENCE_KEYS } from '../../../src/nest/settings/settings.mcp';
|
|
import { MANAGED_LOCKED_SETTING_KEYS } from '../../../src/nest/common/managed';
|
|
import { isAdminOnlyEndpointSetting, ENCRYPTED_SETTING_KEYS, MASKED_SETTING_KEYS } from '../../../src/nest/settings/settings.service';
|
|
|
|
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 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<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');
|
|
});
|
|
});
|