mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:14:51 +00:00
241 lines
11 KiB
Swift
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"))
|
|
}
|
|
}
|