100 lines
7.4 KiB
Markdown
100 lines
7.4 KiB
Markdown
# QA procedure: invitations
|
|
|
|
Package: `test` (black-box). New fakes must have unique type names across the package:
|
|
`mockInviteRepo` (implements `invite.Repository` with an in-memory slice and a `failSwitch`),
|
|
plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `stubHasher`
|
|
(`user_test.go`) and `mockMailRepo` (`mail_test.go`; `SentEmails` entries expose `Address`,
|
|
`Template`, `Data`; `fail.failNext("Send")`).
|
|
|
|
## Setup shared by every case
|
|
1. Call `validator.InitValidator()` once.
|
|
2. Use cases: `useCases.NewCreateInviteUseCase(userRepo, inviteRepo, &stubHasher{}, mailRepo,
|
|
"https://cms.example.com")` and `useCases.NewListUsersAndInvitesUseCase(userRepo, inviteRepo)`
|
|
(final names are the coder's; constructors take only domain interfaces).
|
|
3. Handler: `user.NewHandler(updateProfile, createInvite, listUsersAndInvites)` mounted on a fresh
|
|
`chi.NewRouter()` mirroring `NewUserRouter`: `jwtauth.Verifier` + `jwtauth.Authenticator` with
|
|
`jwtauth.New("HS256", []byte("test-secret"), nil)`, `RequireCurrentToken(getUser)`,
|
|
`RequireVerifiedUser(getUser)`, then `POST /invite` and `GET /`. For the rate-limit case
|
|
wrap only `POST /invite` in `TrustedProxyRealIP(nil)` + `NewRateLimiter(2, time.Minute)`.
|
|
Requests via `httptest`; jwt cookie built with the `user_id` and `token_version` claims as in
|
|
`test/verified_account_required_http_test.go`. No live server, no real SQLite, no real SMTP.
|
|
4. Seed users: verified id 7 "marta"; unverified id 8 for the 403 cases. Time is real: build
|
|
invites with `ExpiresAt`/`CreatedAt` relative to `time.Now()` and compare with a 5 s tolerance.
|
|
|
|
## POST /v1/user/invite
|
|
5. Body `{"email":"Sam.Oliveira@Renew.example"}`, cookie for 7 -> `201`. Decode into
|
|
`map[string]json.RawMessage`: the key set is exactly `id`, `kind`, `email`, `status`,
|
|
`created_at`, `expires_at`, with `kind`="invite", `email`="sam.oliveira@renew.example",
|
|
`status`="pending"; none of `code`, `hashed_code`, `verification_code`, `password`.
|
|
`inviteRepo` holds one invite: lower-case email, `InvitedBy==7`, `AcceptedAt` nil,
|
|
`abs(ExpiresAt - (CreatedAt+72h)) < 5s`, stored code == `"hashed:"+rawCode` (stubHasher) where
|
|
`rawCode` is taken from the mail data; the stored code is not equal to `rawCode`.
|
|
6. `mailRepo.SentEmails` has exactly one entry: `Address`, `Template=="invitation"`, data of the
|
|
invitation mail type with `Host=="https://cms.example.com"`, `Email` the invited address,
|
|
`InvitedBy=="marta"` and a uuid-shaped `VerificationCode`.
|
|
7. Inviter with `DisplayName:"Ines Duarte"` -> `InvitedBy=="Ines Duarte"`. Inviter with an
|
|
empty display name -> username.
|
|
8. Table for 400 (`{"email":"sam.oliveira@renew"}`, `{"email":""}`, `{}`, `{"email":"not-an-email"}`,
|
|
`not json`): status `400`, `inviteRepo` empty, `SentEmails` empty.
|
|
9. Existing user with email `"Jonas@Renew.example"`, invite `jonas@renew.example` -> `409`, body exactly the new
|
|
apperr constant "This address already has an account.", no invite, no mail. This case pins that
|
|
the match is case-insensitive.
|
|
10. Seed a pending invite (expires in 2 days) for the address -> `409`, body "There's already a pending
|
|
invite for this address.", the seeded invite unchanged (same code hash and expiry), no mail.
|
|
11. Seed an expired invite (expired 1 h ago) -> `201`; `inviteRepo` has exactly one invite for the address, with
|
|
a different code hash and an expiry about 3 days out; one mail sent.
|
|
12. `mailRepo.fail.failNext("Send")` -> `500`, body "Couldn't send the invite. Try again in a moment.",
|
|
`inviteRepo` empty. Repeat the same request without re-arming -> `201`. With a seeded expired invite and a
|
|
failing send -> `500` and no invite for that address remains.
|
|
13. Auth table (`POST`): no cookie -> `401`; cookie of unverified user 8 -> `403`; cookie with stale
|
|
`token_version` -> `401`. Each asserts no invite and no mail.
|
|
14. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.20:1234`, three valid POSTs -> `201`,
|
|
`201`, `429`; third body is exactly `apperr.TooManyRequestsErrorMessage`, has `Retry-After`, and adds no
|
|
invite or mail. `GET /` from the same address afterwards is not `429`.
|
|
|
|
## GET /v1/user/
|
|
15. Seed per the feature scenario (three users, an expired, a pending and an accepted invite, with the
|
|
stated `CreatedAt` values). `200`; decode as `[]map[string]json.RawMessage`. Assert length 5, order
|
|
(pending sam, expired p.huber, ines, jonas, marta), key sets exactly as specified for user and invite
|
|
items, accepted invite absent, and none of the forbidden keys anywhere. Also assert the raw body string
|
|
contains no user email address.
|
|
16. Only user 7 -> array with exactly one `kind:"user"` item, status `accepted`.
|
|
17. Invite created 3 days + 1 minute ago -> `status:"expired"`; created 2 days ago -> `pending`;
|
|
with `AcceptedAt` set -> not listed.
|
|
18. Auth table (`GET`): no cookie -> `401`; unverified -> `403`.
|
|
|
|
## Use-case level (no HTTP)
|
|
19. `CreateInvite` on a repository holding a user with the same email -> `errors.Is(err, invite.ErrEmailHasAccount)`;
|
|
live pending invite -> `errors.Is(err, invite.ErrPendingInvite)` (sentinel names are the coder's).
|
|
A mail failure wraps the mail repository error and the `Delete` of the invite is called
|
|
(if `Delete` also fails, both errors are joined, as `CreateUser` does).
|
|
|
|
## Mailer (real embedded templates, no SMTP)
|
|
20. `mailer.MailRepository{}.Render("invitation", data)` with `Host:"https://cms.example.com"`,
|
|
`VerificationCode:"abc-123"`, `Email:"lea@example.com"`, `InvitedBy:"Ines Duarte"` returns subject
|
|
`"Ines Duarte invited you to RenewCMS"` and a body containing
|
|
`https://cms.example.com/invite/accept?c=abc-123`, `lea@example.com` and `Ines Duarte`.
|
|
An `InvitedBy` of `<b>x</b>` is HTML-escaped in body. Subjects for `verification` and `password-reset` are
|
|
unchanged, and an unknown template still gets the default subject.
|
|
21. `internal/infrastructure/mailer/templates/invitation.html` is byte-identical to
|
|
`docs/design/source/emails/invitation.html` (read both files and compare).
|
|
|
|
## Regression: registration (extends registration_gate_and_verification_email.qa.md)
|
|
22. With one verified user and a pending invite for `sam.oliveira@renew.example`, `POST /v1/auth/register` with that
|
|
email -> `403`, body exactly `apperr.RegistrationClosedErrorMessage`, no user created, no mail sent.
|
|
23. Empty user repository -> `POST /v1/auth/register` still `200` with a `jwt` cookie and one `verification`
|
|
mail, and `inviteRepo` untouched. `GET /v1/auth/registration` still reports closed when a verified user
|
|
and a pending invite exist.
|
|
|
|
## Manual verification (not exercised by the Go tests)
|
|
- Start the app once against a fresh SQLite file: the `invites` table exists (model registered in
|
|
`AutoMigrate` in `api/dependecyInjection.go`) and inviting persists across a restart.
|
|
- Send a real invitation through a real or sandboxed SMTP server: the subject line, the rendering in a mail
|
|
client (light and dark) and that the link `{PUBLIC_URL}/invite/accept?c=...` carries the raw code.
|
|
The screen the link opens is not designed and is out of scope, so the link will not resolve in the SPA yet.
|
|
- SPA copy for the 429 ("Too many invites. Wait a moment, then try again.") is a note for the later SPA
|
|
slice and is not tested here. The SPA keys list rows by kind and id, since a user and an invite can share
|
|
a numeric id.
|
|
- Layering check by review: use cases import only `internal/domain/*`; the mailer subject logic lives in
|
|
`internal/infrastructure/mailer`.
|