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:
1 parent
461e93e3b5
commit
153fd8719a
18 files changed
+1147
-44
No files matched your search
@@ -38,6 +38,8 @@ type UseCases struct {
|
|||||||
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
|
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
|
||||||
ListUsersUseCase *useCases.ListUsersUseCase
|
ListUsersUseCase *useCases.ListUsersUseCase
|
||||||
CreateInviteUseCase *useCases.CreateInviteUseCase
|
CreateInviteUseCase *useCases.CreateInviteUseCase
|
||||||
|
CancelInviteUseCase *useCases.CancelInviteUseCase
|
||||||
|
RenewInviteUseCase *useCases.RenewInviteUseCase
|
||||||
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
||||||
GetInviteUseCase *useCases.GetInviteUseCase
|
GetInviteUseCase *useCases.GetInviteUseCase
|
||||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||||
@@ -111,6 +113,8 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
|||||||
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
|
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
|
||||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
|
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),
|
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
|
||||||
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
|
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
|
||||||
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
|
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type InviteHandler struct {
|
type InviteHandler struct {
|
||||||
CreateInviteUseCase *useCases.CreateInviteUseCase
|
CreateInviteUseCase *useCases.CreateInviteUseCase
|
||||||
|
CancelInviteUseCase *useCases.CancelInviteUseCase
|
||||||
|
RenewInviteUseCase *useCases.RenewInviteUseCase
|
||||||
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +55,11 @@ type userListItem struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
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{
|
return &InviteHandler{
|
||||||
CreateInviteUseCase: createInvite,
|
CreateInviteUseCase: createInvite,
|
||||||
|
CancelInviteUseCase: cancelInvite,
|
||||||
|
RenewInviteUseCase: renewInvite,
|
||||||
ListUsersAndInvitesUseCase: listUsersAndInvites,
|
ListUsersAndInvitesUseCase: listUsersAndInvites,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,23 +91,82 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeInviteResponse(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInviteResponse(w http.ResponseWriter, status int, entry invite.Invite) {
|
||||||
body, err := json.Marshal(inviteResponse{
|
body, err := json.Marshal(inviteResponse{
|
||||||
ID: created.ID,
|
ID: entry.ID,
|
||||||
Kind: useCases.EntryKindInvite,
|
Kind: useCases.EntryKindInvite,
|
||||||
Email: created.Email,
|
Email: entry.Email,
|
||||||
Status: string(created.StatusAt(time.Now())),
|
Status: string(entry.StatusAt(time.Now())),
|
||||||
CreatedAt: created.CreatedAt,
|
CreatedAt: entry.CreatedAt,
|
||||||
ExpiresAt: created.ExpiresAt,
|
ExpiresAt: entry.ExpiresAt,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(status)
|
||||||
_, _ = w.Write(body)
|
_, _ = 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) {
|
func writeCreateInviteError(w http.ResponseWriter, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, invite.ErrEmailHasAccount):
|
case errors.Is(err, invite.ErrEmailHasAccount):
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
|
|||||||
const EmailHasAccountErrorMessage = "This address already has an account."
|
const EmailHasAccountErrorMessage = "This address already has an account."
|
||||||
const PendingInviteErrorMessage = "There's already a pending invite for this address."
|
const PendingInviteErrorMessage = "There's already a pending invite for this address."
|
||||||
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
|
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 InviteInvalidErrorMessage = "This invite link doesn't work."
|
||||||
const InviteExpiredErrorMessage = "This invite has expired."
|
const InviteExpiredErrorMessage = "This invite has expired."
|
||||||
const OwnerRequiredErrorMessage = "Only the owner can do this."
|
const OwnerRequiredErrorMessage = "Only the owner can do this."
|
||||||
|
|||||||
+3
-1
@@ -100,7 +100,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
userHandler := user.NewHandler(container.UpdateUserProfileUseCase)
|
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.Verifier(container.Token))
|
||||||
r.Use(jwtauth.Authenticator(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.TrustedProxyRealIP(trustedProxies))
|
||||||
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||||
r.Post("/invite", inviteHandler.CreateInvite)
|
r.Post("/invite", inviteHandler.CreateInvite)
|
||||||
|
r.Delete("/invite/{id}", inviteHandler.CancelInvite)
|
||||||
|
r.Post("/invite/{id}/renew", inviteHandler.RenewInvite)
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -11,6 +11,7 @@ var ErrInviteNotFound = errors.New("invite not found")
|
|||||||
var ErrInviteEmailNotSent = errors.New("invite email not sent")
|
var ErrInviteEmailNotSent = errors.New("invite email not sent")
|
||||||
var ErrInviteInvalid = errors.New("invite invalid")
|
var ErrInviteInvalid = errors.New("invite invalid")
|
||||||
var ErrInviteExpired = errors.New("invite expired")
|
var ErrInviteExpired = errors.New("invite expired")
|
||||||
|
var ErrInviteAlreadyAccepted = errors.New("invite already accepted")
|
||||||
|
|
||||||
const Lifetime = 3 * 24 * time.Hour
|
const Lifetime = 3 * 24 * time.Hour
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ type Repository interface {
|
|||||||
Create(invite Invite) (Invite, error)
|
Create(invite Invite) (Invite, error)
|
||||||
GetAll() []Invite
|
GetAll() []Invite
|
||||||
Delete(id uint32) error
|
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)
|
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)
|
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) {
|
func (i *InviteRepository) AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser domainUser.User) (domainUser.User, error) {
|
||||||
createdModel := mappers.UserToModel(newUser)
|
createdModel := mappers.UserToModel(newUser)
|
||||||
err := i.db.Transaction(func(tx *gorm.DB) error {
|
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/mail"
|
||||||
"RenewCMS/internal/domain/user"
|
"RenewCMS/internal/domain/user"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const inviteTemplateName = "invitation"
|
|
||||||
|
|
||||||
type CreateInviteUseCase struct {
|
type CreateInviteUseCase struct {
|
||||||
userRepository user.Repository
|
userRepository user.Repository
|
||||||
inviteRepository invite.Repository
|
inviteRepository invite.Repository
|
||||||
hasher hash.Hasher
|
hasher hash.Hasher
|
||||||
mailRepository mail.Repository
|
mailer inviteMailer
|
||||||
publicURL string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateInviteCommand struct {
|
type CreateInviteCommand struct {
|
||||||
@@ -28,20 +22,8 @@ type CreateInviteCommand struct {
|
|||||||
InvitedBy uint32
|
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 {
|
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) {
|
func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.Invite, error) {
|
||||||
@@ -61,8 +43,7 @@ func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.
|
|||||||
return invite.Invite{}, err
|
return invite.Invite{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rawCode := uuid.NewString()
|
rawCode, hashedCode, err := newInviteCode(g.hasher)
|
||||||
hashedCode, err := g.hasher.Hash(rawCode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return invite.Invite{}, err
|
return invite.Invite{}, err
|
||||||
}
|
}
|
||||||
@@ -78,13 +59,7 @@ func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.
|
|||||||
return invite.Invite{}, err
|
return invite.Invite{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.mailRepository.Send(email, inviteTemplateName, MailInviteData{
|
if sendErr := g.mailer.send(email, rawCode, inviter); sendErr != nil {
|
||||||
Host: g.publicURL,
|
|
||||||
VerificationCode: rawCode,
|
|
||||||
Email: email,
|
|
||||||
InvitedBy: inviterName(inviter),
|
|
||||||
}); err != nil {
|
|
||||||
sendErr := fmt.Errorf("%w: %w", invite.ErrInviteEmailNotSent, err)
|
|
||||||
if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil {
|
if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil {
|
||||||
return invite.Invite{}, errors.Join(sendErr, deleteErr)
|
return invite.Invite{}, errors.Join(sendErr, deleteErr)
|
||||||
}
|
}
|
||||||
@@ -119,10 +94,3 @@ func (g *CreateInviteUseCase) discardOpenInvite(email string, now time.Time) err
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
@@ -63,6 +63,8 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
|
|||||||
meHandler := auth.NewMeHandler(getUser)
|
meHandler := auth.NewMeHandler(getUser)
|
||||||
listHandler := userhandler.NewInviteHandler(
|
listHandler := userhandler.NewInviteHandler(
|
||||||
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
|
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||||
)
|
)
|
||||||
registerHandler := auth.NewLoginHandler(
|
registerHandler := auth.NewLoginHandler(
|
||||||
|
|||||||
@@ -76,6 +76,21 @@ func (m *inviteFakeRepo) Delete(id uint32) error {
|
|||||||
return nil
|
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) {
|
func (m *inviteFakeRepo) AcceptWithUser(id uint32, at time.Time, newUser user.User) (user.User, error) {
|
||||||
if err := m.fail.trigger("MarkAccepted"); err != nil {
|
if err := m.fail.trigger("MarkAccepted"); err != nil {
|
||||||
return user.User{}, err
|
return user.User{}, err
|
||||||
@@ -138,6 +153,8 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
|
|||||||
getUser := useCases.NewGetUserUseCase(f.users)
|
getUser := useCases.NewGetUserUseCase(f.users)
|
||||||
handler := userhandler.NewInviteHandler(
|
handler := userhandler.NewInviteHandler(
|
||||||
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
|
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||||
)
|
)
|
||||||
f.router = chi.NewRouter()
|
f.router = chi.NewRouter()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -63,6 +63,8 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
|
|||||||
meHandler := auth.NewMeHandler(getUser)
|
meHandler := auth.NewMeHandler(getUser)
|
||||||
inviteHandler := userhandler.NewInviteHandler(
|
inviteHandler := userhandler.NewInviteHandler(
|
||||||
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL),
|
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL),
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user