Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/ProxyControlWorkflowTests.swift
T
Qiu Yuzhou d2a8d6105c feat(system-proxy): route writes through privileged LaunchDaemon helper
Implements issue #71 with ADR-0020 (supersedes ADR-0019's endpoint-signature
cleanup, startup cleanup, and health-gate cleanup).

- New ShadowsocksX-NG2SystemProxyHelper target: app-bundled root LaunchDaemon
  (SMAppService.daemon, on-demand via MachServices) that mechanically applies
  a closed typed configuration or executes an unconditional clear. Clients are
  accepted by code-signing bundle identifier only; requests run serially on a
  shared queue (last-writer-wins).
- SystemProxyConfiguration is now the closed typed apply payload (Codable over
  JSON XPC): explicit SOCKS/HTTP/HTTPS enable + endpoints, PAC and
  auto-discovery disabled with URL/JS removed, explicit simple-hostname
  exclusion, complete ExceptionsList. Unmodeled Proxies keys are preserved and
  equivalent values skip the commit.
- Cleanup is a side effect of a persisted system-proxy intent on-to-off
  transition only: the switch itself, or the agent-off cascade (persist both
  intents off, clear, then stop listeners; failed cascade persistence records
  no transition and clears nothing). Launch with the switch off, health-gate
  closure, invalid targets, and passive events never clear. Failed clears are
  surfaced without deferred retry.
- Endpoint-signature store, ownership migration, and direct Authorization
  Services writes are removed; old signature files stay inert. Helper
  unavailability or pending approval keeps an enabled intent pending and
  surfaces the Login Items approval path (HomeView button + menu label).
2026-09-30 12:26:40 +08:00

297 lines
12 KiB
Swift
Raw 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 Combine
import XCTest
@testable import ShadowsocksX_NG2
// MARK: - 确定性替身(snapshot 与命令 contract;不触真实运行时)
/// 可编程 runtime 替身(issue #47/#60,story 31):事实可编程、命令全记录;
/// changes 为手动 subject,同步发值保证观察测试确定性。
@MainActor
private final class FakeProxyRuntime: ProxyRuntimeAdapting {
var runtimeFacts: ProxyRuntimeFacts
var agentIntentEnabled: Bool
var activationFailure: ActivationFailure?
var systemProxyIntentEnabled: Bool
var systemProxyApplication: SystemProxyApplicationFacts
var systemProxyApprovalRequired = false
var proxyMode: ProxyMode
var ruleDefaultAction: RuleDefaultAction = .proxyWhenUnmatched
var skippedInvalidServerCount = 0
var activeTargetID: NodeID?
var httpExportCapability = HTTPExportCapability(
copyableLine:
"export http_proxy=http://127.0.0.1:11087;export https_proxy=http://127.0.0.1:11087;")
var terminalProxyEnvironmentCommands = TerminalProxyEnvironmentCommands(
listen: SslocalListenSettings())
private let changeSubject = PassthroughSubject<Void, Never>()
var changes: AnyPublisher<Void, Never> { changeSubject.eraseToAnyPublisher() }
private(set) var resyncCount = 0
private(set) var agentCommands: [Bool] = []
private(set) var systemProxyCommands: [Bool] = []
private(set) var approvalCommandCount = 0
private(set) var modeCommands: [ProxyMode] = []
private(set) var ruleDefaultActionCommands: [RuleDefaultAction] = []
init(
facts: ProxyRuntimeFacts = ProxyRuntimeFacts(status: .off, isOn: false),
agentIntent: Bool = true,
systemProxyIntent: Bool = false,
systemProxyApplication: SystemProxyApplicationFacts = .idle,
mode: ProxyMode = .rule
) {
runtimeFacts = facts
agentIntentEnabled = agentIntent
systemProxyIntentEnabled = systemProxyIntent
self.systemProxyApplication = systemProxyApplication
proxyMode = mode
}
func emitChange() { changeSubject.send() }
func resyncOnLaunch() async { resyncCount += 1 }
func setAgentEnabled(_ enabled: Bool) async { agentCommands.append(enabled) }
func setSystemProxyEnabled(_ enabled: Bool) async { systemProxyCommands.append(enabled) }
func openSystemProxyHelperApproval() async { approvalCommandCount += 1 }
func setProxyMode(_ mode: ProxyMode) async { modeCommands.append(mode) }
func setRuleDefaultAction(_ action: RuleDefaultAction) async {
ruleDefaultActionCommands.append(action)
}
}
/// 可编程目标事实替身(story 32):路径摘要可编程、查询全记录。
@MainActor
private final class FakeTargetFacts: ProxyTargetFactsReading {
var pathByTarget: [NodeID: String] = [:]
private(set) var queriedTargetIDs: [NodeID?] = []
func activeTargetFacts(for targetID: NodeID?) -> ProxyActiveTargetFacts? {
queriedTargetIDs.append(targetID)
guard let targetID else { return nil }
return ProxyActiveTargetFacts(id: targetID, pathSummary: pathByTarget[targetID])
}
}
/// 代理控制工作流的 snapshot 与命令 contract(issue #47/#60):整体一致性、
/// typed commands、窄目录事实缝、运行时变化重观察与命令边界。
@MainActor
final class ProxyControlWorkflowTests: XCTestCase {
private var runtime: FakeProxyRuntime!
private var targetFacts: FakeTargetFacts!
private var workflow: ProxyControlWorkflow!
override func setUp() async throws {
try await super.setUp()
runtime = FakeProxyRuntime()
targetFacts = FakeTargetFacts()
workflow = ProxyControlWorkflow(runtime: runtime, targetFacts: targetFacts)
}
private static let serverID = NodeID(rawValue: "manual:server-1")
// MARK: - 整体 snapshot
func testSnapshotAssemblesAllFactsFromSingleObservation() {
runtime.runtimeFacts = ProxyRuntimeFacts(
status: .running,
isOn: true,
failure: .firewallBlocked(FirewallBlockedFacts(executableName: "sslocal")))
runtime.agentIntentEnabled = true
runtime.activationFailure = .noActiveTarget
runtime.systemProxyIntentEnabled = true
runtime.systemProxyApplication = .pending
runtime.proxyMode = .global
runtime.skippedInvalidServerCount = 3
runtime.activeTargetID = Self.serverID
runtime.httpExportCapability = HTTPExportCapability(
copyableLine: "export http_proxy=http://127.0.0.1:11087;")
targetFacts.pathByTarget[Self.serverID] = "组A / 香港 01"
runtime.emitChange()
XCTAssertEqual(
workflow.snapshot,
ProxyControlSnapshot(
runtime: ProxyRuntimeFacts(
status: .running,
isOn: true,
failure: .firewallBlocked(FirewallBlockedFacts(executableName: "sslocal"))),
agentIntentEnabled: true,
activationFailure: .noActiveTarget,
systemProxyIntentEnabled: true,
systemProxyApplication: .pending,
systemProxyApprovalRequired: false,
proxyMode: .global,
ruleDefaultAction: .proxyWhenUnmatched,
availableModes: ProxyMode.availableModes,
activeTarget: ProxyActiveTargetFacts(id: Self.serverID, pathSummary: "组A / 香港 01"),
skippedInvalidServerCount: 3,
httpExport: HTTPExportCapability(
copyableLine: "export http_proxy=http://127.0.0.1:11087;"),
terminalProxyEnvironmentCommands: runtime.terminalProxyEnvironmentCommands))
XCTAssertEqual(
workflow.snapshot.availableModes, ProxyMode.availableModes,
"可用模式是 issue #46 Domain 单点策略的投影")
}
// MARK: - typed commands:转发并整体重发布
func testAgentEnableCommandForwardsAndRepublishesWholeSnapshot() async {
let before = workflow.snapshot
runtime.runtimeFacts = ProxyRuntimeFacts(status: .running, isOn: true)
runtime.agentIntentEnabled = true
runtime.skippedInvalidServerCount = 2
runtime.activeTargetID = Self.serverID
let after = await workflow.setAgentEnabled(true)
XCTAssertEqual(runtime.agentCommands, [true])
XCTAssertEqual(after, workflow.snapshot, "命令返回值就是已发布的最新 snapshot")
XCTAssertNotEqual(after, before, "命令完成后整体重发布")
XCTAssertEqual(after.runtime, ProxyRuntimeFacts(status: .running, isOn: true))
XCTAssertEqual(after.skippedInvalidServerCount, 2)
}
func testAgentDisableCommandForwardsAndRepublishesWholeSnapshot() async {
runtime.runtimeFacts = ProxyRuntimeFacts(status: .running, isOn: true)
runtime.agentIntentEnabled = true
_ = await workflow.setAgentEnabled(true)
runtime.runtimeFacts = ProxyRuntimeFacts(status: .off, isOn: false)
runtime.agentIntentEnabled = false
runtime.skippedInvalidServerCount = 0
runtime.activeTargetID = nil
let after = await workflow.setAgentEnabled(false)
XCTAssertEqual(runtime.agentCommands, [true, false])
XCTAssertEqual(after.runtime, ProxyRuntimeFacts(status: .off, isOn: false))
XCTAssertFalse(after.agentIntentEnabled)
XCTAssertNil(after.activeTarget)
}
/// 系统代理命令与 agent 命令互不触碰对方事实(issue #60)。
func testSystemProxyCommandsForwardIndependentlyFromAgentCommands() async {
runtime.agentIntentEnabled = true
runtime.runtimeFacts = ProxyRuntimeFacts(status: .running, isOn: true)
_ = await workflow.setAgentEnabled(true)
runtime.systemProxyIntentEnabled = true
runtime.systemProxyApplication = .applied
let applied = await workflow.setSystemProxyEnabled(true)
XCTAssertEqual(runtime.systemProxyCommands, [true])
XCTAssertTrue(applied.systemProxyIntentEnabled)
XCTAssertEqual(applied.systemProxyApplication, .applied)
XCTAssertEqual(applied.agentIntentEnabled, true, "agent 意图不被系统代理命令触碰")
runtime.systemProxyIntentEnabled = false
runtime.systemProxyApplication = .idle
let off = await workflow.setSystemProxyEnabled(false)
XCTAssertEqual(runtime.systemProxyCommands, [true, false])
XCTAssertFalse(off.systemProxyIntentEnabled)
XCTAssertEqual(off.systemProxyApplication, .idle)
XCTAssertEqual(runtime.agentCommands, [true], "系统代理命令不触发 agent 命令")
}
func testModeCommandForwardsAndRepublishesWholeSnapshot() async {
runtime.runtimeFacts = ProxyRuntimeFacts(status: .running, isOn: true)
_ = await workflow.setAgentEnabled(true)
runtime.proxyMode = .global
let after = await workflow.setProxyMode(.global)
XCTAssertEqual(runtime.modeCommands, [.global])
XCTAssertEqual(after.proxyMode, .global)
XCTAssertEqual(after.runtime, ProxyRuntimeFacts(status: .running, isOn: true))
}
func testRuleDefaultActionCommandForwardsAndRepublishes() async {
runtime.ruleDefaultAction = .proxyWhenUnmatched
_ = await workflow.setRuleDefaultAction(.directWhenUnmatched)
XCTAssertEqual(runtime.ruleDefaultActionCommands, [.directWhenUnmatched])
// 命令完成后 workflow 从 runtime 重读事实。
XCTAssertEqual(workflow.snapshot.ruleDefaultAction, .proxyWhenUnmatched)
runtime.ruleDefaultAction = .directWhenUnmatched
runtime.emitChange()
XCTAssertEqual(workflow.snapshot.ruleDefaultAction, .directWhenUnmatched)
XCTAssertEqual(workflow.snapshot.proxyMode, runtime.proxyMode)
}
func testResyncOnLaunchForwardsAndRepublishes() async {
let before = workflow.snapshot
runtime.runtimeFacts = ProxyRuntimeFacts(
status: .running, isOn: true, failure: nil)
await workflow.resyncOnLaunch()
XCTAssertEqual(runtime.resyncCount, 1)
XCTAssertNotEqual(workflow.snapshot, before)
XCTAssertEqual(workflow.snapshot.runtime.status, .running)
}
// MARK: - 运行时变化重观察
func testRuntimeChangeRepublishesWholeSnapshotWithoutCommand() {
runtime.runtimeFacts = ProxyRuntimeFacts(status: .starting, isOn: true)
runtime.emitChange()
XCTAssertEqual(workflow.snapshot.runtime.status, .starting)
runtime.runtimeFacts = ProxyRuntimeFacts(status: .running, isOn: true)
runtime.emitChange()
XCTAssertEqual(workflow.snapshot.runtime.status, .running)
XCTAssertTrue(workflow.snapshot.runtime.isOn)
}
// MARK: - 窄目录目标事实缝
func testActiveTargetFactsComeFromNarrowCatalogSeam() {
runtime.activeTargetID = Self.serverID
targetFacts.pathByTarget[Self.serverID] = "订阅分组 / 香港 01"
runtime.emitChange()
XCTAssertEqual(
workflow.snapshot.activeTarget,
ProxyActiveTargetFacts(id: Self.serverID, pathSummary: "订阅分组 / 香港 01"))
XCTAssertEqual(targetFacts.queriedTargetIDs.last, Self.serverID)
}
func testNoActiveTargetYieldsNilTargetFacts() {
XCTAssertNil(workflow.snapshot.activeTarget)
XCTAssertEqual(targetFacts.queriedTargetIDs, [nil] as [NodeID?], "init 观察即查询一次")
}
func testTargetMissingFromTreeKeepsExistenceWithNilSummary() {
runtime.activeTargetID = Self.serverID
targetFacts.pathByTarget[Self.serverID] = nil
runtime.emitChange()
let target = workflow.snapshot.activeTarget
XCTAssertEqual(target?.id, Self.serverID, "存在性来自活动目标身份")
XCTAssertNil(target?.pathSummary, "目标不在当前目录树中时摘要为 nil")
}
// MARK: - 命令边界(story 37)
func testCommandsTouchOnlyRuntimeAndTargetFactsAdapters() async {
await workflow.setAgentEnabled(true)
await workflow.setSystemProxyEnabled(true)
await workflow.setProxyMode(.global)
await workflow.resyncOnLaunch()
XCTAssertEqual(runtime.agentCommands, [true])
XCTAssertEqual(runtime.systemProxyCommands, [true])
XCTAssertEqual(runtime.modeCommands, [.global])
XCTAssertEqual(runtime.resyncCount, 1)
// 目标缝只被 snapshot 组装读取;目录激活、订阅刷新、设置保存与诊断导出
// 不在本 module 的依赖面上(类型层面不存在这些入口)。
XCTAssertFalse(targetFacts.queriedTargetIDs.isEmpty, "每次观察经窄缝取目标事实")
}
}