| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517 |
- package webapi
- import (
- "encoding/base64"
- "encoding/json"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "net/url"
- "testing"
- "time"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "github.com/stretchr/testify/require"
- "github.com/mk6i/open-oscar-server/config"
- "github.com/mk6i/open-oscar-server/state"
- "github.com/mk6i/open-oscar-server/wire"
- )
- func TestBuildMyInfo_UserTypeAndService(t *testing.T) {
- tests := []struct {
- name string
- screenName string
- wantType string
- wantSvc string
- }{
- {"aim screen name", "mikekelly", "aim", "AIM"},
- {"icq uin", "123456789", "icq", "ICQ"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- mi := buildMyInfo(state.DisplayScreenName(tt.screenName), "online", "")
- assert.Equal(t, tt.wantType, mi.UserType)
- assert.Equal(t, tt.wantSvc, mi.Service)
- })
- }
- }
- func TestBuildMyInfo_BuddyIcon(t *testing.T) {
- t.Run("included when set", func(t *testing.T) {
- mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "http://x/icon")
- assert.Equal(t, "http://x/icon", mi.BuddyIcon)
- })
- t.Run("omitted when empty so the client merge preserves the current icon", func(t *testing.T) {
- mi := buildMyInfo(state.DisplayScreenName("mikekelly"), "away", "")
- assert.Empty(t, mi.BuddyIcon)
- // omitempty is what actually keeps it out of the payload.
- body, err := json.Marshal(mi)
- assert.NoError(t, err)
- assert.NotContains(t, string(body), "buddyIcon")
- })
- }
- func TestAimHandler_AddTempBuddy(t *testing.T) {
- tests := []struct {
- name string
- queryParams map[string][]string
- mockSetup func(*mockBuddyService, *state.SessionInstance)
- expectedStatusCode int
- expectedResponse string
- }{
- {
- name: "Success_SingleBuddy",
- queryParams: map[string][]string{
- "aimsid": {"aimsid-1"},
- "t": {"buddy1"},
- },
- mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
- svc.EXPECT().
- AddTempBuddies(mock.Anything, instance, wire.SNACFrame{}, tempBuddiesSNAC("buddy1")).
- Return(nil, nil)
- },
- expectedStatusCode: http.StatusOK,
- expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
- },
- {
- name: "Success_MultipleBuddies",
- queryParams: map[string][]string{
- "aimsid": {"aimsid-1"},
- "t": {"buddy1", "buddy2", "buddy3"},
- },
- mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
- svc.EXPECT().
- AddTempBuddies(mock.Anything, instance, mock.Anything, tempBuddiesSNAC("buddy1", "buddy2", "buddy3")).
- Return(nil, nil)
- },
- expectedStatusCode: http.StatusOK,
- expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
- },
- {
- name: "Success_WhitespaceTrimmed",
- queryParams: map[string][]string{
- "aimsid": {"aimsid-1"},
- "t": {" buddy1 ", "", "buddy2 "},
- },
- mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
- svc.EXPECT().
- AddTempBuddies(mock.Anything, instance, mock.Anything, tempBuddiesSNAC("buddy1", "buddy2")).
- Return(nil, nil)
- },
- expectedStatusCode: http.StatusOK,
- expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
- },
- {
- name: "Success_RejectionIsNotReportedToTheClient",
- queryParams: map[string][]string{
- "aimsid": {"aimsid-1"},
- "t": {"buddy1", "100000"},
- },
- mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
- svc.EXPECT().
- AddTempBuddies(mock.Anything, instance, mock.Anything, tempBuddiesSNAC("buddy1", "100000")).
- Return(&wire.SNACMessage{
- Frame: wire.SNACFrame{
- FoodGroup: wire.Buddy,
- SubGroup: wire.BuddyRejectNotification,
- },
- Body: wire.SNAC_0x03_0x0A_BuddyRejectNotification{
- Buddies: []struct {
- ScreenName string `oscar:"len_prefix=uint8"`
- }{
- {ScreenName: "100000"},
- },
- },
- }, nil)
- },
- expectedStatusCode: http.StatusOK,
- expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
- },
- {
- name: "Success_CommaSeparatedList",
- queryParams: map[string][]string{
- "aimsid": {"aimsid-1"},
- "t": {"buddy1,buddy2", "buddy3"},
- },
- mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
- svc.EXPECT().
- AddTempBuddies(mock.Anything, instance, mock.Anything, tempBuddiesSNAC("buddy1", "buddy2", "buddy3")).
- Return(nil, nil)
- },
- expectedStatusCode: http.StatusOK,
- expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
- },
- {
- name: "Error_MissingBuddyNames",
- queryParams: map[string][]string{
- "aimsid": {"aimsid-1"},
- },
- expectedStatusCode: http.StatusBadRequest,
- expectedResponse: `{"response":{"statusCode":400,"statusText":"missing buddy names (t parameter)","data":{}}}`,
- },
- {
- name: "Error_ServiceFailure",
- queryParams: map[string][]string{
- "aimsid": {"aimsid-1"},
- "t": {"buddy1"},
- },
- mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
- svc.EXPECT().
- AddTempBuddies(mock.Anything, instance, mock.Anything, mock.Anything).
- Return(nil, io.ErrUnexpectedEOF)
- },
- expectedStatusCode: http.StatusInternalServerError,
- expectedResponse: `{"response":{"statusCode":500,"statusText":"unable to add temporary buddies","data":{}}}`,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- session := newTestWebAPISession(t, tightRateLimitClasses())
- buddyService := newMockBuddyService(t)
- if tt.mockSetup != nil {
- tt.mockSetup(buddyService, session.OSCARSession)
- }
- handler := &AimHandler{
- BuddyService: buddyService,
- Logger: slog.Default(),
- }
- values := url.Values{}
- for key, vals := range tt.queryParams {
- for _, val := range vals {
- values.Add(key, val)
- }
- }
- req := httptest.NewRequest(http.MethodGet, "/aim/addTempBuddy?"+values.Encode(), nil)
- rr := httptest.NewRecorder()
- handler.AddTempBuddy(rr, req, session)
- assert.Equal(t, tt.expectedStatusCode, rr.Code)
- assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
- })
- }
- }
- func TestAimHandler_RemoveTempBuddy(t *testing.T) {
- tests := []struct {
- name string
- query string
- mockSetup func(*mockBuddyService, *state.SessionInstance)
- expectedStatusCode int
- expectedResponse string
- }{
- {
- name: "Success",
- query: "aimsid=aimsid-1&t=buddy1&t=+buddy2+&t=",
- mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
- svc.EXPECT().
- DelTempBuddies(mock.Anything, instance, wire.SNAC_0x03_0x10_BuddyDelTempBuddies{
- Buddies: []struct {
- ScreenName string `oscar:"len_prefix=uint8"`
- }{
- {ScreenName: "buddy1"},
- {ScreenName: "buddy2"},
- },
- }).
- Return(nil)
- },
- expectedStatusCode: http.StatusOK,
- expectedResponse: `{"response":{"statusCode":200,"statusText":"Ok","data":{}}}`,
- },
- {
- name: "Error_MissingBuddyNames",
- query: "aimsid=aimsid-1",
- expectedStatusCode: http.StatusBadRequest,
- expectedResponse: `{"response":{"statusCode":400,"statusText":"missing buddy names (t parameter)","data":{}}}`,
- },
- {
- name: "Error_ServiceFailure",
- query: "aimsid=aimsid-1&t=buddy1",
- mockSetup: func(svc *mockBuddyService, instance *state.SessionInstance) {
- svc.EXPECT().
- DelTempBuddies(mock.Anything, instance, mock.Anything).
- Return(io.ErrUnexpectedEOF)
- },
- expectedStatusCode: http.StatusInternalServerError,
- expectedResponse: `{"response":{"statusCode":500,"statusText":"unable to remove temporary buddies","data":{}}}`,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- session := newTestWebAPISession(t, tightRateLimitClasses())
- buddyService := newMockBuddyService(t)
- if tt.mockSetup != nil {
- tt.mockSetup(buddyService, session.OSCARSession)
- }
- handler := &AimHandler{
- BuddyService: buddyService,
- Logger: slog.Default(),
- }
- req := httptest.NewRequest(http.MethodGet, "/aim/removeTempBuddy?"+tt.query, nil)
- rr := httptest.NewRecorder()
- handler.RemoveTempBuddy(rr, req, session)
- assert.Equal(t, tt.expectedStatusCode, rr.Code)
- assert.JSONEq(t, tt.expectedResponse, rr.Body.String())
- })
- }
- }
- func TestAimHandler_AddTempBuddy_RejectsOversizedList(t *testing.T) {
- session := newTestWebAPISession(t, tightRateLimitClasses())
- names := make([]string, 0, maxTempBuddies+1)
- for i := range cap(names) {
- names = append(names, fmt.Sprintf("buddy%d", i))
- }
- // No EXPECT: an oversized list must be rejected before it reaches the service.
- handler := &AimHandler{
- BuddyService: newMockBuddyService(t),
- Logger: slog.Default(),
- }
- values := url.Values{"aimsid": {"aimsid-1"}, "t": names}
- req := httptest.NewRequest(http.MethodGet, "/aim/addTempBuddy?"+values.Encode(), nil)
- rr := httptest.NewRecorder()
- handler.AddTempBuddy(rr, req, session)
- assert.Equal(t, http.StatusBadRequest, rr.Code)
- assert.JSONEq(t, `{"response":{"statusCode":400,"statusText":"too many buddy names (max 160)","data":{}}}`, rr.Body.String())
- }
- // tempBuddiesSNAC builds the add-temp-buddies SNAC the handler is expected to
- // hand the buddy service.
- func tempBuddiesSNAC(screenNames ...string) wire.SNAC_0x03_0x0F_BuddyAddTempBuddies {
- snac := wire.SNAC_0x03_0x0F_BuddyAddTempBuddies{}
- for _, screenName := range screenNames {
- snac.Buddies = append(snac.Buddies, struct {
- ScreenName string `oscar:"len_prefix=uint8"`
- }{ScreenName: screenName})
- }
- return snac
- }
- // testListener is a listener group whose SSL half is present only when the
- // test asks for it.
- func testListener(sslAvailable bool) config.ListenerGroup {
- g := config.ListenerGroup{
- Name: "local",
- BOSListenAddress: "0.0.0.0:5190",
- BOSAdvertisedHostPlain: "bos.example.com:5190",
- }
- if sslAvailable {
- g.BOSListenAddressSSL = "0.0.0.0:5191"
- g.BOSAdvertisedHostSSL = "ssl.example.com:5193"
- }
- return g
- }
- // bridgeRequest builds a startOSCARSession request.
- func bridgeRequest(query string) *http.Request {
- return httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?"+query, nil)
- }
- // bridgeData is the data object of a successful startOSCARSession response.
- type bridgeData struct {
- Response struct {
- StatusCode int `json:"statusCode"`
- Data struct {
- Host string `json:"host"`
- Port int `json:"port"`
- Cookie string `json:"cookie"`
- TLSCertName string `json:"tlsCertName"`
- } `json:"data"`
- } `json:"response"`
- }
- func TestAimHandler_StartOSCARSession(t *testing.T) {
- validToken := base64.URLEncoding.EncodeToString(signedCookieFor("testuser"))
- tests := []struct {
- name string
- query string
- sslAvailable bool
- expectedCode int
- checkBody func(t *testing.T, body string)
- }{
- {
- // No tlsCertName, which is how the client reads "connect in the clear".
- name: "Success_Plaintext",
- query: "a=" + validToken,
- expectedCode: http.StatusOK,
- checkBody: func(t *testing.T, body string) {
- got := decodeBridgeData(t, body)
- assert.Equal(t, 200, got.Response.StatusCode)
- assert.Equal(t, "bos.example.com", got.Response.Data.Host)
- assert.Equal(t, 5190, got.Response.Data.Port)
- assert.Empty(t, got.Response.Data.TLSCertName)
- },
- },
- {
- name: "Success_TLS",
- query: "a=" + validToken + "&useTLS=1",
- sslAvailable: true,
- expectedCode: http.StatusOK,
- checkBody: func(t *testing.T, body string) {
- got := decodeBridgeData(t, body)
- assert.Equal(t, "ssl.example.com", got.Response.Data.Host)
- assert.Equal(t, 5193, got.Response.Data.Port)
- // The certificate is issued to the host the client is sent to.
- assert.Equal(t, "ssl.example.com", got.Response.Data.TLSCertName)
- },
- },
- {
- // Encryption the server cannot provide degrades to a plaintext host
- // rather than failing the handoff.
- name: "TLSRequestedButUnavailable_DegradesToPlaintext",
- query: "a=" + validToken + "&useTLS=true",
- sslAvailable: false,
- expectedCode: http.StatusOK,
- checkBody: func(t *testing.T, body string) {
- got := decodeBridgeData(t, body)
- assert.Equal(t, "bos.example.com", got.Response.Data.Host)
- assert.Empty(t, got.Response.Data.TLSCertName)
- },
- },
- {
- name: "Error_MissingToken",
- query: "",
- expectedCode: http.StatusUnauthorized,
- checkBody: func(t *testing.T, body string) {
- assert.Contains(t, body, "authentication token required")
- },
- },
- {
- name: "Error_TokenNotBase64",
- query: "a=not!valid!base64",
- expectedCode: http.StatusUnauthorized,
- checkBody: func(t *testing.T, body string) {
- assert.Contains(t, body, "invalid or expired token")
- },
- },
- {
- // A well-formed token the baker refuses to crack: wrong signature or
- // past its expiry.
- name: "Error_TokenFailsSignatureCheck",
- query: "a=" + base64.URLEncoding.EncodeToString([]byte("forged")),
- expectedCode: http.StatusUnauthorized,
- checkBody: func(t *testing.T, body string) {
- assert.Contains(t, body, "invalid or expired token")
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- handler := &AimHandler{
- AuthService: &testAuthService{crackCookie: crackSignedCookie},
- BOSListener: testListener(tt.sslAvailable),
- Logger: slog.Default(),
- }
- rr := httptest.NewRecorder()
- handler.StartOSCARSession(rr, bridgeRequest(tt.query))
- assert.Equal(t, tt.expectedCode, rr.Code)
- tt.checkBody(t, rr.Body.String())
- })
- }
- }
- // The token arrives URL-safe, the way clientLogin minted it, and goes back out in
- // standard base64, the alphabet the client decodes the sign-on cookie with. The
- // cookie bytes here encode differently under each.
- func TestAimHandler_StartOSCARSession_ReencodesCookie(t *testing.T) {
- rawCookie := []byte{0xff, 0xef, 0xbe}
- urlSafe := base64.URLEncoding.EncodeToString(rawCookie)
- standard := base64.StdEncoding.EncodeToString(rawCookie)
- assert.NotEqual(t, urlSafe, standard, "test cookie must distinguish the two alphabets")
- var cracked []byte
- handler := &AimHandler{
- AuthService: &testAuthService{
- crackCookie: func(authCookie []byte) (state.ServerCookie, time.Time, error) {
- cracked = authCookie
- return state.ServerCookie{ScreenName: "testuser"}, time.Now().Add(shortTermTTL), nil
- },
- },
- BOSListener: testListener(false),
- Logger: slog.Default(),
- }
- rr := httptest.NewRecorder()
- handler.StartOSCARSession(rr, bridgeRequest("a="+urlSafe))
- assert.Equal(t, http.StatusOK, rr.Code)
- assert.Equal(t, rawCookie, cracked, "the baker sees the decoded cookie")
- assert.Equal(t, standard, decodeBridgeData(t, rr.Body.String()).Response.Data.Cookie)
- }
- func decodeBridgeData(t *testing.T, body string) bridgeData {
- t.Helper()
- got := bridgeData{}
- assert.NoError(t, json.Unmarshal([]byte(body), &got))
- return got
- }
- // The monitor broadcasts transitions, not current state, so without a seed a
- // session signing on mid-limit shows no banner while its sends are rejected — and
- // the client's alert is sticky, so the eventual "clear" has nothing to dismiss.
- func TestSeedRateLimitAlert(t *testing.T) {
- imClass, ok := wire.DefaultSNACRateLimits().RateClassLookup(wire.ICBM, wire.ICBMChannelMsgToHost)
- require.True(t, ok)
- // limitedSession returns a session on an account already in the limited state.
- limitedSession := func(t *testing.T) *Session {
- t.Helper()
- session := newTestWebAPISession(t, tightRateLimitClasses())
- sess := session.OSCARSession.Session()
- for i := 0; sess.RateLimitStates()[imClass-1].CurrentStatus != wire.RateLimitStatusLimited; i++ {
- require.Less(t, i, 100, "class never reached the limited state")
- sess.EvaluateRateLimit(time.Now(), imClass)
- }
- return session
- }
- t.Run("a session starting on a limited account is told", func(t *testing.T) {
- session := limitedSession(t)
- seedRateLimitAlert(session, imClass)
- assert.Equal(t, []string{"limit"}, rateLimitEventStatuses(t, session))
- })
- t.Run("a session starting on a clear account is told nothing", func(t *testing.T) {
- session := newTestWebAPISession(t, tightRateLimitClasses())
- seedRateLimitAlert(session, imClass)
- assert.Empty(t, rateLimitEventStatuses(t, session))
- })
- t.Run("a zero class id disables the alert", func(t *testing.T) {
- session := limitedSession(t)
- seedRateLimitAlert(session, 0)
- assert.Empty(t, rateLimitEventStatuses(t, session))
- })
- }
|