251 lines
10 KiB
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)
|
|
}
|
|
}
|
|
}
|