Bind independent Tailnet connections to group IDs while preserving legacy host identities through the V3 sync format. Persist profile cleanup retries, isolate test storage, and reject stale dialers after re-enrollment. Recover quarantined sync records safely, refresh metrics compatibility, fix multiline shell history, and remove obsolete mobile history gates. Pin libtailscale-spm to 1.0.1 alongside its submodule revision. Validation: default macOS suites (2679 tests), targeted iOS suites (26 tests), strict Swift formatting, shared AppStrings checks, and Chinese localization checks passed. Both subagent reviews approved.
302 lines
15 KiB
Swift
302 lines
15 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPersistence
|
|
import TermindPresentation
|
|
import Testing
|
|
|
|
@testable import TermindRemoteInfrastructure
|
|
|
|
@Suite @MainActor
|
|
struct SessionHistoryTests {
|
|
@Test func snapshotDoesNotReplaceLiveCommandsAndLoadsOnlyOnce() async throws {
|
|
let gate = HistoryReadGate()
|
|
let model = CommandHistoryModel()
|
|
model.load(using: gate, source: nil)
|
|
await gate.waitForRead()
|
|
let live = try #require(model.record("pwd", at: Date(timeIntervalSince1970: 12.5)))
|
|
model.recordCompletion(for: live.id, exitStatus: 0, duration: 0.25)
|
|
await gate.finish([
|
|
.init(command: "pwd", executedAt: Date(timeIntervalSince1970: 10)),
|
|
.init(command: "old without time"),
|
|
.init(command: "pwd", executedAt: Date(timeIntervalSince1970: 12)),
|
|
])
|
|
#expect(await settles { model.hasLoaded })
|
|
let entries = model.entries()
|
|
#expect(entries.map(\.command) == ["pwd", "old without time", "pwd"])
|
|
#expect(entries.first?.id == live.id)
|
|
#expect(entries.first?.exitStatus == 0)
|
|
#expect(entries.first?.duration == 0.25)
|
|
model.cancelLoading()
|
|
model.load(using: gate, source: nil)
|
|
#expect(await gate.readCount == 1)
|
|
}
|
|
|
|
@Test func cancelledOrClosedReadsCannotPublishAndReconnectCanRetry() async {
|
|
let stale = HistoryReadGate()
|
|
let current = HistoryReadGate()
|
|
let model = CommandHistoryModel()
|
|
model.record("live")
|
|
model.load(using: stale, source: nil)
|
|
await stale.waitForRead()
|
|
model.cancelLoading()
|
|
model.load(using: current, source: nil)
|
|
await current.waitForRead()
|
|
await stale.finish([.init(command: "stale")])
|
|
await current.finish([.init(command: "current")])
|
|
#expect(await settles { model.hasLoaded })
|
|
#expect(model.entries().map(\.command) == ["live", "current"])
|
|
|
|
let closing = HistoryReadGate()
|
|
let closed = CommandHistoryModel()
|
|
closed.load(using: closing, source: nil)
|
|
await closing.waitForRead()
|
|
closed.close()
|
|
await closing.finish([.init(command: "too late")])
|
|
for _ in 0..<20 { await Task.yield() }
|
|
#expect(closed.entries().isEmpty)
|
|
#expect(closed.record("callback") == nil)
|
|
#expect(!closed.hasLoaded)
|
|
}
|
|
|
|
@Test func failureKeepsLiveRecordsAndAnEmptySuccessfulReadStopsRetries() async {
|
|
let model = CommandHistoryModel()
|
|
model.record("retained")
|
|
model.load(using: FailedHistoryRead(), source: nil)
|
|
#expect(await settles { model.loadError != nil })
|
|
#expect(model.entries().map(\.command) == ["retained"])
|
|
let empty = HistoryReadGate()
|
|
model.load(using: empty, source: nil)
|
|
await empty.waitForRead()
|
|
await empty.finish([])
|
|
#expect(await settles { model.hasLoaded })
|
|
#expect(model.loadError == nil)
|
|
}
|
|
|
|
@Test func unknownTimeHasNoSubtitleButKnownDurationStillDisplays() {
|
|
var entry = CommandHistoryEntry(sessionID: UUID(), command: "pwd")
|
|
#expect(CommandHistoryPresentation.subtitle(for: entry, locale: Locale(identifier: "en")).isEmpty)
|
|
entry.duration = 2
|
|
#expect(!CommandHistoryPresentation.subtitle(for: entry, locale: Locale(identifier: "en")).isEmpty)
|
|
}
|
|
|
|
private func settles(_ predicate: () -> Bool) async -> Bool {
|
|
for _ in 0..<1000 {
|
|
if predicate() { return true }
|
|
await Task.yield()
|
|
}
|
|
return predicate()
|
|
}
|
|
}
|
|
|
|
private actor HistoryReadGate: ShellHistoryLoading {
|
|
private var continuation: CheckedContinuation<[ShellHistoryRecord], Never>?
|
|
private(set) var readCount = 0
|
|
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] {
|
|
readCount += 1
|
|
return await withCheckedContinuation { continuation = $0 }
|
|
}
|
|
func waitForRead() async {
|
|
while continuation == nil { await Task.yield() }
|
|
}
|
|
func finish(_ records: [ShellHistoryRecord]) {
|
|
continuation?.resume(returning: records)
|
|
continuation = nil
|
|
}
|
|
}
|
|
|
|
private struct FailedHistoryRead: ShellHistoryLoading {
|
|
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] {
|
|
throw CocoaError(.fileReadNoPermission)
|
|
}
|
|
}
|
|
|
|
@Suite
|
|
struct ShellHistoryParsingTests {
|
|
@Test func bashPreservesRepeatedCommandsAndTimestampedMultilineRecords() {
|
|
let records = ShellHistoryParser.parse("#100\nprintf 'one\ntwo'\n#101\npwd\n#102\npwd\n", shell: .bash)
|
|
#expect(records.map(\.command) == ["printf 'one\ntwo'", "pwd", "pwd"])
|
|
#expect(records.map { $0.executedAt?.timeIntervalSince1970 } == [100, 101, 102])
|
|
let plain = ShellHistoryParser.parse("pwd\npwd\npartial", shell: .bash)
|
|
#expect(plain.map(\.command) == ["pwd", "pwd"])
|
|
#expect(plain.allSatisfy { $0.executedAt == nil })
|
|
}
|
|
|
|
@Test func zshAndFishDecodeTheirOwnMultilineFormats() {
|
|
let zsh = ShellHistoryParser.parse(": 100:2;printf 'one\\\ntwo'\n: 103:0;pwd\n", shell: .zsh)
|
|
#expect(zsh.map(\.command) == ["printf 'one\ntwo'", "pwd"])
|
|
#expect(zsh.first?.duration == 2)
|
|
let fish = ShellHistoryParser.parse(
|
|
"- cmd: printf one\\ntwo\\\\three\n when: 100\n paths:\n - /tmp\n- cmd: pwd\n", shell: .fish)
|
|
#expect(fish.map(\.command) == ["printf one\ntwo\\three", "pwd"])
|
|
#expect(fish.first?.executedAt?.timeIntervalSince1970 == 100)
|
|
#expect(fish.last?.executedAt == nil)
|
|
}
|
|
|
|
@Test func truncatedRecordsDoNotTurnIntoCommands() {
|
|
#expect(
|
|
ShellHistoryParser.parse("broken\nremaining\n#123\npwd\n", shell: .bash, truncatedStart: true)
|
|
.map(\.command) == ["pwd"])
|
|
#expect(
|
|
ShellHistoryParser.parse("broken\nremaining\n: 123:0;pwd\n", shell: .zsh, truncatedStart: true)
|
|
.map(\.command) == ["pwd"])
|
|
#expect(
|
|
ShellHistoryParser.parse("broken\n when: 123\n- cmd: pwd\n", shell: .fish, truncatedStart: true)
|
|
.map(\.command) == ["pwd"])
|
|
}
|
|
|
|
@Test(arguments: ["bash", "zsh", "fish"])
|
|
func realInjectedScriptsAreFilteredWithoutHidingUserText(shell: String) throws {
|
|
var bootstrap = RemoteShellIntegrationBootstrap(token: "OLD123")
|
|
#expect(ShellHistoryFilter.isIntegrationCommand(String(decoding: bootstrap.startCommand, as: UTF8.self)))
|
|
let update = bootstrap.receive(Data("__TRMD_OLD123_SHELL__/bin/\(shell)__\n".utf8))
|
|
let install = String(decoding: try #require(update.command), as: UTF8.self)
|
|
#expect(ShellHistoryFilter.isIntegrationCommand(install))
|
|
#expect(ShellHistoryFilter.isIntegrationCommand("__termind_report_prompt"))
|
|
#expect(!ShellHistoryFilter.isIntegrationCommand("echo __termind_report_prompt"))
|
|
#expect(!ShellHistoryFilter.isIntegrationCommand("grep '__TRMD_OLD123_PROBE__' log"))
|
|
#expect(!ShellHistoryFilter.isIntegrationCommand("npm run integration"))
|
|
}
|
|
|
|
@Test func bootstrapCarriesExplicitCustomOrDisabledHistoryPaths() throws {
|
|
for path in ["%2Fhome%2Fuser%2Fcustom%20history", ""] {
|
|
var bootstrap = RemoteShellIntegrationBootstrap(token: "SOURCE")
|
|
_ = bootstrap.receive(Data("__TRMD_SOURCE_SHELL__/bin/bash__\n".utf8))
|
|
let result = bootstrap.receive(Data("__TRMD_SOURCE_HISTORY__\(path)\n__TRMD_SOURCE_DONE__\n".utf8))
|
|
#expect(result.historySource?.shell == .bash)
|
|
#expect(result.historySource?.path == (path.isEmpty ? "" : "/home/user/custom history"))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suite @MainActor
|
|
struct RetiredSessionHistoryMigrationTests {
|
|
@Test func version48RetainsCloudHistoryJournalsAndDeviceStateAfterMigrationAndReopening() 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("CloudHistory.store")
|
|
let device = DeviceRecord(id: UUID(), displayName: "Original device")
|
|
let event = ConnectionHistoryEntry(
|
|
hostID: UUID(), kind: .sftp, connectedAt: Date(timeIntervalSince1970: 100),
|
|
sourceDeviceID: device.id, endedAt: Date(timeIntervalSince1970: 200))
|
|
let deletedEvent = ConnectionHistoryEntry(hostID: event.hostID, kind: .ssh)
|
|
let version = VaultVersion(modifiedAt: Date(timeIntervalSince1970: 300), counter: 4, writerID: device.id)
|
|
let eventPayload = HistoryRecordPayload.connectionHistory(event)
|
|
let devicePayload = HistoryRecordPayload.device(device)
|
|
let envelopes = [
|
|
HistoryEnvelope(key: eventPayload.key, version: version, payload: eventPayload),
|
|
HistoryEnvelope(key: devicePayload.key, version: version, payload: devicePayload),
|
|
HistoryEnvelope(
|
|
key: .init(kind: .connectionHistory, id: deletedEvent.id), version: version, isDeleted: true),
|
|
HistoryEnvelope(key: .init(kind: .device, id: UUID()), version: version, isDeleted: true),
|
|
]
|
|
try writeCloudHistoryStore(url, event: event, device: device, envelopes: envelopes)
|
|
for _ in 0..<2 {
|
|
let container = try TermindPersistence.makeContainer(url: url)
|
|
let context = container.mainContext
|
|
let persistence = HistorySyncPersistence(context: context)
|
|
try persistence.prepare()
|
|
#expect(try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()).map(\.value) == [event])
|
|
let devices = try context.fetch(FetchDescriptor<PersistentDeviceRecord>())
|
|
#expect(
|
|
devices.count == 1 && devices.first?.id == device.id && devices.first?.displayName == device.displayName
|
|
)
|
|
let rows = try context.fetch(FetchDescriptor<PersistentHistorySyncEntry>())
|
|
#expect(Set(rows.compactMap(\.envelope)) == Set(envelopes))
|
|
for (index, envelope) in envelopes.enumerated() {
|
|
let row = try #require(rows.first { $0.recordName == envelope.key.recordName })
|
|
#expect(row.systemFieldsData == Data([UInt8(index), 42]))
|
|
#expect(row.needsUpload == (index != 1))
|
|
}
|
|
for kind in HistoryRecordKind.allCases {
|
|
let state = try HistorySyncJournal(context: context, kind: kind).state()
|
|
#expect(state.bootstrapCompleted)
|
|
#expect(state.installationID == device.id)
|
|
#expect(state.boundUserRecordName == "account-\(kind.rawValue)")
|
|
#expect(state.pendingUserRecordName == "pending-\(kind.rawValue)")
|
|
#expect(state.engineStateData == Data(kind.rawValue.utf8))
|
|
#expect(state.lastSuccessfulSync == version.modifiedAt)
|
|
}
|
|
let late = HistoryRecordPayload.connectionHistory(deletedEvent)
|
|
_ = try persistence.applyRemote([
|
|
HistoryEnvelope(
|
|
key: late.key, version: .next(after: version, writerID: UUID()), payload: late)
|
|
])
|
|
#expect(try persistence.journal.envelope(for: late.key)?.isDeleted == true)
|
|
#expect(try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()).map(\.value) == [event])
|
|
}
|
|
}
|
|
|
|
private func writeCloudHistoryStore(
|
|
_ url: URL, event: ConnectionHistoryEntry, device: DeviceRecord, envelopes: [HistoryEnvelope]
|
|
) throws {
|
|
let schema = Schema(versionedSchema: TermindSchemaV48.self)
|
|
let container = try ModelContainer(
|
|
for: schema,
|
|
configurations: [ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)])
|
|
let context = container.mainContext
|
|
context.insert(PersistentConnectionHistoryEntry(event))
|
|
context.insert(PersistentDeviceRecord(device))
|
|
context.insert(PersistentCommandHistoryEntry(hostID: event.hostID, command: "retired", executedAt: .now))
|
|
context.insert(
|
|
PersistentSFTPHistoryEntry(hostID: event.hostID, kind: "file", path: "/retired", visitedAt: .now))
|
|
for (index, envelope) in envelopes.enumerated() {
|
|
let row = try PersistentHistorySyncEntry(envelope: envelope, needsUpload: index != 1)
|
|
row.systemFieldsData = Data([UInt8(index), 42])
|
|
context.insert(row)
|
|
}
|
|
for kind in HistoryRecordKind.allCases {
|
|
let state = try HistorySyncJournal(context: context, kind: kind).state()
|
|
state.bootstrapCompleted = true
|
|
state.installationID = device.id
|
|
state.boundUserRecordName = "account-\(kind.rawValue)"
|
|
state.pendingUserRecordName = "pending-\(kind.rawValue)"
|
|
state.engineStateData = Data(kind.rawValue.utf8)
|
|
state.lastSuccessfulSync = envelopes[0].version.modifiedAt
|
|
}
|
|
try context.save()
|
|
}
|
|
|
|
@Test(arguments: [9, 48])
|
|
func oldStoresDropSessionHistoryAndRetainOtherDataAfterReopening(version: Int) 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("History.store")
|
|
let group = HostGroup(name: "Keep this group")
|
|
try writeLegacyStore(version: version, url: url, group: group)
|
|
for _ in 0..<2 {
|
|
let container = try TermindPersistence.makeContainer(url: url)
|
|
let names = container.schema.entities.map(\.name)
|
|
#expect(!names.contains("PersistentCommandHistoryEntry"))
|
|
#expect(!names.contains("PersistentSFTPHistoryEntry"))
|
|
#expect(try container.mainContext.fetch(FetchDescriptor<PersistentHostGroup>()).map(\.value) == [group])
|
|
}
|
|
}
|
|
|
|
private func writeLegacyStore(version: Int, url: URL, group: HostGroup) throws {
|
|
let schema =
|
|
version == 9
|
|
? Schema(versionedSchema: TermindSchemaV9.self) : Schema(versionedSchema: TermindSchemaV48.self)
|
|
let container = try ModelContainer(
|
|
for: schema,
|
|
configurations: [
|
|
ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)
|
|
])
|
|
container.mainContext.insert(PersistentHostGroup(group))
|
|
if version == 9 {
|
|
container.mainContext.insert(
|
|
TermindSchemaV9Models.PersistentCommandHistoryEntry(
|
|
hostID: UUID(), command: "old", executedAt: Date()))
|
|
} else {
|
|
container.mainContext.insert(
|
|
PersistentCommandHistoryEntry(hostID: UUID(), command: "old", executedAt: Date()))
|
|
}
|
|
container.mainContext.insert(
|
|
PersistentSFTPHistoryEntry(hostID: UUID(), kind: "directory", path: "/old", visitedAt: Date()))
|
|
try container.mainContext.save()
|
|
}
|
|
}
|