textmachine/platform/internal/pgstore/session_slide_seam_test.go

123 lines
5.4 KiB
Go

package pgstore
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"textmachine/platform/internal/auth"
)
// The idle window SLIDES, through the real middleware, against a live store — and nothing pinned
// that until this test (register row PD-431).
//
// What kept it unpinned is a shape that coverage cannot see: `Touch` throws away the command tag, so
// an UPDATE that matches NO ROW is indistinguishable from one that slid the window. Measured by the
// `sqlc` pack's adversarial pass on 29.08: `where token_sha256 = $1` → `where 1 = 0 and
// token_sha256 = $1` left eighteen packages green and zero tests red, while the control mutation on
// the same stand went red twice — so the harness worked and the silence belonged to `Touch`. The two
// tests that call it are both decided by predicates of the SQL itself and stay true when it does
// nothing at all.
//
// ⚠ The behaviour is NOT changed and must not be: zero rows is a legitimate race with a revocation
// arriving between the Lookup and the Touch (the orchestrator's word, and the reason the `sqlc` pack
// deliberately left it alone). What was missing was the CHECK, so this is the check.
//
// Two things make it honest rather than decorative. It asserts through a LATER Lookup rather than
// through the response, because a failed Touch is logged and swallowed — the 200 proves nothing. And
// it runs the live `*pgstore.Store` as the middleware's `auth.SessionStore`, which no test did: the
// interface is satisfied already, so nothing in production had to change for this to be possible,
// only somebody had to connect the two ends. It lives in package `pgstore` because `pgstore` imports
// `auth` and not the other way round — the same test in package `auth` is an import cycle.
//
// Mutation caught: `where 1 = 0 and token_sha256 = $1` in TouchSession — the update that touches
// nothing; and swapping the two deadlines, which slides the wrong clock.
func TestTheIdleWindowSlidesThroughTheAuthenticatorAgainstALiveStore(t *testing.T) {
s, ctx := testDB(t)
seedUser(t, s, ctx, "u_slide")
const idleTTL, maxAge = time.Hour, 24 * time.Hour
t0 := time.Now().UTC().Truncate(time.Millisecond)
token := auth.NewToken()
digest := auth.Digest(token)
if err := s.CreateSession(ctx, digest, "u_slide", t0, idleTTL, maxAge); err != nil {
t.Fatal(err)
}
// A request in the window's SECOND half — which is the only time the middleware slides at all —
// and far from the absolute ceiling, which is its other condition.
at := t0.Add(40 * time.Minute)
served := false
a := &auth.Authenticator{
Sessions: s, // ⚠ THE POINT: the live store, as the middleware's own dependency.
IdleTTL: idleTTL,
Now: func() time.Time { return at },
Deny: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("a live session was denied")
w.WriteHeader(http.StatusUnauthorized)
}),
}
r := httptest.NewRequest(http.MethodGet, "/v0/books", nil)
r.Header.Set("Authorization", "Bearer "+token)
a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { served = true })).
ServeHTTP(httptest.NewRecorder(), r)
if !served {
t.Fatal("the request never reached the handler: this fixture cannot observe the slide")
}
// PAST the original deadline. A window that did not slide is a session that no longer exists.
after := t0.Add(90 * time.Minute)
got, err := s.Lookup(ctx, digest, after)
if err != nil {
t.Fatalf("the session is gone at t0+90m (%v): the request at t0+40m did not slide the idle"+
" window, so an active user is signed out on the idle TTL counted from LOGIN", err)
}
if got.UserID != "u_slide" {
t.Fatalf("the slid session belongs to %q", got.UserID)
}
// And it slid the RIGHT clock. A Touch that moved the absolute ceiling instead would pass the
// assertion above and quietly turn the session's hard limit into a sliding one.
if want := at.Add(idleTTL); !got.IdleExpiresAt.Equal(want) {
t.Errorf("the idle deadline is %s, want %s (the request's own time plus the TTL)",
got.IdleExpiresAt, want)
}
if want := t0.Add(maxAge); !got.AbsoluteExpiresAt.Equal(want) {
t.Errorf("the absolute deadline moved to %s, want it fixed at %s: the whole point of two"+
" clocks is that this one does not slide", got.AbsoluteExpiresAt, want)
}
}
// The other end of the same seam: a REVOKED session is denied through the live store, and the
// handler never runs. Cheap, and it exercises the Lookup failure path that only fakes had reached.
func TestARevokedSessionIsDeniedThroughTheAuthenticatorAgainstALiveStore(t *testing.T) {
s, ctx := testDB(t)
seedUser(t, s, ctx, "u_revoked")
t0 := time.Now().UTC().Truncate(time.Millisecond)
token := auth.NewToken()
if err := s.CreateSession(ctx, auth.Digest(token), "u_revoked", t0, time.Hour, 24*time.Hour); err != nil {
t.Fatal(err)
}
if err := s.RevokeSession(ctx, auth.Digest(token), t0); err != nil {
t.Fatal(err)
}
denied := false
a := &auth.Authenticator{
Sessions: s,
IdleTTL: time.Hour,
Now: func() time.Time { return t0.Add(time.Minute) },
Deny: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
denied = true
w.WriteHeader(http.StatusUnauthorized)
}),
}
r := httptest.NewRequest(http.MethodGet, "/v0/books", nil)
r.Header.Set("Authorization", "Bearer "+token)
a.Require(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Error("a revoked session reached the handler")
})).ServeHTTP(httptest.NewRecorder(), r)
if !denied {
t.Error("a revoked session was not denied")
}
}