mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
0d8648feb2
Evaluate identified KOReader clients independently so a stale sibling device does not suppress safe updates. Keep the conservative account-wide gate for legacy or unusable client identities. Fixes #1519
350 lines
13 KiB
Lua
350 lines
13 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 request_count = 0
|
|
package.loaded["socket.http"] = {
|
|
request = function(request)
|
|
request_count = request_count + 1
|
|
request_ran_in_subprocess = in_subprocess
|
|
last_request_url = request.url
|
|
last_request_headers = request.headers
|
|
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(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(subprocess_calls, 4, "unwrapped request does not start subprocess")
|
|
assertEqual(request_ran_in_subprocess, false, "unwrapped fallback runs in current process")
|
|
|
|
print("bookorbit_api_test.lua: ok")
|