mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 11:34:51 +00:00
365 lines
16 KiB
Swift
365 lines
16 KiB
Swift
import Testing
|
|
import XCTest
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
/// The workflow export draft is the public seam for complete SIP-008 serialization.
|
|
@MainActor
|
|
final class ConfigurationGroupExportTests: XCTestCase {
|
|
private var workDir: URL!
|
|
private var catalogFileURL: URL!
|
|
private var fetcher: FakeSubscriptionFetcher!
|
|
private var workflow: CatalogWorkflow!
|
|
|
|
override func setUp() async throws {
|
|
try await super.setUp()
|
|
workDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("group-export-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
|
|
catalogFileURL = workDir.appendingPathComponent("catalog.json")
|
|
|
|
fetcher = FakeSubscriptionFetcher(behavior: .success(Data()))
|
|
workflow = makeWorkflow()
|
|
}
|
|
|
|
override func tearDown() async throws {
|
|
try? FileManager.default.removeItem(at: workDir)
|
|
try await super.tearDown()
|
|
}
|
|
|
|
func testDraftContainsFlattenedServersAndOrderedGroupTree() async throws {
|
|
let rootID = try await workflow.createGroup(named: "组 根", into: nil)
|
|
let firstID = try await addServer(
|
|
host: "203.0.113.1", password: "first-secret", remark: "First", into: rootID)
|
|
let nestedID = try await workflow.createGroup(named: "嵌套", into: rootID)
|
|
let nestedServerID = try await addServer(
|
|
host: "203.0.113.2", password: "nested-secret", remark: "Nested", into: nestedID)
|
|
let lastID = try await addServer(
|
|
host: "203.0.113.3", password: "last-secret", remark: "Last", into: rootID)
|
|
|
|
let draft = try workflow.configurationGroupExportDraft(for: rootID)
|
|
|
|
XCTAssertEqual(draft.suggestedFileName, "组 根.json")
|
|
let document = try XCTUnwrap(JSONSerialization.jsonObject(with: draft.data) as? [String: Any])
|
|
XCTAssertEqual(document["version"] as? Int, 1)
|
|
let servers = try XCTUnwrap(document["servers"] as? [[String: Any]])
|
|
XCTAssertEqual(
|
|
servers.compactMap { $0["server"] as? String },
|
|
[
|
|
"203.0.113.1", "203.0.113.2", "203.0.113.3",
|
|
])
|
|
XCTAssertEqual(
|
|
servers.compactMap { $0["password"] as? String },
|
|
[
|
|
"first-secret", "nested-secret", "last-secret",
|
|
])
|
|
XCTAssertEqual(servers.compactMap { $0["remarks"] as? String }, ["First", "Nested", "Last"])
|
|
let serverIDValues = servers.compactMap { $0["id"] as? String }
|
|
XCTAssertEqual(serverIDValues.compactMap { UUID(uuidString: $0) }.count, 3)
|
|
XCTAssertEqual(
|
|
UUID(uuidString: try XCTUnwrap(serverIDValues.first)), UUID(uuidString: firstID.rawValue))
|
|
XCTAssertEqual(
|
|
UUID(uuidString: try XCTUnwrap(serverIDValues.dropFirst().first)),
|
|
UUID(uuidString: nestedServerID.rawValue))
|
|
XCTAssertEqual(
|
|
UUID(uuidString: try XCTUnwrap(serverIDValues.last)), UUID(uuidString: lastID.rawValue))
|
|
|
|
let extensionObject = try XCTUnwrap(document["x_shadowsocksx_ng"] as? [String: Any])
|
|
XCTAssertEqual(extensionObject["schema_version"] as? Int, 1)
|
|
let groups = try XCTUnwrap(extensionObject["groups"] as? [[String: Any]])
|
|
let rootGroupID = try XCTUnwrap(extensionObject["root_group_id"] as? String)
|
|
let rootGroup = try XCTUnwrap(groups.first { $0["id"] as? String == rootGroupID })
|
|
XCTAssertEqual(rootGroup["name"] as? String, "组 根")
|
|
let nestedGroup = try XCTUnwrap(groups.first { $0["name"] as? String == "嵌套" })
|
|
let rootChildren = try XCTUnwrap(rootGroup["children"] as? [[String: String]])
|
|
let nestedGroupIDValue = try XCTUnwrap(nestedGroup["id"] as? String)
|
|
XCTAssertEqual(
|
|
rootChildren,
|
|
[
|
|
["type": "server", "id": try XCTUnwrap(servers[0]["id"] as? String)],
|
|
["type": "group", "id": nestedGroupIDValue],
|
|
["type": "server", "id": try XCTUnwrap(servers[2]["id"] as? String)],
|
|
])
|
|
|
|
}
|
|
|
|
func testSubscriptionExportPreservesProviderIDsAndDerivesStableIDs() async throws {
|
|
let providerID = "aaaaaaaa-0000-4000-8000-00000000000a"
|
|
let nestedProviderID = "bbbbbbbb-0000-4000-8000-00000000000b"
|
|
fetcher.setBehavior(
|
|
.success(
|
|
subscriptionDocument(
|
|
providerID: providerID, nestedProviderID: nestedProviderID)))
|
|
workflow = makeWorkflow()
|
|
let subscription = try await workflow.createSubscription(
|
|
urlString: "https://provider.example.com/config.json")
|
|
let root = try XCTUnwrap(workflow.tree.node(withID: subscription.groupID))
|
|
let nested = try XCTUnwrap(root.childNodes.first(where: \.isGroup))
|
|
|
|
let rootDraft = try workflow.configurationGroupExportDraft(for: subscription.groupID)
|
|
let repeatedRootDraft = try workflow.configurationGroupExportDraft(for: subscription.groupID)
|
|
let nestedDraft = try workflow.configurationGroupExportDraft(for: nested.id)
|
|
let rootServers = try jsonServers(in: rootDraft)
|
|
let nestedServers = try jsonServers(in: nestedDraft)
|
|
let rootIDs = rootServers.compactMap { $0["id"] as? String }
|
|
let nestedIDs = nestedServers.compactMap { $0["id"] as? String }
|
|
|
|
XCTAssertEqual(rootIDs[0], providerID)
|
|
XCTAssertEqual(rootIDs[1], nestedProviderID)
|
|
XCTAssertEqual(nestedIDs, [nestedProviderID])
|
|
let repeatedRootIDs = try jsonServers(in: repeatedRootDraft).compactMap { $0["id"] as? String }
|
|
XCTAssertEqual(rootIDs[2], repeatedRootIDs[2], "无 provider UUID 的服务器身份稳定")
|
|
XCTAssertNotNil(UUID(uuidString: try XCTUnwrap(rootIDs[2])))
|
|
|
|
let rootExtension = try groupExtension(in: rootDraft)
|
|
let nestedExtension = try groupExtension(in: nestedDraft)
|
|
let rootExportID = try XCTUnwrap(rootExtension["root_group_id"] as? String)
|
|
let nestedExportID = try XCTUnwrap(nestedExtension["root_group_id"] as? String)
|
|
let providerRootExportID = try groupID(named: "Provider root", in: rootDraft)
|
|
let nestedGroupExportID = try groupID(named: "Nested", in: rootDraft)
|
|
XCTAssertEqual(rootExportID, providerRootExportID)
|
|
XCTAssertEqual(nestedExportID, nestedGroupExportID, "分组 ID 不随导出根变化")
|
|
}
|
|
|
|
func testExportabilityUsesServerPresenceNotActivationValidity() async throws {
|
|
var catalog = ConfigurationCatalog()
|
|
let emptyRoot = NodeID.fresh()
|
|
let emptyNested = NodeID.fresh()
|
|
let exportRoot = NodeID.fresh()
|
|
let invalidServer = NodeID.fresh()
|
|
try catalog.addGroup("空组", id: emptyRoot)
|
|
try catalog.addGroup("空子组", id: emptyNested, to: emptyRoot)
|
|
try catalog.addGroup("含无效服务器", id: exportRoot)
|
|
try catalog.addServer(
|
|
ServerFields(
|
|
address: "203.0.113.8", port: 8388, encryptionMethod: "aes-256-gcm",
|
|
password: "password", remark: "activation-invalid", pluginProgram: "missing-plugin",
|
|
pluginOptions: "obfs=http"),
|
|
id: invalidServer, to: exportRoot)
|
|
try install(catalog)
|
|
|
|
let empty = try XCTUnwrap(workflow.tree.node(withID: emptyRoot))
|
|
let emptyChild = try XCTUnwrap(workflow.tree.node(withID: emptyNested))
|
|
let nonCandidate = try XCTUnwrap(workflow.tree.node(withID: invalidServer))
|
|
let exportableGroup = try XCTUnwrap(workflow.tree.node(withID: exportRoot))
|
|
XCTAssertFalse(empty.containsServerConfiguration)
|
|
XCTAssertFalse(emptyChild.containsServerConfiguration)
|
|
XCTAssertTrue(nonCandidate.isInvalid, "插件未提供使服务器不具备激活资格")
|
|
XCTAssertTrue(exportableGroup.containsServerConfiguration)
|
|
|
|
let servers = try jsonServers(in: workflow.configurationGroupExportDraft(for: exportRoot))
|
|
XCTAssertEqual(servers.first?["plugin"] as? String, "missing-plugin")
|
|
XCTAssertEqual(servers.first?["plugin_opts"] as? String, "obfs=http")
|
|
}
|
|
|
|
func testPluginOptionsWithoutProgramFailsBeforeCallingExporter() async throws {
|
|
var catalog = ConfigurationCatalog()
|
|
let rootID = NodeID.fresh()
|
|
let serverID = NodeID.fresh()
|
|
try catalog.addGroup("不完整组", id: rootID)
|
|
try catalog.addServer(
|
|
ServerFields(
|
|
address: "203.0.113.9", port: 8388, encryptionMethod: "aes-256-gcm",
|
|
password: "password", pluginOptions: "mode=websocket"),
|
|
id: serverID, to: rootID)
|
|
try install(catalog)
|
|
let exporter = InMemoryConfigurationGroupFileExporter()
|
|
|
|
let outcome = ConfigurationGroupExportAction(workflow: workflow, exporter: exporter)
|
|
.perform(for: rootID)
|
|
|
|
XCTAssertEqual(outcome, .preparationFailed(.pluginOptionsWithoutProgram(serverID)))
|
|
XCTAssertTrue(exporter.drafts.isEmpty, "不完整文档不能进入保存面板")
|
|
}
|
|
|
|
func testExportActionPropagatesCancelSaveAndWriteFailure() async throws {
|
|
let rootID = try await workflow.createGroup(named: "动作", into: nil)
|
|
_ = try await addServer(host: "203.0.113.10", password: "pw", remark: "node", into: rootID)
|
|
let exporter = InMemoryConfigurationGroupFileExporter()
|
|
|
|
exporter.result = .cancelled
|
|
XCTAssertEqual(
|
|
ConfigurationGroupExportAction(workflow: workflow, exporter: exporter).perform(for: rootID),
|
|
.cancelled)
|
|
let savedURL = URL(fileURLWithPath: "/tmp/group.json")
|
|
exporter.result = .saved(savedURL)
|
|
XCTAssertEqual(
|
|
ConfigurationGroupExportAction(workflow: workflow, exporter: exporter).perform(for: rootID),
|
|
.saved(savedURL))
|
|
exporter.result = .failed(.writeFailed)
|
|
XCTAssertEqual(
|
|
ConfigurationGroupExportAction(workflow: workflow, exporter: exporter).perform(for: rootID),
|
|
.exportFailed(.writeFailed))
|
|
XCTAssertEqual(exporter.drafts.count, 3)
|
|
}
|
|
|
|
private func makeWorkflow() -> CatalogWorkflow {
|
|
makeCatalogWorkflow(
|
|
coordinator: CatalogCommitCoordinator(
|
|
fileStore: makeTestCatalogStore(fileURL: catalogFileURL), runtime: FakeCatalogRuntime()),
|
|
subscriptionFetcher: fetcher)
|
|
}
|
|
|
|
private func install(_ catalog: ConfigurationCatalog) throws {
|
|
try makeTestCatalogStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
|
|
workflow = makeWorkflow()
|
|
}
|
|
|
|
private func subscriptionDocument(providerID: String, nestedProviderID: String) -> Data {
|
|
Data(
|
|
"""
|
|
{
|
|
"version": 1,
|
|
"servers": [
|
|
{"id":"\(providerID)","remarks":"root server","server":"203.0.113.11",
|
|
"server_port":8388,"password":"p1","method":"aes-256-gcm"},
|
|
{"id":"\(nestedProviderID)","remarks":"nested server","server":"203.0.113.12",
|
|
"server_port":8389,"password":"p2","method":"aes-256-gcm"},
|
|
{"remarks":"idless","server":"203.0.113.13","server_port":8390,
|
|
"password":"p3","method":"aes-256-gcm"}
|
|
],
|
|
"x_shadowsocksx_ng": {
|
|
"schema_version": 1,
|
|
"root_group_id": "provider-root",
|
|
"groups": [
|
|
{"id":"provider-root","name":"Provider root","children":[
|
|
{"type":"server","id":"\(providerID)"},
|
|
{"type":"group","id":"nested"}]},
|
|
{"id":"nested","name":"Nested","children":[
|
|
{"type":"server","id":"\(nestedProviderID)"}]}
|
|
]
|
|
}
|
|
}
|
|
""".utf8)
|
|
}
|
|
|
|
private func jsonServers(in draft: ConfigurationGroupExportDraft) throws -> [[String: Any]] {
|
|
let document = try XCTUnwrap(JSONSerialization.jsonObject(with: draft.data) as? [String: Any])
|
|
return try XCTUnwrap(document["servers"] as? [[String: Any]])
|
|
}
|
|
|
|
private func groupExtension(in draft: ConfigurationGroupExportDraft) throws -> [String: Any] {
|
|
let document = try XCTUnwrap(JSONSerialization.jsonObject(with: draft.data) as? [String: Any])
|
|
return try XCTUnwrap(document["x_shadowsocksx_ng"] as? [String: Any])
|
|
}
|
|
|
|
private func groupID(named name: String, in draft: ConfigurationGroupExportDraft) throws -> String
|
|
{
|
|
let groups = try XCTUnwrap(groupExtension(in: draft)["groups"] as? [[String: Any]])
|
|
return try XCTUnwrap(groups.first { $0["name"] as? String == name }?["id"] as? String)
|
|
}
|
|
|
|
private func addServer(
|
|
host: String, password: String, remark: String, into parent: NodeID
|
|
) async throws -> NodeID {
|
|
let uri = SsUri(
|
|
method: "aes-256-gcm", password: password, host: host, port: 8388, remark: remark
|
|
).encode()
|
|
let result = await workflow.importServers(from: [.clipboardText(uri)], into: parent)
|
|
XCTAssertEqual(result.sources.first?.result.importedCount, 1)
|
|
return try XCTUnwrap(workflow.tree.node(withID: parent)?.childNodes.last?.id)
|
|
}
|
|
}
|
|
|
|
@Suite("Configuration group sharing")
|
|
@MainActor
|
|
struct ConfigurationGroupSharingTests {
|
|
@Test func nestedURIListPreservesOrderAndCompleteConnectionData() throws {
|
|
let fixture = try Fixture()
|
|
defer { fixture.cleanUp() }
|
|
let draft = try fixture.workflow.configurationGroupShareDraft(for: fixture.root)
|
|
let uris = try draft.uriText.split(separator: "\n").map { try SsUri.decode(String($0)) }
|
|
#expect(uris.map(\.host) == ["203.0.113.1", "2001:db8::2", "203.0.113.3"])
|
|
#expect(uris.map(\.password) == ["first", "nested", "last"])
|
|
#expect(uris[1].pluginProgram == "missing-plugin")
|
|
#expect(uris[1].pluginOptions == "mode=websocket;host=example.com")
|
|
#expect(uris[1].remark == "嵌套服务器")
|
|
#expect(draft.uriList.suggestedFileName == "组 根.txt")
|
|
#expect(draft.uriList.format == .uriList)
|
|
let node = try #require(fixture.workflow.tree.node(withID: fixture.root))
|
|
#expect(node.serverConfigurationCount == 3)
|
|
let document = try #require(
|
|
JSONSerialization.jsonObject(with: draft.json.data) as? [String: Any])
|
|
let servers = try #require(document["servers"] as? [[String: Any]])
|
|
#expect(servers.compactMap { $0["server"] as? String } == uris.map(\.host))
|
|
#expect(draft.json.format == .json)
|
|
}
|
|
|
|
@Test func missingNestedCredentialRejectsWholeShareAndSnapshotRemainsStable() throws {
|
|
let fixture = try Fixture()
|
|
defer { fixture.cleanUp() }
|
|
let snapshot = try fixture.workflow.configurationGroupShareDraft(for: fixture.root)
|
|
try fixture.workflow.commit { catalog in
|
|
guard case .server(var fields) = catalog.entry(for: fixture.nestedServer)?.kind else {
|
|
return
|
|
}
|
|
fields.password = ""
|
|
try catalog.updateServer(fixture.nestedServer, with: fields)
|
|
}
|
|
#expect(
|
|
throws: ConfigurationGroupExportFailure.invalidServer(fixture.nestedServer, .missingPassword)
|
|
) {
|
|
try fixture.workflow.configurationGroupShareDraft(for: fixture.root)
|
|
}
|
|
#expect(
|
|
try SsUri.decode(String(snapshot.uriText.split(separator: "\n")[1])).password == "nested")
|
|
}
|
|
|
|
@Test func emptyGroupCannotPrepareShare() throws {
|
|
let fixture = try Fixture()
|
|
defer { fixture.cleanUp() }
|
|
#expect(throws: ConfigurationGroupExportFailure.noServers) {
|
|
try fixture.workflow.configurationGroupShareDraft(for: fixture.empty)
|
|
}
|
|
#expect(fixture.workflow.tree.node(withID: fixture.empty)?.serverConfigurationCount == 0)
|
|
}
|
|
|
|
@MainActor
|
|
private struct Fixture {
|
|
let directory: URL
|
|
let root = NodeID.fresh()
|
|
let empty = NodeID.fresh()
|
|
let nestedServer = NodeID.fresh()
|
|
let workflow: CatalogWorkflow
|
|
|
|
init() throws {
|
|
directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
"group-share-\(UUID())")
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
var catalog = ConfigurationCatalog()
|
|
try catalog.addGroup("组/根", id: root)
|
|
try catalog.addGroup("空组", id: empty)
|
|
try catalog.addServer(
|
|
ServerFields(
|
|
address: "203.0.113.1", port: 8388,
|
|
encryptionMethod: "aes-256-gcm", password: "first", remark: "First"),
|
|
id: .fresh(), to: root)
|
|
let nested = NodeID.fresh()
|
|
try catalog.addGroup("嵌套", id: nested, to: root)
|
|
try catalog.addServer(
|
|
ServerFields(
|
|
address: "2001:db8::2", port: 8389,
|
|
encryptionMethod: "aes-256-gcm", password: "nested", remark: "嵌套服务器",
|
|
pluginProgram: "missing-plugin", pluginOptions: "mode=websocket;host=example.com"),
|
|
id: nestedServer, to: nested)
|
|
try catalog.addServer(
|
|
ServerFields(
|
|
address: "203.0.113.3", port: 8390,
|
|
encryptionMethod: "aes-256-gcm", password: "last", remark: "Last"),
|
|
id: .fresh(), to: root)
|
|
// 外部服务器不得混入所选子树。
|
|
try catalog.addServer(
|
|
ServerFields(
|
|
address: "203.0.113.99", port: 8388,
|
|
encryptionMethod: "aes-256-gcm", password: "first"), id: .fresh())
|
|
let store = makeTestCatalogStore(fileURL: directory.appendingPathComponent("catalog.json"))
|
|
try store.save(CatalogDocument(catalog: catalog))
|
|
workflow = makeCatalogWorkflow(
|
|
coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()),
|
|
subscriptionFetcher: FakeSubscriptionFetcher(behavior: .success(Data())))
|
|
}
|
|
|
|
func cleanUp() { try? FileManager.default.removeItem(at: directory) }
|
|
}
|
|
}
|