144 lines
4.8 KiB
Go
144 lines
4.8 KiB
Go
package httpserver_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/testutil"
|
|
)
|
|
|
|
const devOrigin = "http://localhost:5173"
|
|
|
|
// corsAPI is newAPI with an explicit CORS allowlist. It is separate because
|
|
// every other test in this package asserts the same-origin behaviour, where the
|
|
// middleware is not installed at all.
|
|
func corsAPI(t *testing.T, origins ...string) http.Handler {
|
|
t.Helper()
|
|
h := testutil.New(t)
|
|
srv := newServer(t, h, origins)
|
|
handler := srv.Handler()
|
|
|
|
// The API routes below now require a session. Signing in once and attaching
|
|
// the cookie to every request keeps these tests about CORS: without it they
|
|
// would assert 401 and prove nothing about the headers.
|
|
userID, email := seededUser(t, h.Pool)
|
|
setPassword(t, h.Pool, userID)
|
|
result := signIn(t, handler, email, harnessPassword, false)
|
|
if result.code != http.StatusOK || result.cookie == nil {
|
|
t.Fatalf("the CORS harness could not sign in: status %d", result.code)
|
|
}
|
|
return withSession(handler, result.cookie)
|
|
}
|
|
|
|
func send(handler http.Handler, method, path string, headers map[string]string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// An allowed origin gets its own origin echoed back, never "*".
|
|
func TestCORSAllowsConfiguredOrigin(t *testing.T) {
|
|
handler := corsAPI(t, devOrigin)
|
|
|
|
rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{"Origin": devOrigin})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != devOrigin {
|
|
t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, devOrigin)
|
|
}
|
|
if rec.Header().Get("Vary") == "" {
|
|
t.Fatal("a response that varies by Origin must say so")
|
|
}
|
|
}
|
|
|
|
// The preflight the browser sends before a PATCH must succeed without reaching
|
|
// the router, and must name the methods the frontend uses.
|
|
func TestCORSPreflight(t *testing.T) {
|
|
handler := corsAPI(t, devOrigin)
|
|
|
|
rec := send(handler, "OPTIONS", "/api/v1/job-applications/some-id", map[string]string{
|
|
"Origin": devOrigin,
|
|
"Access-Control-Request-Method": "PATCH",
|
|
"Access-Control-Request-Headers": "content-type",
|
|
})
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("preflight: expected 204, got %d (%s)", rec.Code, rec.Body.String())
|
|
}
|
|
allow := rec.Header().Get("Access-Control-Allow-Methods")
|
|
for _, m := range []string{"GET", "POST", "PATCH", "DELETE"} {
|
|
if !contains(allow, m) {
|
|
t.Fatalf("Access-Control-Allow-Methods = %q, missing %s", allow, m)
|
|
}
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "content-type" {
|
|
t.Fatalf("Access-Control-Allow-Headers = %q, want the requested header echoed", got)
|
|
}
|
|
if rec.Header().Get("Access-Control-Max-Age") == "" {
|
|
t.Fatal("preflight result should be cacheable")
|
|
}
|
|
}
|
|
|
|
// An origin that is not on the list gets no CORS headers, so the browser will
|
|
// not hand the response to the page.
|
|
func TestCORSRefusesUnknownOrigin(t *testing.T) {
|
|
handler := corsAPI(t, devOrigin)
|
|
|
|
rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{
|
|
"Origin": "http://evil.example",
|
|
})
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Fatalf("an unlisted origin was allowed: %q", got)
|
|
}
|
|
|
|
pre := send(handler, "OPTIONS", "/api/v1/job-postings", map[string]string{
|
|
"Origin": "http://evil.example",
|
|
"Access-Control-Request-Method": "GET",
|
|
})
|
|
if pre.Code != http.StatusForbidden {
|
|
t.Fatalf("preflight from an unlisted origin: expected 403, got %d", pre.Code)
|
|
}
|
|
}
|
|
|
|
// A caller with no Origin — curl, a health checker, anything server-to-server —
|
|
// is untouched by the middleware.
|
|
func TestCORSIgnoresRequestsWithoutOrigin(t *testing.T) {
|
|
handler := corsAPI(t, devOrigin)
|
|
|
|
rec := send(handler, "GET", "/health", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Fatalf("a request with no Origin got CORS headers: %q", got)
|
|
}
|
|
}
|
|
|
|
// With no allowlist the middleware is not installed, which is the posture for
|
|
// any deployment serving the frontend from the API's own origin.
|
|
func TestCORSOffByDefault(t *testing.T) {
|
|
handler := corsAPI(t) // no origins
|
|
|
|
rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{"Origin": devOrigin})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Fatalf("CORS answered with no allowlist configured: %q", got)
|
|
}
|
|
}
|
|
|
|
func contains(haystack, needle string) bool {
|
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
|
if haystack[i:i+len(needle)] == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|