import APIClient import Foundation import HostRegistry import Testing import WireProtocol @testable import WebTerm /// C1 · Access-token UX in the pairing flow (ios-completion §1.1). /// /// Every branch of the frozen contract is pinned here, because three of the four /// `POST /auth` outcomes are NOT errors and must be told apart: /// 204+Set-Cookie = save it · 204 without = this host has no auth (save nothing) /// · 401 = wrong token · 429 = back off. @MainActor @Suite("Pairing access token") struct PairingTokenViewModelTests { // MARK: - Scripted probe (records what the VM asked for, in order) private actor ProbeRecorder { /// `(endpoint, token)` per host-verification call — the token argument is /// what proves the candidate reaches the probe. private(set) var verifyCalls: [(origin: String, token: String?)] = [] private(set) var validateCalls: [String] = [] private var verifyResults: [Result] private var validateResults: [Result] init( verifyResults: [Result] = [], validateResults: [Result] = [] ) { self.verifyResults = verifyResults self.validateResults = validateResults } func verify( _ endpoint: HostEndpoint, _ token: AccessToken? ) -> Result { verifyCalls = verifyCalls + [(endpoint.originHeader, token?.rawValue)] guard let next = verifyResults.first else { return .success(endpoint) } verifyResults = Array(verifyResults.dropFirst()) return next } func validate( _ token: AccessToken ) -> Result { validateCalls = validateCalls + [token.rawValue] guard let next = validateResults.first else { return .success(.valid) } validateResults = Array(validateResults.dropFirst()) return next } } private static let base = "http://192.168.1.5:3000" /// 32 chars from the frozen charset — shape-valid, so any rejection in a test /// comes from the server outcome, not from validation. private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345" private func makeViewModel( store: any HostStore, recorder: ProbeRecorder ) -> PairingViewModel { PairingViewModel( store: store, probe: PairingViewModel.Probe( verifyHost: { endpoint, token in await recorder.verify(endpoint, token) }, validateToken: { _, token in await recorder.validate(token) } ) ) } /// Drive the VM to the token prompt the way the user gets there: the host /// answers 401, which the probe reports as `originRejected` with the /// both-remedies hint. private func viewModelAtTokenPrompt( store: any HostStore = InMemoryHostStore(), recorder: ProbeRecorder ) async -> PairingViewModel { let viewModel = makeViewModel(store: store, recorder: recorder) viewModel.submitManualURL(Self.base) await viewModel.confirmConnect() viewModel.beginAccessTokenEntry() // Loud, not silent: a recorder whose first verification does NOT fail // with a 401 would leave the VM elsewhere and make every assertion below // vacuous. if case .awaitingToken = viewModel.phase {} else { Issue.record("harness expected .awaitingToken, got \(viewModel.phase)") } return viewModel } // MARK: - 401 → prompt @Test("401 配对失败 → 提供「输入访问令牌」,进入令牌输入态") func unauthorizedFailureOffersTokenPrompt() async throws { // The hint text itself is the probe's (pinned in // PairingProbeUnauthorizedTests against the REAL probe); here it stands // in verbatim, because the VM must surface it unchanged. let hint = "主机以 401 拒绝了这次配对……请输入访问令牌后重试;" + "或在主机上设置 ALLOWED_ORIGINS=\(Self.base) 后重启 web-terminal。" let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: hint))]) let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder) viewModel.submitManualURL(Self.base) await viewModel.confirmConnect() guard case .failed(_, let failure) = viewModel.phase else { Issue.record("expected .failed, got \(viewModel.phase)") return } #expect(failure.action == .enterAccessToken) // The ambiguity is spelled out: BOTH remedies, since the server answers // 401 for a bad token and a rejected Origin alike. #expect(failure.message.contains("访问令牌")) #expect(failure.message.contains("ALLOWED_ORIGINS=\(Self.base)")) viewModel.beginAccessTokenEntry() guard case .awaitingToken = viewModel.phase else { Issue.record("expected .awaitingToken, got \(viewModel.phase)") return } } @Test("非 401 失败不给令牌入口(beginAccessTokenEntry 无副作用)") func nonUnauthorizedFailureHasNoTokenEntry() async throws { let recorder = ProbeRecorder(verifyResults: [.failure(.timeout)]) let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder) viewModel.submitManualURL(Self.base) await viewModel.confirmConnect() viewModel.beginAccessTokenEntry() guard case .failed(_, let failure) = viewModel.phase else { Issue.record("expected .failed, got \(viewModel.phase)") return } #expect(failure.action == .retry) } // MARK: - Shape validation happens BEFORE any network I/O @Test("令牌太短 → 本地就拒(不发 /auth),话术只带长度不带令牌") func tooShortTokenIsRejectedLocally() async throws { let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))]) let viewModel = await viewModelAtTokenPrompt(recorder: recorder) await viewModel.submitAccessToken("short") #expect(await recorder.validateCalls.isEmpty) let rejection = try #require(viewModel.tokenRejection) #expect(rejection.contains("\(5)")) #expect(!rejection.contains("short")) // never echo the value guard case .awaitingToken = viewModel.phase else { Issue.record("expected to stay in .awaitingToken, got \(viewModel.phase)") return } } @Test("令牌含非法字符 → 本地就拒,不发 /auth") func invalidCharactersRejectedLocally() async throws { let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))]) let viewModel = await viewModelAtTokenPrompt(recorder: recorder) await viewModel.submitAccessToken("abcdefghijklmnop qrstuv") #expect(await recorder.validateCalls.isEmpty) #expect(viewModel.tokenRejection?.isEmpty == false) } // MARK: - The four §1.1 outcomes @Test("204+Set-Cookie(valid)→ 带令牌重跑探针,主机连令牌一起入库") func validTokenIsSavedWithTheHost() async throws { let store = InMemoryHostStore() let recorder = ProbeRecorder( verifyResults: [.failure(.originRejected(hint: "401"))], // first, unauthenticated validateResults: [.success(.valid)] ) let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder) await viewModel.submitAccessToken(Self.goodToken) // The re-verification carried the candidate token (2nd verify call). let verifyCalls = await recorder.verifyCalls #expect(verifyCalls.count == 2) #expect(verifyCalls.first?.token == nil) #expect(verifyCalls.last?.token == Self.goodToken) // …and the persisted record has it (Keychain in production). guard case .paired(let host) = viewModel.phase else { Issue.record("expected .paired, got \(viewModel.phase)") return } #expect(host.accessToken?.rawValue == Self.goodToken) let stored = try await store.loadAll() #expect(stored.count == 1) #expect(stored.first?.accessToken?.rawValue == Self.goodToken) #expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken) } @Test("204 但没有 Set-Cookie(该主机未启用鉴权)→ 绝不保存令牌,也不声称已认证") func authDisabledNeverPersistsAToken() async throws { let store = InMemoryHostStore() let recorder = ProbeRecorder( verifyResults: [.failure(.originRejected(hint: "401"))], validateResults: [.success(.authDisabled)] ) let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder) await viewModel.submitAccessToken(Self.goodToken) // Still in the prompt, told the truth, and NOTHING was stored. guard case .awaitingToken = viewModel.phase else { Issue.record("expected .awaitingToken, got \(viewModel.phase)") return } #expect(viewModel.tokenRejection == PairingCopy.tokenNotRequired) #expect(try await store.loadAll().isEmpty) // No second verification either — re-probing unauthenticated would just // reproduce the same failure (the cause is not the token). #expect(await recorder.verifyCalls.count == 1) } @Test("authDisabled 之后即使配对成功也不带令牌入库(候选已被清掉)") func authDisabledClearsTheCandidate() async throws { let store = InMemoryHostStore() let recorder = ProbeRecorder( verifyResults: [.failure(.originRejected(hint: "401"))], validateResults: [.success(.authDisabled)] ) let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder) await viewModel.submitAccessToken(Self.goodToken) // Back to confirm, then a (now succeeding) retry of the whole flow. viewModel.cancelAccessTokenEntry() await viewModel.confirmConnect() guard case .paired(let host) = viewModel.phase else { Issue.record("expected .paired, got \(viewModel.phase)") return } #expect(host.accessToken == nil) #expect(await recorder.verifyCalls.last?.token == nil) } @Test("401(令牌错)→ 留在输入态给出「令牌不正确」,不入库") func invalidTokenKeepsThePrompt() async throws { let store = InMemoryHostStore() let recorder = ProbeRecorder( verifyResults: [.failure(.originRejected(hint: "401"))], validateResults: [.success(.invalidToken)] ) let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder) await viewModel.submitAccessToken(Self.goodToken) #expect(viewModel.tokenRejection == PairingCopy.tokenInvalid) #expect(try await store.loadAll().isEmpty) guard case .awaitingToken = viewModel.phase else { Issue.record("expected .awaitingToken, got \(viewModel.phase)") return } } @Test("429 → 「稍后再试」话术,不入库") func rateLimitedKeepsThePrompt() async throws { let recorder = ProbeRecorder( verifyResults: [.failure(.originRejected(hint: "401"))], validateResults: [.success(.rateLimited)] ) let viewModel = await viewModelAtTokenPrompt(recorder: recorder) await viewModel.submitAccessToken(Self.goodToken) #expect(viewModel.tokenRejection == PairingCopy.tokenRateLimited) } @Test("/auth 传输失败 → 网络话术(不吞错、不假装令牌错)") func transportFailureIsSurfaced() async throws { let recorder = ProbeRecorder( verifyResults: [.failure(.originRejected(hint: "401"))], validateResults: [.failure(.unreachable("Connection refused"))] ) let viewModel = await viewModelAtTokenPrompt(recorder: recorder) await viewModel.submitAccessToken(Self.goodToken) let rejection = try #require(viewModel.tokenRejection) #expect(rejection.contains("Connection refused")) #expect(rejection != PairingCopy.tokenInvalid) } // MARK: - Recovering an already-paired host @Test("对同一 origin 再配对一次 = 原地更新(复用 id、不重复添加、补上令牌)") func repairingSameOriginUpdatesInPlace() async throws { // Arrange: a host paired before the server enabled WEBTERM_TOKEN. let store = InMemoryHostStore() let url = try #require(URL(string: Self.base)) let endpoint = try #require(HostEndpoint(baseURL: url)) let existing = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint) _ = try await store.upsert(existing) let recorder = ProbeRecorder( verifyResults: [.failure(.originRejected(hint: "401"))], validateResults: [.success(.valid)] ) let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder) // Act await viewModel.submitAccessToken(Self.goodToken) // Assert: ONE host, same id (every lastSessionId/watermark keyed by it // survives), original name kept, token now present. let hosts = try await store.loadAll() #expect(hosts.count == 1) #expect(hosts.first?.id == existing.id) #expect(hosts.first?.name == "书房 Mac") #expect(hosts.first?.accessToken?.rawValue == Self.goodToken) } @Test("原地更新不会因为「用户没改名字」而把主机名覆盖成 IP") func repairingKeepsUserChosenName() async throws { let store = InMemoryHostStore() let url = try #require(URL(string: Self.base)) let endpoint = try #require(HostEndpoint(baseURL: url)) _ = try await store.upsert( HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint) ) let viewModel = makeViewModel(store: store, recorder: ProbeRecorder()) viewModel.submitManualURL(Self.base) await viewModel.confirmConnect() #expect(try await store.loadAll().first?.name == "书房 Mac") } @Test("重新配对时用户改了名字 → 采用新名字") func repairingAdoptsEditedName() async throws { let store = InMemoryHostStore() let url = try #require(URL(string: Self.base)) let endpoint = try #require(HostEndpoint(baseURL: url)) _ = try await store.upsert( HostRegistry.Host(id: UUID(), name: "旧名字", endpoint: endpoint) ) let viewModel = makeViewModel(store: store, recorder: ProbeRecorder()) viewModel.submitManualURL(Self.base) viewModel.hostName = "客厅 Mac" await viewModel.confirmConnect() #expect(try await store.loadAll().first?.name == "客厅 Mac") } @Test("重新配对(未输新令牌)不会丢掉已保存的令牌") func repairingPreservesStoredToken() async throws { let store = InMemoryHostStore() let url = try #require(URL(string: Self.base)) let endpoint = try #require(HostEndpoint(baseURL: url)) let token = try AccessToken(validating: Self.goodToken) _ = try await store.upsert(HostRegistry.Host( id: UUID(), name: "书房 Mac", endpoint: endpoint, accessToken: token )) let viewModel = makeViewModel(store: store, recorder: ProbeRecorder()) viewModel.submitManualURL(Self.base) await viewModel.confirmConnect() #expect(try await store.loadAll().first?.accessToken == token) } // MARK: - Secret hygiene @Test("取消令牌输入 → 回到确认页,候选令牌被丢弃") func cancellingTokenEntryDropsTheCandidate() async throws { let recorder = ProbeRecorder( verifyResults: [.failure(.originRejected(hint: "401"))], validateResults: [.success(.valid)] ) let viewModel = await viewModelAtTokenPrompt(recorder: recorder) viewModel.cancelAccessTokenEntry() guard case .confirming = viewModel.phase else { Issue.record("expected .confirming, got \(viewModel.phase)") return } #expect(viewModel.tokenRejection == nil) await viewModel.confirmConnect() #expect(await recorder.verifyCalls.last?.token == nil) } @Test("换一个配对目标不会继承上一个目标的令牌") func newTargetDoesNotInheritTheToken() async throws { let recorder = ProbeRecorder(validateResults: [.success(.valid)]) let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder) viewModel.submitManualURL(Self.base) await viewModel.confirmConnect() // succeeds → paired // A fresh VM state is what the sheet gives on re-entry; here we assert the // reset path itself. viewModel.cancel() viewModel.submitManualURL("http://192.168.1.9:3000") await viewModel.confirmConnect() #expect(await recorder.verifyCalls.last?.token == nil) #expect(await recorder.verifyCalls.last?.origin == "http://192.168.1.9:3000") } @Test("令牌绝不出现在任何可观察状态里(阶段/话术/主机描述)") func tokenNeverLeaksIntoObservableState() async throws { let store = InMemoryHostStore() let recorder = ProbeRecorder( verifyResults: [.failure(.originRejected(hint: "401"))], validateResults: [.success(.valid)] ) let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder) await viewModel.submitAccessToken(Self.goodToken) guard case .paired(let host) = viewModel.phase else { Issue.record("expected .paired, got \(viewModel.phase)") return } #expect(!String(describing: viewModel.phase).contains(Self.goodToken)) #expect(!host.description.contains(Self.goodToken)) #expect(host.description.contains("hasAccessToken: true")) // presence only #expect(viewModel.tokenRejection == nil) } }