refac: several unverified users can exist at once: an empty reset or verification code is stored as no value so it doesn't break the unique constraint, and deleting a user removes the row so the same username and email can register again

This commit is contained in:
fsylvain committed 2026-09-24 01:59:24 +02:00
1 parent 0b4e614ec9
commit 6e9e108033
6 files changed
+377 -16

No files matched your search

@@ -0,0 +1,70 @@
Feature: Several unverified users can coexist, and deleting a user frees its username and email
As the maintainer of RenewCMS
I want an absent password reset code or verification code stored as no value instead of an empty
string, and a deleted user's row actually removed
So that several unverified users can register one after another without colliding on the unique
reset-code column, and so that deleting a user lets the same username and email register again
Background:
Given internal/infrastructure/persistence/models.User stores PasswordResetCode and VerificationCode
as nullable, unique columns, so a domain value of "" is persisted as no value (SQL NULL), and SQL
NULL is exempt from the unique constraint
And internal/infrastructure/persistence/mappers.UserToModel maps a domain "" for PasswordResetCode or
VerificationCode to no value, and UserToDomain maps no value back to ""
And internal/infrastructure/persistence/models.User no longer embeds gorm.Model, so
UserRepository.Delete removes the row instead of leaving a soft-deleted row that still occupies the
unique username and email
And POST /v1/auth/register is public, and CreateUser stores a new user with is_verified false and
PasswordResetCode "" as soon as the request is valid
And Register only answers 403 once at least one existing user is verified (SomeUsersVerified), never
because the user table already has rows
And DeleteUser has no handler mounted in api/router.go today, so deletion in these scenarios is
exercised through UserRepository.Delete / DeleteUserUseCase.DeleteUser directly, not through an
HTTP route
Scenario: Two unverified users can register one after another without a reset-code collision
Given the user repository is empty
When the client sends POST /v1/auth/register with username "alice", password "hunter2example12",
email "alice@example.com"
Then the response status is 200
And a jwt cookie is set
When the client sends POST /v1/auth/register with username "bob", password "hunter2example12",
email "bob@example.com"
Then the response status is 200
And a jwt cookie is set
And the user repository contains both "alice" and "bob", each with is_verified false and an empty
password reset code
Scenario: Creating two users with no active reset code does not violate the unique constraint
Given a repository backed by an in-memory SQLite database with models.User migrated
When Create is called with username "alice", email "alice@example.com", password_reset_code "" and
verification_code "code-alice"
And Create is called with username "bob", email "bob@example.com", password_reset_code "" and
verification_code "code-bob"
Then both calls succeed
And GetByUsername "alice" and GetByUsername "bob" each return password_reset_code ""
Scenario: The unique constraint still rejects two users sharing the same active reset code
Given a repository backed by an in-memory SQLite database containing a user "alice" with
password_reset_code "shared-code"
When Create is called with username "bob", email "bob@example.com" and password_reset_code
"shared-code"
Then the Create call returns an error
And GetByUsername "bob" still returns user.ErrUserNotFound
Scenario: Deleting a user removes the row so the same username and email can register again
Given a repository backed by an in-memory SQLite database containing a user with id 1, username
"alice" and email "alice@example.com"
When Delete is called with id 1
And Create is called with username "alice", email "alice@example.com" and a new password
Then the Create call succeeds
And the newly created user's id is different from 1
And Get with id 1 returns user.ErrUserNotFound
Scenario: Registering the same username while the first account still exists is still rejected
Given a repository backed by an in-memory SQLite database containing a user with username "alice"
and email "alice@example.com"
When Create is called with username "alice", email "someone-else@example.com" and a new password
Then the Create call returns an error
And the user repository still contains exactly one user with username "alice"
@@ -0,0 +1,50 @@
# QA procedure: unverified_users_and_reregistration
Package: `test` (black-box), extending `test/user_test.go` (mockUserRepo) and reusing the
`openInMemoryDb(t)` helper from `test/not_found_errors_test.go`.
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Repository-level checks use `openInMemoryDb(t)`, `db.AutoMigrate(&models.User{})`, and a real
`persistence.NewUserRepository(db)`.
3. HTTP-level checks use `mockUserRepo` (from `test/user_test.go`) wired into
`useCases.NewCreateUserUseCase(repo, &hashing.BcryptHasher{})`,
`useCases.NewListUsersUseCase(repo)`, `useCases.NewGetUserUseCase(repo)`,
`useCases.NewAuthenticateUserUseCase(repo, ...)`, and `auth.NewHandler(...)` with a test
`jwtauth.New("HS256", []byte("test-secret"), nil)`. Mount `POST /v1/auth/register` and
`POST /v1/auth/login` unprotected on a fresh `chi.NewRouter()`, mirroring `api/router.go`. No live
server.
## Repository-level checks (real persistence repository, in-memory SQLite, no HTTP)
4. On an empty repository, `Create` a user "alice"/"alice@example.com" with `PasswordResetCode: ""` and
`VerificationCode: "code-alice"` → assert `err == nil`. `Create` a second user "bob"/"bob@example.com"
with `PasswordResetCode: ""` and `VerificationCode: "code-bob"` → assert `err == nil` (regression: before
the fix this fails with a SQLite `UNIQUE constraint failed: users.password_reset_code` error). Then
`GetByUsername("alice")` and `GetByUsername("bob")` → assert `PasswordResetCode == ""` for both.
5. `Create` a user "alice" with `PasswordResetCode: "shared-code"`. `Create` a user "bob" with the same
`PasswordResetCode: "shared-code"` → assert `err != nil` (the unique constraint still applies to an
actual shared value). Assert `GetByUsername("bob")` returns `user.ErrUserNotFound` (the row was never
created).
6. `Create` a user "alice"/"alice@example.com", capture its id. `Delete(id)` → assert `err == nil`.
`Create` a new user with the same username "alice" and email "alice@example.com" (a different
password and verification code) → assert `err == nil` (regression: before the fix this fails with a
SQLite `UNIQUE constraint failed: users.username` error because the row was only soft-deleted). Assert
the new user's id differs from the deleted one. Assert `Get(deletedId)` returns `user.ErrUserNotFound`.
7. `Create` a user "alice"/"alice@example.com" and do **not** delete it. `Create` a second user with
username "alice" and a different email → assert `err != nil` (unchanged: still rejected while the row
is live).
## HTTP checks (fakes, per the shared setup above)
8. `POST /v1/auth/register` with `{"username":"alice","password":"hunter2example12","email":"alice@example.com"}`
against an empty `mockUserRepo` → assert status `200`, a `Set-Cookie: jwt=...` header present, body
decodes to `{"message":"User registered! HTTPonly jwt cookie created"}`.
9. `POST /v1/auth/register` again with `{"username":"bob","password":"hunter2example12","email":"bob@example.com"}`
(repo now holds unverified "alice") → assert status `200` and a `Set-Cookie: jwt=...` header present
(register stays allowed because no user is verified yet).
10. Assert `mockUserRepo.users` holds two entries, both `IsVerified == false` and both
`PasswordResetCode == ""`.
## Manual verification (not exercised by the Go test)
- None. `DeleteUser` has no HTTP route mounted today, so there is no SPA-visible delete-and-re-register
flow to check by hand, and `CreateUser` does not call the mailer, so there is no SMTP involvement in
this slice.