Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+663
View File
@@ -0,0 +1,663 @@
package main
import (
"bufio"
"context"
"crypto/rand"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/url"
"os"
"slices"
"strings"
"text/tabwriter"
"time"
"gardomatic.kleiax.de/internal/auth"
"gardomatic.kleiax.de/internal/mailer"
"gardomatic.kleiax.de/internal/platform/validate"
"gardomatic.kleiax.de/internal/storage"
"gardomatic.kleiax.de/internal/vcs"
"golang.org/x/term"
)
const activationTokenTTL = 3 * 24 * time.Hour
type application struct {
stdin io.Reader
stdout io.Writer
stderr io.Writer
openStore func(string) (adminStore, error)
newMailer func(mailer.Config) (*mailer.Mailer, error)
}
func newApplication(stdin io.Reader, stdout, stderr io.Writer) *application {
return &application{
stdin: stdin,
stdout: stdout,
stderr: stderr,
openStore: openPostgresStore,
newMailer: mailer.New,
}
}
func (app *application) run(ctx context.Context, args []string) error {
cfg, err := configFromEnvironment()
if err != nil {
return err
}
global := flag.NewFlagSet("gardomatic", flag.ContinueOnError)
global.SetOutput(app.stderr)
global.StringVar(&cfg.dsn, "dsn", cfg.dsn, "PostgreSQL DSN (default: GARDOMATIC_DB_DSN)")
global.StringVar(&cfg.environment, "env", cfg.environment, "environment name")
global.StringVar(&cfg.webBaseURL, "web-base-url", cfg.webBaseURL, "public web base URL")
global.BoolVar(&cfg.json, "json", false, "write JSON output")
global.BoolVar(&cfg.yes, "yes", false, "skip production confirmation")
global.Usage = func() { app.printUsage(global) }
if err = global.Parse(args); err != nil {
return err
}
remaining := global.Args()
if len(remaining) == 0 {
global.Usage()
return nil
}
if remaining[0] == "help" || remaining[0] == "--help" || remaining[0] == "-h" {
global.Usage()
return nil
}
if slices.Contains(remaining[1:], "--help") || slices.Contains(remaining[1:], "-h") {
global.Usage()
return nil
}
if remaining[0] == "version" {
return app.writeValue(cfg, map[string]string{"version": vcs.Version()}, "Version: %s\n", vcs.Version())
}
store, err := app.openStore(cfg.dsn)
if err != nil {
return err
}
defer store.Close()
switch remaining[0] {
case "users":
return app.runUsers(ctx, cfg, store, remaining[1:])
case "gardens":
return app.runGardens(ctx, cfg, store, remaining[1:])
case "db":
return app.runDB(ctx, cfg, store, remaining[1:])
default:
return fmt.Errorf("unknown command %q; run gardomatic help", remaining[0])
}
}
func (app *application) runUsers(ctx context.Context, cfg config, store adminStore, args []string) error {
if len(args) == 0 {
return errors.New("missing users command: create, list, show, activate, deactivate, invite, reset-password, or set-role")
}
switch args[0] {
case "create":
return app.createUser(ctx, cfg, store, args[1:])
case "list":
return app.listUsers(ctx, cfg, store, args[1:])
case "show":
return app.showUser(ctx, cfg, store, args[1:])
case "activate":
return app.setUserActivated(ctx, cfg, store, args[1:], true)
case "deactivate":
return app.setUserActivated(ctx, cfg, store, args[1:], false)
case "invite":
return app.inviteUser(ctx, cfg, store, args[1:])
case "reset-password":
return app.resetPassword(ctx, cfg, store, args[1:])
case "set-role":
return app.setUserRole(ctx, cfg, store, args[1:])
default:
return fmt.Errorf("unknown users command %q", args[0])
}
}
func (app *application) runGardens(ctx context.Context, cfg config, store adminStore, args []string) error {
if len(args) == 0 {
return errors.New("missing gardens command: add-user")
}
if args[0] != "add-user" {
return fmt.Errorf("unknown gardens command %q", args[0])
}
return app.addGardenMember(ctx, cfg, store, args[1:])
}
func (app *application) setUserRole(ctx context.Context, cfg config, store adminStore, args []string) error {
fs := newFlagSet("users set-role", app.stderr)
var email, role string
fs.StringVar(&email, "email", "", "email address (required)")
fs.StringVar(&role, "role", "", "application role (required)")
if err := parseFlags(fs, args); err != nil {
return err
}
email, role = strings.TrimSpace(email), strings.TrimSpace(role)
if err := validateEmail(email); err != nil {
return err
}
if role != string(storage.ApplicationRoleUser) && role != string(storage.ApplicationRoleAdmin) {
return errors.New("role must be application:user or application:admin")
}
user, err := store.SetUserRole(ctx, email, role)
if err != nil {
return userError(email, err)
}
return app.writeUser(cfg, user)
}
func (app *application) addGardenMember(ctx context.Context, cfg config, store adminStore, args []string) error {
fs := newFlagSet("gardens add-user", app.stderr)
var gardenID int
var email, role string
fs.IntVar(&gardenID, "garden-id", 0, "garden ID (required)")
fs.StringVar(&email, "email", "", "email address (required)")
fs.StringVar(&role, "role", string(storage.GardenRoleMember), "garden role")
if err := parseFlags(fs, args); err != nil {
return err
}
email, role = strings.TrimSpace(email), strings.TrimSpace(role)
if gardenID < 1 {
return errors.New("garden-id must be a positive integer")
}
if err := validateEmail(email); err != nil {
return err
}
validRole := role == string(storage.GardenRoleOwner) || role == string(storage.GardenRoleAdmin) || role == string(storage.GardenRoleMember) || role == string(storage.GardenRoleViewer) || role == string(storage.GardenRoleWorker)
if !validRole {
return errors.New("role must be owner, admin, member, viewer, or worker")
}
member, err := store.AddGardenMember(ctx, gardenID, email, role)
if err != nil {
if errors.Is(err, storage.ErrRecordNotFound) {
return fmt.Errorf("garden %d, user %s, or role %s was not found", gardenID, email, role)
}
return err
}
if cfg.json {
return writeJSON(app.stdout, member)
}
_, err = fmt.Fprintf(app.stdout, "User: %s\nGarden: %d\nRole: %s\n", member.Email, member.GardenID, member.Role)
return err
}
func (app *application) createUser(ctx context.Context, cfg config, store adminStore, args []string) error {
fs := newFlagSet("users create", app.stderr)
var name, email, role string
var active, invite, generatePassword, passwordStdin, sendEmail bool
fs.StringVar(&name, "name", "", "display name (required)")
fs.StringVar(&email, "email", "", "email address (required)")
fs.StringVar(&role, "role", string(storage.ApplicationRoleUser), "application role")
fs.BoolVar(&active, "active", false, "create an activated account")
fs.BoolVar(&invite, "invite", false, "create an activation token")
fs.BoolVar(&generatePassword, "generate-password", false, "generate a secure password")
fs.BoolVar(&passwordStdin, "password-stdin", false, "read the password from standard input")
fs.BoolVar(&sendEmail, "send-email", false, "send the invitation using configured mail settings")
if err := parseFlags(fs, args); err != nil {
return err
}
name = strings.TrimSpace(name)
email = strings.TrimSpace(email)
role = strings.TrimSpace(role)
if active == invite {
return errors.New("exactly one of --active or --invite is required")
}
if sendEmail && !invite {
return errors.New("--send-email requires --invite")
}
if generatePassword && passwordStdin {
return errors.New("--generate-password and --password-stdin are mutually exclusive")
}
if err := validateIdentity(name, email); err != nil {
return err
}
if role != string(storage.ApplicationRoleUser) && role != string(storage.ApplicationRoleAdmin) {
return errors.New("role must be application:user or application:admin")
}
password, generated, err := app.obtainPassword(generatePassword, passwordStdin)
if err != nil {
return err
}
passwordHash, err := hashPassword(password)
if err != nil {
return err
}
result, err := store.CreateUser(ctx, createUserInput{
Name: name,
Email: email,
PasswordHash: passwordHash,
Activated: active,
Role: role,
Invite: invite,
TokenTTL: activationTokenTTL,
})
if err != nil {
if errors.Is(err, storage.ErrDuplicateEmail) {
return fmt.Errorf("a user with email %s already exists", email)
}
return err
}
output := userMutationOutput{User: result.User}
if generated {
output.GeneratedPassword = password
}
if result.Token != nil {
output.ActivationToken = result.Token.Plaintext
output.ActivationURL, err = activationURL(cfg.webBaseURL, result.Token.Plaintext)
if err != nil {
return err
}
output.TokenExpiry = &result.Token.Expiry
if sendEmail {
if err = app.sendInvitation(cfg, result.User, *result.Token, output.ActivationURL, true); err != nil {
return fmt.Errorf("user was created, but sending invitation failed: %w", err)
}
output.EmailSent = true
}
}
return app.writeUserMutation(cfg, output)
}
func (app *application) listUsers(ctx context.Context, cfg config, store adminStore, args []string) error {
fs := newFlagSet("users list", app.stderr)
if err := parseFlags(fs, args); err != nil {
return err
}
users, err := store.ListUsers(ctx)
if err != nil {
return err
}
if cfg.json {
return writeJSON(app.stdout, users)
}
tw := tabwriter.NewWriter(app.stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tEMAIL\tACTIVE\tROLE\tCREATED")
for _, user := range users {
fmt.Fprintf(tw, "%d\t%s\t%s\t%t\t%s\t%s\n", user.ID, user.Name, user.Email, user.Activated, user.Role, user.CreatedAt.Format(time.RFC3339))
}
return tw.Flush()
}
func (app *application) showUser(ctx context.Context, cfg config, store adminStore, args []string) error {
fs := newFlagSet("users show", app.stderr)
var email string
fs.StringVar(&email, "email", "", "email address (required)")
if err := parseFlags(fs, args); err != nil {
return err
}
if err := validateEmail(email); err != nil {
return err
}
user, err := store.GetUser(ctx, strings.TrimSpace(email))
if err != nil {
return userError(email, err)
}
return app.writeUser(cfg, user)
}
func (app *application) setUserActivated(ctx context.Context, cfg config, store adminStore, args []string, active bool) error {
command := "activate"
if !active {
command = "deactivate"
}
fs := newFlagSet("users "+command, app.stderr)
var email string
fs.StringVar(&email, "email", "", "email address (required)")
if err := parseFlags(fs, args); err != nil {
return err
}
email = strings.TrimSpace(email)
if err := validateEmail(email); err != nil {
return err
}
if !active {
if err := app.confirmProduction(cfg, "deactivate user "+email); err != nil {
return err
}
}
user, err := store.SetActivated(ctx, email, active)
if err != nil {
return userError(email, err)
}
return app.writeUser(cfg, user)
}
func (app *application) inviteUser(ctx context.Context, cfg config, store adminStore, args []string) error {
fs := newFlagSet("users invite", app.stderr)
var email string
var sendEmail bool
fs.StringVar(&email, "email", "", "email address (required)")
fs.BoolVar(&sendEmail, "send-email", false, "send the invitation using configured mail settings")
if err := parseFlags(fs, args); err != nil {
return err
}
email = strings.TrimSpace(email)
if err := validateEmail(email); err != nil {
return err
}
user, token, err := store.IssueInvitation(ctx, email, activationTokenTTL)
if err != nil {
return userError(email, err)
}
link, err := activationURL(cfg.webBaseURL, token.Plaintext)
if err != nil {
return err
}
output := userMutationOutput{
User: user,
ActivationToken: token.Plaintext,
ActivationURL: link,
TokenExpiry: &token.Expiry,
}
if sendEmail {
if err = app.sendInvitation(cfg, user, token, link, false); err != nil {
return fmt.Errorf("invitation token was created, but sending email failed: %w", err)
}
output.EmailSent = true
}
return app.writeUserMutation(cfg, output)
}
func (app *application) resetPassword(ctx context.Context, cfg config, store adminStore, args []string) error {
fs := newFlagSet("users reset-password", app.stderr)
var email string
var generatePassword, passwordStdin bool
fs.StringVar(&email, "email", "", "email address (required)")
fs.BoolVar(&generatePassword, "generate-password", false, "generate a secure password")
fs.BoolVar(&passwordStdin, "password-stdin", false, "read the password from standard input")
if err := parseFlags(fs, args); err != nil {
return err
}
email = strings.TrimSpace(email)
if err := validateEmail(email); err != nil {
return err
}
if generatePassword && passwordStdin {
return errors.New("--generate-password and --password-stdin are mutually exclusive")
}
if err := app.confirmProduction(cfg, "reset password for "+email); err != nil {
return err
}
password, generated, err := app.obtainPassword(generatePassword, passwordStdin)
if err != nil {
return err
}
hash, err := hashPassword(password)
if err != nil {
return err
}
user, err := store.ResetPassword(ctx, email, hash)
if err != nil {
return userError(email, err)
}
output := userMutationOutput{User: user}
if generated {
output.GeneratedPassword = password
}
return app.writeUserMutation(cfg, output)
}
func (app *application) runDB(ctx context.Context, cfg config, store adminStore, args []string) error {
if len(args) != 1 || args[0] != "ping" {
return errors.New("usage: gardomatic db ping")
}
if err := store.Ping(ctx); err != nil {
return err
}
return app.writeValue(cfg, map[string]string{"status": "ok"}, "database: ok\n")
}
type userMutationOutput struct {
User userView `json:"user"`
ActivationToken string `json:"activation_token,omitempty"`
ActivationURL string `json:"activation_url,omitempty"`
TokenExpiry *time.Time `json:"token_expiry,omitempty"`
GeneratedPassword string `json:"generated_password,omitempty"`
EmailSent bool `json:"email_sent,omitempty"`
}
func (app *application) writeUserMutation(cfg config, output userMutationOutput) error {
if cfg.json {
return writeJSON(app.stdout, output)
}
if err := app.writeUser(cfg, output.User); err != nil {
return err
}
if output.GeneratedPassword != "" {
fmt.Fprintf(app.stdout, "Generated password: %s\n", output.GeneratedPassword)
}
if output.ActivationToken != "" {
fmt.Fprintf(app.stdout, "Activation token: %s\n", output.ActivationToken)
fmt.Fprintf(app.stdout, "Activation URL: %s\n", output.ActivationURL)
fmt.Fprintf(app.stdout, "Token expires: %s\n", output.TokenExpiry.Format(time.RFC3339))
}
if output.EmailSent {
fmt.Fprintln(app.stdout, "Invitation email sent.")
}
return nil
}
func (app *application) writeUser(cfg config, user userView) error {
if cfg.json {
return writeJSON(app.stdout, user)
}
tw := tabwriter.NewWriter(app.stdout, 0, 4, 2, ' ', 0)
fmt.Fprintf(tw, "ID:\t%d\n", user.ID)
fmt.Fprintf(tw, "Name:\t%s\n", user.Name)
fmt.Fprintf(tw, "Email:\t%s\n", user.Email)
fmt.Fprintf(tw, "Active:\t%t\n", user.Activated)
fmt.Fprintf(tw, "Role:\t%s\n", user.Role)
return tw.Flush()
}
func (app *application) writeValue(cfg config, value any, format string, args ...any) error {
if cfg.json {
return writeJSON(app.stdout, value)
}
_, err := fmt.Fprintf(app.stdout, format, args...)
return err
}
func (app *application) obtainPassword(generate, fromStdin bool) (string, bool, error) {
if generate {
return rand.Text(), true, nil
}
if fromStdin {
password, err := bufio.NewReader(app.stdin).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", false, err
}
password = strings.TrimRight(password, "\r\n")
if err = validatePassword(password); err != nil {
return "", false, err
}
return password, false, nil
}
input, ok := app.stdin.(*os.File)
if !ok || !term.IsTerminal(int(input.Fd())) {
return "", false, errors.New("standard input is not a terminal; use --password-stdin or --generate-password")
}
fmt.Fprint(app.stderr, "Password: ")
first, err := term.ReadPassword(int(input.Fd()))
fmt.Fprintln(app.stderr)
if err != nil {
return "", false, err
}
fmt.Fprint(app.stderr, "Repeat password: ")
second, err := term.ReadPassword(int(input.Fd()))
fmt.Fprintln(app.stderr)
if err != nil {
return "", false, err
}
if string(first) != string(second) {
return "", false, errors.New("passwords do not match")
}
if err = validatePassword(string(first)); err != nil {
return "", false, err
}
return string(first), false, nil
}
func (app *application) confirmProduction(cfg config, action string) error {
if !strings.EqualFold(cfg.environment, "production") || cfg.yes {
return nil
}
fmt.Fprintf(app.stderr, "Production: %s. Continue? [y/N] ", action)
answer, err := bufio.NewReader(app.stdin).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return err
}
if strings.ToLower(strings.TrimSpace(answer)) != "y" {
return errors.New("operation cancelled")
}
return nil
}
func (app *application) sendInvitation(cfg config, user userView, token auth.Token, link string, welcome bool) error {
m, err := app.newMailer(cfg.mail)
if err != nil {
return err
}
template := "token_activation.tmpl"
if welcome {
template = "user_welcome.tmpl"
}
return m.Send(user.Email, template, map[string]any{
"activationToken": token.Plaintext,
"activationURL": link,
"userID": user.ID,
})
}
func (app *application) printUsage(fs *flag.FlagSet) {
fmt.Fprintln(app.stderr, `Gardomatic administration CLI
Usage:
gardomatic [global options] users create --name NAME --email EMAIL (--active|--invite) [--role ROLE] [options]
gardomatic [global options] users list
gardomatic [global options] users show --email EMAIL
gardomatic [global options] users activate|deactivate --email EMAIL
gardomatic [global options] users invite --email EMAIL [--send-email]
gardomatic [global options] users reset-password --email EMAIL [options]
gardomatic [global options] users set-role --email EMAIL --role ROLE
gardomatic [global options] gardens add-user --garden-id ID --email EMAIL [--role ROLE]
gardomatic [global options] db ping
gardomatic [global options] version
Global options:`)
fs.PrintDefaults()
}
func activationURL(baseURL, token string) (string, error) {
u, err := url.Parse(strings.TrimRight(baseURL, "/") + "/activate")
if err != nil {
return "", fmt.Errorf("invalid web base URL: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return "", errors.New("web base URL must include scheme and host")
}
query := u.Query()
query.Set("token", token)
u.RawQuery = query.Encode()
return u.String(), nil
}
func validateIdentity(name, email string) error {
password := auth.NewPassword([]byte("placeholder"))
user := storage.User{Name: name, Email: email, Password: *password}
v := validate.New()
storage.ValidateUser(v, user)
delete(v.Errors, "password")
if !v.Valid() {
return validationError(v.Errors)
}
return nil
}
func validateEmail(email string) error {
v := validate.New()
storage.ValidateEmail(v, strings.TrimSpace(email))
if !v.Valid() {
return validationError(v.Errors)
}
return nil
}
func validatePassword(password string) error {
v := validate.New()
auth.ValidatePasswordPlaintext(v, password)
if !v.Valid() {
return validationError(v.Errors)
}
return nil
}
func hashPassword(plaintext string) ([]byte, error) {
if err := validatePassword(plaintext); err != nil {
return nil, err
}
var password auth.Password
if err := password.Set(plaintext); err != nil {
return nil, err
}
return password.Get(), nil
}
func validationError(fields map[string]string) error {
keys := make([]string, 0, len(fields))
for key := range fields {
keys = append(keys, key)
}
slices.Sort(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+" "+fields[key])
}
return errors.New(strings.Join(parts, "; "))
}
func userError(email string, err error) error {
switch {
case errors.Is(err, errUserNotFound):
return fmt.Errorf("no user found with email %s", email)
case errors.Is(err, errUserAlreadyActive):
return fmt.Errorf("user %s is already active", email)
default:
return err
}
}
func newFlagSet(name string, output io.Writer) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(output)
return fs
}
func parseFlags(fs *flag.FlagSet, args []string) error {
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
return fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
return nil
}
func writeJSON(w io.Writer, value any) error {
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
encoder.SetEscapeHTML(false)
return encoder.Encode(value)
}
+272
View File
@@ -0,0 +1,272 @@
package main
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gardomatic.kleiax.de/internal/auth"
"golang.org/x/crypto/bcrypt"
)
type fakeAdminStore struct {
createInput createUserInput
createCalls int
setActivatedCalls int
inviteCalls int
setRoleCalls int
addMemberCalls int
lastRole string
}
func (s *fakeAdminStore) Ping(context.Context) error { return nil }
func (s *fakeAdminStore) Close() error { return nil }
func (s *fakeAdminStore) CreateUser(_ context.Context, input createUserInput) (createUserResult, error) {
s.createCalls++
s.createInput = input
result := createUserResult{User: userView{
ID: 42,
Name: input.Name,
Email: input.Email,
Activated: input.Activated,
Role: input.Role,
CreatedAt: time.Date(2026, time.August, 31, 10, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, time.August, 31, 10, 0, 0, 0, time.UTC),
}}
if input.Invite {
token := auth.NewToken(42, input.TokenTTL, auth.ScopeActivation)
result.Token = &token
}
return result, nil
}
func (s *fakeAdminStore) ListUsers(context.Context) ([]userView, error) { return nil, nil }
func (s *fakeAdminStore) GetUser(context.Context, string) (userView, error) {
return userView{}, errUserNotFound
}
func (s *fakeAdminStore) SetActivated(_ context.Context, email string, active bool) (userView, error) {
s.setActivatedCalls++
return userView{ID: 42, Email: email, Activated: active}, nil
}
func (s *fakeAdminStore) IssueInvitation(_ context.Context, email string, ttl time.Duration) (userView, auth.Token, error) {
s.inviteCalls++
return userView{ID: 42, Name: "Alice", Email: email}, auth.NewToken(42, ttl, auth.ScopeActivation), nil
}
func (s *fakeAdminStore) ResetPassword(context.Context, string, []byte) (userView, error) {
return userView{}, nil
}
func (s *fakeAdminStore) SetUserRole(_ context.Context, email, role string) (userView, error) {
s.setRoleCalls++
s.lastRole = role
return userView{ID: 42, Email: email, Role: role}, nil
}
func (s *fakeAdminStore) AddGardenMember(_ context.Context, gardenID int, email, role string) (gardenMemberView, error) {
s.addMemberCalls++
s.lastRole = role
return gardenMemberView{GardenID: gardenID, UserID: 42, Email: email, Role: role}, nil
}
func newTestApplication(t *testing.T, stdin string, store adminStore) (*application, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv("GARDOMATIC_DB_DSN", "postgres://unused")
t.Setenv("GARDOMATIC_ENV", "development")
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
app := newApplication(strings.NewReader(stdin), stdout, stderr)
app.openStore = func(string) (adminStore, error) { return store, nil }
return app, stdout, stderr
}
func TestCreateInvitedUserGeneratesCredentialsAndJSON(t *testing.T) {
store := new(fakeAdminStore)
app, stdout, _ := newTestApplication(t, "", store)
err := app.run(context.Background(), []string{
"--json",
"--web-base-url", "https://gardomatic.example/app",
"users", "create",
"--name", " Alice ",
"--email", "alice@example.com",
"--invite",
"--generate-password",
})
if err != nil {
t.Fatalf("run() returned an error: %v", err)
}
if store.createCalls != 1 {
t.Fatalf("CreateUser calls = %d, want 1", store.createCalls)
}
if store.createInput.Name != "Alice" || !store.createInput.Invite || store.createInput.Activated {
t.Errorf("CreateUser input = %+v", store.createInput)
}
var output userMutationOutput
if err = json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("decoding output: %v; output: %s", err, stdout.String())
}
if output.GeneratedPassword == "" {
t.Fatal("generated password is missing")
}
if err = bcrypt.CompareHashAndPassword(store.createInput.PasswordHash, []byte(output.GeneratedPassword)); err != nil {
t.Errorf("stored password hash does not match generated password: %v", err)
}
if !strings.HasPrefix(output.ActivationURL, "https://gardomatic.example/app/activate?token=") {
t.Errorf("activation URL = %q", output.ActivationURL)
}
if output.ActivationToken == "" || !strings.Contains(output.ActivationURL, output.ActivationToken) {
t.Errorf("activation token and URL do not match: %+v", output)
}
}
func TestCreateUserRequiresExactlyOneAccountMode(t *testing.T) {
store := new(fakeAdminStore)
app, _, _ := newTestApplication(t, "", store)
err := app.run(context.Background(), []string{
"users", "create",
"--name", "Alice",
"--email", "alice@example.com",
"--generate-password",
})
if err == nil || !strings.Contains(err.Error(), "exactly one") {
t.Fatalf("run() error = %v, want account mode error", err)
}
if store.createCalls != 0 {
t.Errorf("CreateUser calls = %d, want 0", store.createCalls)
}
}
func TestCreateUserCanAtomicallyCreateApplicationAdmin(t *testing.T) {
store := new(fakeAdminStore)
app, stdout, _ := newTestApplication(t, "", store)
err := app.run(context.Background(), []string{
"users", "create",
"--name", "Initial Admin",
"--email", "admin@example.com",
"--role", "application:admin",
"--active",
"--generate-password",
})
if err != nil {
t.Fatalf("run() returned an error: %v", err)
}
if store.createInput.Role != "application:admin" {
t.Fatalf("created role = %q, want application:admin", store.createInput.Role)
}
if !strings.Contains(stdout.String(), "application:admin") {
t.Fatalf("output does not contain admin role: %s", stdout.String())
}
}
func TestCreateUserRejectsUnknownApplicationRole(t *testing.T) {
store := new(fakeAdminStore)
app, _, _ := newTestApplication(t, "", store)
err := app.run(context.Background(), []string{
"users", "create",
"--name", "Alice",
"--email", "alice@example.com",
"--role", "superadmin",
"--active",
"--generate-password",
})
if err == nil || !strings.Contains(err.Error(), "role must be") {
t.Fatalf("run() error = %v, want role validation error", err)
}
if store.createCalls != 0 {
t.Fatalf("CreateUser calls = %d, want 0", store.createCalls)
}
}
func TestProductionDeactivationRequiresConfirmation(t *testing.T) {
store := new(fakeAdminStore)
app, _, _ := newTestApplication(t, "n\n", store)
err := app.run(context.Background(), []string{
"--env", "production",
"users", "deactivate", "--email", "alice@example.com",
})
if err == nil || err.Error() != "operation cancelled" {
t.Fatalf("run() error = %v, want operation cancelled", err)
}
if store.setActivatedCalls != 0 {
t.Errorf("SetActivated calls = %d, want 0", store.setActivatedCalls)
}
}
func TestInviteCanWriteEmailWithActivationLink(t *testing.T) {
store := new(fakeAdminStore)
app, _, _ := newTestApplication(t, "", store)
mailPath := filepath.Join(t.TempDir(), "mail.log")
t.Setenv("GARDOMATIC_SMTP_MODE", "file")
t.Setenv("GARDOMATIC_SMTP_FILE_PATH", mailPath)
err := app.run(context.Background(), []string{
"--web-base-url", "https://gardomatic.example",
"users", "invite", "--email", "alice@example.com", "--send-email",
})
if err != nil {
t.Fatalf("run() returned an error: %v", err)
}
content, err := os.ReadFile(mailPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "https://gardomatic.example/activate?token=") {
t.Errorf("mail does not contain activation URL: %s", content)
}
}
func TestActivationURLRejectsRelativeBase(t *testing.T) {
_, err := activationURL("localhost:4040", "token")
if err == nil {
t.Fatal("activationURL() returned no error")
}
}
func TestSetUserRoleCanMakeApplicationAdmin(t *testing.T) {
store := new(fakeAdminStore)
app, stdout, _ := newTestApplication(t, "", store)
err := app.run(context.Background(), []string{"users", "set-role", "--email", "alice@example.com", "--role", "application:admin"})
if err != nil {
t.Fatal(err)
}
if store.setRoleCalls != 1 || store.lastRole != "application:admin" || !strings.Contains(stdout.String(), "application:admin") {
t.Fatalf("role update missing: calls=%d role=%q output=%q", store.setRoleCalls, store.lastRole, stdout.String())
}
}
func TestAddUserToGardenWithAdminRole(t *testing.T) {
store := new(fakeAdminStore)
app, stdout, _ := newTestApplication(t, "", store)
err := app.run(context.Background(), []string{"gardens", "add-user", "--garden-id", "7", "--email", "alice@example.com", "--role", "admin"})
if err != nil {
t.Fatal(err)
}
if store.addMemberCalls != 1 || store.lastRole != "admin" || !strings.Contains(stdout.String(), "Garden: 7") {
t.Fatalf("garden membership missing: calls=%d role=%q output=%q", store.addMemberCalls, store.lastRole, stdout.String())
}
}
func TestRoleCommandsRejectUnknownRoles(t *testing.T) {
tests := [][]string{
{"users", "set-role", "--email", "alice@example.com", "--role", "superadmin"},
{"gardens", "add-user", "--garden-id", "7", "--email", "alice@example.com", "--role", "superadmin"},
}
for _, args := range tests {
store := new(fakeAdminStore)
app, _, _ := newTestApplication(t, "", store)
if err := app.run(context.Background(), args); err == nil || !strings.Contains(err.Error(), "role must be") {
t.Errorf("run(%v) error = %v, want role validation error", args, err)
}
if store.setRoleCalls != 0 || store.addMemberCalls != 0 {
t.Errorf("run(%v) reached store", args)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
"gardomatic.kleiax.de/internal/mailer"
)
type config struct {
dsn string
environment string
webBaseURL string
json bool
yes bool
mail mailer.Config
}
func configFromEnvironment() (config, error) {
port, err := envInt("GARDOMATIC_SMTP_PORT", 25)
if err != nil {
return config{}, err
}
return config{
dsn: os.Getenv("GARDOMATIC_DB_DSN"),
environment: envString("GARDOMATIC_ENV", "development"),
webBaseURL: envString("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
mail: mailer.Config{
Mode: mailer.Mode(envString("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))),
Host: os.Getenv("GARDOMATIC_SMTP_HOST"),
Port: port,
Username: os.Getenv("GARDOMATIC_SMTP_USERNAME"),
Password: os.Getenv("GARDOMATIC_SMTP_PASSWORD"),
Sender: envString("GARDOMATIC_SMTP_SENDER", "gardomatic@localhost"),
FilePath: envString("GARDOMATIC_SMTP_FILE_PATH", "/tmp/gardomatic-mails.log"),
},
}, nil
}
func envString(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func envInt(name string, fallback int) (int, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback, nil
}
n, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s must be an integer: %w", name, err)
}
return n, nil
}
+2
View File
@@ -0,0 +1,2 @@
// Package main provides the Gardomatic administration command-line tool.
package main
+15
View File
@@ -0,0 +1,15 @@
package main
import (
"context"
"fmt"
"os"
)
func main() {
app := newApplication(os.Stdin, os.Stdout, os.Stderr)
if err := app.run(context.Background(), os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
+336
View File
@@ -0,0 +1,336 @@
package main
import (
"context"
"database/sql"
"errors"
"time"
"gardomatic.kleiax.de/internal/auth"
"gardomatic.kleiax.de/internal/storage"
"github.com/lib/pq"
)
var (
errUserNotFound = errors.New("user not found")
errUserAlreadyActive = errors.New("user is already active")
)
type userView struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Activated bool `json:"activated"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type gardenMemberView struct {
GardenID int `json:"garden_id"`
UserID int `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
}
type createUserInput struct {
Name string
Email string
PasswordHash []byte
Activated bool
Role string
Invite bool
TokenTTL time.Duration
}
type createUserResult struct {
User userView
Token *auth.Token
}
type adminStore interface {
Ping(context.Context) error
CreateUser(context.Context, createUserInput) (createUserResult, error)
ListUsers(context.Context) ([]userView, error)
GetUser(context.Context, string) (userView, error)
SetActivated(context.Context, string, bool) (userView, error)
IssueInvitation(context.Context, string, time.Duration) (userView, auth.Token, error)
ResetPassword(context.Context, string, []byte) (userView, error)
SetUserRole(context.Context, string, string) (userView, error)
AddGardenMember(context.Context, int, string, string) (gardenMemberView, error)
Close() error
}
type postgresStore struct {
db *sql.DB
}
func openPostgresStore(dsn string) (adminStore, error) {
if dsn == "" {
return nil, errors.New("database DSN is missing; set GARDOMATIC_DB_DSN or use --dsn")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(2)
db.SetConnMaxIdleTime(5 * time.Minute)
return &postgresStore{db: db}, nil
}
// Close releases the CLI database pool.
func (s *postgresStore) Close() error { return s.db.Close() }
// Ping verifies that the CLI can reach PostgreSQL within a bounded timeout.
func (s *postgresStore) Ping(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.db.PingContext(ctx)
}
// CreateUser creates an account and optionally an invitation token atomically.
func (s *postgresStore) CreateUser(ctx context.Context, input createUserInput) (createUserResult, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return createUserResult{}, err
}
defer tx.Rollback()
var result createUserResult
err = tx.QueryRowContext(ctx, `
INSERT INTO users (name, email, password_hash, activated, application_role)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, email, activated, application_role, created_at, updated_at`,
input.Name, input.Email, input.PasswordHash, input.Activated, input.Role,
).Scan(
&result.User.ID,
&result.User.Name,
&result.User.Email,
&result.User.Activated,
&result.User.Role,
&result.User.CreatedAt,
&result.User.UpdatedAt,
)
if err != nil {
return createUserResult{}, mapPostgresError(err)
}
if input.Invite {
token := auth.NewToken(result.User.ID, input.TokenTTL, auth.ScopeActivation)
if _, err = tx.ExecContext(ctx, `
INSERT INTO tokens (hash, user_id, expiry, scope)
VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil {
return createUserResult{}, err
}
result.Token = &token
}
if err = tx.Commit(); err != nil {
return createUserResult{}, err
}
return result, nil
}
// ListUsers returns all non-deleted accounts for CLI administration.
func (s *postgresStore) ListUsers(ctx context.Context) ([]userView, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, email, activated, application_role, created_at, updated_at
FROM users
WHERE deleted_at IS NULL
ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]userView, 0)
for rows.Next() {
var user userView
if err = rows.Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt); err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
// GetUser returns a non-deleted account by email.
func (s *postgresStore) GetUser(ctx context.Context, email string) (userView, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return getUser(ctx, s.db, email)
}
// SetActivated changes whether an account may authenticate.
func (s *postgresStore) SetActivated(ctx context.Context, email string, activated bool) (userView, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return userView{}, err
}
defer tx.Rollback()
var user userView
err = tx.QueryRowContext(ctx, `
UPDATE users
SET activated = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1
WHERE email = $1 AND deleted_at IS NULL
RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, activated,
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return userView{}, errUserNotFound
}
if err != nil {
return userView{}, err
}
if activated {
if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil {
return userView{}, err
}
}
if err = tx.Commit(); err != nil {
return userView{}, err
}
return user, nil
}
// IssueInvitation replaces an account's activation token.
func (s *postgresStore) IssueInvitation(ctx context.Context, email string, ttl time.Duration) (userView, auth.Token, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return userView{}, auth.Token{}, err
}
defer tx.Rollback()
user, err := getUser(ctx, tx, email)
if err != nil {
return userView{}, auth.Token{}, err
}
if user.Activated {
return userView{}, auth.Token{}, errUserAlreadyActive
}
if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil {
return userView{}, auth.Token{}, err
}
token := auth.NewToken(user.ID, ttl, auth.ScopeActivation)
if _, err = tx.ExecContext(ctx, `
INSERT INTO tokens (hash, user_id, expiry, scope)
VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil {
return userView{}, auth.Token{}, err
}
if err = tx.Commit(); err != nil {
return userView{}, auth.Token{}, err
}
return user, token, nil
}
// ResetPassword replaces an account password hash and revokes authentication tokens.
func (s *postgresStore) ResetPassword(ctx context.Context, email string, passwordHash []byte) (userView, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return userView{}, err
}
defer tx.Rollback()
var user userView
err = tx.QueryRowContext(ctx, `
UPDATE users
SET password_hash = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1
WHERE email = $1 AND deleted_at IS NULL
RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, passwordHash,
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return userView{}, errUserNotFound
}
if err != nil {
return userView{}, err
}
if _, err = tx.ExecContext(ctx, `
DELETE FROM tokens
WHERE user_id = $1 AND scope = ANY($2)`, user.ID, pq.Array([]string{auth.ScopeAuthentication, auth.ScopePasswordReset})); err != nil {
return userView{}, err
}
if err = tx.Commit(); err != nil {
return userView{}, err
}
return user, nil
}
// SetUserRole changes an account's application role.
func (s *postgresStore) SetUserRole(ctx context.Context, email, role string) (userView, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var user userView
err := s.db.QueryRowContext(ctx, `
UPDATE users SET application_role=$2, updated_at=CURRENT_TIMESTAMP, version=version+1
WHERE email=$1 AND deleted_at IS NULL
RETURNING id,name,email,activated,application_role,created_at,updated_at`, email, role,
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return userView{}, errUserNotFound
}
return user, err
}
// AddGardenMember adds or updates a user's membership in a garden.
func (s *postgresStore) AddGardenMember(ctx context.Context, gardenID int, email, role string) (gardenMemberView, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var member gardenMemberView
err := s.db.QueryRowContext(ctx, `
INSERT INTO garden_members(garden_id,user_id,role)
SELECT $1,u.id,$3 FROM users u
WHERE u.email=$2 AND u.deleted_at IS NULL
AND EXISTS (SELECT 1 FROM gardens WHERE id=$1)
AND EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
RETURNING garden_id,user_id,$2,role,joined_at`, gardenID, email, role,
).Scan(&member.GardenID, &member.UserID, &member.Email, &member.Role, &member.JoinedAt)
if errors.Is(err, sql.ErrNoRows) {
return gardenMemberView{}, storage.ErrRecordNotFound
}
return member, mapPostgresError(err)
}
type dbQuerier interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func getUser(ctx context.Context, db dbQuerier, email string) (userView, error) {
var user userView
err := db.QueryRowContext(ctx, `
SELECT id, name, email, activated, application_role, created_at, updated_at
FROM users
WHERE email = $1 AND deleted_at IS NULL`, email,
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return userView{}, errUserNotFound
}
if err != nil {
return userView{}, err
}
return user, nil
}
func mapPostgresError(err error) error {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key" {
return storage.ErrDuplicateEmail
}
return err
}