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.
254 lines
7.5 KiB
Go
254 lines
7.5 KiB
Go
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])
|
|
}
|
|
}
|