package pgstore import ( "context" "errors" "testing" "time" "textmachine/platform/internal/auth" ) // sessions_test.go: the re-check a long-lived response makes on the session behind it. // // The four cases below are one table because what matters is the SHAPE of the answer set — which // facts end a stream and which deliberately do not. Splitting them would let the idle case be // deleted on its own, and the idle case is the whole correction (register row PD-379). func TestStillLiveAnswersRevocationAndTheCeilingButNotTheIdleWindow(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u1") now := time.Now().UTC() for _, c := range []struct { name string // make writes the row and returns its digest; ask is the moment the re-check is made. make func(t *testing.T) []byte ask time.Time want error why string }{ { name: "a live session", make: func(t *testing.T) []byte { return seedSession(t, s, ctx, "u1", now, time.Hour, 24*time.Hour) }, ask: now.Add(time.Minute), want: nil, why: "an ordinary session must keep its stream", }, { name: "a revoked session", make: func(t *testing.T) []byte { d := seedSession(t, s, ctx, "u1", now, time.Hour, 24*time.Hour) if err := s.RevokeSession(ctx, d, now); err != nil { t.Fatal(err) } return d }, ask: now.Add(time.Minute), want: auth.ErrNoSession, why: "`logout`, `logout-all` and `tmplatformctl revoke` all write exactly this, and this is the whole of PD-379", }, { name: "a session past its ABSOLUTE ceiling", make: func(t *testing.T) []byte { return seedSession(t, s, ctx, "u1", now, time.Hour, time.Hour) }, ask: now.Add(2 * time.Hour), want: auth.ErrNoSession, why: "the ceiling is the one clock a stream cannot slide, so it is the one that must end it", }, { // ⚠ THE CORRECTION, and the reason this method exists instead of Lookup. The idle window // slides on a REQUEST, and a stream is ONE request for its whole life — so a stream can // never touch its own idle deadline. Answering ErrNoSession here would end the stream of a // user who is sitting and watching it, which is a regression dressed as a fix. name: "a session past its IDLE window but inside its ceiling", make: func(t *testing.T) []byte { return seedSession(t, s, ctx, "u1", now, time.Minute, 24*time.Hour) }, ask: now.Add(time.Hour), want: nil, why: "the idle clause is deliberately absent: a stream cannot slide its own window", }, { name: "a token no row matches", make: func(*testing.T) []byte { return auth.Digest(auth.NewToken()) }, ask: now, want: auth.ErrNoSession, why: "the sweep deletes revoked rows too, so an absent row has to mean dead", }, } { t.Run(c.name, func(t *testing.T) { digest := c.make(t) err := s.StillLive(ctx, digest, c.ask) if !errors.Is(err, c.want) { t.Errorf("StillLive = %v, want %v — %s", err, c.want, c.why) } }) } } // Lookup and StillLive must DISAGREE about an idle-expired session, and that disagreement is the // mechanism rather than an accident of two queries. // // Pinned as one assertion because the two halves are only meaningful together: a copy of Lookup's // clauses under a new name would pass every case above except this one. // // Mutation caught: adding `and idle_expires_at > $2` to StillLive. func TestTheStreamsQuestionIsNotTheDoorsQuestion(t *testing.T) { s, ctx := testDB(t) seedUser(t, s, ctx, "u1") now := time.Now().UTC() digest := seedSession(t, s, ctx, "u1", now, time.Minute, 24*time.Hour) later := now.Add(time.Hour) if _, err := s.Lookup(ctx, digest, later); !errors.Is(err, auth.ErrNoSession) { t.Errorf("Lookup admitted an idle-expired session (%v): the DOOR must still refuse it", err) } if err := s.StillLive(ctx, digest, later); err != nil { t.Errorf("StillLive ended a stream over the idle window (%v): a stream is one request and "+ "can never slide its own deadline, so this would cut off a user who is watching", err) } } // seedSession writes one session row and returns its digest. func seedSession(t *testing.T, s *Store, ctx context.Context, userID string, now time.Time, idle, maxAge time.Duration) []byte { t.Helper() d := auth.Digest(auth.NewToken()) if err := s.CreateSession(ctx, d, userID, now, idle, maxAge); err != nil { t.Fatal(err) } return d }