MotorSignal-App/website/tests/ride-telemetry-api.test.ts

59 lines
3.9 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { createHash } from 'node:crypto';
import { zipSync, strToU8 } from 'fflate';
const mocks = vi.hoisted(() => ({ verify: vi.fn(), get: vi.fn(), doc: vi.fn() }));
vi.mock('../lib/firebase-admin', () => ({ getAdminAuth: () => ({ verifyIdToken: mocks.verify }), getAdminFirestore: () => ({ doc: mocks.doc }) }));
import { GET } from '../app/api/account/rides/[rideId]/telemetry/route';
const rideId = 'ride_sanitized';
const gpx = '<gpx><trk><trkseg><trkpt lat="40" lon="-74"><time>2026-09-03T20:00:00Z</time></trkpt><trkpt lat="40.001" lon="-73.999"><time>2026-09-03T20:00:01Z</time></trkpt></trkseg></trk></gpx>';
const bundle = zipSync({ 'telemetry.gpx': strToU8(gpx), 'ride_summary.json': strToU8('{}') });
const digest = createHash('sha256').update(bundle).digest('hex');
const call = (authorization?: string) => GET(new NextRequest(`https://motorsignal.app/api/account/rides/${rideId}/telemetry`, {headers: authorization ? {authorization} : {}}), {params: Promise.resolve({rideId})});
beforeEach(() => {
vi.resetAllMocks();
mocks.verify.mockResolvedValue({uid:'sanitized-owner'});
mocks.doc.mockReturnValue({get:mocks.get});
mocks.get.mockResolvedValue({exists:true,data:()=>({id:rideId,ownerId:'sanitized-owner',telemetryBundlePath:`users/sanitized-owner/rides/${rideId}/telemetry.zip`})});
vi.stubGlobal('fetch',vi.fn().mockResolvedValue(new Response(bundle, {status:200,headers:{'x-motorsignal-ride-id':rideId,'x-motorsignal-sha256':digest}})));
});
describe('owned recorded telemetry API', () => {
it('fails closed without authentication before any file/network/Firestore lookup',async()=>{
expect((await call()).status).toBe(401);
expect(mocks.get).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it('rejects invalid tokens and a mismatched owner',async()=>{
mocks.verify.mockRejectedValueOnce(Error('invalid'));
expect((await call('Bearer invalid')).status).toBe(401);
mocks.get.mockResolvedValueOnce({exists:true,data:()=>({ownerId:'another-owner'})});
expect((await call('Bearer valid')).status).toBe(403);
expect(fetch).not.toHaveBeenCalled();
});
it('downloads and verifies the selected R2 bundle and returns its actual GPS',async()=>{
const response = await call('Bearer valid');
expect(response.status).toBe(200);
const body = await response.json();
expect(body.rideId).toBe(rideId);
expect(body.source).toBe('r2-gpx');
expect(body.points.map((p:{latitude:number;longitude:number})=>[p.latitude,p.longitude])).toEqual([[40,-74],[40.001,-73.999]]);
expect(mocks.doc).toHaveBeenCalledWith(`users/sanitized-owner/rides/${rideId}`);
expect(fetch).toHaveBeenCalledWith(expect.stringContaining(`rideId=${rideId}`),expect.objectContaining({cache:'no-store',headers:expect.objectContaining({Authorization:'Bearer valid'})}));
expect(response.headers.get('cache-control')).toContain('no-store');
});
it('rejects a different ride or modified bundle rather than using demo/fallback data',async()=>{
vi.mocked(fetch).mockResolvedValueOnce(new Response(bundle,{headers:{'x-motorsignal-ride-id':'ride_wrong','x-motorsignal-sha256':digest}}));
expect((await call('Bearer valid')).status).toBe(422);
vi.mocked(fetch).mockResolvedValueOnce(new Response(bundle,{headers:{'x-motorsignal-ride-id':rideId,'x-motorsignal-sha256':'0'.repeat(64)}}));
expect((await call('Bearer valid')).status).toBe(422);
});
it('keeps inline telemetry separate from R2 and checks ownership for both',async()=>{
mocks.get.mockResolvedValueOnce({exists:true,data:()=>({id:rideId,ownerId:'sanitized-owner',gpxContent:gpx})});
const response=await call('Bearer valid');
expect(response.status).toBe(200);
expect((await response.json()).source).toBe('inline-gpx');
expect(fetch).not.toHaveBeenCalled();
});
});