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:
2026-06-28 16:57:34 -05:00
commit 981587e83d
83 changed files with 12812 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
package store
import (
"context"
"errors"
"testing"
"urapt/shared/models"
)
func TestCreateRepository(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic)
if r.Name != "myrepo" || r.OwnerUserID != u.ID || r.Visibility != models.VisibilityPublic {
t.Fatalf("repo = %+v", r)
}
if r.ID == "" || r.CreatedAt == "" {
t.Fatal("id/created_at should be set")
}
}
func TestGetRepositoryByNameAndID(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic)
byName, err := s.GetRepositoryByName(ctx, "myrepo")
if err != nil {
t.Fatalf("get by name: %v", err)
}
if byName.ID != r.ID {
t.Fatalf("id mismatch")
}
byID, err := s.GetRepositoryByID(ctx, r.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if byID.Name != "myrepo" {
t.Fatalf("name = %q", byID.Name)
}
if _, err := s.GetRepositoryByName(ctx, "missing"); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestListReposVisible(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
alice := s.createUser(t, ctx, "alice", "p")
bob := s.createUser(t, ctx, "bob", "p")
carol := s.createUser(t, ctx, "carol", "p")
// alice owns: pub-own, priv-own
pubOwn := s.createRepo(t, ctx, "pub-own", alice.ID, models.VisibilityPublic)
privOwn := s.createRepo(t, ctx, "priv-own", alice.ID, models.VisibilityPrivate)
// bob owns: pub-bob, priv-bob, and grants carol read on priv-bob
pubBob := s.createRepo(t, ctx, "pub-bob", bob.ID, models.VisibilityPublic)
privBob := s.createRepo(t, ctx, "priv-bob", bob.ID, models.VisibilityPrivate)
_ = pubOwn
_ = privOwn
_ = pubBob
if err := s.AddMember(ctx, privBob.ID, carol.ID, models.AccessRead); err != nil {
t.Fatalf("add member: %v", err)
}
// Alice sees: her two + bob's public. NOT bob's private.
aliceRepos, err := s.ListReposVisible(ctx, alice.ID)
if err != nil {
t.Fatalf("alice list: %v", err)
}
if len(aliceRepos) != 3 {
t.Fatalf("alice should see 3 repos, got %d", len(aliceRepos))
}
// Carol sees: pub-own, pub-bob (both public) + priv-bob (member). NOT priv-own.
carolRepos, err := s.ListReposVisible(ctx, carol.ID)
if err != nil {
t.Fatalf("carol list: %v", err)
}
if len(carolRepos) != 3 {
t.Fatalf("carol should see 3 repos, got %d", len(carolRepos))
}
// Verify priv-bob is among carol's repos.
foundPrivBob := false
for _, r := range carolRepos {
if r.ID == privBob.ID {
foundPrivBob = true
}
if r.ID == privOwn.ID {
t.Fatal("carol should not see alice's private repo")
}
}
if !foundPrivBob {
t.Fatal("carol should see priv-bob as a member")
}
}
func TestUpdateRepository(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic)
// Update name only.
if err := s.UpdateRepository(ctx, r.ID, "newname", nil, nil); err != nil {
t.Fatalf("update name: %v", err)
}
got, _ := s.GetRepositoryByID(ctx, r.ID)
if got.Name != "newname" || got.Visibility != models.VisibilityPublic {
t.Fatalf("name=%q vis=%s", got.Name, got.Visibility)
}
// Update visibility only.
priv := models.VisibilityPrivate
_ = s.UpdateRepository(ctx, r.ID, "", &priv, nil)
got, _ = s.GetRepositoryByID(ctx, r.ID)
if got.Visibility != models.VisibilityPrivate || got.Name != "newname" {
t.Fatalf("vis=%s name=%q", got.Visibility, got.Name)
}
// Update description only.
desc := "a repo"
_ = s.UpdateRepository(ctx, r.ID, "", nil, &desc)
got, _ = s.GetRepositoryByID(ctx, r.ID)
if got.Description != "a repo" {
t.Fatalf("desc=%q", got.Description)
}
}
func TestDeleteRepository(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
u := s.createUser(t, ctx, "alice", "p")
r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic)
if err := s.DeleteRepository(ctx, r.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := s.GetRepositoryByID(ctx, r.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound after delete, got %v", err)
}
}
// --- members ---
func TestAddMember_Upsert(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
alice := s.createUser(t, ctx, "alice", "p")
bob := s.createUser(t, ctx, "bob", "p")
repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate)
// Initial grant: read.
if err := s.AddMember(ctx, repo.ID, bob.ID, models.AccessRead); err != nil {
t.Fatalf("add: %v", err)
}
access, ok, err := s.GetMemberAccess(ctx, repo.ID, bob.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if !ok || access != models.AccessRead {
t.Fatalf("expected read grant, got ok=%v access=%s", ok, access)
}
// Upsert to write.
if err := s.AddMember(ctx, repo.ID, bob.ID, models.AccessWrite); err != nil {
t.Fatalf("upsert: %v", err)
}
access, ok, _ = s.GetMemberAccess(ctx, repo.ID, bob.ID)
if !ok || access != models.AccessWrite {
t.Fatalf("expected write after upsert, got %s", access)
}
}
func TestGetMemberAccess_NonMember(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
alice := s.createUser(t, ctx, "alice", "p")
bob := s.createUser(t, ctx, "bob", "p")
repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate)
_, ok, err := s.GetMemberAccess(ctx, repo.ID, bob.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if ok {
t.Fatal("non-member should return ok=false")
}
}
func TestUpdateMemberAccess_NotFound(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
alice := s.createUser(t, ctx, "alice", "p")
bob := s.createUser(t, ctx, "bob", "p")
repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate)
if err := s.UpdateMemberAccess(ctx, repo.ID, bob.ID, models.AccessRead); !errors.Is(err, ErrNotFound) {
t.Fatalf("update non-member should be ErrNotFound, got %v", err)
}
}
func TestRemoveMember(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
alice := s.createUser(t, ctx, "alice", "p")
bob := s.createUser(t, ctx, "bob", "p")
repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate)
_ = s.AddMember(ctx, repo.ID, bob.ID, models.AccessRead)
if err := s.RemoveMember(ctx, repo.ID, bob.ID); err != nil {
t.Fatalf("remove: %v", err)
}
_, ok, _ := s.GetMemberAccess(ctx, repo.ID, bob.ID)
if ok {
t.Fatal("member should be gone after remove")
}
// Removing again returns ErrNotFound.
if err := s.RemoveMember(ctx, repo.ID, bob.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("second remove should be ErrNotFound, got %v", err)
}
}
func TestListMembers(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
alice := s.createUser(t, ctx, "alice", "p")
bob := s.createUser(t, ctx, "bob", "p")
carol := s.createUser(t, ctx, "carol", "p")
repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate)
_ = s.AddMember(ctx, repo.ID, carol.ID, models.AccessRead)
_ = s.AddMember(ctx, repo.ID, bob.ID, models.AccessWrite)
members, err := s.ListMembers(ctx, repo.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(members) != 2 {
t.Fatalf("expected 2 members, got %d", len(members))
}
// Ordered by username: bob, carol.
if members[0].User.Username != "bob" || members[0].Access != models.AccessWrite {
t.Fatalf("member[0] = %+v", members[0])
}
if members[1].User.Username != "carol" || members[1].Access != models.AccessRead {
t.Fatalf("member[1] = %+v", members[1])
}
}