Files
RenewCMS/test/article_created_by_http_test.go
T

233 lines
9.1 KiB
Go

package test
import (
"RenewCMS/api"
"RenewCMS/api/pkg/authtoken"
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
const (
createdByWriterID = 5
createdByStaleID = 6
createdByIdleID = 7
)
func newCreatedByEnv(t *testing.T) (*activityHTTPEnv, http.Handler) {
t.Helper()
e := newActivityHTTPEnv(t, 1000)
e.users.users[createdByWriterID] = user.User{ID: createdByWriterID, Username: "wren", Email: "wren@example.com", IsVerified: true, RoleID: writerRoleID}
e.users.users[createdByStaleID] = user.User{ID: createdByStaleID, Username: "stella", Email: "stella@example.com", IsVerified: true, RoleID: writerRoleID, TokenVersion: 3}
e.users.users[createdByIdleID] = user.User{ID: createdByIdleID, Username: "ida", Email: "ida@example.com", IsVerified: false, RoleID: writerRoleID}
e.articles.articles[1] = domainArticle.Article{ID: 1, Title: "Winter checklist for timber windows", IsOnline: true, CreatedBy: createdByWriterID}
e.articles.articles[2] = domainArticle.Article{ID: 2, Title: "Orphaned draft with no owner", IsOnline: true, CreatedBy: 0}
container := &api.UseCases{
Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(e.roles),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
}
router := chi.NewRouter()
router.Use(authtoken.Verifier(e.token))
router.Mount("/v1/article", api.NewArticleRouter(container, nil))
return e, router
}
func sendCreatedBy(router http.Handler, method string, path string, body string, options ...activityRequest) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = activityPeer
for _, option := range options {
option(req)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func asStaleUser(e *activityHTTPEnv, id uint32) activityRequest {
return func(r *http.Request) {
tokenString, _ := e.token.Encode(map[string]any{"user_id": id, "token_version": 0})
r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
}
func decodeArticleFields(t *testing.T, body []byte) map[string]json.RawMessage {
t.Helper()
var object map[string]json.RawMessage
if err := json.Unmarshal(body, &object); err != nil {
t.Fatalf("invalid json %s: %v", body, err)
}
return object
}
func decodeArticleItems(t *testing.T, body []byte) []map[string]json.RawMessage {
t.Helper()
var list struct {
Items []map[string]json.RawMessage `json:"items"`
}
if err := json.Unmarshal(body, &list); err != nil {
t.Fatalf("invalid json %s: %v", body, err)
}
return list.Items
}
func TestArticleCreatedByHttp(t *testing.T) {
t.Run("a signed-in session carries created_by on GET /v1/article/", func(t *testing.T) {
e, router := newCreatedByEnv(t)
rec := sendCreatedBy(router, http.MethodGet, "/v1/article/", "", asUser(e, activityOwnerID))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
seen := map[string]string{}
for _, item := range decodeArticleItems(t, rec.Body.Bytes()) {
seen[string(item["id"])] = string(item["created_by"])
}
if seen["1"] != "5" || seen["2"] != "0" {
t.Errorf("expected created_by 5 and 0, got %v", seen)
}
})
t.Run("a signed-in session carries created_by on GET /v1/article/{id}", func(t *testing.T) {
e, router := newCreatedByEnv(t)
rec := sendCreatedBy(router, http.MethodGet, "/v1/article/1", "", asUser(e, activityOwnerID))
if got := string(decodeArticleFields(t, rec.Body.Bytes())["created_by"]); got != "5" {
t.Errorf("expected 5, got %q", got)
}
})
t.Run("a signed-in session carries created_by on the PATCH response", func(t *testing.T) {
e, router := newCreatedByEnv(t)
rec := sendCreatedBy(router, http.MethodPatch, "/v1/article/1", `{"excerpt":"Short"}`, asUser(e, activityOwnerID))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if got := string(decodeArticleFields(t, rec.Body.Bytes())["created_by"]); got != "5" {
t.Errorf("expected 5, got %q", got)
}
})
t.Run("a signed-in session carries the caller id on the POST response", func(t *testing.T) {
e, router := newCreatedByEnv(t)
rec := sendCreatedBy(router, http.MethodPost, "/v1/article/", `{"title":"Fresh draft title","body":""}`, asUser(e, createdByWriterID))
if rec.Code != http.StatusCreated {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if got := string(decodeArticleFields(t, rec.Body.Bytes())["created_by"]); got != "5" {
t.Errorf("expected 5, got %q", got)
}
})
t.Run("an API key carries created_by on GET /v1/article/", func(t *testing.T) {
e, router := newCreatedByEnv(t)
raw, _ := e.issueKey(t, activityOwnerID, "Deploy bot")
rec := sendCreatedBy(router, http.MethodGet, "/v1/article/", "", withBearer(raw))
items := decodeArticleItems(t, rec.Body.Bytes())
if len(items) == 0 {
t.Fatalf("no items: %s", rec.Body)
}
for _, item := range items {
if _, present := item["created_by"]; !present {
t.Errorf("created_by missing from %v", item)
}
}
})
t.Run("an API key carries created_by on GET /v1/article/{id}", func(t *testing.T) {
e, router := newCreatedByEnv(t)
raw, _ := e.issueKey(t, activityOwnerID, "Deploy bot")
rec := sendCreatedBy(router, http.MethodGet, "/v1/article/1", "", withBearer(raw))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if got := string(decodeArticleFields(t, rec.Body.Bytes())["created_by"]); got != "5" {
t.Errorf("expected 5, got %q", got)
}
})
for _, action := range []string{"publish", "unpublish"} {
t.Run("a signed-in session carries created_by on the "+action+" response", func(t *testing.T) {
e, router := newCreatedByEnv(t)
rec := sendCreatedBy(router, http.MethodPatch, "/v1/article/1/"+action, "", asUser(e, activityOwnerID))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if got := string(decodeArticleFields(t, rec.Body.Bytes())["created_by"]); got != "5" {
t.Errorf("expected 5, got %q", got)
}
})
t.Run("an anonymous caller gets no article body on "+action, func(t *testing.T) {
_, router := newCreatedByEnv(t)
rec := sendCreatedBy(router, http.MethodPatch, "/v1/article/1/"+action, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
if strings.Contains(rec.Body.String(), "created_by") {
t.Errorf("created_by leaked: %s", rec.Body)
}
})
}
t.Run("an anonymous caller gets no created_by field", func(t *testing.T) {
_, router := newCreatedByEnv(t)
list := sendCreatedBy(router, http.MethodGet, "/v1/article/", "")
for _, item := range decodeArticleItems(t, list.Body.Bytes()) {
if _, present := item["created_by"]; present {
t.Errorf("created_by leaked in list: %v", item)
}
}
one := sendCreatedBy(router, http.MethodGet, "/v1/article/1", "")
if _, present := decodeArticleFields(t, one.Body.Bytes())["created_by"]; present {
t.Errorf("created_by leaked: %s", one.Body)
}
})
t.Run("an unverified or stale session is treated as anonymous", func(t *testing.T) {
e, router := newCreatedByEnv(t)
for name, option := range map[string]activityRequest{
"unverified": asUser(e, createdByIdleID),
"stale": asStaleUser(e, createdByStaleID),
} {
rec := sendCreatedBy(router, http.MethodGet, "/v1/article/", "", option)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d", name, rec.Code)
}
for _, item := range decodeArticleItems(t, rec.Body.Bytes()) {
if _, present := item["created_by"]; present {
t.Errorf("%s: created_by leaked: %v", name, item)
}
}
}
})
t.Run("anonymous and signed-in encodings differ only by created_by", func(t *testing.T) {
e, router := newCreatedByEnv(t)
anonymous := decodeArticleFields(t, sendCreatedBy(router, http.MethodGet, "/v1/article/1", "").Body.Bytes())
signedIn := decodeArticleFields(t, sendCreatedBy(router, http.MethodGet, "/v1/article/1", "", asUser(e, activityOwnerID)).Body.Bytes())
delete(signedIn, "created_by")
if len(anonymous) != len(signedIn) {
t.Fatalf("field sets differ: %d vs %d", len(anonymous), len(signedIn))
}
for key, value := range anonymous {
if string(signedIn[key]) != string(value) {
t.Errorf("%s differs: %s vs %s", key, value, signedIn[key])
}
}
})
}