Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/Catalog/Workflow/CatalogWorkflowSubscriptionTests.swift

342 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}