123 lines
5.4 KiB
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")
|
|
}
|
|
}
|