T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected) T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT (node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode, single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22 Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
189 lines
7.9 KiB
Swift
189 lines
7.9 KiB
Swift
import Foundation
|
||
import Testing
|
||
import TestSupport
|
||
import WireProtocol
|
||
import APIClient
|
||
|
||
/// T-iOS-38 · `GET /prefs`(RO,无 Origin) / `PUT /prefs`(G,Origin;body ≤ 64 KB,
|
||
/// src/server.ts:278)。prefs 是"不透明但已验证"的 JSON 对象往返:已知键
|
||
/// favourites/collapsed 松散镜像 web 端(public/prefs.ts sanitizePrefs),
|
||
/// **未知键在往返中原样保留** —— 关键正确性陷阱:iOS 改一个已知键后 PUT,
|
||
/// 绝不能把 web/未来服务器写入的未知键清掉。
|
||
struct PrefsRoundTripTests {
|
||
private static let base = "http://192.168.1.5:3000"
|
||
|
||
/// 含未知键的服务器 blob:string/number/bool/null/嵌套 array/object 全类型。
|
||
private static let blobWithUnknownKeys = """
|
||
{"favourites":["/Users/dev/a"],"collapsed":{"grp":true},\
|
||
"theme":"dark","pollSeconds":42,"beta":false,"legacy":null,\
|
||
"futureList":[1,"two",{"deep":true}],"futureObj":{"x":{"y":[3]}}}
|
||
"""
|
||
|
||
private struct Fixture {
|
||
let endpoint: HostEndpoint
|
||
let http: FakeHTTPTransport
|
||
let client: APIClient
|
||
let url: URL
|
||
}
|
||
|
||
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(
|
||
endpoint: endpoint,
|
||
http: http,
|
||
client: APIClient(endpoint: endpoint, http: http),
|
||
url: try #require(URL(string: Self.base + "/prefs"))
|
||
)
|
||
}
|
||
|
||
private func decodePrefs(_ json: String) throws -> UiPrefs {
|
||
try #require(UiPrefs.decode(from: Data(json.utf8)))
|
||
}
|
||
|
||
/// 重新解析 encode 产物为字典(键序无关的深度比较用)。
|
||
private func reparse(_ data: Data) throws -> NSDictionary {
|
||
try #require(try JSONSerialization.jsonObject(with: data) as? NSDictionary)
|
||
}
|
||
|
||
// MARK: - Origin iff-G
|
||
|
||
@Test("Origin iff-G:GET /prefs 无 Origin;PUT /prefs 带 Origin(逐字符相等)+JSON Content-Type")
|
||
func prefsOriginAppearsIffPut() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(url: fixture.url, body: Data(Self.blobWithUnknownKeys.utf8))
|
||
await fixture.http.queueSuccess(
|
||
method: "PUT", url: fixture.url, body: Data(#"{"favourites":[],"collapsed":{}}"#.utf8)
|
||
)
|
||
|
||
// Act
|
||
_ = try await fixture.client.prefs()
|
||
_ = try await fixture.client.putPrefs(UiPrefs())
|
||
|
||
// Assert
|
||
let requests = await fixture.http.recordedRequests
|
||
#expect(requests.count == 2)
|
||
let get = try #require(requests.first)
|
||
#expect(get.httpMethod == "GET")
|
||
#expect(get.value(forHTTPHeaderField: "Origin") == nil)
|
||
let put = try #require(requests.last)
|
||
#expect(put.httpMethod == "PUT")
|
||
#expect(put.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||
#expect(put.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||
}
|
||
|
||
// MARK: - 已知键的松散镜像(web sanitizePrefs 语义)
|
||
|
||
@Test("已知键解码:favourites 只留非空字符串并去重;collapsed 只留 true 项")
|
||
func knownKeysAreSanitizedLikeWebClient() throws {
|
||
// Arrange — 混入非字符串/空串/重复/非 true 值
|
||
let json = """
|
||
{"favourites":["/a",42,"","/a","/b"],\
|
||
"collapsed":{"g1":true,"g2":false,"g3":"yes","":true}}
|
||
"""
|
||
|
||
// Act
|
||
let prefs = try decodePrefs(json)
|
||
|
||
// Assert
|
||
#expect(prefs.favourites == ["/a", "/b"])
|
||
#expect(prefs.collapsed == ["g1": true])
|
||
}
|
||
|
||
@Test("已知键缺失/类型错 → 空缺省;空构造与 memberwise 构造可用")
|
||
func missingKnownKeysDegradeToEmpty() throws {
|
||
// Act
|
||
let empty = try decodePrefs(#"{"favourites":"not-an-array"}"#)
|
||
let constructed = UiPrefs(favourites: ["/x"], collapsed: ["g": true])
|
||
|
||
// Assert
|
||
#expect(empty.favourites.isEmpty)
|
||
#expect(empty.collapsed.isEmpty)
|
||
#expect(constructed.favourites == ["/x"])
|
||
#expect(constructed.collapsed == ["g": true])
|
||
}
|
||
|
||
// MARK: - 关键正确性陷阱:未知键在往返中存活
|
||
|
||
@Test("关键陷阱:解码含未知键的 blob → withFavourites 改已知键 → 重编码,未知键逐字节存活")
|
||
func unknownKeysSurviveFavouritesMutationRoundTrip() throws {
|
||
// Arrange
|
||
let original = try decodePrefs(Self.blobWithUnknownKeys)
|
||
|
||
// Act — 只动 favourites
|
||
let mutated = original.withFavourites(["/Users/dev/a", "/Users/dev/b"])
|
||
let reencoded = try reparse(try mutated.encodeBody())
|
||
|
||
// Assert — 已知键更新、另一已知键不动、未知键全类型深度相等
|
||
#expect(mutated.favourites == ["/Users/dev/a", "/Users/dev/b"])
|
||
#expect(reencoded["favourites"] as? [String] == ["/Users/dev/a", "/Users/dev/b"])
|
||
#expect(reencoded["collapsed"] as? [String: Bool] == ["grp": true])
|
||
#expect(reencoded["theme"] as? String == "dark")
|
||
#expect(reencoded["pollSeconds"] as? Int == 42)
|
||
#expect(reencoded["beta"] as? Bool == false)
|
||
#expect(reencoded["legacy"] is NSNull)
|
||
#expect(reencoded["futureList"] as? NSArray == [1, "two", ["deep": true]] as NSArray)
|
||
#expect(reencoded["futureObj"] as? NSDictionary == ["x": ["y": [3]]] as NSDictionary)
|
||
// 原值不可变(plan §4 不可变铁律)
|
||
#expect(original.favourites == ["/Users/dev/a"])
|
||
}
|
||
|
||
@Test("withCollapsed 同样保留未知键与 favourites;往返不放大不丢失")
|
||
func unknownKeysSurviveCollapsedMutationRoundTrip() throws {
|
||
// Arrange
|
||
let original = try decodePrefs(Self.blobWithUnknownKeys)
|
||
|
||
// Act
|
||
let mutated = original.withCollapsed(["grp": true, "new-group": true])
|
||
let reencoded = try reparse(try mutated.encodeBody())
|
||
|
||
// Assert
|
||
#expect(reencoded["collapsed"] as? [String: Bool] == ["grp": true, "new-group": true])
|
||
#expect(reencoded["favourites"] as? [String] == ["/Users/dev/a"])
|
||
#expect(reencoded["theme"] as? String == "dark")
|
||
#expect(reencoded["futureObj"] as? NSDictionary == ["x": ["y": [3]]] as NSDictionary)
|
||
// 未知键总数不变(8 个顶层键)
|
||
#expect(reencoded.count == 8)
|
||
}
|
||
|
||
// MARK: - 客户端调用(echo/错误映射)
|
||
|
||
@Test("putPrefs 200 → 返回服务器回显的(已净化)prefs;403(Origin 守卫) → forbidden")
|
||
func putPrefsReturnsServerEchoAndMapsForbidden() async throws {
|
||
// Arrange — 服务器回显是净化后的 blob(src/server.ts:283 res.json(prefsStore.get()))
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(
|
||
method: "PUT", url: fixture.url,
|
||
body: Data(#"{"favourites":["/kept"],"collapsed":{}}"#.utf8)
|
||
)
|
||
await fixture.http.queueSuccess(method: "PUT", url: fixture.url, status: 403)
|
||
|
||
// Act
|
||
let echoed = try await fixture.client.putPrefs(UiPrefs(favourites: ["/kept", ""]))
|
||
|
||
// Assert
|
||
#expect(echoed.favourites == ["/kept"])
|
||
await #expect(throws: APIClientError.forbidden) {
|
||
_ = try await fixture.client.putPrefs(UiPrefs())
|
||
}
|
||
}
|
||
|
||
@Test("GET /prefs 顶层非对象(数组/HTML) → invalidResponseBody(绝不静默降级为空——防止后续 PUT 清空服务器 prefs)")
|
||
func nonObjectPrefsBodyFailsLoudlyInsteadOfDegradingToEmpty() async throws {
|
||
// Arrange
|
||
let fixture = try makeFixture()
|
||
await fixture.http.queueSuccess(url: fixture.url, body: Data("[]".utf8))
|
||
await fixture.http.queueSuccess(url: fixture.url, body: Data("<html>".utf8))
|
||
|
||
// Act + Assert
|
||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||
_ = try await fixture.client.prefs()
|
||
}
|
||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||
_ = try await fixture.client.prefs()
|
||
}
|
||
}
|
||
}
|