7.4 KiB
7.4 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
- Call
validator.InitValidator()once. - Use cases:
useCases.NewCreateInviteUseCase(userRepo, inviteRepo, &stubHasher{}, mailRepo, "https://cms.example.com")anduseCases.NewListUsersAndInvitesUseCase(userRepo, inviteRepo)(final names are the coder's; constructors take only domain interfaces). - Handler:
user.NewHandler(updateProfile, createInvite, listUsersAndInvites)mounted on a freshchi.NewRouter()mirroringNewUserRouter:jwtauth.Verifier+jwtauth.Authenticatorwithjwtauth.New("HS256", []byte("test-secret"), nil),RequireCurrentToken(getUser),RequireVerifiedUser(getUser), thenPOST /inviteandGET /. For the rate-limit case wrap onlyPOST /inviteinTrustedProxyRealIP(nil)+NewRateLimiter(2, time.Minute). Requests viahttptest; jwt cookie built with theuser_idandtoken_versionclaims as intest/verified_account_required_http_test.go. No live server, no real SQLite, no real SMTP. - Seed users: verified id 7 "marta"; unverified id 8 for the 403 cases. Time is real: build
invites with
ExpiresAt/CreatedAtrelative totime.Now()and compare with a 5 s tolerance.
POST /v1/user/invite
- Body
{"email":"Sam.Oliveira@Renew.example"}, cookie for 7 ->201. Decode intomap[string]json.RawMessage: the key set is exactlyid,kind,email,status,created_at,expires_at, withkind="invite",email="sam.oliveira@renew.example",status="pending"; none ofcode,hashed_code,verification_code,password.inviteRepoholds one invite: lower-case email,InvitedBy==7,AcceptedAtnil,abs(ExpiresAt - (CreatedAt+72h)) < 5s, stored code =="hashed:"+rawCode(stubHasher) whererawCodeis taken from the mail data; the stored code is not equal torawCode. mailRepo.SentEmailshas exactly one entry:Address,Template=="invitation", data of the invitation mail type withHost=="https://cms.example.com",Emailthe invited address,InvitedBy=="marta"and a uuid-shapedVerificationCode.- Inviter with
DisplayName:"Ines Duarte"->InvitedBy=="Ines Duarte". Inviter with an empty display name -> username. - Table for 400 (
{"email":"sam.oliveira@renew"},{"email":""},{},{"email":"not-an-email"},not json): status400,inviteRepoempty,SentEmailsempty. - Existing user with email
"Jonas@Renew.example", invitejonas@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. - 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. - Seed an expired invite (expired 1 h ago) ->
201;inviteRepohas exactly one invite for the address, with a different code hash and an expiry about 3 days out; one mail sent. mailRepo.fail.failNext("Send")->500, body "Couldn't send the invite. Try again in a moment.",inviteRepoempty. Repeat the same request without re-arming ->201. With a seeded expired invite and a failing send ->500and no invite for that address remains.- Auth table (
POST): no cookie ->401; cookie of unverified user 8 ->403; cookie with staletoken_version->401. Each asserts no invite and no mail. - Rate limit: limiter of 2 per minute,
RemoteAddr203.0.113.20:1234, three valid POSTs ->201,201,429; third body is exactlyapperr.TooManyRequestsErrorMessage, hasRetry-After, and adds no invite or mail.GET /from the same address afterwards is not429.
GET /v1/user/
- Seed per the feature scenario (three users, an expired, a pending and an accepted invite, with the
stated
CreatedAtvalues).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. - Only user 7 -> array with exactly one
kind:"user"item, statusaccepted. - Invite created 3 days + 1 minute ago ->
status:"expired"; created 2 days ago ->pending; withAcceptedAtset -> not listed. - Auth table (
GET): no cookie ->401; unverified ->403.
Use-case level (no HTTP)
CreateInviteon 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 theDeleteof the invite is called (ifDeletealso fails, both errors are joined, asCreateUserdoes).
Mailer (real embedded templates, no SMTP)
mailer.MailRepository{}.Render("invitation", data)withHost:"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 containinghttps://cms.example.com/invite/accept?c=abc-123,lea@example.comandInes Duarte. AnInvitedByof<b>x</b>is HTML-escaped in body. Subjects forverificationandpassword-resetare unchanged, and an unknown template still gets the default subject.internal/infrastructure/mailer/templates/invitation.htmlis byte-identical todocs/design/source/emails/invitation.html(read both files and compare).
Regression: registration (extends registration_gate_and_verification_email.qa.md)
- With one verified user and a pending invite for
sam.oliveira@renew.example,POST /v1/auth/registerwith that email ->403, body exactlyapperr.RegistrationClosedErrorMessage, no user created, no mail sent. - Empty user repository ->
POST /v1/auth/registerstill200with ajwtcookie and oneverificationmail, andinviteRepountouched.GET /v1/auth/registrationstill 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
invitestable exists (model registered inAutoMigrateinapi/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 ininternal/infrastructure/mailer.