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.
315 lines
13 KiB
Swift
315 lines
13 KiB
Swift
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)
|
||
}
|
||
}
|