Files
ShadowsocksX-NG/ShadowsocksX-NG2/Tests/Subscription/SubscriptionDocumentParserTests.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])
}
}