Files
Termind/FrameworkTests/TailscaleSyncSafetyTests.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

89 lines
4.6 KiB
Swift

import CloudKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@testable import TermindInfrastructure
@Suite @MainActor
struct TailscaleSyncSafetyTests {
@Test func v3ReplayAcceptsIndependentConnectionsToTheSameTailnet() async throws {
let fixture = try CloudSyncSafetyFixture(category: .vault, seed: false)
defer { fixture.stop() }
try await fixture.start()
let persistence = HostPersistence(context: fixture.container.mainContext)
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var first = HostGroup(name: "First")
first.tailscale = TailscaleGroupConfiguration(network: network)
_ = try persistence.save(.init(groups: [first]))
var second = HostGroup(name: "Second")
second.tailscale = TailscaleGroupConfiguration(network: network)
second.tailscale?.username = "other-account"
let record = try groupRecord(second, zone: fixture.lifecycle.zoneID)
let safety = try fixture.safety()
let scope = try #require(fixture.lifecycle.scope)
#expect(try !fixture.lifecycle.store.safety.prepare(scope))
try safety.quarantine(
recordName: record.recordID.recordName,
payloadData: CloudSyncRecordCoding.payloadData(
from: record, invalidEnvelope: CocoaError(.coderReadCorrupt)),
reason: "Old client", reasonCode: .unsupportedFormat)
#expect(fixture.batch([.saveRecord(record.recordID), .deleteRecord(record.recordID)]) == nil)
var oldFormats = SyncRecordFormats.vault.formats
oldFormats["hostGroup"] = .init(readingVersions: [1, 2], writingVersion: 2)
try safety.capability().capabilityID = SyncRecordFormats(oldFormats).capabilityID
try safety.recovery().requiresRefetch = false
try fixture.container.mainContext.save()
fixture.stop()
fixture.network.nextEngine = LifecycleEngine()
fixture.network.nextEngine.fetchBody = { try fixture.receive([record], []) }
try await fixture.start()
#expect(fixture.network.serializations.last! == nil)
let groups = try persistence.load().groups
#expect(Set(groups.map(\.id)) == [first.id, second.id])
#expect(groups.first { $0.id == second.id }?.tailscale?.username == "other-account")
#expect(try !safety.isUploadBlocked(recordName: record.recordID.recordName))
#expect(try safety.capability().capabilityID == SyncRecordFormats.vault.capabilityID)
}
@Test func metricsUpgradeRefetchesAndUnblocksPreviouslyUnsupportedSelections() async throws {
let fixture = try CloudSyncSafetyFixture(category: .metrics)
defer { fixture.stop() }
try await fixture.start()
let safety = try fixture.safety()
let record = try fixture.newerRecord(fixture.recordIDs[0])
let scope = try #require(fixture.lifecycle.scope)
#expect(try !fixture.lifecycle.store.safety.prepare(scope))
try safety.quarantine(
recordName: record.recordID.recordName,
payloadData: CloudSyncRecordCoding.payloadData(
from: record, invalidEnvelope: CocoaError(.coderReadCorrupt)),
reason: "Old client", reasonCode: .unsupportedFormat)
try safety.capability().capabilityID = "selection:1"
try safety.recovery().requiresRefetch = false
#expect(try fixture.lifecycle.store.safety.prepare(scope))
try fixture.container.mainContext.save()
fixture.stop()
fixture.network.nextEngine = LifecycleEngine()
fixture.network.nextEngine.fetchBody = { try fixture.receive([record], []) }
try await fixture.start()
#expect(fixture.network.serializations.last! == nil)
#expect(try safety.capability().capabilityID == MetricsSyncCodec.capabilityID)
#expect(try !safety.isUploadBlocked(recordName: record.recordID.recordName))
let journal = MetricsSyncJournal(context: fixture.container.mainContext)
#expect(
try journal.envelope(for: MetricsRecordKey(recordName: record.recordID.recordName)!)
== MetricsSyncCoordinator.envelope(from: record))
}
private func groupRecord(_ group: HostGroup, zone: CKRecordZone.ID) throws -> CKRecord {
let payload = VaultRecordPayload.hostGroup(group)
let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
return try VaultSyncCoordinator.makeRecord(
.init(envelope: envelope), recordID: .init(recordName: payload.key.recordName, zoneID: zone))
}
}