feat: iphone app
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
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'});
|
||||
});
|
||||
Reference in New Issue
Block a user