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,299 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"urapt/shared/crypto"
|
||||
)
|
||||
|
||||
func TestCreateUser_BootstrapAdmin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
// First user becomes admin.
|
||||
u1 := s.createUser(t, ctx, "alice", "pw1")
|
||||
if !u1.IsAdmin {
|
||||
t.Fatalf("first user should be admin, got is_admin=%v", u1.IsAdmin)
|
||||
}
|
||||
|
||||
// Subsequent users are not admin.
|
||||
u2 := s.createUser(t, ctx, "bob", "pw2")
|
||||
if u2.IsAdmin {
|
||||
t.Fatalf("second user should not be admin")
|
||||
}
|
||||
u3 := s.createUser(t, ctx, "carol", "pw3")
|
||||
if u3.IsAdmin {
|
||||
t.Fatalf("third user should not be admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_DuplicateUsernameRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
s.createUser(t, ctx, "alice", "pw1")
|
||||
if _, _, err := s.CreateUser(ctx, "alice", "hash"); err == nil {
|
||||
t.Fatal("expected error creating duplicate username")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
u := s.createUser(t, ctx, "alice", "pw1")
|
||||
|
||||
got, err := s.GetUserByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got.ID != u.ID || got.Username != "alice" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
|
||||
if _, err := s.GetUserByID(ctx, "nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByUsername_CaseInsensitive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
s.createUser(t, ctx, "Alice", "pw")
|
||||
|
||||
for _, q := range []string{"alice", "ALICE", "AlIcE"} {
|
||||
got, err := s.GetUserByUsername(ctx, q)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup %q: %v", q, err)
|
||||
}
|
||||
if got.Username != "Alice" {
|
||||
t.Fatalf("expected original casing 'Alice', got %q", got.Username)
|
||||
}
|
||||
}
|
||||
if _, err := s.GetUserByUsername(ctx, "bob"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound for missing user, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserPasswordHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
u := s.createUser(t, ctx, "alice", "supersecret")
|
||||
|
||||
hash, err := s.GetUserPasswordHash(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get hash: %v", err)
|
||||
}
|
||||
if !crypto.VerifyPassword(hash, "supersecret") {
|
||||
t.Fatal("bcrypt hash did not verify against original password")
|
||||
}
|
||||
if crypto.VerifyPassword(hash, "wrong") {
|
||||
t.Fatal("bcrypt hash verified against wrong password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
s.createUser(t, ctx, "carol", "p")
|
||||
s.createUser(t, ctx, "alice", "p")
|
||||
s.createUser(t, ctx, "bob", "p")
|
||||
|
||||
users, err := s.ListUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(users) != 3 {
|
||||
t.Fatalf("expected 3 users, got %d", len(users))
|
||||
}
|
||||
// Ordered by username_lc.
|
||||
if users[0].Username != "alice" || users[1].Username != "bob" || users[2].Username != "carol" {
|
||||
names := []string{users[0].Username, users[1].Username, users[2].Username}
|
||||
t.Fatalf("expected alphabetical order, got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
// First user is bootstrap admin; create a second non-admin user to test
|
||||
// promotion/demotion on.
|
||||
s.createUser(t, ctx, "admin", "p")
|
||||
u := s.createUser(t, ctx, "alice", "p")
|
||||
if u.IsAdmin {
|
||||
t.Fatal("non-bootstrap user should not be admin")
|
||||
}
|
||||
|
||||
admin := true
|
||||
if err := s.UpdateUser(ctx, u.ID, &admin); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
got, _ := s.GetUserByID(ctx, u.ID)
|
||||
if !got.IsAdmin {
|
||||
t.Fatal("expected is_admin=true after update")
|
||||
}
|
||||
|
||||
off := false
|
||||
_ = s.UpdateUser(ctx, u.ID, &off)
|
||||
got, _ = s.GetUserByID(ctx, u.ID)
|
||||
if got.IsAdmin {
|
||||
t.Fatal("expected is_admin=false after demotion")
|
||||
}
|
||||
|
||||
// nil leaves it unchanged.
|
||||
_ = s.UpdateUser(ctx, u.ID, nil)
|
||||
got, _ = s.GetUserByID(ctx, u.ID)
|
||||
if got.IsAdmin {
|
||||
t.Fatal("nil isAdmin should leave it false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
u := s.createUser(t, ctx, "alice", "p")
|
||||
|
||||
if err := s.DeleteUser(ctx, u.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := s.GetUserByID(ctx, u.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- tokens ---
|
||||
|
||||
func TestCreateTokenAndGetByHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
u := s.createUser(t, ctx, "alice", "p")
|
||||
|
||||
plaintext, stored := s.createToken(t, ctx, u.ID, "laptop")
|
||||
if stored.Name != "laptop" {
|
||||
t.Fatalf("name = %q", stored.Name)
|
||||
}
|
||||
if stored.Prefix == "" {
|
||||
t.Fatal("prefix should be set")
|
||||
}
|
||||
|
||||
got, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext))
|
||||
if err != nil {
|
||||
t.Fatalf("get by hash: %v", err)
|
||||
}
|
||||
if got.ID != stored.ID {
|
||||
t.Fatalf("id mismatch: %s vs %s", got.ID, stored.ID)
|
||||
}
|
||||
if got.RevokedAt != nil {
|
||||
t.Fatal("fresh token should not be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenByHash_RevokedExcluded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
u := s.createUser(t, ctx, "alice", "p")
|
||||
plaintext, stored := s.createToken(t, ctx, u.ID, "laptop")
|
||||
|
||||
if err := s.RevokeToken(ctx, u.ID, stored.ID); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if _, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext)); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("revoked token should not be returned, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_OwnershipEnforced(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
alice := s.createUser(t, ctx, "alice", "p")
|
||||
bob := s.createUser(t, ctx, "bob", "p")
|
||||
_, aliceToken := s.createToken(t, ctx, alice.ID, "alice-laptop")
|
||||
|
||||
// Bob cannot revoke Alice's token.
|
||||
if err := s.RevokeToken(ctx, bob.ID, aliceToken.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("cross-user revoke should be ErrNotFound, got %v", err)
|
||||
}
|
||||
// Alice can revoke her own.
|
||||
if err := s.RevokeToken(ctx, alice.ID, aliceToken.ID); err != nil {
|
||||
t.Fatalf("own revoke: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeTokenByHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
u := s.createUser(t, ctx, "alice", "p")
|
||||
plaintext, _ := s.createToken(t, ctx, u.ID, "laptop")
|
||||
|
||||
if err := s.RevokeTokenByHash(ctx, crypto.HashToken(plaintext)); err != nil {
|
||||
t.Fatalf("revoke by hash: %v", err)
|
||||
}
|
||||
if _, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext)); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("revoked token should not be found, got %v", err)
|
||||
}
|
||||
// Revoking again is a no-op (no error, no rows affected).
|
||||
if err := s.RevokeTokenByHash(ctx, crypto.HashToken(plaintext)); err != nil {
|
||||
t.Fatalf("idempotent revoke: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTokens_IncludesRevoked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
u := s.createUser(t, ctx, "alice", "p")
|
||||
_, t1 := s.createToken(t, ctx, u.ID, "a")
|
||||
_, t2 := s.createToken(t, ctx, u.ID, "b")
|
||||
_ = s.RevokeToken(ctx, u.ID, t1.ID)
|
||||
|
||||
list, err := s.ListTokens(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected 2 tokens (incl revoked), got %d", len(list))
|
||||
}
|
||||
for _, tk := range list {
|
||||
if tk.ID == t1.ID && tk.RevokedAt == nil {
|
||||
t.Fatal("revoked token should have RevokedAt set")
|
||||
}
|
||||
if tk.ID == t2.ID && tk.RevokedAt != nil {
|
||||
t.Fatal("active token should not be revoked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTokens_ScopedToUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
alice := s.createUser(t, ctx, "alice", "p")
|
||||
bob := s.createUser(t, ctx, "bob", "p")
|
||||
s.createToken(t, ctx, alice.ID, "alice-token")
|
||||
s.createToken(t, ctx, bob.ID, "bob-token")
|
||||
|
||||
aliceList, _ := s.ListTokens(ctx, alice.ID)
|
||||
if len(aliceList) != 1 || aliceList[0].UserID != alice.ID {
|
||||
t.Fatalf("alice should see only her token, got %d", len(aliceList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTouchToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
u := s.createUser(t, ctx, "alice", "p")
|
||||
plaintext, stored := s.createToken(t, ctx, u.ID, "laptop")
|
||||
|
||||
if stored.LastUsedAt != nil {
|
||||
t.Fatal("fresh token should have nil LastUsedAt")
|
||||
}
|
||||
if err := s.TouchToken(ctx, stored.ID); err != nil {
|
||||
t.Fatalf("touch: %v", err)
|
||||
}
|
||||
// Look up via the hash to confirm last_used_at was written.
|
||||
got, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext))
|
||||
if err != nil {
|
||||
t.Fatalf("get by hash: %v", err)
|
||||
}
|
||||
if got.LastUsedAt == nil {
|
||||
t.Fatal("expected LastUsedAt set after touch")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user