package api
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
type strictIn struct {
Repo string `json:"repo"`
Comment string `json:"comment"`
}
type strictGetIn struct {
Repo string `json:"repo"`
}
// strictRouter mounts one POST op and one GET op, both ungated, so the
// decoder is the only thing under test.
func strictRouter(t *testing.T, seen *string) http.Handler {
t.Helper()
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[strictIn, struct{}]{
Name: "strict.post", Kind: ops.Command, Auth: ops.Anonymous,
Route: "POST /api/v1/strict",
Doc: "test double: takes repo and comment and nothing else",
Run: func(_ context.Context, in strictIn) (struct{}, error) {
*seen = in.Comment
return struct{}{}, nil
},
}); err != nil {
t.Fatal(err)
}
if err := ops.Register(r, ops.Op[strictGetIn, struct{}]{
Name: "strict.get", Kind: ops.Query, Auth: ops.Anonymous,
Route: "GET /api/v1/strict",
Doc: "test double: a query op",
Run: func(context.Context, strictGetIn) (struct{}, error) { return struct{}{}, nil },
}); err != nil {
t.Fatal(err)
}
h, err := RouterWithCaps(r, "test", func(_, _ string) CapSet { return oltp.CapSet("s") }, nil)
if err != nil {
t.Fatal(err)
}
return h
}
func post(t *testing.T, h http.Handler, ctype, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", "/api/v1/strict", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", ctype)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// A JSON body naming a field the op does not have is REFUSED, and the
// refusal says what the op does take.
//
// Ignoring it is encoding/json's default and it loses writes: the body
// is discarded at decode and the op answers 200, so the caller is told
// it worked. About a dozen detailed ticket filings were reduced to
// their titles in one afternoon this way, because `description` is a
// plausible spelling of a field ticket.new calls `comment` (628a6ff4).
//
// The alternatives are listed because "no field X" alone leaves the
// caller guessing a second time, and the guess is what caused it.
func TestAJSONBodyWithAnUnknownFieldIsRefused(t *testing.T) {
var seen string
h := strictRouter(t, &seen)
w := post(t, h, "application/json", `{"repo":"quarry","description":"the body I meant to send"}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("answered %d, want 400: a body the op silently discarded was reported as success", w.Code)
}
got := w.Body.String()
for _, want := range []string{"description", "comment", "repo"} {
if !strings.Contains(got, want) {
t.Errorf("the refusal does not mention %q: %s", want, strings.TrimSpace(got))
}
}
if seen != "" {
t.Errorf("the op ran on a body it should have refused: %q", seen)
}
}
// The fields an op DOES have still arrive.
func TestAJSONBodyWithKnownFieldsStillWorks(t *testing.T) {
var seen string
h := strictRouter(t, &seen)
w := post(t, h, "application/json", `{"repo":"quarry","comment":"the real field"}`)
if w.Code != http.StatusOK {
t.Fatalf("answered %d, want 200: %s", w.Code, strings.TrimSpace(w.Body.String()))
}
if seen != "the real field" {
t.Errorf("the body did not reach the op: %q", seen)
}
}
// A FORM carrying keys no op declares still works, and this is the
// compatibility promise that makes the strictness safe to ship.
//
// A browser sends the submit button's name, a CSRF token and whatever
// else the page keeps in the form. Refusing those would break every
// page to fix a mistake only an API caller can make, so the strictness
// is on the JSON path alone.
func TestAFormWithExtraKeysIsStillAccepted(t *testing.T) {
var seen string
h := strictRouter(t, &seen)
w := post(t, h, "application/x-www-form-urlencoded",
"repo=quarry&comment=from+the+page&csrf=abc123&save=Save&_unused=1")
if w.Code != http.StatusOK {
t.Fatalf("a form with a submit button and a CSRF token answered %d: %s",
w.Code, strings.TrimSpace(w.Body.String()))
}
if seen != "from the page" {
t.Errorf("the form body did not reach the op: %q", seen)
}
}
// A query string carrying an extra parameter is likewise still
// accepted: links carry tracking and UI state that no op declares.
func TestAQueryStringWithExtraParamsIsStillAccepted(t *testing.T) {
var seen string
h := strictRouter(t, &seen)
req := httptest.NewRequest("GET", "/api/v1/strict?repo=quarry&from=email&tab=open", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("a link with extra query parameters answered %d: %s",
w.Code, strings.TrimSpace(w.Body.String()))
}
}