/** * 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, RulesTestContext, RulesTestEnvironment, } from "@firebase/rules-unit-testing"; import { doc, setDoc, getDoc, updateDoc, deleteDoc, collection, addDoc, Timestamp, serverTimestamp, } from "firebase/firestore"; // ── Test environment ────────────────────────────────────────────────────────── const PROJECT_ID = process.env.GCLOUD_PROJECT ?? "couples-connect-dev"; let testEnv: RulesTestEnvironment; let aliceContext: RulesTestContext; let bobContext: RulesTestContext; let charlieContext: RulesTestContext; let anonContext: RulesTestContext; beforeAll(async () => { testEnv = await initializeTestEnvironment({ projectId: PROJECT_ID, firestore: { host: "127.0.0.1", port: 8180, }, }); aliceContext = testEnv.authenticatedContext(UID_A); bobContext = testEnv.authenticatedContext(UID_B); charlieContext = testEnv.authenticatedContext(UID_C); anonContext = testEnv.unauthenticatedContext(); }); 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 = { id: COUPLE_ID, userIds: [UID_A, UID_B], inviteCode: "ABC123", createdAt: 1_000_000, streakCount: 0, lastAnsweredAt: null, encryptionVersion: 2, wrappedCoupleKey: "wrapped-key", kdfSalt: "salt", kdfParams: "argon2id", }; const CIPHERTEXT = "enc:v1:YWJj"; /** 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 = () => aliceContext; const bob = () => bobContext; const charlie = () => charlieContext; const anon = () => anonContext; // ── 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", createdAt: Timestamp.now(), expiresAt, wrappedCoupleKey: "wrapped-key", kdfSalt: "salt", kdfParams: "argon2id", ...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", createdAt: Timestamp.now(), expiresAt, wrappedCoupleKey: "wrapped-key", kdfSalt: "salt", kdfParams: "argon2id", }) ); }); 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", createdAt: Timestamp.now(), expiresAt, wrappedCoupleKey: "wrapped-key", kdfSalt: "salt", kdfParams: "argon2id", 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", createdAt: Timestamp.now(), expiresAt, wrappedCoupleKey: "wrapped-key", kdfSalt: "salt", kdfParams: "argon2id", }) ); }); 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", acceptedByUserId: 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", acceptedByUserId: 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 cannot inject a custom couple field — denied", async () => { await assertFails( 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}`))); }); describe("encryption migration", () => { const legacyCouple = { id: COUPLE_ID, userIds: [UID_A, UID_B], inviteCode: "ABC123", createdAt: 1_000_000, streakCount: 0, lastAnsweredAt: null, encryptionVersion: 0, }; test("a member can start encryption migration — allowed", async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), legacyCouple); }); await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), { wrappedCoupleKey: "wrapped-key", kdfSalt: "salt", kdfParams: "argon2id", encryptionVersion: 1, encryptionMigrationUsers: {}, })); }); test("a member can mark only their own migration complete — allowed", async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { const versionOne = { ...COUPLE_DOC, encryptionVersion: 1 }; delete (versionOne as Record).encryptionMigrationUsers; await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), versionOne); }); await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), { encryptionVersion: 1, encryptionMigrationUsers: { [UID_A]: true }, })); }); test("a member cannot claim their partner migrated — denied", async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), { ...COUPLE_DOC, encryptionVersion: 1, encryptionMigrationUsers: {}, }); }); await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), { encryptionVersion: 1, encryptionMigrationUsers: { [UID_B]: true }, })); }); test("version 2 requires both partners to complete migration — denied", async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), { ...COUPLE_DOC, encryptionVersion: 1, encryptionMigrationUsers: {}, }); }); await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), { encryptionVersion: 2, encryptionMigrationUsers: { [UID_A]: true }, })); }); test("the second partner can complete migration and promote to version 2 — allowed", async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), { ...COUPLE_DOC, encryptionVersion: 1, encryptionMigrationUsers: { [UID_A]: true }, }); }); await assertSucceeds(updateDoc(doc(bob().firestore(), `couples/${COUPLE_ID}`), { encryptionVersion: 2, encryptionMigrationUsers: { [UID_A]: true, [UID_B]: true }, })); }); }); }); // ── 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: CIPHERTEXT, createdAt: serverTimestamp(), updatedAt: serverTimestamp(), }) ); }); test("owner cannot write a plaintext answer — denied", async () => { await assertFails( 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: CIPHERTEXT, 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 msgPath = `${MSGS_PATH}/readable-message`; await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), msgPath), { authorUserId: UID_A, text: "Hi", }); }); await assertSucceeds(getDoc(doc(bob().firestore(), msgPath))); }); test("author can update own message — allowed", async () => { const msgPath = `${MSGS_PATH}/owned-message`; await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), msgPath), { authorUserId: UID_A, text: "Hi", }); }); await assertSucceeds( updateDoc(doc(alice().firestore(), msgPath), { text: CIPHERTEXT }) ); }); test("other member cannot update someone else's message — denied", async () => { const msgPath = `${MSGS_PATH}/other-message`; await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), msgPath), { authorUserId: UID_A, text: "Hi", }); }); await assertFails( updateDoc(doc(bob().firestore(), msgPath), { 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))); }); }); }); // ── Private game answers ──────────────────────────────────────────────────── describe.each(["this_or_that", "desire_sync", "how_well", "wheel"])( "couples/{coupleId}/%s/{sessionId}", (gameCollection) => { const gamePath = () => `couples/${COUPLE_ID}/${gameCollection}/session1`; beforeEach(seedCouple); test("a member can submit an encrypted answer — allowed", async () => { await assertSucceeds(setDoc(doc(alice().firestore(), gamePath()), { answers: { [UID_A]: CIPHERTEXT }, })); }); test("a plaintext answer is rejected — denied", async () => { await assertFails(setDoc(doc(alice().firestore(), gamePath()), { answers: { [UID_A]: "private answer" }, })); }); test("a partner cannot overwrite the other user's answer — denied", async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), gamePath()), { answers: { [UID_A]: CIPHERTEXT }, }); }); await assertFails(updateDoc(doc(bob().firestore(), gamePath()), { [`answers.${UID_A}`]: "enc:v1:ZGVm", })); }); test("a partner can add their own encrypted answer — allowed", async () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), gamePath()), { answers: { [UID_A]: CIPHERTEXT }, }); }); await assertSucceeds(updateDoc(doc(bob().firestore(), gamePath()), { [`answers.${UID_B}`]: "enc:v1:ZGVm", })); }); } ); // ── 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, }) ); }); });