Files

278 lines
11 KiB
Swift

import XCTest
@testable import ShadowsocksX_NG2
/// 目录工作流 module 的失败语义与阶段分离:
/// 目录持久化失败时旧配置与旧凭据都保持有效;目录提交完成与运行时收敛是分离
/// 的观察面且收敛失败不回滚目录;Legacy 导入不驱动运行时收敛、不自动重复。
@MainActor
final class CatalogWorkflowRollbackTests: XCTestCase {
private var workDir: URL!
private var fileURL: URL!
private var settingsURL: URL!
private var activationURL: URL!
private var runtime: FakeCatalogRuntime!
private var bootstrap: CatalogCommitBootstrap!
private var rootsSeenByLegacyPostImport: [NodeID]?
override func setUp() async throws {
try await super.setUp()
workDir = FileManager.default.temporaryDirectory
.appendingPathComponent("catalog-rollback-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
// 目录文件置于 gate 子目录:破坏持久化时把 gate 目录替换为同名文件,
// 临时文件创建必失败(比替换目标更确定的失败注入)。
fileURL = workDir.appendingPathComponent("gate/catalog.json")
settingsURL = workDir.appendingPathComponent("proxy-listening.json")
activationURL = workDir.appendingPathComponent("activation.json")
runtime = FakeCatalogRuntime()
}
/// 让下一次目录持久化必然失败:gate 目录替换为同名文件后,临时文件无法
/// 在其中创建。
private func breakPersistence() throws {
let gateDir = workDir.appendingPathComponent("gate")
try FileManager.default.removeItem(at: gateDir)
try Data().write(to: gateDir)
}
override func tearDown() async throws {
try? FileManager.default.removeItem(at: workDir)
try await super.tearDown()
}
private func makeWorkflow(
legacyImportService: LegacyImportService? = nil,
postLegacyImport: ((LegacyImportOutcome) async -> Void)? = nil
) -> CatalogWorkflow {
runtime.hasActiveTarget = true
let fileStore = makeTestCatalogStore(fileURL: fileURL)
bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore)
let coordinator = CatalogCommitCoordinator(
fileStore: fileStore, runtime: runtime, bootstrap: bootstrap)
return makeCatalogWorkflow(
coordinator: coordinator,
plugins: NoManagedPluginProvider(),
legacyImportService: legacyImportService,
postLegacyImport: postLegacyImport)
}
/// 直建一条完整服务器记录,返回其身份。
private func seedServer(password: String) async throws -> NodeID {
var catalog = ConfigurationCatalog()
let fields = ServerFields(
address: "203.0.113.7", port: 8388, encryptionMethod: "aes-256-gcm",
password: password, remark: "夹具")
try catalog.addServer(fields)
try makeTestCatalogStore(fileURL: fileURL).save(CatalogDocument(catalog: catalog))
let workflow = makeWorkflow()
self.workflow = workflow
return try XCTUnwrap(workflow.tree.roots.first?.id)
}
private var workflow: CatalogWorkflow!
private func isFinished(_ status: RuntimeSyncStatus) -> Bool {
if case .finished = status { return true }
return false
}
// MARK: - 持久化失败 → 旧配置与旧凭据都保持有效
func testFailedSavePreservesServerFieldsAndPassword() async throws {
let id = try await seedServer(password: "旧密码")
try breakPersistence()
await expectThrowsAsync {
try await workflow.updateServer(
id,
draft: ServerEditDraft(
address: "198.51.100.9", port: 9999, encryptionMethod: "aes-256-gcm",
password: "新密码", remark: "新名称", plugin: .none, pluginOptions: nil))
}
let form = try XCTUnwrap(try workflow.serverEditForm(for: id))
XCTAssertEqual(form.address, "203.0.113.7", "目录提交失败则已发布状态不动")
XCTAssertEqual(form.password, "旧密码", "保存失败保留旧密码")
}
func testFailedSavePreservesPluginOptions() async throws {
// 尝试移除插件参数时保存失败,应保留完整旧记录。
var catalog = ConfigurationCatalog()
let fields = ServerFields(
address: "203.0.113.7", port: 8388, encryptionMethod: "aes-256-gcm",
password: "旧密码", remark: "夹具",
pluginProgram: "v2ray-plugin",
pluginOptions: "mode=websocket")
try catalog.addServer(fields)
try makeTestCatalogStore(fileURL: fileURL).save(CatalogDocument(catalog: catalog))
workflow = makeWorkflow()
let id = try XCTUnwrap(workflow.tree.roots.first?.id)
try breakPersistence()
await expectThrowsAsync {
try await workflow.updateServer(
id,
draft: ServerEditDraft(
address: "203.0.113.7", port: 8388, encryptionMethod: "aes-256-gcm",
password: "旧密码", remark: "新名称", plugin: .none, pluginOptions: nil))
}
let form = try XCTUnwrap(try workflow.serverEditForm(for: id))
XCTAssertEqual(form.plugin.selection, .named(program: "v2ray-plugin"), "插件引用保留")
XCTAssertEqual(form.plugin.options, "mode=websocket", "被删除的参数秘密已恢复")
}
func testMissingServerUpdateFailsWithoutPublishing() async throws {
_ = try await seedServer(password: "旧密码")
// 节点不存在时拒绝更新。
await expectThrowsAsync {
try await workflow.updateServer(
NodeID(rawValue: "missing"),
draft: ServerEditDraft(
address: "198.51.100.9", port: 9999, encryptionMethod: "aes-256-gcm",
password: "新密码", remark: "新名称", plugin: .none, pluginOptions: nil))
}
}
// MARK: - 表单新建持久化失败不发布记录
func testFailedCreateDoesNotPublishServer() async throws {
let workflow = makeWorkflow()
// 先落一份空目录建立 gate 存储(写入器才会创建父目录),再破坏持久化。
try makeTestCatalogStore(fileURL: fileURL).save(
CatalogDocument(catalog: ConfigurationCatalog()))
try breakPersistence()
await expectThrowsAsync {
try await workflow.createServer(
ServerEditDraft(
address: "198.51.100.9", port: 8388, encryptionMethod: "aes-256-gcm",
password: "新密码", remark: "新名称", plugin: .none, pluginOptions: nil),
into: nil)
}
XCTAssertTrue(workflow.tree.isEmpty, "目录提交失败则已发布状态不动")
}
// MARK: - 提交完成与运行时收敛是分离的观察面
func testCommitCompletesWhileRuntimeConvergenceIsStillSyncing() async throws {
let workflow = makeWorkflow()
runtime.hasActiveTarget = true
runtime.armGate(count: 1)
let groupID = try await workflow.createGroup(named: "组", into: nil)
// 目录提交已完成(树已发布),运行时仍在收敛中。
XCTAssertNotNil(workflow.tree.node(withID: groupID))
XCTAssertEqual(workflow.runtimeSync, .syncing(generation: 1))
runtime.openGate()
await waitUntilRuntimeSettles(isFinished(workflow.runtimeSync))
guard case .finished(1, .converged(skippedServers: [])) = workflow.runtimeSync else {
return XCTFail("收敛应正常结束,实际 \(workflow.runtimeSync)")
}
}
// MARK: - 运行时收敛失败不回滚已提交目录
func testRuntimeConvergenceFailureDoesNotRollbackCatalog() async throws {
let workflow = makeWorkflow()
runtime.hasActiveTarget = true
runtime.enqueueOutcomes([.failed(failure: .launch(.missingRuntimeDocument))])
let groupID = try await workflow.createGroup(named: "组", into: nil)
await waitUntilRuntimeSettles(isFinished(workflow.runtimeSync))
XCTAssertNotNil(workflow.tree.node(withID: groupID), "收敛失败不回滚已提交目录")
XCTAssertEqual(
workflow.runtimeSync,
.finished(generation: 1, outcome: .failed(failure: .launch(.missingRuntimeDocument))))
}
// MARK: - Legacy 导入不驱动运行时,不自动重复
func testLegacyImportCommitsGroupWithoutRuntimeSyncOrRepeat() async throws {
var existingSettings = ProxyRuntimeSettings()
existingSettings.listen.socksPort = 2086
existingSettings.listen.httpPort = 2087
existingSettings.preferredMode = .global
let listeningStore = makeTestListeningStore(
fileURL: settingsURL)
try listeningStore.save(existingSettings.listen)
let existingTarget = NodeID(rawValue: "existing-target")
let activationStore = ActivationStateFileStore(fileURL: activationURL)
try activationStore.save(activeTargetID: existingTarget)
let service = try makeLegacyService()
var postCalls = 0
rootsSeenByLegacyPostImport = nil
let workflow = makeWorkflow(legacyImportService: service) { [weak self] _ in
postCalls += 1
self?.rootsSeenByLegacyPostImport =
self?.bootstrap.catalogSnapshotReader
.catalogSnapshot.rootChildren
}
XCTAssertTrue(
workflow.legacyImportState.snapshotFound, "发现快照后统一导入面板提供 Legacy 入口")
XCTAssertFalse(workflow.legacyImportState.completed)
// 有活动目标也不得触发运行时收敛(导入不启动代理、不写系统代理)。
XCTAssertTrue(runtime.hasActiveTarget)
let outcome = try await workflow.importLegacy()
XCTAssertEqual(outcome.report.importedServerCount, 1)
XCTAssertEqual(workflow.tree.roots.count, 1)
let group = try XCTUnwrap(workflow.tree.roots.first)
XCTAssertTrue(group.isGroup)
XCTAssertTrue(group.isManual)
XCTAssertEqual(group.childCount, 1)
XCTAssertEqual(
rootsSeenByLegacyPostImport, [group.id],
"独立导入先 reload 并发布共享目录快照,再进入运行时清理回调")
XCTAssertEqual(runtime.convergeCount, 0, "导入不经提交管线触发运行时收敛")
XCTAssertEqual(postCalls, 1, "导入后的运行时边界只经注入闭包(独立于普通提交)")
XCTAssertEqual(workflow.legacyImportState.completed, true, "成功导入写完成标记")
XCTAssertNotNil(workflow.tree.node(withID: outcome.groupID), "返回新建分组身份供选中")
XCTAssertEqual(try listeningStore.load(), existingSettings.listen, "工作流导入不得写入 2.0 偏好")
XCTAssertEqual(
try activationStore.loadActiveTargetID(), existingTarget,
"工作流导入不得写入或清除活动目标")
// 首次导入成功后不再自动重复;显式再导入创建独立分组。
workflow.refreshLegacyImportState()
XCTAssertTrue(workflow.legacyImportState.snapshotFound, "发现状态与完成标记独立")
XCTAssertTrue(workflow.legacyImportState.completed)
_ = try await workflow.importLegacy(reimport: true)
XCTAssertEqual(workflow.tree.roots.count, 2, "再导入创建新的独立手动分组")
XCTAssertEqual(runtime.convergeCount, 0)
}
// MARK: - 夹具
private func makeLegacySnapshot() throws -> LegacySnapshot {
let profile: [String: Any] = [
"Id": "11111111-2222-4333-8444-555555555555",
"ServerHost": "203.0.113.9",
"ServerPort": 8388,
"Method": "aes-256-gcm",
"Password": "legacy-pw",
"Remark": "旧服务器",
]
return try LegacySnapshot(
propertyList: ["ServerProfiles": [profile], "ShadowsocksRunningMode": "manual"])
}
private func makeLegacyService() throws -> LegacyImportService {
let snapshot = try makeLegacySnapshot()
return LegacyImportService(
source: FixedLegacySnapshotProvider(snapshot: snapshot),
catalogStore: makeTestCatalogStore(fileURL: fileURL),
marker: InMemoryLegacyImportMarker())
}
}