package service import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" "git.umbrella.haus/ae/notatest/pkg/data" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/assert" "golang.org/x/crypto/bcrypt" ) type mockUserStore struct { CreateUserFunc func(context.Context, data.CreateUserParams) (data.User, error) ListUsersFunc func(context.Context) ([]data.User, error) GetUserByIDFunc func(context.Context, uuid.UUID) (data.User, error) UpdatePasswordFunc func(context.Context, data.UpdatePasswordParams) error DeleteUserFunc func(context.Context, uuid.UUID) error RevokeAllUserRefreshTokensFunc func(context.Context, uuid.UUID) error } func (m *mockUserStore) CreateUser(ctx context.Context, arg data.CreateUserParams) (data.User, error) { return m.CreateUserFunc(ctx, arg) } func (m *mockUserStore) ListUsers(ctx context.Context) ([]data.User, error) { return m.ListUsersFunc(ctx) } func (m *mockUserStore) GetUserByID(ctx context.Context, id uuid.UUID) (data.User, error) { return m.GetUserByIDFunc(ctx, id) } func (m *mockUserStore) UpdatePassword(ctx context.Context, arg data.UpdatePasswordParams) error { return m.UpdatePasswordFunc(ctx, arg) } func (m *mockUserStore) DeleteUser(ctx context.Context, id uuid.UUID) error { return m.DeleteUserFunc(ctx, id) } func (m *mockUserStore) RevokeAllUserRefreshTokens(ctx context.Context, id uuid.UUID) error { return m.RevokeAllUserRefreshTokensFunc(ctx, id) } func TestCreateUser_Duplicate(t *testing.T) { mockStore := &mockUserStore{ CreateUserFunc: func(ctx context.Context, arg data.CreateUserParams) (data.User, error) { return data.User{}, &pgconn.PgError{Code: "23505"} }, } rs := usersResource{Users: mockStore, JWTSecret: "test-secret"} reqBody := `{"username": "existing", "password": "validPass123!"}` req := httptest.NewRequest("POST", "/", strings.NewReader(reqBody)) w := httptest.NewRecorder() rs.Create(w, req) assert.Equal(t, http.StatusConflict, w.Code) assert.Contains(t, w.Body.String(), "Username is already in use") } func TestCreateUser_InvalidUsername(t *testing.T) { mockStore := &mockUserStore{} // No DB calls expected rs := usersResource{Users: mockStore, JWTSecret: "test-secret"} // Test various invalid usernames tests := []struct { name string body string }{ { "too short", `{"username": "a", "password": "validPass123!"}`, }, { "invalid chars", `{"username": "user@name", "password": "validPass123!"}`, }, { "empty", `{"username": "", "password": "validPass123!"}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) w := httptest.NewRecorder() rs.Create(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) }) } } func TestCreateUser_InvalidPassword(t *testing.T) { mockStore := &mockUserStore{} rs := usersResource{Users: mockStore, JWTSecret: "test-secret"} tests := []struct { name string body string }{ { "too short", fmt.Sprintf(`{"username": "valid", "password": "%s"}`, strings.Repeat("a", minPasswordLength-1)), }, { "too long", fmt.Sprintf(`{"username": "valid", "password": "%s"}`, strings.Repeat("a", maxPasswordLength+1)), }, { "low entropy", fmt.Sprintf(`{"username": "valid", "password": "%s"}`, strings.Repeat("a", minPasswordLength)), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) w := httptest.NewRecorder() rs.Create(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) }) } } func TestListUsers_Success(t *testing.T) { testUsers := []data.User{ {ID: uuid.New(), Username: "user1"}, {ID: uuid.New(), Username: "user2"}, } mockStore := &mockUserStore{ ListUsersFunc: func(ctx context.Context) ([]data.User, error) { return testUsers, nil }, } rs := usersResource{Users: mockStore} req := httptest.NewRequest("GET", "/", nil) w := httptest.NewRecorder() rs.List(w, req) assert.Equal(t, http.StatusOK, w.Code) var response []map[string]any if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatal(err) } assert.Len(t, response, 2) assert.Equal(t, "user1", response[0]["username"]) assert.NotContains(t, response[0], "password_hash") } func TestUpdatePassword_InvalidOldPassword(t *testing.T) { // User with password hash that won't match "wrongpassword" user := data.User{ ID: uuid.New(), PasswordHash: "$2a$10$PHhno.bZBF8IEINdFRZAPujMxIN65msElATgJG6FIxZdeWYVLSfFi", // Hash of "correctpassword" } mockStore := &mockUserStore{} rs := usersResource{Users: mockStore} reqBody := `{"old_password": "wrongpassword", "new_password": "NewValidPass321!"}` req := httptest.NewRequest("PUT", "/", strings.NewReader(reqBody)) req = req.WithContext(context.WithValue(req.Context(), userCtxKey{}, user)) w := httptest.NewRecorder() rs.UpdatePassword(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } func TestAdminDelete_Success(t *testing.T) { user := data.User{ID: uuid.New()} deleteCalled := false revokeCalled := false mockStore := &mockUserStore{ DeleteUserFunc: func(ctx context.Context, id uuid.UUID) error { deleteCalled = true assert.Equal(t, user.ID, id) return nil }, RevokeAllUserRefreshTokensFunc: func(ctx context.Context, id uuid.UUID) error { revokeCalled = true assert.Equal(t, user.ID, id) return nil }, } rs := usersResource{Users: mockStore} req := httptest.NewRequest("DELETE", "/", nil) req = req.WithContext(context.WithValue(req.Context(), userCtxKey{}, user)) w := httptest.NewRecorder() rs.AdminDelete(w, req) assert.Equal(t, http.StatusNoContent, w.Code) assert.True(t, deleteCalled) assert.True(t, revokeCalled) } func TestOwnerDelete_InvalidCredentials(t *testing.T) { // Create user with known password hash correctPassword := "CorrectPass123!" hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(correctPassword), bcrypt.DefaultCost) user := data.User{ ID: uuid.New(), PasswordHash: string(hashedPassword), } mockStore := &mockUserStore{} rs := usersResource{Users: mockStore} reqBody := `{"password": "wrongpassword"}` req := httptest.NewRequest("DELETE", "/", strings.NewReader(reqBody)) req = req.WithContext(context.WithValue(req.Context(), userCtxKey{}, user)) w := httptest.NewRecorder() rs.OwnerDelete(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } func TestGetUser_NotFound(t *testing.T) { mockStore := &mockUserStore{} rs := usersResource{Users: mockStore} // No user in context req := httptest.NewRequest("GET", "/", nil) w := httptest.NewRecorder() rs.Get(w, req) assert.Equal(t, http.StatusNotFound, w.Code) } func TestUpdatePassword_DatabaseError(t *testing.T) { // Add user with a valid password to the context oldPassword := "OldValidPass321!" hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(oldPassword), bcrypt.DefaultCost) user := data.User{ ID: uuid.New(), PasswordHash: string(hashedPassword), } mockStore := &mockUserStore{ UpdatePasswordFunc: func(ctx context.Context, arg data.UpdatePasswordParams) error { return errors.New("database error") }, } rs := usersResource{Users: mockStore} reqBody := fmt.Sprintf(`{"old_password": "%s", "new_password": "NewValidPass123!"}`, oldPassword) req := httptest.NewRequest("PUT", "/", strings.NewReader(reqBody)) req = req.WithContext(context.WithValue(req.Context(), userCtxKey{}, user)) w := httptest.NewRecorder() rs.UpdatePassword(w, req) assert.Equal(t, http.StatusInternalServerError, w.Code) assert.Contains(t, w.Body.String(), "Failed to update password") }