package journal_test import ( "encoding/json" "net/http" "strconv" "strings" "testing" "time" "git.golem15.com/golem15/sm-journal-plugin/console" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/summercms/modules/bouncer" ) func TestJournalAPIShowNeighbors(t *testing.T) { env := newPostsEnv(t) past := time.Now().UTC().Add(-2 * time.Hour) mid := past.Add(time.Hour) for i, slug := range []string{"alpha", "beta", "gamma"} { p := models.Post{ Title: "Post " + slug, Slug: slug, Content: "body " + slug, Published: true, PublishedAt: ptrTime(past.Add(time.Duration(i) * time.Hour)), } if err := env.gdb.Create(&p).Error; err != nil { t.Fatal(err) } } _ = mid rec := env.call(t, http.MethodGet, "/_journal/api/v1/posts/beta", "", nil) if rec.Code != http.StatusOK { t.Fatalf("show beta = %d: %s", rec.Code, rec.Body.String()) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } data, _ := body["data"].(map[string]any) if data == nil { t.Fatalf("missing data: %s", rec.Body.String()) } for _, key := range []string{"previous_post", "next_post", "related_posts"} { if _, ok := data[key]; !ok { t.Fatalf("show missing %s: %s", key, rec.Body.String()) } } title, _ := data["title"].(string) if strings.Contains(title, console.UnpublishedTitlePrefix) { t.Fatalf("unpublished lock prefix leaked onto JSON title: %q", title) } } func TestJournalPerPageClamp(t *testing.T) { env := newPostsEnv(t) past := time.Now().UTC().Add(-time.Hour) for i := 0; i < 12; i++ { p := models.Post{ Title: "Row " + strconv.Itoa(i), Slug: "row-" + strconv.Itoa(i), Content: "x", Published: true, PublishedAt: &past, } if err := env.gdb.Create(&p).Error; err != nil { t.Fatal(err) } } nine := env.call(t, http.MethodGet, "/_journal/api/v1/posts?per_page=9", "", nil) if nine.Code != http.StatusOK { t.Fatalf("per_page=9 = %d: %s", nine.Code, nine.Body.String()) } var nineBody struct { Data []any `json:"data"` Meta struct { PerPage int `json:"per_page"` } `json:"meta"` } if err := json.Unmarshal(nine.Body.Bytes(), &nineBody); err != nil { t.Fatal(err) } if nineBody.Meta.PerPage != 9 { t.Fatalf("per_page=9 meta = %d", nineBody.Meta.PerPage) } if len(nineBody.Data) != 9 { t.Fatalf("per_page=9 data len = %d", len(nineBody.Data)) } capped := env.call(t, http.MethodGet, "/_journal/api/v1/posts?per_page=30", "", nil) var capBody struct { Meta struct { PerPage int `json:"per_page"` } `json:"meta"` } if err := json.Unmarshal(capped.Body.Bytes(), &capBody); err != nil { t.Fatal(err) } if capBody.Meta.PerPage != 30 { t.Fatalf("per_page=30 meta = %d, want 30", capBody.Meta.PerPage) } } func TestJournalEditorListDrafts(t *testing.T) { env := newPostsEnv(t) tok := env.admin(t, controllers.PermissionAccessPosts) if err := env.gdb.Create(&models.Post{Title: "Hidden draft", Slug: "hidden-draft", Content: "x", Published: false}).Error; err != nil { t.Fatal(err) } anon := env.call(t, http.MethodGet, "/_journal/api/v1/posts", "", nil) if strings.Contains(anon.Body.String(), "hidden-draft") { t.Fatalf("anonymous list leaked draft: %s", anon.Body.String()) } editor := env.call(t, http.MethodGet, "/_journal/api/v1/posts", tok, nil) if editor.Code != http.StatusOK { t.Fatalf("editor list = %d: %s", editor.Code, editor.Body.String()) } if !strings.Contains(editor.Body.String(), "hidden-draft") { t.Fatalf("editor Bearer list omitted drafts: %s", editor.Body.String()) } } func TestJournalAPIMassAssignRedactor(t *testing.T) { env := newPostsEnv(t) tok := env.admin(t, controllers.PermissionAccessPosts) created := env.call(t, http.MethodPost, "/_journal/api/v1/posts", tok, map[string]any{ "title": "Assign trap", "content": "body", "redactor_id": 99, "user_id": 1, }) if created.Code != http.StatusCreated { t.Fatalf("create = %d: %s", created.Code, created.Body.String()) } var stored models.Post if err := env.gdb.Where("slug = ?", "assign-trap").Or("title = ?", "Assign trap").First(&stored).Error; err != nil { t.Fatal(err) } if stored.RedactorID != nil { t.Fatalf("redactor_id persisted from body: %#v", stored.RedactorID) } authorID := env.lastUserID(t) if stored.UserID == nil || *stored.UserID != authorID { t.Fatalf("user_id = %#v, want stamped principal %d", stored.UserID, authorID) } } func TestPostsAdminForbidden(t *testing.T) { env := newPostsEnv(t) noPerm := env.admin(t) rec := env.call(t, http.MethodGet, adminAPI(adminPostsPath), noPerm, nil) if rec.Code != http.StatusForbidden { t.Fatalf("admin list without access_posts = %d, want 403: %s", rec.Code, rec.Body.String()) } } func TestJournalWriteFrontendAudience(t *testing.T) { env := newPostsEnv(t) front, _, err := bouncer.MintAudience(adminJWTSecret, "1", "https://app.example.test/login", time.Hour, bouncer.AudienceUser) if err != nil { t.Fatal(err) } denied := env.call(t, http.MethodPost, "/_journal/api/v1/posts", front, map[string]any{ "title": "Frontend", "content": "Should not write", }) if denied.Code != http.StatusUnauthorized { t.Fatalf("frontend-audience POST = %d, want 401: %s", denied.Code, denied.Body.String()) } if got, _ := phpErrorString(t, denied.Body.Bytes()); got != "Authentication required" { t.Fatalf("frontend-audience error = %q", got) } }