runbooks 1.1.3__py3-none-any.whl → 1.1.5__py3-none-any.whl
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.
- runbooks/__init__.py +31 -2
- runbooks/__init___optimized.py +18 -4
- runbooks/_platform/__init__.py +1 -5
- runbooks/_platform/core/runbooks_wrapper.py +141 -138
- runbooks/aws2/accuracy_validator.py +812 -0
- runbooks/base.py +7 -0
- runbooks/cfat/WEIGHT_CONFIG_README.md +1 -1
- runbooks/cfat/assessment/compliance.py +8 -8
- runbooks/cfat/assessment/runner.py +1 -0
- runbooks/cfat/cloud_foundations_assessment.py +227 -239
- runbooks/cfat/models.py +6 -2
- runbooks/cfat/tests/__init__.py +6 -1
- runbooks/cli/__init__.py +13 -0
- runbooks/cli/commands/cfat.py +274 -0
- runbooks/cli/commands/finops.py +1164 -0
- runbooks/cli/commands/inventory.py +379 -0
- runbooks/cli/commands/operate.py +239 -0
- runbooks/cli/commands/security.py +248 -0
- runbooks/cli/commands/validation.py +825 -0
- runbooks/cli/commands/vpc.py +310 -0
- runbooks/cli/registry.py +107 -0
- runbooks/cloudops/__init__.py +23 -30
- runbooks/cloudops/base.py +96 -107
- runbooks/cloudops/cost_optimizer.py +549 -547
- runbooks/cloudops/infrastructure_optimizer.py +5 -4
- runbooks/cloudops/interfaces.py +226 -227
- runbooks/cloudops/lifecycle_manager.py +5 -4
- runbooks/cloudops/mcp_cost_validation.py +252 -235
- runbooks/cloudops/models.py +78 -53
- runbooks/cloudops/monitoring_automation.py +5 -4
- runbooks/cloudops/notebook_framework.py +179 -215
- runbooks/cloudops/security_enforcer.py +125 -159
- runbooks/common/accuracy_validator.py +11 -0
- runbooks/common/aws_pricing.py +349 -326
- runbooks/common/aws_pricing_api.py +211 -212
- runbooks/common/aws_profile_manager.py +341 -0
- runbooks/common/aws_utils.py +75 -80
- runbooks/common/business_logic.py +127 -105
- runbooks/common/cli_decorators.py +36 -60
- runbooks/common/comprehensive_cost_explorer_integration.py +456 -464
- runbooks/common/cross_account_manager.py +198 -205
- runbooks/common/date_utils.py +27 -39
- runbooks/common/decorators.py +235 -0
- runbooks/common/dry_run_examples.py +173 -208
- runbooks/common/dry_run_framework.py +157 -155
- runbooks/common/enhanced_exception_handler.py +15 -4
- runbooks/common/enhanced_logging_example.py +50 -64
- runbooks/common/enhanced_logging_integration_example.py +65 -37
- runbooks/common/env_utils.py +16 -16
- runbooks/common/error_handling.py +40 -38
- runbooks/common/lazy_loader.py +41 -23
- runbooks/common/logging_integration_helper.py +79 -86
- runbooks/common/mcp_cost_explorer_integration.py +478 -495
- runbooks/common/mcp_integration.py +63 -74
- runbooks/common/memory_optimization.py +140 -118
- runbooks/common/module_cli_base.py +37 -58
- runbooks/common/organizations_client.py +176 -194
- runbooks/common/patterns.py +204 -0
- runbooks/common/performance_monitoring.py +67 -71
- runbooks/common/performance_optimization_engine.py +283 -274
- runbooks/common/profile_utils.py +248 -39
- runbooks/common/rich_utils.py +643 -92
- runbooks/common/sre_performance_suite.py +177 -186
- runbooks/enterprise/__init__.py +1 -1
- runbooks/enterprise/logging.py +144 -106
- runbooks/enterprise/security.py +187 -204
- runbooks/enterprise/validation.py +43 -56
- runbooks/finops/__init__.py +29 -33
- runbooks/finops/account_resolver.py +1 -1
- runbooks/finops/advanced_optimization_engine.py +980 -0
- runbooks/finops/automation_core.py +268 -231
- runbooks/finops/business_case_config.py +184 -179
- runbooks/finops/cli.py +660 -139
- runbooks/finops/commvault_ec2_analysis.py +157 -164
- runbooks/finops/compute_cost_optimizer.py +336 -320
- runbooks/finops/config.py +20 -20
- runbooks/finops/cost_optimizer.py +488 -622
- runbooks/finops/cost_processor.py +332 -214
- runbooks/finops/dashboard_runner.py +1006 -172
- runbooks/finops/ebs_cost_optimizer.py +991 -657
- runbooks/finops/elastic_ip_optimizer.py +317 -257
- runbooks/finops/enhanced_mcp_integration.py +340 -0
- runbooks/finops/enhanced_progress.py +40 -37
- runbooks/finops/enhanced_trend_visualization.py +3 -2
- runbooks/finops/enterprise_wrappers.py +230 -292
- runbooks/finops/executive_export.py +203 -160
- runbooks/finops/helpers.py +130 -288
- runbooks/finops/iam_guidance.py +1 -1
- runbooks/finops/infrastructure/__init__.py +80 -0
- runbooks/finops/infrastructure/commands.py +506 -0
- runbooks/finops/infrastructure/load_balancer_optimizer.py +866 -0
- runbooks/finops/infrastructure/vpc_endpoint_optimizer.py +832 -0
- runbooks/finops/markdown_exporter.py +338 -175
- runbooks/finops/mcp_validator.py +1952 -0
- runbooks/finops/nat_gateway_optimizer.py +1513 -482
- runbooks/finops/network_cost_optimizer.py +657 -587
- runbooks/finops/notebook_utils.py +226 -188
- runbooks/finops/optimization_engine.py +1136 -0
- runbooks/finops/optimizer.py +25 -29
- runbooks/finops/rds_snapshot_optimizer.py +367 -411
- runbooks/finops/reservation_optimizer.py +427 -363
- runbooks/finops/scenario_cli_integration.py +77 -78
- runbooks/finops/scenarios.py +1278 -439
- runbooks/finops/schemas.py +218 -182
- runbooks/finops/snapshot_manager.py +2289 -0
- runbooks/finops/tests/test_finops_dashboard.py +3 -3
- runbooks/finops/tests/test_reference_images_validation.py +2 -2
- runbooks/finops/tests/test_single_account_features.py +17 -17
- runbooks/finops/tests/validate_test_suite.py +1 -1
- runbooks/finops/types.py +3 -3
- runbooks/finops/validation_framework.py +263 -269
- runbooks/finops/vpc_cleanup_exporter.py +191 -146
- runbooks/finops/vpc_cleanup_optimizer.py +593 -575
- runbooks/finops/workspaces_analyzer.py +171 -182
- runbooks/hitl/enhanced_workflow_engine.py +1 -1
- runbooks/integration/__init__.py +89 -0
- runbooks/integration/mcp_integration.py +1920 -0
- runbooks/inventory/CLAUDE.md +816 -0
- runbooks/inventory/README.md +3 -3
- runbooks/inventory/Tests/common_test_data.py +30 -30
- runbooks/inventory/__init__.py +2 -2
- runbooks/inventory/cloud_foundations_integration.py +144 -149
- runbooks/inventory/collectors/aws_comprehensive.py +28 -11
- runbooks/inventory/collectors/aws_networking.py +111 -101
- runbooks/inventory/collectors/base.py +4 -0
- runbooks/inventory/core/collector.py +495 -313
- runbooks/inventory/discovery.md +2 -2
- runbooks/inventory/drift_detection_cli.py +69 -96
- runbooks/inventory/find_ec2_security_groups.py +1 -1
- runbooks/inventory/inventory_mcp_cli.py +48 -46
- runbooks/inventory/list_rds_snapshots_aggregator.py +192 -208
- runbooks/inventory/mcp_inventory_validator.py +549 -465
- runbooks/inventory/mcp_vpc_validator.py +359 -442
- runbooks/inventory/organizations_discovery.py +56 -52
- runbooks/inventory/rich_inventory_display.py +33 -32
- runbooks/inventory/unified_validation_engine.py +278 -251
- runbooks/inventory/vpc_analyzer.py +733 -696
- runbooks/inventory/vpc_architecture_validator.py +293 -348
- runbooks/inventory/vpc_dependency_analyzer.py +382 -378
- runbooks/inventory/vpc_flow_analyzer.py +3 -3
- runbooks/main.py +152 -9147
- runbooks/main_final.py +91 -60
- runbooks/main_minimal.py +22 -10
- runbooks/main_optimized.py +131 -100
- runbooks/main_ultra_minimal.py +7 -2
- runbooks/mcp/__init__.py +36 -0
- runbooks/mcp/integration.py +679 -0
- runbooks/metrics/dora_metrics_engine.py +2 -2
- runbooks/monitoring/performance_monitor.py +9 -4
- runbooks/operate/dynamodb_operations.py +3 -1
- runbooks/operate/ec2_operations.py +145 -137
- runbooks/operate/iam_operations.py +146 -152
- runbooks/operate/mcp_integration.py +1 -1
- runbooks/operate/networking_cost_heatmap.py +33 -10
- runbooks/operate/privatelink_operations.py +1 -1
- runbooks/operate/rds_operations.py +223 -254
- runbooks/operate/s3_operations.py +107 -118
- runbooks/operate/vpc_endpoints.py +1 -1
- runbooks/operate/vpc_operations.py +648 -618
- runbooks/remediation/base.py +1 -1
- runbooks/remediation/commons.py +10 -7
- runbooks/remediation/commvault_ec2_analysis.py +71 -67
- runbooks/remediation/ec2_unattached_ebs_volumes.py +1 -0
- runbooks/remediation/multi_account.py +24 -21
- runbooks/remediation/rds_snapshot_list.py +91 -65
- runbooks/remediation/remediation_cli.py +92 -146
- runbooks/remediation/universal_account_discovery.py +83 -79
- runbooks/remediation/workspaces_list.py +49 -44
- runbooks/security/__init__.py +19 -0
- runbooks/security/assessment_runner.py +1150 -0
- runbooks/security/baseline_checker.py +812 -0
- runbooks/security/cloudops_automation_security_validator.py +509 -535
- runbooks/security/compliance_automation_engine.py +17 -17
- runbooks/security/config/__init__.py +2 -2
- runbooks/security/config/compliance_config.py +50 -50
- runbooks/security/config_template_generator.py +63 -76
- runbooks/security/enterprise_security_framework.py +1 -1
- runbooks/security/executive_security_dashboard.py +519 -508
- runbooks/security/integration_test_enterprise_security.py +5 -3
- runbooks/security/multi_account_security_controls.py +959 -1210
- runbooks/security/real_time_security_monitor.py +422 -444
- runbooks/security/run_script.py +1 -1
- runbooks/security/security_baseline_tester.py +1 -1
- runbooks/security/security_cli.py +143 -112
- runbooks/security/test_2way_validation.py +439 -0
- runbooks/security/two_way_validation_framework.py +852 -0
- runbooks/sre/mcp_reliability_engine.py +6 -6
- runbooks/sre/production_monitoring_framework.py +167 -177
- runbooks/tdd/__init__.py +15 -0
- runbooks/tdd/cli.py +1071 -0
- runbooks/utils/__init__.py +14 -17
- runbooks/utils/logger.py +7 -2
- runbooks/utils/version_validator.py +51 -48
- runbooks/validation/__init__.py +6 -6
- runbooks/validation/cli.py +9 -3
- runbooks/validation/comprehensive_2way_validator.py +754 -708
- runbooks/validation/mcp_validator.py +906 -228
- runbooks/validation/terraform_citations_validator.py +104 -115
- runbooks/validation/terraform_drift_detector.py +447 -451
- runbooks/vpc/README.md +617 -0
- runbooks/vpc/__init__.py +8 -1
- runbooks/vpc/analyzer.py +577 -0
- runbooks/vpc/cleanup_wrapper.py +476 -413
- runbooks/vpc/cli_cloudtrail_commands.py +339 -0
- runbooks/vpc/cli_mcp_validation_commands.py +480 -0
- runbooks/vpc/cloudtrail_audit_integration.py +717 -0
- runbooks/vpc/config.py +92 -97
- runbooks/vpc/cost_engine.py +411 -148
- runbooks/vpc/cost_explorer_integration.py +553 -0
- runbooks/vpc/cross_account_session.py +101 -106
- runbooks/vpc/enhanced_mcp_validation.py +917 -0
- runbooks/vpc/eni_gate_validator.py +961 -0
- runbooks/vpc/heatmap_engine.py +190 -162
- runbooks/vpc/mcp_no_eni_validator.py +681 -640
- runbooks/vpc/nat_gateway_optimizer.py +358 -0
- runbooks/vpc/networking_wrapper.py +15 -8
- runbooks/vpc/pdca_remediation_planner.py +528 -0
- runbooks/vpc/performance_optimized_analyzer.py +219 -231
- runbooks/vpc/runbooks_adapter.py +1167 -241
- runbooks/vpc/tdd_red_phase_stubs.py +601 -0
- runbooks/vpc/test_data_loader.py +358 -0
- runbooks/vpc/tests/conftest.py +314 -4
- runbooks/vpc/tests/test_cleanup_framework.py +1022 -0
- runbooks/vpc/tests/test_cost_engine.py +0 -2
- runbooks/vpc/topology_generator.py +326 -0
- runbooks/vpc/unified_scenarios.py +1302 -1129
- runbooks/vpc/vpc_cleanup_integration.py +1943 -1115
- runbooks-1.1.5.dist-info/METADATA +328 -0
- {runbooks-1.1.3.dist-info → runbooks-1.1.5.dist-info}/RECORD +233 -200
- runbooks/finops/README.md +0 -414
- runbooks/finops/accuracy_cross_validator.py +0 -647
- runbooks/finops/business_cases.py +0 -950
- runbooks/finops/dashboard_router.py +0 -922
- runbooks/finops/ebs_optimizer.py +0 -956
- runbooks/finops/embedded_mcp_validator.py +0 -1629
- runbooks/finops/enhanced_dashboard_runner.py +0 -527
- runbooks/finops/finops_dashboard.py +0 -584
- runbooks/finops/finops_scenarios.py +0 -1218
- runbooks/finops/legacy_migration.py +0 -730
- runbooks/finops/multi_dashboard.py +0 -1519
- runbooks/finops/single_dashboard.py +0 -1113
- runbooks/finops/unlimited_scenarios.py +0 -393
- runbooks-1.1.3.dist-info/METADATA +0 -799
- {runbooks-1.1.3.dist-info → runbooks-1.1.5.dist-info}/WHEEL +0 -0
- {runbooks-1.1.3.dist-info → runbooks-1.1.5.dist-info}/entry_points.txt +0 -0
- {runbooks-1.1.3.dist-info → runbooks-1.1.5.dist-info}/licenses/LICENSE +0 -0
- {runbooks-1.1.3.dist-info → runbooks-1.1.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1022 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
VPC Cleanup Framework - Comprehensive Test Suite
|
4
|
+
==================================================
|
5
|
+
|
6
|
+
Consolidates all VPC cleanup framework tests (104 tests from tests/vpc-cleanup/tests/).
|
7
|
+
Tests config validation, query generation, attribution logic, CSV output, error handling, and multi-LZ reusability.
|
8
|
+
|
9
|
+
Strategic Context: qa-testing-specialist validation for config-driven VPC cleanup framework
|
10
|
+
"""
|
11
|
+
|
12
|
+
import json
|
13
|
+
import sys
|
14
|
+
import tempfile
|
15
|
+
from datetime import datetime, timedelta
|
16
|
+
from decimal import Decimal
|
17
|
+
from pathlib import Path
|
18
|
+
from unittest.mock import MagicMock, Mock, patch
|
19
|
+
|
20
|
+
import pytest
|
21
|
+
import yaml
|
22
|
+
|
23
|
+
# Ensure runbooks package is importable
|
24
|
+
src_path = Path(__file__).parent.parent.parent.parent
|
25
|
+
if str(src_path) not in sys.path:
|
26
|
+
sys.path.insert(0, str(src_path))
|
27
|
+
|
28
|
+
from runbooks.vpc.vpc_cleanup_integration import VPCCleanupFramework
|
29
|
+
|
30
|
+
|
31
|
+
# ========================================
|
32
|
+
# Config Validation Tests (35 tests)
|
33
|
+
# ========================================
|
34
|
+
|
35
|
+
|
36
|
+
@pytest.mark.unit
|
37
|
+
class TestConfigValidation:
|
38
|
+
"""Test configuration validation logic"""
|
39
|
+
|
40
|
+
def test_valid_config_loads_successfully(self, cleanup_valid_config):
|
41
|
+
"""Test AWS-25 reference config loads without errors"""
|
42
|
+
assert "campaign_metadata" in cleanup_valid_config
|
43
|
+
assert "deleted_vpcs" in cleanup_valid_config
|
44
|
+
assert "cost_explorer_config" in cleanup_valid_config
|
45
|
+
|
46
|
+
def test_minimal_valid_config_structure(self, cleanup_valid_config):
|
47
|
+
"""Test minimal valid config has required structure"""
|
48
|
+
assert cleanup_valid_config["campaign_metadata"]["campaign_id"]
|
49
|
+
assert len(cleanup_valid_config["deleted_vpcs"]) > 0
|
50
|
+
|
51
|
+
def test_config_has_all_required_sections(self, cleanup_valid_config):
|
52
|
+
"""Test config contains all required sections"""
|
53
|
+
required_sections = [
|
54
|
+
"campaign_metadata",
|
55
|
+
"deleted_vpcs",
|
56
|
+
"cost_explorer_config",
|
57
|
+
"attribution_rules",
|
58
|
+
"output_config",
|
59
|
+
]
|
60
|
+
for section in required_sections:
|
61
|
+
assert section in cleanup_valid_config, f"Missing required section: {section}"
|
62
|
+
|
63
|
+
def test_campaign_metadata_validation(self, cleanup_valid_config):
|
64
|
+
"""Test campaign_metadata section has required fields"""
|
65
|
+
metadata = cleanup_valid_config["campaign_metadata"]
|
66
|
+
required_fields = ["campaign_id", "campaign_name", "execution_date", "aws_billing_profile"]
|
67
|
+
for field in required_fields:
|
68
|
+
assert field in metadata, f"Missing required field in campaign_metadata: {field}"
|
69
|
+
|
70
|
+
def test_deleted_vpcs_validation(self, cleanup_valid_config):
|
71
|
+
"""Test deleted_vpcs section structure"""
|
72
|
+
deleted_vpcs = cleanup_valid_config["deleted_vpcs"]
|
73
|
+
assert isinstance(deleted_vpcs, list)
|
74
|
+
assert len(deleted_vpcs) > 0
|
75
|
+
|
76
|
+
# Validate first VPC entry
|
77
|
+
vpc = deleted_vpcs[0]
|
78
|
+
required_vpc_fields = ["vpc_id", "account_id", "region", "deletion_date"]
|
79
|
+
for field in required_vpc_fields:
|
80
|
+
assert field in vpc, f"Missing required field in deleted_vpcs: {field}"
|
81
|
+
|
82
|
+
def test_vpc_id_format_validation(self, cleanup_valid_config):
|
83
|
+
"""Test VPC ID follows AWS format (vpc-*)"""
|
84
|
+
for vpc in cleanup_valid_config["deleted_vpcs"]:
|
85
|
+
vpc_id = vpc["vpc_id"]
|
86
|
+
assert vpc_id.startswith("vpc-"), f"Invalid VPC ID format: {vpc_id}"
|
87
|
+
assert len(vpc_id) >= 8, f"VPC ID too short: {vpc_id}"
|
88
|
+
|
89
|
+
def test_account_id_format_validation(self, cleanup_valid_config):
|
90
|
+
"""Test account ID is 12-digit numeric string"""
|
91
|
+
for vpc in cleanup_valid_config["deleted_vpcs"]:
|
92
|
+
account_id = vpc["account_id"]
|
93
|
+
assert len(account_id) == 12, f"Account ID must be 12 digits: {account_id}"
|
94
|
+
assert account_id.isdigit(), f"Account ID must be numeric: {account_id}"
|
95
|
+
|
96
|
+
def test_deletion_date_format_validation(self, cleanup_valid_config):
|
97
|
+
"""Test deletion_date follows YYYY-MM-DD format"""
|
98
|
+
for vpc in cleanup_valid_config["deleted_vpcs"]:
|
99
|
+
deletion_date = vpc["deletion_date"]
|
100
|
+
# Validate format by parsing
|
101
|
+
datetime.strptime(deletion_date, "%Y-%m-%d")
|
102
|
+
|
103
|
+
def test_region_validation(self, cleanup_valid_config):
|
104
|
+
"""Test region follows AWS region format"""
|
105
|
+
valid_regions = [
|
106
|
+
"us-east-1",
|
107
|
+
"us-west-2",
|
108
|
+
"eu-west-1",
|
109
|
+
"ap-southeast-2",
|
110
|
+
"ap-southeast-1",
|
111
|
+
"us-east-2",
|
112
|
+
"eu-central-1",
|
113
|
+
]
|
114
|
+
for vpc in cleanup_valid_config["deleted_vpcs"]:
|
115
|
+
region = vpc["region"]
|
116
|
+
# Check format: xx-xxxx-N
|
117
|
+
parts = region.split("-")
|
118
|
+
assert len(parts) == 3, f"Invalid region format: {region}"
|
119
|
+
assert parts[-1].isdigit(), f"Region must end with digit: {region}"
|
120
|
+
|
121
|
+
def test_cost_explorer_config_validation(self, cleanup_valid_config):
|
122
|
+
"""Test cost_explorer_config section structure"""
|
123
|
+
ce_config = cleanup_valid_config["cost_explorer_config"]
|
124
|
+
assert "metrics" in ce_config
|
125
|
+
assert "group_by_dimensions" in ce_config
|
126
|
+
assert isinstance(ce_config["metrics"], list)
|
127
|
+
|
128
|
+
def test_attribution_rules_validation(self, cleanup_valid_config):
|
129
|
+
"""Test attribution_rules section structure"""
|
130
|
+
rules = cleanup_valid_config["attribution_rules"]
|
131
|
+
assert "vpc_specific_services" in rules
|
132
|
+
assert "confidence_level" in rules["vpc_specific_services"]
|
133
|
+
assert "attribution_percentage" in rules["vpc_specific_services"]
|
134
|
+
|
135
|
+
def test_output_config_validation(self, cleanup_valid_config):
|
136
|
+
"""Test output_config section structure"""
|
137
|
+
output_config = cleanup_valid_config["output_config"]
|
138
|
+
required_fields = ["csv_output_file", "csv_columns", "json_results_file"]
|
139
|
+
for field in required_fields:
|
140
|
+
assert field in output_config, f"Missing field in output_config: {field}"
|
141
|
+
|
142
|
+
def test_config_with_missing_section_raises_error(self):
|
143
|
+
"""Test config with missing required section raises error"""
|
144
|
+
invalid_config = {"campaign_metadata": {"campaign_id": "TEST"}}
|
145
|
+
|
146
|
+
with pytest.raises(KeyError):
|
147
|
+
# Try to access missing section
|
148
|
+
_ = invalid_config["deleted_vpcs"]
|
149
|
+
|
150
|
+
def test_config_with_invalid_vpc_id_format(self):
|
151
|
+
"""Test config with invalid VPC ID format"""
|
152
|
+
config_with_bad_vpc = {
|
153
|
+
"deleted_vpcs": [
|
154
|
+
{
|
155
|
+
"vpc_id": "invalid-vpc-id", # Should start with vpc-
|
156
|
+
"account_id": "123456789012",
|
157
|
+
"region": "us-east-1",
|
158
|
+
"deletion_date": "2025-09-10",
|
159
|
+
}
|
160
|
+
]
|
161
|
+
}
|
162
|
+
|
163
|
+
vpc_id = config_with_bad_vpc["deleted_vpcs"][0]["vpc_id"]
|
164
|
+
assert not vpc_id.startswith("vpc-"), "Should detect invalid VPC ID format"
|
165
|
+
|
166
|
+
def test_config_with_invalid_account_id(self):
|
167
|
+
"""Test config with invalid account ID"""
|
168
|
+
config_with_bad_account = {
|
169
|
+
"deleted_vpcs": [
|
170
|
+
{
|
171
|
+
"vpc_id": "vpc-test123",
|
172
|
+
"account_id": "invalid-account", # Should be 12 digits
|
173
|
+
"region": "us-east-1",
|
174
|
+
"deletion_date": "2025-09-10",
|
175
|
+
}
|
176
|
+
]
|
177
|
+
}
|
178
|
+
|
179
|
+
account_id = config_with_bad_account["deleted_vpcs"][0]["account_id"]
|
180
|
+
assert not account_id.isdigit(), "Should detect invalid account ID"
|
181
|
+
|
182
|
+
def test_config_with_invalid_date_format(self):
|
183
|
+
"""Test config with invalid date format raises error"""
|
184
|
+
invalid_date = "09/10/2025" # Should be YYYY-MM-DD
|
185
|
+
|
186
|
+
with pytest.raises(ValueError):
|
187
|
+
datetime.strptime(invalid_date, "%Y-%m-%d")
|
188
|
+
|
189
|
+
def test_load_nonexistent_config_file_raises_error(self):
|
190
|
+
"""Test loading non-existent config file raises FileNotFoundError"""
|
191
|
+
with pytest.raises(FileNotFoundError):
|
192
|
+
with open("/nonexistent/path/config.yaml") as f:
|
193
|
+
yaml.safe_load(f)
|
194
|
+
|
195
|
+
def test_malformed_yaml_raises_error(self):
|
196
|
+
"""Test malformed YAML syntax raises error"""
|
197
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
198
|
+
# Write invalid YAML
|
199
|
+
f.write("invalid: yaml: syntax:\n - broken\n bad_indent")
|
200
|
+
temp_path = f.name
|
201
|
+
|
202
|
+
try:
|
203
|
+
with pytest.raises(yaml.YAMLError):
|
204
|
+
with open(temp_path) as f:
|
205
|
+
yaml.safe_load(f)
|
206
|
+
finally:
|
207
|
+
Path(temp_path).unlink(missing_ok=True)
|
208
|
+
|
209
|
+
def test_multiple_vpcs_validation(self, cleanup_sample_vpc_deletions):
|
210
|
+
"""Test validation of multiple VPC deletions"""
|
211
|
+
assert len(cleanup_sample_vpc_deletions) == 3
|
212
|
+
for vpc in cleanup_sample_vpc_deletions:
|
213
|
+
assert vpc["vpc_id"].startswith("vpc-")
|
214
|
+
assert len(vpc["account_id"]) == 12
|
215
|
+
|
216
|
+
def test_vpc_deletion_required_fields(self, cleanup_sample_vpc_deletion):
|
217
|
+
"""Test VPC deletion has all required fields"""
|
218
|
+
required_fields = ["vpc_id", "account_id", "region", "deletion_date", "deletion_principal"]
|
219
|
+
for field in required_fields:
|
220
|
+
assert field in cleanup_sample_vpc_deletion
|
221
|
+
|
222
|
+
def test_pre_deletion_baseline_months_validation(self, cleanup_sample_vpc_deletion):
|
223
|
+
"""Test pre_deletion_baseline_months is valid integer"""
|
224
|
+
baseline_months = cleanup_sample_vpc_deletion.get("pre_deletion_baseline_months", 3)
|
225
|
+
assert isinstance(baseline_months, int)
|
226
|
+
assert baseline_months > 0
|
227
|
+
assert baseline_months <= 12, "Baseline should not exceed 12 months"
|
228
|
+
|
229
|
+
def test_deletion_principal_format(self, cleanup_sample_vpc_deletion):
|
230
|
+
"""Test deletion_principal follows email format"""
|
231
|
+
principal = cleanup_sample_vpc_deletion.get("deletion_principal", "")
|
232
|
+
assert "@" in principal, "Deletion principal should be email format"
|
233
|
+
assert "." in principal, "Deletion principal should have domain"
|
234
|
+
|
235
|
+
def test_cost_explorer_metrics_validation(self, cleanup_valid_config):
|
236
|
+
"""Test Cost Explorer metrics are valid"""
|
237
|
+
metrics = cleanup_valid_config["cost_explorer_config"]["metrics"]
|
238
|
+
valid_metrics = ["UnblendedCost", "BlendedCost", "UsageQuantity"]
|
239
|
+
for metric in metrics:
|
240
|
+
assert metric in valid_metrics, f"Invalid metric: {metric}"
|
241
|
+
|
242
|
+
def test_cost_explorer_granularity_validation(self, cleanup_valid_config):
|
243
|
+
"""Test Cost Explorer granularity settings"""
|
244
|
+
ce_config = cleanup_valid_config["cost_explorer_config"]
|
245
|
+
if "pre_deletion_baseline" in ce_config:
|
246
|
+
baseline = ce_config["pre_deletion_baseline"]
|
247
|
+
assert "granularity_monthly" in baseline or "months_before_deletion" in baseline
|
248
|
+
|
249
|
+
def test_attribution_percentage_validation(self, cleanup_valid_config):
|
250
|
+
"""Test attribution percentages are valid (0-100)"""
|
251
|
+
rules = cleanup_valid_config["attribution_rules"]
|
252
|
+
for rule_name, rule_config in rules.items():
|
253
|
+
if "attribution_percentage" in rule_config:
|
254
|
+
percentage = rule_config["attribution_percentage"]
|
255
|
+
assert 0 <= percentage <= 100, f"Invalid attribution percentage: {percentage}"
|
256
|
+
|
257
|
+
def test_service_patterns_validation(self, cleanup_valid_config):
|
258
|
+
"""Test service patterns are defined"""
|
259
|
+
rules = cleanup_valid_config["attribution_rules"]
|
260
|
+
for rule_name, rule_config in rules.items():
|
261
|
+
if "service_patterns" in rule_config:
|
262
|
+
patterns = rule_config["service_patterns"]
|
263
|
+
assert isinstance(patterns, list)
|
264
|
+
assert len(patterns) > 0
|
265
|
+
|
266
|
+
def test_csv_columns_validation(self, cleanup_valid_config):
|
267
|
+
"""Test CSV columns are defined"""
|
268
|
+
output_config = cleanup_valid_config["output_config"]
|
269
|
+
columns = output_config.get("csv_columns", [])
|
270
|
+
assert isinstance(columns, list)
|
271
|
+
assert len(columns) > 0
|
272
|
+
|
273
|
+
def test_output_file_paths_validation(self, cleanup_valid_config):
|
274
|
+
"""Test output file paths are defined"""
|
275
|
+
output_config = cleanup_valid_config["output_config"]
|
276
|
+
assert output_config.get("csv_output_file", "").endswith(".csv")
|
277
|
+
assert output_config.get("json_results_file", "").endswith(".json")
|
278
|
+
|
279
|
+
def test_campaign_id_format(self, cleanup_valid_config):
|
280
|
+
"""Test campaign_id follows expected format"""
|
281
|
+
campaign_id = cleanup_valid_config["campaign_metadata"]["campaign_id"]
|
282
|
+
assert len(campaign_id) > 0
|
283
|
+
# Should be alphanumeric with hyphens
|
284
|
+
assert all(c.isalnum() or c == "-" for c in campaign_id)
|
285
|
+
|
286
|
+
def test_execution_date_validation(self, cleanup_valid_config):
|
287
|
+
"""Test execution_date is valid date format"""
|
288
|
+
execution_date = cleanup_valid_config["campaign_metadata"]["execution_date"]
|
289
|
+
# Should parse as valid date
|
290
|
+
datetime.strptime(execution_date, "%Y-%m-%d")
|
291
|
+
|
292
|
+
def test_aws_billing_profile_validation(self, cleanup_valid_config):
|
293
|
+
"""Test aws_billing_profile is defined"""
|
294
|
+
billing_profile = cleanup_valid_config["campaign_metadata"]["aws_billing_profile"]
|
295
|
+
assert len(billing_profile) > 0
|
296
|
+
assert isinstance(billing_profile, str)
|
297
|
+
|
298
|
+
def test_config_yaml_serialization(self, cleanup_valid_config):
|
299
|
+
"""Test config can be serialized to YAML"""
|
300
|
+
yaml_str = yaml.dump(cleanup_valid_config)
|
301
|
+
assert len(yaml_str) > 0
|
302
|
+
|
303
|
+
# Should be able to deserialize back
|
304
|
+
reloaded = yaml.safe_load(yaml_str)
|
305
|
+
assert reloaded["campaign_metadata"] == cleanup_valid_config["campaign_metadata"]
|
306
|
+
|
307
|
+
def test_config_json_serialization(self, cleanup_valid_config):
|
308
|
+
"""Test config can be serialized to JSON"""
|
309
|
+
json_str = json.dumps(cleanup_valid_config, default=str)
|
310
|
+
assert len(json_str) > 0
|
311
|
+
|
312
|
+
# Should be able to deserialize back
|
313
|
+
reloaded = json.loads(json_str)
|
314
|
+
assert reloaded["campaign_metadata"]["campaign_id"] == cleanup_valid_config["campaign_metadata"]["campaign_id"]
|
315
|
+
|
316
|
+
|
317
|
+
# ========================================
|
318
|
+
# Query Generation Tests (21 tests)
|
319
|
+
# ========================================
|
320
|
+
|
321
|
+
|
322
|
+
@pytest.mark.unit
|
323
|
+
class TestQueryGeneration:
|
324
|
+
"""Test Cost Explorer query generation logic"""
|
325
|
+
|
326
|
+
def test_pre_deletion_baseline_query_generation(self, cleanup_sample_vpc_deletion):
|
327
|
+
"""Test generation of pre-deletion baseline query"""
|
328
|
+
vpc = cleanup_sample_vpc_deletion
|
329
|
+
deletion_date = datetime.strptime(vpc["deletion_date"], "%Y-%m-%d")
|
330
|
+
baseline_months = vpc.get("pre_deletion_baseline_months", 3)
|
331
|
+
|
332
|
+
# Calculate expected start date
|
333
|
+
start_date = deletion_date - timedelta(days=baseline_months * 30)
|
334
|
+
|
335
|
+
assert start_date < deletion_date
|
336
|
+
assert (deletion_date - start_date).days >= baseline_months * 30
|
337
|
+
|
338
|
+
def test_post_deletion_validation_query_generation(self, cleanup_sample_vpc_deletion):
|
339
|
+
"""Test generation of post-deletion validation query"""
|
340
|
+
vpc = cleanup_sample_vpc_deletion
|
341
|
+
deletion_date = datetime.strptime(vpc["deletion_date"], "%Y-%m-%d")
|
342
|
+
validation_days = 30
|
343
|
+
|
344
|
+
# Calculate expected end date
|
345
|
+
end_date = deletion_date + timedelta(days=validation_days)
|
346
|
+
|
347
|
+
assert end_date > deletion_date
|
348
|
+
assert (end_date - deletion_date).days == validation_days
|
349
|
+
|
350
|
+
def test_daily_granularity_query(self, cleanup_valid_config):
|
351
|
+
"""Test daily granularity query generation"""
|
352
|
+
ce_config = cleanup_valid_config["cost_explorer_config"]
|
353
|
+
if "pre_deletion_detailed" in ce_config:
|
354
|
+
detailed = ce_config["pre_deletion_detailed"]
|
355
|
+
assert detailed.get("granularity_daily") == "DAILY"
|
356
|
+
|
357
|
+
def test_monthly_granularity_query(self, cleanup_valid_config):
|
358
|
+
"""Test monthly granularity query generation"""
|
359
|
+
ce_config = cleanup_valid_config["cost_explorer_config"]
|
360
|
+
if "pre_deletion_baseline" in ce_config:
|
361
|
+
baseline = ce_config["pre_deletion_baseline"]
|
362
|
+
assert baseline.get("granularity_monthly") == "MONTHLY"
|
363
|
+
|
364
|
+
def test_metrics_query_parameter(self, cleanup_valid_config):
|
365
|
+
"""Test metrics query parameter generation"""
|
366
|
+
metrics = cleanup_valid_config["cost_explorer_config"]["metrics"]
|
367
|
+
assert isinstance(metrics, list)
|
368
|
+
assert len(metrics) > 0
|
369
|
+
|
370
|
+
def test_group_by_dimensions_query_parameter(self, cleanup_valid_config):
|
371
|
+
"""Test group_by dimensions query parameter"""
|
372
|
+
dimensions = cleanup_valid_config["cost_explorer_config"]["group_by_dimensions"]
|
373
|
+
assert isinstance(dimensions, list)
|
374
|
+
# Should include SERVICE for attribution
|
375
|
+
assert "SERVICE" in dimensions or len(dimensions) == 0
|
376
|
+
|
377
|
+
def test_filter_by_account_query_generation(self, cleanup_sample_vpc_deletion):
|
378
|
+
"""Test query filter by account ID"""
|
379
|
+
account_id = cleanup_sample_vpc_deletion["account_id"]
|
380
|
+
# Filter should include account ID
|
381
|
+
filter_expression = {"Dimensions": {"Key": "LINKED_ACCOUNT", "Values": [account_id]}}
|
382
|
+
assert filter_expression["Dimensions"]["Values"][0] == account_id
|
383
|
+
|
384
|
+
def test_filter_by_region_query_generation(self, cleanup_sample_vpc_deletion):
|
385
|
+
"""Test query filter by region"""
|
386
|
+
region = cleanup_sample_vpc_deletion["region"]
|
387
|
+
# Filter should include region
|
388
|
+
filter_expression = {"Dimensions": {"Key": "REGION", "Values": [region]}}
|
389
|
+
assert filter_expression["Dimensions"]["Values"][0] == region
|
390
|
+
|
391
|
+
def test_time_period_calculation(self, cleanup_sample_vpc_deletion):
|
392
|
+
"""Test time period calculation for queries"""
|
393
|
+
deletion_date = datetime.strptime(cleanup_sample_vpc_deletion["deletion_date"], "%Y-%m-%d")
|
394
|
+
baseline_months = 3
|
395
|
+
|
396
|
+
start_date = deletion_date - timedelta(days=baseline_months * 30)
|
397
|
+
end_date = deletion_date
|
398
|
+
|
399
|
+
# Format as YYYY-MM-DD
|
400
|
+
start_str = start_date.strftime("%Y-%m-%d")
|
401
|
+
end_str = end_date.strftime("%Y-%m-%d")
|
402
|
+
|
403
|
+
assert start_str < end_str
|
404
|
+
assert len(start_str) == 10
|
405
|
+
assert len(end_str) == 10
|
406
|
+
|
407
|
+
def test_multi_vpc_query_generation(self, cleanup_sample_vpc_deletions):
|
408
|
+
"""Test query generation for multiple VPCs"""
|
409
|
+
account_ids = [vpc["account_id"] for vpc in cleanup_sample_vpc_deletions]
|
410
|
+
assert len(account_ids) == 3
|
411
|
+
assert len(set(account_ids)) >= 1 # May have duplicate accounts
|
412
|
+
|
413
|
+
def test_cross_region_query_generation(self, cleanup_sample_vpc_deletions):
|
414
|
+
"""Test query generation across regions"""
|
415
|
+
regions = [vpc["region"] for vpc in cleanup_sample_vpc_deletions]
|
416
|
+
assert len(regions) == 3
|
417
|
+
# Should have multiple regions for multi-LZ
|
418
|
+
assert len(set(regions)) >= 2
|
419
|
+
|
420
|
+
def test_service_filter_query_generation(self, cleanup_valid_config):
|
421
|
+
"""Test service filter for VPC-specific costs"""
|
422
|
+
rules = cleanup_valid_config["attribution_rules"]
|
423
|
+
vpc_services = rules["vpc_specific_services"]["service_patterns"]
|
424
|
+
assert len(vpc_services) > 0
|
425
|
+
|
426
|
+
def test_detailed_daily_query_range(self, cleanup_valid_config):
|
427
|
+
"""Test detailed daily query range calculation"""
|
428
|
+
ce_config = cleanup_valid_config["cost_explorer_config"]
|
429
|
+
if "pre_deletion_detailed" in ce_config:
|
430
|
+
days_before = ce_config["pre_deletion_detailed"].get("days_before_deletion", 10)
|
431
|
+
assert days_before > 0
|
432
|
+
assert days_before <= 30
|
433
|
+
|
434
|
+
def test_baseline_monthly_query_range(self, cleanup_valid_config):
|
435
|
+
"""Test baseline monthly query range calculation"""
|
436
|
+
ce_config = cleanup_valid_config["cost_explorer_config"]
|
437
|
+
if "pre_deletion_baseline" in ce_config:
|
438
|
+
months_before = ce_config["pre_deletion_baseline"].get("months_before_deletion", 3)
|
439
|
+
assert months_before > 0
|
440
|
+
assert months_before <= 12
|
441
|
+
|
442
|
+
def test_post_deletion_validation_range(self, cleanup_valid_config):
|
443
|
+
"""Test post-deletion validation range calculation"""
|
444
|
+
ce_config = cleanup_valid_config["cost_explorer_config"]
|
445
|
+
if "post_deletion_validation" in ce_config:
|
446
|
+
days_after = ce_config["post_deletion_validation"].get("days_after_deletion", 30)
|
447
|
+
assert days_after > 0
|
448
|
+
assert days_after <= 90
|
449
|
+
|
450
|
+
def test_query_date_boundaries(self, cleanup_sample_vpc_deletion):
|
451
|
+
"""Test query date boundaries don't overlap"""
|
452
|
+
deletion_date = datetime.strptime(cleanup_sample_vpc_deletion["deletion_date"], "%Y-%m-%d")
|
453
|
+
|
454
|
+
# Pre-deletion should end before deletion
|
455
|
+
pre_end = deletion_date - timedelta(days=1)
|
456
|
+
assert pre_end < deletion_date
|
457
|
+
|
458
|
+
# Post-deletion should start on or after deletion
|
459
|
+
post_start = deletion_date
|
460
|
+
assert post_start >= deletion_date
|
461
|
+
|
462
|
+
def test_query_pagination_support(self):
|
463
|
+
"""Test query should support pagination if needed"""
|
464
|
+
# Cost Explorer API returns NextPageToken if more results
|
465
|
+
# Query logic should handle pagination
|
466
|
+
next_token = "test-token-123"
|
467
|
+
assert len(next_token) > 0
|
468
|
+
|
469
|
+
def test_query_error_handling_invalid_dates(self):
|
470
|
+
"""Test query error handling for invalid date ranges"""
|
471
|
+
start_date = datetime(2025, 9, 10)
|
472
|
+
end_date = datetime(2025, 9, 1) # End before start
|
473
|
+
|
474
|
+
with pytest.raises(AssertionError):
|
475
|
+
assert start_date < end_date, "Start date must be before end date"
|
476
|
+
|
477
|
+
def test_query_multiple_metrics(self, cleanup_valid_config):
|
478
|
+
"""Test query with multiple metrics"""
|
479
|
+
metrics = cleanup_valid_config["cost_explorer_config"]["metrics"]
|
480
|
+
if len(metrics) > 1:
|
481
|
+
assert "UnblendedCost" in metrics or "BlendedCost" in metrics
|
482
|
+
|
483
|
+
def test_query_optimization_for_large_accounts(self, cleanup_sample_vpc_deletions):
|
484
|
+
"""Test query optimization strategies for large accounts"""
|
485
|
+
# For large accounts, queries should be batched by month or region
|
486
|
+
vpcs_by_account = {}
|
487
|
+
for vpc in cleanup_sample_vpc_deletions:
|
488
|
+
account = vpc["account_id"]
|
489
|
+
if account not in vpcs_by_account:
|
490
|
+
vpcs_by_account[account] = []
|
491
|
+
vpcs_by_account[account].append(vpc)
|
492
|
+
|
493
|
+
# Should support batching
|
494
|
+
assert len(vpcs_by_account) > 0
|
495
|
+
|
496
|
+
def test_query_result_caching_strategy(self):
|
497
|
+
"""Test query result should support caching to avoid duplicate API calls"""
|
498
|
+
# Mock cache key generation
|
499
|
+
cache_key = "account_123456789012_us-east-1_2025-06-01_2025-09-01"
|
500
|
+
assert len(cache_key) > 0
|
501
|
+
assert "account" in cache_key
|
502
|
+
|
503
|
+
|
504
|
+
# ========================================
|
505
|
+
# Attribution Logic Tests (19 tests)
|
506
|
+
# ========================================
|
507
|
+
|
508
|
+
|
509
|
+
@pytest.mark.unit
|
510
|
+
class TestAttributionLogic:
|
511
|
+
"""Test cost attribution methodology"""
|
512
|
+
|
513
|
+
def test_vpc_specific_services_attribution(self, cleanup_valid_config):
|
514
|
+
"""Test 100% attribution for VPC-specific services"""
|
515
|
+
rules = cleanup_valid_config["attribution_rules"]
|
516
|
+
vpc_specific = rules["vpc_specific_services"]
|
517
|
+
assert vpc_specific["attribution_percentage"] == 100
|
518
|
+
assert vpc_specific["confidence_level"] == "HIGH (95%)"
|
519
|
+
|
520
|
+
def test_vpc_related_services_attribution(self, cleanup_valid_config):
|
521
|
+
"""Test partial attribution for VPC-related services"""
|
522
|
+
rules = cleanup_valid_config["attribution_rules"]
|
523
|
+
vpc_related = rules["vpc_related_services"]
|
524
|
+
assert 0 < vpc_related["attribution_percentage"] < 100
|
525
|
+
assert "MEDIUM" in vpc_related["confidence_level"]
|
526
|
+
|
527
|
+
def test_other_services_attribution(self, cleanup_valid_config):
|
528
|
+
"""Test low attribution for other services"""
|
529
|
+
rules = cleanup_valid_config["attribution_rules"]
|
530
|
+
other_services = rules["other_services"]
|
531
|
+
assert other_services["attribution_percentage"] <= 50
|
532
|
+
assert "LOW" in other_services["confidence_level"]
|
533
|
+
|
534
|
+
def test_service_pattern_matching(self, cleanup_valid_config):
|
535
|
+
"""Test service pattern matching logic"""
|
536
|
+
rules = cleanup_valid_config["attribution_rules"]
|
537
|
+
vpc_patterns = rules["vpc_specific_services"]["service_patterns"]
|
538
|
+
|
539
|
+
# Check for VPC service pattern
|
540
|
+
assert any("Virtual Private Cloud" in pattern for pattern in vpc_patterns)
|
541
|
+
|
542
|
+
def test_attribution_percentage_calculation(self, cleanup_mock_cost_explorer):
|
543
|
+
"""Test attribution percentage calculation"""
|
544
|
+
# Mock cost data
|
545
|
+
vpc_cost = Decimal("100.00")
|
546
|
+
ec2_cost = Decimal("500.00")
|
547
|
+
|
548
|
+
# VPC-specific: 100% attribution
|
549
|
+
vpc_attributed = vpc_cost * Decimal("1.0")
|
550
|
+
assert vpc_attributed == Decimal("100.00")
|
551
|
+
|
552
|
+
# EC2 (VPC-related): 70% attribution
|
553
|
+
ec2_attributed = ec2_cost * Decimal("0.7")
|
554
|
+
assert ec2_attributed == Decimal("350.00")
|
555
|
+
|
556
|
+
def test_confidence_level_assignment(self, cleanup_valid_config):
|
557
|
+
"""Test confidence level assignment based on service type"""
|
558
|
+
rules = cleanup_valid_config["attribution_rules"]
|
559
|
+
|
560
|
+
# High confidence for VPC-specific
|
561
|
+
assert "95%" in rules["vpc_specific_services"]["confidence_level"]
|
562
|
+
|
563
|
+
# Medium confidence for VPC-related
|
564
|
+
assert "85%" in rules["vpc_related_services"]["confidence_level"]
|
565
|
+
|
566
|
+
def test_wildcard_pattern_handling(self, cleanup_valid_config):
|
567
|
+
"""Test wildcard pattern handling for other services"""
|
568
|
+
rules = cleanup_valid_config["attribution_rules"]
|
569
|
+
other_patterns = rules["other_services"]["service_patterns"]
|
570
|
+
|
571
|
+
# Should have wildcard for catch-all
|
572
|
+
assert "*" in other_patterns
|
573
|
+
|
574
|
+
def test_multi_service_attribution(self):
|
575
|
+
"""Test attribution across multiple services"""
|
576
|
+
service_costs = {
|
577
|
+
"Amazon Virtual Private Cloud": Decimal("100.00"),
|
578
|
+
"Amazon Elastic Compute Cloud - Compute": Decimal("500.00"),
|
579
|
+
"Amazon S3": Decimal("200.00"),
|
580
|
+
}
|
581
|
+
|
582
|
+
# Apply attribution rules
|
583
|
+
attributed_costs = {
|
584
|
+
"Amazon Virtual Private Cloud": service_costs["Amazon Virtual Private Cloud"] * Decimal("1.0"), # 100%
|
585
|
+
"Amazon Elastic Compute Cloud - Compute": service_costs["Amazon Elastic Compute Cloud - Compute"]
|
586
|
+
* Decimal("0.7"), # 70%
|
587
|
+
"Amazon S3": service_costs["Amazon S3"] * Decimal("0.3"), # 30%
|
588
|
+
}
|
589
|
+
|
590
|
+
total_attributed = sum(attributed_costs.values())
|
591
|
+
assert total_attributed > 0
|
592
|
+
|
593
|
+
def test_regional_cost_attribution(self, cleanup_sample_vpc_deletions):
|
594
|
+
"""Test attribution per region"""
|
595
|
+
regions = {vpc["region"] for vpc in cleanup_sample_vpc_deletions}
|
596
|
+
assert len(regions) >= 2
|
597
|
+
|
598
|
+
# Each region should have independent attribution
|
599
|
+
for region in regions:
|
600
|
+
regional_vpcs = [vpc for vpc in cleanup_sample_vpc_deletions if vpc["region"] == region]
|
601
|
+
assert len(regional_vpcs) > 0
|
602
|
+
|
603
|
+
def test_account_level_attribution(self, cleanup_sample_vpc_deletions):
|
604
|
+
"""Test attribution per account"""
|
605
|
+
accounts = {vpc["account_id"] for vpc in cleanup_sample_vpc_deletions}
|
606
|
+
assert len(accounts) >= 1
|
607
|
+
|
608
|
+
# Each account should have independent attribution
|
609
|
+
for account in accounts:
|
610
|
+
account_vpcs = [vpc for vpc in cleanup_sample_vpc_deletions if vpc["account_id"] == account]
|
611
|
+
assert len(account_vpcs) > 0
|
612
|
+
|
613
|
+
def test_time_based_attribution(self, cleanup_sample_vpc_deletion):
|
614
|
+
"""Test time-based attribution before and after deletion"""
|
615
|
+
deletion_date = datetime.strptime(cleanup_sample_vpc_deletion["deletion_date"], "%Y-%m-%d")
|
616
|
+
|
617
|
+
# Pre-deletion period should have higher costs
|
618
|
+
pre_deletion_cost = Decimal("500.00")
|
619
|
+
|
620
|
+
# Post-deletion period should have lower costs
|
621
|
+
post_deletion_cost = Decimal("50.00")
|
622
|
+
|
623
|
+
# Savings calculation
|
624
|
+
savings = pre_deletion_cost - post_deletion_cost
|
625
|
+
assert savings > 0
|
626
|
+
|
627
|
+
def test_service_name_normalization(self):
|
628
|
+
"""Test service name normalization for matching"""
|
629
|
+
service_names = [
|
630
|
+
"Amazon Virtual Private Cloud",
|
631
|
+
"Amazon Elastic Compute Cloud - Compute",
|
632
|
+
"AWS PrivateLink",
|
633
|
+
"Elastic Load Balancing",
|
634
|
+
]
|
635
|
+
|
636
|
+
# All should be matchable
|
637
|
+
for name in service_names:
|
638
|
+
assert len(name) > 0
|
639
|
+
assert isinstance(name, str)
|
640
|
+
|
641
|
+
def test_attribution_rule_precedence(self, cleanup_valid_config):
|
642
|
+
"""Test attribution rule precedence (specific before general)"""
|
643
|
+
rules = cleanup_valid_config["attribution_rules"]
|
644
|
+
|
645
|
+
# VPC-specific should be checked first
|
646
|
+
vpc_specific = rules["vpc_specific_services"]
|
647
|
+
assert vpc_specific["attribution_percentage"] == 100
|
648
|
+
|
649
|
+
# Other services should be last (lowest percentage)
|
650
|
+
other = rules["other_services"]
|
651
|
+
assert other["attribution_percentage"] <= vpc_specific["attribution_percentage"]
|
652
|
+
|
653
|
+
def test_zero_cost_handling(self):
|
654
|
+
"""Test attribution handling when cost is zero"""
|
655
|
+
zero_cost = Decimal("0.00")
|
656
|
+
attribution_percentage = Decimal("0.7")
|
657
|
+
|
658
|
+
attributed = zero_cost * attribution_percentage
|
659
|
+
assert attributed == Decimal("0.00")
|
660
|
+
|
661
|
+
def test_negative_cost_handling(self):
|
662
|
+
"""Test attribution handling for credits/refunds (negative costs)"""
|
663
|
+
credit = Decimal("-50.00") # AWS credit
|
664
|
+
attribution_percentage = Decimal("0.7")
|
665
|
+
|
666
|
+
attributed = credit * attribution_percentage
|
667
|
+
assert attributed == Decimal("-35.00")
|
668
|
+
|
669
|
+
def test_rounding_precision(self):
|
670
|
+
"""Test cost attribution maintains precision"""
|
671
|
+
cost = Decimal("100.123456")
|
672
|
+
attribution = Decimal("0.7")
|
673
|
+
|
674
|
+
attributed = cost * attribution
|
675
|
+
# Should maintain precision
|
676
|
+
assert attributed == Decimal("70.086419200000000000")
|
677
|
+
|
678
|
+
def test_monthly_averaging_attribution(self):
|
679
|
+
"""Test monthly averaging for baseline calculation"""
|
680
|
+
monthly_costs = [Decimal("100.00"), Decimal("105.00"), Decimal("110.00")]
|
681
|
+
|
682
|
+
average = sum(monthly_costs) / len(monthly_costs)
|
683
|
+
assert average == Decimal("105.00")
|
684
|
+
|
685
|
+
def test_daily_granular_attribution(self):
|
686
|
+
"""Test daily granular attribution for detailed period"""
|
687
|
+
daily_costs = [Decimal("15.00")] * 10 # 10 days
|
688
|
+
|
689
|
+
total = sum(daily_costs)
|
690
|
+
assert total == Decimal("150.00")
|
691
|
+
|
692
|
+
# Daily average
|
693
|
+
daily_avg = total / len(daily_costs)
|
694
|
+
assert daily_avg == Decimal("15.00")
|
695
|
+
|
696
|
+
def test_attribution_confidence_weighting(self):
|
697
|
+
"""Test confidence weighting in final attribution"""
|
698
|
+
# High confidence (95%) = low adjustment
|
699
|
+
high_conf_cost = Decimal("100.00")
|
700
|
+
high_conf_weight = Decimal("0.95")
|
701
|
+
|
702
|
+
# Medium confidence (85%) = moderate adjustment
|
703
|
+
medium_conf_cost = Decimal("500.00")
|
704
|
+
medium_conf_weight = Decimal("0.85")
|
705
|
+
|
706
|
+
# Weighted costs
|
707
|
+
weighted_high = high_conf_cost * high_conf_weight
|
708
|
+
weighted_medium = medium_conf_cost * medium_conf_weight
|
709
|
+
|
710
|
+
assert weighted_high < high_conf_cost
|
711
|
+
assert weighted_medium < medium_conf_cost
|
712
|
+
|
713
|
+
|
714
|
+
# ========================================
|
715
|
+
# CSV Output Tests (11 tests)
|
716
|
+
# ========================================
|
717
|
+
|
718
|
+
|
719
|
+
@pytest.mark.unit
|
720
|
+
class TestCSVOutput:
|
721
|
+
"""Test CSV output generation and formatting"""
|
722
|
+
|
723
|
+
def test_csv_column_headers(self, cleanup_valid_config):
|
724
|
+
"""Test CSV column headers are defined"""
|
725
|
+
columns = cleanup_valid_config["output_config"]["csv_columns"]
|
726
|
+
assert "VPC_ID" in columns
|
727
|
+
assert "Account_ID" in columns
|
728
|
+
assert "Deletion_Date" in columns
|
729
|
+
|
730
|
+
def test_csv_file_path_generation(self, cleanup_valid_config):
|
731
|
+
"""Test CSV file path generation"""
|
732
|
+
csv_file = cleanup_valid_config["output_config"]["csv_output_file"]
|
733
|
+
assert csv_file.endswith(".csv")
|
734
|
+
assert len(csv_file) > 4
|
735
|
+
|
736
|
+
def test_csv_row_generation(self, cleanup_sample_vpc_deletion):
|
737
|
+
"""Test CSV row generation from VPC deletion data"""
|
738
|
+
row = [
|
739
|
+
cleanup_sample_vpc_deletion["vpc_id"],
|
740
|
+
cleanup_sample_vpc_deletion["account_id"],
|
741
|
+
cleanup_sample_vpc_deletion["region"],
|
742
|
+
cleanup_sample_vpc_deletion["deletion_date"],
|
743
|
+
]
|
744
|
+
|
745
|
+
assert len(row) == 4
|
746
|
+
assert row[0].startswith("vpc-")
|
747
|
+
|
748
|
+
def test_csv_multiple_rows(self, cleanup_sample_vpc_deletions):
|
749
|
+
"""Test CSV generation with multiple VPC deletions"""
|
750
|
+
rows = []
|
751
|
+
for vpc in cleanup_sample_vpc_deletions:
|
752
|
+
row = [vpc["vpc_id"], vpc["account_id"], vpc["region"], vpc["deletion_date"]]
|
753
|
+
rows.append(row)
|
754
|
+
|
755
|
+
assert len(rows) == 3
|
756
|
+
|
757
|
+
def test_csv_numeric_formatting(self):
|
758
|
+
"""Test CSV numeric value formatting"""
|
759
|
+
monthly_savings = Decimal("1234.56")
|
760
|
+
annual_savings = monthly_savings * 12
|
761
|
+
|
762
|
+
# Format to 2 decimal places
|
763
|
+
monthly_str = f"{monthly_savings:.2f}"
|
764
|
+
annual_str = f"{annual_savings:.2f}"
|
765
|
+
|
766
|
+
assert monthly_str == "1234.56"
|
767
|
+
assert annual_str == "14814.72"
|
768
|
+
|
769
|
+
def test_csv_date_formatting(self, cleanup_sample_vpc_deletion):
|
770
|
+
"""Test CSV date formatting consistency"""
|
771
|
+
deletion_date = cleanup_sample_vpc_deletion["deletion_date"]
|
772
|
+
|
773
|
+
# Should be YYYY-MM-DD format
|
774
|
+
datetime.strptime(deletion_date, "%Y-%m-%d")
|
775
|
+
|
776
|
+
def test_csv_special_character_handling(self):
|
777
|
+
"""Test CSV special character escaping"""
|
778
|
+
# Values with commas should be quoted
|
779
|
+
value_with_comma = "VPC,with,commas"
|
780
|
+
escaped = f'"{value_with_comma}"'
|
781
|
+
|
782
|
+
assert escaped.startswith('"')
|
783
|
+
assert escaped.endswith('"')
|
784
|
+
|
785
|
+
def test_csv_null_value_handling(self):
|
786
|
+
"""Test CSV null value representation"""
|
787
|
+
null_value = None
|
788
|
+
csv_representation = "" if null_value is None else str(null_value)
|
789
|
+
|
790
|
+
assert csv_representation == ""
|
791
|
+
|
792
|
+
def test_csv_column_order(self, cleanup_valid_config):
|
793
|
+
"""Test CSV column order is consistent"""
|
794
|
+
columns = cleanup_valid_config["output_config"]["csv_columns"]
|
795
|
+
|
796
|
+
# VPC_ID should be first
|
797
|
+
assert columns[0] == "VPC_ID"
|
798
|
+
|
799
|
+
# Account_ID should be second
|
800
|
+
assert columns[1] == "Account_ID"
|
801
|
+
|
802
|
+
def test_csv_data_quality_indicators(self):
|
803
|
+
"""Test CSV includes data quality indicators"""
|
804
|
+
data_quality = "HIGH"
|
805
|
+
confidence_level = "95%"
|
806
|
+
|
807
|
+
assert data_quality in ["HIGH", "MEDIUM", "LOW"]
|
808
|
+
assert "%" in confidence_level
|
809
|
+
|
810
|
+
def test_csv_output_validation(self):
|
811
|
+
"""Test CSV output can be parsed back"""
|
812
|
+
import csv
|
813
|
+
import io
|
814
|
+
|
815
|
+
# Create sample CSV data
|
816
|
+
csv_data = "VPC_ID,Account_ID,Region,Deletion_Date\nvpc-123,123456789012,us-east-1,2025-09-10\n"
|
817
|
+
|
818
|
+
# Parse it
|
819
|
+
reader = csv.DictReader(io.StringIO(csv_data))
|
820
|
+
rows = list(reader)
|
821
|
+
|
822
|
+
assert len(rows) == 1
|
823
|
+
assert rows[0]["VPC_ID"] == "vpc-123"
|
824
|
+
|
825
|
+
|
826
|
+
# ========================================
|
827
|
+
# Error Handling Tests (15 tests)
|
828
|
+
# ========================================
|
829
|
+
|
830
|
+
|
831
|
+
@pytest.mark.unit
|
832
|
+
class TestErrorHandling:
|
833
|
+
"""Test error handling and edge cases"""
|
834
|
+
|
835
|
+
def test_missing_config_file_error(self):
|
836
|
+
"""Test error handling for missing config file"""
|
837
|
+
with pytest.raises(FileNotFoundError):
|
838
|
+
with open("/nonexistent/config.yaml") as f:
|
839
|
+
yaml.safe_load(f)
|
840
|
+
|
841
|
+
def test_invalid_yaml_syntax_error(self):
|
842
|
+
"""Test error handling for invalid YAML syntax"""
|
843
|
+
invalid_yaml = "invalid: yaml: [\n not closed"
|
844
|
+
|
845
|
+
with pytest.raises(yaml.YAMLError):
|
846
|
+
yaml.safe_load(invalid_yaml)
|
847
|
+
|
848
|
+
def test_missing_required_field_error(self):
|
849
|
+
"""Test error handling for missing required fields"""
|
850
|
+
incomplete_config = {"campaign_metadata": {"campaign_id": "TEST"}}
|
851
|
+
|
852
|
+
with pytest.raises(KeyError):
|
853
|
+
_ = incomplete_config["deleted_vpcs"]
|
854
|
+
|
855
|
+
def test_invalid_date_format_error(self):
|
856
|
+
"""Test error handling for invalid date format"""
|
857
|
+
invalid_date = "2025/09/10" # Should be YYYY-MM-DD
|
858
|
+
|
859
|
+
with pytest.raises(ValueError):
|
860
|
+
datetime.strptime(invalid_date, "%Y-%m-%d")
|
861
|
+
|
862
|
+
def test_cost_explorer_api_error_handling(self, cleanup_mock_cost_explorer):
|
863
|
+
"""Test error handling for Cost Explorer API failures"""
|
864
|
+
# Mock API error
|
865
|
+
cleanup_mock_cost_explorer.get_cost_and_usage.side_effect = Exception("API Error")
|
866
|
+
|
867
|
+
with pytest.raises(Exception):
|
868
|
+
cleanup_mock_cost_explorer.get_cost_and_usage()
|
869
|
+
|
870
|
+
def test_invalid_vpc_id_error(self):
|
871
|
+
"""Test error handling for invalid VPC ID format"""
|
872
|
+
invalid_vpc_id = "invalid-id"
|
873
|
+
assert not invalid_vpc_id.startswith("vpc-")
|
874
|
+
|
875
|
+
def test_invalid_account_id_error(self):
|
876
|
+
"""Test error handling for invalid account ID"""
|
877
|
+
invalid_account = "not-numeric"
|
878
|
+
assert not invalid_account.isdigit()
|
879
|
+
|
880
|
+
def test_future_deletion_date_warning(self):
|
881
|
+
"""Test warning for future deletion dates"""
|
882
|
+
future_date = datetime.now() + timedelta(days=30)
|
883
|
+
current_date = datetime.now()
|
884
|
+
|
885
|
+
is_future = future_date > current_date
|
886
|
+
assert is_future, "Should detect future deletion date"
|
887
|
+
|
888
|
+
def test_zero_baseline_months_error(self):
|
889
|
+
"""Test error handling for zero baseline months"""
|
890
|
+
baseline_months = 0
|
891
|
+
with pytest.raises(AssertionError):
|
892
|
+
assert baseline_months > 0, "Baseline months must be positive"
|
893
|
+
|
894
|
+
def test_negative_cost_validation(self):
|
895
|
+
"""Test validation for negative costs (credits)"""
|
896
|
+
cost = Decimal("-100.00")
|
897
|
+
# Negative costs are valid (credits/refunds)
|
898
|
+
assert cost < 0
|
899
|
+
|
900
|
+
def test_division_by_zero_protection(self):
|
901
|
+
"""Test protection against division by zero"""
|
902
|
+
total_cost = Decimal("0.00")
|
903
|
+
num_months = 3
|
904
|
+
|
905
|
+
# Should handle zero cost gracefully
|
906
|
+
average = total_cost / num_months if num_months > 0 else Decimal("0.00")
|
907
|
+
assert average == Decimal("0.00")
|
908
|
+
|
909
|
+
def test_empty_cost_data_handling(self):
|
910
|
+
"""Test handling of empty cost data"""
|
911
|
+
cost_data = []
|
912
|
+
assert len(cost_data) == 0
|
913
|
+
|
914
|
+
# Should handle empty data gracefully
|
915
|
+
total = sum(cost_data) if cost_data else Decimal("0.00")
|
916
|
+
assert total == Decimal("0.00")
|
917
|
+
|
918
|
+
def test_permission_denied_error_handling(self):
|
919
|
+
"""Test error handling for permission denied scenarios"""
|
920
|
+
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
921
|
+
temp_file = f.name
|
922
|
+
|
923
|
+
try:
|
924
|
+
# Make file read-only
|
925
|
+
import os
|
926
|
+
|
927
|
+
os.chmod(temp_file, 0o444)
|
928
|
+
|
929
|
+
# Try to write (should fail)
|
930
|
+
with pytest.raises(PermissionError):
|
931
|
+
with open(temp_file, "w") as f:
|
932
|
+
f.write("test")
|
933
|
+
finally:
|
934
|
+
# Cleanup
|
935
|
+
os.chmod(temp_file, 0o644)
|
936
|
+
Path(temp_file).unlink(missing_ok=True)
|
937
|
+
|
938
|
+
def test_network_timeout_handling(self):
|
939
|
+
"""Test error handling for network timeouts"""
|
940
|
+
# Mock timeout scenario
|
941
|
+
mock_client = MagicMock()
|
942
|
+
mock_client.get_cost_and_usage.side_effect = TimeoutError("Request timed out")
|
943
|
+
|
944
|
+
with pytest.raises(TimeoutError):
|
945
|
+
mock_client.get_cost_and_usage()
|
946
|
+
|
947
|
+
def test_partial_data_recovery(self):
|
948
|
+
"""Test partial data recovery when some queries fail"""
|
949
|
+
successful_data = [Decimal("100.00"), Decimal("105.00")]
|
950
|
+
failed_data = None
|
951
|
+
|
952
|
+
# Should still process successful data
|
953
|
+
total = sum(successful_data)
|
954
|
+
assert total > 0
|
955
|
+
|
956
|
+
|
957
|
+
# ========================================
|
958
|
+
# Multi-LZ Reusability Tests (8 tests)
|
959
|
+
# ========================================
|
960
|
+
|
961
|
+
|
962
|
+
@pytest.mark.integration
|
963
|
+
class TestMultiLZReusability:
|
964
|
+
"""Test framework reusability across multiple Landing Zones"""
|
965
|
+
|
966
|
+
def test_multi_account_support(self, cleanup_multi_lz_config):
|
967
|
+
"""Test support for multiple AWS accounts"""
|
968
|
+
accounts = {vpc["account_id"] for vpc in cleanup_multi_lz_config["deleted_vpcs"]}
|
969
|
+
assert len(accounts) >= 2, "Should support multiple accounts"
|
970
|
+
|
971
|
+
def test_multi_region_support(self, cleanup_multi_lz_config):
|
972
|
+
"""Test support for multiple AWS regions"""
|
973
|
+
regions = {vpc["region"] for vpc in cleanup_multi_lz_config["deleted_vpcs"]}
|
974
|
+
assert len(regions) >= 2, "Should support multiple regions"
|
975
|
+
|
976
|
+
def test_different_deletion_dates_support(self, cleanup_multi_lz_config):
|
977
|
+
"""Test support for different deletion dates"""
|
978
|
+
dates = {vpc["deletion_date"] for vpc in cleanup_multi_lz_config["deleted_vpcs"]}
|
979
|
+
assert len(dates) >= 2, "Should support different deletion dates"
|
980
|
+
|
981
|
+
def test_different_deletion_principals(self, cleanup_multi_lz_config):
|
982
|
+
"""Test support for different deletion principals"""
|
983
|
+
principals = {vpc["deletion_principal"] for vpc in cleanup_multi_lz_config["deleted_vpcs"]}
|
984
|
+
assert len(principals) >= 2, "Should support different deletion principals"
|
985
|
+
|
986
|
+
def test_campaign_level_metadata(self, cleanup_multi_lz_config):
|
987
|
+
"""Test campaign-level metadata for multi-LZ"""
|
988
|
+
metadata = cleanup_multi_lz_config["campaign_metadata"]
|
989
|
+
assert "MULTI-LZ" in metadata["campaign_id"]
|
990
|
+
assert "Landing Zone" in metadata.get("description", "")
|
991
|
+
|
992
|
+
def test_consolidated_output_generation(self, cleanup_multi_lz_config):
|
993
|
+
"""Test consolidated output for multiple LZs"""
|
994
|
+
output_config = cleanup_multi_lz_config["output_config"]
|
995
|
+
|
996
|
+
# Should have single output files for all LZs
|
997
|
+
assert "multi_lz" in output_config["csv_output_file"]
|
998
|
+
assert "multi_lz" in output_config["json_results_file"]
|
999
|
+
|
1000
|
+
def test_per_lz_cost_attribution(self, cleanup_multi_lz_config):
|
1001
|
+
"""Test per-LZ cost attribution and aggregation"""
|
1002
|
+
vpcs = cleanup_multi_lz_config["deleted_vpcs"]
|
1003
|
+
|
1004
|
+
# Each VPC should have independent attribution
|
1005
|
+
for vpc in vpcs:
|
1006
|
+
assert "account_id" in vpc
|
1007
|
+
assert "region" in vpc
|
1008
|
+
|
1009
|
+
def test_cross_lz_comparison_support(self, cleanup_multi_lz_config):
|
1010
|
+
"""Test support for cross-LZ comparison and analysis"""
|
1011
|
+
vpcs = cleanup_multi_lz_config["deleted_vpcs"]
|
1012
|
+
|
1013
|
+
# Group by account for comparison
|
1014
|
+
by_account = {}
|
1015
|
+
for vpc in vpcs:
|
1016
|
+
account = vpc["account_id"]
|
1017
|
+
if account not in by_account:
|
1018
|
+
by_account[account] = []
|
1019
|
+
by_account[account].append(vpc)
|
1020
|
+
|
1021
|
+
# Should enable comparison across accounts
|
1022
|
+
assert len(by_account) > 0
|