Files
web-terminal/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTokenMigrationTests.swift
Yaojia Wang 850531fd07 feat(ios): access-token support + git-panel endpoints across the package layer
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.
2026-07-30 12:45:26 +02:00

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)))
}