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