- 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.
288 lines
9.6 KiB
Go
288 lines
9.6 KiB
Go
//go:build integration
|
|
|
|
package migration_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/integrationtest"
|
|
"github.com/ultisuite/ulti-backend/internal/migration"
|
|
"github.com/ultisuite/ulti-backend/internal/users"
|
|
)
|
|
|
|
const testProvisionSecret = "test-provision-secret"
|
|
|
|
func postProvision(t *testing.T, h *integrationtest.Harness, body map[string]any) *integrationtest.Response {
|
|
t.Helper()
|
|
data, err := json.Marshal(body)
|
|
integrationtest.FailIf(err, t, "marshal provision body")
|
|
req, err := http.NewRequest(http.MethodPost, h.Server.URL+"/internal/provision/user", bytes.NewReader(data))
|
|
integrationtest.FailIf(err, t, "provision request")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Provision-Secret", testProvisionSecret)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
integrationtest.FailIf(err, t, "provision call")
|
|
defer resp.Body.Close()
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
integrationtest.FailIf(err, t, "read provision response")
|
|
return &integrationtest.Response{Status: resp.StatusCode, Body: bodyBytes, Header: resp.Header}
|
|
}
|
|
|
|
func TestProvisionEnrollThenClaim(t *testing.T) {
|
|
h := integrationtest.RequireHarness(t)
|
|
ctx := context.Background()
|
|
|
|
adminClient, adminClaims := integrationtest.RequireAdminClient(t, h)
|
|
if _, err := users.EnsureUser(ctx, h.Pool, adminClaims); err != nil {
|
|
t.Fatalf("ensure admin: %v", err)
|
|
}
|
|
if err := users.GrantPlatformAdmin(ctx, h.Pool, adminClaims.Sub); err != nil {
|
|
t.Fatalf("grant admin: %v", err)
|
|
}
|
|
|
|
domainName := "enroll-claim-" + uuid.NewString()[:8] + ".test"
|
|
var domainID string
|
|
err := h.Pool.QueryRow(ctx, `
|
|
INSERT INTO mail_domains (name, status, is_platform_domain)
|
|
VALUES ($1, 'active', false)
|
|
RETURNING id::text
|
|
`, domainName).Scan(&domainID)
|
|
integrationtest.FailIf(err, t, "insert domain")
|
|
|
|
createResp, err := adminClient.Post("/api/v1/admin/migration/projects", map[string]any{
|
|
"name": "Enroll then claim",
|
|
"source_provider": "google",
|
|
"domain_id": domainID,
|
|
})
|
|
integrationtest.FailIf(err, t, "create project")
|
|
integrationtest.FailUnlessStatus(t, createResp, 201)
|
|
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
integrationtest.DecodeJSON(t, createResp, &created)
|
|
|
|
actResp, err := adminClient.Post("/api/v1/admin/migration/projects/"+created.ID+"/activate", nil)
|
|
integrationtest.FailIf(err, t, "activate project")
|
|
integrationtest.FailUnlessStatus(t, actResp, 200)
|
|
|
|
migrateeEmail := "user@" + domainName
|
|
inviteResp, err := adminClient.Post("/api/v1/admin/migration/projects/"+created.ID+"/invites", map[string]string{
|
|
"email": migrateeEmail,
|
|
})
|
|
integrationtest.FailIf(err, t, "create invite")
|
|
integrationtest.FailUnlessStatus(t, inviteResp, 201)
|
|
|
|
var invite struct {
|
|
Token string `json:"token"`
|
|
}
|
|
integrationtest.DecodeJSON(t, inviteResp, &invite)
|
|
|
|
externalID := integrationtest.NewExternalID("enroll-claim")
|
|
provisionResp := postProvision(t, h, map[string]any{
|
|
"email": migrateeEmail,
|
|
"name": "Migratee",
|
|
"password": "enroll-password-123",
|
|
"external_id": externalID,
|
|
})
|
|
if provisionResp.Status != 200 {
|
|
t.Fatalf("provision status = %d, want 200; body=%s", provisionResp.Status, string(provisionResp.Body))
|
|
}
|
|
|
|
migrateeClaims := integrationtest.RegularUser(externalID)
|
|
migrateeClaims.Email = migrateeEmail
|
|
migrateeClient, err := h.Client(migrateeClaims)
|
|
integrationtest.FailIf(err, t, "migratee client")
|
|
|
|
if _, err := users.EnsureUser(ctx, h.Pool, migrateeClaims); err != nil {
|
|
t.Fatalf("ensure migratee: %v", err)
|
|
}
|
|
|
|
claimResp, err := migrateeClient.Post("/api/v1/migration/claim", map[string]string{
|
|
"token": invite.Token,
|
|
"password": "claim-password-123",
|
|
})
|
|
integrationtest.FailIf(err, t, "claim invite")
|
|
integrationtest.FailUnlessStatus(t, claimResp, 200)
|
|
|
|
audit, err := migration.AuditProvisionByEmail(ctx, h.Pool, migrateeEmail)
|
|
integrationtest.FailIf(err, t, "audit provision")
|
|
if audit.Users != 1 {
|
|
t.Fatalf("users = %d, want 1", audit.Users)
|
|
}
|
|
if audit.Mailboxes != 1 {
|
|
t.Fatalf("mailboxes = %d, want 1", audit.Mailboxes)
|
|
}
|
|
if audit.MailAccounts != 1 {
|
|
t.Fatalf("mail_accounts = %d, want 1", audit.MailAccounts)
|
|
}
|
|
}
|
|
|
|
func TestProvisionClaimThenEnroll(t *testing.T) {
|
|
h := integrationtest.RequireHarness(t)
|
|
ctx := context.Background()
|
|
|
|
adminClient, adminClaims := integrationtest.RequireAdminClient(t, h)
|
|
if _, err := users.EnsureUser(ctx, h.Pool, adminClaims); err != nil {
|
|
t.Fatalf("ensure admin: %v", err)
|
|
}
|
|
if err := users.GrantPlatformAdmin(ctx, h.Pool, adminClaims.Sub); err != nil {
|
|
t.Fatalf("grant admin: %v", err)
|
|
}
|
|
|
|
domainName := "claim-enroll-" + uuid.NewString()[:8] + ".test"
|
|
var domainID string
|
|
err := h.Pool.QueryRow(ctx, `
|
|
INSERT INTO mail_domains (name, status, is_platform_domain)
|
|
VALUES ($1, 'active', false)
|
|
RETURNING id::text
|
|
`, domainName).Scan(&domainID)
|
|
integrationtest.FailIf(err, t, "insert domain")
|
|
|
|
createResp, err := adminClient.Post("/api/v1/admin/migration/projects", map[string]any{
|
|
"name": "Claim then enroll",
|
|
"source_provider": "google",
|
|
"domain_id": domainID,
|
|
})
|
|
integrationtest.FailIf(err, t, "create project")
|
|
integrationtest.FailUnlessStatus(t, createResp, 201)
|
|
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
integrationtest.DecodeJSON(t, createResp, &created)
|
|
|
|
actResp, err := adminClient.Post("/api/v1/admin/migration/projects/"+created.ID+"/activate", nil)
|
|
integrationtest.FailIf(err, t, "activate project")
|
|
integrationtest.FailUnlessStatus(t, actResp, 200)
|
|
|
|
migrateeEmail := "user@" + domainName
|
|
inviteResp, err := adminClient.Post("/api/v1/admin/migration/projects/"+created.ID+"/invites", map[string]string{
|
|
"email": migrateeEmail,
|
|
})
|
|
integrationtest.FailIf(err, t, "create invite")
|
|
integrationtest.FailUnlessStatus(t, inviteResp, 201)
|
|
|
|
var invite struct {
|
|
Token string `json:"token"`
|
|
}
|
|
integrationtest.DecodeJSON(t, inviteResp, &invite)
|
|
|
|
externalID := integrationtest.NewExternalID("claim-enroll")
|
|
migrateeClaims := integrationtest.RegularUser(externalID)
|
|
migrateeClaims.Email = migrateeEmail
|
|
migrateeClient, err := h.Client(migrateeClaims)
|
|
integrationtest.FailIf(err, t, "migratee client")
|
|
|
|
userID, err := users.EnsureUser(ctx, h.Pool, migrateeClaims)
|
|
integrationtest.FailIf(err, t, "ensure migratee")
|
|
|
|
claimResp, err := migrateeClient.Post("/api/v1/migration/claim", map[string]string{
|
|
"token": invite.Token,
|
|
"password": "claim-password-123",
|
|
})
|
|
integrationtest.FailIf(err, t, "claim invite")
|
|
integrationtest.FailUnlessStatus(t, claimResp, 200)
|
|
|
|
provisionResp := postProvision(t, h, map[string]any{
|
|
"email": migrateeEmail,
|
|
"name": "Migratee",
|
|
"password": "enroll-password-123",
|
|
"external_id": externalID,
|
|
})
|
|
if provisionResp.Status != 200 {
|
|
t.Fatalf("provision status = %d, want 200; body=%s", provisionResp.Status, string(provisionResp.Body))
|
|
}
|
|
|
|
var provisionBody struct {
|
|
UserID string `json:"user_id"`
|
|
}
|
|
integrationtest.DecodeJSON(t, provisionResp, &provisionBody)
|
|
if provisionBody.UserID != userID {
|
|
t.Fatalf("provision user_id = %q, want %q", provisionBody.UserID, userID)
|
|
}
|
|
|
|
audit, err := migration.AuditProvisionByEmail(ctx, h.Pool, migrateeEmail)
|
|
integrationtest.FailIf(err, t, "audit provision")
|
|
if audit.Users != 1 {
|
|
t.Fatalf("users = %d, want 1", audit.Users)
|
|
}
|
|
if audit.Mailboxes != 1 {
|
|
t.Fatalf("mailboxes = %d, want 1", audit.Mailboxes)
|
|
}
|
|
if audit.MailAccounts != 1 {
|
|
t.Fatalf("mail_accounts = %d, want 1", audit.MailAccounts)
|
|
}
|
|
}
|
|
|
|
func TestProvisionEnrollmentDefersMailboxForPendingInvite(t *testing.T) {
|
|
h := integrationtest.RequireHarness(t)
|
|
ctx := context.Background()
|
|
|
|
adminClient, adminClaims := integrationtest.RequireAdminClient(t, h)
|
|
if _, err := users.EnsureUser(ctx, h.Pool, adminClaims); err != nil {
|
|
t.Fatalf("ensure admin: %v", err)
|
|
}
|
|
if err := users.GrantPlatformAdmin(ctx, h.Pool, adminClaims.Sub); err != nil {
|
|
t.Fatalf("grant admin: %v", err)
|
|
}
|
|
|
|
platformEmail := "pending-" + uuid.NewString()[:8] + "@ultisuite.local"
|
|
createResp, err := adminClient.Post("/api/v1/admin/migration/projects", map[string]any{
|
|
"name": "Pending invite defer",
|
|
"source_provider": "google",
|
|
})
|
|
integrationtest.FailIf(err, t, "create project")
|
|
integrationtest.FailUnlessStatus(t, createResp, 201)
|
|
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
integrationtest.DecodeJSON(t, createResp, &created)
|
|
|
|
actResp, err := adminClient.Post("/api/v1/admin/migration/projects/"+created.ID+"/activate", nil)
|
|
integrationtest.FailIf(err, t, "activate project")
|
|
integrationtest.FailUnlessStatus(t, actResp, 200)
|
|
|
|
inviteResp, err := adminClient.Post("/api/v1/admin/migration/projects/"+created.ID+"/invites", map[string]string{
|
|
"email": platformEmail,
|
|
})
|
|
integrationtest.FailIf(err, t, "create invite")
|
|
integrationtest.FailUnlessStatus(t, inviteResp, 201)
|
|
|
|
externalID := integrationtest.NewExternalID("pending-invite")
|
|
provisionResp := postProvision(t, h, map[string]any{
|
|
"email": platformEmail,
|
|
"name": "Pending User",
|
|
"password": "enroll-password-123",
|
|
"external_id": externalID,
|
|
})
|
|
if provisionResp.Status != 200 {
|
|
t.Fatalf("provision status = %d, want 200; body=%s", provisionResp.Status, string(provisionResp.Body))
|
|
}
|
|
|
|
var provisionBody struct {
|
|
MailboxDeferred bool `json:"mailbox_deferred"`
|
|
}
|
|
integrationtest.DecodeJSON(t, provisionResp, &provisionBody)
|
|
if !provisionBody.MailboxDeferred {
|
|
t.Fatal("expected mailbox_deferred=true for pending invite enrollment")
|
|
}
|
|
|
|
audit, err := migration.AuditProvisionByEmail(ctx, h.Pool, platformEmail)
|
|
integrationtest.FailIf(err, t, "audit after deferred enroll")
|
|
if audit.Users != 1 {
|
|
t.Fatalf("users = %d, want 1", audit.Users)
|
|
}
|
|
if audit.Mailboxes != 0 {
|
|
t.Fatalf("mailboxes = %d, want 0 before claim", audit.Mailboxes)
|
|
}
|
|
}
|