Files
RenewCMS/features/multi_user_hardening.feature

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