Files
RenewCMS/features/accept_invitation.qa.md
T

39 lines
4.5 KiB
Markdown

# 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 `AcceptWithUser` set to fail (`failSwitch`) -> 500, no user "lea" remains (the failed accept created none, and the use case never calls user repo `Delete`), invite pending, no cookie. The real rollback is covered against SQLite in `test/accept_invitation_atomic_test.go`.
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).