Docs - docs/ARCHITECTURE.md: how the pieces fit and why. The request path, the six enforced layers, the data model, the scanner, the job queue, direct-play playback, and the three protocol surfaces over one application. - docs/decisions/: seven ADRs with the alternatives that were rejected — SQLite-with-a-seam, direct-play-only, mark-missing-never-delete, the modular monolith, the one item-access chokepoint, the table-backed queue, and fixed-width timestamps. - README: the layout section was stale (it predated catalog, home, scanner, providers, engine, probe, playback, apitoken, arch and the dto packages) and now lists what exists, plus links to the new docs. Two real bugs, both found by verifying the docs instead of trusting them 1. The CLI could not run on a fresh install. `Config.Validate` created the data and config directories, but the CLI never called it, so `vellum user add` — the recovery path when you are locked out — failed with "unable to open database file" until the server had booted at least once. Split the directory creation into `Config.EnsureDirs`, call it from the CLI, and keep the media root check out of it: an admin command must not fail because a library is unmounted. The CLI tests now point DBPath inside DataDir, exactly as LoadConfig derives it, so they exercise the fresh-install path; removing EnsureDirs fails three of them. 2. A fresh clone could not build at all. `//go:embed all:dist` fails to compile when web/dist is absent, and web/.gitignore expected a `dist/.gitkeep` that was never created. Added it, so a clone builds and the server logs "web UI not embedded; run the frontend build to include it" — the intended behaviour for someone who only wants the API. Also: the httpapi tests depended on a built frontend and FAILED rather than skipping, so the suite was red on any fresh clone. findAsset and readAsset now skip with a message naming the missing step. They still RUN when a bundle is present (verified: zero skips here), so they have not been quietly disabled. Verified from a simulated clone of tracked files only: go build exit 0, 25 packages pass, the server boots and applies all 12 migrations, and the CLI creates a user and takes a backup. gofmt clean, vet exit 0, svelte-check 0/0.
194 lines
5.8 KiB
Go
194 lines
5.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
"github.com/mithral/vellum/internal/core"
|
|
)
|
|
|
|
// testConfig points a Config at a fresh database inside a temp dir. The CLI
|
|
// itself opens and migrates it, mirroring the auth_test.go setup pattern.
|
|
//
|
|
// DataDir and ConfigDir are set because the CLI creates them if missing — that
|
|
// is what lets `vellum user add` work on a fresh install, before the server has
|
|
// ever run. An empty dir here would be an unrealistic config.
|
|
func testConfig(t *testing.T) core.Config {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
data := filepath.Join(dir, "data")
|
|
return core.Config{
|
|
// DBPath lives INSIDE DataDir, exactly as LoadConfig derives it. That
|
|
// matters: it is why the CLI must create the directory, and it is what
|
|
// makes this config exercise the fresh-install path.
|
|
DBPath: filepath.Join(data, "vellum.db"),
|
|
DataDir: data,
|
|
ConfigDir: filepath.Join(dir, "config"),
|
|
}
|
|
}
|
|
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// runCapturing runs the exported entrypoint with os.Stdout redirected so the
|
|
// subcommand's output can be asserted on.
|
|
func runCapturing(t *testing.T, args []string, cfg core.Config, log *slog.Logger) (string, bool, error) {
|
|
t.Helper()
|
|
|
|
old := os.Stdout
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("pipe: %v", err)
|
|
}
|
|
os.Stdout = w
|
|
|
|
handled, runErr := Run(args, cfg, log)
|
|
|
|
os.Stdout = old
|
|
if err := w.Close(); err != nil {
|
|
t.Fatalf("close stdout pipe: %v", err)
|
|
}
|
|
out, err := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatalf("read stdout: %v", err)
|
|
}
|
|
_ = r.Close()
|
|
return string(out), handled, runErr
|
|
}
|
|
|
|
func TestRunUserAddAndList(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
log := testLogger()
|
|
|
|
if _, handled, err := runCapturing(t, []string{"user", "add", "alice", "password123"}, cfg, log); !handled || err != nil {
|
|
t.Fatalf("user add alice: handled=%v err=%v", handled, err)
|
|
}
|
|
if _, handled, err := runCapturing(t, []string{"user", "add", "root", "password123", "--admin"}, cfg, log); !handled || err != nil {
|
|
t.Fatalf("user add root --admin: handled=%v err=%v", handled, err)
|
|
}
|
|
|
|
out, handled, err := runCapturing(t, []string{"user", "list"}, cfg, log)
|
|
if !handled {
|
|
t.Fatal("user list was not handled")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("user list: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
if len(lines) != 2 {
|
|
t.Fatalf("user list printed %d lines, want 2:\n%s", len(lines), out)
|
|
}
|
|
if !strings.Contains(lines[0], "alice") || strings.Contains(lines[0], "admin=true") {
|
|
t.Errorf("alice line = %q, want non-admin alice", lines[0])
|
|
}
|
|
if !strings.Contains(lines[1], "root") || !strings.Contains(lines[1], "admin=true") {
|
|
t.Errorf("root line = %q, want admin root", lines[1])
|
|
}
|
|
|
|
// The password written through the CLI must actually authenticate.
|
|
db, err := core.OpenDB(cfg.DBPath)
|
|
if err != nil {
|
|
t.Fatalf("reopen db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if _, err := auth.Authenticate(context.Background(), db, "alice", "password123"); err != nil {
|
|
t.Fatalf("alice cannot authenticate after CLI add: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunUserPasswd(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
log := testLogger()
|
|
ctx := context.Background()
|
|
|
|
if _, handled, err := runCapturing(t, []string{"user", "add", "bob", "oldpassword"}, cfg, log); !handled || err != nil {
|
|
t.Fatalf("user add bob: handled=%v err=%v", handled, err)
|
|
}
|
|
|
|
// The password change goes through GetUserByUsername + SetPassword.
|
|
if _, handled, err := runCapturing(t, []string{"user", "passwd", "bob", "newpassword"}, cfg, log); !handled || err != nil {
|
|
t.Fatalf("user passwd bob: handled=%v err=%v", handled, err)
|
|
}
|
|
|
|
db, err := core.OpenDB(cfg.DBPath)
|
|
if err != nil {
|
|
t.Fatalf("reopen db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if _, err := auth.Authenticate(ctx, db, "bob", "newpassword"); err != nil {
|
|
t.Fatalf("new password does not authenticate: %v", err)
|
|
}
|
|
if _, err := auth.Authenticate(ctx, db, "bob", "oldpassword"); err == nil {
|
|
t.Error("old password still authenticates after passwd")
|
|
}
|
|
|
|
// Unknown users are reported cleanly rather than as sql.ErrNoRows.
|
|
if _, handled, err := runCapturing(t, []string{"user", "passwd", "nobody", "whatever"}, cfg, log); !handled || err == nil {
|
|
t.Errorf("passwd for unknown user: handled=%v err=%v, want handled error", handled, err)
|
|
}
|
|
}
|
|
|
|
func TestRunBackup(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
log := testLogger()
|
|
ctx := context.Background()
|
|
|
|
// Seed a row so the backup has something to carry across.
|
|
if _, handled, err := runCapturing(t, []string{"user", "add", "carol", "password123"}, cfg, log); !handled || err != nil {
|
|
t.Fatalf("user add carol: handled=%v err=%v", handled, err)
|
|
}
|
|
|
|
dest := filepath.Join(t.TempDir(), "backup.db")
|
|
handled, err := run(ctx, []string{"backup", dest}, cfg, log, io.Discard)
|
|
if !handled {
|
|
t.Fatal("backup was not handled")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
|
|
info, err := os.Stat(dest)
|
|
if err != nil {
|
|
t.Fatalf("stat backup: %v", err)
|
|
}
|
|
if info.Size() == 0 {
|
|
t.Fatal("backup file is empty")
|
|
}
|
|
|
|
// The backup must be a complete, independently usable database.
|
|
bdb, err := core.OpenDB(dest)
|
|
if err != nil {
|
|
t.Fatalf("open backup: %v", err)
|
|
}
|
|
defer bdb.Close()
|
|
if _, err := auth.GetUserByUsername(ctx, bdb, "carol"); err != nil {
|
|
t.Fatalf("backup is missing the seeded user: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunUnhandled(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
var buf bytes.Buffer
|
|
|
|
handled, err := run(context.Background(), []string{"serve"}, cfg, testLogger(), &buf)
|
|
if handled {
|
|
t.Error("unknown command reported handled=true")
|
|
}
|
|
if err != nil {
|
|
t.Errorf("unknown command returned error: %v", err)
|
|
}
|
|
|
|
handled, err = run(context.Background(), nil, cfg, testLogger(), &buf)
|
|
if handled || err != nil {
|
|
t.Errorf("empty args: handled=%v err=%v, want false,nil", handled, err)
|
|
}
|
|
}
|