Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks. Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP. Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision.
579 lines
28 KiB
Swift
579 lines
28 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import TermindPersistence
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
import Testing
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@testable import Termind
|
|
|
|
@Suite
|
|
@MainActor
|
|
struct SSHImportTests {
|
|
private func root() throws -> URL {
|
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent("SSHImport-\(UUID())")
|
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
return url
|
|
}
|
|
|
|
private func hash(_ address: String) -> String {
|
|
let salt = Data(repeating: 7, count: 20)
|
|
let digest = HMAC<Insecure.SHA1>.authenticationCode(for: Data(address.utf8), using: SymmetricKey(data: salt))
|
|
return "|1|\(salt.base64EncodedString())|\(Data(digest).base64EncodedString())"
|
|
}
|
|
|
|
@Test func overQuotaImportRetainsSelectionAndWritesNothingUntilEntitlementResolves() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
try material.privateKey.write(to: url.appendingPathComponent("id_ed25519"), atomically: true, encoding: .utf8)
|
|
try """
|
|
Host prod stage
|
|
HostName %h.example
|
|
User deploy
|
|
IdentityFile ~/.ssh/id_ed25519
|
|
""".write(to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
|
try "prod.example \(material.publicKey)".write(
|
|
to: url.appendingPathComponent("known_hosts"), atomically: true, encoding: .utf8)
|
|
let hosts = (0..<3).map { Host(name: "Existing \($0)", host: "existing\($0).invalid") }
|
|
let library = HostLibraryModel(hosts: hosts)
|
|
library.hostAccess.entitlement = .loading
|
|
var upgrades = 0
|
|
library.requestHostUpgrade = { upgrades += 1 }
|
|
library.resolveInitialHostEntitlement = { library.hostAccess.entitlement = .free }
|
|
defer { library.resolveInitialHostEntitlement = nil }
|
|
let model = SSHImportModel(library: library, reader: SSHImportReader())
|
|
await model.scan(url, knownHostsOnly: false)
|
|
let selected = model.selectedItems().map(\.id)
|
|
#expect(model.canImport)
|
|
await model.commit()
|
|
#expect(!model.completed)
|
|
#expect(model.error == nil)
|
|
#expect(upgrades == 1)
|
|
#expect(model.selectedItems().map(\.id) == selected)
|
|
#expect(library.hosts == hosts)
|
|
#expect(library.identities.isEmpty && library.keys.isEmpty && library.knownHosts.isEmpty)
|
|
library.hostAccess.entitlement = .purchased(.purchased)
|
|
await model.commit()
|
|
#expect(model.completed)
|
|
#expect(library.hosts.count == 5)
|
|
#expect(library.keys.count == 1 && library.knownHosts.count == 1)
|
|
}
|
|
|
|
@Test
|
|
func appDefaultsNewCredentialsAndSSHImportToThisMacWhenICloudSyncIsOff() throws {
|
|
let domain = "SSHImportDefaults.\(UUID())"
|
|
let defaults = try #require(UserDefaults(suiteName: domain))
|
|
defer { defaults.removePersistentDomain(forName: domain) }
|
|
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
|
|
let hosts = HostLibraryModel()
|
|
let app = AppModel(
|
|
hosts: hosts,
|
|
dependencies: AppDependencies(
|
|
hostRepository: InMemoryHostRepository(),
|
|
outboxRunner: nil,
|
|
settingsDefaults: defaults))
|
|
defer { app.shutdownForTermination() }
|
|
|
|
#expect(app.newVaultKeyStorage == .thisDevice)
|
|
#expect(app.newVaultPasswordStorage == .thisDevice)
|
|
#expect(app.makeSSHImportModel().storage == .thisDevice)
|
|
}
|
|
|
|
@Test
|
|
func hashedKeysMatchPortsAndSurvivePersistenceWithoutHosts() throws {
|
|
let entry = KnownHostEntry(hostname: hash("[2001:db8::1]:2222"), fingerprint: "SHA256:key")
|
|
let decoded = try JSONDecoder().decode(KnownHostEntry.self, from: JSONEncoder().encode(entry))
|
|
#expect(decoded.matches(hostname: "2001:db8::1", port: 2222))
|
|
#expect(!decoded.matches(hostname: "2001:db8::1", port: 22))
|
|
let store = HostLibraryModel(knownHosts: [decoded])
|
|
let host = Host(name: "IPv6", host: "2001:db8::1", port: 2222)
|
|
#expect(try store.expectedHostKeys(for: host) == [entry.identity])
|
|
store.forgetKnownHosts(for: host)
|
|
#expect(store.knownHosts.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func configHonorsFirstValuesHostPatternsAndOrderedIncludes() {
|
|
let root = URL(fileURLWithPath: "/fixture/.ssh")
|
|
var parser = SSHConfigParser(
|
|
root: root, home: URL(fileURLWithPath: "/fixture"), username: "local",
|
|
files: [
|
|
"/fixture/.ssh/config": """
|
|
Host prod alias
|
|
HostName prod.example
|
|
User deploy
|
|
IdentityFile "~/.ssh/deploy key"
|
|
Include conf/*.conf
|
|
Host * !alias
|
|
Port 2222
|
|
Host *
|
|
User fallback
|
|
""",
|
|
"/fixture/.ssh/conf/01.conf": "Port=2200\nIdentityFile ~/.ssh/second",
|
|
"/fixture/.ssh/conf/02.conf": "Port 2300",
|
|
])
|
|
let entries = parser.parse().compactMap(\.host)
|
|
#expect(entries.count == 2)
|
|
#expect(entries.allSatisfy { $0.port == 2200 && $0.username == "deploy" })
|
|
#expect(entries[0].identityPaths == ["/fixture/.ssh/deploy key", "/fixture/.ssh/second"])
|
|
}
|
|
|
|
@Test
|
|
func configReportsCyclesAndDoesNotExecuteMatchOrProxyCommands() {
|
|
let root = URL(fileURLWithPath: "/fixture")
|
|
var parser = SSHConfigParser(
|
|
root: root, home: root, username: "local",
|
|
files: [
|
|
"/fixture/config":
|
|
"Host app\nHostName app.example\nProxyCommand touch /tmp/never\nInclude loop\nMatch exec \"exit 0\"\nUser wrong",
|
|
"/fixture/loop": "Include config",
|
|
])
|
|
let entries = parser.parse()
|
|
let host = entries.first { $0.host != nil }
|
|
#expect(host?.host?.username == "local")
|
|
#expect(host?.warnings.contains(where: { $0.contains("proxycommand") }) == true)
|
|
#expect(host?.warnings.contains(where: { $0.contains("Match") }) == true)
|
|
#expect(entries.contains { $0.unavailable })
|
|
}
|
|
|
|
@Test
|
|
func knownHostsValidatesWireKeysAndPreservesMultipleEndpoints() throws {
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
let content = """
|
|
# comment
|
|
example,[2001:db8::1]:2222 \(material.publicKey)
|
|
\(hash("other.example")) \(material.publicKey)
|
|
@revoked revoked.example \(material.publicKey)
|
|
*.wild.example \(material.publicKey)
|
|
broken ssh-ed25519 AAAA
|
|
"""
|
|
let rows = SSHImportParser.knownHosts(content, source: "known_hosts")
|
|
#expect(rows.filter { !$0.unavailable }.count == 3)
|
|
#expect(rows.compactMap(\.knownHost).allSatisfy { $0.fingerprint == material.fingerprint })
|
|
#expect(rows.filter(\.unavailable).count == 3)
|
|
#expect(rows.compactMap(\.knownHost).contains { $0.matches(hostname: "other.example", port: 22) })
|
|
}
|
|
|
|
@Test
|
|
func contentClassifierDoesNotUseFilenames() throws {
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
#expect(SSHImportParser.contentKind(material.privateKey) == .privateKey)
|
|
#expect(SSHImportParser.contentKind("example.invalid \(material.publicKey)") == .knownHosts)
|
|
#expect(SSHImportParser.contentKind("Host app\n HostName app.invalid") == .configuration)
|
|
#expect(SSHImportParser.contentKind("ordinary notes") == .unknown)
|
|
}
|
|
|
|
@Test
|
|
func folderRecursivelyFindsRenamedContentAndDoesNotReparseIncludes() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
let pieces = url.appendingPathComponent("pieces", isDirectory: true)
|
|
let secrets = url.appendingPathComponent("secrets", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: pieces, withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(at: secrets, withIntermediateDirectories: true)
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
try "Include pieces/*".write(
|
|
to: url.appendingPathComponent("entry.anything"), atomically: true, encoding: .utf8)
|
|
try "Host app\n HostName app.invalid\n User deploy\n IdentityFile secrets/credential.data".write(
|
|
to: pieces.appendingPathComponent("fragment.notes"), atomically: true, encoding: .utf8)
|
|
try material.privateKey.write(
|
|
to: secrets.appendingPathComponent("credential.data"), atomically: true, encoding: .utf8)
|
|
try "app.invalid \(material.publicKey)".write(
|
|
to: pieces.appendingPathComponent("trust.random"), atomically: true, encoding: .utf8)
|
|
|
|
let rows = try await SSHImportReader().scan(root: url, knownHostsOnly: false)
|
|
|
|
#expect(rows.compactMap(\.host).map(\.alias) == ["app"])
|
|
#expect(rows.compactMap(\.key).count == 1)
|
|
#expect(rows.compactMap(\.knownHost).count == 1)
|
|
}
|
|
|
|
@Test
|
|
func standaloneKnownHostsRejectsOtherSSHContentButAcceptsComments() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
let file = url.appendingPathComponent("arbitrary-name")
|
|
let reader = SSHImportReader()
|
|
try "Host app\nHostName app.invalid".write(to: file, atomically: true, encoding: .utf8)
|
|
await #expect(throws: SSHImportSourceError.unsupportedKnownHostsContent) {
|
|
_ = try await reader.scan(root: file, knownHostsOnly: true)
|
|
}
|
|
try "# intentionally empty\n".write(to: file, atomically: true, encoding: .utf8)
|
|
#expect(try await reader.scan(root: file, knownHostsOnly: true).isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func privateKeyIsRereadAndDigestCheckedAtCommitBoundary() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
let first = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
let second = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
let file = url.appendingPathComponent("credential")
|
|
try first.privateKey.write(to: file, atomically: true, encoding: .utf8)
|
|
let reader = SSHImportReader()
|
|
let rows = try await reader.scan(root: url, knownHostsOnly: false)
|
|
let id = try #require(rows.compactMap(\.key).first?.id)
|
|
|
|
try second.privateKey.write(to: file, atomically: true, encoding: .utf8)
|
|
|
|
await #expect(throws: SSHImportSourceError.sourceChanged) {
|
|
_ = try await reader.credentials(for: [id])
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func droppedSourcesUseTheSameTypeValidationAsTheFilePicker() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
let file = url.appendingPathComponent("known_hosts")
|
|
try "# empty".write(to: file, atomically: true, encoding: .utf8)
|
|
let reader = SSHImportReader()
|
|
await #expect(throws: SSHImportSourceError.expectedFolder) {
|
|
_ = try await reader.scan(root: file, knownHostsOnly: false)
|
|
}
|
|
await #expect(throws: SSHImportSourceError.expectedFile) {
|
|
_ = try await reader.scan(root: url, knownHostsOnly: true)
|
|
}
|
|
#expect(try await reader.scan(root: file, knownHostsOnly: true).isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func malformedPrivateKeysAreUnavailableRatherThanAskingForAPassphrase() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
try "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----".write(
|
|
to: url.appendingPathComponent("broken"), atomically: true, encoding: .utf8)
|
|
let reader = SSHImportReader()
|
|
let rows = try await reader.scan(root: url, knownHostsOnly: false)
|
|
#expect(rows.count == 1)
|
|
#expect(rows[0].unavailable && !rows[0].selected && !rows[0].requiresPassphrase)
|
|
}
|
|
|
|
@Test
|
|
func selectionStartsCheckedAndCollapseDoesNotChangeIt() {
|
|
let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader())
|
|
model.items = [
|
|
SSHImportItem(category: .hosts, name: "a", source: "config"),
|
|
SSHImportItem(category: .keys, name: "b", source: "key"),
|
|
SSHImportItem(category: .knownHosts, name: "c", source: "known_hosts"),
|
|
SSHImportItem(category: .keys, name: "bad", source: "bad", selected: false, unavailable: true),
|
|
]
|
|
#expect(model.selectedItems().count == 3)
|
|
model.expanded.remove(.keys)
|
|
#expect(model.selectedItems().count == 3)
|
|
model.setSelected(false, category: .keys)
|
|
#expect(model.selectedItems().count == 2)
|
|
model.setSelected(false)
|
|
#expect(!model.canImport)
|
|
model.setSelected(true)
|
|
#expect(model.selectedItems().count == 3)
|
|
#expect(!model.items[3].selected)
|
|
}
|
|
|
|
@Test
|
|
func folderImportMigratesSecretsAndIsIdempotent() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
try material.privateKey.write(to: url.appendingPathComponent("id_ed25519"), atomically: true, encoding: .utf8)
|
|
try "Host prod\n HostName prod.example\n User deploy\n IdentityFile ~/.ssh/id_ed25519\n".write(
|
|
to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
|
try "prod.example \(material.publicKey)".write(
|
|
to: url.appendingPathComponent("known_hosts"), atomically: true, encoding: .utf8)
|
|
let group = HostGroup(name: "Imported")
|
|
let store = HostLibraryModel(groups: [group])
|
|
let model = SSHImportModel(library: store, reader: SSHImportReader())
|
|
model.hostGroupID = group.id
|
|
model.hostTags = ["production", "ssh"]
|
|
model.storage = .thisDevice
|
|
model.importColor = .purple
|
|
await model.scan(url, knownHostsOnly: false)
|
|
#expect(model.selectedItems().count == 4)
|
|
#expect(store.hosts.isEmpty && store.keys.isEmpty)
|
|
#expect(model.canImport)
|
|
await model.commit()
|
|
#expect(model.completed)
|
|
#expect(store.hosts.count == 1 && store.keys.count == 1 && store.knownHosts.count == 1)
|
|
let identity = try #require(store.identities.first)
|
|
#expect(identity.username == "deploy" && identity.keyID == store.keys.first?.id)
|
|
#expect(store.hosts.first?.groupID == group.id)
|
|
#expect(store.hosts.first?.colorTag == .purple)
|
|
#expect(store.hosts.first?.tags == ["production", "ssh"])
|
|
#expect(store.keys.first?.colorTag == .purple)
|
|
#expect(store.keys.first?.storage == .thisDevice)
|
|
#expect(identity.colorTag == .purple)
|
|
#expect(identity.passwordStorage == .thisDevice)
|
|
#expect(store.keyAvailability(for: try #require(store.keys.first)) == .available)
|
|
let again = SSHImportModel(library: store, reader: SSHImportReader())
|
|
await again.scan(url, knownHostsOnly: false)
|
|
#expect(again.selectedItems().isEmpty)
|
|
#expect(!again.canImport)
|
|
}
|
|
|
|
@Test
|
|
func encryptedKeyCanBeUnlockedAndDeselectionRefreshesHostAssociation() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "test-password")
|
|
try material.privateKey.write(to: url.appendingPathComponent("deploy"), atomically: true, encoding: .utf8)
|
|
try "Host prod\nIdentityFile ~/.ssh/deploy".write(
|
|
to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
|
let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader())
|
|
await model.scan(url, knownHostsOnly: false)
|
|
let id = try #require(model.items.first { $0.category == .keys }?.id)
|
|
#expect(!model.canImport)
|
|
await model.unlock(id, passphrase: "wrong")
|
|
#expect(!model.canImport)
|
|
await model.unlock(id, passphrase: "test-password")
|
|
#expect(model.canImport)
|
|
let host = try #require(model.items.first { $0.host != nil })
|
|
#expect(model.chosenKey(for: host)?.id == id)
|
|
model.setSelected(false, category: .keys)
|
|
#expect(model.items.first(where: { $0.id == host.id })?.host?.identityID == nil)
|
|
#expect(model.canImport)
|
|
}
|
|
|
|
@Test
|
|
func identitySelectionControlsKeysAndHostBindingWithoutChangingHostSelection() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
try material.privateKey.write(
|
|
to: url.appendingPathComponent("deploy"), atomically: true, encoding: .utf8)
|
|
try "Host app\n User deploy\n IdentityFile ~/.ssh/deploy".write(
|
|
to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
|
let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader())
|
|
|
|
await model.scan(url, knownHostsOnly: false)
|
|
|
|
let hostID = try #require(model.items.first(where: { $0.host != nil })?.id)
|
|
let identityID = try #require(model.items.first(where: { $0.identity != nil })?.id)
|
|
let keyID = try #require(model.items.first(where: { $0.key != nil })?.id)
|
|
#expect(model.items.first(where: { $0.id == hostID })?.host?.identityID == identityID)
|
|
|
|
model.setSelected(false, itemID: identityID)
|
|
#expect(model.items.first(where: { $0.id == hostID })?.selected == true)
|
|
#expect(model.items.first(where: { $0.id == hostID })?.host?.identityID == nil)
|
|
#expect(model.items.first(where: { $0.id == keyID })?.selected == true)
|
|
|
|
model.setSelected(false, itemID: hostID)
|
|
model.setSelected(true, itemID: hostID)
|
|
#expect(model.items.first(where: { $0.id == identityID })?.selected == false)
|
|
#expect(model.items.first(where: { $0.id == hostID })?.host?.identityID == nil)
|
|
|
|
model.setSelected(true, itemID: identityID)
|
|
#expect(model.items.first(where: { $0.id == keyID })?.selected == true)
|
|
#expect(model.items.first(where: { $0.id == hostID })?.host?.identityID == identityID)
|
|
|
|
model.setSelected(false, itemID: keyID)
|
|
#expect(model.items.first(where: { $0.id == identityID })?.selected == false)
|
|
#expect(model.items.first(where: { $0.id == hostID })?.selected == true)
|
|
#expect(model.items.first(where: { $0.id == hostID })?.host?.identityID == nil)
|
|
}
|
|
|
|
@Test
|
|
func sharedIdentityIsImportedOnceAndReceivesImportDefaults() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
try material.privateKey.write(
|
|
to: url.appendingPathComponent("deploy"), atomically: true, encoding: .utf8)
|
|
try """
|
|
Host app-one app-two
|
|
User deploy
|
|
IdentityFile ~/.ssh/deploy
|
|
""".write(to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
|
let store = HostLibraryModel()
|
|
let model = SSHImportModel(library: store, reader: SSHImportReader())
|
|
model.storage = .thisDevice
|
|
model.importColor = .orange
|
|
|
|
await model.scan(url, knownHostsOnly: false)
|
|
|
|
#expect(model.items.filter { $0.category == .identities }.count == 1)
|
|
await model.commit()
|
|
let identity = try #require(store.identities.first)
|
|
#expect(store.identities.count == 1)
|
|
#expect(store.hosts.count == 2)
|
|
#expect(store.hosts.allSatisfy { $0.identityID == identity.id })
|
|
#expect(identity.passwordStorage == .thisDevice)
|
|
#expect(identity.colorTag == .orange)
|
|
#expect(!identity.usesPassword)
|
|
}
|
|
|
|
@Test
|
|
func exactExistingIdentityIsReusedWhileDifferentKeysGetDistinctNames() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
let first = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
let second = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
try first.privateKey.write(to: url.appendingPathComponent("first"), atomically: true, encoding: .utf8)
|
|
try second.privateKey.write(to: url.appendingPathComponent("second"), atomically: true, encoding: .utf8)
|
|
try """
|
|
Host first
|
|
User deploy
|
|
IdentityFile ~/.ssh/first
|
|
Host second
|
|
User deploy
|
|
IdentityFile ~/.ssh/second
|
|
""".write(to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
|
let existingKey = SSHKeyRef(
|
|
label: "Saved", publicKey: first.publicKey, fingerprint: first.fingerprint,
|
|
credentialRef: "saved")
|
|
let existingIdentity = Identity(name: "Existing Deploy", username: "deploy", keyID: existingKey.id)
|
|
let secrets = InMemorySecretStore()
|
|
try secrets.setKeyCredential(
|
|
.software(SSHPrivateKeySecret(privateKey: first.privateKey)),
|
|
for: existingKey.credentialRef, storage: existingKey.storage)
|
|
let store = HostLibraryModel(keys: [existingKey], identities: [existingIdentity], secrets: secrets)
|
|
let model = SSHImportModel(library: store, reader: SSHImportReader())
|
|
|
|
await model.scan(url, knownHostsOnly: false)
|
|
|
|
let identities = model.items.filter { $0.category == .identities }
|
|
#expect(identities.count == 2)
|
|
#expect(identities.contains { $0.existingID == existingIdentity.id && $0.name == "Existing Deploy" })
|
|
#expect(identities.contains { $0.existingID == nil && $0.name.contains("second") })
|
|
}
|
|
|
|
@Test
|
|
func jumpDependenciesAndCyclesBlockCommit() {
|
|
let model = SSHImportModel(library: HostLibraryModel(), reader: SSHImportReader())
|
|
model.items = [
|
|
SSHImportItem(
|
|
category: .hosts, name: "app", source: "config",
|
|
host: SSHImportedHost(alias: "app", hostname: "app", username: "u", jumps: ["jump"])),
|
|
SSHImportItem(
|
|
category: .hosts, name: "jump", source: "config",
|
|
host: SSHImportedHost(alias: "jump", hostname: "jump", username: "u")),
|
|
]
|
|
#expect(model.canImport)
|
|
model.items[1].selected = false
|
|
#expect(!model.canImport)
|
|
model.items[1].selected = true
|
|
model.items[1].host?.jumps = ["app"]
|
|
#expect(!model.canImport)
|
|
#expect(model.routeProblem != nil)
|
|
}
|
|
|
|
@Test
|
|
func importRequiresKnownHostConflictReview() async {
|
|
let original = KnownHostEntry(hostname: "host", fingerprint: "SHA256:old")
|
|
let store = HostLibraryModel(knownHosts: [original])
|
|
let model = SSHImportModel(library: store, reader: SSHImportReader())
|
|
model.items = [
|
|
SSHImportItem(
|
|
category: .knownHosts, name: "host", source: "known_hosts",
|
|
knownHost: KnownHostEntry(hostname: "host", fingerprint: "SHA256:new"))
|
|
]
|
|
#expect(!model.canImport)
|
|
model.items[0].acceptedWarnings = true
|
|
#expect(model.canImport)
|
|
await model.commit()
|
|
#expect(model.completed)
|
|
#expect(store.knownHosts.first?.fingerprint == original.fingerprint)
|
|
}
|
|
|
|
@Test
|
|
func multiHopImportsPreserveStandaloneJumpConfigurations() async throws {
|
|
let url = try root()
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
try "Host app\nProxyJump first,second\nHost first\nHost second".write(
|
|
to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
|
let store = HostLibraryModel()
|
|
let model = SSHImportModel(library: store, reader: SSHImportReader())
|
|
await model.scan(url, knownHostsOnly: false)
|
|
#expect(model.canImport)
|
|
await model.commit()
|
|
#expect(model.completed)
|
|
let second = try #require(store.hosts.first { $0.name == "second" })
|
|
#expect(second.jumpHostID == nil)
|
|
let app = try #require(store.hosts.first { $0.name == "app" })
|
|
let intermediate = try #require(app.jumpHostID.flatMap(store.host))
|
|
#expect(intermediate.host == "second")
|
|
#expect(intermediate.jumpHostID.flatMap(store.host)?.name == "first")
|
|
let again = SSHImportModel(library: store, reader: SSHImportReader())
|
|
await again.scan(url, knownHostsOnly: false)
|
|
#expect(again.selectedItems().isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func externalKeyIsOnlyReadAfterExplicitAuthorization() async throws {
|
|
let url = try root()
|
|
let external = try root()
|
|
defer {
|
|
try? FileManager.default.removeItem(at: url)
|
|
try? FileManager.default.removeItem(at: external)
|
|
}
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
|
|
let keyURL = external.appendingPathComponent("deploy")
|
|
try material.privateKey.write(to: keyURL, atomically: true, encoding: .utf8)
|
|
try "Host app\nIdentityFile \(keyURL.path)".write(
|
|
to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8)
|
|
let reader = SSHImportReader()
|
|
let before = try await reader.scan(root: url, knownHostsOnly: false)
|
|
#expect(before.compactMap(\.key).isEmpty)
|
|
#expect(before.contains { $0.needsAuthorization == keyURL.path })
|
|
let after = try await reader.scan(root: url, knownHostsOnly: false, grants: [keyURL])
|
|
#expect(after.compactMap(\.key).count == 1)
|
|
#expect(!after.contains { $0.needsAuthorization != nil })
|
|
}
|
|
|
|
@Test
|
|
func keychainPagePruningDoesNotEraseKnownHostSelection() {
|
|
let entry = KnownHostEntry(hostname: "host", fingerprint: "SHA256:key")
|
|
let store = HostLibraryModel(knownHosts: [entry])
|
|
let window = WindowModel()
|
|
window.selectVaultItem(.knownHost(entry.id), on: .knownHosts)
|
|
window.pruneVaultSelections(in: CredentialLibraryModel(library: store))
|
|
#expect(window.vaultSelection(for: .knownHosts) == [.knownHost(entry.id)])
|
|
}
|
|
|
|
@Test
|
|
func failedSnapshotRollsBackMetadataAndNewCredentials() throws {
|
|
let persistence = FailingImportPersistence()
|
|
let secrets = InMemorySecretStore()
|
|
let store = HostLibraryModel(persistence: persistence, secrets: secrets)
|
|
let key = SSHKeyRef(label: "Imported", credentialRef: "import-test")
|
|
let batch = SSHImportBatch(
|
|
hosts: [Host(name: "new", host: "new")], keys: [key],
|
|
knownHosts: [KnownHostEntry(hostname: "new", fingerprint: "SHA256:key")],
|
|
credentials: [key.id: .software(SSHPrivateKeySecret(privateKey: "test-private-material"))])
|
|
#expect(throws: (any Error).self) { try store.commitSSHImport(batch) }
|
|
#expect(store.hosts.isEmpty && store.keys.isEmpty && store.knownHosts.isEmpty)
|
|
#expect(try secrets.keyCredential(for: key.credentialRef, storage: key.storage) == nil)
|
|
#expect(!store.saveKnownHost(KnownHostEntry(hostname: "new", fingerprint: "SHA256:key")))
|
|
#expect(store.knownHosts.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func vaultKnownHostNavigationUsesFingerprintAndRetainsSelection() {
|
|
let entry = KnownHostEntry(hostname: "host", fingerprint: "SHA256:key")
|
|
let store = HostLibraryModel(knownHosts: [entry])
|
|
let window = WindowModel()
|
|
window.selectVaultItem(.knownHost(entry.id), on: .knownHosts)
|
|
window.vaultPages.knownHosts.query = "key"
|
|
window.openPage(.hosts)
|
|
window.openPage(.knownHosts)
|
|
window.pruneVaultSelections(in: store)
|
|
#expect(window.vaultSelection(for: .knownHosts) == [.knownHost(entry.id)])
|
|
#expect(window.vaultPages.knownHosts.query == "key")
|
|
#expect(window.findKind == .knownHosts)
|
|
#expect(VaultPage.allCases.last == .knownHosts)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FailingImportPersistence: VaultPersisting {
|
|
func load() throws -> VaultSnapshot { VaultSnapshot() }
|
|
func save(
|
|
_ snapshot: VaultSnapshot, touching keys: Set<VaultRecordKey>
|
|
) throws -> VaultMutationSet { throw CocoaError(.fileWriteUnknown) }
|
|
}
|