From ed29253761c07303b3bd44b5935b979fa0149c20 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Fri, 8 May 2026 16:09:50 -0600 Subject: [PATCH] All float values returned by sys/billing/overview should be rounded to 4 decimal places (#14648) (#14681) (#14693) * rounding float64 values in billing overview by 4 decimal places * add changelog Co-authored-by: akshya96 <87045294+akshya96@users.noreply.github.com> --- changelog/_14648.txt | 3 + vault/logical_system_use_case_billing.go | 33 +++ vault/logical_system_use_case_billing_test.go | 253 ++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 changelog/_14648.txt diff --git a/changelog/_14648.txt b/changelog/_14648.txt new file mode 100644 index 0000000000..15f0ce7c05 --- /dev/null +++ b/changelog/_14648.txt @@ -0,0 +1,3 @@ +```release-note:improvement +consumption-billing: Float64 values returned by `sys/billing/overview` are now rounded to 4 decimal places. +``` \ No newline at end of file diff --git a/vault/logical_system_use_case_billing.go b/vault/logical_system_use_case_billing.go index 484547dd3c..f7d98d312c 100644 --- a/vault/logical_system_use_case_billing.go +++ b/vault/logical_system_use_case_billing.go @@ -6,6 +6,7 @@ package vault import ( "context" "fmt" + "math" "net/http" "time" @@ -286,6 +287,11 @@ func (b *SystemBackend) buildMonthBillingData(ctx context.Context, month time.Ti } usageMetrics = append(usageMetrics, idTokenUnitsMetric) + // Round all float64 values in usageMetrics to 4 decimal places. + // Rounding time for usage metrics is insignificant, so we can keep it centralized here. + // This prevents us from having to do it in each individual metric. + roundUsageMetrics(usageMetrics) + dataUpdatedAt := b.Core.computeUpdatedAt(ctx, month, currentMonth) monthStr := month.Format("2006-01") @@ -297,6 +303,33 @@ func (b *SystemBackend) buildMonthBillingData(ctx context.Context, month time.Ti }, nil } +// roundUsageMetrics rounds all float64 values in the usage metrics to 4 decimal places +func roundUsageMetrics(metrics []map[string]interface{}) { + for _, metric := range metrics { + if metricData, ok := metric["metric_data"].(map[string]interface{}); ok { + // Round the total if it's a float64 + if total, ok := metricData["total"].(float64); ok { + metricData["total"] = roundToFour(total) + } + + // Round values in metric_details if present + if details, ok := metricData["metric_details"].([]map[string]interface{}); ok { + for _, detail := range details { + if count, ok := detail["count"].(float64); ok { + detail["count"] = roundToFour(count) + } + } + } + } + } +} + +// roundToFour takes a float64 and rounds it to 4 decimal places. +func roundToFour(val float64) float64 { + ratio := math.Pow(10, 4) + return math.Round(val*ratio) / ratio +} + // computeUpdatedAt determines the appropriate updated_at timestamp for billing data func (c *Core) computeUpdatedAt(ctx context.Context, month, currentMonth time.Time) time.Time { var dataUpdatedAt time.Time diff --git a/vault/logical_system_use_case_billing_test.go b/vault/logical_system_use_case_billing_test.go index 949567dd67..1d7d385214 100644 --- a/vault/logical_system_use_case_billing_test.go +++ b/vault/logical_system_use_case_billing_test.go @@ -1331,3 +1331,256 @@ func TestSystemBackend_BillingOverview_PreviousMonth_WithError(t *testing.T) { require.True(t, parsedTime.IsZero(), "previous month updated_at should be zero time when timestamp is not stored") } + +// TestRoundToFour tests the roundToFour function +func TestRoundToFour(t *testing.T) { + // Define test cases + tests := []struct { + name string + input float64 + expected float64 + }{ + {"Round up", 1.23456, 1.2346}, + {"Round down", 1.23454, 1.2345}, + {"Exactly four decimals", 1.1111, 1.1111}, + {"Fewer than four decimals", 1.2, 1.2000}, + {"Zero value", 0.0, 0.0}, + {"Large values", 0.189900000000, 0.1899}, + {"Large values with round up", 0.189990000000, 0.1900}, + {"Large values with round down", 0.189920000000, 0.1899}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := roundToFour(tt.input) + require.Equal(t, tt.expected, got) + }) + } +} + +// TestRoundUsageMetrics tests the roundUsageMetrics function. +func TestRoundUsageMetrics(t *testing.T) { + tests := []struct { + name string + input []map[string]interface{} + expected []map[string]interface{} + }{ + { + name: "Round float64 totals and counts in metric_details", + input: []map[string]interface{}{ + { + "metric_name": "pki_units", + "metric_data": map[string]interface{}{ + "total": 123.456789, + }, + }, + { + "metric_name": "ssh_units", + "metric_data": map[string]interface{}{ + "total": 98.765432, + "metric_details": []map[string]interface{}{ + {"type": "otp_units", "count": 45.678901}, + {"type": "certificate_units", "count": 53.086531}, + }, + }, + }, + }, + expected: []map[string]interface{}{ + { + "metric_name": "pki_units", + "metric_data": map[string]interface{}{ + "total": 123.4568, + }, + }, + { + "metric_name": "ssh_units", + "metric_data": map[string]interface{}{ + "total": 98.7654, + "metric_details": []map[string]interface{}{ + {"type": "otp_units", "count": 45.6789}, + {"type": "certificate_units", "count": 53.0865}, + }, + }, + }, + }, + }, + { + name: "Handle integer counts (should not be modified)", + input: []map[string]interface{}{ + { + "metric_name": "static_secrets", + "metric_data": map[string]interface{}{ + "total": 100, + "metric_details": []map[string]interface{}{ + {"type": "kv", "count": 100}, + }, + }, + }, + }, + expected: []map[string]interface{}{ + { + "metric_name": "static_secrets", + "metric_data": map[string]interface{}{ + "total": 100, + "metric_details": []map[string]interface{}{ + {"type": "kv", "count": 100}, + }, + }, + }, + }, + }, + { + name: "Handle mixed float64 and integer values", + input: []map[string]interface{}{ + { + "metric_name": "id_token_units", + "metric_data": map[string]interface{}{ + "total": 150.123456, + "metric_details": []map[string]interface{}{ + {"type": "oidc", "count": 100.987654}, + {"type": "spiffe", "count": 49.135802}, + }, + }, + }, + { + "metric_name": "dynamic_roles", + "metric_data": map[string]interface{}{ + "total": 50, + "metric_details": []map[string]interface{}{ + {"type": "aws_dynamic", "count": 25}, + {"type": "azure_dynamic", "count": 25}, + }, + }, + }, + }, + expected: []map[string]interface{}{ + { + "metric_name": "id_token_units", + "metric_data": map[string]interface{}{ + "total": 150.1235, + "metric_details": []map[string]interface{}{ + {"type": "oidc", "count": 100.9877}, + {"type": "spiffe", "count": 49.1358}, + }, + }, + }, + { + "metric_name": "dynamic_roles", + "metric_data": map[string]interface{}{ + "total": 50, + "metric_details": []map[string]interface{}{ + {"type": "aws_dynamic", "count": 25}, + {"type": "azure_dynamic", "count": 25}, + }, + }, + }, + }, + }, + { + name: "Handle metrics without metric_details", + input: []map[string]interface{}{ + { + "metric_name": "kmip", + "metric_data": map[string]interface{}{ + "used_in_month": true, + }, + }, + { + "metric_name": "external_plugins", + "metric_data": map[string]interface{}{ + "total": 5, + }, + }, + }, + expected: []map[string]interface{}{ + { + "metric_name": "kmip", + "metric_data": map[string]interface{}{ + "used_in_month": true, + }, + }, + { + "metric_name": "external_plugins", + "metric_data": map[string]interface{}{ + "total": 5, + }, + }, + }, + }, + { + name: "Handle empty metrics slice", + input: []map[string]interface{}{}, + expected: []map[string]interface{}{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Make a deep copy of input to avoid modifying the test case + inputCopy := make([]map[string]interface{}, len(tt.input)) + for i, metric := range tt.input { + inputCopy[i] = make(map[string]interface{}) + for k, v := range metric { + if k == "metric_data" { + metricData := v.(map[string]interface{}) + metricDataCopy := make(map[string]interface{}) + for mk, mv := range metricData { + if mk == "metric_details" { + details := mv.([]map[string]interface{}) + detailsCopy := make([]map[string]interface{}, len(details)) + for di, detail := range details { + detailsCopy[di] = make(map[string]interface{}) + for dk, dv := range detail { + detailsCopy[di][dk] = dv + } + } + metricDataCopy[mk] = detailsCopy + } else { + metricDataCopy[mk] = mv + } + } + inputCopy[i][k] = metricDataCopy + } else { + inputCopy[i][k] = v + } + } + } + + // Apply rounding + roundUsageMetrics(inputCopy) + + // Verify the results + require.Equal(t, len(tt.expected), len(inputCopy)) + for i, expectedMetric := range tt.expected { + actualMetric := inputCopy[i] + require.Equal(t, expectedMetric["metric_name"], actualMetric["metric_name"]) + + expectedData := expectedMetric["metric_data"].(map[string]interface{}) + actualData := actualMetric["metric_data"].(map[string]interface{}) + + // Check total + if expectedTotal, ok := expectedData["total"]; ok { + require.Equal(t, expectedTotal, actualData["total"]) + } + + // Check metric_details + if expectedDetails, ok := expectedData["metric_details"].([]map[string]interface{}); ok { + actualDetails := actualData["metric_details"].([]map[string]interface{}) + require.Equal(t, len(expectedDetails), len(actualDetails)) + for j, expectedDetail := range expectedDetails { + actualDetail := actualDetails[j] + require.Equal(t, expectedDetail["type"], actualDetail["type"]) + require.Equal(t, expectedDetail["count"], actualDetail["count"]) + } + } + + // Check other fields (like used_in_month) + for key, expectedValue := range expectedData { + if key != "total" && key != "metric_details" { + require.Equal(t, expectedValue, actualData[key]) + } + } + } + }) + } +}