From 191b02b6de2a17d4b88bb5b76050ffca9c5f034a Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 21:13:58 +0000 Subject: [PATCH] test(maps): the place pin and nominatim bias cases get suites of their own --- .../unit/nest/maps.nominatim-bias.test.ts | 104 ++++++++++++++ server/tests/unit/nest/maps.place-pin.test.ts | 135 ++++++++++++++++++ server/tests/unit/nest/maps.service.test.ts | 129 ----------------- 3 files changed, 239 insertions(+), 129 deletions(-) create mode 100644 server/tests/unit/nest/maps.nominatim-bias.test.ts create mode 100644 server/tests/unit/nest/maps.place-pin.test.ts diff --git a/server/tests/unit/nest/maps.nominatim-bias.test.ts b/server/tests/unit/nest/maps.nominatim-bias.test.ts new file mode 100644 index 000000000..f753568ad --- /dev/null +++ b/server/tests/unit/nest/maps.nominatim-bias.test.ts @@ -0,0 +1,104 @@ +/** + * MAPS-038b, MAPS-081b, MAPS-081c: the Nominatim fallback keeps the location bias (#2620). + * + * With the TREK Places index off and no key, search and autocomplete fall back to + * Nominatim, and the point or box the client sent has to reach it as a viewbox. + * Moved out of maps.service.test.ts as they were, under the describe names they + * had there. + */ +import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; +import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; +import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; +import { noGoogleQuota } from '../../helpers/google-quota'; +import { buildMapsService } from '../../helpers/maps-service'; + +import { describe, it, expect, vi, afterEach } from 'vitest'; + +// Every case below switches the index off. It is stubbed to "nothing found" as +// well, so a case that reached it anyway would not leave the runner for +// places.liketrek.com. +const { mockNearby, mockSearch } = vi.hoisted(() => ({ + mockNearby: vi.fn(async (): Promise => []), + mockSearch: vi.fn(async (): Promise => []), +})); +vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({ + ...(await importOriginal()), + trekPlacesNearby: mockNearby, + trekPlacesSearch: mockSearch, +})); + +// No key is configured, so neither Google nor Amap answers and the query falls +// through to Nominatim. +const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository; +const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository; + +const svc = buildMapsService( + {} as PlacePhotoCacheService, + noAppSettings, + noUsers, + {} as never, + {} as never, + noGoogleQuota, +); + +/** Switch the TREK Places index off for one case, as maps.service.test.ts does. */ +let indexSpy: { mockRestore: () => void } | null = null; +function indexOff(): void { + indexSpy = vi.spyOn(svc, 'trekPlacesEnabled').mockReturnValue(false); +} + +afterEach(() => { + indexSpy?.mockRestore(); + indexSpy = null; + vi.unstubAllGlobals(); +}); + +describe('searchPlaces (fetch stubbed)', () => { + // The same gap on the explicit search: with the index off and no key the + // fallback asked Nominatim again and dropped the bias it had been given (#2620). + it('MAPS-038b: the Nominatim fallback keeps the location bias', async () => { + indexOff(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); + vi.stubGlobal('fetch', fetchMock); + + await svc.searchPlaces(999, 'Starbucks', 'en', { lat: 35, lng: 135.75 }); + + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.searchParams.get('viewbox')).toBe('135.25,34.5,136.25,35.5'); + expect(url.searchParams.get('bounded')).toBe('0'); + }); +}); + +describe('autocompletePlaces (fetch stubbed)', () => { + // Without the index and without a key the suggestions come from Nominatim, + // and the box the client already computed has to reach it (#2620). It ranks, + // it does not restrict: bounded=0, so a far away place still comes back. + it('MAPS-081b: hands the location bias to Nominatim as a viewbox', async () => { + indexOff(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); + vi.stubGlobal('fetch', fetchMock); + + await svc.autocompletePlaces(999, 'Starbucks', 'en', { + low: { lat: 34.9, lng: 135.6 }, + high: { lat: 35.1, lng: 135.9 }, + }); + + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.pathname).toContain('/search'); + expect(url.searchParams.get('bounded')).toBe('0'); + const [west, south, east, north] = String(url.searchParams.get('viewbox')).split(',').map(Number); + expect((west + east) / 2).toBeCloseTo(135.75); + expect((south + north) / 2).toBeCloseTo(35); + }); + + it('MAPS-081c: asks Nominatim without a viewbox when no bias was sent', async () => { + indexOff(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); + vi.stubGlobal('fetch', fetchMock); + + await svc.autocompletePlaces(999, 'Starbucks', 'en'); + + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.searchParams.has('viewbox')).toBe(false); + }); +}); diff --git a/server/tests/unit/nest/maps.place-pin.test.ts b/server/tests/unit/nest/maps.place-pin.test.ts new file mode 100644 index 000000000..179cd0b7d --- /dev/null +++ b/server/tests/unit/nest/maps.place-pin.test.ts @@ -0,0 +1,135 @@ +/** + * MAPS-2635-001..007: a pasted Google place link pins the place, not the camera. + * + * Moved out of maps.service.test.ts as they were. The other resolveGoogleMapsUrl + * cases (coordinate extraction, short links, the SSRF guard, the ReDoS budgets) + * stay there. + */ +import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; +import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; +import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; +import { noGoogleQuota } from '../../helpers/google-quota'; +import { buildMapsService } from '../../helpers/maps-service'; + +import { describe, it, expect, vi, afterEach } from 'vitest'; + +// Short links and cid pages are followed hop by hop through the SSRF guard. Here +// every hop is let through to the stubbed fetch, which already answers with the +// resolved response. +vi.mock('../../../src/utils/ssrfGuard', () => { + class SsrfBlockedError extends Error {} + return { + SsrfBlockedError, + checkSsrf: vi.fn(async () => ({ allowed: true })), + safeFetchFollow: vi.fn(async (url: string, init?: RequestInit) => globalThis.fetch(url, init)), + }; +}); + +// No key is configured: a Google link is resolved without one. +const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository; +const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository; + +const svc = buildMapsService( + {} as PlacePhotoCacheService, + noAppSettings, + noUsers, + {} as never, + {} as never, + noGoogleQuota, +); + +// Nominatim stub used by resolveGoogleMapsUrl after coordinate extraction +const nominatimStub = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ display_name: 'Paris, France', name: null, address: {} }), +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// A Google place URL carries two points: @lat,lng is where the camera looks, +// !3d!4d is the place itself. Pinning the camera lands next to the place (#2635). +describe('resolveGoogleMapsUrl place pin before camera (#2635)', () => { + // What https://maps.app.goo.gl/CdynKSHT16bufnAp9 redirects to. + const EIFFEL_SHARE_TARGET = + 'https://www.google.com/maps/place/Tour+Eiffel/@48.8579431,2.292255,812m/data=!3m1!1e3!4m7!3m6!1s0x47e66e2964e34e2d:0x8ddca9ee380ef7e0!8m2!3d48.8583701!4d2.2944813!15sChFtYXBzIGVpZmZlbCB0b3dlcloTIhFtYXBzIGVpZmZlbCB0b3dlcpIBE2hpc3RvcmljYWxfbGFuZG1hcmuaAURDaTlEUVVsUlFVTnZaRU5vZEhsalJqbHZUMnR3VGxkWGRFaFBWM2gyVFd3NWVtTnVTalJNVlU1RVUxWm9ORTlWUlJBQuABAPoBBAgAEDg!16zL20vMDJqODE?entry=tts&g_ep=EgoyMDI2MTAwNy4wIPu8ASoASAFQAw%3D%3D&skid=20b521f5-0f45-4a02-8475-b2a1c2bdcb0b'; + + const nominatimAt = (calls: string[]) => + vi.fn(async (u: string) => { + calls.push(u); + if (u.includes('goo.gl')) return { url: EIFFEL_SHARE_TARGET }; + return { + ok: true, + json: async () => ({ display_name: 'Avenue Gustave Eiffel, Paris', name: null, address: {} }), + }; + }); + + it('MAPS-2635-001: a place URL with both @ and !3d!4d resolves to the place, not the camera', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', nominatimAt(calls)); + const result = await svc.resolveGoogleMapsUrl(EIFFEL_SHARE_TARGET); + expect(result.lat).toBe(48.8583701); + expect(result.lng).toBe(2.2944813); + expect(result.name).toBe('Tour Eiffel'); + expect(result.google_ftid).toBe('0x47e66e2964e34e2d:0x8ddca9ee380ef7e0'); + // The address is looked up at the place too. + const reverse = calls.find((u) => u.includes('nominatim')); + expect(reverse).toContain('lat=48.8583701'); + expect(reverse).toContain('lon=2.2944813'); + }); + + it('MAPS-2635-002: the share short link resolves to the place pin', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', nominatimAt(calls)); + const result = await svc.resolveGoogleMapsUrl('https://maps.app.goo.gl/CdynKSHT16bufnAp9'); + expect(result.lat).toBe(48.8583701); + expect(result.lng).toBe(2.2944813); + }); + + it('MAPS-2635-003: only !3d!4d still resolves', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl( + 'https://www.google.com/maps/place/Tour+Eiffel/data=!4m7!3m6!8m2!3d48.8583701!4d2.2944813', + ); + expect(result.lat).toBe(48.8583701); + expect(result.lng).toBe(2.2944813); + }); + + it('MAPS-2635-004: only @ still resolves to the camera point', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.8579431,2.292255,812m'); + expect(result.lat).toBe(48.8579431); + expect(result.lng).toBe(2.292255); + }); + + it('MAPS-2635-005: only ?q= still resolves', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8583701,2.2944813'); + expect(result.lat).toBe(48.8583701); + expect(result.lng).toBe(2.2944813); + }); + + it('MAPS-2635-006: @ keeps its precedence over ?q= when no place pin is present', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.85,2.29,15z?q=40.6892,-74.0445'); + expect(result.lat).toBe(48.85); + expect(result.lng).toBe(2.29); + }); + + it('MAPS-2635-007: a page body with both @ and !3d!4d resolves to the place', async () => { + const fetchMock = vi.fn(async (u: string) => { + if (u.includes('nominatim')) { + return { ok: true, json: async () => ({ display_name: 'Paris', name: null, address: {} }) }; + } + return { + url: 'https://www.google.com/maps/place/Somewhere', + text: async () => 'x/@48.8579431,2.292255,812m/y!3d48.8583701!4d2.2944813z', + }; + }); + vi.stubGlobal('fetch', fetchMock); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999'); + expect(result.lat).toBe(48.8583701); + expect(result.lng).toBe(2.2944813); + }); +}); diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index 45c86a122..15056b44a 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -865,89 +865,6 @@ describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => { }); }); -// A Google place URL carries two points: @lat,lng is where the camera looks, -// !3d!4d is the place itself. Pinning the camera lands next to the place (#2635). -describe('resolveGoogleMapsUrl place pin before camera (#2635)', () => { - // What https://maps.app.goo.gl/CdynKSHT16bufnAp9 redirects to. - const EIFFEL_SHARE_TARGET = - 'https://www.google.com/maps/place/Tour+Eiffel/@48.8579431,2.292255,812m/data=!3m1!1e3!4m7!3m6!1s0x47e66e2964e34e2d:0x8ddca9ee380ef7e0!8m2!3d48.8583701!4d2.2944813!15sChFtYXBzIGVpZmZlbCB0b3dlcloTIhFtYXBzIGVpZmZlbCB0b3dlcpIBE2hpc3RvcmljYWxfbGFuZG1hcmuaAURDaTlEUVVsUlFVTnZaRU5vZEhsalJqbHZUMnR3VGxkWGRFaFBWM2gyVFd3NWVtTnVTalJNVlU1RVUxWm9ORTlWUlJBQuABAPoBBAgAEDg!16zL20vMDJqODE?entry=tts&g_ep=EgoyMDI2MTAwNy4wIPu8ASoASAFQAw%3D%3D&skid=20b521f5-0f45-4a02-8475-b2a1c2bdcb0b'; - - const nominatimAt = (calls: string[]) => - vi.fn(async (u: string) => { - calls.push(u); - if (u.includes('goo.gl')) return { url: EIFFEL_SHARE_TARGET }; - return { ok: true, json: async () => ({ display_name: 'Avenue Gustave Eiffel, Paris', name: null, address: {} }) }; - }); - - it('MAPS-2635-001: a place URL with both @ and !3d!4d resolves to the place, not the camera', async () => { - const calls: string[] = []; - vi.stubGlobal('fetch', nominatimAt(calls)); - const result = await svc.resolveGoogleMapsUrl(EIFFEL_SHARE_TARGET); - expect(result.lat).toBe(48.8583701); - expect(result.lng).toBe(2.2944813); - expect(result.name).toBe('Tour Eiffel'); - expect(result.google_ftid).toBe('0x47e66e2964e34e2d:0x8ddca9ee380ef7e0'); - // The address is looked up at the place too. - const reverse = calls.find((u) => u.includes('nominatim')); - expect(reverse).toContain('lat=48.8583701'); - expect(reverse).toContain('lon=2.2944813'); - }); - - it('MAPS-2635-002: the share short link resolves to the place pin', async () => { - const calls: string[] = []; - vi.stubGlobal('fetch', nominatimAt(calls)); - const result = await svc.resolveGoogleMapsUrl('https://maps.app.goo.gl/CdynKSHT16bufnAp9'); - expect(result.lat).toBe(48.8583701); - expect(result.lng).toBe(2.2944813); - }); - - it('MAPS-2635-003: only !3d!4d still resolves', async () => { - vi.stubGlobal('fetch', nominatimStub); - const result = await svc.resolveGoogleMapsUrl( - 'https://www.google.com/maps/place/Tour+Eiffel/data=!4m7!3m6!8m2!3d48.8583701!4d2.2944813', - ); - expect(result.lat).toBe(48.8583701); - expect(result.lng).toBe(2.2944813); - }); - - it('MAPS-2635-004: only @ still resolves to the camera point', async () => { - vi.stubGlobal('fetch', nominatimStub); - const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.8579431,2.292255,812m'); - expect(result.lat).toBe(48.8579431); - expect(result.lng).toBe(2.292255); - }); - - it('MAPS-2635-005: only ?q= still resolves', async () => { - vi.stubGlobal('fetch', nominatimStub); - const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8583701,2.2944813'); - expect(result.lat).toBe(48.8583701); - expect(result.lng).toBe(2.2944813); - }); - - it('MAPS-2635-006: @ keeps its precedence over ?q= when no place pin is present', async () => { - vi.stubGlobal('fetch', nominatimStub); - const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.85,2.29,15z?q=40.6892,-74.0445'); - expect(result.lat).toBe(48.85); - expect(result.lng).toBe(2.29); - }); - - it('MAPS-2635-007: a page body with both @ and !3d!4d resolves to the place', async () => { - const fetchMock = vi.fn(async (u: string) => { - if (u.includes('nominatim')) { - return { ok: true, json: async () => ({ display_name: 'Paris', name: null, address: {} }) }; - } - return { - url: 'https://www.google.com/maps/place/Somewhere', - text: async () => 'x/@48.8579431,2.292255,812m/y!3d48.8583701!4d2.2944813z', - }; - }); - vi.stubGlobal('fetch', fetchMock); - const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999'); - expect(result.lat).toBe(48.8583701); - expect(result.lng).toBe(2.2944813); - }); -}); - // ── searchNominatim (fetch-dependent) ──────────────────────────────────────── describe('searchNominatim (fetch stubbed)', () => { @@ -1413,20 +1330,6 @@ describe('searchPlaces (fetch stubbed)', () => { expect(Array.isArray(result.places)).toBe(true); }); - // The same gap on the explicit search: with the index off and no key the - // fallback asked Nominatim again and dropped the bias it had been given (#2620). - it('MAPS-038b: the Nominatim fallback keeps the location bias', async () => { - indexOff(); - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); - vi.stubGlobal('fetch', fetchMock); - - await svc.searchPlaces(999, 'Starbucks', 'en', { lat: 35, lng: 135.75 }); - - const url = new URL(String(fetchMock.mock.calls[0][0])); - expect(url.searchParams.get('viewbox')).toBe('135.25,34.5,136.25,35.5'); - expect(url.searchParams.get('bounded')).toBe('0'); - }); - // Session tokens: the keystrokes of one search and the details lookup that // ends it must reach Google under the same token, or every request is billed // on its own. The body field and the query parameter are what Google's @@ -1639,38 +1542,6 @@ describe('autocompletePlaces (fetch stubbed)', () => { expect(result.suggestions[0].placeId).toBe('node:1'); }); - // Without the index and without a key the suggestions come from Nominatim, - // and the box the client already computed has to reach it (#2620). It ranks, - // it does not restrict: bounded=0, so a far away place still comes back. - it('MAPS-081b: hands the location bias to Nominatim as a viewbox', async () => { - indexOff(); - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); - vi.stubGlobal('fetch', fetchMock); - - await svc.autocompletePlaces(999, 'Starbucks', 'en', { - low: { lat: 34.9, lng: 135.6 }, - high: { lat: 35.1, lng: 135.9 }, - }); - - const url = new URL(String(fetchMock.mock.calls[0][0])); - expect(url.pathname).toContain('/search'); - expect(url.searchParams.get('bounded')).toBe('0'); - const [west, south, east, north] = String(url.searchParams.get('viewbox')).split(',').map(Number); - expect((west + east) / 2).toBeCloseTo(135.75); - expect((south + north) / 2).toBeCloseTo(35); - }); - - it('MAPS-081c: asks Nominatim without a viewbox when no bias was sent', async () => { - indexOff(); - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); - vi.stubGlobal('fetch', fetchMock); - - await svc.autocompletePlaces(999, 'Starbucks', 'en'); - - const url = new URL(String(fetchMock.mock.calls[0][0])); - expect(url.searchParams.has('viewbox')).toBe(false); - }); - it('MAPS-082: uses Google when user has an API key', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null); vi.stubGlobal(