diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..72f7a66 --- /dev/null +++ b/.env.test.example @@ -0,0 +1,36 @@ +# ============================================================================= +# Integration tests — copy to .env.test and adjust +# ============================================================================= +# Master switch (required) +ULTI_TEST_INTEGRATION=1 + +# Core infra — leave empty to auto-start via Docker testcontainers +# ULTI_TEST_DB_URL=postgres://ulti:test@localhost:5432/ultidb_test?sslmode=disable +# ULTI_TEST_REDIS_ADDR=127.0.0.1:6379 +# ULTI_TEST_S3_ENDPOINT=127.0.0.1:9000 +# ULTI_TEST_S3_ACCESS_KEY=ultiadmin +# ULTI_TEST_S3_SECRET_KEY=changeme123 +# ULTI_TEST_S3_USE_SSL=false +ULTI_TEST_AUTO_MIGRATE=true +ULTI_TEST_PARALLEL=4 + +# Mail credential encryption (test values — never use in production) +MAIL_CREDENTIAL_KEYS=v1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= +MAIL_ACTIVE_CREDENTIAL_KEY_ID=v1 +MAIL_WEBHOOK_SHARED_SECRET=test-webhook-secret + +# Optional suite modules (skipped unless enabled) +# ULTI_TEST_NEXTCLOUD=1 +# ULTI_TEST_NEXTCLOUD_URL=http://localhost:8081 +# ULTI_TEST_IMMICH=1 +# ULTI_TEST_IMMICH_URL=http://localhost:2283/api +# ULTI_TEST_JITSI=1 +# ULTI_TEST_MEILISEARCH=1 +# ULTI_TEST_MEILISEARCH_URL=http://localhost:7700 +# ULTI_TEST_MEILISEARCH_KEY=changeme + +# Disable heavy suite integrations during core API tests +NEXTCLOUD_ENABLED=false +IMMICH_ENABLED=false +JITSI_ENABLED=false +ONLYOFFICE_ENABLED=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74690cc..42b3730 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,28 @@ jobs: - name: Run unit tests run: go test ./... + integration: + name: Integration tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: true + + - name: Run integration tests + env: + ULTI_TEST_INTEGRATION: "1" + MAIL_CREDENTIAL_KEYS: "v1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" + MAIL_ACTIVE_CREDENTIAL_KEY_ID: "v1" + MAIL_WEBHOOK_SHARED_SECRET: "test-webhook-secret" + NEXTCLOUD_ENABLED: "false" + IMMICH_ENABLED: "false" + JITSI_ENABLED: "false" + run: go test -tags=integration ./internal/integrationtest/... -count=1 -timeout=10m + migrations: name: DB migrations runs-on: ubuntu-latest diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8168ad1 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: test test-integration test-integration-mail test-integration-admin test-integration-auth + +test: + go test ./... + +test-integration: + ./scripts/test-integration.sh + +test-integration-mail: + ./scripts/test-integration.sh ./internal/integrationtest/mail/... + +test-integration-admin: + ./scripts/test-integration.sh ./internal/integrationtest/admin/... + +test-integration-auth: + ./scripts/test-integration.sh ./internal/integrationtest/auth/... diff --git a/README.md b/README.md index 5571fc5..19c1829 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,25 @@ cp .env.example .env ## Development +```bash +# Unit tests (fast, no Docker) +make test +# or: go test ./... + +# Integration tests (Docker required — Postgres + MinIO via testcontainers) +cp .env.test.example .env.test # optional overrides +make test-integration + +# Run a single domain or test +make test-integration-mail +go test -tags=integration ./internal/integrationtest/mail/... -run TestMailSettingsCRUD -count=1 -v + +# Enable optional suite modules (Nextcloud, Immich, Jitsi) +# ULTI_TEST_NEXTCLOUD=1 ULTI_TEST_NEXTCLOUD_URL=http://localhost:8081 make test-integration +``` + +Integration tests use a real PostgreSQL database (ephemeral container by default), miniredis, MinIO, and a test OIDC issuer that signs JWTs. Set `ULTI_TEST_INTEGRATION=1` to run them. See `.env.test.example` for all variables. + ```bash # Run locally (needs PG, KeyDB, RustFS, Authentik running; loads .env with {{VAR}} expansion) go run ./cmd/ultid diff --git a/cmd/ultid/main.go b/cmd/ultid/main.go index 2e70a26..5b30d1d 100644 --- a/cmd/ultid/main.go +++ b/cmd/ultid/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "fmt" "log/slog" "net/http" @@ -11,46 +10,10 @@ import ( "syscall" "time" - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" - "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/redis/go-redis/v9" - - "github.com/ultisuite/ulti-backend/internal/api/admin" - "github.com/ultisuite/ulti-backend/internal/api/docs" - "github.com/ultisuite/ulti-backend/internal/api/calendar" - "github.com/ultisuite/ulti-backend/internal/api/contacts" - "github.com/ultisuite/ulti-backend/internal/api/drive" - "github.com/ultisuite/ulti-backend/internal/api/office" - mailapi "github.com/ultisuite/ulti-backend/internal/api/mail" - "github.com/ultisuite/ulti-backend/internal/api/mail/drivebridge" - "github.com/ultisuite/ulti-backend/internal/api/mail/sendguard" - meetapi "github.com/ultisuite/ulti-backend/internal/api/meet" - "github.com/ultisuite/ulti-backend/internal/api/middleware" - photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" - "github.com/ultisuite/ulti-backend/internal/automation" - "github.com/ultisuite/ulti-backend/internal/authentik" - "github.com/ultisuite/ulti-backend/internal/auth" - "github.com/ultisuite/ulti-backend/internal/dbmigrate" "github.com/ultisuite/ulti-backend/internal/config" + "github.com/ultisuite/ulti-backend/internal/dbmigrate" "github.com/ultisuite/ulti-backend/internal/envexpand" - "github.com/ultisuite/ulti-backend/internal/httpcors" - mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials" - imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap" - mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" - "github.com/ultisuite/ulti-backend/internal/mail/rules" - "github.com/ultisuite/ulti-backend/internal/mail/smtp" - mailstorage "github.com/ultisuite/ulti-backend/internal/mail/storage" - "github.com/ultisuite/ulti-backend/internal/mail/webhooks" - "github.com/ultisuite/ulti-backend/internal/meet" - "github.com/ultisuite/ulti-backend/internal/nextcloud" - "github.com/ultisuite/ulti-backend/internal/observability" - "github.com/ultisuite/ulti-backend/internal/photos" - "github.com/ultisuite/ulti-backend/internal/realtime" - "github.com/ultisuite/ulti-backend/internal/search" - "github.com/ultisuite/ulti-backend/internal/securityaudit" + "github.com/ultisuite/ulti-backend/internal/server" ) func main() { @@ -70,227 +33,16 @@ func main() { os.Exit(1) } - pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + app, err := server.New(ctx, cfg, server.Options{}) if err != nil { - slog.Error("failed to connect to database", "error", err) + slog.Error("failed to bootstrap server", "error", err) os.Exit(1) } - defer pool.Close() - - authentik.StartProvisioner(ctx, pool, cfg) - - rdb := redis.NewClient(&redis.Options{Addr: cfg.KeyDBAddr}) - defer rdb.Close() - - minioClient, err := minio.New(cfg.RustFSEndpoint, &minio.Options{ - Creds: credentials.NewStaticV4(cfg.RustFSAccessKey, cfg.RustFSSecretKey, ""), - Secure: cfg.RustFSUseSSL, - }) - if err != nil { - slog.Error("failed to create RustFS client", "error", err) - os.Exit(1) - } - attachmentStorage := mailstorage.NewClient(minioClient, cfg.MailAttachmentsBucket) - if err := attachmentStorage.EnsureBucket(ctx); err != nil { - slog.Warn("mail attachments bucket check failed", "error", err) - } - - verifier, err := auth.NewVerifierWithRetry(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second) - if err != nil { - slog.Warn("OIDC verifier not available at startup (Authentik may still be starting)", "error", err) - } - verifierHolder := auth.NewHolder(verifier) - if !verifierHolder.Ready() && cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" { - pending := auth.NewHolderPending(cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain) - verifierHolder = pending - verifierHolder.StartBackgroundRetry(ctx, 5*time.Second) - } - if cfg.IsProduction() { - if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" { - slog.Error("missing required OIDC configuration in production", - "ULTID_OIDC_ISSUER_set", cfg.OIDCIssuer != "", - "ULTID_OIDC_CLIENT_ID_set", cfg.OIDCClientID != "") - os.Exit(1) - } - if !verifierHolder.Ready() { - slog.Error("OIDC verifier initialization failed in production") - os.Exit(1) - } - if err := cfg.ValidateSecretRotation(); err != nil { - slog.Error("secret rotation policy check failed", "error", err) - os.Exit(1) - } - } else if err := cfg.ValidateSecretRotation(); err != nil { - slog.Warn("secret rotation policy warning", "error", err) - } - - credentialManager, err := mailcredentials.NewManager(cfg.MailCredentialKeys, cfg.MailActiveCredentialKeyID) - if err != nil { - slog.Error("mail credential encryption not configured", "error", err) - os.Exit(1) - } - - auditLogger := securityaudit.NewLogger(pool) - - // Nextcloud client (nil if disabled) - var ncClient *nextcloud.Client - if cfg.NextcloudEnabled { - ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass). - WithPublicURL(cfg.NextcloudPublicURL). - WithDrivePublicURL(cfg.DrivePublicURL). - WithDAVCredentials(nextcloud.NewDAVCredentialStore(pool, credentialManager)) - slog.Info("nextcloud enabled", "url", cfg.NextcloudURL, "public_url", cfg.NextcloudPublicURL, "drive_public_url", cfg.DrivePublicURL) - } - - // Meet config (nil if disabled) - var meetCfg *meet.Config - if cfg.JitsiEnabled { - meetCfg = meet.NewConfig(cfg.JitsiAppID, cfg.JitsiAppSecret, cfg.Domain) - slog.Info("jitsi meet enabled", "domain", cfg.JitsiDomain) - } - - // Photos client (nil if disabled) - var photosClient *photos.Client - if cfg.ImmichEnabled { - photosClient = photos.NewClient(cfg.ImmichAPIURL) - slog.Info("immich photos enabled", "url", cfg.ImmichAPIURL) - } - - // WebSocket hub - hub := realtime.NewHub(verifierHolder, pool) - healthChecker := observability.NewHealthChecker(cfg, pool, rdb) - - hookExec := webhooks.NewExecutor(pool) - rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec) - autoDispatcher := automation.NewDispatcher(pool, rulesEngine, hookExec) - - oauthRedirect := cfg.MailOAuthRedirectURL - if oauthRedirect == "" { - oauthRedirect = fmt.Sprintf("http://localhost:%d/api/v1/mail/accounts/oauth/callback", cfg.Port) - if cfg.Domain != "" && cfg.Domain != "localhost" { - oauthRedirect = fmt.Sprintf("https://%s/api/v1/mail/accounts/oauth/callback", cfg.Domain) - } - } - mailOAuthSvc := mailoauth.NewService(mailoauth.Config{ - GoogleClientID: cfg.MailGoogleOAuthClientID, - GoogleClientSecret: cfg.MailGoogleOAuthClientSecret, - MicrosoftClientID: cfg.MailMicrosoftOAuthClientID, - MicrosoftSecret: cfg.MailMicrosoftOAuthSecret, - MicrosoftTenant: cfg.MailMicrosoftOAuthTenant, - RedirectURL: oauthRedirect, - }, rdb) - - // Start background workers - syncWorker := imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{ - Storage: attachmentStorage, - AttachBucket: cfg.MailAttachmentsBucket, - Automation: autoDispatcher, - Hub: hub, - }) - go syncWorker.Start(ctx) - - sender := smtp.NewSender(pool, credentialManager, mailOAuthSvc) - smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown) - guardedSender := smtp.NewGuardedSender(sender, smtpCircuit) - go smtp.NewOutboxProcessor( - pool, - guardedSender, - cfg.MailOutboxInterval, - cfg.MailOutboxMaxRetries, - smtp.WithAttachmentLoader(&smtp.StorageAttachmentLoader{Client: attachmentStorage}), - ).Start(ctx) - - sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst) - mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL, sender) - mailHandler.SetAccountSync(syncWorker) - - // Router - r := chi.NewRouter() - - r.Use(httpcors.Middleware(cfg)) - r.Use(middleware.TraceID) - r.Use(observability.HTTPMetrics) - r.Use(middleware.Logging) - - r.Mount("/api/docs", docs.NewHandler().Routes()) - - r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { - report := healthChecker.Check(r.Context()) - statusCode := http.StatusOK - if report.Status != "ok" { - statusCode = http.StatusServiceUnavailable - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) - _ = json.NewEncoder(w).Encode(report) - }) - r.Handle("/metrics", promhttp.Handler()) - - r.Get("/ws", hub.HandleWS) - r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback) - - var driveHandler *drive.Handler - var driveSvc *drive.Service - var contactsHandler *contacts.Handler - if ncClient != nil { - driveSvc = drive.NewService(ncClient, hub, pool) - driveSvc.SetAutomation(autoDispatcher) - driveHandler = drive.NewHandlerWithService(driveSvc) - mailHandler.SetDriveUploader(&drivebridge.Bridge{Svc: driveSvc}) - contactsHandler = contacts.NewHandler(ncClient, pool) - contactsHandler.SetAutomation(autoDispatcher) - } - if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil { - officeSvc := office.NewService(ncClient, office.Config{ - Enabled: true, - DocumentURL: cfg.OnlyOfficeURL, - PublicURL: cfg.OnlyOfficePublicURL, - APIInternalURL: cfg.OnlyOfficeAPIInternalURL, - JWTSecret: cfg.OnlyOfficeJWTSecret, - }) - officeHandler := office.NewHandler(officeSvc, driveSvc) - r.Mount("/api/v1/office", officeHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger))) - driveHandler.SetPublicOffice(officeHandler) - } - if driveHandler != nil { - r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes()) - } - - r.Group(func(r chi.Router) { - r.Use(middleware.Auth(verifierHolder, pool, auditLogger)) - r.Use(middleware.EnforceApiTokenPolicy()) - - r.Mount("/api/v1/mail", mailHandler.Routes()) - r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes()) - r.Get("/api/v1/search", search.NewHandler(pool, search.Options{ - Nextcloud: ncClient, - Engine: cfg.SearchEngine, - MeilisearchURL: cfg.MeilisearchURL, - MeilisearchKey: cfg.MeilisearchKey, - MeilisearchIndex: cfg.MeilisearchIndex, - TypesenseURL: cfg.TypesenseURL, - TypesenseKey: cfg.TypesenseKey, - TypesenseCollection: cfg.TypesenseCollection, - }).Search) - - if driveHandler != nil { - r.Mount("/api/v1/drive", driveHandler.Routes()) - r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes()) - r.Mount("/api/v1/contacts", contactsHandler.Routes()) - } - if meetCfg != nil { - r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes()) - } - if photosClient != nil { - r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes()) - } - }) - - slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect) + defer app.Close() srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), - Handler: r, + Handler: app.Router, } errCh := make(chan error, 1) diff --git a/go.mod b/go.mod index a504347..ff713a0 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/ultisuite/ulti-backend go 1.25.0 require ( + github.com/alicebob/miniredis/v2 v2.34.0 github.com/coder/websocket v1.8.14 github.com/coreos/go-oidc/v3 v3.11.0 github.com/emersion/go-imap/v2 v2.0.0-beta.7 @@ -10,27 +11,51 @@ require ( github.com/emersion/go-smtp v0.24.0 github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/cors v1.2.1 + github.com/go-jose/go-jose/v4 v4.0.2 + github.com/golang-migrate/migrate/v4 v4.18.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.1 github.com/microcosm-cc/bluemonday v1.0.27 github.com/minio/minio-go/v7 v7.0.80 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.7.0 - golang.org/x/text v0.28.0 + github.com/testcontainers/testcontainers-go v0.35.0 + github.com/testcontainers/testcontainers-go/modules/minio v0.35.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 + golang.org/x/net v0.55.0 + golang.org/x/oauth2 v0.30.0 + golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 ) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v27.2.0+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emersion/go-message v0.18.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-ini/ini v1.67.0 // indirect - github.com/go-jose/go-jose/v4 v4.0.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/goccy/go-json v0.10.3 // indirect - github.com/golang-migrate/migrate/v4 v4.18.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -39,20 +64,47 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect - github.com/kr/text v0.2.0 // indirect github.com/lib/pq v1.10.9 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/minio/md5-simd v1.1.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/shirou/gopsutil/v3 v3.23.12 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ca658f7..0bdd8d5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,15 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQggTglU/0= +github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -6,18 +18,39 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0= +github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emersion/go-imap/v2 v2.0.0-beta.7 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8= @@ -28,6 +61,8 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= @@ -36,16 +71,30 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -59,6 +108,8 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -72,16 +123,44 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.1.0 h1:YTpF579PYUX475eOL+6zyEO3ngLTOUWck78NBuJVXaM= +github.com/mdelapenya/tlscert v0.1.0/go.mod h1:wrbyM/DwbFCeCeqdPX/8c6hNOqQgbf0rUDErE1uD+64= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk= github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -92,17 +171,65 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= +github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= +github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= +github.com/testcontainers/testcontainers-go/modules/minio v0.35.0 h1:oJMrfB0hIABClRsJrVJ43zTEsCVk0JTN7RdTz9r+tk4= +github.com/testcontainers/testcontainers-go/modules/minio v0.35.0/go.mod h1:Q7gSllC2zi78e2OF6Gwn+DXyqbxdbt6PAuaZdIPh3DQ= +github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 h1:eEGx9kYzZb2cNhRbBrNOCL/YPOM7+RMJiy3bB+ie0/I= +github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0/go.mod h1:hfH71Mia/WWLBgMD2YctYcMlfsbnT0hflweL1dy8Q4s= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -110,54 +237,88 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/internal/integrationtest/admin/suite_test.go b/internal/integrationtest/admin/suite_test.go new file mode 100644 index 0000000..32e5f61 --- /dev/null +++ b/internal/integrationtest/admin/suite_test.go @@ -0,0 +1,14 @@ +//go:build integration + +package admin_test + +import ( + "os" + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestMain(m *testing.M) { + os.Exit(integrationtest.RunMain(m)) +} diff --git a/internal/integrationtest/admin/users_test.go b/internal/integrationtest/admin/users_test.go new file mode 100644 index 0000000..6b34069 --- /dev/null +++ b/internal/integrationtest/admin/users_test.go @@ -0,0 +1,57 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestAdminUsers(t *testing.T) { + h := integrationtest.RequireHarness(t) + adminClient, _ := integrationtest.RequireAdminClient(t, h) + + externalID := integrationtest.NewExternalID("managed") + createResp, err := adminClient.Post("/api/v1/admin/users", map[string]string{ + "external_id": externalID, + "email": externalID + "@example.com", + "name": "Managed User", + }) + integrationtest.FailIf(err, t, "create user") + integrationtest.FailUnlessStatus(t, createResp, 201) + + var created map[string]any + integrationtest.DecodeJSON(t, createResp, &created) + userID, _ := created["id"].(string) + if userID == "" { + t.Fatalf("missing user id: %#v", created) + } + + listResp, err := adminClient.Get("/api/v1/admin/users") + integrationtest.FailIf(err, t, "list users") + integrationtest.FailUnlessStatus(t, listResp, 200) + + getResp, err := adminClient.Get("/api/v1/admin/users/" + userID) + integrationtest.FailIf(err, t, "get user") + integrationtest.FailUnlessStatus(t, getResp, 200) + + disableResp, err := adminClient.Post("/api/v1/admin/users/"+userID+"/disable", nil) + integrationtest.FailIf(err, t, "disable user") + integrationtest.FailUnlessStatus(t, disableResp, 204) + + reactResp, err := adminClient.Post("/api/v1/admin/users/"+userID+"/reactivate", nil) + integrationtest.FailIf(err, t, "reactivate user") + integrationtest.FailUnlessStatus(t, reactResp, 204) +} + +func TestNonAdminForbidden(t *testing.T) { + h := integrationtest.RequireHarness(t) + userClient, _ := integrationtest.RequireUserClient(t, h) + + resp, err := userClient.Get("/api/v1/admin/users") + integrationtest.FailIf(err, t, "list users as non-admin") + if resp.Status != 403 { + t.Fatalf("status = %d, want 403", resp.Status) + } +} diff --git a/internal/integrationtest/auth/provision_test.go b/internal/integrationtest/auth/provision_test.go new file mode 100644 index 0000000..ac78008 --- /dev/null +++ b/internal/integrationtest/auth/provision_test.go @@ -0,0 +1,45 @@ +//go:build integration + +package auth_test + +import ( + "context" + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestUserProvisionedOnFirstRequest(t *testing.T) { + h := integrationtest.RequireHarness(t) + externalID := integrationtest.NewExternalID("provision") + claims := integrationtest.RegularUser(externalID) + client, err := h.Client(claims) + integrationtest.FailIf(err, t, "client") + + resp, err := client.Get("/api/v1/mail/settings") + integrationtest.FailIf(err, t, "GET settings") + integrationtest.FailUnlessStatus(t, resp, 200) + integrationtest.AssertUserProvisioned(t, h, externalID) +} + +func TestDisabledUserForbidden(t *testing.T) { + h := integrationtest.RequireHarness(t) + externalID := integrationtest.NewExternalID("disabled") + claims := integrationtest.RegularUser(externalID) + client, err := h.Client(claims) + integrationtest.FailIf(err, t, "client") + + resp, err := client.Get("/api/v1/mail/settings") + integrationtest.FailIf(err, t, "provision user") + integrationtest.FailUnlessStatus(t, resp, 200) + + if err := integrationtest.DisableUser(context.Background(), h, externalID); err != nil { + t.Fatalf("disable user: %v", err) + } + + resp, err = client.Get("/api/v1/mail/settings") + integrationtest.FailIf(err, t, "GET after disable") + if resp.Status != 403 { + t.Fatalf("status = %d, want 403; body = %s", resp.Status, string(resp.Body)) + } +} diff --git a/internal/integrationtest/auth/suite_test.go b/internal/integrationtest/auth/suite_test.go new file mode 100644 index 0000000..54e2ac2 --- /dev/null +++ b/internal/integrationtest/auth/suite_test.go @@ -0,0 +1,14 @@ +//go:build integration + +package auth_test + +import ( + "os" + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestMain(m *testing.M) { + os.Exit(integrationtest.RunMain(m)) +} diff --git a/internal/integrationtest/client.go b/internal/integrationtest/client.go new file mode 100644 index 0000000..fbd56f3 --- /dev/null +++ b/internal/integrationtest/client.go @@ -0,0 +1,93 @@ +//go:build integration + +package integrationtest + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// Client performs authenticated HTTP requests against the test server. +type Client struct { + baseURL string + token string + http *http.Client +} + +func NewClient(baseURL, token string) *Client { + return &Client{ + baseURL: strings.TrimSuffix(baseURL, "/"), + token: token, + http: &http.Client{}, + } +} + +type Response struct { + Status int + Body []byte + Header http.Header +} + +func (c *Client) Do(method, path string, body any) (*Response, error) { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, c.baseURL+path, reader) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return &Response{Status: resp.StatusCode, Body: data, Header: resp.Header}, nil +} + +func (c *Client) Get(path string) (*Response, error) { return c.Do(http.MethodGet, path, nil) } +func (c *Client) Post(path string, body any) (*Response, error) { + return c.Do(http.MethodPost, path, body) +} +func (c *Client) Put(path string, body any) (*Response, error) { return c.Do(http.MethodPut, path, body) } +func (c *Client) Patch(path string, body any) (*Response, error) { + return c.Do(http.MethodPatch, path, body) +} +func (c *Client) Delete(path string) (*Response, error) { return c.Do(http.MethodDelete, path, nil) } + +func (r *Response) JSON(v any) error { + return json.Unmarshal(r.Body, v) +} + +func (r *Response) MustStatus(want int) error { + if r.Status != want { + return fmt.Errorf("status = %d, want %d; body = %s", r.Status, want, string(r.Body)) + } + return nil +} + +// WithToken returns a copy using a different bearer token (e.g. API token). +func (c *Client) WithToken(token string) *Client { + return &Client{baseURL: c.baseURL, token: token, http: c.http} +} diff --git a/internal/integrationtest/containers.go b/internal/integrationtest/containers.go new file mode 100644 index 0000000..5da109b --- /dev/null +++ b/internal/integrationtest/containers.go @@ -0,0 +1,118 @@ +//go:build integration + +package integrationtest + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/minio" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +type infra struct { + pgContainer testcontainers.Container + minioContainer testcontainers.Container + miniredis *miniredis.Miniredis + dbURL string + s3Endpoint string + s3AccessKey string + s3SecretKey string + redisAddr string +} + +func sharedInfra(ctx context.Context, env Env) (*infra, error) { + return startInfra(ctx, env) +} + +func startInfra(ctx context.Context, env Env) (*infra, error) { + out := &infra{ + dbURL: env.DBURL, + s3Endpoint: env.S3Endpoint, + s3AccessKey: env.S3AccessKey, + s3SecretKey: env.S3SecretKey, + redisAddr: env.RedisAddr, + } + + if out.dbURL == "" { + pg, err := postgres.Run(ctx, + "postgres:16-alpine", + postgres.WithDatabase("ultidb_test"), + postgres.WithUsername("ulti"), + postgres.WithPassword("test"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + return nil, fmt.Errorf("postgres container: %w", err) + } + out.pgContainer = pg + connStr, err := pg.ConnectionString(ctx, "sslmode=disable") + if err != nil { + return nil, fmt.Errorf("postgres connection string: %w", err) + } + out.dbURL = connStr + } + + if out.redisAddr == "" { + mr, err := miniredis.Run() + if err != nil { + return nil, fmt.Errorf("miniredis: %w", err) + } + out.miniredis = mr + out.redisAddr = mr.Addr() + } + + if out.s3Endpoint == "" { + m, err := minio.Run(ctx, + "minio/minio:latest", + minio.WithUsername(env.S3AccessKey), + minio.WithPassword(env.S3SecretKey), + ) + if err != nil { + return nil, fmt.Errorf("minio container: %w", err) + } + out.minioContainer = m + host, err := m.ConnectionString(ctx) + if err != nil { + return nil, fmt.Errorf("minio endpoint: %w", err) + } + out.s3Endpoint = host + } + + if out.s3AccessKey == "" { + out.s3AccessKey = "ultiadmin" + } + if out.s3SecretKey == "" { + out.s3SecretKey = "changeme123" + } + + return out, nil +} + +func (i *infra) Close(ctx context.Context) { + if i == nil { + return + } + if i.miniredis != nil { + i.miniredis.Close() + } + if i.pgContainer != nil { + _ = i.pgContainer.Terminate(ctx) + } + if i.minioContainer != nil { + _ = i.minioContainer.Terminate(ctx) + } +} + +func normalizeEndpoint(endpoint string) string { + return strings.TrimPrefix(strings.TrimPrefix(endpoint, "http://"), "https://") +} diff --git a/internal/integrationtest/env.go b/internal/integrationtest/env.go new file mode 100644 index 0000000..e04976f --- /dev/null +++ b/internal/integrationtest/env.go @@ -0,0 +1,81 @@ +//go:build integration + +package integrationtest + +import ( + "os" + "strconv" + "strings" +) + +// Env holds integration test configuration from ULTI_TEST_* variables. +type Env struct { + Enabled bool + DBURL string + RedisAddr string + S3Endpoint string + S3AccessKey string + S3SecretKey string + S3UseSSL bool + AutoMigrate bool + Parallel int + Nextcloud bool + NextcloudURL string + Immich bool + ImmichURL string + Jitsi bool + Meilisearch bool + MeilisearchURL string + MeilisearchKey string +} + +func LoadEnv() Env { + return Env{ + Enabled: envTruthy("ULTI_TEST_INTEGRATION"), + DBURL: strings.TrimSpace(os.Getenv("ULTI_TEST_DB_URL")), + RedisAddr: strings.TrimSpace(os.Getenv("ULTI_TEST_REDIS_ADDR")), + S3Endpoint: strings.TrimSpace(os.Getenv("ULTI_TEST_S3_ENDPOINT")), + S3AccessKey: envOr("ULTI_TEST_S3_ACCESS_KEY", "ultiadmin"), + S3SecretKey: envOr("ULTI_TEST_S3_SECRET_KEY", "changeme123"), + S3UseSSL: envTruthy("ULTI_TEST_S3_USE_SSL"), + AutoMigrate: !envFalsy("ULTI_TEST_AUTO_MIGRATE"), + Parallel: envInt("ULTI_TEST_PARALLEL", 4), + Nextcloud: envTruthy("ULTI_TEST_NEXTCLOUD"), + NextcloudURL: strings.TrimSpace(os.Getenv("ULTI_TEST_NEXTCLOUD_URL")), + Immich: envTruthy("ULTI_TEST_IMMICH"), + ImmichURL: strings.TrimSpace(os.Getenv("ULTI_TEST_IMMICH_URL")), + Jitsi: envTruthy("ULTI_TEST_JITSI"), + Meilisearch: envTruthy("ULTI_TEST_MEILISEARCH"), + MeilisearchURL: strings.TrimSpace(os.Getenv("ULTI_TEST_MEILISEARCH_URL")), + MeilisearchKey: strings.TrimSpace(os.Getenv("ULTI_TEST_MEILISEARCH_KEY")), + } +} + +func envOr(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func envTruthy(key string) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + return v == "1" || v == "true" || v == "yes" +} + +func envFalsy(key string) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + return v == "0" || v == "false" || v == "no" +} + +func envInt(key string, fallback int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} diff --git a/internal/integrationtest/harness.go b/internal/integrationtest/harness.go new file mode 100644 index 0000000..9113a84 --- /dev/null +++ b/internal/integrationtest/harness.go @@ -0,0 +1,266 @@ +//go:build integration + +package integrationtest + +import ( + "context" + "fmt" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + + "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/config" + "github.com/ultisuite/ulti-backend/internal/dbmigrate" + "github.com/ultisuite/ulti-backend/internal/server" +) + +// Harness is the shared integration test environment. +type Harness struct { + Env Env + Infra *infra + OIDC *OIDCServer + App *server.App + Server *httptest.Server + Pool *pgxpool.Pool + Redis *redis.Client +} + +var ( + harnessMu sync.Mutex + harnessInst *Harness + harnessRefCount int + harnessErr error +) + +// Get returns the shared harness, initializing containers on first call. +func Get() (*Harness, error) { + harnessMu.Lock() + defer harnessMu.Unlock() + + if harnessInst != nil { + return harnessInst, nil + } + if harnessErr != nil { + return nil, harnessErr + } + + harnessInst, harnessErr = newHarness(context.Background()) + return harnessInst, harnessErr +} + +func releaseHarness(ctx context.Context) { + harnessMu.Lock() + defer harnessMu.Unlock() + + harnessRefCount-- + if harnessRefCount > 0 || harnessInst == nil { + return + } + harnessInst.Close(ctx) + harnessInst = nil + harnessErr = nil +} + +// RunMain is called from TestMain in integration test packages. +func RunMain(m *testing.M) int { + env := LoadEnv() + if !env.Enabled { + fmt.Fprintln(os.Stderr, "integration tests skipped: set ULTI_TEST_INTEGRATION=1") + return 0 + } + + harnessMu.Lock() + harnessRefCount++ + harnessMu.Unlock() + + h, err := Get() + if err != nil { + fmt.Fprintf(os.Stderr, "integration harness setup failed: %v\n", err) + return 1 + } + _ = h + + code := m.Run() + releaseHarness(context.Background()) + return code +} + +func newHarness(ctx context.Context) (*Harness, error) { + env := LoadEnv() + if !env.Enabled { + return nil, fmt.Errorf("ULTI_TEST_INTEGRATION is not set") + } + + infra, err := sharedInfra(ctx, env) + if err != nil { + return nil, err + } + + if env.AutoMigrate { + if err := dbmigrate.Up(infra.dbURL); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + } + + oidc, err := NewOIDCServer() + if err != nil { + return nil, err + } + + holder, err := oidc.Holder(ctx) + if err != nil { + return nil, fmt.Errorf("oidc holder: %w", err) + } + + pool, err := pgxpool.New(ctx, infra.dbURL) + if err != nil { + return nil, fmt.Errorf("pg pool: %w", err) + } + + rdb := redis.NewClient(&redis.Options{Addr: infra.redisAddr}) + + cfg := buildTestConfig(env, infra, oidc) + + app, err := server.New(ctx, cfg, server.Options{ + WithoutWorkers: true, + SkipAuthentikProvisioner: true, + VerifierHolder: holder, + Pool: pool, + Redis: rdb, + }) + if err != nil { + pool.Close() + _ = rdb.Close() + return nil, fmt.Errorf("server bootstrap: %w", err) + } + + ts := httptest.NewServer(app.Router) + + return &Harness{ + Env: env, + Infra: infra, + OIDC: oidc, + App: app, + Server: ts, + Pool: pool, + Redis: rdb, + }, nil +} + +func buildTestConfig(env Env, infra *infra, oidc *OIDCServer) *config.Config { + cfg := &config.Config{ + Port: 0, + Domain: "localhost", + AppEnv: "development", + DatabaseURL: infra.dbURL, + KeyDBAddr: infra.redisAddr, + KeyDBDB: 0, + RustFSEndpoint: normalizeEndpoint(infra.s3Endpoint), + RustFSAccessKey: infra.s3AccessKey, + RustFSSecretKey: infra.s3SecretKey, + RustFSUseSSL: env.S3UseSSL, + RustFSRegion: "us-east-1", + OIDCIssuer: oidc.Issuer, + OIDCClientID: oidc.ClientID, + NextcloudEnabled: env.Nextcloud && env.NextcloudURL != "", + NextcloudURL: env.NextcloudURL, + NextcloudPublicURL: env.NextcloudURL, + NCAdminUser: "admin", + NCAdminPass: "test", + OnlyOfficeEnabled: false, + JitsiEnabled: env.Jitsi, + JitsiDomain: "meet.localhost", + JitsiAppID: "ulti", + JitsiAppSecret: "test-jitsi-secret", + ImmichEnabled: env.Immich && env.ImmichURL != "", + ImmichAPIURL: env.ImmichURL, + MailAttachmentsBucket: "mail-attachments-test", + MailSyncInterval: time.Minute, + MailOutboxInterval: time.Minute, + MailOutboxMaxRetries: 3, + MailSendRatePerMinute: 100, + MailSendBurst: 50, + MailSMTPCircuitFailures: 5, + MailSMTPCircuitCooldown: time.Minute, + MailCredentialKeys: "v1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", + MailActiveCredentialKeyID: "v1", + MailWebhookSharedSecret: "test-webhook-secret", + MailAppURL: "http://localhost:3000", + SearchEngine: "postgres", + MeilisearchURL: env.MeilisearchURL, + MeilisearchKey: env.MeilisearchKey, + HealthHTTPTimeout: 2 * time.Second, + } + if env.Meilisearch && env.MeilisearchURL != "" { + cfg.SearchEngine = "meilisearch" + } + return cfg +} + +// Close releases test resources. +func (h *Harness) Close(ctx context.Context) { + if h == nil { + return + } + if h.Server != nil { + h.Server.Close() + } + if h.App != nil { + h.App.Close() + } else { + if h.Pool != nil { + h.Pool.Close() + } + if h.Redis != nil { + _ = h.Redis.Close() + } + } + if h.OIDC != nil { + h.OIDC.Close() + } + if h.Infra != nil { + h.Infra.Close(ctx) + } +} + +// NewExternalID returns a unique OIDC subject for test isolation. +func NewExternalID(prefix string) string { + if prefix == "" { + prefix = "test" + } + return fmt.Sprintf("%s-%s", prefix, uuid.NewString()) +} + +// IssueToken signs an OIDC ID token for the given claims. +func (h *Harness) IssueToken(claims *auth.Claims) (string, error) { + return h.OIDC.IssueToken(claims) +} + +// Client returns an authenticated HTTP client for the given claims. +func (h *Harness) Client(claims *auth.Claims) (*Client, error) { + token, err := h.IssueToken(claims) + if err != nil { + return nil, err + } + return NewClient(h.Server.URL, token), nil +} + +// RequireHarness is a test helper that fails the test if harness setup fails. +func RequireHarness(t *testing.T) *Harness { + t.Helper() + if !LoadEnv().Enabled { + t.Skip("integration tests disabled: set ULTI_TEST_INTEGRATION=1") + } + h, err := Get() + if err != nil { + t.Fatalf("harness: %v", err) + } + return h +} diff --git a/internal/integrationtest/mail/accounts_test.go b/internal/integrationtest/mail/accounts_test.go new file mode 100644 index 0000000..8b37029 --- /dev/null +++ b/internal/integrationtest/mail/accounts_test.go @@ -0,0 +1,86 @@ +//go:build integration + +package mail_test + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestMailAccountCRUD(t *testing.T) { + h := integrationtest.RequireHarness(t) + client, _ := integrationtest.RequireUserClient(t, h) + + createBody := map[string]any{ + "name": "Work", + "email": "work@example.com", + "provider": "custom", + "imap_host": "imap.example.com", + "imap_port": 993, + "imap_tls": true, + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_tls": true, + "username": "work@example.com", + "password": "secret-pass", + } + + resp, err := client.Post("/api/v1/mail/accounts", createBody) + integrationtest.FailIf(err, t, "POST account") + integrationtest.FailUnlessStatus(t, resp, 201) + + var created map[string]any + integrationtest.DecodeJSON(t, resp, &created) + accountID, _ := created["id"].(string) + if accountID == "" { + t.Fatalf("missing account id: %#v", created) + } + + resp, err = client.Get("/api/v1/mail/accounts") + integrationtest.FailIf(err, t, "LIST accounts") + integrationtest.FailUnlessStatus(t, resp, 200) + + var list struct { + Accounts []map[string]any `json:"accounts"` + } + integrationtest.DecodeJSON(t, resp, &list) + if len(list.Accounts) != 1 { + t.Fatalf("accounts len = %d, want 1", len(list.Accounts)) + } + + resp, err = client.Get("/api/v1/mail/accounts/" + accountID) + integrationtest.FailIf(err, t, "GET account") + integrationtest.FailUnlessStatus(t, resp, 200) + + resp, err = client.Put("/api/v1/mail/accounts/"+accountID, map[string]any{ + "name": "Work Updated", + "email": "work@example.com", + "imap_host": "imap.example.com", + "imap_port": 993, + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "username": "work@example.com", + }) + integrationtest.FailIf(err, t, "PUT account") + integrationtest.FailUnlessStatus(t, resp, 204) + + resp, err = client.Get("/api/v1/mail/accounts/" + accountID) + integrationtest.FailIf(err, t, "GET account after update") + integrationtest.FailUnlessStatus(t, resp, 200) + var account map[string]any + integrationtest.DecodeJSON(t, resp, &account) + if account["name"] != "Work Updated" { + t.Fatalf("name = %v", account["name"]) + } + + resp, err = client.Delete("/api/v1/mail/accounts/" + accountID) + integrationtest.FailIf(err, t, "DELETE account") + integrationtest.FailUnlessStatus(t, resp, 204) + + resp, err = client.Get("/api/v1/mail/accounts/" + accountID) + integrationtest.FailIf(err, t, "GET deleted account") + if resp.Status != 404 { + t.Fatalf("status = %d, want 404", resp.Status) + } +} diff --git a/internal/integrationtest/mail/api_tokens_test.go b/internal/integrationtest/mail/api_tokens_test.go new file mode 100644 index 0000000..a0e564e --- /dev/null +++ b/internal/integrationtest/mail/api_tokens_test.go @@ -0,0 +1,59 @@ +//go:build integration + +package mail_test + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestApiTokenLifecycle(t *testing.T) { + h := integrationtest.RequireHarness(t) + client, _ := integrationtest.RequireUserClient(t, h) + + createResp, err := client.Post("/api/v1/mail/api-tokens", map[string]any{ + "name": "integration agent", + "permissions": []map[string]any{ + {"resource": "mail.messages", "read": true, "write": false}, + {"resource": "mail.settings", "read": true, "write": false}, + }, + "mail_scope": map[string]any{ + "all_accounts": true, + "account_ids": []string{}, + }, + "drive_scope": map[string]any{ + "all_folders": true, + "folder_paths": []string{}, + }, + }) + integrationtest.FailIf(err, t, "create token") + integrationtest.FailUnlessStatus(t, createResp, 201) + + var created map[string]any + integrationtest.DecodeJSON(t, createResp, &created) + token, _ := created["token"].(string) + tokenID, _ := created["id"].(string) + if token == "" || tokenID == "" { + t.Fatalf("missing token fields: %#v", created) + } + + apiClient := client.WithToken(token) + resp, err := apiClient.Get("/api/v1/mail/settings") + integrationtest.FailIf(err, t, "use api token") + integrationtest.FailUnlessStatus(t, resp, 200) + + listResp, err := client.Get("/api/v1/mail/api-tokens") + integrationtest.FailIf(err, t, "list tokens") + integrationtest.FailUnlessStatus(t, listResp, 200) + + delResp, err := client.Delete("/api/v1/mail/api-tokens/" + tokenID) + integrationtest.FailIf(err, t, "revoke token") + integrationtest.FailUnlessStatus(t, delResp, 204) + + resp, err = apiClient.Get("/api/v1/mail/settings") + integrationtest.FailIf(err, t, "use revoked token") + if resp.Status != 401 { + t.Fatalf("status = %d, want 401 after revoke", resp.Status) + } +} diff --git a/internal/integrationtest/mail/drafts_test.go b/internal/integrationtest/mail/drafts_test.go new file mode 100644 index 0000000..d905990 --- /dev/null +++ b/internal/integrationtest/mail/drafts_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package mail_test + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestDraftsCRUD(t *testing.T) { + h := integrationtest.RequireHarness(t) + client, _ := integrationtest.RequireUserClient(t, h) + + acctResp, err := client.Post("/api/v1/mail/accounts", map[string]any{ + "name": "Drafts", + "email": "drafts@example.com", + "imap_host": "imap.example.com", + "imap_port": 993, + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "username": "drafts@example.com", + "password": "secret", + }) + integrationtest.FailIf(err, t, "create account") + integrationtest.FailUnlessStatus(t, acctResp, 201) + var acct map[string]string + integrationtest.DecodeJSON(t, acctResp, &acct) + + createResp, err := client.Post("/api/v1/mail/drafts", map[string]any{ + "account_id": acct["id"], + "subject": "Draft subject", + "body_text": "Hello draft body", + "to": []string{"recipient@example.com"}, + }) + integrationtest.FailIf(err, t, "create draft") + integrationtest.FailUnlessStatus(t, createResp, 201) + var created map[string]string + integrationtest.DecodeJSON(t, createResp, &created) + draftID := created["id"] + + resp, err := client.Get("/api/v1/mail/drafts") + integrationtest.FailIf(err, t, "list drafts") + integrationtest.FailUnlessStatus(t, resp, 200) + + resp, err = client.Get("/api/v1/mail/drafts/" + draftID) + integrationtest.FailIf(err, t, "get draft") + integrationtest.FailUnlessStatus(t, resp, 200) + + resp, err = client.Put("/api/v1/mail/drafts/"+draftID, map[string]any{ + "account_id": acct["id"], + "subject": "Updated subject", + "body_text": "Updated body", + }) + integrationtest.FailIf(err, t, "update draft") + integrationtest.FailUnlessStatus(t, resp, 204) + + resp, err = client.Delete("/api/v1/mail/drafts/" + draftID) + integrationtest.FailIf(err, t, "delete draft") + integrationtest.FailUnlessStatus(t, resp, 204) +} diff --git a/internal/integrationtest/mail/labels_folders_test.go b/internal/integrationtest/mail/labels_folders_test.go new file mode 100644 index 0000000..f78ad6a --- /dev/null +++ b/internal/integrationtest/mail/labels_folders_test.go @@ -0,0 +1,78 @@ +//go:build integration + +package mail_test + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestLabelsAndFolders(t *testing.T) { + h := integrationtest.RequireHarness(t) + client, _ := integrationtest.RequireUserClient(t, h) + + // Create mail account first (folders are account-scoped). + acctResp, err := client.Post("/api/v1/mail/accounts", map[string]any{ + "name": "Labels", + "email": "labels@example.com", + "imap_host": "imap.example.com", + "imap_port": 993, + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "username": "labels@example.com", + "password": "secret", + }) + integrationtest.FailIf(err, t, "create account") + integrationtest.FailUnlessStatus(t, acctResp, 201) + var acct map[string]string + integrationtest.DecodeJSON(t, acctResp, &acct) + accountID := acct["id"] + + labelResp, err := client.Post("/api/v1/mail/labels", map[string]string{ + "name": "Important", + "color": "#ff0000", + }) + integrationtest.FailIf(err, t, "create label") + integrationtest.FailUnlessStatus(t, labelResp, 201) + var label map[string]string + integrationtest.DecodeJSON(t, labelResp, &label) + labelID := label["id"] + if labelID == "" { + t.Fatal("missing label id") + } + + resp, err := client.Get("/api/v1/mail/labels") + integrationtest.FailIf(err, t, "list labels") + integrationtest.FailUnlessStatus(t, resp, 200) + + folderResp, err := client.Post("/api/v1/mail/folders", map[string]any{ + "account_id": accountID, + "name": "Projects", + "folder_type": "custom", + }) + integrationtest.FailIf(err, t, "create folder") + integrationtest.FailUnlessStatus(t, folderResp, 201) + var folder map[string]string + integrationtest.DecodeJSON(t, folderResp, &folder) + _ = folder["id"] + + resp, err = client.Get("/api/v1/mail/folders?account_id=" + accountID) + integrationtest.FailIf(err, t, "list folders") + integrationtest.FailUnlessStatus(t, resp, 200) + + ufResp, err := client.Post("/api/v1/mail/unified-folders", map[string]any{ + "name": "All Projects", + "account_id": accountID, + }) + integrationtest.FailIf(err, t, "create unified folder") + integrationtest.FailUnlessStatus(t, ufResp, 201) + + resp, err = client.Get("/api/v1/mail/unified-folders") + integrationtest.FailIf(err, t, "list unified folders") + integrationtest.FailUnlessStatus(t, resp, 200) + + resp, err = client.Delete("/api/v1/mail/labels/" + labelID) + integrationtest.FailIf(err, t, "delete label") + integrationtest.FailUnlessStatus(t, resp, 204) +} diff --git a/internal/integrationtest/mail/rules_webhooks_test.go b/internal/integrationtest/mail/rules_webhooks_test.go new file mode 100644 index 0000000..3036126 --- /dev/null +++ b/internal/integrationtest/mail/rules_webhooks_test.go @@ -0,0 +1,77 @@ +//go:build integration + +package mail_test + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestRulesAndWebhooks(t *testing.T) { + h := integrationtest.RequireHarness(t) + client, _ := integrationtest.RequireUserClient(t, h) + + ruleResp, err := client.Post("/api/v1/mail/rules", map[string]any{ + "name": "Archive invoices", + "conditions": []map[string]string{ + {"field": "subject", "operator": "contains", "value": "invoice"}, + }, + "actions": []map[string]string{ + {"type": "label", "value": "finance"}, + }, + }) + integrationtest.FailIf(err, t, "create rule") + integrationtest.FailUnlessStatus(t, ruleResp, 201) + var rule map[string]string + integrationtest.DecodeJSON(t, ruleResp, &rule) + ruleID := rule["id"] + + resp, err := client.Get("/api/v1/mail/rules") + integrationtest.FailIf(err, t, "list rules") + integrationtest.FailUnlessStatus(t, resp, 200) + + simResp, err := client.Post("/api/v1/mail/rules/simulate", map[string]any{ + "message": map[string]any{ + "subject": "Invoice Q1", + "from": "billing@example.com", + }, + "rule": map[string]any{ + "conditions": []map[string]string{ + {"field": "subject", "operator": "contains", "value": "invoice"}, + }, + "actions": []map[string]string{ + {"type": "label", "value": "finance"}, + }, + }, + }) + integrationtest.FailIf(err, t, "simulate rule") + integrationtest.FailUnlessStatus(t, simResp, 200) + + webhookResp, err := client.Post("/api/v1/mail/webhooks", map[string]any{ + "name": "Slack hook", + "url": "https://hooks.example.com/slack", + "method": "POST", + "body_template": `{"text":"$subject"}`, + "event_types": []string{"mail.received"}, + "mail_scope": map[string]any{ + "all_accounts": true, + }, + }) + integrationtest.FailIf(err, t, "create webhook") + integrationtest.FailUnlessStatus(t, webhookResp, 201) + var hook map[string]string + integrationtest.DecodeJSON(t, webhookResp, &hook) + + resp, err = client.Get("/api/v1/mail/webhooks") + integrationtest.FailIf(err, t, "list webhooks") + integrationtest.FailUnlessStatus(t, resp, 200) + + delResp, err := client.Delete("/api/v1/mail/webhooks/" + hook["id"]) + integrationtest.FailIf(err, t, "delete webhook") + integrationtest.FailUnlessStatus(t, delResp, 204) + + delRule, err := client.Delete("/api/v1/mail/rules/" + ruleID) + integrationtest.FailIf(err, t, "delete rule") + integrationtest.FailUnlessStatus(t, delRule, 204) +} diff --git a/internal/integrationtest/mail/settings_test.go b/internal/integrationtest/mail/settings_test.go new file mode 100644 index 0000000..0dcf9be --- /dev/null +++ b/internal/integrationtest/mail/settings_test.go @@ -0,0 +1,43 @@ +//go:build integration + +package mail_test + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestMailSettingsCRUD(t *testing.T) { + h := integrationtest.RequireHarness(t) + client, claims := integrationtest.RequireUserClient(t, h) + + resp, err := client.Get("/api/v1/mail/settings") + integrationtest.FailIf(err, t, "GET settings") + integrationtest.FailUnlessStatus(t, resp, 200) + integrationtest.AssertUserProvisioned(t, h, claims.Sub) + + var defaults map[string]any + integrationtest.DecodeJSON(t, resp, &defaults) + if defaults["density"] != "default" { + t.Fatalf("density = %v, want default", defaults["density"]) + } + + resp, err = client.Patch("/api/v1/mail/settings", map[string]string{"density": "compact"}) + integrationtest.FailIf(err, t, "PATCH settings") + integrationtest.FailUnlessStatus(t, resp, 200) + + var updated map[string]any + integrationtest.DecodeJSON(t, resp, &updated) + if updated["density"] != "compact" { + t.Fatalf("density = %v, want compact", updated["density"]) + } + + resp, err = client.Get("/api/v1/mail/settings") + integrationtest.FailIf(err, t, "GET settings after patch") + integrationtest.FailUnlessStatus(t, resp, 200) + integrationtest.DecodeJSON(t, resp, &updated) + if updated["density"] != "compact" { + t.Fatalf("persisted density = %v, want compact", updated["density"]) + } +} diff --git a/internal/integrationtest/mail/suite_test.go b/internal/integrationtest/mail/suite_test.go new file mode 100644 index 0000000..f06f2d6 --- /dev/null +++ b/internal/integrationtest/mail/suite_test.go @@ -0,0 +1,14 @@ +//go:build integration + +package mail_test + +import ( + "os" + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestMain(m *testing.M) { + os.Exit(integrationtest.RunMain(m)) +} diff --git a/internal/integrationtest/oidc.go b/internal/integrationtest/oidc.go new file mode 100644 index 0000000..b915103 --- /dev/null +++ b/internal/integrationtest/oidc.go @@ -0,0 +1,107 @@ +//go:build integration + +package integrationtest + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + + "github.com/ultisuite/ulti-backend/internal/auth" +) + +const testOIDCClientID = "ulti-backend-test" + +// OIDCServer is a minimal OIDC issuer for integration tests. +type OIDCServer struct { + URL string + Issuer string + ClientID string + + server *httptest.Server + key *rsa.PrivateKey + jwk jose.JSONWebKey +} + +func NewOIDCServer() (*OIDCServer, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("generate rsa key: %w", err) + } + + jwk := jose.JSONWebKey{Key: key.Public(), KeyID: "test-key", Use: "sig", Algorithm: string(jose.RS256)} + s := &OIDCServer{ + ClientID: testOIDCClientID, + key: key, + jwk: jwk, + } + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": s.Issuer, + "jwks_uri": s.Issuer + "/jwks/", + }) + }) + mux.HandleFunc("/jwks/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []jose.JSONWebKey{s.jwk}}) + }) + + s.server = httptest.NewServer(mux) + s.URL = strings.TrimSuffix(s.server.URL, "/") + s.Issuer = s.URL + return s, nil +} + +func (s *OIDCServer) Verifier(ctx context.Context) (*auth.Verifier, error) { + return auth.NewVerifier(ctx, s.URL, s.ClientID, "localhost") +} + +func (s *OIDCServer) Holder(ctx context.Context) (*auth.Holder, error) { + v, err := s.Verifier(ctx) + if err != nil { + return nil, err + } + return auth.NewHolder(v), nil +} + +func (s *OIDCServer) IssueToken(claims *auth.Claims) (string, error) { + if claims == nil { + return "", fmt.Errorf("claims is nil") + } + now := time.Now() + opts := (&jose.SignerOptions{}).WithHeader(jose.HeaderKey("kid"), s.jwk.KeyID) + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.key}, opts) + if err != nil { + return "", err + } + + builder := jwt.Signed(sig).Claims(jwt.Claims{ + Issuer: s.Issuer, + Subject: claims.Sub, + Audience: jwt.Audience{s.ClientID}, + Expiry: jwt.NewNumericDate(now.Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + }).Claims(map[string]any{ + "email": claims.Email, + "name": claims.Name, + "groups": claims.Groups, + }) + return builder.Serialize() +} + +func (s *OIDCServer) Close() { + if s != nil && s.server != nil { + s.server.Close() + } +} diff --git a/internal/integrationtest/search/search_test.go b/internal/integrationtest/search/search_test.go new file mode 100644 index 0000000..4559f64 --- /dev/null +++ b/internal/integrationtest/search/search_test.go @@ -0,0 +1,35 @@ +//go:build integration + +package search_test + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestSearchPostgresEmptyQuery(t *testing.T) { + h := integrationtest.RequireHarness(t) + client, _ := integrationtest.RequireUserClient(t, h) + + resp, err := client.Get("/api/v1/search?q=hello") + integrationtest.FailIf(err, t, "search") + integrationtest.FailUnlessStatus(t, resp, 200) + + var body map[string]any + integrationtest.DecodeJSON(t, resp, &body) + if body["results"] == nil { + t.Fatalf("missing results field: %#v", body) + } +} + +func TestSearchRequiresAuth(t *testing.T) { + h := integrationtest.RequireHarness(t) + client := integrationtest.NewClient(h.Server.URL, "") + + resp, err := client.Get("/api/v1/search?q=test") + integrationtest.FailIf(err, t, "search without auth") + if resp.Status != 401 { + t.Fatalf("status = %d, want 401", resp.Status) + } +} diff --git a/internal/integrationtest/search/suite_test.go b/internal/integrationtest/search/suite_test.go new file mode 100644 index 0000000..63fbee3 --- /dev/null +++ b/internal/integrationtest/search/suite_test.go @@ -0,0 +1,14 @@ +//go:build integration + +package search_test + +import ( + "os" + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestMain(m *testing.M) { + os.Exit(integrationtest.RunMain(m)) +} diff --git a/internal/integrationtest/smoke_test.go b/internal/integrationtest/smoke_test.go new file mode 100644 index 0000000..fa2336a --- /dev/null +++ b/internal/integrationtest/smoke_test.go @@ -0,0 +1,51 @@ +//go:build integration + +package integrationtest + +import ( + "encoding/json" + "testing" +) + +func TestHealthz(t *testing.T) { + h := RequireHarness(t) + resp, err := h.Server.Client().Get(h.Server.URL + "/healthz") + if err != nil { + t.Fatalf("GET /healthz: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 && resp.StatusCode != 503 { + t.Fatalf("status = %d, want 200 or 503", resp.StatusCode) + } + + var report map[string]any + if err := json.NewDecoder(resp.Body).Decode(&report); err != nil { + t.Fatalf("decode: %v", err) + } + if report["status"] == nil { + t.Fatalf("missing status field: %#v", report) + } +} + +func TestUnauthorizedWithoutToken(t *testing.T) { + h := RequireHarness(t) + client := NewClient(h.Server.URL, "") + resp, err := client.Get("/api/v1/mail/settings") + if err != nil { + t.Fatalf("GET: %v", err) + } + AssertErrorCode(t, resp, 401, "auth.missing_authorization") +} + +func TestInvalidToken(t *testing.T) { + h := RequireHarness(t) + client := NewClient(h.Server.URL, "not-a-valid-jwt") + resp, err := client.Get("/api/v1/mail/settings") + if err != nil { + t.Fatalf("GET: %v", err) + } + if resp.Status != 401 { + t.Fatalf("status = %d, want 401; body = %s", resp.Status, string(resp.Body)) + } +} diff --git a/internal/integrationtest/suite/optional_test.go b/internal/integrationtest/suite/optional_test.go new file mode 100644 index 0000000..83112c3 --- /dev/null +++ b/internal/integrationtest/suite/optional_test.go @@ -0,0 +1,45 @@ +//go:build integration + +package suite_test + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestDriveQuotaOptional(t *testing.T) { + integrationtest.SkipUnlessNextcloud(t) + h := integrationtest.RequireHarness(t) + client, _ := integrationtest.RequireUserClient(t, h) + + resp, err := client.Get("/api/v1/drive/quota") + integrationtest.FailIf(err, t, "drive quota") + if resp.Status != 200 && resp.Status != 502 && resp.Status != 503 { + t.Fatalf("status = %d, body = %s", resp.Status, string(resp.Body)) + } +} + +func TestPhotosAssetsOptional(t *testing.T) { + integrationtest.SkipUnlessImmich(t) + h := integrationtest.RequireHarness(t) + client, _ := integrationtest.RequireUserClient(t, h) + + resp, err := client.Get("/api/v1/photos/assets?page=1&size=1") + integrationtest.FailIf(err, t, "photos assets") + if resp.Status != 200 && resp.Status != 502 && resp.Status != 503 { + t.Fatalf("status = %d, body = %s", resp.Status, string(resp.Body)) + } +} + +func TestMeetRoomOptional(t *testing.T) { + integrationtest.SkipUnlessJitsi(t) + h := integrationtest.RequireHarness(t) + client, _ := integrationtest.RequireUserClient(t, h) + + resp, err := client.Post("/api/v1/meet/rooms", map[string]string{ + "subject": "Integration test room", + }) + integrationtest.FailIf(err, t, "create meet room") + integrationtest.FailUnlessStatus(t, resp, 201) +} diff --git a/internal/integrationtest/suite/suite_test.go b/internal/integrationtest/suite/suite_test.go new file mode 100644 index 0000000..7a6ba99 --- /dev/null +++ b/internal/integrationtest/suite/suite_test.go @@ -0,0 +1,14 @@ +//go:build integration + +package suite_test + +import ( + "os" + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestMain(m *testing.M) { + os.Exit(integrationtest.RunMain(m)) +} diff --git a/internal/integrationtest/suite_test.go b/internal/integrationtest/suite_test.go new file mode 100644 index 0000000..3a40ff1 --- /dev/null +++ b/internal/integrationtest/suite_test.go @@ -0,0 +1,12 @@ +//go:build integration + +package integrationtest + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + os.Exit(RunMain(m)) +} diff --git a/internal/integrationtest/user.go b/internal/integrationtest/user.go new file mode 100644 index 0000000..11d43ef --- /dev/null +++ b/internal/integrationtest/user.go @@ -0,0 +1,155 @@ +//go:build integration + +package integrationtest + +import ( + "context" + "testing" + + "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/permission" + "github.com/ultisuite/ulti-backend/internal/users" +) + +// RegularUser returns claims for a standard suite user. +func RegularUser(externalID string) *auth.Claims { + return &auth.Claims{ + Sub: externalID, + Email: externalID + "@test.ultimail.local", + Name: "Test User", + Groups: []string{string(permission.RoleUser)}, + } +} + +// AdminUser returns claims for a platform admin. +func AdminUser(externalID string) *auth.Claims { + return &auth.Claims{ + Sub: externalID, + Email: externalID + "@admin.ultimail.local", + Name: "Test Admin", + Groups: []string{string(permission.RoleAdmin), "admin:write"}, + } +} + +// EnsureUser provisions a user row directly (optional shortcut). +func EnsureUser(ctx context.Context, h *Harness, claims *auth.Claims) (string, error) { + return users.EnsureUser(ctx, h.Pool, claims) +} + +// DisableUser marks a user as disabled in the database. +func DisableUser(ctx context.Context, h *Harness, externalID string) error { + _, err := h.Pool.Exec(ctx, ` + UPDATE users SET status = 'disabled', updated_at = NOW() + WHERE external_id = $1 + `, externalID) + return err +} + +// RequireUserClient creates a regular user client, failing the test on error. +func RequireUserClient(t *testing.T, h *Harness) (*Client, *auth.Claims) { + t.Helper() + claims := RegularUser(NewExternalID("user")) + client, err := h.Client(claims) + if err != nil { + t.Fatalf("client: %v", err) + } + return client, claims +} + +// RequireAdminClient creates an admin user client. +func RequireAdminClient(t *testing.T, h *Harness) (*Client, *auth.Claims) { + t.Helper() + claims := AdminUser(NewExternalID("admin")) + client, err := h.Client(claims) + if err != nil { + t.Fatalf("admin client: %v", err) + } + return client, claims +} + +// SkipUnlessNextcloud skips the test when Nextcloud integration is not enabled. +func SkipUnlessNextcloud(t *testing.T) { + t.Helper() + env := LoadEnv() + if !env.Nextcloud || env.NextcloudURL == "" { + t.Skip("skipped: set ULTI_TEST_NEXTCLOUD=1 and ULTI_TEST_NEXTCLOUD_URL") + } +} + +// SkipUnlessImmich skips when Immich tests are not enabled. +func SkipUnlessImmich(t *testing.T) { + t.Helper() + env := LoadEnv() + if !env.Immich || env.ImmichURL == "" { + t.Skip("skipped: set ULTI_TEST_IMMICH=1 and ULTI_TEST_IMMICH_URL") + } +} + +// SkipUnlessJitsi skips when Jitsi tests are not enabled. +func SkipUnlessJitsi(t *testing.T) { + t.Helper() + if !LoadEnv().Jitsi { + t.Skip("skipped: set ULTI_TEST_JITSI=1") + } +} + +// AssertUserProvisioned verifies the user exists after an API call. +func AssertUserProvisioned(t *testing.T, h *Harness, externalID string) { + t.Helper() + var id string + err := h.Pool.QueryRow(context.Background(), ` + SELECT id FROM users WHERE external_id = $1 + `, externalID).Scan(&id) + if err != nil { + t.Fatalf("user %q not provisioned: %v", externalID, err) + } + if id == "" { + t.Fatalf("empty user id for %q", externalID) + } +} + +func AssertErrorCode(t *testing.T, resp *Response, wantStatus int, wantCode string) { + t.Helper() + if resp.Status != wantStatus { + t.Fatalf("status = %d, want %d; body = %s", resp.Status, wantStatus, string(resp.Body)) + } + var body struct { + Code string `json:"code"` + } + if err := resp.JSON(&body); err != nil { + t.Fatalf("decode error body: %v", err) + } + if wantCode != "" && body.Code != wantCode { + t.Fatalf("error code = %q, want %q; body = %s", body.Code, wantCode, string(resp.Body)) + } +} + +func FailIf(err error, t *testing.T, msg string) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", msg, err) + } +} + +func FailUnlessStatus(t *testing.T, resp *Response, want int) { + t.Helper() + if err := resp.MustStatus(want); err != nil { + t.Fatal(err) + } +} + +// DecodeJSON decodes response body or fails the test. +func DecodeJSON(t *testing.T, resp *Response, v any) { + t.Helper() + if err := resp.JSON(v); err != nil { + t.Fatalf("decode json: %v; body = %s", err, string(resp.Body)) + } +} + +// FormatSub returns a readable subject for error messages. +func FormatSub(sub string) string { + if sub == "" { + return "" + } + return sub +} diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go new file mode 100644 index 0000000..4198f1f --- /dev/null +++ b/internal/server/bootstrap.go @@ -0,0 +1,352 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/redis/go-redis/v9" + + "github.com/ultisuite/ulti-backend/internal/api/admin" + "github.com/ultisuite/ulti-backend/internal/api/calendar" + "github.com/ultisuite/ulti-backend/internal/api/contacts" + "github.com/ultisuite/ulti-backend/internal/api/docs" + "github.com/ultisuite/ulti-backend/internal/api/drive" + mailapi "github.com/ultisuite/ulti-backend/internal/api/mail" + "github.com/ultisuite/ulti-backend/internal/api/mail/drivebridge" + "github.com/ultisuite/ulti-backend/internal/api/mail/sendguard" + meetapi "github.com/ultisuite/ulti-backend/internal/api/meet" + "github.com/ultisuite/ulti-backend/internal/api/middleware" + "github.com/ultisuite/ulti-backend/internal/api/office" + photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" + "github.com/ultisuite/ulti-backend/internal/automation" + "github.com/ultisuite/ulti-backend/internal/authentik" + "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/config" + "github.com/ultisuite/ulti-backend/internal/httpcors" + mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials" + imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap" + mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth" + "github.com/ultisuite/ulti-backend/internal/mail/rules" + "github.com/ultisuite/ulti-backend/internal/mail/smtp" + mailstorage "github.com/ultisuite/ulti-backend/internal/mail/storage" + "github.com/ultisuite/ulti-backend/internal/mail/webhooks" + "github.com/ultisuite/ulti-backend/internal/meet" + "github.com/ultisuite/ulti-backend/internal/nextcloud" + "github.com/ultisuite/ulti-backend/internal/observability" + "github.com/ultisuite/ulti-backend/internal/photos" + "github.com/ultisuite/ulti-backend/internal/realtime" + "github.com/ultisuite/ulti-backend/internal/search" + "github.com/ultisuite/ulti-backend/internal/securityaudit" +) + +// App holds wired dependencies and the HTTP router. +type App struct { + Router chi.Router + Pool *pgxpool.Pool + Redis *redis.Client + VerifierHolder *auth.Holder + MailOAuth *mailoauth.Service + + ownsPool bool + ownsRedis bool + cancel context.CancelFunc +} + +// New builds the application router and optional background workers. +func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { + if cfg == nil { + return nil, fmt.Errorf("config is nil") + } + + workerCtx, cancel := context.WithCancel(ctx) + + pool := opts.Pool + ownsPool := false + if pool == nil { + var err error + pool, err = pgxpool.New(workerCtx, cfg.DatabaseURL) + if err != nil { + cancel() + return nil, fmt.Errorf("connect database: %w", err) + } + ownsPool = true + } + + if !opts.SkipAuthentikProvisioner { + authentik.StartProvisioner(workerCtx, pool, cfg) + } + + rdb := opts.Redis + ownsRedis := false + if rdb == nil { + rdb = redis.NewClient(&redis.Options{ + Addr: cfg.KeyDBAddr, + Password: cfg.KeyDBPassword, + DB: cfg.KeyDBDB, + }) + ownsRedis = true + } + + minioClient, err := minio.New(cfg.RustFSEndpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.RustFSAccessKey, cfg.RustFSSecretKey, ""), + Secure: cfg.RustFSUseSSL, + }) + if err != nil { + cancel() + if ownsPool { + pool.Close() + } + if ownsRedis { + _ = rdb.Close() + } + return nil, fmt.Errorf("rustfs client: %w", err) + } + attachmentStorage := mailstorage.NewClient(minioClient, cfg.MailAttachmentsBucket) + if err := attachmentStorage.EnsureBucket(workerCtx); err != nil { + slog.Warn("mail attachments bucket check failed", "error", err) + } + + verifierHolder := opts.VerifierHolder + if verifierHolder == nil { + verifier, verr := auth.NewVerifierWithRetry(workerCtx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second) + if verr != nil { + slog.Warn("OIDC verifier not available at startup (Authentik may still be starting)", "error", verr) + } + verifierHolder = auth.NewHolder(verifier) + if !verifierHolder.Ready() && cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" { + pending := auth.NewHolderPending(cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain) + verifierHolder = pending + verifierHolder.StartBackgroundRetry(workerCtx, 5*time.Second) + } + } + + if cfg.IsProduction() { + if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" { + cancel() + closeOwned(ownsPool, pool, ownsRedis, rdb) + return nil, fmt.Errorf("missing required OIDC configuration in production") + } + if !verifierHolder.Ready() { + cancel() + closeOwned(ownsPool, pool, ownsRedis, rdb) + return nil, fmt.Errorf("OIDC verifier initialization failed in production") + } + if err := cfg.ValidateSecretRotation(); err != nil { + cancel() + closeOwned(ownsPool, pool, ownsRedis, rdb) + return nil, fmt.Errorf("secret rotation policy: %w", err) + } + } else if err := cfg.ValidateSecretRotation(); err != nil { + slog.Warn("secret rotation policy warning", "error", err) + } + + credentialManager, err := mailcredentials.NewManager(cfg.MailCredentialKeys, cfg.MailActiveCredentialKeyID) + if err != nil { + cancel() + closeOwned(ownsPool, pool, ownsRedis, rdb) + return nil, fmt.Errorf("mail credential encryption: %w", err) + } + + auditLogger := securityaudit.NewLogger(pool) + + var ncClient *nextcloud.Client + if cfg.NextcloudEnabled { + ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass). + WithPublicURL(cfg.NextcloudPublicURL). + WithDrivePublicURL(cfg.DrivePublicURL). + WithDAVCredentials(nextcloud.NewDAVCredentialStore(pool, credentialManager)) + slog.Info("nextcloud enabled", "url", cfg.NextcloudURL, "public_url", cfg.NextcloudPublicURL, "drive_public_url", cfg.DrivePublicURL) + } + + var meetCfg *meet.Config + if cfg.JitsiEnabled { + meetCfg = meet.NewConfig(cfg.JitsiAppID, cfg.JitsiAppSecret, cfg.Domain) + slog.Info("jitsi meet enabled", "domain", cfg.JitsiDomain) + } + + var photosClient *photos.Client + if cfg.ImmichEnabled { + photosClient = photos.NewClient(cfg.ImmichAPIURL) + slog.Info("immich photos enabled", "url", cfg.ImmichAPIURL) + } + + hub := realtime.NewHub(verifierHolder, pool) + healthChecker := observability.NewHealthChecker(cfg, pool, rdb) + + hookExec := webhooks.NewExecutor(pool) + rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec) + autoDispatcher := automation.NewDispatcher(pool, rulesEngine, hookExec) + + oauthRedirect := cfg.MailOAuthRedirectURL + if oauthRedirect == "" { + oauthRedirect = fmt.Sprintf("http://localhost:%d/api/v1/mail/accounts/oauth/callback", cfg.Port) + if cfg.Domain != "" && cfg.Domain != "localhost" { + oauthRedirect = fmt.Sprintf("https://%s/api/v1/mail/accounts/oauth/callback", cfg.Domain) + } + } + mailOAuthSvc := mailoauth.NewService(mailoauth.Config{ + GoogleClientID: cfg.MailGoogleOAuthClientID, + GoogleClientSecret: cfg.MailGoogleOAuthClientSecret, + MicrosoftClientID: cfg.MailMicrosoftOAuthClientID, + MicrosoftSecret: cfg.MailMicrosoftOAuthSecret, + MicrosoftTenant: cfg.MailMicrosoftOAuthTenant, + RedirectURL: oauthRedirect, + }, rdb) + + var syncWorker *imapsync.SyncWorker + if !opts.WithoutWorkers { + syncWorker = imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{ + Storage: attachmentStorage, + AttachBucket: cfg.MailAttachmentsBucket, + Automation: autoDispatcher, + Hub: hub, + }) + go syncWorker.Start(workerCtx) + } + + sender := smtp.NewSender(pool, credentialManager, mailOAuthSvc) + smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown) + guardedSender := smtp.NewGuardedSender(sender, smtpCircuit) + if !opts.WithoutWorkers { + go smtp.NewOutboxProcessor( + pool, + guardedSender, + cfg.MailOutboxInterval, + cfg.MailOutboxMaxRetries, + smtp.WithAttachmentLoader(&smtp.StorageAttachmentLoader{Client: attachmentStorage}), + ).Start(workerCtx) + } + + sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst) + mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL, sender) + if syncWorker != nil { + mailHandler.SetAccountSync(syncWorker) + } + + r := chi.NewRouter() + r.Use(httpcors.Middleware(cfg)) + r.Use(middleware.TraceID) + r.Use(observability.HTTPMetrics) + r.Use(middleware.Logging) + + r.Mount("/api/docs", docs.NewHandler().Routes()) + + r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) { + report := healthChecker.Check(req.Context()) + statusCode := http.StatusOK + if report.Status != "ok" { + statusCode = http.StatusServiceUnavailable + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(report) + }) + r.Handle("/metrics", promhttp.Handler()) + + r.Get("/ws", hub.HandleWS) + r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback) + + var driveHandler *drive.Handler + var driveSvc *drive.Service + var contactsHandler *contacts.Handler + if ncClient != nil { + driveSvc = drive.NewService(ncClient, hub, pool) + driveSvc.SetAutomation(autoDispatcher) + driveHandler = drive.NewHandlerWithService(driveSvc) + mailHandler.SetDriveUploader(&drivebridge.Bridge{Svc: driveSvc}) + contactsHandler = contacts.NewHandler(ncClient, pool) + contactsHandler.SetAutomation(autoDispatcher) + } + if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil { + officeSvc := office.NewService(ncClient, office.Config{ + Enabled: true, + DocumentURL: cfg.OnlyOfficeURL, + PublicURL: cfg.OnlyOfficePublicURL, + APIInternalURL: cfg.OnlyOfficeAPIInternalURL, + JWTSecret: cfg.OnlyOfficeJWTSecret, + }) + officeHandler := office.NewHandler(officeSvc, driveSvc) + r.Mount("/api/v1/office", officeHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger))) + driveHandler.SetPublicOffice(officeHandler) + } + if driveHandler != nil { + r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes()) + } + + r.Group(func(r chi.Router) { + r.Use(middleware.Auth(verifierHolder, pool, auditLogger)) + r.Use(middleware.EnforceApiTokenPolicy()) + + r.Mount("/api/v1/mail", mailHandler.Routes()) + r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes()) + r.Get("/api/v1/search", search.NewHandler(pool, search.Options{ + Nextcloud: ncClient, + Engine: cfg.SearchEngine, + MeilisearchURL: cfg.MeilisearchURL, + MeilisearchKey: cfg.MeilisearchKey, + MeilisearchIndex: cfg.MeilisearchIndex, + TypesenseURL: cfg.TypesenseURL, + TypesenseKey: cfg.TypesenseKey, + TypesenseCollection: cfg.TypesenseCollection, + }).Search) + + if driveHandler != nil { + r.Mount("/api/v1/drive", driveHandler.Routes()) + r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes()) + r.Mount("/api/v1/contacts", contactsHandler.Routes()) + } + if meetCfg != nil { + r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes()) + } + if photosClient != nil { + r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes()) + } + }) + + slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect) + + return &App{ + Router: r, + Pool: pool, + Redis: rdb, + VerifierHolder: verifierHolder, + MailOAuth: mailOAuthSvc, + ownsPool: ownsPool, + ownsRedis: ownsRedis, + cancel: cancel, + }, nil +} + +// Close stops workers and releases owned connections. +func (a *App) Close() { + if a == nil { + return + } + if a.cancel != nil { + a.cancel() + } + if a.ownsPool && a.Pool != nil { + a.Pool.Close() + } + if a.ownsRedis && a.Redis != nil { + _ = a.Redis.Close() + } +} + +func closeOwned(ownsPool bool, pool *pgxpool.Pool, ownsRedis bool, rdb *redis.Client) { + if ownsPool && pool != nil { + pool.Close() + } + if ownsRedis && rdb != nil { + _ = rdb.Close() + } +} diff --git a/internal/server/options.go b/internal/server/options.go new file mode 100644 index 0000000..ba9e893 --- /dev/null +++ b/internal/server/options.go @@ -0,0 +1,22 @@ +package server + +import ( + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + + "github.com/ultisuite/ulti-backend/internal/auth" +) + +// Options tune server bootstrap for production vs integration tests. +type Options struct { + // WithoutWorkers skips IMAP sync and SMTP outbox background goroutines. + WithoutWorkers bool + // SkipAuthentikProvisioner disables Authentik suite app provisioning. + SkipAuthentikProvisioner bool + // VerifierHolder overrides OIDC verifier setup (integration tests inject a test issuer). + VerifierHolder *auth.Holder + // Pool overrides database pool creation (integration tests may pre-connect). + Pool *pgxpool.Pool + // Redis overrides KeyDB client creation (integration tests use miniredis). + Redis *redis.Client +} diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh new file mode 100755 index 0000000..fbe93c5 --- /dev/null +++ b/scripts/test-integration.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +export ULTI_TEST_INTEGRATION=1 + +if [[ -f .env.test ]]; then + set -a + # shellcheck disable=SC1091 + source .env.test + set +a +fi + +if [[ $# -eq 0 ]]; then + exec go test -tags=integration ./internal/integrationtest/... -count=1 -timeout=10m +fi + +exec go test -tags=integration -count=1 -timeout=10m "$@"