Files
Termind/FrameworkTests/TailscaleMultiConnectionTests.swift
T

129 lines
7.2 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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> { [] }
}