diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts index 4e4573e6..caeb2b53 100644 --- a/server/tests/integration/maps.test.ts +++ b/server/tests/integration/maps.test.ts @@ -46,18 +46,24 @@ vi.mock('../../src/config', () => ({ })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -// Default mock: resolveGoogleMapsUrl rejects with 400 (SSRF-like behaviour for -// URLs that look internal); individual tests override with mockResolvedValueOnce. +vi.mock('../../src/services/amap/amap.places', () => ({ + searchAmapPlaces: vi.fn(), + autocompleteAmapPlaces: vi.fn(), + getAmapPlaceDetails: vi.fn(), + getAmapPlaceDetailsExpanded: vi.fn(), + reverseAmapGeocode: vi.fn(), + resolveAmapUri: vi.fn().mockImplementation(() => { + throw Object.assign(new Error('SSRF or invalid URL'), { status: 400 }) + }), + searchAmapPois: vi.fn(), +})); + vi.mock('../../src/services/mapsService', () => ({ searchPlaces: vi.fn(), autocompletePlaces: vi.fn(), getPlaceDetails: vi.fn(), getPlacePhoto: vi.fn(), reverseGeocode: vi.fn(), - resolveGoogleMapsUrl: vi.fn().mockRejectedValue( - Object.assign(new Error('SSRF or invalid URL'), { status: 400 }) - ), - // Imported at module load by the maps stack (pulled in via app.module). buildUserAgent: () => 'TREK-Test-UA', })); @@ -68,6 +74,7 @@ import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import * as mapsService from '../../src/services/mapsService'; +import * as amapPlaces from '../../src/services/amap/amap.places'; let nestApp: INestApplication; let app: Application; @@ -222,15 +229,17 @@ describe('Maps happy paths (mocked service)', () => { it('MAPS-008 — POST /maps/resolve-url returns extracted coordinates', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.resolveGoogleMapsUrl).mockResolvedValueOnce({ + vi.mocked(amapPlaces.resolveAmapUri).mockReturnValueOnce({ lat: 48.8584, lng: 2.2945, - } as any); + name: null, + address: null, + }); const res = await request(app) .post('/api/maps/resolve-url') .set('Cookie', authCookie(user.id)) - .send({ url: 'https://maps.google.com/place/eiffel-tower' }); + .send({ url: 'https://uri.amap.com/marker?position=2.2945,48.8584' }); expect(res.status).toBe(200); expect(res.body.lat).toBe(48.8584); diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index b8c56e05..922cdf04 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -135,43 +135,6 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou }); }); - describe('POST /import/google-list + naver-list', () => { - it('400 without a url', async () => { - expect(await thrownAsync(() => new PlacesController(svc()).importGoogle(user, '5', undefined))).toEqual({ status: 400, body: { error: 'URL is required' } }); - }); - it('400 when url is the wrong type (not a string)', async () => { - expect(await thrownAsync(() => new PlacesController(svc()).importNaver(user, '5', 123))).toEqual({ status: 400, body: { error: 'URL is required' } }); - }); - it('maps a service { error, status } to the same response', async () => { - const s = svc({ importGoogleList: vi.fn().mockResolvedValue({ error: 'List is empty', status: 400 }) } as Partial); - expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({ status: 400, body: { error: 'List is empty' } }); - }); - it('imports a naver list and returns the count + listName', async () => { - const s = svc({ importNaverList: vi.fn().mockResolvedValue({ places: [{ id: 1 }], listName: 'Trip', skipped: 2 }), broadcast: vi.fn() } as Partial); - expect(await new PlacesController(s).importNaver(user, '5', 'http://x')).toEqual({ places: [{ id: 1 }], count: 1, listName: 'Trip', skipped: 2 }); - }); - it('forwards the enrich flag + userId and broadcasts each imported place', async () => { - const importGoogleList = vi.fn().mockResolvedValue({ places: [{ id: 1 }, { id: 2 }], listName: 'L', skipped: 0 }); - const broadcast = vi.fn(); - const s = svc({ importGoogleList, broadcast } as Partial); - expect(await new PlacesController(s).importGoogle(user, '5', 'http://x', 'true', 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, listName: 'L', skipped: 0 }); - expect(importGoogleList).toHaveBeenCalledWith('5', 'http://x', { enrich: true, userId: 1 }); - expect(broadcast).toHaveBeenCalledTimes(2); - }); - it('wraps a thrown Error in the provider-specific 400 (Google)', async () => { - const s = svc({ importGoogleList: vi.fn().mockRejectedValue(new Error('network down')) } as Partial); - expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({ - status: 400, body: { error: 'Failed to import Google Maps list. Make sure the list is shared publicly.' }, - }); - }); - it('wraps a non-Error rejection in the provider-specific 400 (Naver)', async () => { - const s = svc({ importNaverList: vi.fn().mockRejectedValue('weird') } as Partial); - expect(await thrownAsync(() => new PlacesController(s).importNaver(user, '5', 'http://x'))).toEqual({ - status: 400, body: { error: 'Failed to import Naver Maps list. Make sure the list is shared publicly.' }, - }); - }); - }); - describe('POST /bulk-delete', () => { it('400 when ids is not an array of numbers', () => { expect(thrown(() => new PlacesController(svc()).bulkDelete(user, '5', ['a']))).toEqual({ status: 400, body: { error: 'ids must be an array of numbers' } }); diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index 74902e6d..e47da34b 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -296,159 +296,6 @@ describe('reverseGeocode (fetch stubbed)', () => { expect(result.address).toBe('Somewhere'); }); }); - -// Nominatim stub used by resolveGoogleMapsUrl after coordinate extraction -const nominatimStub = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ display_name: 'Paris, France', name: null, address: {} }), -}); - -// ── resolveGoogleMapsUrl coordinate extraction ──────────────────────────────── - -describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => { - it('MAPS-020: extracts lat/lng from @lat,lng pattern', async () => { - vi.stubGlobal('fetch', nominatimStub); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://www.google.com/maps/@48.8566,2.3522,15z'); - expect(result.lat).toBeCloseTo(48.8566, 3); - expect(result.lng).toBeCloseTo(2.3522, 3); - }); - - it('MAPS-021: extracts lat/lng from !3d!4d data pattern', async () => { - vi.stubGlobal('fetch', nominatimStub); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945'); - expect(result.lat).toBeCloseTo(48.8584, 3); - expect(result.lng).toBeCloseTo(2.2945, 3); - }); - - it('MAPS-022: extracts lat/lng from ?q=lat,lng pattern', async () => { - vi.stubGlobal('fetch', nominatimStub); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8566,2.3522'); - expect(result.lat).toBeCloseTo(48.8566, 3); - expect(result.lng).toBeCloseTo(2.3522, 3); - }); - - it('MAPS-023: extracts place name from /place/ path', async () => { - vi.stubGlobal('fetch', nominatimStub); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,15z'); - expect(result.name).toBe('Eiffel Tower'); - }); - - it('MAPS-CID-001: resolves a cid= URL by following the redirect to a coordinate URL', async () => { - // cid URLs (what get_place_details returns, and Google "Share" links) carry no - // inline coords; the redirect target carries the !3d!4d data param. - const fetchMock = vi.fn(async (u: string) => { - if (u.includes('nominatim')) { - return { ok: true, json: async () => ({ display_name: 'Paris, France', name: 'Eiffel Tower', address: {} }) }; - } - return { url: 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', text: async () => '' }; - }); - vi.stubGlobal('fetch', fetchMock); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://maps.google.com/?cid=1234567890'); - expect(result.lat).toBeCloseTo(48.8584, 3); - expect(result.lng).toBeCloseTo(2.2945, 3); - }); - - it('MAPS-CID-002: falls back to parsing coordinates from the page body', async () => { - const fetchMock = vi.fn(async (u: string) => { - if (u.includes('nominatim')) { - return { ok: true, json: async () => ({ display_name: 'NYC, USA', name: null, address: {} }) }; - } - if (u.includes('cid=')) { - // Redirect target has no inline coords. - return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => '' }; - } - // Body fetch of the resolved URL embeds coords in the map data. - return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => 'x!3d40.6892!4d-74.0445y' }; - }); - vi.stubGlobal('fetch', fetchMock); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://www.google.com/maps?cid=999'); - expect(result.lat).toBeCloseTo(40.6892, 3); - expect(result.lng).toBeCloseTo(-74.0445, 3); - }); - - it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => { - const adversarial = '/@' + '1'.repeat(10000) + '.'; - const start = Date.now(); - adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); - expect(Date.now() - start).toBeLessThan(500); - }); - - it('MAPS-025 (ReDoS): /!3d(-?\\d+\\.?\\d*)!4d/ on adversarial input < 500ms', () => { - const adversarial = '!3d' + '1'.repeat(10000) + '.'; - const start = Date.now(); - adversarial.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/); - expect(Date.now() - start).toBeLessThan(500); - }); - - it('MAPS-026 (ReDoS): /[?&]q=(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => { - const adversarial = '?q=' + '1'.repeat(10000) + '.'; - const start = Date.now(); - adversarial.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/); - expect(Date.now() - start).toBeLessThan(500); - }); - - it('MAPS-027 (ReDoS): /<[^>]+>/ HTML strip on adversarial input < 100ms', () => { - const adversarial = '<' + 'a'.repeat(10000); - const start = Date.now(); - adversarial.replace(/<[^>]+>/g, ''); - expect(Date.now() - start).toBeLessThan(100); - }); - - it('MAPS-028: throws when no coordinates found in URL', async () => { - vi.stubGlobal('fetch', nominatimStub); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - await expect(resolveGoogleMapsUrl('https://www.google.com/maps')).rejects.toThrow(); - }); - - it('MAPS-028b: throws 403 when short URL is blocked by SSRF check', async () => { - mockCheckSsrf.mockResolvedValueOnce({ allowed: false }); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - await expect( - resolveGoogleMapsUrl('https://goo.gl/maps/abc123') - ).rejects.toMatchObject({ status: 403 }); - }); - - it('MAPS-028c: follows redirect for short goo.gl URL and extracts coordinates', async () => { - const redirectFetch = vi.fn() - // First call: the redirect (goo.gl), returns resolved URL in .url - .mockResolvedValueOnce({ - url: 'https://www.google.com/maps/@48.8566,2.3522,15z', - }) - // Second call: the Nominatim reverse geocode - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ display_name: 'Paris, France', name: 'Paris', address: {} }), - }); - vi.stubGlobal('fetch', redirectFetch); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://goo.gl/maps/abc123'); - expect(result.lat).toBeCloseTo(48.8566, 3); - expect(result.lng).toBeCloseTo(2.3522, 3); - }); - - it('MAPS-028d: falls back to nominatim address fields when no placeName in URL', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - display_name: 'Louvre Museum, Paris', - name: null, - address: { tourism: 'Louvre Museum' }, - }), - }); - vi.stubGlobal('fetch', fetchMock); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - // URL with coordinates but no /place/ path segment - const result = await resolveGoogleMapsUrl('https://www.google.com/maps/@48.8606,2.3376,15z'); - expect(result.name).toBe('Louvre Museum'); - }); -}); - // ── searchNominatim (fetch-dependent) ──────────────────────────────────────── describe('searchNominatim (fetch stubbed)', () => { diff --git a/server/tests/unit/services/placeService.test.ts b/server/tests/unit/services/placeService.test.ts index 9ccfcb5f..22e78d51 100644 --- a/server/tests/unit/services/placeService.test.ts +++ b/server/tests/unit/services/placeService.test.ts @@ -55,7 +55,7 @@ import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories'; import path from 'path'; import fs from 'fs'; -import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService'; +import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, searchPlaceImage } from '../../../src/services/placeService'; const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx'); const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); @@ -443,134 +443,6 @@ describe('importGpx', () => { expect(result.places[0].name).toBe('morning-hike'); }); }); - -// ── importGoogleList ────────────────────────────────────────────────────────── - -describe('importGoogleList', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('PLACE-SVC-026 — returns error when list ID cannot be extracted from URL', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const result = await importGoogleList(String(trip.id), 'https://example.com/no-id-here') as any; - expect(result.error).toMatch(/Could not extract list ID/); - expect(result.status).toBe(400); - }); - - it('PLACE-SVC-026b — a single-place link gives a guiding error instead of the generic one (#1304)', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const url = 'https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,17z/data=!3m1'; - const result = await importGoogleList(String(trip.id), url) as any; - expect(result.status).toBe(400); - expect(result.error).toMatch(/single place/i); - }); - - it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => '', status: 502 })); - const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; - const result = await importGoogleList(String(trip.id), url) as any; - expect(result.error).toMatch(/Failed to fetch list/); - expect(result.status).toBe(502); - }); - - it('PLACE-SVC-028 — imports places from a valid Google Maps list response', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - - const listPayload = [ - [null, null, null, null, 'My Test List', null, null, null, [ - [null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null], - [null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'], - ]], - ]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - text: async () => 'prefix\n' + JSON.stringify(listPayload), - })); - - const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; - const result = await importGoogleList(String(trip.id), url) as any; - expect(result.listName).toBe('My Test List'); - expect(result.places).toHaveLength(2); - expect(result.places[0].name).toBe('Paris'); - expect(result.places[1].name).toBe('London'); - }); - - it('PLACE-SVC-028b — stores a Google Maps ftid separately from google_place_id', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - - const listPayload = [ - [null, null, null, null, 'My Test List', null, null, null, [ - [null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"], - ]], - ]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - text: async () => 'prefix\n' + JSON.stringify(listPayload), - })); - - const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; - const result = await importGoogleList(String(trip.id), url) as any; - - expect(result.places).toHaveLength(1); - expect(result.places[0].google_place_id).toBeNull(); - expect(result.places[0].google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); - }); - - it('PLACE-SVC-028c — backfills google_ftid when re-import skips a duplicate', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - const existing = createPlace(testDb, trip.id, { - name: "St. Jacobs Farmers' Market", - lat: 43.5118527, - lng: -80.5542617, - }) as any; - - const listPayload = [ - [null, null, null, null, 'My Test List', null, null, null, [ - [null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"], - ]], - ]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - text: async () => 'prefix\n' + JSON.stringify(listPayload), - })); - - const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; - const result = await importGoogleList(String(trip.id), url) as any; - const row = testDb.prepare('SELECT google_place_id, google_ftid FROM places WHERE id = ?').get(existing.id) as any; - - expect(result.places).toHaveLength(0); - expect(result.skipped).toBe(1); - expect(row.google_place_id).toBeNull(); - expect(row.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); - }); - - it('PLACE-SVC-029 — returns error when list items array is empty', async () => { - const { user } = createUser(testDb); - const trip = createTrip(testDb, user.id); - - const listPayload = [[null, null, null, null, 'Empty List', null, null, null, []]]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - text: async () => 'prefix\n' + JSON.stringify(listPayload), - })); - - const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456'; - const result = await importGoogleList(String(trip.id), url) as any; - expect(result.error).toBeDefined(); - expect(result.status).toBe(400); - }); -}); - -// ── searchPlaceImage ────────────────────────────────────────────────────────── - describe('searchPlaceImage', () => { afterEach(() => { vi.unstubAllGlobals();