refac: GET /v1/auth/registration
This commit is contained in:
1 parent
61828783a1
commit
2a64991916
6 files changed
+329
No files matched your search
@@ -129,6 +129,12 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(message)
|
_, _ = 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) {
|
func (h *LoginHandler) Logout(w http.ResponseWriter, _ *http.Request) {
|
||||||
removeJwtCookie(w, h.IsProduction)
|
removeJwtCookie(w, h.IsProduction)
|
||||||
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
|
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
|||||||
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
|
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
|
||||||
|
|
||||||
r.Post("/register", loginHandler.Register)
|
r.Post("/register", loginHandler.Register)
|
||||||
|
r.Get("/registration", loginHandler.RegistrationStatus)
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||||
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
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
|
return createdUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *CreateUserUseCase) IsRegistrationOpen() bool {
|
||||||
|
return !g.registrationClosed()
|
||||||
|
}
|
||||||
|
|
||||||
func (g *CreateUserUseCase) registrationClosed() bool {
|
func (g *CreateUserUseCase) registrationClosed() bool {
|
||||||
for _, existingUser := range g.userRepository.GetAll() {
|
for _, existingUser := range g.userRepository.GetAll() {
|
||||||
if existingUser.IsVerified {
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user