package cabana import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "git.golem15.com/golem15/summercms/modules/bouncer" ) // previewRequest builds a POST /markdown/preview request with a raw body and // an optional principal in the context. func previewRequest(body string, principal *bouncer.Principal) *http.Request { req := httptest.NewRequest(http.MethodPost, adminAPI("/markdown/preview"), strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") if principal != nil { req = req.WithContext(bouncer.WithUser(req.Context(), principal)) } return req } func previewJSON(t *testing.T, markdown string) string { t.Helper() raw, err := json.Marshal(AdminMarkdownPreviewRequest{Markdown: markdown}) if err != nil { t.Fatal(err) } return string(raw) } func previewBackend() *bouncer.Principal { return &bouncer.Principal{ID: 1, Backend: true} } // decodePreview decodes a 200 answer strictly into the documented envelope. func decodePreview(t *testing.T, raw []byte) AdminMarkdownPreviewResult { t.Helper() dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() var out Envelope[AdminMarkdownPreviewResult] if err := dec.Decode(&out); err != nil { t.Fatalf("decode %s: %v", raw, err) } return out.Data } // previewDetails returns the error code and details of an error answer. func previewDetails(t *testing.T, raw []byte) (string, map[string][]string) { t.Helper() var body struct { Error struct { Code string `json:"code"` Details map[string][]string `json:"details"` } `json:"error"` } if err := json.Unmarshal(raw, &body); err != nil { t.Fatalf("decode %s: %v", raw, err) } return body.Error.Code, body.Error.Details } func TestMarkdownPreviewRequiresBackendPrincipal(t *testing.T) { for name, principal := range map[string]*bouncer.Principal{ "none": nil, "frontend": {ID: 7, Backend: false}, } { t.Run(name, func(t *testing.T) { rec := httptest.NewRecorder() (&service{}).markdownPreview(rec, previewRequest(previewJSON(t, "# Hello"), principal)) if rec.Code != http.StatusUnauthorized { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } assertErrorCode(t, rec.Body.Bytes(), "unauthenticated") }) } } func TestMarkdownPreviewRendersHTML(t *testing.T) { rec := httptest.NewRecorder() (&service{}).markdownPreview(rec, previewRequest(`{"markdown":"# Hello"}`, previewBackend())) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if got := decodePreview(t, rec.Body.Bytes()); !strings.Contains(got.HTML, "

Hello

") { t.Fatalf("html=%q", got.HTML) } empty := httptest.NewRecorder() (&service{}).markdownPreview(empty, previewRequest(`{"markdown":""}`, previewBackend())) if empty.Code != http.StatusOK { t.Fatalf("empty status=%d body=%s", empty.Code, empty.Body.String()) } if got := decodePreview(t, empty.Body.Bytes()); got.HTML != "" { t.Fatalf("empty html=%q", got.HTML) } } func TestMarkdownPreviewStripsUnsafeHTML(t *testing.T) { needles := []string{"alert(1)", true, "`, false, "`, false, "onerror"}, {"javascript", "[x](javascript:alert(1))", false, "javascript:"}, {"vbscript", "[x](vbscript:msgbox(1))", false, "vbscript:"}, {"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", false, "data:"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rec := httptest.NewRecorder() (&service{}).markdownPreview(rec, previewRequest(previewJSON(t, tc.src), previewBackend())) switch rec.Code { case http.StatusOK: html := strings.ToLower(decodePreview(t, rec.Body.Bytes()).HTML) for _, needle := range needles { if strings.Contains(html, needle) { t.Fatalf("unsafe %q survived: %s", needle, html) } } case http.StatusUnprocessableEntity: if tc.must200 { t.Fatalf("%s must be stripped and answered 200, got 422: %s", tc.name, rec.Body.String()) } code, details := previewDetails(t, rec.Body.Bytes()) if code != "validation_failed" || len(details["markdown"]) == 0 { t.Fatalf("code=%q details=%v", code, details) } if strings.Contains(strings.ToLower(rec.Body.String()), tc.mustMiss) { t.Fatalf("refusal echoes %q: %s", tc.mustMiss, rec.Body.String()) } default: t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } }) } } func TestMarkdownPreviewRefusesGatedOutput(t *testing.T) { rec := httptest.NewRecorder() (&service{}).markdownPreview(rec, previewRequest(previewJSON(t, "see data: here"), previewBackend())) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } code, details := previewDetails(t, rec.Body.Bytes()) if code != "validation_failed" || len(details["markdown"]) == 0 || details["markdown"][0] == "" { t.Fatalf("code=%q details=%v", code, details) } if strings.Contains(rec.Body.String(), "cabana:") || strings.Contains(rec.Body.String(), "dangerous URL scheme") { t.Fatalf("refusal echoes the renderer error: %s", rec.Body.String()) } } func TestMarkdownPreviewRefusesInvalidBodies(t *testing.T) { for name, body := range map[string]string{ "unknown key": `{"markdown":"x","extra":1}`, "malformed": `{"markdown":`, "trailing": `{"markdown":"x"} {}`, } { t.Run(name, func(t *testing.T) { rec := httptest.NewRecorder() (&service{}).markdownPreview(rec, previewRequest(body, previewBackend())) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } assertErrorCode(t, rec.Body.Bytes(), "validation_failed") }) } big := httptest.NewRecorder() (&service{defaultBytes: 64}).markdownPreview(big, previewRequest(previewJSON(t, strings.Repeat("a", 200)), previewBackend())) if big.Code != http.StatusRequestEntityTooLarge { t.Fatalf("oversized status=%d body=%s", big.Code, big.Body.String()) } assertErrorCode(t, big.Body.Bytes(), "payload_too_large") }