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) } } }