233 lines
9.1 KiB
Go
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])
|
|
}
|
|
}
|
|
})
|
|
}
|