Files
krow_backend/go-api/internal/httpserver/cors_test.go
Suriyakumarvijayanayagam 954ba9076f Add CORS credentials, transactional endpoints, and container deployment
CORS
  cors.go never set Access-Control-Allow-Credentials, so the
  cookie-authenticated API was unreadable from any cross-origin frontend:
  the server answered correctly and the browser blocked the page from
  reading it. Set for allowlisted origins on both the preflight and the
  actual response. Three tests added.

  HTTP_COOKIE_SAMESITE (lax|none|strict, default lax) is new. CORS is only
  half of what a cross-origin browser call needs; SameSite is judged on
  registrable domain, so a frontend on an unrelated domain gets perfect CORS
  headers and still no cookie. "none" is the only value that survives that,
  and validate() refuses it without the Secure flag.

  The "*" rejection now explains itself: browsers refuse Allow-Origin "*"
  together with credentials, so it would break every authenticated call
  rather than loosen anything.

Transactional endpoints (api-contract.md 12.1)
  POST /api/v1/job-applications/{id}/hire
  POST /api/v1/job-postings/{id}/assignments

  Replaces two client-side loops that wrote several records with no
  transaction and no rollback. Each is now one endpoint and one transaction,
  built over repo.Repo so org scoping, derived columns, type casts and error
  translation are not re-derived. Authorization reuses the existing policy
  table rather than adding a parallel one: a workflow is exactly as
  privileged as the writes it performs. 13 tests, including both rollback
  paths.

Bug fix in the repository layer
  repo.bindValue handled int64/int/float64/string but not int32, which is
  what pgx returns for a PostgreSQL `int` column. Nothing previously read a
  record and wrote one of its fields elsewhere, so it never surfaced; the
  hire flow does exactly that and failed with "ai_score must be a number".
  Both KindInt and KindFloat now accept the widths pgx actually produces.

Deployment
  infrastructure/Dockerfile.api  multi-stage, cross-compiling (BUILDPLATFORM
    + GOARCH) so linux/amd64 builds from arm64 are compiled rather than
    emulated. Alpine runtime, non-root uid 10001, 22.1 MB. Ships api, seed,
    setpassword and migrate, plus the migrations, so a Kubernetes
    initContainer can apply the schema from the same image and tag as the
    API. HEALTHCHECK keys on status code, not body, so a "degraded" instance
    is not pulled from rotation during a migration window.

  infrastructure/docker-compose.yml  migrations run to completion before the
    API starts. Assumes a managed PostgreSQL; the local-db overlay adds one
    with TLS enabled so APP_ENV=production is met rather than dodged.

  scripts/drop_public_tables.go  the one-off used to clear an unrelated
    schema from krowdb on 2026-08-24, kept for the record. Build-tagged
    ignore and gated on CONFIRM_DROP=yes.

Verified against PostgreSQL: 16/16 new tests pass, and the image was built,
run and exercised end to end (login, CORS preflight, authenticated reads,
transaction rollback).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmQiGq73Uyfq7J4yR8Vxxw
2026-08-25 11:33:01 +05:30

200 lines
7.3 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)
}
}
// Authentication is a cookie, so a cross-origin frontend calling with
// `credentials: 'include'` needs Access-Control-Allow-Credentials on the actual
// response. Without it the browser blocks the page from reading a reply the
// server answered perfectly well, and the app sees an opaque network failure
// beside a 200 in the server log.
func TestCORSAllowsCredentialsOnResponse(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-Credentials"); got != "true" {
t.Fatalf("Access-Control-Allow-Credentials = %q, want \"true\" — "+
"a cookie-authenticated API is unreadable cross-origin without it", got)
}
// Allow-Credentials with a wildcard origin is rejected by every browser, so
// the two must never appear together.
if got := rec.Header().Get("Access-Control-Allow-Origin"); got == "*" {
t.Fatal("Access-Control-Allow-Origin is \"*\" alongside credentials; browsers refuse that pairing")
}
}
// The preflight decides whether the browser is willing to SEND the cookie at
// all, so it needs the header too — separately from the actual response.
func TestCORSAllowsCredentialsOnPreflight(t *testing.T) {
handler := corsAPI(t, devOrigin)
rec := send(handler, "OPTIONS", "/api/v1/job-postings", map[string]string{
"Origin": devOrigin,
"Access-Control-Request-Method": "POST",
})
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("preflight Access-Control-Allow-Credentials = %q, want \"true\"", got)
}
}
// An origin that is not on the allowlist must not be handed credentials
// permission — the header is worthless on its own, but pairing it with a
// reflected origin would be the classic misconfiguration.
func TestCORSWithholdsCredentialsFromUnknownOrigin(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-Credentials"); got != "" {
t.Fatalf("an unlisted origin was granted credentials: %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("an unlisted origin was echoed back: %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
}