Files

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"))
]
}
}