diff --git a/server/tests/unit/nest/roadtrip-planning.test.ts b/server/tests/unit/nest/roadtrip-planning.test.ts index 3481dab7f..37fddbcf1 100644 --- a/server/tests/unit/nest/roadtrip-planning.test.ts +++ b/server/tests/unit/nest/roadtrip-planning.test.ts @@ -312,3 +312,68 @@ describe('Roadtrip MCP registration and search', () => { expect(service.broadcast).toHaveBeenCalled(); }); }); + +describe('MCP trip preferences authorization', () => { + it('reads shared preferences and refuses inaccessible trips', async () => { + const s = setup(); + const tool = new RoadtripPreferencesMcp(s.preferences, { isDemoUser: () => false } as never, {} as never, s.db as never, { hasTripPermission: () => true } as never); + expect(JSON.stringify(await tool.read({ tripId: 10 }, ctx))).toContain('100'); + s.db.canAccessTrip.mockReturnValue(false); + expect((await tool.read({ tripId: 10 }, ctx)).isError).toBe(true); + expect((await tool.update({ tripId: 10, settings: {} }, ctx)).isError).toBe(true); + }); + it('rejects demos and readers, but permits a trip editor', async () => { + const s = setup(); + const auth = { isDemoUser: vi.fn(() => true) }; + const guards = { hasTripPermission: vi.fn(() => false) }; + const tool = new RoadtripPreferencesMcp(s.preferences, auth as never, {} as never, s.db as never, guards as never); + expect((await tool.update({ tripId: 10, settings: {} }, ctx)).isError).toBe(true); + auth.isDemoUser.mockReturnValue(false); + expect((await tool.update({ tripId: 10, settings: {} }, ctx)).isError).toBe(true); + guards.hasTripPermission.mockReturnValue(true); + expect(JSON.stringify(await tool.update({ tripId: 10, settings: { roadtrip_range_km: 120 } }, ctx))).toContain('120'); + }); +}); +describe('corridor filtering', () => { + const input = { tripId: 10, dayNumber: 1, category: 'charging' as const, widthKm: 5, offset: 0 }; + function tool() { + const plan = { failures: [] as unknown[], omittedVisits: [] as number[], calculated: { dayWindowIssue: null as string | null, days: [{ dayNumber: 1, geometry: [[48, 10], [48, 10.1]] }] } }; + const maps = { search: vi.fn(async () => ({ pois: [ + { osm_id: 'a', name: 'Fast', lat: 48, lng: 10.03, category: 'charging', charging: { sockets: [{ type: 'type2', kw: 150 }] } }, + { osm_id: 'b', name: 'Slow', lat: 48, lng: 10.07, category: 'charging', charging: { sockets: [{ type: 'type2', kw: 11 }] } }, + { osm_id: 'c', name: 'Unknown', lat: 48, lng: 10.05, category: 'charging', charging: { sockets: [{ type: 'ccs', kw: null }] } }, + { osm_id: 'd', name: 'Far', lat: 50, lng: 12, category: 'charging' }, + ], sources: ['osm'], failedSources: ['plugin'], truncated: false, clamped: false })) }; + return { plan, maps, mcp: new RoadtripPlanningMcp({ calculate: async () => plan } as never, maps as never, {} as never) }; + } + it('refuses failed routes, absent days and reversed search ranges', async () => { + const s = tool(); + s.plan.failures.push({}); + expect((await s.mcp.corridor(input, ctx)).isError).toBe(true); + s.plan.failures = []; + s.plan.omittedVisits = [1]; + expect((await s.mcp.corridor(input, ctx)).isError).toBe(true); + s.plan.omittedVisits = []; + s.plan.calculated.dayWindowIssue = 'conflict'; + expect((await s.mcp.corridor(input, ctx)).isError).toBe(true); + s.plan.calculated.dayWindowIssue = null; + expect((await s.mcp.corridor({ ...input, fromKm: 8, toKm: 2 }, ctx)).isError).toBe(true); + expect((await s.mcp.corridor({ ...input, dayNumber: 2 }, ctx)).isError).toBe(true); + expect(s.maps.search).not.toHaveBeenCalled(); + }); + it('filters sockets, power, name and along-route bounds without hiding source failures', async () => { + const s = tool(); + const read = async (over: Partial[0]>) => JSON.parse((await s.mcp.corridor({ ...input, ...over }, ctx)).content[0].text as string); + expect((await read({ socket: 'type2', minKw: 100 })).hits.map((h: { poi: { name: string } }) => h.poi.name)).toEqual(['Fast']); + expect((await read({ name: 'unknown' })).hits).toHaveLength(1); + expect((await read({ fromKm: 100 })).hits).toHaveLength(0); + expect((await read({ toKm: 0 })).hits).toHaveLength(0); + expect((await read({})).failedSources).toEqual(['plugin']); + }); +}); +it('reads the saved roadtrip context without routing', async () => { + const s = setup(); + const tool = new RoadtripPlanningMcp(s.plans, {} as never, {} as never); + expect(JSON.stringify(await tool.context({ tripId: 10 }, ctx))).toContain('Stop 1'); + expect(s.router.route).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/server/tests/unit/nest/roadtrip-read-tools.test.ts b/server/tests/unit/nest/roadtrip-read-tools.test.ts new file mode 100644 index 000000000..cc953d5c9 --- /dev/null +++ b/server/tests/unit/nest/roadtrip-read-tools.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { McpContext } from '../../../src/nest-mcp'; +import { ChargingMcp } from '../../../src/nest/roadtrip/charging.mcp'; +import { RoadtripHazardsMcp } from '../../../src/nest/roadtrip/roadtrip-hazards.mcp'; +import { GoogleRouteMcp } from '../../../src/nest/roadtrip/google-route.mcp'; +import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp'; + +const ctx = { userId: 5 } as McpContext; +describe('roadtrip read tools', () => { + it('checks trip access before querying charging and hazards', async () => { + const db = { canAccessTrip: vi.fn(() => false) }; + const source = { read: vi.fn(async () => ({ sources: ['public'] })) }; + const charging = new ChargingMcp(source as never, db as never, {} as never); + const hazards = new RoadtripHazardsMcp(source as never, db as never, {} as never); + expect((await charging.read({ tripId: 10, placeId: 2 }, ctx)).isError).toBe(true); + expect((await hazards.read({ tripId: 10 }, ctx)).isError).toBe(true); + expect(source.read).not.toHaveBeenCalled(); + db.canAccessTrip.mockReturnValue(true); + expect(JSON.stringify(await charging.read({ tripId: 10, placeId: 2 }, ctx))).toContain('public'); + expect(source.read).toHaveBeenCalledWith(10, 2); + expect(JSON.stringify(await hazards.read({ tripId: 10 }, ctx))).toContain('public'); + }); + it('passes reviewed Google stops and the authenticated user to the importer', async () => { + const routes = { preview: vi.fn(async () => ({ stops: [] })), import: vi.fn(() => ({ imported: 2 })) }; + const tool = new GoogleRouteMcp(routes as never, {} as never); + await tool.preview({ url: 'https://www.google.com/maps/dir/A/B' }); + expect(routes.preview).toHaveBeenCalledWith('https://www.google.com/maps/dir/A/B'); + const input = { tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] }; + expect(JSON.stringify(tool.import(input, ctx))).toContain('2'); + expect(routes.import).toHaveBeenCalledWith(10, 5, input); + }); + it('does not expose manual boundaries to nonmembers', async () => { + const boundaries = { list: vi.fn(() => [{ day_number: 1 }]) }; + const db = { canAccessTrip: vi.fn(() => false) }; + const tool = new DayBoundariesMcp(boundaries as never, db as never, {} as never, {} as never, {} as never, {} as never); + expect((await tool.list({ tripId: 10 }, ctx)).isError).toBe(true); + expect(boundaries.list).not.toHaveBeenCalled(); + db.canAccessTrip.mockReturnValue(true); + expect(JSON.stringify(await tool.list({ tripId: 10 }, ctx))).toContain('day_number'); + expect(boundaries.list).toHaveBeenCalledWith(10); + }); +}); \ No newline at end of file