"""Integration tests for the github_cli Lambda handler.""" from typing import Any from unittest.mock import patch import pytest from github_client.app import handler from tests.integration.conftest import ( MockResponse, make_copilot_event, make_search_event, make_search_item, ) def _github_env(monkeypatch: pytest.MonkeyPatch) -> None: """Set minimal GitHub env vars required by GitHubConfig.""" monkeypatch.setenv('GITHUB_TOKEN', 'ghp_test_token') @pytest.mark.integration class TestSearchTextInOrg: """Integration tests for the search-text-in-org action.""" def test_email_and_name_merged_and_deduplicated( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Results from per-term searches are merged and deduplicated by URL. The default event (jane@example.com / Jane Doe) runs three searches: 'jane@example.com' and 'jane' (high confidence) plus 'jdoe' (low). The shared item appears in two of them and must be deduplicated. """ _github_env(monkeypatch) shared_item = make_search_item( html_url='https://github.com/org/repo/blob/main/a.tf' ) unique_item = make_search_item( path='other.tf', html_url='https://github.com/org/repo/blob/main/other.tf', ) # One response per search term: email, local-part, jdoe. responses = [ MockResponse(200, {'items': [shared_item]}), # 'jane@example.com' MockResponse(200, {'items': [shared_item, unique_item]}), # 'jane' MockResponse(200, {'items': []}), # 'jdoe' ] with patch('requests.Session.get', side_effect=responses): result = handler(make_search_event(), {}) assert result['ticket_id'] == 'SYS-1' assert result['email'] == 'jane@example.com' assert result['full_name'] == 'Jane Doe' assert len(result['terraform_hits']) == 2 urls = {h['html_url'] for h in result['terraform_hits']} assert shared_item['html_url'] in urls assert unique_item['html_url'] in urls # Both matched only high-confidence terms. assert all(h['confidence'] == 'high' for h in result['terraform_hits']) def test_jdoe_only_match_tagged_low_confidence( self, monkeypatch: pytest.MonkeyPatch ) -> None: """A hit found only via the initial+lastname term is tagged 'low'.""" _github_env(monkeypatch) jdoe_item = make_search_item( path='iam/noise.tf', html_url='https://github.com/org/repo/blob/main/noise.tf', ) # email and local-part searches find nothing; only 'jdoe' matches. responses = [ MockResponse(200, {'items': []}), # 'jane@example.com' MockResponse(200, {'items': []}), # 'jane' MockResponse(200, {'items': [jdoe_item]}), # 'jdoe' ] with patch('requests.Session.get', side_effect=responses): result = handler(make_search_event(), {}) assert len(result['terraform_hits']) == 1 assert result['terraform_hits'][0]['confidence'] == 'low' def test_high_confidence_not_downgraded_by_jdoe( self, monkeypatch: pytest.MonkeyPatch ) -> None: """A URL matched by the email term stays 'high' even if jdoe matches it too.""" _github_env(monkeypatch) shared = make_search_item( html_url='https://github.com/org/repo/blob/main/shared.tf' ) responses = [ MockResponse(200, {'items': [shared]}), # 'jane@example.com' -> high MockResponse(200, {'items': []}), # 'jane' MockResponse(200, {'items': [shared]}), # 'jdoe' also matches ] with patch('requests.Session.get', side_effect=responses): result = handler(make_search_event(), {}) assert len(result['terraform_hits']) == 1 assert result['terraform_hits'][0]['confidence'] == 'high' def test_bare_repo_filter_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: """A repo_filter without an owner raises ValueError (Bug 1 guard).""" _github_env(monkeypatch) with patch( 'requests.Session.get', return_value=MockResponse(200, {'items': []}) ): with pytest.raises(ValueError, match='owner/repo'): handler(make_search_event(repo_filter='terraform-infra'), {}) def test_no_results_returns_empty_list( self, monkeypatch: pytest.MonkeyPatch ) -> None: """No search results → terraform_hits: [].""" _github_env(monkeypatch) with patch( 'requests.Session.get', return_value=MockResponse(200, {'items': []}), ): result = handler(make_search_event(), {}) assert result['terraform_hits'] == [] def test_hit_contains_only_required_fields( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Each hit exposes only path, html_url, repository, confidence.""" _github_env(monkeypatch) item = make_search_item() # Add extra fields that should NOT appear in output item['sha'] = 'abc123' item['score'] = 1.0 with patch( 'requests.Session.get', return_value=MockResponse(200, {'items': [item]}), ): result = handler(make_search_event(), {}) hit = result['terraform_hits'][0] assert set(hit.keys()) == {'path', 'html_url', 'repository', 'confidence'} def test_github_403_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: """GitHub 403 propagates as an exception.""" _github_env(monkeypatch) with patch( 'requests.Session.get', return_value=MockResponse(403, {}), ): with pytest.raises(Exception): handler(make_search_event(), {}) def test_pagination_fetches_multiple_pages( self, monkeypatch: pytest.MonkeyPatch ) -> None: """When first page is full (30 items), a second page is requested.""" _github_env(monkeypatch) page1_items = [ make_search_item( path=f'file{i}.tf', html_url=f'https://github.com/org/repo/blob/main/file{i}.tf', ) for i in range(30) ] page2_items = [ make_search_item( path='last.tf', html_url='https://github.com/org/repo/blob/main/last.tf', ) ] # First search term ('jane@example.com') paginates page1 -> page2. # Remaining terms ('jane', 'jdoe') return the same URLs (deduplicated). responses = [ MockResponse(200, {'items': page1_items}), MockResponse(200, {'items': page2_items}), MockResponse(200, {'items': []}), # 'jane' MockResponse(200, {'items': []}), # 'jdoe' ] with patch('requests.Session.get', side_effect=responses): result = handler(make_search_event(), {}) # 30 unique from page1 + 1 unique from page2 = 31 total assert len(result['terraform_hits']) == 31 def test_rate_limit_sleep_and_retry(self, monkeypatch: pytest.MonkeyPatch) -> None: """When rate limit remaining==1 with a reset time, sleeps then retries.""" import time _github_env(monkeypatch) reset_at = str(int(time.time()) + 60) rate_limited = MockResponse(200, {'items': []}) rate_limited.headers = { 'X-RateLimit-Remaining': '1', 'X-RateLimit-Reset': reset_at, } success = MockResponse( 200, {'items': [make_search_item()]}, ) success.headers = {'X-RateLimit-Remaining': '4999'} # First search term: rate-limited, then retried successfully. # Remaining terms ('jane', 'jdoe') return the same URL (deduplicated). responses = [ rate_limited, success, MockResponse(200, {'items': []}), # 'jane' MockResponse(200, {'items': []}), # 'jdoe' ] with patch('requests.Session.get', side_effect=responses): with patch('github_client.github_client.time.sleep') as mock_sleep: result = handler(make_search_event(), {}) mock_sleep.assert_called_once() assert len(result['terraform_hits']) == 1 @pytest.mark.integration class TestOffboardUserWithCopilot: """Integration tests for the offboard-user-with-copilot action.""" def test_issue_created_with_correct_title( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Issue is created in the repo where hits were found; response contains issue_urls and issue_numbers.""" _github_env(monkeypatch) issue_response = MockResponse( 201, { 'html_url': 'https://github.com/theorchard/terraform-infra/issues/42', 'number': 42, }, ) terraform_hits = [ { 'path': 'modules/iam/users.tf', 'html_url': 'https://github.com/theorchard/terraform-infra/blob/master/modules/iam/users.tf', 'repository': 'theorchard/terraform-infra', } ] with patch('requests.Session.post', return_value=issue_response): result = handler(make_copilot_event(terraform_hits=terraform_hits), {}) assert result['issue_urls'] == [ 'https://github.com/theorchard/terraform-infra/issues/42' ] assert result['issue_numbers'] == [42] assert result['dry_run'] is False assert result['ticket_id'] == 'SYS-1' def test_repo_field_is_optional(self, monkeypatch: pytest.MonkeyPatch) -> None: """Event without repo field is accepted; issues are created per hit repository.""" _github_env(monkeypatch) terraform_hits = [ { 'path': 'modules/iam/users.tf', 'html_url': 'https://github.com/theorchard/terraform-infra/blob/master/modules/iam/users.tf', 'repository': 'theorchard/terraform-infra', } ] issue_response = MockResponse( 201, { 'html_url': 'https://github.com/theorchard/terraform-infra/issues/1', 'number': 1, }, ) with patch('requests.Session.post', return_value=issue_response): result = handler( make_copilot_event(terraform_hits=terraform_hits), # repo omitted {}, ) assert result['issue_urls'] == [ 'https://github.com/theorchard/terraform-infra/issues/1' ] def test_no_hits_skips_issue_creation( self, monkeypatch: pytest.MonkeyPatch ) -> None: """When there are no terraform hits, no issue is created and lists are empty.""" _github_env(monkeypatch) with patch('requests.Session.post') as mock_post: result = handler(make_copilot_event(), {}) mock_post.assert_not_called() assert result['issue_urls'] == [] assert result['issue_numbers'] == [] assert result['dry_run'] is False def test_dry_run_skips_issue_creation( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Dry run returns issue_urls=[], no POST called.""" _github_env(monkeypatch) with patch('requests.Session.post') as mock_post: result = handler(make_copilot_event(dry_run=True), {}) assert result['issue_urls'] == [] assert result['issue_numbers'] == [] assert result['dry_run'] is True mock_post.assert_not_called() def test_issue_title_includes_name_and_email( self, monkeypatch: pytest.MonkeyPatch ) -> None: """The created issue title contains the user's full name and email.""" _github_env(monkeypatch) issue_response = MockResponse( 201, {'html_url': 'https://github.com/x', 'number': 1} ) captured: dict[str, Any] = {} def capture_post(url: str, **kwargs: Any) -> MockResponse: captured['payload'] = kwargs.get('json', {}) return issue_response with patch('requests.Session.post', side_effect=capture_post): handler( make_copilot_event( full_name='Bob Smith', email='bob@example.com', terraform_hits=[ { 'path': 'iam/users.tf', 'html_url': 'https://github.com/org/repo/blob/main/iam/users.tf', 'repository': 'org/repo', } ], ), {}, ) assert 'Bob Smith' in captured['payload']['title'] assert 'bob@example.com' in captured['payload']['title'] def test_issue_body_contains_auth0_and_terraform_details( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Issue body includes auth0 results and terraform hit links.""" _github_env(monkeypatch) captured: dict[str, Any] = {} def capture_post(url: str, **kwargs: Any) -> MockResponse: captured['payload'] = kwargs.get('json', {}) return MockResponse(201, {'html_url': 'https://github.com/x', 'number': 1}) auth0_results = [ {'tenant': 'prod', 'user_id': 'auth0|abc123', 'operation': 'deleted'} ] terraform_hits = [ { 'path': 'modules/iam/main.tf', 'html_url': 'https://github.com/org/repo/blob/main/main.tf', 'repository': 'theorchard/terraform-infra', } ] with patch('requests.Session.post', side_effect=capture_post): handler( make_copilot_event( auth0_results=auth0_results, terraform_hits=terraform_hits, ), {}, ) payload = captured['payload'] body = payload['body'] assert payload['title'] == 'Offboard user Jane Doe (jane@example.com)' assert 'prod' in body assert 'auth0|abc123' in body assert 'deleted' in body assert 'modules/iam/main.tf' in body assert 'https://github.com/org/repo/blob/main/main.tf' in body def test_suspend_operation_produces_suspend_wording( self, monkeypatch: pytest.MonkeyPatch ) -> None: """A suspend operation yields a suspend-worded title/body and 'blocked'.""" _github_env(monkeypatch) captured: dict[str, Any] = {} def capture_post(url: str, **kwargs: Any) -> MockResponse: captured['payload'] = kwargs.get('json', {}) return MockResponse(201, {'html_url': 'https://github.com/x', 'number': 1}) auth0_results = [ {'tenant': 'prod', 'user_id': 'auth0|abc123', 'operation': 'blocked'} ] terraform_hits = [ { 'path': 'modules/iam/main.tf', 'html_url': 'https://github.com/org/repo/blob/main/main.tf', 'repository': 'theorchard/terraform-infra', } ] with patch('requests.Session.post', side_effect=capture_post): handler( make_copilot_event( operation='suspend', auth0_results=auth0_results, terraform_hits=terraform_hits, ), {}, ) payload = captured['payload'] body = payload['body'] assert payload['title'] == 'Suspend access for Jane Doe (jane@example.com)' assert 'Suspension start date' in body assert 'blocked' in body assert 'without deleting' in body def test_low_confidence_hits_in_separate_section( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Low-confidence hits render under a 'Possible matches' heading.""" _github_env(monkeypatch) captured: dict[str, Any] = {} def capture_post(url: str, **kwargs: Any) -> MockResponse: captured['payload'] = kwargs.get('json', {}) return MockResponse(201, {'html_url': 'https://github.com/x', 'number': 1}) terraform_hits = [ { 'path': 'iam/real.tf', 'html_url': 'https://github.com/org/repo/blob/main/real.tf', 'repository': 'org/repo', 'confidence': 'high', }, { 'path': 'iam/noise.tf', 'html_url': 'https://github.com/org/repo/blob/main/noise.tf', 'repository': 'org/repo', 'confidence': 'low', }, ] with patch('requests.Session.post', side_effect=capture_post): handler(make_copilot_event(terraform_hits=terraform_hits), {}) body = captured['payload']['body'] assert '### Terraform references found' in body assert '### Possible matches (low confidence)' in body # High hit is above the low-confidence heading; low hit is below it. high_pos = body.index('iam/real.tf') heading_pos = body.index('### Possible matches') low_pos = body.index('iam/noise.tf') assert high_pos < heading_pos < low_pos def test_no_low_confidence_section_when_all_high( self, monkeypatch: pytest.MonkeyPatch ) -> None: """The 'Possible matches' section is omitted when there are no low hits.""" _github_env(monkeypatch) captured: dict[str, Any] = {} def capture_post(url: str, **kwargs: Any) -> MockResponse: captured['payload'] = kwargs.get('json', {}) return MockResponse(201, {'html_url': 'https://github.com/x', 'number': 1}) terraform_hits = [ { 'path': 'iam/real.tf', 'html_url': 'https://github.com/org/repo/blob/main/real.tf', 'repository': 'org/repo', 'confidence': 'high', }, ] with patch('requests.Session.post', side_effect=capture_post): handler(make_copilot_event(terraform_hits=terraform_hits), {}) assert '### Possible matches' not in captured['payload']['body'] def test_issue_has_no_assignees_or_agent_assignment( self, monkeypatch: pytest.MonkeyPatch ) -> None: """POST payload has no assignees or agent_assignment (manual triage).""" _github_env(monkeypatch) captured: dict[str, Any] = {} def capture_post(url: str, **kwargs: Any) -> MockResponse: captured['payload'] = kwargs.get('json', {}) return MockResponse(201, {'html_url': 'https://github.com/x', 'number': 1}) terraform_hits = [ { 'path': 'iam/users.tf', 'html_url': 'https://github.com/myorg/my-repo/blob/main/iam/users.tf', 'repository': 'myorg/my-repo', } ] with patch('requests.Session.post', side_effect=capture_post): handler( make_copilot_event( terraform_hits=terraform_hits, repo='myorg/my-repo', base_branch='main', ), {}, ) assert 'agent_assignment' not in captured['payload'] assert 'assignees' not in captured['payload'] def test_hits_in_multiple_repos_create_one_issue_per_repo( self, monkeypatch: pytest.MonkeyPatch ) -> None: """When hits span multiple repos, one issue is created in each repo.""" _github_env(monkeypatch) terraform_hits = [ { 'path': 'iam/users.tf', 'html_url': 'https://github.com/org/repo-a/blob/main/iam/users.tf', 'repository': 'org/repo-a', }, { 'path': 'services/main.tf', 'html_url': 'https://github.com/org/repo-b/blob/main/services/main.tf', 'repository': 'org/repo-b', }, ] captured_posts: list[dict[str, Any]] = [] def capture_post(url: str, **kwargs: Any) -> MockResponse: captured_posts.append({'url': url, 'payload': kwargs.get('json', {})}) repo = url.split('/repos/')[1].split('/issues')[0] return MockResponse( 201, { 'html_url': f'https://github.com/{repo}/issues/{len(captured_posts)}', 'number': len(captured_posts), }, ) with patch('requests.Session.post', side_effect=capture_post): result = handler( make_copilot_event(terraform_hits=terraform_hits), {}, ) assert len(captured_posts) == 2 posted_urls = {p['url'] for p in captured_posts} assert 'https://api.github.com/repos/org/repo-a/issues' in posted_urls assert 'https://api.github.com/repos/org/repo-b/issues' in posted_urls assert len(result['issue_urls']) == 2 assert len(result['issue_numbers']) == 2 def test_unknown_action_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: """Unknown action raises ValueError.""" _github_env(monkeypatch) with pytest.raises(ValueError, match='Unknown action'): handler({'action': 'bogus', 'dry_run': False}, {}) @pytest.mark.integration class TestSchemaValidation: """Tests for input validation on github_cli schemas.""" def test_search_empty_email_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: """search-text-in-org with empty email raises ValidationError.""" from pydantic import ValidationError _github_env(monkeypatch) with pytest.raises(ValidationError): handler( { 'action': 'search-text-in-org', 'email': '', 'full_name': 'Jane Doe', 'org': 'myorg', 'dry_run': False, }, {}, ) def test_search_empty_full_name_rejected( self, monkeypatch: pytest.MonkeyPatch ) -> None: """search-text-in-org with empty full_name raises ValidationError.""" from pydantic import ValidationError _github_env(monkeypatch) with pytest.raises(ValidationError): handler( { 'action': 'search-text-in-org', 'email': 'jane@example.com', 'full_name': '', 'org': 'myorg', 'dry_run': False, }, {}, )