2026-06-19 03:45:53 -05:00
|
|
|
/**
|
|
|
|
|
* 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,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
RulesTestContext,
|
2026-06-19 03:45:53 -05:00
|
|
|
RulesTestEnvironment,
|
|
|
|
|
} from "@firebase/rules-unit-testing";
|
|
|
|
|
import {
|
|
|
|
|
doc,
|
|
|
|
|
setDoc,
|
|
|
|
|
getDoc,
|
|
|
|
|
updateDoc,
|
|
|
|
|
deleteDoc,
|
|
|
|
|
collection,
|
|
|
|
|
addDoc,
|
|
|
|
|
Timestamp,
|
|
|
|
|
serverTimestamp,
|
|
|
|
|
} from "firebase/firestore";
|
|
|
|
|
|
|
|
|
|
// ── Test environment ──────────────────────────────────────────────────────────
|
|
|
|
|
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
const PROJECT_ID = process.env.GCLOUD_PROJECT ?? "couples-connect-dev";
|
2026-06-19 21:08:55 -05:00
|
|
|
const FIRESTORE_PORT = Number(process.env.FIRESTORE_EMULATOR_PORT ?? "8180");
|
2026-06-19 03:45:53 -05:00
|
|
|
let testEnv: RulesTestEnvironment;
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
let aliceContext: RulesTestContext;
|
|
|
|
|
let bobContext: RulesTestContext;
|
|
|
|
|
let charlieContext: RulesTestContext;
|
|
|
|
|
let anonContext: RulesTestContext;
|
2026-06-19 03:45:53 -05:00
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
testEnv = await initializeTestEnvironment({
|
|
|
|
|
projectId: PROJECT_ID,
|
|
|
|
|
firestore: {
|
|
|
|
|
host: "127.0.0.1",
|
2026-06-19 21:08:55 -05:00
|
|
|
port: FIRESTORE_PORT,
|
2026-06-19 03:45:53 -05:00
|
|
|
},
|
|
|
|
|
});
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
aliceContext = testEnv.authenticatedContext(UID_A);
|
|
|
|
|
bobContext = testEnv.authenticatedContext(UID_B);
|
|
|
|
|
charlieContext = testEnv.authenticatedContext(UID_C);
|
|
|
|
|
anonContext = testEnv.unauthenticatedContext();
|
2026-06-19 03:45:53 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 = {
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
id: COUPLE_ID,
|
2026-06-19 03:45:53 -05:00
|
|
|
userIds: [UID_A, UID_B],
|
|
|
|
|
inviteCode: "ABC123",
|
|
|
|
|
createdAt: 1_000_000,
|
|
|
|
|
streakCount: 0,
|
|
|
|
|
lastAnsweredAt: null,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
encryptionVersion: 2,
|
|
|
|
|
wrappedCoupleKey: "wrapped-key",
|
|
|
|
|
kdfSalt: "salt",
|
|
|
|
|
kdfParams: "argon2id",
|
2026-06-19 03:45:53 -05:00
|
|
|
};
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
const CIPHERTEXT = "enc:v1:YWJj";
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
// Sealed partner-proof answer shapes (schemaVersion 3): sealed:v1: + ≥80 URL-safe base64
|
|
|
|
|
// chars; commitment = sha256: + exactly 43 URL-safe base64 chars (32-byte digest).
|
|
|
|
|
const SEALED_PAYLOAD = "sealed:v1:" + "Ab1-_".repeat(20); // 100 chars
|
|
|
|
|
const COMMITMENT_HASH = "sha256:" + "A".repeat(43);
|
2026-06-19 03:45:53 -05:00
|
|
|
|
|
|
|
|
/** 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,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
const alice = () => aliceContext;
|
|
|
|
|
const bob = () => bobContext;
|
|
|
|
|
const charlie = () => charlieContext;
|
|
|
|
|
const anon = () => anonContext;
|
2026-06-19 03:45:53 -05:00
|
|
|
|
|
|
|
|
// ── 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`))
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(crypto): escrow the sealed-reveal key under the couple key (heals the second-device gap)
The per-user ECIES keypair lived on exactly one phone. A second device — a new
phone, a reinstall — had no private key, so every sealed reveal died there. Worse
than dying: it reported "waiting for partner". The user isn't waiting for anything;
their key is gone. They'd never report that bug accurately, because the app told
them the wrong story.
Fix: escrow, not multi-device fan-out. The private keyset is stored encrypted under
the COUPLE key (AAD = uid) at users/{uid}/devices/primary/secure/escrow; a device
holding the couple key — which is a precondition for reading anything at all —
imports it and becomes crypto-identical to the original. Fan-out (sealing every
release key to N public keys, plus device lifecycle and pruning) buys the same
outcome for an order of magnitude more protocol, and this product has no
multi-device story to justify it.
Escrow leaks nothing: the only party besides the owner who can hold the couple key
is the partner, and everything this keypair protects — release keys for the
partner's OWN answers, restore keyboxes of the SHARED keyset — is material the
partner already has. It adds zero capability to anyone. It's owner-only anyway:
the escrow is a SUBDOC because the parent devices/primary must stay
partner-readable (they seal to the public key) and rules can't gate one field.
That distinction is now the mutation-checked test — making the escrow inherit the
parent's read rule fails exactly "the PARTNER cannot read the escrow".
Corrected the KDoc while I was in there: it claimed a second sign-in "overwrites
users/{uid}/devices/primary". It doesn't — every publish site is existence-gated,
so device 1 keeps working. The real failure was narrower and quieter than the
documentation said, which is its own small lesson about trusting comments.
Heal-forward on Home load (migrateProfileFields shape: idempotent, best-effort,
never blocks Home) so existing users escrow on next launch. Residual case
documented rather than hidden: a user whose only device dies BEFORE ever escrowing
keeps today's behaviour — that key never left the phone and nothing can fix it
retroactively.
10 new rules tests (151 total, mutation-checked). Android suite green, assemble
clean. Rules deploy + the live wipe→recover→sealed-reveal proof still pending.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:28:43 -05:00
|
|
|
// ── users/{uid}/devices/{deviceId} + the private-key escrow ─────────────────
|
|
|
|
|
|
|
|
|
|
describe("users/{uid}/devices/{deviceId}", () => {
|
|
|
|
|
// The parent doc is deliberately PARTNER-READABLE: they need the public key to seal answers to.
|
|
|
|
|
// The escrow subdoc must NOT be — rules can't gate a single field, which is exactly why the
|
|
|
|
|
// escrowed private key lives in its own document.
|
|
|
|
|
const ESCROW = "enc:v1:YWJjZGVm";
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
await seedCouple();
|
|
|
|
|
await seedUser(UID_A, COUPLE_ID);
|
|
|
|
|
await seedUser(UID_B, COUPLE_ID);
|
|
|
|
|
await seedUser(UID_C);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("owner can publish their public key — allowed", async () => {
|
|
|
|
|
await assertSucceeds(setDoc(doc(alice().firestore(), `users/${UID_A}/devices/primary`), {
|
|
|
|
|
deviceId: "primary",
|
|
|
|
|
publicKey: "pub:v1:AAAA",
|
|
|
|
|
platform: "android",
|
|
|
|
|
updatedAt: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a paired partner can READ the public key (they seal to it) — allowed", async () => {
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), `users/${UID_A}/devices/primary`), {
|
|
|
|
|
deviceId: "primary", publicKey: "pub:v1:AAAA", platform: "android", updatedAt: 1,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await assertSucceeds(getDoc(doc(bob().firestore(), `users/${UID_A}/devices/primary`)));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("owner can read their own escrow — allowed", async () => {
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), `users/${UID_A}/devices/primary/secure/escrow`), {
|
|
|
|
|
encryptedPrivateKey: ESCROW, updatedAt: 1,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await assertSucceeds(getDoc(doc(alice().firestore(), `users/${UID_A}/devices/primary/secure/escrow`)));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("the PARTNER cannot read the escrow — denied", async () => {
|
|
|
|
|
// The whole reason the escrow is a subdoc: partner-readable parent, owner-only secret.
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), `users/${UID_A}/devices/primary/secure/escrow`), {
|
|
|
|
|
encryptedPrivateKey: ESCROW, updatedAt: 1,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await assertFails(getDoc(doc(bob().firestore(), `users/${UID_A}/devices/primary/secure/escrow`)));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("an outsider cannot read the escrow — denied", async () => {
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), `users/${UID_A}/devices/primary/secure/escrow`), {
|
|
|
|
|
encryptedPrivateKey: ESCROW, updatedAt: 1,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await assertFails(getDoc(doc(charlie().firestore(), `users/${UID_A}/devices/primary/secure/escrow`)));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("owner can write a ciphertext escrow — allowed", async () => {
|
|
|
|
|
await assertSucceeds(setDoc(doc(alice().firestore(), `users/${UID_A}/devices/primary/secure/escrow`), {
|
|
|
|
|
encryptedPrivateKey: ESCROW,
|
|
|
|
|
updatedAt: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("owner cannot escrow a PLAINTEXT private key — denied", async () => {
|
|
|
|
|
// The one thing that must never reach the server in the clear.
|
|
|
|
|
await assertFails(setDoc(doc(alice().firestore(), `users/${UID_A}/devices/primary/secure/escrow`), {
|
|
|
|
|
encryptedPrivateKey: '{"primaryKeyId":123,"key":[...]}',
|
|
|
|
|
updatedAt: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("the partner cannot write the owner's escrow — denied", async () => {
|
|
|
|
|
await assertFails(setDoc(doc(bob().firestore(), `users/${UID_A}/devices/primary/secure/escrow`), {
|
|
|
|
|
encryptedPrivateKey: ESCROW,
|
|
|
|
|
updatedAt: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("junk keys on the escrow — denied", async () => {
|
|
|
|
|
await assertFails(setDoc(doc(alice().firestore(), `users/${UID_A}/devices/primary/secure/escrow`), {
|
|
|
|
|
encryptedPrivateKey: ESCROW,
|
|
|
|
|
updatedAt: 1,
|
|
|
|
|
hasPremium: true,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("nobody can delete an escrow — denied", async () => {
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), `users/${UID_A}/devices/primary/secure/escrow`), {
|
|
|
|
|
encryptedPrivateKey: ESCROW, updatedAt: 1,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await assertFails(deleteDoc(doc(alice().firestore(), `users/${UID_A}/devices/primary/secure/escrow`)));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-19 03:45:53 -05:00
|
|
|
// ── users/{uid}/notification_queue/{id} ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("users/{uid}/notification_queue/{id}", () => {
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
// The owner may read their own activity feed and flip a notification's `read`
|
|
|
|
|
// flag; creates/deletes stay server-only.
|
|
|
|
|
test("owner can read own notifications — allowed", async () => {
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), `users/${UID_A}/notification_queue/notif1`), {
|
|
|
|
|
type: "partner_answered",
|
|
|
|
|
read: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await assertSucceeds(
|
|
|
|
|
getDoc(doc(alice().firestore(), `users/${UID_A}/notification_queue/notif1`))
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("other user cannot read — denied", async () => {
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), `users/${UID_A}/notification_queue/notif1`), {
|
|
|
|
|
type: "partner_answered",
|
|
|
|
|
read: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-19 03:45:53 -05:00
|
|
|
await assertFails(
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
getDoc(doc(bob().firestore(), `users/${UID_A}/notification_queue/notif1`))
|
2026-06-19 03:45:53 -05:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
test("owner can flip only the read flag — allowed", async () => {
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), `users/${UID_A}/notification_queue/notif1`), {
|
|
|
|
|
type: "partner_answered",
|
|
|
|
|
read: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await assertSucceeds(
|
|
|
|
|
updateDoc(doc(alice().firestore(), `users/${UID_A}/notification_queue/notif1`), {
|
|
|
|
|
read: true,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("no client create — denied", async () => {
|
2026-06-19 03:45:53 -05:00
|
|
|
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",
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
createdAt: Timestamp.now(),
|
2026-06-19 03:45:53 -05:00
|
|
|
expiresAt,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
wrappedCoupleKey: "wrapped-key",
|
|
|
|
|
kdfSalt: "salt",
|
|
|
|
|
kdfParams: "argon2id",
|
2026-06-19 03:45:53 -05:00
|
|
|
...extra,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
// Invite creation moved server-side (createInviteCallable): 6-char codes are
|
|
|
|
|
// enumerable, so ALL direct client writes are denied regardless of shape.
|
|
|
|
|
test("client cannot create invite even with a valid shape (server-only) — denied", async () => {
|
2026-06-19 03:45:53 -05:00
|
|
|
await assertFails(
|
|
|
|
|
setDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`), {
|
|
|
|
|
inviterUserId: UID_A,
|
|
|
|
|
code: INVITE_CODE,
|
|
|
|
|
status: "pending",
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
createdAt: Timestamp.now(),
|
2026-06-19 03:45:53 -05:00
|
|
|
expiresAt,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
wrappedCoupleKey: "wrapped-key",
|
|
|
|
|
kdfSalt: "salt",
|
|
|
|
|
kdfParams: "argon2id",
|
2026-06-19 03:45:53 -05:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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",
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
createdAt: Timestamp.now(),
|
2026-06-19 03:45:53 -05:00
|
|
|
expiresAt,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
wrappedCoupleKey: "wrapped-key",
|
|
|
|
|
kdfSalt: "salt",
|
|
|
|
|
kdfParams: "argon2id",
|
2026-06-19 03:45:53 -05:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("inviter can read own invite — allowed", async () => {
|
|
|
|
|
await seedInvite();
|
|
|
|
|
await assertSucceeds(getDoc(doc(alice().firestore(), `invites/${INVITE_CODE}`)));
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-19 21:46:12 -05:00
|
|
|
test("unpaired user cannot read pending invite to prevent enumeration — denied", async () => {
|
2026-06-19 03:45:53 -05:00
|
|
|
await seedInvite();
|
|
|
|
|
await seedUser(UID_B); // user doc exists, no coupleId
|
2026-06-19 21:46:12 -05:00
|
|
|
await assertFails(getDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`)));
|
2026-06-19 03:45:53 -05:00
|
|
|
});
|
|
|
|
|
|
2026-06-19 21:08:55 -05:00
|
|
|
test("already-paired outsider cannot read invite — denied", async () => {
|
2026-06-19 03:45:53 -05:00
|
|
|
await seedInvite();
|
2026-06-19 21:08:55 -05:00
|
|
|
await seedUser(UID_C, "another-couple");
|
2026-06-19 03:45:53 -05:00
|
|
|
await assertFails(getDoc(doc(charlie().firestore(), `invites/${INVITE_CODE}`)));
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-19 21:46:12 -05:00
|
|
|
test("acceptor cannot directly update invite (server-only) — denied", async () => {
|
2026-06-19 03:45:53 -05:00
|
|
|
await seedInvite();
|
|
|
|
|
await seedUser(UID_B);
|
2026-06-19 21:46:12 -05:00
|
|
|
await assertFails(
|
2026-06-19 03:45:53 -05:00
|
|
|
updateDoc(doc(bob().firestore(), `invites/${INVITE_CODE}`), {
|
|
|
|
|
status: "accepted",
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
acceptedByUserId: UID_B,
|
2026-06-19 03:45:53 -05:00
|
|
|
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",
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
acceptedByUserId: UID_A,
|
2026-06-19 03:45:53 -05:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
test("member cannot inject a custom couple field — denied", async () => {
|
|
|
|
|
await assertFails(
|
2026-06-19 03:45:53 -05:00
|
|
|
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}`)));
|
|
|
|
|
});
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
feat(crypto): wire "change recovery phrase" with a partner handshake (closes the desync landmine)
The API existed and was deliberately unwired, carrying warnings at three layers:
re-wrapping the couple key under a new phrase leaves the PARTNER's stored copy
stale, so their Settings → Security reveals a phrase that unwraps nothing and the
"lost your phrase? ask your partner" path — the whole reason both partners hold it
— hands over a dud. This makes it shippable rather than deleting it.
The phrase can't travel via the server in plaintext, but it can travel sealed to
the key both partners already hold. And the WRAP moves last:
phase 1 publish the new phrase (enc:v1: under the couple key) + phraseGeneration
phase 2 each device confirms it can read it (ack)
phase 3 once BOTH acked, re-wrap under it + phraseWrapGeneration
Until phase 3 the old phrase unwraps everything, so an interrupted change is a
no-op instead of an unrecoverable couple. A device stores the new phrase only when
the wrap is actually made from it — the stored phrase and the wrap never disagree,
which is the invariant the landmine is about. An old client that ignores the fields
never acks, so the change simply never completes: it degrades to "nothing
happened", the right failure direction for a crypto rollout.
Two design notes against the plan. (1) The plan's "disable Rotate mid-handshake"
guard is gone: nothing stores the new phrase until the wrap moves, so a rotation
landing mid-handshake wraps under the phrase everyone still has, and phase 3 runs
in a TRANSACTION that re-reads keyGeneration — without it, a phase-3 write racing a
rotation republishes a wrap of the pre-rotation keyset and rolls the rotation back,
stranding the partner. The transaction subsumes the guard; a UI gate would have been
theatre. (2) Either device completes phase 3, so an offline changer can't leave the
couple showing a phrase the wrap doesn't honour.
I ALSO FOUND THE HARNESS I SHOULD HAVE BEEN USING: firestore-tests/ runs the rules
against the emulator. It immediately proved three bugs in my own rules, two of them
critical, all now fixed and pinned by 20 new tests (141 total, mutation-checked):
- phase 3 was DEAD for every couple. `request.resource.data.keyGeneration` errors
when the field never existed — couples are created without it — so the first
phrase change of any couple was denied AFTER the UI had already shown the user
their new phrase. The dud-phrase outcome, relocated. Both sides now default.
- `phraseWrapGeneration` was in the allowlist but guarded by NOTHING: a bare
one-field write passed (an unchanged wrap short-circuits the wrap clause), and
the partner's client trusts that field as proof the wrap moved — so one write
made them overwrite their working phrase with one that unwraps nothing.
Permanently, silently, no crypto needed. Now only a genuine phase 3 may move it.
- phase 3 never checked the acks server-side (client-only), so a client could
complete before the partner had the phrase — exactly what the handshake exists
to prevent. The rules now require both members' acks at the current generation.
A fourth, caught by the tests themselves: the predicate didn't require the wrap
to actually change, so advancing the generation alone still passed.
Also: the harness revealed the C-ROTATE-001 rules I shipped earlier broke a
standing test ("a member can re-wrap the couple key — allowed"). No live flow used
it (updateWrappedKey was dead code, now deleted), and the test encoded the very
behaviour the hardening removes — rewritten to assert the new invariant plus the
lawful rotation path.
Rules deploy is user-gated and NOT yet done; the client tolerates the old rules
(phase 1 is rejected, nothing breaks). Android suite + 108 functions tests + 141
rules tests green. Live 2-device verification still pending on the throwaway couple.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:57:58 -05:00
|
|
|
// ── Recovery-phrase change handshake ───────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// The phrase is one per-couple secret both partners hold locally; the server never sees it in
|
|
|
|
|
// plaintext. The invariant these protect: THE WRAP AND EVERY DEVICE'S STORED PHRASE MUST NEVER
|
|
|
|
|
// DISAGREE. The client trusts `phraseWrapGeneration` to mean "the wrap now uses the new phrase —
|
|
|
|
|
// store it", so the rules must make that field unforgeable; otherwise one write makes the partner
|
|
|
|
|
// overwrite their working phrase with one that unwraps nothing, permanently and silently.
|
|
|
|
|
describe("recovery-phrase change handshake", () => {
|
|
|
|
|
const PHRASE_BLOB = "enc:v1:" + "AAAA";
|
|
|
|
|
|
|
|
|
|
/** A couple mid-handshake: phrase published at `gen`, acked by whoever `acks` says. */
|
|
|
|
|
async function seedPending(gen: number, acks: Record<string, number>, extra: object = {}) {
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
...COUPLE_DOC,
|
|
|
|
|
encryptedPhraseSync: PHRASE_BLOB,
|
|
|
|
|
phraseGeneration: gen,
|
|
|
|
|
phraseAckedBy: acks,
|
|
|
|
|
...extra,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── phase 1: publish ──
|
|
|
|
|
test("a member can publish a new phrase — allowed", async () => {
|
|
|
|
|
await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
encryptedPhraseSync: PHRASE_BLOB,
|
|
|
|
|
phraseGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("publishing plaintext instead of ciphertext — denied", async () => {
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
encryptedPhraseSync: "just the words in the clear",
|
|
|
|
|
phraseGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("publishing without advancing the generation — denied", async () => {
|
|
|
|
|
await seedPending(3, {});
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
encryptedPhraseSync: "enc:v1:BBBB",
|
|
|
|
|
phraseGeneration: 3,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("an outsider cannot publish a phrase — denied", async () => {
|
|
|
|
|
await assertFails(updateDoc(doc(charlie().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
encryptedPhraseSync: PHRASE_BLOB,
|
|
|
|
|
phraseGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── phase 2: ack ──
|
|
|
|
|
test("a member can ack the published generation — allowed", async () => {
|
|
|
|
|
await seedPending(1, {});
|
|
|
|
|
await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
phraseAckedBy: { [UID_A]: 1 },
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a member cannot forge the PARTNER's ack — denied", async () => {
|
|
|
|
|
// The attack this blocks: forging Bob's ack lets phase 3 fire before his device has the new
|
|
|
|
|
// phrase, leaving him with a phrase that unwraps nothing.
|
|
|
|
|
await seedPending(1, {});
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
phraseAckedBy: { [UID_B]: 1 },
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a member cannot ack a generation that isn't published — denied", async () => {
|
|
|
|
|
await seedPending(1, {});
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
phraseAckedBy: { [UID_A]: 2 },
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a member cannot delete the partner's ack — denied", async () => {
|
|
|
|
|
await seedPending(1, { [UID_A]: 1, [UID_B]: 1 });
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
phraseAckedBy: { [UID_A]: 1 },
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── phase 3: complete ──
|
|
|
|
|
test("completing after BOTH acked — allowed", async () => {
|
|
|
|
|
// Also the regression for the defaulting bug: this couple has never rotated, so keyGeneration
|
|
|
|
|
// is ABSENT. Reading it without a default is an error and denied every first phrase change.
|
|
|
|
|
await seedPending(1, { [UID_A]: 1, [UID_B]: 1 });
|
|
|
|
|
await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
wrappedCoupleKey: "wrapped-under-new-phrase",
|
|
|
|
|
kdfSalt: "s2",
|
|
|
|
|
kdfParams: "p2",
|
|
|
|
|
phraseWrapGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("completing when only ONE partner acked — denied", async () => {
|
|
|
|
|
await seedPending(1, { [UID_A]: 1 });
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
wrappedCoupleKey: "wrapped-under-new-phrase",
|
|
|
|
|
kdfSalt: "s2",
|
|
|
|
|
kdfParams: "p2",
|
|
|
|
|
phraseWrapGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("completing with NO acks — denied", async () => {
|
|
|
|
|
await seedPending(1, {});
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
wrappedCoupleKey: "wrapped-under-new-phrase",
|
|
|
|
|
kdfSalt: "s2",
|
|
|
|
|
kdfParams: "p2",
|
|
|
|
|
phraseWrapGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("completing with a stale ack from a previous generation — denied", async () => {
|
|
|
|
|
// Phase 1 doesn't clear old acks, so a generation-1 ack must not satisfy generation 2.
|
|
|
|
|
await seedPending(2, { [UID_A]: 1, [UID_B]: 1 });
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
wrappedCoupleKey: "w",
|
|
|
|
|
kdfSalt: "s",
|
|
|
|
|
kdfParams: "p",
|
|
|
|
|
phraseWrapGeneration: 2,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── the one-write weapon ──
|
|
|
|
|
test("moving phraseWrapGeneration ALONE, with no re-wrap — denied", async () => {
|
|
|
|
|
// THE critical hole: the partner's device treats phraseWrapGeneration as proof the wrap moved
|
|
|
|
|
// and overwrites its stored phrase. On its own it must be unforgeable.
|
|
|
|
|
await seedPending(1, { [UID_A]: 1, [UID_B]: 1 });
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
phraseWrapGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a rotation cannot smuggle phraseWrapGeneration along — denied", async () => {
|
|
|
|
|
// Forging the "everything is consistent" signal inside a legitimate rotation would stop the
|
|
|
|
|
// partner's client from ever noticing the pending change.
|
|
|
|
|
await seedPending(1, {});
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
wrappedCoupleKey: "rotated",
|
|
|
|
|
kdfSalt: "s",
|
|
|
|
|
kdfParams: "p",
|
|
|
|
|
keyGeneration: 1,
|
|
|
|
|
phraseWrapGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("completing cannot also move the key — denied", async () => {
|
|
|
|
|
await seedPending(1, { [UID_A]: 1, [UID_B]: 1 });
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
wrappedCoupleKey: "w",
|
|
|
|
|
kdfSalt: "s",
|
|
|
|
|
kdfParams: "p",
|
|
|
|
|
phraseWrapGeneration: 1,
|
|
|
|
|
keyGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("phase 1 and phase 3 cannot be combined into one write — denied", async () => {
|
|
|
|
|
await seedPending(1, { [UID_A]: 1, [UID_B]: 1 });
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
encryptedPhraseSync: "enc:v1:CCCC",
|
|
|
|
|
phraseGeneration: 2,
|
|
|
|
|
wrappedCoupleKey: "w",
|
|
|
|
|
kdfSalt: "s",
|
|
|
|
|
kdfParams: "p",
|
|
|
|
|
phraseWrapGeneration: 2,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("phraseWrapGeneration cannot be rolled back — denied", async () => {
|
|
|
|
|
await seedPending(5, { [UID_A]: 5, [UID_B]: 5 }, { phraseWrapGeneration: 5 });
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
wrappedCoupleKey: "w",
|
|
|
|
|
kdfSalt: "s",
|
|
|
|
|
kdfParams: "p",
|
|
|
|
|
phraseWrapGeneration: 3,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("an outsider cannot complete — denied", async () => {
|
|
|
|
|
await seedPending(1, { [UID_A]: 1, [UID_B]: 1 });
|
|
|
|
|
await assertFails(updateDoc(doc(charlie().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
wrappedCoupleKey: "w",
|
|
|
|
|
kdfSalt: "s",
|
|
|
|
|
kdfParams: "p",
|
|
|
|
|
phraseWrapGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
// The v0→v1→v2 client-side migration flow is gone: couples are created at
|
|
|
|
|
// encryptionVersion 2 server-side, and the only key-material update a client may
|
|
|
|
|
// make is the recovery-wrap re-key (wrappedCoupleKey/kdfSalt/kdfParams together).
|
|
|
|
|
describe("recovery wrap re-key", () => {
|
feat(crypto): wire "change recovery phrase" with a partner handshake (closes the desync landmine)
The API existed and was deliberately unwired, carrying warnings at three layers:
re-wrapping the couple key under a new phrase leaves the PARTNER's stored copy
stale, so their Settings → Security reveals a phrase that unwraps nothing and the
"lost your phrase? ask your partner" path — the whole reason both partners hold it
— hands over a dud. This makes it shippable rather than deleting it.
The phrase can't travel via the server in plaintext, but it can travel sealed to
the key both partners already hold. And the WRAP moves last:
phase 1 publish the new phrase (enc:v1: under the couple key) + phraseGeneration
phase 2 each device confirms it can read it (ack)
phase 3 once BOTH acked, re-wrap under it + phraseWrapGeneration
Until phase 3 the old phrase unwraps everything, so an interrupted change is a
no-op instead of an unrecoverable couple. A device stores the new phrase only when
the wrap is actually made from it — the stored phrase and the wrap never disagree,
which is the invariant the landmine is about. An old client that ignores the fields
never acks, so the change simply never completes: it degrades to "nothing
happened", the right failure direction for a crypto rollout.
Two design notes against the plan. (1) The plan's "disable Rotate mid-handshake"
guard is gone: nothing stores the new phrase until the wrap moves, so a rotation
landing mid-handshake wraps under the phrase everyone still has, and phase 3 runs
in a TRANSACTION that re-reads keyGeneration — without it, a phase-3 write racing a
rotation republishes a wrap of the pre-rotation keyset and rolls the rotation back,
stranding the partner. The transaction subsumes the guard; a UI gate would have been
theatre. (2) Either device completes phase 3, so an offline changer can't leave the
couple showing a phrase the wrap doesn't honour.
I ALSO FOUND THE HARNESS I SHOULD HAVE BEEN USING: firestore-tests/ runs the rules
against the emulator. It immediately proved three bugs in my own rules, two of them
critical, all now fixed and pinned by 20 new tests (141 total, mutation-checked):
- phase 3 was DEAD for every couple. `request.resource.data.keyGeneration` errors
when the field never existed — couples are created without it — so the first
phrase change of any couple was denied AFTER the UI had already shown the user
their new phrase. The dud-phrase outcome, relocated. Both sides now default.
- `phraseWrapGeneration` was in the allowlist but guarded by NOTHING: a bare
one-field write passed (an unchanged wrap short-circuits the wrap clause), and
the partner's client trusts that field as proof the wrap moved — so one write
made them overwrite their working phrase with one that unwraps nothing.
Permanently, silently, no crypto needed. Now only a genuine phase 3 may move it.
- phase 3 never checked the acks server-side (client-only), so a client could
complete before the partner had the phrase — exactly what the handshake exists
to prevent. The rules now require both members' acks at the current generation.
A fourth, caught by the tests themselves: the predicate didn't require the wrap
to actually change, so advancing the generation alone still passed.
Also: the harness revealed the C-ROTATE-001 rules I shipped earlier broke a
standing test ("a member can re-wrap the couple key — allowed"). No live flow used
it (updateWrappedKey was dead code, now deleted), and the test encoded the very
behaviour the hardening removes — rewritten to assert the new invariant plus the
lawful rotation path.
Rules deploy is user-gated and NOT yet done; the client tolerates the old rules
(phase 1 is rejected, nothing breaks). Android suite + 108 functions tests + 141
rules tests green. Live 2-device verification still pending on the throwaway couple.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:57:58 -05:00
|
|
|
// C-ROTATE-001: a bare re-wrap USED to be allowed, and that was the hole. The wrap is what every
|
|
|
|
|
// device's stored phrase must match, so it may only change for a reason the partner's device can
|
|
|
|
|
// see: a key rotation (keyGeneration advances) or a completed phrase handshake. A silent re-wrap
|
|
|
|
|
// strands the partner on content they can't read, with a phrase that unwraps nothing.
|
|
|
|
|
test("a member cannot re-wrap the couple key with no reason — denied", async () => {
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
wrappedCoupleKey: "new-wrapped-key",
|
|
|
|
|
kdfSalt: "new-salt",
|
|
|
|
|
kdfParams: "argon2id-v2",
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
feat(crypto): wire "change recovery phrase" with a partner handshake (closes the desync landmine)
The API existed and was deliberately unwired, carrying warnings at three layers:
re-wrapping the couple key under a new phrase leaves the PARTNER's stored copy
stale, so their Settings → Security reveals a phrase that unwraps nothing and the
"lost your phrase? ask your partner" path — the whole reason both partners hold it
— hands over a dud. This makes it shippable rather than deleting it.
The phrase can't travel via the server in plaintext, but it can travel sealed to
the key both partners already hold. And the WRAP moves last:
phase 1 publish the new phrase (enc:v1: under the couple key) + phraseGeneration
phase 2 each device confirms it can read it (ack)
phase 3 once BOTH acked, re-wrap under it + phraseWrapGeneration
Until phase 3 the old phrase unwraps everything, so an interrupted change is a
no-op instead of an unrecoverable couple. A device stores the new phrase only when
the wrap is actually made from it — the stored phrase and the wrap never disagree,
which is the invariant the landmine is about. An old client that ignores the fields
never acks, so the change simply never completes: it degrades to "nothing
happened", the right failure direction for a crypto rollout.
Two design notes against the plan. (1) The plan's "disable Rotate mid-handshake"
guard is gone: nothing stores the new phrase until the wrap moves, so a rotation
landing mid-handshake wraps under the phrase everyone still has, and phase 3 runs
in a TRANSACTION that re-reads keyGeneration — without it, a phase-3 write racing a
rotation republishes a wrap of the pre-rotation keyset and rolls the rotation back,
stranding the partner. The transaction subsumes the guard; a UI gate would have been
theatre. (2) Either device completes phase 3, so an offline changer can't leave the
couple showing a phrase the wrap doesn't honour.
I ALSO FOUND THE HARNESS I SHOULD HAVE BEEN USING: firestore-tests/ runs the rules
against the emulator. It immediately proved three bugs in my own rules, two of them
critical, all now fixed and pinned by 20 new tests (141 total, mutation-checked):
- phase 3 was DEAD for every couple. `request.resource.data.keyGeneration` errors
when the field never existed — couples are created without it — so the first
phrase change of any couple was denied AFTER the UI had already shown the user
their new phrase. The dud-phrase outcome, relocated. Both sides now default.
- `phraseWrapGeneration` was in the allowlist but guarded by NOTHING: a bare
one-field write passed (an unchanged wrap short-circuits the wrap clause), and
the partner's client trusts that field as proof the wrap moved — so one write
made them overwrite their working phrase with one that unwraps nothing.
Permanently, silently, no crypto needed. Now only a genuine phase 3 may move it.
- phase 3 never checked the acks server-side (client-only), so a client could
complete before the partner had the phrase — exactly what the handshake exists
to prevent. The rules now require both members' acks at the current generation.
A fourth, caught by the tests themselves: the predicate didn't require the wrap
to actually change, so advancing the generation alone still passed.
Also: the harness revealed the C-ROTATE-001 rules I shipped earlier broke a
standing test ("a member can re-wrap the couple key — allowed"). No live flow used
it (updateWrappedKey was dead code, now deleted), and the test encoded the very
behaviour the hardening removes — rewritten to assert the new invariant plus the
lawful rotation path.
Rules deploy is user-gated and NOT yet done; the client tolerates the old rules
(phase 1 is rejected, nothing breaks). Android suite + 108 functions tests + 141
rules tests green. Live 2-device verification still pending on the throwaway couple.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:57:58 -05:00
|
|
|
test("a member can re-wrap as part of a key rotation — allowed", async () => {
|
|
|
|
|
await assertSucceeds(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
wrappedCoupleKey: "rotated-wrapped-key",
|
|
|
|
|
kdfSalt: "new-salt",
|
|
|
|
|
kdfParams: "argon2id-v2",
|
|
|
|
|
keyGeneration: 1,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a rotation cannot roll keyGeneration backwards — denied", async () => {
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), `couples/${COUPLE_ID}`), { ...COUPLE_DOC, keyGeneration: 5 });
|
|
|
|
|
});
|
|
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
|
|
|
|
wrappedCoupleKey: "old-wrapped-key",
|
|
|
|
|
kdfSalt: "s",
|
|
|
|
|
kdfParams: "p",
|
|
|
|
|
keyGeneration: 4,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
test("a member cannot change encryptionVersion alongside the wrap — denied", async () => {
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
wrappedCoupleKey: "new-wrapped-key",
|
|
|
|
|
kdfSalt: "new-salt",
|
|
|
|
|
kdfParams: "argon2id-v2",
|
|
|
|
|
encryptionVersion: 3,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
test("a member cannot downgrade encryptionVersion — denied", async () => {
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
await assertFails(updateDoc(doc(alice().firestore(), `couples/${COUPLE_ID}`), {
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
encryptionVersion: 0,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-19 03:45:53 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── 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(),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-19 21:08:55 -05:00
|
|
|
test("non-starter can complete an active shared session — allowed", async () => {
|
2026-06-19 03:45:53 -05:00
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), SESSION_PATH), {
|
|
|
|
|
startedByUserId: UID_A,
|
|
|
|
|
status: "active",
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-19 21:08:55 -05:00
|
|
|
await assertSucceeds(
|
2026-06-19 03:45:53 -05:00
|
|
|
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>) {
|
2026-06-19 21:08:55 -05:00
|
|
|
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,
|
2026-06-19 03:45:53 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
// Thread answers are sealed partner-proof (schemaVersion 3): the content rides in
|
|
|
|
|
// an encryptedPayload with a commitment hash; plaintext content fields are rejected
|
|
|
|
|
// by the create shape's hasOnly.
|
|
|
|
|
test("owner can write own sealed answer — allowed", async () => {
|
2026-06-19 03:45:53 -05:00
|
|
|
await assertSucceeds(
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
setDoc(doc(alice().firestore(), ANSWER_PATH), {
|
|
|
|
|
userId: UID_A,
|
|
|
|
|
questionId: "q1",
|
|
|
|
|
answerType: "written",
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
encryptedPayload: SEALED_PAYLOAD,
|
|
|
|
|
commitmentHash: COMMITMENT_HASH,
|
|
|
|
|
schemaVersion: 3,
|
|
|
|
|
answerKeyReleased: false,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
createdAt: serverTimestamp(),
|
|
|
|
|
updatedAt: serverTimestamp(),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("owner cannot write a plaintext answer — denied", async () => {
|
|
|
|
|
await assertFails(
|
2026-06-19 03:45:53 -05:00
|
|
|
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",
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-19 21:08:55 -05:00
|
|
|
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(),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-19 03:45:53 -05:00
|
|
|
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)));
|
|
|
|
|
});
|
2026-06-19 21:29:42 -05:00
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-06-19 03:45:53 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── 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,
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
text: CIPHERTEXT,
|
2026-06-19 03:45:53 -05:00
|
|
|
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 () => {
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
const msgPath = `${MSGS_PATH}/readable-message`;
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), msgPath), {
|
2026-06-19 03:45:53 -05:00
|
|
|
authorUserId: UID_A,
|
|
|
|
|
text: "Hi",
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
await assertSucceeds(getDoc(doc(bob().firestore(), msgPath)));
|
2026-06-19 03:45:53 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("author can update own message — allowed", async () => {
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
const msgPath = `${MSGS_PATH}/owned-message`;
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), msgPath), {
|
2026-06-19 03:45:53 -05:00
|
|
|
authorUserId: UID_A,
|
2026-06-19 21:29:42 -05:00
|
|
|
text: CIPHERTEXT,
|
2026-06-19 03:45:53 -05:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await assertSucceeds(
|
2026-06-19 21:29:42 -05:00
|
|
|
setDoc(doc(alice().firestore(), msgPath), { text: CIPHERTEXT })
|
2026-06-19 03:45:53 -05:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("other member cannot update someone else's message — denied", async () => {
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
const msgPath = `${MSGS_PATH}/other-message`;
|
|
|
|
|
await testEnv.withSecurityRulesDisabled(async (ctx) => {
|
|
|
|
|
await setDoc(doc(ctx.firestore(), msgPath), {
|
2026-06-19 03:45:53 -05:00
|
|
|
authorUserId: UID_A,
|
|
|
|
|
text: "Hi",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
await assertFails(
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
updateDoc(doc(bob().firestore(), msgPath), { text: "Tampered" })
|
2026-06-19 03:45:53 -05:00
|
|
|
);
|
|
|
|
|
});
|
2026-06-19 21:29:42 -05:00
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-06-19 03:45:53 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── reactions ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("reactions/{reactionId}", () => {
|
|
|
|
|
const REACTIONS_PATH = `${THREAD_PATH}/reactions`;
|
|
|
|
|
|
|
|
|
|
test("member can add reaction with own userId — allowed", async () => {
|
2026-06-19 21:29:42 -05:00
|
|
|
await seedThread();
|
2026-06-19 03:45:53 -05:00
|
|
|
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(),
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-06-19 21:29:42 -05:00
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-06-19 03:45:53 -05:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── couples/{coupleId}/date_swipes/{dateIdeaId} ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("couples/{coupleId}/date_swipes/{dateIdeaId}", () => {
|
|
|
|
|
const SWIPE_PATH = `couples/${COUPLE_ID}/date_swipes/idea1`;
|
|
|
|
|
|
|
|
|
|
beforeEach(seedCouple);
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
// Swipe actions are E2E ciphertext (the server can't read date preferences) and
|
|
|
|
|
// swipedAt is an epoch-millis number.
|
|
|
|
|
test("member can write own encrypted swipe action — allowed", async () => {
|
2026-06-19 03:45:53 -05:00
|
|
|
await assertSucceeds(
|
|
|
|
|
setDoc(doc(alice().firestore(), SWIPE_PATH), {
|
|
|
|
|
actions: {
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
[UID_A]: { action: CIPHERTEXT, swipedAt: Date.now() },
|
2026-06-19 03:45:53 -05:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
test("plaintext swipe action — denied", async () => {
|
2026-06-19 03:45:53 -05:00
|
|
|
await assertFails(
|
|
|
|
|
setDoc(doc(alice().firestore(), SWIPE_PATH), {
|
|
|
|
|
actions: {
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
[UID_A]: { action: "love", swipedAt: Date.now() },
|
2026-06-19 03:45:53 -05:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("member cannot write other user's action — denied", async () => {
|
|
|
|
|
await assertFails(
|
|
|
|
|
setDoc(doc(alice().firestore(), SWIPE_PATH), {
|
|
|
|
|
actions: {
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
[UID_B]: { action: CIPHERTEXT, swipedAt: Date.now() },
|
2026-06-19 03:45:53 -05:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
// Bucket-list user content (title/description) is E2E ciphertext.
|
2026-06-19 03:45:53 -05:00
|
|
|
const VALID_ITEM = {
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
title: CIPHERTEXT,
|
2026-06-19 03:45:53 -05:00
|
|
|
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" })
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
test("plaintext title — denied", async () => {
|
|
|
|
|
await assertFails(
|
|
|
|
|
setDoc(doc(alice().firestore(), ITEM_PATH), { ...VALID_ITEM, title: "Skydiving" })
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-19 03:45:53 -05:00
|
|
|
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" });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
// Couple-key daily answers (schemaVersion 2): the answer doc is metadata-only
|
|
|
|
|
// (content lives in the read-gated `secure` subdoc); answerDate must match the
|
|
|
|
|
// path date so metadata can't disagree with where the doc lands.
|
|
|
|
|
const VALID_DAILY_ANSWER = {
|
|
|
|
|
userId: UID_A,
|
|
|
|
|
questionId: "q42",
|
|
|
|
|
answerType: "written",
|
|
|
|
|
schemaVersion: 2,
|
|
|
|
|
answerDate: "2026-06-19",
|
|
|
|
|
isRevealed: false,
|
|
|
|
|
createdAt: serverTimestamp(),
|
|
|
|
|
updatedAt: serverTimestamp(),
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-19 03:45:53 -05:00
|
|
|
test("owner can create own daily answer — allowed", async () => {
|
|
|
|
|
await assertSucceeds(
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
setDoc(doc(alice().firestore(), ANSWER_PATH), VALID_DAILY_ANSWER)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("answerDate must match the path date — denied", async () => {
|
|
|
|
|
await assertFails(
|
2026-06-19 03:45:53 -05:00
|
|
|
setDoc(doc(alice().firestore(), ANSWER_PATH), {
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
...VALID_DAILY_ANSWER,
|
|
|
|
|
answerDate: "2026-06-20",
|
2026-06-19 03:45:53 -05:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("owner cannot create answer for another user's path — denied", async () => {
|
|
|
|
|
await assertFails(
|
|
|
|
|
setDoc(doc(alice().firestore(), `${DQ_PATH}/answers/${UID_B}`), {
|
feat: security hardening, CI, analytics, invite deep links, solo mode, weekly recap
Roadmap phases 0-3.1 (see plan). Verified: unit tests, functions tests,
firestore rules 119/119, live emulator smokes.
Security + CI:
- Move App Check debug token out of committed build.gradle.kts to
local.properties (secret() resolver: local.properties -> -P -> env);
admin service-account key relocated outside the repo.
- Add GitHub Actions (android unit/scan/gitleaks, backend functions +
rules emulator via emulators:exec) with a CI google-services.json stub
and a reviewed .gitleaksignore baseline.
- Fix firestore rules tests to the current deployed contract (was 108/118,
now 119/119).
Analytics + consent:
- FirebaseRetentionAnalytics behind a fail-safe composite sink, single-
sourced hashing (AnalyticsHashing), Settings -> Privacy opt-out toggle
gating Firebase + the sink, and the ~11 missing event call sites.
- NoPlaintextInAnalyticsTest guards the content-free rule at build time.
Funnel:
- Invite deep links (https App Link + closer:// fallback), JoinLink parser,
PendingJoinCodeStore, prefilled/already-paired pairing, richer share text.
- Solo pre-pairing: UnpairedLockedCard on Play, SoloAnswerMigrator + consent
dialog moving pre-pairing answers into Couple Lore.
Weekly recap:
- WeeklyRecapScreen driven by the existing generator from local answer data,
Home card rewired, content-free shareable card.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:33:38 -05:00
|
|
|
...VALID_DAILY_ANSWER,
|
2026-06-19 03:45:53 -05:00
|
|
|
userId: UID_B,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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)));
|
|
|
|
|
});
|
2026-06-19 21:29:42 -05:00
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-06-19 03:45:53 -05:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
// ── 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",
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-19 03:45:53 -05:00
|
|
|
// ── 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,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-06 21:06:57 -05:00
|
|
|
|
|
|
|
|
// ── aggregate_stats/{doc} ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("aggregate_stats/{doc}", () => {
|
|
|
|
|
test("no client read — denied (export-only cross-couple rollups)", async () => {
|
|
|
|
|
await assertFails(
|
|
|
|
|
getDoc(doc(alice().firestore(), "aggregate_stats/outcomes"))
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("no client write — denied", async () => {
|
|
|
|
|
await assertFails(
|
|
|
|
|
setDoc(doc(alice().firestore(), "aggregate_stats/outcomes"), { feltCloserPct: 99 })
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|