textmachine/platform/internal/httpapi/problem_test.go

251 lines
10 KiB
Go

package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"textmachine/platform/internal/pgstore"
)
// problem_test.go: the error model (canon §Problem, §ErrorCode).
//
// What is pinned here is the property the whole model stands on: a client dispatches on the machine
// `code` and never on the words. A response that carries a phrase and no code is the state 0.2.3
// was in — six different conditions behind one English sentence, shown to a Russian user as-is.
// Every failure of the versioned surface carries a code and the id of the request that failed.
//
// Mutation caught: writing a problem through a path that does not set Code; dropping request_id.
func TestEveryVersionedFailureCarriesACodeAndARequestID(t *testing.T) {
// Runs are mounted for the malformed-body case below: the bank's decisions handle used to be the
// only POST a library-only server carried, and it was removed with the model it served (D39.144).
h := v0ServerWith(t, Deps{Library: &fakeLibrary{err: pgstore.ErrNoBook}, Runs: &fakeRuns{}})
for _, tc := range []struct {
name, method, path, body string
want string
}{
{"a book that is not there", "GET", "/v0/books/bk_1", "", "not_found"},
{"a path this deployment does not serve", "GET", "/v0/nothing", "", "not_found"},
{"a body that does not parse", "POST", "/v0/books/bk_1/runs", "not json", "invalid_request"},
} {
w := call(t, h, tc.method, tc.path, tc.body)
got := decode(t, w)
if got["code"] != tc.want {
t.Errorf("%s: code = %v, want %q", tc.name, got["code"], tc.want)
}
if id, _ := got["request_id"].(string); id == "" {
t.Errorf("%s: no request_id in the body", tc.name)
}
if w.Header().Get("Content-Type") != "application/problem+json" {
t.Errorf("%s: content type %q", tc.name, w.Header().Get("Content-Type"))
}
if got["type"] != "about:blank" {
t.Errorf("%s: type = %v", tc.name, got["type"])
}
}
}
// RFC 9110 §15.5.2 requires a challenge on every 401, and the contract declares the header
// REQUIRED. Neither WriteProblem nor the deny handler set one before 0.3.0.
//
// Mutation caught: removing the WWW-Authenticate branch from WriteProblem.
func TestA401CarriesItsChallenge(t *testing.T) {
h := readingServer(t, &fakeLibrary{})
r := httptest.NewRequest("GET", "/v0/books", nil) // no credential at all
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status %d", w.Code)
}
if w.Header().Get("WWW-Authenticate") == "" {
t.Error("a 401 carries no challenge")
}
if got := decode(t, w); got["code"] != "unauthenticated" {
t.Errorf("code %v", got["code"])
}
}
// ONE root code for two different facts, told apart in the open second level: a request from an
// origin this deployment does not accept, and a same-origin request with no marker header. The
// client needs to tell "your page is wrong" from "your request is wrong"; the vocabulary of the
// version must not grow a root code for it.
func TestTheTwoRefusalsBeforeAuthorizationShareARootCodeAndDifferInTheirCause(t *testing.T) {
h := readingServer(t, &fakeLibrary{})
// Presented by cookie, unsafe, without the marker header.
// Any unsafe method under `/v0` does: the guard is mounted on every pattern including the
// catch-all, so this refusal does not depend on the route being mounted on this instance.
r := httptest.NewRequest("POST", "/v0/books/bk_1/runs", nil)
r.AddCookie(&http.Cookie{Name: "__Host-tm_session", Value: "token"})
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("status %d: %s", w.Code, w.Body)
}
got := decode(t, w)
cause, _ := got["cause"].(map[string]any)
if got["code"] != "forbidden" || cause["code"] != "client_header_missing" {
t.Errorf("problem: %v", got)
}
cross := httptest.NewRequest("POST", "/v0/books/bk_1/runs", nil)
cross.Header.Set("Sec-Fetch-Site", "cross-site")
cross.AddCookie(&http.Cookie{Name: "__Host-tm_session", Value: "token"})
cw := httptest.NewRecorder()
h.ServeHTTP(cw, cross)
if cw.Code != http.StatusForbidden {
t.Fatalf("cross-origin status %d", cw.Code)
}
crossBody := decode(t, cw)
crossCause, _ := crossBody["cause"].(map[string]any)
if crossBody["code"] != "forbidden" || crossCause["code"] != "origin_rejected" {
t.Errorf("cross-origin problem: %v", crossBody)
}
}
// The status is DERIVED from the code and never passed beside it: a mismatched pair is the class of
// divergence a reviewer cannot see, because both halves look right on their own.
//
// Driven from the production table, so a code added there is covered the moment it exists; the count
// is what pins the table itself to the canon's closed vocabulary — SIXTEEN root codes at 0.3.0 plus
// the TWO bank codes 0.5.0 added (§2.19: refused is re-decided by the person, incomplete is re-sent
// verbatim by the machine).
func TestEveryCodeNamesExactlyOneStatus(t *testing.T) {
if len(codes) != 18 {
t.Fatalf("the root vocabulary has %d codes; 0.5.0 closes it at 18", len(codes))
}
// ⚠ Transcribed from the CANON's own list (§ErrorCode), not read back from the table above.
// Comparing `statusOf(c)` with `codes[c].status` compares the table with itself: it is the same
// lookup twice, and it passed with a status deliberately changed to the wrong one.
canon := map[Code]int{
CodeInvalidRequest: 400,
CodeContentRefused: 400,
CodeUnauthenticated: 401,
CodeForbidden: 403,
CodeNotFound: 404,
CodeGone: 410,
CodeRequestTimeout: 408,
CodePayloadTooLarge: 413,
CodeRunInFlight: 409,
CodeBookNotReady: 409,
CodeRunNotStoppable: 409,
CodeRunNotResumable: 409,
CodeCeilingUnavailable: 409,
CodeIdempotencyConflict: 409,
CodeServiceUnavailable: 503,
CodeInternalError: 500,
CodeBankCorrectionsRefused: 409,
CodeBankCorrectionsIncomplete: 503,
}
unknown := Code("a code that does not exist")
for code := range codes {
want, named := canon[code]
if !named {
t.Errorf("%s is not a code the canon declares", code)
continue
}
if got := statusOf(code); got != want {
t.Errorf("%s answers %d, want the %d the canon names", code, got, want)
}
// ⚠ NOT `title(code) == ""`, which no Code can produce — the fallback always returns something.
// What the schema needs is a title BELONGING to this code.
if got := title(code); got == title(unknown) && code != CodeInternalError {
t.Errorf("%s falls through to the default title %q instead of naming itself", code, got)
}
}
for code := range canon {
if _, ok := codes[code]; !ok {
t.Errorf("the canon declares %s and this deployment names no status for it", code)
}
}
// The 400s the canon separates: `invalid_request` is a malformed call, `content_refused` is a
// refusal to do the work. Same status, and they must not answer the same title.
if title(CodeInvalidRequest) == title(CodeContentRefused) {
t.Error("two codes of one status share a title: a client cannot tell them apart in a log")
}
}
// A Problem written without a code still carries one: the schema requires `code` on every response
// of the versioned surface, `500` included, and `omitempty` exists only for the surface the contract
// does not govern.
//
// Mutation caught: removing the default in WriteProblem.
func TestAProblemWithNoCodeStillAnswersOne(t *testing.T) {
r := httptest.NewRequest("GET", "/v0/books", nil)
w := httptest.NewRecorder()
WriteProblem(w, r, Problem{})
got := decode(t, w)
if w.Code != http.StatusInternalServerError {
t.Errorf("status %d", w.Code)
}
if got["code"] != string(CodeInternalError) {
t.Errorf("a codeless problem answered %v, want internal_error", got["code"])
}
}
// `title` and `detail` are written for a developer and a log, and NEITHER may carry engine or
// database text: the sentence a user reads is drawn by the client from the code.
func TestAProblemNeverCarriesTheTextOfWhatFailed(t *testing.T) {
lib := &fakeLibrary{err: errEngineish{}}
w := call(t, readingServer(t, lib), "GET", "/v0/books/bk_1", "")
if w.Code != http.StatusInternalServerError {
t.Fatalf("status %d", w.Code)
}
if body := w.Body.String(); contains(body, "第一节") || contains(body, "pgstore") {
t.Errorf("the failure's own text reached the wire: %s", body)
}
}
type errEngineish struct{}
func (errEngineish) Error() string { return "pgstore: CJK leak in the ru output: 第一节" }
func contains(haystack, needle string) bool {
return len(needle) > 0 && len(haystack) >= len(needle) && (func() bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
})()
}
// The sign-in mechanics answer the same envelope and NO machine code, and that is a ratified
// decision rather than a gap: "различать причины отказа клиент не может по замыслу" (companion
// §2.14 since 0.2.3). A client shows one neutral phrase there, with a single exception that needs no
// vocabulary at all — 429, whose remedy travels in `Retry-After`.
//
// Mutation caught: re-introducing a status→code inverse and stamping /auth answers with a versioned
// code; dropping request_id from the uncoded writer.
func TestTheSignInSurfaceAnswersTheSameEnvelopeWithoutAVersionedCode(t *testing.T) {
for _, status := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound,
http.StatusMethodNotAllowed, http.StatusTooManyRequests, http.StatusServiceUnavailable} {
r := httptest.NewRequest("POST", "/auth/login", nil)
w := httptest.NewRecorder()
WriteStatusProblem(w, r, status, "Sign-in failed", "")
if w.Code != status {
t.Errorf("%d: wrote %d", status, w.Code)
}
if got := w.Header().Get("Content-Type"); got != "application/problem+json" {
t.Errorf("%d: content type %q", status, got)
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("%d: %v", status, err)
}
if _, coded := body["code"]; coded {
t.Errorf("%d: the sign-in surface carried a machine code: %v", status, body)
}
// Everything else the envelope promises is still there — the id above all, because that is
// what a user quotes when they report a sign-in they could not complete.
if id, _ := body["request_id"].(string); id == "" {
t.Errorf("%d: no request_id", status)
}
if body["type"] != "about:blank" || body["title"] == "" || body["status"] != float64(status) {
t.Errorf("%d: envelope %v", status, body)
}
}
}