feat(cabana): add markdown preview admin route

- POST {prefix}/api/v1/markdown/preview renders {markdown} through
  cabana.RenderMarkdown in the backend-guarded group behind requireAjax
- refused output is a 422 validation_failed on markdown with a fixed message
- swag annotation, regenerated admin.json and schema.d.ts
- route inventories, CSRF walk (26) and OpenAPI conformance learn the route
- README and docs/backend/forms.md document the route
This commit is contained in:
Jakub Zych
2026-10-06 20:53:07 +02:00
parent 0ff928d6cf
commit b492e79f2b
13 changed files with 517 additions and 6 deletions

View File

@@ -0,0 +1,183 @@
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, "<h1>Hello</h1>") {
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{"<script", "<iframe", "onerror", "javascript:", "vbscript:", "data:"}
cases := []struct {
name string
src string
must200 bool
mustMiss string
}{
{"script", "<script>alert(1)</script>", true, "<script"},
{"iframe", `<iframe src="https://evil.test"></iframe>`, false, "<iframe"},
{"onerror", `<img src=x onerror="alert(1)">`, 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")
}