From df8544e04b4117fc8a06114b56c1ba426d5f999b Mon Sep 17 00:00:00 2001 From: Alexander Klein Date: Tue, 15 Sep 2026 20:19:22 +0200 Subject: [PATCH] Capture invite details before starting background email delivery --- internal/api/garden_invites_test.go | 101 ++++++++++++++++++++++++++++ internal/api/garden_members.go | 6 +- 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 internal/api/garden_invites_test.go diff --git a/internal/api/garden_invites_test.go b/internal/api/garden_invites_test.go new file mode 100644 index 0000000..7f8fb64 --- /dev/null +++ b/internal/api/garden_invites_test.go @@ -0,0 +1,101 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type gardenInviteTestModel struct { + invite storage.GardenInvite +} + +func (m *gardenInviteTestModel) Upsert(invite storage.GardenInvite) (storage.GardenInvite, error) { + invite.ID = 23 + invite.Token = "garden-invite-token" + m.invite = invite + return invite, nil +} + +func (m *gardenInviteTestModel) GetByToken(string) (storage.GardenInvite, error) { + return storage.GardenInvite{}, storage.ErrRecordNotFound +} + +func (m *gardenInviteTestModel) GetAllForGarden(int) ([]storage.GardenInvite, error) { + return nil, nil +} + +func (m *gardenInviteTestModel) Delete(int, int) error { return nil } + +func (m *gardenInviteTestModel) Accept(string, storage.User) (storage.GardenMember, error) { + return storage.GardenMember{}, storage.ErrRecordNotFound +} + +type gardenInviteRoleTestModel struct{} + +func (gardenInviteRoleTestModel) List(storage.RoleScope) ([]storage.Role, error) { return nil, nil } +func (gardenInviteRoleTestModel) ListForGarden(int) ([]storage.Role, error) { return nil, nil } +func (gardenInviteRoleTestModel) Get(string) (storage.Role, error) { + return storage.Role{}, storage.ErrRecordNotFound +} +func (gardenInviteRoleTestModel) GetForGarden(gardenID int, name string) (storage.Role, error) { + return storage.Role{Name: name, Scope: storage.RoleScopeGarden, GardenID: &gardenID}, nil +} +func (gardenInviteRoleTestModel) Create(role storage.Role) (storage.Role, error) { return role, nil } +func (gardenInviteRoleTestModel) Update(role storage.Role) (storage.Role, error) { return role, nil } +func (gardenInviteRoleTestModel) Delete(string) error { return nil } +func (gardenInviteRoleTestModel) ListGardenOverrides(int) ([]storage.GardenRolePermissionOverride, error) { + return nil, nil +} +func (gardenInviteRoleTestModel) ReplaceGardenOverrides(int, string, []storage.GardenRolePermissionOverride) error { + return nil +} + +func TestCreateGardenInviteMailContainsToken(t *testing.T) { + invites := new(gardenInviteTestModel) + 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.GardenInvites = invites + app.models.Roles = gardenInviteRoleTestModel{} + app.mailer = configuredMailer + + request := httptest.NewRequest(http.MethodPost, "/v1/gardens/17/invites", strings.NewReader(`{"email":" ADA@EXAMPLE.COM ","role":"member"}`)) + request = request.WithContext(context.WithValue(request.Context(), httprouter.ParamsKey, httprouter.Params{{Key: "gardenID", Value: "17"}})) + request = app.contextSetGardenMember(request, storage.GardenMember{GardenID: 17, UserID: 9, Role: storage.GardenRoleOwner}) + response := httptest.NewRecorder() + + app.createGardenInviteHandler(response, request) + app.wg.Wait() + + if response.Code != http.StatusCreated { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusCreated, response.Body.String()) + } + if strings.Contains(response.Body.String(), "garden-invite-token") { + t.Fatalf("API response exposes invitation token: %s", response.Body.String()) + } + if invites.invite.Email != "ada@example.com" || invites.invite.ExpiresAt.Before(time.Now().Add(6*24*time.Hour)) { + t.Fatalf("unexpected stored invitation: %+v", invites.invite) + } + mailContent, err := os.ReadFile(mailPath) + if err != nil { + t.Fatal(err) + } + want := "token=3Dgarden-invite-token" + if !strings.Contains(string(mailContent), want) { + t.Errorf("invitation mail is missing %q: %s", want, mailContent) + } +} diff --git a/internal/api/garden_members.go b/internal/api/garden_members.go index d673c0e..8b31351 100644 --- a/internal/api/garden_members.go +++ b/internal/api/garden_members.go @@ -143,9 +143,11 @@ func (app *application) createGardenInviteHandler(w http.ResponseWriter, r *http app.serverErrorResponse(w, r, err) return } + inviteEmail := invite.Email + inviteURL := strings.TrimRight(app.config.WebBaseURL, "/") + "/invite?token=" + invite.Token app.background(func() { - data := map[string]any{"inviteURL": strings.TrimRight(app.config.WebBaseURL, "/") + "/invite?token=" + invite.Token} - if sendErr := app.mailer.Send(invite.Email, "garden_invite.tmpl", data); sendErr != nil { + data := map[string]any{"inviteURL": inviteURL} + if sendErr := app.mailer.Send(inviteEmail, "garden_invite.tmpl", data); sendErr != nil { app.logger.Error(sendErr.Error()) } })