Files
Termind/FrameworkTests/TailscaleMultiConnectionTests.swift
T
Kaede Akino 996ea85196 fix(tailscale)!: scope host identities and defer saved logout
Derive groupScopedV2 host UUIDs from the group, MagicDNS suffix and stable
node ID. Remove experimental identity compatibility and Tailnet conflict
handling, and hide unavailable Metrics hosts while retaining configuration.

Keep saved enrollments active until Save. Retire unsaved login candidates
on Logout or Discard, including repeated replacement logins, while preserving
the original enrollment. Use the same behavior in New and Edit forms.

Validation: 94 focused tests, iOS Simulator build, Swift formatting and
AppStrings/Chinese localization checks passed. The default suite still has
9 failures in untouched notification, agent and key export tests.

BREAKING CHANGE: Tailscale only accepts groupScopedV2 identities. Remove
old Tailscale test data before using this build; no ID migration is provided.
2026-10-03 16:58:40 +08:00

126 lines
7.1 KiB
Swift

import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@Suite @MainActor
struct TailscaleMultiConnectionTests {
private static let legacyGroup = Data(
#"{"formatVersion":2,"key":{"kind":"hostGroup","id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"},"version":{"modifiedAt":0,"counter":1,"writerID":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"},"isDeleted":false,"payload":{"hostGroup":{"_0":{"id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","name":"Existing random-ID group","sortIndex":0,"isExpanded":true,"tailscale":{"network":{"controlURL":"https://controlplane.tailscale.com","dnsSuffix":"example.ts.net"},"username":"operator","hostPreferences":{"01bf448f-7943-55d0-96aa-b52f10c25f07":{"username":"deploy","charset":"utf8","sftpInitialPath":"/srv","shellIntegrationMode":"followGlobalSettings","environmentVariables":[]}}}}}}}"#
.utf8)
@Test func sameNodeInTwoGroupsKeepsPreferencesAndRoutingIndependent() throws {
let source = try TermindPersistence.makeContainer(inMemory: true)
let library = HostLibraryModel(persistence: HostPersistence(context: source.mainContext))
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var first = HostGroup(name: "First")
first.tailscale = TailscaleGroupConfiguration(network: network)
var second = HostGroup(name: "Second", parentID: first.id)
second.tailscale = TailscaleGroupConfiguration(network: network)
for group in [first, second] {
library.upsert(group)
library.replaceTailscalePeers([peer()], groupID: group.id)
}
let firstHost = try #require(library.hosts.first { $0.groupID == first.id })
let secondHost = try #require(library.hosts.first { $0.groupID == second.id })
#expect(firstHost.id != secondHost.id)
for (host, username) in [(firstHost, "alice"), (secondHost, "bob")] {
var edited = host
edited.tags = [username]
#expect(library.saveTailscaleHost(edited, username: username))
}
let routing = MultiConnectionRoutingProbe()
library.tailscaleConnections = routing
_ = try library.tailscaleNetwork(for: firstHost)
_ = try library.tailscaleNetwork(for: secondHost)
#expect(routing.groups == [first.id, second.id])
let firstStamp = library.networkConfigurationStamp(for: firstHost)
second.name = "Renamed"
second.parentID = nil
second.tailscale = library.group(second.id)?.tailscale
second.tailscale?.exitNodeID = "exit-two"
library.upsert(second)
#expect(library.networkConfigurationStamp(for: firstHost) == firstStamp)
#expect(library.host(secondHost.id)?.groupID == second.id)
let envelopes = try VaultSyncJournal(context: source.mainContext).pendingEnvelopes()
.map { try VaultSyncCodec.decode(VaultSyncCodec.encode($0)) }
let target = try TermindPersistence.makeContainer(inMemory: true)
let targetStore = HostPersistence(context: target.mainContext)
_ = try targetStore.applyRemote(envelopes)
let otherDevice = HostLibraryModel(persistence: targetStore)
#expect(otherDevice.hosts.isEmpty)
for group in [first, second] { otherDevice.replaceTailscalePeers([peer()], groupID: group.id) }
#expect(otherDevice.host(firstHost.id)?.tags == ["alice"])
#expect(otherDevice.host(secondHost.id)?.tags == ["bob"])
#expect(otherDevice.savedTailscaleUsername(for: firstHost) == "alice")
#expect(otherDevice.savedTailscaleUsername(for: secondHost) == "bob")
otherDevice.deleteGroupProjection(first.id)
#expect(otherDevice.host(firstHost.id) == nil)
#expect(otherDevice.host(secondHost.id)?.tags == ["bob"])
}
@Test func groupScopedIdentityCannotBeWrittenAsV2AndOldReadersRejectV3() throws {
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var group = HostGroup(name: "New connection")
group.tailscale = TailscaleGroupConfiguration(network: network)
let payload = VaultRecordPayload.hostGroup(group)
var envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
let bytes = try VaultSyncCodec.encode(envelope)
#expect(try VaultSyncCodec.decode(bytes) == envelope)
let oldReader = VersionedRecordDecoder<VaultEnvelope>([
"hostGroup": [1: VaultSyncCodec.decode, 2: VaultSyncCodec.decode]
])
#expect(throws: CloudSyncCompatibilityError.self) { try oldReader.decode(bytes) }
envelope.formatVersion = 2
#expect(throws: (any Error).self) { try VaultSyncCodec.encode(envelope) }
#expect(throws: DecodingError.self) { try VaultSyncCodec.decode(Self.legacyGroup) }
let ordinary = VaultRecordPayload.hostGroup(HostGroup(name: "Ordinary"))
let oldEnvelope = VaultEnvelope(
key: ordinary.key, version: .baseline(writerID: UUID()), payload: ordinary, formatVersion: 2)
#expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(oldEnvelope)) == oldEnvelope)
}
@Test(arguments: ["missing", "null", "legacy", "groupScoped", "future"])
func v3RejectsInvalidIdentityMarkers(marker: String) throws {
var value = try #require(JSONSerialization.jsonObject(with: Self.legacyGroup) as? [String: Any])
value["formatVersion"] = 3
var payload = try #require(value["payload"] as? [String: Any])
var groupCase = try #require(payload["hostGroup"] as? [String: Any])
var group = try #require(groupCase["_0"] as? [String: Any])
var configuration = try #require(group["tailscale"] as? [String: Any])
if marker == "null" { configuration["hostIdentityVersion"] = NSNull() }
if !["missing", "null"].contains(marker) { configuration["hostIdentityVersion"] = marker }
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(
TailscaleGroupConfiguration.self, from: JSONSerialization.data(withJSONObject: configuration))
}
group["tailscale"] = configuration
groupCase["_0"] = group
payload["hostGroup"] = groupCase
value["payload"] = payload
#expect(throws: (any Error).self) { try VaultSyncCodec.decode(JSONSerialization.data(withJSONObject: value)) }
}
private func peer() -> TailscalePeer {
TailscalePeer(
stableID: "n123", name: "Server", dnsName: "server.example.ts.net", online: true, sshHostKeys: ["fixture"])
}
}
@MainActor private final class MultiConnectionRoutingProbe: TailscaleConnectionProviding {
var groups: [UUID] = []
func connection(for groupID: UUID) throws -> any SSHNetworkDialing {
groups.append(groupID)
return MultiConnectionDialer()
}
}
private nonisolated struct MultiConnectionDialer: SSHNetworkDialing {
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { throw CancellationError() }
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> { [] }
}