mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 10:44:50 +00:00
Select presets explicitly so document stores, runtime preparation, and browsing assertions use the intended rule sources.
282 lines
12 KiB
Swift
282 lines
12 KiB
Swift
import XCTest
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
/// Mode restoration and commands share the Domain availability policy without
|
|
/// starting a real runtime or touching host proxy settings.
|
|
@MainActor
|
|
final class ProxyRuntimeControllerModeTests: ProxyRuntimeControllerTests {
|
|
override func makeDefaultRuleSnapshots() -> BuiltinRuleSnapshots {
|
|
BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot)
|
|
}
|
|
|
|
func testRuntimeFactsProjectControllerStateAtStableSeam() async {
|
|
let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable())
|
|
|
|
XCTAssertEqual(
|
|
controller.runtimeFacts,
|
|
ProxyRuntimeFacts(status: .off, isOn: false))
|
|
|
|
await controller.resyncOnLaunch()
|
|
|
|
XCTAssertEqual(
|
|
controller.runtimeFacts,
|
|
ProxyRuntimeFacts(status: .running, isOn: true),
|
|
"默认 agent 意图开启:重同步后无目标也进入空列表监听")
|
|
}
|
|
}
|
|
|
|
// MARK: - 收敛票据全事实检查(候选①:agent 关闭不得被任何 await 窗口内的
|
|
// 旧收敛复活;restore 先查时效再动持久层)
|
|
|
|
@MainActor
|
|
extension ProxyRuntimeControllerModeTests {
|
|
/// agent-off 落在 mode 切换的健康门 await 窗口内:票据查全四事实 →
|
|
/// superseded,不触发 restore 重注册,用户的 mode 意图保留在持久层与内存。
|
|
func testAgentOffDuringModeTransitionHealthGateSupersedesWithoutRestore() async throws {
|
|
let seeded = try makeSeededCatalog()
|
|
let probe = ProxyRuntimeFixture.BlockingProbe()
|
|
let started = expectation(description: "mode transition health probe paused")
|
|
let listeningStore = InMemoryProxyListeningStore()
|
|
let controller = makeController(
|
|
probe: probe,
|
|
listeningStore: listeningStore,
|
|
settings: ProxyRuntimeSettings(
|
|
listen: ActivationFixture.listen, preferredMode: .global,
|
|
agentEnabled: true, systemProxyEnabled: true),
|
|
proxyMode: .global)
|
|
try await controller.activate(seeded.server)
|
|
XCTAssertEqual(controller.state, .running)
|
|
|
|
probe.arm(started: started)
|
|
let modeSwitch = Task { await controller.setProxyMode(.direct) }
|
|
await fulfillment(of: [started], timeout: 3)
|
|
await controller.setAgentEnabled(false)
|
|
XCTAssertEqual(controller.state, .off)
|
|
let registrationsAfterStop = agent.registerCount
|
|
probe.release()
|
|
await modeSwitch.value
|
|
|
|
XCTAssertEqual(
|
|
agent.registerCount, registrationsAfterStop, "被取代的切换不得经 restore 重注册 agent")
|
|
XCTAssertEqual(controller.state, .off)
|
|
XCTAssertFalse(controller.settings.agentEnabled)
|
|
XCTAssertEqual(controller.settings.preferredMode, ProxyModeKind.direct, "切换意图保留在内存")
|
|
XCTAssertEqual(controller.controlPreferences.preferredMode, ProxyModeKind.direct, "切换意图保留在持久层")
|
|
XCTAssertEqual(controller.settings.agentEnabled, false)
|
|
XCTAssertNil(controller.runtimeObservation)
|
|
}
|
|
|
|
/// agent-off 落在 mode→rule 的派生窗口内:派生缝 preparationIsCurrent
|
|
/// 恒查 agent 意图与并发流,派生即弃权,apply 根本不会启动。
|
|
func testAgentOffDuringModeRuleDerivationSupersedesDeployment() async throws {
|
|
let seeded = try makeSeededCatalog()
|
|
let started = expectation(description: "rule snapshot load paused")
|
|
let release = TestAsyncGate("rule snapshot load release")
|
|
defer { release.release() }
|
|
let loader = BuiltinRuleSnapshots(loader: { source -> RuleSnapshot in
|
|
if source == .geolocationCN {
|
|
started.fulfill()
|
|
_ = await release.wait()
|
|
}
|
|
return rulesFixture(source)
|
|
})
|
|
let controller = makeController(
|
|
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
|
settings: ProxyRuntimeSettings(
|
|
listen: ActivationFixture.listen, preferredMode: .global,
|
|
rulePreset: .chinaDirect, agentEnabled: true),
|
|
proxyMode: .global,
|
|
ruleSnapshots: loader)
|
|
try await controller.activate(seeded.server)
|
|
|
|
let modeSwitch = Task { await controller.setProxyMode(.rule) }
|
|
await fulfillment(of: [started], timeout: 3)
|
|
await controller.setAgentEnabled(false)
|
|
XCTAssertEqual(controller.state, .off)
|
|
let registrationsAfterStop = agent.registerCount
|
|
release.release()
|
|
await modeSwitch.value
|
|
|
|
XCTAssertEqual(agent.registerCount, registrationsAfterStop, "派生被取代后不得部署")
|
|
XCTAssertEqual(controller.state, .off)
|
|
XCTAssertNil(controller.runtimeObservation)
|
|
XCTAssertFalse(controller.settings.agentEnabled)
|
|
XCTAssertEqual(controller.settings.preferredMode, ProxyModeKind.rule, "切换意图保留")
|
|
}
|
|
|
|
/// agent-off 落在目录部署的健康门窗口内:结果必须是
|
|
/// superseded(而非误分类的 failed),运行时保持 off、lastDocument 不复活。
|
|
func testAgentOffDuringDeploymentHealthGateSupersedesDeployment() async throws {
|
|
let seeded = try makeSeededCatalog()
|
|
let probe = ProxyRuntimeFixture.BlockingProbe()
|
|
let started = expectation(description: "deployment health probe paused")
|
|
let controller = makeController(
|
|
probe: probe,
|
|
settings: ProxyRuntimeSettings(
|
|
listen: ActivationFixture.listen, preferredMode: .global, agentEnabled: true),
|
|
proxyMode: .global)
|
|
try await controller.activate(seeded.server)
|
|
XCTAssertEqual(controller.state, .running)
|
|
|
|
probe.arm(started: started)
|
|
let deployment = Task {
|
|
await controller.deploy(SslocalRuntimeDocument(servers: [], listen: ActivationFixture.listen))
|
|
}
|
|
await fulfillment(of: [started], timeout: 3)
|
|
await controller.setAgentEnabled(false)
|
|
XCTAssertEqual(controller.state, .off)
|
|
let registrationsAfterStop = agent.registerCount
|
|
probe.release()
|
|
let result = await deployment.value
|
|
|
|
XCTAssertEqual(result, .superseded)
|
|
XCTAssertEqual(controller.state, .off)
|
|
XCTAssertEqual(agent.registerCount, registrationsAfterStop)
|
|
XCTAssertNil(controller.runtimeObservation)
|
|
}
|
|
|
|
/// 防火墙轮询在 await 恢复后代际已推进(含取消本任务的 stop 路径)时,
|
|
/// 不得把过期的 .firewallBlocked 盖到最新状态呈现上。
|
|
func testFirewallPollDoesNotWriteAfterExecutionSupersedesIt() async throws {
|
|
let seeded = try makeSeededCatalog()
|
|
let started = expectation(description: "firewall poll awaiting checker")
|
|
let checker = ProxyRuntimeFixture.BlockingFirewallChecker(
|
|
outcomes: [.permitted, .blocked], blockOnCall: 2, started: started)
|
|
let controller = makeController(
|
|
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
|
listen: ProxyListeningConfiguration(listenerMode: .allIPv4Interfaces),
|
|
firewallChecker: checker)
|
|
try await controller.activate(seeded.server)
|
|
XCTAssertEqual(controller.state, .running, "首次防火墙检查通过后进入运行态并武装轮询")
|
|
|
|
await fulfillment(of: [started], timeout: 3)
|
|
await controller.setAgentEnabled(false)
|
|
XCTAssertEqual(controller.state, .off)
|
|
checker.release()
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
|
|
XCTAssertEqual(controller.state, .off, "被取代的轮询不得改写状态")
|
|
}
|
|
|
|
}
|
|
|
|
/// Coordinator/controller integration for the shared committed catalog source.
|
|
@MainActor
|
|
final class CatalogRuntimeSnapshotIntegrationTests: XCTestCase {
|
|
private var runtime: ProxyRuntimeFixture.TemporaryRuntime!
|
|
private var catalogFileURL: URL!
|
|
private var activationFileURL: URL!
|
|
private var agent: ProxyRuntimeFixture.FakeLaunchAgent!
|
|
private var systemProxy: ProxyRuntimeFixture.FakeSystemProxy!
|
|
|
|
override func setUp() async throws {
|
|
try await super.setUp()
|
|
runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
|
|
catalogFileURL = runtime.directory.appendingPathComponent("catalog.json")
|
|
activationFileURL = runtime.directory.appendingPathComponent("activation.json")
|
|
|
|
agent = ProxyRuntimeFixture.FakeLaunchAgent()
|
|
systemProxy = ProxyRuntimeFixture.FakeSystemProxy()
|
|
}
|
|
|
|
override func tearDown() async throws {
|
|
try? FileManager.default.removeItem(at: runtime.directory)
|
|
try await super.tearDown()
|
|
}
|
|
|
|
func testCommitWithoutActiveTargetFeedsLaterActivationThroughSharedSnapshot() async throws {
|
|
let fileStore = makeTestCatalogStore(fileURL: catalogFileURL)
|
|
let bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore)
|
|
let controller = makeController(
|
|
catalogSnapshotReader: bootstrap.catalogSnapshotReader,
|
|
settings: ProxyRuntimeSettings(listen: ActivationFixture.listen, agentEnabled: false))
|
|
let coordinator = CatalogCommitCoordinator(
|
|
fileStore: fileStore,
|
|
runtime: ProxyRuntimeSyncAdapter(controller: controller),
|
|
bootstrap: bootstrap)
|
|
|
|
let server = try coordinator.commit { catalog, _ in
|
|
try ActivationFixture.addPlainServer(
|
|
"提交后激活", in: &catalog)
|
|
}
|
|
|
|
XCTAssertEqual(coordinator.syncStatus, .idle, "无目标时目录只发布,不触发运行时")
|
|
XCTAssertNil(controller.activeTargetID)
|
|
try await controller.activate(server)
|
|
|
|
XCTAssertEqual(controller.activeTargetID, server, "激活读取协调器刚发布的唯一快照")
|
|
XCTAssertEqual(controller.state, .off, "agent 意图关闭时该命令仅选择目标,不部署")
|
|
}
|
|
|
|
func testCoordinatorUsesProductionAdapterToClearRemovedActiveTarget() async throws {
|
|
let server = try makeSeededCatalog()
|
|
try ActivationStateFileStore(fileURL: activationFileURL).save(activeTargetID: server)
|
|
let fileStore = makeTestCatalogStore(fileURL: catalogFileURL)
|
|
let bootstrap = CatalogCommitCoordinator.bootstrap(fileStore: fileStore)
|
|
let controller = makeController(
|
|
catalogSnapshotReader: bootstrap.catalogSnapshotReader,
|
|
probe: ProxyRuntimeFixture.FakeProbe.reachable())
|
|
let coordinator = CatalogCommitCoordinator(
|
|
fileStore: fileStore,
|
|
runtime: ProxyRuntimeSyncAdapter(controller: controller),
|
|
bootstrap: bootstrap)
|
|
|
|
_ = try coordinator.commit { catalog, _ in try catalog.remove(server) }
|
|
|
|
await waitUntilRuntimeSettles(
|
|
{
|
|
if case .finished = coordinator.syncStatus { return true }
|
|
return false
|
|
}())
|
|
|
|
guard case .finished(_, .clearedAndStopped) = coordinator.syncStatus else {
|
|
return XCTFail("生产 adapter 应以刚提交快照清理无效目标")
|
|
}
|
|
XCTAssertNil(controller.activeTargetID)
|
|
XCTAssertNil(
|
|
try ActivationStateFileStore(fileURL: activationFileURL).loadActiveTargetID(),
|
|
"adapter 收敛后持久化清除失效目标")
|
|
}
|
|
|
|
private func makeController(
|
|
catalogSnapshotReader: RuntimeCatalogSnapshotReading,
|
|
settings: ProxyRuntimeSettings? = nil,
|
|
probe: EndpointProbing = SystemEndpointProbe()
|
|
) -> ProxyRuntimeController {
|
|
let runtimeFileStore = RuntimeFileStore(fileURL: runtime.contract)
|
|
agent.onRegister = { [runtimeFileStore] in
|
|
try? runtimeFileStore.writePublishedRuntimeReceipt(processID: 42)
|
|
}
|
|
return ProxyRuntimeController(
|
|
controlPreferences: makeTestProxyControlPreferences(),
|
|
catalogSnapshotReader: catalogSnapshotReader,
|
|
activationFileStore: ActivationStateFileStore(fileURL: activationFileURL),
|
|
runtimeFileStore: runtimeFileStore,
|
|
plugins: ActivationFixture.plugins,
|
|
listeningStore: InMemoryProxyListeningStore(),
|
|
appBundle: AppArtifact.bundle,
|
|
ruleSnapshots: BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot),
|
|
settingsRestore: RestoredProxyRuntimeSettings(
|
|
settings: settings ?? ProxyRuntimeSettings(listen: ActivationFixture.listen),
|
|
unreadableError: nil),
|
|
agent: agent,
|
|
probe: probe,
|
|
systemProxy: systemProxy,
|
|
systemProxyHelper: ProxyRuntimeFixture.FakeSystemProxyHelperService(),
|
|
firewallExecutableURLs: [URL(fileURLWithPath: "/bundle/Helpers/sslocal")],
|
|
firewallPollIntervalNanoseconds: 1_000_000,
|
|
launchHealthTimeoutSeconds: 0.05,
|
|
sendSignal: { _, _ in 0 },
|
|
processIsAlive: { $0 == 42 })
|
|
}
|
|
|
|
private func makeSeededCatalog() throws -> NodeID {
|
|
var catalog = ConfigurationCatalog()
|
|
let server = try ActivationFixture.addPlainServer(
|
|
"待删除", in: &catalog)
|
|
try makeTestCatalogStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
|
|
return server
|
|
}
|
|
}
|