refac: GET /v1/auth/registration

This commit is contained in:
fsylvain committed 2026-09-25 15:54:42 +02:00
1 parent 61828783a1
commit 2a64991916
6 files changed
+329

No files matched your search

+6
View File
@@ -129,6 +129,12 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(message)
}
func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request) {
body, _ := json.Marshal(map[string]bool{"open": h.CreateUseCase.IsRegistrationOpen()})
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
func (h *LoginHandler) Logout(w http.ResponseWriter, _ *http.Request) {
removeJwtCookie(w, h.IsProduction)
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
+1
View File
@@ -62,6 +62,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
r.Post("/register", loginHandler.Register)
r.Get("/registration", loginHandler.RegistrationStatus)
r.Group(func(r chi.Router) {
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
@@ -0,0 +1,80 @@
Feature: The SPA can ask whether registration is currently open, without exposing anything else
As the maintainer of RenewCMS
I want a public, unauthenticated GET endpoint that reports whether registration is open, driven by
the exact same rule CreateUserUseCase already enforces on POST /v1/auth/register, and that reveals
nothing beyond that single boolean
So that the SPA can decide whether to show a registration form before the user submits anything,
without a second, drifting copy of the "is registration closed" rule and without leaking how many
accounts exist or who they belong to
Background:
Given CreateUserUseCase already has a private registrationClosed() bool method that loops over
user.Repository.GetAll() and returns true as soon as any existing user has IsVerified true; this
slice exports that same check as a new method on CreateUserUseCase (e.g. IsRegistrationOpen() bool,
returning the negation) so it can be called from outside the use case, and CreateUser itself keeps
calling it exactly as before
And this slice adds GET /v1/auth/registration, handled from the same handler package as
POST /v1/auth/register (api/handlers/auth), reusing the same CreateUserUseCase instance the
register handler already holds; no new use case, no new domain method beyond the exported check,
and no second loop over user.Repository.GetAll anywhere in the handler
And GET /v1/auth/registration is mounted in api/router.go's NewAuthRouter at the top level, outside
every r.Group: not inside the jwtauth.Verifier/Authenticator group that guards /verify,
DELETE /register and /me, and not inside either middleware.NewRateLimiter group that guards /login
and /password-reset; no jwt cookie is ever read, parsed or validated for this route
And the handler decodes no request body and reads no cookie and no claim; it calls
CreateUserUseCase.IsRegistrationOpen(), sets the response header Cache-Control: no-store, and
responds 200 with a JSON body containing exactly one field, "open", and nothing else: no user
count, no usernames, no emails, no is_verified values
Scenario: Registration is reported open while no verified account exists
Given the user repository is empty
When the client sends GET /v1/auth/registration with no jwt cookie
Then the response status is 200
And the response header Cache-Control is "no-store"
And the response body is exactly {"open": true}
Scenario: Registration is reported open while every existing account is unverified
Given the user repository contains a user "alice" with IsVerified false
When the client sends GET /v1/auth/registration with no jwt cookie
Then the response status is 200
And the response body is exactly {"open": true}
Scenario: Registration is reported closed once a verified account exists
Given the user repository contains a user "alice" with IsVerified true
When the client sends GET /v1/auth/registration with no jwt cookie
Then the response status is 200
And the response header Cache-Control is "no-store"
And the response body is exactly {"open": false}
Scenario: An expired, forged, or otherwise invalid jwt cookie makes no difference to the response
Given the user repository contains a user "alice" with IsVerified true
And the client holds a jwt cookie that is expired, forged, or otherwise invalid and would be
rejected by jwtauth.Authenticator on any protected route
When the client sends GET /v1/auth/registration with that cookie attached
Then the response status is 200
And the response body is exactly {"open": false}, the same as a request with no cookie at all
And the response does not set or clear any cookie
And this proves the route never even attempts to parse or validate the cookie, not merely that a
valid one would be ignored
Scenario: The endpoint reveals nothing beyond the open flag
Given the user repository contains three users, two verified and one unverified
When the client sends GET /v1/auth/registration
Then the response body's only top-level field is "open"
And the response body contains none of: a user count, a username, an email, or an is_verified value
Scenario: The route is never rate-limited, unlike login and password reset
Given the user repository is empty
When the client sends GET /v1/auth/registration 20 times in immediate succession from the same
client
Then every response status is 200
And none of the responses is 429
Scenario: The same rule CreateUser enforces is what answers this endpoint
Given a user.Repository fake containing one user with IsVerified true
And a CreateUserUseCase built directly over that fake, a hasher and a mail.Repository fake
When IsRegistrationOpen is called
Then it returns false
And calling CreateUser on the same use case with a new username/email/password returns
user.ErrRegistrationClosed, asserted with errors.Is
@@ -0,0 +1,58 @@
# QA procedure: registration_status_endpoint
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`) and
`test/mail_test.go` (`mockMailRepo`), and following the router wiring pattern in
`test/registration_gate_and_verification_email_http_test.go` /
`test/get_current_user_http_test.go`.
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Build `createUserUseCase := useCases.NewCreateUserUseCase(repo, &stubHasher{}, mailRepo,
"https://cms.example.com")` where `repo` is a `mockUserRepo` and `mailRepo` is a `mockMailRepo`.
3. Build the auth handler(s) the same way `NewAuthRouter` does, but only mount what each case needs:
`GET /v1/auth/registration` unprotected, at the router's top level (no `jwtauth.Verifier`, no
`jwtauth.Authenticator`, no `middleware.NewRateLimiter`), on a fresh `chi.NewRouter()`. No live
server, no real SQLite file, no real SMTP.
## HTTP-level checks
4. Empty `mockUserRepo`. `GET /v1/auth/registration` with no cookie → assert status `200`,
`Cache-Control` header equals `"no-store"`, and the decoded JSON body is exactly `{"open": true}`
(assert there is no other top-level key by decoding into `map[string]any` and checking `len(body)
== 1`).
5. `mockUserRepo` seeded with one user, `IsVerified: false`. `GET /v1/auth/registration` → assert
status `200` and body exactly `{"open": true}`.
6. `mockUserRepo` seeded with one user, `IsVerified: true`. `GET /v1/auth/registration` → assert
status `200`, `Cache-Control: no-store`, and body exactly `{"open": false}`.
7. Same seed as step 6. Attach a jwt cookie whose value is invalid/expired/garbage — e.g. a plain
string like `"jwt=not-a-real-token"`, or a token minted with a different signing key, or one built
with `token.Encode` and then manually expired/truncated — deliberately NOT a cookie freshly minted
with `token.Encode` against the router's own signing key. `GET /v1/auth/registration` with that
cookie attached → assert status `200`, body exactly `{"open": false}` (identical to step 6's
cookie-less result), and no `Set-Cookie` header on the response. This demonstrates the route never
parses or validates the cookie at all, since an invalid/expired one produces byte-identical output
to no cookie.
8. `mockUserRepo` seeded with three users (two `IsVerified: true`, one `IsVerified: false`).
`GET /v1/auth/registration` → decode the body into `map[string]any`, assert its only key is
`"open"`, and assert the raw response body (as a string) does not contain any of the seeded
usernames or emails, and does not contain a numeric user count anywhere but inside the boolean
`open` value.
9. Empty `mockUserRepo`. Send `GET /v1/auth/registration` 20 times in a row against the same
router/repo (a plain loop of `httptest.NewRequest` + `ServeHTTP`, no external rate limiter
middleware mounted on this route) → assert every response status is `200` and none is `429`.
## Use-case-level check (no HTTP)
10. Build a `user.Repository` fake (`mockUserRepo`) containing one user with `IsVerified: true`. Build
`CreateUserUseCase` over that fake, a `stubHasher`, and a `mockMailRepo`. Call the new exported
method (e.g. `IsRegistrationOpen()`) and assert it returns `false`. Then call `CreateUser` with a
new username/email/password on the same use case instance and assert
`errors.Is(err, user.ErrRegistrationClosed)` — proving both entry points consult the same
underlying check rather than two independently-maintained copies.
## Manual verification (not exercised by the Go test)
- The SPA actually calling `GET /v1/auth/registration` before rendering the registration form (rather
than always showing it and relying on the 403 from POST /v1/auth/register) is out of scope for this
slice's automated test and should be checked by hand against a running dev build.
@@ -79,6 +79,10 @@ func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User,
return createdUser, nil
}
func (g *CreateUserUseCase) IsRegistrationOpen() bool {
return !g.registrationClosed()
}
func (g *CreateUserUseCase) registrationClosed() bool {
for _, existingUser := range g.userRepository.GetAll() {
if existingUser.IsVerified {
@@ -0,0 +1,180 @@
package test
import (
"RenewCMS/api/handlers/auth"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo) (*chi.Mux, *useCases.CreateUserUseCase) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
hasher := &stubHasher{}
createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com")
loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher), true)
r := chi.NewRouter()
r.Route("/v1/auth", func(r chi.Router) {
r.Post("/register", loginHandler.Register)
r.Get("/registration", loginHandler.RegistrationStatus)
})
return r, createUseCase
}
func registrationStatusRequest(router *chi.Mux, cookie *http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/v1/auth/registration", nil)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestRegistrationStatusEndpointHttp(t *testing.T) {
validator.InitValidator()
t.Run("Registration is reported open while no verified account exists", func(t *testing.T) {
userRepo := newMockUserRepo()
mailRepo := newMockMailRepo()
router, _ := newRegistrationStatusRouter(userRepo, mailRepo)
rec := registrationStatusRequest(router, nil)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Errorf("expected Cache-Control no-store, got %q", rec.Header().Get("Cache-Control"))
}
if rec.Body.String() != `{"open":true}` {
t.Errorf("expected body {\"open\":true}, got %q", rec.Body.String())
}
})
t.Run("Registration is reported open while every existing account is unverified", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: false}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
router, _ := newRegistrationStatusRouter(userRepo, mailRepo)
rec := registrationStatusRequest(router, nil)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != `{"open":true}` {
t.Errorf("expected body {\"open\":true}, got %q", rec.Body.String())
}
})
t.Run("Registration is reported closed once a verified account exists", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
router, _ := newRegistrationStatusRouter(userRepo, mailRepo)
rec := registrationStatusRequest(router, nil)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Errorf("expected Cache-Control no-store, got %q", rec.Header().Get("Cache-Control"))
}
if rec.Body.String() != `{"open":false}` {
t.Errorf("expected body {\"open\":false}, got %q", rec.Body.String())
}
})
t.Run("An expired, forged, or otherwise invalid jwt cookie makes no difference to the response", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
router, _ := newRegistrationStatusRouter(userRepo, mailRepo)
invalidCookie := &http.Cookie{Name: "jwt", Value: "not-a-valid-jwt-token"}
rec := registrationStatusRequest(router, invalidCookie)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != `{"open":false}` {
t.Errorf("expected body {\"open\":false}, got %q", rec.Body.String())
}
if rec.Header().Get("Set-Cookie") != "" {
t.Errorf("expected no cookie to be set or cleared, got %q", rec.Header().Get("Set-Cookie"))
}
})
t.Run("The endpoint reveals nothing beyond the open flag", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
userRepo.users[2] = user.User{ID: 2, Username: "bob", Email: "bob@example.com", IsVerified: true}
userRepo.users[3] = user.User{ID: 3, Username: "carol", Email: "carol@example.com", IsVerified: false}
userRepo.nextID = 3
mailRepo := newMockMailRepo()
router, _ := newRegistrationStatusRouter(userRepo, mailRepo)
rec := registrationStatusRequest(router, nil)
body := rec.Body.String()
if body != `{"open":false}` {
t.Fatalf("expected body {\"open\":false} with only the open field, got %q", body)
}
forbidden := []string{"alice", "bob", "carol", "@example.com", "is_verified", "count"}
for _, term := range forbidden {
if strings.Contains(body, term) {
t.Errorf("expected the body to not reveal %q, got %q", term, body)
}
}
})
t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) {
userRepo := newMockUserRepo()
mailRepo := newMockMailRepo()
router, _ := newRegistrationStatusRouter(userRepo, mailRepo)
for i := 0; i < 20; i++ {
rec := registrationStatusRequest(router, nil)
if rec.Code != http.StatusOK {
t.Fatalf("request %d: expected status 200, got %d", i+1, rec.Code)
}
if rec.Code == http.StatusTooManyRequests {
t.Fatalf("request %d: unexpectedly rate-limited", i+1)
}
}
})
t.Run("The same rule CreateUser enforces is what answers this endpoint", func(t *testing.T) {
userRepo := newMockUserRepo()
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com")
if uc.IsRegistrationOpen() {
t.Fatalf("expected IsRegistrationOpen to return false")
}
_, err := uc.CreateUser(useCases.CreateUserCommand{
Username: "bob",
Password: "hunter2example12",
Email: "bob@example.com",
})
if !errors.Is(err, user.ErrRegistrationClosed) {
t.Fatalf("expected user.ErrRegistrationClosed, got %v", err)
}
})
}