Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/Subscription/SubscriptionInformationWorkflowTests.swift

193 lines
7.9 KiB
Swift

import Foundation
import Security
import Testing
@testable import ShadowsocksX_NG2
@MainActor
struct SubscriptionInformationWorkflowTests {
@Test func successfulInformationSurvivesFailureAndReload() async throws {
try await withWorkflow { workflow, store, fetcher in
let summary = try await workflow.createSubscription(urlString: "https://p.example.com/sub")
#expect(summary.information?.bytesUsed == 25)
#expect(summary.information?.bytesRemaining == 75)
#expect(summary.information?.expiresAt == Date(timeIntervalSince1970: 0))
#expect(summary.information?.trafficResetAt == Date(timeIntervalSince1970: 86_400))
let succeededAt = try #require(summary.lastSucceededAt)
if case .succeeded(let attemptAt) = summary.status {
#expect(succeededAt == attemptAt)
} else {
Issue.record("应成功提交")
}
fetcher.setBehavior(.failure(.httpStatus(code: 503)))
await workflow.refreshSubscription(summary.id)
let failed = try #require(workflow.subscriptions.first)
#expect(failed.information == summary.information)
#expect(failed.lastSucceededAt == succeededAt)
let reloaded = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()))
#expect(reloaded.subscriptions.first?.information == summary.information)
#expect(reloaded.subscriptions.first?.lastSucceededAt == succeededAt)
}
}
@Test func successfulRefreshReplacesMissingAndInvalidFields() async throws {
try await withWorkflow { workflow, _, fetcher in
let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub")
fetcher.setBehavior(
.success(
Data(
#"""
{"version":1,
"servers":[],
"bytes_used":50,
"bytes_remaining":-1,
"expires_at":"1970-01-01T00:00:00Z",
"x_shadowsocksx_ng":{"schema_version":1,
"expires_at":123}}
"""#
.utf8)))
await workflow.refreshSubscription(first.id)
let replaced = try #require(workflow.subscriptions.first)
#expect(replaced.information?.bytesUsed == 50)
#expect(replaced.information?.bytesRemaining == nil)
#expect(first.information?.expiresAt != nil)
#expect(first.information?.trafficResetAt != nil)
#expect(replaced.information?.expiresAt == nil)
#expect(replaced.information?.trafficResetAt == nil)
fetcher.setBehavior(.success(Data(#"{"version":1,"servers":[]}"#.utf8)))
await workflow.refreshSubscription(first.id)
let empty = try #require(workflow.subscriptions.first)
#expect(empty.information == nil)
if case .succeeded(let date) = empty.status {
#expect(empty.lastSucceededAt == date)
#expect(date >= first.lastSucceededAt!)
} else {
Issue.record("无资料响应仍应成功")
}
}
}
@Test func cancelledRefreshCannotCommitEvenIfFetcherReturnsData() async throws {
try await withWorkflow { workflow, store, _ in
let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub")
let bytes = try Data(contentsOf: store.fileURL)
let fetcher = InformationGateFetcher()
let reloaded = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()),
subscriptionFetcher: fetcher)
let refresh = Task { await reloaded.refreshSubscription(first.id) }
await fetcher.entered.wait()
refresh.cancel()
fetcher.release.release()
await refresh.value
#expect(reloaded.subscriptions.first == first)
#expect(try Data(contentsOf: store.fileURL) == bytes)
}
}
@Test func persistenceFailureDoesNotPublishNewInformation() async throws {
try await withWorkflow { workflow, store, fetcher in
let first = try await workflow.createSubscription(urlString: "https://p.example.com/sub")
let tree = workflow.tree
// 只破坏本夹具的文件路径,让原子替换失败,不影响其他 runner。
let directory = URL(fileURLWithPath: store.fileURL.deletingLastPathComponent().path)
try FileManager.default.removeItem(at: directory)
try Data().write(to: directory)
fetcher.setBehavior(.success(Data(#"{"version":1,"servers":[],"bytes_used":100}"#.utf8)))
await workflow.refreshSubscription(first.id)
#expect(workflow.subscriptions.first?.information == first.information)
#expect(workflow.subscriptions.first?.lastSucceededAt == first.lastSucceededAt)
#expect(workflow.tree == tree)
}
}
@Test func encryptedDocumentHidesSecretsAndRetainsOnlyAllowlistedInformation() async throws {
try await withWorkflow { workflow, store, fetcher in
fetcher.setBehavior(
.success(
Data(
#"""
{"version":1,
"servers":[{"server":"203.0.113.7",
"server_port":8388,
"method":"aes-256-gcm",
"password":"RAW-SERVER-PASSWORD",
"plugin":"v2ray-plugin",
"plugin_opts":"RAW-PLUGIN-OPTIONS"}],
"bytes_used":18446744073709551615,
"provider_token":"RAW-PROVIDER-TOKEN"}
"""#
.utf8)))
let first = try await workflow.createSubscription(
urlString: "https://p.example.com/sub?secret=RAW-URL-TOKEN")
#expect(first.information?.bytesUsed == UInt64.max)
let raw = try Data(contentsOf: store.fileURL)
for secret in [
"RAW-SERVER-PASSWORD", "RAW-PLUGIN-OPTIONS", "RAW-PROVIDER-TOKEN", "RAW-URL-TOKEN",
] {
#expect(raw.range(of: Data(secret.utf8)) == nil)
}
let reloaded = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()))
#expect(reloaded.subscriptions.first?.information?.bytesUsed == UInt64.max)
}
}
@Test func oldDocumentWithoutInformationOrSuccessfulTimeLoadsWithoutGuessing() async throws {
try await withWorkflow { workflow, store, _ in
_ = try await workflow.createSubscription(urlString: "https://p.example.com/sub")
var document = try store.load()
document.subscriptions[0].information = nil
document.subscriptions[0].lastSucceededAt = nil
try store.save(document)
let reloaded = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()))
#expect(reloaded.subscriptions.count == 1)
#expect(reloaded.subscriptions.first?.information == nil)
#expect(reloaded.subscriptions.first?.lastSucceededAt == nil)
}
}
private func withWorkflow(
_ body:
@MainActor (
CatalogWorkflow, CatalogFileStore, FakeSubscriptionFetcher
) async throws -> Void
) async throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("subscription-information-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: directory) }
let store = makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json"))
let fetcher = FakeSubscriptionFetcher(
behavior: .success(
Data(
#"""
{"version":1,
"servers":[],
"bytes_used":25,
"bytes_remaining":75,
"x_shadowsocksx_ng":{"schema_version":1,
"expires_at":"1970-01-01T00:00:00Z",
"traffic_reset_at":"1970-01-02T00:00:00Z"}}
"""#
.utf8)))
let workflow = makeCatalogWorkflow(
coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()),
subscriptionFetcher: fetcher)
try await body(workflow, store, fetcher)
}
}
private struct InformationGateFetcher: SubscriptionFetching {
let entered = AsyncGate()
let release = AsyncGate()
func fetch(_ url: URL) async throws -> Data {
entered.release()
await release.wait()
return Data(#"{"version":1,"servers":[],"bytes_used":100}"#.utf8)
}
}