mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-14 10:13:54 +00:00
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).
297 lines
12 KiB
Swift
297 lines
12 KiB
Swift
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, "每次观察经窄缝取目标事实")
|
||
}
|
||
}
|