APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four distinct outcomes from the frozen contract, Cookie/Accept landed at the same single header choke point that already enforces Origin-iff-G, plus the whole project-ops surface the server has had since late July and iOS consumed none of: /projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/ fetch, worktree create/remove/prune, GET /sessions, follow-up queue. HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain under the existing SecItemShim conventions (device-only, never synchronizable), charset/length validated at the boundary, old token-less records still decode. SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from the same point that writes Origin, and a 401 handshake is a terminal .unauthorized -- never entering the backoff loop, since retrying one wrong shared token is a brute-force generator against the server's 10/min limiter.
193 lines
6.5 KiB
Swift
193 lines
6.5 KiB
Swift
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)))
|
|
}
|