feat(cli,ci): admin reset-password + migrate subcommands; test-DB isolation + CI integration job
#321 — `minstrel admin reset-password [-user admin] [-password X]`: loads config, updates BOTH password_hash (bcrypt) and subsonic_password (plaintext, for Subsonic t+s) so neither auth path is left stale; generates+prints a strong password when -password is omitted. Recovers a locked-out operator without DB surgery. Subcommand dispatch added to main.go (os.Args switch before flag-parse; server path untouched) plus a `minstrel migrate` subcommand exposing db.Migrate standalone. #339 — integration tests no longer truncate the dev DB: - deploy/initdb creates minstrel_test on a fresh compose volume; `make test-integration` ensures it idempotently and points MINSTREL_TEST_DATABASE_URL at minstrel_test. - docker-compose.yml + README updated. CI integration job (test-go.yml): the prior workflow only ran `go test -short -race` with no DB, so the entire integration suite silently t.Skip'd — "CI green" never covered API/db/scanner. New `integration` job runs the full `go test -race` against an ephemeral Postgres service, using the act_runner shared-daemon pattern: no published ports, discover the service container by job-name filter via the docker socket, reach it by bridge IP, hard exactly-one assertion + dev-compose-name reject (a wrong target would truncate real data), TCP-wait, `minstrel migrate`, then test. Fast `test` job kept as the quick gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,16 @@ name: test-go
|
|||||||
# dev/main and PRs to main, scoped to Go-side files only — web-only or
|
# dev/main and PRs to main, scoped to Go-side files only — web-only or
|
||||||
# Flutter-only diffs don't trigger this workflow.
|
# Flutter-only diffs don't trigger this workflow.
|
||||||
#
|
#
|
||||||
# Integration tests needing Postgres/ffmpeg run locally via docker-compose;
|
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
|
||||||
# they should guard with testing.Short() so this short-mode run skips them.
|
# `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
|
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||||
# without needing the SPA to be freshly built. Real builds happen in
|
# without needing the SPA to be freshly built. Real builds happen in
|
||||||
@@ -54,3 +62,48 @@ jobs:
|
|||||||
|
|
||||||
- name: go test (short, race)
|
- name: go test (short, race)
|
||||||
run: 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:-<none>}"
|
||||||
|
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 ./...
|
||||||
|
|||||||
@@ -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
|
SQLC_VERSION := 1.31.1
|
||||||
|
|
||||||
@@ -11,6 +11,14 @@ test:
|
|||||||
test-short:
|
test-short:
|
||||||
go test -short -race ./...
|
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:
|
lint:
|
||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ Two concurrent dev processes:
|
|||||||
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
|
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.
|
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
|
### 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.
|
`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.
|
||||||
|
|||||||
@@ -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 <subcommand>`.
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -29,6 +29,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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 {
|
if err := run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
|
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -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;
|
||||||
+11
-4
@@ -1,10 +1,14 @@
|
|||||||
version: "3.9"
|
version: "3.9"
|
||||||
|
|
||||||
# Local development environment for Minstrel.
|
# Local development environment for Minstrel.
|
||||||
# Not used by CI — integration tests run against this compose stack manually:
|
#
|
||||||
# docker compose up -d postgres
|
# Integration tests run against a SEPARATE database (minstrel_test) so a
|
||||||
# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable \
|
# test run never truncates the dev `minstrel` DB (Fable #339). Use the
|
||||||
# go test ./...
|
# 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):
|
# Full stack (server + db):
|
||||||
# docker compose up --build
|
# docker compose up --build
|
||||||
@@ -22,6 +26,9 @@ services:
|
|||||||
# - "5432:5432"
|
# - "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- minstrel-pgdata:/var/lib/postgresql/data
|
- 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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U minstrel -d minstrel"]
|
test: ["CMD-SHELL", "pg_isready -U minstrel -d minstrel"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
Reference in New Issue
Block a user