auth_test.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. package handlers
  2. import (
  3. "context"
  4. "errors"
  5. "log/slog"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/mock"
  13. "github.com/mk6i/open-oscar-server/state"
  14. )
  15. // MockUserManager is a mock implementation of UserManager
  16. type MockUserManager struct {
  17. mock.Mock
  18. }
  19. func (m *MockUserManager) AuthenticateUser(ctx context.Context, username, password string) (*state.User, error) {
  20. args := m.Called(ctx, username, password)
  21. if user := args.Get(0); user != nil {
  22. return user.(*state.User), args.Error(1)
  23. }
  24. return nil, args.Error(1)
  25. }
  26. func (m *MockUserManager) FindUserByScreenName(ctx context.Context, screenName state.IdentScreenName) (*state.User, error) {
  27. args := m.Called(ctx, screenName)
  28. if user := args.Get(0); user != nil {
  29. return user.(*state.User), args.Error(1)
  30. }
  31. return nil, args.Error(1)
  32. }
  33. func (m *MockUserManager) InsertUser(ctx context.Context, u state.User) error {
  34. args := m.Called(ctx, u)
  35. return args.Error(0)
  36. }
  37. // MockTokenStore is a mock implementation of TokenStore
  38. type MockTokenStore struct {
  39. mock.Mock
  40. }
  41. func (m *MockTokenStore) StoreToken(ctx context.Context, token string, screenName state.IdentScreenName, expiresAt time.Time) error {
  42. args := m.Called(ctx, token, screenName, expiresAt)
  43. return args.Error(0)
  44. }
  45. func (m *MockTokenStore) ValidateToken(ctx context.Context, token string) (state.IdentScreenName, error) {
  46. args := m.Called(ctx, token)
  47. return args.Get(0).(state.IdentScreenName), args.Error(1)
  48. }
  49. func (m *MockTokenStore) DeleteToken(ctx context.Context, token string) error {
  50. args := m.Called(ctx, token)
  51. return args.Error(0)
  52. }
  53. func TestAuthHandler_ClientLogin(t *testing.T) {
  54. tests := []struct {
  55. name string
  56. method string
  57. contentType string
  58. body string
  59. setupMocks func(*MockUserManager, *MockTokenStore)
  60. disableAuth bool
  61. expectedStatusCode int
  62. checkResponse func(*testing.T, string)
  63. }{
  64. {
  65. name: "Success_JSONBody",
  66. method: "POST",
  67. contentType: "application/json",
  68. body: `{"username":"testuser","password":"testpass","devId":"dev123"}`,
  69. setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
  70. user := &state.User{
  71. IdentScreenName: state.NewIdentScreenName("testuser"),
  72. DisplayScreenName: state.DisplayScreenName("testuser"),
  73. }
  74. um.On("AuthenticateUser", mock.Anything, "testuser", "testpass").Return(user, nil)
  75. ts.On("StoreToken", mock.Anything, mock.AnythingOfType("string"), state.NewIdentScreenName("testuser"), mock.AnythingOfType("time.Time")).Return(nil)
  76. },
  77. expectedStatusCode: http.StatusOK,
  78. checkResponse: func(t *testing.T, body string) {
  79. assert.Contains(t, body, `"statusCode":200`)
  80. assert.Contains(t, body, `"loginId":"testuser"`)
  81. assert.Contains(t, body, `"screenName":"testuser"`)
  82. assert.Contains(t, body, `"token"`)
  83. assert.Contains(t, body, `"sessionSecret"`)
  84. },
  85. },
  86. {
  87. name: "Success_FormEncoded",
  88. method: "POST",
  89. contentType: "application/x-www-form-urlencoded",
  90. body: "s=testuser&pwd=testpass&devId=dev123",
  91. setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
  92. user := &state.User{
  93. IdentScreenName: state.NewIdentScreenName("testuser"),
  94. DisplayScreenName: state.DisplayScreenName("testuser"),
  95. }
  96. um.On("AuthenticateUser", mock.Anything, "testuser", "testpass").Return(user, nil)
  97. ts.On("StoreToken", mock.Anything, mock.AnythingOfType("string"), state.NewIdentScreenName("testuser"), mock.AnythingOfType("time.Time")).Return(nil)
  98. },
  99. expectedStatusCode: http.StatusOK,
  100. checkResponse: func(t *testing.T, body string) {
  101. assert.Contains(t, body, `"statusCode":200`)
  102. assert.Contains(t, body, `"loginId":"testuser"`)
  103. },
  104. },
  105. {
  106. name: "Success_DisableAuth_NewUser",
  107. method: "POST",
  108. contentType: "application/json",
  109. body: `{"username":"newuser","password":"pass123"}`,
  110. disableAuth: true,
  111. setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
  112. // First auth attempt fails - user not found
  113. um.On("AuthenticateUser", mock.Anything, "newuser", "pass123").Return(nil, errors.New("user not found")).Once()
  114. // User is created
  115. um.On("InsertUser", mock.Anything, mock.MatchedBy(func(u state.User) bool {
  116. return u.IdentScreenName == state.NewIdentScreenName("newuser")
  117. })).Return(nil)
  118. // Second auth attempt succeeds
  119. user := &state.User{
  120. IdentScreenName: state.NewIdentScreenName("newuser"),
  121. DisplayScreenName: state.DisplayScreenName("newuser"),
  122. }
  123. um.On("AuthenticateUser", mock.Anything, "newuser", "pass123").Return(user, nil)
  124. ts.On("StoreToken", mock.Anything, mock.AnythingOfType("string"), state.NewIdentScreenName("newuser"), mock.AnythingOfType("time.Time")).Return(nil)
  125. },
  126. expectedStatusCode: http.StatusOK,
  127. checkResponse: func(t *testing.T, body string) {
  128. assert.Contains(t, body, `"statusCode":200`)
  129. assert.Contains(t, body, `"loginId":"newuser"`)
  130. },
  131. },
  132. {
  133. name: "Error_MissingUsername",
  134. method: "POST",
  135. contentType: "application/json",
  136. body: `{"username":"","password":"testpass"}`,
  137. setupMocks: func(um *MockUserManager, ts *MockTokenStore) {},
  138. expectedStatusCode: http.StatusBadRequest,
  139. checkResponse: func(t *testing.T, body string) {
  140. assert.Contains(t, body, "username and password required")
  141. },
  142. },
  143. {
  144. name: "Error_MissingPassword",
  145. method: "POST",
  146. contentType: "application/json",
  147. body: `{"username":"testuser","password":""}`,
  148. setupMocks: func(um *MockUserManager, ts *MockTokenStore) {},
  149. expectedStatusCode: http.StatusBadRequest,
  150. checkResponse: func(t *testing.T, body string) {
  151. assert.Contains(t, body, "username and password required")
  152. },
  153. },
  154. {
  155. name: "Error_AuthFailed",
  156. method: "POST",
  157. contentType: "application/json",
  158. body: `{"username":"testuser","password":"wrongpass"}`,
  159. setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
  160. um.On("AuthenticateUser", mock.Anything, "testuser", "wrongpass").Return(nil, errors.New("invalid credentials"))
  161. },
  162. expectedStatusCode: http.StatusUnauthorized,
  163. checkResponse: func(t *testing.T, body string) {
  164. assert.Contains(t, body, "authentication failed")
  165. },
  166. },
  167. {
  168. name: "Error_InvalidJSON",
  169. method: "POST",
  170. contentType: "application/json",
  171. body: `{invalid json`,
  172. setupMocks: func(um *MockUserManager, ts *MockTokenStore) {},
  173. expectedStatusCode: http.StatusBadRequest,
  174. checkResponse: func(t *testing.T, body string) {
  175. assert.Contains(t, body, "invalid JSON format")
  176. },
  177. },
  178. }
  179. for _, tt := range tests {
  180. t.Run(tt.name, func(t *testing.T) {
  181. userManager := &MockUserManager{}
  182. tokenStore := &MockTokenStore{}
  183. logger := slog.Default()
  184. handler := &AuthHandler{
  185. UserManager: userManager,
  186. TokenStore: tokenStore,
  187. Logger: logger,
  188. DisableAuth: tt.disableAuth,
  189. }
  190. tt.setupMocks(userManager, tokenStore)
  191. req, err := http.NewRequest(tt.method, "/auth/clientLogin", strings.NewReader(tt.body))
  192. assert.NoError(t, err)
  193. req.Header.Set("Content-Type", tt.contentType)
  194. rr := httptest.NewRecorder()
  195. handler.ClientLogin(rr, req)
  196. assert.Equal(t, tt.expectedStatusCode, rr.Code)
  197. responseBody := strings.TrimSpace(rr.Body.String())
  198. if tt.checkResponse != nil {
  199. tt.checkResponse(t, responseBody)
  200. }
  201. userManager.AssertExpectations(t)
  202. tokenStore.AssertExpectations(t)
  203. })
  204. }
  205. }