package api import ( "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" "gardomatic.kleiax.de/internal/auth" "gardomatic.kleiax.de/internal/mailer" "gardomatic.kleiax.de/internal/storage" "github.com/julienschmidt/httprouter" ) type adminInviteUserModel struct { sessionTestUserModel stored storage.User deletedID int deleteErr error } func (m *adminInviteUserModel) Insert(user storage.User) (storage.User, error) { user.ID = 42 m.stored = user return user, nil } func (m *adminInviteUserModel) GetByEmail(email string) (storage.User, error) { if m.stored.ID == 0 || m.stored.Email != email { return storage.User{}, storage.ErrRecordNotFound } return m.stored, nil } func (m *adminInviteUserModel) GetForToken(scope, token string) (storage.User, error) { if scope != auth.ScopeActivation || token == "" || m.stored.ID == 0 { return storage.User{}, storage.ErrRecordNotFound } return m.stored, nil } func (m *adminInviteUserModel) Update(user storage.User) (storage.User, error) { m.stored = user return user, nil } func (m *adminInviteUserModel) Delete(userID int) error { m.deletedID = userID return m.deleteErr } type adminInviteTokenModel struct { token auth.Token deleted bool } func (m *adminInviteTokenModel) New(userID int, ttl time.Duration, scope string) (auth.Token, error) { m.token = auth.NewToken(userID, ttl, scope) return m.token, nil } func (m *adminInviteTokenModel) DeleteAllForUser(scope string, userID int) error { m.deleted = scope == auth.ScopeActivation && userID == 42 return nil } func TestAdminInvitationCreatesAccountAndAllowsInitialPassword(t *testing.T) { users := new(adminInviteUserModel) tokens := new(adminInviteTokenModel) mailPath := filepath.Join(t.TempDir(), "mail.log") configuredMailer, err := mailer.New(mailer.Config{Mode: mailer.ModeFile, Sender: "gardomatic@example.com", FilePath: mailPath}) if err != nil { t.Fatal(err) } app, _, _ := newGardenTestApplication() app.config.WebBaseURL = "https://garden.example.com" app.models.Users = users app.models.Tokens = tokens app.mailer = configuredMailer inviteResponse := httptest.NewRecorder() inviteRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":" Ada ","email":" ADA@EXAMPLE.COM "}`)) app.inviteAdminUserHandler(inviteResponse, inviteRequest) if inviteResponse.Code != http.StatusAccepted { t.Fatalf("invite status: got %d, want %d; body: %s", inviteResponse.Code, http.StatusAccepted, inviteResponse.Body.String()) } if users.stored.Name != "Ada" || users.stored.Email != "ada@example.com" || users.stored.Activated { t.Fatalf("unexpected invited user: %+v", users.stored) } if !tokens.deleted || tokens.token.UserID != users.stored.ID || tokens.token.Scope != auth.ScopeActivation { t.Fatalf("unexpected invitation token: %+v", tokens.token) } firstToken := tokens.token.Plaintext resendResponse := httptest.NewRecorder() resendRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`)) app.inviteAdminUserHandler(resendResponse, resendRequest) if resendResponse.Code != http.StatusAccepted { t.Fatalf("resend status: got %d, want %d; body: %s", resendResponse.Code, http.StatusAccepted, resendResponse.Body.String()) } if tokens.token.Plaintext == firstToken { t.Fatal("resending did not replace the activation token") } mailContent, err := os.ReadFile(mailPath) if err != nil { t.Fatal(err) } for _, want := range []string{"ada@example.com", "Einladung zu Gardomatic", "set-password=3D1", tokens.token.Plaintext} { if !strings.Contains(string(mailContent), want) { t.Errorf("invitation is missing %q: %s", want, mailContent) } } activateResponse := httptest.NewRecorder() activateBody := `{"token":"` + tokens.token.Plaintext + `","password":"correct horse battery staple"}` activateRequest := httptest.NewRequest(http.MethodPut, "/v1/users/activated", strings.NewReader(activateBody)) app.activateUserHandler(activateResponse, activateRequest) if activateResponse.Code != http.StatusOK { t.Fatalf("activation status: got %d, want %d; body: %s", activateResponse.Code, http.StatusOK, activateResponse.Body.String()) } if !users.stored.Activated { t.Fatal("invited user was not activated") } matches, err := users.stored.Password.Matches("correct horse battery staple") if err != nil || !matches { t.Fatalf("initial password was not stored: matches=%t err=%v", matches, err) } duplicateResponse := httptest.NewRecorder() duplicateRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`)) app.inviteAdminUserHandler(duplicateResponse, duplicateRequest) if duplicateResponse.Code != http.StatusUnprocessableEntity { t.Fatalf("active duplicate status: got %d, want %d; body: %s", duplicateResponse.Code, http.StatusUnprocessableEntity, duplicateResponse.Body.String()) } } func TestDeleteAdminUserProtectsSelfAndReportsOwnerConflict(t *testing.T) { users := new(adminInviteUserModel) app, _, _ := newGardenTestApplication() app.models.Users = users router := httprouter.New() router.HandlerFunc(http.MethodDelete, "/v1/admin/users/:id", app.deleteAdminUserHandler) serve := func(actorID, targetID int) *httptest.ResponseRecorder { request := httptest.NewRequest(http.MethodDelete, "/v1/admin/users/"+strconv.Itoa(targetID), nil) request = app.contextSetAuthenticatedUser(request, storage.User{ID: actorID}) response := httptest.NewRecorder() router.ServeHTTP(response, request) return response } if response := serve(7, 7); response.Code != http.StatusForbidden || users.deletedID != 0 { t.Fatalf("self deletion: status=%d deleted=%d", response.Code, users.deletedID) } users.deleteErr = storage.ErrConflict if response := serve(7, 8); response.Code != http.StatusConflict || users.deletedID != 8 { t.Fatalf("owner deletion: status=%d deleted=%d", response.Code, users.deletedID) } users.deleteErr = nil if response := serve(7, 9); response.Code != http.StatusNoContent || users.deletedID != 9 { t.Fatalf("deletion: status=%d deleted=%d", response.Code, users.deletedID) } }