Files
Termind/TFMTests/DesktopAuthenticationSubmissionTests.swift

208 lines
9.2 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindApplication
import TermindDomain
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite @MainActor
struct DesktopAuthenticationSubmissionTests {
@Test(arguments: [ConnectionHistoryEntry.Kind.ssh, .sftp])
func failedIdentitySaveRepromptsWithoutChangingTheLibrary(kind: ConnectionHistoryEntry.Kind) throws {
let key = protectedKey()
let identity = Identity(
name: "Unavailable", username: "deploy", credentialRef: "missing-password", keyID: key.id)
let host = Host(name: "Server", host: "server.invalid")
let store = HostLibraryModel(
hosts: [host], keys: [key], identities: [identity], secrets: InMemorySecretStore())
let session = DesktopAuthenticationSession(kind: kind, host: host)
defer { session.disconnect() }
session.ssh?.connect(using: store)
session.sftp?.connect(using: store)
session.submit(
identityID: identity.id, username: "ignored", password: "replacement",
keyID: nil, save: true, store: store)
let prompt = try #require(session.prompt)
#expect(prompt.failureMessage?.isEmpty == false)
#expect(store.host(host.id) == host)
#expect(store.identity(identity.id) == identity)
if let ssh = session.ssh {
#expect(ssh.status == .disconnected)
#expect(prompt.username == "deploy")
#expect(prompt.selectedKeyID == key.id)
}
if let sftp = session.sftp {
#expect(sftp.status == .disconnected)
#expect(sftp.operationError == prompt.failureMessage)
}
}
@Test(arguments: [ConnectionHistoryEntry.Kind.ssh, .sftp], [false, true])
func unsavedInputPreservesSelectionAndPassphraseContinuation(kind: ConnectionHistoryEntry.Kind, selected: Bool)
throws
{
let key = protectedKey()
let identity = Identity(name: "Deploy", username: " saved-user \n", keyID: key.id)
let host = Host(name: "Server", host: "server.invalid")
let store = HostLibraryModel(hosts: [host], keys: [key], identities: [identity])
let session = DesktopAuthenticationSession(kind: kind, host: host)
defer { session.disconnect() }
session.submit(
identityID: selected ? identity.id : nil, username: " manual-user \n", password: "one-use",
keyID: selected ? UUID() : key.id, save: false, store: store)
let prompt = try #require(session.prompt)
#expect(prompt.requiresPassphrase)
#expect(prompt.username == (selected ? "saved-user" : "manual-user"))
#expect(prompt.selectedKeyID == key.id)
#expect(prompt.selectedIdentityID == (selected ? identity.id : nil))
#expect(store.host(host.id) == host)
#expect(store.identity(identity.id) == identity)
session.submit(
identityID: nil, username: "ignored", password: "ignored", passphrase: "",
keyID: UUID(), save: true, store: store)
#expect(session.prompt?.id == prompt.id)
#expect(store.host(host.id) == host)
#expect(store.identities == [identity])
}
@Test(arguments: [ConnectionHistoryEntry.Kind.ssh, .sftp])
func blankUsernameLeavesTheOriginalPromptAndStoreUntouched(kind: ConnectionHistoryEntry.Kind) throws {
let host = Host(name: "Server", host: "server.invalid")
let store = HostLibraryModel(hosts: [host])
let session = DesktopAuthenticationSession(kind: kind, host: host)
defer { session.disconnect() }
let prompt = try #require(session.prompt)
session.submit(username: " \n\t ", password: "unused", save: true, store: store)
#expect(session.prompt?.id == prompt.id)
#expect(store.host(host.id) == host)
#expect(store.identities.isEmpty)
}
@Test(arguments: [ConnectionHistoryEntry.Kind.ssh, .sftp])
func clearingSavedPasswordDeletesItAndContinuesWithTheKey(kind: ConnectionHistoryEntry.Kind) throws {
let key = protectedKey()
let secrets = InMemorySecretStore()
let identity = Identity(name: "Deploy", username: "deploy", credentialRef: "password", keyID: key.id)
let host = Host(name: "Server", host: "server.invalid")
let store = HostLibraryModel(hosts: [host], keys: [key], identities: [identity], secrets: secrets)
try secrets.setString("old", for: identity.credentialRef, storage: identity.passwordStorage)
let session = DesktopAuthenticationSession(kind: kind, host: host)
defer { session.disconnect() }
session.submit(identityID: identity.id, username: "ignored", password: "", save: true, store: store)
#expect(store.host(host.id)?.identityID == identity.id)
let updatedIdentity = try #require(store.identity(identity.id))
#expect(try store.passwordCredential(for: updatedIdentity) == nil)
#expect(session.prompt?.requiresPassphrase == true)
#expect(session.prompt?.failureMessage == nil)
}
@Test(arguments: [false, true])
func temporarySSHHostCanContinueWithOrWithoutSaving(_ save: Bool) throws {
let key = protectedKey()
let host = Host(name: "Temporary", host: "temporary.invalid")
let store = HostLibraryModel(keys: [key])
let session = DesktopAuthenticationSession(kind: .ssh, host: host)
defer { session.disconnect() }
session.submit(username: " deploy ", password: "one-use", keyID: key.id, save: save, store: store)
let prompt = try #require(session.prompt)
#expect(prompt.host.id == host.id)
#expect(prompt.username == "deploy")
#expect(prompt.requiresPassphrase)
#expect((store.host(host.id) != nil) == save)
#expect(store.identities.count == (save ? 1 : 0))
if save {
let saved = try #require(store.host(host.id))
#expect(prompt.host.identityID == saved.identityID)
#expect(store.identity(for: saved)?.username == "deploy")
#expect(store.identity(for: saved)?.isHostCustom == true)
#expect(store.reusableIdentities.isEmpty)
}
}
@Test(arguments: [false, true])
func sftpPassphraseContinuationSavesOnlyWhenRequested(_ save: Bool) throws {
let key = protectedKey()
let secrets = InMemorySecretStore()
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let host = Host(name: "Server", host: "server.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], keys: [key], identities: [identity], secrets: secrets)
try secrets.setKeyCredential(
.software(.init(privateKey: "encrypted")), for: key.credentialRef, storage: key.storage)
let app = AppModel(hosts: library)
let channel = SFTPChannelModel(hostID: host.id)
defer { channel.disconnect() }
channel.pendingAuthentication = SSHAuthenticationPrompt(
host: host, selectedIdentityID: identity.id, username: identity.username,
selectedKeyID: key.id, requiresPassphrase: true)
channel.continueAuthentication(
identityID: identity.id, username: identity.username, password: nil,
passphrase: "saved", keyID: key.id, save: save, using: app.hostLibrary)
#expect(channel.pendingAuthentication == nil)
guard
case .software(let credential)? = try secrets.keyCredential(
for: key.credentialRef, storage: key.storage)
else {
Issue.record("The protected key must remain available")
return
}
#expect(credential.passphrase == (save ? "saved" : nil))
#expect(library.host(host.id) == host)
}
private func protectedKey() -> SSHKeyRef {
SSHKeyRef(label: "Protected", publicKey: "ssh-ed25519 fixture", hasPassphrase: true)
}
}
/// A pending protected-key prompt keeps these entry-point tests entirely local:
/// continuing without its passphrase cannot open a transport.
@MainActor
private final class DesktopAuthenticationSession {
let ssh: SSHSessionModel?
let sftp: SFTPChannelModel?
var prompt: SSHAuthenticationPrompt? { ssh?.pendingAuthentication ?? sftp?.pendingAuthentication }
init(kind: ConnectionHistoryEntry.Kind, host: Host) {
ssh = kind == .ssh ? SSHSessionModel(host: host) : nil
sftp = kind == .sftp ? SFTPChannelModel(hostID: host.id) : nil
let prompt = SSHAuthenticationPrompt(host: host, username: "")
ssh?.pendingAuthentication = prompt
sftp?.pendingAuthentication = prompt
}
func submit(
identityID: UUID? = nil, username: String, password: String?, passphrase: String? = nil,
keyID: UUID? = nil, save: Bool, store: HostLibraryModel
) {
ssh?.continueAuthentication(
identityID: identityID, username: username, password: password, passphrase: passphrase,
keyID: keyID, save: save, using: store)
sftp?.continueAuthentication(
identityID: identityID, username: username, password: password, passphrase: passphrase,
keyID: keyID, save: save, using: store)
}
func disconnect() {
ssh?.disconnect()
sftp?.disconnect()
}
}