3d3ba84df0
Account deletion removed all local data but left the Ory Kratos
identity intact — an orphaned identity that can still authenticate.
Close the gap:
- kratos.Client gains the admin API: NewClient(publicURL, adminURL)
and DeleteIdentity (DELETE /admin/identities/{id}; a 404 is treated
as success so a retry after a partial failure is idempotent).
- AuthService.DeleteAccount deletes the Kratos identity FIRST; if that
call fails it aborts before touching local data, so the operation is
retryable rather than partially applied.
- KRATOS_ADMIN_URL config (default http://kratos:4434) + router wiring.
- kratos NetworkPolicy split: the api pods may now reach the admin API
:4434 (Traefik still reaches only the public API :4433).
- kratos CORS: allow_credentials + OPTIONS so the web browser flows
(ory_kratos_session cookie) work; origins stay an explicit allowlist.
- Regression tests: identity teardown happens, and a Kratos failure
aborts the deletion instead of orphaning local data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
7.8 KiB
Go
238 lines
7.8 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/config"
|
|
"github.com/treytartt/honeydue-api/internal/dto/requests"
|
|
"github.com/treytartt/honeydue-api/internal/kratos"
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
"github.com/treytartt/honeydue-api/internal/testutil"
|
|
)
|
|
|
|
func setupAuthService(t *testing.T) (*AuthService, *repositories.UserRepository) {
|
|
db := testutil.SetupTestDB(t)
|
|
userRepo := repositories.NewUserRepository(db)
|
|
notifRepo := repositories.NewNotificationRepository(db)
|
|
cfg := &config.Config{}
|
|
service := NewAuthService(userRepo, cfg)
|
|
service.SetNotificationRepository(notifRepo)
|
|
return service, userRepo
|
|
}
|
|
|
|
// === GetCurrentUser ===
|
|
|
|
func TestAuthService_GetCurrentUser(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
userRepo := repositories.NewUserRepository(db)
|
|
cfg := &config.Config{}
|
|
service := NewAuthService(userRepo, cfg)
|
|
|
|
user := testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
|
|
// Create profile
|
|
userRepo.GetOrCreateProfile(user.ID)
|
|
|
|
resp, err := service.GetCurrentUser(context.Background(), user.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "testuser", resp.Username)
|
|
assert.Equal(t, "test@test.com", resp.Email)
|
|
assert.Equal(t, "kratos", resp.AuthProvider) // All users are Kratos-managed
|
|
}
|
|
|
|
// === UpdateProfile ===
|
|
|
|
func TestAuthService_UpdateProfile(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
userRepo := repositories.NewUserRepository(db)
|
|
cfg := &config.Config{}
|
|
service := NewAuthService(userRepo, cfg)
|
|
|
|
user := testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
|
|
userRepo.GetOrCreateProfile(user.ID)
|
|
|
|
newFirst := "John"
|
|
newLast := "Doe"
|
|
req := &requests.UpdateProfileRequest{
|
|
FirstName: &newFirst,
|
|
LastName: &newLast,
|
|
}
|
|
|
|
resp, err := service.UpdateProfile(context.Background(), user.ID, req)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "John", resp.FirstName)
|
|
assert.Equal(t, "Doe", resp.LastName)
|
|
}
|
|
|
|
func TestAuthService_UpdateProfile_DuplicateEmail(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
userRepo := repositories.NewUserRepository(db)
|
|
cfg := &config.Config{}
|
|
service := NewAuthService(userRepo, cfg)
|
|
|
|
testutil.CreateTestUser(t, db, "user1", "user1@test.com", "Password123")
|
|
user2 := testutil.CreateTestUser(t, db, "user2", "user2@test.com", "Password123")
|
|
userRepo.GetOrCreateProfile(user2.ID)
|
|
|
|
takenEmail := "user1@test.com"
|
|
req := &requests.UpdateProfileRequest{
|
|
Email: &takenEmail,
|
|
}
|
|
|
|
_, err := service.UpdateProfile(context.Background(), user2.ID, req)
|
|
testutil.AssertAppError(t, err, http.StatusConflict, "error.email_already_taken")
|
|
}
|
|
|
|
func TestAuthService_UpdateProfile_SameEmail(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
userRepo := repositories.NewUserRepository(db)
|
|
cfg := &config.Config{}
|
|
service := NewAuthService(userRepo, cfg)
|
|
|
|
user := testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
|
|
userRepo.GetOrCreateProfile(user.ID)
|
|
|
|
sameEmail := "test@test.com"
|
|
req := &requests.UpdateProfileRequest{
|
|
Email: &sameEmail,
|
|
}
|
|
|
|
// Same email should not trigger duplicate error
|
|
resp, err := service.UpdateProfile(context.Background(), user.ID, req)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "test@test.com", resp.Email)
|
|
}
|
|
|
|
func TestAuthService_UpdateProfile_ChangeEmail(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
userRepo := repositories.NewUserRepository(db)
|
|
cfg := &config.Config{}
|
|
service := NewAuthService(userRepo, cfg)
|
|
|
|
user := testutil.CreateTestUser(t, db, "testuser", "test@test.com", "Password123")
|
|
userRepo.GetOrCreateProfile(user.ID)
|
|
|
|
newEmail := "newemail@test.com"
|
|
req := &requests.UpdateProfileRequest{
|
|
Email: &newEmail,
|
|
}
|
|
|
|
resp, err := service.UpdateProfile(context.Background(), user.ID, req)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "newemail@test.com", resp.Email)
|
|
}
|
|
|
|
// === DeleteAccount ===
|
|
|
|
func TestAuthService_DeleteAccount_WithConfirmation(t *testing.T) {
|
|
service, userRepo := setupAuthService(t)
|
|
|
|
user := testutil.CreateTestUser(t, (*userRepo).DB(), "testuser", "test@test.com", "")
|
|
_ = user
|
|
|
|
confirmation := "DELETE"
|
|
_, err := service.DeleteAccount(context.Background(), user.ID, nil, &confirmation)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestAuthService_DeleteAccount_WrongConfirmation(t *testing.T) {
|
|
service, userRepo := setupAuthService(t)
|
|
|
|
user := testutil.CreateTestUser(t, (*userRepo).DB(), "testuser", "test@test.com", "")
|
|
|
|
wrongConf := "delete"
|
|
_, err := service.DeleteAccount(context.Background(), user.ID, nil, &wrongConf)
|
|
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.confirmation_required")
|
|
}
|
|
|
|
func TestAuthService_DeleteAccount_NoConfirmation(t *testing.T) {
|
|
service, userRepo := setupAuthService(t)
|
|
|
|
user := testutil.CreateTestUser(t, (*userRepo).DB(), "testuser", "test@test.com", "")
|
|
|
|
_, err := service.DeleteAccount(context.Background(), user.ID, nil, nil)
|
|
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.confirmation_required")
|
|
}
|
|
|
|
func TestAuthService_DeleteAccount_UserNotFound(t *testing.T) {
|
|
service, _ := setupAuthService(t)
|
|
|
|
confirmation := "DELETE"
|
|
_, err := service.DeleteAccount(context.Background(), 99999, nil, &confirmation)
|
|
testutil.AssertAppError(t, err, http.StatusNotFound, "error.user_not_found")
|
|
}
|
|
|
|
// Regression: deleting an account must also delete the user's Kratos
|
|
// identity, or an orphaned, still-loginable identity is left behind.
|
|
func TestAuthService_DeleteAccount_DeletesKratosIdentity(t *testing.T) {
|
|
service, userRepo := setupAuthService(t)
|
|
db := userRepo.DB()
|
|
|
|
user := testutil.CreateTestUser(t, db, "kuser", "kuser@test.com", "")
|
|
require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID).
|
|
Update("kratos_id", "kratos-uuid-123").Error)
|
|
|
|
var gotMethod, gotPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod, gotPath = r.Method, r.URL.Path
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer srv.Close()
|
|
service.SetKratosClient(kratos.NewClient("", srv.URL))
|
|
|
|
confirmation := "DELETE"
|
|
_, err := service.DeleteAccount(context.Background(), user.ID, nil, &confirmation)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.MethodDelete, gotMethod)
|
|
assert.Equal(t, "/admin/identities/kratos-uuid-123", gotPath)
|
|
|
|
// Local user row is gone.
|
|
_, err = userRepo.WithContext(context.Background()).FindByID(user.ID)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// Regression: if the Kratos identity delete fails, local data must NOT be
|
|
// deleted — the operation must be retryable, not partially applied.
|
|
func TestAuthService_DeleteAccount_KratosFailureAbortsDeletion(t *testing.T) {
|
|
service, userRepo := setupAuthService(t)
|
|
db := userRepo.DB()
|
|
|
|
user := testutil.CreateTestUser(t, db, "kuser2", "kuser2@test.com", "")
|
|
require.NoError(t, db.Model(&models.User{}).Where("id = ?", user.ID).
|
|
Update("kratos_id", "kratos-uuid-456").Error)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
service.SetKratosClient(kratos.NewClient("", srv.URL))
|
|
|
|
confirmation := "DELETE"
|
|
_, err := service.DeleteAccount(context.Background(), user.ID, nil, &confirmation)
|
|
require.Error(t, err)
|
|
|
|
// Local user row must still exist.
|
|
_, ferr := userRepo.WithContext(context.Background()).FindByID(user.ID)
|
|
assert.NoError(t, ferr)
|
|
}
|
|
|
|
// === SetNotificationRepository ===
|
|
|
|
func TestAuthService_SetNotificationRepository(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
userRepo := repositories.NewUserRepository(db)
|
|
notifRepo := repositories.NewNotificationRepository(db)
|
|
cfg := &config.Config{}
|
|
service := NewAuthService(userRepo, cfg)
|
|
assert.Nil(t, service.notificationRepo)
|
|
|
|
service.SetNotificationRepository(notifRepo)
|
|
assert.NotNil(t, service.notificationRepo)
|
|
}
|