MotorSignal-App/firebase/emulator-tests/legacy-rides.rules.test.js

145 lines
7.1 KiB
JavaScript

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));
});
});