package cli import ( "bytes" "crypto/rand" "os" "path" "strings" "testing" "time" "postern/internal/db" "postern/internal/persistence" "github.com/spf13/cobra" ) // executeCommand helps invoke a cobra command with specific arguments and captures its stdout/stderr. func executeCommand(cmd *cobra.Command, args ...string) (string, error) { buf := new(bytes.Buffer) cmd.SetOut(buf) cmd.SetErr(buf) cmd.SetArgs(args) err := cmd.Execute() return buf.String(), err } // executeCommandWithInput is like executeCommand but also injects stdin, used // for commands that require an interactive confirmation. func executeCommandWithInput(cmd *cobra.Command, input string, args ...string) (string, error) { buf := new(bytes.Buffer) cmd.SetOut(buf) cmd.SetErr(buf) cmd.SetIn(strings.NewReader(input)) cmd.SetArgs(args) err := cmd.Execute() return buf.String(), err } // setupTestCommand builds a *Command backed by a real DB and a running HTTP // service on a temp unix socket, for exercising the client/CLI path end-to-end. func setupTestCommand(t *testing.T) (*Command, *HTTPService) { t.Helper() f := t.TempDir() mkeyPath := path.Join(f, "masterkey") secretKey := make([]byte, 32) _, _ = rand.Read(secretKey) if err := os.WriteFile(mkeyPath, secretKey, 0666); err != nil { t.Fatal(err) } p, err := persistence.New(persistence.Config{ BlobRoot: path.Join(f, "blobs"), MasterkeyFile: mkeyPath, }) if err != nil { t.Fatal(err) } posternDB, err := db.OpenDB(&db.Config{ DBPath: path.Join(f, "meta.db"), }) if err != nil { t.Fatal(err) } c, err := New(&Config{ DB: posternDB, Persistence: p, }) if err != nil { t.Fatal(err) } tempSocket := path.Join(f, "test.sock") c.socketPath = tempSocket c.httpClient = newUnixHTTPClient(tempSocket) service := NewHTTPService(tempSocket, c) go func() { _ = service.Start() }() timeout := time.After(10 * time.Second) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for service.Status() != "running" { select { case <-timeout: t.Fatal("timed out waiting for http service to start") case <-ticker.C: } } t.Cleanup(func() { _ = service.Stop() }) return c, service } // TestAddUserCommand checks the loop of creating a new user, finding it in the db and checking that the password works. func TestAddRemoveUserCommand(t *testing.T) { c, service := setupTestCommand(t) // Run the cobra command output, err := executeCommand(c.rootCmd(), "user", "add", "user1") if err != nil { t.Fatalf("expected no error, got: %v", err) } _, after, found := strings.Cut(output, "Password: ") password := strings.TrimSpace(after) if !found { t.Fatal("expected to find a password in output. Found none") } // Test that the user was created by directly accessing the DB u, err := c.db.GetUser(t.Context(), "user1") if err != nil { t.Fatalf("expected no error, got: %v", err) } err = u.VerifyPassword([]byte(password)) if err != nil { t.Fatalf("password mismatch, got: %v", err) } // Try removing user output, err = executeCommand(c.rootCmd(), "user", "remove", "user1") if err != nil { t.Fatalf("expected no error, got: %v", err) } if !strings.Contains(output, "removed") { t.Fatal("expected to find 'removed' in output. Found none") } // Try removing user again (failure) _, err = executeCommand(c.rootCmd(), "user", "remove", "user1") if err == nil { t.Errorf("expected error when deleting non-existing user, got none") } // Clean up _ = service.Stop() } // TestRotateUserPasswordCommand checks that the password is rotated, the new // password verifies, the old one no longer does, and that declining aborts. func TestRotateUserPasswordCommand(t *testing.T) { c, _ := setupTestCommand(t) // Create a user first. output, err := executeCommand(c.rootCmd(), "user", "add", "user1") if err != nil { t.Fatalf("expected no error, got: %v", err) } _, oldPassword, found := strings.Cut(output, "Password: ") if !found { t.Fatal("expected to find a password in output. Found none") } oldPassword = strings.TrimSpace(oldPassword) // Decline the confirmation: nothing should change. out, err := executeCommandWithInput(c.rootCmd(), "no\n", "user", "rotate-password", "user1") if err != nil { t.Fatalf("expected no error on decline, got: %v", err) } if !strings.Contains(out, "Aborted") { t.Fatalf("expected 'Aborted' in output, got: %q", out) } u, err := c.db.GetUser(t.Context(), "user1") if err != nil { t.Fatalf("expected no error, got: %v", err) } if err := u.VerifyPassword([]byte(oldPassword)); err != nil { t.Fatalf("old password should still verify after decline, got: %v", err) } // Confirm the rotation. out, err = executeCommandWithInput(c.rootCmd(), "yes\n", "user", "rotate-password", "user1") if err != nil { t.Fatalf("expected no error, got: %v", err) } _, newPassword, found := strings.Cut(out, "Password: ") if !found { t.Fatal("expected to find a new password in output. Found none") } newPassword = strings.TrimSpace(newPassword) if newPassword == oldPassword { t.Fatal("expected a different password after rotation") } u, err = c.db.GetUser(t.Context(), "user1") if err != nil { t.Fatalf("expected no error, got: %v", err) } if err := u.VerifyPassword([]byte(newPassword)); err != nil { t.Fatalf("new password mismatch, got: %v", err) } if err := u.VerifyPassword([]byte(oldPassword)); err == nil { t.Fatal("old password should no longer verify after rotation") } // Rotating a non-existent user should error. _, err = executeCommandWithInput(c.rootCmd(), "yes\n", "user", "rotate-password", "ghost") if err == nil { t.Error("expected error when rotating non-existing user, got none") } }