726 lines
34 KiB
Swift
726 lines
34 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPersistence
|
|
import TermindRemoteInfrastructure
|
|
import Testing
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite @MainActor
|
|
struct MobileVaultFeatureTests {
|
|
@Test func appDefaultsNewCredentialsToThisDeviceWhenVaultSyncIsOff() throws {
|
|
let domain = "MobileVaultCredentialDefaults.\(UUID())"
|
|
let defaults = try #require(UserDefaults(suiteName: domain))
|
|
defer { defaults.removePersistentDomain(forName: domain) }
|
|
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
|
|
let app = try MobileAppModel(inMemory: true, defaults: defaults)
|
|
defer { app.outbox.stop() }
|
|
|
|
#expect(app.newVaultKeyStorage == .thisDevice)
|
|
#expect(app.newVaultPasswordStorage == .thisDevice)
|
|
let generator = MobileKeyEditorModel(
|
|
mode: .generate,
|
|
credentials: app.credentials,
|
|
defaultStorage: app.newVaultKeyStorage)
|
|
#expect(generator.storage == .thisDevice)
|
|
|
|
generator.storage = .iCloud
|
|
#expect(generator.storage == .iCloud)
|
|
}
|
|
|
|
@Test func hostMenuSeparatesConnectionEditingAndDeletionActions() {
|
|
#expect(
|
|
MobileHostMenuPolicy.sections == [
|
|
[.ssh, .telnet, .sftp],
|
|
[.edit, .duplicate],
|
|
[.delete],
|
|
])
|
|
|
|
var host = TermindDomain.Host(name: "Remote", host: "remote.example.invalid")
|
|
#expect(
|
|
MobileHostMenuPolicy.sections(for: host) == [
|
|
[.ssh, .sftp],
|
|
[.edit, .duplicate],
|
|
[.delete],
|
|
])
|
|
|
|
host.sshEnabled = false
|
|
host.telnetEnabled = true
|
|
#expect(
|
|
MobileHostMenuPolicy.sections(for: host) == [
|
|
[.telnet],
|
|
[.edit, .duplicate],
|
|
[.delete],
|
|
])
|
|
|
|
host.sshEnabled = true
|
|
#expect(MobileHostMenuPolicy.sections(for: host) == MobileHostMenuPolicy.sections)
|
|
|
|
host.sshEnabled = false
|
|
host.telnetEnabled = false
|
|
#expect(
|
|
MobileHostMenuPolicy.sections(for: host) == [
|
|
[.edit, .duplicate],
|
|
[.delete],
|
|
])
|
|
}
|
|
|
|
@Test func duplicatingAHostPersistsAnIndependentCopyWithTheSameConfiguration() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let group = HostGroup(name: "Production")
|
|
let jump = TermindDomain.Host(name: "Jump", host: "jump.example.invalid")
|
|
app.library.upsert(group)
|
|
app.library.upsert(jump)
|
|
let host = TermindDomain.Host(
|
|
name: "Remote", host: "remote.example.invalid", port: 2222, identityID: UUID(),
|
|
jumpHostID: jump.id,
|
|
proxy: HostProxy(kind: .socks5, hostname: "proxy.example.invalid", port: 1080),
|
|
sftpChannelInitialPath: "/srv/app", groupID: group.id, tags: ["production", "api"],
|
|
colorTag: .teal, detectedDistribution: DetectedDistribution(id: "debian"),
|
|
shellIntegrationMode: .enabled, portForwardingMaxConnections: 12)
|
|
app.library.upsert(host)
|
|
let original = try #require(app.library.host(host.id))
|
|
|
|
app.duplicateHost(original)
|
|
|
|
#expect(app.library.hosts.count == 3)
|
|
#expect(app.library.host(original.id) == original)
|
|
let copy = try #require(app.library.hosts.first { $0.id != jump.id && $0.id != original.id })
|
|
#expect(copy.id != original.id)
|
|
#expect(copy.name == "Remote copy")
|
|
var expected = original
|
|
expected.id = copy.id
|
|
expected.name = copy.name
|
|
expected.sortIndex = copy.sortIndex
|
|
#expect(copy == expected)
|
|
app.outbox.stop()
|
|
}
|
|
|
|
@Test func deletingAGroupTreeCanKeepOrRemoveItsHosts() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
|
|
let keptRoot = HostGroup(name: "Keep")
|
|
let keptChild = HostGroup(name: "Child", parentID: keptRoot.id)
|
|
let keptHost = TermindDomain.Host(
|
|
name: "Kept", host: "kept.example.invalid", groupID: keptChild.id)
|
|
app.library.upsert(keptRoot)
|
|
app.library.upsert(keptChild)
|
|
app.library.upsert(keptHost)
|
|
|
|
app.deleteHostGroup(keptRoot.id, keepHosts: true)
|
|
|
|
#expect(app.library.groups.isEmpty)
|
|
#expect(app.library.host(keptHost.id)?.groupID == nil)
|
|
|
|
let removedRoot = HostGroup(name: "Remove")
|
|
let removedChild = HostGroup(name: "Child", parentID: removedRoot.id)
|
|
let removedHost = TermindDomain.Host(
|
|
name: "Removed", host: "removed.example.invalid", groupID: removedChild.id)
|
|
app.library.upsert(removedRoot)
|
|
app.library.upsert(removedChild)
|
|
app.library.upsert(removedHost)
|
|
|
|
app.deleteHostGroup(removedRoot.id, keepHosts: false)
|
|
|
|
#expect(app.library.groups.isEmpty)
|
|
#expect(app.library.host(removedHost.id) == nil)
|
|
let stored = try HostPersistence(context: ModelContext(app.container)).load()
|
|
#expect(stored.groups.isEmpty)
|
|
#expect(stored.hosts.map(\.id) == [keptHost.id])
|
|
#expect(stored.hosts.first?.groupID == nil)
|
|
}
|
|
|
|
@Test func editingAHostPreservesTheMacForwardingPoolLimit() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
var host = TermindDomain.Host(
|
|
name: "Remote", host: "example.invalid", portForwardingMaxConnections: 12)
|
|
app.library.upsert(host)
|
|
host.name = "Renamed"
|
|
app.library.upsert(host)
|
|
#expect(app.library.host(host.id)?.portForwardingMaxConnections == 12)
|
|
app.outbox.stop()
|
|
}
|
|
|
|
@Test func blankKeyNamesUseTheCurrentAlgorithmForGenerationImportAndReplacement() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
|
generator.algorithm = .ecdsa
|
|
generator.label = " \n"
|
|
#expect(generator.defaultName == "ECDSA")
|
|
#expect(generator.canSave)
|
|
try await generator.save()
|
|
let generated = try #require(app.credentials.keys.first)
|
|
#expect(generated.label == "ECDSA")
|
|
|
|
let replacement = try SSHKeyMaterial.generate(.ed25519, comment: "fixture", passphrase: nil)
|
|
let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
|
#expect(!importer.canSave)
|
|
#expect(importer.defaultName == "")
|
|
importer.privateKey = replacement.privateKey
|
|
await importer.derivePublicMaterial()
|
|
#expect(importer.defaultName == "Ed25519")
|
|
#expect(importer.canSave)
|
|
try await importer.save()
|
|
let imported = try #require(app.credentials.keys.first { $0.id != generated.id })
|
|
#expect(imported.label == "Ed25519")
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: generated)
|
|
await editor.loadPrivateKey()
|
|
editor.label = ""
|
|
editor.privateKey = replacement.privateKey
|
|
#expect(editor.defaultName == "")
|
|
await editor.derivePublicMaterial()
|
|
#expect(editor.defaultName == "Ed25519")
|
|
#expect(editor.canSave)
|
|
try await editor.save()
|
|
let edited = try #require(app.credentials.key(generated.id))
|
|
#expect(edited.label == "Ed25519")
|
|
#expect(edited.algorithm == .ed25519)
|
|
let reopened = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: edited)
|
|
#expect(reopened.label == "Ed25519")
|
|
await reopened.loadPrivateKey()
|
|
reopened.privateKey = "invalid replacement"
|
|
#expect(reopened.defaultName == "")
|
|
await reopened.derivePublicMaterial()
|
|
#expect(reopened.defaultName == "")
|
|
reopened.privateKey = ""
|
|
#expect(reopened.defaultName == "")
|
|
#expect(!reopened.canSave)
|
|
}
|
|
|
|
@Test func enclaveMetadataCanSaveABlankNameWithoutGeneratingAKey() async throws {
|
|
let original = SSHKeyRef(label: "Custom", algorithm: .ecdsa256, storage: .secureEnclave)
|
|
let library = HostLibraryModel(keys: [original])
|
|
let credentials = CredentialLibraryModel(library: library)
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
|
|
editor.label = " \t"
|
|
#expect(editor.defaultName == "Secure Enclave")
|
|
#expect(editor.canSave)
|
|
try await editor.save()
|
|
#expect(credentials.key(original.id)?.label == "Secure Enclave")
|
|
#expect(credentials.key(original.id)?.publicKey == original.publicKey)
|
|
}
|
|
|
|
@Test func hostProxyEditorAppliesDefaultsPreservesCustomPortsAndValidatesAuthentication() {
|
|
var host = TermindDomain.Host(name: "Remote", host: "example.invalid")
|
|
let library = HostLibraryModel(hosts: [host])
|
|
var identityDraft = HostCustomIdentityDraft(host: host, library: library)
|
|
#expect(identityDraft.isProxyValid(for: host))
|
|
|
|
host.proxy = MobileHostEditorPolicy.selectProxy(.http, current: nil)
|
|
#expect(host.proxy?.port == 8080)
|
|
#expect(!identityDraft.isProxyValid(for: host))
|
|
|
|
host.proxy?.hostname = "proxy.example"
|
|
#expect(identityDraft.isProxyValid(for: host))
|
|
host.proxy?.port = 0
|
|
#expect(!identityDraft.isProxyValid(for: host))
|
|
host.proxy?.port = 8080
|
|
host.proxy = MobileHostEditorPolicy.selectProxy(.socks5, current: host.proxy)
|
|
#expect(host.proxy?.port == 1080)
|
|
#expect(host.proxy?.hostname == "proxy.example")
|
|
|
|
host.proxy?.port = 9000
|
|
host.proxy = MobileHostEditorPolicy.selectProxy(.http, current: host.proxy)
|
|
#expect(host.proxy?.port == 9000)
|
|
|
|
let identity = Identity(
|
|
name: "Proxy", username: "proxy-user", credentialRef: "termind.password.proxy")
|
|
identityDraft.selectProxyIdentity(identity, on: &host)
|
|
#expect(host.proxy?.identityID == identity.id)
|
|
#expect(host.proxy?.username == "proxy-user")
|
|
#expect(identityDraft.isProxyValid(for: host))
|
|
|
|
host.proxy?.username = ""
|
|
#expect(!identityDraft.isProxyValid(for: host))
|
|
identityDraft.clearProxyIdentity(on: &host)
|
|
#expect(host.proxy?.identityID == nil)
|
|
#expect(host.proxy?.username.isEmpty == true)
|
|
#expect(identityDraft.isProxyValid(for: host))
|
|
|
|
host.proxy = MobileHostEditorPolicy.selectProxy(nil, current: host.proxy)
|
|
#expect(host.proxy == nil)
|
|
}
|
|
|
|
@Test func mobileTagFieldAddsCreatesAndRemovesTagsLikeTheDesktopField() {
|
|
let suggestions = ["api", "production", "web"]
|
|
|
|
#expect(
|
|
MobileTagFieldPolicy.rows(tags: ["production"], suggestions: suggestions, query: "PI") == [
|
|
.existing("api"), .create("PI"),
|
|
])
|
|
#expect(
|
|
MobileTagFieldPolicy.rows(tags: [], suggestions: suggestions, query: "API") == [
|
|
.existing("api")
|
|
])
|
|
#expect(
|
|
MobileTagFieldPolicy.rows(tags: ["production"], suggestions: suggestions, query: "edge") == [
|
|
.create("edge")
|
|
])
|
|
#expect(MobileTagFieldPolicy.submission(query: " API ", suggestions: suggestions) == "api")
|
|
#expect(MobileTagFieldPolicy.adding(" api ", to: ["production"]) == ["production", "api"])
|
|
#expect(MobileTagFieldPolicy.adding(" Production ", to: ["production"]) == ["production"])
|
|
#expect(MobileTagFieldPolicy.adding(" ", to: ["production"]) == ["production"])
|
|
#expect(MobileTagFieldPolicy.removing("production", from: ["production", "api"]) == ["api"])
|
|
}
|
|
|
|
@Test func aTagDisappearsAfterItsLastHostStopsUsingIt() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
var first = TermindDomain.Host(name: "First", host: "first.example.invalid", tags: ["production"])
|
|
var second = TermindDomain.Host(name: "Second", host: "second.example.invalid", tags: ["production"])
|
|
app.library.upsert(first)
|
|
app.library.upsert(second)
|
|
#expect(app.library.allTags == ["production"])
|
|
|
|
first.tags = []
|
|
app.library.upsert(first)
|
|
#expect(app.library.allTags == ["production"])
|
|
|
|
second.tags = []
|
|
app.library.upsert(second)
|
|
#expect(app.library.allTags.isEmpty)
|
|
}
|
|
|
|
@Test func identityCreationConfigurationsMatchHostAndProxyRequirements() {
|
|
let identity = Identity(name: "Deploy", username: "deploy", keyID: UUID())
|
|
|
|
#expect(MobileIdentityEditorConfiguration.standard.showsKey)
|
|
#expect(
|
|
MobileIdentityEditorConfiguration.standard.canSave(
|
|
identity: identity, password: "", hasConfiguredPassword: false, removesPassword: false))
|
|
#expect(!MobileIdentityEditorConfiguration.proxy.showsKey)
|
|
#expect(
|
|
!MobileIdentityEditorConfiguration.proxy.canSave(
|
|
identity: identity, password: "", hasConfiguredPassword: false, removesPassword: false))
|
|
#expect(
|
|
MobileIdentityEditorConfiguration.proxy.canSave(
|
|
identity: identity, password: "secret", hasConfiguredPassword: false, removesPassword: false))
|
|
#expect(
|
|
MobileIdentityEditorConfiguration.proxy.canSave(
|
|
identity: identity, password: "", hasConfiguredPassword: true, removesPassword: false))
|
|
}
|
|
|
|
@Test func identityEditorUsesNewAndEditTitles() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let identity = Identity(name: "Deploy", username: "deploy")
|
|
|
|
#expect(MobileIdentityEditor(app: app, identity: identity).title == String(localized: "New Identity"))
|
|
app.library.upsert(identity)
|
|
#expect(MobileIdentityEditor(app: app, identity: identity).title == String(localized: "Edit Identity"))
|
|
app.outbox.stop()
|
|
}
|
|
|
|
@Test func newlyCreatedIdentitiesAreAppliedOnlyAfterTheEditorSaves() {
|
|
let originalIdentity = Identity(name: "Original", username: "old")
|
|
let createdIdentity = Identity(name: "Created", username: "new")
|
|
var host = TermindDomain.Host(
|
|
name: "Remote", host: "example.invalid", identityID: originalIdentity.id,
|
|
proxy: HostProxy(
|
|
kind: .http, hostname: "proxy.example.invalid", port: 8080,
|
|
username: originalIdentity.username, identityID: originalIdentity.id))
|
|
|
|
#expect(host.identityID == originalIdentity.id)
|
|
#expect(host.proxy?.identityID == originalIdentity.id)
|
|
|
|
var identityDraft = HostCustomIdentityDraft()
|
|
identityDraft.selectSSHIdentity(createdIdentity, on: &host)
|
|
#expect(host.identityID == createdIdentity.id)
|
|
identityDraft.selectProxyIdentity(createdIdentity, on: &host)
|
|
#expect(host.proxy?.identityID == createdIdentity.id)
|
|
#expect(host.proxy?.username == createdIdentity.username)
|
|
}
|
|
|
|
@Test func proxyIdentityChoicesRequireAnAvailablePasswordOnThisDevice() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let available = Identity(name: "Available", username: "proxy")
|
|
let missing = Identity(
|
|
name: "Missing", username: "proxy", credentialRef: "termind.password.missing")
|
|
let keyOnly = Identity(name: "Key only", username: "proxy", keyID: UUID())
|
|
|
|
try app.library.upsert(available, passwordUpdate: .replace("secret"))
|
|
app.library.upsert(missing)
|
|
app.library.upsert(keyOnly)
|
|
|
|
let storedAvailable = try #require(app.library.identity(available.id))
|
|
#expect(app.library.isProxyEligibleOnThisDevice(storedAvailable))
|
|
#expect(!app.library.isProxyEligibleOnThisDevice(missing))
|
|
#expect(!app.library.isProxyEligibleOnThisDevice(keyOnly))
|
|
}
|
|
|
|
@Test func savingAndForegroundingNeverStartForwardings() throws {
|
|
let suite = "MobileManualForwardingTests.\(UUID().uuidString)"
|
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let host = TermindDomain.Host(name: "Remote", host: "example.invalid")
|
|
let rule = PortForwardingRule(hostID: host.id)
|
|
defaults.set([rule.id.uuidString], forKey: "portForwarding.enabledRuleIDs")
|
|
let app = try MobileAppModel(inMemory: true, defaults: defaults)
|
|
app.library.upsert(host)
|
|
try app.forwardingLibrary.save(rule)
|
|
let sceneID = UUID()
|
|
app.sceneChanged(sceneID, phase: .active)
|
|
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
|
#expect(app.forwardings.connection(for: rule.id) == nil)
|
|
app.forwardings.stopAll()
|
|
app.sceneChanged(sceneID, phase: .background)
|
|
app.sceneChanged(sceneID, phase: .active)
|
|
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
|
app.removeScene(sceneID)
|
|
}
|
|
|
|
@Test func encryptedKeyGenerationImportAndMetadataEditingKeepTheSecretOutOfRecords() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
|
#expect(!generator.isDirty)
|
|
generator.label = "Generated"
|
|
#expect(generator.isDirty)
|
|
#expect(generator.storage == .iCloud)
|
|
generator.passphrase = "test-only-passphrase"
|
|
generator.confirmation = "test-only-passphrase"
|
|
try await generator.save()
|
|
let key = try #require(app.credentials.keys.first)
|
|
#expect(key.hasPassphrase)
|
|
#expect(generator.privateKey.isEmpty && generator.passphrase.isEmpty)
|
|
let savedCredential = try app.credentials.keyCredential(for: key)
|
|
guard case .software(let secret) = try #require(savedCredential) else {
|
|
Issue.record("Expected a software key")
|
|
return
|
|
}
|
|
let record = String(decoding: try JSONEncoder().encode(key), as: UTF8.self)
|
|
#expect(!record.contains("PRIVATE KEY"))
|
|
#expect(!record.contains("test-only-passphrase"))
|
|
|
|
let invalid = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
|
invalid.label = "Invalid"
|
|
invalid.privateKey = secret.privateKey
|
|
invalid.passphrase = "wrong"
|
|
await #expect(throws: (any Error).self) { try await invalid.save() }
|
|
#expect(app.credentials.keys.count == 1)
|
|
|
|
let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
|
importer.label = "Imported"
|
|
importer.privateKey = secret.privateKey
|
|
importer.passphrase = "test-only-passphrase"
|
|
try await importer.save()
|
|
#expect(app.credentials.keys.count == 2)
|
|
#expect(app.credentials.keys.allSatisfy { $0.fingerprint == key.fingerprint })
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
|
|
await editor.loadPrivateKey()
|
|
#expect(!editor.isDirty)
|
|
#expect(editor.privateKey == secret.privateKey)
|
|
editor.label = "Renamed"
|
|
try await editor.save()
|
|
let edited = try #require(app.credentials.key(key.id))
|
|
#expect(edited.label == "Renamed")
|
|
#expect(edited.credentialRef == key.credentialRef)
|
|
#expect(try app.credentials.keyCredential(for: edited) == .software(secret))
|
|
}
|
|
|
|
@Test func editingSoftwareKeyReplacesPrivateMaterialAndDerivedMetadata() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let originalMaterial = try SSHKeyMaterial.generate(.ed25519, comment: "original", passphrase: nil)
|
|
let original = SSHKeyRef(
|
|
label: "Deploy", algorithm: originalMaterial.algorithm, publicKey: originalMaterial.publicKey,
|
|
fingerprint: originalMaterial.fingerprint, credentialRef: "termind.key.\(UUID())", storage: .iCloud,
|
|
keySize: originalMaterial.keySize)
|
|
try app.credentials.upsert(
|
|
original, credential: .software(SSHPrivateKeySecret(privateKey: originalMaterial.privateKey)))
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: original)
|
|
#expect(editor.isLoadingPrivateKey)
|
|
#expect(!editor.canSave)
|
|
await editor.loadPrivateKey()
|
|
#expect(editor.privateKeyLoadError == nil)
|
|
#expect(editor.privateKey == originalMaterial.privateKey)
|
|
#expect(!editor.isDirty)
|
|
#expect(editor.canSave)
|
|
|
|
let replacement = try SSHKeyMaterial.generate(.rsa(bits: 2048), comment: "replacement", passphrase: nil)
|
|
editor.privateKey = replacement.privateKey
|
|
await editor.derivePublicMaterial()
|
|
#expect(editor.replacesPublicKey)
|
|
#expect(editor.fingerprint == replacement.fingerprint)
|
|
try await editor.save()
|
|
|
|
let updated = try #require(app.credentials.key(original.id))
|
|
#expect(updated.id == original.id)
|
|
#expect(updated.credentialRef == original.credentialRef)
|
|
#expect(updated.algorithm == .rsa)
|
|
#expect(updated.keySize == 2048)
|
|
#expect(updated.fingerprint == replacement.fingerprint)
|
|
#expect(updated.publicKey.hasPrefix("ssh-rsa "))
|
|
#expect(!updated.hasPassphrase)
|
|
#expect(
|
|
try app.credentials.keyCredential(for: updated)
|
|
== .software(SSHPrivateKeySecret(privateKey: replacement.privateKey))
|
|
)
|
|
#expect(editor.privateKey.isEmpty && editor.passphrase.isEmpty)
|
|
}
|
|
|
|
@Test func encryptedSoftwareKeyCanBeRenamedWithoutReenteringItsPassphrase() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let material = try SSHKeyMaterial.generate(
|
|
.ed25519, comment: "protected", passphrase: "test-only-passphrase")
|
|
let original = SSHKeyRef(
|
|
label: "Protected", algorithm: material.algorithm, publicKey: material.publicKey,
|
|
fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", storage: .iCloud,
|
|
keySize: material.keySize, hasPassphrase: true)
|
|
let secret = SSHPrivateKeySecret(privateKey: material.privateKey)
|
|
try app.credentials.upsert(original, credential: .software(secret))
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: original)
|
|
await editor.loadPrivateKey()
|
|
editor.label = "Renamed Protected"
|
|
try await editor.save()
|
|
|
|
let updated = try #require(app.credentials.key(original.id))
|
|
#expect(updated.label == "Renamed Protected")
|
|
#expect(updated.hasPassphrase)
|
|
#expect(updated.publicKey == original.publicKey)
|
|
#expect(updated.fingerprint == original.fingerprint)
|
|
#expect(try app.credentials.keyCredential(for: updated) == .software(secret))
|
|
}
|
|
|
|
@Test func missingPrivateKeyAllowsMetadataOnlyEditWithoutCreatingACredential() async throws {
|
|
let original = SSHKeyRef(
|
|
label: "Missing", publicKey: "ssh-ed25519 test", fingerprint: "SHA256:test",
|
|
credentialRef: "termind.key.\(UUID())", storage: .thisDevice)
|
|
let library = HostLibraryModel(keys: [original], secrets: InMemorySecretStore())
|
|
let credentials = CredentialLibraryModel(library: library)
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
|
|
|
|
await editor.loadPrivateKey()
|
|
#expect(editor.privateKeyLoadError != nil)
|
|
#expect(!editor.canEditPrivateKey)
|
|
#expect(editor.canSave)
|
|
|
|
editor.storage = .iCloud
|
|
#expect(!editor.canSave)
|
|
editor.storage = original.storage
|
|
editor.privateKey = "replacement"
|
|
#expect(!editor.canSave)
|
|
editor.privateKey = ""
|
|
editor.certificate = "not an SSH certificate"
|
|
#expect(editor.canSave)
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
#expect(credentials.key(original.id) == original)
|
|
editor.certificate = ""
|
|
|
|
editor.label = "Renamed Missing"
|
|
editor.colorTag = .orange
|
|
try await editor.save()
|
|
let updated = try #require(credentials.key(original.id))
|
|
#expect(updated.label == "Renamed Missing")
|
|
#expect(updated.colorTag == .orange)
|
|
#expect(updated.credentialRef == original.credentialRef)
|
|
#expect(updated.publicKey == original.publicKey)
|
|
#expect(try credentials.keyCredential(for: updated) == nil)
|
|
}
|
|
|
|
@Test func missingIdentityPasswordLocksOnlyPasswordEditing() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let identity = Identity(
|
|
name: "Remote", username: "deploy", credentialRef: "termind.password.remote",
|
|
passwordStorage: .thisDevice)
|
|
app.library.upsert(identity)
|
|
|
|
let editor = MobileIdentityEditor(app: app, identity: identity)
|
|
#expect(!editor.canEditPassword)
|
|
#expect(
|
|
MobileIdentityEditorConfiguration.standard.canSave(
|
|
identity: identity, password: "", hasConfiguredPassword: true, removesPassword: false))
|
|
}
|
|
|
|
@Test func invalidPrivateKeyAndCertificateEditsPreserveTheOriginalCredential() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "original", passphrase: nil)
|
|
let original = SSHKeyRef(
|
|
label: "Deploy", algorithm: material.algorithm, publicKey: material.publicKey,
|
|
fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", storage: .iCloud,
|
|
keySize: material.keySize)
|
|
let secret = SSHPrivateKeySecret(privateKey: material.privateKey)
|
|
try app.credentials.upsert(original, credential: .software(secret))
|
|
|
|
let invalidKeyEditor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: original)
|
|
await invalidKeyEditor.loadPrivateKey()
|
|
invalidKeyEditor.privateKey = "not a private key"
|
|
await #expect(throws: (any Error).self) { try await invalidKeyEditor.save() }
|
|
#expect(app.credentials.key(original.id) == original)
|
|
#expect(try app.credentials.keyCredential(for: original) == .software(secret))
|
|
|
|
let invalidCertificateEditor = MobileKeyEditorModel(
|
|
mode: .edit, credentials: app.credentials, key: original)
|
|
await invalidCertificateEditor.loadPrivateKey()
|
|
invalidCertificateEditor.certificate = "not an SSH certificate"
|
|
await #expect(throws: (any Error).self) { try await invalidCertificateEditor.save() }
|
|
#expect(app.credentials.key(original.id) == original)
|
|
#expect(try app.credentials.keyCredential(for: original) == .software(secret))
|
|
}
|
|
|
|
@Test func editingICloudSoftwareKeyCannotMoveItToThisDevice() async throws {
|
|
let secrets = MobileStorageAwareSecretStore()
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "move", passphrase: nil)
|
|
let original = SSHKeyRef(
|
|
label: "Move", algorithm: material.algorithm, publicKey: material.publicKey,
|
|
fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", storage: .iCloud,
|
|
keySize: material.keySize)
|
|
let secret = StoredSSHKeyCredential.software(SSHPrivateKeySecret(privateKey: material.privateKey))
|
|
let library = HostLibraryModel(keys: [original], secrets: secrets)
|
|
let credentials = CredentialLibraryModel(library: library)
|
|
try credentials.upsert(original, credential: secret)
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
|
|
await editor.loadPrivateKey()
|
|
#expect(!editor.canSelectThisDevice)
|
|
editor.storage = .thisDevice
|
|
#expect(!editor.canSave)
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
|
|
let updated = try #require(credentials.key(original.id))
|
|
#expect(updated.storage == .iCloud)
|
|
#expect(try secrets.keyCredential(for: original.credentialRef, storage: .thisDevice) == nil)
|
|
#expect(try secrets.keyCredential(for: original.credentialRef, storage: .iCloud) == secret)
|
|
}
|
|
|
|
@Test func importRequiresAnExplicitUsernameWhenTheConfigurationOmitsIt() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let root = FileManager.default.temporaryDirectory.appending(
|
|
path: UUID().uuidString, directoryHint: .isDirectory)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let configURL = root.appending(path: "config")
|
|
let config = "Host remote\n HostName example.invalid\n"
|
|
try config.write(to: configURL, atomically: true, encoding: .utf8)
|
|
let model = SSHImportModel(library: app.library, reader: SSHImportReader(username: "", home: root))
|
|
await model.scan(root, knownHostsOnly: false)
|
|
#expect(model.error == nil)
|
|
#expect(model.selectedItems(.hosts).count == 1)
|
|
#expect(model.selectedItems(.identities).count == 1)
|
|
#expect(!model.canImport)
|
|
await model.commit()
|
|
#expect(!model.completed)
|
|
#expect(app.library.hosts.isEmpty && app.library.identities.isEmpty)
|
|
|
|
// Rescan the corrected source so both Host and Identity previews are rebuilt together.
|
|
try (config + " User deploy\n").write(to: configURL, atomically: true, encoding: .utf8)
|
|
await model.scan(root, knownHostsOnly: false)
|
|
#expect(model.error == nil)
|
|
#expect(model.selectedItems(.identities).map { $0.identity?.username } == ["deploy"])
|
|
#expect(model.canImport)
|
|
await model.commit()
|
|
#expect(model.completed)
|
|
#expect(model.error == nil)
|
|
#expect(app.library.hosts.count == 1 && app.library.identities.count == 1)
|
|
let host = try #require(app.library.hosts.first)
|
|
#expect(app.library.identity(for: host)?.username == "deploy")
|
|
}
|
|
|
|
@Test func forwardingEditsValidateAndStopOnlyTheChangedRule() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = TermindDomain.Host(name: "Remote", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
var rule = PortForwardingRule(hostID: host.id)
|
|
try app.forwardingLibrary.save(rule)
|
|
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
|
var stopped: [UUID] = []
|
|
app.library.willDeletePortForwardings = { stopped.append(contentsOf: $0) }
|
|
rule.bindPort = 9000
|
|
try app.forwardingLibrary.save(rule)
|
|
#expect(stopped == [rule.id])
|
|
var invalid = rule
|
|
invalid.bindPort = 0
|
|
#expect(throws: (any Error).self) { try app.forwardingLibrary.save(invalid) }
|
|
#expect(app.forwardingLibrary.portForwarding(rule.id)?.bindPort == 9000)
|
|
try app.forwardingLibrary.delete(rule.id)
|
|
#expect(app.forwardingLibrary.rules.isEmpty)
|
|
}
|
|
|
|
@Test func deletingAHostCompletesDurableCleanup() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
var host = TermindDomain.Host(
|
|
name: "Remote", host: "example.invalid",
|
|
proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080))
|
|
app.library.upsert(host)
|
|
let dependent = TermindDomain.Host(name: "Dependent", host: "dependent.invalid", jumpHostID: host.id)
|
|
app.library.upsert(dependent)
|
|
let reusable = Identity(name: "Reusable", username: "shared")
|
|
app.library.upsert(reusable)
|
|
let ssh = try #require(
|
|
try app.library.saveCustomIdentity(
|
|
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
|
|
username: "deploy", password: "ssh-secret", keyID: nil))
|
|
let proxy = try #require(
|
|
try app.library.saveCustomIdentity(
|
|
ownerHostID: host.id, purpose: .proxy, displayName: "Remote Proxy",
|
|
username: "proxy", password: "proxy-secret", keyID: nil))
|
|
let retained = try #require(
|
|
try app.library.saveCustomIdentity(
|
|
ownerHostID: dependent.id, purpose: .ssh, displayName: dependent.name,
|
|
username: "dependent", password: "retained-secret", keyID: nil))
|
|
host.identityID = ssh.id
|
|
host.proxy?.identityID = proxy.id
|
|
host.proxy?.username = proxy.username
|
|
#expect(app.library.saveHost(host))
|
|
|
|
app.deleteHost(host.id)
|
|
#expect(app.library.host(host.id) == nil)
|
|
#expect(app.library.identity(ssh.id) == nil)
|
|
#expect(app.library.identity(proxy.id) == nil)
|
|
#expect(app.library.identity(reusable.id) == reusable)
|
|
#expect(app.library.identity(retained.id)?.ownerHostID == dependent.id)
|
|
var finished = false
|
|
for _ in 0..<100 {
|
|
let context = ModelContext(app.container)
|
|
let jobs = try context.fetch(FetchDescriptor<PersistentOutboxJob>())
|
|
if jobs.count == 5 && jobs.allSatisfy({ $0.status == OutboxJobStatus.succeeded.rawValue }) {
|
|
finished = true
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
#expect(finished)
|
|
let saved = try HostPersistence(context: ModelContext(app.container)).load()
|
|
#expect(!saved.hosts.contains(where: { $0.id == host.id }))
|
|
#expect(saved.hosts.first(where: { $0.id == dependent.id })?.jumpHostID == nil)
|
|
#expect(Set(saved.identities.map(\.id)) == [reusable.id, retained.id])
|
|
}
|
|
}
|
|
|
|
private final class MobileStorageAwareSecretStore: SecretStoring, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var strings: [String: String] = [:]
|
|
private var keys: [String: StoredSSHKeyCredential] = [:]
|
|
|
|
func string(for account: String, storage: PasswordStorage) throws -> String? {
|
|
lock.withLock { strings["\(storage.rawValue):\(account)"] }
|
|
}
|
|
|
|
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
|
|
lock.withLock { strings["\(storage.rawValue):\(account)"] = value }
|
|
}
|
|
|
|
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
|
|
lock.withLock { keys["\(storage.rawValue):\(account)"] }
|
|
}
|
|
|
|
func setKeyCredential(
|
|
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
|
|
) throws {
|
|
lock.withLock { keys["\(storage.rawValue):\(account)"] = value }
|
|
}
|
|
|
|
func removePassword(_ account: String, storage: PasswordStorage) throws {
|
|
lock.withLock { strings["\(storage.rawValue):\(account)"] = nil }
|
|
}
|
|
|
|
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
|
|
lock.withLock { keys["\(storage.rawValue):\(account)"] = nil }
|
|
}
|
|
}
|