186 lines
5.5 KiB
Go
186 lines
5.5 KiB
Go
|
|
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) {})
|