refac: hardening pass with multiple overhauls

This commit is contained in:
fsylvain committed 2026-09-29 10:55:23 +02:00
1 parent b0f7b7892b
commit da36c78bc0
28 files changed
+1375 -39

No files matched your search

+8 -1
View File
@@ -12,6 +12,7 @@ import (
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"fmt"
"os"
"path/filepath"
@@ -53,11 +54,17 @@ type Repositories struct {
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 {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
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 {
panic("Unable to open the database: " + err.Error())
}
+1 -1
View File
@@ -19,7 +19,7 @@ type InviteHandler struct {
}
type CreateInviteRequest struct {
Email string `json:"email" validate:"required,email"`
Email string `json:"email" validate:"required,inviteemail"`
}
type inviteResponse struct {
+14 -1
View File
@@ -1,9 +1,22 @@
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
func InitValidator() {
Validate = validator.New()
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
}
func isInviteEmail(field validator.FieldLevel) bool {
return inviteEmailPattern.MatchString(field.Field().String())
}
+7
View File
@@ -21,6 +21,13 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
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
```
+3 -2
View File
@@ -132,10 +132,11 @@ Feature: An invited person accepts their invitation and gets a verified account
Then the response status is 200
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
Given a pending invite and the invite repository set to fail marking it accepted
Scenario: A failure while accepting rolls back and leaves the invite pending and no user behind
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"
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
Scenario: The accept endpoints are rate-limited
+1 -2
View File
@@ -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.
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.
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.
## Regression
@@ -36,4 +36,3 @@ Package `test` (black-box). Reuse `mockUserRepo`, `stubHasher`, `mockMailRepo`,
## 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).
- Concurrency: two simultaneous accepts of one code create one user (not exercisable with the in-memory fakes).
+215
View File
@@ -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
+63
View File
@@ -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
import "time"
import (
"RenewCMS/internal/domain/user"
"time"
)
type Repository interface {
Create(invite Invite) (Invite, error)
GetAll() []Invite
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 (
domainInvite "RenewCMS/internal/domain/invite"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"time"
@@ -44,9 +45,22 @@ func (i *InviteRepository) Delete(id uint32) error {
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
}
func (i *InviteRepository) MarkAccepted(id uint32, acceptedAt time.Time) error {
result := i.db.Model(&entity.Invite{}).Where("id = ?", id).Update("accepted_at", acceptedAt)
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
func (i *InviteRepository) AcceptWithUser(inviteID uint32, acceptedAt time.Time, newUser domainUser.User) (domainUser.User, error) {
createdModel := mappers.UserToModel(newUser)
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{}
@@ -49,7 +49,7 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, 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)
}
@@ -34,6 +34,9 @@ func (a *AcceptInviteUseCase) AcceptInvite(command AcceptInviteCommand) (user.Us
}
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
}
@@ -46,20 +49,16 @@ func (a *AcceptInviteUseCase) AcceptInvite(command AcceptInviteCommand) (user.Us
return user.User{}, err
}
createdUser, err := a.userRepository.Create(user.User{
createdUser, err := a.inviteRepository.AcceptWithUser(pending.ID, now, user.User{
Username: command.Username,
Password: hashedPassword,
Email: pending.Email,
IsVerified: true,
})
if err != nil {
return user.User{}, err
if errors.Is(err, invite.ErrInviteNotFound) {
return user.User{}, invite.ErrInviteInvalid
}
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)
}
if err != nil {
return user.User{}, err
}
+307
View File
@@ -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")
}
})
}
+41 -7
View File
@@ -52,6 +52,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
}
f.invites.users = f.users
getUser := useCases.NewGetUserUseCase(f.users)
acceptHandler := auth.NewAcceptInviteHandler(
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)
stored := f.seedPending()
f.invites.fail.failNext("MarkAccepted")
@@ -496,6 +497,7 @@ func TestAcceptInvitationHttp(t *testing.T) {
func TestAcceptInviteUseCase(t *testing.T) {
newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) {
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
invites.users = users
return useCases.NewAcceptInviteUseCase(users, invites, hasher), users, invites, hasher
}
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) {
uc, users, invites, hasher := newUseCase()
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()
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
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)
invites.fail.failNextWith("MarkAccepted", invite.ErrInviteNotFound)
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) || len(users.users) != 1 {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
}
+10 -5
View File
@@ -31,6 +31,7 @@ type inviteFakeRepo struct {
invites map[uint32]invite.Invite
nextID uint32
fail failSwitch
users *mockUserRepo
}
func newInviteFakeRepo() *inviteFakeRepo {
@@ -75,17 +76,21 @@ func (m *inviteFakeRepo) Delete(id uint32) error {
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 {
return err
return user.User{}, err
}
stored, ok := m.invites[id]
if !ok {
return invite.ErrInviteNotFound
if !ok || stored.AcceptedAt != nil {
return user.User{}, invite.ErrInviteNotFound
}
created, err := m.users.Create(newUser)
if err != nil {
return user.User{}, err
}
stored.AcceptedAt = &at
m.invites[id] = stored
return nil
return created, nil
}
var _ invite.Repository = &inviteFakeRepo{}
+59
View File
@@ -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())
}
})
}
+49
View File
@@ -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)
}
})
}
+17
View File
@@ -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 }
]
+40
View File
@@ -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)
}
})
}
+24 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { useApi } from '@/composables/useApi'
@@ -68,12 +68,33 @@ const navUi = computed(() => ({
const loggingOut = ref(false)
const sidebarOpen = 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
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)
function onRetry() {
@@ -129,6 +150,7 @@ async function onLogout() {
:display-name="session.session?.displayName"
:email="session.session?.email"
:collapsed="sidebarCollapsed"
:sheet-open="accountSheetOpen"
@logout="onLogout"
@open-sheet="openAccountSheet"
/>
+8 -2
View File
@@ -10,16 +10,18 @@ const props = defineProps<{
displayName?: string
email?: string
collapsed: boolean
sheetOpen?: boolean
}>()
const emit = defineEmits<{
logout: []
'open-sheet': []
'open-sheet': [chip: HTMLElement | undefined]
}>()
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const open = ref(false)
const chip = ref<{ $el: HTMLElement } | null>(null)
const chipActive = computed(() => open.value || onProfile.value)
@@ -41,10 +43,12 @@ function onUpdateOpen(value: boolean) {
return
}
if (value) {
emit('open-sheet')
emit('open-sheet', chip.value?.$el)
}
}
const chipExpanded = computed(() => (isDesktop.value ? open.value : props.sheetOpen === true))
const items = computed(() => [
[
{
@@ -89,10 +93,12 @@ const items = computed(() => [
@update:open="onUpdateOpen"
>
<UButton
ref="chip"
color="neutral"
variant="ghost"
block
title="Account"
:aria-expanded="chipExpanded"
:class="chipClass"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
: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
email?: string
collapsed: boolean
sheetOpen?: boolean
}
const baseProps: MenuProps = {
@@ -302,3 +303,31 @@ describe('Below lg the chip hands the menu to the bottom sheet', () => {
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)
})
})
+27 -2
View File
@@ -9,7 +9,7 @@ import { ApiError, useApi } from '@/composables/useApi'
import { VERIFIED_REDIRECT_DELAY_MS, wait } from '@/lib/verifiedRedirect'
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 USERNAME_TAKEN_MESSAGE = 'That username is taken. Try another.'
@@ -100,6 +100,7 @@ function applyDeadLinkStatus(error: unknown): boolean {
}
async function loadInvite() {
state.value = 'checking'
try {
const invite = await api.request<{ email: string }>(
`/v1/auth/invite?c=${encodeURIComponent(code)}`,
@@ -108,7 +109,9 @@ async function loadInvite() {
inviteEmail.value = invite.email
state.value = 'form'
} 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>
</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
v-else-if="state === 'invalid'"
tone="error"
@@ -368,3 +368,95 @@ describe('Success', () => {
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()
})
})
+9
View File
@@ -1632,6 +1632,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
...acceptInviteWith({ status: 500 }),
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: {
...acceptInviteWith({ status: 200, body: { message: 'ok' } }),
paused: true,