mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 10:44:50 +00:00
283 lines
11 KiB
Swift
283 lines
11 KiB
Swift
import XCTest
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
/// SIP-008 v1 + 私有扩展解析(验收:解码/schema/重复 ID/环/记录校验
|
|
/// 失败逐项覆盖;扩展缺失/未知/无效回退标准列表)。
|
|
final class SubscriptionDocumentParserTests: XCTestCase {
|
|
private let subscriptionID = NodeID(rawValue: "sub-test")
|
|
|
|
// MARK: 文档夹具
|
|
|
|
private func flatDoc(serverCount: Int = 2) -> Data {
|
|
let servers = (1...serverCount).map { index in
|
|
"""
|
|
{"id": "00000000-0000-4000-8000-00000000000\(index)",
|
|
"remarks": "节点 \(index)",
|
|
"server": "203.0.113.\(index)", "server_port": \(8387 + index),
|
|
"password": "pw\(index)", "method": "aes-256-gcm"}
|
|
"""
|
|
}.joined(separator: ",")
|
|
return Data(
|
|
"""
|
|
{"version": 1, "servers": [\(servers)]}
|
|
""".utf8)
|
|
}
|
|
|
|
private func treeDoc(
|
|
schemaVersion: Int = 1,
|
|
serverRefIDs: [String] = ["aaaaaaaa-0000-4000-8000-00000000000a"],
|
|
groups: String? = nil,
|
|
rootGroupID: String = "root"
|
|
) -> Data {
|
|
let groupJSON: String
|
|
if let groups {
|
|
groupJSON = groups
|
|
} else {
|
|
let refs =
|
|
serverRefIDs
|
|
.map { #"{"type": "server", "id": "\#($0)"}"# }
|
|
.joined(separator: ",")
|
|
groupJSON = """
|
|
{"id": "root", "name": "Example subscription", "children": [
|
|
{"type": "group", "id": "jp"}\(refs.isEmpty ? "" : ", \(refs)")]},
|
|
{"id": "jp", "name": "Japan", "children": [{"type": "server", "id": "bbbbbbbb-0000-4000-8000-00000000000b"}]}
|
|
"""
|
|
}
|
|
return Data(
|
|
"""
|
|
{"version": 1,
|
|
"servers": [
|
|
{"id": "aaaaaaaa-0000-4000-8000-00000000000a", "server": "203.0.113.1", "server_port": 8388,
|
|
"password": "p1", "method": "aes-256-gcm"},
|
|
{"id": "bbbbbbbb-0000-4000-8000-00000000000b", "server": "203.0.113.2", "server_port": 8389,
|
|
"password": "p2", "method": "aes-256-gcm"}],
|
|
"x_shadowsocksx_ng": {"schema_version": \(schemaVersion), "root_group_id": "\(rootGroupID)",
|
|
"groups": [\(groupJSON)]}}
|
|
""".utf8)
|
|
}
|
|
|
|
func parse(_ data: Data) throws -> SubscriptionSnapshot {
|
|
try SubscriptionDocumentParser.parse(data, subscriptionID: subscriptionID)
|
|
}
|
|
|
|
private func serverIDs(_ group: SubscriptionSnapshot.Group) -> [String] {
|
|
group.children.compactMap {
|
|
if case .server(let leaf) = $0 { return leaf.id.rawValue }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func serverRecords(_ group: SubscriptionSnapshot.Group) -> [RemoteServerRecord] {
|
|
group.children.compactMap {
|
|
if case .server(let leaf) = $0 { return leaf.record }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: 标准列表与扩展树
|
|
|
|
func testFlatDocumentParsesServersInArrayOrder() throws {
|
|
let snapshot = try parse(flatDoc())
|
|
|
|
XCTAssertEqual(
|
|
snapshot.root.children.filter { if case .group = $0 { return true } else { return false } }
|
|
.count, 0, "扁平回退无嵌套分组")
|
|
XCTAssertEqual(serverIDs(snapshot.root).count, 2)
|
|
XCTAssertTrue(serverIDs(snapshot.root).allSatisfy { $0.hasPrefix("sub-test:id:") })
|
|
let records = serverRecords(snapshot.root)
|
|
XCTAssertEqual(records[0].address, "203.0.113.1")
|
|
XCTAssertEqual(records[0].port, 8388)
|
|
XCTAssertEqual(records[0].remark, "节点 1")
|
|
XCTAssertEqual(records[1].password, "pw2")
|
|
}
|
|
|
|
func testExtensionTreeParsesNestedGroupsAndOrder() throws {
|
|
let snapshot = try parse(treeDoc())
|
|
|
|
XCTAssertEqual(snapshot.root.name, "Example subscription")
|
|
// 根分组子序 = [嵌套组 Japan, 服务器 a](远端交错顺序原样保留)。
|
|
XCTAssertEqual(snapshot.root.children.count, 2)
|
|
guard case .group(let japan) = snapshot.root.children[0] else {
|
|
return XCTFail("首子应为嵌套组")
|
|
}
|
|
guard case .server(let rootServer) = snapshot.root.children[1] else {
|
|
return XCTFail("次子应为服务器")
|
|
}
|
|
XCTAssertEqual(japan.name, "Japan")
|
|
XCTAssertEqual(rootServer.record.address, "203.0.113.1")
|
|
XCTAssertEqual(serverRecords(japan).map(\.address), ["203.0.113.2"])
|
|
// 分组身份按订阅作用域限定且跨刷新稳定。
|
|
XCTAssertTrue(japan.id.rawValue.hasPrefix("sub-test:g:jp"))
|
|
}
|
|
|
|
func testEmptyServersIsValidEmptySnapshot() throws {
|
|
let snapshot = try parse(Data(#"{"version": 1, "servers": []}"#.utf8))
|
|
|
|
XCTAssertEqual(snapshot.root.children.count, 0)
|
|
}
|
|
|
|
func testPluginFieldsCarriedVerbatim() throws {
|
|
let data = Data(
|
|
"""
|
|
{"version": 1, "servers": [
|
|
{"id": "aaaaaaaa-0000-4000-8000-00000000000a", "server": "203.0.113.1", "server_port": 443,
|
|
"password": "p", "method": "aes-256-gcm",
|
|
"plugin": "v2ray-plugin", "plugin_opts": "mode=websocket;host=example.com"}]}
|
|
""".utf8)
|
|
|
|
let snapshot = try parse(data)
|
|
|
|
let records = serverRecords(snapshot.root)
|
|
XCTAssertEqual(records[0].pluginProgram, "v2ray-plugin")
|
|
XCTAssertEqual(records[0].pluginOptions, "mode=websocket;host=example.com")
|
|
}
|
|
|
|
// MARK: 扩展缺失/未知/无效 → 回退扁平
|
|
|
|
func testMissingExtensionFallsBackToFlat() throws {
|
|
let snapshot = try parse(flatDoc())
|
|
|
|
XCTAssertEqual(snapshot.root.name, "", "扁平回退无根名(由调用方以 host 兜底)")
|
|
XCTAssertEqual(serverIDs(snapshot.root).count, 2)
|
|
}
|
|
|
|
func testUnknownSchemaVersionFallsBackToFlat() throws {
|
|
let snapshot = try parse(treeDoc(schemaVersion: 2))
|
|
|
|
XCTAssertEqual(snapshot.root.name, "")
|
|
XCTAssertEqual(serverIDs(snapshot.root).count, 2, "标准服务器不因扩展版本未知而丢失")
|
|
XCTAssertEqual(
|
|
snapshot.root.children.filter { if case .group = $0 { return true } else { return false } }
|
|
.count, 0)
|
|
}
|
|
|
|
func testExtensionNotAnObjectFallsBackToFlat() throws {
|
|
let data = Data(
|
|
"""
|
|
{"version": 1, "servers": [], "x_shadowsocksx_ng": "broken"}
|
|
""".utf8)
|
|
|
|
let snapshot = try parse(data)
|
|
|
|
XCTAssertEqual(serverIDs(snapshot.root).count, 0)
|
|
}
|
|
|
|
func testUnknownServerRefFallsBackToFlat() throws {
|
|
let snapshot = try parse(treeDoc(serverRefIDs: ["ghost"]))
|
|
|
|
XCTAssertEqual(snapshot.root.name, "")
|
|
XCTAssertEqual(serverIDs(snapshot.root).count, 2, "引用损坏时保留标准服务器")
|
|
}
|
|
|
|
func testCycleFallsBackToFlat() throws {
|
|
let groups = """
|
|
{"id": "root", "name": "R", "children": [{"type": "group", "id": "jp"}]},
|
|
{"id": "jp", "name": "J", "children": [{"type": "group", "id": "root"}]}
|
|
"""
|
|
let snapshot = try parse(treeDoc(groups: groups))
|
|
|
|
XCTAssertEqual(snapshot.root.name, "", "环使扩展无效 → 回退扁平")
|
|
XCTAssertEqual(serverIDs(snapshot.root).count, 2)
|
|
XCTAssertEqual(
|
|
snapshot.root.children.filter { if case .group = $0 { return true } else { return false } }
|
|
.count, 0)
|
|
}
|
|
|
|
func testSharedParentFallsBackToFlat() throws {
|
|
let groups = """
|
|
{"id": "root", "name": "R", "children": [
|
|
{"type": "group", "id": "x"}, {"type": "group", "id": "y"}]},
|
|
{"id": "x", "name": "X", "children": [{"type": "group", "id": "shared"}]},
|
|
{"id": "y", "name": "Y", "children": [{"type": "group", "id": "shared"}]},
|
|
{"id": "shared", "name": "S", "children": []}
|
|
"""
|
|
let snapshot = try parse(treeDoc(groups: groups))
|
|
|
|
XCTAssertEqual(snapshot.root.name, "", "同一分组被两个父引用 → 扩展无效")
|
|
}
|
|
|
|
func testDuplicateGroupIDFallsBackToFlat() throws {
|
|
let groups = """
|
|
{"id": "root", "name": "R", "children": []},
|
|
{"id": "root", "name": "R2", "children": []}
|
|
"""
|
|
let snapshot = try parse(treeDoc(groups: groups))
|
|
|
|
XCTAssertEqual(snapshot.root.name, "")
|
|
}
|
|
|
|
func testGroupIDCollidingWithServerIDFallsBackToFlat() throws {
|
|
let groups = """
|
|
{"id": "aaaaaaaa-0000-4000-8000-00000000000a", "name": "冒充服务器 ID", "children": []},
|
|
{"id": "root", "name": "R", "children": [{"type": "group", "id": "aaaaaaaa-0000-4000-8000-00000000000a"}]}
|
|
"""
|
|
let snapshot = try parse(treeDoc(groups: groups))
|
|
|
|
XCTAssertEqual(snapshot.root.name, "")
|
|
}
|
|
|
|
func testMissingRootGroupFallsBackToFlat() throws {
|
|
let snapshot = try parse(treeDoc(rootGroupID: "ghost"))
|
|
|
|
XCTAssertEqual(snapshot.root.name, "")
|
|
XCTAssertEqual(serverIDs(snapshot.root).count, 2)
|
|
}
|
|
|
|
func testUnreferencedServersAppendedToRootInArrayOrder() throws {
|
|
let groups = """
|
|
{"id": "root", "name": "R", "children": [{"type": "server", "id": "aaaaaaaa-0000-4000-8000-00000000000a"}]}
|
|
"""
|
|
let data = Data(
|
|
"""
|
|
{"version": 1,
|
|
"servers": [
|
|
{"id": "bbbbbbbb-0000-4000-8000-00000000000b", "server": "203.0.113.2", "server_port": 8389,
|
|
"password": "p2", "method": "m"},
|
|
{"id": "aaaaaaaa-0000-4000-8000-00000000000a", "server": "203.0.113.1", "server_port": 8388,
|
|
"password": "p1", "method": "m"}],
|
|
"x_shadowsocksx_ng": {"schema_version": 1, "root_group_id": "root",
|
|
"groups": [\(groups)]}}
|
|
""".utf8)
|
|
|
|
let snapshot = try parse(data)
|
|
|
|
XCTAssertEqual(serverIDs(snapshot.root).count, 2, "未引用服务器不丢弃")
|
|
XCTAssertEqual(serverRecords(snapshot.root)[0].address, "203.0.113.1", "树引用在前")
|
|
XCTAssertEqual(serverRecords(snapshot.root)[1].address, "203.0.113.2", "未引用按数组序追加")
|
|
}
|
|
|
|
// MARK: 身份延续规则(无稳定 ID 按地址端口精确匹配延续)
|
|
|
|
func testIdLessRecordIdentityIsStableForIdenticalContent() throws {
|
|
let record =
|
|
#"{"server": "203.0.113.1", "server_port": 8388, "password": "p", "method": "m"}"#
|
|
let first = try parse(Data("{\"version\": 1, \"servers\": [\(record)]}".utf8))
|
|
let second = try parse(Data("{\"version\": 1, \"servers\": [\(record)]}".utf8))
|
|
|
|
XCTAssertEqual(serverIDs(first.root)[0], serverIDs(second.root)[0])
|
|
}
|
|
|
|
func testIdLessRecordIdentitySurvivesRemoteRename() throws {
|
|
let base =
|
|
#"{"server": "203.0.113.1", "server_port": 8388, "password": "p", "method": "m", "remarks": "r"}"#
|
|
let original = try parse(Data("{\"version\": 1, \"servers\": [\(base)]}".utf8))
|
|
// 仅改 remarks(远端改名):显示元数据不参与端点指纹。
|
|
let renamed =
|
|
#"{"server": "203.0.113.1", "server_port": 8388, "password": "p", "method": "m", "remarks": "r2"}"#
|
|
let changed = try parse(Data("{\"version\": 1, \"servers\": [\(renamed)]}".utf8))
|
|
|
|
XCTAssertEqual(serverIDs(original.root)[0], serverIDs(changed.root)[0])
|
|
}
|
|
|
|
func testIdentityScopedPerSubscription() throws {
|
|
let data = flatDoc()
|
|
let other = try SubscriptionDocumentParser.parse(
|
|
data, subscriptionID: NodeID(rawValue: "sub-other"))
|
|
|
|
XCTAssertTrue(serverIDs(other.root).allSatisfy { $0.hasPrefix("sub-other:id:") })
|
|
let baseline = try parse(data)
|
|
XCTAssertNotEqual(serverIDs(other.root)[0], serverIDs(baseline.root)[0])
|
|
}
|
|
}
|