Files
RenewCMS/features/verified_account_required.feature

175 lines
11 KiB
Gherkin

Feature: Protected article routes and the offline-inclusive listing require a verified account
As the maintainer of RenewCMS
I want every protected article route, and the "see offline articles too" behavior of
GET /v1/article/, to require an account that has completed email verification, not merely
a valid jwt session
So that a user who registered but never confirmed their email cannot create, read, edit,
publish, unpublish, delete or attach images to articles, and cannot see unpublished drafts
through the listing endpoint, while a verified user's existing workflow is unchanged
Background:
Given before this slice, the protected group inside NewArticleRouter (api/router.go) applies
only jwtauth.Verifier then jwtauth.Authenticator to GET /v1/article/{id}, POST /v1/article/,
DELETE /v1/article/{id}, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish,
PATCH /v1/article/{id}/unpublish, so any caller with a
validly signed, unexpired jwt cookie reaches the handler regardless of that account's
is_verified status
And this slice adds a third middleware, api/middleware.RequireVerifiedUser(getUser
*useCases.GetUserUseCase), mounted after jwtauth.Authenticator in that same group, so the
order becomes Verifier, Authenticator, RequireVerifiedUser for all six routes above
And RequireVerifiedUser reads user_id from the request's jwt claims using a new exported
helper, api/pkg/claims.UserId(r *http.Request) (uint32, bool), which replaces the
identical private userIdFromClaims method api/handlers/auth.Handler.Verify and
CancelRegistration already used (features/account_verification.feature,
features/cancel_pending_registration.feature); those two handlers are updated to call the
shared helper, and their observable 401 behavior on a missing or non-numeric user_id claim
is unchanged
And claims.UserId now also rejects a "user_id" claim whose JSON number is not a whole value in
the inclusive range 0 to 4294967295 (math.MaxUint32): a negative number, a value with a
fractional part (e.g. 7.5), or a value larger than 4294967295 all now return ok=false instead
of being silently truncated or wrapped into a uint32 that could coincidentally match a
different, real user id
And when claims.UserId reports no usable user_id, RequireVerifiedUser answers 401 with
apperr.UnauthorizedErrorMessage without calling GetUserUseCase, the same body and status the
protected auth routes already use for the same condition
And otherwise RequireVerifiedUser calls GetUserUseCase.GetUser(userId); when that returns
user.ErrUserNotFound (a token minted for an account since deleted), it answers 401 with
apperr.UnauthorizedErrorMessage; when it returns any other error, it answers 500 with body
"Internal server error"; when it returns a user whose IsVerified is false, it answers 403
with a new apperr.VerificationRequiredErrorMessage constant, "You must verify your account
to access this resource."; when IsVerified is true, it calls next.ServeHTTP unchanged, so
the wrapped handler runs exactly as it did before this slice
And this slice deletes the dead api/handlers/auth.Handler.IsVerified method: it was never
wired to any route, and it performed the bare token.PrivateClaims()["user_id"].(float64)
assertion this slice avoids everywhere else; RequireVerifiedUser is the behavior it was
meant to provide, so it is removed rather than kept unused
And this slice also changes GET /v1/article/, which sits under jwtauth.Verifier only (no
Authenticator, per features/public_article_listing.feature) and therefore is never rejected
outright: article.Handler gains a GetUserUseCase field, wired from container.GetUserUseCase
in api/router.go's NewArticleRouter, and Handler.ListArticles now calls ListAllArticles only
when the request carries a resolvable user_id (via the same claims.UserId helper) whose
GetUserUseCase.GetUser lookup succeeds and returns IsVerified true; any other case (no jwt,
malformed jwt, unresolvable user_id, deleted account, or an account that exists but is not
yet verified) falls back to ListOnlineArticles, the same fallback
features/public_article_listing.feature already documents for a malformed cookie; this
listing route never answers 401 or 403 for an unverified caller, it just narrows the result
And a repository failure while ListArticles resolves the caller (GetUserUseCase.GetUser
returning an error other than user.ErrUserNotFound) is treated the same as "not verified"
for the purpose of choosing which list to show, so the listing still answers 200 with the
online-only array rather than failing the request; only a failure inside ListOnlineArticles
or ListAllArticles itself still answers 500, unchanged from features/public_article_listing.feature
And a later slice, features/public_article_read.feature, moves GET /v1/article/{id} out of the
Verifier + Authenticator + RequireVerifiedUser group entirely and into the Verifier-only group
next to GET /v1/article/, so that route no longer answers 401 for a missing cookie or 403 for
an unverified caller; the "GET /v1/article/1" row is therefore removed from both Examples
tables below
Scenario Outline: An authenticated but unverified user is refused on every protected article route
Given a user repository containing user id 1, is_verified false
And a valid jwt cookie for user id 1
And the article repository contains an article with id 1 titled "Backyard Renovation Update"
When the client sends <method> <path> with that cookie
Then the response status is 403
And the response body is the apperr.VerificationRequiredErrorMessage text
Examples:
| method | path |
| POST | /v1/article/ |
| DELETE | /v1/article/1 |
| PATCH | /v1/article/1 |
| PATCH | /v1/article/1/publish |
| PATCH | /v1/article/1/unpublish |
Scenario: A verified user still exercises the protected routes exactly as before
Given a user repository containing user id 1, is_verified true
And a valid jwt cookie for user id 1
When the client sends POST /v1/article/ with that cookie and body
{"title": "Valid Title", "body": "Some content"}
Then the response status is 201
And the response body is the created article as JSON
Scenario Outline: A token that resolves to no usable account still answers 401, never 403
Given a user repository containing user id 1, is_verified false
And <token description>
And the article repository contains an article with id 1 titled "Backyard Renovation Update"
When the client sends POST /v1/article/ with that cookie and body
{"title": "Valid Title", "body": "Some content"}
Then the response status is 401
And the response body is the apperr.UnauthorizedErrorMessage text
Examples:
| token description |
| a validly signed jwt cookie whose claims carry no "user_id" key at all |
| a validly signed jwt cookie whose "user_id" claim is the string "one" |
| a validly signed jwt cookie whose "user_id" claim is the number 999, which does not match any user |
| a validly signed jwt cookie whose "user_id" claim is the number -1 |
| a validly signed jwt cookie whose "user_id" claim is the number 4294967296 (one past the uint32 max) |
Scenario: A fractional user_id claim is never truncated into impersonating a different real user
Given a user repository containing user id 7, is_verified true
And a validly signed jwt cookie whose claims are {"user_id": 7.5}
When the client sends POST /v1/article/ with that cookie and body
{"title": "Valid Title", "body": "Some content"}
Then the response status is 401
And the response body is the apperr.UnauthorizedErrorMessage text
And no article was created
Scenario Outline: A request with no jwt cookie is still refused with 401, never 403
Given no jwt cookie is sent
And the article repository contains an article with id 1 titled "Backyard Renovation Update"
When the client sends <method> <path> without a cookie
Then the response status is 401
Examples:
| method | path |
| POST | /v1/article/ |
Scenario: A repository failure while resolving the caller's verification status answers 500
Given a valid jwt cookie for user id 1
And the user repository fails on its next call to Get with an error other than
user.ErrUserNotFound
And the article repository contains an article with id 1 titled "Backyard Renovation Update"
When the client sends DELETE /v1/article/1 with that cookie
Then the response status is 500
And the response body is the text "Internal server error"
And the article repository's article with id 1 still exists
Scenario: Listing falls back to the online-only list for an authenticated but unverified caller
Given a user repository containing user id 1, is_verified false
And a valid jwt cookie for user id 1
And the article repository contains an online article titled "Backyard Renovation Update" with id 1
And the same repository contains an offline article titled "Draft: Kitchen Remodel" with id 2
When the client sends GET /v1/article/ with that cookie
Then the response status is 200
And the response body is a JSON array containing exactly the article with id 1
And the article with id 2 is absent from the response body
Scenario: Listing still returns every article for an authenticated and verified caller
Given a user repository containing user id 1, is_verified true
And a valid jwt cookie for user id 1
And the article repository contains an online article titled "Backyard Renovation Update" with id 1
And the same repository contains an offline article titled "Draft: Kitchen Remodel" with id 2
When the client sends GET /v1/article/ with that cookie
Then the response status is 200
And the response body is a JSON array containing both the article with id 1 and the article with id 2
Scenario: Listing falls back to the online-only list when the caller's account no longer exists
Given the user repository contains no user with id 999
And a valid jwt cookie for user id 999
And the article repository contains an online article titled "Backyard Renovation Update" with id 1
And the same repository contains an offline article titled "Draft: Kitchen Remodel" with id 2
When the client sends GET /v1/article/ with that cookie
Then the response status is 200
And the response body is a JSON array containing exactly the article with id 1
Scenario: Listing falls back to the online-only list rather than failing when the user lookup errors
Given a valid jwt cookie for user id 1
And the user repository fails on its next call to Get with an error other than
user.ErrUserNotFound
And the article repository contains an online article titled "Backyard Renovation Update" with id 1
And the same repository contains an offline article titled "Draft: Kitchen Remodel" with id 2
When the client sends GET /v1/article/ with that cookie
Then the response status is 200
And the response body is a JSON array containing exactly the article with id 1