Files
urapt/server/store/packages_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

367 lines
11 KiB
Go

package store
import (
"context"
"errors"
"testing"
"urapt/shared/models"
)
// newPackage creates a minimal valid Package row ready to insert.
func newPackage(repoID, distroID, compID, userID string) *models.Package {
return &models.Package{
RepositoryID: repoID,
DistributionID: distroID,
ComponentID: compID,
Name: "myapp-hello",
Version: "1.0.0",
Architecture: "amd64",
Maintainer: "Test <test@example.com>",
Description: "a test package",
RawControl: "Package: myapp-hello\nVersion: 1.0.0\n",
Filename: "myapp-hello_1.0.0_amd64.deb",
PoolPath: "pool/main/m/myapp-hello/myapp-hello_1.0.0_amd64.deb",
Size: 712,
MD5sum: "d41d8cd98f00b204e9800998ecf8427e",
SHA1: "da39a3ee5e6b4b0d3255bfef95601890afd80709",
SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
UploadedByUserID: userID,
}
}
func TestCreatePackageAndGetByID(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
d := s.createDistro(t, ctx, repo.ID, "stable")
c := s.createComponent(t, ctx, d.ID, "main")
_ = c
p := newPackage(repo.ID, d.ID, c.ID, u.ID)
if err := s.CreatePackage(ctx, p); err != nil {
t.Fatalf("create: %v", err)
}
if p.ID == "" || p.CreatedAt == "" {
t.Fatal("id/created_at should be populated by CreatePackage")
}
got, err := s.GetPackageByID(ctx, p.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got.Name != "myapp-hello" || got.Version != "1.0.0" || got.Architecture != "amd64" {
t.Fatalf("got = %+v", got)
}
if got.SHA256 != p.SHA256 {
t.Fatalf("sha256 mismatch")
}
}
func TestGetPackageByID_NotFound(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
if _, err := s.GetPackageByID(ctx, "nope"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestGetPackageByPoolPath(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
d := s.createDistro(t, ctx, repo.ID, "stable")
c := s.createComponent(t, ctx, d.ID, "main")
p := newPackage(repo.ID, d.ID, c.ID, u.ID)
_ = s.CreatePackage(ctx, p)
got, err := s.GetPackageByPoolPath(ctx, repo.ID, p.PoolPath)
if err != nil {
t.Fatalf("get by pool path: %v", err)
}
if got.ID != p.ID {
t.Fatal("id mismatch")
}
// Wrong repo.
if _, err := s.GetPackageByPoolPath(ctx, "other-repo", p.PoolPath); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound for wrong repo, got %v", err)
}
}
func TestListPackages_FiltersAndPagination(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
d := s.createDistro(t, ctx, repo.ID, "stable")
main := s.createComponent(t, ctx, d.ID, "main")
contrib := s.createComponent(t, ctx, d.ID, "contrib")
// Insert 5 packages: 3 in main, 2 in contrib; mix of amd64 and arm64.
pkgs := []*models.Package{
newPackage(repo.ID, d.ID, main.ID, u.ID),
newPackage(repo.ID, d.ID, main.ID, u.ID),
newPackage(repo.ID, d.ID, main.ID, u.ID),
newPackage(repo.ID, d.ID, contrib.ID, u.ID),
newPackage(repo.ID, d.ID, contrib.ID, u.ID),
}
names := []string{"alpha", "beta", "gamma", "delta", "epsilon"}
arches := []string{"amd64", "amd64", "arm64", "amd64", "arm64"}
for i, p := range pkgs {
p.Name = names[i]
p.Architecture = arches[i]
p.PoolPath = "pool/main/m/" + names[i] + "/" + names[i] + "_1.0.0_" + arches[i] + ".deb"
p.Filename = names[i] + "_1.0.0_" + arches[i] + ".deb"
if err := s.CreatePackage(ctx, p); err != nil {
t.Fatalf("create %d: %v", i, err)
}
}
// All.
list, total, err := s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 1, 100)
if err != nil {
t.Fatalf("list all: %v", err)
}
if total != 5 || len(list) != 5 {
t.Fatalf("expected total=5 len=5, got total=%d len=%d", total, len(list))
}
// Filter by component.
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{ComponentID: main.ID}, 1, 100)
if total != 3 || len(list) != 3 {
t.Fatalf("main filter: expected 3, got total=%d len=%d", total, len(list))
}
// Filter by arch (matches arch OR 'all').
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Arch: "amd64"}, 1, 100)
if total != 3 { // alpha, beta, delta
t.Fatalf("amd64 filter: expected 3, got %d", total)
}
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Arch: "arm64"}, 1, 100)
if total != 2 { // gamma, epsilon
t.Fatalf("arm64 filter: expected 2, got %d", total)
}
// Filter by exact name.
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Name: "beta"}, 1, 100)
if total != 1 || len(list) != 1 || list[0].Name != "beta" {
t.Fatalf("name filter: total=%d list=%v", total, list)
}
// Query (LIKE on name/description).
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Query: "a test"}, 1, 100)
if total != 5 {
t.Fatalf("query filter: expected all 5 to match description, got %d", total)
}
// Pagination: page 1, perPage 2 -> 2 items, total 5.
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 1, 2)
if total != 5 || len(list) != 2 {
t.Fatalf("page 1: total=%d len=%d", total, len(list))
}
// page 3 -> only 1 item.
list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 3, 2)
if len(list) != 1 {
t.Fatalf("page 3: expected 1 item, got %d", len(list))
}
_ = list
}
func TestDeletePackage(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
d := s.createDistro(t, ctx, repo.ID, "stable")
c := s.createComponent(t, ctx, d.ID, "main")
p := newPackage(repo.ID, d.ID, c.ID, u.ID)
_ = s.CreatePackage(ctx, p)
if err := s.DeletePackage(ctx, p.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := s.GetPackageByID(ctx, p.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound after delete, got %v", err)
}
}
func TestDeletePackagesByRepo(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
d := s.createDistro(t, ctx, repo.ID, "stable")
c := s.createComponent(t, ctx, d.ID, "main")
_ = s.CreatePackage(ctx, newPackage(repo.ID, d.ID, c.ID, u.ID))
_ = s.CreatePackage(ctx, newPackage(repo.ID, d.ID, c.ID, u.ID))
if err := s.DeletePackagesByRepo(ctx, repo.ID); err != nil {
t.Fatalf("delete by repo: %v", err)
}
list, total, _ := s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 1, 100)
if total != 0 || len(list) != 0 {
t.Fatalf("expected no packages after DeletePackagesByRepo, got total=%d", total)
}
}
func TestListSuitePackages(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
d := s.createDistro(t, ctx, repo.ID, "stable")
c := s.createComponent(t, ctx, d.ID, "main")
p := newPackage(repo.ID, d.ID, c.ID, u.ID)
_ = s.CreatePackage(ctx, p)
suite, err := s.ListSuitePackages(ctx, repo.ID, d.ID)
if err != nil {
t.Fatalf("list suite: %v", err)
}
if len(suite) != 1 {
t.Fatalf("expected 1 suite package, got %d", len(suite))
}
if suite[0].ComponentName != "main" || suite[0].DistributionName != "stable" {
t.Fatalf("component=%q distro=%q", suite[0].ComponentName, suite[0].DistributionName)
}
if suite[0].Name != "myapp-hello" {
t.Fatalf("name=%q", suite[0].Name)
}
}
func TestListPackageBlobSHA256sByRepo(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic)
d := s.createDistro(t, ctx, repo.ID, "stable")
c := s.createComponent(t, ctx, d.ID, "main")
p1 := newPackage(repo.ID, d.ID, c.ID, u.ID)
p1.Name = "alpha"
p1.SHA256 = "aaa"
p1.PoolPath = "pool/main/m/alpha/alpha.deb"
p1.Filename = "alpha.deb"
_ = s.CreatePackage(ctx, p1)
p2 := newPackage(repo.ID, d.ID, c.ID, u.ID)
p2.Name = "beta"
p2.SHA256 = "bbb"
p2.PoolPath = "pool/main/m/beta/beta.deb"
p2.Filename = "beta.deb"
_ = s.CreatePackage(ctx, p2)
shas, err := s.ListPackageBlobSHA256sByRepo(ctx, repo.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(shas) != 2 {
t.Fatalf("expected 2 shas, got %d", len(shas))
}
}
// --- blobs ---
func TestCreateBlob_NewAndExisting(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
created, err := s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
if err != nil {
t.Fatalf("create: %v", err)
}
if !created {
t.Fatal("first CreateBlob should report created=true")
}
// Second time, same sha -> not created, no error.
created, err = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
if err != nil {
t.Fatalf("second create: %v", err)
}
if created {
t.Fatal("second CreateBlob should report created=false (already exists)")
}
}
func TestGetBlob(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
_, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
filename, size, refCount, err := s.GetBlob(ctx, "sha-aaa")
if err != nil {
t.Fatalf("get: %v", err)
}
if filename != "hello.deb" || size != 712 || refCount != 1 {
t.Fatalf("got filename=%q size=%d ref=%d", filename, size, refCount)
}
if _, _, _, err := s.GetBlob(ctx, "missing"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestIncBlobRef(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
_, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
rc, err := s.IncBlobRef(ctx, "sha-aaa")
if err != nil {
t.Fatalf("inc: %v", err)
}
if rc != 2 {
t.Fatalf("expected ref=2 after inc, got %d", rc)
}
rc, _ = s.IncBlobRef(ctx, "sha-aaa")
if rc != 3 {
t.Fatalf("expected ref=3, got %d", rc)
}
// Inc on missing blob.
if _, err := s.IncBlobRef(ctx, "missing"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestDecBlobRef(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
_, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
_, _ = s.IncBlobRef(ctx, "sha-aaa") // ref=2
rc, err := s.DecBlobRef(ctx, "sha-aaa")
if err != nil {
t.Fatalf("dec: %v", err)
}
if rc != 1 {
t.Fatalf("expected ref=1 after dec, got %d", rc)
}
rc, _ = s.DecBlobRef(ctx, "sha-aaa")
if rc != 0 {
t.Fatalf("expected ref=0, got %d", rc)
}
// Dec below 0 should not go negative; ref_count > 0 guard.
rc, _ = s.DecBlobRef(ctx, "sha-aaa")
if rc != 0 {
t.Fatalf("expected ref to stay at 0, got %d", rc)
}
// Dec on missing blob.
if _, err := s.DecBlobRef(ctx, "totally-missing"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound for missing, got %v", err)
}
}
func TestDeleteBlob(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
_, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712)
if err := s.DeleteBlob(ctx, "sha-aaa"); err != nil {
t.Fatalf("delete: %v", err)
}
if _, _, _, err := s.GetBlob(ctx, "sha-aaa"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound after delete, got %v", err)
}
}