package server_test import ( "bytes" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "cmbuyer/admin/internal/tasks" ) const ( startKeyForHTTP = "c3c9f507-7473-4fa6-8d71-8786c34c6301" taskIDForHTTP = "a3c9f507-7473-4fa6-8d71-8786c34c6301" ) func TestStartPurchasesAuthenticatesBeforeInspectingRequestBody(t *testing.T) { store := &startRecordingStore{} router, _ := newRouterWithStore(t, store) hugeMalformed := `{"start_key":"` + strings.Repeat("x", 70<<10) for name, request := range map[string]*http.Request{ "anonymous malformed": newStartRequest(t, hugeMalformed, "text/plain", "", nil), "device bearer": newStartRequest(t, validStartBody(), "application/json", "", nil), } { t.Run(name, func(t *testing.T) { if name == "device bearer" { request.Header.Set("Authorization", "Bearer device-token") } response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", response.Code) } }) } cookie, csrf := authenticatedStartSession(t, router) for name, token := range map[string]string{"missing CSRF": "", "wrong CSRF": "wrong-csrf"} { request := newStartRequest(t, hugeMalformed, "text/plain", token, cookie) response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("%s status = %d, want 403", name, response.Code) } } if csrf == "" { t.Fatal("authenticated page did not contain a CSRF token") } if store.startCalls != 0 { t.Fatalf("unauthorized requests called store %d times", store.startCalls) } } func TestStartPurchasesRejectsInvalidUTF8BeforeJSONDecoding(t *testing.T) { validPrefix := []byte(`{"start_key":"` + startKeyForHTTP + `","tasks":[],"start_key":"`) duplicateKeyBypass := append(append([]byte(nil), validPrefix...), 0xff) duplicateKeyBypass = append(duplicateKeyBypass, []byte(`"}`)...) invalidWhitespace := append([]byte(validStartBody()), 0xfe) for name, body := range map[string][]byte{ "invalid byte after JSON": invalidWhitespace, "invalid duplicate-key value": duplicateKeyBypass, } { t.Run(name, func(t *testing.T) { store := &startRecordingStore{} router, _ := newRouterWithStore(t, store) cookie, csrf := authenticatedStartSession(t, router) response := serveStartBytes(t, router, body, "application/json", csrf, cookie) if response.Code != http.StatusBadRequest || store.startCalls != 0 { t.Fatalf("status/calls = %d/%d, want 400/0", response.Code, store.startCalls) } if response.Body.Len() != 0 { t.Fatalf("invalid UTF-8 response leaked body %q", response.Body.String()) } }) } } func TestStartPurchasesEnforcesExact64KiBBodyBoundary(t *testing.T) { const limit = 64 << 10 base := validStartBody() for name, test := range map[string]struct { body string want int wantCalls int }{ "exact limit": {body: base + strings.Repeat(" ", limit-len(base)), want: http.StatusOK, wantCalls: 1}, "one over": {body: base + strings.Repeat(" ", limit-len(base)+1), want: http.StatusRequestEntityTooLarge}, } { t.Run(name, func(t *testing.T) { store := &startRecordingStore{startResult: successfulStartResult()} router, _ := newRouterWithStore(t, store) cookie, csrf := authenticatedStartSession(t, router) response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie) if response.Code != test.want || store.startCalls != test.wantCalls { t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls) } }) } } func TestStartPurchasesContentTypeContract(t *testing.T) { for _, contentType := range []string{ "application/json", "application/json; charset=utf-8", "application/json;charset=UTF-8", } { t.Run("accept "+contentType, func(t *testing.T) { store := &startRecordingStore{startResult: successfulStartResult()} router, _ := newRouterWithStore(t, store) cookie, csrf := authenticatedStartSession(t, router) response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie) if response.Code != http.StatusOK || store.startCalls != 1 { t.Fatalf("status/calls = %d/%d, want 200/1", response.Code, store.startCalls) } }) } for _, contentType := range []string{ "", "text/plain", "application/json-patch+json", "application/json; charset=gbk", "application/json; profile=unapproved", "application/json; charset", } { t.Run("reject "+contentType, func(t *testing.T) { store := &startRecordingStore{} router, _ := newRouterWithStore(t, store) cookie, csrf := authenticatedStartSession(t, router) response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie) if response.Code != http.StatusUnsupportedMediaType || store.startCalls != 0 { t.Fatalf("status/calls = %d/%d, want 415/0", response.Code, store.startCalls) } }) } } func TestStartPurchasesRejectsMalformedAndOversizedJSON(t *testing.T) { tests := []struct { name string body string want int storeErr error wantCalls int }{ {name: "empty", body: "", want: http.StatusBadRequest}, {name: "empty object", body: `{}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1}, {name: "null object", body: `null`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1}, {name: "malformed", body: `{`, want: http.StatusBadRequest}, {name: "wrong top-level type", body: `[]`, want: http.StatusBadRequest}, {name: "unknown field", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[],"created_by":"attacker"}`, want: http.StatusBadRequest}, {name: "wrong field type", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":"1"}]}`, want: http.StatusBadRequest}, {name: "second JSON value", body: validStartBody() + `{}`, want: http.StatusBadRequest}, {name: "duplicate task ids", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":1},{"task_id":"` + taskIDForHTTP + `","expected_task_version":1}]}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1}, {name: "oversized first value", body: `{"start_key":"` + strings.Repeat("x", 70<<10), want: http.StatusRequestEntityTooLarge}, {name: "oversized trailing whitespace", body: validStartBody() + strings.Repeat(" ", 70<<10), want: http.StatusRequestEntityTooLarge}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { store := &startRecordingStore{startErr: test.storeErr} router, _ := newRouterWithStore(t, store) cookie, csrf := authenticatedStartSession(t, router) response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie) if response.Code != test.want || store.startCalls != test.wantCalls { t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls) } if response.Body.Len() != 0 { t.Fatalf("error response leaked body %q", response.Body.String()) } assertSecurityHeaders(t, response) }) } } func TestStartPurchasesUsesAuthenticatedAdminAndReturnsStableSafeResult(t *testing.T) { result := successfulStartResult() store := &startRecordingStore{startResult: result} router, _ := newRouterWithStore(t, store) cookie, csrf := authenticatedStartSession(t, router) first := serveStartRequest(t, router, validStartBody(), "application/json; charset=utf-8", csrf, cookie) second := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie) for index, response := range []*httptest.ResponseRecorder{first, second} { if response.Code != http.StatusOK { t.Fatalf("response %d status = %d, want 200", index, response.Code) } if got := response.Header().Get("Content-Type"); got != "application/json; charset=utf-8" { t.Fatalf("response content type = %q", got) } var decoded tasks.StartResult if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil { t.Fatalf("decode response: %v", err) } if decoded.PaymentAutomated || decoded.AuthorizedCount != 1 || decoded.Tasks[0].AuthorizationID != result.Tasks[0].AuthorizationID { t.Fatalf("unsafe or unstable response = %#v", decoded) } assertSecurityHeaders(t, response) } if store.startCalls != 2 || len(store.createdBy) != 2 || store.createdBy[0] != "admin" || store.createdBy[1] != "admin" { t.Fatalf("store calls/created_by = %d/%#v", store.startCalls, store.createdBy) } for _, command := range store.commands { if command.StartKey != startKeyForHTTP || len(command.Tasks) != 1 || command.Tasks[0].TaskID != taskIDForHTTP || command.Tasks[0].ExpectedTaskVersion != 7 { t.Fatalf("decoded command = %#v", command) } } } func TestStartPurchasesMapsStoreErrorsWithoutLeakingDetails(t *testing.T) { for name, test := range map[string]struct { err error want int }{ "invalid": {err: tasks.ErrInvalidStart, want: http.StatusBadRequest}, "conflict": {err: tasks.ErrStartConflict, want: http.StatusConflict}, "internal": {err: errors.New("sqlite secret path and query"), want: http.StatusInternalServerError}, } { t.Run(name, func(t *testing.T) { store := &startRecordingStore{startErr: test.err} router, _ := newRouterWithStore(t, store) cookie, csrf := authenticatedStartSession(t, router) response := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie) if response.Code != test.want || store.startCalls != 1 { t.Fatalf("status/calls = %d/%d, want %d/1", response.Code, store.startCalls, test.want) } if response.Body.Len() != 0 || strings.Contains(response.Body.String(), "sqlite") { t.Fatalf("error leaked details: %q", response.Body.String()) } }) } } type startRecordingStore struct { startResult tasks.StartResult startErr error startCalls int commands []tasks.StartCommand createdBy []string } func (store *startRecordingStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) { return draft, nil } func (store *startRecordingStore) ListDrafts(context.Context) ([]tasks.Draft, error) { return nil, nil } func (store *startRecordingStore) ListTasks(context.Context, tasks.TaskFilter) ([]tasks.TaskRow, error) { return nil, nil } func (store *startRecordingStore) StartPurchases(_ context.Context, command tasks.StartCommand, createdBy string) (tasks.StartResult, error) { store.startCalls++ store.commands = append(store.commands, command) store.createdBy = append(store.createdBy, createdBy) return store.startResult, store.startErr } func authenticatedStartSession(t *testing.T, router http.Handler) (*http.Cookie, string) { t.Helper() cookie := authenticate(t, router) page := serve(router, http.MethodGet, "/tasks", nil, cookie) if page.Code != http.StatusOK { t.Fatalf("GET /tasks status = %d", page.Code) } return cookie, csrfToken(t, page.Body.String()) } func newStartRequest(t *testing.T, body, contentType, csrf string, cookie *http.Cookie) *http.Request { t.Helper() return newStartByteRequest(t, []byte(body), contentType, csrf, cookie) } func newStartByteRequest(t *testing.T, body []byte, contentType, csrf string, cookie *http.Cookie) *http.Request { t.Helper() request := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", bytes.NewReader(body)) if contentType != "" { request.Header.Set("Content-Type", contentType) } if csrf != "" { request.Header.Set("X-CSRF-Token", csrf) } if cookie != nil { request.AddCookie(cookie) } return request } func serveStartBytes(t *testing.T, router http.Handler, body []byte, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder { t.Helper() response := httptest.NewRecorder() router.ServeHTTP(response, newStartByteRequest(t, body, contentType, csrf, cookie)) return response } func serveStartRequest(t *testing.T, router http.Handler, body, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder { t.Helper() response := httptest.NewRecorder() router.ServeHTTP(response, newStartRequest(t, body, contentType, csrf, cookie)) return response } func validStartBody() string { return `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":7}]}` } func successfulStartResult() tasks.StartResult { expires := time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC) return tasks.StartResult{ StartKey: startKeyForHTTP, AuthorizedCount: 1, PaymentAutomated: false, Tasks: []tasks.AuthorizedTask{{ TaskID: taskIDForHTTP, TaskVersion: 8, AuthorizationID: "d3c9f507-7473-4fa6-8d71-8786c34c6301", ExpiresAt: expires, }}, } }