Files

241 lines
11 KiB
Swift

import Combine
import XCTest
@testable import ShadowsocksX_NG2
/// Saved custom-rule intent and independent runtime application/recovery.
extension ProxyRuntimeControllerTests {
func makeCustomRuleStore(preset: RulePreset = .chinaDirect) throws -> (
store: CustomRuleStore, directory: URL
) {
let directory = runtime.directory.appendingPathComponent(
"custom-rules-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let store = CustomRuleStore(
preset: preset, baseURL: directory.appendingPathComponent("custom-rules.json"))
return (store, directory)
}
func makeControllerWithCustomRules(
store: CustomRuleStore,
probe: EndpointProbing = ProxyRuntimeFixture.FakeProbe.reachable(),
settings: ProxyRuntimeSettings? = nil,
proxyMode: ProxyMode? = nil,
launchHealthTimeoutSeconds: TimeInterval = 0.05,
launchHealthRetryDelay: @escaping () async throws -> Void = {
try await Task.sleep(for: .milliseconds(200))
},
ruleApplicationDelay: @escaping () async throws -> Void = {
try await Task.sleep(for: .milliseconds(150))
},
ruleSnapshots: BuiltinRuleSnapshots? = nil
) -> ProxyRuntimeController {
makeController(
probe: probe,
listeningStore: InMemoryProxyListeningStore(),
settings: settings
?? ProxyRuntimeSettings(
listen: ActivationFixture.listen, rulePreset: .chinaDirect, agentEnabled: true),
proxyMode: proxyMode,
launchHealthTimeoutSeconds: launchHealthTimeoutSeconds,
launchHealthRetryDelay: launchHealthRetryDelay,
ruleApplicationDelay: ruleApplicationDelay,
customRuleStore: store, ruleSnapshots: ruleSnapshots)
}
/// 从链接解析读取当前 ACL 变体内容(契约不再内嵌 content)。
func activeACLContent(_ store: RuntimeFileStore) throws -> String {
let data = try Data(contentsOf: store.aclFileURL)
return String(bytes: data, encoding: .utf8) ?? ""
}
}
final class CustomRuleControllerTests: ProxyRuntimeControllerTests {
override func makeDefaultRuleSnapshots() -> BuiltinRuleSnapshots {
BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot)
}
/// 规则内容变化重编译 ACL 并按完整重启路径生效。
func testUpdateCustomRulesRecompilesACLAndRestarts() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen,
preferredMode: .rule,
rulePreset: .chinaDirect,
agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule)
try await controller.activate(seeded.server)
XCTAssertEqual(controller.state, .running)
XCTAssertEqual(controller.proxyMode, .rule)
let unregisterBefore = agent.unregisterCount
let rule = CustomRule(
action: .direct, match: try RuleMatch(domainSuffix: "internal.example"))
let outcome = await controller.commitRuleDocument(CustomRuleDocument(rules: [rule])).outcome
XCTAssertEqual(outcome, .saved)
await controller.ruleApplicationTask?.value
XCTAssertEqual(try store.load(), [rule])
XCTAssertEqual(agent.unregisterCount, unregisterBefore + 1, "ACL 变化触发完整 agent 重启")
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
let document = try XCTUnwrap(runtimeStore.loadDocument())
XCTAssertEqual(document.aclRuntime?.summary, "rule-proxy-default")
XCTAssertTrue(
try activeACLContent(runtimeStore).contains("||internal.example"),
"自定义直连规则应进入规则模式 ACL")
}
func testSchemeActionRulesPersistAndReorderingDoesNotRedeploy() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore(preset: .gfwlist)
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen, preferredMode: .rule,
rulePreset: .gfwlist, agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule)
try await controller.activate(seeded.server)
let rules = [
CustomRule(action: .proxy, match: try RuleMatch(domainSuffix: "order.example")),
CustomRule(action: .proxy, match: try RuleMatch(domainExact: "a.order.example")),
CustomRule(action: .proxy, match: try RuleMatch(domainExact: "b.order.example")),
]
let first = await controller.commitRuleDocument(CustomRuleDocument(rules: rules)).outcome
XCTAssertEqual(first, .saved)
await controller.ruleApplicationTask?.value
XCTAssertEqual(try store.load(), rules, "保存保留用户 UUID 和全部意图")
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
let before = try activeACLContent(runtimeStore)
let unregisterBefore = agent.unregisterCount
let summary = controller.readCustomRuleSummary()
let reversed = Array(rules.reversed())
let reordered = await controller.commitRuleDocument(CustomRuleDocument(rules: reversed))
.outcome
XCTAssertEqual(reordered, .saved)
await controller.ruleApplicationTask?.value
XCTAssertEqual(try store.load(), reversed)
XCTAssertEqual(try activeACLContent(runtimeStore), before)
XCTAssertEqual(agent.unregisterCount, unregisterBefore)
XCTAssertEqual(controller.readCustomRuleSummary(), summary)
}
/// 校验拒绝:固定本地冲突整批不落地,旧规则保持不变。
func testUpdateCustomRulesRejectsFixedLocalConflictAndKeepsPreviousRules() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let existing = CustomRule(
action: .direct, match: try RuleMatch(domainSuffix: "kept.example"))
try store.save([existing])
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen,
preferredMode: .rule,
rulePreset: .chinaDirect,
agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule)
try await controller.activate(seeded.server)
let conflicting = CustomRule(
action: .proxy, match: try RuleMatch(ipv4CIDR: "127.0.0.0/8"))
let outcome = await controller.commitRuleDocument(
CustomRuleDocument(rules: [existing, conflicting])
).outcome
guard case .rejected(let rejected) = outcome else {
return XCTFail("应拒绝固定本地冲突,实际 \(outcome)")
}
XCTAssertEqual(rejected.map(\.reason), [.conflictsWithFixedLocalScope])
XCTAssertFalse(rejected[0].explanation.isEmpty)
XCTAssertEqual(try store.load(), [existing], "拒绝时旧规则保持不变")
}
/// Failed application retains saved rules and the failed deployment.
func testFailedCustomRuleDeployKeepsSavedRulesAndStopsRuntime() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let existing = CustomRule(
action: .direct, match: try RuleMatch(domainSuffix: "kept.example"))
try store.save([existing])
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen,
preferredMode: .rule,
rulePreset: .chinaDirect,
agentEnabled: true,
systemProxyEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .rule, launchHealthTimeoutSeconds: 0.05)
try await controller.activate(seeded.server)
XCTAssertEqual(controller.state, .running)
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
let previousDocument = try XCTUnwrap(runtimeStore.loadDocument())
let previousApplicationCount = systemProxy.applied.count
agent.onRegister = { [runtimeStore, previousDocument] in
try? runtimeStore.writeRuntimeReceipt(for: previousDocument, processID: 42)
}
let newRule = CustomRule(
action: .direct, match: try RuleMatch(domainSuffix: "new-rule.example"))
let result = await controller.commitRuleDocument(CustomRuleDocument(rules: [newRule]))
XCTAssertEqual(result.outcome, .saved)
XCTAssertEqual(result.document, CustomRuleDocument(rules: [newRule]))
await controller.ruleApplicationTask?.value
XCTAssertNotNil(controller.runtimeFacts.failure)
XCTAssertEqual(try store.load(), [newRule], "部署失败保留已保存规则")
XCTAssertNotEqual(runtimeStore.loadDocument(), previousDocument)
XCTAssertNotNil(runtimeStore.readFailure())
XCTAssertFalse(controller.state.isOn)
XCTAssertTrue(controller.agentIntentEnabled)
XCTAssertTrue(controller.systemProxyIntentEnabled)
XCTAssertEqual(systemProxy.applied.count, previousApplicationCount)
XCTAssertGreaterThan(systemProxy.clearCount, 0)
}
/// 全局模式不加载自定义规则:保存成功但 ACL 不变、不触发重启。
func testGlobalModePersistsCustomRulesWithoutACLRestart() async throws {
let seeded = try makeSeededCatalog()
let (store, _) = try makeCustomRuleStore()
let settings = ProxyRuntimeSettings(
listen: ActivationFixture.listen,
preferredMode: .global,
rulePreset: .chinaDirect,
agentEnabled: true)
let controller = makeControllerWithCustomRules(
store: store, settings: settings, proxyMode: .global)
try await controller.activate(seeded.server)
XCTAssertEqual(controller.proxyMode, .global)
let unregisterBefore = agent.unregisterCount
let rule = CustomRule(
action: .direct, match: try RuleMatch(domainSuffix: "internal.example"))
let outcome = await controller.commitRuleDocument(CustomRuleDocument(rules: [rule])).outcome
XCTAssertEqual(outcome, .saved)
await controller.ruleApplicationTask?.value
XCTAssertEqual(try store.load(), [rule])
XCTAssertEqual(agent.unregisterCount, unregisterBefore, "全局模式 ACL 不含自定义规则,无需重启")
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
let document = try XCTUnwrap(runtimeStore.loadDocument())
XCTAssertEqual(document.aclRuntime?.summary, "global")
XCTAssertFalse(try activeACLContent(runtimeStore).contains("internal.example"))
}
/// 诊断摘要只含数量与内容版本。
func testCustomRuleSummaryExposesCountAndVersionOnly() async throws {
let (store, _) = try makeCustomRuleStore()
try store.save([
CustomRule(action: .direct, match: try RuleMatch(domainSuffix: "secret.example"))
])
let controller = makeControllerWithCustomRules(store: store)
let summary = try XCTUnwrap(controller.readCustomRuleSummary())
XCTAssertEqual(summary.count, 1)
XCTAssertEqual(summary.contentVersion.count, 12)
XCTAssertFalse(summary.contentVersion.contains("secret"))
}
}