1225 lines
39 KiB
TypeScript
1225 lines
39 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)));
|
|
});
|
|
});
|
|
|
|
// ── 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: "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`;
|
|
|
|
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,
|
|
})
|
|
);
|
|
});
|
|
});
|