const {before, after, test} = require('node:test'); const {readFileSync} = require('node:fs'); const {initializeTestEnvironment, assertSucceeds:ok, assertFails:no} = require('@firebase/rules-unit-testing'); const {doc, setDoc, updateDoc, getDoc, deleteDoc, serverTimestamp, Timestamp} = require('firebase/firestore'); let env, a, b; const meta=()=>({schemaVersion:1,createdAt:serverTimestamp(),updatedAt:serverTimestamp()}); const profile=()=>({...meta(),accountState:'active',firstName:'Test',birthYear:null,gender:null,locale:'fr',timeZone:'Europe/Paris'}); const measure=()=>({...meta(),source:'appleWatchOptical',deviceId:'watch-1',startedAt:Timestamp.fromMillis(1000),endedAt:Timestamp.fromMillis(61000),durationSeconds:60,averageBpm:70,minimumBpm:65,maximumBpm:80,estimatedRmssdMs:null,sdnnMs:null,quality:0.9,coverage:1,valid:true,algorithmVersion:'1'}); const session=()=>({...meta(),revision:1,practiceId:'breathing',protocolVersion:1,deviceId:'phone-1',source:'iphoneCameraPPG',startedAt:Timestamp.fromMillis(1000),endedAt:null,plannedDurationSeconds:300,actualDurationSeconds:0,status:'inProgress',beforeMeasurementId:null,afterMeasurementId:null,feelingBefore:null,feelingAfter:null}); before(async()=>{env=await initializeTestEnvironment({projectId:'demo-haptika',firestore:{host:'127.0.0.1',port:8085,rules:readFileSync('firestore.rules','utf8')}});a=env.authenticatedContext('a').firestore();b=env.authenticatedContext('b').firestore();await setDoc(doc(a,'users/a'),profile());await setDoc(doc(b,'users/b'),profile());}); after(async()=>env?.cleanup()); test('authentication and ownership',async()=>{await no(getDoc(doc(env.unauthenticatedContext().firestore(),'users/a')));await no(getDoc(doc(b,'users/a')));await no(setDoc(doc(b,'users/a/measurements/cross'),measure()));await ok(getDoc(doc(a,'users/a')));}); test('profile rejects privilege and invalid fields',async()=>{for(const patch of [{plan:'premium'},{birthYear:3000},{accountState:'deleting'}])await no(updateDoc(doc(a,'users/a'),{...patch,updatedAt:serverTimestamp()}));}); test('measurements immutable and validated',async()=>{const ref=doc(a,'users/a/measurements/valid');await ok(setDoc(ref,measure()));await no(updateDoc(ref,{averageBpm:95,updatedAt:serverTimestamp()}));await no(deleteDoc(ref));for(const [id,patch] of [['bad',{quality:2}],['time',{updatedAt:Timestamp.fromMillis(0)}],['raw',{samples:[70]}]])await no(setDoc(doc(a,`users/a/measurements/${id}`),{...measure(),...patch}));}); test('client cannot grant server-owned data',async()=>{for(const c of ['entitlements','baselines','dailyStats','tombstones'])await no(setDoc(doc(a,`users/a/${c}/current`),{plan:'premium'}));await no(setDoc(doc(a,'protocols/breathing/versions/1'),{published:true}));}); test('settings closed schema',async()=>{const p={...meta(),notificationsEnabled:false,saveMindAndBodyWorkouts:false,blackScreenEnabled:true};await ok(setDoc(doc(a,'users/a/preferences/general'),p));await no(setDoc(doc(a,'users/a/preferences/other'),p));await no(setDoc(doc(a,'users/a/practiceSettings/breathing'),{...meta(),durationSeconds:0,intensity:0.5,soundEnabled:false}));}); test('session revision, references and terminal state',async()=>{const ref=doc(a,'users/a/sessions/s1');await ok(setDoc(ref,session()));await no(updateDoc(ref,{revision:3,updatedAt:serverTimestamp()}));await no(updateDoc(ref,{revision:2,afterMeasurementId:'missing',updatedAt:serverTimestamp()}));await ok(updateDoc(ref,{revision:2,status:'completed',endedAt:Timestamp.fromMillis(301000),actualDurationSeconds:300,updatedAt:serverTimestamp()}));await no(updateDoc(ref,{revision:3,status:'inProgress',updatedAt:serverTimestamp()}));}); test('annotations require parent, revision and feeling',async()=>{const v={...meta(),revision:1,feeling:3,posture:'seated',atRest:true,recentExercise:false,recentCaffeine:false,recentMeal:false,recordedAt:Timestamp.fromMillis(61000)};await no(setDoc(doc(a,'users/a/measurementAnnotations/missing'),v));await setDoc(doc(a,'users/a/measurements/annotated'),measure());await ok(setDoc(doc(a,'users/a/measurementAnnotations/annotated'),v));await no(updateDoc(doc(a,'users/a/measurementAnnotations/annotated'),{feeling:6,revision:2,updatedAt:serverTimestamp()}));}); test('tombstones block resurrection',async()=>{await env.withSecurityRulesDisabled(async c=>setDoc(doc(c.firestore(),'users/a/tombstones/measurement_deleted'),{deletedAt:Timestamp.now()}));await no(setDoc(doc(a,'users/a/measurements/deleted'),measure()));}); test('deleting account is blocked',async()=>{await env.withSecurityRulesDisabled(async c=>setDoc(doc(c.firestore(),'users/deleting'),{...profile(),accountState:'deleting'}));const db=env.authenticatedContext('deleting').firestore();await no(setDoc(doc(db,'users/deleting/measurements/m'),measure()));await no(updateDoc(doc(db,'users/deleting'),{accountState:'active',updatedAt:serverTimestamp()}));}); test('server deletion is idempotent and clears same-user references',async()=>{ process.env.GCLOUD_PROJECT='demo-haptika';process.env.FIRESTORE_EMULATOR_HOST='127.0.0.1:8085'; const {deleteEntity}=require('../functions'); await env.withSecurityRulesDisabled(async c=>{ const db=c.firestore();await setDoc(doc(db,'users/delete-test'),{...profile()}); await setDoc(doc(db,'users/delete-test/measurements/target'),measure()); await setDoc(doc(db,'users/delete-test/measurementAnnotations/target'),{feeling:3}); await setDoc(doc(db,'users/delete-test/sessions/linked'),{...session(),beforeMeasurementId:'target'}); }); const request={auth:{uid:'delete-test',token:{}},data:{kind:'measurement',id:'target'}}; await deleteEntity.run(request);await deleteEntity.run(request); await env.withSecurityRulesDisabled(async c=>{ const db=c.firestore();require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/measurements/target'))).exists(),false); require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/measurementAnnotations/target'))).exists(),false); require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/tombstones/measurement_target'))).exists(),true); require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/sessions/linked'))).data().beforeMeasurementId,null); }); await require('node:assert/strict').rejects(deleteEntity.run({data:{kind:'measurement',id:'target'}}),{code:'unauthenticated'}); });