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")