162 lines
6.6 KiB
Go
162 lines
6.6 KiB
Go
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
|
|
}
|
|
|
|
// The two deadlines of a session are DIFFERENT facts, and Lookup must not hand them back swapped.
|
|
//
|
|
// This test exists because the pack that generated this query measured that nothing asserted either
|
|
// field: a transposition of the two `time.Time` targets planted in the projection stayed green
|
|
// across all eighteen packages. Generating the Scan removed the drift between the SELECT list and
|
|
// the Scan — those two cannot disagree any more — but the projection from the generated row into
|
|
// auth.Session is still written by hand, and that half is what this pins.
|
|
//
|
|
// Cost if it is wrong, which is why it is worth a test of its own: auth.Authenticator slides the
|
|
// idle window only while `IdleExpiresAt.Before(AbsoluteExpiresAt)`. Swapped, that comparison is
|
|
// false for the whole life of every session, Touch becomes dead code, and every user is signed out
|
|
// at the idle TTL counted from LOGIN however active they are.
|
|
func TestTheTwoSessionDeadlinesAreNotInterchangeable(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u_dl")
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
const idleTTL, maxAge = time.Hour, 24 * time.Hour
|
|
// The two TTLs are far apart on purpose: a fixture where they coincide — which is what `Touch`
|
|
// produces, because it clamps idle to absolute — cannot tell a transposition from a correct read.
|
|
if err := s.CreateSession(ctx, []byte("digest-deadlines"), "u_dl", now, idleTTL, maxAge); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := s.Lookup(ctx, []byte("digest-deadlines"), now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.UserID != "u_dl" {
|
|
t.Errorf("UserID = %q, want u_dl", got.UserID)
|
|
}
|
|
if !got.IdleExpiresAt.Equal(now.Add(idleTTL)) {
|
|
t.Errorf("IdleExpiresAt = %s, want %s (the absolute deadline %s means the two were swapped)",
|
|
got.IdleExpiresAt, now.Add(idleTTL), now.Add(maxAge))
|
|
}
|
|
if !got.AbsoluteExpiresAt.Equal(now.Add(maxAge)) {
|
|
t.Errorf("AbsoluteExpiresAt = %s, want %s (the idle deadline %s means the two were swapped)",
|
|
got.AbsoluteExpiresAt, now.Add(maxAge), now.Add(idleTTL))
|
|
}
|
|
// The ordering is the property auth.Authenticator actually branches on, so it is asserted as
|
|
// itself rather than left implicit in the two equalities above.
|
|
if !got.IdleExpiresAt.Before(got.AbsoluteExpiresAt) {
|
|
t.Error("the idle deadline must fall before the absolute one, or the idle window can never slide")
|
|
}
|
|
}
|