mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 10:54:50 +00:00
298 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|