@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user