diff --git a/api/handlers/auth/loginHandler.go b/api/handlers/auth/loginHandler.go index 24455eb..0243bea 100644 --- a/api/handlers/auth/loginHandler.go +++ b/api/handlers/auth/loginHandler.go @@ -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"}) diff --git a/api/router.go b/api/router.go index ff7d5a8..51a13c7 100644 --- a/api/router.go +++ b/api/router.go @@ -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)) diff --git a/features/registration_status_endpoint.feature b/features/registration_status_endpoint.feature new file mode 100644 index 0000000..1ddc4ce --- /dev/null +++ b/features/registration_status_endpoint.feature @@ -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 diff --git a/features/registration_status_endpoint.qa.md b/features/registration_status_endpoint.qa.md new file mode 100644 index 0000000..14f202b --- /dev/null +++ b/features/registration_status_endpoint.qa.md @@ -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. diff --git a/internal/infrastructure/useCases/CreateUser.go b/internal/infrastructure/useCases/CreateUser.go index f5d3f97..33c2ff3 100644 --- a/internal/infrastructure/useCases/CreateUser.go +++ b/internal/infrastructure/useCases/CreateUser.go @@ -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 { diff --git a/test/registration_status_endpoint_http_test.go b/test/registration_status_endpoint_http_test.go new file mode 100644 index 0000000..e43c2ee --- /dev/null +++ b/test/registration_status_endpoint_http_test.go @@ -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) + } + }) +}