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:
183
modules/cabana/markdown_preview_test.go
Normal file
183
modules/cabana/markdown_preview_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user