Files
bookorbit/koreader-plugin/spec/bookorbit_api_test.lua
T
Irohoho 900514c2b3 fix(koreader): respect KOReader global HTTP proxy settings (#1562)
* fix(koreader): respect KOReader global HTTP proxy settings

Allow KOReader's Network -> Proxy settings (stored in G_reader_settings)
to be respected for both JSON API requests and binary downloads.
Also support explicit proxy in BookOrbitApi options, and route Tailscale
tailnet addresses (100.x.y.z and *.ts.net) through the local userspace
HTTP proxy (127.0.0.1:1056) when running on e-ink devices lacking TUN.

Fixes #753

* fix(koreader): address review feedback on host parsing and HTTPS proxy safety

* fix(koreader): simplify getProxy to respect KOReader proxy settings without vendor fallbacks

* fix: tunnel KOReader HTTPS through configured HTTP proxies

Route HTTPS requests through CONNECT so proxy-only servers stay reachable without exposing BookOrbit credentials to the proxy. Restrict redirects to the original host and allow secure upgrades for API calls and downloads. Bump the plugin version so installed clients discover the fix.

---------

Co-authored-by: Andrej <acikarski14@gmail.com>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-09-26 11:15:47 -06:00

502 lines
20 KiB
Lua

local mock_http_body = "{}"
local mock_http_code = 200
local request_ran_in_subprocess = false
local in_subprocess = false
local encoded_subprocess_result
package.loaded["logger"] = {
dbg = function() end,
warn = function() end,
}
package.loaded["util"] = {
trim = function(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end,
fixUtf8 = function(value)
return value
end,
urlEncode = function(value)
return tostring(value)
end,
removeFile = function() end,
}
package.loaded["socketutil"] = {
LARGE_BLOCK_TIMEOUT = 1,
LARGE_TOTAL_TIMEOUT = 1,
FILE_BLOCK_TIMEOUT = 1,
FILE_TOTAL_TIMEOUT = 1,
set_timeout = function() end,
reset_timeout = function() end,
}
package.loaded["socket"] = {
skip = function(n, ...)
local values = { ... }
local shifted = {}
for index = n + 1, #values do
table.insert(shifted, values[index])
end
return unpack(shifted)
end,
}
package.loaded["ltn12"] = {
sink = {
table = function(target)
return function(chunk)
if chunk then table.insert(target, chunk) end
return 1
end
end,
file = function(file)
return function(chunk)
if chunk then file:write(chunk) else file:close() end
return 1
end
end,
},
source = {
string = function(value)
local pending = value
return function()
local chunk = pending
pending = nil
return chunk
end
end,
},
}
local last_request_url
local last_request_headers
local last_request_proxy
local last_global_proxy
local mock_http_error = false
local mock_http_queue
local request_history = {}
local request_count = 0
package.loaded["socket.http"] = {
request = function(request)
last_global_proxy = package.loaded["socket.http"].PROXY
if mock_http_error then error("simulated transport failure") end
request_count = request_count + 1
request_ran_in_subprocess = in_subprocess
last_request_url = request.url
last_request_headers = request.headers
last_request_proxy = request.proxy
table.insert(request_history, {
url = request.url,
proxy = request.proxy,
global_proxy = last_global_proxy,
redirect = request.redirect,
create = request.create,
})
if mock_http_queue then
local response = table.remove(mock_http_queue, 1)
if response.body then request.sink(response.body) end
return 1, response.code, response.headers or {}, "HTTP " .. tostring(response.code)
end
if mock_http_body then
request.sink(mock_http_body)
end
return 1, mock_http_code, {}, "HTTP " .. tostring(mock_http_code)
end,
}
local rapidjson_null = {}
local last_encoded_value
package.loaded["rapidjson"] = {
null = rapidjson_null,
encode = function(value)
if type(value) == "table"
and (value.body ~= nil or value.err ~= nil or value.errbody ~= nil) then
encoded_subprocess_result = value
return "__subprocess_result__"
end
last_encoded_value = value
return "{}"
end,
decode = function(raw)
if raw == "{}" then return {} end
if raw == "__subprocess_result__" then return encoded_subprocess_result end
if raw == "{\"ok\":true}" then return { ok = true } end
if raw == "{\"value\":null}" then return { value = rapidjson_null } end
if raw == "null" then return rapidjson_null end
return nil, "parse error"
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BookOrbitApi = require("bookorbit_api")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
assertEqual(BookOrbitApi.normalizeServerUrl("https://bookorbit.example.com"),
"https://bookorbit.example.com/api/v1", "origin-only server URL is normalized")
assertEqual(BookOrbitApi.normalizeServerUrl("https://bookorbit.example.com/api/v1/koreader"),
"https://bookorbit.example.com/api/v1", "stock sync URL is normalized")
local decoded, err = BookOrbitApi.decodeResponse({ "" })
assertEqual(type(decoded), "table", "empty body decodes to table")
assertEqual(err, nil, "empty body has no error")
decoded, err = BookOrbitApi.decodeResponse({ "{\"value\":null}" })
assertEqual(decoded.value, nil, "JSON null is scrubbed")
assertEqual(err, nil, "valid JSON has no error")
decoded, err = BookOrbitApi.decodeResponse({ "not-json" })
assertEqual(decoded, nil, "invalid JSON has no decoded body")
assertEqual(err, "invalid_json", "invalid JSON error code")
local client = BookOrbitApi.new{
server_url = "https://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
}
local loopback_client = BookOrbitApi.new{
server_url = "http://localhost:3000/api/v1",
}
assertEqual(loopback_client.server_url, "http://127.0.0.1:3000/api/v1",
"HTTP localhost is made safe before a macOS subprocess fork")
local secure_loopback_client = BookOrbitApi.new{
server_url = "https://localhost:3443/api/v1",
}
assertEqual(secure_loopback_client.server_url, "https://localhost:3443/api/v1",
"HTTPS localhost retains its certificate hostname")
mock_http_body = "{\"ok\":true}"
mock_http_code = 200
local body
body, err = client:auth()
assertEqual(body.ok, true, "request returns decoded body")
assertEqual(err, nil, "valid request has no error")
mock_http_body = "not-json"
mock_http_code = 200
body, err = client:auth()
assertEqual(body, nil, "invalid success body has no decoded body")
assertEqual(err, "invalid_json", "invalid success body returns invalid_json")
mock_http_body = "not-json"
mock_http_code = 503
local errbody
body, err, errbody = client:auth()
assertEqual(body, nil, "HTTP error has no decoded body")
assertEqual(err, 503, "HTTP error preserves status code")
assertEqual(errbody, nil, "invalid HTTP error body is ignored")
mock_http_body = "{\"ok\":true}"
mock_http_code = 200
client:catalogDashboardSection("up-next-in-series")
assertEqual(last_request_url,
"https://bookorbit.example.com/api/v1/koreader/plugin/catalog/dashboard/sections/up-next-in-series",
"the dashboard-section endpoint is addressed by source type")
client:getPluginVersion()
assertEqual(last_request_headers["x-bookorbit-device-id"], nil,
"a legacy client sends no partial self-update identity")
assertEqual(last_request_headers["x-bookorbit-plugin-version"], nil,
"a legacy client sends no partial self-update version")
local partial_identity_client = BookOrbitApi.new{
server_url = "https://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
device_id = "device-id",
}
partial_identity_client:getPluginVersion()
assertEqual(last_request_headers["x-bookorbit-device-id"], nil,
"an incomplete identity sends no device header")
assertEqual(last_request_headers["x-bookorbit-plugin-version"], nil,
"an incomplete identity sends no version header")
local unsafe_identity_client = BookOrbitApi.new{
server_url = "https://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
device_id = "device-id\r\ninjected",
plugin_version = "1.5.4",
}
unsafe_identity_client:getPluginVersion()
assertEqual(last_request_headers["x-bookorbit-device-id"], nil,
"an unsafe device id sends no identity headers")
assertEqual(last_request_headers["x-bookorbit-plugin-version"], nil,
"an unsafe device id sends no version header")
local match_client = BookOrbitApi.new{
server_url = "https://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
device_id = "device-id",
device_model = "Test device",
plugin_version = "1.5.1",
}
local hash_a = string.rep("a", 32)
local hash_b = string.rep("B", 32)
local invalid_hash = "not-an-md5"
local oversized_title = string.rep("t", 501)
local oversized_authors = string.rep("a", 1001)
match_client:getPluginVersion()
assertEqual(last_request_headers["x-bookorbit-device-id"], "device-id",
"the update check identifies the requesting device")
assertEqual(last_request_headers["x-bookorbit-plugin-version"], "1.5.1",
"the update check reports the requesting plugin version")
mock_http_body = "{\"ok\":true}"
mock_http_code = 200
body, err = match_client:matchCheck({ hash_a, invalid_hash, hash_b }, {
[hash_a] = {
title = oversized_title,
authors = oversized_authors,
last_open = -1,
source = "invalid",
metadata_ambiguous = "yes",
},
[hash_b] = {
title = 123,
authors = {},
last_open = "123",
source = "file",
metadata_ambiguous = false,
},
})
assertEqual(body.ok, true, "a match-check with one invalid row still reaches the server")
assertEqual(err, nil, "a sanitized match-check succeeds")
assertEqual(#last_encoded_value.hashes, 2, "an invalid hash is removed without dropping valid peers")
assertEqual(last_encoded_value.hashes[1], hash_a, "the first valid hash keeps its position")
assertEqual(last_encoded_value.hashes[2], hash_b, "the later valid hash closes the filtered gap")
assertEqual(#last_encoded_value.books, 2, "candidate metadata stays aligned with filtered hashes")
assertEqual(#last_encoded_value.books[1].title, 500, "an oversized title is bounded to the server contract")
assertEqual(#last_encoded_value.books[1].authors, 1000, "oversized authors are bounded to the server contract")
assertEqual(last_encoded_value.books[1].lastOpen, nil, "a negative timestamp is omitted")
assertEqual(last_encoded_value.books[1].source, nil, "an unknown source is omitted")
assertEqual(last_encoded_value.books[1].metadataAmbiguous, nil, "a non-boolean ambiguity flag is omitted")
assertEqual(last_encoded_value.books[2].title, nil, "non-string titles are omitted")
assertEqual(last_encoded_value.books[2].authors, nil, "non-string authors are omitted")
assertEqual(last_encoded_value.books[2].lastOpen, 123, "a numeric timestamp string is normalized")
assertEqual(last_encoded_value.books[2].source, "file", "a valid source is preserved")
assertEqual(last_encoded_value.books[2].metadataAmbiguous, false, "a boolean ambiguity flag is preserved")
local requests_before_empty_match = request_count
body, err = match_client:matchCheck({ invalid_hash }, { [invalid_hash] = { title = "Ignored" } })
assertEqual(type(body), "table", "an all-invalid batch resolves locally")
assertEqual(#body.matches, 0, "an all-invalid batch has no matches")
assertEqual(err, nil, "an all-invalid batch is not reported as a network error")
assertEqual(request_count, requests_before_empty_match, "an empty sanitized batch is not sent to the server")
local wrapped = true
local subprocess_calls = 0
local subprocess_result_mode = "normal"
package.loaded["ui/trapper"] = {
isWrapped = function()
return wrapped
end,
dismissableRunInSubprocess = function(_, task, trap_widget, task_returns_simple_string)
assertEqual(type(trap_widget), "table", "background request uses a detached trap widget")
assertEqual(task_returns_simple_string, true, "background request uses a string result envelope")
subprocess_calls = subprocess_calls + 1
in_subprocess = true
local result = task()
in_subprocess = false
if subprocess_result_mode == "missing" then
return true
elseif subprocess_result_mode == "malformed" then
return true, "not-json"
end
return true, result
end,
}
local background_client = BookOrbitApi.new{
server_url = "https://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
background_requests = true,
}
mock_http_body = "{\"ok\":true}"
mock_http_code = 200
body, err = background_client:auth()
assertEqual(body.ok, true, "background request returns decoded body")
assertEqual(err, nil, "background request preserves success result")
assertEqual(subprocess_calls, 1, "wrapped background request uses subprocess")
assertEqual(request_ran_in_subprocess, true, "HTTP request runs inside subprocess task")
local background_identity_client = BookOrbitApi.new{
server_url = "https://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
device_id = "background-device",
plugin_version = "1.5.4",
background_requests = true,
}
body, err = background_identity_client:getPluginVersion()
assertEqual(body ~= nil, true, "background update check has a response body")
assertEqual(err, nil, "background update check succeeds")
assertEqual(subprocess_calls, 2, "background update check uses subprocess")
assertEqual(last_request_headers["x-bookorbit-device-id"], "background-device",
"the subprocess preserves the requesting device header")
assertEqual(last_request_headers["x-bookorbit-plugin-version"], "1.5.4",
"the subprocess preserves the requesting version header")
subprocess_result_mode = "missing"
body, err = background_client:auth()
assertEqual(body, nil, "missing subprocess payload has no response body")
assertEqual(err, "subprocess_no_result", "missing subprocess payload has a specific error")
subprocess_result_mode = "malformed"
body, err = background_client:auth()
assertEqual(body, nil, "malformed subprocess payload has no response body")
assertEqual(err, "subprocess_invalid_result", "malformed subprocess payload has a specific error")
subprocess_result_mode = "normal"
wrapped = false
request_ran_in_subprocess = false
body, err = background_client:auth()
assertEqual(body.ok, true, "unwrapped request falls back safely")
assertEqual(err, nil, "unwrapped request succeeds")
assertEqual(subprocess_calls, 4, "unwrapped request does not start subprocess")
assertEqual(request_ran_in_subprocess, false, "unwrapped fallback runs in current process")
-- Proxy resolution tests
assertEqual(client:getProxy(), nil, "default client has no proxy")
local explicit_proxy_client = BookOrbitApi.new{
server_url = "http://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
proxy = "http://127.0.0.1:8080",
}
assertEqual(explicit_proxy_client:getProxy(), "http://127.0.0.1:8080",
"explicit proxy is returned for HTTP server")
mock_http_body = "{\"ok\":true}"
mock_http_code = 200
explicit_proxy_client:auth()
assertEqual(last_request_proxy, "http://127.0.0.1:8080",
"explicit proxy is passed to request table for HTTP server")
local https_explicit_proxy_client = BookOrbitApi.new{
server_url = "https://bookorbit.example.com/api/v1",
proxy = "http://127.0.0.1:8080",
}
assertEqual(https_explicit_proxy_client:getProxy(), "http://127.0.0.1:8080",
"explicit proxy is returned for HTTPS server")
local mock_http = package.loaded["socket.http"]
mock_http.PROXY = "http://127.0.0.1:8080"
https_explicit_proxy_client:auth()
assertEqual(last_global_proxy, nil, "HTTPS request clears LuaSocket's global proxy")
assertEqual(last_request_proxy, nil, "HTTPS request does not send a proxy URL to LuaSocket")
assertEqual(type(request_history[#request_history].create), "function",
"HTTPS request uses a CONNECT socket")
assertEqual(mock_http.PROXY, "http://127.0.0.1:8080", "HTTPS request restores the global proxy")
mock_http_error = true
local _, transport_error = https_explicit_proxy_client:auth()
assertEqual(transport_error:find("simulated transport failure", 1, true) ~= nil, true,
"HTTPS transport failure is reported")
assertEqual(mock_http.PROXY, "http://127.0.0.1:8080",
"HTTPS transport failure restores the global proxy")
mock_http_error = false
mock_http.PROXY = nil
mock_http.PROXY = "http://127.0.0.1:8080"
mock_http_queue = {
{ code = 302, headers = { location = "https://attacker.example.com/api/v1" } },
}
local before_redirect = #request_history
local _, unsafe_redirect = https_explicit_proxy_client:auth()
assertEqual(unsafe_redirect, "unsafe_redirect", "cross-host API redirect is rejected")
assertEqual(#request_history, before_redirect + 1, "cross-host API redirect sends one request")
assertEqual(request_history[#request_history].redirect, false,
"LuaSocket cannot automatically forward API credentials")
assertEqual(request_history[#request_history].global_proxy, nil,
"HTTPS redirect response cannot use the global HTTP proxy")
mock_http_queue = {
{ code = 302, headers = { location = "/api/v1/redirected" } },
{ code = 200, body = "{\"ok\":true}" },
}
local same_origin_body, same_origin_error = https_explicit_proxy_client:auth()
assertEqual(same_origin_error, nil, "same-origin API redirect succeeds")
assertEqual(same_origin_body.ok, true, "same-origin API redirect decodes the final body")
assertEqual(request_history[#request_history].url,
"https://bookorbit.example.com/api/v1/redirected", "same-origin redirect uses the new URL")
assertEqual(request_history[#request_history].global_proxy, nil,
"same-origin HTTPS redirect stays off the global HTTP proxy")
mock_http_queue = {
{ code = 302, headers = { location = "?refresh=1" } },
{ code = 200, body = "{\"ok\":true}" },
}
https_explicit_proxy_client:auth()
assertEqual(request_history[#request_history].url,
"https://bookorbit.example.com/api/v1/koreader/users/auth?refresh=1",
"query-only redirect preserves the original path")
mock_http_queue = nil
mock_http.PROXY = nil
-- KOReader global settings proxy test
local http_client = BookOrbitApi.new{
server_url = "http://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
}
local mock_settings = {
http_proxy_enabled = true,
http_proxy = "http://192.168.1.1:3128",
}
G_reader_settings = {
isTrue = function(self, key) return mock_settings[key] == true end,
readSetting = function(self, key) return mock_settings[key] end,
}
assertEqual(http_client:getProxy(), "http://192.168.1.1:3128",
"G_reader_settings proxy is used when enabled for HTTP server")
http_client:auth()
assertEqual(last_request_proxy, "http://192.168.1.1:3128",
"G_reader_settings proxy is passed to request table")
assertEqual(last_global_proxy, nil, "HTTP requests do not change LuaSocket's global proxy")
mock_http.PROXY = "http://192.168.1.1:3128"
mock_http_queue = {
{ code = 301, headers = { location = "https://bookorbit.example.com/api/v1/redirected" } },
{ code = 200, body = "{\"ok\":true}" },
}
local upgraded_body, upgraded_error = http_client:auth()
assertEqual(upgraded_error, nil, "same-host HTTPS upgrade succeeds")
assertEqual(upgraded_body.ok, true, "same-host HTTPS upgrade decodes the final body")
assertEqual(request_history[#request_history - 1].proxy, "http://192.168.1.1:3128",
"HTTP upgrade starts through the configured proxy")
assertEqual(request_history[#request_history - 1].global_proxy, "http://192.168.1.1:3128",
"HTTP upgrade leaves KOReader's global proxy intact")
assertEqual(request_history[#request_history].proxy, nil,
"HTTPS upgrade keeps the proxy URL out of LuaSocket's request")
assertEqual(type(request_history[#request_history].create), "function",
"HTTPS upgrade uses a CONNECT socket")
assertEqual(request_history[#request_history].global_proxy, nil,
"HTTPS upgrade clears LuaSocket's global HTTP proxy")
assertEqual(mock_http.PROXY, "http://192.168.1.1:3128",
"HTTPS upgrade restores LuaSocket's global HTTP proxy")
mock_http_queue = nil
mock_http.PROXY = nil
mock_settings.http_proxy_enabled = false
assertEqual(http_client:getProxy(), nil,
"G_reader_settings proxy is ignored when disabled")
G_reader_settings = nil
print("bookorbit_api_test.lua: ok")