Files
legado-ios/Tests/LegadoCoreTests/JavaScriptRuleEvaluatorTests.swift
T

501 lines
22 KiB
Swift

import Foundation
import Testing
@testable import LegadoCore
private struct JavaScriptNetworkMock: JavaScriptNetworkLoading {
func load(_ request: URLRequest) throws -> HTTPResponse {
#expect(request.url?.absoluteString == "https://js-network.example.test/api?page=2")
#expect(request.httpMethod == "POST")
#expect(String(data: request.httpBody ?? Data(), encoding: .utf8) == #"{"id":7}"#)
#expect(request.value(forHTTPHeaderField: "X-Source") == "source")
#expect(request.value(forHTTPHeaderField: "X-Extra") == "extra")
let url = try #require(request.url)
return .init(
data: Data(#"{"ok":true}"#.utf8),
statusCode: 201,
headers: ["X-Reply": "yes"],
finalURL: url
)
}
}
private final class RetryingLegacyJavaScriptNetworkMock: JavaScriptNetworkLoading, @unchecked Sendable {
private let lock = NSLock()
private var count = 0
func load(_ request: URLRequest) throws -> HTTPResponse {
lock.lock()
count += 1
let current = count
lock.unlock()
if current == 1 {
return .init(
data: Data("temporary".utf8),
statusCode: 503,
headers: [:],
finalURL: try #require(request.url)
)
}
#expect(request.value(forHTTPHeaderField: "Cookie")?.contains("explicit=yes") == true)
let encoding = try #require(HTTPTextDecoder.encoding(named: "gb18030"))
return .init(
data: try #require("旧编码辅助请求".data(using: encoding)),
statusCode: 200,
headers: ["Set-Cookie": "session=js-ok; Path=/"],
finalURL: try #require(request.url)
)
}
func calls() -> Int {
lock.lock()
defer { lock.unlock() }
return count
}
}
private struct JavaScriptAjaxAllMock: JavaScriptNetworkLoading {
func load(_ request: URLRequest) throws -> HTTPResponse {
let url = try #require(request.url)
return .init(
data: Data("body:\(url.lastPathComponent)".utf8),
statusCode: 200,
headers: [:],
finalURL: url
)
}
}
private final class JavaScriptDirectRequestMock: JavaScriptRedirectNetworkLoading, @unchecked Sendable {
private let lock = NSLock()
private var recorded: [(URLRequest, Bool)] = []
func load(_ request: URLRequest) throws -> HTTPResponse {
try load(request, followRedirects: true)
}
func load(_ request: URLRequest, followRedirects: Bool) throws -> HTTPResponse {
lock.lock()
recorded.append((request, followRedirects))
lock.unlock()
let url = try #require(request.url)
return .init(
data: Data("reply-\(request.httpMethod ?? "")".utf8),
statusCode: request.httpMethod == "GET" ? 302 : 200,
headers: ["X-Reply": "direct"],
finalURL: url
)
}
func calls() -> [(URLRequest, Bool)] {
lock.lock()
defer { lock.unlock() }
return recorded
}
}
struct JavaScriptRuleEvaluatorTests {
@Test func decryptsSourceContentUsingRhinoPackageImportsAndAESByteArrays() throws {
// OpenSSL AES-128-CBC/PKCS7 fixture, with a 16-byte IV prepended.
let script = #"""
var javaImport = new JavaImporter();
javaImport.importPackage(Packages.java.lang, Packages.javax.crypto.spec,
Packages.javax.crypto, Packages.java.util);
with (javaImport) {
var encrypted = Base64.getDecoder().decode('ZmVkY2JhOTg3NjU0MzIxMLgd3vmXCW/KPKUrbea1zRjjTRawCuSDRVQflh/mHcYAv/gm12GQKUL0NhitscZMdw==');
var key = SecretKeySpec(String('0123456789abcdef').getBytes(), 'AES');
var iv = IvParameterSpec(Arrays.copyOfRange(encrypted, 0, 16));
var cipher = Cipher.getInstance('AES/CBC/PKCS5Padding');
cipher.init(Cipher.DECRYPT_MODE, key, iv);
var decoded = String(cipher.doFinal(Arrays.copyOfRange(encrypted, 16, encrypted.length)));
cipher.init(Cipher.ENCRYPT_MODE, key, iv);
var encoded = Base64.getEncoder().encodeToString(cipher.doFinal(decoded.getBytes()));
decoded + '|' + encoded;
}
"""#
let value = try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init())
#expect(value.value.stringValue == "第一段正文\n第二段正文。|uB3e+ZcJb8o8pStt5rXNGONNFrAK5INFVB+WH+YdxgC/+CbXYZApQvQ2GK2xxkx3")
}
@Test func javaCipherRejectsInvalidInputsAndUnsupportedPackageImports() throws {
for script in [
"var i = new JavaImporter(); i.importPackage(Packages.java.io);",
"Packages.javax.crypto.Cipher.getInstance('DES/ECB/NoPadding');",
"var i = new JavaImporter(Packages.java.lang, Packages.javax.crypto.spec, Packages.javax.crypto); with(i) { var c = Cipher.getInstance('AES/CBC/PKCS5Padding'); c.init(2, SecretKeySpec(String('short').getBytes(), 'AES'), IvParameterSpec(String('0123456789abcdef').getBytes())); c.doFinal([1, 2, 3]); }"
] {
#expect(throws: RuleEngineError.self) {
try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init())
}
}
}
@Test func importedAESHelperDeclaredInsideWithRetainsJavaStringByteDecoding() throws {
let script = #"""
var javaImport = new JavaImporter();
javaImport.importPackage(Packages.java.lang, Packages.javax.crypto.spec,
Packages.javax.crypto, Packages.java.util);
with (javaImport) {
function decode(content) {
var bytes = Base64.getDecoder().decode(String(content));
var key = SecretKeySpec(String('0123456789abcdef').getBytes(), 'AES');
var iv = IvParameterSpec(Arrays.copyOfRange(bytes, 0, 16));
var cipher = Cipher.getInstance('AES/CBC/PKCS5Padding');
cipher.init(2, key, iv);
return String(cipher.doFinal(Arrays.copyOfRange(bytes, 16, bytes.length)));
}
}
decode('ZmVkY2JhOTg3NjU0MzIxMLgd3vmXCW/KPKUrbea1zRjjTRawCuSDRVQflh/mHcYAv/gm12GQKUL0NhitscZMdw==');
"""#
let value = try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init())
#expect(value.value.stringValue == "第一段正文\n第二段正文。")
}
@Test func supportsBundledHttpTTSJavaImporterHmacSHA1Script() throws {
let script = #"""
var aly = new JavaImporter(
Packages.javax.crypto.Mac,
Packages.javax.crypto.spec.SecretKeySpec,
Packages.javax.xml.bind.DatatypeConverter,
Packages.java.net.URLEncoder,
Packages.java.lang.String,
Packages.android.util.Base64
);
with (aly) {
var mac = Mac.getInstance('HmacSHA1');
mac.init(new SecretKeySpec(String('key').getBytes('UTF-8'), 'HmacSHA1'));
var signature = Base64.encodeToString(
mac.doFinal(String('The quick brown fox jumps over the lazy dog').getBytes('UTF-8')),
Base64.NO_WRAP
);
signature + ':' + URLEncoder.encode('a b*~中', 'UTF-8');
}
"""#
let value = try JavaScriptRuleEvaluator().evaluate(
script: script,
result: .null,
executionContext: .init()
)
#expect(value.value.stringValue == "3nybhbi3iqa8ino29wqQcBydtNk=:a+b*%7E%E4%B8%AD")
}
@Test func javaImporterDoesNotExposeArbitraryNativeClasses() throws {
let value = try JavaScriptRuleEvaluator().evaluate(
script: "typeof Packages.java.io + ':' + typeof Packages.java.lang.Runtime + ':' + Object.isFrozen(Packages)",
result: .null,
executionContext: .init()
)
#expect(value.value.stringValue == "undefined:undefined:true")
}
@Test func exposesDollarAsCurrentJSONForLegadoInlineExpressions() throws {
let value = try JavaScriptRuleEvaluator().evaluate(
script: "$.book_id",
result: .object(["book_id": .string("42")]),
executionContext: .init()
)
#expect(value.value.stringValue == "42")
}
@Test func exposesJavaGetStringRuleBridge() throws {
let evaluator = JavaScriptRuleEvaluator()
let html = #"<article><a href="/chapter/1">第一章</a></article>"#
let value = try evaluator.evaluate(
script: "java.getString('a@text', result) + ':' + java.getString('a@href', result, true)",
result: .string(html),
executionContext: .init(baseURL: "https://reader.example/book/")
)
let directJSON = try evaluator.evaluate(
script: "java.getString('name', result) + ':' + java.getString('$.id', result)",
result: .object(["name": .string("书名"), "id": .number(42)]),
executionContext: .init()
)
#expect(value.value.stringValue == "第一章:https://reader.example/chapter/1")
#expect(directJSON.value.stringValue == "书名:42")
}
@Test func jsonParseKeepsPlainTextResponsesButStillRejectsBrokenJSON() throws {
let evaluator = JavaScriptRuleEvaluator()
let plain = try evaluator.evaluate(
script: "JSON.parse(result)",
result: .string("The server is temporarily unavailable"),
executionContext: .init()
)
#expect(plain.value.stringValue == "The server is temporarily unavailable")
#expect(throws: RuleEngineError.self) {
_ = try evaluator.evaluate(
script: "JSON.parse(result)",
result: .string(#"{"broken":}"#),
executionContext: .init()
)
}
}
@Test func directJavaGetResolvesRelativeURLAndAppliesSourceHeaders() throws {
var source = BookSource(bookSourceUrl: "https://direct.example.test/base/", bookSourceName: "相对请求")
source.header = #"{"X-Source":"yes"}"#
let loader = JavaScriptDirectRequestMock()
let evaluator = JavaScriptRuleEvaluator(networkLoader: loader)
_ = try evaluator.evaluate(
script: "java.get('/chapter/1').body()",
result: .null,
executionContext: .init(baseURL: source.bookSourceUrl, source: source)
)
let call = try #require(loader.calls().first)
#expect(call.0.url?.absoluteString == "https://direct.example.test/chapter/1")
#expect(call.0.value(forHTTPHeaderField: "X-Source") == "yes")
#expect(call.0.value(forHTTPHeaderField: "User-Agent") != nil)
}
@Test func loadsSharedSourceJavaScriptLibrary() throws {
var source = BookSource(bookSourceUrl: "https://js-lib.example.test", bookSourceName: "共享脚本源")
source.jsLib = "function decorate(value) { return 'lib:' + value; }"
let value = try JavaScriptRuleEvaluator().evaluate(
script: "decorate(result)",
result: .string("ok"),
executionContext: .init(source: source)
)
#expect(value.value.stringValue == "lib:ok")
}
@Test func providesAjaxAndConnectWithLegadoURLOptions() throws {
var source = BookSource(bookSourceUrl: "https://js-network.example.test", bookSourceName: "JS 网络源")
source.header = #"{"X-Source":"source"}"#
let evaluator = JavaScriptRuleEvaluator(networkLoader: JavaScriptNetworkMock())
let url = #"/api?page=2,{"method":"POST","body":{"id":7},"headers":{"X-Extra":"extra"}}"#
let ajax = try evaluator.evaluate(
script: "java.ajax(result)",
result: .string(url),
executionContext: .init(baseURL: source.bookSourceUrl, source: source)
)
let connect = try evaluator.evaluate(
script: "var r=java.connect(result); r.body + ':' + r.code + ':' + r.headers['X-Reply']",
result: .string(url),
executionContext: .init(baseURL: source.bookSourceUrl, source: source)
)
#expect(ajax.value.stringValue == #"{"ok":true}"#)
#expect(connect.value.stringValue == #"{"ok":true}:201:yes"#)
}
@Test func ajaxUsesCharsetHTTPRetryCookieMergeAndBodyJavaScript() throws {
let host = "https://legacy-js.example.test"
LegadoCookieStore.shared.removeCookies(for: host)
defer { LegadoCookieStore.shared.removeCookies(for: host) }
LegadoCookieStore.shared.setCookie("stored=one; Path=/", for: host)
var source = BookSource(bookSourceUrl: host, bookSourceName: "旧编码 JS 源")
source.enabledCookieJar = true
let loader = RetryingLegacyJavaScriptNetworkMock()
let evaluator = JavaScriptRuleEvaluator(networkLoader: loader)
let rule = #"/api, {"charset":"gb18030","retry":1,"headers":{"Cookie":"explicit=yes"},"bodyJs":"result.replace('辅助', '内部')"}"#
let value = try evaluator.evaluate(
script: "java.ajax(result) + ':' + java.getCookie('\(host)', 'session')",
result: .string(rule),
executionContext: .init(baseURL: host, source: source)
)
#expect(value.value.stringValue == "旧编码内部请求:js-ok")
#expect(loader.calls() == 2)
}
@Test func readsAndWritesSharedCookies() throws {
let host = "https://cookie-js-bridge.test"
LegadoCookieStore.shared.removeCookies(for: host)
defer { LegadoCookieStore.shared.removeCookies(for: host) }
let evaluator = JavaScriptRuleEvaluator()
let value = try evaluator.evaluate(
script: "java.setCookie('\(host)', 'token=abc; Path=/'); java.getCookie('\(host)', 'token') + ':' + java.getCookie('\(host)').indexOf('token=abc')",
result: .null,
executionContext: .init()
)
#expect(value.value.stringValue == "abc:0")
#expect(LegadoCookieStore.shared.cookieHeader(for: host).contains("token=abc"))
}
@Test func exposesGlobalCookieStore() throws {
// Use a separate registrable domain because Swift Testing runs these
// cookie-store tests concurrently.
let host = "https://global-cookie.test"
LegadoCookieStore.shared.removeCookies(for: host)
defer { LegadoCookieStore.shared.removeCookies(for: host) }
let value = try JavaScriptRuleEvaluator().evaluate(
script: "cookie.setCookie('\(host)', 'token=abc; Path=/'); var map=cookie.cookieToMap(cookie.getCookie('\(host)')); var header=cookie.mapToCookie(map); cookie.getKey('\(host)', 'token') + ':' + header",
result: .null,
executionContext: .init()
)
#expect(value.value.stringValue == "abc:token=abc")
let removed = try JavaScriptRuleEvaluator().evaluate(
script: "cookie.removeCookie('\(host)'); cookie.getCookie('\(host)')",
result: .null,
executionContext: .init()
)
#expect(removed.value.stringValue.isEmpty)
}
@Test func ajaxAllReturnsResponseObjectsInInputOrder() throws {
let evaluator = JavaScriptRuleEvaluator(networkLoader: JavaScriptAjaxAllMock())
let value = try evaluator.evaluate(
script: "var rows=java.ajaxAll(['https://ajax-all.test/a','https://ajax-all.test/b'], false); rows[0].body + '|' + rows[1].url",
result: .null,
executionContext: .init()
)
#expect(value.value.stringValue == "body:a|https://ajax-all.test/b")
}
@Test func getHeadPostExposeJsoupStyleResponseAndDisableRedirects() throws {
let loader = JavaScriptDirectRequestMock()
let evaluator = JavaScriptRuleEvaluator(networkLoader: loader)
let value = try evaluator.evaluate(
script: "var g=java.get('https://direct.test/get', {'X-Test':'yes'}, 5000); var h=java.head('https://direct.test/head', {}); var p=java.post('https://direct.test/post', 'raw=正文', {'Content-Type':'text/plain'}); g.statusCode() + ':' + g.header('x-reply') + ':' + h.body() + ':' + p.body()",
result: .null,
executionContext: .init()
)
let calls = loader.calls()
#expect(value.value.stringValue == "302:direct:reply-HEAD:reply-POST")
#expect(calls.count == 3)
#expect(calls.allSatisfy { !$0.1 })
#expect(calls[0].0.value(forHTTPHeaderField: "X-Test") == "yes")
#expect(calls[1].0.httpBody == nil)
#expect(String(decoding: calls[2].0.httpBody ?? Data(), as: UTF8.self) == "raw=正文")
}
@Test func executesPureJavaScriptAndJSONPathPipeline() async throws {
let engine = RuleEngine()
let root = try JSONValue.decode(data: Data(#"{"id":42,"name":"测试"}"#.utf8))
let pure = await engine.evaluate("@js:result.name + '-JS'", in: root)
#expect(pure.firstString == "测试-JS")
#expect(pure.diagnostics.isEmpty)
let pipeline = await engine.evaluate(
"$.id@js:java.put('bookId', result); '/book/' + result",
in: root
)
#expect(pipeline.firstString == "/book/42")
#expect(pipeline.variables["bookId"] == "42")
#expect(pipeline.diagnostics.isEmpty)
}
@Test func carriesVariablesAndProvidesLegadoHelpers() async {
let engine = RuleEngine()
let context = RuleExecutionContext(variables: ["bookId": "1001"])
let result = await engine.evaluate(
"@js:java.md5Encode(java.get('bookId')).length + ':' + java.get('bookId')",
in: .null,
context: context
)
#expect(result.firstString == "32:1001")
#expect(result.variables["bookId"] == "1001")
}
@Test func providesByteHexTimeAndUUIDHelpers() throws {
let value = try JavaScriptRuleEvaluator().evaluate(
script: "java.bytesToStr(java.strToBytes('正文')) + ':' + java.hexDecodeToString(java.hexEncodeToString('A')) + ':' + java.timeFormatUTC(0, 'yyyy-MM-dd HH:mm', 0) + ':' + java.randomUUID().length",
result: .null,
executionContext: .init()
)
#expect(value.value.stringValue == "正文:A:1970-01-01 00:00:36")
}
@Test func exposesSourceTagBase64BytesAndStableInstallationIdentifier() throws {
let source = BookSource(bookSourceUrl: "https://bridge-source.test", bookSourceName: "桥接源")
let evaluator = JavaScriptRuleEvaluator()
let value = try evaluator.evaluate(
script: "java.getSource().bookSourceName + ':' + java.getTag() + ':' + java.bytesToStr(java.base64DecodeToByteArray('b2s=')) + ':' + (java.androidId() === java.androidId())",
result: .null,
executionContext: .init(source: source)
)
#expect(value.value.stringValue == "桥接源:https://bridge-source.test:ok:true")
}
@Test func exposesEntityPropertiesAndFormStyleEncodeURI() throws {
var book = Book(bookUrl: "https://book.test/1", name: "书名", author: "作者")
book.kind = "玄幻"
let chapter = BookChapter(url: "/chapter/1", title: "第一章", bookUrl: book.bookUrl, index: 3)
let value = try JavaScriptRuleEvaluator().evaluate(
script: "book.name + ':' + book.kind + ':' + chapter.title + ':' + chapter.index + ':' + java.encodeURI('a b&中')",
result: .null,
executionContext: .init(book: book, chapter: chapter)
)
#expect(value.value.stringValue == "书名:玄幻:第一章:3:a+b%26%E4%B8%AD")
}
@Test func keepsJavaSpecialVariablesAndSourceVariablesInSeparateScopes() throws {
let source = BookSource(bookSourceUrl: "https://variable-source.test", bookSourceName: "变量源")
let book = Book(bookUrl: "https://book.test/variable", name: "变量书")
let chapter = BookChapter(url: "/one", title: "变量章", bookUrl: book.bookUrl, index: 0)
let evaluator = JavaScriptRuleEvaluator()
let first = try evaluator.evaluate(
script: "source.put('token','persisted'); java.get('bookName') + ':' + java.get('title')",
result: .null,
executionContext: .init(source: source, book: book, chapter: chapter)
)
let second = try evaluator.evaluate(
script: "source.get('token')",
result: .null,
executionContext: .init(source: source)
)
#expect(first.value.stringValue == "变量书:变量章")
#expect(second.value.stringValue == "persisted")
}
@Test func resolvesDynamicLenientAndLoginHeadersByPriority() throws {
var source = BookSource(bookSourceUrl: "https://header-source.test", bookSourceName: "Header源")
source.header = #"@js:"{'X-Static':'base','X-Dynamic':'js'}""#
BookSourceRuntimeStore.shared.put(
sourceID: source.id,
key: "loginHeader",
value: #"{"X-Static":"login","Cookie":"token=ok"}"#
)
let headers = try BookSourceHeaderResolver().resolve(source)
#expect(headers["X-Dynamic"] == "js")
#expect(headers["X-Static"] == "login")
#expect(headers["Cookie"] == "token=ok")
}
@Test func exposesSourceLoginInfoMapAndHeaderMap() throws {
var source = BookSource(bookSourceUrl: "httpTts:99", bookSourceName: "在线朗读")
source.header = #"{"X-Base":"one"}"#
BookSourceRuntimeStore.shared.put(sourceID: source.id, key: "loginHeader", value: #"{"X-Login":"two"}"#)
let value = try JavaScriptRuleEvaluator().evaluate(
script: "source.putLoginInfo(JSON.stringify({token:'abc'})); source.getLoginInfoMap().get('token') + ':' + source.getHeaderMap(true)['X-Login'] + ':' + source.getKey()",
result: .null,
executionContext: .init(source: source)
)
#expect(value.value.stringValue == "abc:two:httpTts:99")
}
@Test func rejectsObviousUnboundedScripts() async {
let result = await RuleEngine().evaluate("@js:while(true){}", in: .null)
#expect(result.values.isEmpty)
#expect(result.diagnostics.contains { $0.code == "rule.resource_limit" })
}
}