Преглед изворни кода

webapi: implement client-side caps and mood management

Mike пре 2 дана
родитељ
комит
4219161ad2

+ 1 - 1
foodgroup/locate_test.go

@@ -925,7 +925,7 @@ func TestLocateService_SetInfo_MoodChange(t *testing.T) {
 			}
 			instance := newTestInstance("screen-name", opts...)
 			if tt.mood != (uuid.UUID{}) {
-				instance.SetMood(tt.mood)
+				instance.SetCaps([][16]byte{tt.mood})
 			}
 
 			messageRelayer := newMockMessageRelayer(t)

+ 1 - 5
server/webapi/aim_handler.go

@@ -14,8 +14,6 @@ import (
 	"strings"
 	"time"
 
-	"github.com/google/uuid"
-
 	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
@@ -242,9 +240,7 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
 		TLVRestBlock: wire.TLVRestBlock{
 			TLVList: wire.TLVList{
-				wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, []uuid.UUID{
-					wire.CapICQCh2Extended,
-				}),
+				wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, webAPICaps),
 			},
 		},
 	}

+ 3 - 3
server/webapi/presence_handler.go

@@ -481,7 +481,7 @@ func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, sess
 	if r.URL.Query().Has("mood") {
 		moodID := r.URL.Query().Get("mood")
 		if moodID == "" {
-			session.OSCARSession.ClearMood()
+			session.ClearMood()
 		} else {
 			m, hasMood := wire.MoodByID(moodID)
 			if !hasMood {
@@ -489,12 +489,12 @@ func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, sess
 				return
 			}
 
-			session.OSCARSession.SetMood(m.Cap)
+			session.SetMood(m.Cap)
 		}
 		setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
 			TLVRestBlock: wire.TLVRestBlock{
 				TLVList: wire.TLVList{
-					wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, session.OSCARSession.Caps()),
+					wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, session.Caps()),
 				},
 			},
 		}

+ 39 - 28
server/webapi/presence_handler_test.go

@@ -1029,13 +1029,23 @@ func searchPageTargets(n int) []string {
 	return names
 }
 
-// setStatusCaps drives setStatus and returns the capability list that reached
-// LocateService.SetInfo, or nil when SetInfo was never called.
-func setStatusCaps(t *testing.T, instance *state.SessionInstance, query string) ([][16]byte, int) {
+// newSetStatusDriver returns a function that drives setStatus and reports the
+// capability list that reached LocateService.SetInfo, or nil when SetInfo was
+// never called. The web session is built once, so consecutive calls see the
+// capabilities the earlier ones left on it. It starts out with the sign-on
+// capabilities, unless caps replaces them.
+func newSetStatusDriver(t *testing.T, caps ...[16]byte) func(query string) ([][16]byte, int) {
 	t.Helper()
 
+	instance := state.NewSession().AddInstance()
 	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
 
+	session, err := sessionMgr.GetSession(context.Background(), aimsid)
+	require.NoError(t, err)
+	if len(caps) > 0 {
+		session.capabilities = caps
+	}
+
 	var gotCaps [][16]byte
 	var called bool
 	locate := newMockLocateService(t)
@@ -1050,8 +1060,6 @@ func setStatusCaps(t *testing.T, instance *state.SessionInstance, query string)
 					TLVList: wire.TLVList{wire.NewTLVBE(wire.OServiceUserInfoOscarCaps, b)},
 				},
 			})
-			// Apply it, as the real service would, so a follow-up call sees it.
-			instance.SetCaps(gotCaps)
 		}).Return(nil).Maybe()
 
 	oservice := newMockOServiceService(t)
@@ -1067,16 +1075,20 @@ func setStatusCaps(t *testing.T, instance *state.SessionInstance, query string)
 		Logger:          slog.Default(),
 	}
 
-	req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+query, nil)
-	require.NoError(t, err)
+	return func(query string) ([][16]byte, int) {
+		gotCaps, called = nil, false
 
-	rr := httptest.NewRecorder()
-	requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
+		req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+query, nil)
+		require.NoError(t, err)
+
+		rr := httptest.NewRecorder()
+		requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
 
-	if !called {
-		return nil, rr.Code
+		if !called {
+			return nil, rr.Code
+		}
+		return gotCaps, rr.Code
 	}
-	return gotCaps, rr.Code
 }
 
 // A state change must not blank the status message: the client renders both from
@@ -1252,19 +1264,20 @@ func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
 		{
 			name:     "a known mood is advertised as its capability",
 			query:    "&mood=0icqmood6",
-			wantCaps: [][16]byte{wire.CapXStatusPlate},
+			wantCaps: [][16]byte{wire.CapICQCh2Extended, wire.CapXStatusPlate},
 		},
 		{
 			name:     "a mood with only a placeholder capability still resolves",
 			query:    "&mood=0icqmood13",
-			wantCaps: [][16]byte{wire.CapMoodHavingFun},
+			wantCaps: [][16]byte{wire.CapICQCh2Extended, wire.CapMoodHavingFun},
 		},
 		{
 			// The client sends mood= alongside every plain state change, so this
-			// is the path back to a moodless online/away/invisible.
+			// is the path back to a moodless online/away/invisible. Only the
+			// mood is dropped.
 			name:     "an empty mood clears the capability",
 			query:    "&mood=",
-			wantCaps: [][16]byte{},
+			wantCaps: [][16]byte{wire.CapICQCh2Extended},
 		},
 		{
 			// A token the server cannot map is a client bug, not a reset: the
@@ -1282,9 +1295,7 @@ func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			instance := state.NewSession().AddInstance()
-
-			gotCaps, code := setStatusCaps(t, instance, tt.query)
+			gotCaps, code := newSetStatusDriver(t)(tt.query)
 
 			if tt.wantCode != 0 {
 				assert.Equal(t, tt.wantCode, code)
@@ -1299,27 +1310,27 @@ func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
 }
 
 func TestPresenceHandler_SetStatus_MoodReplacesRatherThanAccumulates(t *testing.T) {
-	instance := state.NewSession().AddInstance()
+	setStatus := newSetStatusDriver(t)
 
-	_, code := setStatusCaps(t, instance, "&mood=0icqmood6")
+	_, code := setStatus("&mood=0icqmood6")
 	assert.Equal(t, http.StatusOK, code)
 
-	gotCaps, code := setStatusCaps(t, instance, "&mood=0icqmood4")
+	gotCaps, code := setStatus("&mood=0icqmood4")
 	assert.Equal(t, http.StatusOK, code)
-	assert.Equal(t, [][16]byte{wire.CapXStatusBeer}, gotCaps, "the previous mood must be dropped")
+	assert.Equal(t, [][16]byte{wire.CapICQCh2Extended, wire.CapXStatusBeer}, gotCaps,
+		"the previous mood must be dropped")
 }
 
 func TestPresenceHandler_SetStatus_PreservesNonMoodCaps(t *testing.T) {
-	// The capability list is rewritten wholesale, so anything the instance
+	// The capability list is rewritten wholesale, so anything the session
 	// advertises that is not a mood has to be carried over.
-	instance := state.NewSession().AddInstance()
-	instance.SetCaps([][16]byte{wire.CapChat, wire.CapXStatusBeer})
+	setStatus := newSetStatusDriver(t, wire.CapChat, wire.CapXStatusBeer)
 
-	gotCaps, code := setStatusCaps(t, instance, "&mood=0icqmood6")
+	gotCaps, code := setStatus("&mood=0icqmood6")
 	assert.Equal(t, http.StatusOK, code)
 	assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusPlate}, gotCaps)
 
-	gotCaps, code = setStatusCaps(t, instance, "&mood=")
+	gotCaps, code = setStatus("&mood=")
 	assert.Equal(t, http.StatusOK, code)
 	assert.Equal(t, [][16]byte{wire.CapChat}, gotCaps, "clearing a mood must keep the other caps")
 }

+ 50 - 0
server/webapi/session.go

@@ -15,6 +15,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/google/uuid"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
@@ -56,6 +57,11 @@ const (
 	webAPISessionReapInterval = 30 * time.Second
 )
 
+// webAPICaps are the capabilities the Web API advertises on behalf of its
+// clients. It seeds every session's capability list and is sent as-is at
+// sign-on, so the two never drift.
+var webAPICaps = [][16]byte{wire.CapICQCh2Extended}
+
 // Session represents an active Web AIM API session.
 type Session struct {
 	AimSID              string                                 // Unique session ID for web client
@@ -97,6 +103,9 @@ type Session struct {
 
 	closeMu sync.Mutex
 	closed  bool
+
+	capabilities [][16]byte
+	capsMu       sync.RWMutex
 }
 
 // IsExpired checks if the session has expired.
@@ -774,6 +783,7 @@ func (m *SessionManager) CreateSession(screenName state.DisplayScreenName, event
 		FetchTimeout:    60000, // 60 seconds default for better stability
 		TimeToNextFetch: 500,   // 500ms suggested delay
 		logger:          logger,
+		capabilities:    slices.Clone(webAPICaps),
 	}
 
 	m.sessions[aimsid] = session
@@ -1103,6 +1113,46 @@ func (s *Session) GetStoredIMs(q StoredIMQuery) []StoredIM {
 	return out
 }
 
+func (s *Session) Caps() [][16]byte {
+	s.capsMu.RLock()
+	defer s.capsMu.RUnlock()
+	return slices.Clone(s.capabilities)
+}
+
+// ClearMood removes the mood capability the session advertises, if any. The
+// user then presents whatever presence state they are in.
+func (s *Session) ClearMood() {
+	s.capsMu.Lock()
+	defer s.capsMu.Unlock()
+	s.clearMood()
+}
+
+// clearMood drops every mood capability the session advertises. The caller must
+// hold s.capsMu.
+func (s *Session) clearMood() {
+	s.capabilities = slices.DeleteFunc(s.capabilities, func(cap [16]byte) bool {
+		return wire.IsMoodCap(cap)
+	})
+}
+
+// SetMood replaces the mood capability the session advertises. A client shows
+// one mood at a time, so whichever mood was set before is dropped.
+//
+// It panics when mood is not a mood capability: the caller resolves it from the
+// mood table, so anything else is a programming error rather than bad input.
+func (s *Session) SetMood(mood uuid.UUID) {
+	s.capsMu.Lock()
+	defer s.capsMu.Unlock()
+
+	if !wire.IsMoodCap(mood) {
+		panic("uuid is not a mood capability")
+	}
+
+	s.clearMood()
+
+	s.capabilities = append(s.capabilities, mood)
+}
+
 // normalizeWebAPIAimID keys the IM log by the same normalization the web client
 // applies to aimIds, so a partner stored from a display screen name is still
 // found when the client queries by aimId.

+ 78 - 0
server/webapi/session_test.go

@@ -1523,3 +1523,81 @@ func TestSession_PublishesMoodOnPresence(t *testing.T) {
 		assert.Empty(t, got.MoodIcon)
 	})
 }
+
+func TestSession_SetMood(t *testing.T) {
+	t.Run("advertises the mood alongside the existing capabilities", func(t *testing.T) {
+		sess := &Session{capabilities: [][16]byte{wire.CapChat}}
+
+		sess.SetMood(wire.CapXStatusBeer)
+
+		assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusBeer}, sess.Caps())
+	})
+
+	t.Run("a second mood replaces the first", func(t *testing.T) {
+		// A client shows one mood at a time, so the moods must not accumulate.
+		sess := &Session{capabilities: [][16]byte{wire.CapChat}}
+
+		sess.SetMood(wire.CapXStatusBeer)
+		sess.SetMood(wire.CapXStatusMusic)
+
+		assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusMusic}, sess.Caps())
+	})
+
+	t.Run("panics on a capability that is not a mood", func(t *testing.T) {
+		sess := &Session{}
+		assert.Panics(t, func() {
+			sess.SetMood(wire.CapChat)
+		})
+	})
+}
+
+func TestSession_ClearMood(t *testing.T) {
+	t.Run("removes the mood and keeps every other capability", func(t *testing.T) {
+		sess := &Session{capabilities: [][16]byte{wire.CapChat, wire.CapFileTransfer}}
+		sess.SetMood(wire.CapXStatusBeer)
+
+		sess.ClearMood()
+
+		assert.Equal(t, [][16]byte{wire.CapChat, wire.CapFileTransfer}, sess.Caps())
+	})
+
+	t.Run("is a no-op when no mood is set", func(t *testing.T) {
+		sess := &Session{capabilities: [][16]byte{wire.CapChat}}
+
+		sess.ClearMood()
+
+		assert.Equal(t, [][16]byte{wire.CapChat}, sess.Caps())
+	})
+}
+
+func TestSession_Caps(t *testing.T) {
+	t.Run("returns a copy the caller cannot write through", func(t *testing.T) {
+		sess := &Session{capabilities: [][16]byte{wire.CapChat}}
+
+		caps := sess.Caps()
+		caps[0] = wire.CapFileTransfer
+
+		assert.Equal(t, [][16]byte{wire.CapChat}, sess.Caps())
+	})
+
+	t.Run("a session advertising no capabilities returns an empty list", func(t *testing.T) {
+		assert.Empty(t, (&Session{}).Caps())
+	})
+}
+
+func TestSessionManager_CreateSession_SeedsCapabilities(t *testing.T) {
+	// setStatus rewrites the capability list wholesale from the session's own
+	// list, so the sign-on capabilities have to start out in it.
+	mgr := NewSessionManager()
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+
+	sess, err := mgr.CreateSession("testuser", nil, state.NewSession().AddInstance(), "", logger)
+	require.NoError(t, err)
+	assert.Equal(t, webAPICaps, sess.Caps())
+
+	sess.SetMood(wire.CapXStatusBeer)
+
+	other, err := mgr.CreateSession("otheruser", nil, state.NewSession().AddInstance(), "", logger)
+	require.NoError(t, err)
+	assert.Equal(t, webAPICaps, other.Caps(), "one session's mood must not reach the next session's seed")
+}

+ 0 - 35
state/session.go

@@ -1386,15 +1386,6 @@ func (s *SessionInstance) Caps() [][16]byte {
 	return slices.Clone(s.capabilities)
 }
 
-// ClearMood removes the mood capability the instance advertises, if any. The
-// user then presents whatever presence state they are in.
-func (s *SessionInstance) ClearMood() {
-	s.mutex.Lock()
-	defer s.mutex.Unlock()
-
-	s.clearMood()
-}
-
 // ClearUserInfoFlag clears a flag from the user info bitmask.
 func (s *SessionInstance) ClearUserInfoFlag(flag uint16) (flags uint16) {
 	s.mutex.Lock()
@@ -1486,24 +1477,6 @@ func (s *SessionInstance) SetKerberosAuth(enabled bool) {
 	s.kerberosAuth = enabled
 }
 
-// SetMood replaces the mood capability the instance advertises. A client shows
-// one mood at a time, so whichever mood was set before is dropped.
-//
-// It panics when mood is not a mood capability: the caller resolves it from the
-// mood table, so anything else is a programming error rather than bad input.
-func (s *SessionInstance) SetMood(mood uuid.UUID) {
-	s.mutex.Lock()
-	defer s.mutex.Unlock()
-
-	if !wire.IsMoodCap(mood) {
-		panic("uuid is not a mood capability")
-	}
-
-	s.clearMood()
-
-	s.capabilities = append(s.capabilities, mood)
-}
-
 // SetMultiConnFlag sets the multi-connection flag for this instance.
 func (s *SessionInstance) SetMultiConnFlag(flag wire.MultiConnFlag) {
 	s.mutex.Lock()
@@ -1592,14 +1565,6 @@ func (s *SessionInstance) caps() [][16]byte {
 	return s.capabilities
 }
 
-// clearMood drops every mood capability the instance advertises. The caller must
-// hold s.mutex.
-func (s *SessionInstance) clearMood() {
-	s.capabilities = slices.DeleteFunc(s.capabilities, func(cap [16]byte) bool {
-		return wire.IsMoodCap(cap)
-	})
-}
-
 //
 // Message Sending
 //

+ 10 - 49
state/session_test.go

@@ -10,6 +10,8 @@ import (
 	"testing/synctest"
 	"time"
 
+	"github.com/google/uuid"
+
 	"github.com/mk6i/open-oscar-server/wire"
 
 	"github.com/stretchr/testify/assert"
@@ -2658,63 +2660,22 @@ func TestSessionInstance_Caps(t *testing.T) {
 	})
 }
 
-func TestSessionInstance_SetMood(t *testing.T) {
-	t.Run("advertises the mood alongside the existing capabilities", func(t *testing.T) {
+func TestSessionInstance_Mood(t *testing.T) {
+	t.Run("returns the mood among the advertised capabilities", func(t *testing.T) {
 		instance := NewSession().AddInstance()
-		instance.SetCaps([][16]byte{wire.CapChat})
+		instance.SetCaps([][16]byte{wire.CapChat, wire.CapXStatusBeer})
 
-		instance.SetMood(wire.CapXStatusBeer)
-
-		assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusBeer}, instance.Caps())
+		assert.Equal(t, uuid.UUID(wire.CapXStatusBeer), instance.Mood())
 	})
 
-	t.Run("a second mood replaces the first", func(t *testing.T) {
-		// A client shows one mood at a time, so the moods must not accumulate.
+	t.Run("returns the zero UUID when no capability is a mood", func(t *testing.T) {
 		instance := NewSession().AddInstance()
 		instance.SetCaps([][16]byte{wire.CapChat})
 
-		instance.SetMood(wire.CapXStatusBeer)
-		instance.SetMood(wire.CapXStatusMusic)
-
-		assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusMusic}, instance.Caps())
+		assert.Equal(t, uuid.UUID{}, instance.Mood())
 	})
 
-	t.Run("panics on a capability that is not a mood", func(t *testing.T) {
-		instance := NewSession().AddInstance()
-		assert.Panics(t, func() {
-			instance.SetMood(wire.CapChat)
-		})
-	})
-}
-
-func TestSessionInstance_ClearMood(t *testing.T) {
-	t.Run("removes the mood and keeps every other capability", func(t *testing.T) {
-		instance := NewSession().AddInstance()
-		instance.SetCaps([][16]byte{wire.CapChat, wire.CapFileTransfer})
-		instance.SetMood(wire.CapXStatusBeer)
-
-		instance.ClearMood()
-
-		assert.Equal(t, [][16]byte{wire.CapChat, wire.CapFileTransfer}, instance.Caps())
-	})
-
-	t.Run("is a no-op when no mood is set", func(t *testing.T) {
-		instance := NewSession().AddInstance()
-		instance.SetCaps([][16]byte{wire.CapChat})
-
-		instance.ClearMood()
-
-		assert.Equal(t, [][16]byte{wire.CapChat}, instance.Caps())
-	})
-
-	t.Run("removes a mood a client advertised through SetCaps", func(t *testing.T) {
-		// An ICQ client sets its mood by sending it in its own capability list,
-		// so a mood can arrive without SetMood ever being called.
-		instance := NewSession().AddInstance()
-		instance.SetCaps([][16]byte{wire.CapXStatusBeer, wire.CapChat})
-
-		instance.ClearMood()
-
-		assert.Equal(t, [][16]byte{wire.CapChat}, instance.Caps())
+	t.Run("returns the zero UUID when no capabilities are advertised", func(t *testing.T) {
+		assert.Equal(t, uuid.UUID{}, NewSession().AddInstance().Mood())
 	})
 }