import Foundation import Testing import WireProtocol import HostRegistry // 迁移安全 + 持久化形状(B2)。 // 已配对的主机是**当前线上构建**写进 Keychain 的旧形状记录(没有 accessToken 字段); // 新构建必须照常读出它们 —— 缺字段 = nil,绝不是解码失败(否则用户的主机列表整体变 // .corruptedData,等于要求重新配对所有主机)。 /// 上一版 `Host` 的编码形状:只有 id/name/endpoint,合成 Codable。 /// 用它来生成"旧记录",比手写 JSON 更能保真上一版编码器的输出。 private struct LegacyHostRecord: Encodable { let id: UUID let name: String let endpoint: HostEndpoint } private func encodedLegacyPayload(_ host: Host) throws -> Data { try JSONEncoder().encode([ LegacyHostRecord(id: host.id, name: host.name, endpoint: host.endpoint), ]) } // MARK: - 旧形状读取(迁移回归) @Test("迁移:旧形状记录(无 accessToken 字段)照常读出,令牌为 nil") func legacyRecordWithoutTokenFieldStillLoads() async throws { // Arrange let legacy = Fixtures.makeHost(name: "paired-before-tokens") let shim = FakeSecItemShim() shim.forceCopyData(try encodedLegacyPayload(legacy)) let store = KeychainHostStore(shim: shim) // Act let loaded = try await store.loadAll() // Assert #expect(loaded.count == 1) #expect(loaded.first?.id == legacy.id) #expect(loaded.first?.name == "paired-before-tokens") #expect(loaded.first?.endpoint == legacy.endpoint) #expect(loaded.first?.accessToken == nil) #expect(loaded == [legacy]) // 旧记录 == 令牌为 nil 的新值 } @Test("迁移:逐字旧 JSON(键名钉死 id/name/endpoint.baseURL)解码成功且令牌为 nil") func literalLegacyJSONDecodesWithNilToken() throws { // Arrange —— 逐字旧形状,钉死上一版的键名(键名一改就红) let json = """ [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F",\ "name":"mac-studio","endpoint":{"baseURL":"http://192.168.1.5:3000"}}] """ // Act let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8)) // Assert #expect(hosts.count == 1) #expect(hosts.first?.name == "mac-studio") #expect(hosts.first?.endpoint.originHeader == "http://192.168.1.5:3000") #expect(hosts.first?.accessToken == nil) } @Test("被篡改的非法令牌值:降级为 nil,不把整张主机表打成 .corruptedData") func tamperedTokenValueDegradesToNilInsteadOfCorruptingTheList() async throws { // Arrange —— 只有外部篡改才可能出现非法值(写路径已在边界校验) let json = """ [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\ "endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":"short!"}] """ let shim = FakeSecItemShim() shim.forceCopyData(Data(json.utf8)) let store = KeychainHostStore(shim: shim) // Act let loaded = try await store.loadAll() // Assert #expect(loaded.count == 1) #expect(loaded.first?.name == "mac-studio") #expect(loaded.first?.accessToken == nil) } @Test("accessToken 为 JSON null:等同缺字段,令牌为 nil") func explicitNullTokenDecodesAsNil() throws { // Arrange let json = """ [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\ "endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":null}] """ // Act let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8)) // Assert #expect(hosts.first?.accessToken == nil) } @Test("endpoint 仍是硬校验:非 http(s) 的 baseURL → 解码失败(令牌宽松不放宽 endpoint)") func endpointRemainsStrictlyValidatedOnDecode() { // Arrange let json = """ [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"bad",\ "endpoint":{"baseURL":"ftp://192.168.1.5"}}] """ // Act / Assert #expect(throws: (any Error).self) { _ = try JSONDecoder().decode([Host].self, from: Data(json.utf8)) } } // MARK: - 新形状 @Test("新形状:令牌以纯字符串存在 accessToken 键下(键名/形状钉死)") func tokenIsPersistedAsAPlainStringUnderAccessTokenKey() throws { // Arrange let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken()) // Act let data = try JSONEncoder().encode(host) let object = try #require( JSONSerialization.jsonObject(with: data) as? [String: Any] ) // Assert #expect(object["accessToken"] as? String == Fixtures.validTokenString) } @Test("新形状:令牌为 nil 时不写 accessToken 键(不留空字段)") func nilTokenIsOmittedFromTheEncodedForm() throws { // Arrange let host = Fixtures.makeHost() // Act let data = try JSONEncoder().encode(host) let object = try #require( JSONSerialization.jsonObject(with: data) as? [String: Any] ) // Assert #expect(object.keys.contains("accessToken") == false) #expect(object.keys.sorted() == ["endpoint", "id", "name"]) } @Test("Codable 往返:带令牌的 Host 全字段保真") func codableRoundTripPreservesTheToken() throws { // Arrange let host = Fixtures.makeHost(name: "studio", urlString: "https://mac.ts.net:8443") .withAccessToken(Fixtures.makeToken()) // Act let decoded = try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) // Assert #expect(decoded == host) #expect(decoded.accessToken == Fixtures.makeToken()) #expect(decoded.endpoint.originHeader == "https://mac.ts.net:8443") } // MARK: - 不可变性 @Test("withAccessToken 不可变:返回新值,原 Host 不变") func withAccessTokenReturnsANewValueWithoutMutatingTheOriginal() { // Arrange let original = Fixtures.makeHost() // Act let withToken = original.withAccessToken(Fixtures.makeToken()) let cleared = withToken.withAccessToken(nil) // Assert #expect(original.accessToken == nil) #expect(withToken.accessToken == Fixtures.makeToken()) #expect(cleared.accessToken == nil) #expect(withToken.id == original.id) #expect(withToken.name == original.name) #expect(withToken.endpoint == original.endpoint) #expect(cleared == original) } @Test("Equatable 含令牌:令牌不同的同 id 主机不相等(UI 才能感知令牌变化)") func hostsDifferingOnlyByTokenAreNotEqual() { // Arrange let base = Fixtures.makeHost() // Act / Assert #expect(base.withAccessToken(Fixtures.makeToken()) != base) #expect(base.withAccessToken(Fixtures.makeToken()) != base.withAccessToken(Fixtures.makeToken(Fixtures.otherValidTokenString))) }