You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
490 lines
14 KiB
Go
490 lines
14 KiB
Go
package user
|
|
|
|
// service_test.go contains tests for backend behavior.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
domain "knowfoolery/backend/services/user-service/internal/domain/user"
|
|
sharederrors "knowfoolery/backend/shared/domain/errors"
|
|
sharedtypes "knowfoolery/backend/shared/domain/types"
|
|
)
|
|
|
|
// fakeRepo is an in-memory repository stub used by service unit tests.
|
|
type fakeRepo struct {
|
|
usersByID map[string]*domain.User
|
|
usersByEmail map[string]*domain.User
|
|
usersByZitadel map[string]*domain.User
|
|
audit []domain.AuditLogEntry
|
|
createErr error
|
|
getByIDErr error
|
|
getByEmailErr error
|
|
getByZidErr error
|
|
updateErr error
|
|
verifyErr error
|
|
deleteErr error
|
|
listErr error
|
|
logsErr error
|
|
writeAuditErr error
|
|
}
|
|
|
|
// newFakeRepo creates a fake repository with empty stores.
|
|
func newFakeRepo() *fakeRepo {
|
|
return &fakeRepo{
|
|
usersByID: map[string]*domain.User{},
|
|
usersByEmail: map[string]*domain.User{},
|
|
usersByZitadel: map[string]*domain.User{},
|
|
}
|
|
}
|
|
|
|
// EnsureSchema is a no-op for the in-memory fake repository.
|
|
func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil }
|
|
|
|
// Create stores a user and assigns deterministic timestamps and ID for tests.
|
|
func (r *fakeRepo) Create(ctx context.Context, user *domain.User) (*domain.User, error) {
|
|
if r.createErr != nil {
|
|
return nil, r.createErr
|
|
}
|
|
user.ID = "u-1"
|
|
now := time.Now().UTC()
|
|
user.CreatedAt = now
|
|
user.UpdatedAt = now
|
|
r.usersByID[user.ID] = user
|
|
r.usersByEmail[user.Email] = user
|
|
r.usersByZitadel[user.ZitadelUserID] = user
|
|
return user, nil
|
|
}
|
|
|
|
// GetByID returns a non-deleted user by ID.
|
|
func (r *fakeRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
|
if r.getByIDErr != nil {
|
|
return nil, r.getByIDErr
|
|
}
|
|
if u, ok := r.usersByID[id]; ok && !u.IsDeleted() {
|
|
return u, nil
|
|
}
|
|
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
|
|
}
|
|
|
|
// GetByEmail returns a non-deleted user by email.
|
|
func (r *fakeRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
|
if r.getByEmailErr != nil {
|
|
return nil, r.getByEmailErr
|
|
}
|
|
if u, ok := r.usersByEmail[email]; ok && !u.IsDeleted() {
|
|
return u, nil
|
|
}
|
|
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
|
|
}
|
|
|
|
// GetByZitadelUserID returns a non-deleted user by Zitadel subject.
|
|
func (r *fakeRepo) GetByZitadelUserID(ctx context.Context, zid string) (*domain.User, error) {
|
|
if r.getByZidErr != nil {
|
|
return nil, r.getByZidErr
|
|
}
|
|
if u, ok := r.usersByZitadel[zid]; ok && !u.IsDeleted() {
|
|
return u, nil
|
|
}
|
|
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
|
|
}
|
|
|
|
// UpdateProfile updates mutable user profile fields in place.
|
|
func (r *fakeRepo) UpdateProfile(
|
|
ctx context.Context,
|
|
id, displayName string,
|
|
consent domain.ConsentRecord,
|
|
) (*domain.User, error) {
|
|
if r.updateErr != nil {
|
|
return nil, r.updateErr
|
|
}
|
|
u, err := r.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u.DisplayName = displayName
|
|
u.ConsentVersion = consent.Version
|
|
u.ConsentGivenAt = consent.GivenAt
|
|
u.ConsentSource = consent.Source
|
|
u.UpdatedAt = time.Now().UTC()
|
|
return u, nil
|
|
}
|
|
|
|
// MarkEmailVerified marks a user as verified in the fake store.
|
|
func (r *fakeRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.User, error) {
|
|
if r.verifyErr != nil {
|
|
return nil, r.verifyErr
|
|
}
|
|
u, err := r.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u.EmailVerified = true
|
|
u.UpdatedAt = time.Now().UTC()
|
|
return u, nil
|
|
}
|
|
|
|
// SoftDelete marks a user as deleted and records an audit entry.
|
|
func (r *fakeRepo) SoftDelete(ctx context.Context, id string, actorUserID string) error {
|
|
if r.deleteErr != nil {
|
|
return r.deleteErr
|
|
}
|
|
u, err := r.GetByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC()
|
|
u.DeletedAt = &now
|
|
r.audit = append(
|
|
r.audit,
|
|
domain.AuditLogEntry{
|
|
Action: domain.AuditActionGDPRDelete,
|
|
TargetUserID: id,
|
|
ActorUserID: actorUserID,
|
|
},
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// List returns users while honoring include-deleted filtering.
|
|
func (r *fakeRepo) List(
|
|
ctx context.Context,
|
|
pagination sharedtypes.Pagination,
|
|
filter domain.ListFilter,
|
|
) ([]*domain.User, int64, error) {
|
|
if r.listErr != nil {
|
|
return nil, 0, r.listErr
|
|
}
|
|
items := make([]*domain.User, 0, len(r.usersByID))
|
|
for _, u := range r.usersByID {
|
|
if !filter.IncludeDeleted && u.IsDeleted() {
|
|
continue
|
|
}
|
|
items = append(items, u)
|
|
}
|
|
return items, int64(len(items)), nil
|
|
}
|
|
|
|
// AuditLogsByUserID returns audit entries associated with a target user.
|
|
func (r *fakeRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.AuditLogEntry, error) {
|
|
if r.logsErr != nil {
|
|
return nil, r.logsErr
|
|
}
|
|
out := make([]domain.AuditLogEntry, 0)
|
|
for _, a := range r.audit {
|
|
if a.TargetUserID == id {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// WriteAuditLog appends an audit entry to the fake repository.
|
|
func (r *fakeRepo) WriteAuditLog(ctx context.Context, entry domain.AuditLogEntry) error {
|
|
if r.writeAuditErr != nil {
|
|
return r.writeAuditErr
|
|
}
|
|
r.audit = append(r.audit, entry)
|
|
return nil
|
|
}
|
|
|
|
// TestRegisterIdempotent verifies repeated registration returns the same existing user.
|
|
func TestRegisterIdempotent(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
svc := NewService(repo)
|
|
|
|
first, err := svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zitadel-1",
|
|
ActorEmail: "player@example.com",
|
|
DisplayName: "Player One",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first register failed: %v", err)
|
|
}
|
|
|
|
second, err := svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zitadel-1",
|
|
ActorEmail: "player@example.com",
|
|
DisplayName: "Player One",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("second register failed: %v", err)
|
|
}
|
|
|
|
if first.ID != second.ID {
|
|
t.Fatalf("expected idempotent register, got %s and %s", first.ID, second.ID)
|
|
}
|
|
}
|
|
|
|
// TestDeleteAndExport verifies a deleted user can no longer be fetched.
|
|
func TestDeleteAndExport(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
svc := NewService(repo)
|
|
|
|
u, err := svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zitadel-1",
|
|
ActorEmail: "player@example.com",
|
|
DisplayName: "Player One",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("register failed: %v", err)
|
|
}
|
|
|
|
if err := svc.DeleteUser(context.Background(), u.ID, "admin-1"); err != nil {
|
|
t.Fatalf("delete failed: %v", err)
|
|
}
|
|
|
|
_, err = svc.GetProfile(context.Background(), u.ID)
|
|
if err == nil {
|
|
t.Fatal("expected deleted user to be unavailable")
|
|
}
|
|
}
|
|
|
|
// TestRegisterValidationAndRepoErrors verifies expected behavior.
|
|
func TestRegisterValidationAndRepoErrors(t *testing.T) {
|
|
svc := NewService(newFakeRepo())
|
|
_, err := svc.Register(context.Background(), RegisterInput{})
|
|
if !errors.Is(err, domain.ErrUnauthorized) {
|
|
t.Fatalf("expected unauthorized, got %v", err)
|
|
}
|
|
|
|
_, err = svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zid-1",
|
|
ActorEmail: "bad-email",
|
|
DisplayName: "Player",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
if !errors.Is(err, domain.ErrValidationFailed) {
|
|
t.Fatalf("expected validation for bad email, got %v", err)
|
|
}
|
|
|
|
_, err = svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zid-1",
|
|
ActorEmail: "a@b.com",
|
|
DisplayName: "",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
if !errors.Is(err, domain.ErrValidationFailed) {
|
|
t.Fatalf("expected validation for display name, got %v", err)
|
|
}
|
|
|
|
repo := newFakeRepo()
|
|
repo.getByZidErr = errors.New("zid boom")
|
|
svc = NewService(repo)
|
|
_, err = svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zid-1",
|
|
ActorEmail: "p@example.com",
|
|
DisplayName: "Player",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "zid boom") {
|
|
t.Fatalf("expected zid lookup error, got %v", err)
|
|
}
|
|
|
|
repo = newFakeRepo()
|
|
repo.createErr = errors.New("create boom")
|
|
svc = NewService(repo)
|
|
_, err = svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zid-1",
|
|
ActorEmail: "p@example.com",
|
|
DisplayName: "Player",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "create boom") {
|
|
t.Fatalf("expected create error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestProfileAndEmailFlows verifies expected behavior.
|
|
func TestProfileAndEmailFlows(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
svc := NewService(repo)
|
|
user, err := svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zid-1",
|
|
ActorEmail: "player@example.com",
|
|
DisplayName: "Player One",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("register failed: %v", err)
|
|
}
|
|
|
|
_, err = svc.GetProfile(context.Background(), "")
|
|
if !errors.Is(err, domain.ErrValidationFailed) {
|
|
t.Fatalf("expected validation on empty id, got %v", err)
|
|
}
|
|
|
|
updated, err := svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{
|
|
DisplayName: "Player Renamed",
|
|
ConsentVersion: "v2",
|
|
ConsentSource: "",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update profile failed: %v", err)
|
|
}
|
|
if updated.DisplayName != "Player Renamed" {
|
|
t.Fatalf("unexpected updated name: %s", updated.DisplayName)
|
|
}
|
|
|
|
verified, err := svc.VerifyEmail(context.Background(), user.ID)
|
|
if err != nil {
|
|
t.Fatalf("verify email failed: %v", err)
|
|
}
|
|
if !verified.EmailVerified {
|
|
t.Fatalf("expected email verified")
|
|
}
|
|
}
|
|
|
|
// TestProfileValidationAndRepoErrors verifies expected behavior.
|
|
func TestProfileValidationAndRepoErrors(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
svc := NewService(repo)
|
|
user, _ := svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zid-1",
|
|
ActorEmail: "player@example.com",
|
|
DisplayName: "Player One",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
|
|
_, err := svc.UpdateProfile(context.Background(), "", UpdateProfileInput{DisplayName: "x", ConsentVersion: "v1"})
|
|
if !errors.Is(err, domain.ErrValidationFailed) {
|
|
t.Fatalf("expected validation on empty user id, got %v", err)
|
|
}
|
|
_, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{DisplayName: "", ConsentVersion: "v1"})
|
|
if !errors.Is(err, domain.ErrValidationFailed) {
|
|
t.Fatalf("expected validation on display name, got %v", err)
|
|
}
|
|
_, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{DisplayName: "ok",
|
|
ConsentVersion: strings.Repeat("a", 40)})
|
|
if !errors.Is(err, domain.ErrValidationFailed) {
|
|
t.Fatalf("expected validation on consent version, got %v", err)
|
|
}
|
|
_, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{
|
|
DisplayName: "ok",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: strings.Repeat("a", 40),
|
|
})
|
|
if !errors.Is(err, domain.ErrValidationFailed) {
|
|
t.Fatalf("expected validation on consent source, got %v", err)
|
|
}
|
|
|
|
repo.updateErr = errors.New("update boom")
|
|
_, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{
|
|
DisplayName: "ok",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "update boom") {
|
|
t.Fatalf("expected update repo error, got %v", err)
|
|
}
|
|
|
|
repo.verifyErr = errors.New("verify boom")
|
|
_, err = svc.VerifyEmail(context.Background(), user.ID)
|
|
if err == nil || !strings.Contains(err.Error(), "verify boom") {
|
|
t.Fatalf("expected verify repo error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestAdminListAndExport verifies expected behavior.
|
|
func TestAdminListAndExport(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
svc := NewService(repo)
|
|
user, _ := svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zid-1",
|
|
ActorEmail: "player@example.com",
|
|
DisplayName: "Player One",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
repo.audit = append(repo.audit, domain.AuditLogEntry{TargetUserID: user.ID, Action: domain.AuditActionGDPRExport})
|
|
|
|
items, total, pagination, err := svc.AdminListUsers(context.Background(), ListUsersInput{
|
|
Page: 0, PageSize: 0, Email: "PLAYER@EXAMPLE.COM", DisplayName: " Player ",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("admin list failed: %v", err)
|
|
}
|
|
if total == 0 || len(items) == 0 {
|
|
t.Fatalf("expected listed users")
|
|
}
|
|
if pagination.Page <= 0 || pagination.PageSize <= 0 {
|
|
t.Fatalf("expected normalized pagination, got %+v", pagination)
|
|
}
|
|
|
|
bundle, err := svc.AdminExportUser(context.Background(), user.ID, "admin-1")
|
|
if err != nil {
|
|
t.Fatalf("admin export failed: %v", err)
|
|
}
|
|
if bundle.User.ID != user.ID {
|
|
t.Fatalf("unexpected export user id: %s", bundle.User.ID)
|
|
}
|
|
if len(repo.audit) == 0 {
|
|
t.Fatalf("expected audit writes during export")
|
|
}
|
|
}
|
|
|
|
// TestDeleteListExportErrors verifies expected behavior.
|
|
func TestDeleteListExportErrors(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
svc := NewService(repo)
|
|
err := svc.DeleteUser(context.Background(), "", "admin")
|
|
if !errors.Is(err, domain.ErrValidationFailed) {
|
|
t.Fatalf("expected delete validation error, got %v", err)
|
|
}
|
|
|
|
repo.deleteErr = errors.New("delete boom")
|
|
err = svc.DeleteUser(context.Background(), "u-1", "admin")
|
|
if err == nil || !strings.Contains(err.Error(), "delete boom") {
|
|
t.Fatalf("expected delete repo error, got %v", err)
|
|
}
|
|
|
|
repo.listErr = errors.New("list boom")
|
|
_, _, _, err = svc.AdminListUsers(context.Background(), ListUsersInput{Page: 1, PageSize: 10})
|
|
if err == nil || !strings.Contains(err.Error(), "list boom") {
|
|
t.Fatalf("expected list repo error, got %v", err)
|
|
}
|
|
|
|
_, err = svc.AdminExportUser(context.Background(), "", "admin")
|
|
if !errors.Is(err, domain.ErrValidationFailed) {
|
|
t.Fatalf("expected export validation error, got %v", err)
|
|
}
|
|
|
|
repo = newFakeRepo()
|
|
svc = NewService(repo)
|
|
repo.getByIDErr = errors.New("profile boom")
|
|
_, err = svc.AdminExportUser(context.Background(), "u-1", "admin")
|
|
if err == nil || !strings.Contains(err.Error(), "profile boom") {
|
|
t.Fatalf("expected get profile error, got %v", err)
|
|
}
|
|
|
|
repo = newFakeRepo()
|
|
svc = NewService(repo)
|
|
user, _ := svc.Register(context.Background(), RegisterInput{
|
|
ActorZitadelUserID: "zid-1",
|
|
ActorEmail: "player@example.com",
|
|
DisplayName: "Player One",
|
|
ConsentVersion: "v1",
|
|
ConsentSource: "web",
|
|
})
|
|
repo.logsErr = errors.New("logs boom")
|
|
_, err = svc.AdminExportUser(context.Background(), user.ID, "admin")
|
|
if err == nil || !strings.Contains(err.Error(), "logs boom") {
|
|
t.Fatalf("expected logs error, got %v", err)
|
|
}
|
|
}
|