go-bananas/internal/httpapi/embed_test.go

226 lines
7.4 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:11:35 +02:00
package httpapi_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// embed is the decoded shape of /rt/v1/embed.
type embed struct {
Slug string `json:"slug"`
Name string `json:"name"`
Version int64 `json:"version"`
Language string `json:"language"`
Snippet string `json:"snippet"`
Note string `json:"note"`
BaseURL string `json:"baseUrl"`
}
// embedFor requests the snippet endpoint and fails the test unless it answers 200
// with JSON.
func embedFor(t *testing.T, h http.Handler, query string) embed {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?"+query, nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("content type %q, want JSON. The admin UI fetches this with a JSON parser.", ct)
}
var out embed
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
return out
}
// TestEmbedAlwaysAnswersJSON is the regression test for the bug that made the
// Integrate page unusable: the default target answered text/html while the admin
// UI asked for JSON, so every load reported "the server returned a response that
// could not be parsed".
func TestEmbedAlwaysAnswersJSON(t *testing.T) {
h := newRuntimeRouter(t)
for _, target := range []string{"html", "react", "javascript", ""} {
t.Run("target="+target, func(t *testing.T) {
query := "slug=midnight"
if target != "" {
query += "&target=" + target
}
got := embedFor(t, h, query)
if got.Snippet == "" {
t.Error("snippet is empty")
}
})
}
}
// TestEmbedRejectsUnknownTheme checks that a snippet is never generated for a theme
// that does not exist. The endpoint used to echo back whatever slug it was given.
func TestEmbedRejectsUnknownTheme(t *testing.T) {
h := newRuntimeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?slug=no-such-theme", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404: %s", rec.Code, rec.Body.String())
}
}
// TestEmbedRejectsMalformedSlug covers the shape check. Slugs are interpolated into
// generated source, so a slug carrying a quote has to be refused at the door rather
// than trusted to be escaped later.
func TestEmbedRejectsMalformedSlug(t *testing.T) {
h := newRuntimeRouter(t)
// Each of these breaks out of a JavaScript string literal or a CSS selector if
// it is not rejected.
slugs := []string{
"x';fetch('//evil.example')//",
`x"+alert(1)+"`,
"x</style><script>alert(1)</script>",
"../etc/passwd",
"UPPERCASE",
"has space",
"trailing-",
"-leading",
}
for _, slug := range slugs {
t.Run(slug, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/rt/v1/embed?slug="+urlQueryEscape(slug)+"&target=javascript", nil))
if rec.Code == http.StatusOK {
t.Errorf("accepted a malformed slug and returned a snippet: %s", rec.Body.String())
}
})
}
}
// urlQueryEscape percent-encodes a query parameter value.
func urlQueryEscape(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
const hexDigits = "0123456789ABCDEF"
b.WriteByte('%')
b.WriteByte(hexDigits[c>>4])
b.WriteByte(hexDigits[c&0x0f])
}
return b.String()
}
// TestEmbedSnippetsAreSyntacticallyPlausible is the property that actually matters:
// the snippet a developer pastes has to be parseable code for its language. A
// balanced-quote check is a cheap proxy that catches the whole class of escaping
// bug without pulling in a JavaScript parser.
func TestEmbedSnippetsAreSyntacticallyPlausible(t *testing.T) {
h := newRuntimeRouter(t)
for _, target := range []string{"html", "react", "javascript"} {
t.Run(target, func(t *testing.T) {
got := embedFor(t, h, "slug=midnight&target="+target)
assertBalancedSingleQuotes(t, got.Snippet)
assertBalancedBackticks(t, got.Snippet)
if strings.Count(got.Snippet, "\n") < 2 {
t.Errorf("snippet looks truncated:\n%s", got.Snippet)
}
})
}
}
// assertBalancedSingleQuotes fails when a snippet has an odd number of single
// quotes outside a comment, which is what an unescaped value looks like.
func assertBalancedSingleQuotes(t *testing.T, snippet string) {
t.Helper()
inSingle, inDouble, inBacktick := false, false, false
for i := 0; i < len(snippet); i++ {
c := snippet[i]
switch {
case c == '\\' && (inSingle || inDouble || inBacktick):
i++ // skip the escaped character
case c == '\'' && !inDouble && !inBacktick:
inSingle = !inSingle
case c == '"' && !inSingle && !inBacktick:
inDouble = !inDouble
case c == '`' && !inSingle && !inDouble:
inBacktick = !inBacktick
}
}
if inSingle || inDouble || inBacktick {
t.Errorf("snippet has an unterminated string literal:\n%s", snippet)
}
}
// assertBalancedBackticks fails on an odd number of backticks.
func assertBalancedBackticks(t *testing.T, snippet string) {
t.Helper()
if strings.Count(snippet, "`")%2 != 0 {
t.Errorf("snippet has an unbalanced backtick:\n%s", snippet)
}
}
// TestEmbedRejectsUnknownTarget checks that a typo is reported rather than silently
// returning the HTML page.
func TestEmbedRejectsUnknownTarget(t *testing.T) {
h := newRuntimeRouter(t)
for _, target := range []string{"xml", "HTML", "ts", ".."} {
t.Run(target, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/rt/v1/embed?slug=midnight&target="+urlQueryEscape(target), nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400: %s", rec.Code, rec.Body.String())
}
})
}
}
// TestEmbedIsNotCacheable guards the header. Every snippet embeds this deployment's
// absolute URL, so a cached copy would keep serving the wrong origin after a
// hostname change.
func TestEmbedIsNotCacheable(t *testing.T) {
h := newRuntimeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?slug=midnight", nil))
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control %q, want no-store", cc)
}
}
// TestEmbedPageIsHTML checks the standalone page is still reachable and still HTML,
// since it is a separate handler serving a different kind of caller.
func TestEmbedPageIsHTML(t *testing.T) {
h := newRuntimeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed-page?slug=midnight", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("content type %q, want text/html", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "<!doctype html>") {
t.Error("not an HTML document")
}
// The page must not inline a raw script from the slug: every sample is escaped
// for display inside a <pre>.
if strings.Contains(body, "<script>alert") {
t.Error("page contains an unescaped injected script")
}
}