- Added endpoints for listing and importing migration rosters. - Introduced audit export functionality for migration jobs in CSV and NDJSON formats. - Implemented tenant mismatch validation for Microsoft migration claims. - Enhanced error handling for email claiming and migration processes. - Added integration tests for roster import and claim workflows.
291 lines
7.4 KiB
Go
291 lines
7.4 KiB
Go
//go:build integration
|
|
|
|
package integrationtest
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/minio/minio-go/v7"
|
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
"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"
|
|
mailstorage "github.com/ultisuite/ulti-backend/internal/mail/storage"
|
|
)
|
|
|
|
// 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
|
|
AttachmentStorage *mailstorage.Client
|
|
AttachmentsBucket string
|
|
}
|
|
|
|
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)
|
|
|
|
minioClient, err := minio.New(cfg.RustFSEndpoint, &minio.Options{
|
|
Creds: credentials.NewStaticV4(cfg.RustFSAccessKey, cfg.RustFSSecretKey, ""),
|
|
Secure: cfg.RustFSUseSSL,
|
|
})
|
|
if err != nil {
|
|
pool.Close()
|
|
_ = rdb.Close()
|
|
return nil, fmt.Errorf("rustfs client: %w", err)
|
|
}
|
|
attachmentStorage := mailstorage.NewClient(minioClient, cfg.MailAttachmentsBucket)
|
|
if err := attachmentStorage.EnsureBucket(ctx); err != nil {
|
|
slog.Warn("mail attachments bucket check failed", "error", err)
|
|
}
|
|
|
|
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,
|
|
AttachmentStorage: attachmentStorage,
|
|
AttachmentsBucket: cfg.MailAttachmentsBucket,
|
|
}, 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:3004",
|
|
ProvisionWebhookSecret: "test-provision-secret",
|
|
PlatformMailDomain: "ultisuite.local",
|
|
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
|
|
}
|