Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Tests for PR#1101 #1126

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
020e7cf
feat: Add ACH payment method (#1083)
suejung-sentry Jan 16, 2025
261ea42
feat: Django admin portal changes for plans and tiers (#1097)
RulaKhaled Jan 16, 2025
bf0f1b2
Release 25.1.16 (#1103)
codecov-releaser Jan 16, 2025
ab5c1ff
feat: Add Stripe_id to Django Admin (#1104)
ajay-sentry Jan 17, 2025
9f89174
feat: Add repoid to repository graphql schema (#1095)
spalmurray-codecov Jan 17, 2025
b4cc89d
[feat] Add endpoint that checks whether an owner has Gen AI consent (…
rohitvinnakota-codecov Jan 22, 2025
914b22c
feat: Receive user name for new Terms of Service UI (#1094)
calvin-codecov Jan 22, 2025
e06c5d8
dev: Add Ruff print rules to API (T20) (#1107)
ajay-sentry Jan 24, 2025
ae83c2f
Feat: Django Command run on startup to add plans / tiers (#1111)
ajay-sentry Jan 29, 2025
d80087a
Allow erasing a foreign Repo (#1112)
Swatinem Jan 30, 2025
9c91ee2
Clean up user's expired login sessions (#1113)
JerrySentry Jan 30, 2025
354b640
API: Migrate to Plan / Tier Tables (#1099)
ajay-sentry Jan 30, 2025
7e5a947
feat: Add list unverified payment methods api (#1115)
suejung-sentry Jan 30, 2025
e4924ba
feat: Improve upload storage path generation for reports (#1119)
trent-codecov Jan 31, 2025
b7b6cbc
[feat] Add patch totals to the /pulls endpoint (#1108)
rohitvinnakota-codecov Feb 3, 2025
86286d2
chore: Update shared to 5bcbfc8 (#1122)
spalmurray-codecov Feb 3, 2025
8ec5b6d
Release 25.2.3 (#1124)
codecov-releaser Feb 3, 2025
0c945a0
Add Tests for PR#1101
sentry-autofix[bot] Feb 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
25.1.10
25.2.3
Empty file added api/gen_ai/__init__.py
Empty file.
5 changes: 5 additions & 0 deletions api/gen_ai/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from rest_framework import serializers


class GenAIAuthSerializer(serializers.Serializer):
is_valid = serializers.BooleanField()
114 changes: 114 additions & 0 deletions api/gen_ai/tests/test_gen_ai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import hmac
from hashlib import sha256
from unittest.mock import patch

from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from shared.django_apps.core.tests.factories import OwnerFactory

from codecov_auth.models import GithubAppInstallation

PAYLOAD_SECRET = b"testixik8qdauiab1yiffydimvi72ekq"
VIEW_URL = reverse("auth")


def sign_payload(data: bytes, secret=PAYLOAD_SECRET):
signature = "sha256=" + hmac.new(secret, data, digestmod=sha256).hexdigest()
return signature, data


class GenAIAuthViewTests(APITestCase):
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
def test_missing_parameters(self, mock_config):
payload = b"{}"
sig, data = sign_payload(payload)
response = self.client.post(
VIEW_URL,
data=data,
content_type="application/json",
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
)
self.assertEqual(response.status_code, 400)
self.assertIn("Missing required parameters", response.data)

@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
def test_invalid_signature(self, mock_config):
# Correct payload
payload = b'{"external_owner_id":"owner1","repo_service_id":"101"}'
# Wrong signature based on a different payload
wrong_sig = "sha256=" + hmac.new(PAYLOAD_SECRET, b"{}", sha256).hexdigest()
response = self.client.post(
VIEW_URL,
data=payload,
content_type="application/json",
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=wrong_sig,
)
self.assertEqual(response.status_code, 403)

@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
def test_owner_not_found(self, mock_config):
payload = b'{"external_owner_id":"nonexistent_owner","repo_service_id":"101"}'
sig, data = sign_payload(payload)
response = self.client.post(
VIEW_URL,
data=data,
content_type="application/json",
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
)
self.assertEqual(response.status_code, 404)

@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
def test_no_installation(self, mock_config):
# Create a valid owner but no installation
OwnerFactory(service="github", service_id="owner1", username="test1")
payload = b'{"external_owner_id":"owner1","repo_service_id":"101"}'
sig, data = sign_payload(payload)
response = self.client.post(
VIEW_URL,
data=data,
content_type="application/json",
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, {"is_valid": False})

@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
def test_authorized(self, mock_config):
owner = OwnerFactory(service="github", service_id="owner2", username="test2")
GithubAppInstallation.objects.create(
installation_id=12345,
owner=owner,
name="ai-features",
repository_service_ids=["101", "202"],
)
payload = b'{"external_owner_id":"owner2","repo_service_id":"101"}'
sig, data = sign_payload(payload)
response = self.client.post(
VIEW_URL,
data=data,
content_type="application/json",
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {"is_valid": True})

@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
def test_unauthorized(self, mock_config):
owner = OwnerFactory(service="github", service_id="owner3", username="test3")
GithubAppInstallation.objects.create(
installation_id=2,
owner=owner,
name="ai-features",
repository_service_ids=["303", "404"],
)
payload = b'{"external_owner_id":"owner3","repo_service_id":"101"}'
sig, data = sign_payload(payload)
response = self.client.post(
VIEW_URL,
data=data,
content_type="application/json",
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, {"is_valid": False})
7 changes: 7 additions & 0 deletions api/gen_ai/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.urls import path

from .views import GenAIAuthView

urlpatterns = [
path("auth/", GenAIAuthView.as_view(), name="auth"),
]
61 changes: 61 additions & 0 deletions api/gen_ai/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import hmac
import logging
from hashlib import sha256

from rest_framework.exceptions import NotFound, PermissionDenied
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView

from api.gen_ai.serializers import GenAIAuthSerializer
from codecov_auth.models import GithubAppInstallation, Owner
from graphql_api.types.owner.owner import AI_FEATURES_GH_APP_ID
from utils.config import get_config

log = logging.getLogger(__name__)


class GenAIAuthView(APIView):
permission_classes = [AllowAny]
serializer_class = GenAIAuthSerializer

def validate_signature(self, request):
key = get_config("gen_ai", "auth_secret")
if not key:
raise PermissionDenied("Invalid signature")

if isinstance(key, str):
key = key.encode("utf-8")
expected_sig = request.headers.get("HTTP-X-GEN-AI-AUTH-SIGNATURE")
computed_sig = (
"sha256=" + hmac.new(key, request.body, digestmod=sha256).hexdigest()
)
if not hmac.compare_digest(computed_sig, expected_sig):
raise PermissionDenied("Invalid signature")

def post(self, request, *args, **kwargs):
self.validate_signature(request)
external_owner_id = request.data.get("external_owner_id")
repo_service_id = request.data.get("repo_service_id")
if not external_owner_id or not repo_service_id:
return Response("Missing required parameters", status=400)
try:
owner = Owner.objects.get(service_id=external_owner_id)
except Owner.DoesNotExist:
raise NotFound("Owner not found")

is_authorized = True

app_install = GithubAppInstallation.objects.filter(
owner_id=owner.ownerid, app_id=AI_FEATURES_GH_APP_ID
).first()

if not app_install:
is_authorized = False

else:
repo_ids = app_install.repository_service_ids
if repo_ids and repo_service_id not in repo_ids:
is_authorized = False

return Response({"is_valid": is_authorized})
38 changes: 25 additions & 13 deletions api/internal/owner/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from shared.plan.constants import (
PAID_PLANS,
SENTRY_PAID_USER_PLAN_REPRESENTATIONS,
TEAM_PLAN_MAX_USERS,
TEAM_PLAN_REPRESENTATIONS,
TierName,
)
from shared.plan.service import PlanService

from codecov_auth.models import Owner
from codecov_auth.models import Owner, Plan
from services.billing import BillingService
from services.sentry import send_user_webhook as send_sentry_webhook

Expand Down Expand Up @@ -109,8 +107,14 @@ class StripeCardSerializer(serializers.Serializer):
last4 = serializers.CharField()


class StripeUSBankAccountSerializer(serializers.Serializer):
bank_name = serializers.CharField()
last4 = serializers.CharField()


class StripePaymentMethodSerializer(serializers.Serializer):
card = StripeCardSerializer(read_only=True)
us_bank_account = StripeUSBankAccountSerializer(read_only=True)
billing_details = serializers.JSONField(read_only=True)


Expand All @@ -131,11 +135,6 @@ def validate_value(self, value: str) -> str:
plan["value"] for plan in plan_service.available_plans(current_owner)
]
if value not in plan_values:
if value in SENTRY_PAID_USER_PLAN_REPRESENTATIONS:
log.warning(
"Non-Sentry user attempted to transition to Sentry plan",
extra=dict(owner_id=current_owner.pk, plan=value),
)
raise serializers.ValidationError(
f"Invalid value for plan: {value}; must be one of {plan_values}"
)
Expand All @@ -148,8 +147,17 @@ def validate(self, plan: Dict[str, Any]) -> Dict[str, Any]:
detail="You cannot update your plan manually, for help or changes to plan, connect with [email protected]"
)

active_plans = Plan.objects.select_related("tier").filter(
paid_plan=True, is_active=True
)

active_plan_names = set(active_plans.values_list("name", flat=True))
team_tier_plans = active_plans.filter(
tier__tier_name=TierName.TEAM.value
).values_list("name", flat=True)

# Validate quantity here because we need access to whole plan object
if plan["value"] in PAID_PLANS:
if plan["value"] in active_plan_names:
if "quantity" not in plan:
raise serializers.ValidationError(
"Field 'quantity' required for updating to paid plans"
Expand Down Expand Up @@ -178,7 +186,7 @@ def validate(self, plan: Dict[str, Any]) -> Dict[str, Any]:
"Quantity or plan for paid plan must be different from the existing one"
)
if (
plan["value"] in TEAM_PLAN_REPRESENTATIONS
plan["value"] in team_tier_plans
and plan["quantity"] > TEAM_PLAN_MAX_USERS
):
raise serializers.ValidationError(
Expand Down Expand Up @@ -213,7 +221,7 @@ def get_plan(self, phase: Dict[str, Any]) -> str:
plan_name = list(stripe_plan_dict.keys())[
list(stripe_plan_dict.values()).index(plan_id)
]
marketing_plan_name = PAID_PLANS[plan_name].billing_rate
marketing_plan_name = Plan.objects.get(name=plan_name).marketing_name
return marketing_plan_name

def get_quantity(self, phase: Dict[str, Any]) -> int:
Expand Down Expand Up @@ -336,7 +344,11 @@ def update(self, instance: Owner, validated_data: Dict[str, Any]) -> object:
instance, desired_plan
)

if desired_plan["value"] in SENTRY_PAID_USER_PLAN_REPRESENTATIONS:
sentry_plans = Plan.objects.filter(
tier__tier_name=TierName.SENTRY.value, is_active=True
).values_list("name", flat=True)

if desired_plan["value"] in sentry_plans:
current_owner = self.context["view"].request.current_owner
send_sentry_webhook(current_owner, instance)

Expand Down
23 changes: 22 additions & 1 deletion api/internal/owner/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,33 @@ def update_payment(self, request, *args, **kwargs):
@action(detail=False, methods=["patch"])
@stripe_safe
def update_email(self, request, *args, **kwargs):
"""
Update the email address associated with the owner's billing account.

Args:
request: The HTTP request object containing:
- new_email: The new email address to update to
- apply_to_default_payment_method: Boolean flag to update email on the default payment method (default False)

Returns:
Response with serialized owner data

Raises:
ValidationError: If no new_email is provided in the request
"""
new_email = request.data.get("new_email")
if not new_email:
raise ValidationError(detail="No new_email sent")
owner = self.get_object()
billing = BillingService(requesting_user=request.current_owner)
billing.update_email_address(owner, new_email)
apply_to_default_payment_method = request.data.get(
"apply_to_default_payment_method", False
)
billing.update_email_address(
owner,
new_email,
apply_to_default_payment_method=apply_to_default_payment_method,
)
return Response(self.get_serializer(owner).data)

@action(detail=False, methods=["patch"])
Expand Down
6 changes: 5 additions & 1 deletion api/internal/tests/test_pagination.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
from rest_framework.reverse import reverse
from rest_framework.test import APITestCase
from shared.django_apps.codecov_auth.tests.factories import PlanFactory, TierFactory
from shared.django_apps.core.tests.factories import OwnerFactory
from shared.plan.constants import TierName

from utils.test_utils import Client


class PageNumberPaginationTests(APITestCase):
def setUp(self):
self.client = Client()
self.owner = OwnerFactory(plan="users-free", plan_user_count=5)
tier = TierFactory(tier_name=TierName.BASIC.value)
plan = PlanFactory(tier=tier, is_active=True)
self.owner = OwnerFactory(plan=plan.name, plan_user_count=5)
self.users = [
OwnerFactory(organizations=[self.owner.ownerid]),
OwnerFactory(organizations=[self.owner.ownerid]),
Expand Down
Loading