Files
TREK/server/tests/unit/nest/place-import/google-list.provider.test.ts

233 lines
10 KiB
TypeScript

/**
* The Google list source on its own: the feature-id parsing (moved from
* places.helpers.test.ts with its code) and the answers the import route
* gets back from it.
*/
import {
GoogleListProvider,
googleMapsFeatureIdFromItem,
googleMapsHexId,
isGoogleListDataUrl,
} from '../../../../src/nest/place-import/providers/google-list.provider';
import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard';
import { describe, it, expect, vi, afterEach } from 'vitest';
const { mockCheckSsrf, mockFollow } = vi.hoisted(() => ({
mockCheckSsrf: vi.fn(async (_url: string) => ({ allowed: true })),
mockFollow: vi.fn(),
}));
vi.mock('../../../../src/utils/ssrfGuard', () => ({
checkSsrf: mockCheckSsrf,
safeFetchFollow: mockFollow,
SsrfBlockedError: class SsrfBlockedError extends Error {},
}));
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
mockCheckSsrf.mockReset();
mockCheckSsrf.mockResolvedValue({ allowed: true });
mockFollow.mockReset();
});
// ── Google Maps feature ids ───────────────────────────────────────────────────
describe('googleMapsHexId / googleMapsFeatureIdFromItem', () => {
it('passes an already-hex id through lower-cased', () => {
expect(googleMapsHexId('0x882BF179E806D471')).toBe('0x882bf179e806d471');
});
it("converts Google's signed 64-bit decimals to unsigned hex", () => {
expect(googleMapsHexId('-8634542354666695567')).toBe('0x882bf179e806d471');
expect(googleMapsHexId(255)).toBe('0xff');
});
it('rejects anything that is not a hex or decimal id', () => {
expect(googleMapsHexId('not-an-id')).toBeNull();
expect(googleMapsHexId(null)).toBeNull();
expect(googleMapsHexId({})).toBeNull();
});
it('reads the ftid pair from either item slot, else null', () => {
expect(googleMapsFeatureIdFromItem([null, [0, 0, 0, 0, 0, 0, ['0x1', '0x2']]])).toBe('0x1:0x2');
expect(googleMapsFeatureIdFromItem([null, null, null, null, null, null, null, [null, ['0x3', '0x4']]])).toBe(
'0x3:0x4',
);
expect(googleMapsFeatureIdFromItem([null, [0, 0, 0, 0, 0, 0, ['0x1']]])).toBeNull();
expect(googleMapsFeatureIdFromItem('not an array')).toBeNull();
});
});
describe('GoogleListProvider.read', () => {
it('fetches list data from a URL beginning with the configured base', async () => {
const url = 'http://list-source.example.test/maps/dir/fixture?other=2';
vi.stubEnv('GOOGLE_LIST_DATA_BASE', 'http://list-source.example.test');
const body = `)]}'\n${JSON.stringify([
[
null,
null,
null,
null,
'Fixture list',
null,
null,
null,
[[null, [null, null, null, null, null, [null, null, 12.34, 56.78]], 'Sample place', '']],
],
])}`;
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => new Response(body));
vi.stubGlobal('fetch', fetchMock);
expect(isGoogleListDataUrl(url)).toBe(true);
expect(await new GoogleListProvider().read(url)).toEqual({
listName: 'Fixture list',
places: [{ name: 'Sample place', lat: 12.34, lng: 56.78, notes: null, googleFtid: null }],
});
expect(fetchMock).toHaveBeenCalledWith(url, expect.objectContaining({ redirect: 'error' }));
expect(mockCheckSsrf).not.toHaveBeenCalled();
expect(mockFollow).not.toHaveBeenCalled();
});
it('matches the literal base prefix but rejects lookalike origins', () => {
const url = 'http://list-source.example.test/fixture';
vi.stubEnv('GOOGLE_LIST_DATA_BASE', '');
expect(isGoogleListDataUrl(url)).toBe(false);
vi.stubEnv('GOOGLE_LIST_DATA_BASE', 'http://list-source.example.test');
expect(isGoogleListDataUrl(url)).toBe(true);
expect(isGoogleListDataUrl('http://list-source.example.test/other?id=another')).toBe(true);
expect(isGoogleListDataUrl('https://list-source.example.test/other')).toBe(false);
expect(isGoogleListDataUrl('http://list-source.example.test:8787/fixture')).toBe(false);
expect(isGoogleListDataUrl('http://list-source.example.test.evil.test/fixture')).toBe(false);
expect(isGoogleListDataUrl('http://list-source.example.test@127.0.0.1/fixture')).toBe(false);
vi.stubEnv('GOOGLE_LIST_DATA_BASE', 'http://list-source.example.test/list');
expect(isGoogleListDataUrl('http://list-source.example.test/list?id=fixture')).toBe(true);
expect(isGoogleListDataUrl('http://list-source.example.test/other?id=fixture')).toBe(false);
});
it('keeps the original Google-link flow when the base does not match', async () => {
vi.stubEnv('GOOGLE_LIST_DATA_BASE', 'http://other.example.test');
const url = 'http://list-source.example.test/fixture';
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
expect(await new GoogleListProvider().read(url)).toEqual({
error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.',
status: 400,
});
expect(mockCheckSsrf).toHaveBeenCalledWith(url);
expect(fetchMock).not.toHaveBeenCalled();
});
it('reports an invalid configured response without falling back to Google', async () => {
const url = 'http://list-source.example.test/data';
vi.stubEnv('GOOGLE_LIST_DATA_BASE', 'http://list-source.example.test');
const fetchMock = vi.fn(async (_url: string) => new Response('not list data'));
vi.stubGlobal('fetch', fetchMock);
expect(await new GoogleListProvider().read(url)).toEqual({
error: 'Invalid list data received from Google Maps',
status: 400,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe(url);
});
it('refuses a URL the SSRF guard blocks, before fetching anything', async () => {
mockCheckSsrf.mockResolvedValueOnce({ allowed: false });
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
expect(await new GoogleListProvider().read('https://10.0.0.1/maps')).toEqual({
error: 'URL is not allowed',
status: 400,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('hands a short link that lands on a route back as a directions link', async () => {
mockFollow.mockResolvedValueOnce({ url: 'https://www.google.com/maps/dir/Berlin/Dresden/' });
expect(await new GoogleListProvider().read('https://maps.app.goo.gl/abc')).toEqual({
directions: 'https://www.google.com/maps/dir/Berlin/Dresden/',
});
});
it('points a single-place link at the search box', async () => {
const out = await new GoogleListProvider().read('https://www.google.com/maps/place/Berlin');
expect(out).toMatchObject({ status: 400 });
expect((out as { error: string }).error).toContain('single place, not a list');
});
it('reads the list name and the places that carry coordinates', async () => {
const items = [
[null, [null, null, null, null, null, [null, null, 52.5, 13.4], ['0x1', '0x2']], 'Cafe', 'note'],
[null, [null, null, null, null, null, [null, null, null, null]], 'No coords'],
];
const body = `)]}'\n${JSON.stringify([[null, null, null, null, 'Trip ideas', null, null, null, items]])}`;
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(body, { status: 200 })),
);
expect(await new GoogleListProvider().read('https://www.google.com/maps/placelists/list/ABCDEFGHIJKLMNOP')).toEqual(
{
listName: 'Trip ideas',
places: [{ name: 'Cafe', lat: 52.5, lng: 13.4, notes: 'note', googleFtid: '0x1:0x2' }],
},
);
});
});
describe('GoogleListProvider.read: what Google sends back', () => {
const LIST = 'https://www.google.com/maps/@52,13,10z/data=!4m3!11m2!2sABCDEFGHIJKLMNOPQRS!3e3';
const respond = (body: string, init: ResponseInit = { status: 200 }) =>
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(body, init)),
);
const read = () => new GoogleListProvider().read(LIST);
it('finds the list id in the data parameter too', async () => {
const fetchMock = vi.fn(async () => new Response('x', { status: 500 }));
vi.stubGlobal('fetch', fetchMock);
await read();
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toContain('!1sABCDEFGHIJKLMNOPQRS');
});
it('answers a failed or oversized fetch with the provider message', async () => {
respond('x', { status: 500 });
expect(await read()).toEqual({ error: 'Failed to fetch list from Google Maps', status: 502 });
respond('x', { status: 200, headers: { 'content-length': String(9 * 1024 * 1024) } });
expect(await read()).toEqual({ error: 'Failed to fetch list from Google Maps', status: 502 });
});
it('answers an unreadable payload with the invalid-data message', async () => {
respond('prefix\nnot json');
expect(await read()).toEqual({ error: 'Invalid list data received from Google Maps', status: 400 });
respond('prefix\n{"a":1}');
expect(await read()).toEqual({ error: 'Invalid list data received from Google Maps', status: 400 });
respond('prefix\n[]');
expect(await read()).toEqual({ error: 'Invalid list data received from Google Maps', status: 400 });
});
it('refuses an empty list and one without a single coordinate', async () => {
respond('prefix\n[[null,null,null,null,"L",null,null,null,[]]]');
expect(await read()).toEqual({ error: 'List is empty or could not be read', status: 400 });
respond('prefix\n[[null,null,null,null,null,null,null,null,[[null,[],"Nowhere"]]]]');
expect(await read()).toEqual({ error: 'No places with coordinates found in list', status: 400 });
});
it('refuses a short link the guard blocks on a hop, and lets a network failure through', async () => {
mockFollow.mockRejectedValueOnce(new SsrfBlockedError('blocked'));
expect(await new GoogleListProvider().read('https://maps.app.goo.gl/x')).toEqual({
error: 'URL is not allowed',
status: 400,
});
mockFollow.mockRejectedValueOnce(new Error('network'));
await expect(new GoogleListProvider().read('https://maps.app.goo.gl/x')).rejects.toThrow('network');
});
it('needs a list id', async () => {
expect(await new GoogleListProvider().read('https://www.google.com/maps/@52,13,10z')).toEqual({
error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.',
status: 400,
});
});
});