Skip to content

Commit 27c4f93

Browse files
authored
Add user deletion endpoint (#35)
1 parent c49d853 commit 27c4f93

9 files changed

Lines changed: 377 additions & 7 deletions

File tree

auth/cleanup.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
type cleanupEnqueuer interface {
9+
Enqueue(ctx context.Context, job UserCleanupJob) error
10+
}
11+
12+
// UserCleanupJob represents a cleanup job that is enqueued after a user is deleted.
13+
// It contains the necessary information for cleanup handlers to identify and process
14+
// the cleanup tasks associated with the deleted user.
15+
type UserCleanupJob struct {
16+
UserID string
17+
DeletedAt time.Time
18+
}

auth/cleanup_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package auth_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/platforma-dev/platforma/auth"
9+
)
10+
11+
func TestDeleteUser_WithCleanupEnqueuer_EnqueuesJob(t *testing.T) {
12+
t.Parallel()
13+
14+
mockRepo := &mockRepository{}
15+
mockAuthStorage := &mockAuthStorage{}
16+
mockEnqueuer := &mockCleanupEnqueuer{}
17+
18+
service := auth.NewService(mockRepo, mockAuthStorage, "session", nil, nil, mockEnqueuer)
19+
20+
user := &auth.User{ID: "test-user-id"}
21+
ctx := context.WithValue(context.Background(), auth.UserContextKey, user)
22+
23+
err := service.DeleteUser(ctx)
24+
if err != nil {
25+
t.Fatalf("expected no error, got %v", err)
26+
}
27+
28+
if !mockEnqueuer.enqueueCalled {
29+
t.Fatal("expected Enqueue to be called")
30+
}
31+
32+
if mockEnqueuer.lastJob.UserID != "test-user-id" {
33+
t.Fatalf("expected UserID 'test-user-id', got %q", mockEnqueuer.lastJob.UserID)
34+
}
35+
36+
if mockEnqueuer.lastJob.DeletedAt.IsZero() {
37+
t.Fatal("expected DeletedAt to be set")
38+
}
39+
}
40+
41+
func TestDeleteUser_WithNilEnqueuer_Succeeds(t *testing.T) {
42+
t.Parallel()
43+
44+
mockRepo := &mockRepository{}
45+
mockAuthStorage := &mockAuthStorage{}
46+
47+
service := auth.NewService(mockRepo, mockAuthStorage, "session", nil, nil, nil)
48+
49+
user := &auth.User{ID: "test-user-id"}
50+
ctx := context.WithValue(context.Background(), auth.UserContextKey, user)
51+
52+
err := service.DeleteUser(ctx)
53+
if err != nil {
54+
t.Fatalf("expected no error, got %v", err)
55+
}
56+
}
57+
58+
func TestDeleteUser_EnqueueError_StillSucceeds(t *testing.T) {
59+
t.Parallel()
60+
61+
mockRepo := &mockRepository{}
62+
mockAuthStorage := &mockAuthStorage{}
63+
mockEnqueuer := &mockCleanupEnqueuer{
64+
enqueueErr: errors.New("queue is full"),
65+
}
66+
67+
service := auth.NewService(mockRepo, mockAuthStorage, "session", nil, nil, mockEnqueuer)
68+
69+
user := &auth.User{ID: "test-user-id"}
70+
ctx := context.WithValue(context.Background(), auth.UserContextKey, user)
71+
72+
err := service.DeleteUser(ctx)
73+
if err != nil {
74+
t.Fatalf("expected no error even with enqueue failure, got %v", err)
75+
}
76+
77+
if !mockEnqueuer.enqueueCalled {
78+
t.Fatal("expected Enqueue to be called")
79+
}
80+
}
81+
82+
type mockCleanupEnqueuer struct {
83+
enqueueCalled bool
84+
lastJob auth.UserCleanupJob
85+
enqueueErr error
86+
}
87+
88+
func (m *mockCleanupEnqueuer) Enqueue(_ context.Context, job auth.UserCleanupJob) error {
89+
m.enqueueCalled = true
90+
m.lastJob = job
91+
return m.enqueueErr
92+
}
93+
94+
type mockRepository struct{}
95+
96+
func (m *mockRepository) Get(_ context.Context, _ string) (*auth.User, error) {
97+
return nil, nil
98+
}
99+
100+
func (m *mockRepository) GetByUsername(_ context.Context, _ string) (*auth.User, error) {
101+
return nil, nil
102+
}
103+
104+
func (m *mockRepository) Create(_ context.Context, _ *auth.User) error {
105+
return nil
106+
}
107+
108+
func (m *mockRepository) UpdatePassword(_ context.Context, _, _, _ string) error {
109+
return nil
110+
}
111+
112+
func (m *mockRepository) Delete(_ context.Context, _ string) error {
113+
return nil
114+
}
115+
116+
type mockAuthStorage struct{}
117+
118+
func (m *mockAuthStorage) GetUserIdFromSessionId(_ context.Context, _ string) (string, error) {
119+
return "", nil
120+
}
121+
122+
func (m *mockAuthStorage) CreateSessionForUser(_ context.Context, _ string) (string, error) {
123+
return "", nil
124+
}
125+
126+
func (m *mockAuthStorage) DeleteSession(_ context.Context, _ string) error {
127+
return nil
128+
}
129+
130+
func (m *mockAuthStorage) DeleteSessionsByUserId(_ context.Context, _ string) error {
131+
return nil
132+
}

auth/domain.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,25 @@ func (d *Domain) GetRepository() any {
1515
return d.Repository
1616
}
1717

18-
func New(db db, authStorage authStorage, sessionCookieName string, usernameValidator, passwordValidator func(string) error) *Domain {
18+
func New(db db, authStorage authStorage, sessionCookieName string, usernameValidator, passwordValidator func(string) error, cleanupEnqueuer cleanupEnqueuer) *Domain {
1919
repository := NewRepository(db)
20-
service := NewService(repository, authStorage, sessionCookieName, usernameValidator, passwordValidator)
20+
service := NewService(repository, authStorage, sessionCookieName, usernameValidator, passwordValidator, cleanupEnqueuer)
2121

2222
authMiddleware := NewAuthenticationMiddleware(service)
2323
registerHandler := NewRegisterHandler(service)
2424
loginHandler := NewLoginHandler(service)
2525
logoutHandler := NewLogoutHandler(service)
2626
getUserHandler := NewGetHandler(service)
2727
changePasswordHandler := authMiddleware.Wrap(NewChangePasswordHandler(service))
28+
deleteHandler := authMiddleware.Wrap(NewDeleteHandler(service))
2829

2930
authAPI := httpserver.NewHandlerGroup()
3031
authAPI.Handle("POST /register", registerHandler)
3132
authAPI.Handle("POST /login", loginHandler)
3233
authAPI.Handle("POST /logout", logoutHandler)
3334
authAPI.Handle("GET /me", getUserHandler)
3435
authAPI.Handle("POST /change-password", changePasswordHandler)
36+
authAPI.Handle("DELETE /me", deleteHandler)
3537

3638
return &Domain{
3739
Repository: repository,

auth/handler_delete.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"net/http"
8+
9+
"github.com/platforma-dev/platforma/log"
10+
)
11+
12+
type userDeleter interface {
13+
DeleteUser(ctx context.Context) error
14+
}
15+
16+
type DeleteHandler struct {
17+
service userDeleter
18+
}
19+
20+
func NewDeleteHandler(service userDeleter) *DeleteHandler {
21+
return &DeleteHandler{
22+
service: service,
23+
}
24+
}
25+
26+
func (h *DeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
27+
ctx := r.Context()
28+
29+
if r.Method != http.MethodDelete {
30+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
31+
return
32+
}
33+
34+
err := h.service.DeleteUser(ctx)
35+
if err != nil {
36+
if errors.Is(err, ErrUserNotFound) {
37+
http.Error(w, err.Error(), http.StatusUnauthorized)
38+
return
39+
}
40+
http.Error(w, err.Error(), http.StatusInternalServerError)
41+
return
42+
}
43+
44+
w.WriteHeader(http.StatusOK)
45+
if err := json.NewEncoder(w).Encode("User deleted successfully"); err != nil {
46+
log.ErrorContext(ctx, "failed to decode response to json", "error", err)
47+
}
48+
}

auth/handler_delete_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package auth_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/platforma-dev/platforma/auth"
11+
)
12+
13+
func TestDeleteHandler_Success(t *testing.T) {
14+
t.Parallel()
15+
16+
mockService := &mockDeleteService{
17+
deleteUserErr: nil,
18+
}
19+
handler := auth.NewDeleteHandler(mockService)
20+
21+
req := httptest.NewRequest(http.MethodDelete, "/", nil)
22+
ctx := context.WithValue(req.Context(), auth.UserContextKey, &auth.User{ID: "user-id"})
23+
req = req.WithContext(ctx)
24+
w := httptest.NewRecorder()
25+
26+
handler.ServeHTTP(w, req)
27+
28+
if w.Code != http.StatusOK {
29+
t.Fatalf("expected status 200, got %d", w.Code)
30+
}
31+
32+
expectedBody := `"User deleted successfully"`
33+
actualBody := w.Body.String()
34+
if actualBody != expectedBody+"\n" {
35+
t.Fatalf("expected body %q, got %q", expectedBody+"\n", actualBody)
36+
}
37+
}
38+
39+
func TestDeleteHandler_WrongMethod(t *testing.T) {
40+
t.Parallel()
41+
42+
mockService := &mockDeleteService{}
43+
handler := auth.NewDeleteHandler(mockService)
44+
45+
req := httptest.NewRequest(http.MethodGet, "/", nil)
46+
w := httptest.NewRecorder()
47+
48+
handler.ServeHTTP(w, req)
49+
50+
if w.Code != http.StatusMethodNotAllowed {
51+
t.Fatalf("expected status 405, got %d", w.Code)
52+
}
53+
}
54+
55+
func TestDeleteHandler_UserNotFound(t *testing.T) {
56+
t.Parallel()
57+
58+
mockService := &mockDeleteService{
59+
deleteUserErr: auth.ErrUserNotFound,
60+
}
61+
handler := auth.NewDeleteHandler(mockService)
62+
63+
req := httptest.NewRequest(http.MethodDelete, "/", nil)
64+
w := httptest.NewRecorder()
65+
66+
handler.ServeHTTP(w, req)
67+
68+
if w.Code != http.StatusUnauthorized {
69+
t.Fatalf("expected status 401, got %d", w.Code)
70+
}
71+
}
72+
73+
func TestDeleteHandler_InternalError(t *testing.T) {
74+
t.Parallel()
75+
76+
mockService := &mockDeleteService{
77+
deleteUserErr: errors.New("database error"),
78+
}
79+
handler := auth.NewDeleteHandler(mockService)
80+
81+
req := httptest.NewRequest(http.MethodDelete, "/", nil)
82+
ctx := context.WithValue(req.Context(), auth.UserContextKey, &auth.User{ID: "user-id"})
83+
req = req.WithContext(ctx)
84+
w := httptest.NewRecorder()
85+
86+
handler.ServeHTTP(w, req)
87+
88+
if w.Code != http.StatusInternalServerError {
89+
t.Fatalf("expected status 500, got %d", w.Code)
90+
}
91+
}
92+
93+
func TestDeleteHandler_NoUserInContext(t *testing.T) {
94+
t.Parallel()
95+
96+
mockService := &mockDeleteService{
97+
deleteUserErr: auth.ErrUserNotFound,
98+
}
99+
handler := auth.NewDeleteHandler(mockService)
100+
101+
req := httptest.NewRequest(http.MethodDelete, "/", nil)
102+
w := httptest.NewRecorder()
103+
104+
handler.ServeHTTP(w, req)
105+
106+
if w.Code != http.StatusUnauthorized {
107+
t.Fatalf("expected status 401, got %d", w.Code)
108+
}
109+
}
110+
111+
type mockDeleteService struct {
112+
deleteUserErr error
113+
}
114+
115+
func (m *mockDeleteService) DeleteUser(ctx context.Context) error {
116+
return m.deleteUserErr
117+
}

auth/repository.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,12 @@ func (r *Repository) UpdatePassword(ctx context.Context, id, password, salt stri
8484
}
8585
return nil
8686
}
87+
88+
func (r *Repository) Delete(ctx context.Context, id string) error {
89+
query := `DELETE FROM users WHERE id = $1`
90+
_, err := r.db.ExecContext(ctx, query, id)
91+
if err != nil {
92+
return fmt.Errorf("failed to delete user: %w", err)
93+
}
94+
return nil
95+
}

0 commit comments

Comments
 (0)