From 153fd8719a76a222223d5b9e086e3b8e1e2d0d38 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Wed, 30 Sep 2026 01:36:43 +0200 Subject: [PATCH] feat: invite management API: DELETE /v1/user/invite/:id (204, cancels a pending invite) and POST /v1/user/invite/:id/renew (200, new code, expires_at = now+3d, created_at = now, resends the invitation email, old link dead) --- api/dependecyInjection.go | 4 + api/handlers/user/inviteHandler.go | 80 ++- api/pkg/apperr/messages.go | 2 + api/router.go | 4 +- features/invite_management.feature | 205 ++++++ features/invite_management.qa.md | 71 +++ internal/domain/invite/inviteDomain.go | 1 + .../invite/inviteRepositoryInterface.go | 1 + .../repositories/inviteRepository.go | 19 + .../infrastructure/useCases/CancelInvite.go | 18 + .../infrastructure/useCases/CreateInvite.go | 40 +- .../infrastructure/useCases/RenewInvite.go | 59 ++ .../infrastructure/useCases/inviteLookup.go | 16 + .../infrastructure/useCases/inviteMail.go | 59 ++ test/accept_invitation_test.go | 2 + test/invitations_test.go | 17 + test/invite_management_test.go | 591 ++++++++++++++++++ test/user_roles_test.go | 2 + 18 files changed, 1147 insertions(+), 44 deletions(-) create mode 100644 features/invite_management.feature create mode 100644 features/invite_management.qa.md create mode 100644 internal/infrastructure/useCases/CancelInvite.go create mode 100644 internal/infrastructure/useCases/RenewInvite.go create mode 100644 internal/infrastructure/useCases/inviteLookup.go create mode 100644 internal/infrastructure/useCases/inviteMail.go create mode 100644 test/invite_management_test.go diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index e5fd36a..8a43da2 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -38,6 +38,8 @@ type UseCases struct { UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase ListUsersUseCase *useCases.ListUsersUseCase CreateInviteUseCase *useCases.CreateInviteUseCase + CancelInviteUseCase *useCases.CancelInviteUseCase + RenewInviteUseCase *useCases.RenewInviteUseCase ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase GetInviteUseCase *useCases.GetInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase @@ -111,6 +113,8 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases { UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL), + CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo), + RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL), ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo), GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher), diff --git a/api/handlers/user/inviteHandler.go b/api/handlers/user/inviteHandler.go index 2eb81b5..a602fb4 100644 --- a/api/handlers/user/inviteHandler.go +++ b/api/handlers/user/inviteHandler.go @@ -10,11 +10,16 @@ import ( "encoding/json" "errors" "net/http" + "strconv" "time" + + "github.com/go-chi/chi/v5" ) type InviteHandler struct { CreateInviteUseCase *useCases.CreateInviteUseCase + CancelInviteUseCase *useCases.CancelInviteUseCase + RenewInviteUseCase *useCases.RenewInviteUseCase ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase } @@ -50,9 +55,11 @@ type userListItem struct { CreatedAt time.Time `json:"created_at"` } -func NewInviteHandler(createInvite *useCases.CreateInviteUseCase, listUsersAndInvites *useCases.ListUsersAndInvitesUseCase) *InviteHandler { +func NewInviteHandler(createInvite *useCases.CreateInviteUseCase, cancelInvite *useCases.CancelInviteUseCase, renewInvite *useCases.RenewInviteUseCase, listUsersAndInvites *useCases.ListUsersAndInvitesUseCase) *InviteHandler { return &InviteHandler{ CreateInviteUseCase: createInvite, + CancelInviteUseCase: cancelInvite, + RenewInviteUseCase: renewInvite, ListUsersAndInvitesUseCase: listUsersAndInvites, } } @@ -84,23 +91,82 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { return } + writeInviteResponse(w, http.StatusCreated, created) +} + +func writeInviteResponse(w http.ResponseWriter, status int, entry invite.Invite) { body, err := json.Marshal(inviteResponse{ - ID: created.ID, + ID: entry.ID, Kind: useCases.EntryKindInvite, - Email: created.Email, - Status: string(created.StatusAt(time.Now())), - CreatedAt: created.CreatedAt, - ExpiresAt: created.ExpiresAt, + Email: entry.Email, + Status: string(entry.StatusAt(time.Now())), + CreatedAt: entry.CreatedAt, + ExpiresAt: entry.ExpiresAt, }) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusCreated) + w.WriteHeader(status) _, _ = w.Write(body) } +func parseInviteId(r *http.Request) (uint32, error) { + id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) + return uint32(id), err +} + +func (h *InviteHandler) CancelInvite(w http.ResponseWriter, r *http.Request) { + id, err := parseInviteId(r) + if err != nil { + http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) + return + } + + if err := h.CancelInviteUseCase.CancelInvite(id); err != nil { + writeManageInviteError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) { + id, err := parseInviteId(r) + if err != nil { + http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) + return + } + + userId, ok := claims.UserId(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + + renewed, err := h.RenewInviteUseCase.RenewInvite(useCases.RenewInviteCommand{ID: id, RenewedBy: userId}) + if err != nil { + writeManageInviteError(w, err) + return + } + writeInviteResponse(w, http.StatusOK, renewed) +} + +func writeManageInviteError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, invite.ErrInviteNotFound): + http.Error(w, apperr.InviteNotFoundErrorMessage, http.StatusNotFound) + case errors.Is(err, invite.ErrInviteAlreadyAccepted): + http.Error(w, apperr.InviteAlreadyAcceptedErrorMessage, http.StatusConflict) + case errors.Is(err, invite.ErrInviteEmailNotSent): + http.Error(w, apperr.InviteNotSentErrorMessage, http.StatusInternalServerError) + case errors.Is(err, user.ErrUserNotFound): + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + func writeCreateInviteError(w http.ResponseWriter, err error) { switch { case errors.Is(err, invite.ErrEmailHasAccount): diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index d503de0..eee780b 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -19,6 +19,8 @@ const TooManyRequestsErrorMessage = "Too many requests. Please try again later." const EmailHasAccountErrorMessage = "This address already has an account." const PendingInviteErrorMessage = "There's already a pending invite for this address." const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment." +const InviteNotFoundErrorMessage = "Invite not found" +const InviteAlreadyAcceptedErrorMessage = "This invite has already been accepted." const InviteInvalidErrorMessage = "This invite link doesn't work." const InviteExpiredErrorMessage = "This invite has expired." const OwnerRequiredErrorMessage = "Only the owner can do this." diff --git a/api/router.go b/api/router.go index e4440e9..63912e8 100644 --- a/api/router.go +++ b/api/router.go @@ -100,7 +100,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, r := chi.NewRouter() userHandler := user.NewHandler(container.UpdateUserProfileUseCase) - inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.ListUsersAndInvitesUseCase) + inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase) r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Authenticator(container.Token)) @@ -113,6 +113,8 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, r.Use(middleware.TrustedProxyRealIP(trustedProxies)) r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window)) r.Post("/invite", inviteHandler.CreateInvite) + r.Delete("/invite/{id}", inviteHandler.CancelInvite) + r.Post("/invite/{id}/renew", inviteHandler.RenewInvite) }) return r } diff --git a/features/invite_management.feature b/features/invite_management.feature new file mode 100644 index 0000000..bb57db9 --- /dev/null +++ b/features/invite_management.feature @@ -0,0 +1,205 @@ +Feature: The owner can cancel a pending invite and renew a pending or expired one + + As the owner of the shared RenewCMS workspace + I want to cancel an invite I no longer want, or renew one that is stale or expired + So that only the people I currently intend to invite can create an account + + Background: + Given the invite domain, the invitation email and the roles are those of features/invitations.feature + and features/user_roles.feature + And DELETE /v1/user/invite/{id} and POST /v1/user/invite/{id}/renew are mounted in NewUserRouter, + in the same group as POST /v1/user/invite: valid jwt cookie, current token_version, verified user, + owner role, trusted-proxy real IP, then the auth rate limiter + And the checks run in this order: 401, 403 unverified, 403 not owner, 429, 400 invalid id, 404, 409, + then the action + And user 7 "marta" is the verified owner and user 9 "jonas" is a verified admin + And error responses are plain text from http.Error, and the messages are: + | case | status | body | + | unknown invite | 404 | Invite not found | + | invite already accepted | 409 | This invite has already been accepted. | + | id is not a uint32 | 400 | apperr.IdUint32ErrorMessage | + | not the owner | 403 | Only the owner can do this. | + | no valid session | 401 | apperr.UnauthorizedErrorMessage | + | rate limited | 429 | apperr.TooManyRequestsErrorMessage | + | renew email not sent | 500 | Couldn't send the invite. Try again in a moment. | + And the renew response has the same shape as the create response: exactly the keys id, kind + ("invite"), email, status, created_at and expires_at + + Rule: Cancelling an invite + + Scenario: The owner cancels a pending invite + Given an invite id 3 for "sam.oliveira@renew.example", pending, created by user 7 + And a valid jwt cookie for user 7 + When the client sends DELETE /v1/user/invite/3 + Then the response status is 204 + And the response body is empty + And the invite repository no longer holds invite 3 + And no email was sent + And GET /v1/user/ no longer lists the invite + + Scenario: A cancelled invite's link stops working + Given invite 3 was cancelled by the owner + When the client sends GET /v1/auth/invite?c= + Then the response status is 404 + And the response body is "This invite link doesn't work." + And accepting the invite with that code returns 404, creates no user and sets no cookie + + Scenario: A cancelled address can be invited again + Given invite 3 for "sam.oliveira@renew.example" was cancelled + When the owner sends POST /v1/user/invite for that address + Then the response status is 201 + + Scenario: The owner cancels an expired invite + Given invite 4 is expired + When the owner sends DELETE /v1/user/invite/4 + Then the response status is 204 + And the invite repository no longer holds invite 4 + + Scenario: Other invites and users are untouched + Given invites 3 and 4 and users 7 and 9 + When the owner cancels invite 3 + Then invite 4 and users 7 and 9 are unchanged + + Scenario: An unknown invite is not found + When the owner sends DELETE /v1/user/invite/999 + Then the response status is 404 + And the response body is "Invite not found" + + Scenario: Cancelling twice is not found + Given the owner cancelled invite 3 + When the owner sends DELETE /v1/user/invite/3 again + Then the response status is 404 + + Scenario: An accepted invite cannot be cancelled + Given invite 5 was accepted and its user exists + When the owner sends DELETE /v1/user/invite/5 + Then the response status is 409 + And the response body is "This invite has already been accepted." + And invite 5 and its user remain + + Rule: Renewing an invite + + Scenario: The owner renews an expired invite + Given invite 4 for "lea.martin@renew.example", created 5 days ago, expired 2 days ago, + invited_by 7, stored code hash "hashed:OLD-CODE" + And a valid jwt cookie for user 7 + When the client sends POST /v1/user/invite/4/renew + Then the response status is 200 + And the response has Cache-Control "no-store" + And the response id is 4, kind "invite", email "lea.martin@renew.example" and status "pending" + And created_at is now and expires_at is 3 days after created_at, each within 5 seconds + And the response body has no key named code, hashed_code, invited_by or accepted_at + And the invite repository holds exactly one invite for that address, with id 4, a new code hash, + invited_by 7 and accepted_at empty + + Scenario: The owner renews a pending invite + Given invite 3 is pending + When the owner sends POST /v1/user/invite/3/renew + Then the response status is 200 + And invite 3 has a new code hash and created_at now + + Scenario: A renewed invite moves to the top of the users list + Given invite 3 is pending and older than invite 6 + When the owner renews invite 3 + Then GET /v1/user/ lists invite 3 before invite 6 + + Scenario: The old link is dead and the new link works + Given invite 4 was renewed and the raw code in the new email is NEW-CODE + When the client sends GET /v1/auth/invite?c= + Then the response status is 404 + And accepting with the old code returns 404 and creates no user + And GET /v1/auth/invite?c=NEW-CODE returns 200 with {"email": "lea.martin@renew.example"} + And this holds when the renewed invite was pending as well as expired + + Scenario: Renewing sends the invitation email again + When the owner renews invite 4 + Then exactly one email was sent, with template "invitation" + And its Host is the configured PublicURL, its Email is the invited address, its InvitedBy is + the acting owner's display name or, when empty, username + And its VerificationCode is the new raw code + And the link in it is /invite/accept?c= + And the invite's invited_by is unchanged + + Scenario: An unknown or cancelled invite is not found + When the owner sends POST /v1/user/invite/999/renew + Then the response status is 404 + And the response body is "Invite not found" + And renewing a cancelled invite also returns 404 + + Scenario: An accepted invite cannot be renewed + Given invite 5 was accepted + When the owner sends POST /v1/user/invite/5/renew + Then the response status is 409 + And the response body is "This invite has already been accepted." + And invite 5 is unchanged and no email was sent + + Scenario: A failed email leaves the invite as it was + Given expired invite 4 and an email repository whose next send fails + When the owner sends POST /v1/user/invite/4/renew + Then the response status is 500 + And the response body is "Couldn't send the invite. Try again in a moment." + And invite 4 keeps its old code hash, expires_at and created_at, and is still expired + And a pending invite whose renew mail fails keeps its old link valid + And a retry of the same request returns 200 + + Rule: Only the owner can manage invites + + Scenario Outline: Access is checked on both endpoints + Given invite 3 is pending + When sends + Then the response status is + And invite 3 is unchanged + And no email was sent + + Examples: + | caller | method | path | status | + | a client with no cookie | DELETE | /v1/user/invite/3 | 401 | + | a client with no cookie | POST | /v1/user/invite/3/renew | 401 | + | a stale token_version | DELETE | /v1/user/invite/3 | 401 | + | a stale token_version | POST | /v1/user/invite/3/renew | 401 | + | an unverified user | DELETE | /v1/user/invite/3 | 403 | + | an unverified user | POST | /v1/user/invite/3/renew | 403 | + | verified admin 9 | DELETE | /v1/user/invite/3 | 403 | + | verified admin 9 | POST | /v1/user/invite/3/renew | 403 | + + Scenario: An admin is refused with the owner message, even on an unknown id + When admin 9 sends DELETE /v1/user/invite/999 + Then the response status is 403 + And the response body is "Only the owner can do this." + + Scenario Outline: An id that is not a uint32 is rejected + When the owner sends + Then the response status is 400 + And the response body is apperr.IdUint32ErrorMessage + + Examples: + | method | path | + | DELETE | /v1/user/invite/abc | + | DELETE | /v1/user/invite/-1 | + | DELETE | /v1/user/invite/4294967296 | + | POST | /v1/user/invite/abc/renew | + | POST | /v1/user/invite/-1/renew | + | POST | /v1/user/invite/4294967296/renew| + + Rule: Rate limiting + + Scenario: Requests over the limit are refused + Given the auth rate limiter allows 2 requests per window + When the owner sends three valid requests to DELETE or renew from IP 203.0.113.40 + Then the third response is 429 with apperr.TooManyRequestsErrorMessage and a Retry-After header + And the third request changes nothing and sends no email + And GET /v1/user/ from the same address is not 429 + + Scenario: An admin does not use up the quota + When admin 9 sends three requests from one IP + Then all three responses are 403, not 429 + + Scenario: The counter is shared with inviting + Given the auth rate limiter allows 2 requests per window + When the owner sends POST /v1/user/invite, then renews an invite, then sends DELETE /v1/user/invite/3 + Then the DELETE returns 429 + + Rule: Existing behaviour is unchanged + + Scenario: The invitation and acceptance features still hold + Then every scenario of features/invitations.feature and features/accept_invitation.feature still passes diff --git a/features/invite_management.qa.md b/features/invite_management.qa.md new file mode 100644 index 0000000..2f8fc2a --- /dev/null +++ b/features/invite_management.qa.md @@ -0,0 +1,71 @@ +# QA procedure: invite management + +Package: `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo` and `mockInviteRepo`. +Extend `mockInviteRepo` with the new renew method and a `failSwitch` entry for it (unique type names +across the package). No live server, no real SQLite (except case 27), no real SMTP. + +## Setup shared by every case +1. Call `validator.InitValidator()` once. +2. Use cases over domain interfaces only: `CreateInvite`, `ListUsersAndInvites`, `GetInvite`, the accept + use case, plus the new cancel and renew use cases (final names are the coder's). +3. Handler built with `NewHandler(...)` on a fresh `chi.NewRouter()` mirroring `NewUserRouter`: + `jwtauth.Verifier` + `jwtauth.Authenticator` (`jwtauth.New("HS256", []byte("test-secret"), nil)`), + `RequireCurrentToken`, `RequireVerifiedUser`, `GET /`, then a group with `RequireOwner`, + `TrustedProxyRealIP(nil)` and `NewRateLimiter` containing `POST /invite`, `DELETE /invite/{id}` and + `POST /invite/{id}/renew`. Mount `GET /invite` and `POST /invite/accept` from the auth handler for the + old-link cases. +4. Seed users: owner 7 "marta", unverified 8, admin 9. Invite hashes are `"hashed:"+raw`. Times are + relative to `time.Now()`, compared with a 5 s tolerance. + +## DELETE /v1/user/invite/{id} +5. Pending invite, owner cookie -> `204`, empty body, invite gone, no mail, absent from `GET /`. +6. After cancel: `GET /invite?c=` -> `404`; accept -> `404`, no user, no cookie; re-invite the address -> `201`. +7. Expired invite -> `204`. +8. Unknown id -> `404` "Invite not found"; second delete -> `404`. +9. Accepted invite -> `409` "This invite has already been accepted."; invite and user remain. +10. Other invites and users unchanged after a cancel. +11. Repository delete failure -> `500`, invite still present. + +## POST /v1/user/invite/{id}/renew +12. Expired invite -> `200`, `Cache-Control: no-store`; key set exactly `id`, `kind`, `email`, `status`, + `created_at`, `expires_at`; no `code`, `hashed_code`, `invited_by`, `accepted_at`. +13. Stored invite: same id, new hash, `invited_by` 7, `accepted_at` nil, `created_at` about now, + `expires_at` = `created_at` + 72h. +14. Pending invite renews with `200`, new hash. +15. Mail: exactly one entry, template `invitation`, `Host`, `Email`, `InvitedBy` by display name or username + of the acting owner, `VerificationCode` = raw new code. +16. `GET /` lists the renewed invite first. +17. Old code: `GET /invite` and accept -> `404`, no user; new code -> `200` with the email. Control checks before + the renew show the old code worked. Same for pending. +18. Unknown id -> `404`; cancelled id -> `404`. +19. Accepted -> `409`, all fields unchanged, no mail. +20. `mailRepo.fail.failNext("Send")` -> `500` "Couldn't send the invite. Try again in a moment."; invite hash, + expiry and created_at unchanged; retry -> `200`. +21. Renew persistence failure -> `500`, no mail, invite unchanged. + +## Access and ids (table over both endpoints) +22. No cookie -> `401`; stale `token_version` -> `401`; unverified -> `403`; admin -> `403` with body + `apperr.OwnerRequiredErrorMessage`; admin on an unknown id -> `403`. Each: invite unchanged, no mail. +23. Ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage` on both endpoints. + +## Rate limit +24. Limiter of 2, `RemoteAddr` `203.0.113.40:1234`: third request -> `429`, `apperr.TooManyRequestsErrorMessage`, + `Retry-After`, no change, no mail. `GET /` from the same address is not `429`. +25. Admin's three requests -> `403`, `403`, `403`. +26. Shared counter: `POST /invite`, renew, then `DELETE` -> `429`. + +## Use case and persistence +27. Use case level: + - unknown id -> `errors.Is(err, ErrInviteNotFound)`; accepted -> the accepted sentinel (name is the coder's); + - success returns the same id, a new hash, `CreatedAt` about now, `ExpiresAt` = `CreatedAt` + `invite.Lifetime`; + - mail failure wraps `ErrInviteEmailNotSent` and restores the prior values; + - the mail logic is shared with `CreateInvite`. +28. Persistence on in-memory SQLite: the renew method updates hash, expiry and created_at by id, returns + `ErrInviteNotFound` when the row is missing or already accepted. + +## Manual +Renew against a real or sandboxed SMTP server: one mail arrives, the new link works, the old mail's link is dead. +Persistence across a restart on a fresh SQLite file. Review layering and that no comments were introduced. + +## Out of scope +SPA handling of 404, 409 and 429 on these endpoints: this slice is API-only. diff --git a/internal/domain/invite/inviteDomain.go b/internal/domain/invite/inviteDomain.go index ab30c22..0204a6b 100644 --- a/internal/domain/invite/inviteDomain.go +++ b/internal/domain/invite/inviteDomain.go @@ -11,6 +11,7 @@ var ErrInviteNotFound = errors.New("invite not found") var ErrInviteEmailNotSent = errors.New("invite email not sent") var ErrInviteInvalid = errors.New("invite invalid") var ErrInviteExpired = errors.New("invite expired") +var ErrInviteAlreadyAccepted = errors.New("invite already accepted") const Lifetime = 3 * 24 * time.Hour diff --git a/internal/domain/invite/inviteRepositoryInterface.go b/internal/domain/invite/inviteRepositoryInterface.go index 0b9e75a..e5e0713 100644 --- a/internal/domain/invite/inviteRepositoryInterface.go +++ b/internal/domain/invite/inviteRepositoryInterface.go @@ -9,5 +9,6 @@ type Repository interface { Create(invite Invite) (Invite, error) GetAll() []Invite Delete(id uint32) error + Renew(id uint32, hashedCode string, expiresAt time.Time, createdAt time.Time) (Invite, error) AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser user.User) (user.User, error) } diff --git a/internal/infrastructure/persistence/repositories/inviteRepository.go b/internal/infrastructure/persistence/repositories/inviteRepository.go index 6f3fe52..089b7f6 100644 --- a/internal/infrastructure/persistence/repositories/inviteRepository.go +++ b/internal/infrastructure/persistence/repositories/inviteRepository.go @@ -45,6 +45,25 @@ func (i *InviteRepository) Delete(id uint32) error { return deleteOrNotFound(result, domainInvite.ErrInviteNotFound) } +func (i *InviteRepository) Renew(id uint32, hashedCode string, expiresAt time.Time, createdAt time.Time) (domainInvite.Invite, error) { + result := i.db.Model(&entity.Invite{}). + Where("id = ? AND accepted_at IS NULL", id). + Updates(map[string]any{ + "hashed_code": hashedCode, + "expires_at": expiresAt, + "created_at": createdAt, + }) + if err := deleteOrNotFound(result, domainInvite.ErrInviteNotFound); err != nil { + return domainInvite.Invite{}, err + } + + var model entity.Invite + if err := i.db.First(&model, id).Error; err != nil { + return domainInvite.Invite{}, err + } + return mappers.InviteToDomain(model), nil +} + func (i *InviteRepository) AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser domainUser.User) (domainUser.User, error) { createdModel := mappers.UserToModel(newUser) err := i.db.Transaction(func(tx *gorm.DB) error { diff --git a/internal/infrastructure/useCases/CancelInvite.go b/internal/infrastructure/useCases/CancelInvite.go new file mode 100644 index 0000000..8181d11 --- /dev/null +++ b/internal/infrastructure/useCases/CancelInvite.go @@ -0,0 +1,18 @@ +package useCases + +import "RenewCMS/internal/domain/invite" + +type CancelInviteUseCase struct { + inviteRepository invite.Repository +} + +func NewCancelInviteUseCase(inviteRepository invite.Repository) *CancelInviteUseCase { + return &CancelInviteUseCase{inviteRepository} +} + +func (c *CancelInviteUseCase) CancelInvite(id uint32) error { + if _, err := findManageableInvite(c.inviteRepository, id); err != nil { + return err + } + return c.inviteRepository.Delete(id) +} diff --git a/internal/infrastructure/useCases/CreateInvite.go b/internal/infrastructure/useCases/CreateInvite.go index d2fee3b..c270fe4 100644 --- a/internal/infrastructure/useCases/CreateInvite.go +++ b/internal/infrastructure/useCases/CreateInvite.go @@ -6,21 +6,15 @@ import ( "RenewCMS/internal/domain/mail" "RenewCMS/internal/domain/user" "errors" - "fmt" "strings" "time" - - "github.com/google/uuid" ) -const inviteTemplateName = "invitation" - type CreateInviteUseCase struct { userRepository user.Repository inviteRepository invite.Repository hasher hash.Hasher - mailRepository mail.Repository - publicURL string + mailer inviteMailer } type CreateInviteCommand struct { @@ -28,20 +22,8 @@ type CreateInviteCommand struct { InvitedBy uint32 } -type MailInviteData struct { - Host string - VerificationCode string - Email string - InvitedBy string -} - -func (d MailInviteData) Subject() string { - inviter := strings.NewReplacer("\r", " ", "\n", " ").Replace(d.InvitedBy) - return inviter + " invited you to RenewCMS" -} - func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *CreateInviteUseCase { - return &CreateInviteUseCase{userRepository, inviteRepository, hasher, mailRepository, publicURL} + return &CreateInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}} } func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.Invite, error) { @@ -61,8 +43,7 @@ func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite. return invite.Invite{}, err } - rawCode := uuid.NewString() - hashedCode, err := g.hasher.Hash(rawCode) + rawCode, hashedCode, err := newInviteCode(g.hasher) if err != nil { return invite.Invite{}, err } @@ -78,13 +59,7 @@ func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite. return invite.Invite{}, err } - if err := g.mailRepository.Send(email, inviteTemplateName, MailInviteData{ - Host: g.publicURL, - VerificationCode: rawCode, - Email: email, - InvitedBy: inviterName(inviter), - }); err != nil { - sendErr := fmt.Errorf("%w: %w", invite.ErrInviteEmailNotSent, err) + if sendErr := g.mailer.send(email, rawCode, inviter); sendErr != nil { if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil { return invite.Invite{}, errors.Join(sendErr, deleteErr) } @@ -119,10 +94,3 @@ func (g *CreateInviteUseCase) discardOpenInvite(email string, now time.Time) err } return nil } - -func inviterName(inviter user.User) string { - if inviter.DisplayName != "" { - return inviter.DisplayName - } - return inviter.Username -} diff --git a/internal/infrastructure/useCases/RenewInvite.go b/internal/infrastructure/useCases/RenewInvite.go new file mode 100644 index 0000000..53d566e --- /dev/null +++ b/internal/infrastructure/useCases/RenewInvite.go @@ -0,0 +1,59 @@ +package useCases + +import ( + "RenewCMS/internal/domain/hash" + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/mail" + "RenewCMS/internal/domain/user" + "errors" + "time" +) + +type RenewInviteUseCase struct { + userRepository user.Repository + inviteRepository invite.Repository + hasher hash.Hasher + mailer inviteMailer +} + +type RenewInviteCommand struct { + ID uint32 + RenewedBy uint32 +} + +func NewRenewInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *RenewInviteUseCase { + return &RenewInviteUseCase{userRepository, inviteRepository, hasher, inviteMailer{mailRepository, publicURL}} +} + +func (u *RenewInviteUseCase) RenewInvite(command RenewInviteCommand) (invite.Invite, error) { + now := time.Now() + + renewer, err := u.userRepository.Get(command.RenewedBy) + if err != nil { + return invite.Invite{}, err + } + + previous, err := findManageableInvite(u.inviteRepository, command.ID) + if err != nil { + return invite.Invite{}, err + } + + rawCode, hashedCode, err := newInviteCode(u.hasher) + if err != nil { + return invite.Invite{}, err + } + + renewed, err := u.inviteRepository.Renew(previous.ID, hashedCode, now.Add(invite.Lifetime), now) + if err != nil { + return invite.Invite{}, err + } + + if sendErr := u.mailer.send(renewed.Email, rawCode, renewer); sendErr != nil { + if _, restoreErr := u.inviteRepository.Renew(previous.ID, previous.HashedCode, previous.ExpiresAt, previous.CreatedAt); restoreErr != nil { + return invite.Invite{}, errors.Join(sendErr, restoreErr) + } + return invite.Invite{}, sendErr + } + + return renewed, nil +} diff --git a/internal/infrastructure/useCases/inviteLookup.go b/internal/infrastructure/useCases/inviteLookup.go new file mode 100644 index 0000000..67b9262 --- /dev/null +++ b/internal/infrastructure/useCases/inviteLookup.go @@ -0,0 +1,16 @@ +package useCases + +import "RenewCMS/internal/domain/invite" + +func findManageableInvite(inviteRepository invite.Repository, id uint32) (invite.Invite, error) { + for _, existing := range inviteRepository.GetAll() { + if existing.ID != id { + continue + } + if existing.AcceptedAt != nil { + return invite.Invite{}, invite.ErrInviteAlreadyAccepted + } + return existing, nil + } + return invite.Invite{}, invite.ErrInviteNotFound +} diff --git a/internal/infrastructure/useCases/inviteMail.go b/internal/infrastructure/useCases/inviteMail.go new file mode 100644 index 0000000..e1f79a0 --- /dev/null +++ b/internal/infrastructure/useCases/inviteMail.go @@ -0,0 +1,59 @@ +package useCases + +import ( + "RenewCMS/internal/domain/hash" + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/mail" + "RenewCMS/internal/domain/user" + "fmt" + "strings" + + "github.com/google/uuid" +) + +const inviteTemplateName = "invitation" + +type MailInviteData struct { + Host string + VerificationCode string + Email string + InvitedBy string +} + +func (d MailInviteData) Subject() string { + inviter := strings.NewReplacer("\r", " ", "\n", " ").Replace(d.InvitedBy) + return inviter + " invited you to RenewCMS" +} + +type inviteMailer struct { + mailRepository mail.Repository + publicURL string +} + +func (m inviteMailer) send(email string, rawCode string, inviter user.User) error { + if err := m.mailRepository.Send(email, inviteTemplateName, MailInviteData{ + Host: m.publicURL, + VerificationCode: rawCode, + Email: email, + InvitedBy: inviterName(inviter), + }); err != nil { + return fmt.Errorf("%w: %w", invite.ErrInviteEmailNotSent, err) + } + return nil +} + +func newInviteCode(hasher hash.Hasher) (string, string, error) { + rawCode := uuid.NewString() + hashedCode, err := hasher.Hash(rawCode) + if err != nil { + return "", "", err + } + return rawCode, hashedCode, nil +} + +func inviterName(inviter user.User) string { + if inviter.DisplayName != "" { + return inviter.DisplayName + } + return inviter.Username +} diff --git a/test/accept_invitation_test.go b/test/accept_invitation_test.go index 4d800fc..179c26c 100644 --- a/test/accept_invitation_test.go +++ b/test/accept_invitation_test.go @@ -63,6 +63,8 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture meHandler := auth.NewMeHandler(getUser) listHandler := userhandler.NewInviteHandler( useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL), + nil, + nil, useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), ) registerHandler := auth.NewLoginHandler( diff --git a/test/invitations_test.go b/test/invitations_test.go index 8cf9a8b..a6d6fa2 100644 --- a/test/invitations_test.go +++ b/test/invitations_test.go @@ -76,6 +76,21 @@ func (m *inviteFakeRepo) Delete(id uint32) error { return nil } +func (m *inviteFakeRepo) Renew(id uint32, hashedCode string, expiresAt time.Time, createdAt time.Time) (invite.Invite, error) { + if err := m.fail.trigger("Renew"); err != nil { + return invite.Invite{}, err + } + stored, ok := m.invites[id] + if !ok || stored.AcceptedAt != nil { + return invite.Invite{}, invite.ErrInviteNotFound + } + stored.HashedCode = hashedCode + stored.ExpiresAt = expiresAt + stored.CreatedAt = createdAt + m.invites[id] = stored + return stored, nil +} + func (m *inviteFakeRepo) AcceptWithUser(id uint32, at time.Time, newUser user.User) (user.User, error) { if err := m.fail.trigger("MarkAccepted"); err != nil { return user.User{}, err @@ -138,6 +153,8 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture { getUser := useCases.NewGetUserUseCase(f.users) handler := userhandler.NewInviteHandler( useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL), + nil, + nil, useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), ) f.router = chi.NewRouter() diff --git a/test/invite_management_test.go b/test/invite_management_test.go new file mode 100644 index 0000000..1338fcd --- /dev/null +++ b/test/invite_management_test.go @@ -0,0 +1,591 @@ +package test + +import ( + "RenewCMS/api/handlers/auth" + userhandler "RenewCMS/api/handlers/user" + "RenewCMS/api/middleware" + "RenewCMS/api/pkg/apperr" + validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/domain/invite" + "RenewCMS/internal/domain/user" + entity "RenewCMS/internal/infrastructure/persistence/models" + persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "RenewCMS/internal/infrastructure/useCases" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/go-chi/chi/v5" + "github.com/go-chi/jwtauth/v5" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +const manageClientIP = "203.0.113.40" + +type inviteManagementFixture struct { + users *mockUserRepo + invites *inviteFakeRepo + mail *mockMailRepo + hasher *stubHasher + router *chi.Mux + token *jwtauth.JWTAuth +} + +func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagementFixture { + t.Helper() + validator.InitValidator() + f := &inviteManagementFixture{ + users: inviteInviterRepo(""), + invites: newInviteFakeRepo(), + mail: newMockMailRepo(), + hasher: &stubHasher{}, + token: jwtauth.New("HS256", []byte("test-secret"), nil), + } + f.invites.users = f.users + f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin} + f.users.users[11] = user.User{ID: 11, Username: "unverified", Email: "unverified@renew.example", IsVerified: false, Role: user.RoleAdmin} + f.users.users[12] = user.User{ID: 12, Username: "stale", Email: "stale@renew.example", IsVerified: true, Role: user.RoleOwner, TokenVersion: 2} + f.users.nextID = 12 + + getUser := useCases.NewGetUserUseCase(f.users) + handler := userhandler.NewInviteHandler( + useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL), + useCases.NewCancelInviteUseCase(f.invites), + useCases.NewRenewInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL), + useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), + ) + acceptHandler := auth.NewAcceptInviteHandler( + useCases.NewGetInviteUseCase(f.invites, f.hasher), + useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher), + f.token, + true, + ) + + f.router = chi.NewRouter() + f.router.Get("/v1/auth/invite", acceptHandler.GetInvite) + f.router.Post("/v1/auth/invite/accept", acceptHandler.AcceptInvite) + f.router.Route("/v1/user", func(r chi.Router) { + r.Use(jwtauth.Verifier(f.token)) + r.Use(jwtauth.Authenticator(f.token)) + r.Use(middleware.RequireCurrentToken(getUser)) + r.Use(middleware.RequireVerifiedUser(getUser)) + r.Get("/", handler.ListUsers) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireOwner(getUser)) + r.Use(middleware.TrustedProxyRealIP(nil)) + r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour)) + r.Post("/invite", handler.CreateInvite) + r.Delete("/invite/{id}", handler.CancelInvite) + r.Post("/invite/{id}/renew", handler.RenewInvite) + }) + }) + return f +} + +func (f *inviteManagementFixture) send(t *testing.T, method, target string, cookie *http.Cookie) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, target, strings.NewReader("")) + req.RemoteAddr = manageClientIP + ":4321" + if cookie != nil { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, req) + return rec +} + +func (f *inviteManagementFixture) asOwner(t *testing.T, method, target string) *httptest.ResponseRecorder { + t.Helper() + return f.send(t, method, target, displayNameCookie(t, f.token, 7, 0)) +} + +func (f *inviteManagementFixture) asUser(t *testing.T, id uint32, version uint32, method, target string) *httptest.ResponseRecorder { + t.Helper() + return f.send(t, method, target, displayNameCookie(t, f.token, id, version)) +} + +func (f *inviteManagementFixture) accepted(email string) invite.Invite { + at := time.Now().Add(-time.Hour) + seeded := pendingInvite(email) + seeded.AcceptedAt = &at + return f.invites.seed(seeded) +} + +func TestInviteManagementHttp(t *testing.T) { + t.Run("The owner cancels a pending invite", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + f.invites.seed(pendingInvite("sam.oliveira@renew.example")) + f.invites.seed(pendingInvite("lea.martin@renew.example")) + f.invites.seed(expiredInvite("old@renew.example")) + f.invites.seed(pendingInvite("third@renew.example")) + before := f.invites.invites[2] + ownerBefore, adminBefore := f.users.users[7], f.users.users[9] + + rec := f.asOwner(t, http.MethodDelete, "/v1/user/invite/1") + + if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 || rec.Header().Get("Cache-Control") != "" { + t.Fatalf("unexpected response %d %q", rec.Code, rec.Body.String()) + } + if _, ok := f.invites.invites[1]; ok || len(f.invites.invites) != 3 || f.invites.invites[2] != before { + t.Errorf("unexpected invite state %+v", f.invites.invites) + } + if len(f.mail.SentEmails) != 0 || len(f.users.users) != 4 || f.users.users[7] != ownerBefore || f.users.users[9] != adminBefore { + t.Errorf("mail or users changed") + } + if strings.Contains(f.asOwner(t, http.MethodGet, "/v1/user/").Body.String(), "sam.oliveira@renew.example") { + t.Errorf("cancelled invite still listed") + } + }) + + t.Run("A cancelled invite's link stops working and its address can be invited again", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + f.invites.seed(invite.Invite{Email: "sam.oliveira@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) + f.asOwner(t, http.MethodDelete, "/v1/user/invite/1") + + rec := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil) + if rec.Code != http.StatusNotFound || rec.Body.String() != inviteInvalidBody { + t.Errorf("expected 404 invalid, got %d %q", rec.Code, rec.Body.String()) + } + req := httptest.NewRequest(http.MethodPost, "/v1/user/invite", strings.NewReader(`{"email": "sam.oliveira@renew.example"}`)) + req.RemoteAddr = manageClientIP + ":4321" + req.AddCookie(displayNameCookie(t, f.token, 7, 0)) + created := httptest.NewRecorder() + f.router.ServeHTTP(created, req) + if created.Code != http.StatusCreated { + t.Errorf("expected 201, got %d", created.Code) + } + }) + + t.Run("The owner cancels an expired invite", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + f.invites.seed(expiredInvite("old@renew.example")) + + rec := f.asOwner(t, http.MethodDelete, "/v1/user/invite/1") + + if rec.Code != http.StatusNoContent || len(f.invites.invites) != 0 { + t.Errorf("expected 204 and empty repo, got %d", rec.Code) + } + }) + + t.Run("Renewing a cancelled invite is not found", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + f.invites.seed(pendingInvite("sam.oliveira@renew.example")) + f.asOwner(t, http.MethodDelete, "/v1/user/invite/1") + + rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") + + if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.InviteNotFoundErrorMessage+"\n" || len(f.mail.SentEmails) != 0 { + t.Errorf("unexpected %d %q", rec.Code, rec.Body.String()) + } + }) + + t.Run("An unknown invite is not found, and cancelling twice too", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + f.invites.seed(pendingInvite("sam.oliveira@renew.example")) + + rec := f.asOwner(t, http.MethodDelete, "/v1/user/invite/999") + if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.InviteNotFoundErrorMessage+"\n" { + t.Errorf("unexpected %d %q", rec.Code, rec.Body.String()) + } + f.asOwner(t, http.MethodDelete, "/v1/user/invite/1") + if again := f.asOwner(t, http.MethodDelete, "/v1/user/invite/1"); again.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", again.Code) + } + }) + + t.Run("An accepted invite cannot be cancelled or renewed", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + accepted := f.accepted("lea.martin@renew.example") + + for _, call := range []struct{ method, path string }{ + {http.MethodDelete, "/v1/user/invite/1"}, + {http.MethodPost, "/v1/user/invite/1/renew"}, + } { + rec := f.asOwner(t, call.method, call.path) + if rec.Code != http.StatusConflict || rec.Body.String() != apperr.InviteAlreadyAcceptedErrorMessage+"\n" { + t.Errorf("%s: unexpected %d %q", call.method, rec.Code, rec.Body.String()) + } + } + if f.invites.invites[1].HashedCode != accepted.HashedCode || len(f.mail.SentEmails) != 0 { + t.Errorf("accepted invite changed or mail sent") + } + }) + + t.Run("The owner renews an expired invite", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + now := time.Now() + for _, filler := range []string{"a@renew.example", "b@renew.example", "c@renew.example"} { + f.invites.seed(pendingInvite(filler)) + } + f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 9, CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(-2 * 24 * time.Hour)}) + + rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/4/renew") + + if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Cache-Control")) + } + decoded := decodeKeys(t, rec.Body.Bytes()) + assertExactKeys(t, decoded, "id", "kind", "email", "status", "created_at", "expires_at") + if decodedString(t, decoded, "kind") != "invite" || decodedString(t, decoded, "email") != "lea.martin@renew.example" || decodedString(t, decoded, "status") != "pending" { + t.Errorf("unexpected body %s", rec.Body.String()) + } + var id uint32 + if err := json.Unmarshal(decoded["id"], &id); err != nil || id != 4 { + t.Errorf("unexpected id %s", decoded["id"]) + } + createdAt, err := time.Parse(time.RFC3339Nano, decodedString(t, decoded, "created_at")) + if err != nil { + t.Fatalf("bad created_at: %v", err) + } + expiresAt, err := time.Parse(time.RFC3339Nano, decodedString(t, decoded, "expires_at")) + if err != nil { + t.Fatalf("bad expires_at: %v", err) + } + if time.Since(createdAt) > 5*time.Second || time.Until(createdAt) > 5*time.Second { + t.Errorf("unexpected created_at %s", createdAt) + } + if delta := expiresAt.Sub(createdAt) - invite.Lifetime; delta > 5*time.Second || delta < -5*time.Second { + t.Errorf("unexpected expires_at %s", expiresAt) + } + stored := f.invites.invites[4] + data := inviteMailData(t, f.mail) + if time.Since(stored.CreatedAt) > 5*time.Second || stored.ExpiresAt.Sub(stored.CreatedAt) != invite.Lifetime { + t.Errorf("unexpected dates %+v", stored) + } + if stored.HashedCode != "hashed:"+data.VerificationCode || stored.HashedCode == "hashed:OLD-CODE" || stored.InvitedBy != 9 || stored.AcceptedAt != nil || len(f.invites.invites) != 4 { + t.Errorf("unexpected stored invite %+v", stored) + } + if data.Host != invitePublicURL || data.Email != "lea.martin@renew.example" || data.InvitedBy != "marta" || f.mail.SentEmails[0].Template != "invitation" || f.mail.SentEmails[0].Address != "lea.martin@renew.example" { + t.Errorf("unexpected mail %+v", f.mail.SentEmails[0]) + } + }) + + t.Run("A renewed pending invite moves to the top of the list and the old link dies", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-48 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) + f.invites.seed(invite.Invite{Email: "newer@renew.example", HashedCode: "hashed:OTHER", InvitedBy: 7, CreatedAt: time.Now().Add(-24 * time.Hour), ExpiresAt: time.Now().Add(48 * time.Hour)}) + + f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") + + list := f.asOwner(t, http.MethodGet, "/v1/user/").Body.String() + if strings.Index(list, "lea.martin@renew.example") > strings.Index(list, "newer@renew.example") { + t.Errorf("renewed invite not listed first: %s", list) + } + if old := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); old.Code != http.StatusNotFound { + t.Errorf("old link expected 404, got %d", old.Code) + } + fresh := f.send(t, http.MethodGet, "/v1/auth/invite?c="+inviteMailData(t, f.mail).VerificationCode, nil) + if fresh.Code != http.StatusOK || !strings.Contains(fresh.Body.String(), "lea.martin@renew.example") { + t.Errorf("new link expected 200, got %d %q", fresh.Code, fresh.Body.String()) + } + }) + + t.Run("Accepting with a cancelled or replaced code is not found and creates no user", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) + f.invites.seed(invite.Invite{Email: "sam@renew.example", HashedCode: "hashed:CANCELLED", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) + f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") + f.asOwner(t, http.MethodDelete, "/v1/user/invite/2") + usersBefore := len(f.users.users) + + for _, code := range []string{"OLD-CODE", "CANCELLED"} { + body := `{"code":"` + code + `","username":"newperson","password":"password123"}` + req := httptest.NewRequest(http.MethodPost, "/v1/auth/invite/accept", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("%s: expected 404, got %d %q", code, rec.Code, rec.Body.String()) + } + } + if len(f.users.users) != usersBefore { + t.Errorf("a user was created") + } + }) + + t.Run("Renewing an unknown invite is not found", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + + rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/999/renew") + + if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.InviteNotFoundErrorMessage+"\n" { + t.Errorf("unexpected %d %q", rec.Code, rec.Body.String()) + } + }) + + t.Run("A failed email leaves the invite as it was", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + seeded := f.invites.seed(expiredInvite("lea.martin@renew.example")) + f.mail.fail.failNext("Send") + + rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") + + if rec.Code != http.StatusInternalServerError || rec.Body.String() != apperr.InviteNotSentErrorMessage+"\n" { + t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) + } + if f.invites.invites[1] != seeded { + t.Errorf("invite changed: %+v vs %+v", f.invites.invites[1], seeded) + } + if retry := f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew"); retry.Code != http.StatusOK { + t.Errorf("retry expected 200, got %d", retry.Code) + } + }) + + t.Run("A repository failure on cancel leaves the invite in place", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + seeded := f.invites.seed(pendingInvite("sam.oliveira@renew.example")) + f.invites.fail.failNextWith("Delete", errRepositoryFailure) + + rec := f.asOwner(t, http.MethodDelete, "/v1/user/invite/1") + + if rec.Code != http.StatusInternalServerError || f.invites.invites[1] != seeded { + t.Errorf("unexpected %d or invite changed", rec.Code) + } + }) + + t.Run("A repository failure on renew sends no mail and leaves the invite as it was", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + seeded := f.invites.seed(pendingInvite("sam.oliveira@renew.example")) + f.invites.fail.failNextWith("Renew", errRepositoryFailure) + + rec := f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") + + if rec.Code != http.StatusInternalServerError || f.invites.invites[1] != seeded || len(f.mail.SentEmails) != 0 { + t.Errorf("unexpected %d, invite or mail changed", rec.Code) + } + }) + + t.Run("The old code of a pending invite works until it is renewed", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + f.invites.seed(invite.Invite{Email: "lea.martin@renew.example", HashedCode: "hashed:OLD-CODE", InvitedBy: 7, CreatedAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour)}) + + if before := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); before.Code != http.StatusOK { + t.Fatalf("control expected 200, got %d", before.Code) + } + f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") + if after := f.send(t, http.MethodGet, "/v1/auth/invite?c=OLD-CODE", nil); after.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", after.Code) + } + }) + + t.Run("Access is checked on both endpoints", func(t *testing.T) { + cases := []struct { + name string + cookie func(f *inviteManagementFixture) *http.Cookie + wantErr int + }{ + {"no cookie", func(f *inviteManagementFixture) *http.Cookie { return nil }, http.StatusUnauthorized}, + {"stale token_version", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 12, 1) }, http.StatusUnauthorized}, + {"unverified user", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 11, 0) }, http.StatusForbidden}, + {"admin", func(f *inviteManagementFixture) *http.Cookie { return displayNameCookie(t, f.token, 9, 0) }, http.StatusForbidden}, + } + for _, tc := range cases { + for _, call := range []struct{ method, path string }{ + {http.MethodDelete, "/v1/user/invite/1"}, + {http.MethodPost, "/v1/user/invite/1/renew"}, + } { + f := newInviteManagementFixture(t, 100) + seeded := f.invites.seed(pendingInvite("sam.oliveira@renew.example")) + + rec := f.send(t, call.method, call.path, tc.cookie(f)) + + if rec.Code != tc.wantErr || f.invites.invites[1] != seeded || len(f.mail.SentEmails) != 0 { + t.Errorf("%s %s %s: got %d", tc.name, call.method, call.path, rec.Code) + } + } + } + }) + + t.Run("An admin is refused with the owner message, even on an unknown id", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + + rec := f.asUser(t, 9, 0, http.MethodDelete, "/v1/user/invite/999") + + if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" { + t.Errorf("unexpected %d %q", rec.Code, rec.Body.String()) + } + }) + + t.Run("An id that is not a uint32 is rejected", func(t *testing.T) { + f := newInviteManagementFixture(t, 100) + for _, ident := range []string{"abc", "-1", "4294967296"} { + for _, call := range []struct{ method, suffix string }{{http.MethodDelete, ""}, {http.MethodPost, "/renew"}} { + rec := f.asOwner(t, call.method, "/v1/user/invite/"+ident+call.suffix) + if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" { + t.Errorf("%s %s: unexpected %d %q", call.method, ident, rec.Code, rec.Body.String()) + } + } + } + }) + + t.Run("Requests over the limit are refused and the counter is shared with inviting", func(t *testing.T) { + f := newInviteManagementFixture(t, 2) + f.invites.seed(pendingInvite("sam.oliveira@renew.example")) + + req := httptest.NewRequest(http.MethodPost, "/v1/user/invite", strings.NewReader(`{"email": "new.person@renew.example"}`)) + req.RemoteAddr = manageClientIP + ":4321" + req.AddCookie(displayNameCookie(t, f.token, 7, 0)) + created := httptest.NewRecorder() + f.router.ServeHTTP(created, req) + if created.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d", created.Code) + } + f.asOwner(t, http.MethodPost, "/v1/user/invite/1/renew") + rec := f.asOwner(t, http.MethodDelete, "/v1/user/invite/1") + + if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || rec.Header().Get("Retry-After") == "" { + t.Errorf("unexpected %d %q", rec.Code, rec.Body.String()) + } + if _, ok := f.invites.invites[1]; !ok || len(f.mail.SentEmails) != 2 { + t.Errorf("third request changed something") + } + if list := f.asOwner(t, http.MethodGet, "/v1/user/"); list.Code != http.StatusOK { + t.Errorf("list should not be limited, got %d", list.Code) + } + }) + + t.Run("An admin does not use up the quota", func(t *testing.T) { + f := newInviteManagementFixture(t, 2) + for i := 0; i < 3; i++ { + if rec := f.asUser(t, 9, 0, http.MethodDelete, "/v1/user/invite/1"); rec.Code != http.StatusForbidden { + t.Errorf("request %d: expected 403, got %d", i, rec.Code) + } + } + }) +} + +type renewRestoreFailRepo struct { + *inviteFakeRepo + calls int +} + +func (r *renewRestoreFailRepo) Renew(id uint32, hashedCode string, expiresAt time.Time, createdAt time.Time) (invite.Invite, error) { + r.calls++ + if r.calls == 2 { + return invite.Invite{}, errRepositoryFailure + } + return r.inviteFakeRepo.Renew(id, hashedCode, expiresAt, createdAt) +} + +func TestManageInviteUseCases(t *testing.T) { + t.Run("Renew restores the previous values when the mail fails", func(t *testing.T) { + users := inviteInviterRepo("Marta") + invites := newInviteFakeRepo() + mail := newMockMailRepo() + seeded := invites.seed(pendingInvite("a@renew.example")) + mail.fail.failNextWith("Send", errors.New("smtp down")) + renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL) + + _, err := renew.RenewInvite(useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}) + + if !errors.Is(err, invite.ErrInviteEmailNotSent) || errors.Is(err, errRepositoryFailure) || invites.invites[1] != seeded { + t.Errorf("unexpected result %v", err) + } + }) + + t.Run("Renew joins the restore failure with the mail failure", func(t *testing.T) { + users := inviteInviterRepo("Marta") + invites := newInviteFakeRepo() + mail := newMockMailRepo() + invites.seed(pendingInvite("a@renew.example")) + mail.fail.failNextWith("Send", errors.New("smtp down")) + renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, &stubHasher{}, mail, invitePublicURL) + + _, err := renew.RenewInvite(useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}) + + if !errors.Is(err, invite.ErrInviteEmailNotSent) || !errors.Is(err, errRepositoryFailure) { + t.Errorf("unexpected result %v", err) + } + }) + + t.Run("Cancel returns the sentinels", func(t *testing.T) { + invites := newInviteFakeRepo() + invites.seed(pendingInvite("a@renew.example")) + accepted := pendingInvite("b@renew.example") + at := time.Now() + accepted.AcceptedAt = &at + invites.seed(accepted) + cancel := useCases.NewCancelInviteUseCase(invites) + + if err := cancel.CancelInvite(9); !errors.Is(err, invite.ErrInviteNotFound) { + t.Errorf("expected not found, got %v", err) + } + if err := cancel.CancelInvite(2); !errors.Is(err, invite.ErrInviteAlreadyAccepted) { + t.Errorf("expected already accepted, got %v", err) + } + if err := cancel.CancelInvite(1); err != nil { + t.Errorf("unexpected %v", err) + } + }) + + t.Run("Renew sends nothing when persisting fails", func(t *testing.T) { + users := inviteInviterRepo("Marta") + invites := newInviteFakeRepo() + mail := newMockMailRepo() + seeded := invites.seed(pendingInvite("a@renew.example")) + invites.fail.failNextWith("Renew", errRepositoryFailure) + renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL) + + _, err := renew.RenewInvite(useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}) + + if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 || invites.invites[1] != seeded { + t.Errorf("unexpected result %v", err) + } + }) + + t.Run("Renew names the acting owner by display name", func(t *testing.T) { + users := inviteInviterRepo("Marta L.") + invites := newInviteFakeRepo() + mail := newMockMailRepo() + invites.seed(pendingInvite("a@renew.example")) + renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL) + + if _, err := renew.RenewInvite(useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}); err != nil { + t.Fatalf("unexpected %v", err) + } + if inviteMailData(t, mail).InvitedBy != "Marta L." { + t.Errorf("unexpected inviter name") + } + }) +} + +func TestInviteRepositoryRenew(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:inviterenew?mode=memory&cache=private"), &gorm.Config{Logger: logger.Discard}) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + if err := db.AutoMigrate(&entity.Invite{}); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + repo := persistence.NewInviteRepository(db) + now := time.Now().UTC().Truncate(time.Second) + created, err := repo.Create(invite.Invite{Email: "a@renew.example", HashedCode: "old", InvitedBy: 7, CreatedAt: now.Add(-96 * time.Hour), ExpiresAt: now.Add(-24 * time.Hour)}) + if err != nil { + t.Fatalf("create failed: %v", err) + } + accepted := now + acceptedRow := entity.Invite{Email: "b@renew.example", HashedCode: "x", InvitedBy: 7, AcceptedAt: &accepted} + db.Create(&acceptedRow) + + t.Run("Renew replaces code hash and dates and keeps the id", func(t *testing.T) { + renewed, err := repo.Renew(created.ID, "new", now.Add(invite.Lifetime), now) + if err != nil || renewed.ID != created.ID || renewed.HashedCode != "new" || !renewed.CreatedAt.Equal(now) || !renewed.ExpiresAt.Equal(now.Add(invite.Lifetime)) || renewed.InvitedBy != 7 { + t.Errorf("unexpected %+v %v", renewed, err) + } + }) + + t.Run("Renew of an unknown or accepted invite is not found", func(t *testing.T) { + if _, err := repo.Renew(999, "new", now, now); !errors.Is(err, invite.ErrInviteNotFound) { + t.Errorf("expected not found, got %v", err) + } + if _, err := repo.Renew(acceptedRow.ID, "new", now, now); !errors.Is(err, invite.ErrInviteNotFound) { + t.Errorf("expected not found, got %v", err) + } + }) +} diff --git a/test/user_roles_test.go b/test/user_roles_test.go index 7587d42..57b4e71 100644 --- a/test/user_roles_test.go +++ b/test/user_roles_test.go @@ -63,6 +63,8 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture { meHandler := auth.NewMeHandler(getUser) inviteHandler := userhandler.NewInviteHandler( useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL), + nil, + nil, useCases.NewListUsersAndInvitesUseCase(f.users, f.invites), )