mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 20:24:50 +00:00
349 lines
16 KiB
Swift
349 lines
16 KiB
Swift
import XCTest
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
@MainActor
|
|
final class RulesWorkflowTests: XCTestCase {
|
|
func testDisableAcrossSourcesRestoresCoverageAndOrphanCanBeEnabled() async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json"))
|
|
let broad = CustomRule(
|
|
action: .proxy, match: try RuleMatch(domainSuffix: "example.com"))
|
|
let narrow = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "safe.example.com"))
|
|
let orphan = ProxyRule(action: .proxy, match: try RuleMatch(domainExact: "gone.example"))
|
|
try store.saveDocument(
|
|
CustomRuleDocument(rules: [broad, narrow], disabledIdentities: [orphan]))
|
|
var commits = 0
|
|
let workflow = RulesWorkflow(
|
|
preset: .gfwlist,
|
|
loadDocument: { try store.loadDocument() },
|
|
commitDocument: { document in
|
|
commits += 1
|
|
do {
|
|
try store.saveDocument(document)
|
|
return RuleDocumentCommit(outcome: .saved, document: document)
|
|
} catch {
|
|
XCTFail("Fixture save failed: \(error)")
|
|
return RuleDocumentCommit(
|
|
outcome: .persistenceFailed, document: try? store.loadDocument())
|
|
}
|
|
},
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { source in
|
|
guard source == .gfwlist else { return rulesFixture(source) }
|
|
return rulesFixture(
|
|
source,
|
|
rules: [
|
|
ProxyRule(action: .proxy, match: broad.match)
|
|
])
|
|
}))
|
|
await workflow.refresh()
|
|
let version = workflow.snapshot.version
|
|
let covered = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == narrow.identity })
|
|
XCTAssertTrue(covered.relationships.contains { $0.kind == .absorption && $0.extent == .full })
|
|
workflow.select([.rule(broad.identity), .noDotHostname])
|
|
XCTAssertEqual(workflow.actionableSelection, [broad.identity])
|
|
await workflow.setEnabled(false, identities: workflow.actionableSelection)
|
|
XCTAssertEqual(commits, 1)
|
|
XCTAssertNotEqual(workflow.snapshot.version, version)
|
|
try await assertDisabledRowsAndOrphanCanBeEnabled(
|
|
workflow: workflow, store: store, broad: broad, narrow: narrow, orphan: orphan)
|
|
}
|
|
|
|
private func assertDisabledRowsAndOrphanCanBeEnabled(
|
|
workflow: RulesWorkflow, store: CustomRuleStore, broad: CustomRule,
|
|
narrow: CustomRule, orphan: ProxyRule
|
|
) async throws {
|
|
let disabled = try XCTUnwrap(workflow.snapshot.rows.first { $0.identity == broad.identity })
|
|
XCTAssertFalse(disabled.isEnabled)
|
|
XCTAssertEqual(disabled.sources, [.custom, .gfwlist])
|
|
await workflow.analysisTask?.value
|
|
XCTAssertTrue(
|
|
workflow.snapshot.rows.first { $0.identity == narrow.identity }!.relationships.isEmpty)
|
|
workflow.setTestTarget("safe.example.com")
|
|
await workflow.testAddress()
|
|
XCTAssertEqual(workflow.snapshot.addressTest.result?.outcome, .proxy)
|
|
workflow.query(RulesQuery(enabled: false))
|
|
XCTAssertTrue(workflow.snapshot.rows.contains { $0.identity == orphan && !$0.hasCurrentSource })
|
|
await workflow.setEnabled(true, identities: [orphan])
|
|
XCTAssertFalse(workflow.snapshot.rows.contains { $0.identity == orphan })
|
|
XCTAssertNil(workflow.snapshot.addressTest.result)
|
|
XCTAssertFalse(try store.loadDocument().disabledIdentities.contains(orphan))
|
|
}
|
|
|
|
func testEquivalentSourcesMergeForShippedRules() async throws {
|
|
let match = try RuleMatch(domainExact: "Example.COM")
|
|
let custom = CustomRule(action: .direct, match: match)
|
|
let source = RuleSourceIdentity(kind: .geolocationCN, upstreamVersion: "fixture", label: "geo")
|
|
let snapshot = RuleSnapshot(
|
|
metadata: RuleSnapshotMetadata(
|
|
source: source, upstreamReference: "upstream", inputDigest: "digest",
|
|
fetchedAt: Date(timeIntervalSince1970: 0), converterVersion: "2.0.0", license: "MIT",
|
|
attribution: "author"),
|
|
rules: [ProxyRule(action: .direct, match: match)])
|
|
let workflow = RulesWorkflow(
|
|
preset: .chinaDirect,
|
|
loadCustom: { [custom] },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { source in
|
|
source == .geolocationCN ? snapshot : rulesFixture(source)
|
|
}))
|
|
await workflow.refresh()
|
|
workflow.query(RulesQuery(search: "EXAMPLE", action: .direct, source: .geolocationCN))
|
|
let row = try XCTUnwrap(workflow.snapshot.rows.first)
|
|
XCTAssertEqual(workflow.snapshot.rows.count, 1)
|
|
XCTAssertTrue(row.sources.contains(.custom))
|
|
XCTAssertTrue(row.sources.contains(.geolocationCN))
|
|
XCTAssertEqual(row.customIDs, [custom.id])
|
|
XCTAssertEqual(row.identity, custom.identity)
|
|
}
|
|
func testWrongBuiltinSourceIsAnIncompleteCollectionRatherThanMislabeledRules() async throws {
|
|
let workflow = RulesWorkflow(
|
|
loadCustom: { [] },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { _ in
|
|
rulesFixture(.geolocationCN)
|
|
}))
|
|
await workflow.refresh()
|
|
XCTAssertFalse(workflow.snapshot.isComplete)
|
|
XCTAssertEqual(workflow.snapshot.issues.count, 1)
|
|
}
|
|
|
|
func testSearchActionAndSourceIntersectAndHiddenSelectionIsCleared() async throws {
|
|
let direct = CustomRule(action: .direct, match: try RuleMatch(domainSuffix: "example.com"))
|
|
let other = CustomRule(action: .direct, match: try RuleMatch(domainExact: "outside.net"))
|
|
let workflow = RulesWorkflow(
|
|
preset: .chinaDirect, loadCustom: { [direct, other] },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
await workflow.refresh()
|
|
workflow.select([.rule(direct.identity), .rule(other.identity), .noDotHostname])
|
|
workflow.query(RulesQuery(search: "EXAMPLE", action: .direct, source: .custom))
|
|
XCTAssertEqual(workflow.snapshot.rows.map(\.identity), [direct.identity])
|
|
XCTAssertEqual(workflow.snapshot.selection, [.rule(direct.identity)])
|
|
workflow.query(RulesQuery(search: "EXAMPLE", action: .proxy, source: .custom))
|
|
XCTAssertTrue(workflow.snapshot.rows.isEmpty)
|
|
XCTAssertTrue(workflow.snapshot.selection.isEmpty)
|
|
XCTAssertTrue(workflow.snapshot.isComplete)
|
|
}
|
|
|
|
func testStableInputOrderAndNavigationPreserveVersionAndSessionQuery() async throws {
|
|
let entries = [
|
|
CustomRule(action: .proxy, match: try RuleMatch(domainExact: "z.net")),
|
|
CustomRule(action: .proxy, match: try RuleMatch(domainExact: "a.net")),
|
|
]
|
|
let workflow = RulesWorkflow(
|
|
loadCustom: { entries }, builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
await workflow.refresh()
|
|
let version = workflow.snapshot.version
|
|
workflow.query(RulesQuery(source: .custom))
|
|
workflow.select([.rule(entries[0].identity)])
|
|
let route = WorkspaceRoute()
|
|
route.navigate(to: .rules)
|
|
route.navigate(to: .home)
|
|
route.navigate(to: .rules)
|
|
XCTAssertEqual(workflow.snapshot.rows.map(\.content), ["z.net", "a.net"])
|
|
XCTAssertEqual(workflow.snapshot.query.source, .custom)
|
|
XCTAssertEqual(workflow.snapshot.selection, [.rule(entries[0].identity)])
|
|
await workflow.refresh()
|
|
XCTAssertEqual(workflow.snapshot.version, version)
|
|
XCTAssertEqual(workflow.snapshot.selection, [.rule(entries[0].identity)])
|
|
}
|
|
|
|
func testCorruptUserDocumentAndMissingBuiltinStayDistinctFromEmptySearch() async throws {
|
|
let workflow = RulesWorkflow(
|
|
preset: .chinaDirect,
|
|
loadCustom: { throw CustomRuleStoreError.corrupt(detail: "fixture") },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { source in
|
|
if source == .chinaIPv4 { throw RuleSnapshotError.missing }
|
|
return rulesFixture(source)
|
|
}))
|
|
await workflow.refresh()
|
|
XCTAssertFalse(workflow.snapshot.isComplete)
|
|
XCTAssertEqual(
|
|
workflow.snapshot.issues,
|
|
[.userDocument("corrupt(detail: \"fixture\")"), .builtin(.chinaIPv4, "missing")])
|
|
workflow.query(RulesQuery(source: .fixed))
|
|
XCTAssertTrue(workflow.snapshot.rows.contains { $0.isFixed })
|
|
workflow.query(RulesQuery(search: "no-result.example"))
|
|
XCTAssertTrue(workflow.snapshot.rows.isEmpty)
|
|
XCTAssertFalse(workflow.snapshot.isComplete)
|
|
XCTAssertEqual(workflow.snapshot.issues.count, 2)
|
|
}
|
|
|
|
func testPackagedCollectionLoadsMetadataShippedRulesAndFixedSemanticPolicy() async throws {
|
|
let bundle = AppArtifact.bundle
|
|
let workflow = RulesWorkflow(
|
|
preset: .chinaDirect,
|
|
loadCustom: { [] },
|
|
builtinSnapshots: BuiltinRuleSnapshots(bundle: bundle))
|
|
await workflow.refresh()
|
|
XCTAssertTrue(workflow.snapshot.isComplete, "\(workflow.snapshot.issues)")
|
|
XCTAssertEqual(workflow.snapshot.sources.count, 4)
|
|
workflow.query(RulesQuery(source: .fixed))
|
|
XCTAssertTrue(workflow.snapshot.rows.contains { $0.id == .noDotHostname && $0.isFixed })
|
|
let geo = try XCTUnwrap(workflow.snapshot.sources.first { $0.id == .geolocationCN })
|
|
XCTAssertEqual(geo.count, 4241)
|
|
XCTAssertEqual(geo.metadata?.source.upstreamVersion, "20260925234224")
|
|
XCTAssertNotNil(geo.conversionReport)
|
|
let rows = workflow.snapshot.rows
|
|
XCTAssertEqual(Set(rows.map(\.id)).count, rows.count)
|
|
workflow.query(RulesQuery(source: .geolocationCN))
|
|
XCTAssertTrue(workflow.snapshot.rows.contains { $0.content == "cn" })
|
|
XCTAssertEqual(workflow.snapshot.rows.count, 4241)
|
|
}
|
|
|
|
func testRefreshPublishesVersionAndRowsTogetherAndInvalidatesRemovedSelection() async throws {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = CustomRuleStore(fileURL: directory.appendingPathComponent("rules.json"))
|
|
let old = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "old.net"))
|
|
let new = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "new.net"))
|
|
try store.save([old])
|
|
let workflow = RulesWorkflow(
|
|
loadCustom: { try store.load() },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
await workflow.refresh()
|
|
workflow.query(RulesQuery(source: .custom))
|
|
workflow.select([.rule(old.identity)])
|
|
let oldVersion = workflow.snapshot.version
|
|
try store.save([new])
|
|
let observation = workflow.$snapshot.sink { snapshot in
|
|
if snapshot.version != oldVersion {
|
|
XCTAssertEqual(snapshot.rows.map(\.content), ["new.net"])
|
|
XCTAssertTrue(snapshot.selection.isEmpty)
|
|
}
|
|
}
|
|
await workflow.refresh()
|
|
observation.cancel()
|
|
XCTAssertNotEqual(workflow.snapshot.version, oldVersion)
|
|
XCTAssertEqual(workflow.snapshot.rows.map(\.identity), [new.identity])
|
|
let loadedVersion = workflow.snapshot.version
|
|
try Data("broken".utf8).write(to: store.fileURL)
|
|
await workflow.refresh()
|
|
XCTAssertFalse(workflow.snapshot.isComplete)
|
|
XCTAssertEqual(workflow.snapshot.issues.count, 1)
|
|
XCTAssertEqual(workflow.snapshot.rows.map(\.identity), [new.identity])
|
|
XCTAssertEqual(workflow.snapshot.version, loadedVersion)
|
|
XCTAssertEqual(workflow.snapshot.operationStatus, .collectionIncomplete)
|
|
}
|
|
|
|
func testLateOldRefreshCannotReplaceTheNewCollection() async throws {
|
|
let firstRead = XCTestExpectation(description: "first read")
|
|
let barrier = RulesReadBarrier(firstRead: firstRead)
|
|
let workflow = RulesWorkflow(
|
|
loadCustom: { try barrier.read() },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
let older = Task { await workflow.refresh() }
|
|
await fulfillment(of: [firstRead], timeout: 3)
|
|
defer { barrier.release.signal() }
|
|
XCTAssertTrue(workflow.snapshot.isLoading)
|
|
await workflow.refresh()
|
|
workflow.query(RulesQuery(source: .custom))
|
|
XCTAssertEqual(workflow.snapshot.rows.map(\.content), ["new.net"])
|
|
let version = workflow.snapshot.version
|
|
barrier.release.signal()
|
|
await older.value
|
|
XCTAssertEqual(workflow.snapshot.rows.map(\.content), ["new.net"])
|
|
XCTAssertEqual(workflow.snapshot.version, version)
|
|
XCTAssertFalse(workflow.snapshot.isLoading)
|
|
}
|
|
|
|
}
|
|
|
|
extension RulesWorkflowTests {
|
|
func testCandidatePermutationKeepsVersionAndCoverageWhilePreservingInputOrder() async throws {
|
|
let broad = CustomRule(action: .proxy, match: try RuleMatch(domainSuffix: "example.com"))
|
|
let narrow = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "www.example.com"))
|
|
let forward = RulesWorkflow(
|
|
loadCustom: { [broad, narrow] },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
let reverse = RulesWorkflow(
|
|
loadCustom: { [narrow, broad] },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
await forward.refresh()
|
|
await reverse.refresh()
|
|
XCTAssertEqual(forward.snapshot.version, reverse.snapshot.version)
|
|
XCTAssertEqual(
|
|
Dictionary(uniqueKeysWithValues: forward.snapshot.rows.map { ($0.id, $0) }),
|
|
Dictionary(uniqueKeysWithValues: reverse.snapshot.rows.map { ($0.id, $0) }))
|
|
XCTAssertEqual(
|
|
forward.snapshot.rows.prefix(2).compactMap(\.identity), [broad.identity, narrow.identity])
|
|
XCTAssertEqual(
|
|
reverse.snapshot.rows.prefix(2).compactMap(\.identity), [narrow.identity, broad.identity])
|
|
}
|
|
|
|
func testSameNamedExactAndSuffixHaveStableVersionsAcrossRefreshes() async throws {
|
|
let exact = CustomRule(action: .proxy, match: try RuleMatch(domainExact: "example.com"))
|
|
let suffix = CustomRule(action: .proxy, match: try RuleMatch(domainSuffix: "example.com"))
|
|
let workflow = RulesWorkflow(
|
|
loadCustom: { [suffix, exact] },
|
|
builtinSnapshots: BuiltinRuleSnapshots(loader: { rulesFixture($0) }))
|
|
await workflow.refresh()
|
|
let version = workflow.snapshot.version
|
|
let identities = workflow.snapshot.rows.map(\.identity)
|
|
for _ in 0..<12 {
|
|
await workflow.refresh()
|
|
XCTAssertEqual(workflow.snapshot.version, version)
|
|
XCTAssertEqual(workflow.snapshot.rows.map(\.identity), identities)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension RulesWorkflowTests {
|
|
func testCollectionRemovesReverseActionsAndPreservesRemainingInputOrder() throws {
|
|
let exact = try RuleMatch(domainExact: "same.example")
|
|
let suffix = try RuleMatch(domainSuffix: "same.example")
|
|
let rules = [
|
|
CustomRule(action: .proxy, match: suffix),
|
|
CustomRule(action: .direct, match: suffix),
|
|
CustomRule(action: .proxy, match: exact),
|
|
CustomRule(action: .direct, match: exact),
|
|
]
|
|
for input in [rules, Array(rules.reversed())] {
|
|
let collection = RulesCollection.load(
|
|
preset: .gfwlist, custom: { input }, builtin: { rulesFixture($0) })
|
|
XCTAssertTrue(collection.issues.isEmpty)
|
|
XCTAssertEqual(
|
|
collection.rows.filter { $0.content == "same.example" }.compactMap(\.identity),
|
|
input.filter { $0.action == .proxy }.map(\.identity))
|
|
XCTAssertTrue(collection.rows.contains { $0.id == .noDotHostname && $0.isFixed })
|
|
}
|
|
}
|
|
}
|
|
|
|
func rulesFixture(
|
|
_ source: RulesSource, rules: [ProxyRule] = []
|
|
) -> RuleSnapshot {
|
|
guard let kind = source.sourceKind else {
|
|
preconditionFailure("Fixed policy has no rule snapshot")
|
|
}
|
|
return RuleSnapshot(
|
|
metadata: RuleSnapshotMetadata(
|
|
source: RuleSourceIdentity(kind: kind, upstreamVersion: "v1", label: source.rawValue),
|
|
upstreamReference: "https://example.com/source", inputDigest: "fixture",
|
|
fetchedAt: Date(timeIntervalSince1970: 0), converterVersion: "2.0.0",
|
|
license: "MIT", attribution: "fixture author"), rules: rules)
|
|
}
|
|
|
|
private final class RulesReadBarrier: @unchecked Sendable {
|
|
let release = DispatchSemaphore(value: 0)
|
|
private let lock = NSLock()
|
|
private var reads = 0
|
|
private let firstRead: XCTestExpectation
|
|
|
|
init(firstRead: XCTestExpectation) { self.firstRead = firstRead }
|
|
|
|
func read() throws -> [CustomRule] {
|
|
lock.lock()
|
|
reads += 1
|
|
let first = reads == 1
|
|
lock.unlock()
|
|
if first {
|
|
firstRead.fulfill()
|
|
guard release.wait(timeout: .now() + 5) == .success else { throw RuleSnapshotError.missing }
|
|
}
|
|
return [
|
|
CustomRule(action: .proxy, match: try RuleMatch(domainExact: first ? "old.net" : "new.net"))
|
|
]
|
|
}
|
|
}
|