diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 306b3ff63..caa69141e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,10 @@ jobs: - name: Run Shell Script Test Suites run: | - bash tests/scripts/run_shell_tests.sh + for test_file in tests/test_*.sh; do + echo "=== Running $(basename "${test_file}") ===" + bash "${test_file}" + done tflint: name: Terraform Lint @@ -88,7 +91,7 @@ jobs: - name: Test TFLint Runner run: | - bash tests/scripts/test_tflint.sh + bash tests/test_tflint.sh - name: Run TFLint run: | @@ -155,6 +158,7 @@ jobs: - name: Run Python Unit Tests run: | + uv run --locked python tools/check_boilerplate.py tests tools uv run --locked pytest cli-tests: diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index c589fef65..000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import sys - -REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -GEM4GOV_PATH = os.path.join( - REPO_ROOT, "blueprints", "gemini-enterprise", "gem4gov-cli" -) -TOOLS_PATH = os.path.join(REPO_ROOT, "tools") - -for p in (REPO_ROOT, GEM4GOV_PATH, TOOLS_PATH): - if p not in sys.path: - sys.path.insert(0, p) diff --git a/tests/gem4gov_cli/__init__.py b/tests/gem4gov_cli/__init__.py deleted file mode 100644 index 58d482ea3..000000000 --- a/tests/gem4gov_cli/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/tests/scripts/run_shell_tests.sh b/tests/scripts/run_shell_tests.sh deleted file mode 100755 index 20aee1144..000000000 --- a/tests/scripts/run_shell_tests.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -echo "============================================================" -echo " Running Stellar Engine Shell Script Test Suites" -echo "============================================================" - -bash "${SCRIPT_DIR}/test_shell_syntax.sh" -echo "" -bash "${SCRIPT_DIR}/test_common_functions.sh" -echo "" -bash "${SCRIPT_DIR}/test_tflint.sh" -echo "" - -echo "============================================================" -echo " All shell script test suites PASSED successfully." -echo "============================================================" diff --git a/tests/gem4gov_cli/test_auth.py b/tests/test_auth.py similarity index 74% rename from tests/gem4gov_cli/test_auth.py rename to tests/test_auth.py index 4b98c60c4..fb0e91fe1 100644 --- a/tests/gem4gov_cli/test_auth.py +++ b/tests/test_auth.py @@ -15,6 +15,7 @@ import subprocess import unittest from unittest.mock import MagicMock +from unittest.mock import call from unittest.mock import patch import auth @@ -89,6 +90,42 @@ def test_get_credentials(self, mock_default, mock_run): scopes=["https://www.googleapis.com/auth/cloud-platform"] ) + @patch("subprocess.run") + @patch("auth.google.auth.default") + def test_get_credentials_triggers_gcloud_login_on_error( + self, mock_default, mock_run + ): + mock_creds = MagicMock() + mock_default.return_value = (mock_creds, "dummy-project") + mock_run.side_effect = [ + subprocess.CalledProcessError(1, "gcloud"), + MagicMock(), + MagicMock(), + ] + + creds = auth.get_credentials() + + self.assertEqual(creds, mock_creds) + self.assertEqual( + mock_run.call_args_list[1:], + [ + call(["gcloud", "auth", "login"]), + call(["gcloud", "auth", "application-default", "login"]), + ], + ) + + @patch("subprocess.run") + def test_force_reauthentication_runs_both_login_commands(self, mock_run): + auth.force_reauthentication() + + self.assertEqual( + mock_run.call_args_list, + [ + call(["gcloud", "auth", "login"]), + call(["gcloud", "auth", "application-default", "login"]), + ], + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/tools/test_blueprint_and_module_validations.py b/tests/test_blueprint_and_module_validations.py similarity index 87% rename from tests/tools/test_blueprint_and_module_validations.py rename to tests/test_blueprint_and_module_validations.py index 5d44436d5..3308c86e3 100644 --- a/tests/tools/test_blueprint_and_module_validations.py +++ b/tests/test_blueprint_and_module_validations.py @@ -15,20 +15,17 @@ """Tests for blueprint API enablement and module ID length preconditions.""" import pathlib +import re import unittest -REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] class TestBlueprintAndModuleValidations(unittest.TestCase): - """Verify API enablement in blueprints and 30-char ID preconditions in - modules. - """ + """Verify API enablement in blueprints and 30-char ID preconditions.""" def test_blueprints_enable_required_project_services(self): - """Ensure app-engine and postgresql blueprints enable required APIs - (#104). - """ + """Ensure app-engine and postgresql blueprints enable APIs (#104).""" ae_tf = ( REPO_ROOT / "blueprints/fedramp-high/app-engine/main.tf" ).read_text(encoding="utf-8") @@ -44,9 +41,7 @@ def test_blueprints_enable_required_project_services(self): self.assertIn("google_project_service.sqladmin", pg_tf) def test_modules_enforce_30_char_id_preconditions(self): - """Ensure project and iam-service-account modules enforce 30-character - ID preconditions (#228). - """ + """Ensure project and iam-service-account enforce 30-char IDs (#228).""" proj_tf = (REPO_ROOT / "modules/project/main.tf").read_text( encoding="utf-8" ) @@ -70,9 +65,7 @@ def test_modules_enforce_30_char_id_preconditions(self): ) def _iter_terraform_dirs(self): - """Yield every directory under fast/, modules/, and blueprints/ - containing .tf files. - """ + """Yield every directory under fast/ and blueprints/ with .tf files.""" seen = set() for root_name in ("fast", "blueprints"): base = REPO_ROOT / root_name @@ -86,8 +79,6 @@ def _iter_terraform_dirs(self): @staticmethod def _strip_comments_and_moved(text: str) -> str: - import re - lines = [] in_block = False for line in text.splitlines(): @@ -113,11 +104,7 @@ def _strip_comments_and_moved(text: str) -> str: def test_all_terraform_dirs_have_valid_variable_and_resource_references( self, ): - """Ensure every Terraform directory declares all referenced vars, - resources, data sources, and modules. - """ - import re - + """Ensure every Terraform directory declares referenced symbols.""" errors = [] for tf_dir in sorted(self._iter_terraform_dirs()): rel_dir = tf_dir.relative_to(REPO_ROOT) @@ -127,10 +114,9 @@ def test_all_terraform_dirs_have_valid_variable_and_resource_references( ) cleaned = self._strip_comments_and_moved(raw_combined) - # Catch common HCL prefix typos like vvar. or lcoal. - for typo in re.findall( - r"\b(?:vvar|lcoal| moudle)\.[a-zA-Z0-9_-]+", cleaned - ): + # Catch common HCL prefix typos like vvar., lcoal., or moudle. + typo_re = r"\b(?:vvar|lcoal|moudle)\.[a-zA-Z0-9_-]+" + for typo in re.findall(typo_re, cleaned): errors.append(f"{rel_dir}: suspicious typo reference '{typo}'") decl_vars = set(re.findall(r'variable\s+"([^"]+)"', raw_combined)) @@ -141,17 +127,17 @@ def test_all_terraform_dirs_have_valid_variable_and_resource_references( f"{rel_dir}: undeclared var reference(s): {missing_vars}" ) + res_decl_re = r'resource\s+"([^"]+)"\s+"([^"]+)"' decl_res = { f"{m.group(1)}.{m.group(2)}" - for m in re.finditer( - r'resource\s+"([^"]+)"\s+"([^"]+)"', raw_combined - ) + for m in re.finditer(res_decl_re, raw_combined) } ref_res = set() - for m in re.finditer( - r'(? 0", + "api.getAttribute('iam.googleapis.com/modifiedGrantsByRole'," + " []).size() > 0", content, ) diff --git a/tests/tools/test_check_boilerplate.py b/tests/test_check_boilerplate.py similarity index 59% rename from tests/tools/test_check_boilerplate.py rename to tests/test_check_boilerplate.py index 7a8df0561..852a4e156 100644 --- a/tests/tools/test_check_boilerplate.py +++ b/tests/test_check_boilerplate.py @@ -12,6 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +import contextlib +import io +import os +import tempfile import unittest import check_boilerplate @@ -30,6 +34,7 @@ def test_missing_boilerplate(self): content = """# Some random script without license print('Hello World') """ + self.assertIsNotNone(content) self.assertIsNone(check_boilerplate._MATCH_RE.search(content)) def test_exclude_comment_detected(self): @@ -43,11 +48,9 @@ def test_match_files_extensions(self): self.assertIn(".sh", check_boilerplate._MATCH_FILES) self.assertIn(".tf", check_boilerplate._MATCH_FILES) self.assertIn(".yaml", check_boilerplate._MATCH_FILES) + self.assertIn(".yml", check_boilerplate._MATCH_FILES) def test_skips_symlinks(self): - import os - import tempfile - with tempfile.TemporaryDirectory() as tmpdir: target = os.path.join(tmpdir, "unlicensed.txt") with open(target, "w", encoding="utf-8") as f: @@ -57,54 +60,7 @@ def test_skips_symlinks(self): # Should not raise SystemExit(1) because symlinks are skipped check_boilerplate.main([tmpdir]) - def test_match_files_includes_yml(self): - """Both .yaml and .yml extensions must be checked for boilerplate.""" - self.assertIn(".yml", check_boilerplate._MATCH_FILES) - - def test_EndToEnd_fails_on_missing_boilerplate_and_passes_on_valid(self): - """End-to-end test of check_boilerplate.main on real files.""" - import os - import tempfile - - with tempfile.TemporaryDirectory() as tmpdir: - bad_py = os.path.join(tmpdir, "bad.py") - with open(bad_py, "w", encoding="utf-8") as f: - f.write('print("missing license")\n') - with self.assertRaises(SystemExit) as ctx: - check_boilerplate.main([tmpdir]) - self.assertEqual(ctx.exception.code, 1) - - # Now replace with valid boilerplate - with open(bad_py, "w", encoding="utf-8") as f: - f.write( - "# Copyright 2026 Google LLC\n" - "#\n" - "# Licensed under the Apache License, Version 2.0 " - '(the "License");\n' - "# you may not use this file except in compliance " - "with the License.\n" - "# You may obtain a copy of the License at\n" - "#\n" - "# http://www.apache.org/licenses/LICENSE-2.0\n" - "#\n" - "# Unless required by applicable law or agreed to in " - "writing, software\n" - "# distributed under the License is distributed on an " - '"AS IS" BASIS,\n' - "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either " - "express or implied.\n" - "# See the License for the specific language governing " - "permissions and\n" - "# limitations under the License.\n" - ) - # Should complete without raising SystemExit - check_boilerplate.main([tmpdir]) - - def test_EndToEnd_skips_ignored_dirs(self): - """Directories in _EXCLUDE_DIRS must be skipped.""" - import os - import tempfile - + def test_skips_ignored_dirs(self): with tempfile.TemporaryDirectory() as tmpdir: for skip_dir in check_boilerplate._EXCLUDE_DIRS: dpath = os.path.join(tmpdir, skip_dir) @@ -117,6 +73,18 @@ def test_EndToEnd_skips_ignored_dirs(self): f.write("x = 1\n") check_boilerplate.main([tmpdir]) + def test_main_exits_nonzero_when_file_missing_boilerplate(self): + with tempfile.TemporaryDirectory() as tmpdir: + bad_py = os.path.join(tmpdir, "missing.py") + with open(bad_py, "w", encoding="utf-8") as f: + f.write('print("missing header")\n') + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + with self.assertRaises(SystemExit) as ctx: + check_boilerplate.main([tmpdir]) + self.assertEqual(ctx.exception.code, 1) + self.assertIn("missing.py", buf.getvalue()) + if __name__ == "__main__": unittest.main() diff --git a/tests/tools/test_check_names.py b/tests/test_check_names.py similarity index 97% rename from tests/tools/test_check_names.py rename to tests/test_check_names.py index 061ddd00f..d8c531537 100644 --- a/tests/tools/test_check_names.py +++ b/tests/test_check_names.py @@ -60,7 +60,7 @@ def test_directory_without_names_is_a_usage_error(self): self.assertIn("no names found in missing", result.output) def test_lint_target_holds_names(self): - lint = pathlib.Path(__file__).resolve().parents[2] / "tools" / "lint.sh" + lint = pathlib.Path(__file__).resolve().parents[1] / "tools" / "lint.sh" line = next( text for text in lint.read_text().splitlines() diff --git a/tests/scripts/test_common_functions.sh b/tests/test_common_functions.sh similarity index 72% rename from tests/scripts/test_common_functions.sh rename to tests/test_common_functions.sh index 0350cdfa1..c7d971bd6 100755 --- a/tests/scripts/test_common_functions.sh +++ b/tests/test_common_functions.sh @@ -16,7 +16,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # Source common functions # shellcheck source=scripts/common-functions.sh @@ -240,6 +240,63 @@ assert_false "deploy.sh ends every bare handle_prompt call with || true" 'bare_h # shellcheck disable=SC2016 assert_true "deploy.sh passes the failing status to error_handler" 'grep -qF "trap '"'"'error_handler \$LINENO \$?'"'"' ERR" "$DEPLOY_SH"' + +# 18. Test load_config with valid and missing config.env +echo 'export CFG_VAR_1="ok"' > "${TMP_TEST_DIR}/config.env" +assert_true "load_config succeeds with valid config.env" 'load_config "$TMP_TEST_DIR" "CFG_VAR_1"' +assert_false "load_config fails when required var missing" 'load_config "$TMP_TEST_DIR" "MISSING_CFG_VAR" 2>/dev/null' +assert_false "load_config fails when directory has no config.env" 'load_config "${TMP_TEST_DIR}/nonexistent" "CFG_VAR_1" 2>/dev/null' + +# 19. Test command_exists and retry_command +assert_true "command_exists succeeds for bash" 'command_exists bash' +assert_false "command_exists fails for nonexistent binary" 'command_exists nonexistent_binary_xyz_123' +assert_true "retry_command succeeds on passing command" 'retry_command 2 0 true' +assert_false "retry_command fails after max attempts" 'retry_command 2 0 false 2>/dev/null' + +# 20. Test gcloud_safe, terraform_safe, check_prerequisites, check_gcp_quotas, resource_exists, safe_delete +export GCLOUD_CALL_LOG="${TMP_TEST_DIR}/gcloud_calls.log" +: > "$GCLOUD_CALL_LOG" +gcloud() { + echo "$*" >> "$GCLOUD_CALL_LOG" + if [[ "$*" == *"missing-res"* ]]; then return 1; fi + if [[ "${1:-}" == "org-policies" && "${2:-}" == "list-custom-constraints" ]]; then + echo "custom.myConstraint" + return 0 + fi + return 0 +} +terraform() { return 0; } +export -f gcloud terraform +export GCLOUD_RETRY_DELAY=0 TERRAFORM_RETRY_DELAY=0 +assert_true "gcloud_safe succeeds with mocked gcloud" 'gcloud_safe version >/dev/null 2>&1' +assert_true "terraform_safe succeeds with mocked terraform" 'terraform_safe version >/dev/null 2>&1' +assert_true "check_prerequisites succeeds with mocked gcloud and terraform" 'check_prerequisites >/dev/null 2>&1' +assert_true "check_gcp_quotas succeeds with mocked gcloud" 'check_gcp_quotas "test-proj" >/dev/null 2>&1' +for rtype in project org-policy custom-constraint storage-bucket iam-role; do + assert_true "resource_exists succeeds for ${rtype}" "resource_exists '${rtype}' 'custom.myConstraint'" + assert_true "safe_delete succeeds for existing ${rtype}" "safe_delete '${rtype}' 'custom.myConstraint' >/dev/null 2>&1" +done +assert_true "safe_delete records gcloud projects delete command" 'grep -q "projects delete custom.myConstraint --quiet" "$GCLOUD_CALL_LOG"' +: > "$GCLOUD_CALL_LOG" +assert_true "safe_delete skips deletion when resource does not exist" 'safe_delete "project" "missing-res" >/dev/null 2>&1' +assert_false "safe_delete does not invoke projects delete for missing resource" 'grep -q "projects delete missing-res" "$GCLOUD_CALL_LOG"' +assert_false "resource_exists fails for unknown type" 'resource_exists "invalid-type" "res" 2>/dev/null' + +# 21. Test wait_for_consistency, display_header, cleanup, promptUser, confirm_destructive_operation +assert_true "wait_for_consistency succeeds with 0s delay" 'wait_for_consistency "op" 0 >/dev/null 2>&1' +assert_true "display_header prints banner" 'display_header "Test" "Desc" >/dev/null 2>&1' +assert_true "cleanup succeeds" 'cleanup >/dev/null 2>&1' +PROMPT_MARKER="${TMP_TEST_DIR}/prompt_executed" +rm -f "$PROMPT_MARKER" +assert_true "promptUser returns 0 in NON_INTERACTIVE=true with y default" 'NON_INTERACTIVE=true PROMPT_DEFAULT_RESPONSE=y promptUser "Proceed?" "touch $PROMPT_MARKER" >/dev/null 2>&1' +assert_true "promptUser executed command when response is y" '[[ -f "$PROMPT_MARKER" ]]' +rm -f "$PROMPT_MARKER" +assert_false "promptUser returns 1 in NON_INTERACTIVE=true with n default" 'NON_INTERACTIVE=true PROMPT_DEFAULT_RESPONSE=n promptUser "Proceed?" "touch $PROMPT_MARKER" >/dev/null 2>&1' +assert_true "promptUser did not execute command when response is n" '[[ ! -f "$PROMPT_MARKER" ]]' +assert_false "confirm_destructive_operation fails safe in NON_INTERACTIVE=true" 'NON_INTERACTIVE=true confirm_destructive_operation "Delete all" >/dev/null 2>&1' +assert_true "confirm_destructive_operation succeeds when user types DELETE" 'NON_INTERACTIVE=false confirm_destructive_operation "Delete all" <<<"DELETE" >/dev/null 2>&1' +assert_false "confirm_destructive_operation cancels when user types no" 'NON_INTERACTIVE=false confirm_destructive_operation "Delete all" <<<"no" >/dev/null 2>&1' + echo "" echo "Summary: ${TEST_COUNT} shell unit tests run, ${FAIL_COUNT} failures." diff --git a/tests/gem4gov_cli/test_data_stores.py b/tests/test_data_stores.py similarity index 88% rename from tests/gem4gov_cli/test_data_stores.py rename to tests/test_data_stores.py index a6ba46c07..55112988c 100644 --- a/tests/gem4gov_cli/test_data_stores.py +++ b/tests/test_data_stores.py @@ -217,13 +217,37 @@ def test_import_bq_documents_auto_and_custom_id_field(self, mock_build): self.assertEqual(kwargs_field["body"]["idField"], "doc_id") def test_analytics_dockerfile_defines_healthcheck(self): - repo_root = pathlib.Path(__file__).resolve().parents[2] + repo_root = pathlib.Path(__file__).resolve().parents[1] dockerfile = ( repo_root / "blueprints/gemini-enterprise/analytics/Dockerfile" ).read_text(encoding="utf-8") self.assertIn("HEALTHCHECK ", dockerfile) self.assertIn("/_stcore/health", dockerfile) + def test_transform_bq_schema_maps_types_modes_and_keys(self): + bq_schema = { + "fields": [ + {"name": "id", "type": "STRING", "mode": "REQUIRED"}, + {"name": "cnt", "type": "INTEGER", "mode": "NULLABLE"}, + {"name": "tags", "type": "STRING", "mode": "REPEATED"}, + { + "name": "meta", + "type": "RECORD", + "fields": [{"name": "k", "type": "BOOL"}], + }, + ] + } + + result = data_stores.transform_bq_schema(bq_schema, {"title": "id"}) + + props = result["properties"] + self.assertEqual(props["id"]["keyPropertyMapping"], "title") + self.assertEqual(props["cnt"]["type"], "integer") + self.assertFalse(props["cnt"]["searchable"]) + self.assertEqual(props["tags"]["type"], "array") + self.assertEqual(props["tags"]["items"]["type"], "string") + self.assertEqual(props["meta"]["properties"]["k"]["type"], "boolean") + if __name__ == "__main__": unittest.main() diff --git a/tests/tools/test_gemini_enterprise_fixes.py b/tests/test_gemini_enterprise_fixes.py similarity index 84% rename from tests/tools/test_gemini_enterprise_fixes.py rename to tests/test_gemini_enterprise_fixes.py index 11a70e23d..8d13d0178 100644 --- a/tests/tools/test_gemini_enterprise_fixes.py +++ b/tests/test_gemini_enterprise_fixes.py @@ -17,8 +17,12 @@ import ast import pathlib import unittest +from unittest.mock import MagicMock +from unittest.mock import patch -REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +import gem4gov + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] GEM4GOV_PATH = REPO_ROOT / "blueprints/gemini-enterprise/gem4gov-cli/gem4gov.py" @@ -26,16 +30,13 @@ class TestGeminiEnterpriseFixes(unittest.TestCase): """Verify Gemini Enterprise CLI, Terraform, and deploy.sh fixes.""" def test_engine_patch_omits_immutable_disable_analytics(self): - """Ensure update-compliance engine PATCH does not include immutable - disableAnalytics (#259). - """ + """Ensure update-compliance PATCH omits disableAnalytics (#259).""" content = GEM4GOV_PATH.read_text(encoding="utf-8") tree = ast.parse(content) self.assertNotIn( 'engine_update_mask = "features,disableAnalytics"', content, - "engine_update_mask must not include immutable disableAnalytics " - "field", + "engine_update_mask must not include immutable disableAnalytics", ) func_node = next( @@ -67,9 +68,7 @@ def test_engine_patch_omits_immutable_disable_analytics(self): self.assertNotIn('"disableAnalytics": True', wrapper_src) def test_compliance_regime_respected_in_terraform_and_cli(self): - """Ensure discovery-engine.tf and gem4gov.py respect compliance_regime - (#190). - """ + """Ensure discovery-engine.tf and gem4gov.py respect regime (#190).""" tf_path = ( REPO_ROOT / "blueprints/gemini-enterprise/gemini-stage-0/discovery-engine.tf" @@ -107,12 +106,8 @@ def test_compliance_regime_respected_in_terraform_and_cli(self): ) self.assertNotIn("updateMask=agentConfigs", py_content) - def test_deploy_sh_validates_observability_and_assistant_patch_responses( - self, - ): - """Ensure deploy.sh validates HTTP status and sensitiveLoggingEnabled on - PATCH (#182). - """ + def test_deploy_sh_validates_observability_and_assistant_patch(self): + """Ensure deploy.sh validates HTTP status on PATCH (#182).""" deploy_sh = ( REPO_ROOT / "blueprints/gemini-enterprise/deploy.sh" ).read_text(encoding="utf-8") @@ -121,9 +116,7 @@ def test_deploy_sh_validates_observability_and_assistant_patch_responses( self.assertIn("ASST_RESPONSE=$(curl -s -w", deploy_sh) def test_time_access_level_timezone_and_preservation(self): - """Ensure time access level title, variable descriptions, and deploy.sh - preservation use access_time_zone (#187). - """ + """Ensure time access level and deploy.sh use access_time_zone.""" access_policy_tf = ( REPO_ROOT / "blueprints/gemini-enterprise/gemini-stage-0/access_policy.tf" @@ -154,18 +147,16 @@ def test_time_access_level_timezone_and_preservation(self): ) def test_deploy_sh_prioritizes_tenant_iac_state_bucket(self): - """Ensure deploy.sh checks the tenant IaC state bucket before falling - back to the core bucket (#230). - """ + """Ensure deploy.sh checks tenant IaC state bucket first (#230).""" deploy_sh = ( REPO_ROOT / "blueprints/gemini-enterprise/deploy.sh" ).read_text(encoding="utf-8") - iac_idx = deploy_sh.find( - 'IAC_STATE_BUCKET="${PREFIX}-${ENVIRONMENT}-${TENANT}-iac-0"' - ) - core_idx = deploy_sh.find( + iac_var = 'IAC_STATE_BUCKET="${PREFIX}-${ENVIRONMENT}-${TENANT}-iac-0"' + core_var = ( 'LEGACY_CORE_BUCKET="${PREFIX}-tn-${ENVIRONMENT}-${TENANT}-0"' ) + iac_idx = deploy_sh.find(iac_var) + core_idx = deploy_sh.find(core_var) self.assertNotEqual(iac_idx, -1) self.assertNotEqual(core_idx, -1) self.assertIn( @@ -178,9 +169,7 @@ def test_deploy_sh_prioritizes_tenant_iac_state_bucket(self): ) def test_deploy_sh_uses_existing_compatible_terraform_before_tfenv(self): - """Ensure check_dependencies uses an existing compatible terraform (>= - 1.7.4) before tfenv (#114). - """ + """Ensure check_dependencies uses compatible terraform >= 1.7.4.""" deploy_sh = ( REPO_ROOT / "blueprints/gemini-enterprise/deploy.sh" ).read_text(encoding="utf-8") @@ -191,9 +180,7 @@ def test_deploy_sh_uses_existing_compatible_terraform_before_tfenv(self): self.assertIn('if [[ "$tf_compatible" != "true" ]]; then', deploy_sh) def test_cloudarmor_and_cli_token_handling(self): - """Ensure Cloud Armor rules deny matches and CLI passes tokens via - stdin. - """ + """Ensure Cloud Armor rules deny matches and CLI uses stdin tokens.""" cloudarmor_tf = ( REPO_ROOT / "blueprints/gemini-enterprise/gemini-stage-0/cloudarmor.tf" @@ -214,9 +201,7 @@ def test_cloudarmor_and_cli_token_handling(self): self.assertRegex(py_content, r'"-H",\s*"@-"') def test_relocation_and_multi_regime_compliance_support(self): - """Ensure gemini-enterprise is relocated and supports target compliance - regimes (#99, #102, #18, #30). - """ + """Ensure gemini-enterprise supports target regimes (#99, #102, #18).""" self.assertTrue( (REPO_ROOT / "blueprints/gemini-enterprise").is_dir(), "blueprints/gemini-enterprise directory must exist", @@ -260,12 +245,9 @@ def test_relocation_and_multi_regime_compliance_support(self): REPO_ROOT / "blueprints/gemini-enterprise/gemini-stage-0/model_armor.tf" ).read_text(encoding="utf-8") - self.assertIn( - '!contains(["IL4", "IL5"], var.compliance_regime)', main_tf - ) - self.assertIn( - '!contains(["IL4", "IL5"], var.compliance_regime)', model_armor_tf - ) + il_guard = '!contains(["IL4", "IL5"], var.compliance_regime)' + self.assertIn(il_guard, main_tf) + self.assertIn(il_guard, model_armor_tf) self.assertIn('_STATE_PROTECTION_LEVEL="software"', deploy_sh) self.assertLess( deploy_sh.index("--- Compliance Regime (Assured Workloads) ---"), @@ -273,10 +255,7 @@ def test_relocation_and_multi_regime_compliance_support(self): ) def test_brownfield_cmek_discovery_and_stage1_hardening(self): - """Verify Brownfield CMEK discovery (#106, #132), single group check - (#135), Stage 1 Shared-VPC preconditions (#111), and Terraform SSL - cert upload (#161). - """ + """Verify Brownfield CMEK discovery and Stage 1 preconditions.""" deploy_sh = ( REPO_ROOT / "blueprints/gemini-enterprise/deploy.sh" ).read_text(encoding="utf-8") @@ -284,18 +263,17 @@ def test_brownfield_cmek_discovery_and_stage1_hardening(self): 'POTENTIAL_SEC_PROJECT="${PREFIX}-${ENVIRONMENT}-sec-core-0"', deploy_sh, ) - self.assertIn( + kr_loop = ( 'for CANDIDATE_KR in "${ENVIRONMENT}-us" ' - '"${CAP_ENV}-${TENANT}-keyring"; do', - deploy_sh, + '"${CAP_ENV}-${TENANT}-keyring"; do' ) + self.assertIn(kr_loop, deploy_sh) self.assertNotIn( "2. User Role Groups: Created admin/user groups", deploy_sh ) self.assertIn("CONFIRM_IDENTITY_GROUPS", deploy_sh) self.assertIn( - "1) Provision a new regional SSL certificate via Terraform from " - "local PEM files", + "1) Provision a new regional SSL certificate via Terraform", deploy_sh, ) self.assertIn( @@ -304,7 +282,6 @@ def test_brownfield_cmek_discovery_and_stage1_hardening(self): self.assertIn( 'ssl_private_key_path = "${SSL_PRIVATE_KEY_PATH}"', deploy_sh ) - self.assertIn( 'for CANDIDATE_REG_KR in "${ENVIRONMENT}-${REGION}"', deploy_sh ) @@ -318,7 +295,8 @@ def test_brownfield_cmek_discovery_and_stage1_hardening(self): / "blueprints/gemini-enterprise/gemini-stage-1/load_balancer.tf" ).read_text(encoding="utf-8") self.assertIn( - "try(trimspace(data.terraform_remote_state.stage_0.outputs.network_project_id)", + "try(trimspace(data.terraform_remote_state.stage_0.outputs" + ".network_project_id)", lb_tf, ) self.assertIn( @@ -342,19 +320,7 @@ def test_brownfield_cmek_discovery_and_stage1_hardening(self): self.assertIn('variable "ssl_private_key_path"', stage1_vars) def test_gem4gov_cli_subcommands_for_bigquery_and_licenses(self): - """Verify gem4gov datastore import BigQuery support (#122), license - retract (#123), and license assign/unassign (#121). - """ - import sys - from unittest.mock import MagicMock - from unittest.mock import patch - - cli_dir = str(REPO_ROOT / "blueprints/gemini-enterprise/gem4gov-cli") - if cli_dir not in sys.path: - sys.path.insert(0, cli_dir) - import gem4gov - - # Verify CLI command definitions in AST + """Verify gem4gov BigQuery import and license subcommands.""" content = GEM4GOV_PATH.read_text(encoding="utf-8") self.assertIn("--dataset-id", content) self.assertIn("--table-id", content) @@ -363,8 +329,6 @@ def test_gem4gov_cli_subcommands_for_bigquery_and_licenses(self): self.assertIn('@license.command(name="assign")', content) self.assertIn('@license.command(name="unassign")', content) - # 1. Test datastore import --source-type bigquery non-interactively - # (#122) mock_creds = MagicMock() with ( patch.object( @@ -390,20 +354,20 @@ def test_gem4gov_cli_subcommands_for_bigquery_and_licenses(self): {"id": "record_uuid"}, ) - # 2. Test license retract single and batch (#123) with patch.object(gem4gov, "build") as mock_build: mock_service = MagicMock() mock_build.return_value = mock_service mock_ba = ( mock_service.billingAccounts().billingAccountLicenseConfigs() ) + cfg_name = ( + "billingAccounts/012345-6789AB-CDEF01/" + "billingAccountLicenseConfigs/cfg-main" + ) mock_ba.list.return_value.execute.return_value = { "billingAccountLicenseConfigs": [ { - "name": ( - "billingAccounts/012345-6789AB-CDEF01/" - "billingAccountLicenseConfigs/cfg-main" - ), + "name": cfg_name, "licenseConfigDistributions": { ( "projects/111/locations/us/" @@ -434,8 +398,7 @@ def test_gem4gov_cli_subcommands_for_bigquery_and_licenses(self): 1 ] self.assertEqual( - first_call_kwargs["billingAccountLicenseConfig"], - "billingAccounts/012345-6789AB-CDEF01/billingAccountLicenseConfigs/cfg-main", + first_call_kwargs["billingAccountLicenseConfig"], cfg_name ) first_call_body = first_call_kwargs["body"] self.assertEqual( @@ -444,13 +407,14 @@ def test_gem4gov_cli_subcommands_for_bigquery_and_licenses(self): ) self.assertTrue(first_call_body["fullRetract"]) - # 3. Test license assign and unassign (#121) with patch.object(gem4gov, "build") as mock_build: mock_service = MagicMock() mock_build.return_value = mock_service mock_us = mock_service.projects().locations().userStores() - mock_batch = mock_us.batchUpdateUserLicenses.return_value - mock_batch.execute.return_value = {"name": "operations/batch-op-1"} + batch_update = mock_us.batchUpdateUserLicenses + batch_update.return_value.execute.return_value = { + "name": "operations/batch-op-1" + } gem4gov._batch_update_user_licenses( mock_creds, diff --git a/tests/tools/test_lint_sh.py b/tests/test_lint_sh.py similarity index 99% rename from tests/tools/test_lint_sh.py rename to tests/test_lint_sh.py index e167426a5..aa33392f9 100644 --- a/tests/tools/test_lint_sh.py +++ b/tests/test_lint_sh.py @@ -83,7 +83,7 @@ class TestLintSh(unittest.TestCase): @classmethod def setUpClass(cls): - cls.repo_root = Path(__file__).resolve().parent.parent.parent + cls.repo_root = Path(__file__).resolve().parent.parent cls.lint_script = cls.repo_root / "tools" / "lint.sh" def _build_version_check_sandbox( diff --git a/tests/scripts/test_shell_syntax.sh b/tests/test_shell_syntax.sh similarity index 96% rename from tests/scripts/test_shell_syntax.sh rename to tests/test_shell_syntax.sh index d7cab9642..46f840111 100755 --- a/tests/scripts/test_shell_syntax.sh +++ b/tests/test_shell_syntax.sh @@ -16,7 +16,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" echo "==> Running Shell Script Syntax Validation (bash -n)" diff --git a/tests/tools/test_stage2_networking.py b/tests/test_stage2_networking.py similarity index 89% rename from tests/tools/test_stage2_networking.py rename to tests/test_stage2_networking.py index abb84872d..cca4a061e 100644 --- a/tests/tools/test_stage2_networking.py +++ b/tests/test_stage2_networking.py @@ -15,7 +15,7 @@ import pathlib import unittest -_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] _FEDRAMP_NET_DIR = _REPO_ROOT / "fast/stages-aw/2-networking-a-fedramp" _IL5_NET_DIR = _REPO_ROOT / "fast/stages-aw/2-networking-b-il5-ngfw" @@ -49,9 +49,9 @@ def test_peering_envs_depends_on_per_instance_sleep(self): encoding="utf-8" ) self.assertIn( - "local_network =" - " " - 'time_sleep.peering_delay[each.key].triggers["local_network"]', + "local_network = " + "time_sleep.peering_delay[each.key]" + '.triggers["local_network"]', branch_tf, ) self.assertNotIn( @@ -64,13 +64,13 @@ def test_nva_defaults_to_standard_on_demand_vms(self): vars_tf = (_FEDRAMP_NET_DIR / "variables.tf").read_text( encoding="utf-8" ) - self.assertIn("spot = var.nva_spot_vms", nva_tf) - self.assertIn( - 'termination_action = var.nva_spot_vms ? "STOP" : null', + self.assertRegex(nva_tf, r"spot\s*=\s*var\.nva_spot_vms") + self.assertRegex( nva_tf, + r'termination_action\s*=\s*var\.nva_spot_vms\s*\?\s*"STOP"\s*:\s*null', ) self.assertIn('variable "nva_spot_vms"', vars_tf) - self.assertIn("default = false", vars_tf) + self.assertRegex(vars_tf, r"default\s*=\s*false") def test_nva_and_ngfw_service_account_and_helper_hardening(self): nva_tf = (_FEDRAMP_NET_DIR / "nva.tf").read_text(encoding="utf-8") @@ -85,9 +85,7 @@ def test_nva_and_ngfw_service_account_and_helper_hardening(self): self.assertIn("-stdin", openssl_sh) def test_stage2_and_ato_documentation_coherence(self): - """Verify ATO baseline guidance (#232), Stage 2a TDD and regional docs - (#195), and firewall policy docs (#117). - """ + """Verify ATO baseline guidance and Stage 2a/firewall docs.""" ato_doc = (_REPO_ROOT / "docs/path-to-authorization.md").read_text( encoding="utf-8" ) @@ -122,9 +120,7 @@ def test_stage2_and_ato_documentation_coherence(self): self.assertIn("gcloud compute firewall-rules list", bp_readme) def test_essential_contacts_spoke_iam_and_ngfw_secrets(self): - """Verify essential_contacts map support (#119), spoke_project_iam - (#120), and NGFW Secret Manager keys (#20). - """ + """Verify essential_contacts, spoke_project_iam, and NGFW secrets.""" for stage in [ "0-bootstrap", "2-networking-a-fedramp", @@ -135,8 +131,8 @@ def test_essential_contacts_spoke_iam_and_ngfw_secrets(self): _REPO_ROOT / f"fast/stages-aw/{stage}/variables.tf" ).read_text(encoding="utf-8") self.assertIn( - "can({ for k, v in tomap(var.essential_contacts) : k => " - "tolist(v) })", + "can({ for k, v in tomap(var.essential_contacts)" + " : k => tolist(v) })", var_text, ) self.assertIn("can(tostring(var.essential_contacts))", var_text) @@ -145,8 +141,8 @@ def test_essential_contacts_spoke_iam_and_ngfw_secrets(self): _REPO_ROOT / "fast/stages-aw/0-bootstrap/organization.tf" ).read_text(encoding="utf-8") self.assertIn( - "contacts = var.bootstrap_user != null ? {} : " - "local.essential_contacts", + "contacts = var.bootstrap_user != null" + " ? {} : local.essential_contacts", org_tf, ) self.assertIn( @@ -157,11 +153,11 @@ def test_essential_contacts_spoke_iam_and_ngfw_secrets(self): branch_tf = ( _REPO_ROOT / f"fast/stages-aw/{stage}/branch-net-envs.tf" ).read_text(encoding="utf-8") - self.assertIn( + expected_lookup = ( "lookup(var.spoke_project_iam, lower(each.key), " - "lookup(var.spoke_project_iam, each.key, {}))", - branch_tf, + "lookup(var.spoke_project_iam, each.key, {}))" ) + self.assertIn(expected_lookup, branch_tf) stage_vars = ( _REPO_ROOT / f"fast/stages-aw/{stage}/variables.tf" ).read_text(encoding="utf-8") diff --git a/tests/tools/test_stage_links.py b/tests/test_stage_links.py similarity index 99% rename from tests/tools/test_stage_links.py rename to tests/test_stage_links.py index c8060bb0f..4788d0af5 100644 --- a/tests/tools/test_stage_links.py +++ b/tests/test_stage_links.py @@ -18,7 +18,7 @@ import tempfile import unittest -_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] _STAGE_LINKS = _REPO_ROOT / "fast/stage-links.sh" diff --git a/tests/tools/test_state_iam.py b/tests/test_state_iam.py similarity index 76% rename from tests/tools/test_state_iam.py rename to tests/test_state_iam.py index a2e2a5396..ed9c60af1 100644 --- a/tests/tools/test_state_iam.py +++ b/tests/test_state_iam.py @@ -60,6 +60,7 @@ def test_get_bindings_project_iam(self): self.assertEqual(b.member_id, "viewers") def test_get_bindings_with_prefix(self): + member = "serviceAccount:sa1@prefix-proj-1.iam.gserviceaccount.com" resources = [ { "type": "google_project_iam_member", @@ -68,10 +69,7 @@ def test_get_bindings_with_prefix(self): "attributes": { "project": "prefix-proj-1", "role": "roles/editor", - "member": ( - "serviceAccount:sa1@prefix-proj-1." - "iam.gserviceaccount.com" - ), + "member": member, "condition": [], } } @@ -85,16 +83,14 @@ def test_get_bindings_with_prefix(self): self.assertEqual(b.resource_id, "proj-1") def test_get_bindings_multi_underscore_resource_type(self): + key_id = "projects/p1/locations/us/keyRings/kr1/cryptoKeys/k1" resources = [ { "type": "google_kms_crypto_key_iam_member", "instances": [ { "attributes": { - "crypto_key_id": ( - "projects/p1/locations/us/keyRings/kr1/" - "cryptoKeys/k1" - ), + "crypto_key_id": key_id, "role": ( "roles/cloudkms.cryptoKeyEncrypterDecrypter" ), @@ -111,6 +107,33 @@ def test_get_bindings_multi_underscore_resource_type(self): self.assertEqual(len(bindings), 1) self.assertEqual(bindings[0].resource_type, "kms_crypto_key") + def test_get_folders_resolves_parent_child_names(self): + resources = [ + { + "type": "google_folder", + "instances": [ + { + "attributes": { + "id": "folders/1", + "display_name": "Root", + "parent": "organizations/111", + } + }, + { + "attributes": { + "id": "folders/2", + "display_name": "Sub", + "parent": "folders/1", + } + }, + ], + } + ] + self.assertEqual( + dict(state_iam.get_folders(resources)), + {"folders/1": "Root", "folders/2": "Root/Sub"}, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/tools/test_tfdoc.py b/tests/test_tfdoc.py similarity index 71% rename from tests/tools/test_tfdoc.py rename to tests/test_tfdoc.py index 55ac3ef54..5844ba798 100644 --- a/tests/tools/test_tfdoc.py +++ b/tests/test_tfdoc.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import tempfile import unittest import tfdoc @@ -82,9 +84,6 @@ def test_output_regex_parsing(self): ) def test_parse_files_skips_symlinks(self): - import os - import tempfile - with tempfile.TemporaryDirectory() as tmpdir: real_tf = os.path.join(tmpdir, "main.tf") with open(real_tf, "w", encoding="utf-8") as f: @@ -97,6 +96,38 @@ def test_parse_files_skips_symlinks(self): self.assertEqual(len(files), 1) self.assertEqual(files[0].name, "main.tf") + def test_create_and_render_tfref_generates_expected_tables(self): + with tempfile.TemporaryDirectory() as tmpdir: + vars_path = os.path.join(tmpdir, "variables.tf") + with open(vars_path, "w", encoding="utf-8") as f: + f.write( + 'variable "project_id" {\n' + ' description = "Project ID."\n' + " type = string\n" + "}\n" + ) + outs_path = os.path.join(tmpdir, "outputs.tf") + with open(outs_path, "w", encoding="utf-8") as f: + f.write( + 'output "id" {\n' + ' description = "Output ID."\n' + " value = var.project_id\n" + " sensitive = true\n" + "}\n" + ) + doc = tfdoc.create_tfref( + tmpdir, files=False, show_extra=False, readme="" + ) + rendered = tfdoc.render_tfref( + f"# Mod\n{tfdoc.MARK_BEGIN}\n{tfdoc.MARK_END}\n", doc.content + ) + self.assertIn( + "| [project_id](variables.tf#L1) | Project ID." + " | string | ✓ | |", + rendered, + ) + self.assertIn("| [id](outputs.tf#L1) | Output ID. | ✓ |", rendered) + if __name__ == "__main__": unittest.main() diff --git a/tests/scripts/test_tflint.sh b/tests/test_tflint.sh similarity index 98% rename from tests/scripts/test_tflint.sh rename to tests/test_tflint.sh index de457324c..e03405cb9 100755 --- a/tests/scripts/test_tflint.sh +++ b/tests/test_tflint.sh @@ -16,7 +16,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" TEST_COUNT=0 FAIL_COUNT=0 diff --git a/tests/tools/test_uv_tooling.py b/tests/test_uv_tooling.py similarity index 98% rename from tests/tools/test_uv_tooling.py rename to tests/test_uv_tooling.py index 975d25c73..034631777 100644 --- a/tests/tools/test_uv_tooling.py +++ b/tests/test_uv_tooling.py @@ -18,7 +18,7 @@ import tomllib import unittest -_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] _BARE_PYTHON_RE = re.compile(r"(? [2/3] Running Shell Script Tests..." -bash "${REPO_ROOT}/tests/scripts/run_shell_tests.sh" +for test_file in "${REPO_ROOT}"/tests/test_*.sh; do + echo "=== Running $(basename "${test_file}") ===" + bash "${test_file}" +done -# 3. Python Code Quality / AST Unused Local Variable Check +# 3. Python Code Quality / AST Unused Local Variable & Boilerplate Checks echo "" -echo "==> [3/3] Verifying CodeQL py/unused-local-variable compliance..." +echo "==> [3/3] Verifying CodeQL py/unused-local-variable and boilerplate compliance..." +uv run --project "${REPO_ROOT}" --locked python "${REPO_ROOT}/tools/check_boilerplate.py" "${REPO_ROOT}/tests" "${REPO_ROOT}/tools" uv run --project "${REPO_ROOT}" --locked python -c " import ast import os