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.
304 lines
9.7 KiB
Go
304 lines
9.7 KiB
Go
package tests
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/gofiber/fiber/v3/middleware/adaptor"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
|
|
appu "knowfoolery/backend/services/user-service/internal/application/user"
|
|
domain "knowfoolery/backend/services/user-service/internal/domain/user"
|
|
httpapi "knowfoolery/backend/services/user-service/internal/interfaces/http"
|
|
sharederrors "knowfoolery/backend/shared/domain/errors"
|
|
sharedtypes "knowfoolery/backend/shared/domain/types"
|
|
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
|
|
"knowfoolery/backend/shared/infra/utils/validation"
|
|
sharedhttpx "knowfoolery/backend/shared/testutil/httpx"
|
|
)
|
|
|
|
// inMemoryRepo is a lightweight repository double for HTTP integration tests.
|
|
type inMemoryRepo struct {
|
|
items map[string]*domain.User
|
|
audit []domain.AuditLogEntry
|
|
}
|
|
|
|
// newInMemoryRepo creates an empty in-memory repository.
|
|
func newInMemoryRepo() *inMemoryRepo {
|
|
return &inMemoryRepo{items: map[string]*domain.User{}, audit: make([]domain.AuditLogEntry, 0)}
|
|
}
|
|
|
|
// EnsureSchema is a no-op for the in-memory repository.
|
|
func (r *inMemoryRepo) EnsureSchema(ctx context.Context) error { return nil }
|
|
|
|
// Create inserts a user in memory and fills missing identity/timestamps.
|
|
func (r *inMemoryRepo) Create(ctx context.Context, user *domain.User) (*domain.User, error) {
|
|
if user.ID == "" {
|
|
user.ID = "user-" + strconv.Itoa(len(r.items)+1)
|
|
}
|
|
now := time.Now().UTC()
|
|
if user.CreatedAt.IsZero() {
|
|
user.CreatedAt = now
|
|
}
|
|
user.UpdatedAt = now
|
|
r.items[user.ID] = user
|
|
return user, nil
|
|
}
|
|
|
|
// GetByID returns a non-deleted user by ID.
|
|
func (r *inMemoryRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
|
if u, ok := r.items[id]; ok && !u.IsDeleted() {
|
|
return u, nil
|
|
}
|
|
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
|
|
}
|
|
|
|
// GetByEmail returns a non-deleted user matching the provided email.
|
|
func (r *inMemoryRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
|
for _, u := range r.items {
|
|
if u.Email == email && !u.IsDeleted() {
|
|
return u, nil
|
|
}
|
|
}
|
|
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
|
|
}
|
|
|
|
// GetByZitadelUserID returns a non-deleted user by identity subject.
|
|
func (r *inMemoryRepo) GetByZitadelUserID(ctx context.Context, zid string) (*domain.User, error) {
|
|
for _, u := range r.items {
|
|
if u.ZitadelUserID == zid && !u.IsDeleted() {
|
|
return u, nil
|
|
}
|
|
}
|
|
return nil, sharederrors.Wrap(sharederrors.CodeUserNotFound, "user not found", nil)
|
|
}
|
|
|
|
// UpdateProfile updates mutable profile fields for the selected user.
|
|
func (r *inMemoryRepo) UpdateProfile(
|
|
ctx context.Context,
|
|
id, displayName string,
|
|
consent domain.ConsentRecord,
|
|
) (*domain.User, error) {
|
|
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 sets email verification on a stored user.
|
|
func (r *inMemoryRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.User, error) {
|
|
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 a delete audit log.
|
|
func (r *inMemoryRepo) SoftDelete(ctx context.Context, id string, actorUserID string) error {
|
|
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{
|
|
TargetUserID: id,
|
|
ActorUserID: actorUserID,
|
|
Action: domain.AuditActionGDPRDelete,
|
|
CreatedAt: now,
|
|
},
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// List returns users and applies the include-deleted filter.
|
|
func (r *inMemoryRepo) List(
|
|
ctx context.Context,
|
|
pagination sharedtypes.Pagination,
|
|
filter domain.ListFilter,
|
|
) ([]*domain.User, int64, error) {
|
|
out := make([]*domain.User, 0)
|
|
for _, u := range r.items {
|
|
if !filter.IncludeDeleted && u.IsDeleted() {
|
|
continue
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out, int64(len(out)), nil
|
|
}
|
|
|
|
// AuditLogsByUserID returns audit events for a target user.
|
|
func (r *inMemoryRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.AuditLogEntry, error) {
|
|
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 event with a synthetic timestamp.
|
|
func (r *inMemoryRepo) WriteAuditLog(ctx context.Context, entry domain.AuditLogEntry) error {
|
|
entry.CreatedAt = time.Now().UTC()
|
|
r.audit = append(r.audit, entry)
|
|
return nil
|
|
}
|
|
|
|
// setupApp wires a Fiber test app with auth middleware and in-memory dependencies.
|
|
func setupApp(t *testing.T) (*fiber.App, *inMemoryRepo) {
|
|
t.Helper()
|
|
|
|
repo := newInMemoryRepo()
|
|
_, _ = repo.Create(context.Background(), &domain.User{
|
|
ID: "user-1",
|
|
ZitadelUserID: "user-1",
|
|
Email: "player@example.com",
|
|
DisplayName: "Player One",
|
|
ConsentVersion: "v1",
|
|
ConsentGivenAt: time.Now().UTC(),
|
|
ConsentSource: "web",
|
|
})
|
|
_, _ = repo.Create(context.Background(), &domain.User{
|
|
ID: "user-2",
|
|
ZitadelUserID: "user-2",
|
|
Email: "other@example.com",
|
|
DisplayName: "Player Two",
|
|
ConsentVersion: "v1",
|
|
ConsentGivenAt: time.Now().UTC(),
|
|
ConsentSource: "web",
|
|
})
|
|
|
|
svc := appu.NewService(repo)
|
|
metrics := sharedmetrics.NewMetrics(sharedmetrics.Config{
|
|
ServiceName: "user-service-test",
|
|
Enabled: true,
|
|
Registry: prometheus.NewRegistry(),
|
|
})
|
|
h := httpapi.NewHandler(svc, validation.NewValidator(), nil, metrics, 50, 200)
|
|
|
|
app := fiber.New()
|
|
authMW := func(c fiber.Ctx) error {
|
|
auth := c.Get("Authorization")
|
|
switch auth {
|
|
case "Bearer user-1":
|
|
c.Locals("user_id", "user-1")
|
|
c.Locals("user_email", "player@example.com")
|
|
c.Locals("user_roles", []string{"player"})
|
|
return c.Next()
|
|
case "Bearer admin":
|
|
c.Locals("user_id", "admin-1")
|
|
c.Locals("user_email", "admin@example.com")
|
|
c.Locals("user_roles", []string{"admin"})
|
|
return c.Next()
|
|
default:
|
|
return c.SendStatus(http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
httpapi.RegisterRoutes(app, h, authMW, nil)
|
|
app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler()))
|
|
return app, repo
|
|
}
|
|
|
|
// TestRegisterAndCRUD verifies register, read, update, and delete HTTP flows.
|
|
func TestRegisterAndCRUD(t *testing.T) {
|
|
app, _ := setupApp(t)
|
|
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"display_name": "Player One",
|
|
"consent_version": "v1",
|
|
"consent_source": "web",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader(payload))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer user-1")
|
|
resp := sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "register failed")
|
|
defer resp.Body.Close()
|
|
req = httptest.NewRequest(http.MethodGet, "/users/user-1", nil)
|
|
req.Header.Set("Authorization", "Bearer user-1")
|
|
resp = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "get failed")
|
|
defer resp.Body.Close()
|
|
|
|
updatePayload, _ := json.Marshal(map[string]any{
|
|
"display_name": "Player One Updated",
|
|
"consent_version": "v2",
|
|
"consent_source": "web",
|
|
})
|
|
req = httptest.NewRequest(http.MethodPut, "/users/user-1", bytes.NewReader(updatePayload))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer user-1")
|
|
resp = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "update failed")
|
|
defer resp.Body.Close()
|
|
req = httptest.NewRequest(http.MethodDelete, "/users/user-1", nil)
|
|
req.Header.Set("Authorization", "Bearer user-1")
|
|
resp = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNoContent, "delete failed")
|
|
defer resp.Body.Close()
|
|
}
|
|
|
|
// TestAuthGuardsAndAdminRoutes verifies auth guards and admin-only route access.
|
|
func TestAuthGuardsAndAdminRoutes(t *testing.T) {
|
|
app, _ := setupApp(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/users/user-1", nil)
|
|
resp := sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized")
|
|
defer resp.Body.Close()
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/users/user-2", nil)
|
|
req.Header.Set("Authorization", "Bearer user-1")
|
|
resp = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected forbidden")
|
|
defer resp.Body.Close()
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
|
req.Header.Set("Authorization", "Bearer user-1")
|
|
resp = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected admin forbidden")
|
|
defer resp.Body.Close()
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
|
req.Header.Set("Authorization", "Bearer admin")
|
|
resp = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected admin list ok")
|
|
defer resp.Body.Close()
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/admin/users/user-2/export", nil)
|
|
req.Header.Set("Authorization", "Bearer admin")
|
|
resp = sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected export ok")
|
|
defer resp.Body.Close()
|
|
}
|
|
|
|
// TestMetricsEndpoint verifies the Prometheus metrics endpoint is exposed.
|
|
func TestMetricsEndpoint(t *testing.T) {
|
|
app, _ := setupApp(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
|
resp := sharedhttpx.MustTest(t, app, req)
|
|
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed")
|
|
defer resp.Body.Close()
|
|
}
|