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":""}`,`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=`,不解析 /// `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) } }