273 lines
9.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|