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.
653 lines
29 KiB
Swift
653 lines
29 KiB
Swift
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import Testing
|
|
|
|
@Suite @MainActor
|
|
struct TailscaleGroupEditorTests {
|
|
@Test(arguments: ["", "My servers"])
|
|
func loginWithoutSavingOnlyUpdatesTheEditor(name: String) async throws {
|
|
let fixture = try EditorFixture()
|
|
let editor = fixture.editor()
|
|
editor.draft.name = name
|
|
editor.setEnabled(true)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
#expect(editor.draft.name == (name.isEmpty ? "Test Tailnet" : name))
|
|
#expect(editor.canSave)
|
|
#expect(editor.isConnected)
|
|
#expect(editor.isDirty)
|
|
#expect(editor.candidates.count == 1)
|
|
#expect(fixture.library.groups.isEmpty)
|
|
#expect(fixture.library.hosts.isEmpty)
|
|
#expect(try fixture.store.load().isEmpty)
|
|
#expect(fixture.model.loginCompletedGroupID == nil)
|
|
#expect(throws: TailscaleConfigurationError.self) { try fixture.model.connection(for: editor.draft.id) }
|
|
// Further editing is reflected only when the user explicitly saves.
|
|
editor.draft.name = "Edited after login"
|
|
editor.hostname = "my-laptop"
|
|
editor.exitNodeID = "exit"
|
|
let saved = try await editor.save()
|
|
#expect(saved.name == "Edited after login")
|
|
#expect(saved.tailscale?.exitNodeID == "exit")
|
|
#expect(fixture.node.hostname == "my-laptop")
|
|
#expect(fixture.node.exitNode == "exit")
|
|
#expect(fixture.library.hosts.count == 1)
|
|
#expect(fixture.model.runningGroups.contains(saved.id))
|
|
editor.cancel()
|
|
#expect(!fixture.node.closed)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func lateLoginDoesNotOverwriteANameTypedWhileAuthenticating() async throws {
|
|
let fixture = try EditorFixture()
|
|
fixture.node.holdStatus = true
|
|
let editor = fixture.editor()
|
|
editor.setEnabled(true)
|
|
editor.login(authKey: nil)
|
|
try await wait { fixture.node.statusContinuation != nil }
|
|
editor.draft.name = "Typed while waiting"
|
|
fixture.node.releaseStatus()
|
|
try await fixture.ready(editor)
|
|
#expect(editor.draft.name == "Typed while waiting")
|
|
editor.cancel()
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func missingNetworkNameUsesDNSSuffixAndWhitespaceDoesNotBlockLogin() async throws {
|
|
let fixture = try EditorFixture()
|
|
fixture.node.networkName = " "
|
|
let editor = fixture.editor()
|
|
editor.draft.name = " \n "
|
|
editor.setEnabled(true)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
#expect(editor.draft.name == "test.ts.net")
|
|
editor.cancel()
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func cancellingOrDisablingAnAuthenticatedDraftCleansOnlyItsCandidate(disable: Bool) async throws {
|
|
let fixture = try EditorFixture()
|
|
let editor = fixture.editor()
|
|
editor.setEnabled(true)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
if disable { editor.setEnabled(false) } else { editor.cancel() }
|
|
try await wait { fixture.node.closed }
|
|
#expect(!editor.isConnected)
|
|
#expect(fixture.node.logouts == 1)
|
|
#expect(fixture.library.groups.isEmpty)
|
|
#expect(try fixture.store.load().isEmpty)
|
|
if disable {
|
|
let saved = try await editor.save()
|
|
#expect(saved.tailscale == nil)
|
|
}
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func logoutOfAnUnsavedLoginTakesEffectImmediatelyInNewAndEdit(existing: Bool) async throws {
|
|
let fixture = try EditorFixture(existing: existing)
|
|
let savedGroups = fixture.library.groups
|
|
let editor = fixture.editor(savedGroups.first)
|
|
editor.setEnabled(true)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
#expect(editor.pending != nil && editor.isConnected)
|
|
editor.logout()
|
|
#expect(editor.pending == nil && !editor.isConnected && !editor.isLogoutPending)
|
|
try await wait { fixture.node.closed }
|
|
#expect(fixture.node.logouts == 1)
|
|
#expect(fixture.library.groups == savedGroups)
|
|
#expect(try fixture.store.load().isEmpty)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func anOlderEditorCannotCancelAnotherEditorsLogin() async throws {
|
|
let fixture = try EditorFixture()
|
|
let first = fixture.editor()
|
|
first.setEnabled(true)
|
|
first.login(authKey: nil)
|
|
try await fixture.ready(first)
|
|
let second = fixture.editor()
|
|
second.setEnabled(true)
|
|
second.login(authKey: nil)
|
|
let operation = second.operationID
|
|
first.cancel()
|
|
#expect(fixture.model.loginOperationID == operation)
|
|
#expect(second.operationID == operation)
|
|
second.cancel()
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func reloginPreservesOrReplacesNetworkPreferencesOnlyWhenSaved(switchNetwork: Bool) async throws {
|
|
let fixture = try EditorFixture(existing: true, otherNetwork: switchNetwork)
|
|
let group = try #require(fixture.library.groups.first)
|
|
let editor = fixture.editor(group)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
#expect(fixture.library.group(group.id)?.tailscale == group.tailscale)
|
|
#expect(fixture.model.runningGroups.isEmpty)
|
|
let saved = try await editor.save()
|
|
#expect(saved.id == group.id)
|
|
let before = try #require(group.tailscale).hostID(groupID: group.id, stableNodeID: "exit")
|
|
let after = try #require(saved.tailscale).hostID(groupID: saved.id, stableNodeID: "exit")
|
|
#expect((before != after) == switchNetwork)
|
|
#expect(saved.tailscale?.hostIdentityVersion == .groupScopedV2)
|
|
#expect(saved.tailscale?.network == fixture.node.network)
|
|
#expect(saved.tailscale?.username == (switchNetwork ? "" : "operator"))
|
|
#expect(saved.tailscale?.exitNodeID == (switchNetwork ? nil : "exit"))
|
|
#expect(saved.tailscale?.hostPreferences.isEmpty == switchNetwork)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func disablingBindingPreservesManualHostsAndSubgroupsAndNotifiesCleanup() async throws {
|
|
let fixture = try EditorFixture(existing: true)
|
|
let group = try #require(fixture.library.groups.first)
|
|
let child = HostGroup(name: "Child", parentID: group.id)
|
|
fixture.library.upsert(child)
|
|
var manual = Host(name: "Manual", host: "example.com")
|
|
manual.groupID = group.id
|
|
fixture.library.upsert(manual)
|
|
fixture.library.replaceTailscalePeers(fixture.node.peers, groupID: group.id)
|
|
let managed = try #require(fixture.library.hosts.first { $0.isTailscaleManaged })
|
|
var removed: Set<UUID> = []
|
|
fixture.library.didDeleteTailscaleGroups = { _, hosts in removed.formUnion(hosts) }
|
|
let editor = fixture.editor(group)
|
|
editor.setEnabled(false)
|
|
#expect(fixture.library.group(group.id)?.tailscale != nil)
|
|
let saved = try await editor.save()
|
|
#expect(saved.tailscale == nil)
|
|
#expect(fixture.library.host(manual.id) != nil)
|
|
#expect(fixture.library.group(child.id)?.parentID == group.id)
|
|
#expect(fixture.library.host(managed.id) == nil)
|
|
#expect(removed == [managed.id])
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func localSaveFailureRetainsTheCandidateForRetry() async throws {
|
|
let fixture = try EditorFixture()
|
|
let editor = fixture.editor()
|
|
editor.setEnabled(true)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
fixture.store.failSave = true
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
#expect(fixture.library.group(editor.draft.id)?.tailscale != nil)
|
|
#expect(editor.pending != nil)
|
|
#expect(!fixture.node.closed)
|
|
#expect(fixture.model.runningGroups.isEmpty)
|
|
#expect(fixture.model.error != nil)
|
|
fixture.store.failSave = false
|
|
_ = try await editor.save()
|
|
#expect(editor.pending == nil)
|
|
#expect(fixture.model.runningGroups.contains(editor.draft.id))
|
|
#expect(try fixture.store.load().count == 1)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func syncedNetworkReplacementInvalidatesCachedPeersAndThePendingLogin() async throws {
|
|
let fixture = try EditorFixture(existing: true)
|
|
var group = try #require(fixture.library.groups.first)
|
|
fixture.library.replaceTailscalePeers(fixture.node.peers, groupID: group.id)
|
|
let editor = fixture.editor(group)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
group.tailscale = TailscaleGroupConfiguration(
|
|
network: try TailscaleNetwork(controlURL: "https://other.example.com", dnsSuffix: "other.ts.net"))
|
|
fixture.library.replacePersistedSnapshot(.init(groups: [group]))
|
|
#expect(fixture.library.hosts.isEmpty)
|
|
#expect(editor.pending == nil)
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
#expect(fixture.library.group(group.id)?.tailscale == group.tailscale)
|
|
try await wait { fixture.node.closed }
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func cancellationDuringSaveCannotActivateTheLateResult() async throws {
|
|
let fixture = try EditorFixture()
|
|
let editor = fixture.editor()
|
|
editor.setEnabled(true)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
fixture.node.holdStatus = true
|
|
let saving = Task { try await editor.save() }
|
|
try await wait { fixture.node.statusContinuation != nil }
|
|
editor.cancel()
|
|
for _ in 0..<10 { await Task.yield() }
|
|
#expect(!fixture.node.closed)
|
|
fixture.node.releaseStatus()
|
|
await #expect(throws: (any Error).self) { try await saving.value }
|
|
try await wait { fixture.node.closed }
|
|
#expect(fixture.library.groups.isEmpty)
|
|
#expect(fixture.model.runningGroups.isEmpty)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func vaultSaveFailureKeepsTheCandidateAndDoesNotCreateAGroup() async throws {
|
|
let fixture = try EditorFixture()
|
|
let editor = fixture.editor()
|
|
editor.setEnabled(true)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
fixture.vault.failSave = true
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
#expect(fixture.library.groups.isEmpty)
|
|
#expect(try fixture.store.load().isEmpty)
|
|
#expect(editor.pending != nil)
|
|
#expect(!fixture.node.closed)
|
|
fixture.vault.failSave = false
|
|
_ = try await editor.save()
|
|
#expect(fixture.library.groups.count == 1)
|
|
#expect(fixture.model.runningGroups.contains(editor.draft.id))
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func logoutRemainsAnEditorDraftUntilSaveAndCancellationKeepsTheConnection() async throws {
|
|
let fixture = try EditorFixture()
|
|
let saved = try await fixture.connect()
|
|
let state = try #require(fixture.store.load().first)
|
|
let editor = fixture.editor(saved)
|
|
editor.logout()
|
|
#expect(editor.isLogoutPending && editor.isDirty && editor.canSave)
|
|
#expect(!editor.isConnected)
|
|
#expect(fixture.model.runningGroups.contains(saved.id))
|
|
#expect(try fixture.store.load().first?.profileID == state.profileID)
|
|
#expect(fixture.node.logouts == 0 && !fixture.node.closed)
|
|
#expect(!fixture.library.hosts.isEmpty)
|
|
editor.cancel()
|
|
#expect(!editor.isLogoutPending && editor.isConnected && !editor.isDirty)
|
|
#expect(fixture.model.runningGroups.contains(saved.id))
|
|
#expect(fixture.node.logouts == 0)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func savingLogoutKeepsTheBindingAndEndsOnlyTheLocalEnrollment() async throws {
|
|
let fixture = try EditorFixture()
|
|
let saved = try await fixture.connect()
|
|
let editor = fixture.editor(saved)
|
|
editor.logout()
|
|
editor.draft.name = "Renamed while logging out"
|
|
let updated = try await editor.save()
|
|
#expect(updated.name == editor.draft.name)
|
|
#expect(updated.tailscale == saved.tailscale)
|
|
#expect(fixture.node.logouts == 1 && fixture.node.closed)
|
|
#expect(fixture.model.runningGroups.isEmpty)
|
|
#expect(fixture.library.hosts.isEmpty)
|
|
#expect(try fixture.store.load().isEmpty)
|
|
#expect(!editor.isLogoutPending && !editor.isDirty)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func disablingAfterLogoutRemainsPendingUntilSave() async throws {
|
|
let fixture = try EditorFixture()
|
|
let saved = try await fixture.connect()
|
|
let editor = fixture.editor(saved)
|
|
editor.setEnabled(false)
|
|
#expect(editor.enabled)
|
|
editor.logout()
|
|
editor.setEnabled(false)
|
|
#expect(!editor.enabled)
|
|
#expect(fixture.model.runningGroups.contains(saved.id))
|
|
#expect(fixture.node.logouts == 0)
|
|
let updated = try await editor.save()
|
|
#expect(updated.tailscale == nil)
|
|
#expect(!fixture.model.runningGroups.contains(saved.id))
|
|
#expect(fixture.node.logouts == 1)
|
|
#expect(try fixture.store.load().isEmpty)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func cancellingTheWholeEditKeepsSavedSettingsRegardlessOfEnabled(enabled: Bool) async throws {
|
|
let fixture = try EditorFixture()
|
|
let saved = try await fixture.connect()
|
|
let profileID = try #require(fixture.store.load().first?.profileID)
|
|
let editor = fixture.editor(saved)
|
|
let hostname = editor.hostname
|
|
let controlURL = editor.controlURL
|
|
let exitNodeID = editor.exitNodeID
|
|
editor.logout()
|
|
editor.hostname = "Replacement device"
|
|
editor.controlURL = "https://another.example.com"
|
|
editor.exitNodeID = "other-exit"
|
|
editor.setEnabled(enabled)
|
|
#expect(editor.isLogoutPending)
|
|
editor.cancel()
|
|
let persistedGroup = try #require(fixture.library.group(saved.id))
|
|
let reopened = fixture.editor(persistedGroup)
|
|
#expect(reopened.enabled && reopened.isConnected && !reopened.isLogoutPending && !reopened.isDirty)
|
|
#expect(reopened.hostname == hostname && reopened.controlURL == controlURL && reopened.exitNodeID == exitNodeID)
|
|
#expect(fixture.model.runningGroups.contains(saved.id))
|
|
#expect(try fixture.store.load().first?.profileID == profileID)
|
|
#expect(fixture.node.logouts == 0 && !fixture.node.closed)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func failedLogoutSaveKeepsTheIntentAndTheConnectionForRetry(localFailure: Bool) async throws {
|
|
let fixture = try EditorFixture()
|
|
let saved = try await fixture.connect()
|
|
let editor = fixture.editor(saved)
|
|
editor.draft.name = "Saved edit"
|
|
editor.logout()
|
|
fixture.vault.failSave = !localFailure
|
|
fixture.store.failRemove = localFailure
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
#expect(editor.isLogoutPending && editor.canSave)
|
|
#expect(fixture.model.runningGroups.contains(saved.id))
|
|
#expect(fixture.node.logouts == 0 && !fixture.node.closed)
|
|
fixture.vault.failSave = false
|
|
fixture.store.failRemove = false
|
|
let updated = try await editor.save()
|
|
#expect(updated.name == "Saved edit")
|
|
#expect(!editor.isLogoutPending && !editor.isDirty)
|
|
#expect(fixture.model.error == nil)
|
|
#expect(fixture.node.logouts == 1)
|
|
#expect(try fixture.store.load().isEmpty)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func replacementLoginKeepsTheOriginalConnectionUntilSave(commit: Bool) async throws {
|
|
let fixture = try EditorFixture()
|
|
let saved = try await fixture.connect()
|
|
let originalProfile = try #require(fixture.store.load().first?.profileID)
|
|
let replacement = EditorNode(
|
|
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "other.ts.net"))
|
|
fixture.nodes.next = replacement
|
|
let editor = fixture.editor(saved)
|
|
editor.logout()
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
#expect(fixture.node.logouts == 0 && !fixture.node.closed)
|
|
#expect(try fixture.store.load().first?.profileID == originalProfile)
|
|
#expect(fixture.library.group(saved.id)?.tailscale?.network == fixture.node.network)
|
|
if commit {
|
|
let updated = try await editor.save()
|
|
#expect(updated.tailscale?.network == replacement.network)
|
|
try await wait { fixture.node.closed }
|
|
#expect(fixture.node.logouts == 1)
|
|
#expect(!replacement.closed && replacement.logouts == 0)
|
|
#expect(try fixture.store.load().first?.profileID != originalProfile)
|
|
#expect(!editor.isLogoutPending)
|
|
} else {
|
|
editor.cancel()
|
|
try await wait { replacement.closed }
|
|
#expect(fixture.library.group(saved.id)?.tailscale?.network == fixture.node.network)
|
|
#expect(fixture.node.logouts == 0 && !fixture.node.closed)
|
|
#expect(try fixture.store.load().first?.profileID == originalProfile)
|
|
}
|
|
#expect(fixture.model.runningGroups.contains(saved.id))
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func repeatedLogoutAndLoginThenDiscardRetainsTheOriginalAndRetiresEveryCandidate() async throws {
|
|
let fixture = try EditorFixture()
|
|
let saved = try await fixture.connect()
|
|
let originalState = try #require(fixture.store.load().first)
|
|
let originalHosts = fixture.library.hosts.map(\.id)
|
|
let editor = fixture.editor(saved)
|
|
let first = EditorNode(
|
|
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "first.ts.net"))
|
|
let second = EditorNode(
|
|
network: try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "second.ts.net"))
|
|
|
|
editor.logout()
|
|
fixture.nodes.next = first
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
first.holdLogout = true
|
|
editor.logout()
|
|
try await wait { first.logoutContinuation != nil }
|
|
#expect(editor.isLogoutPending && !editor.isConnected)
|
|
|
|
fixture.nodes.next = second
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
#expect(first.logouts == 1 && !first.closed)
|
|
#expect(second.logouts == 0 && !second.closed)
|
|
#expect(fixture.library.group(saved.id)?.tailscale == saved.tailscale)
|
|
#expect(try fixture.store.load().first?.profileID == originalState.profileID)
|
|
|
|
editor.cancel()
|
|
try await wait { second.closed }
|
|
first.releaseLogout()
|
|
try await wait { first.closed }
|
|
try await wait { (try? fixture.store.pendingCleanupProfiles().isEmpty) == true }
|
|
#expect(first.logouts == 1 && second.logouts == 1)
|
|
#expect(fixture.node.logouts == 0 && !fixture.node.closed)
|
|
#expect(fixture.model.pendingLogin == nil)
|
|
#expect(fixture.model.runningGroups.contains(saved.id))
|
|
#expect(try fixture.store.load().first?.profileID == originalState.profileID)
|
|
#expect(try fixture.store.pendingCleanupProfiles().isEmpty)
|
|
#expect(fixture.library.hosts.map(\.id) == originalHosts)
|
|
let persistedGroup = try #require(fixture.library.group(saved.id))
|
|
let reopened = fixture.editor(persistedGroup)
|
|
#expect(reopened.isConnected && !reopened.isDirty && !reopened.isLogoutPending)
|
|
#expect(reopened.network == fixture.node.network)
|
|
#expect(reopened.hostname == originalState.hostname)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func deferredLogoutCannotEndANewerEnrollmentFromAnotherEditor() async throws {
|
|
let fixture = try EditorFixture()
|
|
let saved = try await fixture.connect()
|
|
let older = fixture.editor(saved)
|
|
older.logout()
|
|
let newer = fixture.editor(saved)
|
|
newer.logout()
|
|
let replacement = EditorNode(network: fixture.node.network)
|
|
fixture.nodes.next = replacement
|
|
newer.login(authKey: nil)
|
|
try await fixture.ready(newer)
|
|
_ = try await newer.save()
|
|
let profile = try #require(fixture.store.load().first?.profileID)
|
|
await #expect(throws: TailscaleConfigurationError.self) { try await older.save() }
|
|
#expect(try fixture.store.load().first?.profileID == profile)
|
|
#expect(fixture.model.runningGroups.contains(saved.id))
|
|
#expect(replacement.logouts == 0 && !replacement.closed)
|
|
older.cancel()
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func cloudEditsDuringSaveDoNotGetReplacedByUnchangedFormValues() async throws {
|
|
let fixture = try EditorFixture(existing: true)
|
|
var group = try #require(fixture.library.groups.first)
|
|
let editor = fixture.editor(group)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
fixture.node.holdStatus = true
|
|
let saving = Task { try await editor.save() }
|
|
try await wait { fixture.node.statusContinuation != nil }
|
|
group.name = "Remote rename"
|
|
group.tailscale?.username = "remote-user"
|
|
group.tailscale?.exitNodeID = "remote-exit"
|
|
fixture.library.replacePersistedSnapshot(.init(groups: [group]))
|
|
fixture.node.releaseStatus()
|
|
let saved = try await saving.value
|
|
#expect(saved.name == "Remote rename")
|
|
#expect(saved.tailscale?.username == "remote-user")
|
|
#expect(saved.tailscale?.exitNodeID == "remote-exit")
|
|
#expect(fixture.node.exitNode == "remote-exit")
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func changedLoginFieldsCannotBeSilentlyDiscardedBySave() async throws {
|
|
let fixture = try EditorFixture(existing: true)
|
|
let group = try #require(fixture.library.groups.first)
|
|
let editor = fixture.editor(group)
|
|
#expect(editor.canSave)
|
|
editor.controlURL = "https://another.example.com"
|
|
#expect(!editor.canSave)
|
|
editor.controlURL = TailscaleNetwork.defaultControlURL
|
|
editor.hostname = "Another device"
|
|
#expect(!editor.canSave)
|
|
editor.setEnabled(false)
|
|
#expect(editor.canSave)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
@Test func disablingAfterPartialSaveCanRemoveThePublishedBinding() async throws {
|
|
let fixture = try EditorFixture()
|
|
let editor = fixture.editor()
|
|
editor.setEnabled(true)
|
|
editor.login(authKey: nil)
|
|
try await fixture.ready(editor)
|
|
fixture.store.failSave = true
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
editor.setEnabled(false)
|
|
let saved = try await editor.save()
|
|
#expect(saved.tailscale == nil)
|
|
#expect(fixture.library.group(saved.id)?.tailscale == nil)
|
|
await fixture.model.shutdown()
|
|
}
|
|
|
|
private func wait(_ condition: () -> Bool) async throws {
|
|
for _ in 0..<200 {
|
|
if condition() { return }
|
|
try await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
try #require(condition())
|
|
}
|
|
}
|
|
|
|
@MainActor private final class EditorFixture {
|
|
let library: HostLibraryModel
|
|
let vault: EditorVaultStore
|
|
let store = EditorLocalStore()
|
|
let node: EditorNode
|
|
let nodes: EditorNodes
|
|
let model: TailscaleModel
|
|
|
|
init(existing: Bool = false, otherNetwork: Bool = false) throws {
|
|
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "test.ts.net")
|
|
node = EditorNode(network: network)
|
|
var group = HostGroup(name: "Original")
|
|
group.tailscale = TailscaleGroupConfiguration(
|
|
network: otherNetwork
|
|
? try TailscaleNetwork(controlURL: "https://old.example.com", dnsSuffix: "old.ts.net") : network)
|
|
group.tailscale?.username = "operator"
|
|
group.tailscale?.exitNodeID = "exit"
|
|
group.tailscale?.hostPreferences[UUID()] = TailscaleHostPreferences()
|
|
vault = EditorVaultStore(.init(groups: existing ? [group] : []))
|
|
library = HostLibraryModel(persistence: vault)
|
|
nodes = EditorNodes(next: node)
|
|
let nodes = nodes
|
|
model = TailscaleModel(
|
|
library: library, store: store, factory: { _, _, _, _ in await nodes.next },
|
|
profileStorage: .init(profiles: { [] }, erase: { _ in }), hostname: { "default-device" },
|
|
hasAccess: { true })
|
|
}
|
|
|
|
func connect() async throws -> HostGroup {
|
|
let editor = editor()
|
|
editor.setEnabled(true)
|
|
editor.login(authKey: nil)
|
|
try await ready(editor)
|
|
return try await editor.save()
|
|
}
|
|
|
|
func editor(_ group: HostGroup? = nil) -> TailscaleGroupEditor { TailscaleGroupEditor(model: model, group: group) }
|
|
func ready(_ editor: TailscaleGroupEditor) async throws {
|
|
for _ in 0..<200 {
|
|
if editor.pending != nil {
|
|
editor.receiveLogin()
|
|
return
|
|
}
|
|
try await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
try #require(editor.pending != nil)
|
|
}
|
|
}
|
|
|
|
@MainActor private final class EditorNodes {
|
|
var next: EditorNode
|
|
init(next: EditorNode) { self.next = next }
|
|
}
|
|
|
|
@MainActor private final class EditorNode: TailscaleNodeSession {
|
|
let network: TailscaleNetwork
|
|
var networkName = "Test Tailnet"
|
|
var hostname = "default-device"
|
|
var exitNode: String?
|
|
var closed = false
|
|
var logouts = 0
|
|
var holdLogout = false
|
|
var logoutContinuation: CheckedContinuation<Void, Never>?
|
|
var holdStatus = false
|
|
var statusContinuation: CheckedContinuation<Void, Never>?
|
|
var peers: [TailscalePeer] {
|
|
[
|
|
TailscalePeer(
|
|
stableID: "exit", name: "Exit", dnsName: "exit.\(network.dnsSuffix)", online: true,
|
|
sshHostKeys: ["fixture"], exitNodeOption: true)
|
|
]
|
|
}
|
|
init(network: TailscaleNetwork) { self.network = network }
|
|
@MainActor func status() async throws -> TailscaleNodeStatus {
|
|
if holdStatus { await withCheckedContinuation { statusContinuation = $0 } }
|
|
return .init(
|
|
running: true, network: network, authURL: nil, peers: peers,
|
|
device: .init(
|
|
networkName: networkName, dnsName: "device.\(network.dnsSuffix)", hostname: hostname,
|
|
addresses: ["100.64.0.1"], operatingSystem: "macOS", keyExpiry: nil))
|
|
}
|
|
@MainActor func releaseStatus() {
|
|
holdStatus = false
|
|
statusContinuation?.resume()
|
|
statusContinuation = nil
|
|
}
|
|
@MainActor func login() async throws {}
|
|
@MainActor func logout() async throws {
|
|
logouts += 1
|
|
if holdLogout { await withCheckedContinuation { logoutContinuation = $0 } }
|
|
}
|
|
@MainActor func releaseLogout() {
|
|
holdLogout = false
|
|
logoutContinuation?.resume()
|
|
logoutContinuation = nil
|
|
}
|
|
@MainActor func close() async { closed = true }
|
|
@MainActor func setHostname(_ value: String) async throws { hostname = value }
|
|
@MainActor func setExitNode(_ value: String?) async throws { exitNode = value }
|
|
@MainActor func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { 42 }
|
|
@MainActor func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> { [] }
|
|
}
|
|
|
|
@MainActor private final class EditorLocalStore: TailscaleLocalStoring {
|
|
let base = InMemoryTailscaleLocalStore()
|
|
var failSave = false
|
|
var failRemove = false
|
|
func load() throws -> [TailscaleLocalState] { try base.load() }
|
|
func save(_ state: TailscaleLocalState) throws {
|
|
if failSave { throw CocoaError(.fileWriteUnknown) }
|
|
try base.save(state)
|
|
}
|
|
func remove(groupID: UUID) throws {
|
|
if failRemove { throw CocoaError(.fileWriteUnknown) }
|
|
try base.remove(groupID: groupID)
|
|
}
|
|
func scheduleCleanup(profileID: UUID) throws { try base.scheduleCleanup(profileID: profileID) }
|
|
func pendingCleanupProfiles() throws -> Set<UUID> { try base.pendingCleanupProfiles() }
|
|
func completeCleanup(profileID: UUID) throws { try base.completeCleanup(profileID: profileID) }
|
|
}
|
|
|
|
@MainActor private final class EditorVaultStore: VaultPersisting {
|
|
var snapshot: VaultSnapshot
|
|
var failSave = false
|
|
init(_ snapshot: VaultSnapshot) { self.snapshot = snapshot }
|
|
func load() throws -> VaultSnapshot { snapshot }
|
|
func save(_ snapshot: VaultSnapshot, touching keys: Set<VaultRecordKey>) throws -> VaultMutationSet {
|
|
if failSave { throw CocoaError(.fileWriteUnknown) }
|
|
self.snapshot = snapshot
|
|
return .empty
|
|
}
|
|
func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult {
|
|
.init(snapshot: snapshot, acceptedKeys: [], needsUpload: [])
|
|
}
|
|
}
|