Files
RenewCMS/features/invitations.qa.md
T

7.8 KiB

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 /. POST /invite is also wrapped in RequireOwner(getUser), placed before the limiter. For the rate-limit case wrap only POST /invite in RequireOwner(getUser) + 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" with Role: owner; 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

  1. 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.
  2. 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.
  3. Inviter with DisplayName:"Ines Duarte" -> InvitedBy=="Ines Duarte". Inviter with an empty display name -> username.
  4. Table for 400 ({"email":"sam.oliveira@renew"}, {"email":""}, {}, {"email":"not-an-email"}, not json): status 400, inviteRepo empty, SentEmails empty.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. Auth table (POST): no cookie -> 401; cookie of unverified user 8 -> 403; cookie with stale token_version -> 401; cookie of a verified admin (seeded with Role: admin) -> 403, plain text body exactly apperr.OwnerRequiredErrorMessage. Each asserts no invite and no mail.
  10. 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/

  1. 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: id, kind, username, display_name, email, role, status, created_at for user items and id, kind, email, status, created_at for invite items, accepted invite absent, and none of the forbidden keys anywhere. Also assert the raw body string contains each accepted user's own email address.
  2. Only user 7 -> array with exactly one kind:"user" item, status accepted.
  3. Invite created 3 days + 1 minute ago -> status:"expired"; created 2 days ago -> pending; with AcceptedAt set -> not listed.
  4. Auth table (GET): no cookie -> 401; unverified -> 403.

Use-case level (no HTTP)

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

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

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