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", "../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, "") { 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
. if strings.Contains(body, "