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