197 lines
7.9 KiB
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")
|
|
}
|
|
}
|