From d224a8ce8ab32eadeff2932e73d8ed1d5d5b0ceb Mon Sep 17 00:00:00 2001 From: Sherwin Heydarbeygi Date: Tue, 21 Apr 2026 13:08:39 -0400 Subject: [PATCH] feat: hash PII (email/SMS) in SharedPreferences at rest (#2614) --- .../java/com/onesignal/common/PIIHasher.kt | 25 +++ .../onesignal/common/modeling/ModelStore.kt | 12 +- .../user/internal/EmailSubscription.kt | 6 +- .../user/internal/SmsSubscription.kt | 6 +- .../subscriptions/SubscriptionList.kt | 18 +- .../subscriptions/SubscriptionModelStore.kt | 16 ++ .../subscriptions/impl/SubscriptionManager.kt | 16 +- .../com/onesignal/common/PIIHasherTests.kt | 60 ++++++ .../subscriptions/SubscriptionManagerTests.kt | 201 ++++++++++++++++++ .../SubscriptionModelStoreTests.kt | 124 +++++++++++ 10 files changed, 476 insertions(+), 8 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/PIIHasherTests.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStoreTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt new file mode 100644 index 0000000000..31a6028fca --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/PIIHasher.kt @@ -0,0 +1,25 @@ +package com.onesignal.common + +import java.security.MessageDigest + +/** + * Deterministic SHA-256 hashing for PII fields (email, phone number) so that + * sensitive data stored in SharedPreferences is not readable in plain text on + * rooted devices or via ADB backup. + * + * The hash is hex-encoded and always 64 characters long. + */ +object PIIHasher { + private const val SHA256_HEX_LENGTH = 64 + private val SHA256_HEX_REGEX = Regex("^[a-f0-9]{$SHA256_HEX_LENGTH}$") + + /** Returns the lowercase hex-encoded SHA-256 hash of [value]. */ + fun hash(value: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val bytes = digest.digest(value.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } + } + + /** Returns `true` if [value] looks like a 64-char lowercase hex SHA-256 digest. */ + fun isHashed(value: String): Boolean = SHA256_HEX_REGEX.matches(value) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/modeling/ModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/modeling/ModelStore.kt index f7c41a410b..fc4b6566b0 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/modeling/ModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/modeling/ModelStore.kt @@ -7,6 +7,7 @@ import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.internal.logging.Logging import org.json.JSONArray +import org.json.JSONObject /** * The abstract implementation of a model store. Implements all but the [create] method, @@ -212,13 +213,22 @@ abstract class ModelStore( val jsonArray = JSONArray() synchronized(models) { for (model in models) { - jsonArray.put(model.toJSON()) + jsonArray.put(transformJsonForPersistence(model, model.toJSON())) } } _prefs.saveString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.MODEL_STORE_PREFIX + name, jsonArray.toString()) } + /** + * Hook for subclasses to transform a model's JSON representation before it is + * written to SharedPreferences. The default implementation returns the JSON unchanged. + */ + protected open fun transformJsonForPersistence( + model: TModel, + json: JSONObject, + ): JSONObject = json + override fun subscribe(handler: IModelStoreChangeHandler) = changeSubscription.subscribe(handler) override fun unsubscribe(handler: IModelStoreChangeHandler) = changeSubscription.unsubscribe(handler) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/EmailSubscription.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/EmailSubscription.kt index 1160db5010..9cad21041a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/EmailSubscription.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/EmailSubscription.kt @@ -1,5 +1,6 @@ package com.onesignal.user.internal +import com.onesignal.common.PIIHasher import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.subscriptions.IEmailSubscription @@ -7,5 +8,8 @@ internal class EmailSubscription( model: SubscriptionModel, ) : Subscription(model), IEmailSubscription { override val email: String - get() = model.address + get() { + val address = model.address + return if (PIIHasher.isHashed(address)) "" else address + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/SmsSubscription.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/SmsSubscription.kt index e40c9bc2a9..29fe962902 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/SmsSubscription.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/SmsSubscription.kt @@ -1,5 +1,6 @@ package com.onesignal.user.internal +import com.onesignal.common.PIIHasher import com.onesignal.user.internal.subscriptions.SubscriptionModel import com.onesignal.user.subscriptions.ISmsSubscription @@ -7,5 +8,8 @@ internal class SmsSubscription( model: SubscriptionModel, ) : Subscription(model), ISmsSubscription { override val number: String - get() = model.address + get() { + val address = model.address + return if (PIIHasher.isHashed(address)) "" else address + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionList.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionList.kt index 7bfd086105..ab0d5488e5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionList.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionList.kt @@ -1,5 +1,7 @@ package com.onesignal.user.internal.subscriptions +import com.onesignal.common.PIIHasher +import com.onesignal.user.internal.Subscription import com.onesignal.user.subscriptions.IEmailSubscription import com.onesignal.user.subscriptions.IPushSubscription import com.onesignal.user.subscriptions.ISmsSubscription @@ -30,15 +32,27 @@ class SubscriptionList(val collection: List, private val _fallbac /** * Retrieve the Email subscription with the matching email, if there is one. + * Compares against the underlying model address (raw or hashed) so lookups + * work both before and after server hydration. */ fun getByEmail(email: String): IEmailSubscription? { - return emails.firstOrNull { it.email == email } + val hashed = PIIHasher.hash(email) + return emails.firstOrNull { + val address = (it as Subscription).model.address + address == email || address == hashed + } } /** * Retrieve the SMS subscription with the matching SMS number, if there is one. + * Compares against the underlying model address (raw or hashed) so lookups + * work both before and after server hydration. */ fun getBySMS(sms: String): ISmsSubscription? { - return smss.firstOrNull { it.number == sms } + val hashed = PIIHasher.hash(sms) + return smss.firstOrNull { + val address = (it as Subscription).model.address + address == sms || address == hashed + } } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt index 2f8d0e2354..089d2bc881 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt @@ -1,8 +1,10 @@ package com.onesignal.user.internal.subscriptions +import com.onesignal.common.PIIHasher import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modeling.SimpleModelStore import com.onesignal.core.internal.preferences.IPreferencesService +import org.json.JSONObject open class SubscriptionModelStore(prefs: IPreferencesService) : SimpleModelStore({ SubscriptionModel() @@ -32,4 +34,18 @@ open class SubscriptionModelStore(prefs: IPreferencesService) : SimpleModelStore super.replaceAll(models, tag) } } + + override fun transformJsonForPersistence( + model: SubscriptionModel, + json: JSONObject, + ): JSONObject { + val type = json.optString("type", "") + if (type.isEmpty() || type == SubscriptionType.PUSH.toString()) return json + + val address = json.optString("address", "") + if (address.isNotEmpty() && !PIIHasher.isHashed(address)) { + json.put("address", PIIHasher.hash(address)) + } + return json + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt index 07834d9ba8..73618a12f2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/impl/SubscriptionManager.kt @@ -5,6 +5,7 @@ import com.onesignal.common.AndroidUtils import com.onesignal.common.DeviceUtils import com.onesignal.common.IDManager import com.onesignal.common.OneSignalUtils +import com.onesignal.common.PIIHasher import com.onesignal.common.events.EventProducer import com.onesignal.common.modeling.IModelStoreChangeHandler import com.onesignal.common.modeling.ModelChangedArgs @@ -93,7 +94,11 @@ internal class SubscriptionManager( } override fun removeEmailSubscription(email: String) { - val subscriptionToRem = subscriptions.emails.firstOrNull { it is EmailSubscription && it.email == email } + val hashedEmail = PIIHasher.hash(email) + val subscriptionToRem = + subscriptions.emails.firstOrNull { + it is EmailSubscription && (it.model.address == email || it.model.address == hashedEmail) + } if (subscriptionToRem != null) { removeSubscriptionFromModels(subscriptionToRem) @@ -101,7 +106,11 @@ internal class SubscriptionManager( } override fun removeSmsSubscription(sms: String) { - val subscriptionToRem = subscriptions.smss.firstOrNull { it is SmsSubscription && it.number == sms } + val hashedSms = PIIHasher.hash(sms) + val subscriptionToRem = + subscriptions.smss.firstOrNull { + it is SmsSubscription && (it.model.address == sms || it.model.address == hashedSms) + } if (subscriptionToRem != null) { removeSubscriptionFromModels(subscriptionToRem) @@ -113,7 +122,8 @@ internal class SubscriptionManager( address: String, status: SubscriptionStatus? = null, ) { - Logging.log(LogLevel.DEBUG, "SubscriptionManager.addSubscription(type: $type, address: $address)") + val logAddress = if (type != SubscriptionType.PUSH) PIIHasher.hash(address) else address + Logging.log(LogLevel.DEBUG, "SubscriptionManager.addSubscription(type: $type, address: $logAddress)") val subscriptionModel = SubscriptionModel() subscriptionModel.id = IDManager.createLocalId() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/PIIHasherTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/PIIHasherTests.kt new file mode 100644 index 0000000000..874c051d04 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/PIIHasherTests.kt @@ -0,0 +1,60 @@ +package com.onesignal.common + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldHaveLength +import io.kotest.matchers.string.shouldMatch + +class PIIHasherTests : FunSpec({ + + test("hash produces 64-char lowercase hex string") { + val result = PIIHasher.hash("test@example.com") + result shouldHaveLength 64 + result shouldMatch Regex("^[a-f0-9]{64}$") + } + + test("hash is deterministic") { + PIIHasher.hash("test@example.com") shouldBe PIIHasher.hash("test@example.com") + } + + test("hash produces different output for different input") { + val hash1 = PIIHasher.hash("user1@example.com") + val hash2 = PIIHasher.hash("user2@example.com") + (hash1 != hash2) shouldBe true + } + + test("hash matches known SHA-256 digest") { + // SHA-256 of "hello" is well-known + PIIHasher.hash("hello") shouldBe "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + } + + test("isHashed returns true for valid 64-char hex string") { + val hashed = PIIHasher.hash("test@example.com") + PIIHasher.isHashed(hashed) shouldBe true + } + + test("isHashed returns false for plain email") { + PIIHasher.isHashed("test@example.com") shouldBe false + } + + test("isHashed returns false for phone number") { + PIIHasher.isHashed("+15558675309") shouldBe false + } + + test("isHashed returns false for empty string") { + PIIHasher.isHashed("") shouldBe false + } + + test("isHashed returns false for uppercase hex") { + val upper = PIIHasher.hash("test").uppercase() + PIIHasher.isHashed(upper) shouldBe false + } + + test("isHashed returns false for 63-char hex string") { + PIIHasher.isHashed("a".repeat(63)) shouldBe false + } + + test("isHashed returns false for 65-char hex string") { + PIIHasher.isHashed("a".repeat(65)) shouldBe false + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionManagerTests.kt index f753d01db8..68a272db62 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionManagerTests.kt @@ -1,6 +1,7 @@ package com.onesignal.user.internal.subscriptions import com.onesignal.common.IDManager.LOCAL_PREFIX +import com.onesignal.common.PIIHasher import com.onesignal.common.modeling.ModelChangeTags import com.onesignal.common.modeling.ModelChangedArgs import com.onesignal.core.internal.application.IApplicationService @@ -416,4 +417,204 @@ class SubscriptionManagerTests : FunSpec({ ) } } + + test("remove email subscription matches hashed address (pre-hydration)") { + // Given + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + val emailSubscription = SubscriptionModel() + emailSubscription.id = "subscription1" + emailSubscription.type = SubscriptionType.EMAIL + emailSubscription.status = SubscriptionStatus.SUBSCRIBED + emailSubscription.optedIn = true + emailSubscription.address = PIIHasher.hash("name@company.com") + + val listOfSubscriptions = listOf(emailSubscription) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.add(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOfSubscriptions + every { mockSubscriptionModelStore.remove("subscription1") } just runs + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When — raw email is passed but model has hashed address + subscriptionManager.removeEmailSubscription("name@company.com") + + // Then + verify(exactly = 1) { mockSubscriptionModelStore.remove("subscription1") } + } + + test("remove sms subscription matches hashed address (pre-hydration)") { + // Given + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + val smsSubscription = SubscriptionModel() + smsSubscription.id = "subscription1" + smsSubscription.type = SubscriptionType.SMS + smsSubscription.status = SubscriptionStatus.SUBSCRIBED + smsSubscription.optedIn = true + smsSubscription.address = PIIHasher.hash("+18458675309") + + val listOfSubscriptions = listOf(smsSubscription) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.add(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOfSubscriptions + every { mockSubscriptionModelStore.remove("subscription1") } just runs + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When — raw phone is passed but model has hashed address + subscriptionManager.removeSmsSubscription("+18458675309") + + // Then + verify(exactly = 1) { mockSubscriptionModelStore.remove("subscription1") } + } + + test("email getter returns empty string when address is hashed") { + // Given + val emailSubscription = SubscriptionModel() + emailSubscription.id = "subscription1" + emailSubscription.type = SubscriptionType.EMAIL + emailSubscription.address = PIIHasher.hash("user@example.com") + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(emailSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val subscriptions = subscriptionManager.subscriptions + + // Then — public getter returns "" for hashed address + subscriptions.emails.count() shouldBe 1 + subscriptions.emails[0].email shouldBe "" + } + + test("email getter returns raw value when address is not hashed") { + // Given + val emailSubscription = SubscriptionModel() + emailSubscription.id = "subscription1" + emailSubscription.type = SubscriptionType.EMAIL + emailSubscription.address = "user@example.com" + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(emailSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val subscriptions = subscriptionManager.subscriptions + + // Then + subscriptions.emails[0].email shouldBe "user@example.com" + } + + test("sms getter returns empty string when address is hashed") { + // Given + val smsSubscription = SubscriptionModel() + smsSubscription.id = "subscription1" + smsSubscription.type = SubscriptionType.SMS + smsSubscription.address = PIIHasher.hash("+15558675309") + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(smsSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val subscriptions = subscriptionManager.subscriptions + + // Then — public getter returns "" for hashed address + subscriptions.smss.count() shouldBe 1 + subscriptions.smss[0].number shouldBe "" + } + + test("sms getter returns raw value when address is not hashed") { + // Given + val smsSubscription = SubscriptionModel() + smsSubscription.id = "subscription1" + smsSubscription.type = SubscriptionType.SMS + smsSubscription.address = "+15558675309" + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(smsSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val subscriptions = subscriptionManager.subscriptions + + // Then + subscriptions.smss[0].number shouldBe "+15558675309" + } + + test("getByEmail finds subscription with hashed address") { + // Given + val emailSubscription = SubscriptionModel() + emailSubscription.id = "subscription1" + emailSubscription.type = SubscriptionType.EMAIL + emailSubscription.address = PIIHasher.hash("user@example.com") + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(emailSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val result = subscriptionManager.subscriptions.getByEmail("user@example.com") + + // Then + result shouldNotBe null + result!!.id shouldBe "subscription1" + } + + test("getBySMS finds subscription with hashed address") { + // Given + val smsSubscription = SubscriptionModel() + smsSubscription.id = "subscription1" + smsSubscription.type = SubscriptionType.SMS + smsSubscription.address = PIIHasher.hash("+15558675309") + + val mockSubscriptionModelStore = mockk() + val mockApplicationService = mockk() + val mockSessionService = mockk(relaxed = true) + + every { mockSubscriptionModelStore.subscribe(any()) } just runs + every { mockSubscriptionModelStore.list() } returns listOf(smsSubscription) + + val subscriptionManager = SubscriptionManager(mockApplicationService, mockSessionService, mockSubscriptionModelStore) + + // When + val result = subscriptionManager.subscriptions.getBySMS("+15558675309") + + // Then + result shouldNotBe null + result!!.id shouldBe "subscription1" + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStoreTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStoreTests.kt new file mode 100644 index 0000000000..2efb330ce5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStoreTests.kt @@ -0,0 +1,124 @@ +package com.onesignal.user.internal.subscriptions + +import com.onesignal.common.PIIHasher +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.mocks.MockPreferencesService +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldMatch +import org.json.JSONArray + +class SubscriptionModelStoreTests : FunSpec({ + + fun getPersistedJson(prefs: IPreferencesService): JSONArray { + val raw = prefs.getString( + PreferenceStores.ONESIGNAL, + PreferenceOneSignalKeys.MODEL_STORE_PREFIX + "subscriptions", + null, + ) + return JSONArray(raw!!) + } + + test("persist hashes email address in SharedPreferences") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val model = SubscriptionModel() + model.id = "email1" + model.type = SubscriptionType.EMAIL + model.address = "user@example.com" + store.add(model) + + val json = getPersistedJson(prefs) + val persisted = json.getJSONObject(0) + persisted.getString("address") shouldBe PIIHasher.hash("user@example.com") + } + + test("persist hashes SMS address in SharedPreferences") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val model = SubscriptionModel() + model.id = "sms1" + model.type = SubscriptionType.SMS + model.address = "+15558675309" + store.add(model) + + val json = getPersistedJson(prefs) + val persisted = json.getJSONObject(0) + persisted.getString("address") shouldBe PIIHasher.hash("+15558675309") + } + + test("persist does not hash push token in SharedPreferences") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val pushToken = "dz1A0qydQGCYM9dDgo6rB_:APA91bEqFakeToken" + val model = SubscriptionModel() + model.id = "push1" + model.type = SubscriptionType.PUSH + model.address = pushToken + store.add(model) + + val json = getPersistedJson(prefs) + val persisted = json.getJSONObject(0) + persisted.getString("address") shouldBe pushToken + } + + test("persist does not double-hash already-hashed email") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val alreadyHashed = PIIHasher.hash("user@example.com") + val model = SubscriptionModel() + model.id = "email1" + model.type = SubscriptionType.EMAIL + model.address = alreadyHashed + store.add(model) + + val json = getPersistedJson(prefs) + val persisted = json.getJSONObject(0) + persisted.getString("address") shouldBe alreadyHashed + } + + test("persist keeps in-memory model address as raw value") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val model = SubscriptionModel() + model.id = "email1" + model.type = SubscriptionType.EMAIL + model.address = "user@example.com" + store.add(model) + + model.address shouldBe "user@example.com" + } + + test("persist hashes email but not push when both are present") { + val prefs = MockPreferencesService() + val store = SubscriptionModelStore(prefs) + + val pushModel = SubscriptionModel() + pushModel.id = "push1" + pushModel.type = SubscriptionType.PUSH + pushModel.address = "fcm-token-abc123" + store.add(pushModel) + + val emailModel = SubscriptionModel() + emailModel.id = "email1" + emailModel.type = SubscriptionType.EMAIL + emailModel.address = "user@example.com" + store.add(emailModel) + + val json = getPersistedJson(prefs) + val models = (0 until json.length()).map { json.getJSONObject(it) } + val pushJson = models.first { it.getString("type") == SubscriptionType.PUSH.toString() } + val emailJson = models.first { it.getString("type") == SubscriptionType.EMAIL.toString() } + + pushJson.getString("address") shouldBe "fcm-token-abc123" + emailJson.getString("address") shouldBe PIIHasher.hash("user@example.com") + emailJson.getString("address") shouldMatch Regex("^[a-f0-9]{64}$") + } +})