You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale/client/systray/systray_test.go

708 lines
17 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build cgo || !darwin
package systray
import (
"net/netip"
"runtime"
"testing"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
)
// ===== profileTitle Tests =====
func TestProfileTitle(t *testing.T) {
tests := []struct {
name string
profile ipn.LoginProfile
expected string
}{
{
name: "profile_without_domain",
profile: ipn.LoginProfile{
Name: "user@example.com",
},
expected: "user@example.com",
},
{
name: "profile_with_domain_on_windows",
profile: ipn.LoginProfile{
Name: "user@example.com",
NetworkProfile: ipn.NetworkProfile{
DomainName: "tailnet.ts.net",
MagicDNSName: "tailnet",
},
},
// On Windows/Mac, should append domain in parentheses
expected: func() string {
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return "user@example.com (tailnet)"
}
// On Linux, should use newline
return "user@example.com\ntailnet"
}(),
},
{
name: "profile_with_custom_display_name",
profile: ipn.LoginProfile{
Name: "user@example.com",
NetworkProfile: ipn.NetworkProfile{
DomainName: "custom.ts.net",
MagicDNSName: "custom-tailnet",
},
},
expected: func() string {
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return "user@example.com (custom-tailnet)"
}
return "user@example.com\ncustom-tailnet"
}(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := profileTitle(tt.profile)
if got != tt.expected {
t.Errorf("profileTitle() = %q, want %q", got, tt.expected)
}
})
}
}
func TestProfileTitle_EmptyProfile(t *testing.T) {
profile := ipn.LoginProfile{}
result := profileTitle(profile)
if result != "" {
t.Errorf("profileTitle(empty) = %q, want empty string", result)
}
}
// ===== countryFlag Tests =====
func TestCountryFlag(t *testing.T) {
tests := []struct {
code string
expected string
}{
{"US", "🇺🇸"},
{"GB", "🇬🇧"},
{"DE", "🇩🇪"},
{"FR", "🇫🇷"},
{"JP", "🇯🇵"},
{"CA", "🇨🇦"},
{"AU", "🇦🇺"},
{"SE", "🇸🇪"},
{"NL", "🇳🇱"},
{"CH", "🇨🇭"},
// lowercase should also work
{"us", "🇺🇸"},
{"gb", "🇬🇧"},
}
for _, tt := range tests {
t.Run(tt.code, func(t *testing.T) {
got := countryFlag(tt.code)
if got != tt.expected {
t.Errorf("countryFlag(%q) = %q, want %q", tt.code, got, tt.expected)
}
})
}
}
func TestCountryFlag_InvalidInputs(t *testing.T) {
tests := []struct {
name string
code string
}{
{"empty", ""},
{"too_short", "U"},
{"too_long", "USA"},
{"numbers", "12"},
{"special_chars", "U$"},
{"spaces", "U "},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := countryFlag(tt.code)
if got != "" {
t.Errorf("countryFlag(%q) = %q, want empty string", tt.code, got)
}
})
}
}
// ===== mullvadPeers Tests =====
func TestNewMullvadPeers(t *testing.T) {
status := &ipnstate.Status{
Peer: map[tailcfg.NodeKey]*ipnstate.PeerStatus{
tailcfg.NodeKey{1}: {
ID: tailcfg.StableNodeID("node1"),
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "United States",
CountryCode: "US",
City: "New York",
CityCode: "nyc",
Priority: 100,
},
},
tailcfg.NodeKey{2}: {
ID: tailcfg.StableNodeID("node2"),
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "United States",
CountryCode: "US",
City: "Los Angeles",
CityCode: "lax",
Priority: 90,
},
},
tailcfg.NodeKey{3}: {
ID: tailcfg.StableNodeID("node3"),
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "Germany",
CountryCode: "DE",
City: "Berlin",
CityCode: "ber",
Priority: 80,
},
},
},
}
mp := newMullvadPeers(status)
// Should have 2 countries
if len(mp.countries) != 2 {
t.Errorf("expected 2 countries, got %d", len(mp.countries))
}
// Check US country
us, ok := mp.countries["US"]
if !ok {
t.Fatal("expected US country")
}
if us.name != "United States" {
t.Errorf("US country name = %q, want %q", us.name, "United States")
}
if us.code != "US" {
t.Errorf("US country code = %q, want %q", us.code, "US")
}
if len(us.cities) != 2 {
t.Errorf("US should have 2 cities, got %d", len(us.cities))
}
// Best peer should be the one with highest priority
if us.best.ID != "node1" {
t.Errorf("US best peer = %q, want %q", us.best.ID, "node1")
}
// Check Germany country
de, ok := mp.countries["DE"]
if !ok {
t.Fatal("expected DE country")
}
if de.name != "Germany" {
t.Errorf("DE country name = %q, want %q", de.name, "Germany")
}
if len(de.cities) != 1 {
t.Errorf("DE should have 1 city, got %d", len(de.cities))
}
}
func TestNewMullvadPeers_EmptyStatus(t *testing.T) {
status := &ipnstate.Status{
Peer: map[tailcfg.NodeKey]*ipnstate.PeerStatus{},
}
mp := newMullvadPeers(status)
if len(mp.countries) != 0 {
t.Errorf("expected 0 countries for empty status, got %d", len(mp.countries))
}
}
func TestNewMullvadPeers_SkipsNonExitNodes(t *testing.T) {
status := &ipnstate.Status{
Peer: map[tailcfg.NodeKey]*ipnstate.PeerStatus{
tailcfg.NodeKey{1}: {
ID: tailcfg.StableNodeID("node1"),
ExitNodeOption: false, // Not an exit node
Location: &tailcfg.Location{
Country: "United States",
CountryCode: "US",
City: "New York",
CityCode: "nyc",
Priority: 100,
},
},
tailcfg.NodeKey{2}: {
ID: tailcfg.StableNodeID("node2"),
ExitNodeOption: true,
Location: nil, // No location
},
},
}
mp := newMullvadPeers(status)
// Should skip both: one is not an exit node, one has no location
if len(mp.countries) != 0 {
t.Errorf("expected 0 countries (both peers should be skipped), got %d", len(mp.countries))
}
}
func TestMullvadPeers_SortedCountries(t *testing.T) {
mp := mullvadPeers{
countries: map[string]*mvCountry{
"US": {code: "US", name: "United States"},
"DE": {code: "DE", name: "Germany"},
"FR": {code: "FR", name: "France"},
"GB": {code: "GB", name: "United Kingdom"},
},
}
sorted := mp.sortedCountries()
if len(sorted) != 4 {
t.Fatalf("expected 4 countries, got %d", len(sorted))
}
// Should be sorted alphabetically by name (case-insensitive)
expected := []string{"France", "Germany", "United Kingdom", "United States"}
for i, country := range sorted {
if country.name != expected[i] {
t.Errorf("country[%d] = %q, want %q", i, country.name, expected[i])
}
}
}
func TestMvCountry_SortedCities(t *testing.T) {
country := &mvCountry{
code: "US",
name: "United States",
cities: map[string]*mvCity{
"sea": {name: "Seattle"},
"nyc": {name: "New York"},
"lax": {name: "Los Angeles"},
"chi": {name: "Chicago"},
},
}
sorted := country.sortedCities()
if len(sorted) != 4 {
t.Fatalf("expected 4 cities, got %d", len(sorted))
}
// Should be sorted alphabetically by name (case-insensitive)
expected := []string{"Chicago", "Los Angeles", "New York", "Seattle"}
for i, city := range sorted {
if city.name != expected[i] {
t.Errorf("city[%d] = %q, want %q", i, city.name, expected[i])
}
}
}
func TestMullvadPeers_PrioritySelection(t *testing.T) {
// Test that the best peer is selected based on priority
status := &ipnstate.Status{
Peer: map[tailcfg.NodeKey]*ipnstate.PeerStatus{
tailcfg.NodeKey{1}: {
ID: tailcfg.StableNodeID("node1"),
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "Germany",
CountryCode: "DE",
City: "Berlin",
CityCode: "ber",
Priority: 50, // Lower priority
},
},
tailcfg.NodeKey{2}: {
ID: tailcfg.StableNodeID("node2"),
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "Germany",
CountryCode: "DE",
City: "Berlin",
CityCode: "ber",
Priority: 100, // Higher priority - should be selected
},
},
},
}
mp := newMullvadPeers(status)
de := mp.countries["DE"]
if de.best.ID != "node2" {
t.Errorf("best country peer = %q, want node2 (highest priority)", de.best.ID)
}
berlin := de.cities["ber"]
if berlin.best.ID != "node2" {
t.Errorf("best city peer = %q, want node2 (highest priority)", berlin.best.ID)
}
}
// ===== Menu State Tests =====
func TestMenu_Init(t *testing.T) {
menu := &Menu{}
// Should be uninitialized
if menu.bgCtx != nil {
t.Error("expected nil bgCtx before init")
}
menu.init()
// After init, channels and context should be set
if menu.rebuildCh == nil {
t.Error("rebuildCh should be initialized")
}
if menu.accountsCh == nil {
t.Error("accountsCh should be initialized")
}
if menu.exitNodeCh == nil {
t.Error("exitNodeCh should be initialized")
}
if menu.bgCtx == nil {
t.Error("bgCtx should be initialized")
}
if menu.bgCancel == nil {
t.Error("bgCancel should be initialized")
}
// Calling init again should be a no-op
oldCtx := menu.bgCtx
menu.init()
if menu.bgCtx != oldCtx {
t.Error("second init() should not recreate context")
}
// Cleanup
menu.bgCancel()
}
func TestMenu_OnExit(t *testing.T) {
menu := &Menu{}
menu.init()
// Create a temp file for notification icon
menu.notificationIcon, _ = nil, nil // Can't actually create temp file in test
// Should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("onExit panicked: %v", r)
}
}()
menu.onExit()
}
// ===== Package Variables Tests =====
func TestPackageVariables(t *testing.T) {
// Test that package variables are initialized
// On non-Linux platforms, newMenuDelay should remain unset (0)
// On Linux, it depends on the desktop environment
if runtime.GOOS != "linux" {
if newMenuDelay != 0 {
t.Errorf("newMenuDelay should be 0 on non-Linux, got %v", newMenuDelay)
}
if hideMullvadCities {
t.Error("hideMullvadCities should be false on non-Linux")
}
}
// On Linux, we can't test the exact values since they depend on XDG_CURRENT_DESKTOP
// but we can verify they are reasonable
}
// ===== Mullvad City Tests =====
func TestMvCity_BestPeerSelection(t *testing.T) {
ps1 := &ipnstate.PeerStatus{
ID: tailcfg.StableNodeID("peer1"),
Location: &tailcfg.Location{
Priority: 50,
},
}
ps2 := &ipnstate.PeerStatus{
ID: tailcfg.StableNodeID("peer2"),
Location: &tailcfg.Location{
Priority: 100,
},
}
ps3 := &ipnstate.PeerStatus{
ID: tailcfg.StableNodeID("peer3"),
Location: &tailcfg.Location{
Priority: 75,
},
}
city := &mvCity{
name: "TestCity",
peers: []*ipnstate.PeerStatus{ps1, ps2, ps3},
}
// Manually find best (simulating what newMullvadPeers does)
for _, ps := range city.peers {
if city.best == nil || ps.Location.Priority > city.best.Location.Priority {
city.best = ps
}
}
if city.best.ID != "peer2" {
t.Errorf("best peer = %q, want peer2 (priority 100)", city.best.ID)
}
}
// ===== Edge Cases =====
func TestCountryFlag_Unicode(t *testing.T) {
// Test that the flag emoji is actually 2 runes (regional indicators)
flag := countryFlag("US")
runes := []rune(flag)
if len(runes) != 2 {
t.Errorf("US flag should be 2 runes, got %d", len(runes))
}
// Regional indicator for U (🇺)
expectedU := rune(0x1F1FA)
// Regional indicator for S (🇸)
expectedS := rune(0x1F1F8)
if runes[0] != expectedU {
t.Errorf("first rune = %U, want %U", runes[0], expectedU)
}
if runes[1] != expectedS {
t.Errorf("second rune = %U, want %U", runes[1], expectedS)
}
}
func TestNewMullvadPeers_MultiplePeersInCity(t *testing.T) {
status := &ipnstate.Status{
Peer: map[tailcfg.NodeKey]*ipnstate.PeerStatus{
tailcfg.NodeKey{1}: {
ID: tailcfg.StableNodeID("node1"),
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "Germany",
CountryCode: "DE",
City: "Berlin",
CityCode: "ber",
Priority: 100,
},
},
tailcfg.NodeKey{2}: {
ID: tailcfg.StableNodeID("node2"),
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "Germany",
CountryCode: "DE",
City: "Berlin",
CityCode: "ber",
Priority: 50,
},
},
tailcfg.NodeKey{3}: {
ID: tailcfg.StableNodeID("node3"),
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "Germany",
CountryCode: "DE",
City: "Berlin",
CityCode: "ber",
Priority: 75,
},
},
},
}
mp := newMullvadPeers(status)
de := mp.countries["DE"]
berlin := de.cities["ber"]
// Should have all 3 peers
if len(berlin.peers) != 3 {
t.Errorf("Berlin should have 3 peers, got %d", len(berlin.peers))
}
// Best should be node1 (priority 100)
if berlin.best.ID != "node1" {
t.Errorf("best Berlin peer = %q, want node1", berlin.best.ID)
}
}
func TestProfileTitle_MultilineOnLinux(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("skipping Linux-specific test")
}
profile := ipn.LoginProfile{
Name: "user@example.com",
NetworkProfile: ipn.NetworkProfile{
DomainName: "tailnet.ts.net",
MagicDNSName: "tailnet",
},
}
result := profileTitle(profile)
// On Linux, should use newline separator
if result != "user@example.com\ntailnet" {
t.Errorf("Linux profile title = %q, want %q", result, "user@example.com\ntailnet")
}
}
func TestMullvadPeers_EmptyCountries(t *testing.T) {
mp := mullvadPeers{
countries: map[string]*mvCountry{},
}
sorted := mp.sortedCountries()
if len(sorted) != 0 {
t.Errorf("expected 0 countries, got %d", len(sorted))
}
}
func TestMvCountry_EmptyCities(t *testing.T) {
country := &mvCountry{
code: "US",
name: "United States",
cities: map[string]*mvCity{},
}
sorted := country.sortedCities()
if len(sorted) != 0 {
t.Errorf("expected 0 cities, got %d", len(sorted))
}
}
// ===== Integration-style Tests =====
func TestMullvadPeers_RealWorldScenario(t *testing.T) {
// Simulate a real-world scenario with multiple countries and cities
status := &ipnstate.Status{
Self: &ipnstate.PeerStatus{
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1")},
},
Peer: map[tailcfg.NodeKey]*ipnstate.PeerStatus{
tailcfg.NodeKey{1}: {
ID: "us-nyc-1",
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "United States",
CountryCode: "US",
City: "New York",
CityCode: "nyc",
Priority: 100,
},
},
tailcfg.NodeKey{2}: {
ID: "us-nyc-2",
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "United States",
CountryCode: "US",
City: "New York",
CityCode: "nyc",
Priority: 90,
},
},
tailcfg.NodeKey{3}: {
ID: "us-lax-1",
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "United States",
CountryCode: "US",
City: "Los Angeles",
CityCode: "lax",
Priority: 95,
},
},
tailcfg.NodeKey{4}: {
ID: "de-ber-1",
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "Germany",
CountryCode: "DE",
City: "Berlin",
CityCode: "ber",
Priority: 85,
},
},
tailcfg.NodeKey{5}: {
ID: "jp-tyo-1",
ExitNodeOption: true,
Location: &tailcfg.Location{
Country: "Japan",
CountryCode: "JP",
City: "Tokyo",
CityCode: "tyo",
Priority: 80,
},
},
},
}
mp := newMullvadPeers(status)
// Verify country count
if len(mp.countries) != 3 {
t.Errorf("expected 3 countries, got %d", len(mp.countries))
}
// Verify US has 2 cities
us := mp.countries["US"]
if len(us.cities) != 2 {
t.Errorf("US should have 2 cities, got %d", len(us.cities))
}
// Verify US best is us-nyc-1 (priority 100)
if us.best.ID != "us-nyc-1" {
t.Errorf("US best = %q, want us-nyc-1", us.best.ID)
}
// Verify NYC has 2 peers
nyc := us.cities["nyc"]
if len(nyc.peers) != 2 {
t.Errorf("NYC should have 2 peers, got %d", len(nyc.peers))
}
// Verify sorted countries
sorted := mp.sortedCountries()
expectedOrder := []string{"Germany", "Japan", "United States"}
for i, country := range sorted {
if country.name != expectedOrder[i] {
t.Errorf("sorted country[%d] = %q, want %q", i, country.name, expectedOrder[i])
}
}
// Verify sorted US cities
sortedCities := us.sortedCities()
expectedCityOrder := []string{"Los Angeles", "New York"}
for i, city := range sortedCities {
if city.name != expectedCityOrder[i] {
t.Errorf("sorted city[%d] = %q, want %q", i, city.name, expectedCityOrder[i])
}
}
}