| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036 |
- package webapi
- import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/require"
- "github.com/mk6i/open-oscar-server/state"
- "github.com/mk6i/open-oscar-server/wire"
- )
- // onlineUserInfoReply builds a locate UserInfoReply for an online user,
- // optionally marking them idle by the given number of minutes (0 = not idle).
- func onlineUserInfoReply(screenName string, idleMinutes uint16) wire.SNACMessage {
- info := wire.TLVUserInfo{ScreenName: screenName}
- if idleMinutes > 0 {
- info.Append(wire.NewTLVBE(wire.OServiceUserInfoIdleTime, idleMinutes))
- }
- return wire.SNACMessage{
- Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{TLVUserInfo: info},
- }
- }
- // screenNameMatcher matches a UserInfoQuery request body by its target screen name.
- func screenNameMatcher(screenName string) any {
- return mock.MatchedBy(func(b wire.SNAC_0x02_0x05_LocateUserInfoQuery) bool {
- return b.ScreenName == screenName
- })
- }
- func TestPresenceHandler_GetPresence(t *testing.T) {
- tests := []struct {
- name string
- queryParams string
- setupMocks func(*mockFeedbagService, *mockLocateService)
- expectedStatusCode int
- checkResponse func(*testing.T, string)
- }{
- {
- name: "Success_BuddyList",
- queryParams: "bl=1",
- setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
- // Return feedbag with a group and buddy
- fr.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{
- Body: wire.SNAC_0x13_0x06_FeedbagReply{
- Items: []wire.FeedbagItem{
- {ItemID: 0, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 1},
- {ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1},
- },
- },
- }, nil)
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("buddy1")).
- Return(onlineUserInfoReply("buddy1", 0), nil)
- },
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- assert.Contains(t, body, `"groups"`)
- assert.Contains(t, body, `"Friends"`)
- assert.Contains(t, body, `"buddy1"`)
- assert.Contains(t, body, `"online"`)
- },
- },
- {
- name: "Success_TargetUsers",
- queryParams: "t=user1,user2",
- setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user1")).
- Return(onlineUserInfoReply("user1", 0), nil)
- // user2 is idle for 7 minutes.
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("user2")).
- Return(onlineUserInfoReply("user2", 7), nil)
- },
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- assert.Contains(t, body, `"users"`)
- assert.Contains(t, body, `"user1"`)
- assert.Contains(t, body, `"user2"`)
- assert.Contains(t, body, `"idle"`)
- },
- },
- {
- name: "Success_BlockedOrOfflineUser",
- queryParams: "t=blockeduser",
- setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
- // A blocked or offline user comes back as a locate error.
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("blockeduser")).
- Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
- },
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- assert.Contains(t, body, `"blockeduser"`)
- assert.Contains(t, body, `"offline"`)
- },
- },
- {
- name: "Success_EmptyRequest",
- queryParams: "",
- setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {},
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- },
- },
- {
- // A full search page: memberDir/search returns 20 profiles and the
- // client asks about every one, plus the keyword when it is a UIN.
- // This is the largest list a real client sends, and it must be served
- // whole — anything but a 200 costs the user the entire page.
- name: "Success_FullSearchPage",
- queryParams: "t=" + strings.Join(searchPageTargets(21), "&t="),
- setupMocks: func(fr *mockFeedbagService, ls *mockLocateService) {
- ls.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
- return onlineUserInfoReply(body.ScreenName, 0), nil
- }).Times(21)
- },
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- assert.Contains(t, body, `"user0"`)
- assert.Contains(t, body, `"user20"`)
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- feedbagService := newMockFeedbagService(t)
- locateService := newMockLocateService(t)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- tt.setupMocks(feedbagService, locateService)
- // Presence payloads carry the viewer's alias, so GetPresence reads the
- // feedbag. Registered last so a case's own Query stub takes precedence.
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- reqURL := "/presence/get?aimsid=" + aimsid
- if tt.queryParams != "" {
- reqURL += "&" + tt.queryParams
- }
- req, err := http.NewRequest("GET", reqURL, nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, tt.expectedStatusCode, rr.Code)
- if tt.checkResponse != nil {
- responseBody := strings.TrimSpace(rr.Body.String())
- tt.checkResponse(t, responseBody)
- }
- })
- }
- }
- // TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly verifies that
- // the icon is published only for an online, non-blocking user, and that an
- // offline or blocking user is never even looked up — so neither their icon nor
- // its hash leaks to a caller they are invisible to.
- func TestPresenceHandler_GetPresence_PublishesIconForOnlineBuddiesOnly(t *testing.T) {
- ctx := context.Background()
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("onlineuser")).
- Return(onlineUserInfoReply("onlineuser", 0), nil)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("offlineuser")).
- Return(wire.SNACMessage{Body: wire.SNACError{Code: wire.ErrorCodeNotLoggedOn}}, nil)
- iconRetriever := newMockBuddyIconRetriever(t)
- iconRetriever.EXPECT().BuddyIconMetadata(mock.Anything, state.NewIdentScreenName("onlineuser")).
- Return(bartID([]byte{0xab, 0xcd}), nil).Once()
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- sess, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- sess.BaseURL = "http://api.example.com"
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- IconSource: BuddyIconSource{IconRetriever: iconRetriever, Logger: slog.Default()},
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=onlineuser,offlineuser", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- var got struct {
- Response struct {
- Data struct {
- Users []struct {
- AimID string `json:"aimId"`
- State string `json:"state"`
- BuddyIcon string `json:"buddyIcon"`
- } `json:"users"`
- } `json:"data"`
- } `json:"response"`
- }
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
- icons := map[string]string{}
- states := map[string]string{}
- for _, u := range got.Response.Data.Users {
- icons[u.AimID] = u.BuddyIcon
- states[u.AimID] = u.State
- }
- assert.Equal(t, "online", states["onlineuser"])
- assert.Equal(t,
- "http://api.example.com/expressions/get?t=onlineuser&type=buddyIcon&bartId=abcd",
- icons["onlineuser"])
- assert.Equal(t, "offline", states["offlineuser"])
- assert.Empty(t, icons["offlineuser"])
- iconRetriever.AssertNotCalled(t, "BuddyIconMetadata", mock.Anything, state.NewIdentScreenName("offlineuser"))
- }
- // TestPresenceHandler_GetPresence_BuddyListGrouping verifies that bl=1 places
- // each buddy under its own group using realistic feedbag data, where group rows
- // carry ItemID 0 and a distinct nonzero GroupID, and buddy rows reference those
- // GroupIDs. This is the shape the OSCAR feedbag actually stores.
- func TestPresenceHandler_GetPresence_BuddyListGrouping(t *testing.T) {
- feedbagService := newMockFeedbagService(t)
- locateService := newMockLocateService(t)
- items := []wire.FeedbagItem{
- // Root order group: ItemID 0, GroupID 0, empty name — not a real buddy group.
- {ItemID: 0, GroupID: 0, ClassID: wire.FeedbagClassIdGroup, Name: ""},
- // Named groups: ItemID 0, distinct nonzero GroupIDs.
- {ItemID: 0, GroupID: 10, ClassID: wire.FeedbagClassIdGroup, Name: "Friends"},
- {ItemID: 0, GroupID: 20, ClassID: wire.FeedbagClassIdGroup, Name: "Work"},
- // Buddies reference their group's GroupID.
- {ItemID: 101, GroupID: 10, ClassID: wire.FeedbagClassIdBuddy, Name: "alice"},
- {ItemID: 201, GroupID: 20, ClassID: wire.FeedbagClassIdBuddy, Name: "bob"},
- }
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: items}}, nil)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("alice")).
- Return(onlineUserInfoReply("alice", 0), nil)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("bob")).
- Return(onlineUserInfoReply("bob", 0), nil)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&bl=1", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- var parsed struct {
- Response struct {
- Data struct {
- Groups []struct {
- Name string `json:"name"`
- Buddies []struct {
- AimID string `json:"aimId"`
- } `json:"buddies"`
- } `json:"groups"`
- } `json:"data"`
- } `json:"response"`
- }
- assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &parsed))
- // Build name -> set of buddy aimIds.
- byGroup := map[string][]string{}
- for _, g := range parsed.Response.Data.Groups {
- for _, b := range g.Buddies {
- byGroup[g.Name] = append(byGroup[g.Name], b.AimID)
- }
- }
- // Exactly the two named groups appear; the root group is excluded.
- assert.Len(t, parsed.Response.Data.Groups, 2)
- assert.Equal(t, []string{"alice"}, byGroup["Friends"])
- assert.Equal(t, []string{"bob"}, byGroup["Work"])
- }
- func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
- handler := &PresenceHandler{
- SessionManager: NewSessionManager(),
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusBadRequest, rr.Code)
- assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
- }
- func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
- handler := &PresenceHandler{
- SessionManager: NewSessionManager(),
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid=nonexistent", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusUnauthorized, rr.Code)
- assert.Contains(t, rr.Body.String(), "invalid or expired session")
- }
- func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
- handler := &PresenceHandler{
- SessionManager: NewSessionManager(),
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusBadRequest, rr.Code)
- assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
- }
- func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=bogus", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusBadRequest, rr.Code)
- assert.Contains(t, rr.Body.String(), "invalid state parameter")
- }
- func TestPresenceHandler_SetState_EmitsMyInfoEvent(t *testing.T) {
- // The AIM client re-renders its own status badge only from "myInfo" events,
- // so setState must queue one on the user's own session for the change to be
- // visible in their UI.
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- broadcaster := newMockBuddyBroadcaster(t)
- broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- BuddyBroadcaster: broadcaster,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away&awayMsg=brb", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- session, err := sessionMgr.GetSession(context.Background(), aimsid)
- assert.NoError(t, err)
- myInfo := queuedMyInfo(session)
- assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
- assert.Equal(t, "away", myInfo.State)
- assert.Equal(t, "brb", myInfo.AwayMsg)
- assert.Equal(t, "testuser", myInfo.AimID)
- }
- func TestPresenceHandler_SetState_MyInfoNormalizesAimID(t *testing.T) {
- // The client shallow-merges myInfo onto the shared user object, so aimId must
- // be the normalized id while displayId and friendly keep the user's own
- // casing and spacing.
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("Mike Kelly", oscarInstance)
- broadcaster := newMockBuddyBroadcaster(t)
- broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- BuddyBroadcaster: broadcaster,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=away", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- // The setState response body carries the same identity fields.
- var resp struct {
- Response struct {
- Data map[string]any `json:"data"`
- } `json:"response"`
- }
- assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
- assert.Equal(t, "mikekelly", resp.Response.Data["aimId"])
- assert.Equal(t, "Mike Kelly", resp.Response.Data["displayId"])
- session, err := sessionMgr.GetSession(context.Background(), aimsid)
- assert.NoError(t, err)
- myInfo := queuedMyInfo(session)
- require.NotNil(t, myInfo, "expected a myInfo event to be queued")
- assert.Equal(t, "mikekelly", myInfo.AimID)
- assert.Equal(t, "Mike Kelly", myInfo.DisplayID)
- assert.Equal(t, "Mike Kelly", myInfo.Friendly)
- }
- func TestPresenceHandler_Icon(t *testing.T) {
- tests := []struct {
- name string
- queryParams string
- expectedStatusCode int
- checkRedirect func(*testing.T, *httptest.ResponseRecorder)
- }{
- {
- name: "Redirect_OfflineUser",
- // No aimsid, so there is no OSCAR session to query on behalf of and
- // the target resolves to offline.
- queryParams: "name=offlineuser",
- expectedStatusCode: http.StatusFound,
- checkRedirect: func(t *testing.T, rr *httptest.ResponseRecorder) {
- location := rr.Header().Get("Location")
- assert.Contains(t, location, "offline")
- },
- },
- {
- name: "Error_MissingName",
- queryParams: "",
- expectedStatusCode: http.StatusBadRequest,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- handler := &PresenceHandler{
- SessionManager: NewSessionManager(),
- LocateService: newMockLocateService(t),
- Logger: slog.Default(),
- }
- reqURL := "/presence/icon"
- if tt.queryParams != "" {
- reqURL += "?" + tt.queryParams
- }
- req, err := http.NewRequest("GET", reqURL, nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- handler.Icon(rr, req)
- assert.Equal(t, tt.expectedStatusCode, rr.Code)
- if tt.checkRedirect != nil {
- tt.checkRedirect(t, rr)
- }
- })
- }
- }
- func TestPresenceHandler_SetProfile(t *testing.T) {
- oscarInstance := state.NewSession().AddInstance()
- tests := []struct {
- name string
- queryParams string
- setupMocks func(*mockLocateService)
- expectedStatusCode int
- checkResponse func(*testing.T, string)
- }{
- {
- name: "Success_SetProfile",
- queryParams: "profile=Hello+World",
- setupMocks: func(ls *mockLocateService) {
- ls.EXPECT().SetInfo(mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNAC_0x02_0x04_LocateSetInfo")).Return(nil)
- },
- expectedStatusCode: http.StatusOK,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, `"statusCode":200`)
- },
- },
- {
- name: "Error_ProfileTooLarge",
- queryParams: "profile=" + strings.Repeat("x", 4097),
- setupMocks: func(ls *mockLocateService) {},
- expectedStatusCode: http.StatusBadRequest,
- checkResponse: func(t *testing.T, body string) {
- assert.Contains(t, body, "profile too large")
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- locateService := newMockLocateService(t)
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- tt.setupMocks(locateService)
- reqURL := "/presence/setProfile?aimsid=" + aimsid + "&" + tt.queryParams
- req, err := http.NewRequest("GET", reqURL, nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetProfile).ServeHTTP(rr, req)
- assert.Equal(t, tt.expectedStatusCode, rr.Code)
- if tt.checkResponse != nil {
- responseBody := strings.TrimSpace(rr.Body.String())
- tt.checkResponse(t, responseBody)
- }
- })
- }
- }
- func TestPresenceHandler_GetProfile(t *testing.T) {
- locateService := newMockLocateService(t)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("testuser")).
- Return(wire.SNACMessage{
- Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
- LocateInfo: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "My profile"),
- },
- },
- },
- }, nil)
- req, err := http.NewRequest("GET", "/presence/getProfile?aimsid="+aimsid, nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetProfile).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- body := rr.Body.String()
- assert.Contains(t, body, `"statusCode":200`)
- assert.Contains(t, body, `"My profile"`)
- assert.Contains(t, body, `"testuser"`)
- }
- // queuedMyInfo returns the myInfo event the session has queued, if any.
- // queuedMyInfo returns the myInfo event the session has queued, if any.
- func queuedMyInfo(session *Session) *MyInfo {
- var myInfo *MyInfo
- for _, event := range session.EventQueue.GetAllEvents() {
- if event.Type == "myInfo" {
- myInfo, _ = event.Data.(*MyInfo)
- }
- }
- return myInfo
- }
- func TestPresenceHandler_SetState_Occupied(t *testing.T) {
- // ICQ's Busy, which is a selectable connect state and must be accepted.
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- broadcaster := newMockBuddyBroadcaster(t)
- broadcaster.EXPECT().BroadcastBuddyArrived(mock.Anything, mock.Anything, mock.Anything).Return(nil)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- BuddyBroadcaster: broadcaster,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&view=occupied&away=", nil)
- assert.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetState).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- assert.Equal(t, wire.OServiceUserStatusBusy, oscarInstance.UserStatusBitmask())
- // The state must survive the round trip: a later myInfo push reads it back
- // through currentWebState, and reporting "away" or "online" there would undo
- // the change in the user's own UI.
- assert.Equal(t, "occupied", currentWebState(oscarInstance))
- session, err := sessionMgr.GetSession(context.Background(), aimsid)
- assert.NoError(t, err)
- myInfo := queuedMyInfo(session)
- assert.NotNil(t, myInfo, "expected a myInfo event to be queued")
- assert.Equal(t, "occupied", myInfo.State)
- }
- func TestPresenceHandler_GetPresence_MdirAttachesProfile(t *testing.T) {
- // Search results are populated from this call, and a user carrying no nested
- // "profile" object is dropped by clients, so a found user would not render.
- ctx := context.Background()
- dirReply := wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}
- dirReply.Append(wire.NewTLVBE(wire.ODirTLVFirstName, "Bob"))
- dirReply.Append(wire.NewTLVBE(wire.ODirTLVLastName, "Smith"))
- dirReply.Append(wire.NewTLVBE(wire.ODirTLVCity, "Reno"))
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(onlineUserInfoReply("founduser", 0), nil)
- locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: dirReply}, nil)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- _, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=founduser", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- require.Equal(t, http.StatusOK, rr.Code)
- var got struct {
- Response struct {
- Data struct {
- Users []struct {
- AimID string `json:"aimId"`
- Profile *BuddyProfileInfo `json:"profile"`
- } `json:"users"`
- } `json:"data"`
- } `json:"response"`
- }
- require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
- require.Len(t, got.Response.Data.Users, 1)
- profile := got.Response.Data.Users[0].Profile
- require.NotNil(t, profile, "mdir=1 must carry a profile object")
- assert.Equal(t, "Bob", profile.FirstName)
- assert.Equal(t, "Smith", profile.LastName)
- require.Len(t, profile.HomeAddress, 1)
- assert.Equal(t, "Reno", profile.HomeAddress[0].City)
- }
- func TestPresenceHandler_GetPresence_MdirProfileIsEmptyNotAbsent(t *testing.T) {
- // A user with no directory record must still render, or a freshly created
- // account can never be found.
- ctx := context.Background()
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(onlineUserInfoReply("blankuser", 0), nil)
- locateService.EXPECT().DirInfo(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x02_0x0C_LocateGetDirReply{Status: wire.LocateGetDirReplyOK}}, nil)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- _, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&mdir=1&t=blankuser", nil)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- // The key must be present and non-null, which is what the client tests for.
- assert.Contains(t, rr.Body.String(), `"profile":{}`)
- }
- func TestPresenceHandler_GetPresence_NoMdirOmitsProfile(t *testing.T) {
- // Without mdir the directory is not consulted at all — the mock asserts that by
- // having no DirInfo expectation.
- ctx := context.Background()
- feedbagService := newMockFeedbagService(t)
- feedbagService.EXPECT().Query(mock.Anything, mock.Anything, mock.Anything).
- Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil).Maybe()
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- Return(onlineUserInfoReply("someuser", 0), nil)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- _, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&t=someuser", nil)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.NotContains(t, rr.Body.String(), `"profile"`)
- }
- func TestPresenceHandler_GetPresence_EmptyQueryRendersBothArrays(t *testing.T) {
- ctx := context.Background()
- // No expectations on either service: naming no list must cost no lookups.
- feedbagService := newMockFeedbagService(t)
- locateService := newMockLocateService(t)
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- _, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- req, _ := http.NewRequest("GET", "/presence/get?aimsid="+aimsid+"&f=json&mdir=1", nil)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- assert.Contains(t, rr.Body.String(), `"users":[]`)
- assert.Contains(t, rr.Body.String(), `"groups":[]`)
- }
- func TestPresenceHandler_GetPresence_TruncatesOversizedTargetList(t *testing.T) {
- ctx := context.Background()
- feedbagService := newMockFeedbagService(t)
- var queried int
- locateService := newMockLocateService(t)
- locateService.EXPECT().UserInfoQuery(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
- RunAndReturn(func(_ context.Context, _ *state.SessionInstance, _ wire.SNACFrame, body wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
- queried++
- return onlineUserInfoReply(body.ScreenName, 0), nil
- })
- oscarInstance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
- _, err := sessionMgr.GetSession(ctx, aimsid)
- require.NoError(t, err)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- FeedbagService: feedbagService,
- LocateService: locateService,
- Logger: slog.Default(),
- }
- query := "/presence/get?aimsid=" + aimsid + "&f=json"
- for i := range maxPresenceTargets + 8 {
- query += fmt.Sprintf("&t=user%d", i)
- }
- req, _ := http.NewRequest("GET", query, nil)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.GetPresence).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusOK, rr.Code)
- assert.Contains(t, rr.Body.String(), `"statusCode":200`)
- assert.Equal(t, maxPresenceTargets, queried, "the list is cut to the cap, not refused")
- // A full search page has to fit: 20 profiles plus the searched-for UIN.
- assert.GreaterOrEqual(t, maxPresenceTargets, 21)
- }
- // searchPageTargets builds n distinct screen names, standing in for the hits of a
- // member-directory search page.
- func searchPageTargets(n int) []string {
- names := make([]string, 0, n)
- for i := range n {
- names = append(names, fmt.Sprintf("user%d", i))
- }
- 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, *Session, int) {
- t.Helper()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
- var gotCaps [][16]byte
- var called bool
- locate := newMockLocateService(t)
- locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).
- Run(func(_ context.Context, _ *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) {
- called = true
- b, ok := inBody.Bytes(wire.LocateTLVTagsInfoCapabilities)
- require.True(t, ok, "setStatus must always send a capabilities TLV")
- require.Zero(t, len(b)%16)
- gotCaps = userInfoCaps(wire.TLVUserInfo{
- TLVBlock: wire.TLVBlock{
- 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()
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locate,
- Logger: slog.Default(),
- }
- 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)
- session, err := sessionMgr.GetSession(context.Background(), aimsid)
- require.NoError(t, err)
- if !called {
- return nil, session, rr.Code
- }
- return gotCaps, session, rr.Code
- }
- func TestPresenceHandler_SetStatus_Mood(t *testing.T) {
- tests := []struct {
- name string
- // query is appended to the request, after aimsid.
- query string
- // wantCaps is the capability list that must reach SetInfo. A nil value
- // means SetInfo must not be called at all.
- wantCaps [][16]byte
- // wantMoodIcon is the id parameter expected on the queued myInfo, or ""
- // for no mood.
- wantMoodID string
- // wantCode defaults to 200.
- wantCode int
- }{
- {
- name: "a known mood is advertised as its capability",
- query: "&mood=0icqmood6",
- wantCaps: [][16]byte{wire.CapXStatusPlate},
- wantMoodID: "0icqmood6",
- },
- {
- name: "a mood with only a placeholder capability still resolves",
- query: "&mood=0icqmood13",
- wantCaps: [][16]byte{wire.CapMoodHavingFun},
- wantMoodID: "0icqmood13",
- },
- {
- // The client sends mood= alongside every plain state change, so this
- // is the path back to a moodless online/away/invisible.
- name: "an empty mood clears the capability",
- query: "&mood=",
- wantCaps: [][16]byte{},
- },
- {
- // A token the server cannot map is a client bug, not a reset: the
- // mood the user is actually showing is left alone.
- name: "an unrecognized mood is rejected",
- query: "&mood=0icqmood999",
- wantCode: http.StatusBadRequest,
- },
- {
- // A status-message-only update must leave the mood alone.
- name: "an absent mood parameter leaves capabilities untouched",
- query: "&statusMsg=hello",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- instance := state.NewSession().AddInstance()
- gotCaps, session, code := setStatusCaps(t, instance, tt.query)
- if tt.wantCode != 0 {
- assert.Equal(t, tt.wantCode, code)
- assert.Nil(t, gotCaps, "a rejected mood must not reach SetInfo")
- return
- }
- assert.Equal(t, http.StatusOK, code)
- assert.Equal(t, tt.wantCaps, gotCaps)
- myInfo := queuedMyInfo(session)
- require.NotNil(t, myInfo, "setStatus must queue a myInfo event")
- if tt.wantMoodID == "" {
- assert.Empty(t, myInfo.MoodIcon)
- return
- }
- assert.Equal(t, "/mood?id="+wire.MoodIconID(tt.wantMoodID), myInfo.MoodIcon)
- })
- }
- }
- func TestPresenceHandler_SetStatus_MoodReplacesRatherThanAccumulates(t *testing.T) {
- instance := state.NewSession().AddInstance()
- _, _, code := setStatusCaps(t, instance, "&mood=0icqmood6")
- assert.Equal(t, http.StatusOK, code)
- gotCaps, _, code := setStatusCaps(t, instance, "&mood=0icqmood4")
- assert.Equal(t, http.StatusOK, code)
- assert.Equal(t, [][16]byte{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
- // advertises that is not a mood has to be carried over.
- instance := state.NewSession().AddInstance()
- instance.SetCaps([][16]byte{wire.CapChat, wire.CapXStatusBeer})
- gotCaps, _, code := setStatusCaps(t, instance, "&mood=0icqmood6")
- assert.Equal(t, http.StatusOK, code)
- assert.Equal(t, [][16]byte{wire.CapChat, wire.CapXStatusPlate}, gotCaps)
- gotCaps, _, code = setStatusCaps(t, instance, "&mood=")
- assert.Equal(t, http.StatusOK, code)
- assert.Equal(t, [][16]byte{wire.CapChat}, gotCaps, "clearing a mood must keep the other caps")
- }
- func TestPresenceHandler_SetStatus_SetInfoError(t *testing.T) {
- instance := state.NewSession().AddInstance()
- sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", instance)
- locate := newMockLocateService(t)
- locate.EXPECT().SetInfo(mock.Anything, mock.Anything, mock.Anything).Return(io.EOF)
- handler := &PresenceHandler{
- SessionManager: sessionMgr,
- LocateService: locate,
- Logger: slog.Default(),
- }
- req, err := http.NewRequest("GET", "/presence/setStatus?aimsid="+aimsid+"&mood=0icqmood6", nil)
- require.NoError(t, err)
- rr := httptest.NewRecorder()
- requireSession(handler.SessionManager, handler.SetStatus).ServeHTTP(rr, req)
- assert.Equal(t, http.StatusInternalServerError, rr.Code)
- }
|