@@ -15,6 +15,9 @@ import org.robolectric.RobolectricTestRunner
1515import org.robolectric.RuntimeEnvironment
1616import org.robolectric.Shadows.shadowOf
1717import org.robolectric.annotation.Config
18+ import io.mockk.every
19+ import io.mockk.mockkObject
20+ import io.mockk.unmockkObject
1821
1922@RunWith(RobolectricTestRunner ::class )
2023@Config(sdk = [34 ])
@@ -69,6 +72,162 @@ class UnifiedPushNotifierTest {
6972 private fun canonicalId (roomId : String , userId : String = "@alice:example.org") =
7073 UnifiedPushNotifier .roomNotificationId(userId, roomId)
7174
75+ @Test
76+ fun showFromPush_ntfyPayloadUsesRegisteredAccountForIdentityAndTap () {
77+ val payload = JSONObject (pushPayload(" !ntfy:example.org" , " \$ ntfy" , userId = null ))
78+ payload.getJSONObject(" notification" ).put(" devices" , org.json.JSONArray ().put(
79+ JSONObject ().put(" pushkey" , " https://ntfy.sh/up123?up=1" ).put(" data" ,
80+ JSONObject ().put(" default_payload" , JSONObject ().put(" user_id" , " @alice:example.org" )))
81+ ))
82+ UnifiedPushNotifier .showFromPush(context, payload.toString())
83+ val posted = shadowNotificationManager().getNotification(null , canonicalId(" !ntfy:example.org" ))
84+ assertNotNull(" ntfy delivery must use the same identity as warm enrichment" , posted)
85+ val source = shadowOf(posted!! .contentIntent).savedIntent.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY )!!
86+ assertTrue(source.contains(" @alice:example.org" ))
87+ }
88+
89+ @Test
90+ fun showFromPush_routesSupportedGatewayPayloadsToTheSameAccountAndRoom () {
91+ for (wrapper in listOf (" flat" , " object" , " string" )) {
92+ for (recipient in listOf (" notification" , " envelope" , " device" , " default_payload" )) {
93+ val notification = JSONObject ()
94+ .put(" room_id" , " !contract:example.org" )
95+ .put(" event_id" , " \$ contract" )
96+ .put(" type" , " m.room.message" )
97+ .put(" content" , JSONObject ().put(" body" , " contract message" ))
98+ val envelope = when (wrapper) {
99+ " flat" -> notification
100+ else -> JSONObject ()
101+ }
102+ when (recipient) {
103+ " notification" -> notification.put(" user_id" , " @alice:example.org" )
104+ " envelope" -> envelope.put(" user_id" , " @alice:example.org" )
105+ else -> {
106+ val data = JSONObject ()
107+ if (recipient == " device" ) data.put(" user_id" , " @alice:example.org" )
108+ else data.put(" default_payload" , JSONObject ().put(" user_id" , " @alice:example.org" ))
109+ notification.put(" devices" , org.json.JSONArray ().put(JSONObject ().put(" data" , data)))
110+ }
111+ }
112+ if (wrapper == " object" ) envelope.put(" notification" , notification)
113+ if (wrapper == " string" ) envelope.put(" notification" , notification.toString())
114+ notificationManager.cancelAll()
115+ UnifiedPushNotifier .showFromPush(context, envelope.toString())
116+ val posted = shadowNotificationManager().getNotification(null , canonicalId(" !contract:example.org" ))
117+ assertNotNull(" $wrapper / $recipient " , posted)
118+ assertTrue(posted!! .extras.getString(Notification .EXTRA_TEXT )!! .contains(" contract message" ))
119+ val source = shadowOf(posted.contentIntent).savedIntent.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY )!!
120+ assertTrue(source.contains(" @alice:example.org" ))
121+ }
122+ }
123+ }
124+
125+ @Test
126+ fun showFromPush_acceptsFlatMinimalPush () {
127+ val payload = JSONObject ().put(" room_id" , " !flat:example.org" )
128+ .put(" event_id" , " \$ flat" ).put(" user_id" , " @alice:example.org" ).toString()
129+ UnifiedPushNotifier .showFromPush(context, payload)
130+ assertNotNull(shadowNotificationManager().getNotification(null , canonicalId(" !flat:example.org" )))
131+ }
132+
133+ @Test
134+ fun showFromPush_postsBaselineBeforeNativeDecryption () {
135+ val state = UnifiedPushStateStore (context)
136+ state.pushUserId = " @alice:example.org"
137+ state.pushDeviceId = " DEVICE"
138+ state.showEncryptedContent = true
139+ var baselineWasVisible = false
140+ mockkObject(PushPayloadDecryptor )
141+ try {
142+ every { PushPayloadDecryptor .decrypt(any(), any(), any(), any(), any()) } answers {
143+ baselineWasVisible = shadowNotificationManager().getNotification(null , canonicalId(" !enc:example.org" )) != null
144+ PushDecryptResult .Success (""" {"content":{"body":"decrypted"}}""" )
145+ }
146+ val payload = JSONObject (pushPayload(" !enc:example.org" , " \$ enc" ))
147+ payload.getJSONObject(" notification" ).put(" type" , " m.room.encrypted" )
148+ UnifiedPushNotifier .showFromPush(context, payload.toString())
149+ assertTrue(" a slow native decrypt must not delay notification delivery" , baselineWasVisible)
150+ val posted = shadowNotificationManager().getNotification(null , canonicalId(" !enc:example.org" ))!!
151+ assertTrue(posted.extras.getString(Notification .EXTRA_TEXT )!! .contains(" decrypted" ))
152+ } finally {
153+ unmockkObject(PushPayloadDecryptor )
154+ }
155+ }
156+
157+ @Test
158+ fun showFromPush_honorsPrivacyChangesDuringDecryption () {
159+ val state = UnifiedPushStateStore (context)
160+ state.pushUserId = " @alice:example.org"
161+ state.pushDeviceId = " DEVICE"
162+ state.showEncryptedContent = true
163+ mockkObject(PushPayloadDecryptor )
164+ try {
165+ every { PushPayloadDecryptor .decrypt(any(), any(), any(), any(), any()) } answers {
166+ state.showEncryptedContent = false
167+ PushDecryptResult .Success (""" {"content":{"body":"private plaintext"}}""" )
168+ }
169+ val payload = JSONObject (pushPayload(" !enc:example.org" , " \$ enc" ))
170+ payload.getJSONObject(" notification" ).put(" type" , " m.room.encrypted" )
171+ UnifiedPushNotifier .showFromPush(context, payload.toString())
172+ val posted = shadowNotificationManager().getNotification(null , canonicalId(" !enc:example.org" ))!!
173+ assertTrue(posted.extras.getString(Notification .EXTRA_TEXT )!! .contains(" Encrypted message" ))
174+ } finally {
175+ unmockkObject(PushPayloadDecryptor )
176+ }
177+ }
178+
179+ @Test
180+ fun showFromPush_acceptsV2DeviceAccountMetadata () {
181+ val payload = JSONObject (pushPayload(" !v2:example.org" , " \$ v2" , userId = null ))
182+ payload.getJSONObject(" notification" ).put(" devices" , org.json.JSONArray ().put(
183+ JSONObject ().put(" data" , JSONObject ().put(" user_id" , " @alice:example.org" ))
184+ ))
185+ UnifiedPushNotifier .showFromPush(context, payload.toString())
186+ assertNotNull(shadowNotificationManager().getNotification(null , canonicalId(" !v2:example.org" )))
187+ }
188+
189+ @Test
190+ fun showFromPush_rejectsConflictingRecipientsAndControlMessages () {
191+ val payload = JSONObject (pushPayload(" !conflict:example.org" , " \$ conflict" ))
192+ payload.getJSONObject(" notification" ).put(" user_id" , " @other:example.org" )
193+ UnifiedPushNotifier .showFromPush(context, payload.toString())
194+ UnifiedPushNotifier .showFromPush(context, """ {"notification":{"counts":{"unread":5}}}""" )
195+ UnifiedPushNotifier .showFromPush(context, """ {"app_id":"app","ack_token":"token"}""" )
196+ assertTrue(shadowNotificationManager().allNotifications.isEmpty())
197+ }
198+
199+ @Test
200+ fun showFromPush_clearsReadRoomsWithoutPostingAnAlert () {
201+ UnifiedPushNotifier .showFromPush(context, pushPayload(" !read:example.org" , " \$ read" ))
202+ UnifiedPushNotifier .showFromPush(context, """ {"notification":{"user_id":"@alice:example.org","room_id":"!read:example.org","counts":{"unread":0}}}""" )
203+ assertTrue(shadowNotificationManager().allNotifications.isEmpty())
204+ }
205+
206+ @Test
207+ fun showFromPush_lateDecryptionDoesNotResurrectDismissedOrSupersededAlerts () {
208+ val state = UnifiedPushStateStore (context)
209+ state.pushUserId = " @alice:example.org"
210+ state.pushDeviceId = " DEVICE"
211+ state.showEncryptedContent = true
212+ mockkObject(PushPayloadDecryptor )
213+ try {
214+ for (supersede in listOf (false , true )) {
215+ every { PushPayloadDecryptor .decrypt(any(), any(), any(), any(), any()) } answers {
216+ notificationManager.cancel(canonicalId(" !enc:example.org" ))
217+ if (supersede) UnifiedPushNotifier .showFromPush(context,
218+ pushPayload(" !enc:example.org" , " \$ new" , " newer message" ))
219+ PushDecryptResult .Success (""" {"content":{"body":"stale plaintext"}}""" )
220+ }
221+ val payload = JSONObject (pushPayload(" !enc:example.org" , " \$ old" ))
222+ payload.getJSONObject(" notification" ).put(" type" , " m.room.encrypted" )
223+ UnifiedPushNotifier .showFromPush(context, payload.toString())
224+ val posted = shadowNotificationManager().getNotification(null , canonicalId(" !enc:example.org" ))
225+ if (supersede) assertTrue(posted!! .extras.getString(Notification .EXTRA_TEXT )!! .contains(" newer message" ))
226+ else assertNull(posted)
227+ }
228+ } finally { unmockkObject(PushPayloadDecryptor ) }
229+ }
230+
72231 @Test
73232 fun roomNotificationId_matchesDeployedJsAbsHashSemantics () {
74233 // Fixed vectors with expectations computed from Sable's JS
0 commit comments