// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import SwiftData import TermindApplication import TermindDomain import TermindPersistence import TermindRemoteInfrastructure import Testing import struct TermindDomain.Host @Suite @MainActor struct TailscaleIntegrationTests { @Test func lateMetricsSyncCannotRestoreAnExplicitlyDeletedOrdinaryHost() throws { let container = try TermindPersistence.makeContainer(inMemory: true) let hostID = UUID() let tombstone = VaultEnvelope( key: VaultRecordKey(kind: .host, id: hostID), version: .baseline(writerID: UUID()), isDeleted: true) try VaultSyncJournal(context: container.mainContext).acceptRemote([tombstone]) try container.mainContext.save() let metrics = MetricsSyncPersistence(context: container.mainContext) let payload = MetricsRecordPayload.selection( SyncedMetricsSelection(selection: MetricsSelection(id: hostID), sortIndex: 0)) let result = try metrics.applyRemote([ MetricsEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) ]) #expect(result.snapshot.selections.isEmpty) #expect(result.needsUpload.contains(payload.key)) #expect(try metrics.journal.envelope(for: payload.key)?.isDeleted == true) } @Test func losingProBlocksRetainedDialersWithoutClosingExistingNodes() async throws { let group = try group("example.ts.net") let library = HostLibraryModel(groups: [group]) let store = InMemoryTailscaleLocalStore() try store.save(TailscaleLocalState(groupID: group.id, profileID: UUID())) let node = TestTailscaleNode(network: try #require(group.tailscale?.network)) let access = TailscaleTestAccess() let model = TailscaleModel( library: library, store: store, factory: { _, _, _, _ in node }, profileStorage: .init(profiles: { [] }, erase: { _ in }), hostname: { "Existing device name" }, hasAccess: { access.allowed }) model.start() for _ in 0..<100 where !model.runningGroups.contains(group.id) { try await Task.sleep(for: .milliseconds(10)) } #expect(model.runningGroups.contains(group.id)) let retained = try model.connection(for: group.id) access.allowed = false model.reconcile() await #expect(throws: TailscaleConfigurationError.self) { try await retained.open(host: "server.example.ts.net", port: 22, timeout: 1) } #expect(await node.closed == false) await model.shutdown() #expect(await node.closed) } @Test func previousStoreMigratesGroupsWithoutChangingOrdinaryHosts() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("Termind.store") let ordinaryGroup = HostGroup(name: "Existing") let ordinaryHost = Host(name: "Existing", host: "example.invalid", groupID: ordinaryGroup.id) try writePreviousStore(url, group: ordinaryGroup, host: ordinaryHost) for _ in 0..<2 { let container = try TermindPersistence.makeContainer(url: url) let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext)) #expect(library.host(ordinaryHost.id)?.host == ordinaryHost.host) #expect(library.group(ordinaryGroup.id)?.name == ordinaryGroup.name) #expect(library.group(ordinaryGroup.id)?.tailscale == nil) } } @Test func tailnetStoreReopensWithoutLosingEnrollmentOrPreferences() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("Termind.store") var group = try group("example.ts.net") let hostID = try #require(group.tailscale).hostID(groupID: group.id, stableNodeID: "n123") var preferences = TailscaleHostPreferences() preferences.username = "deploy" preferences.tags = ["production"] preferences.colorTag = .purple group.tailscale?.hostPreferences[hostID] = preferences let profileID = UUID() let peer = peer() try writeTailnetStore(url, group: group, profileID: profileID, peer: peer) for pass in 0..<2 { let container = try TermindPersistence.makeContainer(url: url) let localStore = SwiftDataTailscaleLocalStore(context: container.mainContext) var state = try #require(localStore.load().first) #expect(state.groupID == group.id && state.profileID == profileID) #expect(state.peers == [peer]) #expect(state.hostname == (pass == 0 ? nil : "saved-device")) let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext)) #expect(library.group(group.id)?.tailscale?.hostPreferences[hostID] == preferences) library.replaceTailscalePeers([peer], groupID: group.id) #expect(library.hosts.first?.id == hostID) let retainedHost = try #require(library.host(hostID)) #expect(library.savedTailscaleUsername(for: retainedHost) == "deploy") state.hostname = "saved-device" try localStore.save(state) } } private func writeTailnetStore(_ url: URL, group: HostGroup, profileID: UUID, peer: TailscalePeer) throws { let container = try TermindPersistence.makeContainer(url: url) _ = try HostPersistence(context: container.mainContext).save(.init(groups: [group])) try SwiftDataTailscaleLocalStore(context: container.mainContext).save( TailscaleLocalState(groupID: group.id, profileID: profileID, peers: [peer])) } private func writePreviousStore(_ url: URL, group: HostGroup, host: Host) throws { let schema = Schema(versionedSchema: TermindSchemaV46.self) let container = try ModelContainer( for: schema, configurations: [ ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none) ]) container.mainContext.insert(PersistentHostGroup(group)) container.mainContext.insert(PersistentHost(host)) try container.mainContext.save() } private func group(_ suffix: String, parentID: UUID? = nil) throws -> HostGroup { let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: suffix) var group = HostGroup(name: suffix, parentID: parentID) group.tailscale = TailscaleGroupConfiguration(network: network) group.tailscale?.username = "operator" return group } @Test func controlURLsRemainCanonicalConnectionConfiguration() throws { let first = try TailscaleNetwork(controlURL: "https://LOGIN.TAILSCALE.COM:443/", dnsSuffix: "Example.TS.NET.") let second = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net") #expect(first == second) #expect(throws: TailscaleConfigurationError.self) { try TailscaleNetwork(controlURL: "https://user:secret@example.com", dnsSuffix: "example.com") } } @Test func subgroupOverridesNearestParentAndMovingChangesNewConnections() throws { let parent = try group("parent.ts.net") var child = HostGroup(name: "Child", parentID: parent.id) let leaf = HostGroup(name: "Leaf", parentID: child.id) let host = Host(name: "Manual", host: "server", groupID: leaf.id) let library = HostLibraryModel(hosts: [host], groups: [parent, child, leaf]) #expect(library.tailscaleGroup(for: host)?.id == parent.id) child.tailscale = try group("child.ts.net").tailscale library.upsert(child) #expect(library.tailscaleGroup(for: host)?.id == child.id) child.tailscale = nil child.parentID = nil library.upsert(child) #expect(library.tailscaleGroup(for: host) == nil) child.parentID = parent.id library.upsert(child) #expect(throws: TailscaleConfigurationError.self) { try library.tailscaleNetwork(for: host) } } @Test func additionalOrdinaryConnectionsCannotDropTheirRequiredTailnetRoute() throws { let group = try group("example.ts.net") let host = Host(name: "Manual", host: "server", groupID: group.id) let library = HostLibraryModel(hosts: [host], groups: [group]) let provider = TestTailscaleProvider() library.tailscaleConnections = provider var configuration = SSHConnectionConfiguration( hostID: host.id, host: host.host, port: 22, username: "operator", credentials: [], expectedHostKeys: [], authenticationReference: SSHAuthenticationReference(username: "operator", permitsNone: true)) configuration.network = try library.tailscaleNetwork(for: host) let snapshot = library.additionalConnectionSnapshot(from: configuration) #expect(try library.additionalConnectionRequest(from: snapshot).configuration.network != nil) library.move(hostIDs: [host.id], into: nil) #expect(throws: TailscaleConfigurationError.self) { try library.additionalConnectionRequest(from: snapshot) } #expect(try library.tailscaleNetwork(for: host) == nil) } @Test func networkStampTracksOnlyThisConnectionsRoutingAndAuthentication() throws { var group = try group("example.ts.net") let library = HostLibraryModel(groups: [group]) library.replaceTailscalePeers([peer()], groupID: group.id) let host = try #require(library.hosts.first) let original = library.networkConfigurationStamp(for: host) for _ in 0..<100 { #expect(library.networkConfigurationStamp(for: host) == original) } var unrelated = TailscaleHostPreferences() unrelated.username = "another-user" unrelated.tags = ["production"] group.tailscale?.hostPreferences[UUID()] = unrelated library.upsert(group) #expect(library.networkConfigurationStamp(for: host) == original) group.tailscale?.username = "changed-user" library.upsert(group) let changedUser = library.networkConfigurationStamp(for: host) #expect(changedUser != original) group.tailscale?.exitNodeID = "exit-node" library.upsert(group) #expect(library.networkConfigurationStamp(for: host) != changedUser) } @Test(arguments: [true, false]) func loginRechecksRemoteGroupChangesAfterApplyingExitNode(deleted: Bool) async throws { var group = try group("example.ts.net") let library = HostLibraryModel(groups: [group]) let store = InMemoryTailscaleLocalStore() let node = TestTailscaleNode(network: try #require(group.tailscale?.network), holdExitNode: true) let model = TailscaleModel( library: library, store: store, factory: { _, _, _, _ in node }, profileStorage: .init(profiles: { [] }, erase: { _ in }), hostname: { "device" }, hasAccess: { true }) model.beginLoginAndSave( name: group.name, parentID: nil, controlURL: TailscaleNetwork.defaultControlURL, authKey: nil, groupID: group.id) await node.waitForExitNode() group.name = "Remote name" group.tailscale?.username = "remote-user" library.replacePersistedSnapshot(VaultSnapshot(groups: deleted ? [] : [group])) await node.releaseExitNode() for _ in 0..<1000 { if deleted ? await node.closed : model.loginCompletedGroupID != nil { break } await Task.yield() } if deleted { #expect(library.group(group.id) == nil) #expect(try store.load().isEmpty) #expect(model.loginCompletedGroupID == nil) #expect(await node.closed) #expect(await node.logoutCount == 1) } else { #expect(library.group(group.id)?.name == "Remote name") #expect(library.group(group.id)?.tailscale?.username == "remote-user") #expect(model.loginCompletedGroupID == group.id) #expect(try store.load().count == 1) } await model.shutdown() } @Test func sameTailnetCanBeBoundToAnExplicitSubgroup() throws { let parent = try group("example.ts.net") let library = HostLibraryModel(groups: [parent]) var duplicate = HostGroup(name: "Duplicate", parentID: parent.id) duplicate.tailscale = parent.tailscale library.upsert(duplicate) #expect(library.group(duplicate.id)?.tailscale?.network == parent.tailscale?.network) #expect(library.storageError == nil) } @Test func discoveredHostsStayLocalAndPreferencesRoundTripThroughGroup() throws { let container = try TermindPersistence.makeContainer(inMemory: true) let persistence = HostPersistence(context: container.mainContext) let library = HostLibraryModel(persistence: persistence) let group = try group("example.ts.net") library.upsert(group) library.replaceTailscalePeers([peer()], groupID: group.id) let managed = try #require(library.hosts.first) #expect(managed.host == "server.example.ts.net") #expect(library.saveTailscaleUsername("root", hostID: managed.id)) library.projectHostDeletion(managed.id) #expect(library.host(managed.id) != nil) var forged = managed forged.host = "attacker.invalid" #expect(!library.saveHost(forged)) let reloaded = HostLibraryModel(persistence: persistence) #expect(reloaded.hosts.isEmpty) reloaded.replaceTailscalePeers([peer(name: "Renamed", online: false)], groupID: group.id) #expect(reloaded.hosts.first?.id == managed.id) #expect(reloaded.tailscaleUsername(for: managed) == "root") #expect(!reloaded.isEligible(managed, for: .keyExport)) #expect(!reloaded.isEligible(managed, for: .jumpHost)) } @Test func managedPreparationAndExtraConnectionsNeverResolveOrdinaryCredentials() async throws { let group = try group("example.ts.net") let library = HostLibraryModel(groups: [group]) let provider = TestTailscaleProvider() library.tailscaleConnections = provider library.replaceTailscalePeers([peer()], groupID: group.id) let host = try #require(library.hosts.first) let preparation = try library.prepareSSHConnection(for: host, submissions: [:], proxySubmissions: [:]) #expect(preparation.nodes.first?.identity == nil) #expect(preparation.nodes.first?.key == nil) let configuration = try await preparation.resolve() #expect(configuration.credentials.isEmpty) #expect(configuration.expectedHostKeys.isEmpty) #expect(configuration.tailscaleNodeID == "n123") #expect(configuration.network != nil) let snapshot = library.additionalConnectionSnapshot(from: configuration) let extra = try library.additionalConnectionRequest(from: snapshot) #expect(extra.configuration.credentials.isEmpty) #expect(extra.configuration.network != nil) #expect(extra.configuration.tailscaleNodeID == "n123") provider.allowed = false #expect(throws: TailscaleConfigurationError.self) { try library.additionalConnectionRequest(from: snapshot) } } @Test func tagsAndColorSyncBeforeAnotherDeviceDiscoversTheHost() throws { let source = try TermindPersistence.makeContainer(inMemory: true) let sourceLibrary = HostLibraryModel(persistence: HostPersistence(context: source.mainContext)) let group = try group("example.ts.net") sourceLibrary.upsert(group) sourceLibrary.replaceTailscalePeers([peer()], groupID: group.id) var host = try #require(sourceLibrary.hosts.first) host.tags = ["production", "linux"] host.colorTag = .blue #expect(sourceLibrary.saveHost(host)) sourceLibrary.recordRemoteSystemInfo( RemoteSystemInfo(distributionID: "ubuntu", distributionLikeIDs: ["debian"], kernelName: "Linux"), for: host.id) sourceLibrary.replaceTailscalePeers([peer(name: "Refreshed")], groupID: group.id) #expect(sourceLibrary.host(host.id)?.detectedDistribution?.id == "ubuntu") var exitGroup = try #require(sourceLibrary.group(group.id)) exitGroup.tailscale?.exitNodeID = "exit-node-stable-id" sourceLibrary.upsert(exitGroup) let envelopes = try VaultSyncJournal(context: source.mainContext).pendingEnvelopes() #expect(!envelopes.contains { $0.key.kind == .host }) let remote = try envelopes.map { try VaultSyncCodec.decode(VaultSyncCodec.encode($0)) } let destination = try TermindPersistence.makeContainer(inMemory: true) let destinationStore = HostPersistence(context: destination.mainContext) _ = try destinationStore.applyRemote(remote) let destinationLibrary = HostLibraryModel(persistence: destinationStore) #expect(destinationLibrary.hosts.isEmpty) destinationLibrary.replaceTailscalePeers([peer(name: "Renamed")], groupID: group.id) let discovered = try #require(destinationLibrary.host(host.id)) #expect(discovered.tags == ["production", "linux"]) #expect(discovered.colorTag == .blue) #expect(discovered.detectedDistribution?.id == "ubuntu") #expect(destinationLibrary.group(group.id)?.tailscale?.exitNodeID == "exit-node-stable-id") #expect(discovered.name == "Renamed") destinationLibrary.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "Linux"), for: discovered.id) #expect(destinationLibrary.host(discovered.id)?.detectedDistribution?.id == "linux") destinationLibrary.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "FreeBSD"), for: discovered.id) destinationLibrary.replaceTailscalePeers([peer()], groupID: group.id) #expect(destinationLibrary.host(discovered.id)?.detectedDistribution?.id == "freebsd") destinationLibrary.recordRemoteSystemInfo(RemoteSystemInfo(), for: discovered.id) #expect(destinationLibrary.host(discovered.id)?.detectedDistribution?.id == "freebsd") destinationLibrary.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "SunOS"), for: discovered.id) #expect(destinationLibrary.host(discovered.id)?.detectedDistribution == nil) } @Test func usernamePromptsAndRouteSavingNeverCreateOrdinaryIdentities() async throws { var group = try group("example.ts.net") group.tailscale?.username = "" let library = HostLibraryModel(groups: [group]) let provider = TestTailscaleProvider() library.tailscaleConnections = provider library.replaceTailscalePeers([peer()], groupID: group.id) let host = try #require(library.hosts.first) let prompt = SSHAuthenticationPromptResolver.host(host, using: library, username: "") #expect(prompt.usernameOnly) #expect(prompt.username == "root") #expect(prompt.selectedIdentityID == nil && prompt.selectedKeyID == nil) #expect(!prompt.requiresPassphrase) let submission = SSHAuthenticationSubmission(username: "operator") _ = try library.saveAuthentication(submission, for: host, selectedIdentity: nil, suppliedPassword: nil) #expect(library.tailscaleUsername(for: host) == "operator") #expect(library.identities.isEmpty) _ = try library.saveConnectionRoute( SSHConnectionRoute(hosts: [host]), authentication: submission, authenticationHostID: host.id) #expect(library.identities.isEmpty && library.hosts.count == 1) let ephemeral = SSHAuthenticationSubmission(username: "another-user", password: "ignored", keyID: UUID()) let preparation = try library.prepareSSHConnection(for: host, submissions: [host.id: ephemeral]) let configuration = try await preparation.resolve() #expect(configuration.username == "another-user") #expect(configuration.credentials.isEmpty) #expect(configuration.tailscaleNodeID == "n123") #expect(library.tailscaleUsername(for: host) == "operator") } @Test func assistantAndExternalUsernameRequestsUseTheTailscalePrompt() async throws { var group = try group("example.ts.net") group.tailscale?.username = "" let library = HostLibraryModel(groups: [group]) let provider = TestTailscaleProvider() library.tailscaleConnections = provider library.replaceTailscalePeers([peer()], groupID: group.id) let host = try #require(library.hosts.first) let interaction = AgentSSHInteraction( authenticate: { request in #expect(request.mode == .tailscaleUsername) #expect(request.journeyPrompt.usernameOnly) #expect(request.selectedIdentityID == nil && request.selectedKeyID == nil) return .submit(AgentSSHAuthenticationAnswer(username: "deploy", save: true)) }, confirmHostKey: { _ in Issue.record("Managed hosts must not ask for ordinary host-key trust") return .deny }) let session = AgentSSHAuthenticationContext( resolver: AgentHostResolver(store: library), hostID: host.id, invocationID: UUID(), interaction: interaction ) let configuration = try await session.configuration() #expect(configuration.username == "deploy") #expect(configuration.credentials.isEmpty) #expect(configuration.tailscaleNodeID == "n123") #expect(library.tailscaleUsername(for: host) == "deploy") #expect(library.identities.isEmpty) let resumed = AgentSSHAuthenticationContext( resolver: AgentHostResolver(store: library), hostID: host.id, invocationID: UUID(), interaction: AgentSSHInteraction( authenticate: { _ in Issue.record("Saved Tailscale username must bypass the initial prompt") return .cancelled }, confirmHostKey: { _ in .deny })) #expect(try await resumed.configuration().username == "deploy") } @Test func olderHostPreferencesDecodeWithoutTagsOrColor() throws { let data = Data( #"{"username":"operator","charset":"utf8","sftpInitialPath":"/srv","shellIntegrationMode":"followGlobalSettings","environmentVariables":[]}"# .utf8) let preferences = try JSONDecoder().decode(TailscaleHostPreferences.self, from: data) #expect(preferences.tags.isEmpty) #expect(preferences.colorTag == .none) #expect(preferences.username == "operator") #expect(preferences.sftpInitialPath == "/srv") } @Test func nodesWithoutSSHDisappearWithoutLosingSyncedPreferencesOrManualHosts() throws { let group = try group("example.ts.net") let manual = Host(name: "Manual", host: "manual.example.ts.net", groupID: group.id) let library = HostLibraryModel(hosts: [manual], groups: [group]) library.replaceTailscalePeers([peer()], groupID: group.id) var managed = try #require(library.hosts.first { $0.isTailscaleManaged }) managed.tags = ["keep"] #expect(library.saveHost(managed)) var disabled = peer() disabled.sshHostKeys = [] library.replaceTailscalePeers([disabled], groupID: group.id) #expect(library.host(managed.id) == nil) #expect(library.host(manual.id) != nil) library.replaceTailscalePeers([peer(online: false)], groupID: group.id) #expect(library.host(managed.id)?.tags == ["keep"]) } @Test func statusRetainsNonSSHPeersForDiagnosticsAndReportsLocalDeviceDetails() throws { let data = Data( #"{"BackendState":"Running","CurrentTailnet":{"Name":"Example","MagicDNSSuffix":"example.ts.net"},"Self":{"HostName":"Phone","DNSName":"phone.example.ts.net.","TailscaleIPs":["100.64.0.1","fd7a:115c:a1e0::1"],"OS":"iOS","KeyExpiry":"2027-01-02T03:04:05.123Z"},"Peer":{"disabled":{"ID":"n456","HostName":"desktop","DNSName":"desktop.example.ts.net.","Online":true,"InNetworkMap":true,"ExitNodeOption":true}}}"# .utf8) let status = try LibTailscaleNodeSession.decodeStatus(data, controlURL: TailscaleNetwork.defaultControlURL) #expect(status.peers.count == 1) #expect(status.peers.first?.supportsSSH == false) #expect(status.peers.first?.exitNodeOption == true) let device = try #require(status.device) #expect(device.networkName == "Example") #expect(device.dnsName == "phone.example.ts.net") #expect(device.hostname == "Phone") #expect(device.addresses == ["100.64.0.1", "fd7a:115c:a1e0::1"]) #expect(device.operatingSystem == "iOS") #expect(device.keyExpiry != nil) } @Test func missingTailscaleMetricsRetainConfigurationUntilExplicitGroupDeletion() throws { let group = try group("example.ts.net") var selection = MetricsSelection(id: UUID(), enabled: false) selection.tailscaleGroupID = group.id selection.displayName = "Offline node" let preferences = TailscaleMetricsPreferences(values: [selection]) let library = HostLibraryModel(groups: [group]) let metrics = ServerMetricsModel( hosts: library, preferences: preferences, makeSampler: { TailscaleTestSampler() }) metrics.start() defer { metrics.shutdown() } #expect(metrics.servers.isEmpty) #expect(metrics.selections == [selection]) #expect(metrics.replaceHosts(selectedIDs: [], editableHostIDs: [])) #expect(preferences.values == [selection]) metrics.removeTailscaleGroups([group.id]) #expect(preferences.values.isEmpty) #expect(metrics.servers.isEmpty) } @Test func statusRequiresAnAuthoritativePeerMapAndKeepsOfflineNodes() throws { let data = Data( #"{"BackendState":"Running","CurrentTailnet":{"MagicDNSSuffix":"example.ts.net"},"Peer":{"key":{"ID":"n123","HostName":"server","DNSName":"server.example.ts.net.","Online":false,"InNetworkMap":true,"sshHostKeys":["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"]}}}"# .utf8) let status = try LibTailscaleNodeSession.decodeStatus(data, controlURL: TailscaleNetwork.defaultControlURL) #expect(status.running) #expect(status.peers.count == 1) #expect(status.peers.first?.online == false) let incomplete = Data(#"{"BackendState":"Running","CurrentTailnet":{"MagicDNSSuffix":"example.ts.net"}}"#.utf8) #expect(throws: DecodingError.self) { try LibTailscaleNodeSession.decodeStatus(incomplete, controlURL: TailscaleNetwork.defaultControlURL) } let empty = Data( #"{"BackendState":"Running","CurrentTailnet":{"MagicDNSSuffix":"example.ts.net"},"Peer":null}"#.utf8) #expect(try LibTailscaleNodeSession.decodeStatus(empty, controlURL: TailscaleNetwork.defaultControlURL).running) #expect(LibTailscaleNodeSession.hostKey("ssh-ed25519 invalid") == nil) #expect( LibTailscaleNodeSession.hostKey( "ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") == nil) } @Test func savedUsernameAppearsInCardsWithoutInventingAnUnsavedLogin() throws { var group = try group("example.ts.net") group.tailscale?.username = "" let library = HostLibraryModel(groups: [group]) library.replaceTailscalePeers([peer()], groupID: group.id) let host = try #require(library.hosts.first) #expect(library.savedTailscaleUsername(for: host) == nil) #expect(library.loginAddress(for: host) == "server.example.ts.net") #expect(library.saveTailscaleUsername("operator", hostID: host.id)) #expect(library.loginAddress(for: host) == "operator@server.example.ts.net") #expect(library.identities.isEmpty) } @Test func sshCommandUsesTailscaleUsernameAndPreviewsUnsavedEdits() throws { let group = try group("example.ts.net") let library = HostLibraryModel(groups: [group]) library.replaceTailscalePeers([peer()], groupID: group.id) let host = try #require(library.hosts.first) #expect(library.sshCommand(for: host) == "ssh operator@server.example.ts.net") #expect(library.saveTailscaleUsername("deploy", hostID: host.id)) #expect(library.sshCommand(for: host) == "ssh deploy@server.example.ts.net") #expect( library.sshCommand(for: host, username: " o'brien ") == "ssh 'o'\"'\"'brien@server.example.ts.net'") #expect(library.sshCommand(for: host, username: "") == "ssh server.example.ts.net") #expect(library.savedTailscaleUsername(for: host) == "deploy") #expect(library.identities.isEmpty) } @Test func hostnamePersistsLocallyAndEditsReachTheRunningNode() async throws { let group = try group("example.ts.net") let library = HostLibraryModel(groups: [group]) let container = try TermindPersistence.makeContainer(inMemory: true) let store = SwiftDataTailscaleLocalStore(context: container.mainContext) try store.save(TailscaleLocalState(groupID: group.id, profileID: UUID())) let node = TestTailscaleNode(network: try #require(group.tailscale?.network)) let model = TailscaleModel( library: library, store: store, factory: { _, _, _, _ in node }, profileStorage: .init(profiles: { [] }, erase: { _ in }), hostname: { "default-device" }, hasAccess: { true }) model.start() for _ in 0..<100 where !model.runningGroups.contains(group.id) { try await Task.sleep(for: .milliseconds(10)) } #expect(try store.load().first?.hostname == "default-device") try await model.saveHostname(" custom-device ", groupID: group.id) #expect(await node.hostname == "custom-device") #expect(try store.load().first?.hostname == "custom-device") await model.shutdown() let restored = TailscaleModel( library: library, store: store, factory: { _, _, _, _ in node }, profileStorage: .init(profiles: { [] }, erase: { _ in }), hostname: { "renamed-system-device" }, hasAccess: { false }) restored.start() #expect(restored.savedHostname(groupID: group.id) == "custom-device") await restored.shutdown() } @Test func pingDistinguishesDirectRelayUnknownAndServerFailure() throws { func decode(_ json: String) throws -> TailscalePingResult { try LibTailscaleNodeSession.decodePing(Data(json.utf8)) } let direct = try decode(#"{"LatencySeconds":0.012,"Endpoint":"192.0.2.1:41641"}"#) #expect(direct.milliseconds == 12) #expect(direct.route == .direct("192.0.2.1:41641")) #expect(try decode(#"{"LatencySeconds":0.1,"DERPRegionID":3,"DERPRegionCode":"sin"}"#).route == .derp("sin")) #expect( try decode(#"{"LatencySeconds":0.1,"PeerRelay":"100.64.0.1:1:vni:2"}"#).route == .peerRelay("100.64.0.1:1:vni:2")) #expect(try decode(#"{"LatencySeconds":0.1}"#).route == .unknown) #expect(throws: TermindSSHError.self) { try decode(#"{"Err":"peer is offline"}"#) } } @Test func syncedExitNodeIsAppliedAndCanBeDisabled() async throws { var group = try group("example.ts.net") group.tailscale?.exitNodeID = "exit-one" let library = HostLibraryModel(groups: [group]) let store = InMemoryTailscaleLocalStore() try store.save(TailscaleLocalState(groupID: group.id, profileID: UUID(), hostname: "device")) let exitPeer = TailscalePeer( stableID: "exit-one", name: "Exit without SSH", dnsName: "exit.example.ts.net", online: true, sshHostKeys: [], exitNodeOption: true) let node = TestTailscaleNode( network: try #require(group.tailscale?.network), peers: [exitPeer, peer()]) let model = TailscaleModel( library: library, store: store, factory: { _, _, _, _ in node }, profileStorage: .init(profiles: { [] }, erase: { _ in }), hostname: { "device" }, hasAccess: { true }) model.start() for _ in 0..<100 where !model.runningGroups.contains(group.id) { try await Task.sleep(for: .milliseconds(10)) } #expect(await node.selectedExitNode == "exit-one") #expect( TailscaleGroupEditor(model: model, group: library.group(group.id)).candidates.map(\.stableID) == [ "exit-one" ]) #expect(!library.hosts.contains { $0.tailscale?.stableNodeID == "exit-one" }) let retained = try model.connection(for: group.id) group.tailscale?.exitNodeID = "exit-two" library.upsert(group) await #expect(throws: TailscaleConfigurationError.self) { try await retained.open(host: "example.com", port: 22, timeout: 1) } for _ in 0..<400 { if await node.selectedExitNode == "exit-two" { break } try await Task.sleep(for: .milliseconds(10)) } #expect(await node.selectedExitNode == "exit-two") let editor = TailscaleGroupEditor(model: model, group: library.group(group.id)) editor.exitNodeID = "" _ = try await editor.save() for _ in 0..<400 { if await node.selectedExitNode == nil { break } try await Task.sleep(for: .milliseconds(10)) } #expect(await node.selectedExitNode == nil) #expect(library.group(group.id)?.tailscale?.exitNodeID == nil) await model.shutdown() } private func peer(name: String = "Server", online: Bool = true) -> TailscalePeer { TailscalePeer( stableID: "n123", name: name, dnsName: "server.example.ts.net", online: online, sshHostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"]) } } @MainActor private final class TestTailscaleProvider: TailscaleConnectionProviding { var allowed = true func connection(for groupID: UUID) throws -> any SSHNetworkDialing { guard allowed else { throw TailscaleConfigurationError.requiresPro } return TestTailscaleDialer() } } private nonisolated struct TestTailscaleDialer: SSHNetworkDialing { func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { throw CancellationError() } func hostKeys(stableNodeID: String) async throws -> Set { [] } } @MainActor private final class TailscaleMetricsPreferences: MetricsConfigurationStoring { var values: [MetricsSelection] init(values: [MetricsSelection]) { self.values = values } func load() throws -> [MetricsSelection] { values } func save(_ selections: [MetricsSelection]) throws { values = selections } } private nonisolated struct TailscaleTestSampler: MetricsSampling { func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() } func sample() async throws -> MetricsSample { throw CancellationError() } func gpu() async throws -> NvidiaSnapshot? { nil } func disconnect() async {} } @MainActor private final class TailscaleTestAccess { var allowed = true } private actor TestTailscaleNode: TailscaleNodeSession { let network: TailscaleNetwork let peers: [TailscalePeer] var closed = false var hostname = "" var selectedExitNode: String? var logoutCount = 0 private var holdExitNode: Bool private var exitNodeContinuation: CheckedContinuation? func setExitNode(_ stableID: String?) async throws { if holdExitNode { holdExitNode = false await withCheckedContinuation { exitNodeContinuation = $0 } } selectedExitNode = stableID } func waitForExitNode() async { while exitNodeContinuation == nil { await Task.yield() } } func releaseExitNode() { exitNodeContinuation?.resume() exitNodeContinuation = nil } func setHostname(_ hostname: String) async throws { self.hostname = hostname } init(network: TailscaleNetwork, peers: [TailscalePeer] = [], holdExitNode: Bool = false) { self.network = network self.peers = peers self.holdExitNode = holdExitNode } func status() async throws -> TailscaleNodeStatus { TailscaleNodeStatus(running: true, network: network, authURL: nil, peers: peers) } func login() async throws {} func logout() async throws { logoutCount += 1 } func close() async { closed = true } func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { throw CancellationError() } func hostKeys(stableNodeID: String) async throws -> Set { [] } }