refac: missing user or article is reported with a domain not-found error instead of a raw GORM error, and the API answers 404 for it
This commit is contained in:
1 parent
7c57173497
commit
8460a078e3
13 files changed
+426
-32
No files matched your search
@@ -0,0 +1,75 @@
|
||||
Feature: Domain not-found errors instead of raw GORM errors
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want a missing article or user reported as a domain not-found error
|
||||
So that the API answers 404 for it instead of leaking a raw GORM error as a 500, and so that
|
||||
a caller can rely on errors.Is(err, article.ErrArticleNotFound) or errors.Is(err, user.ErrUserNotFound)
|
||||
|
||||
Background:
|
||||
Given internal/domain/article defines the sentinel article.ErrArticleNotFound
|
||||
And internal/domain/user defines the sentinel user.ErrUserNotFound
|
||||
And ArticleRepository.Get and ArticleRepository.GetByName return article.ErrArticleNotFound, not gorm.ErrRecordNotFound, when no row matches
|
||||
And UserRepository.Get, UserRepository.GetByUsername and UserRepository.GetByEmail return user.ErrUserNotFound, not gorm.ErrRecordNotFound, when no row matches
|
||||
And GET /v1/article/{id} sits behind the jwtauth Verifier and Authenticator group, and requires a valid jwt cookie
|
||||
|
||||
Scenario Outline: The article repository reports a missing article as a domain error
|
||||
Given a repository backed by an in-memory SQLite database containing an article titled "Backyard Renovation Update" with id 1
|
||||
When <method> is called with <input>
|
||||
Then the result is the error article.ErrArticleNotFound
|
||||
And errors.Is(err, gorm.ErrRecordNotFound) is false
|
||||
|
||||
Examples:
|
||||
| method | input |
|
||||
| Get | id 9 |
|
||||
| GetByName | "Unknown Title" |
|
||||
|
||||
Scenario Outline: The user repository reports a missing user as a domain error
|
||||
Given a repository backed by an in-memory SQLite database containing a user with id 1, username "alice" and email "alice@example.com"
|
||||
When <method> is called with <input>
|
||||
Then the result is the error user.ErrUserNotFound
|
||||
And errors.Is(err, gorm.ErrRecordNotFound) is false
|
||||
|
||||
Examples:
|
||||
| method | input |
|
||||
| Get | id 9 |
|
||||
| GetByUsername | "bob" |
|
||||
| GetByEmail | "bob@example.com" |
|
||||
|
||||
Scenario: Fetching a nonexistent article answers 404, not 500
|
||||
Given a valid jwt cookie for a user with id 1
|
||||
And the article repository has no article with id 9999
|
||||
When the client sends GET /v1/article/9999 with that cookie
|
||||
Then the response status is 404
|
||||
And the response body is the text "Article not found"
|
||||
|
||||
Scenario: Fetching an offline article still answers 404 with the same message
|
||||
Given a valid jwt cookie for a user with id 1
|
||||
And the article repository contains an article with id 2, is_online false
|
||||
When the client sends GET /v1/article/2 with that cookie
|
||||
Then the response status is 404
|
||||
And the response body is the text "Article not found"
|
||||
|
||||
Scenario: Fetching an online article still answers 200
|
||||
Given a valid jwt cookie for a user with id 1
|
||||
And the article repository contains an article with id 1, is_online true
|
||||
When the client sends GET /v1/article/1 with that cookie
|
||||
Then the response status is 200
|
||||
And the response body is the article's JSON representation
|
||||
|
||||
Scenario: The article route still requires authentication before existence is even checked
|
||||
Given no jwt cookie is sent
|
||||
And the article repository has no article with id 9999
|
||||
When the client sends GET /v1/article/9999 without a cookie
|
||||
Then the response status is 401
|
||||
|
||||
Scenario Outline: Login never reveals whether the username exists
|
||||
Given a user repository containing a user "alice" with password "hunter2example"
|
||||
When the client sends POST /v1/auth/login with username "<username>" and password "<password>"
|
||||
Then the response status is 403
|
||||
And the response body is the text apperr.LogsErrorMessage
|
||||
And no jwt cookie is set
|
||||
|
||||
Examples:
|
||||
| username | password | case |
|
||||
| alice | wrongpassword12 | existing user, bad pass |
|
||||
| bob | hunter2example | unknown username |
|
||||
@@ -0,0 +1,26 @@
|
||||
# QA procedure: not_found_errors
|
||||
|
||||
Package: `test` (black-box, alongside the existing `article_test.go` / `user_test.go` / `use_case_baseline_test.go`).
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once.
|
||||
2. Build in-memory fakes for `article.Repository` and `user.Repository` (extend the existing `mockArticleRepo` / `mockUserRepo` patterns in `test/`), updated so that a lookup by an unknown id/name/username/email now returns `article.ErrArticleNotFound` / `user.ErrUserNotFound` instead of `gorm.ErrRecordNotFound` — mirroring the fixed real repositories.
|
||||
3. Wire the real use cases (`useCases.NewGetArticleUseCase`, `useCases.NewCreateArticleUseCase` if needed, `useCases.NewGetUserUseCase`, `useCases.NewListUsersUseCase`, `useCases.NewCreateUserUseCase`) over those fakes.
|
||||
4. Build `article.NewHandler(...)` and `auth.NewHandler(...)` from those use cases plus a test `jwtauth.New("HS256", []byte("test-secret"), nil)`.
|
||||
5. Mount a fresh `chi.NewRouter()` mirroring `api/router.go`: the article handler's routes inside a group wrapped by `jwtauth.Verifier(token)` + `jwtauth.Authenticator(token)`, the auth handler's routes unprotected. No live server.
|
||||
|
||||
## Repository-level checks (real persistence repositories, in-memory SQLite, no HTTP)
|
||||
6. Open a `gorm.DB` with `sqlite.Open("file:" + t.Name() + "?mode=memory&cache=shared")` (one named in-memory DB per test, so tests never share rows) (the project's `github.com/glebarez/sqlite` driver, never the real `DB_FILE`), and `db.AutoMigrate(&models.Article{})`. Construct a real `persistence.NewArticleRepository(db)`, seed it with an article id 1 via `Create`. Call `Get(9)` directly; assert `errors.Is(err, article.ErrArticleNotFound)` is true and `errors.Is(err, gorm.ErrRecordNotFound)` is false. Repeat for `GetByName("Unknown Title")`. Close the in-memory DB (or let it drop) at the end of the test.
|
||||
7. Open a second `gorm.DB` the same way with `db.AutoMigrate(&models.User{})`. Construct a real `persistence.NewUserRepository(db)`, seed it with a user id 1 / username "alice" / email "alice@example.com" via `Create`. Call `Get(9)`, `GetByUsername("bob")`, `GetByEmail("bob@example.com")` directly; assert each returns `user.ErrUserNotFound` and not `gorm.ErrRecordNotFound`.
|
||||
|
||||
## HTTP checks (fakes, per the shared setup above)
|
||||
8. Mint a valid jwt cookie for user id 1 with the test `jwtauth.JWTAuth` (encode with `token.Encode(map[string]any{"user_id": uint32(1)})` and set it as the `jwt` cookie on the `httptest.NewRequest`).
|
||||
9. `GET /v1/article/9999` with that cookie, empty repo id 9999 → assert status `404`, body `"Article not found\n"` (http.Error appends a trailing newline).
|
||||
10. `GET /v1/article/2` with that cookie, repo seeded with an offline article id 2 → assert status `404`, body `"Article not found\n"` (regression: unchanged from before this fix).
|
||||
11. `GET /v1/article/1` with that cookie, repo seeded with an online article id 1 → assert status `200`, body decodes to the article JSON with id 1.
|
||||
12. `GET /v1/article/9999` with **no** cookie → assert status `401` (regression: jwtauth still gates the route).
|
||||
13. `POST /v1/auth/login` with `{"username":"alice","password":"wrongpassword12"}` (repo seeded with user "alice"/bcrypt-hashed "hunter2example") → assert status `403`, body equals `apperr.LogsErrorMessage + "\n"`, no `Set-Cookie: jwt=...` header present.
|
||||
14. `POST /v1/auth/login` with `{"username":"bob","password":"hunter2example"}` (no such user in the repo) → assert status `403`, **identical** body to step 13, no `Set-Cookie` header — proves the domain-error change didn't turn this into a distinguishable 404/500 and didn't start leaking existence.
|
||||
|
||||
## Manual verification (not exercised by the Go test)
|
||||
- None. This slice has no SPA-visible behavior and no SMTP involvement; the SPA's article-detail view already treats any non-2xx as "not found" generically (verify only if the SPA has an article-detail page — otherwise skip).
|
||||
@@ -40,10 +40,10 @@ Feature: Use case baseline characterization
|
||||
| method | id | outcome |
|
||||
| GetArticle | 1 | the article with id 1 |
|
||||
| GetArticle | 2 | the article with id 2 (GetArticle does not hide offline articles) |
|
||||
| GetArticle | 9 | the repository's not-found error (gorm.ErrRecordNotFound) |
|
||||
| GetArticle | 9 | the error article.ErrArticleNotFound |
|
||||
| GetOnlineArticle | 1 | the article with id 1 |
|
||||
| GetOnlineArticle | 2 | the error article.ErrArticleNotFound |
|
||||
| GetOnlineArticle | 9 | the repository's not-found error (gorm.ErrRecordNotFound), not article.ErrArticleNotFound |
|
||||
| GetOnlineArticle | 9 | the error article.ErrArticleNotFound |
|
||||
|
||||
Scenario Outline: GetArticleByName delegates verbatim to the repository
|
||||
Given an article repository fake containing an article titled "Backyard Renovation Update" with id 1
|
||||
@@ -53,7 +53,7 @@ Feature: Use case baseline characterization
|
||||
Examples:
|
||||
| name | outcome |
|
||||
| Backyard Renovation Update | the article with id 1 |
|
||||
| Unknown Title | the repository's not-found error (gorm.ErrRecordNotFound) |
|
||||
| Unknown Title | the error article.ErrArticleNotFound |
|
||||
|
||||
Scenario Outline: ListArticles filters by is_online
|
||||
Given an article repository fake containing:
|
||||
@@ -150,11 +150,11 @@ Feature: Use case baseline characterization
|
||||
Examples:
|
||||
| method | input | outcome |
|
||||
| GetUser | 1 | the user with id 1 |
|
||||
| GetUser | 9 | the repository's not-found error (gorm.ErrRecordNotFound) |
|
||||
| GetUser | 9 | the error user.ErrUserNotFound |
|
||||
| GetUserByUsername | alice | the user with id 1 |
|
||||
| GetUserByUsername | bob | the repository's not-found error (gorm.ErrRecordNotFound) |
|
||||
| GetUserByUsername | bob | the error user.ErrUserNotFound |
|
||||
| GetUserByEmail | alice@example.com | the user with id 1 |
|
||||
| GetUserByEmail | bob@example.com | the repository's not-found error (gorm.ErrRecordNotFound) |
|
||||
| GetUserByEmail | bob@example.com | the error user.ErrUserNotFound |
|
||||
|
||||
Scenario: ListUsers returns every user in the fake
|
||||
Given a user repository fake containing users with ids 1 and 2
|
||||
|
||||
Reference in new issue
Block a user