diff --git a/.forgejo/workflows/test-go.yml b/.forgejo/workflows/test-go.yml index cb4a405e..3906148b 100644 --- a/.forgejo/workflows/test-go.yml +++ b/.forgejo/workflows/test-go.yml @@ -4,8 +4,16 @@ name: test-go # dev/main and PRs to main, scoped to Go-side files only — web-only or # Flutter-only diffs don't trigger this workflow. # -# Integration tests needing Postgres/ffmpeg run locally via docker-compose; -# they should guard with testing.Short() so this short-mode run skips them. +# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and +# `integration` (full `go test -race` against an ephemeral Postgres). +# +# Integration-job DB wiring follows the act_runner shared-daemon pattern: +# the runner's Docker daemon also runs the operator's dev compose stack, +# so service containers get NO published ports (collision) and no +# service-name DNS. We discover the service container by the job-scoped +# name filter via the mounted docker socket and reach it by bridge IP. +# The exactly-one assertion is a hard guard — pointing tests at the dev +# Postgres would truncate it (the disaster Fable #339 exists to prevent). # # `web/build/` has a committed placeholder index.html so go:embed succeeds # without needing the SPA to be freshly built. Real builds happen in @@ -54,3 +62,48 @@ jobs: - name: go test (short, race) run: go test -short -race ./... + + integration: + runs-on: go-ci + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: minstrel + POSTGRES_PASSWORD: minstrel + POSTGRES_DB: minstrel_test + # No `ports:` — the runner shares the operator's dev compose + # Docker daemon; publishing a fixed host port collides. + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Integration suite (discover service by bridge IP, migrate, test) + run: | + set -eux + # Discover THIS job's Postgres service container via the + # mounted docker socket. Scope by the job-name filter so the + # operator's dev compose `minstrel-postgres-*` (same image, + # same daemon) can never match. Require exactly one — abort + # loudly otherwise; a wrong target would truncate real data. + PG_LIST=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" --format '{{.ID}} {{.Names}}') + echo "candidates: ${PG_LIST:-}" + PG_COUNT=$(printf '%s\n' "$PG_LIST" | grep -c . || true) + test "$PG_COUNT" = "1" || { echo "FATAL: expected exactly 1 postgres service container, got $PG_COUNT"; exit 1; } + PG_ID=$(printf '%s' "$PG_LIST" | awk '{print $1}') + PG_NAME=$(printf '%s' "$PG_LIST" | awk '{print $2}') + case "$PG_NAME" in + *minstrel-postgres*|*_postgres_*) echo "FATAL: matched the dev compose container ($PG_NAME), refusing"; exit 1 ;; + esac + PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID") + test -n "$PG_IP" + export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable" + + # Wait for Postgres to accept TCP (no health-check dependency). + for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done + + # Apply embedded migrations to the fresh test DB, then run the + # full suite (no -short → integration tests execute). + MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate + go test -race ./... diff --git a/Makefile b/Makefile index ac425514..9799d2ff 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: generate test test-short lint build +.PHONY: generate test test-short test-integration lint build SQLC_VERSION := 1.31.1 @@ -11,6 +11,14 @@ test: test-short: go test -short -race ./... +# Full suite incl. integration tests, against the dedicated minstrel_test +# DB so a run never truncates the dev `minstrel` DB (Fable #339). Ensures +# the test DB exists (idempotent — createdb errors if present, ignored). +test-integration: + docker compose up -d postgres + -docker compose exec -T postgres createdb -U minstrel minstrel_test + MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable go test -race ./... + lint: golangci-lint run ./... diff --git a/README.md b/README.md index 3366afd4..58265896 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,16 @@ Two concurrent dev processes: 1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`. 2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work. +### Testing + +- Unit + race (no DB): `make test-short`. +- Full suite incl. integration tests: `make test-integration`. This runs + against a dedicated `minstrel_test` database so a test run never + truncates your dev `minstrel` data (admin user, library, likes). It + brings up the compose Postgres and creates the test DB if missing. +- CI runs both: a fast `go test -short -race` gate plus an integration + job with its own ephemeral Postgres (`.forgejo/workflows/test-go.yml`). + ### Production build `docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required. diff --git a/cmd/minstrel/admin.go b/cmd/minstrel/admin.go new file mode 100644 index 00000000..72248b40 --- /dev/null +++ b/cmd/minstrel/admin.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "flag" + "fmt" + "os" + + "golang.org/x/crypto/bcrypt" + + "git.fabledsword.com/bvandeusen/minstrel/internal/config" + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/logging" +) + +// runMigrate applies the embedded migrations and exits. The server also +// auto-migrates on startup (main.go); this standalone path exists for CI +// (provision a fresh DB before the integration suite) and operators who +// want to migrate without starting the server. +func runMigrate(args []string) error { + fs := flag.NewFlagSet("migrate", flag.ContinueOnError) + configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file") + if err := fs.Parse(args); err != nil { + return err + } + cfg, err := config.Load(*configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + logger, err := logging.New(os.Stdout, cfg.Log.Level, cfg.Log.Format) + if err != nil { + return fmt.Errorf("init logger: %w", err) + } + if err := db.Migrate(cfg.Database.URL, logger); err != nil { + return fmt.Errorf("migrate: %w", err) + } + fmt.Println("minstrel: migrations applied") + return nil +} + +// runAdmin dispatches `minstrel admin `. +func runAdmin(args []string) error { + if len(args) == 0 { + return errors.New("usage: minstrel admin reset-password [-user NAME] [-password PW] [-config PATH]") + } + switch args[0] { + case "reset-password": + return adminResetPassword(args[1:]) + default: + return fmt.Errorf("unknown admin subcommand %q", args[0]) + } +} + +// adminResetPassword resets a user's credentials. It updates BOTH +// password_hash (bcrypt, for /api/auth/login) and subsonic_password +// (plaintext, required for Subsonic t+s token verification) so neither +// auth path is left stale. Recovers a locked-out operator when the +// bootstrap password was missed or the DB volume was recreated (Fable +// #321) without DB surgery. +func adminResetPassword(args []string) error { + fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError) + configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file") + username := fs.String("user", "admin", "username to reset") + password := fs.String("password", "", "new password; if empty a strong one is generated and printed") + if err := fs.Parse(args); err != nil { + return err + } + + cfg, err := config.Load(*configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + if cfg.Database.URL == "" { + return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)") + } + + ctx := context.Background() + pool, err := db.Open(ctx, cfg.Database.URL) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer pool.Close() + q := dbq.New(pool) + + user, err := q.GetUserByUsername(ctx, *username) + if err != nil { + return fmt.Errorf("look up user %q: %w", *username, err) + } + + pw := *password + generated := false + if pw == "" { + b := make([]byte, 18) + if _, err := rand.Read(b); err != nil { + return fmt.Errorf("generate password: %w", err) + } + pw = base64.RawURLEncoding.EncodeToString(b) + generated = true + } + + hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + if err := q.ChangeUserPassword(ctx, dbq.ChangeUserPasswordParams{ + ID: user.ID, + PasswordHash: string(hash), + }); err != nil { + return fmt.Errorf("update password_hash: %w", err) + } + sp := pw + if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{ + ID: user.ID, + SubsonicPassword: &sp, + }); err != nil { + return fmt.Errorf("update subsonic_password: %w", err) + } + + if generated { + fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw) + } else { + fmt.Printf("minstrel: password for %q reset.\n", *username) + } + return nil +} diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 84a198d7..1169204a 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -29,6 +29,22 @@ import ( ) func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "admin": + if err := runAdmin(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "minstrel admin: %v\n", err) + os.Exit(1) + } + return + case "migrate": + if err := runMigrate(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "minstrel migrate: %v\n", err) + os.Exit(1) + } + return + } + } if err := run(); err != nil { fmt.Fprintf(os.Stderr, "minstrel: %v\n", err) os.Exit(1) diff --git a/deploy/initdb/01-create-test-db.sql b/deploy/initdb/01-create-test-db.sql new file mode 100644 index 00000000..e1c339f1 --- /dev/null +++ b/deploy/initdb/01-create-test-db.sql @@ -0,0 +1,6 @@ +-- Runs once, only on a fresh Postgres data volume (Docker entrypoint +-- initdb hook). Creates the dedicated integration-test database so +-- `go test` never truncates the operator's dev `minstrel` DB (Fable +-- #339). For an already-initialised volume this script does NOT run; +-- `make test-integration` creates the DB idempotently instead. +CREATE DATABASE minstrel_test OWNER minstrel; diff --git a/docker-compose.yml b/docker-compose.yml index a22a72a4..7e32270d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,14 @@ version: "3.9" # Local development environment for Minstrel. -# Not used by CI — integration tests run against this compose stack manually: -# docker compose up -d postgres -# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable \ -# go test ./... +# +# Integration tests run against a SEPARATE database (minstrel_test) so a +# test run never truncates the dev `minstrel` DB (Fable #339). Use the +# Makefile target, which ensures the test DB exists then points the +# tests at it: +# make test-integration +# (CI runs the same suite against its own ephemeral Postgres service — +# see .forgejo/workflows/test-go.yml.) # # Full stack (server + db): # docker compose up --build @@ -22,6 +26,9 @@ services: # - "5432:5432" volumes: - minstrel-pgdata:/var/lib/postgresql/data + # Creates minstrel_test on a fresh volume (Fable #339). No-op on + # an existing volume — make test-integration ensures it instead. + - ./deploy/initdb:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U minstrel -d minstrel"] interval: 5s