Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
Closes the six remediation items from the 2026-07-29 iOS completion audit plus the whole P2 wave and Android access-token parity. The audit's headline was that the client was code-complete but stuck at the device door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware once, and it had fallen two months behind the server (Android had the git panel, iOS had none) while neither native client could connect at all once WEBTERM_TOKEN was set. Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49% and is now actually in the coverage gate, which it never was. Device build now succeeds on the free personal team. src/ and public/ are untouched — the git-panel endpoints already existed server-side; iOS simply never consumed them. # Conflicts: # android/.gitignore # android/README.md # android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt # android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt # android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt # android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt # android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt # docs/PROGRESS_LOG.md
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// B1 · 访问令牌(`WEBTERM_TOKEN`)—— ios-completion §1.1 冻结契约。
|
||||
///
|
||||
/// 服务端事实(`src/http/auth.ts` + `src/server.ts:393-460`):
|
||||
/// - `POST /auth`,body `{"token":"<t>"}`,`Content-Type: application/json`;
|
||||
/// - **`Accept` 必须不含 `text/html`** —— 含则被当成表单走 302,而不是 204/401;
|
||||
/// - 204 **有** `Set-Cookie: webterm_auth=…` ⇒ 令牌正确;
|
||||
/// - 204 **无** `Set-Cookie` ⇒ 该服务器**没开鉴权**(**不得**据此认为"已认证");
|
||||
/// - 401 ⇒ 令牌错;429 ⇒ 限流(10 次/分/IP)。
|
||||
///
|
||||
/// 原生客户端**自己知道令牌**,因此手写 `Cookie: webterm_auth=<t>`,不解析
|
||||
/// `Set-Cookie`、不依赖系统 cookie jar。`Cookie` 与 `Origin` **正交**:令牌
|
||||
/// **不替代** Origin 检查,两者都要带(`server.ts` 先 Origin 再 cookie)。
|
||||
///
|
||||
/// 安全:令牌**绝不进 URL query**(`?token=` bootstrap 只给浏览器用)、绝不进日志。
|
||||
struct AccessTokenTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
|
||||
/// 合法形状:16–512 个 `[A-Za-z0-9._~+/=-]` 字符(CLAUDE.md / config 校验)。
|
||||
private static let token = "s3cret-token_value.~+/="
|
||||
private static let setCookieValue =
|
||||
"webterm_auth=\(token); Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict"
|
||||
|
||||
private func makeEndpoint(_ base: String = AccessTokenTests.base) throws -> HostEndpoint {
|
||||
let url = try #require(URL(string: base))
|
||||
return try #require(HostEndpoint(baseURL: url))
|
||||
}
|
||||
|
||||
private func routeURL(_ path: String) throws -> URL {
|
||||
try #require(URL(string: Self.base + path))
|
||||
}
|
||||
|
||||
private func makeClient(
|
||||
token: String?, http: FakeHTTPTransport
|
||||
) throws -> APIClient {
|
||||
APIClient(endpoint: try makeEndpoint(), http: http, accessToken: token)
|
||||
}
|
||||
|
||||
// MARK: - POST /auth 探针:请求形状
|
||||
|
||||
@Test("POST /auth:body 恰为 {\"token\":…}、Content-Type=JSON、Accept 不含 text/html、令牌绝不进 URL")
|
||||
func authProbeRequestShapeIsFrozen() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/auth"), status: 204,
|
||||
headers: ["Set-Cookie": Self.setCookieValue]
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await client.probeAccessToken(Self.token)
|
||||
|
||||
// Assert
|
||||
let request = try #require(await http.recordedRequests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.url == (try routeURL("/auth")))
|
||||
#expect(request.url?.query == nil) // 令牌绝不进 URL query
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
// /auth 是变更型 POST(签发 cookie)⇒ 照 Origin-iff-G 规则带 Origin(与 Android 一致)
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == (try makeEndpoint()).originHeader)
|
||||
let accept = try #require(request.value(forHTTPHeaderField: "Accept"))
|
||||
#expect(!accept.contains("text/html")) // 含 text/html 会被当成表单走 302
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(Set(object.keys) == Set(["token"]))
|
||||
#expect(object["token"] as? String == Self.token)
|
||||
}
|
||||
|
||||
@Test("每个请求都带 Accept: application/json —— 未认证时服务器才回 401 JSON 而不是 302 登录页")
|
||||
func everyRequestAcceptsJSONSoTheGateAnswers401NotARedirect() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: Self.token, http: http)
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
|
||||
await http.queueSuccess(
|
||||
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await client.liveSessions()
|
||||
try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString)))
|
||||
|
||||
// Assert
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.count == 2)
|
||||
for request in requests {
|
||||
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - POST /auth 探针:四种结局互不混淆
|
||||
|
||||
@Test("204 + Set-Cookie ⇒ .valid(令牌正确,可保存)")
|
||||
func probe204WithSetCookieMeansTokenValid() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/auth"), status: 204,
|
||||
headers: ["Set-Cookie": Self.setCookieValue]
|
||||
)
|
||||
|
||||
// Act
|
||||
let result = try await client.probeAccessToken(Self.token)
|
||||
|
||||
// Assert
|
||||
#expect(result == .valid)
|
||||
}
|
||||
|
||||
@Test("204 但无 Set-Cookie ⇒ .authDisabled(服务器没开鉴权),绝不报成 .valid")
|
||||
func probe204WithoutSetCookieMeansAuthDisabledNotAuthenticated() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 204)
|
||||
|
||||
// Act
|
||||
let result = try await client.probeAccessToken(Self.token)
|
||||
|
||||
// Assert
|
||||
#expect(result == .authDisabled)
|
||||
#expect(result != .valid) // 冻结契约:不得据此认为"已认证"
|
||||
}
|
||||
|
||||
@Test("401 ⇒ .invalidToken(不是 .unauthorized —— /auth 自己的 401 是路由语义)")
|
||||
func probe401MeansInvalidTokenNotGateUnauthorized() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/auth"), status: 401,
|
||||
body: Data(#"{"error":"invalid token"}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let result = try await client.probeAccessToken(Self.token)
|
||||
|
||||
// Assert
|
||||
#expect(result == .invalidToken)
|
||||
}
|
||||
|
||||
@Test("429 ⇒ .rateLimited(10 次/分/IP,src/server.ts:399-402)")
|
||||
func probe429MeansRateLimited() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 429)
|
||||
|
||||
// Act
|
||||
let result = try await client.probeAccessToken(Self.token)
|
||||
|
||||
// Assert
|
||||
#expect(result == .rateLimited)
|
||||
}
|
||||
|
||||
@Test("其他状态码(500) ⇒ unexpectedStatus,不猜测")
|
||||
func probeUnexpectedStatusThrows() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 500)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unexpectedStatus(500)) {
|
||||
_ = try await client.probeAccessToken(Self.token)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("形状非法的令牌(过短/越界字符/过长)联网前就拒:malformedToken,零请求")
|
||||
func malformedTokenIsRejectedBeforeAnyNetworkIO() async throws {
|
||||
// Arrange — 服务器启动时就校验 16–512 与字符集,故形状非法者不可能是正确令牌
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
let malformed = [
|
||||
"short", // < 16
|
||||
String(repeating: "a", count: 513), // > 512
|
||||
"has space in it x", // 越界字符:空格
|
||||
"has\r\nCRLF-injection-x", // 越界字符:CRLF(头注入)
|
||||
"中文令牌中文令牌中文令牌中文令牌", // 越界字符:非 ASCII
|
||||
]
|
||||
|
||||
// Act + Assert
|
||||
for candidate in malformed {
|
||||
await #expect(throws: APIClientError.malformedToken) {
|
||||
_ = try await client.probeAccessToken(candidate)
|
||||
}
|
||||
}
|
||||
#expect(await http.recordedRequests.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Cookie 与 Origin 正交
|
||||
|
||||
@Test("Cookie iff 配置了令牌:RO 带 Cookie 不带 Origin;G 带 Cookie **并且**带 Origin")
|
||||
func cookieIsOrthogonalToTheOriginRule() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let endpoint = try makeEndpoint()
|
||||
let client = APIClient(endpoint: endpoint, http: http, accessToken: Self.token)
|
||||
let id = try #require(UUID(uuidString: Self.sessionIdString))
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
|
||||
await http.queueSuccess(
|
||||
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await client.liveSessions()
|
||||
try await client.killSession(id: id)
|
||||
|
||||
// Assert
|
||||
let requests = await http.recordedRequests
|
||||
let readOnly = try #require(requests.first)
|
||||
let guarded = try #require(requests.last)
|
||||
#expect(readOnly.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||||
#expect(readOnly.value(forHTTPHeaderField: "Origin") == nil) // RO 仍绝不带 Origin
|
||||
#expect(guarded.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||||
#expect(guarded.value(forHTTPHeaderField: "Origin") == endpoint.originHeader) // 令牌不替代 Origin
|
||||
}
|
||||
|
||||
@Test("未配置令牌 ⇒ 完全不带 Cookie 头(LAN 零配置行为不变)")
|
||||
func noTokenConfiguredMeansNoCookieHeaderAtAll() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
|
||||
|
||||
// Act
|
||||
_ = try await client.liveSessions()
|
||||
|
||||
// Assert
|
||||
let request = try #require(await http.recordedRequests.first)
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
|
||||
}
|
||||
|
||||
@Test("配置了形状非法的令牌 ⇒ 任何调用联网前抛 malformedToken(绝不静默发无认证请求)")
|
||||
func malformedConfiguredTokenFailsFastOnEveryCall() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: "bad token", http: http)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.malformedToken) {
|
||||
_ = try await client.liveSessions()
|
||||
}
|
||||
#expect(await http.recordedRequests.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 401 语义:类型化 .unauthorized,可与网络错区分
|
||||
|
||||
@Test("普通 RO 端点收 401 ⇒ 类型化 .unauthorized(不是 unexpectedStatus/网络错)")
|
||||
func gate401OnReadOnlyEndpointSurfacesAsUnauthorized() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: nil, http: http)
|
||||
await http.queueSuccess(
|
||||
url: try routeURL("/live-sessions"), status: 401,
|
||||
body: Data(#"{"error":"authentication required"}"#.utf8)
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unauthorized) {
|
||||
_ = try await client.liveSessions()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("普通 G 端点收 401 ⇒ 同样是 .unauthorized,且话术非空(UI 引导补令牌)")
|
||||
func gate401OnGuardedEndpointSurfacesAsUnauthorized() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: Self.token, http: http)
|
||||
await http.queueSuccess(
|
||||
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 401
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unauthorized) {
|
||||
try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString)))
|
||||
}
|
||||
#expect(!APIClientError.unauthorized.message.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 令牌零泄漏
|
||||
|
||||
@Test("APIClient 的 description/debugDescription 不含令牌明文(零日志泄漏)")
|
||||
func clientDescriptionNeverLeaksTheToken() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let client = try makeClient(token: Self.token, http: http)
|
||||
|
||||
// Act
|
||||
let rendered = "\(client)" + String(reflecting: client)
|
||||
|
||||
// Assert
|
||||
#expect(!rendered.contains(Self.token))
|
||||
#expect(rendered.contains("redacted"))
|
||||
}
|
||||
|
||||
@Test("hasAccessToken 只暴露有无,不暴露值")
|
||||
func hasAccessTokenExposesPresenceOnly() async throws {
|
||||
// Arrange + Act
|
||||
let http = FakeHTTPTransport()
|
||||
let withToken = try makeClient(token: Self.token, http: http)
|
||||
let without = try makeClient(token: nil, http: http)
|
||||
|
||||
// Assert
|
||||
#expect(withToken.hasAccessToken)
|
||||
#expect(!without.hasAccessToken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
526
ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift
Normal file
526
ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift
Normal file
@@ -0,0 +1,526 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// B1 · **G(变更)** 端点:git 写通道 + worktree 管理 + w2 注入队列
|
||||
/// (ios-completion §1.2;真源 = `src/`)。
|
||||
///
|
||||
/// - `POST /projects/git/stage`(`src/server.ts:1184-1218`)body `{path,files,stage}`
|
||||
/// - `POST /projects/git/commit`(`:1219-1255`)body `{path,message}`
|
||||
/// - `POST /projects/git/push`(`:1256-1289`)body `{path}`
|
||||
/// - `POST /projects/git/fetch`(`:1290-1319`)body `{path}`
|
||||
/// - `POST /projects/worktree`(`:1095-1123`)body `{path,branch[,base]}`
|
||||
/// - `DELETE /projects/worktree`(`:1124-1153`)body `{path,worktreePath,force}`
|
||||
/// - `POST /projects/worktree/prune`(`:1154-1183`)body `{path}`
|
||||
/// - `POST /live-sessions/:id/queue`(`:605-643`)body `{text[,appendEnter]}`
|
||||
///
|
||||
/// 全部 **必带 `Origin`**。失败 body 只带服务端**已分类、已脱敏**的 `error`
|
||||
/// 字符串(`src/http/git-ops.ts` / `worktrees.ts`,SEC-M10)—— 客户端逐字展示,
|
||||
/// 绝不自己拼 git stderr。403 是**重载**状态(Origin 守卫失败 **和** 功能开关关闭
|
||||
/// 都是 403),客户端无法凭状态区分,因此原样展示 message。
|
||||
struct GitWriteTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
private static let repoPath = "/Users/dev/web-terminal"
|
||||
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
|
||||
|
||||
private struct Fixture {
|
||||
let http: FakeHTTPTransport
|
||||
let endpoint: HostEndpoint
|
||||
let client: APIClient
|
||||
}
|
||||
|
||||
private func makeFixture(accessToken: String? = nil) throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let http = FakeHTTPTransport()
|
||||
return Fixture(
|
||||
http: http, endpoint: endpoint,
|
||||
client: APIClient(endpoint: endpoint, http: http, accessToken: accessToken)
|
||||
)
|
||||
}
|
||||
|
||||
private func routeURL(_ path: String) throws -> URL {
|
||||
try #require(URL(string: Self.base + path))
|
||||
}
|
||||
|
||||
private func bodyObject(_ request: URLRequest) throws -> [String: Any] {
|
||||
let body = try #require(request.httpBody)
|
||||
return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
}
|
||||
|
||||
// MARK: - Origin iff-G(G 侧:七条写路由全带 Origin,逐字符相等)
|
||||
|
||||
@Test("Origin iff-G(G 侧):七条 git/worktree 写路由全部带逐字符相等的 Origin + JSON Content-Type")
|
||||
func everyGitWriteRouteCarriesByteEqualOrigin() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let ok = Data(#"{"ok":true}"#.utf8)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt"], stage: true)
|
||||
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "feat: x")
|
||||
_ = try await fixture.client.gitPush(path: Self.repoPath)
|
||||
_ = try await fixture.client.gitFetch(path: Self.repoPath)
|
||||
_ = try await fixture.client.createWorktree(
|
||||
path: Self.repoPath, branch: "wt-x", base: nil
|
||||
)
|
||||
_ = try await fixture.client.removeWorktree(
|
||||
path: Self.repoPath, worktreePath: "\(Self.repoPath)/.claude/worktrees/x", force: false
|
||||
)
|
||||
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||||
|
||||
// Assert
|
||||
let requests = await fixture.http.recordedRequests
|
||||
#expect(requests.count == 7)
|
||||
for request in requests {
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("配置了访问令牌时,G 写路由同时带 Cookie 与 Origin(令牌不替代 Origin)")
|
||||
func gitWriteCarriesCookieAlongsideOrigin() async throws {
|
||||
// Arrange
|
||||
let token = "s3cret-token_value.~+/="
|
||||
let fixture = try makeFixture(accessToken: token)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/push"), body: Data(#"{"ok":true}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.gitPush(path: Self.repoPath)
|
||||
|
||||
// Assert
|
||||
let request = try #require(await fixture.http.recordedRequests.first)
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(token)")
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
}
|
||||
|
||||
// MARK: - body 形状逐字段冻结
|
||||
|
||||
@Test("body 形状:stage={path,files,stage} · commit={path,message} · push/fetch/prune={path}")
|
||||
func gitWriteBodyShapesAreExact() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let ok = Data(#"{"ok":true}"#.utf8)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt", "b/c.md"], stage: false)
|
||||
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "fix: 修中文")
|
||||
_ = try await fixture.client.gitPush(path: Self.repoPath)
|
||||
_ = try await fixture.client.gitFetch(path: Self.repoPath)
|
||||
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||||
|
||||
// Assert
|
||||
let requests = await fixture.http.recordedRequests
|
||||
let stage = try bodyObject(try #require(requests.first))
|
||||
#expect(Set(stage.keys) == Set(["path", "files", "stage"]))
|
||||
#expect(stage["path"] as? String == Self.repoPath)
|
||||
#expect(stage["files"] as? [String] == ["a.txt", "b/c.md"])
|
||||
#expect(stage["stage"] as? Bool == false)
|
||||
let commit = try bodyObject(requests[1])
|
||||
#expect(Set(commit.keys) == Set(["path", "message"]))
|
||||
#expect(commit["message"] as? String == "fix: 修中文")
|
||||
for index in 2...4 {
|
||||
let single = try bodyObject(requests[index])
|
||||
#expect(Set(single.keys) == Set(["path"]))
|
||||
#expect(single["path"] as? String == Self.repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("worktree body:create 的 base 为 nil 时**不带该键**;remove 恒带 {path,worktreePath,force}")
|
||||
func worktreeBodyShapesAreExact() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let ok = Data(#"{"ok":true}"#.utf8)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-a", base: nil)
|
||||
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-b", base: "develop")
|
||||
_ = try await fixture.client.removeWorktree(
|
||||
path: Self.repoPath, worktreePath: "/tmp/wt", force: true
|
||||
)
|
||||
|
||||
// Assert
|
||||
let requests = await fixture.http.recordedRequests
|
||||
let withoutBase = try bodyObject(try #require(requests.first))
|
||||
#expect(Set(withoutBase.keys) == Set(["path", "branch"]))
|
||||
#expect(withoutBase["branch"] as? String == "wt-a")
|
||||
let withBase = try bodyObject(requests[1])
|
||||
#expect(Set(withBase.keys) == Set(["path", "branch", "base"]))
|
||||
#expect(withBase["base"] as? String == "develop")
|
||||
let remove = try bodyObject(requests[2])
|
||||
#expect(Set(remove.keys) == Set(["path", "worktreePath", "force"]))
|
||||
#expect(remove["worktreePath"] as? String == "/tmp/wt")
|
||||
#expect(remove["force"] as? Bool == true)
|
||||
#expect(requests[2].httpMethod == "DELETE") // DELETE **带** JSON body
|
||||
}
|
||||
|
||||
// MARK: - 200 payload 解码(畸形 body 降级为默认值,绝不抛)
|
||||
|
||||
@Test("200 payload 解码:stage/commit/push/fetch/create/remove/prune 各自形状")
|
||||
func successPayloadsDecodePerRoute() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/stage"),
|
||||
body: Data(#"{"ok":true,"staged":true,"count":2}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/commit"),
|
||||
body: Data(#"{"ok":true,"commit":"abc1234"}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/push"),
|
||||
body: Data(#"{"ok":true,"branch":"develop","remote":"origin"}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/fetch"),
|
||||
body: Data(#"{"ok":true,"remote":"origin","lastFetchMs":1785390645813.5327}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree"),
|
||||
body: Data(#"{"ok":true,"path":"/tmp/wt","branch":"worktree-x"}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "DELETE", url: try routeURL("/projects/worktree"),
|
||||
body: Data(#"{"ok":true,"path":"/tmp/wt"}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree/prune"),
|
||||
body: Data(#"{"ok":true,"pruned":["wt-a","wt-b"]}"#.utf8)
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
#expect(
|
||||
try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true)
|
||||
== .ok(StageResult(staged: true, count: 2))
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||||
== .ok(CommitResult(commit: "abc1234"))
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.gitPush(path: Self.repoPath)
|
||||
== .ok(PushResult(branch: "develop", remote: "origin"))
|
||||
)
|
||||
let fetched = try await fixture.client.gitFetch(path: Self.repoPath)
|
||||
guard case .ok(let fetchResult) = fetched else {
|
||||
Issue.record("fetch 应为 .ok")
|
||||
return
|
||||
}
|
||||
#expect(fetchResult.remote == "origin")
|
||||
// lastFetchMs 来自 fs.stat().mtimeMs —— 带小数,必须解出来
|
||||
#expect(abs(try #require(fetchResult.lastFetchMs) - 1_785_390_645_813.5327) < 0.001)
|
||||
#expect(
|
||||
try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil)
|
||||
== .ok(CreateWorktreeResult(path: "/tmp/wt", branch: "worktree-x"))
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.removeWorktree(
|
||||
path: Self.repoPath, worktreePath: "/tmp/wt", force: false
|
||||
) == .ok(RemoveWorktreeResult(path: "/tmp/wt"))
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||||
== .ok(PruneWorktreesResult(pruned: ["wt-a", "wt-b"]))
|
||||
)
|
||||
}
|
||||
|
||||
@Test("200 但 payload 缺失/畸形 → 降级为默认值(空 sha / 空 pruned),绝不抛")
|
||||
func garbledSuccessPayloadDegradesToDefaults() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/commit"), body: Data("[]".utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree/prune"),
|
||||
body: Data(#"{"ok":true,"pruned":"not-an-array"}"#.utf8)
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
#expect(
|
||||
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||||
== .ok(CommitResult(commit: ""))
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||||
== .ok(PruneWorktreesResult(pruned: []))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 失败映射(429 独立;其余原样带出服务端安全 message)
|
||||
|
||||
@Test("429 → .rateLimited(stage/commit 共用一个限流器,push/fetch 各有更紧的),不得自动重试")
|
||||
func rateLimitedIsItsOwnOutcome() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/push"), status: 429,
|
||||
body: Data(#"{"error":"Too many requests."}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
|
||||
|
||||
// Assert
|
||||
#expect(outcome == .rateLimited)
|
||||
}
|
||||
|
||||
@Test("403/409/400/500 → .rejected(status, 服务端已脱敏 message)逐字带出(403 重载:Origin 或开关)")
|
||||
func failuresCarryTheServerSafeMessageVerbatim() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let cases: [(Int, String)] = [
|
||||
(403, "Git operations are disabled."),
|
||||
(409, "Nothing staged to commit."),
|
||||
(400, "Set a git author identity (user.name / user.email) first."),
|
||||
(500, "Git operation failed."),
|
||||
]
|
||||
for (status, message) in cases {
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/commit"), status: status,
|
||||
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
|
||||
)
|
||||
}
|
||||
|
||||
// Act + Assert
|
||||
for (status, message) in cases {
|
||||
let outcome = try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||||
#expect(outcome == .rejected(status: status, message: message))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("失败 body 无法解析 → .rejected(status, nil),不编造原因")
|
||||
func unparseableFailureBodyYieldsNilMessage() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree"), status: 500,
|
||||
body: Data("<html>oops</html>".utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let outcome = try await fixture.client.createWorktree(
|
||||
path: Self.repoPath, branch: "x", base: nil
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(outcome == .rejected(status: 500, message: nil))
|
||||
}
|
||||
|
||||
@Test("push 的 401 是**路由自身**语义('Push authentication required on the host.'),不得当成访问令牌 401")
|
||||
func push401IsRouteClassifiedNotTheAccessTokenGate() async throws {
|
||||
// Arrange — src/http/git-ops.ts:108 把主机侧 git 凭据失败分类成 401
|
||||
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
|
||||
let message = "Push authentication required on the host."
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/push"), status: 401,
|
||||
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
|
||||
|
||||
// Assert — 若被 gate 规则吞掉就会抛 .unauthorized,那会误导用户去补令牌
|
||||
#expect(outcome == .rejected(status: 401, message: message))
|
||||
}
|
||||
|
||||
/// F5 · 401 的归属必须**逐路由**钉死,不能按"写路由"一刀切。
|
||||
///
|
||||
/// 服务器只有一处会自己产出 401:`src/http/git-ops.ts:108`(主机侧 git 凭据失败),
|
||||
/// 而它只被 `stageFiles`/`commit`/`push`/`fetch` 走到。worktree 三条落在
|
||||
/// `src/http/worktrees.ts`,那里根本不产 401(403 开关 / 400 / 404 / 500),
|
||||
/// `src/server.ts:1095-1183` 也没加。所以在启用访问令牌的主机上,worktree 的 401
|
||||
/// 只可能是 gate —— 当成 git 失败会把用户卡在"创建 worktree 失败",而不是补令牌。
|
||||
@Test("worktree 三条的 401 是访问令牌 gate(不是 git 拒绝);git-ops 四条才是路由自身语义")
|
||||
func worktreeRoutes401IsTheAccessTokenGateNotAGitRejection() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
|
||||
let gateBody = Data(#"{"error":"authentication required"}"#.utf8)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree"), status: 401, body: gateBody
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "DELETE", url: try routeURL("/projects/worktree"), status: 401, body: gateBody
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree/prune"), status: 401, body: gateBody
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unauthorized) {
|
||||
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil)
|
||||
}
|
||||
await #expect(throws: APIClientError.unauthorized) {
|
||||
_ = try await fixture.client.removeWorktree(
|
||||
path: Self.repoPath, worktreePath: "\(Self.repoPath)/wt", force: false
|
||||
)
|
||||
}
|
||||
await #expect(throws: APIClientError.unauthorized) {
|
||||
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("git-ops 四条(stage/commit/push/fetch)全部保留路由自身的 401")
|
||||
func allFourGitOpsRoutesKeepTheirOwn401() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
|
||||
let message = "Push authentication required on the host."
|
||||
let body = Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
|
||||
for path in ["/projects/git/stage", "/projects/git/commit", "/projects/git/push", "/projects/git/fetch"] {
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL(path), status: 401, body: body
|
||||
)
|
||||
}
|
||||
|
||||
// Act + Assert — 服务器自己的话术必须原样到达 UI
|
||||
#expect(
|
||||
try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true)
|
||||
== .rejected(status: 401, message: message)
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||||
== .rejected(status: 401, message: message)
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.gitPush(path: Self.repoPath)
|
||||
== .rejected(status: 401, message: message)
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.gitFetch(path: Self.repoPath)
|
||||
== .rejected(status: 401, message: message)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("空 path 联网前拒(projectPathInvalid),七条写路由一致")
|
||||
func emptyPathIsRejectedBeforeNetworkOnEveryWriteRoute() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.gitStage(path: "", files: ["a"], stage: true)
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.gitCommit(path: "", message: "m")
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.gitPush(path: "")
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.gitFetch(path: "")
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.createWorktree(path: "", branch: "x", base: nil)
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.removeWorktree(path: "", worktreePath: "/tmp/wt", force: false)
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.pruneWorktrees(path: "")
|
||||
}
|
||||
#expect(await fixture.http.recordedRequests.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - POST /live-sessions/:id/queue(w2 pty 注入队列)
|
||||
|
||||
@Test("queue 路由:POST /live-sessions/<小写 UUID>/queue,带 Origin,body 恰为 {text,appendEnter}")
|
||||
func queueRouteShapeIsExact() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let id = try #require(UUID(uuidString: Self.sessionIdString))
|
||||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue")
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: url, body: Data(#"{"length":2}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let depth = try await fixture.client.enqueueFollowup(
|
||||
sessionId: id, text: "继续", appendEnter: true
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(depth == 2)
|
||||
let request = try #require(await fixture.http.recordedRequests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.url == url) // :id 按字符串精确匹配 ⇒ 必须小写
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
let body = try bodyObject(request)
|
||||
#expect(Set(body.keys) == Set(["text", "appendEnter"]))
|
||||
#expect(body["text"] as? String == "继续")
|
||||
#expect(body["appendEnter"] as? Bool == true)
|
||||
}
|
||||
|
||||
@Test("queue 错误映射:400/403/404/409/413/429/503 各自类型化,话术非空")
|
||||
func queueMapsEveryServerStatusToATypedError() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let id = try #require(UUID(uuidString: Self.sessionIdString))
|
||||
let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue")
|
||||
let cases: [(Int, APIClientError)] = [
|
||||
(400, .queueTextInvalid),
|
||||
(403, .forbidden),
|
||||
(404, .sessionNotFound),
|
||||
(409, .queueFull),
|
||||
(413, .queueTextTooLarge),
|
||||
(429, .rateLimited),
|
||||
(503, .queueDisabled),
|
||||
(500, .unexpectedStatus(500)),
|
||||
]
|
||||
for (status, _) in cases {
|
||||
await fixture.http.queueSuccess(method: "POST", url: url, status: status)
|
||||
}
|
||||
|
||||
// Act + Assert
|
||||
for (_, expected) in cases {
|
||||
await #expect(throws: expected) {
|
||||
_ = try await fixture.client.enqueueFollowup(
|
||||
sessionId: id, text: "x", appendEnter: false
|
||||
)
|
||||
}
|
||||
#expect(!expected.message.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("queue 空 text 联网前拒(镜像服务器 400 规则);200 但 body 无 length → invalidResponseBody")
|
||||
func queueRejectsEmptyTextAndGarbledSuccessBody() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let id = try #require(UUID(uuidString: Self.sessionIdString))
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/live-sessions/\(Self.sessionIdString)/queue"),
|
||||
body: Data(#"{"ok":true}"#.utf8)
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.queueTextInvalid) {
|
||||
_ = try await fixture.client.enqueueFollowup(sessionId: id, text: "", appendEnter: true)
|
||||
}
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await fixture.client.enqueueFollowup(sessionId: id, text: "x", appendEnter: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,67 @@ struct ProjectsTests {
|
||||
#expect(projects.last?.sessions.isEmpty == true) // sessions 缺失 → []
|
||||
}
|
||||
|
||||
@Test("回归:lastActiveMs 来自 fs.stat().mtimeMs —— **带小数**必须解出来(否则真机上排序键永远为 nil)")
|
||||
func lastActiveMsDecodesFractionalStatMtime() async throws {
|
||||
// Arrange — 真实服务器值形如 1785390645813.5327(APFS 纳秒精度);
|
||||
// 旧实现 decode(Int.self) 对小数直接失败,被 try? 吞成 nil。
|
||||
let body = """
|
||||
[{"name":"a","path":"/a","isGit":true,"lastActiveMs":1785390645813.5327,\
|
||||
"lastCommitMs":1720000000000,"ahead":2,"behind":0,"sessions":[]}]
|
||||
"""
|
||||
|
||||
// Act
|
||||
let projects = try await fetchProjects(try makeFixture(), body: body)
|
||||
|
||||
// Assert
|
||||
let project = try #require(projects.first)
|
||||
#expect(project.lastActiveMs == 1_785_390_645_813)
|
||||
#expect(project.lastCommitMs == 1_720_000_000_000)
|
||||
#expect(project.ahead == 2)
|
||||
#expect(project.behind == 0)
|
||||
}
|
||||
|
||||
@Test("session ref 的 cwd(w6/G7)可选解码:有则解出,缺失 → nil")
|
||||
func sessionRefDecodesOptionalCwd() async throws {
|
||||
// Arrange
|
||||
let body = """
|
||||
[{"name":"a","path":"/a","isGit":true,"sessions":[\
|
||||
{"id":"\(Self.sessionIdString)","status":"idle","clientCount":0,"createdAt":1,\
|
||||
"exited":false,"cwd":"/a/.claude/worktrees/x"}]}]
|
||||
"""
|
||||
|
||||
// Act
|
||||
let projects = try await fetchProjects(try makeFixture(), body: body)
|
||||
|
||||
// Assert
|
||||
#expect(projects.first?.sessions.first?.cwd == "/a/.claude/worktrees/x")
|
||||
}
|
||||
|
||||
@Test("detail 的 dirtyCount / sync(w6/G1)可选解码,与 worktree/state 用同一 SyncState")
|
||||
func projectDetailDecodesDirtyCountAndSync() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let body = """
|
||||
{"name":"a","path":"/a","isGit":true,"dirty":true,"dirtyCount":7,\
|
||||
"sync":{"upstream":"origin/main","ahead":1,"behind":0,"lastFetchMs":1785390645813.5327},\
|
||||
"worktrees":[],"sessions":[],"hasClaudeMd":false}
|
||||
"""
|
||||
await fixture.http.queueSuccess(
|
||||
url: try routeURL("/projects/detail?path=%2Fa"), body: Data(body.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let detail = try await fixture.client.projectDetail(path: "/a")
|
||||
|
||||
// Assert
|
||||
#expect(detail.dirtyCount == 7)
|
||||
let sync = try #require(detail.sync)
|
||||
#expect(sync.upstream == "origin/main")
|
||||
#expect(sync.ahead == 1)
|
||||
#expect(sync.behind == 0)
|
||||
#expect(sync.lastFetchMs != nil)
|
||||
}
|
||||
|
||||
@Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus")
|
||||
func projectsRejectsNonArrayBodyAndBadStatus() async throws {
|
||||
// Act + Assert — 非数组
|
||||
|
||||
Reference in New Issue
Block a user