145 lines
7.1 KiB
JavaScript
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));
|
|
});
|
|
});
|