diff --git a/internal/plugins/internal_test.go b/internal/plugins/internal_test.go new file mode 100644 index 00000000..4cdb0420 --- /dev/null +++ b/internal/plugins/internal_test.go @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package plugins + +import ( + "context" + "testing" + + "github.com/Azure/azqr/internal/models" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// mockInternalScanner is a minimal InternalPluginScanner implementation for tests. +type mockInternalScanner struct { + metadata PluginMetadata +} + +func (m *mockInternalScanner) Scan(_ context.Context, _ azcore.TokenCredential, _ map[string]string, _ *models.ScanParams) ([]ExternalPluginOutput, error) { + return nil, nil +} + +func (m *mockInternalScanner) GetMetadata() PluginMetadata { + return m.metadata +} + +// mockFlagProviderScanner additionally implements FlagProvider. +type mockFlagProviderScanner struct { + mockInternalScanner + flagRegistered bool +} + +func (m *mockFlagProviderScanner) RegisterFlags(cmd *cobra.Command) { + m.flagRegistered = true + cmd.Flags().String("custom-flag", "", "a plugin-specific flag") +} + +func TestRegisterInternalPlugin(t *testing.T) { + const name = "test-internal-register" + scanner := &mockInternalScanner{ + metadata: PluginMetadata{ + Name: name, + Version: "1.0.0", + Description: "internal test plugin", + Type: PluginTypeInternal, + }, + } + + RegisterInternalPlugin(name, scanner) + + // Retrievable via the internal plugin registry. + got, exists := GetInternalPlugin(name) + assert.True(t, exists) + assert.Equal(t, scanner, got) + + // Registered with the global plugin registry, with a command attached. + plugin, ok := GetRegistry().Get(name) + assert.True(t, ok) + assert.NotNil(t, plugin.Command) + assert.Equal(t, scanner, plugin.InternalScanner) + assert.Equal(t, "internal test plugin", plugin.Metadata.Description) +} + +func TestRegisterInternalPlugin_FlagProvider(t *testing.T) { + const name = "test-internal-flagprovider" + scanner := &mockFlagProviderScanner{ + mockInternalScanner: mockInternalScanner{ + metadata: PluginMetadata{ + Name: name, + Version: "1.0.0", + Description: "flag provider test plugin", + Type: PluginTypeInternal, + }, + }, + } + + RegisterInternalPlugin(name, scanner) + + assert.True(t, scanner.flagRegistered, "RegisterFlags should be invoked for FlagProvider scanners") + + plugin, ok := GetRegistry().Get(name) + assert.True(t, ok) + assert.NotNil(t, plugin.Command.Flags().Lookup("custom-flag")) +} + +func TestGetInternalPlugin_NotFound(t *testing.T) { + got, exists := GetInternalPlugin("does-not-exist") + assert.False(t, exists) + assert.Nil(t, got) +} + +func TestCreatePluginCommand(t *testing.T) { + cmd := createPluginCommand("sample", "sample description") + + assert.Equal(t, "sample", cmd.Use) + assert.Equal(t, "sample description", cmd.Short) + + // All standard scan flags must be present. + standardFlags := []string{ + "management-group-id", + "subscription-id", + "resource-group", + "xlsx", + "json", + "csv", + "stdout", + "output-name", + "mask", + "filters", + } + for _, f := range standardFlags { + assert.NotNilf(t, cmd.Flags().Lookup(f), "expected flag %q to be registered", f) + } +} diff --git a/internal/plugins/loader_test.go b/internal/plugins/loader_test.go new file mode 100644 index 00000000..10bd5315 --- /dev/null +++ b/internal/plugins/loader_test.go @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package plugins + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetPluginDirs(t *testing.T) { + dirs := getPluginDirs() + + assert.Len(t, dirs, 2) + assert.Equal(t, "./plugins", dirs[1]) + assert.True(t, filepath.IsAbs(dirs[0]) || dirs[0] != "", + "first plugin dir should resolve from the user home directory") + assert.Equal(t, filepath.Join(".azqr", "plugins"), filepath.Join(filepath.Base(filepath.Dir(dirs[0])), filepath.Base(dirs[0]))) +} + +func TestLoadAll_NoPluginDirsReturnsNil(t *testing.T) { + // Isolate from any real plugin directories. + t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) + + err := LoadAll() + assert.NoError(t, err) +} + +func TestLoadAll_RegistersDiscoveredPlugin(t *testing.T) { + // Isolate home so ~/.azqr/plugins cannot interfere. + t.Setenv("HOME", t.TempDir()) + + // Create a ./plugins directory with a valid YAML plugin relative to the cwd. + workDir := t.TempDir() + pluginDir := filepath.Join(workDir, "plugins") + if err := os.MkdirAll(pluginDir, 0750); err != nil { + t.Fatalf("failed to create plugin dir: %v", err) + } + + const pluginName = "loader-test-plugin" + yamlContent := `--- +name: ` + pluginName + ` +version: 2.1.0 +description: Loader discovery test plugin +queries: + - aprlGuid: loader-guid-001 + description: Loader test recommendation + query: | + resources + | project id, name +` + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(yamlContent), 0600); err != nil { + t.Fatalf("failed to write plugin file: %v", err) + } + + t.Chdir(workDir) + + if err := LoadAll(); err != nil { + t.Fatalf("LoadAll returned error: %v", err) + } + + plugin, ok := GetRegistry().Get(pluginName) + assert.True(t, ok, "discovered plugin should be registered") + assert.Equal(t, "2.1.0", plugin.Metadata.Version) + assert.Equal(t, PluginTypeYaml, plugin.Metadata.Type) +} diff --git a/internal/plugins/yaml_validation_test.go b/internal/plugins/yaml_validation_test.go new file mode 100644 index 00000000..321fbd38 --- /dev/null +++ b/internal/plugins/yaml_validation_test.go @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package plugins + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeYamlPlugin writes content to a temp file and returns its path. +func writeYamlPlugin(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "plugin.yaml") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("failed to write yaml plugin: %v", err) + } + return path +} + +func TestLoadYamlPlugin_MissingQuery(t *testing.T) { + // Query has neither inline 'query' nor 'queryFile'. + path := writeYamlPlugin(t, `--- +name: test-plugin +queries: + - aprlGuid: guid-001 + description: A recommendation +`) + _, _, err := LoadYamlPlugin(path) + if err == nil || !strings.Contains(err.Error(), "must have either 'query' or 'queryFile'") { + t.Fatalf("expected query-required error, got %v", err) + } +} + +func TestLoadYamlPlugin_MissingAprlGuid(t *testing.T) { + path := writeYamlPlugin(t, `--- +name: test-plugin +queries: + - description: A recommendation + query: | + resources | project id +`) + _, _, err := LoadYamlPlugin(path) + if err == nil || !strings.Contains(err.Error(), "aprlGuid") { + t.Fatalf("expected missing aprlGuid error, got %v", err) + } +} + +func TestLoadYamlPlugin_MissingDescription(t *testing.T) { + path := writeYamlPlugin(t, `--- +name: test-plugin +queries: + - aprlGuid: guid-001 + query: | + resources | project id +`) + _, _, err := LoadYamlPlugin(path) + if err == nil || !strings.Contains(err.Error(), "description") { + t.Fatalf("expected missing description error, got %v", err) + } +} + +func TestLoadYamlPlugin_MissingQueryFile(t *testing.T) { + // queryFile references a file that does not exist. + dir := t.TempDir() + path := filepath.Join(dir, "plugin.yaml") + content := `--- +name: test-plugin +queries: + - aprlGuid: guid-001 + description: A recommendation + queryFile: missing.kql +` + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("failed to write yaml plugin: %v", err) + } + + _, _, err := LoadYamlPlugin(path) + if err == nil || !strings.Contains(err.Error(), "failed to read query file") { + t.Fatalf("expected query file read error, got %v", err) + } +} + +func TestLoadYamlPlugin_ExternalQueryFileLoaded(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "query.kql"), []byte("resources | project id, name"), 0600); err != nil { + t.Fatalf("failed to write kql file: %v", err) + } + path := filepath.Join(dir, "plugin.yaml") + content := `--- +name: test-plugin +queries: + - aprlGuid: guid-001 + description: A recommendation + queryFile: query.kql +` + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("failed to write yaml plugin: %v", err) + } + + _, recs, err := LoadYamlPlugin(path) + if err != nil { + t.Fatalf("LoadYamlPlugin failed: %v", err) + } + if len(recs) != 1 { + t.Fatalf("expected 1 recommendation, got %d", len(recs)) + } + if !strings.Contains(recs[0].GraphQuery, "project id, name") { + t.Errorf("expected external kql content to populate GraphQuery, got %q", recs[0].GraphQuery) + } +} + +func TestLoadYamlPlugin_VersionDefaulting(t *testing.T) { + // Version omitted should default to 1.0.0. + path := writeYamlPlugin(t, `--- +name: test-plugin +queries: + - aprlGuid: guid-001 + description: A recommendation + query: | + resources | project id +`) + plugin, _, err := LoadYamlPlugin(path) + if err != nil { + t.Fatalf("LoadYamlPlugin failed: %v", err) + } + if plugin.Metadata.Version != "1.0.0" { + t.Errorf("expected default version 1.0.0, got %q", plugin.Metadata.Version) + } +} diff --git a/internal/scanners/plugins/carbon/emissions.go b/internal/scanners/plugins/carbon/emissions.go index 4a2b66e8..706e2393 100644 --- a/internal/scanners/plugins/carbon/emissions.go +++ b/internal/scanners/plugins/carbon/emissions.go @@ -167,36 +167,7 @@ func (s *EmissionsScanner) Scan(ctx context.Context, cred azcore.TokenCredential // Convert aggregated results to table rows for resourceType, agg := range aggregatedResults { - row := []string{ - fromTime.Format("2006-01-02"), - toTime.Format("2006-01-02"), - resourceType, - fmt.Sprintf("%.2f", agg.latestMonth), - } - - // Add optional fields - if agg.previousMonth > 0 { - row = append(row, fmt.Sprintf("%.2f", agg.previousMonth)) - } else { - row = append(row, "") - } - - if agg.previousMonth != 0 { - avgRatio := (agg.latestMonth - agg.previousMonth) / agg.previousMonth - row = append(row, fmt.Sprintf("%.2f%%", avgRatio*100)) - } else { - row = append(row, "") - } - - if agg.monthlyChangeValue != 0 { - row = append(row, fmt.Sprintf("%.2f", agg.monthlyChangeValue)) - } else { - row = append(row, "") - } - - row = append(row, "kgCO2e") // kilograms of CO2 equivalent - - table = append(table, row) + table = append(table, buildEmissionRow(fromTime, toTime, resourceType, *agg)) } log.Info().Msgf("Carbon emissions scan completed with %d resource types", len(aggregatedResults)) @@ -209,6 +180,42 @@ func (s *EmissionsScanner) Scan(ctx context.Context, cred azcore.TokenCredential }}, nil } +// buildEmissionRow formats a single aggregated-emissions entry into a table row. +// Optional values (previous month, change ratio, monthly change) are rendered as +// empty strings when not available, matching the report's display conventions. +func buildEmissionRow(fromTime, toTime time.Time, resourceType string, agg aggregatedEmissions) []string { + row := []string{ + fromTime.Format("2006-01-02"), + toTime.Format("2006-01-02"), + resourceType, + fmt.Sprintf("%.2f", agg.latestMonth), + } + + // Add optional fields + if agg.previousMonth > 0 { + row = append(row, fmt.Sprintf("%.2f", agg.previousMonth)) + } else { + row = append(row, "") + } + + if agg.previousMonth != 0 { + avgRatio := (agg.latestMonth - agg.previousMonth) / agg.previousMonth + row = append(row, fmt.Sprintf("%.2f%%", avgRatio*100)) + } else { + row = append(row, "") + } + + if agg.monthlyChangeValue != 0 { + row = append(row, fmt.Sprintf("%.2f", agg.monthlyChangeValue)) + } else { + row = append(row, "") + } + + row = append(row, "kgCO2e") // kilograms of CO2 equivalent + + return row +} + // getAvailableDateRange queries the Carbon API for the available date range // and returns the end date as both fromTime and toTime to get the latest month's data. func (s *EmissionsScanner) getAvailableDateRange(ctx context.Context, clientFactory *armcarbonoptimization.ClientFactory) (time.Time, time.Time, error) { @@ -221,14 +228,21 @@ func (s *EmissionsScanner) getAvailableDateRange(ctx context.Context, clientFact return time.Time{}, time.Time{}, fmt.Errorf("available date range response missing start or end date") } - endDate, err := time.Parse("2006-01-02", *resp.EndDate) + return parseAvailableDateRange(*resp.StartDate, *resp.EndDate) +} + +// parseAvailableDateRange parses the start/end date strings returned by the +// Carbon API and returns the end date (latest available month) as both the +// from and to time, which is what the report queries against. +func parseAvailableDateRange(startStr, endStr string) (time.Time, time.Time, error) { + endDate, err := time.Parse("2006-01-02", endStr) if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("failed to parse end date %q: %w", *resp.EndDate, err) + return time.Time{}, time.Time{}, fmt.Errorf("failed to parse end date %q: %w", endStr, err) } - startDate, err := time.Parse("2006-01-02", *resp.StartDate) + startDate, err := time.Parse("2006-01-02", startStr) if err != nil { - return time.Time{}, time.Time{}, fmt.Errorf("failed to parse start date %q: %w", *resp.StartDate, err) + return time.Time{}, time.Time{}, fmt.Errorf("failed to parse start date %q: %w", startStr, err) } log.Debug().Msgf("Carbon emissions available range: %s to %s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) diff --git a/internal/scanners/plugins/carbon/emissions_test.go b/internal/scanners/plugins/carbon/emissions_test.go new file mode 100644 index 00000000..d8b1d7cf --- /dev/null +++ b/internal/scanners/plugins/carbon/emissions_test.go @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package carbon + +import ( + "testing" + "time" + + "github.com/Azure/azqr/internal/plugins" +) + +func TestNewEmissionsScanner(t *testing.T) { + if NewEmissionsScanner() == nil { + t.Fatal("NewEmissionsScanner returned nil") + } +} + +func TestEmissionsScanner_GetMetadata(t *testing.T) { + meta := NewEmissionsScanner().GetMetadata() + + if meta.Name != "carbon-emissions" { + t.Errorf("Name = %q, want carbon-emissions", meta.Name) + } + if meta.Version == "" { + t.Error("Version must not be empty") + } + if meta.Type != plugins.PluginTypeInternal { + t.Errorf("Type = %v, want PluginTypeInternal", meta.Type) + } + if len(meta.ColumnMetadata) != 8 { + t.Errorf("ColumnMetadata len = %d, want 8", len(meta.ColumnMetadata)) + } + + assertDataKeysValid(t, meta) +} + +// assertDataKeysValid checks that every column has a non-empty, unique DataKey. +// HeaderRow consistency is covered separately; this guards the DataKeys used by +// the web viewer and filters. +func assertDataKeysValid(t *testing.T, meta plugins.PluginMetadata) { + t.Helper() + seen := make(map[string]bool, len(meta.ColumnMetadata)) + for i, col := range meta.ColumnMetadata { + if col.DataKey == "" { + t.Errorf("ColumnMetadata[%d] (%q) has empty DataKey", i, col.Name) + } + if seen[col.DataKey] { + t.Errorf("duplicate DataKey %q at index %d", col.DataKey, i) + } + seen[col.DataKey] = true + } +} + +func TestBuildEmissionRow(t *testing.T) { + from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + agg aggregatedEmissions + expected []string + }{ + { + name: "all fields populated", + agg: aggregatedEmissions{latestMonth: 150, previousMonth: 100, monthlyChangeValue: 50}, + expected: []string{ + "2026-03-01", "2026-03-31", "Microsoft.Compute/virtualMachines", + "150.00", "100.00", "50.00%", "50.00", "kgCO2e", + }, + }, + { + name: "no previous month leaves ratio and previous empty", + agg: aggregatedEmissions{latestMonth: 75}, + expected: []string{ + "2026-03-01", "2026-03-31", "Microsoft.Compute/virtualMachines", + "75.00", "", "", "", "kgCO2e", + }, + }, + { + name: "negative change ratio", + agg: aggregatedEmissions{latestMonth: 80, previousMonth: 100, monthlyChangeValue: -20}, + expected: []string{ + "2026-03-01", "2026-03-31", "Microsoft.Compute/virtualMachines", + "80.00", "100.00", "-20.00%", "-20.00", "kgCO2e", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + row := buildEmissionRow(from, to, "Microsoft.Compute/virtualMachines", tt.agg) + if len(row) != len(tt.expected) { + t.Fatalf("row len = %d, want %d", len(row), len(tt.expected)) + } + for i := range tt.expected { + if row[i] != tt.expected[i] { + t.Errorf("row[%d] = %q, want %q", i, row[i], tt.expected[i]) + } + } + }) + } +} + +func TestParseAvailableDateRange(t *testing.T) { + from, to, err := parseAvailableDateRange("2026-01-01", "2026-03-31") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Both returned values must be the end date (latest available month). + want := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC) + if !from.Equal(want) || !to.Equal(want) { + t.Errorf("got (%s, %s), want both %s", from, to, want) + } +} + +func TestParseAvailableDateRange_Invalid(t *testing.T) { + if _, _, err := parseAvailableDateRange("2026-01-01", "not-a-date"); err == nil { + t.Error("expected error for invalid end date, got nil") + } + if _, _, err := parseAvailableDateRange("bad", "2026-03-31"); err == nil { + t.Error("expected error for invalid start date, got nil") + } +} diff --git a/internal/scanners/plugins/region/availability_test.go b/internal/scanners/plugins/region/availability_test.go new file mode 100644 index 00000000..306f7cb2 --- /dev/null +++ b/internal/scanners/plugins/region/availability_test.go @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package region + +import ( + "testing" +) + +func TestSkuAppliesToRegion(t *testing.T) { + tests := []struct { + name string + item skuAPIItem + targetRegion string + want bool + }{ + { + name: "locationInfo match", + item: skuAPIItem{LocationInfo: []skuLocationInfo{{Location: "eastus"}}}, + targetRegion: "eastus", + want: true, + }, + { + name: "locationInfo no match", + item: skuAPIItem{LocationInfo: []skuLocationInfo{{Location: "westus"}}}, + targetRegion: "eastus", + want: false, + }, + { + name: "locationInfo match with normalization", + item: skuAPIItem{LocationInfo: []skuLocationInfo{{Location: "East US"}}}, + targetRegion: "eastus", + want: true, + }, + { + name: "locations match", + item: skuAPIItem{Locations: []string{"westeurope"}}, + targetRegion: "westeurope", + want: true, + }, + { + name: "locations no match", + item: skuAPIItem{Locations: []string{"westeurope"}}, + targetRegion: "eastus", + want: false, + }, + { + name: "locations match with mixed case and spaces", + item: skuAPIItem{Locations: []string{"West Europe"}}, + targetRegion: "westeurope", + want: true, + }, + { + name: "no location data is globally available", + item: skuAPIItem{Name: "Standard_D2s_v3"}, + targetRegion: "eastus", + want: true, + }, + { + name: "locationInfo takes precedence over locations", + item: skuAPIItem{ + LocationInfo: []skuLocationInfo{{Location: "westus"}}, + Locations: []string{"eastus"}, + }, + targetRegion: "eastus", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item := tt.item + if got := skuAppliesToRegion(&item, tt.targetRegion); got != tt.want { + t.Errorf("skuAppliesToRegion() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractSKUName(t *testing.T) { + configWith := func(nameProp string) *propertyMapConfig { + c := &propertyMapConfig{} + c.Properties.TopLevelProperties = map[string]string{"name": nameProp} + return c + } + + tests := []struct { + name string + item skuAPIItem + config *propertyMapConfig + want string + }{ + { + name: "configured name property", + item: skuAPIItem{Name: "Standard_D2s_v3", Size: "D2s_v3", Tier: "Standard"}, + config: configWith("name"), + want: "Standard_D2s_v3", + }, + { + name: "configured size property", + item: skuAPIItem{Name: "Standard_D2s_v3", Size: "D2s_v3", Tier: "Standard"}, + config: configWith("size"), + want: "D2s_v3", + }, + { + name: "configured tier property", + item: skuAPIItem{Tier: "Premium"}, + config: configWith("tier"), + want: "Premium", + }, + { + name: "configured property empty falls back to name", + item: skuAPIItem{Name: "fallbackName"}, + config: configWith("size"), + want: "fallbackName", + }, + { + name: "no top-level properties uses name first", + item: skuAPIItem{Name: "n", Size: "s", Tier: "t"}, + config: &propertyMapConfig{}, + want: "n", + }, + { + name: "no top-level properties falls back to size", + item: skuAPIItem{Size: "s", Tier: "t"}, + config: &propertyMapConfig{}, + want: "s", + }, + { + name: "no top-level properties falls back to tier", + item: skuAPIItem{Tier: "t"}, + config: &propertyMapConfig{}, + want: "t", + }, + { + name: "all empty yields empty string", + item: skuAPIItem{}, + config: &propertyMapConfig{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item := tt.item + if got := extractSKUName(&item, tt.config); got != tt.want { + t.Errorf("extractSKUName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCheckSKURestrictions(t *testing.T) { + tests := []struct { + name string + item skuAPIItem + want skuAvailabilityState + }{ + { + name: "no restrictions or capabilities is available", + item: skuAPIItem{Name: "sku"}, + want: skuAvailable, + }, + { + name: "location restriction NotAvailableForSubscription is restricted", + item: skuAPIItem{Restrictions: []skuRestriction{{Type: "Location", ReasonCode: "NotAvailableForSubscription"}}}, + want: skuRestricted, + }, + { + name: "location restriction other reason is unavailable", + item: skuAPIItem{Restrictions: []skuRestriction{{Type: "Location", ReasonCode: "NotAvailableForRegion"}}}, + want: skuUnavailable, + }, + { + name: "restriction type is case-insensitive", + item: skuAPIItem{Restrictions: []skuRestriction{{Type: "location", ReasonCode: "notavailableforsubscription"}}}, + want: skuRestricted, + }, + { + name: "non-location restriction is ignored", + item: skuAPIItem{Restrictions: []skuRestriction{{Type: "Zone", ReasonCode: "NotAvailableForSubscription"}}}, + want: skuAvailable, + }, + { + name: "capability available false is unavailable", + item: skuAPIItem{Capabilities: []skuCapability{{Name: "available", Value: "false"}}}, + want: skuUnavailable, + }, + { + name: "capability available true is available", + item: skuAPIItem{Capabilities: []skuCapability{{Name: "available", Value: "true"}}}, + want: skuAvailable, + }, + { + name: "restriction takes precedence over capabilities", + item: skuAPIItem{ + Restrictions: []skuRestriction{{Type: "Location", ReasonCode: "NotAvailableForSubscription"}}, + Capabilities: []skuCapability{{Name: "available", Value: "false"}}, + }, + want: skuRestricted, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item := tt.item + if got := checkSKURestrictions(&item); got != tt.want { + t.Errorf("checkSKURestrictions() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractSKUsFromResponse(t *testing.T) { + cache := newSKUAvailabilityCache() + + t.Run("regional API skips region filtering", func(t *testing.T) { + config := &propertyMapConfig{RegionalAPI: true} + items := []skuAPIItem{ + {Name: "SkuA", Locations: []string{"westus"}}, + {Name: "SkuB", Locations: []string{"eastus"}}, + } + got := cache.extractSKUsFromResponse(items, config, "eastus") + if len(got) != 2 { + t.Fatalf("expected 2 SKUs (no filtering), got %d", len(got)) + } + if _, ok := got["skua"]; !ok { + t.Errorf("expected lowercased key 'skua' present") + } + }) + + t.Run("global API filters by region", func(t *testing.T) { + config := &propertyMapConfig{RegionalAPI: false} + items := []skuAPIItem{ + {Name: "SkuA", Locations: []string{"westus"}}, + {Name: "SkuB", Locations: []string{"eastus"}}, + } + got := cache.extractSKUsFromResponse(items, config, "eastus") + if len(got) != 1 { + t.Fatalf("expected 1 SKU after region filter, got %d", len(got)) + } + if _, ok := got["skub"]; !ok { + t.Errorf("expected 'skub' to be retained for eastus") + } + }) + + t.Run("empty SKU names are skipped", func(t *testing.T) { + config := &propertyMapConfig{RegionalAPI: true} + items := []skuAPIItem{ + {Name: ""}, + {Name: "Valid"}, + } + got := cache.extractSKUsFromResponse(items, config, "eastus") + if len(got) != 1 { + t.Fatalf("expected 1 SKU (empty name skipped), got %d", len(got)) + } + }) + + t.Run("availability state is captured", func(t *testing.T) { + config := &propertyMapConfig{RegionalAPI: true} + items := []skuAPIItem{ + {Name: "Restricted", Restrictions: []skuRestriction{{Type: "Location", ReasonCode: "NotAvailableForSubscription"}}}, + } + got := cache.extractSKUsFromResponse(items, config, "eastus") + if got["restricted"] != skuRestricted { + t.Errorf("expected skuRestricted, got %v", got["restricted"]) + } + }) +} diff --git a/internal/scanners/plugins/region/config_test.go b/internal/scanners/plugins/region/config_test.go new file mode 100644 index 00000000..af7b686d --- /dev/null +++ b/internal/scanners/plugins/region/config_test.go @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package region + +import ( + "strings" + "testing" +) + +func TestInit_LoadedEmbeddedConfig(t *testing.T) { + // The package init() must successfully unmarshal both embedded JSON files. + if len(skuConfigs) == 0 { + t.Error("expected skuConfigs to be populated from embedded sku.json") + } + if len(propertyMapsConfig) == 0 { + t.Fatal("expected propertyMapsConfig to be populated from embedded propertyMaps.json") + } + if len(propertyMapsIndex) != len(propertyMapsConfig) { + t.Errorf("index size %d does not match config slice size %d", len(propertyMapsIndex), len(propertyMapsConfig)) + } + + // Every config must have a non-empty resource type and be reachable via the index. + for i := range propertyMapsConfig { + rt := propertyMapsConfig[i].ResourceType + if rt == "" { + t.Errorf("propertyMapsConfig[%d] has empty resourceType", i) + continue + } + if getPropertyMapConfig(rt) == nil { + t.Errorf("getPropertyMapConfig(%q) returned nil for a configured resource type", rt) + } + } +} + +func TestGetPropertyMapConfig(t *testing.T) { + // Use the first loaded entry as a known-present resource type. + if len(propertyMapsConfig) == 0 { + t.Skip("no property map configs loaded") + } + known := propertyMapsConfig[0].ResourceType + + t.Run("exact match", func(t *testing.T) { + if got := getPropertyMapConfig(known); got == nil { + t.Fatalf("expected config for %q, got nil", known) + } + }) + + t.Run("case-insensitive match", func(t *testing.T) { + got := getPropertyMapConfig(strings.ToUpper(known)) + if got == nil { + t.Fatalf("expected case-insensitive lookup of %q to succeed", known) + } + if !strings.EqualFold(got.ResourceType, known) { + t.Errorf("expected resource type %q, got %q", known, got.ResourceType) + } + }) + + t.Run("unknown resource type returns nil", func(t *testing.T) { + if got := getPropertyMapConfig("microsoft.fake/doesnotexist"); got != nil { + t.Errorf("expected nil for unknown resource type, got %+v", got) + } + }) + + t.Run("empty string returns nil", func(t *testing.T) { + if got := getPropertyMapConfig(""); got != nil { + t.Errorf("expected nil for empty resource type, got %+v", got) + } + }) +} diff --git a/internal/scanners/plugins/region/excel_sheets_test.go b/internal/scanners/plugins/region/excel_sheets_test.go new file mode 100644 index 00000000..00a920c4 --- /dev/null +++ b/internal/scanners/plugins/region/excel_sheets_test.go @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package region + +import ( + "strings" + "testing" +) + +func TestSafeSheetName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "short name unchanged", + in: "SvcAvail_eastus", + want: "SvcAvail_eastus", + }, + { + name: "empty string unchanged", + in: "", + want: "", + }, + { + name: "exactly 31 chars unchanged", + in: strings.Repeat("a", 31), + want: strings.Repeat("a", 31), + }, + { + name: "32 chars truncated to 31", + in: strings.Repeat("a", 32), + want: strings.Repeat("a", 31), + }, + { + name: "long name truncated to 31", + in: "SvcAvail_australiacentral_extra_region", + want: "SvcAvail_australiacentral_extra", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := safeSheetName(tt.in) + if got != tt.want { + t.Errorf("safeSheetName(%q) = %q, want %q", tt.in, got, tt.want) + } + if len([]rune(got)) > 31 { + t.Errorf("safeSheetName(%q) returned %d runes, exceeds Excel limit of 31", tt.in, len([]rune(got))) + } + }) + } +} + +func TestSafeSheetName_MultiByteRunes(t *testing.T) { + // 40 multi-byte runes (each 'é' is 2 bytes in UTF-8) must be truncated by + // rune count, not byte count, and must not split a rune. + in := strings.Repeat("é", 40) + got := safeSheetName(in) + + if rc := len([]rune(got)); rc != 31 { + t.Errorf("expected 31 runes, got %d", rc) + } + if got != strings.Repeat("é", 31) { + t.Errorf("unexpected truncation result: %q", got) + } +} diff --git a/internal/scanners/plugins/region/latency_test.go b/internal/scanners/plugins/region/latency_test.go index 6386b799..3160b292 100644 --- a/internal/scanners/plugins/region/latency_test.go +++ b/internal/scanners/plugins/region/latency_test.go @@ -112,3 +112,52 @@ func TestEnrichWithLatencyData_RegionNamesNormalized(t *testing.T) { t.Errorf("expected normalization to yield 85.0 ms, got %.1f", results[0].avgLatencyMs) } } + +func TestGetRegionLatency(t *testing.T) { + defer setTestLatencyMatrix(map[string]map[string]float64{ + "eastus": {"westeurope": 85.0}, + "westeurope": {"swedencentral": 30.0}, + })() + + tests := []struct { + name string + source string + target string + want float64 + }{ + {name: "same region is zero", source: "eastus", target: "eastus", want: 0}, + {name: "direct lookup", source: "eastus", target: "westeurope", want: 85.0}, + {name: "symmetric reverse lookup", source: "westeurope", target: "eastus", want: 85.0}, + {name: "unknown pair is zero", source: "eastus", target: "brazilsouth", want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getRegionLatency(tt.source, tt.target); got != tt.want { + t.Errorf("getRegionLatency(%q, %q) = %.1f, want %.1f", tt.source, tt.target, got, tt.want) + } + }) + } +} + +func TestNormalizeRegionName(t *testing.T) { + tests := []struct { + in string + want string + }{ + {in: "East US", want: "eastus"}, + {in: "eastus", want: "eastus"}, + {in: "West Europe", want: "westeurope"}, + {in: "AUSTRALIA CENTRAL", want: "australiacentral"}, + {in: "", want: ""}, + {in: " East US ", want: "eastus"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := normalizeRegionName(tt.in); got != tt.want { + t.Errorf("normalizeRegionName(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/scanners/plugins/region/types_test.go b/internal/scanners/plugins/region/types_test.go new file mode 100644 index 00000000..041fec9c --- /dev/null +++ b/internal/scanners/plugins/region/types_test.go @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package region + +import ( + "testing" +) + +func TestDefaultScoringWeights(t *testing.T) { + w := defaultScoringWeights() + + if w.ResourceAvailability != 0.35 { + t.Errorf("ResourceAvailability = %v, want 0.35", w.ResourceAvailability) + } + if w.SKUAvailability != 0.30 { + t.Errorf("SKUAvailability = %v, want 0.30", w.SKUAvailability) + } + if w.Cost != 0.15 { + t.Errorf("Cost = %v, want 0.15", w.Cost) + } + if w.Latency != 0.20 { + t.Errorf("Latency = %v, want 0.20", w.Latency) + } + + // Weights must sum to 1.0 so the weighted score stays in the 0-100 range. + sum := w.ResourceAvailability + w.SKUAvailability + w.Cost + w.Latency + if diff := sum - 1.0; diff > 1e-9 || diff < -1e-9 { + t.Errorf("weights sum to %v, want 1.0", sum) + } +} + +func TestResourceTypeLocationData_IsAvailable(t *testing.T) { + rtl := &resourceTypeLocationData{ + data: map[string]map[string]map[string]struct{}{ + "microsoft.compute": { + "virtualmachines": {"eastus": {}, "westeurope": {}}, + }, + }, + } + + tests := []struct { + name string + resourceType string + region string + want bool + }{ + { + name: "available type and region", + resourceType: "microsoft.compute/virtualmachines", + region: "eastus", + want: true, + }, + { + name: "type present but region absent", + resourceType: "microsoft.compute/virtualmachines", + region: "brazilsouth", + want: false, + }, + { + name: "namespace present but type absent", + resourceType: "microsoft.compute/disks", + region: "eastus", + want: false, + }, + { + name: "unknown namespace", + resourceType: "microsoft.storage/storageaccounts", + region: "eastus", + want: false, + }, + { + name: "missing slash separator", + resourceType: "microsoft.compute", + region: "eastus", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rtl.isAvailable(tt.resourceType, tt.region); got != tt.want { + t.Errorf("isAvailable(%q, %q) = %v, want %v", tt.resourceType, tt.region, got, tt.want) + } + }) + } +} diff --git a/internal/scanners/plugins/sqlesu/sqlesu.go b/internal/scanners/plugins/sqlesu/sqlesu.go index d6038f3b..ac94aedc 100644 --- a/internal/scanners/plugins/sqlesu/sqlesu.go +++ b/internal/scanners/plugins/sqlesu/sqlesu.go @@ -97,48 +97,6 @@ func (s *Scanner) Scan(ctx context.Context, cred azcore.TokenCredential, subscri table := [][]string{s.GetMetadata().HeaderRow()} if result.Data != nil { - type sqlESURow struct { - SubscriptionID string `json:"SubscriptionId"` - Name string `json:"Name"` - ResourceGroup string `json:"ResourceGroup"` - Subscription string `json:"Subscription"` - Location string `json:"Location"` - CloudType string `json:"CloudType"` - SQLVersion string `json:"SQLVersion"` - Edition string `json:"Edition"` - VCores string `json:"vCores"` - BillableCores string `json:"BillableCores"` - EOLStatus string `json:"EOLStatus"` - MigrationRecommendation string `json:"MigrationRecommendation"` - MigrationTargetTier string `json:"MigrationTargetTier"` - ESUStartDate string `json:"ESUStartDate"` - ESUEndDate string `json:"ESUEndDate"` - ESUMonthlyCostPerCore string `json:"ESUMonthlyCostPerCore"` - SQLLicenseType string `json:"SQLLicenseType"` - SQLLicenseMonthlyCostPerCore string `json:"SQLLicenseMonthlyCostPerCore"` - SQLLicenseMonthlyCost string `json:"SQLLicenseMonthlyCost"` - SQLLicenseAnnualCost string `json:"SQLLicenseAnnualCost"` - VMCostPerCorePerMonth string `json:"VMCostPerCorePerMonth"` - EstVMComputeMonthlyCost string `json:"EstVMComputeMonthlyCost"` - EstVMComputeAnnualCost string `json:"EstVMComputeAnnualCost"` - EstVMComputeThreeYearCost string `json:"EstVMComputeThreeYearCost"` - EstESUMonthlyCost string `json:"EstESUMonthlyCost"` - EstESUAnnualCost string `json:"EstESUAnnualCost"` - EstESUThreeYearCost string `json:"EstESUThreeYearCost"` - PatchOpsMonthlyCost string `json:"PatchOpsMonthlyCost"` - PatchOpsAnnualCost string `json:"PatchOpsAnnualCost"` - PatchOpsThreeYearCost string `json:"PatchOpsThreeYearCost"` - CurrentMonthlyCost string `json:"CurrentMonthlyCost"` - CurrentAnnualCost string `json:"CurrentAnnualCost"` - CurrentThreeYearCost string `json:"CurrentThreeYearCost"` - EstSQLMIMonthlyCost string `json:"EstSQLMIMonthlyCost"` - EstSQLMIAnnualCost string `json:"EstSQLMIAnnualCost"` - EstSQLMIThreeYearCost string `json:"EstSQLMIThreeYearCost"` - EstSQLMIMonthlySaving string `json:"EstSQLMIMonthlySaving"` - EstSQLMIAnnualSaving string `json:"EstSQLMIAnnualSaving"` - EstSQLMIThreeYearSaving string `json:"EstSQLMIThreeYearSaving"` - SQLMIMigrationVerdict string `json:"SQLMIMigrationVerdict"` - } for _, raw := range result.Data { var r sqlESURow if err := json.Unmarshal(raw, &r); err != nil { @@ -150,47 +108,7 @@ func (s *Scanner) Scan(ctx context.Context, cred azcore.TokenCredential, subscri continue } - table = append(table, []string{ - r.Name, - r.ResourceGroup, - r.Subscription, - r.Location, - r.CloudType, - r.SQLVersion, - r.Edition, - r.VCores, - r.BillableCores, - r.EOLStatus, - r.MigrationRecommendation, - r.MigrationTargetTier, - r.ESUStartDate, - r.ESUEndDate, - r.ESUMonthlyCostPerCore, - r.SQLLicenseType, - r.SQLLicenseMonthlyCostPerCore, - r.SQLLicenseMonthlyCost, - r.SQLLicenseAnnualCost, - r.VMCostPerCorePerMonth, - r.EstVMComputeMonthlyCost, - r.EstVMComputeAnnualCost, - r.EstVMComputeThreeYearCost, - r.EstESUMonthlyCost, - r.EstESUAnnualCost, - r.EstESUThreeYearCost, - r.PatchOpsMonthlyCost, - r.PatchOpsAnnualCost, - r.PatchOpsThreeYearCost, - r.CurrentMonthlyCost, - r.CurrentAnnualCost, - r.CurrentThreeYearCost, - r.EstSQLMIMonthlyCost, - r.EstSQLMIAnnualCost, - r.EstSQLMIThreeYearCost, - r.EstSQLMIMonthlySaving, - r.EstSQLMIAnnualSaving, - r.EstSQLMIThreeYearSaving, - r.SQLMIMigrationVerdict, - }) + table = append(table, r.toRecord()) } } @@ -204,6 +122,96 @@ func (s *Scanner) Scan(ctx context.Context, cred azcore.TokenCredential, subscri }}, nil } +// sqlESURow is the shape of a single row returned by the SQL ESU ARG query. +type sqlESURow struct { + SubscriptionID string `json:"SubscriptionId"` + Name string `json:"Name"` + ResourceGroup string `json:"ResourceGroup"` + Subscription string `json:"Subscription"` + Location string `json:"Location"` + CloudType string `json:"CloudType"` + SQLVersion string `json:"SQLVersion"` + Edition string `json:"Edition"` + VCores string `json:"vCores"` + BillableCores string `json:"BillableCores"` + EOLStatus string `json:"EOLStatus"` + MigrationRecommendation string `json:"MigrationRecommendation"` + MigrationTargetTier string `json:"MigrationTargetTier"` + ESUStartDate string `json:"ESUStartDate"` + ESUEndDate string `json:"ESUEndDate"` + ESUMonthlyCostPerCore string `json:"ESUMonthlyCostPerCore"` + SQLLicenseType string `json:"SQLLicenseType"` + SQLLicenseMonthlyCostPerCore string `json:"SQLLicenseMonthlyCostPerCore"` + SQLLicenseMonthlyCost string `json:"SQLLicenseMonthlyCost"` + SQLLicenseAnnualCost string `json:"SQLLicenseAnnualCost"` + VMCostPerCorePerMonth string `json:"VMCostPerCorePerMonth"` + EstVMComputeMonthlyCost string `json:"EstVMComputeMonthlyCost"` + EstVMComputeAnnualCost string `json:"EstVMComputeAnnualCost"` + EstVMComputeThreeYearCost string `json:"EstVMComputeThreeYearCost"` + EstESUMonthlyCost string `json:"EstESUMonthlyCost"` + EstESUAnnualCost string `json:"EstESUAnnualCost"` + EstESUThreeYearCost string `json:"EstESUThreeYearCost"` + PatchOpsMonthlyCost string `json:"PatchOpsMonthlyCost"` + PatchOpsAnnualCost string `json:"PatchOpsAnnualCost"` + PatchOpsThreeYearCost string `json:"PatchOpsThreeYearCost"` + CurrentMonthlyCost string `json:"CurrentMonthlyCost"` + CurrentAnnualCost string `json:"CurrentAnnualCost"` + CurrentThreeYearCost string `json:"CurrentThreeYearCost"` + EstSQLMIMonthlyCost string `json:"EstSQLMIMonthlyCost"` + EstSQLMIAnnualCost string `json:"EstSQLMIAnnualCost"` + EstSQLMIThreeYearCost string `json:"EstSQLMIThreeYearCost"` + EstSQLMIMonthlySaving string `json:"EstSQLMIMonthlySaving"` + EstSQLMIAnnualSaving string `json:"EstSQLMIAnnualSaving"` + EstSQLMIThreeYearSaving string `json:"EstSQLMIThreeYearSaving"` + SQLMIMigrationVerdict string `json:"SQLMIMigrationVerdict"` +} + +// toRecord flattens a sqlESURow into a table row in the same column order as +// the plugin's ColumnMetadata. +func (r sqlESURow) toRecord() []string { + return []string{ + r.Name, + r.ResourceGroup, + r.Subscription, + r.Location, + r.CloudType, + r.SQLVersion, + r.Edition, + r.VCores, + r.BillableCores, + r.EOLStatus, + r.MigrationRecommendation, + r.MigrationTargetTier, + r.ESUStartDate, + r.ESUEndDate, + r.ESUMonthlyCostPerCore, + r.SQLLicenseType, + r.SQLLicenseMonthlyCostPerCore, + r.SQLLicenseMonthlyCost, + r.SQLLicenseAnnualCost, + r.VMCostPerCorePerMonth, + r.EstVMComputeMonthlyCost, + r.EstVMComputeAnnualCost, + r.EstVMComputeThreeYearCost, + r.EstESUMonthlyCost, + r.EstESUAnnualCost, + r.EstESUThreeYearCost, + r.PatchOpsMonthlyCost, + r.PatchOpsAnnualCost, + r.PatchOpsThreeYearCost, + r.CurrentMonthlyCost, + r.CurrentAnnualCost, + r.CurrentThreeYearCost, + r.EstSQLMIMonthlyCost, + r.EstSQLMIAnnualCost, + r.EstSQLMIThreeYearCost, + r.EstSQLMIMonthlySaving, + r.EstSQLMIAnnualSaving, + r.EstSQLMIThreeYearSaving, + r.SQLMIMigrationVerdict, + } +} + // init registers the plugin automatically func init() { plugins.RegisterInternalPlugin("sql-esu", NewScanner()) diff --git a/internal/scanners/plugins/sqlesu/sqlesu_test.go b/internal/scanners/plugins/sqlesu/sqlesu_test.go new file mode 100644 index 00000000..689e6f25 --- /dev/null +++ b/internal/scanners/plugins/sqlesu/sqlesu_test.go @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package sqlesu + +import ( + "encoding/json" + "testing" + + "github.com/Azure/azqr/internal/plugins" +) + +func TestNewScanner(t *testing.T) { + if NewScanner() == nil { + t.Fatal("NewScanner returned nil") + } +} + +func TestScanner_GetMetadata(t *testing.T) { + meta := NewScanner().GetMetadata() + + if meta.Name != "sql-esu" { + t.Errorf("Name = %q, want sql-esu", meta.Name) + } + if meta.Version == "" { + t.Error("Version must not be empty") + } + if meta.Type != plugins.PluginTypeInternal { + t.Errorf("Type = %v, want PluginTypeInternal", meta.Type) + } + // sql-esu exposes a wide table; guard against an accidental large drop in columns. + if len(meta.ColumnMetadata) < 30 { + t.Errorf("ColumnMetadata len = %d, want >= 30", len(meta.ColumnMetadata)) + } + + seen := make(map[string]bool, len(meta.ColumnMetadata)) + for i, col := range meta.ColumnMetadata { + if col.DataKey == "" { + t.Errorf("ColumnMetadata[%d] (%q) has empty DataKey", i, col.Name) + } + if seen[col.DataKey] { + t.Errorf("duplicate DataKey %q at index %d", col.DataKey, i) + } + seen[col.DataKey] = true + } +} + +// TestSQLESURow_Unmarshal verifies the JSON tag mapping from the ARG query +// result into sqlESURow, including the lower-cased "vCores" tag. +func TestSQLESURow_Unmarshal(t *testing.T) { + raw := []byte(`{ + "SubscriptionId": "sub-123", + "Name": "sql-vm-1", + "ResourceGroup": "rg-sql", + "Edition": "Enterprise", + "vCores": "8", + "EOLStatus": "Out of Support", + "SQLMIMigrationVerdict": "Recommended" + }`) + + var r sqlESURow + if err := json.Unmarshal(raw, &r); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if r.SubscriptionID != "sub-123" { + t.Errorf("SubscriptionID = %q, want sub-123", r.SubscriptionID) + } + if r.VCores != "8" { + t.Errorf("VCores = %q, want 8 (check 'vCores' json tag)", r.VCores) + } + if r.Edition != "Enterprise" { + t.Errorf("Edition = %q, want Enterprise", r.Edition) + } + if r.SQLMIMigrationVerdict != "Recommended" { + t.Errorf("SQLMIMigrationVerdict = %q, want Recommended", r.SQLMIMigrationVerdict) + } +} + +// TestSQLESURow_ToRecord verifies the flattened record preserves field order and +// has one entry per declared column. +func TestSQLESURow_ToRecord(t *testing.T) { + r := sqlESURow{ + Name: "sql-vm-1", + ResourceGroup: "rg-sql", + Subscription: "Prod", + Location: "eastus", + Edition: "Enterprise", + VCores: "8", + EOLStatus: "Out of Support", + SQLMIMigrationVerdict: "Recommended", + } + + record := r.toRecord() + + wantLen := len(NewScanner().GetMetadata().ColumnMetadata) + if len(record) != wantLen { + t.Fatalf("record len = %d, want %d (one per column)", len(record), wantLen) + } + + // Spot-check ordering against the first columns and the final column. + if record[0] != "sql-vm-1" { + t.Errorf("record[0] = %q, want sql-vm-1", record[0]) + } + if record[1] != "rg-sql" { + t.Errorf("record[1] = %q, want rg-sql", record[1]) + } + if record[2] != "Prod" { + t.Errorf("record[2] = %q, want Prod", record[2]) + } + if record[6] != "Enterprise" { + t.Errorf("record[6] = %q, want Enterprise", record[6]) + } + if record[len(record)-1] != "Recommended" { + t.Errorf("last record = %q, want Recommended", record[len(record)-1]) + } +} diff --git a/internal/scanners/plugins/zone/mapping.go b/internal/scanners/plugins/zone/mapping.go index 6e50fd43..07a193fa 100644 --- a/internal/scanners/plugins/zone/mapping.go +++ b/internal/scanners/plugins/zone/mapping.go @@ -163,7 +163,13 @@ func (s *ZoneMappingScanner) fetchZoneMappings(ctx context.Context, httpClient * return nil, fmt.Errorf("failed to fetch locations: %w", err) } - // Parse the response + return parseZoneMappings(body, subscriptionID, subscriptionName) +} + +// parseZoneMappings parses a locations REST response body and converts it into +// zoneMappingResult records. Locations without availability zone mappings are +// skipped, and nil (optional) fields are normalized to empty strings. +func parseZoneMappings(body []byte, subscriptionID, subscriptionName string) ([]zoneMappingResult, error) { var locationsResp locationResponse if err := json.Unmarshal(body, &locationsResp); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) diff --git a/internal/scanners/plugins/zone/mapping_test.go b/internal/scanners/plugins/zone/mapping_test.go new file mode 100644 index 00000000..aa4fb13d --- /dev/null +++ b/internal/scanners/plugins/zone/mapping_test.go @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package zone + +import ( + "encoding/json" + "testing" + + "github.com/Azure/azqr/internal/plugins" +) + +func TestNewZoneMappingScanner(t *testing.T) { + if NewZoneMappingScanner() == nil { + t.Fatal("NewZoneMappingScanner returned nil") + } +} + +func TestZoneMappingScanner_GetMetadata(t *testing.T) { + meta := NewZoneMappingScanner().GetMetadata() + + if meta.Name != "zone-mapping" { + t.Errorf("Name = %q, want zone-mapping", meta.Name) + } + if meta.Version == "" { + t.Error("Version must not be empty") + } + if meta.Type != plugins.PluginTypeInternal { + t.Errorf("Type = %v, want PluginTypeInternal", meta.Type) + } + if len(meta.ColumnMetadata) != 5 { + t.Errorf("ColumnMetadata len = %d, want 5", len(meta.ColumnMetadata)) + } + + seen := make(map[string]bool, len(meta.ColumnMetadata)) + for i, col := range meta.ColumnMetadata { + if col.DataKey == "" { + t.Errorf("ColumnMetadata[%d] (%q) has empty DataKey", i, col.Name) + } + if seen[col.DataKey] { + t.Errorf("duplicate DataKey %q at index %d", col.DataKey, i) + } + seen[col.DataKey] = true + } +} + +// TestLocationResponse_Unmarshal verifies the JSON contract that fetchZoneMappings +// relies on: the locations REST response shape, optional (pointer) fields, and +// nested availability zone mappings. +func TestLocationResponse_Unmarshal(t *testing.T) { + body := []byte(`{ + "value": [ + { + "name": "eastus", + "displayName": "East US", + "availabilityZoneMappings": [ + {"logicalZone": "1", "physicalZone": "eastus-az1"}, + {"logicalZone": "2", "physicalZone": "eastus-az2"} + ] + }, + { + "name": "westus", + "displayName": "West US" + } + ] + }`) + + var resp locationResponse + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if len(resp.Value) != 2 { + t.Fatalf("Value len = %d, want 2", len(resp.Value)) + } + + east := resp.Value[0] + if east.Name == nil || *east.Name != "eastus" { + t.Errorf("Value[0].Name = %v, want eastus", east.Name) + } + if east.DisplayName == nil || *east.DisplayName != "East US" { + t.Errorf("Value[0].DisplayName = %v, want East US", east.DisplayName) + } + if len(east.AvailabilityZoneMappings) != 2 { + t.Fatalf("Value[0] zone mappings len = %d, want 2", len(east.AvailabilityZoneMappings)) + } + m := east.AvailabilityZoneMappings[0] + if m.LogicalZone == nil || *m.LogicalZone != "1" { + t.Errorf("LogicalZone = %v, want 1", m.LogicalZone) + } + if m.PhysicalZone == nil || *m.PhysicalZone != "eastus-az1" { + t.Errorf("PhysicalZone = %v, want eastus-az1", m.PhysicalZone) + } + + // A region without availabilityZoneMappings must unmarshal to an empty slice, + // which fetchZoneMappings skips. + if len(resp.Value[1].AvailabilityZoneMappings) != 0 { + t.Errorf("Value[1] zone mappings len = %d, want 0", len(resp.Value[1].AvailabilityZoneMappings)) + } +} + +func TestParseZoneMappings(t *testing.T) { + body := []byte(`{ + "value": [ + { + "name": "eastus", + "displayName": "East US", + "availabilityZoneMappings": [ + {"logicalZone": "1", "physicalZone": "eastus-az1"}, + {"logicalZone": "2", "physicalZone": "eastus-az2"} + ] + }, + { + "name": "westus", + "displayName": "West US" + }, + { + "name": "centralus", + "availabilityZoneMappings": [ + {"logicalZone": "1"} + ] + } + ] + }`) + + results, err := parseZoneMappings(body, "sub-id", "Sub Name") + if err != nil { + t.Fatalf("parseZoneMappings returned error: %v", err) + } + + // eastus contributes 2 rows, westus 0 (no mappings), centralus 1 row. + if len(results) != 3 { + t.Fatalf("results len = %d, want 3", len(results)) + } + + first := results[0] + if first.subscriptionID != "sub-id" || first.subscriptionName != "Sub Name" { + t.Errorf("subscription fields = (%q,%q), want (sub-id, Sub Name)", first.subscriptionID, first.subscriptionName) + } + if first.location != "eastus" || first.displayName != "East US" { + t.Errorf("location/displayName = (%q,%q), want (eastus, East US)", first.location, first.displayName) + } + if first.logicalZone != "1" || first.physicalZone != "eastus-az1" { + t.Errorf("zones = (%q,%q), want (1, eastus-az1)", first.logicalZone, first.physicalZone) + } + + // centralus row has a nil physicalZone, which must normalize to "". + last := results[2] + if last.location != "centralus" || last.displayName != "" { + t.Errorf("centralus row = (%q,%q), want (centralus, \"\")", last.location, last.displayName) + } + if last.logicalZone != "1" || last.physicalZone != "" { + t.Errorf("centralus zones = (%q,%q), want (1, \"\")", last.logicalZone, last.physicalZone) + } +} + +func TestParseZoneMappings_InvalidJSON(t *testing.T) { + if _, err := parseZoneMappings([]byte("{not json"), "sub", "Sub"); err == nil { + t.Error("expected error for invalid JSON, got nil") + } +} + +func TestParseZoneMappings_Empty(t *testing.T) { + results, err := parseZoneMappings([]byte(`{"value": []}`), "sub", "Sub") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(results) != 0 { + t.Errorf("results len = %d, want 0", len(results)) + } +}