textmachine/platform/internal/httpapi/negotiation_test.go

197 lines
7.9 KiB
Go

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