package tests // integration_http_test.go contains tests for backend behavior. import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "time" "github.com/gofiber/fiber/v3" "github.com/prometheus/client_golang/prometheus" appadmin "knowfoolery/backend/services/admin-service/internal/application/admin" "knowfoolery/backend/services/admin-service/internal/domain/audit" httpapi "knowfoolery/backend/services/admin-service/internal/interfaces/http" "knowfoolery/backend/shared/infra/auth/zitadel" "knowfoolery/backend/shared/infra/observability/logging" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" sharedhttpx "knowfoolery/backend/shared/testutil/httpx" ) type inMemoryAuditRepo struct { entries []audit.Entry countErr error listErr error appendErr error } // newInMemoryAuditRepo is a test helper. func newInMemoryAuditRepo() *inMemoryAuditRepo { return &inMemoryAuditRepo{entries: make([]audit.Entry, 0)} } // EnsureSchema is a test helper. func (r *inMemoryAuditRepo) EnsureSchema(ctx context.Context) error { return nil } // Append is a test helper. func (r *inMemoryAuditRepo) Append(ctx context.Context, e audit.Entry) error { if r.appendErr != nil { return r.appendErr } r.entries = append(r.entries, e) return nil } // List is a test helper. func (r *inMemoryAuditRepo) List(ctx context.Context, limit, offset int) ([]audit.Entry, error) { if r.listErr != nil { return nil, r.listErr } if offset >= len(r.entries) { return []audit.Entry{}, nil } end := offset + limit if end > len(r.entries) { end = len(r.entries) } out := make([]audit.Entry, end-offset) copy(out, r.entries[offset:end]) return out, nil } // Count is a test helper. func (r *inMemoryAuditRepo) Count(ctx context.Context) (int64, error) { if r.countErr != nil { return 0, r.countErr } return int64(len(r.entries)), nil } // PruneBefore is a test helper. func (r *inMemoryAuditRepo) PruneBefore(ctx context.Context, before time.Time) (int64, error) { return 0, nil } // setupApp is a test helper. func setupApp(t *testing.T, repo *inMemoryAuditRepo) *fiber.App { t.Helper() svc := appadmin.NewService(repo, 90) logger := logging.NewLogger(logging.DefaultConfig()) m := sharedmetrics.NewMetrics(sharedmetrics.Config{ ServiceName: "admin-service-test", Enabled: true, Registry: prometheus.NewRegistry(), }) h := httpapi.NewHandler(svc, logger, m) app := fiber.New() auth := func(c fiber.Ctx) error { switch c.Get("Authorization") { case "Bearer admin": c.Locals(string(zitadel.ContextKeyUserID), "admin-1") c.Locals(string(zitadel.ContextKeyUserEmail), "admin@example.com") c.Locals(string(zitadel.ContextKeyUserRoles), []string{"admin"}) c.Locals(string(zitadel.ContextKeyMFAVerified), true) return c.Next() case "Bearer player": c.Locals(string(zitadel.ContextKeyUserID), "player-1") c.Locals(string(zitadel.ContextKeyUserEmail), "player@example.com") c.Locals(string(zitadel.ContextKeyUserRoles), []string{"player"}) c.Locals(string(zitadel.ContextKeyMFAVerified), false) return c.Next() default: return c.SendStatus(http.StatusUnauthorized) } } httpapi.RegisterRoutes(app, h, auth) return app } // TestAdminAuthRoute verifies expected behavior. func TestAdminAuthRoute(t *testing.T) { repo := newInMemoryAuditRepo() app := setupApp(t, repo) req := httptest.NewRequest(http.MethodPost, "/admin/auth", nil) resp := sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized without token") defer resp.Body.Close() req = httptest.NewRequest(http.MethodPost, "/admin/auth", nil) req.Header.Set("Authorization", "Bearer admin") resp = sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected admin auth success") defer resp.Body.Close() if len(repo.entries) == 0 { t.Fatalf("expected audit entries after /admin/auth") } } // TestDashboardRouteSuccessAndErrors verifies expected behavior. func TestDashboardRouteSuccessAndErrors(t *testing.T) { t.Run("forbidden for non-admin", func(t *testing.T) { repo := newInMemoryAuditRepo() app := setupApp(t, repo) req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) req.Header.Set("Authorization", "Bearer player") resp := sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected player forbidden") defer resp.Body.Close() }) t.Run("success for admin", func(t *testing.T) { repo := newInMemoryAuditRepo() repo.entries = append(repo.entries, audit.Entry{ID: "seed"}) app := setupApp(t, repo) req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) req.Header.Set("Authorization", "Bearer admin") resp := sharedhttpx.MustTest(t, app, req) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode response: %v", err) } if _, ok := body["audit_entries_total"]; !ok { t.Fatalf("expected audit_entries_total in dashboard response") } }) t.Run("internal error when count fails", func(t *testing.T) { repo := newInMemoryAuditRepo() repo.countErr = errors.New("boom") app := setupApp(t, repo) req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil) req.Header.Set("Authorization", "Bearer admin") resp := sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusInternalServerError, "expected dashboard error mapping") defer resp.Body.Close() }) } // TestAuditRouteSuccessAndErrors verifies expected behavior. func TestAuditRouteSuccessAndErrors(t *testing.T) { t.Run("forbidden for non-admin", func(t *testing.T) { repo := newInMemoryAuditRepo() app := setupApp(t, repo) req := httptest.NewRequest(http.MethodGet, "/admin/audit", nil) req.Header.Set("Authorization", "Bearer player") resp := sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected player forbidden") defer resp.Body.Close() }) t.Run("success with pagination", func(t *testing.T) { repo := newInMemoryAuditRepo() repo.entries = append( repo.entries, audit.Entry{ID: "1", Action: "admin.auth"}, audit.Entry{ID: "2", Action: "admin.dashboard.view"}, ) app := setupApp(t, repo) req := httptest.NewRequest(http.MethodGet, "/admin/audit?limit=1&offset=0", nil) req.Header.Set("Authorization", "Bearer admin") resp := sharedhttpx.MustTest(t, app, req) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode response: %v", err) } if body["limit"] != float64(1) { t.Fatalf("expected limit=1, got %v", body["limit"]) } }) t.Run("internal error when list fails", func(t *testing.T) { repo := newInMemoryAuditRepo() repo.listErr = errors.New("boom") app := setupApp(t, repo) req := httptest.NewRequest(http.MethodGet, "/admin/audit", nil) req.Header.Set("Authorization", "Bearer admin") resp := sharedhttpx.MustTest(t, app, req) sharedhttpx.AssertStatusAndClose(t, resp, http.StatusInternalServerError, "expected audit list error mapping") defer resp.Body.Close() }) } // TestRegisterRoutesDoesNotPanic verifies expected behavior. func TestRegisterRoutesDoesNotPanic(t *testing.T) { app := fiber.New() repo := newInMemoryAuditRepo() svc := appadmin.NewService(repo, 90) logger := logging.NewLogger(logging.DefaultConfig()) m := sharedmetrics.NewMetrics(sharedmetrics.Config{ ServiceName: "admin-service-test", Enabled: true, Registry: prometheus.NewRegistry(), }) h := httpapi.NewHandler(svc, logger, m) httpapi.RegisterRoutes(app, h, nil) }