175 lines
11 KiB
Gherkin
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
|