import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; import { assertFails, assertSucceeds, initializeTestEnvironment } from '@firebase/rules-unit-testing'; import { deleteDoc, deleteField, doc, getDoc, serverTimestamp, setDoc, updateDoc } from 'firebase/firestore'; import fs from 'node:fs'; const projectId = 'demo-legacy-rules'; const rideId = 'ride_legacy_fixture'; const ridePath = `users/alice/rides/${rideId}`; const legacy = { uid: 'alice', remoteRideId: rideId, distanceMiles: 2 }; const canonical = { ownerId: 'alice', id: rideId, distanceMiles: 2 }; let env; beforeAll(async () => { if (process.env.FIRESTORE_EMULATOR_HOST !== '127.0.0.1:8088') { throw new Error('Requires isolated loopback emulator at 127.0.0.1:8088'); } env = await initializeTestEnvironment({ projectId, firestore: { host: '127.0.0.1', port: 8088, rules: fs.readFileSync(new URL('../../firestore.rules', import.meta.url), 'utf8'), } }); }); afterEach(async () => { await env?.clearFirestore(); }); afterAll(async () => { await env?.cleanup(); }); const owner = () => doc(env.authenticatedContext('alice').firestore(), ridePath); async function seed(data) { await env.withSecurityRulesDisabled(context => setDoc(doc(context.firestore(), ridePath), data)); } // Mirrors FirebaseSyncProvider.uploadRide's full Firestore map and merge operation; // telemetry metadata is a fixture, never an R2 request or proof of an uploaded bundle. function uploadPayload() { const objectKey = `users/alice/rides/${rideId}/telemetry.zip`; return { id: rideId, ownerId: 'alice', localRideId: 42, startTime: '2026-10-12T10:00:00.000Z', endTime: '2026-10-12T10:01:00.000Z', distanceMiles: 1, topSpeedMph: 60, maxLeanLeft: 12, maxLeanRight: 14, maxAccelG: 0.3, maxBrakingG: 0.2, averageSpeed: 30, movingTime: 60, stoppedTime: 0, elevationGain: 5, elevationLoss: 2, qualityScore: 100, qualityRating: 'EXCELLENT', motorcycleId: null, motorcycleMake: null, motorcycleModel: null, motorcycleYear: null, motorcycleVariant: null, hardBrakingEvents: 0, extremeLeanEvents: 0, highSpeedEvents: 0, totalEventsCount: 0, performanceRuns: [{ type: '0-60', timeMs: 5000, topSpeedReached: 60, timestamp: '2026-10-12T10:00:30.000Z' }], telemetryBundle: { provider: 'cloudflare-r2', objectKey, status: 'uploaded', bundleSizeBytes: 128, sha256: 'a'.repeat(64) }, telemetryBundlePath: objectKey, bundleSizeBytes: 128, syncVersion: 3, uploadedAt: serverTimestamp(), updatedAt: serverTimestamp(), software: { semanticVersion: '1.0.0', buildNumber: '1' }, }; } describe('legacy ride compatibility', () => { test('owner gets legacy document', async () => { await seed(legacy); expect((await assertSucceeds(getDoc(owner()))).data()).toEqual(legacy); }); test('owner updates legacy metadata without replacing identity', async () => { await seed(legacy); await assertSucceeds(updateDoc(owner(), { distanceMiles: 3 })); }); test('owner deletes legacy document', async () => { await seed(legacy); await assertSucceeds(deleteDoc(owner())); }); test('full upload merge canonicalizes legacy identity and remains retryable', async () => { await seed(legacy); const payload = uploadPayload(); await assertSucceeds(setDoc(owner(), payload, { merge: true })); await assertSucceeds(setDoc(owner(), payload, { merge: true })); const data = (await getDoc(owner())).data(); expect(data).toMatchObject({ ...legacy, ...canonical, distanceMiles: 1, performanceRuns: payload.performanceRuns, telemetryBundle: payload.telemetryBundle }); expect(data.uploadedAt.toMillis()).toBeGreaterThan(0); await assertSucceeds(deleteDoc(owner())); }); test.each([ { ...legacy, ownerId: 'alice' }, { ...legacy, id: rideId }, { ...canonical, uid: 'alice', remoteRideId: rideId }, canonical, ])('valid mixed/canonical identity supports get/update/delete: %j', async data => { await seed(data); await assertSucceeds(getDoc(owner())); await assertSucceeds(updateDoc(owner(), { distanceMiles: 3 })); await assertSucceeds(deleteDoc(owner())); }); test.each([ { ...legacy, uid: 'bob' }, { ...legacy, remoteRideId: 'other' }, { ...legacy, ownerId: 'bob' }, { ...legacy, id: 'other' }, { ...canonical, uid: 'bob' }, { ...canonical, remoteRideId: 'other' }, { ...canonical, ownerId: 'bob' }, { ...canonical, id: 'other' }, { uid: 'alice' }, { remoteRideId: rideId }, {}, { ...legacy, ownerId: null }, { ...legacy, id: null }, ])('rejects malformed stored identity and attempted repair: %j', async data => { await seed(data); await assertFails(getDoc(owner())); await assertFails(updateDoc(owner(), { distanceMiles: 3 })); await assertFails(setDoc(owner(), uploadPayload(), { merge: true })); await assertFails(deleteDoc(owner())); }); test.each([ { uid: 'bob' }, { remoteRideId: 'other' }, { ownerId: 'bob' }, { id: 'other' }, { uid: null }, { remoteRideId: null }, { uid: deleteField() }, { remoteRideId: deleteField() }, ])('rejects legacy identity tampering: %j', async patch => { await seed(legacy); await assertFails(updateDoc(owner(), patch)); }); test.each([ { ownerId: 'bob' }, { id: 'other' }, { uid: 'bob' }, { remoteRideId: 'other' }, { ownerId: deleteField() }, { id: deleteField() }, ])('rejects canonical identity tampering: %j', async patch => { await seed({ ...legacy, ...canonical }); await assertFails(updateDoc(owner(), patch)); }); test.each([ legacy, { id: rideId, uid: 'alice' }, { ownerId: 'alice', remoteRideId: rideId }, { ...canonical, uid: 'bob' }, { ...canonical, remoteRideId: 'other' }, { ...canonical, ownerId: 'bob' }, { ...canonical, id: 'other' }, ])('new creates require canonical matching identity: %j', async data => { await assertFails(setDoc(owner(), data)); }); test('full canonical upload create still succeeds', async () => { await assertSucceeds(setDoc(owner(), uploadPayload(), { merge: true })); }); test.each(['bob', 'signed-out', 'anonymous-bob'])('%s cannot access Alice legacy ride', async actor => { await seed(legacy); const context = actor === 'signed-out' ? env.unauthenticatedContext() : env.authenticatedContext('bob', actor === 'anonymous-bob' ? { firebase: { sign_in_provider: 'anonymous' } } : {}); const ref = doc(context.firestore(), ridePath); await assertFails(getDoc(ref)); await assertFails(updateDoc(ref, { distanceMiles: 3 })); await assertFails(setDoc(ref, uploadPayload(), { merge: true })); await assertFails(deleteDoc(ref)); await assertFails(setDoc(doc(context.firestore(), 'users/alice/rides/new'), { id: 'new', ownerId: 'alice' })); }); test('anonymous owner retains existing canonical private-ride policy', async () => { const ref = doc(env.authenticatedContext('alice', { firebase: { sign_in_provider: 'anonymous' }, }).firestore(), ridePath); await assertSucceeds(setDoc(ref, canonical)); await assertSucceeds(getDoc(ref)); await assertSucceeds(updateDoc(ref, { distanceMiles: 3 })); await assertSucceeds(deleteDoc(ref)); }); });