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
@@ -12,6 +12,7 @@ import (
|
|||||||
"RenewCMS/internal/infrastructure/persistence/models"
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -53,11 +54,17 @@ type Repositories struct {
|
|||||||
MailRepo domainMail.Repository
|
MailRepo domainMail.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sqliteBusyTimeoutMs = 5000
|
||||||
|
|
||||||
|
func SQLiteDSN(dbName string) string {
|
||||||
|
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
func getDb(dbName string) *gorm.DB {
|
func getDb(dbName string) *gorm.DB {
|
||||||
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
|
||||||
panic("Unable to create necessary subdirectories: " + err.Error())
|
panic("Unable to create necessary subdirectories: " + err.Error())
|
||||||
}
|
}
|
||||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(SQLiteDSN(dbName)), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("Unable to open the database: " + err.Error())
|
panic("Unable to open the database: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type InviteHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateInviteRequest struct {
|
type CreateInviteRequest struct {
|
||||||
Email string `json:"email" validate:"required,email"`
|
Email string `json:"email" validate:"required,inviteemail"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type inviteResponse struct {
|
type inviteResponse struct {
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
package validator
|
package validator
|
||||||
|
|
||||||
import "github.com/go-playground/validator/v10"
|
import (
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
const inviteEmailTag = "inviteemail"
|
||||||
|
|
||||||
|
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
|
||||||
|
|
||||||
var Validate *validator.Validate
|
var Validate *validator.Validate
|
||||||
|
|
||||||
func InitValidator() {
|
func InitValidator() {
|
||||||
Validate = validator.New()
|
Validate = validator.New()
|
||||||
|
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInviteEmail(field validator.FieldLevel) bool {
|
||||||
|
return inviteEmailPattern.MatchString(field.Field().String())
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,13 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
|
|||||||
|
|
||||||
Nothing under `internal/` imports `api/`.
|
Nothing under `internal/` imports `api/`.
|
||||||
|
|
||||||
|
### Delivery packages
|
||||||
|
|
||||||
|
| Package | Role |
|
||||||
|
|---|---|
|
||||||
|
| `api/handlers/user` | Authenticated user endpoints: profile update (`UpdateMe`) and the users list with invitations (`CreateInvite`, `ListUsers`). |
|
||||||
|
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
|
||||||
|
|
||||||
## Frontend ↔ backend
|
## Frontend ↔ backend
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -132,10 +132,11 @@ Feature: An invited person accepts their invitation and gets a verified account
|
|||||||
Then the response status is 200
|
Then the response status is 200
|
||||||
And the Set-Cookie jwt carries the new user's id, not marta's
|
And the Set-Cookie jwt carries the new user's id, not marta's
|
||||||
|
|
||||||
Scenario: A failure after the user is created leaves the invite pending and no user behind
|
Scenario: A failure while accepting rolls back and leaves the invite pending and no user behind
|
||||||
Given a pending invite and the invite repository set to fail marking it accepted
|
Given a pending invite and the accept operation of the invite repository set to fail
|
||||||
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
|
When the client sends POST /v1/auth/invite/accept with username "lea" and password "limewash2026"
|
||||||
Then the response status is 500
|
Then the response status is 500
|
||||||
|
And the user insert and the invite update are rolled back together
|
||||||
And no user "lea" exists, no jwt cookie is set and the invite is still pending
|
And no user "lea" exists, no jwt cookie is set and the invite is still pending
|
||||||
|
|
||||||
Scenario: The accept endpoints are rate-limited
|
Scenario: The accept endpoints are rate-limited
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Package `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo`,
|
|||||||
12. Unverified user with email `Lea.Martin@Renew.example` present -> 409, body `apperr.EmailHasAccountErrorMessage`, no user created, invite pending, no cookie.
|
12. Unverified user with email `Lea.Martin@Renew.example` present -> 409, body `apperr.EmailHasAccountErrorMessage`, no user created, invite pending, no cookie.
|
||||||
13. 400 table (username 2 chars, 21 chars, password 4 chars, no username, no code, `not json`): status 400, user repo and invite unchanged. Code lookup is not reached (assert the invite repo saw no accept call).
|
13. 400 table (username 2 chars, 21 chars, password 4 chars, no username, no code, `not json`): status 400, user repo and invite unchanged. Code lookup is not reached (assert the invite repo saw no accept call).
|
||||||
14. Cookie of verified user 7 sent with a valid accept -> 200 and the new cookie's `user_id` is the new user's.
|
14. Cookie of verified user 7 sent with a valid accept -> 200 and the new cookie's `user_id` is the new user's.
|
||||||
15. Invite repo accept method set to fail (`failSwitch`) -> 500, no user "lea" remains (user repo `Delete` was called), invite pending, no cookie.
|
15. Invite repo `AcceptWithUser` set to fail (`failSwitch`) -> 500, no user "lea" remains (the failed accept created none, and the use case never calls user repo `Delete`), invite pending, no cookie. The real rollback is covered against SQLite in `test/accept_invitation_atomic_test.go`.
|
||||||
16. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.30:1234`, three POSTs -> third is 429 with body `apperr.TooManyRequestsErrorMessage` and `Retry-After`; `GET /registration` afterwards is not 429.
|
16. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.30:1234`, three POSTs -> third is 429 with body `apperr.TooManyRequestsErrorMessage` and `Retry-After`; `GET /registration` afterwards is not 429.
|
||||||
|
|
||||||
## Regression
|
## Regression
|
||||||
@@ -36,4 +36,3 @@ Package `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo`,
|
|||||||
|
|
||||||
## Manual verification
|
## Manual verification
|
||||||
- Fresh SQLite file: invite acceptance persists across restart, `accepted_at` stored, unique username constraint holds; send a real invitation and open its link end to end (real SMTP, real embedded SPA).
|
- Fresh SQLite file: invite acceptance persists across restart, `accepted_at` stored, unique username constraint holds; send a real invitation and open its link end to end (real SMTP, real embedded SPA).
|
||||||
- Concurrency: two simultaneous accepts of one code create one user (not exercisable with the in-memory fakes).
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# QA procedure: multi-user hardening
|
||||||
|
|
||||||
|
Two parts. Go tests in `test/` (package `test`, black-box). Vitest for the SPA. Every item is written so it fails first.
|
||||||
|
|
||||||
|
## Part A. Go
|
||||||
|
|
||||||
|
### A1. Atomic accept (real in-memory SQLite, no fake)
|
||||||
|
1. Open a real GORM SQLite database with a shared-cache in-memory DSN (or a `t.TempDir()` file if shared memory is flaky on Windows), migrated with the same models as `api/dependecyInjection.go`. Build the real persistence repositories, the real accept use case and the handler on a fresh `chi.NewRouter()`. `validator.InitValidator()`, `jwtauth.New("HS256", []byte("test-secret"), nil)`, `httptest`. Package-unique type names.
|
||||||
|
2. Do not use `SetMaxOpenConns(1)`: it serialises the requests and hides the race. Two connections must truly contend.
|
||||||
|
3. Seed one pending invite with the real hasher's hash of raw code `3f0c1a52-8d0e-4c53-9a3a-6b7c0d9e1f42`, email `lea.martin@renew.example`.
|
||||||
|
4. Start two goroutines behind a `sync.WaitGroup` and a start channel, one POST with username `lea`, one with `lea2`. Collect both recorders.
|
||||||
|
5. Assert: exactly one 200 with a `jwt` cookie; the other 404 with body exactly the invite-invalid message and no `Set-Cookie`; one user with that email; invite `accepted_at` not null.
|
||||||
|
6. Repeat 50 times, each on a fresh DB. It must fail before the change.
|
||||||
|
7. Rollback case: force the invite update to fail (a `BEFORE UPDATE` trigger with `RAISE(ABORT, ...)` on invites). POST gives 500, no user `lea`, invite still pending, no cookie.
|
||||||
|
8. Taken username: seed user `marta`, POST username `marta` gives 409 with the username-taken message, invite `accepted_at` still null.
|
||||||
|
9. Zero rows affected: a thin decorator over the real repository marks the invite accepted in SQL between lookup and update. Expect the invite-not-found sentinel via `errors.Is` and no user.
|
||||||
|
10. Re-run the existing accept-invitation cases. The case asserting `Delete` after a failing mark-accepted is rewritten to assert no user is left.
|
||||||
|
|
||||||
|
### A2. userRepository.Delete via the persistence model
|
||||||
|
11. Against the same real SQLite: create users 1 to 3, `Delete(2)`, assert row 2 gone and 1 and 3 remain. Keep the existing behaviour for a missing id.
|
||||||
|
12. If step 11 passes before the refactor, report "no failing test possible, internal refactor only". Do not fabricate a failing test.
|
||||||
|
|
||||||
|
### A3. Password reset send failure
|
||||||
|
13. Handler over the real reset-request use case, `mockUserRepo` holding alice, a mail fake failing with `errors.New("smtp: connection refused")`.
|
||||||
|
14. Capture the standard log with `log.SetOutput(&buf)`, restore with `t.Cleanup`. No parallel tests.
|
||||||
|
15. POST `{"email":"alice@example.com"}` gives 200, body exactly the generic message JSON, no `Set-Cookie`. `buf` names the send failure and `smtp: connection refused`, and contains neither the raw code nor its hash.
|
||||||
|
16. Compare failing mailer, working mailer and unknown email: same status, body and headers except `Date`.
|
||||||
|
|
||||||
|
### A4. Invite email validation (Go side)
|
||||||
|
17. Table from the feature. POST `/v1/user/invite` with a verified user's `jwt` cookie. Accepted gives 200; rejected gives 400, no invite stored, no email sent. `user@localhost` is rejected.
|
||||||
|
18. Share the table between the Go and Vitest suites through one JSON fixture so they cannot drift. Location is the coder's call.
|
||||||
|
|
||||||
|
## Part B. SPA (Vitest + Vue Test Utils, `task test:web`)
|
||||||
|
|
||||||
|
### B1. Accept screen, failed lookup
|
||||||
|
| Mock | Expected |
|
||||||
|
| --- | --- |
|
||||||
|
| GET /v1/auth/invite?c=abc123 gives 500 | Alert title "Couldn't check your invite", text "Something went wrong on the server. Try again in a moment.", button "Try again", no spinner, no form |
|
||||||
|
| gives 502 | same |
|
||||||
|
| network error | same |
|
||||||
|
| gives 404 | ResultPanel unchanged |
|
||||||
|
| gives 410 | ResultPanel unchanged |
|
||||||
|
19. Try again: click, assert a second GET and the spinner; second mock answers 200 `{email:'lea.martin@renew.example'}` and the Default state shows. A second failure shows the alert again.
|
||||||
|
20. Existing accept-screen specs pass unchanged.
|
||||||
|
|
||||||
|
### B2. Invite modal email
|
||||||
|
21. Feed each row of the shared table into the invite modal, blur, assert the error text or none and the submit state.
|
||||||
|
|
||||||
|
### B3. Mobile account sheet
|
||||||
|
22. Mount at the mobile branch, attach to `document.body`. Chip `aria-expanded` is "false", then "true" after click, then "false" after each close (Escape, backdrop, Profile), with focus on the chip, or on the navbar toggle when the drawer chip is unmounted. Log out throws nothing. Await `nextTick` and the leave transition before asserting focus.
|
||||||
|
|
||||||
|
## Visual (`task visual:check -- accept-invite`)
|
||||||
|
- The failed-lookup state has no board frame. The user approved a per-state `exception` (`approvedBy` the user) for it in `web/visual/states.ts`.
|
||||||
|
- All existing accept-invite states stay within 10 %.
|
||||||
|
- The sheet fix is not a visual change. Re-run the screens containing the sheet at mobile in light and dark to confirm nothing moved.
|
||||||
|
|
||||||
|
## Manual verification
|
||||||
|
- Unreachable SMTP host on password reset returns 200 and the server log shows the error.
|
||||||
|
- Real file SQLite: two concurrent accepts against a running binary leave one user.
|
||||||
|
- Screen reader: chip announces expanded and collapsed; focus lands on the chip after closing the sheet.
|
||||||
|
|
||||||
|
## Docs (no scenario)
|
||||||
|
- `docs/architecture.md` lacks `api/handlers/user` and `api/pkg/publicuser`; coder adds both.
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package invite
|
package invite
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
Create(invite Invite) (Invite, error)
|
Create(invite Invite) (Invite, error)
|
||||||
GetAll() []Invite
|
GetAll() []Invite
|
||||||
Delete(id uint32) error
|
Delete(id uint32) error
|
||||||
MarkAccepted(id uint32, acceptedAt time.Time) error
|
AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser user.User) (user.User, error)
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package persistence
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
domainInvite "RenewCMS/internal/domain/invite"
|
domainInvite "RenewCMS/internal/domain/invite"
|
||||||
|
domainUser "RenewCMS/internal/domain/user"
|
||||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||||
"time"
|
"time"
|
||||||
@@ -44,9 +45,22 @@ func (i *InviteRepository) Delete(id uint32) error {
|
|||||||
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
|
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *InviteRepository) MarkAccepted(id uint32, acceptedAt time.Time) error {
|
func (i *InviteRepository) AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser domainUser.User) (domainUser.User, error) {
|
||||||
result := i.db.Model(&entity.Invite{}).Where("id = ?", id).Update("accepted_at", acceptedAt)
|
createdModel := mappers.UserToModel(newUser)
|
||||||
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
|
err := i.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
marked := tx.Model(&entity.Invite{}).
|
||||||
|
Where("id = ? AND accepted_at IS NULL", inviteID).
|
||||||
|
Update("accepted_at", acceptedAt)
|
||||||
|
if err := deleteOrNotFound(marked, domainInvite.ErrInviteNotFound); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&createdModel).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return domainUser.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mappers.UserToDomain(createdModel), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ domainInvite.Repository = &InviteRepository{}
|
var _ domainInvite.Repository = &InviteRepository{}
|
||||||
@@ -49,7 +49,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *UserRepository) Delete(id uint32) error {
|
func (u *UserRepository) Delete(id uint32) error {
|
||||||
result := u.db.Delete(&domainUser.User{}, id)
|
result := u.db.Delete(&entity.User{}, id)
|
||||||
return deleteOrNotFound(result, domainUser.ErrUserNotFound)
|
return deleteOrNotFound(result, domainUser.ErrUserNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ func (a *AcceptInviteUseCase) AcceptInvite(command AcceptInviteCommand) (user.Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
if a.emailHasAccount(pending.Email) {
|
if a.emailHasAccount(pending.Email) {
|
||||||
|
if _, err := findUsableInvite(a.inviteRepository, a.hasher, command.Code, now); err != nil {
|
||||||
|
return user.User{}, err
|
||||||
|
}
|
||||||
return user.User{}, invite.ErrEmailHasAccount
|
return user.User{}, invite.ErrEmailHasAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,20 +49,16 @@ func (a *AcceptInviteUseCase) AcceptInvite(command AcceptInviteCommand) (user.Us
|
|||||||
return user.User{}, err
|
return user.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
createdUser, err := a.userRepository.Create(user.User{
|
createdUser, err := a.inviteRepository.AcceptWithUser(pending.ID, now, user.User{
|
||||||
Username: command.Username,
|
Username: command.Username,
|
||||||
Password: hashedPassword,
|
Password: hashedPassword,
|
||||||
Email: pending.Email,
|
Email: pending.Email,
|
||||||
IsVerified: true,
|
IsVerified: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if errors.Is(err, invite.ErrInviteNotFound) {
|
||||||
return user.User{}, err
|
return user.User{}, invite.ErrInviteInvalid
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
if err := a.inviteRepository.MarkAccepted(pending.ID, now); err != nil {
|
|
||||||
if deleteErr := a.userRepository.Delete(createdUser.ID); deleteErr != nil {
|
|
||||||
return user.User{}, errors.Join(err, deleteErr)
|
|
||||||
}
|
|
||||||
return user.User{}, err
|
return user.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/handlers/auth"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
"RenewCMS/internal/domain/invite"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/jwtauth/v5"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const atomicAcceptRuns = 50
|
||||||
|
|
||||||
|
type atomicPlainHasher struct{}
|
||||||
|
|
||||||
|
func (atomicPlainHasher) Hash(value string) (string, error) {
|
||||||
|
return "hashed:" + value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (atomicPlainHasher) Compare(hashed string, value string) error {
|
||||||
|
if hashed == "hashed:"+value {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("hash mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
type atomicInviteRepoDecorator struct {
|
||||||
|
invite.Repository
|
||||||
|
afterGetAll func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *atomicInviteRepoDecorator) GetAll() []invite.Invite {
|
||||||
|
all := d.Repository.GetAll()
|
||||||
|
d.afterGetAll()
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
type atomicLookupBarrier struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
arrived int
|
||||||
|
parties int
|
||||||
|
open chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAtomicLookupBarrier(parties int) *atomicLookupBarrier {
|
||||||
|
return &atomicLookupBarrier{parties: parties, open: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *atomicLookupBarrier) wait() {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.arrived++
|
||||||
|
if b.arrived == b.parties {
|
||||||
|
close(b.open)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-b.open:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withLookupBarrier(parties int) func(db *gorm.DB, repo invite.Repository) invite.Repository {
|
||||||
|
barrier := newAtomicLookupBarrier(parties)
|
||||||
|
return func(db *gorm.DB, repo invite.Repository) invite.Repository {
|
||||||
|
return &atomicInviteRepoDecorator{Repository: repo, afterGetAll: barrier.wait}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type atomicAcceptFixture struct {
|
||||||
|
db *gorm.DB
|
||||||
|
router *chi.Mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func openRealSqlite(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.ToSlash(filepath.Join(t.TempDir(), "atomic.db"))
|
||||||
|
dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)"
|
||||||
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.Article{}, &models.User{}, &models.Invite{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if sqlDB, err := db.DB(); err == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAtomicAcceptFixture(t *testing.T, wrap func(db *gorm.DB, repo invite.Repository) invite.Repository) *atomicAcceptFixture {
|
||||||
|
t.Helper()
|
||||||
|
validator.InitValidator()
|
||||||
|
db := openRealSqlite(t)
|
||||||
|
users := persistence.NewUserRepository(db)
|
||||||
|
var invites invite.Repository = persistence.NewInviteRepository(db)
|
||||||
|
if wrap != nil {
|
||||||
|
invites = wrap(db, invites)
|
||||||
|
}
|
||||||
|
hasher := atomicPlainHasher{}
|
||||||
|
handler := auth.NewAcceptInviteHandler(
|
||||||
|
useCases.NewGetInviteUseCase(invites, hasher),
|
||||||
|
useCases.NewAcceptInviteUseCase(users, invites, hasher),
|
||||||
|
jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Post("/v1/auth/invite/accept", handler.AcceptInvite)
|
||||||
|
|
||||||
|
seedRealUser(t, db, models.User{Username: "marta", Password: "x", Email: "marta@renew.example", IsVerified: true})
|
||||||
|
seedRealInvite(t, db)
|
||||||
|
return &atomicAcceptFixture{db: db, router: router}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedRealUser(t *testing.T, db *gorm.DB, u models.User) models.User {
|
||||||
|
t.Helper()
|
||||||
|
if err := db.Create(&u).Error; err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedRealInvite(t *testing.T, db *gorm.DB) models.Invite {
|
||||||
|
t.Helper()
|
||||||
|
pending := models.Invite{
|
||||||
|
Email: acceptEmail,
|
||||||
|
HashedCode: "hashed:" + acceptCode,
|
||||||
|
ExpiresAt: time.Now().Add(48 * time.Hour),
|
||||||
|
InvitedBy: 1,
|
||||||
|
}
|
||||||
|
if err := db.Create(&pending).Error; err != nil {
|
||||||
|
t.Fatalf("seed invite: %v", err)
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *atomicAcceptFixture) accept(username string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/auth/invite/accept", bytes.NewBufferString(acceptBody(acceptCode, username, acceptPassword)))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
f.router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *atomicAcceptFixture) usersWithEmail(email string) int64 {
|
||||||
|
var count int64
|
||||||
|
f.db.Model(&models.User{}).Where("email = ?", email).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *atomicAcceptFixture) usersNamed(username string) int64 {
|
||||||
|
var count int64
|
||||||
|
f.db.Model(&models.User{}).Where("username = ?", username).Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *atomicAcceptFixture) storedInvite() models.Invite {
|
||||||
|
var stored models.Invite
|
||||||
|
f.db.First(&stored)
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *atomicAcceptFixture) acceptConcurrently(usernames ...string) []*httptest.ResponseRecorder {
|
||||||
|
recorders := make([]*httptest.ResponseRecorder, len(usernames))
|
||||||
|
start := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for index, username := range usernames {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
recorders[index] = f.accept(username)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
return recorders
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOneWinnerOneLoser(t *testing.T, f *atomicAcceptFixture, recorders []*httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
winners, losers := 0, 0
|
||||||
|
for _, rec := range recorders {
|
||||||
|
switch {
|
||||||
|
case rec.Code == http.StatusOK && jwtCookieOf(rec) != nil:
|
||||||
|
winners++
|
||||||
|
case rec.Code == http.StatusNotFound && rec.Body.String() == inviteInvalidBody && jwtCookieOf(rec) == nil:
|
||||||
|
losers++
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected response %d %q cookie=%v", rec.Code, rec.Body.String(), jwtCookieOf(rec))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if winners != 1 || losers != 1 {
|
||||||
|
t.Errorf("expected one winner and one loser, got %d and %d", winners, losers)
|
||||||
|
}
|
||||||
|
if count := f.usersWithEmail(acceptEmail); count != 1 {
|
||||||
|
t.Errorf("expected exactly one user for the invited email, got %d", count)
|
||||||
|
}
|
||||||
|
if f.storedInvite().AcceptedAt == nil {
|
||||||
|
t.Errorf("invite must have accepted_at set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptInvitationAtomic(t *testing.T) {
|
||||||
|
t.Run("Two concurrent accepts of one code create exactly one user", func(t *testing.T) {
|
||||||
|
f := newAtomicAcceptFixture(t, withLookupBarrier(2))
|
||||||
|
|
||||||
|
assertOneWinnerOneLoser(t, f, f.acceptConcurrently("lea", "lea2"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The concurrency test is repeatable", func(t *testing.T) {
|
||||||
|
for run := range atomicAcceptRuns {
|
||||||
|
t.Run(fmt.Sprintf("run %d", run), func(t *testing.T) {
|
||||||
|
f := newAtomicAcceptFixture(t, withLookupBarrier(2))
|
||||||
|
|
||||||
|
assertOneWinnerOneLoser(t, f, f.acceptConcurrently("lea", "lea2"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A used code loses at the conditional update, not at the earlier lookup", func(t *testing.T) {
|
||||||
|
f := newAtomicAcceptFixture(t, func(db *gorm.DB, repo invite.Repository) invite.Repository {
|
||||||
|
return &atomicInviteRepoDecorator{Repository: repo, afterGetAll: func() {
|
||||||
|
db.Exec("UPDATE invites SET accepted_at = ?", time.Now())
|
||||||
|
}}
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := f.accept("lea")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound || rec.Body.String() != inviteInvalidBody {
|
||||||
|
t.Errorf("expected 404 %q, got %d %q", inviteInvalidBody, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if jwtCookieOf(rec) != nil {
|
||||||
|
t.Errorf("no jwt cookie may be set")
|
||||||
|
}
|
||||||
|
if f.usersNamed("lea") != 0 {
|
||||||
|
t.Errorf("no user may be inserted for the losing accept")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A failed user insert leaves the invite pending", func(t *testing.T) {
|
||||||
|
f := newAtomicAcceptFixture(t, nil)
|
||||||
|
|
||||||
|
rec := f.accept("marta")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusConflict || rec.Body.String() != usernameTakenBody {
|
||||||
|
t.Errorf("expected 409 %q, got %d %q", usernameTakenBody, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if f.storedInvite().AcceptedAt != nil {
|
||||||
|
t.Errorf("invite must stay pending")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A user insert rejected by the database leaves the invite pending", func(t *testing.T) {
|
||||||
|
f := newAtomicAcceptFixture(t, nil)
|
||||||
|
seedRealUser(t, f.db, models.User{Username: "leam", Password: "x", Email: "someone@renew.example"})
|
||||||
|
if err := f.db.Exec("CREATE TRIGGER users_reject_insert BEFORE INSERT ON users WHEN NEW.username = 'lea' BEGIN SELECT RAISE(ABORT, 'insert refused'); END").Error; err != nil {
|
||||||
|
t.Fatalf("trigger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := f.accept("lea")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError || jwtCookieOf(rec) != nil {
|
||||||
|
t.Errorf("expected 500 without cookie, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if f.storedInvite().AcceptedAt != nil {
|
||||||
|
t.Errorf("invite must stay pending after the insert failed")
|
||||||
|
}
|
||||||
|
if f.usersNamed("lea") != 0 {
|
||||||
|
t.Errorf("no user lea may exist")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A failed invite update leaves no user behind", func(t *testing.T) {
|
||||||
|
f := newAtomicAcceptFixture(t, nil)
|
||||||
|
if err := f.db.Exec("CREATE TRIGGER invites_frozen BEFORE UPDATE ON invites BEGIN SELECT RAISE(ABORT, 'invites are frozen'); END").Error; err != nil {
|
||||||
|
t.Fatalf("trigger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := f.accept("lea")
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError || jwtCookieOf(rec) != nil {
|
||||||
|
t.Errorf("expected 500 without cookie, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if f.usersNamed("lea") != 0 {
|
||||||
|
t.Errorf("no user lea may exist")
|
||||||
|
}
|
||||||
|
if f.storedInvite().AcceptedAt != nil {
|
||||||
|
t.Errorf("invite must stay pending")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
|
|||||||
hasher: &stubHasher{},
|
hasher: &stubHasher{},
|
||||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||||
}
|
}
|
||||||
|
f.invites.users = f.users
|
||||||
getUser := useCases.NewGetUserUseCase(f.users)
|
getUser := useCases.NewGetUserUseCase(f.users)
|
||||||
acceptHandler := auth.NewAcceptInviteHandler(
|
acceptHandler := auth.NewAcceptInviteHandler(
|
||||||
useCases.NewGetInviteUseCase(f.invites, f.hasher),
|
useCases.NewGetInviteUseCase(f.invites, f.hasher),
|
||||||
@@ -420,7 +421,7 @@ func TestAcceptInvitationHttp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("A failure after the user is created leaves the invite pending and no user behind", func(t *testing.T) {
|
t.Run("A failure while accepting leaves the invite pending and no user behind", func(t *testing.T) {
|
||||||
f := newAcceptInviteFixture(t, 100)
|
f := newAcceptInviteFixture(t, 100)
|
||||||
stored := f.seedPending()
|
stored := f.seedPending()
|
||||||
f.invites.fail.failNext("MarkAccepted")
|
f.invites.fail.failNext("MarkAccepted")
|
||||||
@@ -496,6 +497,7 @@ func TestAcceptInvitationHttp(t *testing.T) {
|
|||||||
func TestAcceptInviteUseCase(t *testing.T) {
|
func TestAcceptInviteUseCase(t *testing.T) {
|
||||||
newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) {
|
newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) {
|
||||||
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
|
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
|
||||||
|
invites.users = users
|
||||||
return useCases.NewAcceptInviteUseCase(users, invites, hasher), users, invites, hasher
|
return useCases.NewAcceptInviteUseCase(users, invites, hasher), users, invites, hasher
|
||||||
}
|
}
|
||||||
command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}
|
command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}
|
||||||
@@ -558,6 +560,24 @@ func TestAcceptInviteUseCase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("an address that got an account because the invite was just accepted is invalid", func(t *testing.T) {
|
||||||
|
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
|
||||||
|
invites.users = users
|
||||||
|
stored := pendingInvite(acceptEmail)
|
||||||
|
stored.HashedCode = "hashed:" + acceptCode
|
||||||
|
seeded := invites.seed(stored)
|
||||||
|
racing := &atomicInviteRepoDecorator{Repository: invites, afterGetAll: func() {
|
||||||
|
acceptedAt := time.Now()
|
||||||
|
seeded.AcceptedAt = &acceptedAt
|
||||||
|
invites.invites[seeded.ID] = seeded
|
||||||
|
users.users[8] = user.User{ID: 8, Username: "lea2", Email: acceptEmail}
|
||||||
|
}}
|
||||||
|
uc := useCases.NewAcceptInviteUseCase(users, racing, hasher)
|
||||||
|
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) {
|
||||||
|
t.Errorf("expected ErrInviteInvalid, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("hash failure creates nothing", func(t *testing.T) {
|
t.Run("hash failure creates nothing", func(t *testing.T) {
|
||||||
uc, users, invites, hasher := newUseCase()
|
uc, users, invites, hasher := newUseCase()
|
||||||
stored := pendingInvite(acceptEmail)
|
stored := pendingInvite(acceptEmail)
|
||||||
@@ -580,16 +600,30 @@ func TestAcceptInviteUseCase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("rollback failure is joined with the accept failure", func(t *testing.T) {
|
t.Run("accept failure leaves no user and no delete call", func(t *testing.T) {
|
||||||
|
uc, users, invites, _ := newUseCase()
|
||||||
|
stored := pendingInvite(acceptEmail)
|
||||||
|
stored.HashedCode = "hashed:" + acceptCode
|
||||||
|
seeded := invites.seed(stored)
|
||||||
|
invites.fail.failNextWith("MarkAccepted", errors.New("mark down"))
|
||||||
|
users.fail.failNextWith("Delete", errors.New("delete down"))
|
||||||
|
_, err := uc.AcceptInvite(command)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "mark down") || strings.Contains(err.Error(), "delete down") {
|
||||||
|
t.Errorf("unexpected error %v", err)
|
||||||
|
}
|
||||||
|
if len(users.users) != 1 || invites.invites[seeded.ID].AcceptedAt != nil {
|
||||||
|
t.Errorf("expected no user and a pending invite")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("an invite that was taken meanwhile is invalid", func(t *testing.T) {
|
||||||
uc, users, invites, _ := newUseCase()
|
uc, users, invites, _ := newUseCase()
|
||||||
stored := pendingInvite(acceptEmail)
|
stored := pendingInvite(acceptEmail)
|
||||||
stored.HashedCode = "hashed:" + acceptCode
|
stored.HashedCode = "hashed:" + acceptCode
|
||||||
invites.seed(stored)
|
invites.seed(stored)
|
||||||
invites.fail.failNextWith("MarkAccepted", errors.New("mark down"))
|
invites.fail.failNextWith("MarkAccepted", invite.ErrInviteNotFound)
|
||||||
users.fail.failNextWith("Delete", errors.New("delete down"))
|
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) || len(users.users) != 1 {
|
||||||
_, err := uc.AcceptInvite(command)
|
t.Errorf("expected ErrInviteInvalid, got %v", err)
|
||||||
if err == nil || !strings.Contains(err.Error(), "mark down") || !strings.Contains(err.Error(), "delete down") {
|
|
||||||
t.Errorf("unexpected error %v", err)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type inviteFakeRepo struct {
|
|||||||
invites map[uint32]invite.Invite
|
invites map[uint32]invite.Invite
|
||||||
nextID uint32
|
nextID uint32
|
||||||
fail failSwitch
|
fail failSwitch
|
||||||
|
users *mockUserRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
func newInviteFakeRepo() *inviteFakeRepo {
|
func newInviteFakeRepo() *inviteFakeRepo {
|
||||||
@@ -75,17 +76,21 @@ func (m *inviteFakeRepo) Delete(id uint32) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *inviteFakeRepo) MarkAccepted(id uint32, at time.Time) error {
|
func (m *inviteFakeRepo) AcceptWithUser(id uint32, at time.Time, newUser user.User) (user.User, error) {
|
||||||
if err := m.fail.trigger("MarkAccepted"); err != nil {
|
if err := m.fail.trigger("MarkAccepted"); err != nil {
|
||||||
return err
|
return user.User{}, err
|
||||||
}
|
}
|
||||||
stored, ok := m.invites[id]
|
stored, ok := m.invites[id]
|
||||||
if !ok {
|
if !ok || stored.AcceptedAt != nil {
|
||||||
return invite.ErrInviteNotFound
|
return user.User{}, invite.ErrInviteNotFound
|
||||||
|
}
|
||||||
|
created, err := m.users.Create(newUser)
|
||||||
|
if err != nil {
|
||||||
|
return user.User{}, err
|
||||||
}
|
}
|
||||||
stored.AcceptedAt = &at
|
stored.AcceptedAt = &at
|
||||||
m.invites[id] = stored
|
m.invites[id] = stored
|
||||||
return nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ invite.Repository = &inviteFakeRepo{}
|
var _ invite.Repository = &inviteFakeRepo{}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inviteEmailCase struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadInviteEmailCases(t *testing.T) []inviteEmailCase {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile("testdata/invite_emails.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
var cases []inviteEmailCase
|
||||||
|
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||||
|
t.Fatalf("decode fixture: %v", err)
|
||||||
|
}
|
||||||
|
return cases
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteEmailValidationHttp(t *testing.T) {
|
||||||
|
t.Run("The same address gets the same verdict on both sides", func(t *testing.T) {
|
||||||
|
for _, tc := range loadInviteEmailCases(t) {
|
||||||
|
t.Run("["+tc.Email+"]", func(t *testing.T) {
|
||||||
|
f := newInviteFixture(t, 100)
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": tc.Email})
|
||||||
|
|
||||||
|
rec := f.post(t, string(body))
|
||||||
|
|
||||||
|
if tc.Valid && rec.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !tc.Valid && rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A rejected address stores nothing and sends nothing", func(t *testing.T) {
|
||||||
|
f := newInviteFixture(t, 100)
|
||||||
|
|
||||||
|
rec := f.post(t, `{"email": "user@localhost"}`)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(f.invites.invites) != 0 || len(f.mail.SentEmails) != 0 {
|
||||||
|
t.Errorf("nothing may be stored or sent, got %d invites and %d emails", len(f.invites.invites), len(f.mail.SentEmails))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/handlers/auth"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resetMailFailureHasher struct{}
|
||||||
|
|
||||||
|
func (resetMailFailureHasher) Hash(value string) (string, error) {
|
||||||
|
return "hash-of-" + value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (resetMailFailureHasher) Compare(hashed string, value string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureStandardLog(t *testing.T) *bytes.Buffer {
|
||||||
|
t.Helper()
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
previousOutput, previousFlags := log.Writer(), log.Flags()
|
||||||
|
log.SetOutput(&buffer)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
log.SetOutput(previousOutput)
|
||||||
|
log.SetFlags(previousFlags)
|
||||||
|
})
|
||||||
|
return &buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetRequestWith(mailRepo *mockMailRepo, email string) (*httptest.ResponseRecorder, string) {
|
||||||
|
userRepo := newMockUserRepo()
|
||||||
|
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
|
||||||
|
userRepo.nextID = 1
|
||||||
|
hasher := resetMailFailureHasher{}
|
||||||
|
handler := auth.NewPasswordResetHandler(
|
||||||
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
|
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
||||||
|
)
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Post("/v1/auth/password-reset", handler.RequestPasswordReset)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/auth/password-reset", bytes.NewBufferString(`{"email": "`+email+`"}`))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(recorder, req)
|
||||||
|
return recorder, userRepo.lastUpdatePasswordResetCodeCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestPasswordResetMailFailure(t *testing.T) {
|
||||||
|
validator.InitValidator()
|
||||||
|
sendFailure := errors.New("smtp: connection refused")
|
||||||
|
|
||||||
|
failingMail := func() *mockMailRepo {
|
||||||
|
mailRepo := newMockMailRepo()
|
||||||
|
mailRepo.fail.failNextWith("Send", sendFailure)
|
||||||
|
return mailRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("A failed send is logged and the response is unchanged", func(t *testing.T) {
|
||||||
|
logged := captureStandardLog(t)
|
||||||
|
|
||||||
|
recorder, _ := resetRequestWith(failingMail(), "alice@example.com")
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK || strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody {
|
||||||
|
t.Errorf("expected the generic 200, got %d %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if jwtCookieOf(recorder) != nil {
|
||||||
|
t.Errorf("no jwt cookie may be set")
|
||||||
|
}
|
||||||
|
line := logged.String()
|
||||||
|
if !strings.Contains(line, "Send") || !strings.Contains(line, "smtp: connection refused") {
|
||||||
|
t.Errorf("log must name the failed send and its error, got %q", line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The response is identical for a failed send, a successful send and an unknown email", func(t *testing.T) {
|
||||||
|
captureStandardLog(t)
|
||||||
|
|
||||||
|
failed, _ := resetRequestWith(failingMail(), "alice@example.com")
|
||||||
|
sent, _ := resetRequestWith(newMockMailRepo(), "alice@example.com")
|
||||||
|
unknown, _ := resetRequestWith(newMockMailRepo(), "nobody@example.com")
|
||||||
|
|
||||||
|
for name, other := range map[string]*httptest.ResponseRecorder{"successful send": sent, "unknown email": unknown} {
|
||||||
|
if failed.Code != other.Code || failed.Body.String() != other.Body.String() {
|
||||||
|
t.Errorf("%s differs: %d %q vs %d %q", name, failed.Code, failed.Body.String(), other.Code, other.Body.String())
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(failed.Header(), other.Header()) {
|
||||||
|
t.Errorf("%s headers differ: %v vs %v", name, failed.Header(), other.Header())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The log does not contain the raw reset code", func(t *testing.T) {
|
||||||
|
logged := captureStandardLog(t)
|
||||||
|
|
||||||
|
_, storedHash := resetRequestWith(failingMail(), "alice@example.com")
|
||||||
|
|
||||||
|
rawCode := strings.TrimPrefix(storedHash, "hash-of-")
|
||||||
|
if rawCode == "" || strings.Contains(logged.String(), rawCode) || strings.Contains(logged.String(), storedHash) {
|
||||||
|
t.Errorf("log must contain neither the raw code nor its hash, got %q", logged.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProductionSQLiteDSNWaitsForTheWriteLock(t *testing.T) {
|
||||||
|
t.Run("A second writer waits for the first instead of failing with a locked database", func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "busy.db")
|
||||||
|
open := func() *gorm.DB {
|
||||||
|
db, err := gorm.Open(sqlite.Open(api.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if sqlDB, err := db.DB(); err == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
first, second := open(), open()
|
||||||
|
if err := first.AutoMigrate(&models.User{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
tx := first.Begin()
|
||||||
|
if err := tx.Create(&models.User{Username: "a", Password: "x", Email: "a@x.example"}).Error; err != nil {
|
||||||
|
t.Fatalf("first write: %v", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
tx.Commit()
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := second.Create(&models.User{Username: "b", Password: "x", Email: "b@x.example"}).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("second writer must wait for the lock, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Vendored
+17
@@ -0,0 +1,17 @@
|
|||||||
|
[
|
||||||
|
{ "email": "sam.oliveira@renew.example", "valid": true },
|
||||||
|
{ "email": "Sam.Oliveira@Renew.example", "valid": true },
|
||||||
|
{ "email": "sam+news@renew.example", "valid": true },
|
||||||
|
{ "email": "sam@mail.renew.example", "valid": true },
|
||||||
|
{ "email": "sam@-renew.example", "valid": true },
|
||||||
|
{ "email": "user@localhost", "valid": false },
|
||||||
|
{ "email": "\"sam smith\"@renew.example", "valid": false },
|
||||||
|
{ "email": "sam@renew", "valid": false },
|
||||||
|
{ "email": "sam@.renew.example", "valid": false },
|
||||||
|
{ "email": "sam@renew..example", "valid": false },
|
||||||
|
{ "email": "sam@renew.example.", "valid": false },
|
||||||
|
{ "email": "sam @renew.example", "valid": false },
|
||||||
|
{ "email": "@renew.example", "valid": false },
|
||||||
|
{ "email": "not-an-email", "valid": false },
|
||||||
|
{ "email": "", "valid": false }
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserRepositoryDelete(t *testing.T) {
|
||||||
|
t.Run("Deleting a user removes only that row", func(t *testing.T) {
|
||||||
|
db := openRealSqlite(t)
|
||||||
|
for _, name := range []string{"one", "two", "three"} {
|
||||||
|
seedRealUser(t, db, models.User{Username: name, Password: "x", Email: name + "@renew.example"})
|
||||||
|
}
|
||||||
|
repo := persistence.NewUserRepository(db)
|
||||||
|
|
||||||
|
if err := repo.Delete(2); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.Get(2); !errors.Is(err, user.ErrUserNotFound) {
|
||||||
|
t.Errorf("user 2 must be gone, got %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []uint32{1, 3} {
|
||||||
|
if _, err := repo.Get(id); err != nil {
|
||||||
|
t.Errorf("user %d must remain, got %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Deleting a missing user reports not found", func(t *testing.T) {
|
||||||
|
repo := persistence.NewUserRepository(openRealSqlite(t))
|
||||||
|
|
||||||
|
if err := repo.Delete(99); !errors.Is(err, user.ErrUserNotFound) {
|
||||||
|
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
@@ -68,12 +68,33 @@ const navUi = computed(() => ({
|
|||||||
const loggingOut = ref(false)
|
const loggingOut = ref(false)
|
||||||
const sidebarOpen = ref(false)
|
const sidebarOpen = ref(false)
|
||||||
const accountSheetOpen = ref(false)
|
const accountSheetOpen = ref(false)
|
||||||
|
const NAVBAR_TOGGLE_SELECTOR = '[data-slot="toggle"]'
|
||||||
|
let sheetOpener: HTMLElement | undefined
|
||||||
|
|
||||||
function openAccountSheet() {
|
function openAccountSheet(opener: HTMLElement | undefined) {
|
||||||
|
sheetOpener = opener
|
||||||
sidebarOpen.value = false
|
sidebarOpen.value = false
|
||||||
accountSheetOpen.value = true
|
accountSheetOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restoreFocusAfterSheet() {
|
||||||
|
await nextTick()
|
||||||
|
if (loggingOut.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = sheetOpener?.isConnected
|
||||||
|
? sheetOpener
|
||||||
|
: document.querySelector<HTMLElement>(NAVBAR_TOGGLE_SELECTOR)
|
||||||
|
sheetOpener = undefined
|
||||||
|
target?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(accountSheetOpen, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
restoreFocusAfterSheet()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const showBoot = computed(() => session.status === 'checking' && !props.renderWhileChecking)
|
const showBoot = computed(() => session.status === 'checking' && !props.renderWhileChecking)
|
||||||
|
|
||||||
function onRetry() {
|
function onRetry() {
|
||||||
@@ -129,6 +150,7 @@ async function onLogout() {
|
|||||||
:display-name="session.session?.displayName"
|
:display-name="session.session?.displayName"
|
||||||
:email="session.session?.email"
|
:email="session.session?.email"
|
||||||
:collapsed="sidebarCollapsed"
|
:collapsed="sidebarCollapsed"
|
||||||
|
:sheet-open="accountSheetOpen"
|
||||||
@logout="onLogout"
|
@logout="onLogout"
|
||||||
@open-sheet="openAccountSheet"
|
@open-sheet="openAccountSheet"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,16 +10,18 @@ const props = defineProps<{
|
|||||||
displayName?: string
|
displayName?: string
|
||||||
email?: string
|
email?: string
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
|
sheetOpen?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
logout: []
|
logout: []
|
||||||
'open-sheet': []
|
'open-sheet': [chip: HTMLElement | undefined]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
|
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
|
||||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
const chip = ref<{ $el: HTMLElement } | null>(null)
|
||||||
|
|
||||||
const chipActive = computed(() => open.value || onProfile.value)
|
const chipActive = computed(() => open.value || onProfile.value)
|
||||||
|
|
||||||
@@ -41,10 +43,12 @@ function onUpdateOpen(value: boolean) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (value) {
|
if (value) {
|
||||||
emit('open-sheet')
|
emit('open-sheet', chip.value?.$el)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chipExpanded = computed(() => (isDesktop.value ? open.value : props.sheetOpen === true))
|
||||||
|
|
||||||
const items = computed(() => [
|
const items = computed(() => [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -89,10 +93,12 @@ const items = computed(() => [
|
|||||||
@update:open="onUpdateOpen"
|
@update:open="onUpdateOpen"
|
||||||
>
|
>
|
||||||
<UButton
|
<UButton
|
||||||
|
ref="chip"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
block
|
block
|
||||||
title="Account"
|
title="Account"
|
||||||
|
:aria-expanded="chipExpanded"
|
||||||
:class="chipClass"
|
:class="chipClass"
|
||||||
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
|
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
|
||||||
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current' }"
|
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current' }"
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||||
|
import router from '@/router'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import DashboardShell from '../DashboardShell.vue'
|
||||||
|
import { MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useApi: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let mountedShell: VueWrapper | null = null
|
||||||
|
|
||||||
|
async function mountMobileShell() {
|
||||||
|
localStorage.clear()
|
||||||
|
setViewportWidth(MOBILE_WIDTH)
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.mocked(useApi).mockReturnValue({
|
||||||
|
getMe: vi.fn().mockResolvedValue({
|
||||||
|
verified: true,
|
||||||
|
username: 'marta',
|
||||||
|
email: 'marta@renew.example',
|
||||||
|
displayName: '',
|
||||||
|
}),
|
||||||
|
request: vi.fn().mockResolvedValue(undefined),
|
||||||
|
})
|
||||||
|
await router.push('/home')
|
||||||
|
mountedShell = mount(DashboardShell, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { plugins: [router, uiPlugin] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawerChip() {
|
||||||
|
return document.body.querySelector<HTMLButtonElement>('[role="dialog"] button[aria-haspopup]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sheet() {
|
||||||
|
return document.body.querySelector('[role="menu"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuItem(label: string) {
|
||||||
|
return Array.from(
|
||||||
|
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
|
||||||
|
).find((item) => item.textContent?.trim() === label)!
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDrawer() {
|
||||||
|
document.body.querySelector<HTMLButtonElement>('[data-slot="toggle"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSheetFromChip() {
|
||||||
|
await openDrawer()
|
||||||
|
drawerChip()!.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closingActions = [
|
||||||
|
{
|
||||||
|
name: 'pressing Escape',
|
||||||
|
close: async () => {
|
||||||
|
document.activeElement?.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tapping the backdrop',
|
||||||
|
close: async () => {
|
||||||
|
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
|
||||||
|
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||||||
|
overlay.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||||
|
overlay.click()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'activating "Profile"',
|
||||||
|
close: async () => {
|
||||||
|
menuItem('Profile').click()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
mountedShell?.unmount()
|
||||||
|
mountedShell = null
|
||||||
|
await router.push('/login')
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Mobile account sheet focus', () => {
|
||||||
|
it.each(closingActions)(
|
||||||
|
'returns focus to the navbar toggle when the sheet closes by $name',
|
||||||
|
async ({ close }) => {
|
||||||
|
await mountMobileShell()
|
||||||
|
await openSheetFromChip()
|
||||||
|
expect(sheet()).not.toBeNull()
|
||||||
|
|
||||||
|
await close()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(sheet()).toBeNull()
|
||||||
|
expect(document.activeElement).toBe(document.body.querySelector('[data-slot="toggle"]'))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('logs out from the sheet without throwing', async () => {
|
||||||
|
const unhandled = vi.fn()
|
||||||
|
process.on('unhandledRejection', unhandled)
|
||||||
|
await mountMobileShell()
|
||||||
|
await openSheetFromChip()
|
||||||
|
|
||||||
|
menuItem('Log out').click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.path).toBe('/login')
|
||||||
|
expect(unhandled).not.toHaveBeenCalled()
|
||||||
|
process.off('unhandledRejection', unhandled)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not put focus on the chip or the toggle when the sheet was never opened', async () => {
|
||||||
|
await mountMobileShell()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(document.body)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||||
|
import inviteEmailCases from '../../../../test/testdata/invite_emails.json'
|
||||||
|
import InviteUserModal from '../InviteUserModal.vue'
|
||||||
|
|
||||||
|
const INVALID_EMAIL = 'Enter a full email address, like name@example.com.'
|
||||||
|
|
||||||
|
const inviteUser = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/composables/useUsers', () => ({
|
||||||
|
useUsers: () => ({ inviteUser }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
function emailInput() {
|
||||||
|
return document.body.querySelector<HTMLInputElement>('input[placeholder="name@example.com"]')!
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialogText() {
|
||||||
|
return document.body.querySelector('[role="dialog"]')?.textContent ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatch(element: HTMLElement, type: string) {
|
||||||
|
element.dispatchEvent(new Event(type, { bubbles: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountOpenModal() {
|
||||||
|
wrapper = mount(InviteUserModal, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { open: true },
|
||||||
|
global: { plugins: [uiPlugin] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickSend() {
|
||||||
|
const send = [...document.body.querySelectorAll('button')].find(
|
||||||
|
(button) => button.textContent?.trim() === 'Send invite',
|
||||||
|
)!
|
||||||
|
send.click()
|
||||||
|
return flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
inviteUser.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Invite modal email rule', () => {
|
||||||
|
it.each(inviteEmailCases)('gives "$email" the shared verdict (valid: $valid)', async (row) => {
|
||||||
|
inviteUser.mockResolvedValue({ id: 1 })
|
||||||
|
await mountOpenModal()
|
||||||
|
|
||||||
|
const input = emailInput()
|
||||||
|
input.value = row.email
|
||||||
|
dispatch(input, 'input')
|
||||||
|
dispatch(input, 'blur')
|
||||||
|
await flushPromises()
|
||||||
|
await clickSend()
|
||||||
|
|
||||||
|
if (row.valid) {
|
||||||
|
expect(dialogText()).not.toContain(INVALID_EMAIL)
|
||||||
|
expect(inviteUser).toHaveBeenCalledTimes(1)
|
||||||
|
} else {
|
||||||
|
expect(dialogText()).toContain(INVALID_EMAIL)
|
||||||
|
expect(inviteUser).not.toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,6 +13,7 @@ interface MenuProps {
|
|||||||
displayName?: string
|
displayName?: string
|
||||||
email?: string
|
email?: string
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
|
sheetOpen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseProps: MenuProps = {
|
const baseProps: MenuProps = {
|
||||||
@@ -302,3 +303,31 @@ describe('Below lg the chip hands the menu to the bottom sheet', () => {
|
|||||||
expect(menu()).toBeNull()
|
expect(menu()).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Account menu: the chip on mobile follows the sheet', () => {
|
||||||
|
it('reports the sheet through aria-expanded and hands its own element to open-sheet', async () => {
|
||||||
|
setViewportWidth(MOBILE_WIDTH)
|
||||||
|
const wrapper = await mountMenu({ sheetOpen: false })
|
||||||
|
|
||||||
|
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||||
|
|
||||||
|
await openMenu()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('open-sheet')).toEqual([[chip()]])
|
||||||
|
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||||
|
expect(menu()).toBeNull()
|
||||||
|
|
||||||
|
await wrapper.setProps({ sheetOpen: true })
|
||||||
|
expect(chip().getAttribute('aria-expanded')).toBe('true')
|
||||||
|
|
||||||
|
await wrapper.setProps({ sheetOpen: false })
|
||||||
|
expect(chip().getAttribute('aria-expanded')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not add the sand fill while the sheet is open', async () => {
|
||||||
|
setViewportWidth(MOBILE_WIDTH)
|
||||||
|
await mountMenu({ sheetOpen: true })
|
||||||
|
|
||||||
|
expect(chip().classList.contains('bg-secondary-200')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,7 +9,7 @@ import { ApiError, useApi } from '@/composables/useApi'
|
|||||||
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
|
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
|
||||||
type InviteState = 'checking' | 'form' | 'invalid' | 'expired' | 'success'
|
type InviteState = 'checking' | 'form' | 'invalid' | 'expired' | 'failed' | 'success'
|
||||||
|
|
||||||
const EMAIL_HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
|
const EMAIL_HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
|
||||||
const USERNAME_TAKEN_MESSAGE = 'That username is taken. Try another.'
|
const USERNAME_TAKEN_MESSAGE = 'That username is taken. Try another.'
|
||||||
@@ -100,6 +100,7 @@ function applyDeadLinkStatus(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadInvite() {
|
async function loadInvite() {
|
||||||
|
state.value = 'checking'
|
||||||
try {
|
try {
|
||||||
const invite = await api.request<{ email: string }>(
|
const invite = await api.request<{ email: string }>(
|
||||||
`/v1/auth/invite?c=${encodeURIComponent(code)}`,
|
`/v1/auth/invite?c=${encodeURIComponent(code)}`,
|
||||||
@@ -108,7 +109,9 @@ async function loadInvite() {
|
|||||||
inviteEmail.value = invite.email
|
inviteEmail.value = invite.email
|
||||||
state.value = 'form'
|
state.value = 'form'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
applyDeadLinkStatus(error)
|
if (!applyDeadLinkStatus(error)) {
|
||||||
|
state.value = 'failed'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +206,28 @@ onMounted(() => {
|
|||||||
<p class="text-base text-muted">This only takes a moment.</p>
|
<p class="text-base text-muted">This only takes a moment.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="state === 'failed'" class="flex flex-col gap-4.5">
|
||||||
|
<UAlert
|
||||||
|
color="error"
|
||||||
|
icon="i-lucide-circle-x"
|
||||||
|
title="Couldn't check your invite"
|
||||||
|
description="Something went wrong on the server. Try again in a moment."
|
||||||
|
variant="soft"
|
||||||
|
:close="false"
|
||||||
|
role="alert"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
block
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:size="controlSize"
|
||||||
|
:ui="{ base: controlClass }"
|
||||||
|
@click="loadInvite"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ResultPanel
|
<ResultPanel
|
||||||
v-else-if="state === 'invalid'"
|
v-else-if="state === 'invalid'"
|
||||||
tone="error"
|
tone="error"
|
||||||
|
|||||||
@@ -368,3 +368,95 @@ describe('Success', () => {
|
|||||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Failed lookup', () => {
|
||||||
|
const lookupKey = 'GET /v1/auth/invite?c=abc123'
|
||||||
|
const alertTitle = "Couldn't check your invite"
|
||||||
|
const alertText = 'Something went wrong on the server. Try again in a moment.'
|
||||||
|
|
||||||
|
function tryAgainButton(wrapper: Wrapper) {
|
||||||
|
return wrapper.findAll('button').find((button) => button.text() === 'Try again')
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectFailedLookup(wrapper: Wrapper) {
|
||||||
|
expect(wrapper.text()).toContain(alertTitle)
|
||||||
|
expect(wrapper.text()).toContain(alertText)
|
||||||
|
expect(tryAgainButton(wrapper)).toBeTruthy()
|
||||||
|
expect(wrapper.text()).not.toContain('Checking your invite…')
|
||||||
|
expect(wrapper.find('form').exists()).toBe(false)
|
||||||
|
expect(emailInput(wrapper).exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).not.toContain("This invite link doesn't work")
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ name: 'answers 500', handler: () => mockResponse(500) },
|
||||||
|
{ name: 'answers 502', handler: () => mockResponse(502) },
|
||||||
|
{
|
||||||
|
name: 'fails with a network error',
|
||||||
|
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||||
|
},
|
||||||
|
])('shows the alert and a Try again button when the lookup $name', async ({ handler }) => {
|
||||||
|
const { wrapper } = await mountAccept('/invite/accept?c=abc123', { [lookupKey]: handler })
|
||||||
|
|
||||||
|
expectFailedLookup(wrapper)
|
||||||
|
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('looks the invite up again when Try again is activated', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=abc123', {
|
||||||
|
[lookupKey]: () => {
|
||||||
|
calls += 1
|
||||||
|
return calls === 1
|
||||||
|
? mockResponse(500)
|
||||||
|
: new Promise<Response>((resolve) =>
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
resolve(
|
||||||
|
mockResponse(200, { email: 'lea.martin@renew.example' }),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await tryAgainButton(wrapper)!.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Checking your invite…')
|
||||||
|
expect(wrapper.text()).not.toContain(alertTitle)
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||||
|
await flushPromises()
|
||||||
|
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe(
|
||||||
|
'lea.martin@renew.example',
|
||||||
|
)
|
||||||
|
expect(wrapper.find('h1').text()).toBe('Accept your invite')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the alert again with an enabled button when the lookup fails again', async () => {
|
||||||
|
const { wrapper, fetchMock } = await mountAccept('/invite/accept?c=abc123', {
|
||||||
|
[lookupKey]: () => mockResponse(500),
|
||||||
|
})
|
||||||
|
|
||||||
|
await tryAgainButton(wrapper)!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||||
|
expectFailedLookup(wrapper)
|
||||||
|
expect(tryAgainButton(wrapper)!.attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ status: 404, text: "This invite link doesn't work" },
|
||||||
|
{ status: 410, text: 'This invite has expired' },
|
||||||
|
])('keeps the design state when the lookup answers $status', async ({ status, text }) => {
|
||||||
|
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
|
||||||
|
[lookupKey]: () => mockResponse(status),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain(text)
|
||||||
|
expect(wrapper.text()).not.toContain(alertTitle)
|
||||||
|
expect(tryAgainButton(wrapper)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1632,6 +1632,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
...acceptInviteWith({ status: 500 }),
|
...acceptInviteWith({ status: 500 }),
|
||||||
steps: submitAcceptInvite('lea', 'limewash2026'),
|
steps: submitAcceptInvite('lea', 'limewash2026'),
|
||||||
},
|
},
|
||||||
|
'Failed lookup (500 or network)': {
|
||||||
|
path: acceptInvitePath,
|
||||||
|
api: { 'GET /v1/auth/invite': { status: 500 } },
|
||||||
|
exception: {
|
||||||
|
maxPercent: 100,
|
||||||
|
reason: 'State approved by the user with no board frame in docs/design; nothing to diff against',
|
||||||
|
approvedBy: 'user',
|
||||||
|
},
|
||||||
|
},
|
||||||
Success: {
|
Success: {
|
||||||
...acceptInviteWith({ status: 200, body: { message: 'ok' } }),
|
...acceptInviteWith({ status: 200, body: { message: 'ok' } }),
|
||||||
paused: true,
|
paused: true,
|
||||||
|
|||||||
Reference in new issue
Block a user