From 3fe6dd46711908f1c01bab1daa7ec850c0860591 Mon Sep 17 00:00:00 2001 From: desartstudio95 Date: Wed, 6 May 2026 15:07:22 +0200 Subject: [PATCH] feat: Add Firebase security rules testing and error handling Introduces unit tests for Firestore security rules and adds error handling to data fetching hooks. This enhances the robustness and security of the application by validating access control logic and providing feedback on data retrieval failures. Includes: - Setup for Firestore unit testing. - Basic tests for signal query and user collection access. - Error logging for `testimonials` and `notifications` data fetching. - Updates to `firestore.rules` for stricter access checks. - Adds necessary development dependencies for testing. --- firestore.rules | 9 +- package-lock.json | 172 +++++++++++++++++++++++++ package.json | 2 + src/components/LandingPage.tsx | 2 + src/components/NotificationManager.tsx | 2 + test-rules.js | 36 ++++++ 6 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 test-rules.js diff --git a/firestore.rules b/firestore.rules index d779970..8df862e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -20,7 +20,13 @@ service cloud.firestore { // IMPORTANT: Avoid using this in rules for the 'users' collection itself to prevent recursion. function isDbAdmin() { return isSignedIn() && - (isRootAdmin() || get(/databases/$(database)/documents/users/$(request.auth.uid)).data.get('isAdmin', false) == true); + ( + isRootAdmin() || + ( + exists(/databases/$(database)/documents/users/$(request.auth.uid)) && + get(/databases/$(database)/documents/users/$(request.auth.uid)).data.get('isAdmin', false) == true + ) + ); } function isOwner(userId) { @@ -89,6 +95,7 @@ service cloud.firestore { allow create: if isSignedIn() && isValidSignal(incoming()) // Check approval status (Requires DB read) + && exists(/databases/$(database)/documents/users/$(request.auth.uid)) && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.isApproved == true; allow update, delete: if isDbAdmin(); diff --git a/package-lock.json b/package-lock.json index 4cfc6e7..5f51ea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,11 +25,13 @@ "vite": "^6.2.0" }, "devDependencies": { + "@firebase/rules-unit-testing": "^5.0.0", "@types/express": "^4.17.21", "@types/node": "^22.14.0", "autoprefixer": "^10.4.21", "eslint": "^10.2.1", "tailwindcss": "^4.1.14", + "ts-node": "^10.9.2", "tsx": "^4.21.0", "typescript": "~5.8.2", "vite": "^6.2.0" @@ -298,6 +300,30 @@ "node": ">=6.9.0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1354,6 +1380,19 @@ "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==", "license": "Apache-2.0" }, + "node_modules/@firebase/rules-unit-testing": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@firebase/rules-unit-testing/-/rules-unit-testing-5.0.0.tgz", + "integrity": "sha512-C6+d3Msgjnqay2ml663ChvKYoD8VsQ+TIa0e+fGq0LFC0CKSPlacT1EVGL/ryo6Rc+wFs7Fpqz3fRlYdUEa2bA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "firebase": "^12.0.0" + } + }, "node_modules/@firebase/storage": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.2.tgz", @@ -2284,6 +2323,34 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2593,6 +2660,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2643,6 +2723,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2993,6 +3080,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3196,6 +3290,16 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -4697,6 +4801,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5704,6 +5815,50 @@ "node": ">=0.6" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5843,6 +5998,13 @@ "node": ">= 0.4.0" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -6549,6 +6711,16 @@ "node": ">=12" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 79d2a7e..566b9ae 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,13 @@ "vite": "^6.2.0" }, "devDependencies": { + "@firebase/rules-unit-testing": "^5.0.0", "@types/express": "^4.17.21", "@types/node": "^22.14.0", "autoprefixer": "^10.4.21", "eslint": "^10.2.1", "tailwindcss": "^4.1.14", + "ts-node": "^10.9.2", "tsx": "^4.21.0", "typescript": "~5.8.2", "vite": "^6.2.0" diff --git a/src/components/LandingPage.tsx b/src/components/LandingPage.tsx index 8e6f2de..645ba4a 100644 --- a/src/components/LandingPage.tsx +++ b/src/components/LandingPage.tsx @@ -18,6 +18,8 @@ export const LandingPage: React.FC = ({ onGetStarted, onViewPl const q = query(collection(db, 'testimonials'), orderBy('timestamp', 'desc')); const unsubscribe = onSnapshot(q, (snapshot) => { setTestimonials(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }))); + }, (error) => { + console.error("Testimonials fetch error:", error); }); return () => unsubscribe(); }, []); diff --git a/src/components/NotificationManager.tsx b/src/components/NotificationManager.tsx index a6125c7..2c043cd 100644 --- a/src/components/NotificationManager.tsx +++ b/src/components/NotificationManager.tsx @@ -39,6 +39,8 @@ export const NotificationManager: React.FC = () => { } } }); + }, (error) => { + console.error("Notifications fetch error:", error); }); return () => unsubscribe(); diff --git a/test-rules.js b/test-rules.js new file mode 100644 index 0000000..d241cd9 --- /dev/null +++ b/test-rules.js @@ -0,0 +1,36 @@ +import { readFileSync } from 'fs'; +import { initializeTestEnvironment, assertFails, assertSucceeds } from '@firebase/rules-unit-testing'; + +async function main() { + const projectId = `test-project-${Date.now()}`; + const testEnv = await initializeTestEnvironment({ + projectId, + firestore: { + rules: readFileSync('firestore.rules', 'utf8'), + }, + }); + + const alice = testEnv.authenticatedContext('alice', { email: 'alice@example.com' }); + const db = alice.firestore(); + + // Test signals query + const signalsRef = db.collection('signals'); + const q = signalsRef.where('userId', '==', 'alice'); + + try { + await assertSucceeds(q.get()); + console.log("SUCCESS: signals query passed"); + } catch (err) { + console.error("FAILED: signals query failed", err); + } + + // Next, let's try users list + try { + await assertFails(db.collection('users').get()); + console.log("SUCCESS: non-admin users list correctly denied"); + } catch(err) { + console.error("FAILED: non-admin users list passed or threw error we didn't expect", err); + } +} + +main().then(() => process.exit(0)).catch(err => { console.error(err); process.exit(1); });