From 13121122068640cfe335779f195f84cf9b7adc8d Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 28 Sep 2026 14:35:24 +0000 Subject: [PATCH] android: test Notifier drops peers removed by a full netmap Deleted peers only leave the list once tailscale.com sends PeersRemoved with full netmaps; this pins the app side of that contract. Signed-off-by: Kristoffer Dalby --- .../com/tailscale/ipn/ui/notifier/Notifier.kt | 4 +- .../tailscale/ipn/ui/notifier/NotifierTest.kt | 78 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 android/src/test/kotlin/com/tailscale/ipn/ui/notifier/NotifierTest.kt diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index 8844ae078d..d9c4f0a4b1 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -3,6 +3,7 @@ package com.tailscale.ipn.ui.notifier +import androidx.annotation.VisibleForTesting import com.tailscale.ipn.App import com.tailscale.ipn.BuildConfig import com.tailscale.ipn.ui.model.Empty @@ -177,7 +178,8 @@ object Notifier { } @Synchronized - private fun updateNetworkMap(notify: Notify) { + @VisibleForTesting + internal fun updateNetworkMap(notify: Notify) { val initial = notify.InitialStatus if (initial != null) { userProfiles.clear() diff --git a/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/NotifierTest.kt b/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/NotifierTest.kt new file mode 100644 index 0000000000..1c597a930f --- /dev/null +++ b/android/src/test/kotlin/com/tailscale/ipn/ui/notifier/NotifierTest.kt @@ -0,0 +1,78 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.notifier + +import com.tailscale.ipn.App +import com.tailscale.ipn.UninitializedApp +import com.tailscale.ipn.ui.model.Ipn.Notify +import com.tailscale.ipn.ui.model.IpnState +import com.tailscale.ipn.ui.model.NodeID +import com.tailscale.ipn.ui.model.Tailcfg +import com.tailscale.ipn.util.TSLog +import com.tailscale.ipn.util.TSLog.LibtailscaleWrapper +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.RETURNS_DEEP_STUBS +import org.mockito.Mockito.mock + +class NotifierTest { + private lateinit var originalWrapper: LibtailscaleWrapper + + @Before + fun setUp() { + originalWrapper = TSLog.libtailscaleWrapper + TSLog.libtailscaleWrapper = mock(LibtailscaleWrapper::class.java) + // Notifier's initializer loads the inline-share inbox from encrypted prefs. + UninitializedApp::class + .java + .getDeclaredField("appInstance") + .apply { isAccessible = true } + .set(null, mock(App::class.java, RETURNS_DEEP_STUBS)) + } + + @After + fun tearDown() { + TSLog.libtailscaleWrapper = originalWrapper + } + + private fun peer(id: NodeID) = + IpnState.PeerStatus(ID = "s$id", NodeID = id, DNSName = "peer$id.ts.net.", UserID = 1) + + private fun node(id: NodeID) = + Tailcfg.Node(ID = id, StableID = "s$id", Name = "peer$id.ts.net.", User = 1) + + private fun seed(vararg peers: NodeID) { + Notifier.updateNetworkMap( + Notify( + InitialStatus = + IpnState.Status( + Self = peer(1), + Peer = peers.associate { "key$it" to peer(it) }, + ) + ) + ) + } + + private fun peerIDs() = Notifier.netmap.value?.Peers?.map { it.ID } + + // A full netmap from control arrives as SelfChange plus every current peer + // in PeersChanged; peers it dropped arrive in PeersRemoved. + @Test + fun fullNetmapDropsRemovedPeer() { + seed(10, 20) + Notifier.updateNetworkMap( + Notify(SelfChange = node(1), PeersChanged = listOf(node(10)), PeersRemoved = listOf(20)) + ) + assertEquals(listOf(10L), peerIDs()) + } + + @Test + fun fullNetmapDropsLastPeer() { + seed(10) + Notifier.updateNetworkMap(Notify(SelfChange = node(1), PeersRemoved = listOf(10))) + assertEquals(emptyList(), peerIDs()) + } +}