mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
Remove lossy default-action writes that can replace china-proxy with gfwlist. Preserve legacy preference reads and derive the default action from the selected preset. Update tests to use explicit presets and verify identity, legacy compatibility, and no-op reselection.
247 lines
11 KiB
Swift
247 lines
11 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
/// 通过模式命令验证配置派生失败和无 ACL 变化路径,不调用恢复实现。
|
|
@MainActor
|
|
struct RuntimeConvergenceTests {
|
|
@Test(arguments: [false, true])
|
|
func modeAndRuleChangesUseLatestCommittedCatalog(savedRules: Bool) async throws {
|
|
let fixture = try Fixture(systemProxyEnabled: false)
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.server)
|
|
if savedRules { await fixture.controller.setProxyMode(.rule) }
|
|
var catalog = fixture.catalogReader.catalogSnapshot
|
|
var fields = ActivationFixture.plainFields(remark: "更新资料", password: "updated-password")
|
|
fields.address = "198.51.100.9"
|
|
try catalog.updateServer(fixture.server, with: fields)
|
|
try makeTestCatalogStore(
|
|
fileURL: fixture.runtime.directory.appendingPathComponent("catalog.json")
|
|
)
|
|
.save(CatalogDocument(catalog: catalog))
|
|
fixture.catalogReader.catalogSnapshot = catalog
|
|
|
|
if savedRules {
|
|
await fixture.controller.convergeRuntime(.savedRules)
|
|
} else {
|
|
await fixture.controller.setProxyMode(.direct)
|
|
}
|
|
|
|
let output = try #require(fixture.runtimeStore.loadDocument())
|
|
#expect(output.servers.first?.server == "198.51.100.9")
|
|
#expect(output.servers.first?.password == "updated-password")
|
|
#expect(fixture.controller.state == .running)
|
|
}
|
|
|
|
@Test func modeDerivationFailurePreservesNewIntentAndPublishedFiles() async throws {
|
|
let fixture = try Fixture(
|
|
systemProxyEnabled: true,
|
|
snapshots: BuiltinRuleSnapshots(loader: { _ in throw RuleSnapshotError.missing }))
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.server)
|
|
let previous = try #require(fixture.runtimeStore.loadDocument())
|
|
let appliedCount = fixture.proxy.applied.count
|
|
|
|
await fixture.controller.setProxyMode(.rule)
|
|
|
|
#expect(fixture.controller.proxyMode == .rule)
|
|
#expect(fixture.controller.settings.preferredMode == .rule)
|
|
#expect(fixture.runtimeStore.loadDocument() == previous)
|
|
#expect(
|
|
fixture.controller.state
|
|
== .serviceFailed(.runtimeFile))
|
|
#expect(fixture.controller.controlPreferences.preferredMode == .rule)
|
|
#expect(fixture.controller.systemProxyState == .applied)
|
|
#expect(fixture.proxy.applied.count == appliedCount)
|
|
#expect(fixture.proxy.clearCount == 0)
|
|
}
|
|
|
|
@Test func publishedFailureSurvivesGUIReconstructionAndExplicitRetryWorks() async throws {
|
|
let fixture = try Fixture(systemProxyEnabled: true)
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.server)
|
|
fixture.probe.setOutcomes([.refused(detail: "test failure")])
|
|
await fixture.controller.setProxyMode(.direct)
|
|
#expect(fixture.controller.proxyMode == .direct)
|
|
#expect(fixture.controller.settings.agentEnabled)
|
|
#expect(fixture.controller.settings.systemProxyEnabled)
|
|
#expect(!fixture.controller.state.isOn)
|
|
#expect(fixture.proxy.clearCount > 0)
|
|
let failure = try #require(fixture.runtimeStore.readFailure())
|
|
let requested = try #require(fixture.runtimeStore.loadDocument())
|
|
#expect(requested.aclRuntime?.summary == "direct")
|
|
let registerCount = fixture.agent.registerCount
|
|
fixture.probe.setOutcomes([.reachable])
|
|
await fixture.controller.resyncOnLaunch()
|
|
#expect(fixture.agent.registerCount == registerCount)
|
|
#expect(fixture.runtimeStore.readFailure() == failure)
|
|
// 文件存取对象重建后仍使用磁盘失败身份,而非控制器中的瞬时状态。
|
|
let reopened = RuntimeFileStore(fileURL: fixture.runtimeStore.fileURL)
|
|
#expect(reopened.readFailure()?.deploymentID == reopened.receiptIdentity(for: requested))
|
|
await fixture.controller.setAgentEnabled(true)
|
|
#expect(fixture.controller.state == .running)
|
|
#expect(fixture.runtimeStore.readFailure() == nil)
|
|
#expect(fixture.agent.registerCount > registerCount)
|
|
}
|
|
|
|
@Test(arguments: ["configuration", "acl", "intent"])
|
|
func missingResourcesCannotTurnGUIRestartIntoRetry(resource: String) async throws {
|
|
let fixture = try Fixture(systemProxyEnabled: true)
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.server)
|
|
fixture.probe.setOutcomes([.refused(detail: "failure")])
|
|
await fixture.controller.setProxyMode(.direct)
|
|
let snapshot = try #require(
|
|
RuntimePublication.load(at: fixture.runtimeStore.supervisionFileURL))
|
|
let name =
|
|
resource == "configuration"
|
|
? snapshot.configuration
|
|
: resource == "intent" ? snapshot.intentMetadata : try #require(snapshot.acl?.file)
|
|
try FileManager.default.removeItem(at: fixture.runtime.directory.appendingPathComponent(name))
|
|
let count = fixture.agent.registerCount
|
|
let restarted = fixture.reopeningController()
|
|
defer {
|
|
restarted.systemProxyObserver.systemProxyHealthTask?.cancel()
|
|
restarted.cancelFirewallObservation()
|
|
}
|
|
fixture.probe.setOutcomes([.reachable])
|
|
await restarted.resyncOnLaunch()
|
|
#expect(fixture.agent.registerCount == count)
|
|
#expect(restarted.state == .serviceFailed(.agent))
|
|
#expect(fixture.runtimeStore.readFailure()?.deploymentID == snapshot.deploymentID)
|
|
await restarted.setAgentEnabled(true)
|
|
#expect(restarted.state == .running)
|
|
#expect(fixture.agent.registerCount > count)
|
|
}
|
|
|
|
@Test(arguments: [false, true], RulePreset.allCases)
|
|
func unchangedModeACLDoesNotReprobeHealthyListeners(
|
|
systemProxyEnabled: Bool, preset: RulePreset
|
|
) async throws {
|
|
let fixture = try Fixture(systemProxyEnabled: systemProxyEnabled)
|
|
defer { fixture.cleanUp() }
|
|
try await fixture.controller.activate(fixture.server)
|
|
try Data("42".utf8).write(to: fixture.runtime.pidFile)
|
|
let bytes = try Data(contentsOf: fixture.runtimeStore.fileURL)
|
|
let registers = fixture.agent.registerCount
|
|
let unregisters = fixture.agent.unregisterCount
|
|
let probes = fixture.probe.ports.count
|
|
let signals = fixture.signals.signalsSent.filter { $0.signal != 0 }.count
|
|
|
|
// 全局模式仍保存规则预设,但它不改变全局 ACL。
|
|
await fixture.controller.setRulePreset(preset)
|
|
let generation = fixture.controller.modeChangeGeneration
|
|
await fixture.controller.setRulePreset(preset)
|
|
|
|
#expect(fixture.controller.rulePreset == preset)
|
|
#expect(fixture.controller.modeChangeGeneration == generation)
|
|
if preset != .gfwlist {
|
|
#expect(fixture.controller.controlPreferences.rulePreset == preset)
|
|
}
|
|
#expect(fixture.controller.proxyMode == .global)
|
|
#expect(fixture.controller.state == .running)
|
|
#expect(try Data(contentsOf: fixture.runtimeStore.fileURL) == bytes)
|
|
#expect(fixture.agent.registerCount == registers)
|
|
#expect(fixture.agent.unregisterCount == unregisters)
|
|
#expect(fixture.signals.signalsSent.filter { $0.signal != 0 }.count == signals)
|
|
#expect(fixture.probe.ports.count == probes)
|
|
}
|
|
|
|
@MainActor
|
|
private final class CatalogReader: RuntimeCatalogSnapshotReading {
|
|
var catalogSnapshot: ConfigurationCatalog
|
|
init(_ catalog: ConfigurationCatalog) { catalogSnapshot = catalog }
|
|
}
|
|
|
|
@MainActor
|
|
private struct Fixture {
|
|
let runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
|
|
let agent = ProxyRuntimeFixture.FakeLaunchAgent()
|
|
let probe = ProxyRuntimeFixture.FakeProbe.reachable()
|
|
let proxy = ProxyRuntimeFixture.FakeSystemProxy()
|
|
let settings = InMemoryProxyListeningStore()
|
|
let signals = ProxyRuntimeControllerTests.SignalRecorder()
|
|
let runtimeStore: RuntimeFileStore
|
|
let controller: ProxyRuntimeController
|
|
let server: NodeID
|
|
let catalogReader: CatalogReader
|
|
|
|
init(
|
|
systemProxyEnabled: Bool,
|
|
snapshots: BuiltinRuleSnapshots = BuiltinRuleSnapshots(
|
|
loader: ProxyRuntimeFixture.controlFlowRuleSnapshot)
|
|
) throws {
|
|
var catalog = ConfigurationCatalog()
|
|
server = try ActivationFixture.addPlainServer("收敛测试", in: &catalog)
|
|
let catalogURL = runtime.directory.appendingPathComponent("catalog.json")
|
|
try makeTestCatalogStore(fileURL: catalogURL).save(CatalogDocument(catalog: catalog))
|
|
catalogReader = CatalogReader(catalog)
|
|
runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
|
|
let store = runtimeStore
|
|
let recorder = signals
|
|
agent.onUnregister = { recorder.terminateWrapper() }
|
|
agent.onRegister = {
|
|
recorder.relaunchWrapper()
|
|
try? store.writePublishedRuntimeReceipt(processID: 42)
|
|
}
|
|
recorder.reloadReceipt = {
|
|
try? store.writePublishedRuntimeReceipt(processID: 42)
|
|
}
|
|
controller = ProxyRuntimeController(
|
|
controlPreferences: makeTestProxyControlPreferences(),
|
|
catalogSnapshotReader: catalogReader,
|
|
activationFileStore: ActivationStateFileStore(
|
|
fileURL: runtime.directory.appendingPathComponent("activation.json")),
|
|
runtimeFileStore: runtimeStore,
|
|
plugins: ActivationFixture.plugins, listeningStore: settings,
|
|
customRuleStore: CustomRuleStore(
|
|
fileURL: runtime.directory.appendingPathComponent("rules.json")),
|
|
appBundle: AppArtifact.bundle, ruleSnapshots: snapshots,
|
|
settingsRestore: RestoredProxyRuntimeSettings(
|
|
settings: ProxyRuntimeSettings(
|
|
listen: ActivationFixture.listen, preferredMode: .global,
|
|
agentEnabled: true, systemProxyEnabled: systemProxyEnabled),
|
|
unreadableError: nil),
|
|
agent: agent, probe: probe, systemProxy: proxy,
|
|
systemProxyHelper: ProxyRuntimeFixture.FakeSystemProxyHelperService(),
|
|
proxyMode: .global, firewallChecker: ProxyRuntimeFixture.FakeFirewallChecker(),
|
|
launchHealthTimeoutSeconds: 0.05,
|
|
launchHealthRetryDelay: { await Task.yield() },
|
|
systemProxyHealthPollIntervalNanoseconds: 60_000_000_000,
|
|
helperRefreshDelayNanoseconds: 0,
|
|
sendSignal: { recorder.send($0, $1) },
|
|
processIsAlive: { recorder.send($0, 0) == 0 })
|
|
}
|
|
|
|
func reopeningController() -> ProxyRuntimeController {
|
|
ProxyRuntimeController(
|
|
controlPreferences: makeTestProxyControlPreferences(),
|
|
catalogSnapshotReader: controller.catalogSnapshotReader,
|
|
activationFileStore: controller.activationFileStore,
|
|
runtimeFileStore: runtimeStore,
|
|
plugins: ActivationFixture.plugins, listeningStore: settings,
|
|
customRuleStore: CustomRuleStore(
|
|
fileURL: runtime.directory.appendingPathComponent("rules.json")),
|
|
appBundle: AppArtifact.bundle,
|
|
ruleSnapshots: BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot),
|
|
settingsRestore: RestoredProxyRuntimeSettings(
|
|
settings: controller.settings, unreadableError: nil),
|
|
agent: agent, probe: probe, systemProxy: proxy,
|
|
systemProxyHelper: ProxyRuntimeFixture.FakeSystemProxyHelperService(),
|
|
firewallChecker: ProxyRuntimeFixture.FakeFirewallChecker(),
|
|
launchHealthTimeoutSeconds: 0.05, launchHealthRetryDelay: { await Task.yield() },
|
|
systemProxyHealthPollIntervalNanoseconds: 60_000_000_000,
|
|
helperRefreshDelayNanoseconds: 0,
|
|
sendSignal: { [signals] in signals.send($0, $1) },
|
|
processIsAlive: { [signals] in signals.send($0, 0) == 0 })
|
|
}
|
|
|
|
func cleanUp() {
|
|
controller.systemProxyObserver.systemProxyHealthTask?.cancel()
|
|
controller.cancelFirewallObservation()
|
|
try? FileManager.default.removeItem(at: runtime.directory)
|
|
}
|
|
}
|
|
}
|