Skip to content

Commit

Permalink
Merge branch 'main' into rvinnakota/skip-error-unconf-repos
Browse files Browse the repository at this point in the history
  • Loading branch information
rohitvinnakota-codecov authored Apr 29, 2024
2 parents b3b36ad + 252b887 commit c689574
Show file tree
Hide file tree
Showing 42 changed files with 638 additions and 113 deletions.
1 change: 1 addition & 0 deletions api/public/v2/component/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
class ComponentSerializer(serializers.Serializer):
component_id = serializers.CharField(label="component id")
name = serializers.CharField(label="component name")
coverage = serializers.FloatField(label="component coverage")
17 changes: 15 additions & 2 deletions api/public/v2/component/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from api.public.v2.schema import repo_parameters
from api.shared.mixins import RepoPropertyMixin
from api.shared.permissions import RepositoryArtifactPermissions
from services.components import commit_components
from services.components import commit_components, component_filtered_report


@extend_schema(
Expand Down Expand Up @@ -38,6 +38,19 @@ def list(self, request, *args, **kwargs):
Returns a list of components for the specified repository
"""
commit = self.get_commit()
report = commit.full_report
components = commit_components(commit, request.user)
serializer = ComponentSerializer(components, many=True)
components_with_coverage = []
for component in components:
component_report = component_filtered_report(report, [component])
coverage = round(float(component_report.totals.coverage), 2)
components_with_coverage.append(
{
"component_id": component.component_id,
"name": component.name,
"coverage": coverage,
}
)

serializer = ComponentSerializer(components_with_coverage, many=True)
return Response(serializer.data)
39 changes: 36 additions & 3 deletions api/public/v2/tests/test_api_component_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,42 @@

from django.test import TestCase
from rest_framework.reverse import reverse
from shared.reports.resources import Report, ReportFile, ReportLine
from shared.utils.sessions import Session

from codecov_auth.tests.factories import OwnerFactory
from core.tests.factories import CommitFactory, RepositoryFactory
from services.components import Component
from utils.test_utils import APIClient


# Borrowed from ./test_file_report_viewset.py
def sample_report():
report = Report()
first_file = ReportFile("foo/file1.py")
first_file.append(
1, ReportLine.create(coverage=1, sessions=[[0, 1]], complexity=(10, 2))
)
first_file.append(2, ReportLine.create(coverage=0, sessions=[[0, 1]]))
first_file.append(3, ReportLine.create(coverage=1, sessions=[[0, 1]]))
first_file.append(5, ReportLine.create(coverage=1, sessions=[[0, 1]]))
first_file.append(6, ReportLine.create(coverage=0, sessions=[[0, 1]]))
first_file.append(8, ReportLine.create(coverage=1, sessions=[[0, 1]]))
first_file.append(9, ReportLine.create(coverage=1, sessions=[[0, 1]]))
first_file.append(10, ReportLine.create(coverage=0, sessions=[[0, 1]]))
# (1 * 5 hits + 0 * 3 misses) / 8 lines = 0.625 coverage
second_file = ReportFile("bar/file2.py")
second_file.append(12, ReportLine.create(coverage=1, sessions=[[0, 1]]))
second_file.append(
51, ReportLine.create(coverage="1/2", type="b", sessions=[[0, 1]])
)
# (1 * 1 hit + 0 * 1 partial) / 2 lines = 0.5 coverage
report.append(first_file)
report.append(second_file)
report.add_session(Session(flags=["flag1", "flag2"]))
return report


@patch("api.shared.repo.repository_accessors.RepoAccessors.get_repo_permissions")
class ComponentViewSetTestCase(TestCase):
def setUp(self):
Expand Down Expand Up @@ -37,8 +66,12 @@ def _request_components(self):
return self.client.get(url)

@patch("api.public.v2.component.views.commit_components")
def test_component_list(self, commit_compontents, get_repo_permissions):
@patch("shared.reports.api_report_service.build_report_from_commit")
def test_component_list(
self, build_report_from_commit, commit_compontents, get_repo_permissions
):
get_repo_permissions.return_value = (True, True)
build_report_from_commit.side_effect = [sample_report()]
commit_compontents.return_value = [
Component(
component_id="foo",
Expand All @@ -59,6 +92,6 @@ def test_component_list(self, commit_compontents, get_repo_permissions):
res = self._request_components()
assert res.status_code == 200
assert res.json() == [
{"component_id": "foo", "name": "Foo"},
{"component_id": "bar", "name": "Bar"},
{"component_id": "foo", "name": "Foo", "coverage": 62.5},
{"component_id": "bar", "name": "Bar", "coverage": 50.0},
]
8 changes: 5 additions & 3 deletions codecov/settings_base.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import os
from urllib.parse import urlparse

import asgiref.sync as sync
import sentry_sdk
from asgiref.sync import SyncToAsync
from corsheaders.defaults import default_headers
from django.db import close_old_connections
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber

from utils.config import SettingsModule, get_config, get_settings_module

Expand Down Expand Up @@ -533,10 +531,13 @@

SENTRY_ENV = os.environ.get("CODECOV_ENV", False)
SENTRY_DSN = os.environ.get("SERVICES__SENTRY__SERVER_DSN", None)
SENTRY_DENY_LIST = DEFAULT_DENYLIST + ["_headers", "token_to_use"]

if SENTRY_DSN is not None:
SENTRY_SAMPLE_RATE = float(os.environ.get("SERVICES__SENTRY__SAMPLE_RATE", 0.1))
sentry_sdk.init(
dsn=SENTRY_DSN,
event_scrubber=EventScrubber(denylist=SENTRY_DENY_LIST),
integrations=[
DjangoIntegration(),
CeleryIntegration(),
Expand All @@ -554,6 +555,7 @@
elif IS_DEV:
sentry_sdk.init(
spotlight=IS_DEV,
event_scrubber=EventScrubber(denylist=SENTRY_DENY_LIST),
)

SHELTER_PUBSUB_PROJECT_ID = get_config("setup", "shelter", "pubsub_project_id")
Expand Down
41 changes: 29 additions & 12 deletions codecov_auth/authentication/repo_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from django.utils import timezone
from jwt import PyJWTError
from rest_framework import authentication, exceptions
from rest_framework.exceptions import NotAuthenticated
from rest_framework.views import exception_handler
from sentry_sdk import metrics as sentry_metrics
from shared.metrics import metrics
from shared.torngit.exceptions import TorngitObjectNotFoundError, TorngitRateLimitError
Expand All @@ -27,6 +29,26 @@
from utils import is_uuid


def repo_auth_custom_exception_handler(exc, context):
"""
User arrives here if they have correctly supplied a Token or the Tokenless Headers,
but their Token has not matched with any of our Authentication methods. The goal is to
give the user something better than "Invalid Token" or "Authentication credentials were not provided."
"""
response = exception_handler(exc, context)
if response is not None:
try:
exc_code = response.data["detail"].code
except TypeError:
return response
if exc_code == NotAuthenticated.default_code:
response.data["detail"] = (
"Failed token authentication, please double-check that your repository token matches in the Codecov UI, "
"or review the docs https://docs.codecov.com/docs/adding-the-codecov-token"
)
return response


class LegacyTokenRepositoryAuth(RepositoryAuthInterface):
def __init__(self, repository, auth_data):
self._auth_data = auth_data
Expand Down Expand Up @@ -121,12 +143,9 @@ class RepositoryLegacyTokenAuthentication(authentication.TokenAuthentication):
def authenticate_credentials(self, token):
try:
token = UUID(token)
except (ValueError, TypeError):
raise exceptions.AuthenticationFailed("Invalid token.")
try:
repository = Repository.objects.get(upload_token=token)
except Repository.DoesNotExist:
raise exceptions.AuthenticationFailed("Invalid token.")
except (ValueError, TypeError, Repository.DoesNotExist):
return None # continue to next auth class
return (
RepositoryAsUser(repository),
LegacyTokenRepositoryAuth(repository, {"token": token}),
Expand Down Expand Up @@ -173,7 +192,7 @@ def authenticate(self, request):
"Could not find a repository, try using repo upload token"
)
else:
return None
return None # continue to next auth class
return (
RepositoryAsUser(repository),
LegacyTokenRepositoryAuth(repository, {"token": token}),
Expand All @@ -194,7 +213,7 @@ def get_owner(self, request):

class OrgLevelTokenAuthentication(authentication.TokenAuthentication):
def authenticate_credentials(self, key):
if is_uuid(key):
if is_uuid(key): # else, continue to next auth class
# Actual verification for org level tokens
token = OrganizationLevelToken.objects.filter(token=key).first()

Expand All @@ -216,9 +235,7 @@ def authenticate_credentials(self, token):
try:
repository = get_repo_with_github_actions_oidc_token(token)
except (ObjectDoesNotExist, PyJWTError):
raise exceptions.AuthenticationFailed(
f"Github OIDC Token Auth: Invalid token."
)
return None # continue to next auth class

return (
RepositoryAsUser(repository),
Expand All @@ -232,7 +249,7 @@ class TokenlessAuthentication(authentication.TokenAuthentication):
It allows PRs from external contributors (forks) to upload coverage
for the upstream repo when running in a PR.
While it uses the same "shell" (authentication.TOkenAuthentication)
While it uses the same "shell" (authentication.TokenAuthentication)
it doesn't really rely on tokens to authenticate.
"""

Expand Down Expand Up @@ -288,7 +305,7 @@ def authenticate(self, request):
fork_slug = request.headers.get("X-Tokenless", None)
fork_pr = request.headers.get("X-Tokenless-PR", None)
if fork_slug is None or fork_pr is None:
return None
return None # continue to next auth class
# Get the repo
repository = self._get_repo_info_from_request_path(request)
# Tokneless is only for public repos
Expand Down
11 changes: 8 additions & 3 deletions codecov_auth/commands/owner/interactors/delete_session.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from django.contrib.sessions.models import Session as DjangoSession

from codecov.commands.base import BaseInteractor
from codecov.commands.exceptions import Unauthenticated, Unauthorized
from codecov.commands.exceptions import Unauthenticated
from codecov.db import sync_to_async
from codecov_auth.models import Session

Expand All @@ -10,6 +12,9 @@ def validate(self):
raise Unauthenticated()

@sync_to_async
def execute(self, sessionid):
def execute(self, sessionid: int):
self.validate()
Session.objects.filter(sessionid=sessionid, owner=self.current_owner).delete()
session_to_delete = Session.objects.get(sessionid=sessionid)
DjangoSession.objects.filter(
session_key=session_to_delete.login_session_id
).delete()
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import pytest
from django.contrib.auth.models import AnonymousUser
from django.test import TransactionTestCase
from django.utils import timezone

from codecov.commands.exceptions import Unauthenticated
from codecov.db import sync_to_async
from codecov_auth.models import Session
from codecov_auth.tests.factories import OwnerFactory, SessionFactory
from codecov_auth.models import DjangoSession, Session
from codecov_auth.tests.factories import (
DjangoSessionFactory,
OwnerFactory,
SessionFactory,
)

from ..delete_session import DeleteSessionInteractor

Expand All @@ -18,7 +23,10 @@ def get_session(id):
class DeleteSessionInteractorTest(TransactionTestCase):
def setUp(self):
self.owner = OwnerFactory(username="codecov-user")
self.session = SessionFactory(owner=self.owner)
self.django_session = DjangoSessionFactory()
self.session = SessionFactory(
owner=self.owner, login_session=self.django_session
)

async def test_when_unauthenticated_raise(self):
with pytest.raises(Unauthenticated):
Expand All @@ -28,5 +36,12 @@ async def test_delete_session(self):
await DeleteSessionInteractor(self.owner, "github").execute(
self.session.sessionid
)
with pytest.raises(Session.DoesNotExist):
await get_session(self.session.sessionid)

@sync_to_async
def assert_sessions():
return (
len(DjangoSession.objects.all()) == 0
and len(Session.objects.all()) == 0
)

assert assert_sessions
2 changes: 1 addition & 1 deletion codecov_auth/commands/owner/owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class OwnerCommands(BaseCommand):
def create_api_token(self, name):
return self.get_interactor(CreateApiTokenInteractor).execute(name)

def delete_session(self, sessionid):
def delete_session(self, sessionid: int):
return self.get_interactor(DeleteSessionInteractor).execute(sessionid)

def create_user_token(self, name, token_type=None):
Expand Down
10 changes: 10 additions & 0 deletions codecov_auth/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from factory.django import DjangoModelFactory

from codecov_auth.models import (
DjangoSession,
OktaUser,
OrganizationLevelToken,
Owner,
Expand Down Expand Up @@ -88,6 +89,14 @@ class Meta:
default_org = factory.SubFactory(OwnerFactory)


class DjangoSessionFactory(DjangoModelFactory):
class Meta:
model = DjangoSession

expire_date = timezone.now()
session_key = factory.Faker("uuid4")


class SessionFactory(DjangoModelFactory):
class Meta:
model = Session
Expand All @@ -96,6 +105,7 @@ class Meta:
lastseen = timezone.now()
type = Session.SessionType.API.value
token = factory.Faker("uuid4")
login_session = factory.SubFactory(DjangoSessionFactory)


class OrganizationLevelTokenFactory(DjangoModelFactory):
Expand Down
20 changes: 10 additions & 10 deletions codecov_auth/tests/unit/test_repo_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,20 @@ class TestRepositoryLegacyTokenAuthentication(object):
def test_authenticate_credentials_empty(self, db):
token = None
authentication = RepositoryLegacyTokenAuthentication()
with pytest.raises(exceptions.AuthenticationFailed):
authentication.authenticate_credentials(token)
res = authentication.authenticate_credentials(token)
assert res is None

def test_authenticate_credentials_not_uuid(self, db):
token = "not-a-uuid"
authentication = RepositoryLegacyTokenAuthentication()
with pytest.raises(exceptions.AuthenticationFailed):
authentication.authenticate_credentials(token)
res = authentication.authenticate_credentials(token)
assert res is None

def test_authenticate_credentials_uuid_no_repo(self, db):
token = str(uuid.uuid4())
authentication = RepositoryLegacyTokenAuthentication()
with pytest.raises(exceptions.AuthenticationFailed):
authentication.authenticate_credentials(token)
res = authentication.authenticate_credentials(token)
assert res is None

def test_authenticate_credentials_uuid_token_with_repo(self, db):
repo = RepositoryFactory.create()
Expand Down Expand Up @@ -246,15 +246,15 @@ def test_authenticate_credentials_no_repo(self, mocked_get_repo_with_token, db):
mocked_get_repo_with_token.side_effect = ObjectDoesNotExist()
token = "the best token"
authentication = GitHubOIDCTokenAuthentication()
with pytest.raises(exceptions.AuthenticationFailed):
authentication.authenticate_credentials(token)
res = authentication.authenticate_credentials(token)
assert res is None

def test_authenticate_credentials_oidc_error(self, mocked_get_repo_with_token, db):
mocked_get_repo_with_token.side_effect = PyJWTError()
token = "the best token"
authentication = GitHubOIDCTokenAuthentication()
with pytest.raises(exceptions.AuthenticationFailed):
authentication.authenticate_credentials(token)
res = authentication.authenticate_credentials(token)
assert res is None

def test_authenticate_credentials_oidc_valid(self, mocked_get_repo_with_token, db):
token = "the best token"
Expand Down
Loading

0 comments on commit c689574

Please sign in to comment.