From 803b681d06b5a41a269b2c762d144b68e32a4c21 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 19 Jun 2026 03:45:53 -0500 Subject: [PATCH] feat: update PrivacyScreen, add Firestore test scripts, gitleaks audit artifacts --- .../app/closer/ui/settings/PrivacyScreen.kt | 183 ++- firestore-tests/jest.globalSetup.ts | 6 + firestore-tests/jest.globalTeardown.ts | 3 + firestore-tests/package.json | 25 + firestore-tests/rules.test.ts | 1043 +++++++++++++++++ firestore-tests/tsconfig.json | 13 + gitleaks-current.json | 42 + gitleaks-history.json | 1 + 8 files changed, 1308 insertions(+), 8 deletions(-) create mode 100644 firestore-tests/jest.globalSetup.ts create mode 100644 firestore-tests/jest.globalTeardown.ts create mode 100644 firestore-tests/package.json create mode 100644 firestore-tests/rules.test.ts create mode 100644 firestore-tests/tsconfig.json create mode 100644 gitleaks-current.json create mode 100644 gitleaks-history.json diff --git a/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt b/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt index 445b12c7..a7b55037 100644 --- a/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/PrivacyScreen.kt @@ -3,20 +3,27 @@ package app.closer.ui.settings import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Divider @@ -32,11 +39,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.closer.core.navigation.ExternalLinks +import app.closer.ui.theme.CloserPalette @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -72,14 +81,129 @@ fun PrivacyScreen( .verticalScroll(rememberScrollState()) .padding(padding) .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(20.dp) ) { Text( - text = "Your data stays between the two of you. These documents explain exactly what we collect, how we use it, and what rights you have.", + text = "Closer is built on one rule: answers stay private until both of you have answered. Here's exactly what that means.", style = MaterialTheme.typography.bodyLarge, color = SettingsMuted ) + // ── What your partner can see ───────────────────────────────────── + PrivacySectionHeader( + icon = Icons.Default.CheckCircle, + iconTint = CloserPalette.PurpleDeep, + title = "What your partner can see" + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = SettingsCard) + ) { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + PrivacyRow( + title = "Daily question answers", + body = "Only after you've both answered. Until then, your partner sees \"waiting for you\" — not your answer." + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + PrivacyRow( + title = "Game results", + body = "Revealed together at the end of a round — This or That matches, How Well Do You Know Me scores, and Desire Sync overlaps." + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + PrivacyRow( + title = "Desire Sync: shared yes answers only", + body = "Questions where only one of you said yes are never shown to either partner. Only mutual overlap is revealed." + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + PrivacyRow( + title = "Discussion messages and reactions", + body = "Messages you send in question threads are visible to your partner in real time." + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + PrivacyRow( + title = "Shared game history", + body = "Both partners can replay past rounds from Past Games. The replay shows the same answers both of you gave." + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + PrivacyRow( + title = "Streak and shared wins", + body = "Your couple's streak count and challenge completions are shared between both of you." + ) + } + } + + // ── What stays private ──────────────────────────────────────────── + PrivacySectionHeader( + icon = Icons.Default.Lock, + iconTint = CloserPalette.Romantic, + title = "What stays private" + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = SettingsCard) + ) { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + PrivacyRow( + title = "Answers before your partner answers", + body = "Your partner cannot see what you said until they've answered too. No peeking." + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + PrivacyRow( + title = "Unanswered desire prompts", + body = "In Desire Sync, prompts where only one of you tapped yes are never surfaced to either person." + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + PrivacyRow( + title = "Time capsule contents", + body = "What's inside a capsule stays sealed until the unlock date both partners agreed on." + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + PrivacyRow( + title = "Notification settings", + body = "Your notification preferences are yours alone. Your partner cannot see or change them." + ) + } + } + + // ── Deleting your account ───────────────────────────────────────── + PrivacySectionHeader( + icon = Icons.Default.VisibilityOff, + iconTint = SettingsDanger, + title = "Deleting your account" + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = SettingsCard) + ) { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + PrivacyRow( + title = "Immediate and permanent", + body = "Deleting your account removes your profile and sign-in instantly. Your partner is unpaired and can start fresh. This cannot be undone." + ) + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + PrivacyRow( + title = "No data export", + body = "Closer does not currently offer a data export. Your answers and history are deleted along with your account." + ) + } + } + + Spacer(Modifier.height(4.dp)) + + // ── Legal docs ──────────────────────────────────────────────────── + Text( + text = "Legal documents", + style = MaterialTheme.typography.labelLarge, + color = SettingsInk, + modifier = Modifier.padding(horizontal = 4.dp) + ) + Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), @@ -112,16 +236,59 @@ fun PrivacyScreen( } } - Text( - text = "Answer text and messages are private by design and are never shared with third parties.", - style = MaterialTheme.typography.bodySmall, - color = SettingsMuted, - modifier = Modifier.padding(horizontal = 4.dp) - ) + Spacer(Modifier.height(8.dp)) } } } +@Composable +private fun PrivacySectionHeader( + icon: ImageVector, + iconTint: Color, + title: String +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(30.dp) + .background(iconTint.copy(alpha = 0.12f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp), tint = iconTint) + } + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + color = SettingsInk + ) + } +} + +@Composable +private fun PrivacyRow(title: String, body: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = SettingsInk + ) + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + color = SettingsMuted + ) + } +} + @Composable private fun LegalLinkRow( label: String, diff --git a/firestore-tests/jest.globalSetup.ts b/firestore-tests/jest.globalSetup.ts new file mode 100644 index 00000000..a646fe6a --- /dev/null +++ b/firestore-tests/jest.globalSetup.ts @@ -0,0 +1,6 @@ +// Runs once before the full test suite. +// The Firestore emulator must already be running on port 8080 before running tests. +// Start it with: firebase emulators:start --only firestore +export default async function () { + process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080"; +} diff --git a/firestore-tests/jest.globalTeardown.ts b/firestore-tests/jest.globalTeardown.ts new file mode 100644 index 00000000..69de1407 --- /dev/null +++ b/firestore-tests/jest.globalTeardown.ts @@ -0,0 +1,3 @@ +export default async function () { + // Nothing to tear down — the emulator manages its own lifecycle. +} diff --git a/firestore-tests/package.json b/firestore-tests/package.json new file mode 100644 index 00000000..154ec837 --- /dev/null +++ b/firestore-tests/package.json @@ -0,0 +1,25 @@ +{ + "name": "closer-firestore-rules-tests", + "private": true, + "scripts": { + "test": "jest --runInBand", + "test:watch": "jest --runInBand --watch" + }, + "dependencies": {}, + "devDependencies": { + "@firebase/rules-unit-testing": "^4.0.1", + "@types/jest": "^29.5.14", + "@types/node": "^22.0.0", + "firebase": "^11.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typescript": "^5.7.3" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testTimeout": 30000, + "globalSetup": "./jest.globalSetup.ts", + "globalTeardown": "./jest.globalTeardown.ts" + } +} diff --git a/firestore-tests/rules.test.ts b/firestore-tests/rules.test.ts new file mode 100644 index 00000000..1176b7d8 --- /dev/null +++ b/firestore-tests/rules.test.ts @@ -0,0 +1,1043 @@ +/** + * Firestore Security Rules — QA Matrix + * + * Covers every client-read/write path in firestore.rules. + * Run against the local emulator: + * + * firebase emulators:start --only firestore + * cd firestore-tests && npm test + * + * Each test name follows the pattern: "[path] [operation] [who] → [allowed|denied]" + * "allowed" = assertSucceeds, "denied" = assertFails. + */ + +import { + assertFails, + assertSucceeds, + initializeTestEnvironment, + RulesTestEnvironment, +} from "@firebase/rules-unit-testing"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { + doc, + setDoc, + getDoc, + updateDoc, + deleteDoc, + collection, + addDoc, + Timestamp, + serverTimestamp, +} from "firebase/firestore"; + +// ── Test environment ────────────────────────────────────────────────────────── + +const PROJECT_ID = "closer-rules-test"; +let testEnv: RulesTestEnvironment; + +beforeAll(async () => { + testEnv = await initializeTestEnvironment({ + projectId: PROJECT_ID, + firestore: { + rules: readFileSync(join(__dirname, "../firestore.rules"), "utf8"), + host: "127.0.0.1", + port: 8080, + }, + }); +}); + +afterAll(async () => { + await testEnv?.cleanup(); +}); + +afterEach(async () => { + await testEnv.clearFirestore(); +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const UID_A = "user_alice"; +const UID_B = "user_bob"; +const UID_C = "user_charlie"; // outsider +const COUPLE_ID = "couple_ab"; +const COUPLE_DOC = { + userIds: [UID_A, UID_B], + inviteCode: "ABC123", + createdAt: 1_000_000, + streakCount: 0, + lastAnsweredAt: null, +}; + +/** Seed documents that rules' helper functions need (e.g. isCouplesMember reads the couple). */ +async function seedCouple() { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), COUPLE_DOC); + }); +} + +async function seedUser(uid: string, coupleId?: string) { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), `users/${uid}`), { + displayName: uid, + coupleId: coupleId ?? null, + }); + }); +} + +const alice = () => testEnv.authenticatedContext(UID_A); +const bob = () => testEnv.authenticatedContext(UID_B); +const charlie = () => testEnv.authenticatedContext(UID_C); +const anon = () => testEnv.unauthenticatedContext(); + +// ── users/{uid} ─────────────────────────────────────────────────────────────── + +describe("users/{uid}", () => { + test("owner can read own doc — allowed", async () => { + await seedUser(UID_A); + await assertSucceeds(getDoc(doc(alice().firestore(), `users/${UID_A}`))); + }); + + test("other user cannot read alice's doc — denied", async () => { + await seedUser(UID_A); + await assertFails(getDoc(doc(bob().firestore(), `users/${UID_A}`))); + }); + + test("unauthenticated cannot read — denied", async () => { + await seedUser(UID_A); + await assertFails(getDoc(doc(anon().firestore(), `users/${UID_A}`))); + }); + + test("owner can create own doc without hasPremium — allowed", async () => { + await assertSucceeds( + setDoc(doc(alice().firestore(), `users/${UID_A}`), { displayName: "Alice" }) + ); + }); + + test("owner cannot set hasPremium on create — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), `users/${UID_A}`), { + displayName: "Alice", + hasPremium: true, + }) + ); + }); + + test("owner can update own doc (non-premium fields) — allowed", async () => { + await seedUser(UID_A); + await assertSucceeds( + updateDoc(doc(alice().firestore(), `users/${UID_A}`), { displayName: "Alice 2" }) + ); + }); + + test("owner cannot update hasPremium — denied", async () => { + await seedUser(UID_A); + await assertFails( + updateDoc(doc(alice().firestore(), `users/${UID_A}`), { hasPremium: true }) + ); + }); +}); + +// ── users/{uid}/entitlements/{doc} ─────────────────────────────────────────── + +describe("users/{uid}/entitlements/{doc}", () => { + test("owner can read entitlements — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc( + doc(ctx.firestore(), `users/${UID_A}/entitlements/premium`), + { active: true } + ); + }); + await assertSucceeds( + getDoc(doc(alice().firestore(), `users/${UID_A}/entitlements/premium`)) + ); + }); + + test("owner cannot write entitlements (server-only) — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), `users/${UID_A}/entitlements/premium`), { + active: true, + }) + ); + }); + + test("other user cannot read entitlements — denied", async () => { + await assertFails( + getDoc(doc(bob().firestore(), `users/${UID_A}/entitlements/premium`)) + ); + }); +}); + +// ── users/{uid}/notification_queue/{id} ────────────────────────────────────── + +describe("users/{uid}/notification_queue/{id}", () => { + test("no client read — denied", async () => { + await assertFails( + getDoc( + doc(alice().firestore(), `users/${UID_A}/notification_queue/notif1`) + ) + ); + }); + + test("no client write — denied", async () => { + await assertFails( + setDoc( + doc(alice().firestore(), `users/${UID_A}/notification_queue/notif1`), + { type: "partner_answered" } + ) + ); + }); +}); + +// ── date_ideas/{id} ────────────────────────────────────────────────────────── + +describe("date_ideas/{id}", () => { + test("any auth user can read — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), "date_ideas/idea1"), { title: "Picnic" }); + }); + await assertSucceeds(getDoc(doc(alice().firestore(), "date_ideas/idea1"))); + }); + + test("client cannot write date_ideas — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), "date_ideas/idea1"), { title: "Picnic" }) + ); + }); + + test("unauthenticated cannot read — denied", async () => { + await assertFails(getDoc(doc(anon().firestore(), "date_ideas/idea1"))); + }); +}); + +// ── invites/{code} ─────────────────────────────────────────────────────────── + +describe("invites/{code}", () => { + const INVITE_CODE = "ABC123"; + const expiresAt = Timestamp.fromMillis(Date.now() + 3_600_000); + + async function seedInvite(extra?: Record) { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), `invites/${INVITE_CODE}`), { + inviterUserId: UID_A, + code: INVITE_CODE, + status: "pending", + expiresAt, + ...extra, + }); + }); + } + + test("inviter can create valid invite — allowed", async () => { + await assertSucceeds( + setDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`), { + inviterUserId: UID_A, + code: INVITE_CODE, + status: "pending", + expiresAt, + }) + ); + }); + + test("inviter cannot set coupleId on create — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`), { + inviterUserId: UID_A, + code: INVITE_CODE, + status: "pending", + expiresAt, + coupleId: "injected", + }) + ); + }); + + test("creator cannot set inviterUserId to another user — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`), { + inviterUserId: UID_B, + code: INVITE_CODE, + status: "pending", + expiresAt, + }) + ); + }); + + test("inviter can read own invite — allowed", async () => { + await seedInvite(); + await assertSucceeds(getDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`))); + }); + + test("unpaired user can read pending invite to accept it — allowed", async () => { + await seedInvite(); + await seedUser(UID_B); // user doc exists, no coupleId + await assertSucceeds(getDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`))); + }); + + test("outsider cannot read invite — denied", async () => { + await seedInvite(); + await seedUser(UID_C); + await assertFails(getDoc(doc(charlie().firestore(), `invites/${INVITE_CODE}`))); + }); + + test("acceptor can accept pending invite — allowed", async () => { + await seedInvite(); + await seedUser(UID_B); + await assertSucceeds( + updateDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`), { + status: "accepted", + acceptorUserId: UID_B, + acceptedAt: Timestamp.now(), + }) + ); + }); + + test("inviter cannot accept own invite — denied", async () => { + await seedInvite(); + await seedUser(UID_A); + await assertFails( + updateDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`), { + status: "accepted", + acceptorUserId: UID_A, + acceptedAt: Timestamp.now(), + }) + ); + }); +}); + +// ── couples/{coupleId} ─────────────────────────────────────────────────────── + +describe("couples/{coupleId}", () => { + beforeEach(seedCouple); + + test("member can read couple — allowed", async () => { + await assertSucceeds(getDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`))); + }); + + test("outsider cannot read couple — denied", async () => { + await assertFails(getDoc(doc(charlie().firestore(), `couples/${COUPLE_ID}`))); + }); + + test("no client can create couple (server-only) — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), "couples/newCouple"), { + userIds: [UID_A, UID_C], + inviteCode: "XYZ789", + createdAt: 1, + streakCount: 0, + }) + ); + }); + + test("member can update allowed fields — allowed", async () => { + await assertSucceeds( + updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), { + someCustomField: "hello", + }) + ); + }); + + test("member can update streakCount from client — allowed", async () => { + await assertSucceeds( + updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), { + streakCount: 5, + lastAnsweredAt: Date.now(), + }) + ); + }); + + test("member cannot change userIds — denied", async () => { + await assertFails( + updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), { + userIds: [UID_A, UID_C], + }) + ); + }); + + test("member cannot change inviteCode — denied", async () => { + await assertFails( + updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), { + inviteCode: "HACKED1", + }) + ); + }); + + test("member cannot change createdAt — denied", async () => { + await assertFails( + updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), { + createdAt: 0, + }) + ); + }); + + test("couple cannot be deleted by client — denied", async () => { + await assertFails(deleteDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`))); + }); +}); + +// ── couples/{coupleId}/sessions/{sessionId} ────────────────────────────────── + +describe("couples/{coupleId}/sessions/{sessionId}", () => { + const SESSION_PATH = `couples/${COUPLE_ID}/sessions/sess1`; + + beforeEach(seedCouple); + + test("member can read session — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), SESSION_PATH), { + startedByUserId: UID_A, + status: "active", + }); + }); + await assertSucceeds(getDoc(doc(alice().firestore(), SESSION_PATH))); + }); + + test("outsider cannot read session — denied", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), SESSION_PATH), { + startedByUserId: UID_A, + status: "active", + }); + }); + await assertFails(getDoc(doc(charlie().firestore(), SESSION_PATH))); + }); + + test("member can create session with own startedByUserId — allowed", async () => { + await assertSucceeds( + setDoc(doc(alice().firestore(), SESSION_PATH), { + startedByUserId: UID_A, + status: "active", + }) + ); + }); + + test("member cannot create session with other's startedByUserId — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), SESSION_PATH), { + startedByUserId: UID_B, + status: "active", + }) + ); + }); + + test("starter can update status to completed — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), SESSION_PATH), { + startedByUserId: UID_A, + status: "active", + }); + }); + await assertSucceeds( + updateDoc(doc(alice().firestore(), SESSION_PATH), { + status: "completed", + completedAt: Timestamp.now(), + }) + ); + }); + + test("non-starter cannot update session — denied", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), SESSION_PATH), { + startedByUserId: UID_A, + status: "active", + }); + }); + await assertFails( + updateDoc(doc(bob().firestore(), SESSION_PATH), { + status: "completed", + completedAt: Timestamp.now(), + }) + ); + }); + + test("client cannot delete session — denied", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), SESSION_PATH), { + startedByUserId: UID_A, + status: "active", + }); + }); + await assertFails(deleteDoc(doc(alice().firestore(), SESSION_PATH))); + }); +}); + +// ── couples/{coupleId}/question_threads/{threadId} ─────────────────────────── + +describe("couples/{coupleId}/question_threads/{threadId}", () => { + const THREAD_PATH = `couples/${COUPLE_ID}/question_threads/thread1`; + + beforeEach(seedCouple); + + async function seedThread(extra?: Record) { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), THREAD_PATH), { + questionId: "q1", + categoryId: "emotional_intimacy", + status: "NOT_STARTED", + currentIndex: 0, + createdByUserId: UID_A, + createdAt: Timestamp.now(), + updatedAt: Timestamp.now(), + ...extra, + }); + }); + } + + test("member can create thread with own createdByUserId — allowed", async () => { + await assertSucceeds( + setDoc(doc(alice().firestore(), THREAD_PATH), { + questionId: "q1", + categoryId: "emotional_intimacy", + status: "NOT_STARTED", + currentIndex: 0, + createdByUserId: UID_A, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }) + ); + }); + + test("member cannot create thread with other's createdByUserId — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), THREAD_PATH), { + questionId: "q1", + categoryId: "emotional_intimacy", + status: "NOT_STARTED", + currentIndex: 0, + createdByUserId: UID_B, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }) + ); + }); + + test("member cannot create thread without createdByUserId — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), THREAD_PATH), { + questionId: "q1", + categoryId: "emotional_intimacy", + status: "NOT_STARTED", + currentIndex: 0, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }) + ); + }); + + test("member can read thread — allowed", async () => { + await seedThread(); + await assertSucceeds(getDoc(doc(bob().firestore(), THREAD_PATH))); + }); + + test("outsider cannot read thread — denied", async () => { + await seedThread(); + await assertFails(getDoc(doc(charlie().firestore(), THREAD_PATH))); + }); + + test("member can update status (valid transition, no extra fields) — allowed", async () => { + await seedThread({ status: "NOT_STARTED", currentIndex: 0 }); + await assertSucceeds( + updateDoc(doc(alice().firestore(), THREAD_PATH), { + status: "ANSWERED_BY_ONE", + }) + ); + }); + + test("status update with updatedAt field — denied", async () => { + await seedThread({ status: "NOT_STARTED" }); + await assertFails( + updateDoc(doc(alice().firestore(), THREAD_PATH), { + status: "ANSWERED_BY_ONE", + updatedAt: serverTimestamp(), + }) + ); + }); + + test("invalid status transition — denied", async () => { + await seedThread({ status: "NOT_STARTED" }); + await assertFails( + updateDoc(doc(alice().firestore(), THREAD_PATH), { + status: "REVEALED", + }) + ); + }); + + test("currentIndex can be incremented — allowed", async () => { + await seedThread({ status: "NOT_STARTED", currentIndex: 0 }); + await assertSucceeds( + updateDoc(doc(alice().firestore(), THREAD_PATH), { + currentIndex: 1, + }) + ); + }); + + test("currentIndex cannot be decremented — denied", async () => { + await seedThread({ status: "ANSWERED_BY_ONE", currentIndex: 2 }); + await assertFails( + updateDoc(doc(alice().firestore(), THREAD_PATH), { + currentIndex: 1, + }) + ); + }); + + test("client cannot delete thread — denied", async () => { + await seedThread(); + await assertFails(deleteDoc(doc(alice().firestore(), THREAD_PATH))); + }); + + // ── answers ──────────────────────────────────────────────────────────────── + + describe("answers/{userId}", () => { + const ANSWER_PATH = `${THREAD_PATH}/answers/${UID_A}`; + + beforeEach(seedThread); + + test("owner can write own answer — allowed", async () => { + await assertSucceeds( + setDoc(doc(alice().firestore(), ANSWER_PATH), { + userId: UID_A, + questionId: "q1", + answerType: "written", + writtenText: "Hello", + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }) + ); + }); + + test("other user cannot write alice's answer — denied", async () => { + await assertFails( + setDoc(doc(bob().firestore(), ANSWER_PATH), { + userId: UID_A, + questionId: "q1", + answerType: "written", + writtenText: "Hacked", + }) + ); + }); + + test("couple member can read any answer — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), ANSWER_PATH), { userId: UID_A }); + }); + await assertSucceeds(getDoc(doc(bob().firestore(), ANSWER_PATH))); + }); + + test("outsider cannot read answer — denied", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), ANSWER_PATH), { userId: UID_A }); + }); + await assertFails(getDoc(doc(charlie().firestore(), ANSWER_PATH))); + }); + }); + + // ── messages ─────────────────────────────────────────────────────────────── + + describe("messages/{messageId}", () => { + const MSGS_PATH = `${THREAD_PATH}/messages`; + + beforeEach(seedThread); + + test("member can send message with own authorUserId — allowed", async () => { + await assertSucceeds( + addDoc(collection(alice().firestore(), MSGS_PATH), { + authorUserId: UID_A, + text: "Hi", + createdAt: serverTimestamp(), + }) + ); + }); + + test("member cannot send message with other's authorUserId — denied", async () => { + await assertFails( + addDoc(collection(alice().firestore(), MSGS_PATH), { + authorUserId: UID_B, + text: "Impersonation attempt", + createdAt: serverTimestamp(), + }) + ); + }); + + test("message without authorUserId — denied", async () => { + await assertFails( + addDoc(collection(alice().firestore(), MSGS_PATH), { + userId: UID_A, + text: "Old-style message", + createdAt: serverTimestamp(), + }) + ); + }); + + test("outsider cannot send message — denied", async () => { + await assertFails( + addDoc(collection(charlie().firestore(), MSGS_PATH), { + authorUserId: UID_C, + text: "Intruder", + createdAt: serverTimestamp(), + }) + ); + }); + + test("member can read messages — allowed", async () => { + const msgRef = await testEnv.withSecurityRulesDisabled(async (ctx) => { + return addDoc(collection(ctx.firestore(), MSGS_PATH), { + authorUserId: UID_A, + text: "Hi", + }); + }); + await assertSucceeds(getDoc(doc(bob().firestore(), msgRef.path))); + }); + + test("author can update own message — allowed", async () => { + const msgRef = await testEnv.withSecurityRulesDisabled(async (ctx) => { + return addDoc(collection(ctx.firestore(), MSGS_PATH), { + authorUserId: UID_A, + text: "Hi", + }); + }); + await assertSucceeds( + updateDoc(doc(alice().firestore(), msgRef.path), { text: "Updated" }) + ); + }); + + test("other member cannot update someone else's message — denied", async () => { + const msgRef = await testEnv.withSecurityRulesDisabled(async (ctx) => { + return addDoc(collection(ctx.firestore(), MSGS_PATH), { + authorUserId: UID_A, + text: "Hi", + }); + }); + await assertFails( + updateDoc(doc(bob().firestore(), msgRef.path), { text: "Tampered" }) + ); + }); + }); + + // ── reactions ────────────────────────────────────────────────────────────── + + describe("reactions/{reactionId}", () => { + const REACTIONS_PATH = `${THREAD_PATH}/reactions`; + + beforeEach(seedThread); + + test("member can add reaction with own userId — allowed", async () => { + await assertSucceeds( + setDoc(doc(alice().firestore(), `${REACTIONS_PATH}/${UID_A}_${UID_B}`), { + userId: UID_A, + targetUserId: UID_B, + emoji: "❤️", + createdAt: serverTimestamp(), + }) + ); + }); + + test("member cannot add reaction with other's userId — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), `${REACTIONS_PATH}/${UID_B}_${UID_A}`), { + userId: UID_B, + targetUserId: UID_A, + emoji: "❤️", + createdAt: serverTimestamp(), + }) + ); + }); + }); +}); + +// ── couples/{coupleId}/date_swipes/{dateIdeaId} ────────────────────────────── + +describe("couples/{coupleId}/date_swipes/{dateIdeaId}", () => { + const SWIPE_PATH = `couples/${COUPLE_ID}/date_swipes/idea1`; + + beforeEach(seedCouple); + + test("member can write own swipe action — allowed", async () => { + await assertSucceeds( + setDoc(doc(alice().firestore(), SWIPE_PATH), { + actions: { + [UID_A]: { action: "love", swipedAt: Timestamp.now() }, + }, + }) + ); + }); + + test("invalid swipe action value — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), SWIPE_PATH), { + actions: { + [UID_A]: { action: "hate", swipedAt: Timestamp.now() }, + }, + }) + ); + }); + + test("member cannot write other user's action — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), SWIPE_PATH), { + actions: { + [UID_B]: { action: "love", swipedAt: Timestamp.now() }, + }, + }) + ); + }); + + test("member can read swipes — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), SWIPE_PATH), { + actions: { [UID_A]: { action: "love", swipedAt: Timestamp.now() } }, + }); + }); + await assertSucceeds(getDoc(doc(bob().firestore(), SWIPE_PATH))); + }); + + test("client cannot delete swipe — denied", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), SWIPE_PATH), { + actions: { [UID_A]: { action: "love", swipedAt: Timestamp.now() } }, + }); + }); + await assertFails(deleteDoc(doc(alice().firestore(), SWIPE_PATH))); + }); +}); + +// ── couples/{coupleId}/date_matches/{matchId} ──────────────────────────────── + +describe("couples/{coupleId}/date_matches/{matchId}", () => { + const MATCH_PATH = `couples/${COUPLE_ID}/date_matches/match1`; + + beforeEach(seedCouple); + + test("member can read match — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), MATCH_PATH), { dateIdeaId: "idea1" }); + }); + await assertSucceeds(getDoc(doc(alice().firestore(), MATCH_PATH))); + }); + + test("client cannot create match (server-only) — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), MATCH_PATH), { dateIdeaId: "idea1" }) + ); + }); +}); + +// ── couples/{coupleId}/date_plans/{planId} ─────────────────────────────────── + +describe("couples/{coupleId}/date_plans/{planId}", () => { + const PLAN_PATH = `couples/${COUPLE_ID}/date_plans/plan1`; + + beforeEach(seedCouple); + + const VALID_PLAN = { + dateIdeaId: "idea1", + scheduledDate: "2026-07-01", + status: "draft", + createdAt: Timestamp.now(), + updatedAt: Timestamp.now(), + }; + + test("member can create date plan — allowed", async () => { + await assertSucceeds(setDoc(doc(alice().firestore(), PLAN_PATH), VALID_PLAN)); + }); + + test("invalid status on create — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), PLAN_PATH), { + ...VALID_PLAN, + status: "cancelled", + }) + ); + }); + + test("member can update plan — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), PLAN_PATH), VALID_PLAN); + }); + await assertSucceeds( + updateDoc(doc(alice().firestore(), PLAN_PATH), { + status: "planned", + updatedAt: Timestamp.now(), + }) + ); + }); + + test("member can delete plan — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), PLAN_PATH), VALID_PLAN); + }); + await assertSucceeds(deleteDoc(doc(alice().firestore(), PLAN_PATH))); + }); +}); + +// ── couples/{coupleId}/bucket_list/{itemId} ────────────────────────────────── + +describe("couples/{coupleId}/bucket_list/{itemId}", () => { + const ITEM_PATH = `couples/${COUPLE_ID}/bucket_list/item1`; + + beforeEach(seedCouple); + + const VALID_ITEM = { + title: "Skydiving", + addedBy: UID_A, + addedAt: Timestamp.now(), + isCompleted: false, + category: "adventure", + }; + + test("member can create bucket list item — allowed", async () => { + await assertSucceeds(setDoc(doc(alice().firestore(), ITEM_PATH), VALID_ITEM)); + }); + + test("creator must match addedBy — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), ITEM_PATH), { ...VALID_ITEM, addedBy: UID_B }) + ); + }); + + test("invalid category — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), ITEM_PATH), { ...VALID_ITEM, category: "other" }) + ); + }); + + test("member can mark item complete (own completedBy) — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), ITEM_PATH), VALID_ITEM); + }); + await assertSucceeds( + updateDoc(doc(bob().firestore(), ITEM_PATH), { + isCompleted: true, + completedBy: UID_B, + completedAt: Timestamp.now(), + }) + ); + }); + + test("member cannot mark complete with other's completedBy — denied", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), ITEM_PATH), VALID_ITEM); + }); + await assertFails( + updateDoc(doc(alice().firestore(), ITEM_PATH), { + isCompleted: true, + completedBy: UID_B, + completedAt: Timestamp.now(), + }) + ); + }); + + test("addedBy is immutable — denied", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), ITEM_PATH), VALID_ITEM); + }); + await assertFails( + updateDoc(doc(alice().firestore(), ITEM_PATH), { addedBy: UID_B }) + ); + }); + + test("member can delete item — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), ITEM_PATH), VALID_ITEM); + }); + await assertSucceeds(deleteDoc(doc(alice().firestore(), ITEM_PATH))); + }); +}); + +// ── couples/{coupleId}/daily_question/{date} ───────────────────────────────── + +describe("couples/{coupleId}/daily_question/{date}", () => { + const DQ_PATH = `couples/${COUPLE_ID}/daily_question/2026-06-19`; + + beforeEach(seedCouple); + + test("member can read daily question — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), DQ_PATH), { questionId: "q42" }); + }); + await assertSucceeds(getDoc(doc(alice().firestore(), DQ_PATH))); + }); + + test("client cannot write daily question (server-only) — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), DQ_PATH), { questionId: "q42" }) + ); + }); + + // ── daily_question answers ────────────────────────────────────────────────── + + describe("answers/{userId}", () => { + const ANSWER_PATH = `${DQ_PATH}/answers/${UID_A}`; + + beforeEach(async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), DQ_PATH), { questionId: "q42" }); + }); + }); + + test("owner can create own daily answer — allowed", async () => { + await assertSucceeds( + setDoc(doc(alice().firestore(), ANSWER_PATH), { + userId: UID_A, + questionId: "q42", + answerType: "written", + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }) + ); + }); + + test("owner cannot create answer for another user's path — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), `${DQ_PATH}/answers/${UID_B}`), { + userId: UID_B, + questionId: "q42", + answerType: "written", + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }) + ); + }); + + test("partner can read daily answer — allowed", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), ANSWER_PATH), { + userId: UID_A, + questionId: "q42", + answerType: "written", + }); + }); + await assertSucceeds(getDoc(doc(bob().firestore(), ANSWER_PATH))); + }); + + test("outsider cannot read daily answer — denied", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), ANSWER_PATH), { userId: UID_A }); + }); + await assertFails(getDoc(doc(charlie().firestore(), ANSWER_PATH))); + }); + + test("client cannot delete daily answer — denied", async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), ANSWER_PATH), { userId: UID_A }); + }); + await assertFails(deleteDoc(doc(alice().firestore(), ANSWER_PATH))); + }); + }); +}); + +// ── entitlement_events/{eventId} ───────────────────────────────────────────── + +describe("entitlement_events/{eventId}", () => { + test("no client read — denied", async () => { + await assertFails( + getDoc(doc(alice().firestore(), "entitlement_events/event1")) + ); + }); + + test("no client write — denied", async () => { + await assertFails( + setDoc(doc(alice().firestore(), "entitlement_events/event1"), { + processed: true, + }) + ); + }); +}); diff --git a/firestore-tests/tsconfig.json b/firestore-tests/tsconfig.json new file mode 100644 index 00000000..3494344d --- /dev/null +++ b/firestore-tests/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "outDir": "./dist" + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/gitleaks-current.json b/gitleaks-current.json new file mode 100644 index 00000000..35253221 --- /dev/null +++ b/gitleaks-current.json @@ -0,0 +1,42 @@ +[ + { + "Description": "Generic API Key", + "StartLine": 31, + "EndLine": 31, + "StartColumn": 21, + "EndColumn": 67, + "Match": "key\": \"REDACTED\"", + "Secret": "REDACTED", + "File": "app/google-services.json", + "SymlinkFile": "", + "Commit": "", + "Entropy": 4.7851515, + "Author": "", + "Email": "", + "Date": "", + "Message": "", + "Tags": [], + "RuleID": "generic-api-key", + "Fingerprint": "app/google-services.json:generic-api-key:31" + }, + { + "Description": "Generic API Key", + "StartLine": 68, + "EndLine": 68, + "StartColumn": 21, + "EndColumn": 67, + "Match": "key\": \"REDACTED\"", + "Secret": "REDACTED", + "File": "app/google-services.json", + "SymlinkFile": "", + "Commit": "", + "Entropy": 4.7851515, + "Author": "", + "Email": "", + "Date": "", + "Message": "", + "Tags": [], + "RuleID": "generic-api-key", + "Fingerprint": "app/google-services.json:generic-api-key:68" + } +] diff --git a/gitleaks-history.json b/gitleaks-history.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/gitleaks-history.json @@ -0,0 +1 @@ +[]