feat: invitation accept screen and api
This commit is contained in:
1 parent
310cfd528e
commit
55525c06d6
23 files changed
+2043
-11
No files matched your search
@@ -38,6 +38,8 @@ type UseCases struct {
|
||||
ListUsersUseCase *useCases.ListUsersUseCase
|
||||
CreateInviteUseCase *useCases.CreateInviteUseCase
|
||||
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
|
||||
GetInviteUseCase *useCases.GetInviteUseCase
|
||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||
SendMailUseCase *useCases.SendMailUseCase
|
||||
CreateImageUseCase *useCases.CreateImageUseCase
|
||||
DeleteImageUseCase *useCases.DeleteImageUseCase
|
||||
@@ -98,6 +100,8 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(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),
|
||||
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
|
||||
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type AcceptInviteHandler struct {
|
||||
Token *jwtauth.JWTAuth
|
||||
GetInviteUseCase *useCases.GetInviteUseCase
|
||||
AcceptInviteUseCase *useCases.AcceptInviteUseCase
|
||||
IsProduction bool
|
||||
}
|
||||
|
||||
type AcceptInviteRequest struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
Username string `json:"username" validate:"required,min=3,max=20"`
|
||||
Password string `json:"password" validate:"required,min=8"`
|
||||
}
|
||||
|
||||
type inviteEmailResponse struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *jwtauth.JWTAuth, isProduction bool) *AcceptInviteHandler {
|
||||
return &AcceptInviteHandler{
|
||||
Token: token,
|
||||
GetInviteUseCase: getInvite,
|
||||
AcceptInviteUseCase: acceptInvite,
|
||||
IsProduction: isProduction,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AcceptInviteHandler) GetInvite(w http.ResponseWriter, r *http.Request) {
|
||||
found, err := h.GetInviteUseCase.GetInvite(r.URL.Query().Get("c"))
|
||||
if err != nil {
|
||||
writeAcceptInviteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(inviteEmailResponse{Email: found.Email})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (h *AcceptInviteHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
|
||||
var request AcceptInviteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
createdUser, err := h.AcceptInviteUseCase.AcceptInvite(useCases.AcceptInviteCommand{
|
||||
Code: request.Code,
|
||||
Username: request.Username,
|
||||
Password: request.Password,
|
||||
})
|
||||
if err != nil {
|
||||
writeAcceptInviteError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := setJwtCookie(w, h.Token, h.IsProduction, createdUser); err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
message, _ := json.Marshal(map[string]any{"message": "Invite accepted! HTTPonly jwt cookie created"})
|
||||
_, _ = w.Write(message)
|
||||
}
|
||||
|
||||
func writeAcceptInviteError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, invite.ErrInviteInvalid):
|
||||
http.Error(w, apperr.InviteInvalidErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, invite.ErrInviteExpired):
|
||||
http.Error(w, apperr.InviteExpiredErrorMessage, http.StatusGone)
|
||||
case errors.Is(err, user.ErrUsernameTaken):
|
||||
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
|
||||
case errors.Is(err, invite.ErrEmailHasAccount):
|
||||
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -52,22 +52,26 @@ func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *LoginHandler) SetJwtCookie(w *http.ResponseWriter, u user.User) error {
|
||||
_, tokenString, err := h.Token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
|
||||
func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error {
|
||||
_, tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(*w, &http.Cookie{
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "jwt",
|
||||
Value: tokenString,
|
||||
Expires: time.Now().Add(24 * time.Hour),
|
||||
Secure: h.IsProduction,
|
||||
Secure: isProduction,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *LoginHandler) SetJwtCookie(w *http.ResponseWriter, u user.User) error {
|
||||
return setJwtCookie(*w, h.Token, h.IsProduction, u)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var credentials LoginCredentials
|
||||
|
||||
|
||||
@@ -19,3 +19,6 @@ 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 InviteInvalidErrorMessage = "This invite link doesn't work."
|
||||
const InviteExpiredErrorMessage = "This invite has expired."
|
||||
const UsernameTakenErrorMessage = "That username is taken. Try another."
|
||||
@@ -62,6 +62,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
|
||||
meHandler := auth.NewMeHandler(container.GetUserUseCase)
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
|
||||
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
|
||||
|
||||
r.Post("/register", loginHandler.Register)
|
||||
r.Get("/registration", loginHandler.RegistrationStatus)
|
||||
@@ -77,6 +78,12 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
|
||||
})
|
||||
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||
r.Get("/invite", acceptInviteHandler.GetInvite)
|
||||
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
|
||||
@@ -27,9 +27,9 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
||||
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. |
|
||||
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. |
|
||||
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
|
||||
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login` or `POST /v1/auth/password-reset` within the window, tracked independently per route. |
|
||||
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the two routes above. |
|
||||
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for `POST /v1/auth/login` and `POST /v1/auth/password-reset` rate limiting. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
|
||||
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
|
||||
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
|
||||
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
|
||||
|
||||
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
Feature: An invited person accepts their invitation and gets a verified account
|
||||
|
||||
As a person who received an invitation email
|
||||
I want to open the link, see which address it was sent to, choose a username and password,
|
||||
and land logged in
|
||||
So that I get an account with exactly the same access as everyone else, while registration
|
||||
stays closed to anyone who was not invited
|
||||
|
||||
Background:
|
||||
Given GET /v1/auth/invite and POST /v1/auth/invite/accept are mounted in NewAuthRouter and are
|
||||
public: no jwt cookie is required and an existing jwt cookie is ignored
|
||||
And both are rate-limited per client IP with the auth rate limiter (AUTH_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
AUTH_RATE_LIMIT_WINDOW_SECONDS), counted independently of /login, /password-reset and
|
||||
POST /v1/user/invite
|
||||
And an invite is looked up by comparing the raw code against the stored hashed code with the
|
||||
same hash.Hasher used to create it, and the raw code is never stored
|
||||
And an invite is usable only when accepted_at is empty and expires_at is in the future
|
||||
And a code that matches no invite, matches an accepted invite, or is missing or empty answers
|
||||
404 with body apperr.InviteInvalidErrorMessage "This invite link doesn't work."
|
||||
And a code that matches an unaccepted invite whose expires_at is in the past answers 410 with
|
||||
body apperr.InviteExpiredErrorMessage "This invite has expired."
|
||||
And the POST body has the validate tags code required, username required,min=3,max=20 and
|
||||
password required,min=8, checked with validator.Validate.Struct before any lookup, and a
|
||||
failure answers 400 with the validator's text; an unparseable body answers 400 with
|
||||
apperr.BodyErrorMessage
|
||||
And error responses are plain text from http.Error, success responses are JSON
|
||||
And accepting creates a user with the invite's email (already lower case), the chosen username,
|
||||
the hashed password, is_verified true, an empty verification code, and an empty display name
|
||||
And the created user has the same access as any other verified user: no roles, no ownership
|
||||
And the jwt cookie is set exactly as registration sets it: HttpOnly, path "/", 24 hours, with
|
||||
claims user_id and token_version, and Secure in production
|
||||
|
||||
Scenario: The invited email is read from a valid code
|
||||
Given a pending invite for "lea.martin@renew.example" whose raw code is "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
|
||||
When the client sends GET /v1/auth/invite?c=3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42
|
||||
Then the response status is 200
|
||||
And the response body is a JSON object with exactly the key email, with value "lea.martin@renew.example"
|
||||
And the response has Cache-Control "no-store"
|
||||
And the invite is unchanged
|
||||
|
||||
Scenario Outline: A link that does not work is reported without saying why
|
||||
Given a pending invite for "lea.martin@renew.example", an accepted invite for "sam@renew.example"
|
||||
and an expired invite for "p.huber@gmail.com", each with its own raw code
|
||||
When the client sends GET /v1/auth/invite<query>
|
||||
Then the response status is <status>
|
||||
And the response body is "<body>"
|
||||
|
||||
Examples:
|
||||
| query | status | body |
|
||||
| ?c=not-a-known-code | 404 | This invite link doesn't work. |
|
||||
| ?c=<accepted invite code> | 404 | This invite link doesn't work. |
|
||||
| ?c= | 404 | This invite link doesn't work. |
|
||||
| | 404 | This invite link doesn't work. |
|
||||
| ?c=<expired invite code> | 410 | This invite has expired. |
|
||||
|
||||
Scenario: Accepting creates a verified user, logs them in and closes the invite
|
||||
Given a verified user "marta" and a pending invite for "Lea.Martin@Renew.example" created at
|
||||
invitation time, with raw code "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
|
||||
When the client sends POST /v1/auth/invite/accept with body
|
||||
{"code": "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42", "username": "lea", "password": "limewash2026"}
|
||||
Then the response status is 200
|
||||
And the response body is a JSON object {"message": "Invite accepted! HTTPonly jwt cookie created"}
|
||||
And a jwt cookie is set, and decoding it gives the new user's id and token_version
|
||||
And the user repository holds a user "lea" with email "lea.martin@renew.example", is_verified
|
||||
true, an empty verification_code and a stored password that is a hash, not "limewash2026"
|
||||
And the invite now has accepted_at set and its status is "accepted"
|
||||
And no email was sent
|
||||
|
||||
Scenario: The new account works like any other account
|
||||
Given the invite from the previous scenario was accepted and the jwt cookie was kept
|
||||
When the client sends GET /v1/auth/me and GET /v1/user/ with that cookie
|
||||
Then both responses are 200
|
||||
And the GET /v1/user/ list shows user "lea" with status "accepted" and no invite for
|
||||
"lea.martin@renew.example"
|
||||
|
||||
Scenario: An accepted code cannot be used again
|
||||
Given an invite that was just accepted by "lea"
|
||||
When the client sends POST /v1/auth/invite/accept with the same code, username "lea2" and
|
||||
password "limewash2026"
|
||||
Then the response status is 404
|
||||
And the response body is "This invite link doesn't work."
|
||||
And no user "lea2" exists and no jwt cookie is set
|
||||
When the client sends GET /v1/auth/invite with the same code
|
||||
Then the response status is 404
|
||||
|
||||
Scenario Outline: A code that cannot be used creates nothing
|
||||
Given <state>
|
||||
When the client sends POST /v1/auth/invite/accept with a body using <code> and username "lea" and password "limewash2026"
|
||||
Then the response status is <status>
|
||||
And the response body is "<body>"
|
||||
And no user was created, no jwt cookie is set and no invite changed
|
||||
|
||||
Examples:
|
||||
| state | code | status | body |
|
||||
| no invite has that code | an unknown one | 404 | This invite link doesn't work. |
|
||||
| an invite that expired an hour ago | its code | 410 | This invite has expired. |
|
||||
|
||||
Scenario: A taken username is refused and the invite stays usable
|
||||
Given a verified user "marta" and a pending invite for "lea.martin@renew.example"
|
||||
When the client sends POST /v1/auth/invite/accept with username "marta" and password "limewash2026"
|
||||
Then the response status is 409
|
||||
And the response body is "That username is taken. Try another."
|
||||
And no jwt cookie is set and the invite is still pending
|
||||
When the client resends with username "lea"
|
||||
Then the response status is 200
|
||||
|
||||
Scenario: An address that got an account in the meantime is refused
|
||||
Given a pending invite for "lea.martin@renew.example" and an unverified user "leam" with email "Lea.Martin@Renew.example"
|
||||
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
|
||||
Then the response status is 409
|
||||
And the response body is exactly apperr.EmailHasAccountErrorMessage
|
||||
And no user "lea" was created, no jwt cookie is set and the invite is still pending
|
||||
|
||||
Scenario Outline: An invalid body is rejected before the code is looked up
|
||||
Given a pending invite with a known code
|
||||
When the client sends POST /v1/auth/invite/accept with body <body>
|
||||
Then the response status is 400
|
||||
And no user was created and the invite is still pending
|
||||
|
||||
Examples:
|
||||
| body |
|
||||
| {"code": "<code>", "username": "le", "password": "limewash2026"} |
|
||||
| {"code": "<code>", "username": "abcdefghijklmnopqrstu", "password": "limewash2026"} |
|
||||
| {"code": "<code>", "username": "lea", "password": "lime"} |
|
||||
| {"code": "<code>", "password": "limewash2026"} |
|
||||
| {"username": "lea", "password": "limewash2026"} |
|
||||
| not json |
|
||||
|
||||
Scenario: An existing jwt cookie is ignored and replaced
|
||||
Given a valid jwt cookie for verified user "marta" and a pending invite for "lea.martin@renew.example"
|
||||
When the client sends POST /v1/auth/invite/accept with that cookie and username "lea", password "limewash2026"
|
||||
Then the response status is 200
|
||||
And the Set-Cookie jwt carries the new user's id, not marta's
|
||||
|
||||
Scenario: A failure after the user is created leaves the invite pending and no user behind
|
||||
Given a pending invite and the invite repository set to fail marking it accepted
|
||||
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
|
||||
Then the response status is 500
|
||||
And no user "lea" exists, no jwt cookie is set and the invite is still pending
|
||||
|
||||
Scenario: The accept endpoints are rate-limited
|
||||
Given AUTH_RATE_LIMIT_MAX_ATTEMPTS is 2 for a window covering the whole test
|
||||
When the client at IP "203.0.113.30" sends 2 requests to POST /v1/auth/invite/accept and then a 3rd
|
||||
Then the 3rd response is 429 with body apperr.TooManyRequestsErrorMessage and a Retry-After header
|
||||
And GET /v1/auth/registration from the same client is not rejected with 429
|
||||
|
||||
Scenario: Registration stays closed for everyone else
|
||||
Given a verified user and an accepted invite
|
||||
When the client sends POST /v1/auth/register with username "sam", password "hunter2example12",
|
||||
email "sam@renew.example"
|
||||
Then the response status is 403
|
||||
And the response body is exactly apperr.RegistrationClosedErrorMessage
|
||||
And GET /v1/auth/registration still reports {"open": false}
|
||||
|
||||
Scenario: The invite response never leaks the code or other invite data
|
||||
Given a pending invite
|
||||
When the client sends GET /v1/auth/invite with its code
|
||||
Then the body contains no key named code, hashed_code, invited_by, expires_at, id or accepted_at
|
||||
@@ -0,0 +1,39 @@
|
||||
# QA procedure: accept invitation (API)
|
||||
|
||||
Package `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo`, `mockInviteRepo` (extend it with an accept method and a `failSwitch` entry for it; type names stay unique in the package).
|
||||
|
||||
## Setup shared by every case
|
||||
1. `validator.InitValidator()` once.
|
||||
2. Use cases: the accept use case (constructor takes only domain interfaces: user repo, invite repo, hasher; final names are the coder's), a get-invite use case for GET, plus `CreateUser`, `GetUser`, `ListUsersAndInvites` for the regression cases.
|
||||
3. Handler built with `NewHandler(...)` over the use cases, mounted on a fresh `chi.NewRouter()` mirroring `NewAuthRouter`: `GET /invite` and `POST /invite/accept` inside a group with `TrustedProxyRealIP(nil)` + `NewRateLimiter(...)`; `/register`, `/registration` and (with `jwtauth.Verifier`, `Authenticator`, `RequireCurrentToken`) `/me` for regression. `jwtauth.New("HS256", []byte("test-secret"), nil)`. `httptest` only.
|
||||
4. Seed invites via `mockInviteRepo` with `HashedCode == "hashed:"+rawCode` (stubHasher); times relative to `time.Now()`, 5 s tolerance.
|
||||
|
||||
## GET /v1/auth/invite
|
||||
5. Valid code -> 200; decode `map[string]json.RawMessage`: keys exactly `email`; value the stored lower-case email; `Cache-Control: no-store`; invite unchanged. Raw body contains none of `code`, `hashed_code`, `invited_by`, `expires_at`, `id`, `accepted_at`.
|
||||
6. Table: unknown code, accepted invite code, `?c=`, no query -> 404 with body exactly `apperr.InviteInvalidErrorMessage`; expired code -> 410 with `apperr.InviteExpiredErrorMessage`.
|
||||
|
||||
## POST /v1/auth/invite/accept
|
||||
7. Happy path: body `{code, username:"lea", password:"limewash2026"}` -> 200, `{"message":…}`. `Set-Cookie` `jwt` HttpOnly, Path "/", decode via `jwtauth` to get `user_id` and `token_version` of the created user. `userRepo`: user "lea", `Email=="lea.martin@renew.example"`, `IsVerified`, empty `VerificationCode`, password `"hashed:limewash2026"`. Invite `AcceptedAt` not nil. `SentEmails` empty.
|
||||
8. Follow-up with the cookie: `GET /me` -> 200; list use case shows user "lea" and no invite for the address.
|
||||
9. Repeat POST with the same code -> 404 `InviteInvalid`, no user "lea2", no `Set-Cookie`; GET same code -> 404.
|
||||
10. Table for 404 (unknown code) and 410 (expired an hour ago): exact bodies, no user, no cookie, invites unchanged.
|
||||
11. Username "marta" taken -> 409, body `apperr.UsernameTakenErrorMessage` ("That username is taken. Try another."), no cookie, invite still pending; retry with "lea" -> 200.
|
||||
12. Unverified user with email `Lea.Martin@Renew.example` present -> 409, body `apperr.EmailHasAccountErrorMessage`, no user created, invite pending, no cookie.
|
||||
13. 400 table (username 2 chars, 21 chars, password 4 chars, no username, no code, `not json`): status 400, user repo and invite unchanged. Code lookup is not reached (assert the invite repo saw no accept call).
|
||||
14. Cookie of verified user 7 sent with a valid accept -> 200 and the new cookie's `user_id` is the new user's.
|
||||
15. Invite repo accept method set to fail (`failSwitch`) -> 500, no user "lea" remains (user repo `Delete` was called), invite pending, no cookie.
|
||||
16. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.30:1234`, three POSTs -> third is 429 with body `apperr.TooManyRequestsErrorMessage` and `Retry-After`; `GET /registration` afterwards is not 429.
|
||||
|
||||
## Regression
|
||||
17. Verified user present: `POST /register` -> 403 body `apperr.RegistrationClosedErrorMessage`, no user created; `GET /registration` -> `{"open":false}` after an invite was accepted. Empty user repo -> `POST /register` still 200 with a `verification` mail.
|
||||
|
||||
## Use case level (no HTTP)
|
||||
18. Accept use case: unknown or empty code -> `errors.Is(err, invite.ErrInviteNotFound)`; expired -> an expired sentinel; taken username -> a username-taken sentinel; email owned -> `invite.ErrEmailHasAccount` (sentinel names are the coder's). Domain sentinels asserted with `errors.Is`.
|
||||
|
||||
## Open questions (no design state, not invented)
|
||||
- GET /v1/auth/invite failing on load with a 500 or a network error: the design has no state for it.
|
||||
- 429 on POST /v1/auth/invite/accept: the SPA shows the design's server-error alert ("Couldn't create the account").
|
||||
|
||||
## Manual verification
|
||||
- Fresh SQLite file: invite acceptance persists across restart, `accepted_at` stored, unique username constraint holds; send a real invitation and open its link end to end (real SMTP, real embedded SPA).
|
||||
- Concurrency: two simultaneous accepts of one code create one user (not exercisable with the in-memory fakes).
|
||||
@@ -0,0 +1,105 @@
|
||||
Feature: Accept invite screen
|
||||
|
||||
As a person who opened the link in the invitation email
|
||||
I want /invite/accept?c=CODE to show me the invited address and let me choose a username and
|
||||
password
|
||||
So that I finish setting up my account and arrive logged in
|
||||
|
||||
Background:
|
||||
Given /invite/accept is a public route (meta.auth "public") rendered as AcceptInvitePage inside
|
||||
AuthShell, per docs/design/routes.md
|
||||
And on mount AcceptInvitePage reads the "c" query parameter once: if it is absent or empty no
|
||||
request is made and the "Invalid or used link (404)" state renders
|
||||
And otherwise it calls GET /v1/auth/invite?c=CODE and shows "Checking the link" until it resolves
|
||||
And the Username and Password rules are Register's, including PasswordStrengthHint, with no
|
||||
confirm field; validation runs on blur and on submit and nothing is sent when it fails
|
||||
And a submit that passes calls POST /v1/auth/invite/accept with { code: c, username, password }
|
||||
And a logged-in visitor sees the same screen: the route stays public and nothing redirects
|
||||
And the board is docs/design/source/RenewCMS Screens Auth.dc.html, group "Accept invite"; the
|
||||
board frame decides layout and sample data (lea.martin@renew.example, "lea", "limewash2026",
|
||||
"marta")
|
||||
|
||||
Scenario: Checking the link
|
||||
When the user opens /invite/accept?c=abc123 and GET /v1/auth/invite has not resolved
|
||||
Then a full-card spinner reads "Checking your invite…"
|
||||
|
||||
Scenario: Default
|
||||
Given GET /v1/auth/invite?c=abc123 resolves 200 with {"email": "lea.martin@renew.example"}
|
||||
Then the heading is "Accept your invite"
|
||||
And the description is "Choose a username and password to finish setting up your account."
|
||||
And the Email field has placeholder "you@example.com", shows "lea.martin@renew.example", is
|
||||
read-only, with help "From your invitation."
|
||||
And the Username field has placeholder "e.g. lea", counter "0 / 20" and help "Letters and numbers,
|
||||
3 to 20 characters. You'll use it to log in."
|
||||
And the Password field is a password input with show/hide and checklist "At least 8 characters" unmet
|
||||
And the submit button reads "Accept invite"
|
||||
And the footer reads "Already have an account?" with a "Log in" link to /login
|
||||
|
||||
Scenario: Validation errors
|
||||
Given the form is shown, Username is "ma" and Password is "lime"
|
||||
When the user blurs both fields and submits
|
||||
Then POST /v1/auth/invite/accept is never called
|
||||
And the Username shows counter "2 / 20" in error and the error "Use 3 to 20 characters."
|
||||
And the checklist item "At least 8 characters" is in its error state
|
||||
|
||||
Scenario: Filled, valid
|
||||
Given Username is "lea" and Password is "limewash2026" with the password shown
|
||||
Then the counter reads "3 / 20", the checklist item "At least 8 characters" is met and no error shows
|
||||
|
||||
Scenario: Submitting
|
||||
When the user submits valid values and POST /v1/auth/invite/accept has not resolved
|
||||
Then the fields and button are disabled
|
||||
And the submit button reads "Creating account…" and is loading
|
||||
|
||||
Scenario: Username taken (409)
|
||||
When POST /v1/auth/invite/accept resolves 409 with body "That username is taken. Try another."
|
||||
Then the Username keeps "marta", shows counter "5 / 20", is focused and shows the error
|
||||
"That username is taken. Try another."
|
||||
And the Password keeps its value, the form re-enables and the button reads "Accept invite"
|
||||
|
||||
Scenario Outline: The link does not work
|
||||
When <trigger>
|
||||
Then no form is shown
|
||||
And a ResultPanel (tone error, icon i-lucide-link-2-off) reads "This invite link doesn't work"
|
||||
with text "It may be incomplete, or it may already have been used. If you already set up your
|
||||
account, log in instead." and note "Open the link from your email again, or copy the full
|
||||
address into your browser."
|
||||
And its only action is a primary "Go to log in" that navigates to /login
|
||||
|
||||
Examples:
|
||||
| trigger |
|
||||
| the user opens /invite/accept with no c parameter (no request is made) |
|
||||
| GET /v1/auth/invite?c=abc123 resolves 404 |
|
||||
| the submit resolves 404, replacing the form |
|
||||
| the submit resolves 409 with apperr.EmailHasAccountErrorMessage |
|
||||
|
||||
Scenario Outline: Expired link (410)
|
||||
When <trigger>
|
||||
Then no form is shown
|
||||
And a ResultPanel (tone error, icon i-lucide-clock) reads "This invite has expired" with text
|
||||
"Invites work once, for 3 days. Ask the person who invited you to send a new one."
|
||||
And its only action is a secondary "Go to log in" that navigates to /login
|
||||
|
||||
Examples:
|
||||
| trigger |
|
||||
| GET /v1/auth/invite?c=abc123 resolves 410 |
|
||||
| the submit resolves 410, replacing the form |
|
||||
|
||||
Scenario Outline: Server error
|
||||
When the submit <failure>
|
||||
Then an error Alert (icon i-lucide-circle-x) reads "Couldn't create the account" with "Something
|
||||
went wrong on the server. Try again in a moment." above the heading
|
||||
And the form is still shown with Email, Username "lea" and Password "limewash2026" kept, re-enabled
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves 500 |
|
||||
| resolves 429 |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Success
|
||||
When POST /v1/auth/invite/accept resolves 200 with the jwt cookie
|
||||
Then a ResultPanel (tone success, icon i-lucide-circle-check) reads "Your account is ready" with
|
||||
text "Taking you to your dashboard…"
|
||||
And a Toast (icon i-lucide-circle-check) reads "Welcome to RenewCMS" with "You're logged in as lea."
|
||||
And after 800 ms the app navigates to /home with no verification step
|
||||
@@ -0,0 +1,26 @@
|
||||
# QA: Accept invite screen
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
Mock `useApi` for GET `/v1/auth/invite` and POST `/v1/auth/invite/accept` per case; mount AcceptInvitePage inside AuthShell with a test router at `/invite/accept?c=abc123`. Use fake timers for the 800 ms redirect only; never freeze the date.
|
||||
1. Checking the link: GET unresolved -> "Checking your invite…" spinner.
|
||||
2. Default: GET 200 `{email:"lea.martin@renew.example"}` -> heading, description, read-only Email with help, Username "0 / 20" and its help, Password checklist unmet, button "Accept invite", footer "Log in" -> /login.
|
||||
3. Validation errors: Username "ma", Password "lime", blur + submit -> no POST, "Use 3 to 20 characters.", counter "2 / 20" error, checklist error.
|
||||
4. Filled, valid: "lea" / "limewash2026" shown -> "3 / 20", checklist met.
|
||||
5. Submitting: POST unresolved -> fields disabled, "Creating account…" loading.
|
||||
6. Username taken: POST 409 body "That username is taken. Try another." -> value "marta" kept, "5 / 20", focus on Username, error copy, password kept, form re-enabled.
|
||||
7. Link does not work (table): no `c` (no request at all), GET 404, POST 404, POST 409 with `This address already has an account.` -> ResultPanel link-2-off with the title, text, note and one primary "Go to log in"; click navigates to /login.
|
||||
8. Expired (table): GET 410, POST 410 -> ResultPanel clock, title, text, one secondary "Go to log in".
|
||||
9. Server error (table): POST 500, POST 429, rejected fetch -> Alert "Couldn't create the account" + description, form kept.
|
||||
10. Success: POST 200 -> ResultPanel "Your account is ready", Toast "Welcome to RenewCMS" / "You're logged in as lea."; advance 800 ms -> router path is /home.
|
||||
11. Logged-in visitor: session store holds a verified user; mounting shows the Default state, no redirect.
|
||||
|
||||
## Open questions (no design state, not invented)
|
||||
- GET /v1/auth/invite failing on load with a 500 or a network error: the design has no state for it.
|
||||
|
||||
## Visual (`task visual:check -- accept-invite`, desktop and mobile, light and dark; hard gate 10 %)
|
||||
Sample data from the board frame: email `lea.martin@renew.example`; username `lea` (taken state: `marta`); password `limewash2026`; validation state username `ma`, password `lime`. States: Checking the link, Default, Validation errors, Filled valid, Submitting, Username taken (409), Invalid or used link (404), Expired link (410), Server error, Success. `skip: clock-sensitive`: none (expiry is driven by the 410 status, not the clock). Layout placement comes from the board frame markup, not from components.md prose.
|
||||
|
||||
## Manual checks (not covered by screenshots)
|
||||
- Keyboard: Tab order Username -> Password -> show/hide -> Accept invite -> Log in; Username is focused after a 409; the ResultPanel title takes focus on 404/410.
|
||||
- Password show/hide and hover states; the 800 ms hand-off; the Toast disappearing.
|
||||
- Real e2e: open the emailed link against `task dev`.
|
||||
@@ -9,6 +9,8 @@ var ErrEmailHasAccount = errors.New("email already has an account")
|
||||
var ErrPendingInvite = errors.New("pending invite already exists")
|
||||
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")
|
||||
|
||||
const Lifetime = 3 * 24 * time.Hour
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package invite
|
||||
|
||||
import "time"
|
||||
|
||||
type Repository interface {
|
||||
Create(invite Invite) (Invite, error)
|
||||
GetAll() []Invite
|
||||
Delete(id uint32) error
|
||||
MarkAccepted(id uint32, acceptedAt time.Time) error
|
||||
}
|
||||
@@ -14,6 +14,7 @@ var ErrAlreadyVerified = errors.New("user already verified")
|
||||
var ErrInvalidPasswordResetCode = errors.New("invalid password reset code")
|
||||
var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
|
||||
var ErrInvalidDisplayName = errors.New("invalid display name")
|
||||
var ErrUsernameTaken = errors.New("username taken")
|
||||
|
||||
type User struct {
|
||||
ID uint32 `json:"id"`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -43,4 +44,9 @@ func (i *InviteRepository) Delete(id uint32) error {
|
||||
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
|
||||
}
|
||||
|
||||
func (i *InviteRepository) MarkAccepted(id uint32, acceptedAt time.Time) error {
|
||||
result := i.db.Model(&entity.Invite{}).Where("id = ?", id).Update("accepted_at", acceptedAt)
|
||||
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
|
||||
}
|
||||
|
||||
var _ domainInvite.Repository = &InviteRepository{}
|
||||
@@ -33,6 +33,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
|
||||
Password: user.Password,
|
||||
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
|
||||
Email: user.Email,
|
||||
IsVerified: user.IsVerified,
|
||||
VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
|
||||
VerificationExpiration: user.VerificationExpiration,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AcceptInviteUseCase struct {
|
||||
userRepository user.Repository
|
||||
inviteRepository invite.Repository
|
||||
hasher hash.Hasher
|
||||
}
|
||||
|
||||
type AcceptInviteCommand struct {
|
||||
Code string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func NewAcceptInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher) *AcceptInviteUseCase {
|
||||
return &AcceptInviteUseCase{userRepository, inviteRepository, hasher}
|
||||
}
|
||||
|
||||
func (a *AcceptInviteUseCase) AcceptInvite(command AcceptInviteCommand) (user.User, error) {
|
||||
now := time.Now()
|
||||
|
||||
pending, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now)
|
||||
if err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
if a.emailHasAccount(pending.Email) {
|
||||
return user.User{}, invite.ErrEmailHasAccount
|
||||
}
|
||||
|
||||
if err := a.ensureUsernameFree(command.Username); err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
hashedPassword, err := a.hasher.Hash(command.Password)
|
||||
if err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
createdUser, err := a.userRepository.Create(user.User{
|
||||
Username: command.Username,
|
||||
Password: hashedPassword,
|
||||
Email: pending.Email,
|
||||
IsVerified: true,
|
||||
})
|
||||
if err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
if err := a.inviteRepository.MarkAccepted(pending.ID, now); err != nil {
|
||||
if deleteErr := a.userRepository.Delete(createdUser.ID); deleteErr != nil {
|
||||
return user.User{}, errors.Join(err, deleteErr)
|
||||
}
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
return createdUser, nil
|
||||
}
|
||||
|
||||
func (a *AcceptInviteUseCase) emailHasAccount(email string) bool {
|
||||
for _, existingUser := range a.userRepository.GetAll() {
|
||||
if strings.EqualFold(existingUser.Email, email) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *AcceptInviteUseCase) ensureUsernameFree(username string) error {
|
||||
_, err := a.userRepository.GetByUsername(username)
|
||||
if err == nil {
|
||||
return user.ErrUsernameTaken
|
||||
}
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GetInviteUseCase struct {
|
||||
inviteRepository invite.Repository
|
||||
hasher hash.Hasher
|
||||
}
|
||||
|
||||
func NewGetInviteUseCase(inviteRepository invite.Repository, hasher hash.Hasher) *GetInviteUseCase {
|
||||
return &GetInviteUseCase{inviteRepository, hasher}
|
||||
}
|
||||
|
||||
func (g *GetInviteUseCase) GetInvite(code string) (invite.Invite, error) {
|
||||
return findUsableInvite(g.inviteRepository, g.hasher, code, time.Now())
|
||||
}
|
||||
|
||||
func findUsableInvite(inviteRepository invite.Repository, hasher hash.Hasher, code string, now time.Time) (invite.Invite, error) {
|
||||
if code == "" {
|
||||
return invite.Invite{}, invite.ErrInviteInvalid
|
||||
}
|
||||
|
||||
for _, candidate := range inviteRepository.GetAll() {
|
||||
if candidate.AcceptedAt != nil {
|
||||
continue
|
||||
}
|
||||
if hasher.Compare(candidate.HashedCode, code) != nil {
|
||||
continue
|
||||
}
|
||||
if candidate.StatusAt(now) == invite.StatusExpired {
|
||||
return invite.Invite{}, invite.ErrInviteExpired
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
return invite.Invite{}, invite.ErrInviteInvalid
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
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"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
acceptCode = "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
|
||||
acceptEmail = "lea.martin@renew.example"
|
||||
acceptPassword = "limewash2026"
|
||||
acceptedInviteMsg = `{"message":"Invite accepted! HTTPonly jwt cookie created"}`
|
||||
inviteInvalidBody = apperr.InviteInvalidErrorMessage + "\n"
|
||||
inviteExpiredBody = apperr.InviteExpiredErrorMessage + "\n"
|
||||
usernameTakenBody = apperr.UsernameTakenErrorMessage + "\n"
|
||||
emailHasAccountBody = apperr.EmailHasAccountErrorMessage + "\n"
|
||||
)
|
||||
|
||||
type acceptInviteFixture struct {
|
||||
users *mockUserRepo
|
||||
invites *inviteFakeRepo
|
||||
mail *mockMailRepo
|
||||
hasher *stubHasher
|
||||
router *chi.Mux
|
||||
token *jwtauth.JWTAuth
|
||||
}
|
||||
|
||||
func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
f := &acceptInviteFixture{
|
||||
users: inviteInviterRepo(""),
|
||||
invites: newInviteFakeRepo(),
|
||||
mail: newMockMailRepo(),
|
||||
hasher: &stubHasher{},
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
}
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
acceptHandler := auth.NewAcceptInviteHandler(
|
||||
useCases.NewGetInviteUseCase(f.invites, f.hasher),
|
||||
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher),
|
||||
f.token,
|
||||
true,
|
||||
)
|
||||
meHandler := auth.NewMeHandler(getUser)
|
||||
listHandler := userhandler.NewInviteHandler(
|
||||
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
|
||||
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
|
||||
)
|
||||
registerHandler := auth.NewLoginHandler(
|
||||
f.token,
|
||||
useCases.NewCreateUserUseCase(f.users, f.hasher, f.mail, invitePublicURL),
|
||||
useCases.NewAuthenticateUserUseCase(f.users, f.hasher),
|
||||
true,
|
||||
)
|
||||
|
||||
f.router = chi.NewRouter()
|
||||
f.router.Route("/v1/auth", func(r chi.Router) {
|
||||
r.Post("/register", registerHandler.Register)
|
||||
r.Get("/registration", registerHandler.RegistrationStatus)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.TrustedProxyRealIP(nil))
|
||||
r.Use(middleware.NewRateLimiter(maxAttempts, time.Hour))
|
||||
r.Get("/invite", acceptHandler.GetInvite)
|
||||
r.Post("/invite/accept", acceptHandler.AcceptInvite)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(f.token))
|
||||
r.Use(jwtauth.Authenticator(f.token))
|
||||
r.Use(middleware.RequireCurrentToken(getUser))
|
||||
r.Get("/me", meHandler.Me)
|
||||
})
|
||||
})
|
||||
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("/", listHandler.ListUsers)
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *acceptInviteFixture) seedWithCode(base invite.Invite, code string) invite.Invite {
|
||||
base.HashedCode = "hashed:" + code
|
||||
return f.invites.seed(base)
|
||||
}
|
||||
|
||||
func (f *acceptInviteFixture) seedPending() invite.Invite {
|
||||
return f.seedWithCode(pendingInvite(acceptEmail), acceptCode)
|
||||
}
|
||||
|
||||
func (f *acceptInviteFixture) do(method, target, body string, cookie *http.Cookie, remoteAddr string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, bytes.NewBufferString(body))
|
||||
req.RemoteAddr = remoteAddr + ":4321"
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (f *acceptInviteFixture) get(target string) *httptest.ResponseRecorder {
|
||||
return f.do(http.MethodGet, target, "", nil, "203.0.113.1")
|
||||
}
|
||||
|
||||
func (f *acceptInviteFixture) accept(body string) *httptest.ResponseRecorder {
|
||||
return f.do(http.MethodPost, "/v1/auth/invite/accept", body, nil, "203.0.113.1")
|
||||
}
|
||||
|
||||
func acceptBody(code, username, password string) string {
|
||||
return `{"code": "` + code + `", "username": "` + username + `", "password": "` + password + `"}`
|
||||
}
|
||||
|
||||
func jwtCookieOf(rec *httptest.ResponseRecorder) *http.Cookie {
|
||||
for _, cookie := range rec.Result().Cookies() {
|
||||
if cookie.Name == "jwt" {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *acceptInviteFixture) userNamed(username string) (user.User, bool) {
|
||||
for _, u := range f.users.users {
|
||||
if u.Username == username {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
return user.User{}, false
|
||||
}
|
||||
|
||||
func assertNoAcceptSideEffects(t *testing.T, f *acceptInviteFixture, rec *httptest.ResponseRecorder, usersBefore int) {
|
||||
t.Helper()
|
||||
if len(f.users.users) != usersBefore {
|
||||
t.Errorf("no user may be created, have %d want %d", len(f.users.users), usersBefore)
|
||||
}
|
||||
if jwtCookieOf(rec) != nil {
|
||||
t.Errorf("no jwt cookie may be set")
|
||||
}
|
||||
for _, stored := range f.invites.invites {
|
||||
if stored.AcceptedAt != nil {
|
||||
t.Errorf("no invite may be accepted, got %+v", stored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptInvitationHttp(t *testing.T) {
|
||||
t.Run("The invited email is read from a valid code", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
stored := f.seedPending()
|
||||
|
||||
rec := f.get("/v1/auth/invite?c=" + acceptCode)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
decoded := decodeKeys(t, rec.Body.Bytes())
|
||||
assertExactKeys(t, decoded, "email")
|
||||
if decodedString(t, decoded, "email") != acceptEmail {
|
||||
t.Errorf("unexpected body %s", rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("expected no-store, got %q", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
if f.invites.invites[stored.ID] != stored {
|
||||
t.Errorf("invite must be unchanged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A link that does not work is reported without saying why", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, query string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"unknown code", "?c=not-a-known-code", http.StatusNotFound, inviteInvalidBody},
|
||||
{"accepted code", "?c=accepted-code", http.StatusNotFound, inviteInvalidBody},
|
||||
{"accepted and expired code", "?c=accepted-expired-code", http.StatusNotFound, inviteInvalidBody},
|
||||
{"empty code", "?c=", http.StatusNotFound, inviteInvalidBody},
|
||||
{"no query", "", http.StatusNotFound, inviteInvalidBody},
|
||||
{"expired code", "?c=expired-code", http.StatusGone, inviteExpiredBody},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
f.seedWithCode(pendingInvite(acceptEmail), acceptCode)
|
||||
acceptedAt := time.Now().Add(-time.Hour)
|
||||
accepted := pendingInvite("sam@renew.example")
|
||||
accepted.AcceptedAt = &acceptedAt
|
||||
f.seedWithCode(accepted, "accepted-code")
|
||||
acceptedExpired := expiredInvite("old@renew.example")
|
||||
acceptedExpired.AcceptedAt = &acceptedAt
|
||||
f.seedWithCode(acceptedExpired, "accepted-expired-code")
|
||||
f.seedWithCode(expiredInvite("p.huber@gmail.com"), "expired-code")
|
||||
|
||||
rec := f.get("/v1/auth/invite" + tc.query)
|
||||
|
||||
if rec.Code != tc.status || rec.Body.String() != tc.body {
|
||||
t.Errorf("expected %d %q, got %d %q", tc.status, tc.body, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Accepting creates a verified user, logs them in and closes the invite", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
stored := f.seedWithCode(pendingInvite("Lea.Martin@Renew.example"), acceptCode)
|
||||
stored.Email = "lea.martin@renew.example"
|
||||
f.invites.invites[stored.ID] = stored
|
||||
|
||||
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
|
||||
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != acceptedInviteMsg {
|
||||
t.Fatalf("expected 200 message, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
created, ok := f.userNamed("lea")
|
||||
if !ok {
|
||||
t.Fatalf("user lea must exist")
|
||||
}
|
||||
if created.Email != acceptEmail || !created.IsVerified || created.VerificationCode != "" || created.DisplayName != "" {
|
||||
t.Errorf("unexpected user %+v", created)
|
||||
}
|
||||
if created.Password == acceptPassword || created.Password != "hashed:"+acceptPassword {
|
||||
t.Errorf("password must be stored hashed, got %q", created.Password)
|
||||
}
|
||||
if got := f.invites.invites[stored.ID]; got.AcceptedAt == nil || got.StatusAt(time.Now()) != invite.StatusAccepted {
|
||||
t.Errorf("invite must be accepted, got %+v", got)
|
||||
}
|
||||
if len(f.mail.SentEmails) != 0 {
|
||||
t.Errorf("no email may be sent")
|
||||
}
|
||||
cookie := jwtCookieOf(rec)
|
||||
if cookie == nil || !cookie.HttpOnly || !cookie.Secure || cookie.Path != "/" {
|
||||
t.Fatalf("expected an HttpOnly Secure jwt cookie on /, got %+v", cookie)
|
||||
}
|
||||
if lifetime := time.Until(cookie.Expires); lifetime < 23*time.Hour || lifetime > 24*time.Hour+time.Minute {
|
||||
t.Errorf("expected a 24 hour cookie, got %v", lifetime)
|
||||
}
|
||||
decoded, err := f.token.Decode(cookie.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("cookie does not decode: %v", err)
|
||||
}
|
||||
claims, _ := decoded.AsMap(context.Background())
|
||||
if claims["user_id"] != float64(created.ID) || claims["token_version"] != float64(created.TokenVersion) {
|
||||
t.Errorf("unexpected claims %v", claims)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The new account works like any other account", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
f.seedPending()
|
||||
cookie := jwtCookieOf(f.accept(acceptBody(acceptCode, "lea", acceptPassword)))
|
||||
|
||||
me := f.do(http.MethodGet, "/v1/auth/me", "", cookie, "203.0.113.1")
|
||||
list := f.do(http.MethodGet, "/v1/user/", "", cookie, "203.0.113.1")
|
||||
|
||||
if me.Code != http.StatusOK || list.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 and 200, got %d and %d", me.Code, list.Code)
|
||||
}
|
||||
items := decodeList(t, list.Body.Bytes())
|
||||
var leaStatus string
|
||||
for _, item := range items {
|
||||
if decodedString(t, item, "kind") == "invite" {
|
||||
t.Errorf("no invite may be listed: %v", item)
|
||||
}
|
||||
if decodedString(t, item, "kind") == "user" && decodedString(t, item, "username") == "lea" {
|
||||
leaStatus = decodedString(t, item, "status")
|
||||
}
|
||||
}
|
||||
if leaStatus != "accepted" {
|
||||
t.Errorf("expected lea listed as accepted, got %q", leaStatus)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An accepted code cannot be used again", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
f.seedPending()
|
||||
f.accept(acceptBody(acceptCode, "lea", acceptPassword))
|
||||
usersBefore := len(f.users.users)
|
||||
|
||||
rec := f.accept(acceptBody(acceptCode, "lea2", acceptPassword))
|
||||
|
||||
if rec.Code != http.StatusNotFound || rec.Body.String() != inviteInvalidBody {
|
||||
t.Errorf("expected 404, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(f.users.users) != usersBefore || jwtCookieOf(rec) != nil {
|
||||
t.Errorf("nothing may be created")
|
||||
}
|
||||
if _, exists := f.userNamed("lea2"); exists {
|
||||
t.Errorf("lea2 must not exist")
|
||||
}
|
||||
if got := f.get("/v1/auth/invite?c=" + acceptCode); got.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 on GET, got %d", got.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A code that cannot be used creates nothing", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
seed func(f *acceptInviteFixture)
|
||||
code string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"no invite has that code", func(f *acceptInviteFixture) { f.seedPending() }, "unknown-code", http.StatusNotFound, inviteInvalidBody},
|
||||
{"an invite that expired an hour ago", func(f *acceptInviteFixture) { f.seedWithCode(expiredInvite(acceptEmail), acceptCode) }, acceptCode, http.StatusGone, inviteExpiredBody},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
tc.seed(f)
|
||||
usersBefore := len(f.users.users)
|
||||
|
||||
rec := f.accept(acceptBody(tc.code, "lea", acceptPassword))
|
||||
|
||||
if rec.Code != tc.status || rec.Body.String() != tc.body {
|
||||
t.Errorf("expected %d %q, got %d %q", tc.status, tc.body, rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoAcceptSideEffects(t, f, rec, usersBefore)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A taken username is refused and the invite stays usable", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
f.seedPending()
|
||||
usersBefore := len(f.users.users)
|
||||
|
||||
rec := f.accept(acceptBody(acceptCode, "marta", acceptPassword))
|
||||
|
||||
if rec.Code != http.StatusConflict || rec.Body.String() != usernameTakenBody {
|
||||
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoAcceptSideEffects(t, f, rec, usersBefore)
|
||||
if retry := f.accept(acceptBody(acceptCode, "lea", acceptPassword)); retry.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 on retry, got %d: %s", retry.Code, retry.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An address that got an account in the meantime is refused", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
f.seedPending()
|
||||
f.users.users[8] = user.User{ID: 8, Username: "leam", Email: "Lea.Martin@Renew.example"}
|
||||
usersBefore := len(f.users.users)
|
||||
|
||||
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
|
||||
|
||||
if rec.Code != http.StatusConflict || rec.Body.String() != emailHasAccountBody {
|
||||
t.Errorf("expected 409, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoAcceptSideEffects(t, f, rec, usersBefore)
|
||||
})
|
||||
|
||||
t.Run("An invalid body is rejected before the code is looked up", func(t *testing.T) {
|
||||
bodies := []string{
|
||||
acceptBody(acceptCode, "le", acceptPassword),
|
||||
acceptBody(acceptCode, "abcdefghijklmnopqrstu", acceptPassword),
|
||||
acceptBody(acceptCode, "lea", "lime"),
|
||||
`{"code": "` + acceptCode + `", "password": "limewash2026"}`,
|
||||
`{"username": "lea", "password": "limewash2026"}`,
|
||||
`not json`,
|
||||
}
|
||||
for _, body := range bodies {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
f.seedPending()
|
||||
usersBefore := len(f.users.users)
|
||||
|
||||
rec := f.accept(body)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoAcceptSideEffects(t, f, rec, usersBefore)
|
||||
if f.hasher.compareCalls != 0 {
|
||||
t.Errorf("the code must not be looked up")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An existing jwt cookie is ignored and replaced", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
f.seedPending()
|
||||
martaCookie := displayNameCookie(t, f.token, 7, 0)
|
||||
|
||||
rec := f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody(acceptCode, "lea", acceptPassword), martaCookie, "203.0.113.1")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
created, _ := f.userNamed("lea")
|
||||
cookie := jwtCookieOf(rec)
|
||||
decoded, err := f.token.Decode(cookie.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("cookie does not decode: %v", err)
|
||||
}
|
||||
claims, _ := decoded.AsMap(context.Background())
|
||||
if claims["user_id"] != float64(created.ID) || created.ID == 7 {
|
||||
t.Errorf("cookie must carry the new user's id, got %v", claims["user_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failure after the user is created leaves the invite pending and no user behind", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
stored := f.seedPending()
|
||||
f.invites.fail.failNext("MarkAccepted")
|
||||
usersBefore := len(f.users.users)
|
||||
|
||||
rec := f.accept(acceptBody(acceptCode, "lea", acceptPassword))
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertNoAcceptSideEffects(t, f, rec, usersBefore)
|
||||
if f.invites.invites[stored.ID] != stored {
|
||||
t.Errorf("invite must be unchanged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The accept endpoints are rate-limited", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 2)
|
||||
ip := "203.0.113.30"
|
||||
for range 2 {
|
||||
f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody("nope", "lea", acceptPassword), nil, ip)
|
||||
}
|
||||
|
||||
rec := f.do(http.MethodPost, "/v1/auth/invite/accept", acceptBody("nope", "lea", acceptPassword), nil, ip)
|
||||
|
||||
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || rec.Header().Get("Retry-After") == "" {
|
||||
t.Errorf("expected 429 with Retry-After, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if other := f.do(http.MethodGet, "/v1/auth/registration", "", nil, ip); other.Code == http.StatusTooManyRequests {
|
||||
t.Errorf("registration status must not be limited")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Registration stays closed for everyone else", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
acceptedAt := time.Now()
|
||||
accepted := pendingInvite("sam@renew.example")
|
||||
accepted.AcceptedAt = &acceptedAt
|
||||
f.seedWithCode(accepted, "accepted-code")
|
||||
|
||||
rec := f.do(http.MethodPost, "/v1/auth/register", `{"username":"sam","password":"hunter2example12","email":"sam@renew.example"}`, nil, "203.0.113.1")
|
||||
status := f.do(http.MethodGet, "/v1/auth/registration", "", nil, "203.0.113.1")
|
||||
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.RegistrationClosedErrorMessage+"\n" {
|
||||
t.Errorf("expected 403, got %d: %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.ReplaceAll(status.Body.String(), " ", "") != `{"open":false}` {
|
||||
t.Errorf("expected registration closed, got %s", status.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The invite response never leaks the code or other invite data", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
f.seedPending()
|
||||
|
||||
rec := f.get("/v1/auth/invite?c=" + acceptCode)
|
||||
|
||||
decoded := decodeKeys(t, rec.Body.Bytes())
|
||||
for _, forbidden := range []string{"code", "hashed_code", "invited_by", "expires_at", "id", "accepted_at"} {
|
||||
if _, present := decoded[forbidden]; present {
|
||||
t.Errorf("forbidden key %q in body", forbidden)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), `"`+forbidden+`"`) {
|
||||
t.Errorf("forbidden name %q in raw body %s", forbidden, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), acceptCode) {
|
||||
t.Errorf("raw code leaked in body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcceptInviteUseCase(t *testing.T) {
|
||||
newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) {
|
||||
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
|
||||
return useCases.NewAcceptInviteUseCase(users, invites, hasher), users, invites, hasher
|
||||
}
|
||||
command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}
|
||||
|
||||
t.Run("unknown code is invalid", func(t *testing.T) {
|
||||
uc, _, _, _ := newUseCase()
|
||||
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) {
|
||||
t.Errorf("expected ErrInviteInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty code is invalid without hashing", func(t *testing.T) {
|
||||
uc, _, invites, hasher := newUseCase()
|
||||
invites.seed(pendingInvite(acceptEmail))
|
||||
if _, err := uc.AcceptInvite(useCases.AcceptInviteCommand{Username: "lea", Password: acceptPassword}); !errors.Is(err, invite.ErrInviteInvalid) || hasher.compareCalls != 0 {
|
||||
t.Errorf("expected ErrInviteInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired code is expired", func(t *testing.T) {
|
||||
uc, _, invites, _ := newUseCase()
|
||||
stored := expiredInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
invites.seed(stored)
|
||||
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteExpired) {
|
||||
t.Errorf("expected ErrInviteExpired, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("taken username", func(t *testing.T) {
|
||||
uc, users, invites, _ := newUseCase()
|
||||
stored := pendingInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
invites.seed(stored)
|
||||
_, err := uc.AcceptInvite(useCases.AcceptInviteCommand{Code: acceptCode, Username: "marta", Password: acceptPassword})
|
||||
if !errors.Is(err, user.ErrUsernameTaken) || len(users.users) != 1 {
|
||||
t.Errorf("expected ErrUsernameTaken, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("username lookup failure is returned", func(t *testing.T) {
|
||||
uc, users, invites, _ := newUseCase()
|
||||
stored := pendingInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
invites.seed(stored)
|
||||
users.fail.failNext("GetByUsername")
|
||||
if _, err := uc.AcceptInvite(command); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("email that has an account", func(t *testing.T) {
|
||||
uc, users, invites, _ := newUseCase()
|
||||
stored := pendingInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
invites.seed(stored)
|
||||
users.users[8] = user.User{ID: 8, Username: "leam", Email: "LEA.martin@renew.example"}
|
||||
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrEmailHasAccount) {
|
||||
t.Errorf("expected ErrEmailHasAccount, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hash failure creates nothing", func(t *testing.T) {
|
||||
uc, users, invites, hasher := newUseCase()
|
||||
stored := pendingInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
invites.seed(stored)
|
||||
hasher.hashErr = errors.New("hash boom")
|
||||
if _, err := uc.AcceptInvite(command); err == nil || len(users.users) != 1 {
|
||||
t.Errorf("expected failure without a user, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user creation failure leaves the invite pending", func(t *testing.T) {
|
||||
uc, users, invites, _ := newUseCase()
|
||||
stored := pendingInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
seeded := invites.seed(stored)
|
||||
users.fail.failNext("Create")
|
||||
if _, err := uc.AcceptInvite(command); !errors.Is(err, errRepositoryFailure) || invites.invites[seeded.ID].AcceptedAt != nil {
|
||||
t.Errorf("expected repository failure and a pending invite, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rollback failure is joined with the accept failure", func(t *testing.T) {
|
||||
uc, users, invites, _ := newUseCase()
|
||||
stored := pendingInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
invites.seed(stored)
|
||||
invites.fail.failNextWith("MarkAccepted", errors.New("mark down"))
|
||||
users.fail.failNextWith("Delete", errors.New("delete down"))
|
||||
_, err := uc.AcceptInvite(command)
|
||||
if err == nil || !strings.Contains(err.Error(), "mark down") || !strings.Contains(err.Error(), "delete down") {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetInviteUseCase(t *testing.T) {
|
||||
t.Run("returns the usable invite", func(t *testing.T) {
|
||||
invites, hasher := newInviteFakeRepo(), &stubHasher{}
|
||||
stored := pendingInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
invites.seed(stored)
|
||||
got, err := useCases.NewGetInviteUseCase(invites, hasher).GetInvite(acceptCode)
|
||||
if err != nil || got.Email != acceptEmail {
|
||||
t.Errorf("unexpected result %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -75,6 +75,19 @@ func (m *inviteFakeRepo) Delete(id uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *inviteFakeRepo) MarkAccepted(id uint32, at time.Time) error {
|
||||
if err := m.fail.trigger("MarkAccepted"); err != nil {
|
||||
return err
|
||||
}
|
||||
stored, ok := m.invites[id]
|
||||
if !ok {
|
||||
return invite.ErrInviteNotFound
|
||||
}
|
||||
stored.AcceptedAt = &at
|
||||
m.invites[id] = stored
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ invite.Repository = &inviteFakeRepo{}
|
||||
|
||||
func inviteAt(email string, createdAt time.Time, expiresAt time.Time) invite.Invite {
|
||||
|
||||
@@ -46,6 +46,11 @@ const router = createRouter({
|
||||
component: () => import('@/views/ResetConfirmView.vue'),
|
||||
meta: { auth: 'public' },
|
||||
},
|
||||
{
|
||||
path: '/invite/accept',
|
||||
component: () => import('@/views/AcceptInviteView.vue'),
|
||||
meta: { auth: 'public' },
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
@@ -153,10 +158,7 @@ router.afterEach((to) => {
|
||||
: Promise.resolve()
|
||||
|
||||
settled.then(() => {
|
||||
const destination = resolveAuthRedirect(
|
||||
router.currentRoute.value,
|
||||
session,
|
||||
)
|
||||
const destination = resolveAuthRedirect(router.currentRoute.value, session)
|
||||
if (destination !== true) {
|
||||
router.replace(destination)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
import AuthShell from '@/components/AuthShell.vue'
|
||||
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
type InviteState = 'checking' | 'form' | 'invalid' | 'expired' | 'success'
|
||||
|
||||
const EMAIL_HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
|
||||
const USERNAME_TAKEN_MESSAGE = 'That username is taken. Try another.'
|
||||
const USERNAME_RULE_MESSAGE = 'Use 3 to 20 characters.'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
const session = useSessionStore()
|
||||
const toast = useToast()
|
||||
|
||||
const code = typeof route.query.c === 'string' ? route.query.c : ''
|
||||
|
||||
const state = ref<InviteState>(code ? 'checking' : 'invalid')
|
||||
const inviteEmail = ref('')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const usernameTouched = ref(false)
|
||||
const passwordTouched = ref(false)
|
||||
const usernameTaken = ref(false)
|
||||
const submitting = ref(false)
|
||||
const passwordShown = ref(false)
|
||||
const serverErrorAlert = ref(false)
|
||||
|
||||
const usernameValid = computed(() => username.value.length >= 3 && username.value.length <= 20)
|
||||
const passwordValid = computed(() => password.value.length >= 8)
|
||||
|
||||
const usernameError = computed(() => {
|
||||
if (usernameTaken.value) {
|
||||
return USERNAME_TAKEN_MESSAGE
|
||||
}
|
||||
return usernameTouched.value && !usernameValid.value ? USERNAME_RULE_MESSAGE : ''
|
||||
})
|
||||
|
||||
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
|
||||
|
||||
const formValid = computed(() => usernameValid.value && passwordValid.value)
|
||||
|
||||
const showForm = computed(() => state.value === 'form')
|
||||
|
||||
const pageTitle = computed(() => (showForm.value ? 'Accept your invite' : ''))
|
||||
|
||||
const pageDescription = computed(() =>
|
||||
showForm.value
|
||||
? 'Choose a username and password to finish setting up your account.'
|
||||
: undefined,
|
||||
)
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const invalidActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
||||
|
||||
const expiredActions = [
|
||||
{
|
||||
label: 'Go to log in',
|
||||
color: 'neutral' as const,
|
||||
variant: 'outline' as const,
|
||||
onClick: goToLogin,
|
||||
},
|
||||
]
|
||||
|
||||
function onUsernameInput() {
|
||||
usernameTaken.value = false
|
||||
}
|
||||
|
||||
async function focusUsername() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
document.querySelector<HTMLInputElement>('input[autocomplete="username"]')?.focus()
|
||||
}
|
||||
|
||||
function applyDeadLinkStatus(error: unknown): boolean {
|
||||
if (!(error instanceof ApiError)) {
|
||||
return false
|
||||
}
|
||||
if (error.status === 404) {
|
||||
state.value = 'invalid'
|
||||
return true
|
||||
}
|
||||
if (error.status === 410) {
|
||||
state.value = 'expired'
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function loadInvite() {
|
||||
try {
|
||||
const invite = await api.request<{ email: string }>(
|
||||
`/v1/auth/invite?c=${encodeURIComponent(code)}`,
|
||||
{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' },
|
||||
)
|
||||
inviteEmail.value = invite.email
|
||||
state.value = 'form'
|
||||
} catch (error) {
|
||||
applyDeadLinkStatus(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSuccess() {
|
||||
state.value = 'success'
|
||||
toast.add({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Welcome to RenewCMS',
|
||||
description: `You're logged in as ${username.value}.`,
|
||||
})
|
||||
await Promise.all([session.retry(), wait(VERIFIED_REDIRECT_DELAY_MS)])
|
||||
await router.push('/home')
|
||||
}
|
||||
|
||||
function handleFailure(error: unknown) {
|
||||
if (applyDeadLinkStatus(error)) {
|
||||
return
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
if (error.body === EMAIL_HAS_ACCOUNT_MESSAGE) {
|
||||
state.value = 'invalid'
|
||||
return
|
||||
}
|
||||
usernameTaken.value = true
|
||||
focusUsername()
|
||||
return
|
||||
}
|
||||
serverErrorAlert.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
usernameTouched.value = true
|
||||
passwordTouched.value = true
|
||||
|
||||
if (!formValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
serverErrorAlert.value = false
|
||||
submitting.value = true
|
||||
try {
|
||||
await api.request('/v1/auth/invite/accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
}),
|
||||
onUnauthorized: 'passthrough',
|
||||
onForbidden: 'passthrough',
|
||||
})
|
||||
} catch (error) {
|
||||
handleFailure(error)
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
submitting.value = false
|
||||
await handleSuccess()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (code) {
|
||||
loadInvite()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthShell :title="pageTitle" :description="pageDescription">
|
||||
<template v-if="serverErrorAlert && showForm" #notice>
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
title="Couldn't create the account"
|
||||
description="Something went wrong on the server. Try again in a moment."
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #default="{ controlSize, controlClass, inputClass }">
|
||||
<div
|
||||
v-if="state === 'checking'"
|
||||
role="status"
|
||||
class="flex flex-col items-center gap-4 py-10 text-center"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
|
||||
<p class="text-xl/7 font-semibold text-highlighted">Checking your invite…</p>
|
||||
<p class="text-base text-muted">This only takes a moment.</p>
|
||||
</div>
|
||||
|
||||
<ResultPanel
|
||||
v-else-if="state === 'invalid'"
|
||||
tone="error"
|
||||
icon="i-lucide-link-2-off"
|
||||
title="This invite link doesn't work"
|
||||
description="It may be incomplete, or it may already have been used. If you already set up your account, log in instead."
|
||||
:actions="invalidActions"
|
||||
>
|
||||
<p class="text-sm text-pretty text-muted">
|
||||
Open the link from your email again, or copy the full address into your browser.
|
||||
</p>
|
||||
</ResultPanel>
|
||||
|
||||
<ResultPanel
|
||||
v-else-if="state === 'expired'"
|
||||
tone="error"
|
||||
icon="i-lucide-clock"
|
||||
title="This invite has expired"
|
||||
description="Invites work once, for 3 days. Ask the person who invited you to send a new one."
|
||||
:actions="expiredActions"
|
||||
/>
|
||||
|
||||
<ResultPanel
|
||||
v-else-if="state === 'success'"
|
||||
tone="success"
|
||||
icon="i-lucide-circle-check"
|
||||
title="Your account is ready"
|
||||
description="Taking you to your dashboard…"
|
||||
:actions="[]"
|
||||
/>
|
||||
|
||||
<UForm
|
||||
v-else
|
||||
:state="{ email: inviteEmail, username, password }"
|
||||
class="flex flex-col gap-4.5"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Email"
|
||||
name="email"
|
||||
help="From your invitation."
|
||||
:size="controlSize"
|
||||
>
|
||||
<UInput
|
||||
:model-value="inviteEmail"
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
readonly
|
||||
:disabled="submitting"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
:size="controlSize"
|
||||
:ui="{
|
||||
base: `${inputClass} bg-elevated text-dimmed ring-(--ui-border)`,
|
||||
}"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Username"
|
||||
name="username"
|
||||
:error="usernameError || undefined"
|
||||
:size="controlSize"
|
||||
>
|
||||
<template v-if="usernameError" #error="{ error }">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else #help>
|
||||
Letters and numbers, 3 to 20 characters. You'll use it to log in.
|
||||
</template>
|
||||
<template #hint>
|
||||
<span
|
||||
:class="[
|
||||
'font-mono text-xs',
|
||||
usernameError ? 'text-error' : 'text-muted',
|
||||
]"
|
||||
>
|
||||
{{ username.length }} / 20
|
||||
</span>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="username"
|
||||
placeholder="e.g. lea"
|
||||
autocomplete="username"
|
||||
:disabled="submitting"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
:highlight="!!usernameError"
|
||||
:size="controlSize"
|
||||
:ui="{ base: inputClass, trailing: 'pe-1' }"
|
||||
class="w-full"
|
||||
@update:model-value="onUsernameInput"
|
||||
@blur="usernameTouched = true"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Password" name="password" :size="controlSize">
|
||||
<template #help>
|
||||
<PasswordStrengthHint
|
||||
:rules="passwordRules"
|
||||
:show-errors="passwordTouched"
|
||||
/>
|
||||
</template>
|
||||
<UInput
|
||||
v-model="password"
|
||||
:type="passwordShown ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:disabled="submitting"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
:size="controlSize"
|
||||
:ui="{
|
||||
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
|
||||
trailing: 'pe-1',
|
||||
}"
|
||||
class="w-full"
|
||||
@blur="passwordTouched = true"
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
square
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
||||
:aria-pressed="passwordShown"
|
||||
@click="passwordShown = !passwordShown"
|
||||
/>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
block
|
||||
:size="controlSize"
|
||||
:ui="{ base: controlClass }"
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? 'Creating account…' : 'Accept invite' }}
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
|
||||
<template v-if="showForm" #footer>
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
Already have an account?
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
Log in
|
||||
</RouterLink>
|
||||
</span>
|
||||
</template>
|
||||
</AuthShell>
|
||||
</template>
|
||||
@@ -0,0 +1,370 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import AcceptInviteView from '../AcceptInviteView.vue'
|
||||
|
||||
type Handler = () => Response | Promise<Response>
|
||||
|
||||
function mockResponse(status: number, body: unknown = null, text = '') {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
const invalidText =
|
||||
'It may be incomplete, or it may already have been used. If you already set up your account, log in instead.'
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', () => ({
|
||||
useToast: () => ({ add: toastAdd }),
|
||||
}))
|
||||
|
||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
async function mountAccept(path: string, handlers: Record<string, Handler> = {}) {
|
||||
setActivePinia(createPinia())
|
||||
toastAdd = vi.fn()
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? 'GET'} ${url}`
|
||||
const handler = handlers[key]
|
||||
if (!handler) {
|
||||
throw new Error(`Unexpected fetch call: ${key}`)
|
||||
}
|
||||
return handler()
|
||||
})
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
await router.push(path)
|
||||
const wrapper = mount(AcceptInviteView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
return { wrapper, fetchMock }
|
||||
}
|
||||
|
||||
type Wrapper = Awaited<ReturnType<typeof mountAccept>>['wrapper']
|
||||
|
||||
const validInvite: Record<string, Handler> = {
|
||||
'GET /v1/auth/invite?c=abc123': () => mockResponse(200, { email: 'lea.martin@renew.example' }),
|
||||
}
|
||||
|
||||
function withAccept(accept: Handler): Record<string, Handler> {
|
||||
return { ...validInvite, 'POST /v1/auth/invite/accept': accept }
|
||||
}
|
||||
|
||||
function usernameInput(wrapper: Wrapper) {
|
||||
return wrapper.find('input[placeholder="e.g. lea"]')
|
||||
}
|
||||
|
||||
function emailInput(wrapper: Wrapper) {
|
||||
return wrapper.find('input[placeholder="you@example.com"]')
|
||||
}
|
||||
|
||||
function passwordInput(wrapper: Wrapper) {
|
||||
return wrapper.find('input[autocomplete="new-password"]')
|
||||
}
|
||||
|
||||
async function fill(wrapper: Wrapper, username: string, password: string) {
|
||||
await usernameInput(wrapper).setValue(username)
|
||||
await passwordInput(wrapper).setValue(password)
|
||||
}
|
||||
|
||||
async function submit(wrapper: Wrapper) {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Checking the link', () => {
|
||||
it('shows a full-card spinner while the invite is looked up', async () => {
|
||||
const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=abc123', {
|
||||
'GET /v1/auth/invite?c=abc123': () => new Promise(() => {}),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Checking your invite…')
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default', () => {
|
||||
it('renders the form with the invited email read-only', async () => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', validInvite)
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('Accept your invite')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Choose a username and password to finish setting up your account.',
|
||||
)
|
||||
const email = emailInput(wrapper).element as HTMLInputElement
|
||||
expect(email.value).toBe('lea.martin@renew.example')
|
||||
expect(email.readOnly).toBe(true)
|
||||
expect(wrapper.text()).toContain('From your invitation.')
|
||||
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('')
|
||||
expect(wrapper.text()).toContain('0 / 20')
|
||||
expect(wrapper.text()).toContain(
|
||||
"Letters and numbers, 3 to 20 characters. You'll use it to log in.",
|
||||
)
|
||||
expect(passwordInput(wrapper).attributes('type')).toBe('password')
|
||||
expect(wrapper.text()).toContain('At least 8 characters')
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Accept invite')
|
||||
expect(wrapper.text()).toContain('Already have an account?')
|
||||
expect(wrapper.find('a[href="/login"]').text()).toBe('Log in')
|
||||
})
|
||||
|
||||
it('shows the same form to a logged-in visitor without redirecting', async () => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', validInvite)
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/invite/accept')
|
||||
expect(wrapper.find('form').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation errors', () => {
|
||||
it('shows the errors and never calls the accept endpoint', async () => {
|
||||
const accept = vi.fn(() => mockResponse(200))
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', withAccept(accept))
|
||||
|
||||
await usernameInput(wrapper).setValue('ma')
|
||||
await usernameInput(wrapper).trigger('blur')
|
||||
await passwordInput(wrapper).setValue('lime')
|
||||
await passwordInput(wrapper).trigger('blur')
|
||||
await submit(wrapper)
|
||||
|
||||
expect(accept).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('2 / 20')
|
||||
expect(wrapper.text()).toContain('Use 3 to 20 characters.')
|
||||
const item = wrapper.findAll('li').find((li) => li.text().includes('At least 8 characters'))
|
||||
expect(item?.find('.text-error').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filled, valid', () => {
|
||||
it('shows the counter and a met checklist item without errors', async () => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', validInvite)
|
||||
|
||||
await fill(wrapper, 'lea', 'limewash2026')
|
||||
await wrapper.find('button[aria-label="Show password"]').trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('3 / 20')
|
||||
expect(passwordInput(wrapper).attributes('type')).toBe('text')
|
||||
expect(wrapper.text()).not.toContain('Use 3 to 20 characters.')
|
||||
const item = wrapper.findAll('li').find((li) => li.text().includes('At least 8 characters'))
|
||||
expect(item?.find('.text-success').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Submitting', () => {
|
||||
it('sends the code, username and password and disables the form', async () => {
|
||||
const accept = vi.fn(() => new Promise<Response>(() => {}))
|
||||
const { wrapper, fetchMock } = await mountAccept(
|
||||
'/invite/accept?c=abc123',
|
||||
withAccept(accept),
|
||||
)
|
||||
|
||||
await fill(wrapper, 'lea', 'limewash2026')
|
||||
await submit(wrapper)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/auth/invite/accept',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: 'abc123',
|
||||
username: 'lea',
|
||||
password: 'limewash2026',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.text()).toBe('Creating account…')
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
expect(usernameInput(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(passwordInput(wrapper).attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Username taken (409)', () => {
|
||||
it('keeps the values, focuses the username and shows the error', async () => {
|
||||
const { wrapper } = await mountAccept(
|
||||
'/invite/accept?c=abc123',
|
||||
withAccept(() => mockResponse(409, null, 'That username is taken. Try another.')),
|
||||
)
|
||||
|
||||
await fill(wrapper, 'marta', 'limewash2026')
|
||||
await submit(wrapper)
|
||||
|
||||
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('marta')
|
||||
expect(wrapper.text()).toContain('5 / 20')
|
||||
expect(wrapper.text()).toContain('That username is taken. Try another.')
|
||||
expect(document.activeElement).toBe(usernameInput(wrapper).element)
|
||||
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
|
||||
expect(passwordInput(wrapper).attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Accept invite')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The link does not work', () => {
|
||||
async function expectInvalidPanel(wrapper: Wrapper) {
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain("This invite link doesn't work")
|
||||
expect(wrapper.text()).toContain(invalidText)
|
||||
expect(wrapper.text()).toContain(
|
||||
'Open the link from your email again, or copy the full address into your browser.',
|
||||
)
|
||||
const buttons = wrapper.find('[role="alert"]').findAll('button')
|
||||
expect(buttons.map((button) => button.text())).toEqual(['Go to log in'])
|
||||
await buttons[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
}
|
||||
|
||||
it('renders it without any request when there is no c parameter', async () => {
|
||||
const { wrapper, fetchMock } = await mountAccept('/invite/accept')
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
await expectInvalidPanel(wrapper)
|
||||
})
|
||||
|
||||
it('renders it without any request when c is empty', async () => {
|
||||
const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=')
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
await expectInvalidPanel(wrapper)
|
||||
})
|
||||
|
||||
it('renders it when the lookup answers 404', async () => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
|
||||
'GET /v1/auth/invite?c=abc123': () => mockResponse(404),
|
||||
})
|
||||
|
||||
await expectInvalidPanel(wrapper)
|
||||
})
|
||||
|
||||
it('replaces the form when the submit answers 404', async () => {
|
||||
const { wrapper } = await mountAccept(
|
||||
'/invite/accept?c=abc123',
|
||||
withAccept(() => mockResponse(404)),
|
||||
)
|
||||
|
||||
await fill(wrapper, 'lea', 'limewash2026')
|
||||
await submit(wrapper)
|
||||
|
||||
await expectInvalidPanel(wrapper)
|
||||
})
|
||||
|
||||
it('replaces the form when the submit answers the email-has-account 409', async () => {
|
||||
const { wrapper } = await mountAccept(
|
||||
'/invite/accept?c=abc123',
|
||||
withAccept(() => mockResponse(409, null, 'This address already has an account.')),
|
||||
)
|
||||
|
||||
await fill(wrapper, 'lea', 'limewash2026')
|
||||
await submit(wrapper)
|
||||
|
||||
await expectInvalidPanel(wrapper)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Expired link (410)', () => {
|
||||
async function expectExpiredPanel(wrapper: Wrapper) {
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('This invite has expired')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Invites work once, for 3 days. Ask the person who invited you to send a new one.',
|
||||
)
|
||||
const buttons = wrapper.find('[role="alert"]').findAll('button')
|
||||
expect(buttons.map((button) => button.text())).toEqual(['Go to log in'])
|
||||
await buttons[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
}
|
||||
|
||||
it('renders it when the lookup answers 410', async () => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
|
||||
'GET /v1/auth/invite?c=abc123': () => mockResponse(410),
|
||||
})
|
||||
|
||||
await expectExpiredPanel(wrapper)
|
||||
})
|
||||
|
||||
it('replaces the form when the submit answers 410', async () => {
|
||||
const { wrapper } = await mountAccept(
|
||||
'/invite/accept?c=abc123',
|
||||
withAccept(() => mockResponse(410)),
|
||||
)
|
||||
|
||||
await fill(wrapper, 'lea', 'limewash2026')
|
||||
await submit(wrapper)
|
||||
|
||||
await expectExpiredPanel(wrapper)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Server error', () => {
|
||||
it.each([
|
||||
{ name: 'resolves 500', handler: () => mockResponse(500) },
|
||||
{ name: 'resolves 429', handler: () => mockResponse(429) },
|
||||
{
|
||||
name: 'never reaches the server',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])('shows the alert and keeps the form when the submit $name', async ({ handler }) => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', withAccept(handler))
|
||||
|
||||
await fill(wrapper, 'lea', 'limewash2026')
|
||||
await submit(wrapper)
|
||||
|
||||
expect(wrapper.text()).toContain("Couldn't create the account")
|
||||
expect(wrapper.text()).toContain(
|
||||
'Something went wrong on the server. Try again in a moment.',
|
||||
)
|
||||
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe(
|
||||
'lea.martin@renew.example',
|
||||
)
|
||||
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('lea')
|
||||
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
|
||||
expect(usernameInput(wrapper).attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Accept invite')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Success', () => {
|
||||
it('shows the panel and toast, then navigates to /home after 800 ms', async () => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
|
||||
...withAccept(() => mockResponse(200, { message: 'ok' })),
|
||||
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
|
||||
await fill(wrapper, 'lea', 'limewash2026')
|
||||
await submit(wrapper)
|
||||
|
||||
expect(wrapper.text()).toContain('Your account is ready')
|
||||
expect(wrapper.text()).toContain('Taking you to your dashboard…')
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Welcome to RenewCMS',
|
||||
description: "You're logged in as lea.",
|
||||
})
|
||||
expect(router.currentRoute.value.path).toBe('/invite/accept')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
})
|
||||
@@ -640,6 +640,33 @@ async function clickBaseUrlCopy(page: Page) {
|
||||
await page.getByRole('button', { name: 'Copy', exact: true }).first().click()
|
||||
}
|
||||
|
||||
const acceptInvitePath = '/invite/accept?c=abc123'
|
||||
const acceptInviteEmail = 'lea.martin@renew.example'
|
||||
|
||||
function acceptInviteWith(response: ApiMock): Omit<VisualState, 'steps'> {
|
||||
return {
|
||||
path: acceptInvitePath,
|
||||
api: {
|
||||
'GET /v1/auth/invite': { status: 200, body: { email: acceptInviteEmail } },
|
||||
'POST /v1/auth/invite/accept': response,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fillAcceptInvite(page: Page, username: string, password: string) {
|
||||
await page.getByPlaceholder('e.g. lea').fill(username)
|
||||
await page.getByPlaceholder('e.g. lea').blur()
|
||||
await page.locator('input[autocomplete="new-password"]').fill(password)
|
||||
await page.locator('input[autocomplete="new-password"]').blur()
|
||||
}
|
||||
|
||||
function submitAcceptInvite(username: string, password: string) {
|
||||
return async (page: Page) => {
|
||||
await fillAcceptInvite(page, username, password)
|
||||
await page.locator('input[autocomplete="new-password"]').press('Enter')
|
||||
}
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
users: {
|
||||
Default: { path: '/users', api: usersApi() },
|
||||
@@ -1401,4 +1428,55 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
steps: submitResetConfirm('limewash2026', 'limewash2026'),
|
||||
},
|
||||
},
|
||||
'accept-invite': {
|
||||
'Checking the link': {
|
||||
path: acceptInvitePath,
|
||||
api: { 'GET /v1/auth/invite': { status: 200, hang: true } },
|
||||
},
|
||||
Default: acceptInviteWith({ status: 200 }),
|
||||
'Validation errors': {
|
||||
...acceptInviteWith({ status: 200 }),
|
||||
steps: async (page) => {
|
||||
await fillAcceptInvite(page, 'ma', 'lime')
|
||||
await page.locator('input[autocomplete="new-password"]').press('Enter')
|
||||
},
|
||||
},
|
||||
'Filled, valid': {
|
||||
...acceptInviteWith({ status: 200 }),
|
||||
steps: async (page) => {
|
||||
await fillAcceptInvite(page, 'lea', 'limewash2026')
|
||||
await page.getByRole('button', { name: 'Show password' }).click()
|
||||
},
|
||||
},
|
||||
Submitting: {
|
||||
...acceptInviteWith({ status: 200, hang: true }),
|
||||
steps: submitAcceptInvite('lea', 'limewash2026'),
|
||||
},
|
||||
'Username taken (409)': {
|
||||
...acceptInviteWith({ status: 409, body: 'That username is taken. Try another.' }),
|
||||
steps: submitAcceptInvite('marta', 'limewash2026'),
|
||||
},
|
||||
'Invalid or used link (404)': {
|
||||
path: acceptInvitePath,
|
||||
api: { 'GET /v1/auth/invite': { status: 404 } },
|
||||
},
|
||||
'Expired link (410)': {
|
||||
path: acceptInvitePath,
|
||||
api: { 'GET /v1/auth/invite': { status: 410 } },
|
||||
},
|
||||
'Server error': {
|
||||
...acceptInviteWith({ status: 500 }),
|
||||
steps: submitAcceptInvite('lea', 'limewash2026'),
|
||||
},
|
||||
Success: {
|
||||
...acceptInviteWith({ status: 200, body: { message: 'ok' } }),
|
||||
paused: true,
|
||||
api: {
|
||||
'GET /v1/auth/invite': { status: 200, body: { email: acceptInviteEmail } },
|
||||
'POST /v1/auth/invite/accept': { status: 200, body: { message: 'ok' } },
|
||||
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
|
||||
},
|
||||
steps: submitAcceptInvite('lea', 'limewash2026'),
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user