// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import TermindApplication import TermindDomain import Testing @testable import TermindRemoteInfrastructure @Suite @MainActor struct SSHTailscaleAuthenticationTests { @Test(arguments: [ ("SSH-2.0-Tailscale", true), ("SSH-2.0-Tailscale_1.90 optional comment", true), ("SSH-2.0-OpenSSH_9.0 Tailscale", false), ("SSH-2.0-TailscaleProxy", false), ("host.ts.net", false), ("100.64.0.1", false), ]) func detectionUsesOnlyTheServerSoftware(banner: String, expected: Bool) { #expect(SSHTailscaleAuthentication.isTailscaleServer(banner) == expected) #expect(!SSHTailscaleAuthentication.isTailscaleServer(nil)) } @Test func serverSuccessDismissesApprovalWithoutAResponse() async throws { let controller = RemoteSessionConnectionController() let configuration = configuration() let host = Host(id: configuration.hostID, name: "Server", host: configuration.host) controller.journey.begin(host: host, route: [host], purpose: .sshSession, username: configuration.username) let exchange = SSHKeyboardInteractiveExchange( configuration: configuration, handler: { try await controller.answerKeyboardInteractive($0) }) let probe = ApprovalProbe() let opening = start(exchange, probe) defer { exchange.cancel() } let challenge = try await wait(controller.keyboardAuthentication) #expect(challenge.username == "deploy") #expect(challenge.isTailscaleApproval) #expect(challenge.prompts.isEmpty) #expect(controller.journey.phase == .waiting) #expect(!controller.journey.plainTextLog.contains(challenge.instruction)) probe.finish(.authenticated) #expect(try await opening.value == .authenticated) await exchange.waitForTailscaleApprovalDismissal() #expect(controller.keyboardAuthentication.pending.isEmpty) #expect(controller.journey.phase == .connecting) try exchange.checkTailscaleApproval() } @Test func duplicateBannersAndRetiredRequestsDoNotCancelTheNextApproval() async throws { let queue = SSHKeyboardInteractiveQueue() let exchange = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: queue.handler) let probe = ApprovalProbe() let opening = start(exchange, probe) defer { exchange.cancel() } let first = try await wait(queue) let count = probe.readCount for _ in 0..<100 where probe.readCount < count + 3 { try await Task.sleep(for: .milliseconds(5)) } #expect(queue.pending.map(\.id) == [first.id]) probe.finish(.pending("Approve at https://login.example/second")) let second = try await wait(queue, excluding: first.id) #expect(second.connectionID == first.connectionID) #expect(second.round == 2) queue.cancel(first.id) probe.finish(.authenticated) #expect(try await opening.value == .authenticated) await exchange.waitForTailscaleApprovalDismissal() #expect(queue.pending.isEmpty) } @Test(arguments: [false, true]) func cancellationClosesOnlyItsOwnPrompt(cancelConnection: Bool) async throws { let queue = SSHKeyboardInteractiveQueue() let exchange = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: queue.handler) let other = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: queue.handler) let probe = ApprovalProbe() let opening = start(exchange, probe) let challenge = try await wait(queue) try other.presentTailscaleApproval("Other connection") defer { other.endTailscaleApproval() } if cancelConnection { exchange.cancel() } else { queue.cancel(challenge.id) } await #expect(throws: CancellationError.self) { try await opening.value } await exchange.waitForTailscaleApprovalDismissal() let retained = try await wait(queue, excluding: challenge.id) #expect(retained.connectionID != challenge.connectionID) other.endTailscaleApproval() await other.waitForTailscaleApprovalDismissal() #expect(queue.pending.isEmpty) } @Test func deadlineIncludesTimeWithTheApprovalFormOpen() async throws { let queue = SSHKeyboardInteractiveQueue() let exchange = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: queue.handler) let probe = ApprovalProbe() let opening = start(exchange, probe) _ = try await wait(queue) probe.advanceTime() await #expect { try await opening.value } throws: { error in if case TailscaleConfigurationError.approvalTimedOut = error { return true } return false } await exchange.waitForTailscaleApprovalDismissal() #expect(queue.pending.isEmpty) } @Test func disconnectDismissesApprovalAndDoesNotExposeTheBannerInErrors() async throws { let queue = SSHKeyboardInteractiveQueue() let exchange = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: queue.handler) let probe = ApprovalProbe() let opening = start(exchange, probe) let challenge = try await wait(queue) probe.disconnect() await #expect { try await opening.value } throws: { error in if case TermindSSHError.disconnected = error { return true } return false } await exchange.waitForTailscaleApprovalDismissal() #expect(queue.pending.isEmpty) let failure = SSHTailscaleAuthentication.failure(code: -18).localizedDescription #expect(!failure.contains(challenge.instruction)) #expect(!failure.contains("https://")) #expect(failure.contains("libssh2 -18")) } @Test func forbiddenInteractionAndMissingResponderFailWithoutShowingAForm() throws { let exchange = SSHKeyboardInteractiveExchange( configuration: configuration(), handler: { _ in Issue.record("Forbidden approval") return [] }, allowsUserInteraction: false) for candidate in [exchange, nil] { #expect(throws: SSHInteractionRequired.self) { try SSHTailscaleAuthentication.run(exchange: candidate) { .pending("Browser approval") } } } } @Test func methodsAndImmediateSuccessDoNotNeedAnInteractiveResponder() throws { for result in [SSHTailscaleAuthentication.Step.authenticated, .methods(["password", "publickey"])] { #expect(try SSHTailscaleAuthentication.run(exchange: nil) { result } == result) } } @Test func responderAcknowledgementDoesNotAuthorizeTheConnection() async throws { let exchange = SSHKeyboardInteractiveExchange(configuration: configuration(), handler: { _ in [] }) let probe = ApprovalProbe() let opening = start(exchange, probe) for _ in 0..<100 where probe.readCount < 3 { try await Task.sleep(for: .milliseconds(5)) } #expect(probe.readCount >= 3) probe.advanceTime() await #expect { try await opening.value } throws: { error in if case TailscaleConfigurationError.approvalTimedOut = error { return true } return false } await exchange.waitForTailscaleApprovalDismissal() } private func configuration() -> SSHConnectionConfiguration { .init( hostID: UUID(), host: "server.example", port: 22, username: "deploy", credentials: [], expectedHostKeys: []) } private func start( _ exchange: SSHKeyboardInteractiveExchange, _ probe: ApprovalProbe ) -> Task { Task.detached { try SSHTailscaleAuthentication.run( exchange: exchange, now: { probe.now }, pause: { Thread.sleep(forTimeInterval: 0.005) }, step: { try probe.next() }) } } private func wait( _ queue: SSHKeyboardInteractiveQueue, excluding: UUID? = nil ) async throws -> SSHKeyboardInteractiveChallenge { for _ in 0..<200 { if let challenge = queue.current, challenge.id != excluding { return challenge } try await Task.sleep(for: .milliseconds(5)) } throw NSError(domain: "Missing approval prompt", code: 1) } } private nonisolated final class ApprovalProbe: @unchecked Sendable { private let lock = NSLock() private var instant = ContinuousClock.now private var reads = 0 private var stopped = false private var step = SSHTailscaleAuthentication.Step.pending("Approve at https://login.example/ssh/check") var now: ContinuousClock.Instant { lock.withLock { instant } } var readCount: Int { lock.withLock { reads } } func finish(_ step: SSHTailscaleAuthentication.Step) { lock.withLock { self.step = step } } func advanceTime() { lock.withLock { instant = instant.advanced(by: .seconds(601)) } } func disconnect() { lock.withLock { stopped = true } } func next() throws -> SSHTailscaleAuthentication.Step { try lock.withLock { reads += 1 if stopped { throw TermindSSHError.disconnected } return step } } }