|
|
@@ -1,12 +1,17 @@
|
|
|
package state
|
|
|
|
|
|
import (
|
|
|
+ "context"
|
|
|
+ "io"
|
|
|
+ "log/slog"
|
|
|
"testing"
|
|
|
"time"
|
|
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
+ "github.com/stretchr/testify/require"
|
|
|
|
|
|
"github.com/mk6i/open-oscar-server/server/webapi/types"
|
|
|
+ "github.com/mk6i/open-oscar-server/wire"
|
|
|
)
|
|
|
|
|
|
func TestWebAPISession_TempBuddies(t *testing.T) {
|
|
|
@@ -258,3 +263,371 @@ func TestWebAPISession_TempBuddiesIndependence(t *testing.T) {
|
|
|
assert.True(t, session1.TempBuddies["buddy3"])
|
|
|
assert.False(t, session2.TempBuddies["buddy3"])
|
|
|
}
|
|
|
+
|
|
|
+// TestWebAPISessionManager_ShutdownIdempotent verifies Shutdown is safe to call
|
|
|
+// more than once (e.g. from overlapping shutdown paths): the closed flag makes
|
|
|
+// the second call a no-op instead of re-draining.
|
|
|
+func TestWebAPISessionManager_ShutdownIdempotent(t *testing.T) {
|
|
|
+ mgr := NewWebAPISessionManager()
|
|
|
+
|
|
|
+ mgr.Shutdown()
|
|
|
+
|
|
|
+ assert.NotPanics(t, func() {
|
|
|
+ mgr.Shutdown()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// TestWebAPISessionManager_CreateAfterShutdown verifies that a session cannot be
|
|
|
+// created once the manager is shut down. Otherwise the reaper is stopped and the
|
|
|
+// session would never be closed or reaped, leaking its OSCAR session.
|
|
|
+func TestWebAPISessionManager_CreateAfterShutdown(t *testing.T) {
|
|
|
+ mgr := NewWebAPISessionManager()
|
|
|
+
|
|
|
+ ctx := context.Background()
|
|
|
+ mgr.Shutdown()
|
|
|
+
|
|
|
+ sess, err := mgr.CreateSession(ctx, DisplayScreenName("testuser"), "dev", []string{"presence"}, nil, nil)
|
|
|
+ assert.Nil(t, sess)
|
|
|
+ assert.ErrorIs(t, err, ErrWebAPISessionManagerClosed)
|
|
|
+}
|
|
|
+
|
|
|
+// TestWebAPISessionManager_ShutdownDrainsAndClosesSessions verifies that Shutdown
|
|
|
+// collects every live session and tears it down: it drains the maps and closes
|
|
|
+// each session's event queue and OSCAR instance.
|
|
|
+func TestWebAPISessionManager_ShutdownDrainsAndClosesSessions(t *testing.T) {
|
|
|
+ mgr := NewWebAPISessionManager()
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ inst1 := NewSession().AddInstance()
|
|
|
+ inst2 := NewSession().AddInstance()
|
|
|
+
|
|
|
+ s1, err := mgr.CreateSession(ctx, DisplayScreenName("alice"), "dev", []string{"presence"}, inst1, slog.Default())
|
|
|
+ assert.NoError(t, err)
|
|
|
+ s2, err := mgr.CreateSession(ctx, DisplayScreenName("bob"), "dev", []string{"presence"}, inst2, slog.Default())
|
|
|
+ assert.NoError(t, err)
|
|
|
+
|
|
|
+ mgr.Shutdown()
|
|
|
+
|
|
|
+ // Maps drained: the collect loop ran over both sessions.
|
|
|
+ assert.Empty(t, mgr.sessions)
|
|
|
+
|
|
|
+ // Each session's event queue and OSCAR instance were closed: the teardown
|
|
|
+ // loop ran for every collected session.
|
|
|
+ for _, s := range []*WebAPISession{s1, s2} {
|
|
|
+ _, err := s.EventQueue.Fetch(ctx, 0, 10*time.Millisecond)
|
|
|
+ assert.Error(t, err, "event queue should be closed")
|
|
|
+ }
|
|
|
+ for _, inst := range []*SessionInstance{inst1, inst2} {
|
|
|
+ select {
|
|
|
+ case <-inst.Closed():
|
|
|
+ default:
|
|
|
+ t.Error("OSCAR instance should be closed")
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestWebAPISessionManager_ReapExpired verifies reapExpired removes and tears
|
|
|
+// down only expired sessions, leaving live ones untouched.
|
|
|
+func TestWebAPISessionManager_ReapExpired(t *testing.T) {
|
|
|
+ mgr := NewWebAPISessionManager()
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ expiredInst := NewSession().AddInstance()
|
|
|
+ liveInst := NewSession().AddInstance()
|
|
|
+
|
|
|
+ expired, err := mgr.CreateSession(ctx, "alice", "dev", []string{"presence"}, expiredInst, slog.Default())
|
|
|
+ assert.NoError(t, err)
|
|
|
+ live, err := mgr.CreateSession(ctx, "bob", "dev", []string{"presence"}, liveInst, slog.Default())
|
|
|
+ assert.NoError(t, err)
|
|
|
+
|
|
|
+ // Force alice's session into the past; bob keeps its default future expiry.
|
|
|
+ expired.ExpiresAt = time.Now().Add(-time.Minute)
|
|
|
+
|
|
|
+ mgr.reapExpired()
|
|
|
+
|
|
|
+ // Expired session removed; live session retained.
|
|
|
+ assert.NotContains(t, mgr.sessions, expired.AimSID)
|
|
|
+ assert.Contains(t, mgr.sessions, live.AimSID)
|
|
|
+
|
|
|
+ // Expired session torn down: event queue and OSCAR instance closed.
|
|
|
+ _, err = expired.EventQueue.Fetch(ctx, 0, 10*time.Millisecond)
|
|
|
+ assert.Error(t, err, "expired session's event queue should be closed")
|
|
|
+ select {
|
|
|
+ case <-expiredInst.Closed():
|
|
|
+ default:
|
|
|
+ t.Error("expired session's OSCAR instance should be closed")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Live session left running.
|
|
|
+ select {
|
|
|
+ case <-liveInst.Closed():
|
|
|
+ t.Error("live session's OSCAR instance should not be closed")
|
|
|
+ default:
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// The client deletes the alias it holds each time it merges a user map, so every
|
|
|
+// event naming a buddy has to repeat it. An incoming IM and a presence change both
|
|
|
+// carry a user map, and both would otherwise rename an aliased buddy.
|
|
|
+func TestWebAPISession_RepeatsBuddyAliasOnOSCAREvents(t *testing.T) {
|
|
|
+ newSession := func() *WebAPISession {
|
|
|
+ return &WebAPISession{
|
|
|
+ ScreenName: DisplayScreenName("me"),
|
|
|
+ Events: []string{"im", "conversation", "presence"},
|
|
|
+ EventQueue: types.NewEventQueue(10),
|
|
|
+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
|
+ BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
|
|
|
+ return map[string]string{"mikekelly": "MICHAELKELLY"}, nil
|
|
|
+ },
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ t.Run("incoming IM", func(t *testing.T) {
|
|
|
+ sess := newSession()
|
|
|
+ frags, err := wire.ICBMFragmentList("hello")
|
|
|
+ require.NoError(t, err)
|
|
|
+ body := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
|
|
|
+ ChannelID: wire.ICBMChannelIM,
|
|
|
+ TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
|
|
|
+ }
|
|
|
+ body.Append(wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags))
|
|
|
+
|
|
|
+ sess.handleIncomingIM(wire.SNACMessage{Body: body})
|
|
|
+
|
|
|
+ events := sess.EventQueue.GetAllEvents()
|
|
|
+ require.NotEmpty(t, events)
|
|
|
+ imEvent := events[0].Data.(types.IMEvent)
|
|
|
+ assert.Equal(t, "mikekelly", imEvent.Source.AimID)
|
|
|
+ assert.Equal(t, "Mike Kelly", imEvent.Source.DisplayID)
|
|
|
+ assert.Equal(t, "MICHAELKELLY", imEvent.Source.Friendly)
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("buddy arrived", func(t *testing.T) {
|
|
|
+ sess := newSession()
|
|
|
+ sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{
|
|
|
+ TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
|
|
|
+ }})
|
|
|
+
|
|
|
+ events := sess.EventQueue.GetAllEvents()
|
|
|
+ require.Len(t, events, 1)
|
|
|
+ presence := events[0].Data.(types.PresenceEvent)
|
|
|
+ assert.Equal(t, "mikekelly", presence.AimID)
|
|
|
+ assert.Equal(t, "MICHAELKELLY", presence.Friendly)
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("buddy departed", func(t *testing.T) {
|
|
|
+ sess := newSession()
|
|
|
+ sess.handleBuddyDeparted(wire.SNACMessage{Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
|
|
|
+ TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
|
|
|
+ }})
|
|
|
+
|
|
|
+ events := sess.EventQueue.GetAllEvents()
|
|
|
+ require.Len(t, events, 1)
|
|
|
+ presence := events[0].Data.(types.PresenceEvent)
|
|
|
+ assert.Equal(t, "mikekelly", presence.AimID)
|
|
|
+ assert.Equal(t, "MICHAELKELLY", presence.Friendly)
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("unaliased buddy omits friendly", func(t *testing.T) {
|
|
|
+ sess := newSession()
|
|
|
+ sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{
|
|
|
+ TLVUserInfo: wire.TLVUserInfo{ScreenName: "Someone Else"},
|
|
|
+ }})
|
|
|
+
|
|
|
+ events := sess.EventQueue.GetAllEvents()
|
|
|
+ require.Len(t, events, 1)
|
|
|
+ assert.Empty(t, events[0].Data.(types.PresenceEvent).Friendly)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// Aliases all come from one feedbag query, so a signon that brings a whole buddy
|
|
|
+// list online must not re-query the feedbag per buddy.
|
|
|
+func TestWebAPISession_CachesBuddyAliases(t *testing.T) {
|
|
|
+ var loads int
|
|
|
+ sess := &WebAPISession{
|
|
|
+ ScreenName: DisplayScreenName("me"),
|
|
|
+ Events: []string{"presence"},
|
|
|
+ EventQueue: types.NewEventQueue(10),
|
|
|
+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
|
+ BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
|
|
|
+ loads++
|
|
|
+ return map[string]string{"mikekelly": "MICHAELKELLY"}, nil
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for range 5 {
|
|
|
+ sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{
|
|
|
+ TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
|
|
|
+ }})
|
|
|
+ }
|
|
|
+
|
|
|
+ events := sess.EventQueue.GetAllEvents()
|
|
|
+ require.Len(t, events, 5)
|
|
|
+ for _, event := range events {
|
|
|
+ assert.Equal(t, "MICHAELKELLY", event.Data.(types.PresenceEvent).Friendly)
|
|
|
+ }
|
|
|
+ assert.Equal(t, 1, loads, "aliases should be loaded once, not once per event")
|
|
|
+}
|
|
|
+
|
|
|
+// A feedbag change from another of the owner's clients arrives as a SNAC, which is
|
|
|
+// the session's only signal that its cached aliases are stale.
|
|
|
+func TestWebAPISession_FeedbagSNACInvalidatesAliasCache(t *testing.T) {
|
|
|
+ alias := "MICHAELKELLY"
|
|
|
+ sess := &WebAPISession{
|
|
|
+ ScreenName: DisplayScreenName("me"),
|
|
|
+ Events: []string{"presence"},
|
|
|
+ EventQueue: types.NewEventQueue(10),
|
|
|
+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
|
+ BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
|
|
|
+ return map[string]string{"mikekelly": alias}, nil
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ arrive := func() types.PresenceEvent {
|
|
|
+ sess.handleBuddyArrived(wire.SNACMessage{Body: wire.SNAC_0x03_0x0B_BuddyArrived{
|
|
|
+ TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
|
|
|
+ }})
|
|
|
+ events := sess.EventQueue.GetAllEvents()
|
|
|
+ require.NotEmpty(t, events)
|
|
|
+ return events[len(events)-1].Data.(types.PresenceEvent)
|
|
|
+ }
|
|
|
+
|
|
|
+ assert.Equal(t, "MICHAELKELLY", arrive().Friendly)
|
|
|
+
|
|
|
+ // The buddy is renamed elsewhere: the feedbag SNAC must drop the cached map.
|
|
|
+ alias = "MIKE"
|
|
|
+ sess.handleFeedbagMessage(wire.SNACMessage{
|
|
|
+ Frame: wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem},
|
|
|
+ Body: wire.SNAC_0x13_0x09_FeedbagUpdateItem{},
|
|
|
+ })
|
|
|
+
|
|
|
+ assert.Equal(t, "MIKE", arrive().Friendly)
|
|
|
+}
|
|
|
+
|
|
|
+// A session sees no SNAC for feedbag writes it makes itself, so the handlers that
|
|
|
+// perform those writes invalidate the cache directly.
|
|
|
+func TestWebAPISession_InvalidateAliases(t *testing.T) {
|
|
|
+ alias := "MICHAELKELLY"
|
|
|
+ sess := &WebAPISession{
|
|
|
+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
|
+ BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
|
|
|
+ return map[string]string{"mikekelly": alias}, nil
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ assert.Equal(t, "MICHAELKELLY", sess.Aliases(context.Background())["mikekelly"])
|
|
|
+
|
|
|
+ alias = "MIKE"
|
|
|
+ assert.Equal(t, "MICHAELKELLY", sess.Aliases(context.Background())["mikekelly"], "cached until invalidated")
|
|
|
+
|
|
|
+ sess.InvalidateAliases()
|
|
|
+ assert.Equal(t, "MIKE", sess.Aliases(context.Background())["mikekelly"])
|
|
|
+}
|
|
|
+
|
|
|
+// A failed load must not be cached as an empty map: aliases would stay missing for
|
|
|
+// the life of the session.
|
|
|
+func TestWebAPISession_AliasLoadErrorIsNotCached(t *testing.T) {
|
|
|
+ var loads int
|
|
|
+ sess := &WebAPISession{
|
|
|
+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
|
+ BuddyAliasLoader: func(_ context.Context) (map[string]string, error) {
|
|
|
+ loads++
|
|
|
+ if loads == 1 {
|
|
|
+ return nil, io.EOF
|
|
|
+ }
|
|
|
+ return map[string]string{"mikekelly": "MICHAELKELLY"}, nil
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ assert.Empty(t, sess.Aliases(context.Background()))
|
|
|
+ assert.Equal(t, "MICHAELKELLY", sess.Aliases(context.Background())["mikekelly"])
|
|
|
+}
|
|
|
+
|
|
|
+func TestWebAPISession_HandleIncomingIM_NormalizesAimID(t *testing.T) {
|
|
|
+ sess := &WebAPISession{
|
|
|
+ ScreenName: DisplayScreenName("me"),
|
|
|
+ Events: []string{"im", "conversation"},
|
|
|
+ EventQueue: types.NewEventQueue(10),
|
|
|
+ logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
|
+ }
|
|
|
+
|
|
|
+ frags, err := wire.ICBMFragmentList("hello")
|
|
|
+ assert.NoError(t, err)
|
|
|
+
|
|
|
+ body := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
|
|
|
+ ChannelID: wire.ICBMChannelIM,
|
|
|
+ TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
|
|
|
+ }
|
|
|
+ body.Append(wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags))
|
|
|
+
|
|
|
+ sess.handleIncomingIM(wire.SNACMessage{Body: body})
|
|
|
+
|
|
|
+ events := sess.EventQueue.GetAllEvents()
|
|
|
+ require.Len(t, events, 2)
|
|
|
+
|
|
|
+ imEvent := events[0].Data.(types.IMEvent)
|
|
|
+ assert.Equal(t, "mikekelly", imEvent.Source.AimID)
|
|
|
+ assert.Equal(t, "Mike Kelly", imEvent.Source.DisplayID)
|
|
|
+
|
|
|
+ convData := events[1].Data.(map[string]interface{})
|
|
|
+ entries := convData["conversations"].([]map[string]interface{})
|
|
|
+ require.Len(t, entries, 1)
|
|
|
+ assert.Equal(t, "mikekelly", entries[0]["aimId"])
|
|
|
+ assert.Equal(t, "Mike Kelly", entries[0]["displayId"])
|
|
|
+ assert.Equal(t, "mikekelly", entries[0]["lastIM"].(map[string]interface{})["sender"])
|
|
|
+
|
|
|
+ // The IM log is keyed by aimId, so the conversation the client opens from
|
|
|
+ // this event finds its own history.
|
|
|
+ msgs := sess.GetStoredIMs(StoredIMQuery{PartnerAimID: "mikekelly", NToGet: 10})
|
|
|
+ require.Len(t, msgs, 1)
|
|
|
+ assert.Equal(t, "hello", msgs[0]["message"])
|
|
|
+}
|
|
|
+
|
|
|
+func TestWebAPISession_HandleTypingNotification_NormalizesAimID(t *testing.T) {
|
|
|
+ sess := &WebAPISession{
|
|
|
+ Events: []string{"typing"},
|
|
|
+ EventQueue: types.NewEventQueue(10),
|
|
|
+ }
|
|
|
+
|
|
|
+ sess.handleTypingNotification(wire.SNACMessage{
|
|
|
+ Body: wire.SNAC_0x04_0x14_ICBMClientEvent{
|
|
|
+ ScreenName: "Mike Kelly",
|
|
|
+ Event: 0x0002,
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ events := sess.EventQueue.GetAllEvents()
|
|
|
+ require.Len(t, events, 1)
|
|
|
+ typing := events[0].Data.(types.TypingEvent)
|
|
|
+ assert.Equal(t, "mikekelly", typing.AimID)
|
|
|
+ assert.Equal(t, "typing", typing.TypingStatus)
|
|
|
+}
|
|
|
+
|
|
|
+func TestWebAPISession_HandleBuddyArrivedDeparted_NormalizesAimID(t *testing.T) {
|
|
|
+ sess := &WebAPISession{
|
|
|
+ Events: []string{"presence"},
|
|
|
+ EventQueue: types.NewEventQueue(10),
|
|
|
+ }
|
|
|
+
|
|
|
+ sess.handleBuddyArrived(wire.SNACMessage{
|
|
|
+ Body: wire.SNAC_0x03_0x0B_BuddyArrived{
|
|
|
+ TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
|
|
|
+ },
|
|
|
+ })
|
|
|
+ sess.handleBuddyDeparted(wire.SNACMessage{
|
|
|
+ Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
|
|
|
+ TLVUserInfo: wire.TLVUserInfo{ScreenName: "Mike Kelly"},
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ events := sess.EventQueue.GetAllEvents()
|
|
|
+ require.Len(t, events, 2)
|
|
|
+
|
|
|
+ arrived := events[0].Data.(types.PresenceEvent)
|
|
|
+ assert.Equal(t, "mikekelly", arrived.AimID)
|
|
|
+ assert.Equal(t, "online", arrived.State)
|
|
|
+
|
|
|
+ departed := events[1].Data.(types.PresenceEvent)
|
|
|
+ assert.Equal(t, "mikekelly", departed.AimID)
|
|
|
+ assert.Equal(t, "offline", departed.State)
|
|
|
+}
|