329 lines
13 KiB
Go
329 lines
13 KiB
Go
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,
|
|
}},
|
|
}
|
|
}
|