Files
bookorbit/koreader-plugin/spec/bookorbit_api_test.lua
T
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
2026-10-20 16:05:27 -06:00

521 lines
21 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")
assertEqual(BookOrbitApi.normalizeServerUrl(" http://127.0.0.1:6262/api/v1/ "),
"http://127.0.0.1:6262/api/v1", "local server URL trims whitespace and trailing slash")
assertEqual(BookOrbitApi.normalizeServerUrl("https://[::1]:443/library"),
"https://[::1]:443/library/api/v1", "IPv6 and deployment subpath remain supported")
for _, value in ipairs({ "not a url", "https://", "ftp://bookorbit.example.com", "http://host:0",
"http://host:65536", "https://user:secret@host", "https://host?token=test", "https://host#part" }) do
assertEqual(BookOrbitApi.normalizeServerUrl(value), nil, "malformed server URL is rejected")
end
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 filtered_client = BookOrbitApi.new{ server_url = "https://bookorbit.example.com" }
local captured_path
filtered_client.request = function(_, method, path) captured_path = path end
local local_query = { ids = "1,2", sort = "title", on_device_filter = true }
filtered_client:catalogBooks(local_query)
assertEqual(captured_path:find("on_device_filter", 1, true), nil, "local device context never reaches server validation")
assertEqual(captured_path:find("ids=1,2", 1, true) ~= nil, true, "device IDs remain in request")
assertEqual(local_query.on_device_filter, true, "request does not mutate local context")
filtered_client:catalogManifest(local_query)
assertEqual(captured_path:find("on_device_filter", 1, true), nil, "bulk manifest also excludes local device context")
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")