mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 16:24:49 +00:00
342 lines
14 KiB
Swift
342 lines
14 KiB
Swift
import Security
|
||
import XCTest
|
||
|
||
@testable import ShadowsocksX_NG2
|
||
|
||
/// 订阅端到端语义经目录工作流 module interface 观察:创建
|
||
/// 门禁、刷新失败保留快照、身份连续性、URL 编辑保身份、删除递归清除、卡片
|
||
/// projection 与提交接线。获取走 FakeSubscriptionFetcher(传输侧契约在
|
||
/// SubscriptionFetcherTests);提交后的运行时收敛以确定性 fake 计数。
|
||
@MainActor
|
||
final class CatalogWorkflowSubscriptionTests: XCTestCase {
|
||
private var workDir: URL!
|
||
var fileURL: URL!
|
||
var fetcher: FakeSubscriptionFetcher!
|
||
var runtime: FakeCatalogRuntime!
|
||
var workflow: CatalogWorkflow!
|
||
|
||
override func setUp() async throws {
|
||
try await super.setUp()
|
||
workDir = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("subscription-workflow-tests-\(UUID().uuidString)", isDirectory: true)
|
||
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
||
fileURL = workDir.appendingPathComponent("catalog.json")
|
||
|
||
runtime = FakeCatalogRuntime()
|
||
fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.flat(serverCount: 1)))
|
||
workflow = makeWorkflow()
|
||
}
|
||
|
||
override func tearDown() async throws {
|
||
try? FileManager.default.removeItem(at: workDir)
|
||
try await super.tearDown()
|
||
}
|
||
|
||
func makeWorkflow(fetcher override: SubscriptionFetching? = nil) -> CatalogWorkflow {
|
||
// 计数即提交计数:提交后的运行时收敛由协调器异步调度。
|
||
runtime.hasActiveTarget = true
|
||
let coordinator = CatalogCommitCoordinator(
|
||
fileStore: makeTestCatalogStore(fileURL: fileURL), runtime: runtime)
|
||
return makeCatalogWorkflow(
|
||
coordinator: coordinator,
|
||
plugins: NoManagedPluginProvider(),
|
||
subscriptionFetcher: override ?? fetcher!)
|
||
}
|
||
|
||
/// 文档稳定 ID 后缀在树 projection 中定位节点(订阅前缀随机)。
|
||
func findNode(withSuffix suffix: String) -> CatalogTreeNode? {
|
||
var stack = workflow.tree.roots
|
||
while let node = stack.popLast() {
|
||
if node.id.rawValue.hasSuffix(suffix) { return node }
|
||
stack.append(contentsOf: node.children ?? [])
|
||
}
|
||
return nil
|
||
}
|
||
|
||
var serverAID: NodeID {
|
||
findNode(withSuffix: "aaaaaaaa-0000-4000-8000-00000000000a")?.id
|
||
?? NodeID(rawValue: "missing-a")
|
||
}
|
||
|
||
private var serverBID: NodeID {
|
||
findNode(withSuffix: "bbbbbbbb-0000-4000-8000-00000000000b")?.id
|
||
?? NodeID(rawValue: "missing-b")
|
||
}
|
||
|
||
/// b 移到根 + a 改名 + 重排(远端改名/移动/排序跟随的对照文档)。
|
||
var treeDoc: Data { SubscriptionDocs.tree() }
|
||
private var changedTreeDoc: Data { SubscriptionDocs.treeRenamedAndReordered() }
|
||
|
||
// MARK: 创建门禁
|
||
|
||
func testCreateRequiresHTTPSURL() async {
|
||
for bad in ["http://example.com/sub.json", "ftp://x/y", "不是 URL", "https://"] {
|
||
do {
|
||
_ = try await workflow.createSubscription(urlString: bad)
|
||
XCTFail("「\(bad)」应被拒绝")
|
||
} catch {
|
||
XCTAssertEqual(error as? SubscriptionFormError, .invalidURL)
|
||
}
|
||
}
|
||
XCTAssertTrue(workflow.subscriptions.isEmpty, "创建失败不留半成品")
|
||
XCTAssertTrue(workflow.tree.isEmpty)
|
||
}
|
||
|
||
func testCreateAddsRecordEmptyGroupAndRefreshes() async throws {
|
||
fetcher = FakeSubscriptionFetcher(behavior: .success(treeDoc))
|
||
workflow = makeWorkflow()
|
||
|
||
let summary = try await workflow.createSubscription(
|
||
urlString: "https://provider.example.com/sub.json")
|
||
|
||
XCTAssertEqual(summary.id, workflow.subscriptions.first?.id)
|
||
XCTAssertEqual(fetcher.lastURL?.host, "provider.example.com", "创建即首次刷新")
|
||
let group = try XCTUnwrap(workflow.tree.node(withID: summary.groupID))
|
||
XCTAssertTrue(group.isGroup)
|
||
XCTAssertEqual(group.name, "Example subscription", "名称跟随远端根分组")
|
||
XCTAssertFalse(group.isManual)
|
||
guard case .succeeded = workflow.subscriptions[0].status else {
|
||
return XCTFail("首次刷新成功应记成功态")
|
||
}
|
||
}
|
||
|
||
func testFirstRefreshFailureLeavesEmptyGroupWithFailedStatus() async throws {
|
||
fetcher = FakeSubscriptionFetcher(
|
||
behavior: .failure(.transport(detail: "URLError.timedOut")))
|
||
workflow = makeWorkflow()
|
||
|
||
let created = try await workflow.createSubscription(
|
||
urlString: "https://provider.example.com/s.json")
|
||
|
||
XCTAssertNotNil(workflow.tree.node(withID: created.groupID), "首刷失败仍留空分组")
|
||
XCTAssertEqual(created.name, "provider.example.com", "空分组以 host 兜底")
|
||
guard case .failed(_, let reason) = workflow.subscriptions[0].status else {
|
||
return XCTFail("首刷失败应记失败态")
|
||
}
|
||
XCTAssertEqual(reason, .transport(.timedOut))
|
||
}
|
||
|
||
// MARK: 身份连续性
|
||
|
||
func testRemoteRenameMoveReorderFollowsWithoutLocalOverlay() async throws {
|
||
fetcher = FakeSubscriptionFetcher(behavior: .success(treeDoc))
|
||
workflow = makeWorkflow()
|
||
let record = try await workflow.createSubscription(urlString: "https://p.example.com/s.json")
|
||
fetcher.setBehavior(.success(changedTreeDoc))
|
||
await workflow.refreshSubscription(record.id)
|
||
|
||
// 身份不变;名称/位置/顺序跟随远端。
|
||
XCTAssertEqual(findNode(withSuffix: "aaaaaaaa-0000-4000-8000-00000000000a")?.name, "香港 01(新名)")
|
||
let group = try XCTUnwrap(workflow.tree.node(withID: record.groupID))
|
||
let japanGroupID = try XCTUnwrap(
|
||
findNode(withSuffix: "g:jp")?.id,
|
||
"嵌套分组应存在")
|
||
XCTAssertEqual(group.children?.map(\.id), [serverBID, japanGroupID], "远端排序跟随")
|
||
}
|
||
|
||
func testIdLessServerContinuityOnlyForExactMatch() async throws {
|
||
fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.idLess()))
|
||
workflow = makeWorkflow()
|
||
let record = try await workflow.createSubscription(urlString: "https://p.example.com/s.json")
|
||
let serverID = try XCTUnwrap(onlyServerChildID(ofGroup: record.groupID))
|
||
// 完全相同记录 → 身份延续。
|
||
await workflow.refreshSubscription(record.id)
|
||
XCTAssertEqual(onlyServerChildID(ofGroup: record.groupID), serverID)
|
||
|
||
// 端口变化 → 新身份:旧节点删除(无墓碑)。
|
||
fetcher.setBehavior(.success(SubscriptionDocs.idLess(port: 9999)))
|
||
await workflow.refreshSubscription(record.id)
|
||
XCTAssertNil(workflow.tree.node(withID: serverID), "无稳定 ID 且记录变化即不同节点")
|
||
let newID = try XCTUnwrap(onlyServerChildID(ofGroup: record.groupID))
|
||
XCTAssertNotEqual(newID, serverID)
|
||
}
|
||
|
||
// MARK: URL 编辑与删除
|
||
|
||
func testEditURLKeepsIdentityAndRetainsSnapshotUntilSuccess() async throws {
|
||
let record = try await seedSuccessfulSubscription()
|
||
let originalRecordID = record.id
|
||
let originalGroupID = record.groupID
|
||
|
||
// 新地址先失败:快照保留、身份不动。
|
||
fetcher.setBehavior(.failure(.httpStatus(code: 404)))
|
||
try await workflow.saveSubscription(
|
||
record.id, urlString: "https://other.example.com/v2.json")
|
||
XCTAssertEqual(workflow.subscriptions[0].id, originalRecordID, "订阅身份保留")
|
||
guard case .failed = workflow.subscriptions[0].status else {
|
||
return XCTFail("新地址失败应记失败态")
|
||
}
|
||
XCTAssertEqual(fetcher.lastURL?.host, "other.example.com")
|
||
XCTAssertNotNil(findNode(withSuffix: "aaaaaaaa-0000-4000-8000-00000000000a"), "失败保留最后成功快照")
|
||
|
||
// 新地址成功:同一身份命名空间下应用新内容。
|
||
fetcher.setBehavior(.success(SubscriptionDocs.flat(serverCount: 1)))
|
||
await workflow.refreshSubscription(record.id)
|
||
XCTAssertEqual(workflow.subscriptions[0].groupID, originalGroupID)
|
||
guard case .succeeded = workflow.subscriptions[0].status else {
|
||
return XCTFail("换址成功应记成功态,实际 \(workflow.subscriptions[0].status)")
|
||
}
|
||
XCTAssertEqual(workflow.tree.roots.count, 1)
|
||
}
|
||
|
||
func testEditURLRequiresHTTPS() async throws {
|
||
let record = try await seedSuccessfulSubscription()
|
||
await expectThrowsAsync(
|
||
{ try await workflow.saveSubscription(record.id, urlString: "http://p.example.com/x") },
|
||
onThrow: { error in
|
||
XCTAssertEqual(error as? SubscriptionFormError, .invalidURL)
|
||
})
|
||
}
|
||
|
||
func testRemoveSubscriptionClearsSubtreeCredentialsAndReportsInvalidation() async throws {
|
||
let record = try await seedSuccessfulSubscription()
|
||
// 身份在删除前捕获(删除后树上已不存在)。
|
||
let serverA = serverAID
|
||
let serverB = serverBID
|
||
let convergesBefore = runtime.convergeCount
|
||
|
||
let outcome = try await workflow.removeSubscription(record.id)
|
||
|
||
XCTAssertTrue(workflow.subscriptions.isEmpty, "订阅记录移除")
|
||
XCTAssertNil(workflow.tree.node(withID: record.groupID), "固定分组递归清除")
|
||
XCTAssertNil(workflow.tree.node(withID: serverA))
|
||
XCTAssertNil(workflow.tree.node(withID: serverB))
|
||
XCTAssertTrue(
|
||
outcome.removedNodeIDs.contains(serverA) && outcome.removedNodeIDs.contains(record.groupID)
|
||
&& outcome.removedNodeIDs.contains(serverB),
|
||
"删除结果携带被删子树身份(selection invalidation,story 18/30)")
|
||
// 删除经提交协调器触发活动目标重展开,恰好一次。
|
||
await waitUntilRuntimeSettles(runtime.convergeCount - convergesBefore == 1)
|
||
}
|
||
|
||
// MARK: 夹具与辅助
|
||
|
||
private func onlyServerChildID(ofGroup groupID: NodeID) -> NodeID? {
|
||
guard let group = workflow.tree.node(withID: groupID),
|
||
group.childCount == 1,
|
||
let only = group.children?.first,
|
||
!only.isGroup
|
||
else { return nil }
|
||
return only.id
|
||
}
|
||
|
||
/// 建一个「创建 + 首刷成功(tree 文档,服务器 a/b)」的订阅。
|
||
func seedSuccessfulSubscription() async throws -> SubscriptionSummary {
|
||
fetcher = FakeSubscriptionFetcher(behavior: .success(SubscriptionDocs.tree()))
|
||
workflow = makeWorkflow()
|
||
return try await createOnly(urlString: "https://p.example.com/s.json")
|
||
}
|
||
|
||
func createOnly(urlString: String) async throws -> SubscriptionSummary {
|
||
try await workflow.createSubscription(urlString: urlString)
|
||
}
|
||
}
|
||
|
||
/// SIP-008 文档夹具(订阅工作流语义测试)。
|
||
enum SubscriptionDocs {
|
||
private static let stableServerA = "aaaaaaaa-0000-4000-8000-00000000000a"
|
||
private static let stableServerB = "bbbbbbbb-0000-4000-8000-00000000000b"
|
||
|
||
static func serverJSON(id: String, address: String, port: Int, remark: String) -> String {
|
||
"""
|
||
{"id": "\(id)", "remarks": "\(remark)", "server": "\(address)", "server_port": \(port), \
|
||
"password": "pw", "method": "aes-256-gcm"}
|
||
"""
|
||
}
|
||
|
||
static func flat(serverCount: Int) -> Data {
|
||
guard serverCount > 0 else {
|
||
return Data(#"{"version": 1, "servers": []}"#.utf8)
|
||
}
|
||
let servers = (1...serverCount).map {
|
||
serverJSON(
|
||
id: String(format: "%08x-0000-4000-8000-%012d", $0, $0), address: "203.0.113.\($0)",
|
||
port: 8388 + $0, remark: "节点 \($0)")
|
||
}.joined(separator: ",")
|
||
return Data(
|
||
"""
|
||
{"version": 1, "servers": [\(servers)]}
|
||
""".utf8)
|
||
}
|
||
|
||
/// 根分组 [嵌套组 Japan(→b), 服务器 a];稳定 UUID a/b。
|
||
static func tree() -> Data {
|
||
Data(
|
||
"""
|
||
{"version": 1,
|
||
"servers": [
|
||
{"id": "aaaaaaaa-0000-4000-8000-00000000000a", "remarks": "香港 01", "server": "203.0.113.1",
|
||
"server_port": 8388, "password": "pa", "method": "aes-256-gcm"},
|
||
{"id": "bbbbbbbb-0000-4000-8000-00000000000b", "remarks": "日本 02", "server": "203.0.113.2",
|
||
"server_port": 8389, "password": "pb", "method": "aes-256-gcm"}],
|
||
"x_shadowsocksx_ng": {"schema_version": 1, "root_group_id": "root",
|
||
"groups": [
|
||
{"id": "root", "name": "Example subscription", "children": [
|
||
{"type": "group", "id": "jp"}, {"type": "server", "id": "aaaaaaaa-0000-4000-8000-00000000000a"}]},
|
||
{"id": "jp", "name": "Japan", "children": [
|
||
{"type": "server", "id": "bbbbbbbb-0000-4000-8000-00000000000b"}]}]}}
|
||
""".utf8)
|
||
}
|
||
|
||
/// 远端改名 + b 移到根 + 重排:[b, Japan(空)],a 改名留在 Japan。
|
||
static func treeRenamedAndReordered() -> Data {
|
||
Data(
|
||
"""
|
||
{"version": 1,
|
||
"servers": [
|
||
{"id": "aaaaaaaa-0000-4000-8000-00000000000a", "remarks": "香港 01(新名)", "server": "203.0.113.1",
|
||
"server_port": 8388, "password": "pa", "method": "aes-256-gcm"},
|
||
{"id": "bbbbbbbb-0000-4000-8000-00000000000b", "remarks": "日本 02", "server": "203.0.113.2",
|
||
"server_port": 8389, "password": "pb", "method": "aes-256-gcm"}],
|
||
"x_shadowsocksx_ng": {"schema_version": 1, "root_group_id": "root",
|
||
"groups": [
|
||
{"id": "root", "name": "Example subscription", "children": [
|
||
{"type": "server", "id": "bbbbbbbb-0000-4000-8000-00000000000b"},
|
||
{"type": "group", "id": "jp"}]},
|
||
{"id": "jp", "name": "Japan", "children": [
|
||
{"type": "server", "id": "aaaaaaaa-0000-4000-8000-00000000000a"}]}]}}
|
||
""".utf8)
|
||
}
|
||
|
||
static func duplicateIDs() -> Data {
|
||
let server = serverJSON(id: stableServerA, address: "203.0.113.1", port: 8388, remark: "x")
|
||
let duplicate = serverJSON(id: stableServerA, address: "203.0.113.2", port: 9999, remark: "y")
|
||
return Data(
|
||
"""
|
||
{"version": 1, "servers": [\(server), \(duplicate)]}
|
||
""".utf8)
|
||
}
|
||
|
||
static func invalidRecord() -> Data {
|
||
Data(
|
||
"""
|
||
{"version": 1, "servers": [
|
||
{"id": "\(stableServerA)", "server": "203.0.113.1", "server_port": 0,
|
||
"password": "p", "method": "m"}]}
|
||
""".utf8)
|
||
}
|
||
|
||
static func idLess(port: Int = 8388) -> Data {
|
||
Data(
|
||
"""
|
||
{"version": 1, "servers": [
|
||
{"server": "203.0.113.5", "server_port": \(port), "password": "p",
|
||
"method": "m", "remarks": "无名"}]}
|
||
""".utf8)
|
||
}
|
||
|
||
static func cycleExtension() -> Data {
|
||
Data(
|
||
"""
|
||
{"version": 1,
|
||
"servers": [
|
||
{"id": "\(stableServerA)", "server": "203.0.113.1", "server_port": 8388, "password": "pa", "method": "m"},
|
||
{"id": "\(stableServerB)", "server": "203.0.113.2", "server_port": 8389, "password": "pb", "method": "m"}],
|
||
"x_shadowsocksx_ng": {"schema_version": 1, "root_group_id": "root",
|
||
"groups": [
|
||
{"id": "root", "name": "R", "children": [{"type": "group", "id": "x"}]},
|
||
{"id": "x", "name": "X", "children": [{"type": "group", "id": "root"}]}]}}
|
||
""".utf8)
|
||
}
|
||
}
|