package httpapi import ( "errors" "net/http" "net/http/httptest" "testing" "time" "textmachine/platform/internal/pgstore" ) // negotiation_test.go: the conditional read and the encoding, in the two shapes the first // implementation got backwards. // A NAMED coding outranks the wildcard, whichever token came first (RFC 9110 §12.5.3). // // Mutation caught: answering on the first matching token, which read `*, gzip;q=0` as consent and // `*;q=0, gzip` as a refusal — in both cases the opposite of what was asked. func TestTheNamedCodingOutranksTheWildcardInEitherOrder(t *testing.T) { for _, tc := range []struct { header string want bool }{ {"gzip", true}, {"", false}, {"br", false}, {"*", true}, {"gzip;q=0", false}, {"gzip;q=0.5", true}, {"identity", false}, {"*, gzip;q=0", false}, // the wildcard says yes, the named coding says no — no wins {"*;q=0, gzip", true}, // and the mirror {"gzip;q=0, *", false}, // order must not decide {"br, gzip, deflate", true}, } { r := httptest.NewRequest("GET", "/v0/books", nil) if tc.header != "" { r.Header.Set("Accept-Encoding", tc.header) } if got := acceptsGzip(r); got != tc.want { t.Errorf("Accept-Encoding %q → %v, want %v", tc.header, got, tc.want) } } } // HEAD reaches the same handler as GET (a `GET` pattern matches it since Go 1.22), so it answers the // same validator — a client's cheapest freshness check must not be the one request that can never // be answered 304. // // Mutation caught: restricting the validator to r.Method == GET. func TestHeadCarriesTheSameValidatorAsGet(t *testing.T) { h := readingServer(t, &fakeLibrary{}) get := call(t, h, "GET", "/v0/books/bk_1/chapters", "") tag := get.Header().Get("ETag") if tag == "" { t.Fatal("GET carries no validator") } r := httptest.NewRequest("HEAD", "/v0/books/bk_1/chapters", nil) r.Header.Set("X-TM-Client", "probe") r.Header.Set("Authorization", "Bearer t") w := httptest.NewRecorder() h.ServeHTTP(w, r) if got := w.Header().Get("ETag"); got != tag { t.Errorf("HEAD answers ETag %q, GET answers %q", got, tag) } r2 := httptest.NewRequest("HEAD", "/v0/books/bk_1/chapters", nil) r2.Header.Set("X-TM-Client", "probe") r2.Header.Set("Authorization", "Bearer t") r2.Header.Set("If-None-Match", tag) w2 := httptest.NewRecorder() h.ServeHTTP(w2, r2) if w2.Code != http.StatusNotModified { t.Errorf("a conditional HEAD answered %d, want 304", w2.Code) } } // An id the server cannot resume from is answered `resync_required` and never a silent fresh start // (canon §streamBookEvents). An id ABOVE anything this server issued is exactly that case: a // database restored from a backup moves `event_position` back, and every watcher then holds a // number from the future. // // ⚠ The at-rest `204` is NOT that case and stays: the canon answers "at or past the last frame, // while the book is at rest" with 204, which is how SSE is told to stop reconnecting. // // Mutation caught: starting such a client from now; dropping the 204 branch. func TestAnEventIDTheServerCannotResumeFromIsToldToResync(t *testing.T) { live := &fakeLibrary{stream: pgstore.StreamState{Position: 4, Revision: 11, AtRest: false}} if body := streamWith(t, live, "999999").Body.String(); !contains(body, "event: resync_required") { t.Errorf("an id from the future did not ask the client to re-sync: %s", body) } rest := &fakeLibrary{stream: pgstore.StreamState{Position: 4, Revision: 11, AtRest: true}} if w := streamWith(t, rest, "4"); w.Code != http.StatusNoContent { t.Errorf("an id at the last frame of a book at rest answered %d, want 204", w.Code) } pruned := &fakeLibrary{stream: pgstore.StreamState{Position: 40, Oldest: 20, Revision: 11}} if body := streamWith(t, pruned, "2").Body.String(); !contains(body, "event: resync_required") { t.Errorf("a client behind the buffer was not told to re-sync: %s", body) } } func streamWith(t *testing.T, lib *fakeLibrary, lastEventID string) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest("GET", "/v0/books/bk_1/events", nil) r.Header.Set("X-TM-Client", "probe") r.Header.Set("Authorization", "Bearer t") if lastEventID != "" { r.Header.Set("Last-Event-ID", lastEventID) } w := httptest.NewRecorder() readingServer(t, lib).ServeHTTP(w, r) return w } // The buffer is pruned while the connection is OPEN, and a client that fell behind must be told // rather than skipped: a `note` is delivered once, so a silent gap is a remark lost forever. // // Mutation caught: removing the `state.Oldest > from+1` branch from the pump — which survived the // battery until this pin existed. func TestFramesPrunedWhileTheConnectionIsOpenAskTheClientToResync(t *testing.T) { lib := &fakeLibrary{stream: pgstore.StreamState{Position: 5, Oldest: 1, Revision: 11}} // Between the first read and the second the buffer moves past what this client holds. lib.onSecondRead = func(s *pgstore.StreamState) { s.Oldest = 40; s.Position = 60 } body := streamWith(t, lib, "5").Body.String() if !contains(body, "event: resync_required") { t.Errorf("a pruned buffer was skipped silently: %s", body) } } // deadlineWriter is a writer that CAN take a write deadline, which httptest's recorder cannot. type deadlineWriter struct { *httptest.ResponseRecorder deadlines int } func (d *deadlineWriter) SetWriteDeadline(time.Time) error { d.deadlines++; return nil } func (d *deadlineWriter) Flush() {} // Every frame is written under a deadline: a peer that stops reading fills the socket and blocks the // write forever, and going quiet does not cancel the request's context. // // Mutation caught: dropping SetWriteDeadline from stream.write — which the battery did not see. func TestEveryFrameIsWrittenUnderADeadline(t *testing.T) { lib := &fakeLibrary{stream: pgstore.StreamState{Position: 2, Revision: 11, AtRest: true}} r := httptest.NewRequest("GET", "/v0/books/bk_1/events", nil) r.Header.Set("X-TM-Client", "probe") r.Header.Set("Authorization", "Bearer t") w := &deadlineWriter{ResponseRecorder: httptest.NewRecorder()} readingServer(t, lib).ServeHTTP(w, r) if w.deadlines == 0 { t.Error("frames were written with no deadline: a silent peer holds the goroutine forever") } } // flushFailsWriter takes the head's flush and fails every later one — a peer that went away after // the response began, which is the only shape this failure has. type flushFailsWriter struct { *httptest.ResponseRecorder flushes int } func (f *flushFailsWriter) FlushError() error { if f.flushes++; f.flushes == 1 { return nil } return errors.New("connection reset by peer") } // A frame is small, so it lands in the connection's buffer and the write returns nil: the FLUSH is // the only call that sees a socket nobody is draining. Discarding its error reported "sent" for a // frame that never left. // // Mutation caught: `_ = s.rc.Flush()` in stream.write. func TestAFrameThatCouldNotBeFlushedEndsTheStream(t *testing.T) { lib := &fakeLibrary{stream: pgstore.StreamState{Position: 2, Revision: 11, AtRest: true}} r := httptest.NewRequest("GET", "/v0/books/bk_1/events", nil) r.Header.Set("X-TM-Client", "probe") r.Header.Set("Authorization", "Bearer t") w := &flushFailsWriter{ResponseRecorder: httptest.NewRecorder()} readingServer(t, lib).ServeHTTP(w, r) if body := w.Body.String(); contains(body, "event: end") { t.Errorf("the stream went on writing after a frame failed to flush: %s", body) } } // Accept-Encoding may arrive as SEVERAL header lines — a proxy adds its own — and reading only the // first let a client that explicitly refused gzip be answered with it. // // Mutation caught: Header.Get instead of Header.Values. func TestARefusalOnASecondAcceptEncodingLineIsHonoured(t *testing.T) { r := httptest.NewRequest("GET", "/v0/books", nil) r.Header.Add("Accept-Encoding", "*") r.Header.Add("Accept-Encoding", "gzip;q=0") if acceptsGzip(r) { t.Error("a refusal on the second header line was ignored") } }