mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 18:24:51 +00:00
193 lines
7.9 KiB
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)
|
|
}
|
|
}
|