notatest/server/pkg/service/users_test.go

260 lines
7.7 KiB
Go

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]interface{}
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")
}