refac: hardening pass with multiple overhauls
This commit is contained in:
1 parent
b0f7b7892b
commit
da36c78bc0
28 files changed
+1375
-39
No files matched your search
@@ -0,0 +1,215 @@
|
||||
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
|
||||
Reference in new issue
Block a user