@pagopa/dx-savemoney 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +33 -27
  2. package/dist/__tests__/finding.test.d.ts +17 -0
  3. package/dist/__tests__/finding.test.d.ts.map +1 -0
  4. package/dist/__tests__/finding.test.js +124 -0
  5. package/dist/__tests__/finding.test.js.map +1 -0
  6. package/dist/azure/__tests__/analyzer-tags.test.d.ts +8 -0
  7. package/dist/azure/__tests__/analyzer-tags.test.d.ts.map +1 -0
  8. package/dist/azure/__tests__/analyzer-tags.test.js +43 -0
  9. package/dist/azure/__tests__/analyzer-tags.test.js.map +1 -0
  10. package/dist/azure/__tests__/config.test.d.ts +9 -0
  11. package/dist/azure/__tests__/config.test.d.ts.map +1 -0
  12. package/dist/azure/__tests__/config.test.js +70 -0
  13. package/dist/azure/__tests__/config.test.js.map +1 -0
  14. package/dist/azure/__tests__/report.test.d.ts +9 -0
  15. package/dist/azure/__tests__/report.test.d.ts.map +1 -0
  16. package/dist/azure/__tests__/report.test.js +120 -0
  17. package/dist/azure/__tests__/report.test.js.map +1 -0
  18. package/dist/azure/__tests__/utils.test.d.ts +15 -0
  19. package/dist/azure/__tests__/utils.test.d.ts.map +1 -0
  20. package/dist/azure/__tests__/utils.test.js +181 -0
  21. package/dist/azure/__tests__/utils.test.js.map +1 -0
  22. package/dist/azure/analyzer.d.ts +18 -5
  23. package/dist/azure/analyzer.d.ts.map +1 -1
  24. package/dist/azure/analyzer.js +295 -48
  25. package/dist/azure/analyzer.js.map +1 -1
  26. package/dist/azure/analyzers/__tests__/advisor.test.d.ts +9 -0
  27. package/dist/azure/analyzers/__tests__/advisor.test.d.ts.map +1 -0
  28. package/dist/azure/analyzers/__tests__/advisor.test.js +314 -0
  29. package/dist/azure/analyzers/__tests__/advisor.test.js.map +1 -0
  30. package/dist/azure/analyzers/advisor.d.ts +68 -0
  31. package/dist/azure/analyzers/advisor.d.ts.map +1 -0
  32. package/dist/azure/analyzers/advisor.js +234 -0
  33. package/dist/azure/analyzers/advisor.js.map +1 -0
  34. package/dist/azure/analyzers/index.d.ts +3 -1
  35. package/dist/azure/analyzers/index.d.ts.map +1 -1
  36. package/dist/azure/analyzers/index.js +2 -1
  37. package/dist/azure/analyzers/index.js.map +1 -1
  38. package/dist/azure/analyzers/registry.d.ts +8 -0
  39. package/dist/azure/analyzers/registry.d.ts.map +1 -1
  40. package/dist/azure/analyzers/registry.js +10 -0
  41. package/dist/azure/analyzers/registry.js.map +1 -1
  42. package/dist/azure/analyzers/subscription.d.ts +53 -0
  43. package/dist/azure/analyzers/subscription.d.ts.map +1 -0
  44. package/dist/azure/analyzers/subscription.js +18 -0
  45. package/dist/azure/analyzers/subscription.js.map +1 -0
  46. package/dist/azure/config.d.ts.map +1 -1
  47. package/dist/azure/config.js +1 -0
  48. package/dist/azure/config.js.map +1 -1
  49. package/dist/azure/index.d.ts +1 -0
  50. package/dist/azure/index.d.ts.map +1 -1
  51. package/dist/azure/index.js +1 -0
  52. package/dist/azure/index.js.map +1 -1
  53. package/dist/azure/report.d.ts.map +1 -1
  54. package/dist/azure/report.js +178 -29
  55. package/dist/azure/report.js.map +1 -1
  56. package/dist/azure/resources/__tests__/storage.test.d.ts +11 -0
  57. package/dist/azure/resources/__tests__/storage.test.d.ts.map +1 -0
  58. package/dist/azure/resources/__tests__/storage.test.js +99 -0
  59. package/dist/azure/resources/__tests__/storage.test.js.map +1 -0
  60. package/dist/azure/types.d.ts +28 -1
  61. package/dist/azure/types.d.ts.map +1 -1
  62. package/dist/index.d.ts +1 -1
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.test.d.ts +2 -0
  65. package/dist/index.test.d.ts.map +1 -0
  66. package/dist/index.test.js +78 -0
  67. package/dist/index.test.js.map +1 -0
  68. package/dist/schema.d.ts +4 -0
  69. package/dist/schema.d.ts.map +1 -1
  70. package/dist/schema.js +9 -0
  71. package/dist/schema.js.map +1 -1
  72. package/package.json +5 -3
  73. package/src/azure/__tests__/analyzer-tags.test.ts +74 -0
  74. package/src/azure/__tests__/report.test.ts +35 -6
  75. package/src/azure/analyzer.ts +421 -65
  76. package/src/azure/analyzers/__tests__/advisor.test.ts +367 -0
  77. package/src/azure/analyzers/advisor.ts +324 -0
  78. package/src/azure/analyzers/index.ts +9 -1
  79. package/src/azure/analyzers/registry.ts +12 -0
  80. package/src/azure/analyzers/subscription.ts +56 -0
  81. package/src/azure/config.ts +1 -0
  82. package/src/azure/index.ts +1 -0
  83. package/src/azure/report.ts +206 -35
  84. package/src/azure/types.ts +29 -1
  85. package/src/index.ts +1 -1
  86. package/src/schema.ts +9 -0
package/README.md CHANGED
@@ -75,6 +75,7 @@ yarn add @pagopa/dx-savemoney
75
75
 
76
76
  - **Multi-Subscription Analysis**: Scans multiple Azure subscriptions in a single command.
77
77
  - **Intelligent Detection**: Uses Azure Monitor metrics (e.g. CPU, network traffic, transactions) to scientifically identify inactive resources.
78
+ - **Azure Advisor Integration**: Fetches Cost recommendations directly from Azure Advisor, including Reserved Instance and Savings Plan opportunities with estimated monthly savings.
78
79
  - **Orphaned Resource Identification**: Detects commonly "forgotten" resources like unattached disks, unassociated public IPs, and unused network interfaces.
79
80
  - **Flexible Reporting**: Offers multiple output formats:
80
81
  - `table`: A human-readable summary for the terminal.
@@ -89,17 +90,18 @@ yarn add @pagopa/dx-savemoney
89
90
 
90
91
  The tool analyzes the following Azure resource types with specific detection methods and risk levels:
91
92
 
92
- | Resource Type | Detection Method | Cost Risk | What's Checked |
93
- | :---------------------- | :---------------------- | :-------: | :------------------------------------------------------------------------------------------------------------------ |
94
- | **Virtual Machines** | Instance View + Metrics | 🔴 High | Deallocated/stopped state, Low CPU usage (<1%), Low network traffic (<3MB per days) |
95
- | **App Service Plans** | API Details + Metrics | 🔴 High | No apps deployed, Very low CPU (<5%), Very low memory (<10%), Oversized Premium tier |
96
- | **Container Apps** | API Details + Metrics | 🟡 Medium | Not running state, Zero replicas configured, Low CPU (<0.001 cores), Low memory (<10MB), Low network traffic (<1MB) |
97
- | **Managed Disks** | API Details | 🟡 Medium | Unattached state, No `managedBy` property |
98
- | **Public IP Addresses** | API Details + Metrics | 🟡 Medium | Not associated with any resource, Static IP not in use, Very low network traffic (<~340KB per day) |
99
- | **Network Interfaces** | API Details | 🟡 Medium | Not attached to VM or Private Endpoint, No public IP assigned |
100
- | **Private Endpoints** | API Details | 🟡 Medium | No private link connections, Rejected/disconnected connections, No network interfaces |
101
- | **Storage Accounts** | Metrics | 🟡 Medium | Very low transaction count (<10 per days in timespan) |
102
- | **Static Web Apps** | Metrics | 🟢 Low | No traffic data available, Very low site hits (<100 requests in 30 days), Very low data transfer (<1MB in 30 days) |
93
+ | Resource Type | Detection Method | Cost Risk | What's Checked |
94
+ | :---------------------- | :---------------------- | :-------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------- |
95
+ | **Virtual Machines** | Instance View + Metrics | 🔴 High | Deallocated/stopped state, Low CPU usage (<1%), Low network traffic (<3MB per days) |
96
+ | **App Service Plans** | API Details + Metrics | 🔴 High | No apps deployed, Very low CPU (<5%), Very low memory (<10%), Oversized Premium tier |
97
+ | **Container Apps** | API Details + Metrics | 🟡 Medium | Not running state, Zero replicas configured, Low CPU (<0.001 cores), Low memory (<10MB), Low network traffic (<1MB) |
98
+ | **Managed Disks** | API Details | 🟡 Medium | Unattached state, No `managedBy` property |
99
+ | **Public IP Addresses** | API Details + Metrics | 🟡 Medium | Not associated with any resource, Static IP not in use, Very low network traffic (<~340KB per day) |
100
+ | **Network Interfaces** | API Details | 🟡 Medium | Not attached to VM or Private Endpoint, No public IP assigned |
101
+ | **Private Endpoints** | API Details | 🟡 Medium | No private link connections, Rejected/disconnected connections, No network interfaces |
102
+ | **Storage Accounts** | Metrics | 🟡 Medium | Very low transaction count (<10 per days in timespan) |
103
+ | **Static Web Apps** | Metrics | 🟢 Low | No traffic data available, Very low site hits (<100 requests in 30 days), Very low data transfer (<1MB in 30 days) |
104
+ | **Azure Advisor** | Advisor API | ⚪ Varies | Reserved Instance and Savings Plan opportunities, right-sizing suggestions, and other Cost recommendations — with estimated monthly savings where available |
103
105
 
104
106
  #### Generic Checks
105
107
 
@@ -128,8 +130,9 @@ import { azure, loadConfig } from "@pagopa/dx-savemoney";
128
130
  // Load configuration (from file, env vars, or interactive prompt)
129
131
  const config = await loadConfig("./config.yaml");
130
132
 
131
- // Run analysis and generate report
132
- await azure.analyzeAzureResources(config, "table");
133
+ // Run analysis, then generate report
134
+ const reports = await azure.analyzeAzureResources(config);
135
+ await azure.generateReport(reports, "table");
133
136
  ```
134
137
 
135
138
  #### Configuration Inputs
@@ -141,6 +144,7 @@ The tool requires the following configuration:
141
144
  | `subscriptionIds` | `string[]` | ✅ | - | Array of Azure subscription IDs to analyze |
142
145
  | `preferredLocation` | `string` | ❌ | `italynorth` | Preferred Azure region (resources elsewhere will be flagged) |
143
146
  | `timespanDays` | `number` | ❌ | `30` | Number of days to look back for metrics analysis |
147
+ | `sources` | `FindingSource[]` | ❌ | all sources | Restrict analysis to specific sources: `"advisor"`, `"custom"`, or both |
144
148
  | `verbose` | `boolean` | ❌ | `false` | Enable detailed logging for each resource analyzed |
145
149
  | `filterTags` | `Record<string, string>` | ❌ | - | Only analyze resources matching **all** the specified tag key-value pairs |
146
150
  | `thresholds` | `Thresholds` | ❌ | see below | Override the default numeric thresholds used during analysis |
@@ -250,16 +254,14 @@ import { azure, loadConfig } from "@pagopa/dx-savemoney";
250
254
 
251
255
  const config = await loadConfig("./config.yaml");
252
256
  // Analyze only resources tagged with environment=prod AND team=platform
253
- await azure.analyzeAzureResources(
254
- {
255
- ...config,
256
- filterTags: new Map([
257
- ["environment", "prod"],
258
- ["team", "platform"],
259
- ]),
260
- },
261
- "lint",
262
- );
257
+ const reports = await azure.analyzeAzureResources({
258
+ ...config,
259
+ filterTags: new Map([
260
+ ["environment", "prod"],
261
+ ["team", "platform"],
262
+ ]),
263
+ });
264
+ await azure.generateReport(reports, "lint");
263
265
  ```
264
266
 
265
267
  ##### Basic Usage
@@ -269,7 +271,8 @@ import { azure, loadConfig } from "@pagopa/dx-savemoney";
269
271
 
270
272
  // Load from config file
271
273
  const config = await loadConfig("./config.yaml");
272
- await azure.analyzeAzureResources(config, "table");
274
+ const reports = await azure.analyzeAzureResources(config);
275
+ await azure.generateReport(reports, "table");
273
276
  ```
274
277
 
275
278
  ##### Custom Configuration
@@ -285,7 +288,8 @@ const config: AzureConfig = {
285
288
  verbose: true,
286
289
  };
287
290
 
288
- await azure.analyzeAzureResources(config, "json");
291
+ const reports = await azure.analyzeAzureResources(config);
292
+ await azure.generateReport(reports, "json");
289
293
  ```
290
294
 
291
295
  ##### Generate Detailed Report
@@ -295,7 +299,8 @@ import { azure, loadConfig } from "@pagopa/dx-savemoney";
295
299
 
296
300
  const config = await loadConfig();
297
301
  // Generate detailed JSON with full resource metadata
298
- await azure.analyzeAzureResources(config, "detailed-json");
302
+ const reports = await azure.analyzeAzureResources(config);
303
+ await azure.generateReport(reports, "detailed-json");
299
304
  ```
300
305
 
301
306
  ##### Using Environment Variables
@@ -307,7 +312,8 @@ import { loadConfig, azure } from "@pagopa/dx-savemoney";
307
312
  // ARM_SUBSCRIPTION_ID=sub1,sub2
308
313
 
309
314
  const config = await loadConfig(); // Will read from env vars
310
- await azure.analyzeAzureResources(config, "json");
315
+ const reports = await azure.analyzeAzureResources(config);
316
+ await azure.generateReport(reports, "json");
311
317
  ```
312
318
 
313
319
  ## AWS (Coming Soon)
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Tests for findingsFromAnalysisResult — verifies the adapter that converts
3
+ * a legacy `AnalysisResult.reason` string into a structured `Finding[]`.
4
+ *
5
+ * Behaviours covered:
6
+ * 1. Empty / whitespace-only reason → empty array.
7
+ * 2. Single sentence (with or without trailing period) → one finding.
8
+ * 3. Multi-sentence reason (". " separator) → one finding per sentence.
9
+ * 4. Trailing period on the whole string → not duplicated.
10
+ * 5. Custom `code` → propagated to every finding.
11
+ * 6. Omitted `code` → defaults to "custom.unknown".
12
+ * 7. Custom `source` → propagated.
13
+ * 8. Omitted `source` → defaults to "custom".
14
+ * 9. Every finding carries the correct `resourceId`, `severity`, `category`.
15
+ */
16
+ export {};
17
+ //# sourceMappingURL=finding.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"finding.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/finding.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG"}
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tests for findingsFromAnalysisResult — verifies the adapter that converts
3
+ * a legacy `AnalysisResult.reason` string into a structured `Finding[]`.
4
+ *
5
+ * Behaviours covered:
6
+ * 1. Empty / whitespace-only reason → empty array.
7
+ * 2. Single sentence (with or without trailing period) → one finding.
8
+ * 3. Multi-sentence reason (". " separator) → one finding per sentence.
9
+ * 4. Trailing period on the whole string → not duplicated.
10
+ * 5. Custom `code` → propagated to every finding.
11
+ * 6. Omitted `code` → defaults to "custom.unknown".
12
+ * 7. Custom `source` → propagated.
13
+ * 8. Omitted `source` → defaults to "custom".
14
+ * 9. Every finding carries the correct `resourceId`, `severity`, `category`.
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+ import { findingsFromAnalysisResult } from "../finding.js";
18
+ const BASE_ARGS = {
19
+ reason: "Low CPU usage.",
20
+ resourceId: "/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm",
21
+ severity: "high",
22
+ };
23
+ describe("findingsFromAnalysisResult", () => {
24
+ describe("empty / whitespace input", () => {
25
+ it("returns [] for an empty string", () => {
26
+ expect(findingsFromAnalysisResult({ ...BASE_ARGS, reason: "" })).toEqual([]);
27
+ });
28
+ it("returns [] for a whitespace-only string", () => {
29
+ expect(findingsFromAnalysisResult({ ...BASE_ARGS, reason: " " })).toEqual([]);
30
+ });
31
+ it("returns [] for a bare period", () => {
32
+ expect(findingsFromAnalysisResult({ ...BASE_ARGS, reason: "." })).toEqual([]);
33
+ });
34
+ });
35
+ describe("single sentence", () => {
36
+ it("returns one finding with a trailing period", () => {
37
+ const result = findingsFromAnalysisResult({
38
+ ...BASE_ARGS,
39
+ reason: "VM is deallocated.",
40
+ });
41
+ expect(result).toHaveLength(1);
42
+ expect(result[0].reason).toBe("VM is deallocated.");
43
+ });
44
+ it("appends a trailing period when missing", () => {
45
+ const result = findingsFromAnalysisResult({
46
+ ...BASE_ARGS,
47
+ reason: "VM is deallocated",
48
+ });
49
+ expect(result).toHaveLength(1);
50
+ expect(result[0].reason).toBe("VM is deallocated.");
51
+ });
52
+ });
53
+ describe("multi-sentence reason", () => {
54
+ it("splits on '. ' into separate findings", () => {
55
+ const result = findingsFromAnalysisResult({
56
+ ...BASE_ARGS,
57
+ reason: "VM is deallocated. No tags found. Low CPU usage.",
58
+ });
59
+ expect(result).toHaveLength(3);
60
+ expect(result[0].reason).toBe("VM is deallocated.");
61
+ expect(result[1].reason).toBe("No tags found.");
62
+ expect(result[2].reason).toBe("Low CPU usage.");
63
+ });
64
+ it("does not create an extra empty finding for a trailing period", () => {
65
+ const result = findingsFromAnalysisResult({
66
+ ...BASE_ARGS,
67
+ reason: "Sentence one. Sentence two.",
68
+ });
69
+ expect(result).toHaveLength(2);
70
+ });
71
+ });
72
+ describe("code field", () => {
73
+ it("uses the provided code for every finding", () => {
74
+ const result = findingsFromAnalysisResult({
75
+ ...BASE_ARGS,
76
+ code: "vm.deallocated",
77
+ reason: "A. B.",
78
+ });
79
+ expect(result.every((f) => f.code === "vm.deallocated")).toBe(true);
80
+ });
81
+ it("defaults code to 'custom.unknown' when omitted", () => {
82
+ const result = findingsFromAnalysisResult(BASE_ARGS);
83
+ expect(result[0].code).toBe("custom.unknown");
84
+ });
85
+ });
86
+ describe("source field", () => {
87
+ it("uses the provided source for every finding", () => {
88
+ const result = findingsFromAnalysisResult({
89
+ ...BASE_ARGS,
90
+ source: "advisor",
91
+ });
92
+ expect(result.every((f) => f.source === "advisor")).toBe(true);
93
+ });
94
+ it("defaults source to 'custom' when omitted", () => {
95
+ const result = findingsFromAnalysisResult(BASE_ARGS);
96
+ expect(result[0].source).toBe("custom");
97
+ });
98
+ });
99
+ describe("static fields on every finding", () => {
100
+ it("propagates resourceId to every finding", () => {
101
+ const result = findingsFromAnalysisResult({
102
+ ...BASE_ARGS,
103
+ reason: "A. B.",
104
+ });
105
+ expect(result.every((f) => f.resourceId === BASE_ARGS.resourceId)).toBe(true);
106
+ });
107
+ it("propagates severity to every finding", () => {
108
+ const result = findingsFromAnalysisResult({
109
+ ...BASE_ARGS,
110
+ reason: "A. B.",
111
+ severity: "medium",
112
+ });
113
+ expect(result.every((f) => f.severity === "medium")).toBe(true);
114
+ });
115
+ it("sets category to 'cost' on every finding", () => {
116
+ const result = findingsFromAnalysisResult({
117
+ ...BASE_ARGS,
118
+ reason: "A. B.",
119
+ });
120
+ expect(result.every((f) => f.category === "cost")).toBe(true);
121
+ });
122
+ });
123
+ });
124
+ //# sourceMappingURL=finding.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"finding.test.js","sourceRoot":"","sources":["../../src/__tests__/finding.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Unit tests for tag-filter behavior on Advisor findings.
3
+ *
4
+ * These tests exercise the pure helper used by the Azure analyzer
5
+ * orchestrator to keep filtering semantics explicit and stable.
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=analyzer-tags.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analyzer-tags.test.d.ts","sourceRoot":"","sources":["../../../src/azure/__tests__/analyzer-tags.test.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Unit tests for tag-filter behavior on Advisor findings.
3
+ *
4
+ * These tests exercise the pure helper used by the Azure analyzer
5
+ * orchestrator to keep filtering semantics explicit and stable.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { shouldIncludeAdvisorFindingForTags } from "../analyzer.js";
9
+ function mkFinding(resourceId, source) {
10
+ return {
11
+ category: "cost",
12
+ code: `${source}.test`,
13
+ reason: "Test finding.",
14
+ resourceId,
15
+ severity: "low",
16
+ source,
17
+ };
18
+ }
19
+ describe("shouldIncludeAdvisorFindingForTags", () => {
20
+ it("includes all findings when no tag filter is active", () => {
21
+ const finding = mkFinding("/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1", "advisor");
22
+ expect(shouldIncludeAdvisorFindingForTags(finding, new Set(), false)).toBe(true);
23
+ });
24
+ it("keeps subscription-level Advisor findings global even with tag filters", () => {
25
+ const finding = mkFinding("/subscriptions/sub1", "advisor");
26
+ expect(shouldIncludeAdvisorFindingForTags(finding, new Set(), true)).toBe(true);
27
+ });
28
+ it("includes resource-level Advisor findings only when resource id is tag-matched", () => {
29
+ const finding = mkFinding("/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1", "advisor");
30
+ const taggedResourceIds = new Set([
31
+ "/subscriptions/sub1/resourcegroups/rg1/providers/microsoft.compute/virtualmachines/vm1",
32
+ ]);
33
+ expect(shouldIncludeAdvisorFindingForTags(finding, taggedResourceIds, true)).toBe(true);
34
+ });
35
+ it("excludes resource-level Advisor findings when resource id is not tag-matched", () => {
36
+ const finding = mkFinding("/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm2", "advisor");
37
+ const taggedResourceIds = new Set([
38
+ "/subscriptions/sub1/resourcegroups/rg1/providers/microsoft.compute/virtualmachines/vm1",
39
+ ]);
40
+ expect(shouldIncludeAdvisorFindingForTags(finding, taggedResourceIds, true)).toBe(false);
41
+ });
42
+ });
43
+ //# sourceMappingURL=analyzer-tags.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analyzer-tags.test.js","sourceRoot":"","sources":["../../../src/azure/__tests__/analyzer-tags.test.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAI9C,OAAO,EAAE,kCAAkC,EAAE,MAAM,gBAAgB,CAAC;AAEpE,SAAS,SAAS,CAAC,UAAkB,EAAE,MAAyB;IAC9D,OAAO;QACL,QAAQ,EAAE,MAAM;QAChB,IAAI,EAAE,GAAG,MAAM,OAAO;QACtB,MAAM,EAAE,eAAe;QACvB,UAAU;QACV,QAAQ,EAAE,KAAK;QACf,MAAM;KACP,CAAC;AACJ,CAAC;AAED,QAAQ,CAAC,oCAAoC,EAAE,GAAG,EAAE;IAClD,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,MAAM,OAAO,GAAG,SAAS,CACvB,wFAAwF,EACxF,SAAS,CACV,CAAC;QAEF,MAAM,CACJ,kCAAkC,CAAC,OAAO,EAAE,IAAI,GAAG,EAAU,EAAE,KAAK,CAAC,CACtE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACf,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wEAAwE,EAAE,GAAG,EAAE;QAChF,MAAM,OAAO,GAAG,SAAS,CAAC,qBAAqB,EAAE,SAAS,CAAC,CAAC;QAE5D,MAAM,CACJ,kCAAkC,CAAC,OAAO,EAAE,IAAI,GAAG,EAAU,EAAE,IAAI,CAAC,CACrE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACf,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+EAA+E,EAAE,GAAG,EAAE;QACvF,MAAM,OAAO,GAAG,SAAS,CACvB,wFAAwF,EACxF,SAAS,CACV,CAAC;QAEF,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAS;YACxC,wFAAwF;SACzF,CAAC,CAAC;QAEH,MAAM,CACJ,kCAAkC,CAAC,OAAO,EAAE,iBAAiB,EAAE,IAAI,CAAC,CACrE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACf,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8EAA8E,EAAE,GAAG,EAAE;QACtF,MAAM,OAAO,GAAG,SAAS,CACvB,wFAAwF,EACxF,SAAS,CACV,CAAC;QAEF,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAS;YACxC,wFAAwF;SACzF,CAAC,CAAC;QAEH,MAAM,CACJ,kCAAkC,CAAC,OAAO,EAAE,iBAAiB,EAAE,IAAI,CAAC,CACrE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAChB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Tests for loadConfig() — verifies that:
3
+ * 1. Returns default thresholds and prompts for subscriptionIds when no file is given.
4
+ * 2. Loads subscriptionIds, location, timespanDays and thresholds from a YAML file.
5
+ * 3. Partial threshold overrides keep defaults for non-overridden fields.
6
+ * 4. Throws a clear error for a non-existent explicit path.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=config.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.test.d.ts","sourceRoot":"","sources":["../../../src/azure/__tests__/config.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tests for loadConfig() — verifies that:
3
+ * 1. Returns default thresholds and prompts for subscriptionIds when no file is given.
4
+ * 2. Loads subscriptionIds, location, timespanDays and thresholds from a YAML file.
5
+ * 3. Partial threshold overrides keep defaults for non-overridden fields.
6
+ * 4. Throws a clear error for a non-existent explicit path.
7
+ */
8
+ import path from "path";
9
+ import { fileURLToPath } from "url";
10
+ import { describe, expect, it } from "vitest";
11
+ import { loadConfig } from "../../index.js";
12
+ import { DEFAULT_THRESHOLDS } from "../../types.js";
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ /** Absolute paths used in tests */
15
+ const FIXTURE_PARTIAL = path.resolve(__dirname, "fixtures/partial-override.yaml");
16
+ const FIXTURE_FULL = path.resolve(__dirname, "fixtures/full-override.yaml");
17
+ describe("loadConfig", () => {
18
+ it("throws when explicit path does not exist", async () => {
19
+ await expect(loadConfig("/nonexistent/config.yaml")).rejects.toThrow("Config file not found");
20
+ });
21
+ it("loads subscriptionIds and defaults from a partial YAML file", async () => {
22
+ const result = await loadConfig(FIXTURE_PARTIAL);
23
+ expect(result.subscriptionIds).toEqual([
24
+ "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
25
+ ]);
26
+ expect(result.preferredLocation).toBe("italynorth");
27
+ expect(result.timespanDays).toBe(30);
28
+ });
29
+ it("loads partial threshold overrides and keeps defaults for missing fields", async () => {
30
+ const result = await loadConfig(FIXTURE_PARTIAL);
31
+ // Overridden values
32
+ expect(result.thresholds?.vm.cpuPercent).toBe(5);
33
+ expect(result.thresholds?.storage.transactionsPerDay).toBe(50);
34
+ // Non-overridden vm field keeps default
35
+ expect(result.thresholds?.vm.networkInBytesPerDay).toBe(DEFAULT_THRESHOLDS.vm.networkInBytesPerDay);
36
+ // Entire non-overridden sections keep defaults
37
+ expect(result.thresholds?.appService).toEqual(DEFAULT_THRESHOLDS.appService);
38
+ expect(result.thresholds?.containerApp).toEqual(DEFAULT_THRESHOLDS.containerApp);
39
+ expect(result.thresholds?.publicIp).toEqual(DEFAULT_THRESHOLDS.publicIp);
40
+ expect(result.thresholds?.staticSite).toEqual(DEFAULT_THRESHOLDS.staticSite);
41
+ });
42
+ it("loads all values from a full YAML file", async () => {
43
+ const result = await loadConfig(FIXTURE_FULL);
44
+ expect(result.subscriptionIds).toHaveLength(2);
45
+ expect(result.preferredLocation).toBe("westeurope");
46
+ expect(result.timespanDays).toBe(60);
47
+ expect(result.thresholds?.vm.cpuPercent).toBe(5);
48
+ expect(result.thresholds?.vm.networkInBytesPerDay).toBe(10485760);
49
+ expect(result.thresholds?.appService.cpuPercent).toBe(10);
50
+ expect(result.thresholds?.appService.memoryPercent).toBe(20);
51
+ expect(result.thresholds?.appService.premiumCpuPercent).toBe(15);
52
+ expect(result.thresholds?.containerApp.cpuNanoCores).toBe(5000000);
53
+ expect(result.thresholds?.containerApp.memoryBytes).toBe(52428800);
54
+ expect(result.thresholds?.containerApp.networkBytes).toBe(100000);
55
+ expect(result.thresholds?.storage.transactionsPerDay).toBe(50);
56
+ expect(result.thresholds?.publicIp.bytesInDDoS).toBe(1048576);
57
+ expect(result.thresholds?.staticSite.siteHits).toBe(500);
58
+ expect(result.thresholds?.staticSite.bytesSent).toBe(5242880);
59
+ });
60
+ it("returns a complete Thresholds object (all required keys present)", async () => {
61
+ const result = await loadConfig(FIXTURE_PARTIAL);
62
+ expect(result.thresholds).toHaveProperty("vm");
63
+ expect(result.thresholds).toHaveProperty("appService");
64
+ expect(result.thresholds).toHaveProperty("containerApp");
65
+ expect(result.thresholds).toHaveProperty("storage");
66
+ expect(result.thresholds).toHaveProperty("publicIp");
67
+ expect(result.thresholds).toHaveProperty("staticSite");
68
+ });
69
+ });
70
+ //# sourceMappingURL=config.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.test.js","sourceRoot":"","sources":["../../../src/azure/__tests__/config.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Tests for generateReport() with the "lint" format.
3
+ *
4
+ * Since process.stdout.isTTY is false/undefined in test environments,
5
+ * ANSI color codes are empty strings (~no-ops), making the output
6
+ * easy to assert on plain text.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=report.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.test.d.ts","sourceRoot":"","sources":["../../../src/azure/__tests__/report.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Tests for generateReport() with the "lint" format.
3
+ *
4
+ * Since process.stdout.isTTY is false/undefined in test environments,
5
+ * ANSI color codes are empty strings (~no-ops), making the output
6
+ * easy to assert on plain text.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { generateReport } from "../report.js";
10
+ // ── test fixtures ──────────────────────────────────────────────────────────
11
+ function makeEntry(id, costRisk, reason, suspectedUnused = true) {
12
+ return {
13
+ analysis: { costRisk, reason, suspectedUnused },
14
+ resource: {
15
+ id,
16
+ name: id.split("/").pop() ?? "unknown",
17
+ type: "Microsoft.Compute/virtualMachines",
18
+ },
19
+ };
20
+ }
21
+ const HIGH_ENTRY = makeEntry("/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-high", "high", "VM is deallocated. No disk activity detected.");
22
+ const MEDIUM_ENTRY = makeEntry("/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-medium", "medium", "Low CPU usage.");
23
+ const LOW_ENTRY = makeEntry("/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm-low", "low", "Possible idle resource.", false);
24
+ // ── helpers ────────────────────────────────────────────────────────────────
25
+ function allLogs(spy) {
26
+ return spy.mock.calls.map((c) => c[0]).join("\n");
27
+ }
28
+ // ── tests ──────────────────────────────────────────────────────────────────
29
+ describe("generateReport — lint format", () => {
30
+ let logSpy;
31
+ beforeEach(() => {
32
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
33
+ });
34
+ afterEach(() => {
35
+ vi.restoreAllMocks();
36
+ });
37
+ it("prints the resource ID for each entry", async () => {
38
+ await generateReport([HIGH_ENTRY], "lint");
39
+ const output = allLogs(logSpy);
40
+ expect(output).toContain(HIGH_ENTRY.resource.id);
41
+ });
42
+ it("uses ✖ icon for high-risk resources", async () => {
43
+ await generateReport([HIGH_ENTRY], "lint");
44
+ const output = allLogs(logSpy);
45
+ expect(output).toContain("✖");
46
+ });
47
+ it("uses ⚠ icon for medium-risk resources", async () => {
48
+ await generateReport([MEDIUM_ENTRY], "lint");
49
+ const output = allLogs(logSpy);
50
+ expect(output).toContain("⚠");
51
+ });
52
+ it("uses ℹ icon for low-risk resources", async () => {
53
+ await generateReport([LOW_ENTRY], "lint");
54
+ const output = allLogs(logSpy);
55
+ expect(output).toContain("ℹ");
56
+ });
57
+ it("splits a multi-sentence reason into separate findings", async () => {
58
+ // Reason has two sentences separated by '. '
59
+ await generateReport([HIGH_ENTRY], "lint");
60
+ const calls = logSpy.mock.calls.map((c) => c[0] ?? "");
61
+ const findingLines = calls.filter((l) => l.startsWith(" "));
62
+ // "VM is deallocated." and "No disk activity detected." → 2 findings
63
+ expect(findingLines.length).toBe(2);
64
+ });
65
+ it("prints a Summary line at the end", async () => {
66
+ await generateReport([HIGH_ENTRY, MEDIUM_ENTRY, LOW_ENTRY], "lint");
67
+ const output = allLogs(logSpy);
68
+ expect(output).toContain("Summary:");
69
+ });
70
+ it("summary reports correct total issue count", async () => {
71
+ await generateReport([HIGH_ENTRY, MEDIUM_ENTRY, LOW_ENTRY], "lint");
72
+ const output = allLogs(logSpy);
73
+ // HIGH has 2 findings (split reason), MEDIUM and LOW have 1 each → total 4
74
+ expect(output).toContain("4 issues found");
75
+ });
76
+ it("summary reports correctly with a single issue (no plural)", async () => {
77
+ await generateReport([MEDIUM_ENTRY], "lint");
78
+ const output = allLogs(logSpy);
79
+ // "1 issue found" (not "1 issues found")
80
+ expect(output).toContain("1 issue found");
81
+ });
82
+ it("shows risk level label in uppercase for each finding", async () => {
83
+ await generateReport([HIGH_ENTRY], "lint");
84
+ const output = allLogs(logSpy);
85
+ expect(output).toContain("HIGH");
86
+ });
87
+ it("prints nothing but the summary for an empty report", async () => {
88
+ await generateReport([], "lint");
89
+ const calls = logSpy.mock.calls.map((c) => c[0]);
90
+ expect(calls).toHaveLength(1);
91
+ expect(calls[0]).toContain("0 issues found");
92
+ });
93
+ });
94
+ describe("generateReport — table format", () => {
95
+ let logSpy;
96
+ beforeEach(() => {
97
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
98
+ });
99
+ afterEach(() => {
100
+ vi.restoreAllMocks();
101
+ });
102
+ it("renders without throwing for an empty report", async () => {
103
+ await expect(generateReport([], "table")).resolves.toBeUndefined();
104
+ // Table is always printed; the summary line ("0 issues found") follows.
105
+ expect(logSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
106
+ const output = logSpy.mock.calls
107
+ .map((c) => String(c[0]))
108
+ .join("\n");
109
+ expect(output).toContain("0 issues found");
110
+ });
111
+ it("includes resource name and reason in the rendered table", async () => {
112
+ await generateReport([HIGH_ENTRY], "table");
113
+ const output = logSpy.mock.calls
114
+ .map((c) => String(c[0]))
115
+ .join("\n");
116
+ expect(output).toContain("vm-high");
117
+ expect(output).toContain("VM is deallocated");
118
+ });
119
+ });
120
+ //# sourceMappingURL=report.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.test.js","sourceRoot":"","sources":["../../../src/azure/__tests__/report.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAIzE,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,8EAA8E;AAE9E,SAAS,SAAS,CAChB,EAAU,EACV,QAAmC,EACnC,MAAc,EACd,eAAe,GAAG,IAAI;IAEtB,OAAO;QACL,QAAQ,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,eAAe,EAAE;QAC/C,QAAQ,EAAE;YACR,EAAE;YACF,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,IAAI,SAAS;YACtC,IAAI,EAAE,mCAAmC;SAC1C;KACF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,GAAG,SAAS,CAC1B,4FAA4F,EAC5F,MAAM,EACN,+CAA+C,CAChD,CAAC;AAEF,MAAM,YAAY,GAAG,SAAS,CAC5B,8FAA8F,EAC9F,QAAQ,EACR,gBAAgB,CACjB,CAAC;AAEF,MAAM,SAAS,GAAG,SAAS,CACzB,2FAA2F,EAC3F,KAAK,EACL,yBAAyB,EACzB,KAAK,CACN,CAAC;AAEF,8EAA8E;AAE9E,SAAS,OAAO,CAAC,GAAgC;IAC/C,OAAO,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAW,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC9D,CAAC;AAED,8EAA8E;AAE9E,QAAQ,CAAC,8BAA8B,EAAE,GAAG,EAAE;IAC5C,IAAI,MAAmC,CAAC;IAExC,UAAU,CAAC,GAAG,EAAE;QACd,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,kBAAkB,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;IACxE,CAAC,CAAC,CAAC;IAEH,SAAS,CAAC,GAAG,EAAE;QACb,EAAE,CAAC,eAAe,EAAE,CAAC;IACvB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,KAAK,IAAI,EAAE;QACrD,MAAM,cAAc,CAAC,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,CAAC;QAC3C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/B,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IACnD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qCAAqC,EAAE,KAAK,IAAI,EAAE;QACnD,MAAM,cAAc,CAAC,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,CAAC;QAC3C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/B,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IAChC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,KAAK,IAAI,EAAE;QACrD,MAAM,cAAc,CAAC,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC,CAAC;QAC7C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/B,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IAChC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oCAAoC,EAAE,KAAK,IAAI,EAAE;QAClD,MAAM,cAAc,CAAC,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC,CAAC;QAC1C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/B,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IAChC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uDAAuD,EAAE,KAAK,IAAI,EAAE;QACrE,6CAA6C;QAC7C,MAAM,cAAc,CAAC,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,CAAC;QAC3C,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAW,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QACjE,MAAM,YAAY,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;QACrE,qEAAqE;QACrE,MAAM,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kCAAkC,EAAE,KAAK,IAAI,EAAE;QAChD,MAAM,cAAc,CAAC,CAAC,UAAU,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,MAAM,CAAC,CAAC;QACpE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/B,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2CAA2C,EAAE,KAAK,IAAI,EAAE;QACzD,MAAM,cAAc,CAAC,CAAC,UAAU,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,MAAM,CAAC,CAAC;QACpE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/B,2EAA2E;QAC3E,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2DAA2D,EAAE,KAAK,IAAI,EAAE;QACzE,MAAM,cAAc,CAAC,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC,CAAC;QAC7C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/B,yCAAyC;QACzC,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,eAAe,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sDAAsD,EAAE,KAAK,IAAI,EAAE;QACpE,MAAM,cAAc,CAAC,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,CAAC;QAC3C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/B,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IACnC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;QAClE,MAAM,cAAc,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;QACjC,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAW,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3D,MAAM,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;QAC9B,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,+BAA+B,EAAE,GAAG,EAAE;IAC7C,IAAI,MAAmC,CAAC;IAExC,UAAU,CAAC,GAAG,EAAE;QACd,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,kBAAkB,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;IACxE,CAAC,CAAC,CAAC;IAEH,SAAS,CAAC,GAAG,EAAE;QACb,EAAE,CAAC,eAAe,EAAE,CAAC;IACvB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,8CAA8C,EAAE,KAAK,IAAI,EAAE;QAC5D,MAAM,MAAM,CAAC,cAAc,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,aAAa,EAAE,CAAC;QACnE,wEAAwE;QACxE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC;QAC3D,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK;aAC7B,GAAG,CAAC,CAAC,CAAW,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;aAClC,IAAI,CAAC,IAAI,CAAC,CAAC;QACd,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yDAAyD,EAAE,KAAK,IAAI,EAAE;QACvE,MAAM,cAAc,CAAC,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC,CAAC;QAC5C,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK;aAC7B,GAAG,CAAC,CAAC,CAAW,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;aAClC,IAAI,CAAC,IAAI,CAAC,CAAC;QACd,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC;QACpC,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,mBAAmB,CAAC,CAAC;IAChD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Tests for matchesTags() — verifies AND-logic tag filtering:
3
+ * 1. No filter (undefined/empty) → always include the resource.
4
+ * 2. Exact key-value match → include.
5
+ * 3. Key missing on resource → exclude.
6
+ * 4. Key present but wrong value → exclude.
7
+ * 5. Multiple tags: all match → include; any mismatch → exclude.
8
+ *
9
+ * Tests for getMetric() cache behaviour:
10
+ * 6. resetMetricsCache() clears state between runs.
11
+ * 7. Concurrent calls for the same key coalesce into one network call.
12
+ * 8. A failed call is cached as a fulfilled null result (not retried silently).
13
+ */
14
+ export {};
15
+ //# sourceMappingURL=utils.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.test.d.ts","sourceRoot":"","sources":["../../../src/azure/__tests__/utils.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG"}