Files
RenewCMS/features/accept_invitation.qa.md
T

4.5 KiB

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

  1. 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.
  2. 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

  1. 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.
  2. Follow-up with the cookie: GET /me -> 200; list use case shows user "lea" and no invite for the address.
  3. Repeat POST with the same code -> 404 InviteInvalid, no user "lea2", no Set-Cookie; GET same code -> 404.
  4. Table for 404 (unknown code) and 410 (expired an hour ago): exact bodies, no user, no cookie, invites unchanged.
  5. Username "marta" taken -> 409, body apperr.UsernameTakenErrorMessage ("That username is taken. Try another."), no cookie, invite still pending; retry with "lea" -> 200.
  6. Unverified user with email Lea.Martin@Renew.example present -> 409, body apperr.EmailHasAccountErrorMessage, no user created, invite pending, no cookie.
  7. 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).
  8. Cookie of verified user 7 sent with a valid accept -> 200 and the new cookie's user_id is the new user's.
  9. 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.
  10. 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

  1. 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)

  1. 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).