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.
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user