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. func testConfig(t *testing.T) core.Config { t.Helper() return core.Config{DBPath: filepath.Join(t.TempDir(), "vellum.db")} } 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) } }