Refactor Host Custom Identity editing
This commit is contained in:
1 parent
e59aa2cbc6
commit
97e2362d7d
9 files changed
+530
-236
No files matched your search
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct HostCustomIdentityDraftTests {
|
||||
@Test
|
||||
func loadsAndSavesSSHAndProxyCustomIdentities() throws {
|
||||
let secrets = InMemorySecretStore()
|
||||
var host = Host(
|
||||
name: "Server", host: "server.invalid",
|
||||
proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080))
|
||||
let library = HostLibraryModel(hosts: [host], secrets: secrets)
|
||||
let ssh = try #require(
|
||||
try library.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
|
||||
username: "deploy", password: "ssh-secret", keyID: nil))
|
||||
let proxy = try #require(
|
||||
try library.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .proxy, displayName: "Server Proxy",
|
||||
username: "proxy-user", password: "proxy-secret", keyID: nil))
|
||||
host.identityID = ssh.id
|
||||
host.proxy?.identityID = proxy.id
|
||||
host.proxy?.username = proxy.username
|
||||
#expect(library.saveHost(host))
|
||||
|
||||
var draft = HostCustomIdentityDraft(host: host, library: library)
|
||||
#expect(draft.sshUsername == "deploy")
|
||||
#expect(draft.sshPassword == "ssh-secret")
|
||||
#expect(draft.usesCustomProxy)
|
||||
#expect(draft.proxyPassword == "proxy-secret")
|
||||
#expect(draft.sshPasswordIsEditable)
|
||||
#expect(draft.proxyPasswordIsEditable)
|
||||
#expect(draft.isSSHValid(for: host))
|
||||
#expect(draft.isProxyValid(for: host))
|
||||
|
||||
draft.sshUsername = "root"
|
||||
draft.proxyPassword = "new-proxy-secret"
|
||||
try draft.save(into: &host, using: library)
|
||||
|
||||
let savedSSH = try #require(library.customIdentity(for: host.id, purpose: .ssh))
|
||||
let savedProxy = try #require(library.customIdentity(for: host.id, purpose: .proxy))
|
||||
#expect(savedSSH.id == ssh.id)
|
||||
#expect(savedSSH.ownerHostID == host.id)
|
||||
#expect(savedSSH.customPurpose == .ssh)
|
||||
#expect(savedSSH.username == "root")
|
||||
#expect(savedProxy.id == proxy.id)
|
||||
#expect(savedProxy.ownerHostID == host.id)
|
||||
#expect(savedProxy.customPurpose == .proxy)
|
||||
#expect(try library.editablePassword(for: savedProxy) == "new-proxy-secret")
|
||||
#expect(library.host(host.id)?.identityID == savedSSH.id)
|
||||
#expect(library.host(host.id)?.proxy?.identityID == savedProxy.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reusableSelectionsRetainCustomIdentitiesUntilTheirOwnerOrProxyIsRemoved() throws {
|
||||
var host = Host(
|
||||
name: "Server", host: "server.invalid",
|
||||
proxy: HostProxy(kind: .socks5, hostname: "proxy.invalid", port: 1080))
|
||||
let reusable = Identity(name: "Reusable", username: "shared")
|
||||
let library = HostLibraryModel(hosts: [host], identities: [reusable])
|
||||
let ssh = try #require(
|
||||
try library.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
|
||||
username: "deploy", password: "", keyID: nil))
|
||||
let proxy = try #require(
|
||||
try library.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .proxy, displayName: "Server Proxy",
|
||||
username: "proxy-user", password: "secret", keyID: nil))
|
||||
host.identityID = ssh.id
|
||||
host.proxy?.identityID = proxy.id
|
||||
host.proxy?.username = proxy.username
|
||||
|
||||
var draft = HostCustomIdentityDraft(host: host, library: library)
|
||||
draft.selectSSHIdentity(reusable, on: &host)
|
||||
draft.selectProxyIdentity(reusable, on: &host)
|
||||
try draft.save(into: &host, using: library)
|
||||
#expect(library.customIdentity(for: host.id, purpose: .ssh)?.id == ssh.id)
|
||||
#expect(library.customIdentity(for: host.id, purpose: .proxy)?.id == proxy.id)
|
||||
#expect(library.host(host.id)?.identityID == reusable.id)
|
||||
#expect(library.host(host.id)?.proxy?.identityID == reusable.id)
|
||||
|
||||
host.proxy = nil
|
||||
try draft.save(into: &host, using: library)
|
||||
#expect(library.customIdentity(for: host.id, purpose: .proxy) == nil)
|
||||
#expect(library.customIdentity(for: host.id, purpose: .ssh)?.id == ssh.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
func unavailableProxyPasswordAllowsMetadataEditingButNotPasswordEditing() throws {
|
||||
let hostID = UUID()
|
||||
let proxy = Identity(
|
||||
name: "Proxy", username: "proxy-user", credentialRef: "missing-password",
|
||||
passwordStorage: .thisDevice, ownerHostID: hostID, customPurpose: .proxy)
|
||||
var host = Host(
|
||||
id: hostID, name: "Server", host: "server.invalid",
|
||||
proxy: HostProxy(
|
||||
kind: .http, hostname: "proxy.invalid", port: 8080,
|
||||
username: proxy.username, identityID: proxy.id))
|
||||
let library = HostLibraryModel(
|
||||
hosts: [host], identities: [proxy], secrets: InMemorySecretStore())
|
||||
var draft = HostCustomIdentityDraft(host: host, library: library)
|
||||
|
||||
#expect(draft.usesCustomProxy)
|
||||
#expect(!draft.proxyPasswordIsEditable)
|
||||
#expect(draft.proxyPassword.isEmpty)
|
||||
#expect(draft.isProxyValid(for: host))
|
||||
|
||||
host.proxy?.hostname = "renamed-proxy.invalid"
|
||||
try draft.save(into: &host, using: library)
|
||||
#expect(library.host(host.id)?.proxy?.hostname == "renamed-proxy.invalid")
|
||||
#expect(library.customIdentity(for: host.id, purpose: .proxy)?.credentialRef == "missing-password")
|
||||
}
|
||||
|
||||
@Test
|
||||
func emptyCustomAuthenticationIsValidAndPersistsNoIdentity() throws {
|
||||
var host = Host(
|
||||
name: "Server", host: "server.invalid",
|
||||
proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080))
|
||||
let library = HostLibraryModel(hosts: [host])
|
||||
var draft = HostCustomIdentityDraft(host: host, library: library)
|
||||
draft.selectCustomSSH(on: &host)
|
||||
draft.selectCustomProxy(on: &host, using: library)
|
||||
|
||||
#expect(draft.isSSHValid(for: host))
|
||||
#expect(draft.isProxyValid(for: host))
|
||||
try draft.save(into: &host, using: library)
|
||||
#expect(library.customIdentity(for: host.id, purpose: .ssh) == nil)
|
||||
#expect(library.customIdentity(for: host.id, purpose: .proxy) == nil)
|
||||
#expect(library.host(host.id)?.identityID == nil)
|
||||
#expect(library.host(host.id)?.proxy?.identityID == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
/// Memory-only Custom Identity fields shared by the native Host editors.
|
||||
///
|
||||
/// Passwords live here only for the lifetime of an editor. This value is never
|
||||
/// persisted, synchronized, logged, or given a textual representation.
|
||||
@MainActor
|
||||
public struct HostCustomIdentityDraft {
|
||||
public var sshUsername = ""
|
||||
public var sshPassword = ""
|
||||
public var sshKeyID: SSHKeyRef.ID?
|
||||
public var usesCustomProxy = false
|
||||
public var proxyPassword = ""
|
||||
|
||||
private var sshIdentityID: Identity.ID?
|
||||
private var proxyIdentityID: Identity.ID?
|
||||
private var sshHasConfiguredPassword = false
|
||||
private var proxyHasConfiguredPassword = false
|
||||
private var sshPasswordAvailability: SecretAvailability = .notConfigured
|
||||
private var proxyPasswordAvailability: SecretAvailability = .notConfigured
|
||||
|
||||
public init() {}
|
||||
|
||||
public init(host: Host, library: HostLibraryModel) {
|
||||
let ssh = library.customIdentity(for: host.id, purpose: .ssh)
|
||||
sshIdentityID = ssh?.id
|
||||
sshUsername = ssh?.username ?? ""
|
||||
sshKeyID = ssh?.keyID
|
||||
sshHasConfiguredPassword = ssh?.usesPassword == true
|
||||
if let ssh {
|
||||
sshPasswordAvailability = library.passwordAvailability(for: ssh)
|
||||
if sshPasswordAvailability == .available {
|
||||
sshPassword = (try? library.editablePassword(for: ssh)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
let proxy = library.customIdentity(for: host.id, purpose: .proxy)
|
||||
proxyIdentityID = proxy?.id
|
||||
proxyHasConfiguredPassword = proxy?.usesPassword == true
|
||||
if let proxy {
|
||||
proxyPasswordAvailability = library.passwordAvailability(for: proxy)
|
||||
if host.proxy?.identityID == proxy.id {
|
||||
usesCustomProxy = true
|
||||
if proxyPasswordAvailability == .available {
|
||||
proxyPassword = (try? library.editablePassword(for: proxy)) ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var sshPasswordIsEditable: Bool {
|
||||
!sshHasConfiguredPassword || sshPasswordAvailability == .available
|
||||
}
|
||||
|
||||
public var proxyPasswordIsEditable: Bool {
|
||||
!proxyHasConfiguredPassword || proxyPasswordAvailability == .available
|
||||
}
|
||||
|
||||
public func usesCustomSSH(for host: Host) -> Bool {
|
||||
guard let identityID = host.identityID else { return true }
|
||||
return identityID == sshIdentityID
|
||||
}
|
||||
|
||||
public func selectCustomSSH(on host: inout Host) {
|
||||
host.identityID = sshIdentityID
|
||||
}
|
||||
|
||||
public func selectSSHIdentity(_ identity: Identity, on host: inout Host) {
|
||||
host.identityID = identity.id
|
||||
}
|
||||
|
||||
public mutating func clearProxyIdentity(on host: inout Host) {
|
||||
usesCustomProxy = false
|
||||
host.proxy?.identityID = nil
|
||||
host.proxy?.username = ""
|
||||
}
|
||||
|
||||
public mutating func selectCustomProxy(on host: inout Host, using library: HostLibraryModel) {
|
||||
usesCustomProxy = true
|
||||
let identity = library.customIdentity(for: host.id, purpose: .proxy)
|
||||
proxyIdentityID = identity?.id
|
||||
proxyHasConfiguredPassword = identity?.usesPassword == true
|
||||
proxyPasswordAvailability = identity.map(library.passwordAvailability) ?? .notConfigured
|
||||
host.proxy?.identityID = identity?.id
|
||||
host.proxy?.username = identity?.username ?? ""
|
||||
proxyPassword = ""
|
||||
if let identity, proxyPasswordAvailability == .available {
|
||||
proxyPassword = (try? library.editablePassword(for: identity)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func selectProxyIdentity(_ identity: Identity, on host: inout Host) {
|
||||
usesCustomProxy = false
|
||||
host.proxy?.identityID = identity.id
|
||||
host.proxy?.username = identity.username
|
||||
}
|
||||
|
||||
public func isSSHValid(for host: Host) -> Bool {
|
||||
!usesCustomSSH(for: host)
|
||||
|| (!sshUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| (sshPassword.isEmpty && sshKeyID == nil))
|
||||
}
|
||||
|
||||
public func isProxyValid(for host: Host) -> Bool {
|
||||
guard let proxy = host.proxy else { return true }
|
||||
guard usesCustomProxy else { return proxy.isValid }
|
||||
|
||||
let username = proxy.username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hasPassword = !proxyPassword.isEmpty || proxyHasConfiguredPassword
|
||||
let hasAuthentication = !username.isEmpty || hasPassword
|
||||
return !proxy.hostname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& (1...65_535).contains(proxy.port)
|
||||
&& (!hasAuthentication || (!username.isEmpty && hasPassword))
|
||||
}
|
||||
|
||||
public mutating func save(into host: inout Host, using library: HostLibraryModel) throws {
|
||||
if usesCustomSSH(for: host) {
|
||||
let identity = try library.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
|
||||
username: sshUsername, password: sshPassword, keyID: sshKeyID)
|
||||
host.identityID = identity?.id
|
||||
sshIdentityID = identity?.id
|
||||
sshHasConfiguredPassword = identity?.usesPassword == true
|
||||
sshPasswordAvailability = identity.map(library.passwordAvailability) ?? .notConfigured
|
||||
}
|
||||
|
||||
if usesCustomProxy, let proxy = host.proxy {
|
||||
let identity = try library.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .proxy, displayName: "\(host.name) Proxy",
|
||||
username: proxy.username, password: proxyPassword, keyID: nil)
|
||||
host.proxy?.identityID = identity?.id
|
||||
proxyIdentityID = identity?.id
|
||||
proxyHasConfiguredPassword = identity?.usesPassword == true
|
||||
proxyPasswordAvailability = identity.map(library.passwordAvailability) ?? .notConfigured
|
||||
} else if host.proxy == nil {
|
||||
_ = try library.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .proxy, displayName: "",
|
||||
username: "", password: "", keyID: nil)
|
||||
proxyIdentityID = nil
|
||||
proxyHasConfiguredPassword = false
|
||||
proxyPasswordAvailability = .notConfigured
|
||||
}
|
||||
|
||||
guard library.saveHost(host) else {
|
||||
throw TermindSSHError.library(
|
||||
library.storageError ?? String(localized: "The host could not be saved."))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,3 +257,23 @@ never justifies extraction. Keep schemas, native lists/terminal teardown and loc
|
||||
- **Verification:** macOS and generic iOS Simulator builds passed. Fifty-three focused authentication, Agent and
|
||||
authorized-Key tests passed; all 381 Framework tests passed. The default macOS run finished with 1,792 passed, 64
|
||||
opt-in skips and no failures. Changed-file strict formatting, diff checks and Chinese localization passed.
|
||||
|
||||
### 2026-09-21 — shared Host Custom Identity editing (complete)
|
||||
|
||||
- **Evidence:** `HostEditorView` and `MobileHostEditor` independently retain and load the same Custom SSH/Proxy Identity
|
||||
fields, decide password editability, validate the same authentication combinations and perform the same Identity-before-
|
||||
Host save sequence. Host deletion already filters projected and persistent identities by `ownerHostID`, but its tests cover
|
||||
only one SSH Identity through the projection helper rather than both purposes through app and outbox entry points.
|
||||
- **Action:** introduce one Application-owned, memory-only Custom Identity draft for both native Host editors; retain their
|
||||
platform controls, routes and navigation. Add end-to-end deletion coverage for SSH and proxy Custom Identities, their
|
||||
passwords and unrelated Identity retention, and remove the inaccurate `legacy` prefix from the strict envelope test name.
|
||||
- **Expected reduction:** delete the duplicated Host-editor loading, validation and saving helpers. Record the measured
|
||||
production-line change and focused/full verification after implementation; no reduction claim is made while in progress.
|
||||
- **Result:** one Application-owned `HostCustomIdentityDraft` now serves both native editors without persisting or describing
|
||||
plaintext. It centralizes selection, availability, validation and Identity-before-Host saves, including metadata edits with
|
||||
unavailable proxy passwords. Host deletion tests now cover both Custom purposes, Keychain failure reporting, app entry points
|
||||
and selective SwiftData finalization. Production changed +204/−220 lines (net −16); no schema or localization changed.
|
||||
- **Verification:** macOS focused Identity/draft/outbox/envelope suites and the 25-test mobile Vault suite passed; TFM and generic
|
||||
iOS Simulator builds passed. The default macOS run recorded 1,831 passed, 64 opt-in skips and two unrelated concurrent Agent
|
||||
timing failures; both complete Agent suites passed immediately on focused rerun (35/35). Recursive strict formatting, diff
|
||||
checks and Chinese localization checks passed.
|
||||
@@ -26,11 +26,7 @@ struct MobileHostEditor: View {
|
||||
let original: Host
|
||||
@State private var draft: Host
|
||||
@State private var editorRoute: EditorRoute?
|
||||
@State private var customUsername = ""
|
||||
@State private var customPassword = ""
|
||||
@State private var customKeyID: SSHKeyRef.ID?
|
||||
@State private var proxyUsesCustom = false
|
||||
@State private var proxyCustomPassword = ""
|
||||
@State private var customIdentity = HostCustomIdentityDraft()
|
||||
@State private var didLoadCustom = false
|
||||
|
||||
init(app: MobileAppModel, host: Host) {
|
||||
@@ -42,27 +38,14 @@ struct MobileHostEditor: View {
|
||||
var body: some View {
|
||||
MobileEditorSheet(
|
||||
title: String(localized: "Host"),
|
||||
isDirty: draft != original || !customUsername.isEmpty || !customPassword.isEmpty || customKeyID != nil,
|
||||
canSave: MobileHostEditorPolicy.canSave(draft) && customIsValid && proxyIsValid,
|
||||
isDirty: draft != original || !customIdentity.sshUsername.isEmpty
|
||||
|| !customIdentity.sshPassword.isEmpty || customIdentity.sshKeyID != nil,
|
||||
canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& (1...65_535).contains(draft.port)
|
||||
&& customIdentity.isSSHValid(for: draft)
|
||||
&& customIdentity.isProxyValid(for: draft),
|
||||
save: {
|
||||
if usesCustomIdentity {
|
||||
let identity = try library.saveCustomIdentity(
|
||||
ownerHostID: draft.id, purpose: .ssh, displayName: draft.name,
|
||||
username: customUsername, password: customPassword, keyID: customKeyID)
|
||||
draft.identityID = identity?.id
|
||||
}
|
||||
if proxyUsesCustom, let proxy = draft.proxy {
|
||||
let identity = try library.saveCustomIdentity(
|
||||
ownerHostID: draft.id, purpose: .proxy, displayName: "\(draft.name) Proxy",
|
||||
username: proxy.username, password: proxyCustomPassword, keyID: nil)
|
||||
draft.proxy?.identityID = identity?.id
|
||||
} else if draft.proxy == nil {
|
||||
_ = try library.saveCustomIdentity(
|
||||
ownerHostID: draft.id, purpose: .proxy, displayName: "",
|
||||
username: "", password: "", keyID: nil)
|
||||
}
|
||||
library.upsert(draft)
|
||||
if let error = library.storageError { throw MobileEditorError.message(error) }
|
||||
try customIdentity.save(into: &draft, using: library)
|
||||
},
|
||||
content: {
|
||||
HostConnectionFields(host: $draft)
|
||||
@@ -78,16 +61,16 @@ struct MobileHostEditor: View {
|
||||
}
|
||||
if usesCustomIdentity {
|
||||
LabeledContent("Username") {
|
||||
TextField("Username", text: $customUsername.singleLineText)
|
||||
TextField("Username", text: $customIdentity.sshUsername.singleLineText)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
LabeledContent("Password") {
|
||||
RevealableSecretField("Optional", text: $customPassword)
|
||||
RevealableSecretField("Optional", text: $customIdentity.sshPassword)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.disabled(!customPasswordEditable)
|
||||
.disabled(!customIdentity.sshPasswordIsEditable)
|
||||
}
|
||||
Picker("Key", selection: $customKeyID) {
|
||||
Picker("Key", selection: $customIdentity.sshKeyID) {
|
||||
Text("None").tag(SSHKeyRef.ID?.none)
|
||||
ForEach(library.keys) { key in Text(key.label).tag(SSHKeyRef.ID?.some(key.id)) }
|
||||
}
|
||||
@@ -133,16 +116,16 @@ struct MobileHostEditor: View {
|
||||
Text("New Identity").tag(Choice<Identity.ID>.new)
|
||||
}
|
||||
.accessibilityLabel("Proxy Identity")
|
||||
if proxyUsesCustom {
|
||||
if customIdentity.usesCustomProxy {
|
||||
LabeledContent("Username") {
|
||||
TextField("Username", text: proxyString(\.username).singleLineText)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
LabeledContent("Password") {
|
||||
RevealableSecretField("Required", text: $proxyCustomPassword)
|
||||
RevealableSecretField("Required", text: $customIdentity.proxyPassword)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.disabled(!proxyCustomPasswordEditable)
|
||||
.disabled(!customIdentity.proxyPasswordIsEditable)
|
||||
}
|
||||
} else if draft.proxy?.identityID != nil {
|
||||
LabeledContent("Username") {
|
||||
@@ -192,7 +175,7 @@ struct MobileHostEditor: View {
|
||||
name: "", username: "",
|
||||
passwordStorage: app.newVaultPasswordStorage)
|
||||
) { identity in
|
||||
draft = MobileHostEditorPolicy.selectIdentity(identity, current: draft)
|
||||
customIdentity.selectSSHIdentity(identity, on: &draft)
|
||||
}
|
||||
case .proxyIdentity:
|
||||
MobileIdentityEditor(
|
||||
@@ -202,7 +185,7 @@ struct MobileHostEditor: View {
|
||||
passwordStorage: app.newVaultPasswordStorage),
|
||||
configuration: .proxy
|
||||
) { identity in
|
||||
draft.proxy = MobileHostEditorPolicy.selectProxyIdentity(identity, current: draft.proxy)
|
||||
customIdentity.selectProxyIdentity(identity, on: &draft)
|
||||
}
|
||||
case .group:
|
||||
MobileHostGroupEditor(app: app, group: nil, parentID: nil) { group in
|
||||
@@ -220,8 +203,10 @@ struct MobileHostEditor: View {
|
||||
} set: { choice in
|
||||
switch choice {
|
||||
case .unset, .custom:
|
||||
draft.identityID = library.customIdentity(for: draft.id, purpose: .ssh)?.id
|
||||
case .existing(let id): draft.identityID = id
|
||||
customIdentity.selectCustomSSH(on: &draft)
|
||||
case .existing(let id):
|
||||
guard let identity = library.identity(id) else { return }
|
||||
customIdentity.selectSSHIdentity(identity, on: &draft)
|
||||
case .new: editorRoute = .hostIdentity
|
||||
}
|
||||
}
|
||||
@@ -271,21 +256,17 @@ struct MobileHostEditor: View {
|
||||
|
||||
private var proxyIdentityChoice: Binding<Choice<Identity.ID>> {
|
||||
Binding {
|
||||
if proxyUsesCustom { return .custom }
|
||||
if customIdentity.usesCustomProxy { return .custom }
|
||||
return draft.proxy?.identityID.map(Choice.existing) ?? .unset
|
||||
} set: { choice in
|
||||
switch choice {
|
||||
case .unset:
|
||||
proxyUsesCustom = false
|
||||
draft.proxy = MobileHostEditorPolicy.selectProxyIdentity(nil, current: draft.proxy)
|
||||
customIdentity.clearProxyIdentity(on: &draft)
|
||||
case .custom:
|
||||
proxyUsesCustom = true
|
||||
let custom = library.customIdentity(for: draft.id, purpose: .proxy)
|
||||
draft.proxy = MobileHostEditorPolicy.selectProxyIdentity(custom, current: draft.proxy)
|
||||
loadProxyPassword(custom)
|
||||
customIdentity.selectCustomProxy(on: &draft, using: library)
|
||||
case .existing(let id):
|
||||
proxyUsesCustom = false
|
||||
draft.proxy = MobileHostEditorPolicy.selectProxyIdentity(library.identity(id), current: draft.proxy)
|
||||
guard let identity = library.identity(id) else { return }
|
||||
customIdentity.selectProxyIdentity(identity, on: &draft)
|
||||
case .new:
|
||||
editorRoute = .proxyIdentity
|
||||
}
|
||||
@@ -297,81 +278,18 @@ struct MobileHostEditor: View {
|
||||
}
|
||||
|
||||
private var usesCustomIdentity: Bool {
|
||||
guard let id = draft.identityID else { return true }
|
||||
return library.identity(id)?.isHostCustom == true
|
||||
}
|
||||
|
||||
private var customPasswordEditable: Bool {
|
||||
guard let identity = library.customIdentity(for: draft.id, purpose: .ssh), identity.usesPassword else {
|
||||
return true
|
||||
}
|
||||
return library.passwordAvailability(for: identity) == .available
|
||||
}
|
||||
|
||||
private var proxyCustomPasswordEditable: Bool {
|
||||
guard let identity = library.customIdentity(for: draft.id, purpose: .proxy), identity.usesPassword else {
|
||||
return true
|
||||
}
|
||||
return library.passwordAvailability(for: identity) == .available
|
||||
}
|
||||
|
||||
private var customIsValid: Bool {
|
||||
!usesCustomIdentity
|
||||
|| (!customUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| (customPassword.isEmpty && customKeyID == nil))
|
||||
}
|
||||
|
||||
private var proxyIsValid: Bool {
|
||||
guard let proxy = draft.proxy else { return true }
|
||||
guard proxyUsesCustom else { return proxy.isValid }
|
||||
let empty =
|
||||
proxy.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& proxyCustomPassword.isEmpty
|
||||
return !proxy.hostname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& (1...65_535).contains(proxy.port)
|
||||
&& (empty
|
||||
|| (!proxy.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !proxyCustomPassword.isEmpty))
|
||||
customIdentity.usesCustomSSH(for: draft)
|
||||
}
|
||||
|
||||
private func loadCustomOnce() {
|
||||
guard !didLoadCustom else { return }
|
||||
didLoadCustom = true
|
||||
let custom = library.customIdentity(for: draft.id, purpose: .ssh)
|
||||
customUsername = custom?.username ?? ""
|
||||
customKeyID = custom?.keyID
|
||||
if let custom, case .available = library.passwordAvailability(for: custom) {
|
||||
customPassword = (try? library.editablePassword(for: custom)) ?? ""
|
||||
}
|
||||
if let custom = library.customIdentity(for: draft.id, purpose: .proxy),
|
||||
draft.proxy?.identityID == custom.id
|
||||
{
|
||||
proxyUsesCustom = true
|
||||
loadProxyPassword(custom)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProxyPassword(_ identity: Identity?) {
|
||||
proxyCustomPassword = ""
|
||||
guard let identity, case .available = library.passwordAvailability(for: identity) else { return }
|
||||
proxyCustomPassword = (try? library.editablePassword(for: identity)) ?? ""
|
||||
customIdentity = HostCustomIdentityDraft(host: draft, library: library)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
nonisolated enum MobileHostEditorPolicy {
|
||||
static func selectIdentity(_ identity: Identity, current: Host) -> Host {
|
||||
var host = current
|
||||
host.identityID = identity.id
|
||||
return host
|
||||
}
|
||||
|
||||
static func canSave(_ host: Host) -> Bool {
|
||||
!host.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& (1...65_535).contains(host.port)
|
||||
&& (host.proxy?.isValid ?? true)
|
||||
}
|
||||
|
||||
static func selectProxy(
|
||||
_ kind: HostProxy.ProtocolKind?, current: HostProxy?
|
||||
) -> HostProxy? {
|
||||
@@ -387,12 +305,6 @@ nonisolated enum MobileHostEditorPolicy {
|
||||
return proxy
|
||||
}
|
||||
|
||||
static func selectProxyIdentity(_ identity: Identity?, current: HostProxy?) -> HostProxy? {
|
||||
guard var proxy = current else { return nil }
|
||||
proxy.identityID = identity?.id
|
||||
proxy.username = identity?.username ?? ""
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated enum MobileEditorError: LocalizedError {
|
||||
|
||||
@@ -178,16 +178,18 @@ struct MobileVaultFeatureTests {
|
||||
|
||||
@Test func hostProxyEditorAppliesDefaultsPreservesCustomPortsAndValidatesAuthentication() {
|
||||
var host = TermindDomain.Host(name: "Remote", host: "example.invalid")
|
||||
#expect(MobileHostEditorPolicy.canSave(host))
|
||||
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(!MobileHostEditorPolicy.canSave(host))
|
||||
#expect(!identityDraft.isProxyValid(for: host))
|
||||
|
||||
host.proxy?.hostname = "proxy.example"
|
||||
#expect(MobileHostEditorPolicy.canSave(host))
|
||||
#expect(identityDraft.isProxyValid(for: host))
|
||||
host.proxy?.port = 0
|
||||
#expect(!MobileHostEditorPolicy.canSave(host))
|
||||
#expect(!identityDraft.isProxyValid(for: host))
|
||||
host.proxy?.port = 8080
|
||||
host.proxy = MobileHostEditorPolicy.selectProxy(.socks5, current: host.proxy)
|
||||
#expect(host.proxy?.port == 1080)
|
||||
@@ -199,17 +201,17 @@ struct MobileVaultFeatureTests {
|
||||
|
||||
let identity = Identity(
|
||||
name: "Proxy", username: "proxy-user", credentialRef: "termind.password.proxy")
|
||||
host.proxy = MobileHostEditorPolicy.selectProxyIdentity(identity, current: host.proxy)
|
||||
identityDraft.selectProxyIdentity(identity, on: &host)
|
||||
#expect(host.proxy?.identityID == identity.id)
|
||||
#expect(host.proxy?.username == "proxy-user")
|
||||
#expect(MobileHostEditorPolicy.canSave(host))
|
||||
#expect(identityDraft.isProxyValid(for: host))
|
||||
|
||||
host.proxy?.username = ""
|
||||
#expect(!MobileHostEditorPolicy.canSave(host))
|
||||
host.proxy = MobileHostEditorPolicy.selectProxyIdentity(nil, current: host.proxy)
|
||||
#expect(!identityDraft.isProxyValid(for: host))
|
||||
identityDraft.clearProxyIdentity(on: &host)
|
||||
#expect(host.proxy?.identityID == nil)
|
||||
#expect(host.proxy?.username.isEmpty == true)
|
||||
#expect(MobileHostEditorPolicy.canSave(host))
|
||||
#expect(identityDraft.isProxyValid(for: host))
|
||||
|
||||
host.proxy = MobileHostEditorPolicy.selectProxy(nil, current: host.proxy)
|
||||
#expect(host.proxy == nil)
|
||||
@@ -296,9 +298,10 @@ struct MobileVaultFeatureTests {
|
||||
#expect(host.identityID == originalIdentity.id)
|
||||
#expect(host.proxy?.identityID == originalIdentity.id)
|
||||
|
||||
host = MobileHostEditorPolicy.selectIdentity(createdIdentity, current: host)
|
||||
var identityDraft = HostCustomIdentityDraft()
|
||||
identityDraft.selectSSHIdentity(createdIdentity, on: &host)
|
||||
#expect(host.identityID == createdIdentity.id)
|
||||
host.proxy = MobileHostEditorPolicy.selectProxyIdentity(createdIdentity, current: host.proxy)
|
||||
identityDraft.selectProxyIdentity(createdIdentity, on: &host)
|
||||
#expect(host.proxy?.identityID == createdIdentity.id)
|
||||
#expect(host.proxy?.username == createdIdentity.username)
|
||||
}
|
||||
@@ -612,12 +615,37 @@ struct MobileVaultFeatureTests {
|
||||
@Test func deletingAHostCompletesDurableCleanup() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
defer { app.outbox.stop() }
|
||||
let host = TermindDomain.Host(name: "Remote", host: "example.invalid")
|
||||
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)
|
||||
@@ -632,6 +660,7 @@ struct MobileVaultFeatureTests {
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
@@ -25,11 +26,7 @@ struct HostEditorView: View {
|
||||
@State private var route: Route?
|
||||
@State private var saveError: String?
|
||||
@State private var didLoad = false
|
||||
@State private var customUsername = ""
|
||||
@State private var customPassword = ""
|
||||
@State private var customKeyID: SSHKeyRef.ID?
|
||||
@State private var proxyUsesCustom = false
|
||||
@State private var proxyCustomPassword = ""
|
||||
@State private var customIdentity = HostCustomIdentityDraft()
|
||||
|
||||
init(hostID: Host.ID?, initialGroupID: HostGroup.ID? = nil) {
|
||||
self.hostID = hostID
|
||||
@@ -93,13 +90,13 @@ struct HostEditorView: View {
|
||||
}
|
||||
|
||||
if usesCustomIdentity {
|
||||
TextField("Username", text: $customUsername.singleLineText, axis: .horizontal)
|
||||
TextField("Username", text: $customIdentity.sshUsername.singleLineText, axis: .horizontal)
|
||||
.singleLinePaste().lineLimit(1)
|
||||
.padding(.leading, Metrics.Space.xxl)
|
||||
RevealableSecretField("Password", text: $customPassword, prompt: Text("Optional"))
|
||||
RevealableSecretField("Password", text: $customIdentity.sshPassword, prompt: Text("Optional"))
|
||||
.padding(.leading, Metrics.Space.xxl)
|
||||
.disabled(!customPasswordEditable)
|
||||
Picker("Key", selection: $customKeyID) {
|
||||
.disabled(!customIdentity.sshPasswordIsEditable)
|
||||
Picker("Key", selection: $customIdentity.sshKeyID) {
|
||||
Text("None").tag(SSHKeyRef.ID?.none)
|
||||
ForEach(app.hostLibrary.keys) { key in
|
||||
Text(key.label).tag(SSHKeyRef.ID?.some(key.id))
|
||||
@@ -155,14 +152,14 @@ struct HostEditorView: View {
|
||||
}
|
||||
.padding(.leading, Metrics.Space.xxl)
|
||||
.accessibilityLabel("Proxy Identity")
|
||||
if proxyUsesCustom {
|
||||
if customIdentity.usesCustomProxy {
|
||||
TextField("Username", text: proxyString(\.username).singleLineText, axis: .horizontal)
|
||||
.singleLinePaste().lineLimit(1)
|
||||
.padding(.leading, Metrics.Space.xxl)
|
||||
.accessibilityLabel("Proxy Username")
|
||||
RevealableSecretField("Password", text: $proxyCustomPassword, prompt: Text("Required"))
|
||||
RevealableSecretField("Password", text: $customIdentity.proxyPassword, prompt: Text("Required"))
|
||||
.padding(.leading, Metrics.Space.xxl)
|
||||
.disabled(!proxyCustomPasswordEditable)
|
||||
.disabled(!customIdentity.proxyPasswordIsEditable)
|
||||
} else if draft.proxy?.identityID != nil {
|
||||
TextField("Username", text: proxyString(\.username).singleLineText, axis: .horizontal)
|
||||
.singleLinePaste()
|
||||
@@ -285,8 +282,7 @@ struct HostEditorView: View {
|
||||
) {
|
||||
identity, passwordUpdate in
|
||||
try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate)
|
||||
draft.proxy?.identityID = identity.id
|
||||
draft.proxy?.username = identity.username
|
||||
customIdentity.selectProxyIdentity(identity, on: &draft)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -325,8 +321,10 @@ struct HostEditorView: View {
|
||||
} set: { choice in
|
||||
switch choice {
|
||||
case .unset, .custom:
|
||||
draft.identityID = app.hostLibrary.customIdentity(for: draft.id, purpose: .ssh)?.id
|
||||
case .existing(let id): draft.identityID = id
|
||||
customIdentity.selectCustomSSH(on: &draft)
|
||||
case .existing(let id):
|
||||
guard let identity = app.hostLibrary.identity(id) else { return }
|
||||
customIdentity.selectSSHIdentity(identity, on: &draft)
|
||||
case .new: route = .identity
|
||||
}
|
||||
}
|
||||
@@ -443,25 +441,17 @@ struct HostEditorView: View {
|
||||
|
||||
private var proxyIdentityChoice: Binding<Choice<Identity.ID>> {
|
||||
Binding {
|
||||
if proxyUsesCustom { return .custom }
|
||||
if customIdentity.usesCustomProxy { return .custom }
|
||||
return draft.proxy?.identityID.map(Choice.existing) ?? .unset
|
||||
} set: { choice in
|
||||
switch choice {
|
||||
case .unset:
|
||||
proxyUsesCustom = false
|
||||
draft.proxy?.identityID = nil
|
||||
draft.proxy?.username = ""
|
||||
customIdentity.clearProxyIdentity(on: &draft)
|
||||
case .custom:
|
||||
proxyUsesCustom = true
|
||||
let custom = app.hostLibrary.customIdentity(for: draft.id, purpose: .proxy)
|
||||
draft.proxy?.identityID = custom?.id
|
||||
draft.proxy?.username = custom?.username ?? ""
|
||||
loadProxyPassword(custom)
|
||||
customIdentity.selectCustomProxy(on: &draft, using: app.hostLibrary)
|
||||
case .existing(let id):
|
||||
proxyUsesCustom = false
|
||||
draft.proxy?.identityID = id
|
||||
guard let identity = app.hostLibrary.identity(id) else { return }
|
||||
draft.proxy?.username = identity.username
|
||||
customIdentity.selectProxyIdentity(identity, on: &draft)
|
||||
case .new:
|
||||
route = .proxyIdentity
|
||||
}
|
||||
@@ -473,20 +463,7 @@ struct HostEditorView: View {
|
||||
}
|
||||
|
||||
private var usesCustomIdentity: Bool {
|
||||
guard let id = draft.identityID else { return true }
|
||||
return app.hostLibrary.identity(id)?.isHostCustom == true
|
||||
}
|
||||
|
||||
private var customPasswordEditable: Bool {
|
||||
guard let identity = app.hostLibrary.customIdentity(for: draft.id, purpose: .ssh), identity.usesPassword
|
||||
else { return true }
|
||||
return app.hostLibrary.passwordAvailability(for: identity) == .available
|
||||
}
|
||||
|
||||
private var proxyCustomPasswordEditable: Bool {
|
||||
guard let identity = app.hostLibrary.customIdentity(for: draft.id, purpose: .proxy), identity.usesPassword
|
||||
else { return true }
|
||||
return app.hostLibrary.passwordAvailability(for: identity) == .available
|
||||
customIdentity.usesCustomSSH(for: draft)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
@@ -521,8 +498,8 @@ struct HostEditorView: View {
|
||||
private var isValid: Bool {
|
||||
!draft.host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& (1...65_535).contains(draft.port)
|
||||
&& proxyIsValid
|
||||
&& customIsValid
|
||||
&& customIdentity.isProxyValid(for: draft)
|
||||
&& customIdentity.isSSHValid(for: draft)
|
||||
&& (draft.sftpChannelInitialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| draft.sftpChannelInitialPath.hasPrefix("/"))
|
||||
&& app.hostLibrary.validatedRoute(for: draft) != nil
|
||||
@@ -537,18 +514,7 @@ struct HostEditorView: View {
|
||||
guard !didLoad else { return }
|
||||
didLoad = true
|
||||
load()
|
||||
let custom = app.hostLibrary.customIdentity(for: draft.id, purpose: .ssh)
|
||||
customUsername = custom?.username ?? ""
|
||||
customKeyID = custom?.keyID
|
||||
if let custom, case .available = app.hostLibrary.passwordAvailability(for: custom) {
|
||||
customPassword = (try? app.hostLibrary.editablePassword(for: custom)) ?? ""
|
||||
}
|
||||
if let custom = app.hostLibrary.customIdentity(for: draft.id, purpose: .proxy),
|
||||
draft.proxy?.identityID == custom.id
|
||||
{
|
||||
proxyUsesCustom = true
|
||||
loadProxyPassword(custom)
|
||||
}
|
||||
customIdentity = HostCustomIdentityDraft(host: draft, library: app.hostLibrary)
|
||||
}
|
||||
|
||||
/// No tag bookkeeping here: `TagField` writes chips straight into
|
||||
@@ -557,58 +523,12 @@ struct HostEditorView: View {
|
||||
let initialPath = draft.sftpChannelInitialPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
draft.sftpChannelInitialPath = initialPath.isEmpty ? "" : RemotePath.normalized(initialPath)
|
||||
do {
|
||||
if usesCustomIdentity {
|
||||
let identity = try app.hostLibrary.saveCustomIdentity(
|
||||
ownerHostID: draft.id, purpose: .ssh, displayName: draft.name,
|
||||
username: customUsername, password: customPassword, keyID: customKeyID)
|
||||
draft.identityID = identity?.id
|
||||
}
|
||||
if proxyUsesCustom, let proxy = draft.proxy {
|
||||
let identity = try app.hostLibrary.saveCustomIdentity(
|
||||
ownerHostID: draft.id, purpose: .proxy, displayName: "\(draft.name) Proxy",
|
||||
username: proxy.username, password: proxyCustomPassword, keyID: nil)
|
||||
draft.proxy?.identityID = identity?.id
|
||||
} else if draft.proxy == nil {
|
||||
_ = try app.hostLibrary.saveCustomIdentity(
|
||||
ownerHostID: draft.id, purpose: .proxy, displayName: "",
|
||||
username: "", password: "", keyID: nil)
|
||||
}
|
||||
try customIdentity.save(into: &draft, using: app.hostLibrary)
|
||||
} catch {
|
||||
saveError = error.localizedDescription
|
||||
return
|
||||
}
|
||||
if app.hostLibrary.saveHost(draft) {
|
||||
closeTab()
|
||||
} else {
|
||||
saveError = app.hostLibrary.storageError ?? String(localized: "The host could not be saved.")
|
||||
}
|
||||
}
|
||||
|
||||
private var customIsValid: Bool {
|
||||
!usesCustomIdentity
|
||||
|| (!customUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| (customPassword.isEmpty && customKeyID == nil))
|
||||
}
|
||||
|
||||
private var proxyIsValid: Bool {
|
||||
guard let proxy = draft.proxy else { return true }
|
||||
if proxyUsesCustom {
|
||||
let empty =
|
||||
proxy.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& proxyCustomPassword.isEmpty
|
||||
return !proxy.hostname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& (1...65_535).contains(proxy.port)
|
||||
&& (empty
|
||||
|| (!proxy.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !proxyCustomPassword.isEmpty))
|
||||
}
|
||||
return proxy.isValid
|
||||
}
|
||||
|
||||
private func loadProxyPassword(_ identity: Identity?) {
|
||||
proxyCustomPassword = ""
|
||||
guard let identity, case .available = app.hostLibrary.passwordAvailability(for: identity) else { return }
|
||||
proxyCustomPassword = (try? app.hostLibrary.editablePassword(for: identity)) ?? ""
|
||||
closeTab()
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
|
||||
@@ -37,7 +37,7 @@ struct AuthorizedKeyExportTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func legacyPrivateKeyEnvelopeRequiresACompleteJSONObject() throws {
|
||||
func privateKeyEnvelopeRequiresACompleteJSONObject() throws {
|
||||
var encoded = try JSONEncoder().encode(
|
||||
SSHPrivateKeySecret(privateKey: "-----BEGIN TEST-----\nsecret\n-----END TEST-----")
|
||||
)
|
||||
|
||||
@@ -21,17 +21,31 @@ struct IdentityCredentialTests {
|
||||
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
|
||||
defaults.set(true, forKey: SettingsKey.vaultSyncEnabled)
|
||||
let host = Host(name: "Server", host: "example.com")
|
||||
let store = HostLibraryModel(hosts: [host], secrets: keychain, localDefaults: defaults)
|
||||
let otherHost = Host(name: "Other", host: "other.example.com")
|
||||
let reusable = Identity(name: "Reusable", username: "shared")
|
||||
let store = HostLibraryModel(
|
||||
hosts: [host, otherHost], identities: [reusable], secrets: keychain,
|
||||
localDefaults: defaults)
|
||||
|
||||
let result = try store.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
|
||||
username: "deploy", password: "secret", keyID: nil)
|
||||
let saved = try #require(result)
|
||||
let proxy = try #require(
|
||||
try store.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .proxy, displayName: "Server Proxy",
|
||||
username: "proxy", password: "proxy-secret", keyID: nil))
|
||||
let retained = try #require(
|
||||
try store.saveCustomIdentity(
|
||||
ownerHostID: otherHost.id, purpose: .ssh, displayName: otherHost.name,
|
||||
username: "other", password: "other-secret", keyID: nil))
|
||||
#expect(saved.isHostCustom)
|
||||
#expect(saved.passwordStorage == .thisDevice)
|
||||
#expect(store.reusableIdentities.isEmpty)
|
||||
#expect(store.searchIdentities("").isEmpty)
|
||||
#expect(proxy.isHostCustom)
|
||||
#expect(store.reusableIdentities == [reusable])
|
||||
#expect(store.searchIdentities("") == [reusable])
|
||||
#expect(try keychain.string(for: saved.credentialRef, storage: .thisDevice) == "secret")
|
||||
#expect(try keychain.string(for: proxy.credentialRef, storage: .thisDevice) == "proxy-secret")
|
||||
|
||||
defaults.set(true, forKey: SettingsKey.iCloudSyncEnabled)
|
||||
try store.migrateCustomPasswordsToICloud()
|
||||
@@ -41,7 +55,75 @@ struct IdentityCredentialTests {
|
||||
|
||||
store.projectHostDeletion(host.id)
|
||||
#expect(store.identity(saved.id) == nil)
|
||||
#expect(store.identity(proxy.id) == nil)
|
||||
#expect(store.identity(reusable.id) == reusable)
|
||||
let retainedAfterDeletion = try #require(store.identity(retained.id))
|
||||
#expect(retainedAfterDeletion.ownerHostID == otherHost.id)
|
||||
#expect(try keychain.string(for: saved.credentialRef, storage: .iCloud) == nil)
|
||||
#expect(try keychain.string(for: proxy.credentialRef, storage: .iCloud) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func desktopHostDeletionRemovesBothCustomIdentityPurposesThroughTheAppEntryPoint() throws {
|
||||
let keychain = InMemorySecretStore()
|
||||
var host = Host(
|
||||
name: "Server", host: "server.invalid",
|
||||
proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080))
|
||||
let otherHost = Host(name: "Other", host: "other.invalid")
|
||||
let reusable = Identity(name: "Reusable", username: "shared")
|
||||
let store = HostLibraryModel(
|
||||
hosts: [host, otherHost], identities: [reusable], secrets: keychain)
|
||||
let app = AppModel(hosts: store)
|
||||
defer { app.shutdownForTermination() }
|
||||
let ssh = try #require(
|
||||
try store.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
|
||||
username: "deploy", password: "ssh-secret", keyID: nil))
|
||||
let proxy = try #require(
|
||||
try store.saveCustomIdentity(
|
||||
ownerHostID: host.id, purpose: .proxy, displayName: "Server Proxy",
|
||||
username: "proxy", password: "proxy-secret", keyID: nil))
|
||||
let retained = try #require(
|
||||
try store.saveCustomIdentity(
|
||||
ownerHostID: otherHost.id, purpose: .ssh, displayName: otherHost.name,
|
||||
username: "other", password: "other-secret", keyID: nil))
|
||||
host.identityID = ssh.id
|
||||
host.proxy?.identityID = proxy.id
|
||||
host.proxy?.username = proxy.username
|
||||
#expect(store.saveHost(host))
|
||||
|
||||
#expect(app.requestHostDeletion(host.id))
|
||||
#expect(store.identity(ssh.id) == nil)
|
||||
#expect(store.identity(proxy.id) == nil)
|
||||
#expect(store.identity(reusable.id) == reusable)
|
||||
#expect(store.identity(retained.id)?.ownerHostID == otherHost.id)
|
||||
#expect(try keychain.string(for: ssh.credentialRef, storage: ssh.passwordStorage) == nil)
|
||||
#expect(try keychain.string(for: proxy.credentialRef, storage: proxy.passwordStorage) == nil)
|
||||
#expect(
|
||||
try keychain.string(for: retained.credentialRef, storage: retained.passwordStorage)
|
||||
== "other-secret")
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedCustomPasswordRemovalReportsTheErrorWithoutRestoringTheDeletedHost() throws {
|
||||
let host = Host(name: "Server", host: "server.invalid")
|
||||
let identity = Identity(
|
||||
name: "Server", username: "deploy", credentialRef: "owned-password",
|
||||
passwordStorage: .thisDevice, ownerHostID: host.id, customPurpose: .ssh)
|
||||
let secrets = FailingPasswordRemovalStore()
|
||||
try secrets.base.setString(
|
||||
"secret", for: identity.credentialRef, storage: identity.passwordStorage)
|
||||
let store = HostLibraryModel(
|
||||
hosts: [host], identities: [identity], secrets: secrets)
|
||||
|
||||
store.projectHostDeletion(host.id)
|
||||
|
||||
#expect(store.host(host.id) == nil)
|
||||
#expect(store.identity(identity.id) == nil)
|
||||
#expect(store.storageError != nil)
|
||||
#expect(
|
||||
try secrets.base.string(
|
||||
for: identity.credentialRef, storage: identity.passwordStorage) == "secret")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -290,3 +372,33 @@ struct IdentityCredentialTests {
|
||||
.shouldRequestAuthentication)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated final class FailingPasswordRemovalStore: SecretStoring, @unchecked Sendable {
|
||||
let base = InMemorySecretStore()
|
||||
|
||||
func string(for account: String, storage: PasswordStorage) throws -> String? {
|
||||
try base.string(for: account, storage: storage)
|
||||
}
|
||||
|
||||
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
|
||||
try base.setString(value, for: account, storage: storage)
|
||||
}
|
||||
|
||||
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
|
||||
try base.keyCredential(for: account, storage: storage)
|
||||
}
|
||||
|
||||
func setKeyCredential(
|
||||
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
|
||||
) throws {
|
||||
try base.setKeyCredential(value, for: account, storage: storage)
|
||||
}
|
||||
|
||||
func removePassword(_ account: String, storage: PasswordStorage) throws {
|
||||
throw CocoaError(.fileWriteNoPermission)
|
||||
}
|
||||
|
||||
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
|
||||
try base.removeKeyCredential(account, storage: storage)
|
||||
}
|
||||
}
|
||||
@@ -178,8 +178,18 @@ struct RepositoryOutboxTests {
|
||||
var snippet = Snippet(title: "Deploy", body: "deploy")
|
||||
snippet.targetHostIDs = [doomed.id, dependent.id]
|
||||
let rule = PortForwardingRule(hostID: doomed.id)
|
||||
let doomedSSH = Identity(
|
||||
name: "Jump", username: "deploy", ownerHostID: doomed.id, customPurpose: .ssh)
|
||||
let doomedProxy = Identity(
|
||||
name: "Jump Proxy", username: "proxy", ownerHostID: doomed.id, customPurpose: .proxy)
|
||||
let reusable = Identity(name: "Reusable", username: "shared")
|
||||
let retainedCustom = Identity(
|
||||
name: "App", username: "app", ownerHostID: dependent.id, customPurpose: .ssh)
|
||||
context.insert(PersistentHost(doomed))
|
||||
context.insert(PersistentHost(dependent))
|
||||
for identity in [doomedSSH, doomedProxy, reusable, retainedCustom] {
|
||||
context.insert(PersistentIdentity(identity))
|
||||
}
|
||||
context.insert(PersistentSnippet(snippet))
|
||||
context.insert(PersistentPortForwardingRule(rule))
|
||||
context.insert(
|
||||
@@ -232,6 +242,10 @@ struct RepositoryOutboxTests {
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentSFTPHistoryEntry>()).isEmpty)
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>()).isEmpty)
|
||||
#expect(try context.fetch(FetchDescriptor<PersistentPendingDeletion>()).isEmpty)
|
||||
#expect(
|
||||
Set(try context.fetch(FetchDescriptor<PersistentIdentity>()).map(\.id))
|
||||
== [reusable.id, retainedCustom.id]
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user