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,102 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveGPGKeyAndGetDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
k, err := s.SaveGPGKey(ctx, "ABCD1234", "user-1", "PUB-ARMORED", "PRIV-ARMORED", true)
|
||||
if err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
if k.ID == "" || k.Fingerprint != "ABCD1234" || !k.IsDefault {
|
||||
t.Fatalf("key = %+v", k)
|
||||
}
|
||||
|
||||
got, err := s.GetDefaultGPGKey(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get default: %v", err)
|
||||
}
|
||||
if got.Fingerprint != "ABCD1234" || got.PublicKeyArmored != "PUB-ARMORED" {
|
||||
t.Fatalf("got = %+v", got)
|
||||
}
|
||||
if got.PrivateKeyArmored != "PRIV-ARMORED" {
|
||||
t.Fatal("private key armor should be retrieved from DB")
|
||||
}
|
||||
if !got.IsDefault {
|
||||
t.Fatal("expected is_default=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultGPGKey_None(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
if _, err := s.GetDefaultGPGKey(ctx); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound when no key, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGPGKeyPublic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
_, _ = s.SaveGPGKey(ctx, "ABCD1234", "user-1", "PUB-ARMORED", "PRIV-ARMORED", true)
|
||||
|
||||
pub, err := s.GetGPGKeyPublic(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("get public: %v", err)
|
||||
}
|
||||
if pub != "PUB-ARMORED" {
|
||||
t.Fatalf("pub = %q", pub)
|
||||
}
|
||||
// No default key.
|
||||
s2 := newTestStore(t)
|
||||
if _, err := s2.GetGPGKeyPublic(ctx); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- audit ---
|
||||
|
||||
func TestRecordAudit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
// audit_log has FK constraints on user_id/repo_id, so use real rows.
|
||||
u := s.createUser(t, ctx, "alice", "p")
|
||||
repo := s.createRepo(t, ctx, "repo", u.ID, "public")
|
||||
uid, repoID := u.ID, repo.ID
|
||||
|
||||
// Insert with both user and repo set.
|
||||
if err := s.RecordAudit(ctx, &uid, &repoID, "push", "pkg-1", "uploaded myapp"); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
// Insert with nil pointers (system action).
|
||||
if err := s.RecordAudit(ctx, nil, nil, "startup", "server", "started"); err != nil {
|
||||
t.Fatalf("record nil: %v", err)
|
||||
}
|
||||
|
||||
// Verify rows exist. The audit_log table is write-only from the store API;
|
||||
// query directly to confirm persistence.
|
||||
var n int
|
||||
err := s.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("expected 2 audit rows, got %d", n)
|
||||
}
|
||||
|
||||
// Verify nullable columns are stored correctly.
|
||||
var uidVal, repoVal *string
|
||||
row := s.DB().QueryRowContext(ctx, `SELECT user_id, repository_id FROM audit_log WHERE action = 'startup'`)
|
||||
if err := row.Scan(&uidVal, &repoVal); err != nil {
|
||||
t.Fatalf("scan startup row: %v", err)
|
||||
}
|
||||
if uidVal != nil || repoVal != nil {
|
||||
t.Fatalf("expected nil user_id/repository_id for system action, got %v %v", uidVal, repoVal)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user