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)

This commit is contained in:
fsylvain committed 2026-09-30 01:36:43 +02:00
1 parent 461e93e3b5
commit 153fd8719a
18 files changed
+1147 -44

No files matched your search

+4
View File
@@ -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),
+73 -7
View File
@@ -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):
+2
View File
@@ -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."
+3 -1
View File
@@ -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
}
+205
View File
@@ -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=<the code from that invite>
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=<the old code>
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 <Host>/invite/accept?c=<new raw code>
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 <caller> sends <method> <path>
Then the response status is <status>
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 <method> <path>
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
+71
View File
@@ -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=<code>` -> `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.
+1
View File
@@ -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
@@ -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)
}
@@ -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 {
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
+2
View File
@@ -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(
+17
View File
@@ -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()
+591
View File
@@ -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)
}
})
}
+2
View File
@@ -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),
)