Files

298 lines
13 KiB
Swift

import Foundation
import Testing
@testable import ShadowsocksX_NG2
private struct ServerNameImportCase: Sendable {
let host: String
let port: Int
let supplied: String?
let expected: String
static let all: [Self] = [
.init(host: "203.0.113.7", port: 8388, supplied: nil, expected: "203.0.113.7:8388"),
.init(host: "203.0.113.7", port: 8389, supplied: "", expected: "203.0.113.7:8389"),
.init(host: "server.example", port: 443, supplied: "", expected: "server.example:443"),
.init(host: "2001:db8::7", port: 8388, supplied: " \t\n ", expected: "[2001:db8::7]:8388"),
.init(host: "server.example", port: 8388, supplied: " 香港 01 ", expected: " 香港 01 "),
]
}
/// 名称行为通过目录命令与导入 interface 观察,覆盖持久化和无副作用拒绝。
@MainActor
struct ServerNameWorkflowTests {
@Test(arguments: ["", " \t\n ", "\u{3000}"])
func manualCreateAndEditRejectBlankNamesWithoutWrites(_ name: String) async throws {
try await withWorkflow { workflow, store in
let id = try await workflow.createServer(Self.draft(name: "原名"), into: nil)
let before = try store.load()
let treeBefore = workflow.tree
do {
_ = try await workflow.createServer(Self.draft(name: name), into: nil)
Issue.record("空白名称应拒绝新建")
} catch {
#expect(error as? ServerFormError == .emptyName)
}
do {
try await workflow.updateServer(id, draft: Self.draft(name: name, password: "changed"))
Issue.record("空白名称应拒绝编辑")
} catch {
#expect(error as? ServerFormError == .emptyName)
}
#expect(try store.load() == before)
#expect(workflow.tree == treeBefore)
}
}
@Test
func manualNamesAreTrimmedAndGeneratedNamesSurviveEndpointEdits() async throws {
try await withWorkflow { workflow, store in
let created = try await workflow.createServer(Self.draft(name: " \t新名称\n "), into: nil)
#expect(try workflow.serverEditForm(for: created)?.remark == "新名称")
try await workflow.updateServer(created, draft: Self.draft(name: "\n编辑名称\t"))
#expect(try workflow.serverEditForm(for: created)?.remark == "编辑名称")
_ = await workflow.importServers(
from: [
.clipboardText(
SsUri(method: "aes-256-gcm", password: "p", host: "203.0.113.7", port: 8388)
.encode())
], into: nil)
let imported = try #require(workflow.tree.roots.last)
var edited = Self.draft(name: imported.name)
edited.address = "198.51.100.9"
edited.port = 9999
try await workflow.updateServer(imported.id, draft: edited)
#expect(try workflow.serverEditForm(for: imported.id)?.remark == "203.0.113.7:8388")
#expect(try store.load().catalog.entry(for: imported.id)?.displayName == "203.0.113.7:8388")
}
}
@Test(arguments: ServerNameImportCase.all)
fileprivate func uriImportsSaveNames(_ sample: ServerNameImportCase) async throws {
try await withWorkflow { workflow, store in
let uri = SsUri(
method: "aes-256-gcm", password: "p", host: sample.host, port: sample.port,
remark: sample.supplied)
let result = await workflow.importServers(from: [.clipboardText(uri.encode())], into: nil)
#expect(result.sources.first?.result.importedCount == 1)
let node = try #require(workflow.tree.roots.first)
#expect(node.name == sample.expected)
#expect(try workflow.serverEditForm(for: node.id)?.remark == sample.expected)
#expect(try store.load().catalog.entry(for: node.id)?.displayName == sample.expected)
}
}
@Test(arguments: ServerNameImportCase.all)
fileprivate func legacyImportsSaveNames(_ sample: ServerNameImportCase) async throws {
try await withWorkflow { _, store in
var profile: [String: Any] = [
"ServerHost": sample.host, "ServerPort": sample.port,
"Method": "aes-256-gcm", "Password": "p",
]
if let supplied = sample.supplied { profile["Remark"] = supplied }
let snapshot = try LegacySnapshot(propertyList: ["ServerProfiles": [profile]])
let importer = LegacyImportService(
source: FixedLegacySnapshotProvider(snapshot: snapshot), catalogStore: store,
marker: InMemoryLegacyImportMarker())
let result = try importer.importCurrentSnapshot()
#expect(result.report.importedServerCount == 1)
let catalog = try store.load().catalog
let group = try #require(catalog.entry(for: result.groupID))
guard case .group(let fields) = group.kind else {
Issue.record("预期导入分组")
return
}
let id = try #require(fields.children.first)
let entry = try #require(catalog.entry(for: id))
#expect(entry.displayName == sample.expected)
guard case .server(let server) = entry.kind else {
Issue.record("预期服务器")
return
}
#expect(server.remark == sample.expected)
}
}
@Test(arguments: ServerNameImportCase.all)
fileprivate func subscriptionImportsSaveNames(_ sample: ServerNameImportCase) async throws {
let fetcher = FakeSubscriptionFetcher(behavior: .success(try Self.document(sample)))
try await withWorkflow(fetcher: fetcher) { workflow, store in
let subscription = try await workflow.createSubscription(
urlString: "https://provider.example/s")
let group = try #require(workflow.tree.node(withID: subscription.groupID))
let node = try #require(group.childNodes.first)
#expect(node.name == sample.expected)
#expect(try workflow.serverEditForm(for: node.id)?.remark == sample.expected)
#expect(try store.load().catalog.entry(for: node.id)?.displayName == sample.expected)
}
}
@Test
func subscriptionRenamesKeepIdentityAndRefreshesRegenerateMissingNames() async throws {
let initial = ServerNameImportCase.all[0]
let fetcher = FakeSubscriptionFetcher(behavior: .success(try Self.document(initial)))
try await withWorkflow(fetcher: fetcher) { workflow, _ in
let subscription = try await workflow.createSubscription(
urlString: "https://provider.example/s")
let id = try #require(workflow.tree.node(withID: subscription.groupID)?.childNodes.first?.id)
fetcher.setBehavior(
.success(
try Self.document(
.init(host: initial.host, port: initial.port, supplied: "远端新名", expected: "远端新名"))))
await workflow.refreshSubscription(subscription.id)
#expect(workflow.tree.node(withID: id)?.name == "远端新名")
fetcher.setBehavior(.success(try Self.document(initial, password: "updated-password")))
await workflow.refreshSubscription(subscription.id)
#expect(try workflow.serverEditForm(for: id)?.password == "updated-password")
fetcher.setBehavior(.success(try Self.document(initial)))
await workflow.refreshSubscription(subscription.id)
#expect(workflow.tree.node(withID: id)?.name == "203.0.113.7:8388")
fetcher.setBehavior(.success(try Self.document(ServerNameImportCase.all[1])))
await workflow.refreshSubscription(subscription.id)
let replacement = try #require(
workflow.tree.node(withID: subscription.groupID)?.childNodes.first)
#expect(replacement.name == "203.0.113.7:8389")
#expect(replacement.id != id)
}
}
@Test
func subscriptionRefreshReplacesSecretsAndClearsUnusedPluginOptions() async throws {
let sample = ServerNameImportCase.all[0]
let fetcher = FakeSubscriptionFetcher(
behavior: .success(
try Self.document(
sample, plugin: "v2ray-plugin", pluginOptions: "mode=websocket")))
try await withWorkflow(fetcher: fetcher) { workflow, _ in
let subscription = try await workflow.createSubscription(
urlString: "https://provider.example/s")
let id = try #require(workflow.tree.node(withID: subscription.groupID)?.childNodes.first?.id)
fetcher.setBehavior(
.success(
try Self.document(
sample, password: "new-password", plugin: "v2ray-plugin", pluginOptions: "mode=quic")))
await workflow.refreshSubscription(subscription.id)
let updated = try #require(try workflow.serverEditForm(for: id))
#expect(updated.password == "new-password")
#expect(updated.plugin.options == "mode=quic")
fetcher.setBehavior(.success(try Self.document(sample, password: "new-password")))
await workflow.refreshSubscription(subscription.id)
let cleared = try #require(try workflow.serverEditForm(for: id))
#expect(cleared.password == "new-password")
#expect(cleared.plugin.selection == .none)
#expect(cleared.plugin.options.isEmpty)
#expect(!cleared.plugin.optionsPresent)
}
}
@Test
func duplicateEndpointsRejectRefreshAndPreserveSavedSnapshot() async throws {
let sample = ServerNameImportCase.all[0]
let fetcher = FakeSubscriptionFetcher(behavior: .success(try Self.document(sample)))
try await withWorkflow(fetcher: fetcher) { workflow, store in
let subscription = try await workflow.createSubscription(
urlString: "https://provider.example/s")
let treeBefore = workflow.tree
let catalogBefore = try store.load().catalog
let duplicate = Data(
"""
{"version":1,"servers":[
{"server":"203.0.113.7","server_port":8388,"method":"aes-256-gcm","password":"p","remarks":"甲"},
{"server":"203.0.113.7","server_port":8388,"method":"aes-256-gcm","password":"other","remarks":"乙"}
]}
""".utf8)
fetcher.setBehavior(.success(duplicate))
await workflow.refreshSubscription(subscription.id)
#expect(workflow.tree == treeBefore)
#expect(try store.load().catalog == catalogBefore)
guard case .failed(_, let failure) = workflow.subscriptions.first?.status else {
Issue.record("重复端点应拒绝整个刷新")
return
}
#expect(failure == .duplicateIdentity)
}
}
@Test(arguments: ["server", "server_port", "method", "password", "plugin", "plugin_opts"])
func idLessIdentityDependsOnlyOnEndpoint(_ changedField: String) throws {
let base: [String: Any] = [
"server": "203.0.113.7", "server_port": 8388, "method": "aes-256-gcm",
"password": "p", "plugin": "v2ray-plugin", "plugin_opts": "mode=websocket",
]
let replacements: [String: Any] = [
"server": "203.0.113.8", "server_port": 8389, "method": "aes-128-gcm",
"password": "p2", "plugin": "other-plugin", "plugin_opts": "mode=quic",
]
func identity(_ server: [String: Any]) throws -> NodeID {
let data = try JSONSerialization.data(withJSONObject: ["version": 1, "servers": [server]])
let snapshot = try SubscriptionDocumentParser.parse(
data, subscriptionID: NodeID(rawValue: "sub"))
guard case .server(let leaf) = snapshot.root.children.first else {
throw NSError(domain: "ServerNameTests", code: 1)
}
return leaf.id
}
var changed = base
changed[changedField] = replacements[changedField]
if changedField == "server" || changedField == "server_port" {
#expect(try identity(base) != identity(changed))
} else {
#expect(try identity(base) == identity(changed))
}
}
@Test
func providerIDsAllowDistinctServersAtTheSameEndpoint() throws {
let data = Data(
"""
{"version":1,"servers":[
{"id":"aaaaaaaa-0000-4000-8000-00000000000a","server":"203.0.113.7","server_port":8388,
"method":"aes-256-gcm","password":"p","remarks":"甲"},
{"id":"bbbbbbbb-0000-4000-8000-00000000000b","server":"203.0.113.7","server_port":8388,
"method":"aes-256-gcm","password":"other","remarks":"乙"}
]}
""".utf8)
let snapshot = try SubscriptionDocumentParser.parse(
data, subscriptionID: NodeID(rawValue: "sub"))
#expect(snapshot.root.children.count == 2)
}
}
extension ServerNameWorkflowTests {
private static func draft(name: String, password: String = "p") -> ServerEditDraft {
ServerEditDraft(
address: "203.0.113.7", port: 8388, encryptionMethod: "aes-256-gcm", password: password,
remark: name, plugin: .none, pluginOptions: nil)
}
private static func document(
_ sample: ServerNameImportCase, password: String = "p", plugin: String? = nil,
pluginOptions: String? = nil
) throws -> Data {
var server: [String: Any] = [
"server": sample.host, "server_port": sample.port, "method": "aes-256-gcm",
"password": password,
]
if let supplied = sample.supplied { server["remarks"] = supplied }
if let plugin { server["plugin"] = plugin }
if let pluginOptions { server["plugin_opts"] = pluginOptions }
return try JSONSerialization.data(withJSONObject: ["version": 1, "servers": [server]])
}
private func withWorkflow(
fetcher: SubscriptionFetching = FakeSubscriptionFetcher(behavior: .success(Data())),
_ body:
@MainActor (CatalogWorkflow, CatalogFileStore) async throws -> Void
) async throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("server-name-tests-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: directory) }
let store = makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json"))
let coordinator = CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime())
let workflow = makeCatalogWorkflow(
coordinator: coordinator, subscriptionFetcher: fetcher)
try await body(workflow, store)
}
}