216 lines
11 KiB
Gherkin
216 lines
11 KiB
Gherkin
Feature: The multi-user flows hold up under concurrency, failures and edge input
|
|
|
|
As the maintainer of RenewCMS
|
|
I want the invitation, account-menu and password-reset flows to be safe under concurrent use,
|
|
server failures and unusual input
|
|
So that a closed registration cannot be bypassed, no address is revealed, and nobody is left
|
|
with a spinner or a lost focus
|
|
|
|
Background:
|
|
Given this pass adds no endpoint, no route, no response shape and no design change
|
|
And every item below is covered by a test that fails before the change and passes after it,
|
|
or is stated as not testable
|
|
And error responses are plain text from http.Error, success responses are JSON
|
|
And the layering rule holds: domain has no GORM or HTTP, use cases see only domain
|
|
interfaces, handlers see only use cases, and no comment is added anywhere
|
|
|
|
Rule: Accepting an invite is atomic
|
|
|
|
Background:
|
|
Given the persistence tests run against a real in-memory SQLite database migrated with the
|
|
same models as api/dependecyInjection.go, not a fake repository
|
|
And marking an invite accepted is a conditional update on the invite row with
|
|
accepted_at IS NULL, and the number of rows affected is checked
|
|
And the conditional update and the user insert run in one database transaction, so both
|
|
commit or neither does
|
|
And no domain, use case or handler code knows about the transaction: the use case calls a
|
|
domain repository method and the persistence implementation owns the transaction
|
|
|
|
Scenario: Two concurrent accepts of one code create exactly one user
|
|
Given a pending invite for "lea.martin@renew.example" with raw code "3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42"
|
|
When two clients at the same moment send POST /v1/auth/invite/accept with that code, one
|
|
with username "lea" and one with username "lea2", both with password "limewash2026"
|
|
Then exactly one response is 200 with a jwt cookie
|
|
And the other response is 404 with body "This invite link doesn't work." and no jwt cookie
|
|
And the users table holds exactly one user with email "lea.martin@renew.example"
|
|
And the invite has accepted_at set
|
|
|
|
Scenario: The concurrency test is repeatable
|
|
Given the two-accept scenario above
|
|
When it runs 50 times, each on a fresh database
|
|
Then every run ends with exactly one user and one 200
|
|
|
|
Scenario: A used code loses at the conditional update, not at the earlier lookup
|
|
Given a pending invite that another accept marks accepted after this accept has read it
|
|
but before it marks it
|
|
When this accept marks the invite
|
|
Then zero rows are affected and the accept fails with the same invite-not-found error a
|
|
used code gives
|
|
And the response is 404 with body "This invite link doesn't work."
|
|
And no user was inserted for this accept
|
|
|
|
Scenario: A failed user insert leaves the invite pending
|
|
Given a pending invite for "lea.martin@renew.example"
|
|
And a verified user "marta"
|
|
When an accept with username "marta" is sent
|
|
Then the response is 409 with body "That username is taken. Try another."
|
|
And the invite is still pending, with accepted_at empty
|
|
|
|
Scenario: A failed invite update leaves no user behind
|
|
Given a pending invite for "lea.martin@renew.example"
|
|
And the conditional update fails after the user insert has been attempted in the transaction
|
|
When the accept runs
|
|
Then the transaction rolls back and no user "lea" exists
|
|
And the invite is still pending
|
|
And the response is 500 and no jwt cookie is set
|
|
|
|
Scenario: The existing accept behaviour is unchanged
|
|
Given the scenarios of features/accept_invitation.feature
|
|
Then all of them still pass, except that a failure marking the invite accepted no longer
|
|
makes the use case call userRepository.Delete: the rollback replaces the compensating delete
|
|
|
|
Rule: The accept screen recovers from a failed invite lookup
|
|
|
|
Background:
|
|
Given the route /invite/accept?c=CODE of docs/design/screens/accept-invite.md
|
|
And the design has a "Server error" state for the submit only, so the failed lookup is a
|
|
new state approved by the user, with no board frame
|
|
|
|
Scenario Outline: A failed lookup shows a server-error alert with a Try again action
|
|
Given the SPA is at /invite/accept?c=abc123
|
|
When GET /v1/auth/invite?c=abc123 <outcome>
|
|
Then the "Checking your invite…" spinner is replaced by an Alert (error, circle-x) with
|
|
title "Couldn't check your invite" and text "Something went wrong on the server. Try again in a moment."
|
|
And a "Try again" button is visible
|
|
And no form, no Email field and no ResultPanel is shown
|
|
And the spinner is not shown
|
|
|
|
Examples:
|
|
| outcome |
|
|
| answers 500 |
|
|
| answers 502 |
|
|
| fails with a network error |
|
|
|
|
Scenario: Try again looks the invite up again
|
|
Given the failed-lookup alert is shown
|
|
When the person activates "Try again"
|
|
Then the "Checking your invite…" spinner is shown and GET /v1/auth/invite?c=abc123 is sent again
|
|
And when it answers 200 {"email": "lea.martin@renew.example"} the Default state is shown
|
|
|
|
Scenario: Try again failing again shows the alert again
|
|
Given the failed-lookup alert is shown
|
|
When the person activates "Try again" and the lookup fails again
|
|
Then the alert is shown again and the button is enabled
|
|
|
|
Scenario Outline: Other lookup outcomes keep their design states
|
|
When GET /v1/auth/invite?c=abc123 answers <status>
|
|
Then the design state "<state>" is shown
|
|
|
|
Examples:
|
|
| status | state |
|
|
| 404 | Invalid or used link (404) |
|
|
| 410 | Expired link (410) |
|
|
|
|
Rule: The reset-request 200 stays generic when the mail fails
|
|
|
|
Scenario: A failed send is logged and the response is unchanged
|
|
Given the user repository contains a user "alice" with email "alice@example.com"
|
|
And the mail repository is set to fail on Send with the error "smtp: connection refused"
|
|
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
|
|
Then the response status is 200
|
|
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
|
|
And no jwt cookie is set
|
|
And the process log contains a line naming the failed send and the error "smtp: connection refused"
|
|
|
|
Scenario: The response is identical for a failed send, a successful send and an unknown email
|
|
Given three requests: alice with a failing mailer, alice with a working mailer, and an unknown address
|
|
Then the status, the body and the response headers other than Date are the same in all three
|
|
|
|
Scenario: The log does not contain the raw reset code
|
|
Given the same failing send
|
|
Then the process log contains neither the raw code nor its hash
|
|
|
|
Rule: The invite email is validated identically in the SPA and the Go validator
|
|
|
|
Background:
|
|
Given the Go rule is the validate tag of the invite request struct checked with
|
|
validator.Validate.Struct, extended through validator.InitValidator, not by hand
|
|
And the SPA rule is the InviteUserModal pattern
|
|
And the accepted and rejected addresses are the single table below
|
|
And user@localhost is rejected in both
|
|
|
|
Scenario Outline: The same address gets the same verdict on both sides
|
|
When POST /v1/user/invite is sent with body {"email": "<email>"} by a verified user
|
|
And the same address is typed in the invite modal
|
|
Then the API answers <status>
|
|
And the modal shows "<modal>"
|
|
|
|
Examples:
|
|
| email | status | modal |
|
|
| sam.oliveira@renew.example | 201 | no error |
|
|
| Sam.Oliveira@Renew.example | 201 | no error |
|
|
| sam+news@renew.example | 201 | no error |
|
|
| sam@mail.renew.example | 201 | no error |
|
|
| user@localhost | 400 | Enter a full email address, like name@example.com. |
|
|
| sam@renew | 400 | Enter a full email address, like name@example.com. |
|
|
| sam@.renew.example | 400 | Enter a full email address, like name@example.com. |
|
|
| sam@renew..example | 400 | Enter a full email address, like name@example.com. |
|
|
| sam@renew.example. | 400 | Enter a full email address, like name@example.com. |
|
|
| sam @renew.example | 400 | Enter a full email address, like name@example.com. |
|
|
| @renew.example | 400 | Enter a full email address, like name@example.com. |
|
|
| not-an-email | 400 | Enter a full email address, like name@example.com. |
|
|
| | 400 | Enter a full email address, like name@example.com. |
|
|
|
|
Scenario: A rejected address stores nothing and sends nothing
|
|
When POST /v1/user/invite is sent with body {"email": "user@localhost"}
|
|
Then no invite was created and no email was sent
|
|
|
|
Rule: The mobile account sheet keeps its chip state and focus
|
|
|
|
Background:
|
|
Given the account chip (aria-haspopup "menu", title "Account") of docs/design/components.md
|
|
and the account menu of docs/design/screens/profile.md, shown as a sheet on mobile (390 px)
|
|
And there is no visual change: the sand fill while open is unchanged
|
|
|
|
Scenario Outline: The chip's aria-expanded tracks the sheet
|
|
Given the chip is visible and the sheet is closed
|
|
Then the chip has aria-expanded "false"
|
|
When the person activates the chip
|
|
Then the sheet is open and the chip has aria-expanded "true"
|
|
When the sheet closes by <closing action>
|
|
Then the chip has aria-expanded "false"
|
|
|
|
Examples:
|
|
| closing action |
|
|
| pressing Escape |
|
|
| tapping the backdrop |
|
|
| activating "Profile" |
|
|
|
|
Scenario Outline: Focus returns to the opener when the sheet closes
|
|
Given the sheet was opened from the chip
|
|
When the sheet closes by <closing action>
|
|
Then focus returns to the chip if it is still mounted, otherwise to the navbar toggle
|
|
|
|
Examples:
|
|
| closing action |
|
|
| pressing Escape |
|
|
| tapping the backdrop |
|
|
| activating "Profile" |
|
|
|
|
Scenario: Logging out does not try to focus a removed chip
|
|
Given the sheet is open
|
|
When the person activates "Log out"
|
|
Then the session ends and no error is thrown
|
|
|
|
Scenario: Focus is not stolen when the sheet was never opened
|
|
Given the page has just loaded
|
|
Then focus is not on the chip
|
|
|
|
Rule: userRepository.Delete deletes through the persistence model
|
|
|
|
Scenario: Deleting a user removes only that row
|
|
Given a real in-memory SQLite database with users 1, 2 and 3
|
|
When Delete is called for user 2
|
|
Then user 2 is gone and users 1 and 3 remain
|
|
And Delete goes through the persistence model, not the domain struct
|