361 lines
12 KiB
Go
361 lines
12 KiB
Go
|
|
package bdd
|
||
|
|
|
||
|
|
import (
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"go-doc/storage"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TestFeatureBadgedCommentInjection covers:
|
||
|
|
//
|
||
|
|
// Given an existing documentation page
|
||
|
|
// When a developer adds a comment with a developer badge
|
||
|
|
// Then it should be appended to the JSON array and rendered with a red visual badge
|
||
|
|
func TestFeatureBadgedCommentInjection(t *testing.T) {
|
||
|
|
Run(t, Feature{
|
||
|
|
Name: "Badged comment injection",
|
||
|
|
Scenarios: []Scenario{
|
||
|
|
{
|
||
|
|
Name: "a developer comment is appended to the JSON array with a red badge",
|
||
|
|
Given: Steps{
|
||
|
|
{Text: "an existing documentation page for Core Service API", Do: func(c *Context) error {
|
||
|
|
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nBilling endpoints.\n")
|
||
|
|
return err
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
When: Steps{
|
||
|
|
{Text: "a developer posts a comment through POST /api/docs/core-service-api/comments", Do: func(c *Context) error {
|
||
|
|
response, payload, err := c.Do(
|
||
|
|
"POST", "/api/docs/core-service-api/comments", "application/json",
|
||
|
|
`{"author":"ana","role":"Developer","content":"The retry budget is undocumented."}`,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := ExpectStatus(response, 201); err != nil {
|
||
|
|
return errf("%w (body: %s)", err, payload)
|
||
|
|
}
|
||
|
|
created, err := Decode[struct {
|
||
|
|
Comment storage.Comment `json:"comment"`
|
||
|
|
Badge string `json:"badge"`
|
||
|
|
}](payload)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
c.Remember("comment", created.Comment)
|
||
|
|
c.Remember("badge", created.Badge)
|
||
|
|
return nil
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
Then: Steps{
|
||
|
|
{Text: "the comment is stored with the developer role", Do: func(c *Context) error {
|
||
|
|
comment := c.RecallAs[storage.Comment]("comment")
|
||
|
|
return ExpectEqual(comment.Role, storage.RoleDeveloper, "stored role")
|
||
|
|
}},
|
||
|
|
{Text: "the comment carries the red 🔴 developer badge", Do: func(c *Context) error {
|
||
|
|
return ExpectEqual(c.RecallAs[string]("badge"), badgeContract[storage.RoleDeveloper], "response badge")
|
||
|
|
}},
|
||
|
|
{Text: "data/comments/core-service-api-comments.json is a JSON array containing it", Do: func(c *Context) error {
|
||
|
|
raw, err := c.RawJSON("core-service-api")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !strings.HasPrefix(strings.TrimLeft(string(raw), " \n\t"), JSONArrayPrefix) {
|
||
|
|
return errf("feedback file is not a JSON array: %q", string(raw))
|
||
|
|
}
|
||
|
|
comments, err := c.RawComments("core-service-api")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if len(comments) != 1 {
|
||
|
|
return errf("expected 1 stored comment, got %d", len(comments))
|
||
|
|
}
|
||
|
|
stored := comments[0]
|
||
|
|
if stored.ID == "" {
|
||
|
|
return errf("stored comment has no ID")
|
||
|
|
}
|
||
|
|
if stored.Author != "ana" {
|
||
|
|
return errf("expected author ana, got %q", stored.Author)
|
||
|
|
}
|
||
|
|
if !strings.Contains(stored.Content, "retry budget") {
|
||
|
|
return errf("stored content mismatch: %q", stored.Content)
|
||
|
|
}
|
||
|
|
return ExpectEqual(stored.Badge, badgeContract[storage.RoleDeveloper], "stored badge")
|
||
|
|
}},
|
||
|
|
{Text: "the badge renders in the red developer colour #ef4444", Do: func(c *Context) error {
|
||
|
|
role := c.RecallAs[storage.Comment]("comment").Role
|
||
|
|
return ExpectEqual(role.Hex(), badgeHex[storage.RoleDeveloper], "badge colour")
|
||
|
|
}},
|
||
|
|
{Text: "GET /api/docs/core-service-api/comments returns the badged comment", Do: func(c *Context) error {
|
||
|
|
response, payload, err := c.Do("GET", "/api/docs/core-service-api/comments", "", "")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := ExpectStatus(response, 200); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
listed, err := Decode[struct {
|
||
|
|
Comments []storage.Comment `json:"comments"`
|
||
|
|
Count int `json:"count"`
|
||
|
|
}](payload)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if listed.Count != 1 {
|
||
|
|
return errf("expected count 1, got %d", listed.Count)
|
||
|
|
}
|
||
|
|
return ExpectEqual(listed.Comments[0].Badge, "🔴", "listed badge")
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
Name: "all three audience badges are applied to their own roles",
|
||
|
|
Given: Steps{
|
||
|
|
{Text: "a documentation page with an existing user comment", Do: func(c *Context) error {
|
||
|
|
if _, err := c.Engine.SaveDoc("deploy-pipeline", "# Deploy Pipeline\n"); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
_, err := appendComment(c, "deploy-pipeline", storage.RoleUser, "sam", "The wizard is confusing")
|
||
|
|
return err
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
When: Steps{
|
||
|
|
{Text: "an operator and a developer comment are added", Do: func(c *Context) error {
|
||
|
|
if _, err := appendComment(c, "deploy-pipeline", storage.RoleOperator, "robin", "Rollback takes 20 minutes"); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
_, err := appendComment(c, "deploy-pipeline", storage.RoleDeveloper, "kit", "Add a --dry-run flag")
|
||
|
|
return err
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
Then: Steps{
|
||
|
|
{Text: "the JSON array holds all three comments in insertion order", Do: func(c *Context) error {
|
||
|
|
comments, err := c.RawComments("deploy-pipeline")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if len(comments) != 3 {
|
||
|
|
return errf("expected 3 comments, got %d", len(comments))
|
||
|
|
}
|
||
|
|
want := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
|
||
|
|
for i, role := range want {
|
||
|
|
if comments[i].Role != role {
|
||
|
|
return errf("comment %d: expected role %s, got %s", i, role, comments[i].Role)
|
||
|
|
}
|
||
|
|
if comments[i].Badge != badgeContract[role] {
|
||
|
|
return errf("comment %d: expected badge %s, got %s", i, badgeContract[role], comments[i].Badge)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}},
|
||
|
|
{Text: "each badge matches its role colour", Do: func(c *Context) error {
|
||
|
|
comments, err := c.RawComments("deploy-pipeline")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
for _, comment := range comments {
|
||
|
|
if comment.Role.Hex() != badgeHex[comment.Role] {
|
||
|
|
return errf("unexpected colour for %s", comment.Role)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}},
|
||
|
|
{Text: "GET /api/docs/deploy-pipeline/stats aggregates by role", Do: func(c *Context) error {
|
||
|
|
response, payload, err := c.Do("GET", "/api/docs/deploy-pipeline/stats", "", "")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := ExpectStatus(response, 200); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
stats, err := Decode[storage.CommentStats](payload)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if stats.Total != 3 {
|
||
|
|
return errf("expected total 3, got %d", stats.Total)
|
||
|
|
}
|
||
|
|
for _, role := range []string{"user", "operator", "developer"} {
|
||
|
|
if stats.ByRole[role] != 1 {
|
||
|
|
return errf("expected 1 %s comment, got %d", role, stats.ByRole[role])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
Name: "the badge is derived server-side and cannot be spoofed by the client",
|
||
|
|
Given: Steps{
|
||
|
|
{Text: "a documentation page", Do: func(c *Context) error {
|
||
|
|
_, err := c.Engine.SaveDoc("trust-boundary", "# Trust Boundary\n")
|
||
|
|
return err
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
When: Steps{
|
||
|
|
{Text: `a developer posts a comment claiming the user badge "🟢"`, Do: func(c *Context) error {
|
||
|
|
response, payload, err := c.Do(
|
||
|
|
"POST", "/api/docs/trust-boundary/comments", "application/json",
|
||
|
|
`{"author":"mallory","role":"developer","content":"I am a user, honest","badge":"🟢"}`,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := ExpectStatus(response, 201); err != nil {
|
||
|
|
return errf("%w (body: %s)", err, payload)
|
||
|
|
}
|
||
|
|
c.Remember("payload", string(payload))
|
||
|
|
return nil
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
Then: Steps{
|
||
|
|
{Text: "the server overrides the claim with the developer badge", Do: func(c *Context) error {
|
||
|
|
comments, err := c.RawComments("trust-boundary")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if len(comments) != 1 {
|
||
|
|
return errf("expected 1 comment, got %d", len(comments))
|
||
|
|
}
|
||
|
|
return ExpectEqual(comments[0].Badge, "🔴", "server-assigned badge")
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
Name: "an unknown role is rejected with a 400",
|
||
|
|
Given: Steps{
|
||
|
|
{Text: "a documentation page", Do: func(c *Context) error {
|
||
|
|
_, err := c.Engine.SaveDoc("role-guard", "# Role Guard\n")
|
||
|
|
return err
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
When: Steps{
|
||
|
|
{Text: `a comment is posted with role "root"`, Do: func(c *Context) error {
|
||
|
|
response, payload, err := c.Do(
|
||
|
|
"POST", "/api/docs/role-guard/comments", "application/json",
|
||
|
|
`{"author":"mallory","role":"root","content":"let me in"}`,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
c.Remember("status", response.StatusCode)
|
||
|
|
c.Remember("body", string(payload))
|
||
|
|
return nil
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
Then: Steps{
|
||
|
|
{Text: "the API answers 400 with the invalid_role code", Do: func(c *Context) error {
|
||
|
|
if err := ExpectEqual(c.RecallAs[int]("status"), 400, "status"); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return ExpectContains(c.RecallAs[string]("body"), "invalid_role", "error body")
|
||
|
|
}},
|
||
|
|
{Text: "no feedback file is created", Do: func(c *Context) error {
|
||
|
|
exists, err := c.FileExists(c.CommentsPath("role-guard"))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if exists {
|
||
|
|
return errf("a feedback file was created for a rejected comment")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
Name: "an empty comment is rejected",
|
||
|
|
Given: Steps{
|
||
|
|
{Text: "a documentation page", Do: func(c *Context) error {
|
||
|
|
_, err := c.Engine.SaveDoc("empty-guard", "# Empty Guard\n")
|
||
|
|
return err
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
When: Steps{
|
||
|
|
{Text: `a comment with blank content is posted`, Do: func(c *Context) error {
|
||
|
|
response, _, err := c.Do(
|
||
|
|
"POST", "/api/docs/empty-guard/comments", "application/json",
|
||
|
|
`{"author":"ana","role":"operator","content":" "}`,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
c.Remember("status", response.StatusCode)
|
||
|
|
return nil
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
Then: Steps{
|
||
|
|
{Text: "the API answers 400", Do: func(c *Context) error {
|
||
|
|
return ExpectEqual(c.RecallAs[int]("status"), 400, "status")
|
||
|
|
}},
|
||
|
|
{Text: "no feedback file is created", Do: func(c *Context) error {
|
||
|
|
exists, err := c.FileExists(c.CommentsPath("empty-guard"))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if exists {
|
||
|
|
return errf("a feedback file was created for a rejected comment")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
Name: "a comment on an unknown slug still creates the feedback file",
|
||
|
|
Given: Steps{
|
||
|
|
{Text: "no document exists for the slug new-service", Do: func(c *Context) error {
|
||
|
|
exists, err := c.FileExists(c.DocPath("new-service"))
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if exists {
|
||
|
|
return errf("the document should not exist yet")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
When: Steps{
|
||
|
|
{Text: "the first user comment is posted for new-service", Do: func(c *Context) error {
|
||
|
|
response, _, err := c.Do(
|
||
|
|
"POST", "/api/docs/new-service/comments", "application/json",
|
||
|
|
`{"author":"jo","role":"user","content":"Is this documented?"}`,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return ExpectStatus(response, 201)
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
Then: Steps{
|
||
|
|
{Text: "the feedback file holds the user badge 🟢", Do: func(c *Context) error {
|
||
|
|
comments, err := c.RawComments("new-service")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if len(comments) != 1 {
|
||
|
|
return errf("expected 1 comment, got %d", len(comments))
|
||
|
|
}
|
||
|
|
return ExpectEqual(comments[0].Badge, "🟢", "badge")
|
||
|
|
}},
|
||
|
|
{Text: "reading the comments of a slug with no feedback returns an empty list", Do: func(c *Context) error {
|
||
|
|
response, payload, err := c.Do("GET", "/api/docs/never-seen/comments", "", "")
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := ExpectStatus(response, 200); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
listed, err := Decode[struct {
|
||
|
|
Comments []storage.Comment `json:"comments"`
|
||
|
|
}](payload)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return ExpectEqual(len(listed.Comments), 0, "comment count")
|
||
|
|
}},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|