Files
Gardomatic/cmd/cli/application_test.go
T
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

273 lines
9.1 KiB
Go

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)
}
}
}