Files
urapt/server/aptrepo/aptrepo_test.go
T
owen 981587e83d Initial release: self-hostable APT repository server and CLI
urapt is a self-hostable APT repository server with a companion CLI for
pushing and managing Debian .deb packages.

Server (urapt-server):
- REST API + APT endpoint, SQLite storage (pure-Go modernc driver, no CGO)
- .deb files stored content-addressed on disk, reference-counted for dedup
- Server-managed RSA-4096 OpenPGP signing key (ProtonMail/go-crypto)
- APT indices (Release/InRelease/Packages[.gz/.xz]) generated on demand
  from the DB, cached in memory, signed with the server key
- Full APT model: repositories -> distributions -> components -> architectures
- Bearer-token auth for REST; HTTP Basic auth for private-repo APT reads
- First registrant becomes admin; repo-scoped permissions
  (read/write/read-write/admin) plus owner and server-admin roles
- Multipart package push with control-field extraction, list/show/delete,
  pool serving, blob ref-count cleanup
- Audit log

CLI (urapt):
- register/login/logout/whoami, token management
- repo/distro/component/arch CRUD, member management
- push/pull/ls/show/rm for packages
- apt-config helper that emits apt setup commands (key, sources.list,
  auth.conf for private repos)

Packaging & docs:
- Dockerfile (multi-stage distroless), docker-compose.yml, sample config
- README quick start, architecture overview, config reference, security notes
- PLAN.md design blueprint, CHANGELOG.md, GPL-3.0 LICENSE
- GitHub Actions CI (test, lint, cross-build for linux/darwin amd64/arm64)
- Makefile release target producing static binaries + tarballs + checksums

Tests cover the data-access layer, auth/permission checks, APT index
generation, .deb parsing, GPG signing, the REST API, and the typed API
client. Verified end-to-end on a Raspberry Pi (arm64) pushing and installing
a real package.
2026-06-28 16:57:34 -05:00

196 lines
6.3 KiB
Go

package aptrepo_test
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"urapt/server/aptrepo"
"urapt/server/auth"
"urapt/server/cache"
"urapt/server/restapi"
"urapt/server/store"
"urapt/shared/config"
"urapt/shared/db"
"urapt/shared/gpg"
)
// newServer spins up a REST + APT server backed by a temp DB and store, with a
// pre-created owner token. It returns the server, the owner token, and the
// armored public key.
func newServer(t *testing.T) (*httptest.Server, string, string) {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("db open: %v", err)
}
t.Cleanup(func() { database.Close() })
st := store.New(database)
authSvc := auth.NewService(st)
key, err := gpg.GenerateKey("urapt-test <test>", 2048)
if err != nil {
t.Fatalf("gpg key: %v", err)
}
sp := &testSigner{key: key}
cfg := config.Defaults
cfg.StoreDir = dir
cfg.PackagesDir = filepath.Join(dir, "packages")
cfg.DBPath = filepath.Join(dir, "test.db")
if err := mkdirAll(cfg.PackagesDir); err != nil {
t.Fatalf("mkdir pkg dir: %v", err)
}
idxCache := cache.New()
root := chi.NewRouter()
root.Mount("/api/v1", restapi.New(st, authSvc, sp, &cfg, idxCache))
root.Mount("/apt", aptrepo.New(st, authSvc, key, idxCache))
srv := httptest.NewServer(root)
t.Cleanup(srv.Close)
// Register owner.
body := post(t, srv, "/api/v1/auth/register", "", map[string]string{"username": "owner", "password": "supersecret"})
token := str(body, "token")
pub, err := key.ArmoredPublic()
if err != nil {
t.Fatalf("armored public: %v", err)
}
return srv, token, pub
}
type testSigner struct{ key *gpg.Key }
func (s *testSigner) PublicKeyArmored() (string, error) { return s.key.ArmoredPublic() }
func (s *testSigner) Fingerprint() string { return s.key.Fingerprint }
func post(t *testing.T, srv *httptest.Server, path, token string, body any) []byte {
t.Helper()
b := jsonMarshal(body)
req, _ := http.NewRequest("POST", srv.URL+path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post %s: %v", path, err)
}
defer resp.Body.Close()
return readAll(resp.Body)
}
func get(t *testing.T, srv *httptest.Server, path, token string) (int, []byte) {
t.Helper()
req, _ := http.NewRequest("GET", srv.URL+path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
defer resp.Body.Close()
return resp.StatusCode, readAll(resp.Body)
}
func setupRepo(t *testing.T, srv *httptest.Server, token string) {
post(t, srv, "/api/v1/repositories", token, map[string]any{"name": "myrepo", "visibility": "public"})
post(t, srv, "/api/v1/repositories/myrepo/distributions", token, map[string]string{"name": "stable"})
post(t, srv, "/api/v1/repositories/myrepo/distributions/stable/components", token, map[string]string{"name": "main"})
post(t, srv, "/api/v1/repositories/myrepo/distributions/stable/architectures", token, map[string]string{"name": "amd64"})
}
func TestAPTIndicesAndPool(t *testing.T) {
srv, token, pub := newServer(t)
setupRepo(t, srv, token)
// Push a real .deb via the REST API.
debBytes := buildDeb("foo", "1.0", "amd64")
upload(t, srv, token, "myrepo", "stable", "main", "foo_1.0_amd64.deb", debBytes)
// Fetch the Packages index.
code, body := get(t, srv, "/apt/myrepo/dists/stable/main/binary-amd64/Packages", "")
if code != 200 {
t.Fatalf("Packages status %d body %s", code, body)
}
if !bytes.Contains(body, []byte("Package: foo")) || !bytes.Contains(body, []byte("Filename: pool/main/f/foo/foo_1.0_amd64.deb")) {
t.Fatalf("Packages index missing entry:\n%s", body)
}
// Fetch Release.
code, rel := get(t, srv, "/apt/myrepo/dists/stable/Release", "")
if code != 200 {
t.Fatalf("Release status %d", code)
}
if !bytes.Contains(rel, []byte("Suite: stable")) || !bytes.Contains(rel, []byte("Components: main")) {
t.Fatalf("Release missing fields:\n%s", rel)
}
// Fetch InRelease (clearsigned) and verify against the public key.
code, inrel := get(t, srv, "/apt/myrepo/dists/stable/InRelease", "")
if code != 200 {
t.Fatalf("InRelease status %d", code)
}
if !bytes.Contains(inrel, []byte("BEGIN PGP SIGNED MESSAGE")) {
t.Fatalf("InRelease not clearsigned:\n%s", inrel)
}
if _, err := gpg.VerifyClearSign(pub, inrel); err != nil {
t.Fatalf("verify InRelease: %v", err)
}
// Fetch Release.gpg and verify as a detached signature of Release.
code, sig := get(t, srv, "/apt/myrepo/dists/stable/Release.gpg", "")
if code != 200 {
t.Fatalf("Release.gpg status %d", code)
}
if err := gpg.VerifyDetached(pub, rel, sig); err != nil {
t.Fatalf("verify Release.gpg: %v", err)
}
// Fetch the pool .deb and compare bytes.
code, deb := get(t, srv, "/apt/myrepo/pool/main/f/foo/foo_1.0_amd64.deb", "")
if code != 200 {
t.Fatalf("pool status %d", code)
}
if !bytes.Equal(deb, debBytes) {
t.Fatalf("pool bytes mismatch (%d vs %d)", len(deb), len(debBytes))
}
}
func TestPrivateRepoRequiresAuth(t *testing.T) {
srv, token, _ := newServer(t)
// private repo
post(t, srv, "/api/v1/repositories", token, map[string]any{"name": "priv", "visibility": "private"})
post(t, srv, "/api/v1/repositories/priv/distributions", token, map[string]string{"name": "stable"})
post(t, srv, "/api/v1/repositories/priv/distributions/stable/components", token, map[string]string{"name": "main"})
post(t, srv, "/api/v1/repositories/priv/distributions/stable/architectures", token, map[string]string{"name": "amd64"})
upload(t, srv, token, "priv", "stable", "main", "foo_1.0_amd64.deb", buildDeb("foo", "1.0", "amd64"))
// anonymous → 401
code, _ := get(t, srv, "/apt/priv/dists/stable/Release", "")
if code != 401 {
t.Fatalf("anonymous private repo should be 401, got %d", code)
}
// with token as basic auth password → 200
req, _ := http.NewRequest("GET", srv.URL+"/apt/priv/dists/stable/Release", nil)
req.SetBasicAuth("owner", token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("authenticated private repo should be 200, got %d", resp.StatusCode)
}
}
// keep context import used
var _ = context.Background