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
|
||||
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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
inviteHandler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL),
|
||||
nil,
|
||||
nil,
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user