M12f: :app invite links dropped while paired, push-token retry, private notifications, en/sv only (D-112, D-116)

- AppViewModel.onLink drops an invite link while paired (AC-2.6, D-084), so it never prefills S-1 later.
- PushTokens: a failed E-17 PUT (network, 429, 5xx) is kept by PushTokenRetry and sent again after each successful
  sync until accepted; a newer FID replaces it (AC-13.7).
- Notifications are VISIBILITY_PRIVATE with a generic public version (notif_generic_title, notif_generic_body):
  no description, amount, account or name on a secure lock screen (security review L-7).
- androidResources.localeFilters en, sv (SPEC G-8, AC-19.1).
- Tests: AppViewModelTest (links while paired and unpaired, AC-2.9 permission after the first finished successful
  sync), PushTokenRetryTest, NotificationPrivacyTest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 22:32:55 +02:00
parent 5b2ed37f65
commit 2bd6b584f8
7 changed files with 229 additions and 21 deletions
@@ -0,0 +1,70 @@
package se.hagstedt.kvitt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.link.AppLink
import se.hagstedt.kvitt.model.SessionState
/** The shell's link and permission rules: AC-2.6 (D-084) and AC-2.9. A plain JVM test over the fakes. */
@OptIn(ExperimentalCoroutinesApi::class)
class AppViewModelTest {
@Before
fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun tearDown() = Dispatchers.resetMain()
private fun vm(fakes: FakeRepositories) = AppViewModel(fakes.session, fakes.sync, fakes.accounts)
private val link = AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCD2345EFGH", "Jacob")
@Test
fun anInviteLinkWhilePaired_isDropped_andNeverPrefillsALaterPairing() {
val fakes = FakeRepositories(FakeScenarios.empty)
val vm = vm(fakes)
assertTrue(vm.session.value is SessionState.Paired)
vm.onLink(link)
assertNull(vm.invite.value)
fakes.store.edit { it.copy(session = SessionState.Revoked) }
assertEquals(SessionState.Revoked, vm.session.value)
assertNull("a link from the paired time does not come back", vm.invite.value)
}
@Test
fun anInviteLinkWhileUnpaired_prefillsThePairingScreen() {
val vm = vm(FakeRepositories(FakeScenarios.empty.copy(session = SessionState.Unpaired)))
vm.onLink(link)
assertEquals(link, vm.invite.value)
vm.onPaired()
assertNull("consumed by the pairing", vm.invite.value)
}
@Test
fun notificationPermission_isAskedOnlyAfterTheFirstSuccessfulSyncAfterPairing() {
val first: FakeScenario = FakeScenarios.empty.copy(lastSuccessAt = null, syncRunning = true)
val fakes = FakeRepositories(first)
val vm = vm(fakes)
assertFalse(vm.askNotifications.value)
vm.onPaired()
assertFalse("the first sync is still running", vm.askNotifications.value)
fakes.store.edit { it.copy(lastSuccessAt = "2026-10-17T12:00:00.000Z") }
assertFalse("success recorded but the cycle still runs", vm.askNotifications.value)
fakes.store.edit { it.copy(syncRunning = false) }
assertTrue(vm.askNotifications.value)
vm.notificationsAsked()
assertFalse(vm.askNotifications.value)
}
}
@@ -0,0 +1,34 @@
package se.hagstedt.kvitt.push
import android.app.Application
import android.app.Notification
import androidx.core.app.NotificationCompat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import se.hagstedt.kvitt.link.OpenTarget
/** Security review L-7 (D-116): the lock screen shows only the generic public version, never the expense. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36], application = Application::class)
class NotificationPrivacyTest {
private val context = RuntimeEnvironment.getApplication()
@Test
fun notificationsArePrivate_withAGenericPublicVersion() {
val content = NotificationContent("Anna added an expense", "ICA Maxi · SEK 1,234.50 · Vardag", OpenTarget.Expense("e-1"))
val n = Notifications.build(context, content, null)
assertEquals(NotificationCompat.VISIBILITY_PRIVATE, n.visibility)
assertEquals("Anna added an expense", n.extras.getCharSequence(Notification.EXTRA_TITLE).toString())
val public = requireNotNull(n.publicVersion)
val title = public.extras.getCharSequence(Notification.EXTRA_TITLE).toString()
val text = public.extras.getCharSequence(Notification.EXTRA_TEXT).toString()
assertEquals("Kvitt", title)
assertEquals("Open Kvitt to see it", text)
listOf("ICA", "1,234.50", "Vardag", "Anna").forEach { assertFalse(it, (title + text).contains(it)) }
}
}
@@ -0,0 +1,36 @@
package se.hagstedt.kvitt.push
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import se.hagstedt.kvitt.push.PushTokenRetry.Outcome
/** Audit AC-13.7: a failed E-17 send is retried after the next successful sync, until accepted. */
class PushTokenRetryTest {
@Test
fun aRetryableFailure_isSentAgainOnRetry_untilAccepted() = runTest {
val sent = mutableListOf<String>()
val answers = ArrayDeque(listOf(Outcome.Retry, Outcome.Retry, Outcome.Done))
val retry = PushTokenRetry { sent += it; answers.removeFirst() }
retry.send("fid-1")
assertEquals("fid-1", retry.pending.value)
retry.retry()
assertEquals("fid-1", retry.pending.value)
retry.retry()
assertNull(retry.pending.value)
retry.retry()
assertEquals(listOf("fid-1", "fid-1", "fid-1"), sent)
}
@Test
fun aDefinitiveAnswer_isNotRetried_andANewerTokenReplacesTheOldOne() = runTest {
val sent = mutableListOf<String>()
val retry = PushTokenRetry { sent += it; if (it == "old") Outcome.Retry else Outcome.Done }
retry.send("old")
retry.send("new")
assertNull(retry.pending.value)
retry.retry()
assertEquals(listOf("old", "new"), sent)
}
}