Keyboard-interactive for Metrics

This commit is contained in:
Kaede Akino committed 2026-09-18 22:41:41 +08:00
1 parent 2f2c800843
commit 32aed0a610
13 files changed
+682 -25

No files matched your search

+28 -1
View File
@@ -60,7 +60,7 @@ struct MetricsJourneyTests {
hosts.upsert(host)
clock.advance(2)
try await wait { server.name == host.name }
try await wait { server.name == host.name && server.collectionState == .failed }
#expect(server.collectionState == .failed)
model.suspend()
@@ -170,6 +170,31 @@ struct MetricsJourneyTests {
#expect(model.servers.isEmpty)
}
@Test func suspendingHostKeyConfirmationRequiresAFreshChallengeOnResume() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
let host = TermindDomain.Host(name: "Monitor", host: "example.invalid", identityID: identity.id)
let hosts = HostLibraryModel(hosts: [host], identities: [identity])
try hosts.upsert(identity, passwordUpdate: .replace("test-only"))
let sampler = PortMetricsSampler()
let model = ServerMetricsModel(
hosts: hosts, preferences: PortMetricsPreferences(), makeSampler: { sampler })
defer { model.shutdown() }
#expect(model.add(host))
let server = try #require(model.servers.first)
try await wait { server.hostKeyPrompt != nil }
let observation = try #require(server.hostKey)
model.suspend()
#expect(server.hostKeyPrompt == nil && server.hostKey == nil && !server.needsAttention)
model.acceptHostKey(server, observation: observation, remember: true)
#expect(hosts.knownHosts(for: host).isEmpty)
model.resume()
try await wait { server.hostKeyPrompt != nil }
#expect(await sampler.hostKeyChallenges == 2)
model.acceptHostKey(server, observation: try #require(server.hostKey), remember: false)
try await wait { server.point != nil }
#expect(await sampler.connections == 1)
}
@Test func checkpointAndLogsSurviveAuthenticationContinuation() {
let host = TermindDomain.Host(name: "Target", host: "example.invalid")
let journey = ConnectionJourney()
@@ -200,6 +225,7 @@ struct MetricsJourneyTests {
private actor PortMetricsSampler: MetricsSampling {
var connections = 0
var hostKeyChallenges = 0
var processRequests = 0
func connect(_ configuration: SSHConnectionConfiguration) async throws { connections += 1 }
func connect(_ request: SSHConnectionRequest) async throws {
@@ -209,6 +235,7 @@ private actor PortMetricsSampler: MetricsSampling {
algorithm: .ed25519, fingerprint: "SHA256:test"
)
if !configuration.expectedHostKeys.contains(observation.identity) {
hostKeyChallenges += 1
guard let authorizeHostKey = request.authorizeHostKey else {
throw TermindSSHError.untrustedHost
}
@@ -0,0 +1,232 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
import struct TermindDomain.Host
@Suite @MainActor
struct MetricsKeyboardInteractiveTests {
@Test func multipleRoundsContinueOneConnectionWithoutRecordingAnswers() async throws {
let sampler = JourneyMetricsSampler(rounds: [
[], [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)],
[.init(text: "Second code", echo: false)],
])
let fixture = try Fixture(sampler: sampler)
defer { fixture.model.shutdown() }
let server = try fixture.add()
try await wait { server.keyboardAuthentication.current?.round == 2 }
let first = try #require(server.keyboardAuthentication.current)
#expect(server.requiresAuthentication && server.showsConnectionJourney && server.needsAttention)
#expect(!server.isConnecting)
#expect(server.journey.phase == .waiting)
#expect(server.journey.checkpoint?.kind == .authentication)
#expect(fixture.model.snapshot(for: server.id)?.state == .awaitingAuthentication)
#expect(server.status == String(localized: "Authentication required"))
#expect(await sampler.attempts == 1)
server.keyboardAuthentication.respond(first.id, answers: [" deploy ", "private-answer"])
try await wait { server.keyboardAuthentication.current?.round == 3 }
let second = try #require(server.keyboardAuthentication.current)
#expect(second.id != first.id && second.connectionID == first.connectionID)
server.keyboardAuthentication.respond(first.id, answers: ["stale", "stale"])
#expect(server.keyboardAuthentication.current?.id == second.id)
server.keyboardAuthentication.respond(second.id, answers: [""])
server.keyboardAuthentication.respond(second.id, answers: ["duplicate"])
try await wait { server.point != nil }
#expect(await sampler.attempts == 1)
#expect(await sampler.answers == [[], [" deploy ", "private-answer"], [""]])
#expect(!server.needsAttention && !server.requiresAuthentication)
#expect(fixture.model.snapshot(for: server.id)?.state == .live)
#expect(!server.journey.plainTextLog.contains("private-answer"))
#expect(!server.journey.plainTextLog.contains(first.instruction))
#expect(!server.journey.plainTextLog.contains(first.prompts[1].text))
#expect(fixture.library.identities.count == 1)
}
@Test func informationalRoundsFinishWithoutOpeningDetails() async throws {
let sampler = JourneyMetricsSampler(rounds: [[], []])
let fixture = try Fixture(sampler: sampler)
defer { fixture.model.shutdown() }
let server = try fixture.add()
try await wait { server.point != nil }
#expect(server.keyboardAuthentication.pending.isEmpty)
#expect(!server.requiresAuthentication)
#expect(await sampler.answers == [[], []])
#expect(!server.journey.plainTextLog.contains(String(localized: "Waiting for authentication")))
}
@Test func monitorsSharingAJumpHostKeepTheirChallengesIndependent() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
let jump = Host(name: "Gateway", host: "gateway.invalid", identityID: identity.id)
let firstHost = Host(name: "First", host: "first.invalid", identityID: identity.id, jumpHostID: jump.id)
let secondHost = Host(name: "Second", host: "second.invalid", identityID: identity.id, jumpHostID: jump.id)
let library = HostLibraryModel(hosts: [jump, firstHost, secondHost], identities: [identity])
try library.upsert(identity, passwordUpdate: .replace("test-only"))
let sampler = JourneyMetricsSampler(challengeHost: jump)
let model = ServerMetricsModel(
hosts: library, preferences: JourneyMetricsPreferences(), makeSampler: { sampler })
defer { model.shutdown() }
#expect(model.add(firstHost))
#expect(model.add(secondHost))
let first = try #require(model.servers.first { $0.id == firstHost.id })
let second = try #require(model.servers.first { $0.id == secondHost.id })
try await wait { first.keyboardAuthentication.current != nil && second.keyboardAuthentication.current != nil }
let a = try #require(first.keyboardAuthentication.current)
let b = try #require(second.keyboardAuthentication.current)
#expect(a.hostID == jump.id && b.hostID == jump.id)
#expect(a.connectionID != b.connectionID)
#expect(first.journey.checkpoint?.endpointIndex == 1)
#expect(second.journey.checkpoint?.endpointIndex == 1)
first.keyboardAuthentication.respond(b.id, answers: ["wrong-monitor"])
#expect(first.keyboardAuthentication.current?.id == a.id)
first.keyboardAuthentication.respond(a.id, answers: ["first-answer"])
try await wait { first.point != nil }
#expect(second.keyboardAuthentication.current?.id == b.id)
model.setEnabled(false, for: second)
#expect(second.keyboardAuthentication.pending.isEmpty)
#expect(first.selection.enabled && first.collectionState == .live)
}
@Test(arguments: Retirement.allCases)
func retiringAttemptsCancelsPromptsAndRejectsLateCallbacks(_ retirement: Retirement) async throws {
let fixture = try Fixture()
defer { fixture.model.shutdown() }
let server = try fixture.add()
try await wait { server.keyboardAuthentication.current != nil }
let oldChallenge = try #require(server.keyboardAuthentication.current)
let oldRequest = try #require(await fixture.sampler.requests.first)
let retiredHandler = try #require(oldRequest.keyboardInteractive)
switch retirement {
case .pause: fixture.model.setEnabled(false, for: server)
case .remove: fixture.model.remove(server.id)
case .retry: fixture.model.retry(server)
case .configuration:
var host = fixture.host
host.port += 1
fixture.library.upsert(host)
fixture.model.reconcile()
case .suspend: fixture.model.suspend()
case .shutdown: fixture.model.shutdown()
}
#expect(server.keyboardAuthentication.pending.isEmpty)
#expect(!server.needsAttention)
server.keyboardAuthentication.respond(oldChallenge.id, answers: ["stale"])
await #expect(throws: CancellationError.self) { try await retiredHandler(oldChallenge) }
if retirement == .suspend { fixture.model.resume() }
if retirement == .pause { fixture.model.setEnabled(true, for: server) }
if retirement == .remove {
#expect(fixture.model.servers.isEmpty)
} else if retirement == .shutdown {
#expect(server.journey.phase == .hidden)
} else {
try await wait { server.keyboardAuthentication.current != nil }
let next = try #require(server.keyboardAuthentication.current)
#expect(next.id != oldChallenge.id)
await #expect(throws: CancellationError.self) { try await retiredHandler(oldChallenge) }
#expect(server.keyboardAuthentication.current?.id == next.id)
#expect(server.journey.phase == .waiting && server.needsAttention)
server.keyboardAuthentication.respond(next.id, answers: ["new-answer"])
try await wait { server.point != nil }
#expect(await fixture.sampler.answers == [["new-answer"]])
}
}
@Test func transportCancellationRemovesThePendingPrompt() async throws {
let fixture = try Fixture()
defer { fixture.model.shutdown() }
let server = try fixture.add()
try await wait { server.keyboardAuthentication.current != nil }
// A transport may cancel its responder independently of the monitor's worker.
await fixture.sampler.cancelResponder()
try await wait { server.keyboardAuthentication.pending.isEmpty && !server.needsAttention }
#expect(server.journey.phase != .waiting)
}
enum Retirement: CaseIterable { case pause, remove, retry, configuration, suspend, shutdown }
private func wait(_ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation) async throws {
for _ in 0..<400 {
if condition() { return }
try await Task.sleep(for: .milliseconds(5))
}
try #require(condition(), sourceLocation: sourceLocation)
}
private struct Fixture {
let host: Host
let library: HostLibraryModel
let sampler: JourneyMetricsSampler
let model: ServerMetricsModel
init(sampler: JourneyMetricsSampler = JourneyMetricsSampler()) throws {
self.sampler = sampler
let identity = Identity(name: "Deploy", username: "deploy")
host = Host(name: "Monitor", host: "metrics.invalid", identityID: identity.id)
library = HostLibraryModel(hosts: [host], identities: [identity])
try library.upsert(identity, passwordUpdate: .replace("test-only"))
model = ServerMetricsModel(
hosts: library, preferences: JourneyMetricsPreferences(), makeSampler: { sampler })
}
func add() throws -> ServerMetric {
#expect(model.add(host))
return try #require(model.servers.first)
}
}
}
@MainActor private final class JourneyMetricsPreferences: MetricsConfigurationStoring {
func load() -> [MetricsSelection] { [] }
func save(_ selections: [MetricsSelection]) {}
}
private actor JourneyMetricsSampler: MetricsSampling {
let rounds: [[SSHKeyboardInteractiveChallenge.Prompt]]
let challengeHost: Host?
var attempts = 0
var answers: [[String]] = []
var requests: [SSHConnectionRequest] = []
private var responder: Task<[String], any Error>?
init(
rounds: [[SSHKeyboardInteractiveChallenge.Prompt]] = [[.init(text: "Verification code", echo: false)]],
challengeHost: Host? = nil
) {
self.rounds = rounds
self.challengeHost = challengeHost
}
func connect(_ configuration: SSHConnectionConfiguration) async throws {
Issue.record("Metrics must supply the request containing its own authentication handler")
throw CancellationError()
}
func connect(_ request: SSHConnectionRequest) async throws {
attempts += 1
requests.append(request)
let handler = try #require(request.keyboardInteractive)
let connectionID = UUID()
for (index, prompts) in rounds.enumerated() {
let challenge = SSHKeyboardInteractiveChallenge(
connectionID: connectionID, hostID: challengeHost?.id ?? request.configuration.hostID,
hostname: challengeHost?.host ?? request.configuration.host, port: request.configuration.port,
username: request.configuration.username, round: index + 1, name: "PAM",
instruction: "Private server instruction", prompts: prompts)
let task = Task { try await handler(challenge) }
responder = task
let answer = try await withTaskCancellationHandler {
try await task.value
} onCancel: {
task.cancel()
}
answers.append(answer)
}
}
func cancelResponder() { responder?.cancel() }
func sample() async throws -> MetricsSample {
.init(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100)
}
func gpu() async throws -> NvidiaSnapshot? { nil }
func disconnect() async {}
}
@@ -22,10 +22,13 @@ public final class ServerMetric: Identifiable {
public var hostKey: SSHHostKeyObservation?
public var needsAttention = false
public let journey = ConnectionJourney()
public let keyboardAuthentication = SSHKeyboardInteractiveQueue()
public var authenticationPrompt: SSHAuthenticationPrompt?
public var hostKeyPrompt: SSHHostKeyPrompt?
public var hostKeyHostID: UUID?
public var requiresAuthentication: Bool { hostKey != nil || authenticationPrompt != nil }
public var requiresAuthentication: Bool {
hostKey != nil || authenticationPrompt != nil || keyboardAuthentication.current != nil
}
public var showsConnectionJourney: Bool { requiresAuthentication || journey.isPresented }
public var processes: [MetricsProcess] = []
public var processesDate: Date?
@@ -71,6 +74,10 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
var id: UUID
var continuation: AsyncStream<SSHHostKeyDecision>.Continuation
}
private struct PendingKeyboardAuthentication {
var id: UUID
var task: Task<[String], any Error>
}
private nonisolated struct RunAttempt: Sendable {
var host: Host
var preparation: SSHConnectionPreparation
@@ -84,8 +91,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
case stop, attention, retry
}
private var pendingHostKeyAuthorizations: [UUID: PendingHostKeyAuthorization] = [:]
private nonisolated let keyboardInteractive: SSHKeyboardInteractiveHandler?
private var pendingKeyboardAuthentications: [UUID: PendingKeyboardAuthentication] = [:]
public var keepsRunning: Bool { servers.contains { $0.selection.enabled } }
@@ -94,10 +100,8 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
makeSampler: @escaping @Sendable () -> any MetricsSampling,
clock: MetricsClock = MetricsClock(),
cardLimit: @escaping () -> Int? = { ServerMetricsModel.freeCardLimit },
limitReached: @escaping () -> Void = {},
keyboardInteractive: SSHKeyboardInteractiveHandler? = nil
limitReached: @escaping () -> Void = {}
) {
self.keyboardInteractive = keyboardInteractive
self.hosts = hosts
self.preferences = preferences
self.makeSampler = makeSampler
@@ -466,9 +470,21 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
}
private func cancel(_ id: UUID) {
finishHostKeyAuthorization(for: id, decision: .reject)
generations[id] = nil
workers.removeValue(forKey: id)?.cancel()
let hadHostKeyAuthorization = pendingHostKeyAuthorizations[id] != nil
finishHostKeyAuthorization(for: id, decision: .reject)
let authentication = pendingKeyboardAuthentications.removeValue(forKey: id)
authentication?.task.cancel()
if let server = servers.first(where: { $0.id == id }) {
if hadHostKeyAuthorization {
server.hostKey = nil
server.hostKeyHostID = nil
server.hostKeyPrompt = nil
}
if hadHostKeyAuthorization || authentication != nil { server.needsAttention = false }
server.keyboardAuthentication.cancelAll()
}
if let sampler = samplers.removeValue(forKey: id) {
Task { await sampler.disconnect() }
}
@@ -547,7 +563,11 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
serverID, challenge: challenge, token: token
)
},
keyboardInteractive: keyboardInteractive
keyboardInteractive: { [weak self] challenge in
guard let self else { throw CancellationError() }
return try await self.answerKeyboardInteractiveFromWorker(
serverID, challenge: challenge, token: token)
}
)
)
guard await publishConnected(serverID, token: token, hostID: attempt.host.id) else {
@@ -766,6 +786,61 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
return .retry
}
private nonisolated func answerKeyboardInteractiveFromWorker(
_ serverID: UUID, challenge: SSHKeyboardInteractiveChallenge, token: UUID
) async throws -> [String] {
guard await isCurrent(serverID, token: token) else { throw CancellationError() }
// Informational rounds must complete even when no Metrics detail is visible.
guard !challenge.prompts.isEmpty else { return [] }
guard let task = await beginKeyboardAuthentication(serverID, challenge: challenge, token: token) else {
throw CancellationError()
}
do {
let answers = try await withTaskCancellationHandler {
try await task.value
} onCancel: {
task.cancel()
}
guard await finishKeyboardAuthentication(serverID, requestID: challenge.id, token: token) else {
throw CancellationError()
}
try Task.checkCancellation()
return answers
} catch {
_ = await finishKeyboardAuthentication(serverID, requestID: challenge.id, token: token)
throw error
}
}
private func beginKeyboardAuthentication(
_ serverID: UUID, challenge: SSHKeyboardInteractiveChallenge, token: UUID
) -> Task<[String], any Error>? {
guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return nil }
let queue = server.keyboardAuthentication
// Retain the responder before yielding so retiring this attempt also cancels a not-yet-enqueued request.
let task = Task { try await queue.request(challenge) }
pendingKeyboardAuthentications[serverID] = PendingKeyboardAuthentication(id: challenge.id, task: task)
server.needsAttention = true
server.isConnecting = false
server.status = String(localized: "Authentication required")
server.collectionState = .awaitingAuthentication
server.journey.waitForAuthentication(at: challenge.hostID)
return task
}
private func finishKeyboardAuthentication(_ serverID: UUID, requestID: UUID, token: UUID) -> Bool {
guard generations[serverID] == token, pendingKeyboardAuthentications[serverID]?.id == requestID,
let server = servers.first(where: { $0.id == serverID })
else { return false }
pendingKeyboardAuthentications[serverID] = nil
server.needsAttention = false
server.isConnecting = true
server.status = String(localized: "Connecting…")
server.collectionState = .connecting
server.journey.resume()
return true
}
private nonisolated func authorizeHostKeyFromWorker(
_ serverID: UUID, challenge: SSHHostKeyChallenge, token: UUID
) async -> SSHHostKeyDecision {
@@ -118,6 +118,7 @@ public struct MetricsInstrumentBody: View {
}
private var connectionLabel: String {
if server.keyboardAuthentication.current != nil { return String(localized: "Authentication required") }
if server.requiresAuthentication { return String(localized: "Server Identity Verification Required") }
return server.status
}
+11 -3
View File
@@ -192,8 +192,9 @@ scene. Terminal file drops and all additional transfer connections forbid Termin
credential, passphrase, MFA and host-key forms, including fallback responders on jump
hosts. System key authorization such as Touch ID remains allowed. Username-only and
SSH `none` authentication remain eligible; actual connection success decides availability.
No upload-specific mobile sheet exists; the app-owned MFA sheet still serves Metrics
and forwarding. Cancellation interrupts opening, and temporary-file cleanup must also
No upload-specific mobile sheet exists; the app-owned MFA sheet serves forwarding,
while Metrics owns its challenges inside Connection Journey on both platforms.
Cancellation interrupts opening, and temporary-file cleanup must also
remain silent. Cleanup requiring new interaction retains the existing failure report.
Forwarding rules sharing a host session share authentication; a new connection must
answer fresh challenges. An Identity may contain only a username: preparation
@@ -767,7 +768,14 @@ processes to 20. Monitoring itself does not depend on AgentKit.
Metrics order syncs independently of Vault order; local layout, process sorting,
search, detail navigation, and menu-bar visibility do not. Detail observation alone
enables process/filesystem collection. Authentication opens the shared Connection
Journey while the metrics coordinator retains transport ownership. Temporary trust
Journey while the metrics coordinator retains transport ownership. Each monitor owns
its keyboard-interactive queue, including challenges from its jump hosts. Metrics
always supplies its own responder, never falling back to an app-owned sheet or external
authentication window. Cards indicate required authentication without changing navigation;
opening a card presents its Journey. Leaving details retains the challenge, while explicit
authentication cancellation pauses the monitor and returns that window to the list.
Retiring an attempt cancels both queued and not-yet-enqueued responders; generation and
request IDs prevent stale answers or cleanup from affecting another attempt. Temporary trust
is cleared on pause/removal/configuration change; rejection pauses the monitor.
Free access admits at most three cards (paused included), atomically and without
deleting restored cards after downgrade; Pro/trial access is unlimited.
+1 -2
View File
@@ -177,8 +177,7 @@ final class MobileAppModel {
storage: GRDBMemoryStore(directory: GRDBMemoryStore.defaultDirectory), defaults: settingsDefaults)
library = HostLibraryModel(persistence: persistence, secrets: secrets)
metrics = MobileMetricsModel(
preferences: metricsPersistence, hosts: library, services: dependencies.metrics,
keyboardInteractive: keyboardAuthentication.handler)
preferences: metricsPersistence, hosts: library, services: dependencies.metrics)
let speechAudio = IOSSpeechAudioInput()
let speech = SpeechTranscriptionCoordinator(
service: AppleSpeechTranscriptionService(audioInput: speechAudio)
@@ -16,15 +16,14 @@ final class MobileMetricsModel {
init(
preferences: any MetricsConfigurationStoring,
hosts: any RemoteSessionHostStore,
services: MobileMetricsServices,
keyboardInteractive: SSHKeyboardInteractiveHandler? = nil
services: MobileMetricsServices
) {
self.hosts = hosts
let limit = limit
core = ServerMetricsModel(
hosts: hosts, preferences: preferences,
makeSampler: services.makeSampler,
cardLimit: { limit.maximum }, keyboardInteractive: keyboardInteractive)
cardLimit: { limit.maximum })
}
func add(_ id: UUID, pro: Bool) -> Bool {
+9 -5
View File
@@ -98,10 +98,9 @@ struct MobileMetricsDetailView: View {
}
}, retry: { app.metrics.core.retry(server) },
retryReconnectNow: {}, stopReconnecting: {},
cancel: {
app.metrics.core.setEnabled(false, for: server)
dismiss()
})
cancel: cancelAuthentication,
keyboardAuthentication: server.keyboardAuthentication,
cancelKeyboardAuthentication: cancelAuthentication)
} else {
GeometryReader { geometry in
if MobileMetricsDetailPage.showsBothColumns(width: geometry.size.width) {
@@ -128,7 +127,7 @@ struct MobileMetricsDetailView: View {
.toolbar(removing: tabBarPlacement == .topBar ? .title : nil)
.toolbar {
ToolbarItem(placement: .principal) {
if !MobileMetricsDetailPage.showsBothColumns(width: availableWidth) {
if !server.showsConnectionJourney && !MobileMetricsDetailPage.showsBothColumns(width: availableWidth) {
Picker("Details", selection: $page) {
Image(systemName: "gauge.with.dots.needle.33percent").accessibilityLabel("Status").tag(
MobileMetricsDetailPage.status)
@@ -156,6 +155,11 @@ struct MobileMetricsDetailView: View {
.onDisappear { app.metrics.core.stopObservingDetails(observer) }
}
private func cancelAuthentication() {
app.metrics.core.setEnabled(false, for: server)
dismiss()
}
private func observe() {
if server.showsConnectionJourney {
app.metrics.core.stopObservingDetails(observer)
@@ -16,6 +16,7 @@ struct MobileConnectionJourneyView: View {
let stopReconnecting: () -> Void
let cancel: () -> Void
var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil
var cancelKeyboardAuthentication: (() -> Void)? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -51,7 +52,14 @@ struct MobileConnectionJourneyView: View {
} else if let queue = keyboardAuthentication, let challenge = queue.current {
SSHKeyboardInteractiveForm(
challenge: challenge, submit: { queue.respond(challenge.id, answers: $0) },
cancel: { queue.cancel(challenge.id) }, usesContainerChrome: true
cancel: {
guard queue.current?.id == challenge.id else { return }
if let cancelKeyboardAuthentication {
cancelKeyboardAuthentication()
} else {
queue.cancel(challenge.id)
}
}, usesContainerChrome: true
)
.id(challenge.id)
} else if let authenticationPrompt {
+167
View File
@@ -0,0 +1,167 @@
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
import UIKit
@testable import TermindIOS
@Suite(.serialized) @MainActor
struct MobileMetricsJourneyTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func authenticationStaysInMetricsAcrossNavigationAndSizeChanges(_ language: String) async throws {
let sampler = MobileJourneyMetricsSampler()
let app = try MobileAppModel(dependencies: .testing(metrics: MobileMetricsServices(makeSampler: { sampler })))
let identity = Identity(name: "Deploy", username: "deploy")
let host = TermindDomain.Host(
name: "Production Gateway", host: "gateway.example.invalid", identityID: identity.id)
try app.library.upsert(identity, passwordUpdate: .replace("test-only"))
app.library.upsert(host)
let scene = MobileSceneModel(app: app)
scene.selectedDestination = .metrics
#expect(app.metrics.add(host.id, pro: true))
let server = try #require(app.metrics.servers.first)
let route = MetricsJourneyTestRoute()
var features = MobileFeatureRegistry()
features.register(
MobileFeatureRegistration(id: .metrics, title: "Metrics", symbol: "waveform.path.ecg") { app, _ in
AnyView(
Text("Metrics")
.navigationTitle("Metrics")
.navigationDestination(
isPresented: Binding(get: { route.showsDetails }, set: { route.showsDetails = $0 })
) { MobileMetricsDetailView(app: app, server: server) })
})
let windowScene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = windowScene.keyWindow
let window = UIWindow(windowScene: windowScene)
let container = UIViewController()
let controller = UIHostingController(
rootView: MobileRootView(app: app, scene: scene, features: features)
.environment(\.locale, Locale(identifier: language))
.environment(\.scenePhase, .active))
container.addChild(controller)
container.view.addSubview(controller.view)
controller.didMove(toParent: container)
window.rootViewController = container
window.windowLevel = .alert + 3
window.makeKeyAndVisible()
defer {
app.metrics.core.shutdown()
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
resize(controller, width: 430)
try await wait { server.keyboardAuthentication.current != nil }
let challenge = try #require(server.keyboardAuthentication.current)
#expect(!route.showsDetails)
#expect(app.keyboardAuthentication.pending.isEmpty)
route.showsDetails = true
for width in [430.0, 600.0, 1024.0] {
resize(controller, width: width)
try await Task.sleep(for: .milliseconds(500))
let fields = descendants(UITextField.self, in: controller.view)
#expect(fields.count == 2)
#expect(fields.filter(\.isSecureTextEntry).count == 1)
#expect(descendants(UISegmentedControl.self, in: controller.view).isEmpty)
#expect(presented(in: controller) == nil)
#expect(server.keyboardAuthentication.current?.id == challenge.id)
#expect(app.keyboardAuthentication.pending.isEmpty)
try capture(controller.view, name: "metrics-journey-\(language)-\(Int(width))")
}
route.showsDetails = false
try await Task.sleep(for: .milliseconds(400))
#expect(server.keyboardAuthentication.current?.id == challenge.id)
route.showsDetails = true
resize(controller, width: 430)
// Native traits exercise large text without replacing the feature navigation.
controller.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge
try await Task.sleep(for: .milliseconds(500))
#expect(descendants(UITextField.self, in: controller.view).count == 2)
try capture(controller.view, name: "metrics-journey-\(language)-large-type")
#expect(await sampler.connections == 1)
controller.traitOverrides.preferredContentSizeCategory = .large
let fields = descendants(UITextField.self, in: controller.view)
let account = try #require(fields.first { !$0.isSecureTextEntry })
let code = try #require(fields.first { $0.isSecureTextEntry })
account.text = "deploy"
account.sendActions(for: .editingChanged)
code.text = "test-code"
code.sendActions(for: .editingChanged)
#expect(code.becomeFirstResponder())
code.sendActions(for: .editingDidEndOnExit)
try await wait { server.point != nil && !server.showsConnectionJourney }
try await Task.sleep(for: .milliseconds(400))
#expect(descendants(UITextField.self, in: controller.view).isEmpty)
#expect(app.keyboardAuthentication.pending.isEmpty)
#expect(presented(in: controller) == nil)
#expect(await sampler.answers == ["deploy", "test-code"])
#expect(await sampler.connections == 1)
try capture(controller.view, name: "metrics-journey-\(language)-live")
}
private func wait(_ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation) async throws {
for _ in 0..<400 {
if condition() { return }
try await Task.sleep(for: .milliseconds(5))
}
try #require(condition(), sourceLocation: sourceLocation)
}
private func resize(_ controller: UIViewController, width: CGFloat) {
let frame = CGRect(x: 0, y: 0, width: width, height: 900)
controller.view.window?.frame = frame
controller.parent?.view.frame = frame
controller.traitOverrides.horizontalSizeClass = width >= 600 ? .regular : .compact
controller.view.frame = frame
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
}
private func descendants<T: UIView>(_ type: T.Type, in view: UIView) -> [T] {
(view as? T).map { [$0] } ?? view.subviews.flatMap { descendants(type, in: $0) }
}
private func presented(in controller: UIViewController) -> UIViewController? {
controller.presentedViewController ?? controller.children.lazy.compactMap { presented(in: $0) }.first
}
private func capture(_ view: UIView, name: String) throws {
let data = UIGraphicsImageRenderer(bounds: view.bounds).pngData { _ in
view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
}
let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
Attachment.record(data, named: "\(device)-\(name).png")
let directory = FileManager.default.temporaryDirectory.appending(path: "termind-metrics-journey")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appending(path: "\(device)-\(name).png"))
print("Metrics Journey snapshot: \(directory.path)/\(device)-\(name).png")
}
}
@Observable @MainActor private final class MetricsJourneyTestRoute {
var showsDetails = false
}
private actor MobileJourneyMetricsSampler: MetricsSampling {
var connections = 0
var answers: [String] = []
func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() }
func connect(_ request: SSHConnectionRequest) async throws {
connections += 1
let handler = try #require(request.keyboardInteractive)
answers = try await handler(
SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: request.configuration.hostID,
hostname: request.configuration.host, port: request.configuration.port,
username: request.configuration.username, round: 1, name: "PAM",
instruction: "Enter your account and verification code.",
prompts: [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)]))
}
func sample() async throws -> MetricsSample {
.init(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100)
}
func gpu() async throws -> NvidiaSnapshot? { nil }
func disconnect() async {}
}
@@ -24,7 +24,9 @@ struct MetricsJourneyView: View {
trustHostKey: { model.acceptHostKey(server, observation: $0, remember: true) },
denyHostKey: cancel,
retry: { model.retry(server) },
cancel: cancel
cancel: cancel,
keyboardAuthentication: server.keyboardAuthentication,
cancelKeyboardAuthentication: cancel
)
.environment(app)
}
+10 -1
View File
@@ -158,6 +158,7 @@ struct ConnectionJourneyView: View {
var failureRetryLabel: LocalizedStringKey = "Retry"
var compactFailureActions = false
var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil
var cancelKeyboardAuthentication: (() -> Void)? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -195,7 +196,15 @@ struct ConnectionJourneyView: View {
SSHKeyboardInteractiveForm(
challenge: challenge,
submit: { queue.respond(challenge.id, answers: $0) },
cancel: { queue.cancel(challenge.id) }, keyboard: DesktopConnectionFormKeyboard()
cancel: {
guard queue.current?.id == challenge.id else { return }
if let cancelKeyboardAuthentication {
cancelKeyboardAuthentication()
} else {
queue.cancel(challenge.id)
}
},
keyboard: DesktopConnectionFormKeyboard()
)
.id(challenge.id)
.connectionFormChrome(isEnabled: true)
+126
View File
@@ -0,0 +1,126 @@
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite(.serialized) @MainActor
struct MetricsJourneyRenderingTests {
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func metricsAuthenticateInsideTheirJourneyWithoutOpeningAnExternalWindow(_ language: String) async throws {
let identity = Identity(name: "Deploy", username: "deploy")
let host = Host(name: "Production Gateway", host: "gateway.example.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], identities: [identity])
try library.upsert(identity, passwordUpdate: .replace("test-only"))
let sampler = DesktopJourneyMetricsSampler()
let model = ServerMetricsModel(
hosts: library, preferences: DesktopJourneyPreferences(), makeSampler: { sampler })
let app = AppModel(hosts: library, serverMetrics: model)
await sampler.setFallback(app.externalAuthentication.keyboardInteractive)
let navigation = WindowModel()
navigation.selectedDestination = .metrics
navigation.metricsModel = model
let controller = NSHostingController(
rootView: ServerMetricsView().environment(app).environment(navigation)
.environment(\.locale, Locale(identifier: language)))
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 740),
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentViewController = controller
window.makeKeyAndOrderFront(nil)
defer {
model.shutdown()
window.orderOut(nil)
window.contentViewController = nil
window.close()
}
#expect(model.add(host))
let server = try #require(model.servers.first)
for _ in 0..<400 where server.keyboardAuthentication.current == nil {
try await Task.sleep(for: .milliseconds(5))
}
let challenge = try #require(server.keyboardAuthentication.current)
#expect(navigation.metricsHostID == nil)
#expect(app.externalAuthentication.pending.isEmpty)
#expect(app.externalAuthentication.presentationRevision == 0)
navigation.metricsHostID = host.id
for width in [600.0, 1000.0] {
window.setContentSize(NSSize(width: width, height: 740))
try await Task.sleep(for: .milliseconds(300))
controller.view.layoutSubtreeIfNeeded()
let fields = editableFields(in: controller.view)
#expect(fields.count == 2)
#expect(fields.filter { $0 is NSSecureTextField }.count == 1)
#expect(window.attachedSheet == nil)
#expect(!navigation.showsCompactMetricsDetail)
try capture(controller.view, name: "macos-metrics-journey-\(language)-\(Int(width))")
}
navigation.metricsHostID = nil
try await Task.sleep(for: .milliseconds(300))
#expect(server.keyboardAuthentication.current?.id == challenge.id)
navigation.metricsHostID = host.id
try await Task.sleep(for: .milliseconds(300))
window.makeKeyAndOrderFront(nil)
let escape = try #require(
NSEvent.keyEvent(
with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0,
windowNumber: window.windowNumber, context: nil, characters: "\u{1b}",
charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53))
NSApp.sendEvent(escape)
for _ in 0..<100 where server.selection.enabled { try await Task.sleep(for: .milliseconds(5)) }
#expect(!server.selection.enabled)
#expect(navigation.metricsHostID == nil)
#expect(server.keyboardAuthentication.pending.isEmpty)
#expect(app.externalAuthentication.pending.isEmpty)
#expect(await sampler.connections == 1)
}
private func editableFields(in view: NSView) -> [NSTextField] {
if let field = view as? NSTextField, field.isEditable { return [field] }
return view.subviews.flatMap { editableFields(in: $0) }
}
private func capture(_ view: NSView, name: String) throws {
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(data, named: "\(name).png")
let directory = FileManager.default.temporaryDirectory.appending(path: "termind-metrics-journey")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: directory.appending(path: "\(name).png"))
}
}
@MainActor private final class DesktopJourneyPreferences: MetricsConfigurationStoring {
func load() -> [MetricsSelection] { [] }
func save(_ selections: [MetricsSelection]) {}
}
private actor DesktopJourneyMetricsSampler: MetricsSampling {
var connections = 0
private var fallback: SSHKeyboardInteractiveHandler?
func setFallback(_ handler: @escaping SSHKeyboardInteractiveHandler) { fallback = handler }
func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() }
func connect(_ request: SSHConnectionRequest) async throws {
connections += 1
// Match the live exec session's fallback path: Metrics must always override it.
let handler = try #require(request.withKeyboardInteractiveFallback(fallback).keyboardInteractive)
_ = try await handler(
SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: request.configuration.hostID,
hostname: request.configuration.host, port: request.configuration.port,
username: request.configuration.username, round: 1, name: "PAM",
instruction: "Enter your account and verification code.",
prompts: [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)]))
}
func sample() async throws -> MetricsSample {
.init(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100)
}
func gpu() async throws -> NvidiaSnapshot? { nil }
func disconnect() async {}
}