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 demoResetCalls int demoResetInput demoResetInput } 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 (s *fakeAdminStore) ResetDemo(_ context.Context, input demoResetInput) (demoResetResult, error) { s.demoResetCalls++ s.demoResetInput = input return demoResetResult{Email: input.Email, Garden: "Sonnengarten", Images: 3, Locations: 8, Species: 12, Plants: 16, Tasks: 16, Journal: 6, Attachments: 1}, 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 TestDemoResetRequiresExplicitEnablement(t *testing.T) { store := new(fakeAdminStore) app, _, _ := newTestApplication(t, "correct horse battery staple\n", store) err := app.run(context.Background(), []string{"--yes", "demo", "reset", "--password-stdin"}) if err == nil || !strings.Contains(err.Error(), "GARDOMATIC_DEMO_RESET_ENABLED") { t.Fatalf("run() error = %v, want disabled demo reset error", err) } if store.demoResetCalls != 0 { t.Fatalf("ResetDemo calls = %d, want 0", store.demoResetCalls) } } func TestDemoResetRequiresYes(t *testing.T) { store := new(fakeAdminStore) app, _, _ := newTestApplication(t, "correct horse battery staple\n", store) t.Setenv("GARDOMATIC_DEMO_RESET_ENABLED", "true") err := app.run(context.Background(), []string{"demo", "reset", "--password-stdin"}) if err == nil || !strings.Contains(err.Error(), "--yes") { t.Fatalf("run() error = %v, want --yes error", err) } if store.demoResetCalls != 0 { t.Fatalf("ResetDemo calls = %d, want 0", store.demoResetCalls) } } func TestDemoResetHashesPasswordAndWritesSummary(t *testing.T) { store := new(fakeAdminStore) app, stdout, _ := newTestApplication(t, "correct horse battery staple\n", store) t.Setenv("GARDOMATIC_DEMO_RESET_ENABLED", "true") err := app.run(context.Background(), []string{ "--yes", "demo", "reset", "--password-stdin", "--name", " Schaugarten ", "--email", "besuch@example.com", }) if err != nil { t.Fatalf("run() returned an error: %v", err) } if store.demoResetCalls != 1 { t.Fatalf("ResetDemo calls = %d, want 1", store.demoResetCalls) } if store.demoResetInput.Name != "Schaugarten" || store.demoResetInput.Email != "besuch@example.com" { t.Errorf("ResetDemo input = %+v", store.demoResetInput) } if err = bcrypt.CompareHashAndPassword(store.demoResetInput.PasswordHash, []byte("correct horse battery staple")); err != nil { t.Errorf("stored password hash does not match input: %v", err) } if !strings.Contains(stdout.String(), "Demo reset complete") || !strings.Contains(stdout.String(), "Plants: 16") { t.Errorf("output = %q", stdout.String()) } } 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) } } }