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.
436 lines
18 KiB
Swift
436 lines
18 KiB
Swift
import Foundation
|
||
import Testing
|
||
import TestSupport
|
||
import WireProtocol
|
||
import APIClient
|
||
|
||
/// B1 · git 面板**只读**端点(ios-completion §1.2;真源 = `src/`):
|
||
/// - `GET /projects/log?path=[&n=]`(`src/server.ts:1030-1056`)→ `GitLogResult`
|
||
/// (`src/types.ts:757-772`,含 w6/G4 的 `unpushed`/`upstream`);
|
||
/// - `GET /projects/pr?path=`(`src/server.ts:1058-1076`)→ `PrStatus`
|
||
/// (`src/types.ts:617-648`);有效 git 目录一律 200,降级写在 body 的 availability 里;
|
||
/// - `GET /projects/worktree/state?path=`(`src/server.ts:542-562`)→ `WorktreeState`
|
||
/// (`src/types.ts:392-408`,`sync` 各字段独立降级);
|
||
/// - `GET /sessions`(`src/server.ts:479-481`)→ `[HistorySession]`
|
||
/// (`src/http/history.ts:13-19`,`claude --resume` 历史)。
|
||
///
|
||
/// 三条 `/projects/*` 都做同样的三叉校验:`path` 缺失 → 400、非 git 目录 → 404、
|
||
/// 读失败 → 500。全部 RO ⇒ **绝不带 Origin**。
|
||
struct GitPanelReadTests {
|
||
private static let base = "http://192.168.1.5:3000"
|
||
private static let repoPath = "/Users/dev/web-terminal"
|
||
private static let encodedRepoPath = "%2FUsers%2Fdev%2Fweb-terminal"
|
||
|
||
private struct Fixture {
|
||
let http: FakeHTTPTransport
|
||
let client: APIClient
|
||
}
|
||
|
||
private func makeFixture() throws -> Fixture {
|
||
let baseURL = try #require(URL(string: Self.base))
|
||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||
let http = FakeHTTPTransport()
|
||
return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http))
|
||
}
|
||
|
||
private func routeURL(_ pathAndQuery: String) throws -> URL {
|
||
try #require(URL(string: Self.base + pathAndQuery))
|
||
}
|
||
|
||
// MARK: - Origin iff-G(RO 侧:四个端点一律不带 Origin)
|
||
|
||
@Test("Origin iff-G(RO 侧):log/pr/worktree-state/sessions 四个端点均为 GET 且不带 Origin")
|
||
func readOnlyGitEndpointsNeverCarryOrigin() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let query = "?path=\(Self.encodedRepoPath)"
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/log\(query)"), body: Data(#"{"commits":[]}"#.utf8)
|
||
)
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/pr\(query)"), body: Data(#"{"availability":"no-pr"}"#.utf8)
|
||
)
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/worktree/state\(query)"),
|
||
body: Data(#"{"path":"\#(Self.repoPath)"}"#.utf8)
|
||
)
|
||
await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data("[]".utf8))
|
||
|
||
// Act
|
||
_ = try await fixture.client.gitLog(path: Self.repoPath)
|
||
_ = try await fixture.client.prStatus(path: Self.repoPath)
|
||
_ = try await fixture.client.worktreeState(path: Self.repoPath)
|
||
_ = try await fixture.client.claudeSessions()
|
||
|
||
// Assert
|
||
let requests = await fixture.http.recordedRequests
|
||
#expect(requests.count == 4)
|
||
for request in requests {
|
||
#expect(request.httpMethod == "GET")
|
||
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
|
||
}
|
||
}
|
||
|
||
// MARK: - /projects/log
|
||
|
||
@Test("log 的 path 严格 percent-encode(单点);n 缺省则不带 n 参数")
|
||
func logPathIsStrictlyEncodedAndNilCountOmitsTheParam() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let raw = "/Users/dev/my proj+α"
|
||
let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1"
|
||
let expected = try routeURL("/projects/log?path=\(encoded)")
|
||
await fixture.http.queueSuccess(url: expected, body: Data(#"{"commits":[]}"#.utf8))
|
||
|
||
// Act
|
||
_ = try await fixture.client.gitLog(path: raw, n: nil)
|
||
|
||
// Assert
|
||
#expect(await fixture.http.recordedRequests.first?.url == expected)
|
||
}
|
||
|
||
@Test("log 的 n 客户端夹到 [1,50](镜像 src/http/git-log.ts GIT_LOG_MAX,服务端仍会再夹一次)")
|
||
func logCountIsClampedClientSide() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let body = Data(#"{"commits":[]}"#.utf8)
|
||
let cases: [(Int, Int)] = [(0, 1), (-7, 1), (10, 10), (999, 50)]
|
||
for (_, clamped) in cases {
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)&n=\(clamped)"),
|
||
body: body
|
||
)
|
||
}
|
||
|
||
// Act
|
||
for (requested, _) in cases {
|
||
_ = try await fixture.client.gitLog(path: Self.repoPath, n: requested)
|
||
}
|
||
|
||
// Assert — FakeHTTPTransport 按整 URL 精确匹配,走到这里即夹取正确
|
||
let queries = await fixture.http.recordedRequests.compactMap(\.url?.query)
|
||
#expect(queries == cases.map { "path=\(Self.encodedRepoPath)&n=\($0.1)" })
|
||
}
|
||
|
||
@Test("GitLogResult 全字段解码(hash/at/subject/unpushed + truncated + upstream,w6/G4)")
|
||
func gitLogDecodesFullSample() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let body = """
|
||
{"commits":[{"hash":"abc1234","at":1720000000000,"subject":"feat: x","unpushed":true},\
|
||
{"hash":"def5678","at":1719990000000,"subject":"fix: y"}],\
|
||
"truncated":true,"upstream":"origin/develop"}
|
||
"""
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
|
||
)
|
||
|
||
// Act
|
||
let result = try await fixture.client.gitLog(path: Self.repoPath)
|
||
|
||
// Assert
|
||
#expect(result.commits.count == 2)
|
||
#expect(result.truncated)
|
||
#expect(result.upstream == "origin/develop")
|
||
let first = try #require(result.commits.first)
|
||
#expect(first.hash == "abc1234")
|
||
#expect(first.at == 1_720_000_000_000)
|
||
#expect(first.subject == "feat: x")
|
||
#expect(first.unpushed == true)
|
||
#expect(result.commits.last?.unpushed == nil) // 缺省 ⇒ nil(不是 false)
|
||
}
|
||
|
||
@Test("log 容忍:畸形 commit 逐条丢弃、subject 缺省为空、truncated 缺省 false、未知字段忽略")
|
||
func gitLogToleratesDegradedShapes() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let body = """
|
||
{"commits":[{"hash":"ok1","at":1,"subject":"s"},42,{"at":2},{"hash":"ok2","at":3},\
|
||
{"hash":"bad","at":"nope"}],"futureField":{"x":1}}
|
||
"""
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
|
||
)
|
||
|
||
// Act
|
||
let result = try await fixture.client.gitLog(path: Self.repoPath)
|
||
|
||
// Assert
|
||
#expect(result.commits.map(\.hash) == ["ok1", "ok2"])
|
||
#expect(result.commits.last?.subject == "")
|
||
#expect(!result.truncated)
|
||
#expect(result.upstream == nil)
|
||
}
|
||
|
||
@Test("log 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒")
|
||
func gitLogMapsErrorStatuses() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let url = try routeURL("/projects/log?path=\(Self.encodedRepoPath)")
|
||
for status in [400, 404, 500] {
|
||
await fixture.http.queueSuccess(
|
||
url: url, status: status, body: Data(#"{"error":"nope"}"#.utf8)
|
||
)
|
||
}
|
||
|
||
// Act + Assert
|
||
let expected: [APIClientError] = [
|
||
.projectPathInvalid, .projectNotFound, .gitDataUnavailable,
|
||
]
|
||
for error in expected {
|
||
await #expect(throws: error) {
|
||
_ = try await fixture.client.gitLog(path: Self.repoPath)
|
||
}
|
||
#expect(!error.message.isEmpty)
|
||
}
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.gitLog(path: "")
|
||
}
|
||
#expect(await fixture.http.recordedRequests.count == 3) // 空 path 未联网
|
||
}
|
||
|
||
@Test("log 200 但 body 非对象 → invalidResponseBody")
|
||
func gitLogRejectsNonObjectBody() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"),
|
||
body: Data("[1,2]".utf8)
|
||
)
|
||
|
||
// Act + Assert
|
||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||
_ = try await fixture.client.gitLog(path: Self.repoPath)
|
||
}
|
||
}
|
||
|
||
// MARK: - /projects/pr
|
||
|
||
@Test("PrStatus 全字段解码(availability=ok + checks 嵌套计数)")
|
||
func prStatusDecodesFullSample() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let body = """
|
||
{"availability":"ok","number":42,"title":"feat: x","url":"https://github.com/a/b/pull/42",\
|
||
"state":"open","isDraft":false,"mergeable":"mergeable","headRefName":"feat/x",\
|
||
"baseRefName":"develop","checks":{"total":5,"passing":4,"failing":0,"pending":1}}
|
||
"""
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/pr?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
|
||
)
|
||
|
||
// Act
|
||
let pr = try await fixture.client.prStatus(path: Self.repoPath)
|
||
|
||
// Assert
|
||
#expect(pr.availability == .ok)
|
||
#expect(pr.number == 42)
|
||
#expect(pr.state == "open")
|
||
#expect(pr.isDraft == false)
|
||
#expect(pr.mergeable == "mergeable")
|
||
#expect(pr.headRefName == "feat/x")
|
||
#expect(pr.baseRefName == "develop")
|
||
#expect(pr.checks == PrCheckSummary(total: 5, passing: 4, failing: 0, pending: 1))
|
||
}
|
||
|
||
@Test("PrStatus 降级:未知/缺失 availability → .error;各降级值可解;字段缺省 → nil")
|
||
func prStatusDegradesUnknownAvailability() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)")
|
||
let bodies = [
|
||
#"{"availability":"quantum-ci"}"#, // 未来值 → .error
|
||
#"{}"#, // 缺失 → .error
|
||
#"{"availability":"not-installed"}"#,
|
||
#"{"availability":"unauthenticated"}"#,
|
||
#"{"availability":"disabled"}"#,
|
||
#"{"availability":"no-pr"}"#,
|
||
]
|
||
for body in bodies {
|
||
await fixture.http.queueSuccess(url: url, body: Data(body.utf8))
|
||
}
|
||
|
||
// Act
|
||
var seen: [PrAvailability] = []
|
||
for _ in bodies {
|
||
seen.append(try await fixture.client.prStatus(path: Self.repoPath).availability)
|
||
}
|
||
|
||
// Assert
|
||
#expect(seen == [.error, .error, .notInstalled, .unauthenticated, .disabled, .noPr])
|
||
// 非 ok 时兄弟字段一律缺省 → nil(绝不编造 PR 号)
|
||
await fixture.http.queueSuccess(url: url, body: Data(#"{"availability":"no-pr"}"#.utf8))
|
||
let degraded = try await fixture.client.prStatus(path: Self.repoPath)
|
||
#expect(degraded.number == nil)
|
||
#expect(degraded.checks == nil)
|
||
}
|
||
|
||
@Test("pr 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒")
|
||
func prStatusMapsErrorStatuses() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)")
|
||
for status in [400, 404, 500] {
|
||
await fixture.http.queueSuccess(url: url, status: status)
|
||
}
|
||
|
||
// Act + Assert
|
||
for error in [APIClientError.projectPathInvalid, .projectNotFound, .gitDataUnavailable] {
|
||
await #expect(throws: error) {
|
||
_ = try await fixture.client.prStatus(path: Self.repoPath)
|
||
}
|
||
}
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.prStatus(path: "")
|
||
}
|
||
}
|
||
|
||
// MARK: - /projects/worktree/state
|
||
|
||
@Test("WorktreeState 全字段解码;sync.lastFetchMs 是 fs.stat().mtimeMs —— **带小数**也必须解出来")
|
||
func worktreeStateDecodesFullSampleIncludingFractionalMtime() async throws {
|
||
// Arrange — 1785390645813.5327 是真实 stat().mtimeMs 的形状(APFS 纳秒精度)
|
||
let fixture = try makeFixture()
|
||
let body = """
|
||
{"path":"\(Self.repoPath)","branch":"develop","dirtyCount":3,\
|
||
"sync":{"upstream":"origin/develop","ahead":2,"behind":1,\
|
||
"lastFetchMs":1785390645813.5327,"detached":false}}
|
||
"""
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"),
|
||
body: Data(body.utf8)
|
||
)
|
||
|
||
// Act
|
||
let state = try await fixture.client.worktreeState(path: Self.repoPath)
|
||
|
||
// Assert
|
||
#expect(state.path == Self.repoPath)
|
||
#expect(state.branch == "develop")
|
||
#expect(state.dirtyCount == 3)
|
||
let sync = try #require(state.sync)
|
||
#expect(sync.upstream == "origin/develop")
|
||
#expect(sync.ahead == 2)
|
||
#expect(sync.behind == 1)
|
||
#expect(sync.detached == false)
|
||
let lastFetchMs = try #require(sync.lastFetchMs)
|
||
#expect(abs(lastFetchMs - 1_785_390_645_813.5327) < 0.001)
|
||
}
|
||
|
||
@Test("WorktreeState 各字段独立降级:无 upstream/detached HEAD/从未 fetch 都是正常态")
|
||
func worktreeStateDegradesEachFieldIndependently() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let body = """
|
||
{"path":"\(Self.repoPath)","sync":{"detached":true},"unknownField":1}
|
||
"""
|
||
await fixture.http.queueSuccess(
|
||
url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"),
|
||
body: Data(body.utf8)
|
||
)
|
||
|
||
// Act
|
||
let state = try await fixture.client.worktreeState(path: Self.repoPath)
|
||
|
||
// Assert
|
||
#expect(state.branch == nil)
|
||
#expect(state.dirtyCount == nil)
|
||
let sync = try #require(state.sync)
|
||
#expect(sync.detached == true)
|
||
#expect(sync.upstream == nil)
|
||
#expect(sync.ahead == nil)
|
||
#expect(sync.behind == nil)
|
||
#expect(sync.lastFetchMs == nil) // 从未 fetch —— 绝不编造时间戳
|
||
}
|
||
|
||
@Test("worktree/state 400/404/500 → projectPathInvalid/worktreeNotFound/gitDataUnavailable;非对象 body → invalidResponseBody")
|
||
func worktreeStateMapsErrorStatuses() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let url = try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)")
|
||
for status in [400, 404, 500] {
|
||
await fixture.http.queueSuccess(url: url, status: status)
|
||
}
|
||
await fixture.http.queueSuccess(url: url, body: Data("7".utf8))
|
||
|
||
// Act + Assert
|
||
for error in [APIClientError.projectPathInvalid, .worktreeNotFound, .gitDataUnavailable] {
|
||
await #expect(throws: error) {
|
||
_ = try await fixture.client.worktreeState(path: Self.repoPath)
|
||
}
|
||
#expect(!error.message.isEmpty)
|
||
}
|
||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||
_ = try await fixture.client.worktreeState(path: Self.repoPath)
|
||
}
|
||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||
_ = try await fixture.client.worktreeState(path: "")
|
||
}
|
||
}
|
||
|
||
// MARK: - GET /sessions(claude --resume 历史)
|
||
|
||
@Test("HistorySession 解码:mtimeMs 来自 fs.stat() —— **带小数**必须解出来(否则整条被丢)")
|
||
func claudeSessionsDecodeFractionalMtime() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let body = """
|
||
[{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cwd":"/Users/dev/web-terminal",\
|
||
"project":"web-terminal","mtimeMs":1785390645813.5327,"preview":"修一下 CJK locale"},\
|
||
{"id":"11111111-2222-4333-8444-555555555555","cwd":"","project":"unknown",\
|
||
"mtimeMs":1700000000000,"preview":""}]
|
||
"""
|
||
await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data(body.utf8))
|
||
|
||
// Act
|
||
let sessions = try await fixture.client.claudeSessions()
|
||
|
||
// Assert
|
||
#expect(sessions.count == 2)
|
||
let first = try #require(sessions.first)
|
||
#expect(first.id == "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b")
|
||
#expect(first.cwd == "/Users/dev/web-terminal")
|
||
#expect(first.project == "web-terminal")
|
||
#expect(abs(first.mtimeMs - 1_785_390_645_813.5327) < 0.001)
|
||
#expect(first.preview == "修一下 CJK locale")
|
||
#expect(sessions.last?.mtimeMs == 1_700_000_000_000) // 整数也要能解
|
||
}
|
||
|
||
@Test("/sessions 容忍:畸形条目逐条丢弃、可选字段缺省、非数组 body → invalidResponseBody")
|
||
func claudeSessionsToleratesDegradedShapes() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
let url = try routeURL("/sessions")
|
||
let degraded = """
|
||
[{"id":"keep-me","mtimeMs":1},"nope",{"cwd":"/x"},{"id":"","mtimeMs":2},\
|
||
{"id":"also-keep","mtimeMs":3,"extra":true}]
|
||
"""
|
||
await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8))
|
||
await fixture.http.queueSuccess(url: url, body: Data(#"{"error":"x"}"#.utf8))
|
||
|
||
// Act
|
||
let sessions = try await fixture.client.claudeSessions()
|
||
|
||
// Assert — id 是 `claude --resume <id>` 的实参,缺失/空 ⇒ 该条无用,丢弃
|
||
#expect(sessions.map(\.id) == ["keep-me", "also-keep"])
|
||
#expect(sessions.first?.cwd == "")
|
||
#expect(sessions.first?.project == "")
|
||
#expect(sessions.first?.preview == "")
|
||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||
_ = try await fixture.client.claudeSessions()
|
||
}
|
||
}
|
||
|
||
@Test("/sessions 非 200 → unexpectedStatus")
|
||
func claudeSessionsRejectsBadStatus() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(url: try routeURL("/sessions"), status: 500)
|
||
|
||
// Act + Assert
|
||
await #expect(throws: APIClientError.unexpectedStatus(500)) {
|
||
_ = try await fixture.client.claudeSessions()
|
||
}
|
||
}
|
||
}
|