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,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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user