package webapi import ( "context" "log/slog" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/mk6i/open-oscar-server/state" "github.com/mk6i/open-oscar-server/wire" ) // buddyPrefsFeedbag builds a feedbag reply carrying a single buddy-prefs item // with the given prefs pre-set. func buddyPrefsFeedbag(prefs map[uint16]bool) wire.SNACMessage { var list wire.TLVList for num, val := range prefs { list = wire.SetBuddyPref(list, num, val) } return wire.SNACMessage{ Body: wire.SNAC_0x13_0x06_FeedbagReply{ Items: []wire.FeedbagItem{ {ClassID: wire.FeedbagClassIdBuddyPrefs, ItemID: 1, TLVLBlock: wire.TLVLBlock{TLVList: list}}, }, }, } } func TestPreferenceHandler_SetPreferences(t *testing.T) { fs := newMockFeedbagService(t) oscarInstance := state.NewSession().AddInstance() sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance) // Existing feedbag already has acceptCustomBart (0x0B, default false) enabled; // it must survive the read-modify-write. Using a default-false pref means an // observed true value can only come from the stored bit, not the default. fs.EXPECT().Query(mock.Anything, oscarInstance, mock.Anything). Return(buddyPrefsFeedbag(map[uint16]bool{wire.FeedbagBuddyPrefsAcceptCustomBart: true}), nil) var upserted []wire.FeedbagItem fs.EXPECT().UpsertItem(mock.Anything, oscarInstance, mock.Anything, mock.Anything). Run(func(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, items []wire.FeedbagItem) { upserted = items }). Return(nil, nil) handler := &PreferenceHandler{ SessionManager: sessionMgr, FeedbagService: fs, Logger: slog.Default(), } req, _ := http.NewRequest("GET", "/preference/set?aimsid="+aimsid+"&playIMSound=0&discloseTyping=1", nil) rr := httptest.NewRecorder() requireSession(handler.SessionManager, handler.SetPreferences).ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) if assert.Len(t, upserted, 1) { item := upserted[0] assert.Equal(t, wire.FeedbagClassIdBuddyPrefs, item.ClassID) assertPref := func(num uint16, want bool) { assert.Equalf(t, want, wire.BuddyPref(item.TLVList, num), "pref 0x%02x", num) } assertPref(wire.FeedbagBuddyPrefsAcceptCustomBart, true) // preserved (default false) assertPref(0x15, false) // playIMSound off (default true) assertPref(0x16, true) // discloseTyping on } } func TestPreferenceHandler_GetPreferences_Selected(t *testing.T) { fs := newMockFeedbagService(t) oscarInstance := state.NewSession().AddInstance() sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance) // playIMSound (0x15) explicitly disabled in the feedbag. fs.EXPECT().Query(mock.Anything, oscarInstance, mock.Anything). Return(buddyPrefsFeedbag(map[uint16]bool{0x15: false}), nil) handler := &PreferenceHandler{ SessionManager: sessionMgr, FeedbagService: fs, Logger: slog.Default(), } // Request two prefs: playIMSound (stored=false) and acceptIcons (unset -> default true). req, _ := http.NewRequest("GET", "/preference/get?aimsid="+aimsid+"&playIMSound&acceptIcons", nil) rr := httptest.NewRecorder() requireSession(handler.SessionManager, handler.GetPreferences).ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) body := rr.Body.String() assert.Contains(t, body, `"playIMSound":0`) assert.Contains(t, body, `"acceptIcons":1`) // Only the two requested prefs should be present. assert.NotContains(t, body, `"displayLogin"`) } func TestPreferenceHandler_GetPreferences_All(t *testing.T) { fs := newMockFeedbagService(t) oscarInstance := state.NewSession().AddInstance() sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance) // Empty feedbag -> every pref resolves to its spec default. fs.EXPECT().Query(mock.Anything, oscarInstance, mock.Anything). Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil) handler := &PreferenceHandler{ SessionManager: sessionMgr, FeedbagService: fs, Logger: slog.Default(), } req, _ := http.NewRequest("GET", "/preference/get?aimsid="+aimsid, nil) rr := httptest.NewRecorder() requireSession(handler.SessionManager, handler.GetPreferences).ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) body := rr.Body.String() assert.Contains(t, body, `"displayLogin":1`) // default true assert.Contains(t, body, `"acceptCustomBart":0`) // default false } func TestPreferenceHandler_GetPreferences_AMF(t *testing.T) { fs := newMockFeedbagService(t) oscarInstance := state.NewSession().AddInstance() sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance) fs.EXPECT().Query(mock.Anything, oscarInstance, mock.Anything). Return(buddyPrefsFeedbag(map[uint16]bool{0x15: false}), nil) handler := &PreferenceHandler{ SessionManager: sessionMgr, FeedbagService: fs, Logger: slog.Default(), } // Single-pref AMF request returns a numeric value (not wrapped in jsonData). req, _ := http.NewRequest("GET", "/preference/get?aimsid="+aimsid+"&f=amf&playIMSound", nil) rr := httptest.NewRecorder() requireSession(handler.SessionManager, handler.GetPreferences).ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) body := rr.Body.String() // AMF-encoded response: the value is a numeric 0 (AMF integer marker 0x04 // followed by 0x00), not the string "0". assert.Contains(t, body, "playIMSound\x04\x00") assert.NotContains(t, body, `playIMSound":"0"`) } func TestEffectiveBuddyPrefs_StoredOverridesDefault(t *testing.T) { // playIMSound defaults true but is stored false; viewIMsInBubbles defaults // true and is stored true. Stored (valid) values must win over defaults. var list wire.TLVList list = wire.SetBuddyPref(list, wire.FeedbagBuddyPrefsPlayIMSound, false) list = wire.SetBuddyPref(list, wire.FeedbagBuddyPrefsViewIMsInBubbles, true) got := effectiveBuddyPrefs(list) // Every pref is present (defaults applied for unset ones). assert.Equal(t, len(webBuddyPrefs), got.Len()) assertPref(t, got, "playIMSound", 0) assertPref(t, got, "viewIMsInBubbles", 1) } func TestEffectiveBuddyPrefs_AppliesDefaultsWhenNothingSet(t *testing.T) { got := effectiveBuddyPrefs(wire.TLVList{}) // Unset prefs resolve to their spec defaults rather than being omitted. assert.Equal(t, len(webBuddyPrefs), got.Len()) assertPref(t, got, "showGroups", 1) // showGroups should default to shown assertPref(t, got, "playIMSound", 1) // playIMSound defaults true assertPref(t, got, "sortBuddyList", 0) // sortBuddyList defaults false } func TestPreferenceHandler_SetPermitDeny_QueuesPermitDenyEvent(t *testing.T) { // The client renders blocked buddies from the permitDeny event alone, and it // sees no SNAC for its own write, so the handler has to queue the new state. fs := newMockFeedbagService(t) oscarInstance := state.NewSession().AddInstance() sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance) fs.EXPECT().Query(mock.Anything, oscarInstance, mock.Anything). Return(wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{}}, nil) fs.EXPECT().UpsertItem(mock.Anything, oscarInstance, mock.Anything, mock.Anything). Return(nil, nil) handler := &PreferenceHandler{ SessionManager: sessionMgr, FeedbagService: fs, Logger: slog.Default(), } req, _ := http.NewRequest("GET", "/preference/setPermitDeny?aimsid="+aimsid+"&pdMode=denySome&pdBlock=BlockedUser", nil) rr := httptest.NewRecorder() requireSession(handler.SessionManager, handler.SetPermitDeny).ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) session, err := sessionMgr.GetSession(context.Background(), aimsid) assert.NoError(t, err) var pdd PermitDenyData var found bool for _, event := range session.EventQueue.GetAllEvents() { if event.Type == EventTypePermitDeny { pdd, found = event.Data.(PermitDenyData) } } assert.True(t, found, "expected a permitDeny event to be queued") assert.Equal(t, "denySome", pdd.PDMode) assert.Equal(t, []string{"blockeduser"}, pdd.DenyList) } func TestPermitDenyData_DefaultsToPermitAllWithoutPDInfo(t *testing.T) { got := permitDenyData([]wire.FeedbagItem{ {ClassID: wire.FeedbagClassIDDeny, Name: "blockeduser"}, }) assert.Equal(t, "permitAll", got.PDMode) assert.Equal(t, []string{"blockeduser"}, got.DenyList) } func TestPermitDenyData_PDInfoModeWins(t *testing.T) { var tlvs wire.TLVList tlvs.Append(wire.NewTLVBE(wire.FeedbagAttributesPdMode, uint8(wire.FeedbagPDModePermitSome))) got := permitDenyData([]wire.FeedbagItem{ {ClassID: wire.FeedbagClassIdPdinfo, TLVLBlock: wire.TLVLBlock{TLVList: tlvs}}, {ClassID: wire.FeedbagClassIDPermit, Name: "alloweduser"}, }) assert.Equal(t, "permitSome", got.PDMode) assert.Equal(t, []string{"alloweduser"}, got.PermitList) } // assertPref asserts a preference is carried and holds want. func assertPref(t *testing.T, prefs *PreferenceData, name string, want int) { t.Helper() got, ok := prefs.Get(name) if assert.True(t, ok, "%s should be carried", name) { assert.Equal(t, want, got, "%s", name) } } // The struct and the table must describe the same set of preferences: a // preference in the table but not the struct is silently never sent, and one in // the struct but not the table is always absent. func TestPreferenceDataMatchesPrefTable(t *testing.T) { structNames := make([]string, 0, len(prefFieldIndex)) for name := range prefFieldIndex { structNames = append(structNames, name) } tableNames := make([]string, 0, len(webBuddyPrefs)) for name := range webBuddyPrefs { tableNames = append(tableNames, name) } assert.ElementsMatch(t, tableNames, structNames) }