feat(tests): add integration testing framework and configuration
- Introduced a new `.env.test.example` file for integration test configuration. - Added a `Makefile` to streamline test commands for unit and integration tests. - Implemented an integration testing harness with support for PostgreSQL, MinIO, and Redis using testcontainers. - Created a suite of integration tests covering health checks and user management functionalities. - Enhanced CI workflow to include integration tests with necessary environment variables.
This commit is contained in:
parent
082cac36b2
commit
fa5394e10d
36
.env.test.example
Normal file
36
.env.test.example
Normal file
@ -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
|
||||
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
@ -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/...
|
||||
19
README.md
19
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
|
||||
|
||||
@ -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)
|
||||
|
||||
72
go.mod
72
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
|
||||
)
|
||||
|
||||
193
go.sum
193
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=
|
||||
|
||||
14
internal/integrationtest/admin/suite_test.go
Normal file
14
internal/integrationtest/admin/suite_test.go
Normal file
@ -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))
|
||||
}
|
||||
57
internal/integrationtest/admin/users_test.go
Normal file
57
internal/integrationtest/admin/users_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
45
internal/integrationtest/auth/provision_test.go
Normal file
45
internal/integrationtest/auth/provision_test.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
14
internal/integrationtest/auth/suite_test.go
Normal file
14
internal/integrationtest/auth/suite_test.go
Normal file
@ -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))
|
||||
}
|
||||
93
internal/integrationtest/client.go
Normal file
93
internal/integrationtest/client.go
Normal file
@ -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}
|
||||
}
|
||||
118
internal/integrationtest/containers.go
Normal file
118
internal/integrationtest/containers.go
Normal file
@ -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://")
|
||||
}
|
||||
81
internal/integrationtest/env.go
Normal file
81
internal/integrationtest/env.go
Normal file
@ -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
|
||||
}
|
||||
266
internal/integrationtest/harness.go
Normal file
266
internal/integrationtest/harness.go
Normal file
@ -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
|
||||
}
|
||||
86
internal/integrationtest/mail/accounts_test.go
Normal file
86
internal/integrationtest/mail/accounts_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
59
internal/integrationtest/mail/api_tokens_test.go
Normal file
59
internal/integrationtest/mail/api_tokens_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
61
internal/integrationtest/mail/drafts_test.go
Normal file
61
internal/integrationtest/mail/drafts_test.go
Normal file
@ -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)
|
||||
}
|
||||
78
internal/integrationtest/mail/labels_folders_test.go
Normal file
78
internal/integrationtest/mail/labels_folders_test.go
Normal file
@ -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)
|
||||
}
|
||||
77
internal/integrationtest/mail/rules_webhooks_test.go
Normal file
77
internal/integrationtest/mail/rules_webhooks_test.go
Normal file
@ -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)
|
||||
}
|
||||
43
internal/integrationtest/mail/settings_test.go
Normal file
43
internal/integrationtest/mail/settings_test.go
Normal file
@ -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"])
|
||||
}
|
||||
}
|
||||
14
internal/integrationtest/mail/suite_test.go
Normal file
14
internal/integrationtest/mail/suite_test.go
Normal file
@ -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))
|
||||
}
|
||||
107
internal/integrationtest/oidc.go
Normal file
107
internal/integrationtest/oidc.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
35
internal/integrationtest/search/search_test.go
Normal file
35
internal/integrationtest/search/search_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
14
internal/integrationtest/search/suite_test.go
Normal file
14
internal/integrationtest/search/suite_test.go
Normal file
@ -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))
|
||||
}
|
||||
51
internal/integrationtest/smoke_test.go
Normal file
51
internal/integrationtest/smoke_test.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
45
internal/integrationtest/suite/optional_test.go
Normal file
45
internal/integrationtest/suite/optional_test.go
Normal file
@ -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)
|
||||
}
|
||||
14
internal/integrationtest/suite/suite_test.go
Normal file
14
internal/integrationtest/suite/suite_test.go
Normal file
@ -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))
|
||||
}
|
||||
12
internal/integrationtest/suite_test.go
Normal file
12
internal/integrationtest/suite_test.go
Normal file
@ -0,0 +1,12 @@
|
||||
//go:build integration
|
||||
|
||||
package integrationtest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(RunMain(m))
|
||||
}
|
||||
155
internal/integrationtest/user.go
Normal file
155
internal/integrationtest/user.go
Normal file
@ -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 "<empty>"
|
||||
}
|
||||
return sub
|
||||
}
|
||||
352
internal/server/bootstrap.go
Normal file
352
internal/server/bootstrap.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
22
internal/server/options.go
Normal file
22
internal/server/options.go
Normal file
@ -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
|
||||
}
|
||||
20
scripts/test-integration.sh
Executable file
20
scripts/test-integration.sh
Executable file
@ -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 "$@"
|
||||
Loading…
Reference in New Issue
Block a user