7.8 KiB
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
- 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 /.POST /inviteis also wrapped inRequireOwner(getUser), placed before the limiter. For the rate-limit case wrap onlyPOST /inviteinRequireOwner(getUser)+TrustedProxyRealIP(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" with
Role: owner; unverified id 8 for the 403 cases. Time is real: build invites withExpiresAt/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; cookie of a verified admin (seeded withRole: admin) ->403, plain text body exactlyapperr.OwnerRequiredErrorMessage. 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:id,kind,username,display_name,email,role,status,created_atfor user items andid,kind,email,status,created_atfor 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. - 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.