Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit e2560c5

Browse files
committed
Merge branch 'main' into Ajay/milestone-3-migration
2 parents 49f2af8 + e06c5d8 commit e2560c5

File tree

19 files changed

+422
-55
lines changed

19 files changed

+422
-55
lines changed

api/gen_ai/__init__.py

Whitespace-only changes.

api/gen_ai/serializers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from rest_framework import serializers
2+
3+
4+
class GenAIAuthSerializer(serializers.Serializer):
5+
is_valid = serializers.BooleanField()

api/gen_ai/tests/test_gen_ai.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import hmac
2+
from hashlib import sha256
3+
from unittest.mock import patch
4+
5+
from django.urls import reverse
6+
from rest_framework import status
7+
from rest_framework.test import APITestCase
8+
from shared.django_apps.core.tests.factories import OwnerFactory
9+
10+
from codecov_auth.models import GithubAppInstallation
11+
12+
PAYLOAD_SECRET = b"testixik8qdauiab1yiffydimvi72ekq"
13+
VIEW_URL = reverse("auth")
14+
15+
16+
def sign_payload(data: bytes, secret=PAYLOAD_SECRET):
17+
signature = "sha256=" + hmac.new(secret, data, digestmod=sha256).hexdigest()
18+
return signature, data
19+
20+
21+
class GenAIAuthViewTests(APITestCase):
22+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
23+
def test_missing_parameters(self, mock_config):
24+
payload = b"{}"
25+
sig, data = sign_payload(payload)
26+
response = self.client.post(
27+
VIEW_URL,
28+
data=data,
29+
content_type="application/json",
30+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
31+
)
32+
self.assertEqual(response.status_code, 400)
33+
self.assertIn("Missing required parameters", response.data)
34+
35+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
36+
def test_invalid_signature(self, mock_config):
37+
# Correct payload
38+
payload = b'{"external_owner_id":"owner1","repo_service_id":"101"}'
39+
# Wrong signature based on a different payload
40+
wrong_sig = "sha256=" + hmac.new(PAYLOAD_SECRET, b"{}", sha256).hexdigest()
41+
response = self.client.post(
42+
VIEW_URL,
43+
data=payload,
44+
content_type="application/json",
45+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=wrong_sig,
46+
)
47+
self.assertEqual(response.status_code, 403)
48+
49+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
50+
def test_owner_not_found(self, mock_config):
51+
payload = b'{"external_owner_id":"nonexistent_owner","repo_service_id":"101"}'
52+
sig, data = sign_payload(payload)
53+
response = self.client.post(
54+
VIEW_URL,
55+
data=data,
56+
content_type="application/json",
57+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
58+
)
59+
self.assertEqual(response.status_code, 404)
60+
61+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
62+
def test_no_installation(self, mock_config):
63+
# Create a valid owner but no installation
64+
OwnerFactory(service="github", service_id="owner1", username="test1")
65+
payload = b'{"external_owner_id":"owner1","repo_service_id":"101"}'
66+
sig, data = sign_payload(payload)
67+
response = self.client.post(
68+
VIEW_URL,
69+
data=data,
70+
content_type="application/json",
71+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
72+
)
73+
self.assertEqual(response.status_code, 200)
74+
self.assertEqual(response.data, {"is_valid": False})
75+
76+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
77+
def test_authorized(self, mock_config):
78+
owner = OwnerFactory(service="github", service_id="owner2", username="test2")
79+
GithubAppInstallation.objects.create(
80+
installation_id=12345,
81+
owner=owner,
82+
name="ai-features",
83+
repository_service_ids=["101", "202"],
84+
)
85+
payload = b'{"external_owner_id":"owner2","repo_service_id":"101"}'
86+
sig, data = sign_payload(payload)
87+
response = self.client.post(
88+
VIEW_URL,
89+
data=data,
90+
content_type="application/json",
91+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
92+
)
93+
self.assertEqual(response.status_code, status.HTTP_200_OK)
94+
self.assertEqual(response.data, {"is_valid": True})
95+
96+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
97+
def test_unauthorized(self, mock_config):
98+
owner = OwnerFactory(service="github", service_id="owner3", username="test3")
99+
GithubAppInstallation.objects.create(
100+
installation_id=2,
101+
owner=owner,
102+
name="ai-features",
103+
repository_service_ids=["303", "404"],
104+
)
105+
payload = b'{"external_owner_id":"owner3","repo_service_id":"101"}'
106+
sig, data = sign_payload(payload)
107+
response = self.client.post(
108+
VIEW_URL,
109+
data=data,
110+
content_type="application/json",
111+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
112+
)
113+
self.assertEqual(response.status_code, 200)
114+
self.assertEqual(response.data, {"is_valid": False})

api/gen_ai/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.urls import path
2+
3+
from .views import GenAIAuthView
4+
5+
urlpatterns = [
6+
path("auth/", GenAIAuthView.as_view(), name="auth"),
7+
]

api/gen_ai/views.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import hmac
2+
import logging
3+
from hashlib import sha256
4+
5+
from rest_framework.exceptions import NotFound, PermissionDenied
6+
from rest_framework.permissions import AllowAny
7+
from rest_framework.response import Response
8+
from rest_framework.views import APIView
9+
10+
from api.gen_ai.serializers import GenAIAuthSerializer
11+
from codecov_auth.models import GithubAppInstallation, Owner
12+
from graphql_api.types.owner.owner import AI_FEATURES_GH_APP_ID
13+
from utils.config import get_config
14+
15+
log = logging.getLogger(__name__)
16+
17+
18+
class GenAIAuthView(APIView):
19+
permission_classes = [AllowAny]
20+
serializer_class = GenAIAuthSerializer
21+
22+
def validate_signature(self, request):
23+
key = get_config("gen_ai", "auth_secret")
24+
if not key:
25+
raise PermissionDenied("Invalid signature")
26+
27+
if isinstance(key, str):
28+
key = key.encode("utf-8")
29+
expected_sig = request.headers.get("HTTP-X-GEN-AI-AUTH-SIGNATURE")
30+
computed_sig = (
31+
"sha256=" + hmac.new(key, request.body, digestmod=sha256).hexdigest()
32+
)
33+
if not hmac.compare_digest(computed_sig, expected_sig):
34+
raise PermissionDenied("Invalid signature")
35+
36+
def post(self, request, *args, **kwargs):
37+
self.validate_signature(request)
38+
external_owner_id = request.data.get("external_owner_id")
39+
repo_service_id = request.data.get("repo_service_id")
40+
if not external_owner_id or not repo_service_id:
41+
return Response("Missing required parameters", status=400)
42+
try:
43+
owner = Owner.objects.get(service_id=external_owner_id)
44+
except Owner.DoesNotExist:
45+
raise NotFound("Owner not found")
46+
47+
is_authorized = True
48+
49+
app_install = GithubAppInstallation.objects.filter(
50+
owner_id=owner.ownerid, app_id=AI_FEATURES_GH_APP_ID
51+
).first()
52+
53+
if not app_install:
54+
is_authorized = False
55+
56+
else:
57+
repo_ids = app_install.repository_service_ids
58+
if repo_ids and repo_service_id not in repo_ids:
59+
is_authorized = False
60+
61+
return Response({"is_valid": is_authorized})

codecov/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@
3737
# /monitoring/metrics will be a public route unless you take steps at a
3838
# higher level to null-route or redirect it.
3939
path("monitoring/", include("django_prometheus.urls")),
40+
path("gen_ai/", include("api.gen_ai.urls")),
4041
]

codecov_auth/commands/owner/interactors/get_is_current_user_an_admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,5 @@ def execute(self, owner, current_owner):
5050
owner.add_admin(current_owner)
5151
return isAdmin or (current_owner.ownerid in admins)
5252
except Exception as error:
53-
print("Error Calling Admin Provider " + repr(error))
53+
print("Error Calling Admin Provider " + repr(error)) # noqa: T201
5454
return False

codecov_auth/commands/owner/interactors/save_terms_agreement.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
@dataclass
1313
class TermsAgreementInput:
1414
business_email: Optional[str] = None
15+
name: Optional[str] = None
1516
terms_agreement: bool = False
1617
marketing_consent: bool = False
1718
customer_intent: Optional[str] = None
@@ -20,7 +21,7 @@ class TermsAgreementInput:
2021
class SaveTermsAgreementInteractor(BaseInteractor):
2122
requires_service = False
2223

23-
def validate(self, input: TermsAgreementInput) -> None:
24+
def validate_deprecated(self, input: TermsAgreementInput) -> None:
2425
valid_customer_intents = ["Business", "BUSINESS", "Personal", "PERSONAL"]
2526
if (
2627
input.customer_intent
@@ -30,7 +31,11 @@ def validate(self, input: TermsAgreementInput) -> None:
3031
if not self.current_user.is_authenticated:
3132
raise Unauthenticated()
3233

33-
def update_terms_agreement(self, input: TermsAgreementInput) -> None:
34+
def validate(self, input: TermsAgreementInput) -> None:
35+
if not self.current_user.is_authenticated:
36+
raise Unauthenticated()
37+
38+
def update_terms_agreement_deprecated(self, input: TermsAgreementInput) -> None:
3439
self.current_user.terms_agreement = input.terms_agreement
3540
self.current_user.terms_agreement_at = timezone.now()
3641
self.current_user.customer_intent = input.customer_intent
@@ -44,6 +49,20 @@ def update_terms_agreement(self, input: TermsAgreementInput) -> None:
4449
if input.marketing_consent:
4550
self.send_data_to_marketo()
4651

52+
def update_terms_agreement(self, input: TermsAgreementInput) -> None:
53+
self.current_user.terms_agreement = input.terms_agreement
54+
self.current_user.terms_agreement_at = timezone.now()
55+
self.current_user.name = input.name
56+
self.current_user.email_opt_in = input.marketing_consent
57+
self.current_user.save()
58+
59+
if input.business_email and input.business_email != "":
60+
self.current_user.email = input.business_email
61+
self.current_user.save()
62+
63+
if input.marketing_consent:
64+
self.send_data_to_marketo()
65+
4766
def send_data_to_marketo(self) -> None:
4867
event_data = {
4968
"email": self.current_user.email,
@@ -52,11 +71,22 @@ def send_data_to_marketo(self) -> None:
5271

5372
@sync_to_async
5473
def execute(self, input: Any) -> None:
55-
typed_input = TermsAgreementInput(
56-
business_email=input.get("business_email"),
57-
terms_agreement=input.get("terms_agreement"),
58-
marketing_consent=input.get("marketing_consent"),
59-
customer_intent=input.get("customer_intent"),
60-
)
61-
self.validate(typed_input)
62-
return self.update_terms_agreement(typed_input)
74+
if input.get("name"):
75+
typed_input = TermsAgreementInput(
76+
business_email=input.get("business_email"),
77+
terms_agreement=input.get("terms_agreement"),
78+
marketing_consent=input.get("marketing_consent"),
79+
name=input.get("name"),
80+
)
81+
self.validate(typed_input)
82+
self.update_terms_agreement(typed_input)
83+
# this handles the deprecated inputs
84+
else:
85+
typed_input = TermsAgreementInput(
86+
business_email=input.get("business_email"),
87+
terms_agreement=input.get("terms_agreement"),
88+
marketing_consent=input.get("marketing_consent"),
89+
customer_intent=input.get("customer_intent"),
90+
)
91+
self.validate_deprecated(typed_input)
92+
self.update_terms_agreement_deprecated(typed_input)

0 commit comments

Comments
 (0)