Closer/firestore-tests/rules.test.ts

1295 lines
41 KiB
TypeScript

/**
* 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";
const FIRESTORE_PORT = Number(process.env.FIRESTORE_EMULATOR_PORT ?? "8180");
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: FIRESTORE_PORT,
},
});
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<string, unknown>) {
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("already-paired outsider cannot read invite — denied", async () => {
await seedInvite();
await seedUser(UID_C, "another-couple");
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<string, unknown>).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 can complete an active shared session — allowed", async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), SESSION_PATH), {
startedByUserId: UID_A,
status: "active",
});
});
await assertSucceeds(
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<string, unknown>) {
await setDoc(doc(alice().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}`;
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("an outsider cannot write under their own user id — denied", async () => {
await assertFails(
setDoc(doc(charlie().firestore(), `${THREAD_PATH}/answers/${UID_C}`), {
userId: UID_C,
questionId: "q1",
answerType: "written",
writtenText: CIPHERTEXT,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
})
);
});
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)));
});
test("create with extra field is denied", async () => {
await seedThread();
await assertFails(
setDoc(doc(alice().firestore(), ANSWER_PATH), {
userId: UID_A,
questionId: "q1",
answerType: "written",
writtenText: CIPHERTEXT,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
maliciousField: "injected",
})
);
});
});
// ── messages ───────────────────────────────────────────────────────────────
describe("messages/{messageId}", () => {
const MSGS_PATH = `${THREAD_PATH}/messages`;
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: CIPHERTEXT,
});
});
await assertSucceeds(
setDoc(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" })
);
});
test("create with extra field is denied", async () => {
await seedThread();
await assertFails(
addDoc(collection(alice().firestore(), MSGS_PATH), {
authorUserId: UID_A,
text: CIPHERTEXT,
createdAt: serverTimestamp(),
maliciousField: "injected",
})
);
});
test("update with extra field is denied", async () => {
const msgPath = `${MSGS_PATH}/extra-field-message`;
await seedThread();
await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), msgPath), {
authorUserId: UID_A,
text: CIPHERTEXT,
});
});
await assertFails(
updateDoc(doc(alice().firestore(), msgPath), {
text: CIPHERTEXT,
maliciousField: "injected",
})
);
});
});
// ── reactions ──────────────────────────────────────────────────────────────
describe("reactions/{reactionId}", () => {
const REACTIONS_PATH = `${THREAD_PATH}/reactions`;
test("member can add reaction with own userId — allowed", async () => {
await seedThread();
await assertSucceeds(
setDoc(doc(alice().firestore(), `${REACTIONS_PATH}/${UID_A}_${UID_B}`), {
userId: UID_A,
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(),
})
);
});
test("create with extra field is denied", async () => {
await seedThread();
await assertFails(
setDoc(doc(alice().firestore(), `${REACTIONS_PATH}/${UID_A}_${UID_B}`), {
userId: UID_A,
emoji: "❤️",
createdAt: serverTimestamp(),
maliciousField: "injected",
})
);
});
});
});
// ── 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)));
});
test("create with extra field is denied", async () => {
await assertFails(
setDoc(doc(alice().firestore(), ANSWER_PATH), {
userId: UID_A,
questionId: "q42",
answerType: "written",
writtenText: CIPHERTEXT,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
maliciousField: "injected",
})
);
});
});
});
// ── 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,
})
);
});
});