diff --git a/internal/server/source_url_internal_test.go b/internal/server/source_url_internal_test.go new file mode 100644 index 0000000..05dd2a3 --- /dev/null +++ b/internal/server/source_url_internal_test.go @@ -0,0 +1,330 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "slices" + "strings" + "sync" + "testing" + + "sneak.berlin/go/pixa/internal/session" +) + +// generateFormPattern extracts the CSRF token rendered into the generator +// page's form. +var generateFormPattern = regexp.MustCompile( + `(?s)action="/generate".*?name="gorilla\.csrf\.Token" value="([^"]+)"`) + +// generatedURLPattern extracts the path of the encrypted URL the generator +// page shows. +var generatedURLPattern = regexp.MustCompile(`value="[^"]*(/v1/e/[^"]+)"`) + +// recordingUpstream is a test upstream that answers every request with a PNG +// and records the request target, the path and query as sent, of each request +// it receives. +type recordingUpstream struct { + *httptest.Server + + mu sync.Mutex + received []string +} + +// startRecordingUpstream starts a recordingUpstream, which is closed when the +// test ends. +func startRecordingUpstream(t *testing.T) *recordingUpstream { + t.Helper() + + source := encodeTestPNG(t, 64, 48) + upstream := &recordingUpstream{} + + upstream.Server = httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + upstream.mu.Lock() + upstream.received = append(upstream.received, r.RequestURI) + upstream.mu.Unlock() + + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(source) + })) + t.Cleanup(upstream.Close) + + return upstream +} + +// requestTargets returns the request targets the upstream has received, in +// the order it received them. +func (u *recordingUpstream) requestTargets() []string { + u.mu.Lock() + defer u.mu.Unlock() + + return slices.Clone(u.received) +} + +// pageOrigin is the origin of the login and generator pages below, opened +// over HTTPS; httptest gives every request the host example.com. +const pageOrigin = "https://example.com" + +// openRoot sends GET / to s over HTTPS, with cookie when it is not nil, and +// returns the page: the login page, or the generator page for a login session. +func openRoot( + t *testing.T, s *Server, cookie *http.Cookie, +) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, + pageOrigin+"/", nil) + if cookie != nil { + req.AddCookie(cookie) + } + + page := httptest.NewRecorder() + s.ServeHTTP(page, req) + + return page +} + +// submitForm posts form to path on s as a browser submits it from page over +// HTTPS: with the CSRF token tokenPattern finds in page, the cookies page set, +// cookie when it is not nil, and a Referer naming the page, which pixa needs +// while debug is off, as it is in startImageProxy's config. +func submitForm( + t *testing.T, s *Server, page *httptest.ResponseRecorder, + tokenPattern *regexp.Regexp, path string, form url.Values, + cookie *http.Cookie, +) *httptest.ResponseRecorder { + t.Helper() + + match := tokenPattern.FindStringSubmatch(page.Body.String()) + if match == nil { + t.Fatalf("the page has no form for %s with a CSRF token", path) + } + + form.Set("gorilla.csrf.Token", match[1]) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, + pageOrigin+path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", pageOrigin+"/") + + for _, c := range page.Result().Cookies() { + req.AddCookie(c) + } + + if cookie != nil { + req.AddCookie(cookie) + } + + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + + return rec +} + +// postGenerate logs in to s with the signing key, submits the generator page's +// form with sourceURL, and returns the response. +func postGenerate( + t *testing.T, s *Server, sourceURL string, +) *httptest.ResponseRecorder { + t.Helper() + + login := submitForm(t, s, openRoot(t, s, nil), csrfFieldPattern, "/", + url.Values{"key": {testSigningKey}}, nil) + + var sessionCookie *http.Cookie + + for _, c := range login.Result().Cookies() { + if c.Name == session.CookieName { + sessionCookie = c + } + } + + if sessionCookie == nil { + t.Fatalf("logging in answered %d and set no session cookie", login.Code) + } + + rec := submitForm(t, s, openRoot(t, s, sessionCookie), generateFormPattern, + "/generate", url.Values{"url": {sourceURL}}, sessionCookie) + t.Logf("POST /generate with url %s: %d", sourceURL, rec.Code) + + return rec +} + +// TestImageURLFetchesItsSourcePathAndQuery requests image URLs whose source +// query holds a space and whose source path holds a "%", each written +// percent-encoded, and checks the one request the upstream receives: the +// source path percent-encoded again, and the source query with the space +// written %20. +func TestImageURLFetchesItsSourcePathAndQuery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + // pathAndQuery is the source path and query as the image URL writes + // them + pathAndQuery string + // wantRequest is the path and query the upstream must receive + wantRequest string + }{ + {"space in the query", "/photo.png%3Fname=my%20cat", + "/photo.png?name=my%20cat"}, + {"percent sign in the path", "/100%25.png", "/100%25.png"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + upstream := startRecordingUpstream(t) + s, _, _ := startImageProxy(t, upstream.Server) + + getImage(t, s, "/v1/image/"+upstreamHost+tt.pathAndQuery+"/orig.png") + + got := upstream.requestTargets() + t.Logf("upstream received %q", got) + + if !slices.Equal(got, []string{tt.wantRequest}) { + t.Errorf("upstream received %q, want [%q]", got, tt.wantRequest) + } + }) + } +} + +// TestSourcePathsHoldingSpaceAndPercent20AreFetchedApart requests twice each +// of the source paths /my cat.png and /my%20cat.png, which the image URL +// writes my%20cat.png and my%2520cat.png. Each is fetched once, as a request +// of its own, and its second request is served from the cache. +func TestSourcePathsHoldingSpaceAndPercent20AreFetchedApart(t *testing.T) { + t.Parallel() + + upstream := startRecordingUpstream(t) + s, _, _ := startImageProxy(t, upstream.Server) + + imageURLs := []string{ + "/v1/image/" + upstreamHost + "/my%20cat.png/orig.png", + "/v1/image/" + upstreamHost + "/my%2520cat.png/orig.png", + } + + for _, imageURL := range imageURLs { + getImage(t, s, imageURL) + } + + for _, imageURL := range imageURLs { + cacheStatus := getImage(t, s, imageURL).Header().Get("X-Pixa-Cache") + if cacheStatus != "HIT" { + t.Errorf("second GET %s: X-Pixa-Cache = %q, want HIT", + imageURL, cacheStatus) + } + } + + got := upstream.requestTargets() + t.Logf("upstream received %q", got) + + want := []string{"/my%20cat.png", "/my%2520cat.png"} + if !slices.Equal(got, want) { + t.Errorf("upstream received %q, want %q", got, want) + } +} + +// TestSourceQueryWrittenTwoWaysIsOneUpstreamRequest requests the source query +// name=my%20cat written as the README says, name=my%2520cat, and with the +// space written %20, name=my%20cat. Both are one source query: the upstream +// receives one request, and the second image URL is served from the cache. +func TestSourceQueryWrittenTwoWaysIsOneUpstreamRequest(t *testing.T) { + t.Parallel() + + upstream := startRecordingUpstream(t) + s, _, _ := startImageProxy(t, upstream.Server) + + getImage(t, s, "/v1/image/"+upstreamHost+"/photo.png%3Fname=my%2520cat/orig.png") + + second := getImage(t, s, + "/v1/image/"+upstreamHost+"/photo.png%3Fname=my%20cat/orig.png") + if got := second.Header().Get("X-Pixa-Cache"); got != "HIT" { + t.Errorf("second X-Pixa-Cache = %q, want HIT", got) + } + + got := upstream.requestTargets() + t.Logf("upstream received %q", got) + + want := []string{"/photo.png?name=my%20cat"} + if !slices.Equal(got, want) { + t.Errorf("upstream received %q, want %q", got, want) + } +} + +// TestGeneratedURLFetchesTheSourceURLAsEntered makes an encrypted URL on the +// generator page for source URLs holding %20, %25 and %23 in the path or the +// query, requests it, and checks that the upstream receives the path and +// query exactly as entered. +func TestGeneratedURLFetchesTheSourceURLAsEntered(t *testing.T) { + t.Parallel() + + // The names hold no "%": each test's state directory is named after it + // and goes into the database URL, which decodes percent-escapes + tests := []struct { + name string + pathAndQuery string + }{ + {"space in the path", "/my%20cat.png"}, + {"percent sign in the path", "/100%25.png"}, + {"number sign in the path", "/a%23b.png"}, + {"all three in the query", "/photo.png?name=my%20cat%25%23"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + upstream := startRecordingUpstream(t) + s, _, _ := startImageProxy(t, upstream.Server) + + rec := postGenerate(t, s, "https://"+upstreamHost+tt.pathAndQuery) + + match := generatedURLPattern.FindStringSubmatch(rec.Body.String()) + if rec.Code != http.StatusOK || match == nil { + t.Fatalf("POST /generate: %d with no encrypted URL; body %s", + rec.Code, rec.Body.String()) + } + + getImage(t, s, match[1]) + + got := upstream.requestTargets() + t.Logf("upstream received %q", got) + + if !slices.Equal(got, []string{tt.pathAndQuery}) { + t.Errorf("upstream received %q, want [%q]", got, tt.pathAndQuery) + } + }) + } +} + +// TestGeneratorRefusesSourceURLItWouldFetchAnotherWay submits a source URL +// whose path holds %2F. pixa keeps the path decoded and would fetch it with a +// "/" there, which is another URL, so the generator page answers 400, shows +// the URL pixa would fetch, and makes no encrypted URL. +func TestGeneratorRefusesSourceURLItWouldFetchAnotherWay(t *testing.T) { + t.Parallel() + + s, _, _ := startImageProxy(t, startRecordingUpstream(t).Server) + + rec := postGenerate(t, s, "https://"+upstreamHost+"/a%2Fb.png") + body := rec.Body.String() + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + + if strings.Contains(body, "/v1/e/") { + t.Error("the page shows an encrypted URL") + } + + // The test config sets allow_http, so pixa would fetch over plain HTTP + fetchURL := "http://" + upstreamHost + "/a/b.png" + if !strings.Contains(body, fetchURL) { + t.Errorf("the page does not show the URL pixa would fetch, %s; body %s", + fetchURL, body) + } +}