Files
RenewCMS/features/invite_management.qa.md

4.4 KiB

QA procedure: invite management

Package: test (black-box). Reuse mockUserRepo, stubHasher, mockMailRepo and mockInviteRepo. Extend mockInviteRepo with the new renew method and a failSwitch entry for it (unique type names across the package). No live server, no real SQLite (except case 27), no real SMTP.

Setup shared by every case

  1. Call validator.InitValidator() once.
  2. Use cases over domain interfaces only: CreateInvite, ListUsersAndInvites, GetInvite, the accept use case, plus the new cancel and renew use cases (final names are the coder's).
  3. Handler built with NewHandler(...) on a fresh chi.NewRouter() mirroring NewUserRouter: jwtauth.Verifier + jwtauth.Authenticator (jwtauth.New("HS256", []byte("test-secret"), nil)), RequireCurrentToken, RequireVerifiedUser, GET /, then a group with RequireOwner, TrustedProxyRealIP(nil) and NewRateLimiter containing POST /invite, DELETE /invite/{id} and POST /invite/{id}/renew. Mount GET /invite and POST /invite/accept from the auth handler for the old-link cases.
  4. Seed users: owner 7 "marta", unverified 8, admin 9. Invite hashes are "hashed:"+raw. Times are relative to time.Now(), compared with a 5 s tolerance.

DELETE /v1/user/invite/{id}

  1. Pending invite, owner cookie -> 204, empty body, invite gone, no mail, absent from GET /.
  2. After cancel: GET /invite?c=<code> -> 404; accept -> 404, no user, no cookie; re-invite the address -> 201.
  3. Expired invite -> 204.
  4. Unknown id -> 404 "Invite not found"; second delete -> 404.
  5. Accepted invite -> 409 "This invite has already been accepted."; invite and user remain.
  6. Other invites and users unchanged after a cancel.
  7. Repository delete failure -> 500, invite still present.

POST /v1/user/invite/{id}/renew

  1. Expired invite -> 200, Cache-Control: no-store; key set exactly id, kind, email, status, created_at, expires_at; no code, hashed_code, invited_by, accepted_at.
  2. Stored invite: same id, new hash, invited_by 7, accepted_at nil, created_at about now, expires_at = created_at + 72h.
  3. Pending invite renews with 200, new hash.
  4. Mail: exactly one entry, template invitation, Host, Email, InvitedBy by display name or username of the acting owner, VerificationCode = raw new code.
  5. GET / lists the renewed invite first.
  6. Old code: GET /invite and accept -> 404, no user; new code -> 200 with the email. Control checks before the renew show the old code worked. Same for pending.
  7. Unknown id -> 404; cancelled id -> 404.
  8. Accepted -> 409, all fields unchanged, no mail.
  9. mailRepo.fail.failNext("Send") -> 500 "Couldn't send the invite. Try again in a moment."; invite hash, expiry and created_at unchanged; retry -> 200.
  10. Renew persistence failure -> 500, no mail, invite unchanged.

Access and ids (table over both endpoints)

  1. No cookie -> 401; stale token_version -> 401; unverified -> 403; admin -> 403 with body apperr.OwnerRequiredErrorMessage; admin on an unknown id -> 403. Each: invite unchanged, no mail.
  2. Ids abc, -1, 4294967296 -> 400 apperr.IdUint32ErrorMessage on both endpoints.

Rate limit

  1. Limiter of 2, RemoteAddr 203.0.113.40:1234: third request -> 429, apperr.TooManyRequestsErrorMessage, Retry-After, no change, no mail. GET / from the same address is not 429.
  2. Admin's three requests -> 403, 403, 403.
  3. Shared counter: POST /invite, renew, then DELETE -> 429.

Use case and persistence

  1. Use case level:
    • unknown id -> errors.Is(err, ErrInviteNotFound); accepted -> the accepted sentinel (name is the coder's);
    • success returns the same id, a new hash, CreatedAt about now, ExpiresAt = CreatedAt + invite.Lifetime;
    • mail failure wraps ErrInviteEmailNotSent and restores the prior values;
    • the mail logic is shared with CreateInvite.
  2. Persistence on in-memory SQLite: the renew method updates hash, expiry and created_at by id, returns ErrInviteNotFound when the row is missing or already accepted.

Manual

Renew against a real or sandboxed SMTP server: one mail arrives, the new link works, the old mail's link is dead. Persistence across a restart on a fresh SQLite file. Review layering and that no comments were introduced.

Out of scope

SPA handling of 404, 409 and 429 on these endpoints: this slice is API-only.