package bootstrap import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/testsupport" "context" "log" "os" "path/filepath" "testing" "time" "github.com/testcontainers/testcontainers-go" postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" gorm_postgres "gorm.io/driver/postgres" "gorm.io/gorm" ) func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) { configs, err := loadSeedTenantConfigs() if err != nil { t.Fatalf("loadSeedTenantConfigs returned error: %v", err) } expected := []struct { name string slug string tenantType string parentSlug string domains []string }{ { name: "한맥가족", slug: "hanmac-family", tenantType: domain.TenantTypeCompanyGroup, }, { name: "삼안", slug: "saman", tenantType: domain.TenantTypeCompany, parentSlug: "hanmac-family", domains: []string{"samaneng.com"}, }, { name: "한맥기술", slug: "hanmac", tenantType: domain.TenantTypeCompany, parentSlug: "hanmac-family", domains: []string{"hanmaceng.co.kr"}, }, { name: "총괄기획&기술개발센터", slug: "gpdtdc", tenantType: domain.TenantTypeCompany, parentSlug: "hanmac-family", domains: []string{"baroncs.co.kr"}, }, { name: "바론그룹", slug: "baron-group", tenantType: domain.TenantTypeCompanyGroup, parentSlug: "hanmac-family", domains: []string{"brsw.kr"}, }, { name: "(주)장헌", slug: "jangheon", tenantType: domain.TenantTypeCompany, parentSlug: "baron-group", domains: []string{"jangheon.com"}, }, { name: "장헌산업", slug: "jangheon-sanup", tenantType: domain.TenantTypeCompany, parentSlug: "baron-group", domains: []string{"jangheon.co.kr"}, }, { name: "한라산업개발", slug: "halla", tenantType: domain.TenantTypeCompany, parentSlug: "hanmac-family", domains: []string{"hallasanup.com"}, }, { name: "(주)피티씨", slug: "ptc", tenantType: domain.TenantTypeCompany, parentSlug: "baron-group", domains: []string{"pre-cast.co.kr"}, }, { name: "Personal", slug: "personal", tenantType: domain.TenantTypePersonal, }, } if len(configs) < len(expected) { t.Fatalf("expected at least %d seed tenants, got %d", len(expected), len(configs)) } wantFamilyChildOrder := []string{ "gpdtdc", "saman", "hanmac", "baron-group", "halla", } policyFamilyChildSlugs := map[string]bool{} for _, slug := range wantFamilyChildOrder { policyFamilyChildSlugs[slug] = true } gotFamilyChildOrder := make([]string, 0, len(wantFamilyChildOrder)) for _, config := range configs { if config.ParentSlug == "hanmac-family" && policyFamilyChildSlugs[config.Slug] { gotFamilyChildOrder = append(gotFamilyChildOrder, config.Slug) } } if len(gotFamilyChildOrder) != len(wantFamilyChildOrder) { t.Fatalf("hanmac-family child order = %#v, want %#v", gotFamilyChildOrder, wantFamilyChildOrder) } for i, wantSlug := range wantFamilyChildOrder { if gotFamilyChildOrder[i] != wantSlug { t.Fatalf("hanmac-family child order[%d] = %q, want %q", i, gotFamilyChildOrder[i], wantSlug) } } configBySlug := make(map[string]InitialTenantConfig, len(configs)) for _, config := range configs { configBySlug[config.Slug] = config } for _, want := range expected { got, ok := configBySlug[want.slug] if !ok { t.Fatalf("tenant slug %q not found in seed configs", want.slug) } if got.Name != want.name { t.Fatalf("tenant[%s] name = %q, want %q", want.slug, got.Name, want.name) } if got.Slug != want.slug { t.Fatalf("tenant[%s] slug = %q, want %q", want.slug, got.Slug, want.slug) } if got.Type != want.tenantType { t.Fatalf("tenant[%s] type = %q, want %q", want.slug, got.Type, want.tenantType) } if got.ParentSlug != want.parentSlug { t.Fatalf("tenant[%s] parent slug = %q, want %q", want.slug, got.ParentSlug, want.parentSlug) } if len(got.Domains) != len(want.domains) { t.Fatalf("tenant[%s] domains = %#v, want %#v", want.slug, got.Domains, want.domains) } for j, wantDomain := range want.domains { if got.Domains[j] != wantDomain { t.Fatalf("tenant[%s] domain[%d] = %q, want %q", want.slug, j, got.Domains[j], wantDomain) } } } } func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) { if got := normalizeSeedTenantType("organization"); got != domain.TenantTypeOrganization { t.Fatalf("normalizeSeedTenantType(organization) = %q, want %q", got, domain.TenantTypeOrganization) } } func TestLoadSeedTenantConfigsUsesConfiguredCSVPath(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "seed-tenant.csv") csv := "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n" + "Root,COMPANY_GROUP,,root,Root memo,,,,\n" + "Child,USER_GROUP,root,child,Child memo,child.example.com,private,팀,no\n" if err := os.WriteFile(path, []byte(csv), 0o600); err != nil { t.Fatalf("failed to write seed csv: %v", err) } t.Setenv(seedTenantCSVPathEnv, path) configs, err := loadSeedTenantConfigs() if err != nil { t.Fatalf("loadSeedTenantConfigs returned error: %v", err) } if len(configs) != 2 { t.Fatalf("expected 2 configs, got %d", len(configs)) } if configs[1].ParentSlug != "root" { t.Fatalf("child parent slug = %q, want root", configs[1].ParentSlug) } if len(configs[1].Domains) != 1 || configs[1].Domains[0] != "child.example.com" { t.Fatalf("child domains = %#v, want child.example.com", configs[1].Domains) } if configs[1].Config["visibility"] != "private" { t.Fatalf("child visibility = %#v, want private", configs[1].Config["visibility"]) } if configs[1].Config["orgUnitType"] != "팀" { t.Fatalf("child orgUnitType = %#v, want 팀", configs[1].Config["orgUnitType"]) } if configs[1].Config["worksmobileExcluded"] != true { t.Fatalf("child worksmobileExcluded = %#v, want true", configs[1].Config["worksmobileExcluded"]) } } func TestSeedTenantCSVDefinesMHDAsPrivateUserGroup(t *testing.T) { configs, err := loadSeedTenantConfigs() if err != nil { t.Fatalf("loadSeedTenantConfigs returned error: %v", err) } configBySlug := make(map[string]InitialTenantConfig, len(configs)) for _, config := range configs { configBySlug[config.Slug] = config } mhd, ok := configBySlug["mhd"] if !ok { t.Fatal("mhd seed tenant not found") } if mhd.Type != domain.TenantTypeUserGroup { t.Fatalf("mhd type = %q, want %q", mhd.Type, domain.TenantTypeUserGroup) } if mhd.Config["visibility"] != "private" { t.Fatalf("mhd visibility = %#v, want private", mhd.Config["visibility"]) } if mhd.Config["worksmobileExcluded"] != true { t.Fatalf("mhd worksmobileExcluded = %#v, want true", mhd.Config["worksmobileExcluded"]) } } func TestIsSeedTenantSlugUsesConfiguredCSVPath(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "seed-tenant.csv") csv := "name,type,parent_tenant_slug,slug,memo,email_domain\n" + "Root,COMPANY_GROUP,,protected-root,Root memo,\n" if err := os.WriteFile(path, []byte(csv), 0o600); err != nil { t.Fatalf("failed to write seed csv: %v", err) } t.Setenv(seedTenantCSVPathEnv, path) if !IsSeedTenantSlug("protected-root") { t.Fatal("protected-root must be detected as seed tenant") } if IsSeedTenantSlug("normal-tenant") { t.Fatal("normal-tenant must not be detected as seed tenant") } } func TestFilterMissingSeedTenantConfigsSkipsExistingSlugs(t *testing.T) { configs := []InitialTenantConfig{ {TenantID: "existing-root-id", Name: "Existing Root", Slug: "existing-root"}, {Name: "Missing Child", Slug: "missing-child", ParentSlug: "existing-root"}, {TenantID: "existing-child-id", Name: "Existing Child", Slug: "existing-child", ParentSlug: "existing-root"}, {TenantID: "existing-other-id", Name: "Conflicting ID", Slug: "new-slug"}, } existingSlugs := map[string]bool{ "existing-root": true, "existing-child": true, } existingIDs := map[string]bool{ "existing-root-id": true, "existing-child-id": true, "existing-other-id": true, } filtered := filterMissingSeedTenantConfigs(configs, existingSlugs, existingIDs) if len(filtered) != 1 { t.Fatalf("filtered count = %d, want 1: %#v", len(filtered), filtered) } if filtered[0].Slug != "missing-child" { t.Fatalf("filtered slug = %q, want missing-child", filtered[0].Slug) } } func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testing.T) { if !testsupport.DockerAvailable() { t.Skip("Docker provider is unavailable in this environment") } ctx := context.Background() postgresContainer, err := postgres_module.Run(ctx, "postgres:16-alpine", postgres_module.WithDatabase("testdb"), postgres_module.WithUsername("user"), postgres_module.WithPassword("password"), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(30*time.Second), ), ) if err != nil { t.Fatalf("failed to start postgres container: %v", err) } t.Cleanup(func() { if err := postgresContainer.Terminate(ctx); err != nil { log.Printf("failed to terminate postgres container: %v", err) } }) connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatalf("failed to get postgres connection string: %v", err) } db, err := gorm.Open(gorm_postgres.Open(connStr), &gorm.Config{}) if err != nil { t.Fatalf("failed to open postgres connection: %v", err) } if err := db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.KetoOutbox{}); err != nil { t.Fatalf("failed to migrate seed test tables: %v", err) } existingRoot := domain.Tenant{ ID: "00000000-0000-0000-0000-000000000001", Name: "Existing Root Name", Slug: "existing-root", Type: domain.TenantTypeCompanyGroup, Description: "manual tenant must not be overwritten", Status: domain.TenantStatusActive, } nonSeedTenant := domain.Tenant{ ID: "00000000-0000-0000-0000-000000000002", Name: "Manual Tenant", Slug: "manual-tenant", Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, } if err := db.Create(&existingRoot).Error; err != nil { t.Fatalf("failed to create existing root tenant: %v", err) } if err := db.Create(&nonSeedTenant).Error; err != nil { t.Fatalf("failed to create non-seed tenant: %v", err) } dir := t.TempDir() path := filepath.Join(dir, "seed-tenant.csv") csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" + "10000000-0000-0000-0000-000000000001,Seed Root Name,COMPANY_GROUP,,existing-root,seed must be skipped,\n" + "00000000-0000-0000-0000-000000000002,Conflicting ID,COMPANY,existing-root,conflicting-id,seed id must be skipped,\n" + "10000000-0000-0000-0000-000000000002,Missing Child,COMPANY,existing-root,missing-child,created from seed,child.example.com\n" if err := os.WriteFile(path, []byte(csv), 0o600); err != nil { t.Fatalf("failed to write seed csv: %v", err) } t.Setenv(seedTenantCSVPathEnv, path) if err := SeedTenants(db); err != nil { t.Fatalf("SeedTenants returned error: %v", err) } var root domain.Tenant if err := db.First(&root, "slug = ?", "existing-root").Error; err != nil { t.Fatalf("failed to load existing root after seed: %v", err) } if root.ID != existingRoot.ID { t.Fatalf("existing root ID = %q, want %q", root.ID, existingRoot.ID) } if root.Name != existingRoot.Name { t.Fatalf("existing root name = %q, want untouched %q", root.Name, existingRoot.Name) } var child domain.Tenant if err := db.Preload("Domains").First(&child, "slug = ?", "missing-child").Error; err != nil { t.Fatalf("missing seed child was not created: %v", err) } if child.ParentID == nil || *child.ParentID != existingRoot.ID { t.Fatalf("child parent ID = %v, want %q", child.ParentID, existingRoot.ID) } if len(child.Domains) != 1 || child.Domains[0].Domain != "child.example.com" { t.Fatalf("child domains = %#v, want child.example.com", child.Domains) } var rootCount int64 if err := db.Model(&domain.Tenant{}).Where("slug = ?", "existing-root").Count(&rootCount).Error; err != nil { t.Fatalf("failed to count existing root rows: %v", err) } if rootCount != 1 { t.Fatalf("existing-root row count = %d, want 1", rootCount) } var conflictingIDCount int64 if err := db.Model(&domain.Tenant{}).Where("slug = ?", "conflicting-id").Count(&conflictingIDCount).Error; err != nil { t.Fatalf("failed to count conflicting-id rows: %v", err) } if conflictingIDCount != 0 { t.Fatalf("conflicting-id row count = %d, want 0", conflictingIDCount) } }