mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 17:24:50 +00:00
184 lines
6.0 KiB
Swift
184 lines
6.0 KiB
Swift
import XCTest
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
/// 订阅获取缝(验收「HTTP 缝夹具」的传输侧):HTTPS 门禁先于任何
|
|
/// 网络行为、状态码与 Content-Type 契约、传输失败点名、错误文案脱敏。
|
|
final class SubscriptionFetcherTests: XCTestCase {
|
|
private let fetcher = HTTPSSubscriptionFetcher()
|
|
|
|
// MARK: URL 门禁(发起连接之前)
|
|
|
|
func testNonHTTPSRejectedBeforeAnyNetwork() async {
|
|
// 端口 1 无监听:若实现先连网会得到 transport 错误;断言精确错误即证明
|
|
// 门禁在连接之前。
|
|
let url = URL(string: "http://127.0.0.1:1/sub.json")!
|
|
|
|
do {
|
|
_ = try await fetcher.fetch(url)
|
|
XCTFail("HTTP 地址必须被拒绝")
|
|
} catch let error as SubscriptionFetchError {
|
|
XCTAssertEqual(error, .unsupportedScheme)
|
|
} catch {
|
|
XCTFail("应报 unsupportedScheme,实际 \(error)")
|
|
}
|
|
}
|
|
|
|
func testFTPRejected() async {
|
|
let url = URL(string: "ftp://127.0.0.1/sub.json")!
|
|
do {
|
|
_ = try await fetcher.fetch(url)
|
|
XCTFail("非 HTTPS 必须被拒绝")
|
|
} catch let error as SubscriptionFetchError {
|
|
XCTAssertEqual(error, .unsupportedScheme)
|
|
} catch {
|
|
XCTFail("应报 unsupportedScheme,实际 \(error)")
|
|
}
|
|
}
|
|
|
|
func testURLWithoutHostRejected() async {
|
|
let url = URL(string: "https:///path")!
|
|
do {
|
|
_ = try await fetcher.fetch(url)
|
|
XCTFail("无主机地址必须被拒绝")
|
|
} catch let error as SubscriptionFetchError {
|
|
XCTAssertEqual(error, .invalidURL)
|
|
} catch {
|
|
XCTFail("应报 invalidURL,实际 \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: 传输失败(连接被拒;TLS 校验走系统默认、从不忽略)
|
|
|
|
func testTransportErrorOnUnreachableHost() async throws {
|
|
let port = try ProxyRuntimeFixture.unusedLoopbackPort()
|
|
let url = URL(string: "https://127.0.0.1:\(port)/sub.json")!
|
|
|
|
do {
|
|
_ = try await fetcher.fetch(url)
|
|
XCTFail("无监听端口必须报传输失败")
|
|
} catch let error as SubscriptionFetchError {
|
|
guard case .transport = error else {
|
|
return XCTFail("应报 transport,实际 \(error)")
|
|
}
|
|
} catch {
|
|
XCTFail("应报 transport,实际 \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: 响应契约(纯函数)
|
|
|
|
func testValidateAcceptsExactContentType() {
|
|
XCTAssertNoThrow(
|
|
try HTTPSSubscriptionFetcher.validate(
|
|
statusCode: 200, contentType: "application/json; charset=utf-8"))
|
|
}
|
|
|
|
func testValidateIsCaseAndWhitespaceInsensitive() {
|
|
XCTAssertNoThrow(
|
|
try HTTPSSubscriptionFetcher.validate(
|
|
statusCode: 200, contentType: "Application/JSON ; charset=UTF-8"))
|
|
}
|
|
|
|
func testValidateRejectsContractViolations() {
|
|
struct Case {
|
|
let status: Int
|
|
let type: String?
|
|
let expected: SubscriptionFetchError
|
|
}
|
|
let cases: [Case] = [
|
|
Case(status: 404, type: "application/json; charset=utf-8", expected: .httpStatus(code: 404)),
|
|
Case(status: 500, type: "application/json; charset=utf-8", expected: .httpStatus(code: 500)),
|
|
Case(status: 301, type: "application/json; charset=utf-8", expected: .httpStatus(code: 301)),
|
|
Case(status: 200, type: nil, expected: .contentType(received: nil)),
|
|
Case(
|
|
status: 200, type: "text/plain; charset=utf-8",
|
|
expected: .contentType(received: "text/plain; charset=utf-8")),
|
|
Case(
|
|
status: 200, type: "application/json",
|
|
expected: .contentType(received: "application/json")),
|
|
Case(
|
|
status: 200, type: "application/json; charset=latin1",
|
|
expected: .contentType(received: "application/json; charset=latin1")),
|
|
Case(
|
|
status: 200, type: "application/json; charset=utf-8; boundary=x",
|
|
expected: .contentType(received: "application/json; charset=utf-8; boundary=x")),
|
|
]
|
|
for violation in cases {
|
|
XCTAssertThrowsError(
|
|
try HTTPSSubscriptionFetcher.validate(
|
|
statusCode: violation.status, contentType: violation.type),
|
|
String(describing: violation.expected)
|
|
) { error in
|
|
XCTAssertEqual(error as? SubscriptionFetchError, violation.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: 重定向策略(纯函数)与脱敏
|
|
|
|
func testRedirectPolicyOnlyAllowsHTTPS() {
|
|
XCTAssertTrue(HTTPSSubscriptionFetcher.allowsRedirect(to: "https"))
|
|
XCTAssertTrue(HTTPSSubscriptionFetcher.allowsRedirect(to: "HTTPS"))
|
|
XCTAssertFalse(HTTPSSubscriptionFetcher.allowsRedirect(to: "http"))
|
|
XCTAssertFalse(HTTPSSubscriptionFetcher.allowsRedirect(to: nil))
|
|
}
|
|
|
|
func testTransportDetailNeverCarriesURL() {
|
|
let error = URLError(.cannotFindHost)
|
|
let detail = HTTPSSubscriptionFetcher.redactedTransportDetail(error)
|
|
|
|
XCTAssertFalse(detail.contains("sub.json"), "传输错误细节不得携带 URL:\(detail)")
|
|
XCTAssertFalse(detail.contains("example.com"), "传输错误细节不得携带主机:\(detail)")
|
|
XCTAssertTrue(detail.contains("URLError"), "点名错误类型:\(detail)")
|
|
}
|
|
}
|
|
|
|
/// 获取缝夹具:按 URL 返回预设结果并记录请求(VM 语义测试用)。
|
|
final class FakeSubscriptionFetcher: SubscriptionFetching, @unchecked Sendable {
|
|
enum Behavior {
|
|
case success(Data)
|
|
case failure(SubscriptionFetchError)
|
|
}
|
|
|
|
private let lock = NSLock()
|
|
private var behavior: Behavior
|
|
private var requests: [URL] = []
|
|
|
|
init(behavior: Behavior) {
|
|
self.behavior = behavior
|
|
}
|
|
|
|
/// 中途切换预设结果(工作流不再暴露可替换的获取器属性)。
|
|
func setBehavior(_ behavior: Behavior) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
self.behavior = behavior
|
|
}
|
|
|
|
var lastURL: URL? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return requests.last
|
|
}
|
|
|
|
var requestCount: Int {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return requests.count
|
|
}
|
|
|
|
func fetch(_ url: URL) async throws -> Data {
|
|
let behavior = lock.withLock {
|
|
requests.append(url)
|
|
return self.behavior
|
|
}
|
|
switch behavior {
|
|
case .success(let data):
|
|
return data
|
|
case .failure(let error):
|
|
throw error
|
|
}
|
|
}
|
|
}
|