feat: backend service
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/admin"
|
||||
"galaxy/backend/internal/config"
|
||||
backendpg "galaxy/backend/internal/postgres"
|
||||
pgshared "galaxy/postgres"
|
||||
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
pgImage = "postgres:16-alpine"
|
||||
pgUser = "galaxy"
|
||||
pgPassword = "galaxy"
|
||||
pgDatabase = "galaxy_backend"
|
||||
pgSchema = "backend"
|
||||
pgStartup = 90 * time.Second
|
||||
pgOpTO = 10 * time.Second
|
||||
)
|
||||
|
||||
// startPostgres spins up a Postgres testcontainer with the backend
|
||||
// migrations applied. The returned *sql.DB is closed and the container
|
||||
// terminated by t.Cleanup hooks. Tests skip cleanly when Docker is
|
||||
// unavailable.
|
||||
func startPostgres(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
pgContainer, err := tcpostgres.Run(ctx, pgImage,
|
||||
tcpostgres.WithDatabase(pgDatabase),
|
||||
tcpostgres.WithUsername(pgUser),
|
||||
tcpostgres.WithPassword(pgPassword),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(pgStartup),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
|
||||
t.Errorf("terminate postgres container: %v", termErr)
|
||||
}
|
||||
})
|
||||
|
||||
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("connection string: %v", err)
|
||||
}
|
||||
scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
|
||||
if err != nil {
|
||||
t.Fatalf("scope dsn: %v", err)
|
||||
}
|
||||
|
||||
cfg := pgshared.DefaultConfig()
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = pgOpTO
|
||||
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
|
||||
parsed, err := url.Parse(baseDSN)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := parsed.Query()
|
||||
values.Set("search_path", schema)
|
||||
if values.Get("sslmode") == "" {
|
||||
values.Set("sslmode", "disable")
|
||||
}
|
||||
parsed.RawQuery = values.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func buildService(t *testing.T, db *sql.DB) (*admin.Service, *admin.Store, *admin.Cache) {
|
||||
t.Helper()
|
||||
store := admin.NewStore(db)
|
||||
cache := admin.NewCache()
|
||||
if err := cache.Warm(context.Background(), store); err != nil {
|
||||
t.Fatalf("warm admin cache: %v", err)
|
||||
}
|
||||
svc := admin.NewService(admin.Deps{
|
||||
Store: store,
|
||||
Cache: cache,
|
||||
Logger: zap.NewNop(),
|
||||
})
|
||||
return svc, store, cache
|
||||
}
|
||||
|
||||
func TestBootstrapInsertsThenSkips(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
store := admin.NewStore(db)
|
||||
|
||||
cfg := config.AdminBootstrapConfig{User: "root", Password: "root-secret"}
|
||||
logger := zap.NewNop()
|
||||
|
||||
if err := admin.Bootstrap(context.Background(), store, cfg, logger); err != nil {
|
||||
t.Fatalf("first bootstrap: %v", err)
|
||||
}
|
||||
first, hash, err := store.Lookup(context.Background(), "root")
|
||||
if err != nil {
|
||||
t.Fatalf("lookup after first bootstrap: %v", err)
|
||||
}
|
||||
if first.Username != "root" {
|
||||
t.Fatalf("Username = %q, want root", first.Username)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword(hash, []byte("root-secret")); err != nil {
|
||||
t.Fatalf("CompareHashAndPassword: %v", err)
|
||||
}
|
||||
|
||||
// Second call must not modify the row even when the password value
|
||||
// supplied via env vars differs.
|
||||
cfg.Password = "different"
|
||||
if err := admin.Bootstrap(context.Background(), store, cfg, logger); err != nil {
|
||||
t.Fatalf("second bootstrap: %v", err)
|
||||
}
|
||||
_, sameHash, err := store.Lookup(context.Background(), "root")
|
||||
if err != nil {
|
||||
t.Fatalf("lookup after second bootstrap: %v", err)
|
||||
}
|
||||
if string(hash) != string(sameHash) {
|
||||
t.Fatalf("password_hash mutated by idempotent bootstrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSkipsWhenUserEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
store := admin.NewStore(db)
|
||||
|
||||
if err := admin.Bootstrap(context.Background(), store, config.AdminBootstrapConfig{}, zap.NewNop()); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
admins, _, err := store.ListAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(admins) != 0 {
|
||||
t.Fatalf("ListAll = %d rows, want 0", len(admins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
|
||||
created, err := svc.Create(context.Background(), admin.CreateInput{
|
||||
Username: "alice",
|
||||
Password: "alice-secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.Username != "alice" {
|
||||
t.Fatalf("Username = %q, want alice", created.Username)
|
||||
}
|
||||
|
||||
ok, err := svc.Verify(context.Background(), "alice", "alice-secret")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Verify(correct) = (%v, %v), want (true, nil)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsWrongPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
_, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
ok, err := svc.Verify(context.Background(), "alice", "bad")
|
||||
if err != nil {
|
||||
t.Fatalf("Verify returned error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("Verify(wrong) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsUnknownUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
|
||||
ok, err := svc.Verify(context.Background(), "ghost", "x")
|
||||
if err != nil || ok {
|
||||
t.Fatalf("Verify(ghost) = (%v, %v), want (false, nil)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsDisabledAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := svc.Disable(context.Background(), "alice"); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
|
||||
ok, err := svc.Verify(context.Background(), "alice", "good")
|
||||
if err != nil || ok {
|
||||
t.Fatalf("Verify(disabled) = (%v, %v), want (false, nil)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableReversesDisable(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := svc.Disable(context.Background(), "alice"); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
got, err := svc.Enable(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("enable: %v", err)
|
||||
}
|
||||
if got.DisabledAt != nil {
|
||||
t.Fatalf("DisabledAt = %v, want nil after enable", got.DisabledAt)
|
||||
}
|
||||
|
||||
ok, err := svc.Verify(context.Background(), "alice", "good")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Verify after enable = (%v, %v), want (true, nil)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsDuplicateUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "x"}); err != nil {
|
||||
t.Fatalf("create #1: %v", err)
|
||||
}
|
||||
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "y"}); !errors.Is(err, admin.ErrUsernameTaken) {
|
||||
t.Fatalf("Create #2 err = %v, want ErrUsernameTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsEmptyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
|
||||
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "", Password: "x"}); !errors.Is(err, admin.ErrInvalidInput) {
|
||||
t.Fatalf("Create(empty username) err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: ""}); !errors.Is(err, admin.ErrInvalidInput) {
|
||||
t.Fatalf("Create(empty password) err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordReplacesHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "old"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := svc.ResetPassword(context.Background(), "alice", "new-secret"); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
|
||||
if ok, _ := svc.Verify(context.Background(), "alice", "old"); ok {
|
||||
t.Fatalf("Verify(old) = true after reset")
|
||||
}
|
||||
if ok, err := svc.Verify(context.Background(), "alice", "new-secret"); err != nil || !ok {
|
||||
t.Fatalf("Verify(new) = (%v, %v), want (true, nil)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordOnUnknownUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
|
||||
if _, err := svc.ResetPassword(context.Background(), "ghost", "x"); !errors.Is(err, admin.ErrNotFound) {
|
||||
t.Fatalf("ResetPassword(ghost) err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReturnsAllRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
for _, u := range []string{"alice", "bob", "carol"} {
|
||||
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: u, Password: "x"}); err != nil {
|
||||
t.Fatalf("create %s: %v", u, err)
|
||||
}
|
||||
}
|
||||
got, err := svc.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("List = %d rows, want 3", len(got))
|
||||
}
|
||||
// Order is by username ASC at the SQL level.
|
||||
if got[0].Username != "alice" || got[1].Username != "bob" || got[2].Username != "carol" {
|
||||
t.Fatalf("List order = %v, want [alice bob carol]", []string{got[0].Username, got[1].Username, got[2].Username})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTouchesLastUsedAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, store, _ := buildService(t, db)
|
||||
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "good"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
if ok, err := svc.Verify(context.Background(), "alice", "good"); err != nil || !ok {
|
||||
t.Fatalf("Verify: (%v, %v)", ok, err)
|
||||
}
|
||||
|
||||
// last_used_at is updated by a fire-and-forget goroutine. Poll until
|
||||
// it lands or the deadline passes.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
got, _, err := store.Lookup(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
if got.LastUsedAt != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("LastUsedAt not populated after Verify")
|
||||
}
|
||||
|
||||
func TestDisableIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
if _, err := svc.Create(context.Background(), admin.CreateInput{Username: "alice", Password: "x"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
first, err := svc.Disable(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("disable #1: %v", err)
|
||||
}
|
||||
if first.DisabledAt == nil {
|
||||
t.Fatalf("DisabledAt = nil after disable")
|
||||
}
|
||||
second, err := svc.Disable(context.Background(), "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("disable #2: %v", err)
|
||||
}
|
||||
if second.DisabledAt == nil {
|
||||
t.Fatalf("DisabledAt = nil on second disable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableUnknownUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := startPostgres(t)
|
||||
svc, _, _ := buildService(t, db)
|
||||
if _, err := svc.Disable(context.Background(), "ghost"); !errors.Is(err, admin.ErrNotFound) {
|
||||
t.Fatalf("Disable(ghost) err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user