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