Files

240 lines
7.0 KiB
Swift

import Darwin
import XCTest
@testable import ShadowsocksX_NG2
/// 代理运行时测试共享夹具:临时运行时目录、契约文档构造、LaunchAgent
/// 与探测替身。
/// 跨替身共享的有序事件记录:断言「先清理系统代理、再停止监听」等次序。
final class ProxyRuntimeEventLog: @unchecked Sendable {
private let lock = NSLock()
private var items: [String] = []
func record(_ item: String) {
lock.lock()
defer { lock.unlock() }
items.append(item)
}
var events: [String] {
lock.lock()
defer { lock.unlock() }
return items
}
}
extension ProxyRuntimeFixture {
/// 在临时目录中建一套隔离的运行时文件组(不触碰真实 ~/Library)。
struct TemporaryRuntime {
let directory: URL
let contract: URL
let pidFile: URL
}
static func makeTemporaryRuntime(file: StaticString = #filePath, line: UInt = #line)
-> TemporaryRuntime
{
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("ssxng-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return TemporaryRuntime(
directory: directory,
contract: directory.appendingPathComponent("sslocal-active.json"),
pidFile: directory.appendingPathComponent("agent.pid"))
}
@MainActor
static func catalogSnapshotReader(at fileURL: URL) -> RuntimeCatalogSnapshotReading {
CatalogCommitCoordinator.bootstrap(fileStore: makeTestCatalogStore(fileURL: fileURL))
.catalogSnapshotReader
}
/// LaunchAgent 注册态可编程替身,记录全部调用。
final class FakeLaunchAgent: LaunchAgentControlling {
private(set) var currentStatus: LaunchAgentStatus
private(set) var registerCount = 0
private(set) var unregisterCount = 0
var registerError: Error?
/// register 成功后自动迁移到的状态(模拟 launchd 拉起)。
var statusAfterRegister: LaunchAgentStatus = .registered
/// 测试用的运行回执。生产 wrapper 对 ACL 实例需等 sslocal 绑定监听后才写。
var onRegister: (() -> Void)?
var onUnregister: (() -> Void)?
/// 可选共享事件日志(次序断言用)。
weak var eventLog: ProxyRuntimeEventLog?
init(initialStatus: LaunchAgentStatus = .notRegistered) {
currentStatus = initialStatus
}
var status: LaunchAgentStatus { currentStatus }
func setStatus(_ status: LaunchAgentStatus) {
currentStatus = status
}
func register() throws {
registerCount += 1
eventLog?.record("register")
if let registerError {
currentStatus = .registered
throw registerError
}
currentStatus = statusAfterRegister
onRegister?()
}
func unregister() throws {
unregisterCount += 1
eventLog?.record("unregister")
currentStatus = .notRegistered
onUnregister?()
}
}
/// 探测替身:按预设序列返回,序列耗尽后停留在最后一项。
final class FakeProbe: EndpointProbing, @unchecked Sendable {
private let lock = NSLock()
private var outcomes: [EndpointHealthProbe.Outcome]
private var requestedPorts: [Int] = []
private(set) var callCount = 0
init(outcomes: [EndpointHealthProbe.Outcome]) {
self.outcomes = outcomes
}
static func reachable() -> FakeProbe {
FakeProbe(outcomes: [.reachable])
}
static func refusing() -> FakeProbe {
FakeProbe(outcomes: [.refused(detail: "Connection refused")])
}
/// 运行中改写探测序列(「条件恢复后自动收敛」类测试用)。
func setOutcomes(_ newOutcomes: [EndpointHealthProbe.Outcome]) {
lock.lock()
defer { lock.unlock() }
outcomes = newOutcomes
}
var ports: [Int] {
lock.lock()
defer { lock.unlock() }
return requestedPorts
}
func probe(host: String, port: Int, timeout: TimeInterval) async
-> EndpointHealthProbe.Outcome
{
recordProbe(port: port)
}
private func recordProbe(port: Int) -> EndpointHealthProbe.Outcome {
lock.lock()
defer { lock.unlock() }
callCount += 1
requestedPorts.append(port)
guard let outcome = outcomes.first else { return .timedOut }
if outcomes.count > 1 {
outcomes.removeFirst()
}
return outcome
}
}
final class FakeFirewallChecker: FirewallStatusChecking, @unchecked Sendable {
private let lock = NSLock()
private var outcomes: [FirewallBlockStatus]
private var recordedURLs: [URL] = []
var checkedURLs: [URL] {
lock.lock()
defer { lock.unlock() }
return recordedURLs
}
init(_ result: FirewallBlockStatus = .permitted) {
outcomes = [result]
}
init(outcomes: [FirewallBlockStatus]) {
precondition(!outcomes.isEmpty)
self.outcomes = outcomes
}
func status(for executableURL: URL) async -> FirewallBlockStatus {
recordStatus(for: executableURL)
}
private func recordStatus(for executableURL: URL) -> FirewallBlockStatus {
lock.lock()
defer { lock.unlock() }
recordedURLs.append(executableURL)
let outcome = outcomes[0]
if outcomes.count > 1 { outcomes.removeFirst() }
return outcome
}
}
}
/// 每个夹具使用独立 defaults 域,最后一个引用释放时清理持久值。
private final class TestProxyUserDefaults: UserDefaults, @unchecked Sendable {
private let domain: String
init() {
domain = "ng2-proxy-control-tests-\(UUID().uuidString)"
super.init(suiteName: domain)!
}
deinit { removePersistentDomain(forName: domain) }
}
func makeTestProxyControlPreferences() -> ProxyControlPreferences {
ProxyControlPreferences(defaults: TestProxyUserDefaults())
}
final class InMemoryProxyListeningStore: ProxyListeningStoring {
var saved: ProxyListeningConfiguration?
func load() throws -> ProxyListeningConfiguration { saved ?? ProxyListeningConfiguration() }
func save(_ configuration: ProxyListeningConfiguration) throws {
try configuration.enforcePersistenceConstraints()
saved = configuration
}
}
extension ProxyRuntimeFixture {
static func controlFlowRuleSnapshot(_ source: RulesSource) throws
-> RuleSnapshot
{
switch source {
case .geolocationCN:
return rulesFixture(
source,
rules: [
ProxyRule(action: .direct, match: try RuleMatch(domainExact: "direct.example")),
ProxyRule(
action: .direct, match: try RuleMatch(domainSuffix: "fixture-direct.example")),
])
case .chinaIPv4:
return rulesFixture(
source,
rules: [
ProxyRule(action: .direct, match: try RuleMatch(ipv4CIDR: "1.0.0.0/24"))
])
case .gfwlist:
return rulesFixture(
source,
rules: [
ProxyRule(action: .proxy, match: try RuleMatch(domainSuffix: "proxy.example")),
ProxyRule(action: .direct, match: try RuleMatch(domainExact: "safe.proxy.example")),
])
case .custom, .fixed:
throw RuleSnapshotError.missing
}
}
}