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]) } } }) }