package session_test import ( "context" "errors" "net/http" "sync" "sync/atomic" "testing" "time" brokerlog "kode.naiv.no/olemd/forgejo-mcp-broker/internal/log" "kode.naiv.no/olemd/forgejo-mcp-broker/internal/oauth" "kode.naiv.no/olemd/forgejo-mcp-broker/internal/session" ) // fakeClock is a manually advanced clock for the reaper tests. The // reaper goroutines tick on real wall time, so tests trigger eviction // by waiting briefly between request and reap-interval expiry. type fakeClock struct { mu sync.Mutex now time.Time } func newFakeClock() *fakeClock { return &fakeClock{now: time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)} } func (c *fakeClock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.now } func (c *fakeClock) Advance(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.now = c.now.Add(d) } func TestReaper_EvictsIdleSession(t *testing.T) { clk := newFakeClock() spawn, backends := fakeSpawner(t) r, _ := session.New(session.Config{Spawn: spawn, Log: brokerlog.Discard(), Now: clk.Now}) srv := newTestServer(t, r) // Spawn a session. resp := doReq(t, srv.URL, "", bearerSess("idle-user"), `{"jsonrpc":"2.0","id":1,"method":"initialize"}`) resp.Body.Close() if r.Active() != 1 { t.Fatalf("expected 1 active, got %d", r.Active()) } // Push the clock past the idle timeout. clk.Advance(time.Hour) // Start the reaper with a tight tick so the test runs quickly. stop := r.StartReaper(session.ReaperConfig{ IdleTimeout: 10 * time.Minute, ReapInterval: 20 * time.Millisecond, }) defer stop() if !waitForActive(r, 0, 2*time.Second) { t.Fatalf("session was not reaped: Active=%d", r.Active()) } if !backends.at(0).stopped.Load() { t.Error("backend was not stopped on reap") } } func TestReaper_KeepsRecentlyActiveSession(t *testing.T) { clk := newFakeClock() spawn, _ := fakeSpawner(t) r, _ := session.New(session.Config{Spawn: spawn, Log: brokerlog.Discard(), Now: clk.Now}) srv := newTestServer(t, r) resp := doReq(t, srv.URL, "", bearerSess("active-user"), `{"jsonrpc":"2.0","id":1,"method":"initialize"}`) resp.Body.Close() // Clock barely moves — well within the idle timeout. clk.Advance(time.Minute) stop := r.StartReaper(session.ReaperConfig{ IdleTimeout: 10 * time.Minute, ReapInterval: 20 * time.Millisecond, }) defer stop() // Wait long enough for ≥1 reaper tick, then confirm the session is still // alive. time.Sleep(100 * time.Millisecond) if r.Active() != 1 { t.Errorf("active session was evicted prematurely: Active=%d", r.Active()) } } func TestRotator_RefreshesAndRespawns(t *testing.T) { clk := newFakeClock() spawn, backends := fakeSpawner(t) r, _ := session.New(session.Config{Spawn: spawn, Log: brokerlog.Discard(), Now: clk.Now}) srv := newTestServer(t, r) // The fake bearer's ForgejoTokenExp is the zero time, which is "well // past expiry" by definition — the rotator should fire on first sweep. resp := doReq(t, srv.URL, "", bearerSess("rotate-user"), `{"jsonrpc":"2.0","id":1}`) resp.Body.Close() var refreshCalls atomic.Int32 refresh := func(ctx context.Context, sess *oauth.Session) (string, string, time.Time, error) { refreshCalls.Add(1) return "new-fj-access", "new-fj-refresh", clk.Now().Add(time.Hour), nil } stop := r.StartReaper(session.ReaperConfig{ IdleTimeout: time.Hour, // not testing idle here ReapInterval: time.Hour, // disable idle reaper effectively RotateInterval: 20 * time.Millisecond, RefreshLead: 10 * time.Minute, RefreshForgejo: refresh, Respawn: spawn, // reuse the same fake; produces a new backend }) defer stop() // Wait for the rotator to spawn a replacement. deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) && backends.count() < 2 { time.Sleep(10 * time.Millisecond) } if backends.count() < 2 { t.Fatalf("rotator did not spawn replacement; backends=%d, refreshes=%d", backends.count(), refreshCalls.Load()) } // Original backend was stopped, replacement is alive, session count unchanged. if !backends.at(0).stopped.Load() { t.Error("original backend not stopped after rotation") } if r.Active() != 1 { t.Errorf("Active = %d, want 1 (sid preserved across rotation)", r.Active()) } } func TestRotator_RefreshFailureEvictsSession(t *testing.T) { clk := newFakeClock() spawn, _ := fakeSpawner(t) r, _ := session.New(session.Config{Spawn: spawn, Log: brokerlog.Discard(), Now: clk.Now}) srv := newTestServer(t, r) resp := doReq(t, srv.URL, "", bearerSess("rotate-fail"), `{}`) resp.Body.Close() refresh := func(context.Context, *oauth.Session) (string, string, time.Time, error) { return "", "", time.Time{}, errors.New("forgejo refused") } stop := r.StartReaper(session.ReaperConfig{ IdleTimeout: time.Hour, ReapInterval: time.Hour, RotateInterval: 20 * time.Millisecond, RefreshLead: 10 * time.Minute, RefreshForgejo: refresh, Respawn: spawn, }) defer stop() if !waitForActive(r, 0, 2*time.Second) { t.Fatalf("session not evicted on refresh failure: Active=%d", r.Active()) } } func TestStartReaper_StopIsIdempotent(t *testing.T) { clk := newFakeClock() spawn, _ := fakeSpawner(t) r, _ := session.New(session.Config{Spawn: spawn, Log: brokerlog.Discard(), Now: clk.Now}) stop := r.StartReaper(session.ReaperConfig{ IdleTimeout: time.Hour, ReapInterval: time.Hour, }) stop() stop() // must not panic } // errPlaceholder keeps unused-import warnings quiet during edits. Remove // once the file is stable. var _ http.Handler = http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})