221 lines
12 KiB
Lua
221 lines
12 KiB
Lua
-- The MIT License (MIT)
|
|
--
|
|
-- Copyright (C) 2026 Scott Duensing
|
|
--
|
|
-- Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
-- of this software and associated documentation files (the "Software"), to
|
|
-- deal in the Software without restriction, including without limitation the
|
|
-- rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
-- sell copies of the Software, and to permit persons to whom the Software is
|
|
-- furnished to do so, subject to the following conditions:
|
|
--
|
|
-- The above copyright notice and this permission notice shall be included in
|
|
-- all copies or substantial portions of the Software.
|
|
--
|
|
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
-- IN THE SOFTWARE.
|
|
|
|
-- testPackages.lua -- the package server end to end: uploads checked by the package tool and
|
|
-- refused for the right reasons, review with the repository's signature (made here, as on the
|
|
-- operator's own computer), the public catalogue, downloads (resumed) that check against the
|
|
-- signature, updates, withdrawing, an account's own packages, and a slow client not holding up
|
|
-- the others.
|
|
--
|
|
-- calog test/testPackages.lua -- <base URL> <data folder> <dvxpkg> <admin token>
|
|
|
|
local BASE = calogArgs()[1]
|
|
local DATA = calogArgs()[2]
|
|
local DVXPKG = calogArgs()[3]
|
|
local ADMIN = calogArgs()[4]
|
|
local DB = DATA .. "/server/dvx.db"
|
|
|
|
local pass, fail = 0, 0
|
|
local function check(name, got, want)
|
|
if got == want then pass = pass + 1 else fail = fail + 1
|
|
calogPrint("FAIL", name, "got", tostring(got), "want", tostring(want)) end
|
|
end
|
|
|
|
local function call(method, path, body, token, raw, headers)
|
|
headers = headers or {}
|
|
headers["Content-Type"] = raw and "application/octet-stream" or "application/json"
|
|
if token then headers["Authorization"] = "Bearer " .. token end
|
|
local r = httpRequest({ method = method, url = BASE .. path, headers = headers,
|
|
body = raw and body or (body and jsonStringify(body) or nil) })
|
|
local parsed = {}
|
|
if r.body and r.body ~= "" then
|
|
local ok, value = pcall(jsonParse, r.body)
|
|
if ok and type(value) == "table" then parsed = value end
|
|
end
|
|
return r.status, parsed, r
|
|
end
|
|
|
|
ACCOUNT_LOADED = true
|
|
local accounts = load(fsRead("test/account.lua"), "@test/account.lua")()
|
|
|
|
local function account(tag)
|
|
local mailbox = tag .. "-" .. cryptoHexEncode(cryptoRandomBytes(4)) .. "@example.com"
|
|
local token, mail = accounts.make(BASE, DB, mailbox, "a good long password")
|
|
check(tag .. ": the mail names DVX", tostring(mail):find("DVX account", 1, true) ~= nil, true)
|
|
return token, mailbox
|
|
end
|
|
|
|
local function dpk(name)
|
|
return fsRead(DATA .. "/" .. name .. ".dpk")
|
|
end
|
|
|
|
-- The repository's signature of a package's bytes, as the operator makes it on their own computer
|
|
local function repoSign(bytes)
|
|
local path = DATA .. "/review.dpk"
|
|
fsWrite(path, bytes)
|
|
local r = procRun({ DVXPKG, "sign", path, DATA .. "/REPO.SEC", "-d" })
|
|
check("the operator's tool signs", r.exit, 0)
|
|
return fsRead(DATA .. "/review.SIG")
|
|
end
|
|
|
|
|
|
-- The service
|
|
local status, health = call("GET", "/v1/health")
|
|
check("health", status, 200)
|
|
check("health names the service", health.service, "dvx-packages")
|
|
|
|
-- Uploads: a session, a package, and the tool's word on it
|
|
local uploader, uploaderMail = account("up")
|
|
local stranger = account("other")
|
|
check("an upload needs a session", (call("POST", "/v1/upload", dpk("cards"), nil, true)), 401)
|
|
local junkStatus, junk = call("POST", "/v1/upload", dpk("junk"), uploader, true)
|
|
check("something that is no package is refused", junkStatus, 400)
|
|
check("with the tool's reason", junk.error, "badPackage")
|
|
check("an empty upload is refused", (call("POST", "/v1/upload", "", uploader, true)), 400)
|
|
|
|
local upStatus, up = call("POST", "/v1/upload", dpk("cards"), uploader, true)
|
|
check("a package is taken in", upStatus, 202)
|
|
check("as acme.cards", up.id, "acme.cards")
|
|
check("its version", up.version, "1.0.0")
|
|
check("pending review", up.state, "pending")
|
|
check("the same version again is refused", (call("POST", "/v1/upload", dpk("cards"), uploader, true)), 409)
|
|
check("another account cannot add to it", (call("POST", "/v1/upload", dpk("cards2"), stranger, true)), 403)
|
|
local ownStatus, own = call("POST", "/v1/upload", dpk("dvxown"), uploader, true)
|
|
check("DVX's own packages are the operator's", ownStatus, 403)
|
|
check("and it says so", tostring(own.message):find("operator", 1, true) ~= nil, true)
|
|
check("the operator may upload one", (call("POST", "/v1/upload", dpk("dvxown"), ADMIN, true)), 202)
|
|
check("the uploader's second version", (call("POST", "/v1/upload", dpk("cards2"), uploader, true)), 202)
|
|
check("a package that needs another", (call("POST", "/v1/upload", dpk("chess"), uploader, true)), 202)
|
|
|
|
-- Nothing is listed before review
|
|
local _, cat = call("GET", "/v1/catalogue")
|
|
check("nothing listed before review", cat.count, 0)
|
|
local _, mine = call("GET", "/v1/mine", nil, uploader)
|
|
check("the uploader sees their versions", #(mine.versions or {}), 3)
|
|
check("pending", mine.versions and mine.versions[1].state, "pending")
|
|
check("mine needs a session", (call("GET", "/v1/mine")), 401)
|
|
|
|
-- Review: the operator, the bytes, the repository's signature
|
|
check("pending is the operator's", (call("GET", "/v1/admin/pending", nil, uploader)), 401)
|
|
local _, pend = call("GET", "/v1/admin/pending", nil, ADMIN)
|
|
check("four waiting", pend.count, 4)
|
|
local dlStatus, _, dl = call("GET", "/v1/admin/download/acme.cards/1.0.0", nil, ADMIN)
|
|
check("the operator downloads a pending version", dlStatus, 200)
|
|
check("its bytes are the upload's", dl.body == dpk("cards"), true)
|
|
check("a pending version is not public", (call("GET", "/v1/download/acme.cards/1.0.0")), 404)
|
|
|
|
local good = repoSign(dl.body)
|
|
check("approving needs a signature", (call("POST", "/v1/admin/version/state", { package = "acme.cards", version = "1.0.0", state = "approved" }, ADMIN)), 400)
|
|
-- A signature by another key (the publisher's) is not the repository's
|
|
fsWrite(DATA .. "/review.dpk", dl.body)
|
|
procRun({ DVXPKG, "sign", DATA .. "/review.dpk", DATA .. "/ACME.SEC", "-d" })
|
|
local badSigStatus, badSig = call("POST", "/v1/admin/version/state",
|
|
{ package = "acme.cards", version = "1.0.0", state = "approved", signature = fsRead(DATA .. "/review.SIG") }, ADMIN)
|
|
check("another key's signature is refused", badSigStatus, 400)
|
|
check("as not the repository's", badSig.error, "badSignature")
|
|
check("approved with the repository's", (call("POST", "/v1/admin/version/state",
|
|
{ package = "acme.cards", version = "1.0.0", state = "approved", signature = good }, ADMIN)), 200)
|
|
check("rejected with a note", (call("POST", "/v1/admin/version/state",
|
|
{ package = "acme.cards", version = "1.1.0", state = "rejected", note = "needs work" }, ADMIN)), 200)
|
|
local _, _, chessDl = call("GET", "/v1/admin/download/acme.chess/2.0.0", nil, ADMIN)
|
|
check("chess approved too", (call("POST", "/v1/admin/version/state",
|
|
{ package = "acme.chess", version = "2.0", state = "approved", signature = repoSign(chessDl.body) }, ADMIN)), 200)
|
|
check("no such version", (call("POST", "/v1/admin/version/state", { package = "acme.none", version = "1.0", state = "rejected" }, ADMIN)), 404)
|
|
|
|
-- The public catalogue
|
|
_, cat = call("GET", "/v1/catalogue")
|
|
check("two packages listed", cat.count, 2)
|
|
local cards = cat.packages and cat.packages[1] or {}
|
|
local chess = cat.packages and cat.packages[2] or {}
|
|
check("cards first", cards.id, "acme.cards")
|
|
check("at the approved version", cards.version, "1.0.0")
|
|
check("its summary, ; and all", cards.summary, "Card games; for everyone")
|
|
check("its kind", cards.kind, "library")
|
|
check("its publisher's key", cards.signer ~= nil, true)
|
|
check("signed by the repository", cards.signed, true)
|
|
check("its digest", cards.sha256, cryptoHashSha256(dpk("cards")))
|
|
check("chess needs cards", chess.depends and chess.depends[1] and chess.depends[1].id, "acme.cards")
|
|
check("from 1.0.0", chess.depends and chess.depends[1] and chess.depends[1].min, "1.0.0")
|
|
local _, versions = call("GET", "/v1/package/acme.cards")
|
|
check("its versions: only the approved", #(versions.versions or {}), 1)
|
|
check("an unknown package", (call("GET", "/v1/package/acme.none")), 404)
|
|
_, mine = call("GET", "/v1/mine", nil, uploader)
|
|
local noted = false
|
|
for _, v in ipairs(mine.versions or {}) do
|
|
noted = noted or (v.version == "1.1.0" and v.state == "rejected" and v.note == "needs work")
|
|
end
|
|
check("the uploader sees the rejection and its note", noted, true)
|
|
|
|
-- Downloads: whole, resumed, and checked against the repository's signature
|
|
local getStatus, _, got = call("GET", "/v1/download/acme.cards/1.0.0")
|
|
check("a download", getStatus, 200)
|
|
check("the bytes", got.body == dpk("cards"), true)
|
|
check("their digest in a header", got.headers["x-content-sha256"], cryptoHashSha256(dpk("cards")))
|
|
local partStatus, _, part = call("GET", "/v1/download/acme.cards/1.0.0", nil, nil, false, { Range = "bytes=10-" })
|
|
check("a resumed download", partStatus, 206)
|
|
check("the rest of it", part.body == dpk("cards"):sub(11), true)
|
|
local sigStatus, _, sig = call("GET", "/v1/signature/acme.cards/1.0.0")
|
|
check("its signature", sigStatus, 200)
|
|
fsWrite(DATA .. "/fetched.dpk", got.body)
|
|
fsWrite(DATA .. "/fetched.SIG", sig.body)
|
|
check("DVX would trust it", procRun({ DVXPKG, "verify", DATA .. "/fetched.dpk", "-s", DATA .. "/fetched.SIG", DATA .. "/REPO.KEY" }).exit, 0)
|
|
check("no signature for a rejected version", (call("GET", "/v1/signature/acme.cards/1.1.0")), 404)
|
|
check("a bad download path", (call("GET", "/v1/download/acme.cards")), 404)
|
|
check("a version as written: 2.0 is 2.0.0", (call("GET", "/v1/download/acme.chess/2.0")), 200)
|
|
check("a version that is no version", (call("GET", "/v1/download/acme.chess/2..0")), 404)
|
|
|
|
-- Updates: what is newer than what a DVX has
|
|
local _, upd = call("POST", "/v1/updates", { dvx = "1.0.0", abi = 10, installed = { { id = "acme.cards", version = "0.9" },
|
|
{ id = "acme.chess", version = "2.0.0" }, { id = "acme.gone", version = "1.0" } } })
|
|
check("one update", #(upd.updates or {}), 1)
|
|
check("for cards", upd.updates and upd.updates[1] and upd.updates[1].id, "acme.cards")
|
|
check("one unknown", upd.unknown and upd.unknown[1], "acme.gone")
|
|
check("a list is needed", (call("POST", "/v1/updates", { installed = "x" })), 400)
|
|
|
|
-- Withdrawn: not listed, not handed out; then back
|
|
check("withdrawn", (call("POST", "/v1/admin/package/state", { package = "acme.chess", state = "withdrawn" }, ADMIN)), 200)
|
|
_, cat = call("GET", "/v1/catalogue")
|
|
check("one listed", cat.count, 1)
|
|
check("not handed out", (call("GET", "/v1/download/acme.chess/2.0.0")), 404)
|
|
check("published again", (call("POST", "/v1/admin/package/state", { package = "acme.chess", state = "published" }, ADMIN)), 200)
|
|
check("no such package", (call("POST", "/v1/admin/package/state", { package = "acme.none", state = "withdrawn" }, ADMIN)), 404)
|
|
|
|
-- An administrator is an account holding the admin key
|
|
local _, me = call("GET", "/v1/account", nil, uploader)
|
|
check("the account", me.email, uploaderMail)
|
|
check("granted admin", (call("POST", "/v1/admin/key/grant", { account = uploaderMail, key = "admin" }, ADMIN)), 200)
|
|
check("an administrator sees what is pending", (call("GET", "/v1/admin/pending", nil, uploader)), 200)
|
|
local _, logRows = call("GET", "/v1/admin/log?event=version.", nil, ADMIN)
|
|
check("the log has the reviews", (logRows.count or 0) >= 3, true)
|
|
|
|
-- A slow client holds up only its own context: a request that never ends its headers, and a
|
|
-- second request answered meanwhile
|
|
local host, port = BASE:match("^http://([^:/]+):(%d+)")
|
|
local slow = tcpConnect(host, tonumber(port))
|
|
tcpSend(slow, "GET /v1/catalogue HTTP/1.1\r\nHost: x\r\n")
|
|
local started = timeMonotonic()
|
|
check("answered beside a slow client", (call("GET", "/v1/health")), 200)
|
|
check("at once", timeMonotonic() - started < 2, true)
|
|
tcpClose(slow)
|
|
|
|
calogPrint(string.format("%d passed, %d failed", pass, fail))
|
|
calogExit(fail == 0 and 0 or 1)
|