import glob
import io
import json
import os
import tempfile
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, Mock, patch
from urllib.parse import parse_qs, urlparse
from uuid import uuid4

import jwt
import pytest
from allauth.account.models import EmailAddress
from allauth.socialaccount.models import SocialAccount, SocialApp
from api.attack_paths import (
    AttackPathsQueryDefinition,
    AttackPathsQueryParameterDefinition,
)
from api.compliance import get_compliance_frameworks
from api.db_router import MainRouter
from api.models import (
    AttackSurfaceOverview,
    ComplianceOverviewSummary,
    ComplianceRequirementOverview,
    DailySeveritySummary,
    Finding,
    Integration,
    Invitation,
    InvitationRoleRelationship,
    LighthouseProviderConfiguration,
    LighthouseProviderModels,
    LighthouseTenantConfiguration,
    Membership,
    Processor,
    Provider,
    ProviderGroup,
    ProviderGroupMembership,
    ProviderSecret,
    Resource,
    ResourceFindingMapping,
    ResourceTag,
    Role,
    RoleProviderGroupRelationship,
    SAMLConfiguration,
    SAMLToken,
    Scan,
    ScanSummary,
    StateChoices,
    StatusChoices,
    Task,
    TenantAPIKey,
    ThreatScoreSnapshot,
    User,
    UserRoleRelationship,
)
from api.rls import Tenant
from api.uuid_utils import datetime_to_uuid7
from api.v1.views import (
    ComplianceOverviewViewSet,
    CustomSAMLLoginView,
    TenantFinishACSView,
)
from botocore.exceptions import ClientError, NoCredentialsError
from celery import states
from conftest import (
    API_JSON_CONTENT_TYPE,
    TEST_PASSWORD,
    TEST_USER,
    TODAY,
    today_after_n_days,
)
from django.conf import settings
from django.db import connection
from django.db.models import Count
from django.http import JsonResponse
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django_celery_results.models import TaskResult
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework_simplejwt.token_blacklist.models import (
    BlacklistedToken,
    OutstandingToken,
)
from rest_framework_simplejwt.tokens import RefreshToken


class TestViewSet:
    def test_security_headers(self, client):
        response = client.get("/")
        assert response.headers["X-Content-Type-Options"] == "nosniff"
        assert response.headers["X-Frame-Options"] == "DENY"
        assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"


@pytest.mark.django_db
class TestUserViewSet:
    def test_users_list(self, authenticated_client, create_test_user):
        user = create_test_user
        user.refresh_from_db()
        response = authenticated_client.get(reverse("user-list"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert response.json()["data"][0]["attributes"]["email"] == user.email
        assert response.json()["data"][0]["attributes"]["name"] == user.name
        assert (
            response.json()["data"][0]["attributes"]["company_name"]
            == user.company_name
        )

    def test_users_retrieve(self, authenticated_client, create_test_user):
        response = authenticated_client.get(
            reverse("user-detail", kwargs={"pk": create_test_user.id})
        )
        assert response.status_code == status.HTTP_200_OK

    def test_users_me(self, authenticated_client, create_test_user):
        response = authenticated_client.get(reverse("user-me"))
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"]["attributes"]["email"] == create_test_user.email

    def test_users_create(self, client):
        valid_user_payload = {
            "name": "test",
            "password": "NewPassword123!",
            "email": "NeWuSeR@example.com",
        }
        response = client.post(
            reverse("user-list"), data=valid_user_payload, format="json"
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert User.objects.filter(email__iexact=valid_user_payload["email"]).exists()
        assert (
            response.json()["data"]["attributes"]["email"]
            == valid_user_payload["email"].lower()
        )

    def test_users_create_duplicated_email(self, client):
        # Create a user
        self.test_users_create(client)

        # Try to create it again and expect a 400
        with pytest.raises(AssertionError) as assertion_error:
            self.test_users_create(client)

        assert "Response status_code=400" in str(assertion_error)

    @pytest.mark.parametrize(
        "password",
        [
            # Fails MinimumLengthValidator (too short)
            "short",
            "1234567",
            # Fails CommonPasswordValidator (common passwords)
            "password",
            "12345678",
            "qwerty",
            "abc123",
            # Fails NumericPasswordValidator (entirely numeric)
            "12345678",
            "00000000",
            # Fails multiple validators
            "password1",  # Common password and too similar to a common password
            "dev12345",  # Similar to username
            ("querty12" * 9) + "a",  # Too long, 73 characters
            "NewPassword123",  # No special character
            "newpassword123@",  # No uppercase letter
            "NEWPASSWORD123",  # No lowercase letter
            "NewPassword@",  # No number
        ],
    )
    def test_users_create_invalid_passwords(self, authenticated_client, password):
        invalid_user_payload = {
            "name": "test",
            "password": password,
            "email": "thisisafineemail@prowler.com",
        }
        response = authenticated_client.post(
            reverse("user-list"), data=invalid_user_payload, format="json"
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == "/data/attributes/password"
        )

    @pytest.mark.parametrize(
        "email",
        [
            # Same email, validation error
            "nonexistentemail@prowler.com",
            # Same email with capital letters, validation error
            "NonExistentEmail@prowler.com",
        ],
    )
    def test_users_create_used_email(self, authenticated_client, email):
        # First user created; no errors should occur
        user_payload = {
            "name": "test_email_validator",
            "password": "Newpassword123@",
            "email": "nonexistentemail@prowler.com",
        }
        response = authenticated_client.post(
            reverse("user-list"), data=user_payload, format="json"
        )
        assert response.status_code == status.HTTP_201_CREATED

        user_payload = {
            "name": "test_email_validator",
            "password": "Newpassword123@",
            "email": email,
        }
        response = authenticated_client.post(
            reverse("user-list"), data=user_payload, format="json"
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == "/data/attributes/email"
        )
        assert (
            response.json()["errors"][0]["detail"]
            == "Please check the email address and try again."
        )

    def test_users_partial_update(self, authenticated_client, create_test_user):
        new_company_name = "new company test"
        payload = {
            "data": {
                "type": "users",
                "id": str(create_test_user.id),
                "attributes": {"company_name": new_company_name},
            },
        }
        response = authenticated_client.patch(
            reverse("user-detail", kwargs={"pk": create_test_user.id}),
            data=payload,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        create_test_user.refresh_from_db()
        assert create_test_user.company_name == new_company_name

    def test_users_partial_update_same_tenant_other_user_password_denied(
        self, authenticated_client_no_permissions_rbac, tenants_fixture
    ):
        original_password = "OriginalPassword123@"
        new_password = "UpdatedPassword123@"
        target_user = User.objects.create_user(
            password=original_password,
            email="target-password-update@example.com",
        )
        Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
        payload = {
            "data": {
                "type": "users",
                "id": str(target_user.id),
                "attributes": {"password": new_password},
            },
        }

        response = authenticated_client_no_permissions_rbac.patch(
            reverse("user-detail", kwargs={"pk": target_user.id}),
            data=payload,
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        target_user.refresh_from_db()
        assert target_user.check_password(original_password)
        assert not target_user.check_password(new_password)

    def test_users_partial_update_same_tenant_other_user_email_denied(
        self, authenticated_client_no_permissions_rbac, tenants_fixture
    ):
        original_email = "target-email-update@example.com"
        new_email = "updated-target-email@example.com"
        target_user = User.objects.create_user(
            password="OriginalPassword123@",
            email=original_email,
        )
        Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
        payload = {
            "data": {
                "type": "users",
                "id": str(target_user.id),
                "attributes": {"email": new_email},
            },
        }

        response = authenticated_client_no_permissions_rbac.patch(
            reverse("user-detail", kwargs={"pk": target_user.id}),
            data=payload,
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        target_user.refresh_from_db()
        assert target_user.email == original_email

    def test_users_partial_update_invalid_content_type(
        self, authenticated_client, create_test_user
    ):
        response = authenticated_client.patch(
            reverse("user-detail", kwargs={"pk": create_test_user.id}), data={}
        )
        assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE

    def test_users_partial_update_invalid_content(
        self, authenticated_client, create_test_user
    ):
        payload = {"email": "newemail@example.com"}
        response = authenticated_client.patch(
            reverse("user-detail", kwargs={"pk": create_test_user.id}),
            data=payload,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_users_partial_update_invalid_user(
        self, authenticated_client, create_test_user
    ):
        another_user = User.objects.create_user(
            password="otherpassword", email="other@example.com"
        )
        new_email = "new@example.com"
        payload = {
            "data": {
                "type": "users",
                "id": str(another_user.id),
                "attributes": {"email": new_email},
            },
        }
        response = authenticated_client.patch(
            reverse("user-detail", kwargs={"pk": another_user.id}),
            data=payload,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND
        another_user.refresh_from_db()
        assert another_user.email != new_email

    @pytest.mark.parametrize(
        "password",
        [
            # Fails MinimumLengthValidator (too short)
            "short",
            "1234567",
            # Fails CommonPasswordValidator (common passwords)
            "password",
            "12345678",
            "qwerty",
            "abc123",
            # Fails NumericPasswordValidator (entirely numeric)
            "12345678",
            "00000000",
            # Fails UserAttributeSimilarityValidator (too similar to email)
            "dev12345",
            "test@prowler.com",
            "NewPassword123",  # No special character
            "newpassword123@",  # No uppercase letter
            "NEWPASSWORD123",  # No lowercase letter
            "NewPassword@",  # No number
        ],
    )
    def test_users_partial_update_invalid_password(
        self, authenticated_client, create_test_user, password
    ):
        payload = {
            "data": {
                "type": "users",
                "id": str(create_test_user.id),
                "attributes": {"password": password},
            },
        }

        response = authenticated_client.patch(
            reverse("user-detail", kwargs={"pk": str(create_test_user.id)}),
            data=payload,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == "/data/attributes/password"
        )

    def test_users_destroy(self, authenticated_client, create_test_user):
        response = authenticated_client.delete(
            reverse("user-detail", kwargs={"pk": create_test_user.id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert not User.objects.filter(id=create_test_user.id).exists()

    def test_users_destroy_other_user(
        self, authenticated_client, create_test_user, users_fixture
    ):
        user = users_fixture[2]
        response = authenticated_client.delete(
            reverse("user-detail", kwargs={"pk": str(user.id)})
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert User.objects.filter(id=create_test_user.id).exists()

    def test_users_destroy_invalid_user(self, authenticated_client, create_test_user):
        another_user = User.objects.create_user(
            password="otherpassword", email="other@example.com"
        )
        response = authenticated_client.delete(
            reverse("user-detail", kwargs={"pk": another_user.id})
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert User.objects.filter(id=another_user.id).exists()

    def test_users_destroy_cascades_allauth_and_memberships(
        self, authenticated_client, create_test_user
    ):
        # Create related admin-side objects (email + SocialAccount)
        EmailAddress.objects.create(
            user=create_test_user,
            email=create_test_user.email,
            primary=True,
            verified=True,
        )
        SocialAccount.objects.create(
            user=create_test_user, provider="fake-provider", uid="uid-fake-provider"
        )

        # Sanity check pre-conditions
        assert EmailAddress.objects.filter(user=create_test_user).exists()
        assert SocialAccount.objects.filter(user=create_test_user).exists()
        assert Membership.objects.filter(user=create_test_user).exists()
        assert UserRoleRelationship.objects.filter(user=create_test_user).exists()

        # Delete current user
        response = authenticated_client.delete(
            reverse("user-detail", kwargs={"pk": str(create_test_user.id)})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT

        # Assert user and related objects are gone
        assert not User.objects.filter(id=create_test_user.id).exists()
        assert not EmailAddress.objects.filter(user_id=create_test_user.id).exists()
        assert not SocialAccount.objects.filter(user_id=create_test_user.id).exists()
        assert not Membership.objects.filter(user_id=create_test_user.id).exists()
        assert not UserRoleRelationship.objects.filter(
            user_id=create_test_user.id
        ).exists()

    def test_users_destroy_with_saml_configuration_and_memberships(
        self, authenticated_client, create_test_user, saml_setup
    ):
        # Ensure SAML configuration exists for tenant (from saml_setup fixture)
        domain = saml_setup["domain"]
        config = SAMLConfiguration.objects.get(email_domain=domain)

        # Attach a SAML SocialAccount to the user
        SocialAccount.objects.create(
            user=create_test_user, provider="saml", uid="uid-saml"
        )

        # Sanity check pre-conditions
        assert SocialAccount.objects.filter(
            user=create_test_user, provider="saml"
        ).exists()
        assert Membership.objects.filter(user=create_test_user).exists()
        assert UserRoleRelationship.objects.filter(user=create_test_user).exists()

        # Delete current user
        response = authenticated_client.delete(
            reverse("user-detail", kwargs={"pk": str(create_test_user.id)})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT

        # Assert user-related rows are removed
        assert not User.objects.filter(id=create_test_user.id).exists()
        assert not SocialAccount.objects.filter(user_id=create_test_user.id).exists()
        assert not Membership.objects.filter(user_id=create_test_user.id).exists()
        assert not UserRoleRelationship.objects.filter(
            user_id=create_test_user.id
        ).exists()

        # Tenant-level SAML configuration should remain intact
        assert SAMLConfiguration.objects.filter(id=config.id).exists()
        assert SocialApp.objects.filter(provider="saml", client_id=domain).exists()

    @pytest.mark.parametrize(
        "attribute_key, attribute_value, error_field",
        [
            ("password", "", "password"),
            ("email", "invalidemail", "email"),
        ],
    )
    def test_users_create_invalid_fields(
        self, client, attribute_key, attribute_value, error_field
    ):
        invalid_payload = {
            "name": "test",
            "password": "testpassword",
            "email": "test@example.com",
        }
        invalid_payload[attribute_key] = attribute_value
        response = client.post(
            reverse("user-list"), data=invalid_payload, format="json"
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert error_field in response.json()["errors"][0]["source"]["pointer"]


@pytest.mark.django_db
class TestTenantViewSet:
    @pytest.fixture
    def valid_tenant_payload(self):
        return {
            "name": "Tenant Three",
            "inserted_at": "2023-01-05",
            "updated_at": "2023-01-06",
        }

    @pytest.fixture
    def invalid_tenant_payload(self):
        return {
            "name": "",
            "inserted_at": "2023-01-05",
            "updated_at": "2023-01-06",
        }

    @pytest.fixture
    def extra_users(self, tenants_fixture):
        _, tenant2, _ = tenants_fixture
        user2 = User.objects.create_user(
            name="testing2",
            password=TEST_PASSWORD,
            email="testing2@gmail.com",
        )
        user3 = User.objects.create_user(
            name="testing3",
            password=TEST_PASSWORD,
            email="testing3@gmail.com",
        )
        membership2 = Membership.objects.create(
            user=user2,
            tenant=tenant2,
            role=Membership.RoleChoices.OWNER,
        )
        membership3 = Membership.objects.create(
            user=user3,
            tenant=tenant2,
            role=Membership.RoleChoices.MEMBER,
        )
        return (user2, membership2), (user3, membership3)

    def test_tenants_list(self, authenticated_client, tenants_fixture):
        response = authenticated_client.get(reverse("tenant-list"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2  # Test user belongs to 2 tenants

    def test_tenants_retrieve(self, authenticated_client, tenants_fixture):
        tenant1, *_ = tenants_fixture
        response = authenticated_client.get(
            reverse("tenant-detail", kwargs={"pk": tenant1.id})
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"]["attributes"]["name"] == tenant1.name

    def test_tenants_invalid_retrieve(self, authenticated_client):
        response = authenticated_client.get(
            reverse("tenant-detail", kwargs={"pk": "random_id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_tenants_create(self, authenticated_client, valid_tenant_payload):
        response = authenticated_client.post(
            reverse("tenant-list"), data=valid_tenant_payload, format="json"
        )
        assert response.status_code == status.HTTP_201_CREATED
        # Two tenants from the fixture + the new one
        assert Tenant.objects.count() == 4
        assert (
            response.json()["data"]["attributes"]["name"]
            == valid_tenant_payload["name"]
        )
        new_tenant_id = response.json()["data"]["id"]
        user = authenticated_client.user
        assert UserRoleRelationship.objects.filter(
            user=user,
            tenant_id=new_tenant_id,
            role__name="admin",
        ).exists()

    def test_tenants_invalid_create(self, authenticated_client, invalid_tenant_payload):
        response = authenticated_client.post(
            reverse("tenant-list"),
            data=invalid_tenant_payload,
            format="json",
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_tenants_partial_update(self, authenticated_client, tenants_fixture):
        tenant1, *_ = tenants_fixture
        new_name = "This is the new name"
        payload = {
            "data": {
                "type": "tenants",
                "id": tenant1.id,
                "attributes": {"name": new_name},
            },
        }
        response = authenticated_client.patch(
            reverse("tenant-detail", kwargs={"pk": tenant1.id}),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_200_OK
        tenant1.refresh_from_db()
        assert tenant1.name == new_name

    def test_tenants_partial_update_invalid_content_type(
        self, authenticated_client, tenants_fixture
    ):
        tenant1, *_ = tenants_fixture
        response = authenticated_client.patch(
            reverse("tenant-detail", kwargs={"pk": tenant1.id}), data={}
        )
        assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE

    def test_tenants_partial_update_invalid_content(
        self, authenticated_client, tenants_fixture
    ):
        tenant1, *_ = tenants_fixture
        new_name = "This is the new name"
        payload = {"name": new_name}
        response = authenticated_client.patch(
            reverse("tenant-detail", kwargs={"pk": tenant1.id}),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @patch("api.v1.views.delete_tenant_task.apply_async")
    def test_tenants_delete(
        self, delete_tenant_mock, authenticated_client, tenants_fixture
    ):
        def _delete_tenant(kwargs):
            Tenant.objects.filter(pk=kwargs.get("tenant_id")).delete()

        delete_tenant_mock.side_effect = _delete_tenant
        # Use tenant2 where the user is OWNER
        _, tenant2, _ = tenants_fixture
        response = authenticated_client.delete(
            reverse("tenant-detail", kwargs={"pk": tenant2.id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert Membership.objects.filter(tenant_id=tenant2.id).count() == 0
        # User is not deleted because it has another membership (tenant1)
        assert User.objects.count() == 1

    @patch("api.v1.views.delete_tenant_task.apply_async")
    def test_tenants_delete_as_member_forbidden(
        self, delete_tenant_mock, authenticated_client, tenants_fixture
    ):
        # tenant1: user is MEMBER, not OWNER -> should be forbidden
        tenant1, *_ = tenants_fixture
        response = authenticated_client.delete(
            reverse("tenant-detail", kwargs={"pk": tenant1.id})
        )
        assert response.status_code == status.HTTP_403_FORBIDDEN
        delete_tenant_mock.assert_not_called()

    @patch("api.v1.views.delete_tenant_task.apply_async")
    def test_tenants_delete_cross_tenant(
        self, delete_tenant_mock, authenticated_client, tenants_fixture
    ):
        # tenant3: user has no membership -> should be 404
        _, _, tenant3 = tenants_fixture
        response = authenticated_client.delete(
            reverse("tenant-detail", kwargs={"pk": tenant3.id})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND
        delete_tenant_mock.assert_not_called()

    @patch("api.v1.views.delete_tenant_task.apply_async")
    def test_tenants_delete_only_removes_exclusive_users(
        self, delete_tenant_mock, authenticated_client, tenants_fixture, extra_users
    ):
        def _delete_tenant(kwargs):
            Tenant.objects.filter(pk=kwargs.get("tenant_id")).delete()

        delete_tenant_mock.side_effect = _delete_tenant
        _, tenant2, _ = tenants_fixture
        # extra_users adds user2 (OWNER in tenant2) and user3 (MEMBER in tenant2)
        # user2 and user3 are ONLY in tenant2, so they should be deleted
        # The test user is in tenant1 + tenant2, so should NOT be deleted
        initial_user_count = User.objects.count()  # test_user + user2 + user3 = 3
        assert initial_user_count == 3

        response = authenticated_client.delete(
            reverse("tenant-detail", kwargs={"pk": tenant2.id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        # user2 and user3 are deleted (no other memberships), test_user remains
        assert User.objects.count() == 1

    def test_tenants_delete_invalid(self, authenticated_client):
        response = authenticated_client.delete(
            reverse("tenant-detail", kwargs={"pk": "random_id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_tenants_list_filter_search(self, authenticated_client, tenants_fixture):
        """Search is applied to tenants_fixture  name."""
        tenant1, *_ = tenants_fixture
        response = authenticated_client.get(
            reverse("tenant-list"), {"filter[search]": tenant1.name}
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert response.json()["data"][0]["attributes"]["name"] == tenant1.name

    def test_tenants_list_query_param_name(self, authenticated_client, tenants_fixture):
        tenant1, *_ = tenants_fixture
        response = authenticated_client.get(
            reverse("tenant-list"), {"name": tenant1.name}
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_tenants_list_invalid_query_param(self, authenticated_client):
        response = authenticated_client.get(reverse("tenant-list"), {"random": "value"})
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "filter_name, filter_value, expected_count",
        (
            [
                ("name", "Tenant One", 1),
                ("name.icontains", "Tenant", 2),
                ("inserted_at", TODAY, 2),
                ("inserted_at.gte", "2024-01-01", 2),
                ("inserted_at.lte", "2024-01-01", 0),
                ("updated_at.gte", "2024-01-01", 2),
                ("updated_at.lte", "2024-01-01", 0),
            ]
        ),
    )
    def test_tenants_filters(
        self,
        authenticated_client,
        tenants_fixture,
        filter_name,
        filter_value,
        expected_count,
    ):
        response = authenticated_client.get(
            reverse("tenant-list"),
            {f"filter[{filter_name}]": filter_value},
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == expected_count

    def test_tenants_list_filter_invalid(self, authenticated_client):
        response = authenticated_client.get(
            reverse("tenant-list"), {"filter[invalid]": "whatever"}
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_tenants_list_page_size(self, authenticated_client, tenants_fixture):
        page_size = 1

        response = authenticated_client.get(
            reverse("tenant-list"), {"page[size]": page_size}
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == page_size
        assert response.json()["meta"]["pagination"]["page"] == 1
        assert (
            response.json()["meta"]["pagination"]["pages"] == 2
        )  # Test user belongs to 2 tenants

    def test_tenants_list_page_number(self, authenticated_client, tenants_fixture):
        page_size = 1
        page_number = 2

        response = authenticated_client.get(
            reverse("tenant-list"),
            {"page[size]": page_size, "page[number]": page_number},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == page_size
        assert response.json()["meta"]["pagination"]["page"] == page_number
        assert response.json()["meta"]["pagination"]["pages"] == 2

    def test_tenants_list_sort_name(self, authenticated_client, tenants_fixture):
        _, tenant2, _ = tenants_fixture
        response = authenticated_client.get(reverse("tenant-list"), {"sort": "-name"})
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2
        assert response.json()["data"][0]["attributes"]["name"] == tenant2.name

    def test_tenants_list_memberships_as_owner(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        _, tenant2, _ = tenants_fixture
        response = authenticated_client.get(
            reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id})
        )
        assert response.status_code == status.HTTP_200_OK
        # Test user + 2 extra users for tenant 2
        assert len(response.json()["data"]) == 3

    def test_tenants_list_memberships_filter_by_user(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        _, tenant2, _ = tenants_fixture
        _, user3_membership = extra_users
        user3, membership3 = user3_membership

        response = authenticated_client.get(
            reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}),
            {"filter[user]": str(user3.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == str(membership3.id)

    def test_tenants_list_memberships_filter_by_user_no_match(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        _, tenant2, _ = tenants_fixture
        unrelated_user = User.objects.create_user(
            name="unrelated",
            password=TEST_PASSWORD,
            email="unrelated@gmail.com",
        )

        response = authenticated_client.get(
            reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}),
            {"filter[user]": str(unrelated_user.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"] == []

    def test_tenants_list_memberships_as_member(
        self, authenticated_client_for_tenant_factory, tenants_fixture, extra_users
    ):
        _, tenant2, _ = tenants_fixture
        _, user3_membership = extra_users
        user3, membership3 = user3_membership
        client = authenticated_client_for_tenant_factory(user3, tenant2)

        response = client.get(
            reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}),
        )
        assert response.status_code == status.HTTP_200_OK
        # User is a member and can only see its own membership
        assert len(response.json()["data"]) == 1
        assert response.json()["data"][0]["id"] == str(membership3.id)

    def test_tenants_delete_own_membership_as_member(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        tenant1, *_ = tenants_fixture
        membership = Membership.objects.get(tenant=tenant1, user__email=TEST_USER)

        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant1.id, "pk": membership.id},
            )
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert not Membership.objects.filter(id=membership.id).exists()

    def test_tenants_delete_own_membership_as_owner(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        # With extra_users, tenant2 has 2 owners
        _, tenant2, _ = tenants_fixture
        user_membership = Membership.objects.get(tenant=tenant2, user__email=TEST_USER)
        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant2.id, "pk": user_membership.id},
            )
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert not Membership.objects.filter(id=user_membership.id).exists()

    def test_tenants_delete_own_membership_as_last_owner(
        self, authenticated_client, tenants_fixture
    ):
        _, tenant2, _ = tenants_fixture
        user_membership = Membership.objects.get(tenant=tenant2, user__email=TEST_USER)
        user_id = user_membership.user_id
        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant2.id, "pk": user_membership.id},
            )
        )
        assert response.status_code == status.HTTP_403_FORBIDDEN
        assert Membership.objects.filter(id=user_membership.id).exists()
        assert User.objects.filter(id=user_id).exists()

    def test_expel_user_deletes_account_if_last_membership(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        # TEST_USER is OWNER of tenant2; user3 is MEMBER only in tenant2
        _, tenant2, _ = tenants_fixture
        _, user3_membership = extra_users
        user3, membership3 = user3_membership

        assert Membership.objects.filter(user=user3).count() == 1

        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
            )
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert not Membership.objects.filter(id=membership3.id).exists()
        assert not User.objects.filter(id=user3.id).exists()

    def test_expel_user_blacklists_refresh_tokens(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        _, tenant2, _ = tenants_fixture
        _, user3_membership = extra_users
        user3, membership3 = user3_membership

        # Issue two refresh tokens to simulate active sessions
        RefreshToken.for_user(user3)
        RefreshToken.for_user(user3)
        outstanding_ids = list(
            OutstandingToken.objects.filter(user=user3).values_list("id", flat=True)
        )
        assert len(outstanding_ids) == 2
        assert not BlacklistedToken.objects.filter(
            token_id__in=outstanding_ids
        ).exists()

        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
            )
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert (
            BlacklistedToken.objects.filter(token_id__in=outstanding_ids).count() == 2
        )

    def test_expel_user_blacklists_refresh_tokens_is_idempotent(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        # Regression test for the bulk blacklisting path: if one of the
        # user's refresh tokens is already blacklisted when the expel
        # endpoint runs, the remaining tokens must still be blacklisted
        # and the already-blacklisted one must not be duplicated.
        tenant1, tenant2, _ = tenants_fixture
        _, user3_membership = extra_users
        user3, membership3 = user3_membership

        # Keep the user alive after the expel so the assertions below can
        # still query OutstandingToken by user_id.
        Membership.objects.create(
            user=user3,
            tenant=tenant1,
            role=Membership.RoleChoices.MEMBER,
        )

        RefreshToken.for_user(user3)
        RefreshToken.for_user(user3)
        outstanding_ids = list(
            OutstandingToken.objects.filter(user=user3).values_list("id", flat=True)
        )
        assert len(outstanding_ids) == 2

        # Pre-blacklist one of the two tokens to simulate a prior revocation.
        BlacklistedToken.objects.create(token_id=outstanding_ids[0])
        assert (
            BlacklistedToken.objects.filter(token_id__in=outstanding_ids).count() == 1
        )

        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
            )
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT

        blacklisted = BlacklistedToken.objects.filter(token_id__in=outstanding_ids)
        assert blacklisted.count() == 2
        assert set(blacklisted.values_list("token_id", flat=True)) == set(
            outstanding_ids
        )

    def test_expel_user_keeps_account_if_has_other_memberships(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        tenant1, tenant2, _ = tenants_fixture
        _, user3_membership = extra_users
        user3, membership3 = user3_membership

        # Give user3 an additional membership in tenant1 so they are not orphaned
        other_membership = Membership.objects.create(
            user=user3,
            tenant=tenant1,
            role=Membership.RoleChoices.MEMBER,
        )

        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
            )
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert not Membership.objects.filter(id=membership3.id).exists()
        assert User.objects.filter(id=user3.id).exists()
        assert Membership.objects.filter(id=other_membership.id).exists()

    def test_tenants_delete_another_membership_as_owner(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        _, tenant2, _ = tenants_fixture
        _, user3_membership = extra_users
        user3, membership3 = user3_membership

        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
            )
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert not Membership.objects.filter(id=membership3.id).exists()

    def test_tenants_delete_another_membership_as_member(
        self, authenticated_client, tenants_fixture, extra_users
    ):
        _, tenant2, _ = tenants_fixture
        _, user3_membership = extra_users
        user3, membership3 = user3_membership

        # Downgrade membership role manually
        user_membership = Membership.objects.get(tenant=tenant2, user__email=TEST_USER)
        user_membership.role = Membership.RoleChoices.MEMBER
        user_membership.save()

        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
            )
        )
        assert response.status_code == status.HTTP_403_FORBIDDEN
        assert Membership.objects.filter(id=membership3.id).exists()

    def test_tenants_list_memberships_not_member_of_tenant(self, authenticated_client):
        # Create a tenant the user is not a member of
        tenant4 = Tenant.objects.create(name="Tenant Four")

        response = authenticated_client.get(
            reverse("tenant-membership-list", kwargs={"tenant_pk": tenant4.id})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_tenants_delete_membership_cross_tenant(
        self, authenticated_client, tenants_fixture
    ):
        # Create a tenant with a different user's membership
        other_tenant = Tenant.objects.create(name="Other Tenant")
        other_user = User.objects.create_user(
            name="other", password=TEST_PASSWORD, email="other@test.com"
        )
        other_membership = Membership.objects.create(
            user=other_user,
            tenant=other_tenant,
            role=Membership.RoleChoices.OWNER,
        )

        # Authenticated user is NOT a member of other_tenant -> 404
        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": other_tenant.id, "pk": other_membership.id},
            )
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND
        assert Membership.objects.filter(id=other_membership.id).exists()

    def test_delete_membership_cleans_up_orphaned_role_grants(
        self, authenticated_client, tenants_fixture
    ):
        """Test that deleting a membership removes UserRoleRelationship records
        for that tenant while preserving grants in other tenants."""
        tenant1, tenant2, _ = tenants_fixture

        # Create a user with memberships in both tenants
        user = User.objects.create_user(
            name="Multi-tenant User",
            password=TEST_PASSWORD,
            email="multitenant@test.com",
        )

        # Create memberships in both tenants
        Membership.objects.create(
            user=user, tenant=tenant1, role=Membership.RoleChoices.MEMBER
        )
        membership2 = Membership.objects.create(
            user=user, tenant=tenant2, role=Membership.RoleChoices.MEMBER
        )

        # Create roles in both tenants
        role1 = Role.objects.create(
            name="Test Role 1", tenant=tenant1, manage_providers=True
        )
        role2 = Role.objects.create(
            name="Test Role 2", tenant=tenant2, manage_scans=True
        )

        # Create user role relationships for both tenants
        UserRoleRelationship.objects.create(user=user, role=role1, tenant=tenant1)
        UserRoleRelationship.objects.create(user=user, role=role2, tenant=tenant2)

        # Verify initial state
        assert UserRoleRelationship.objects.filter(user=user, tenant=tenant1).exists()
        assert UserRoleRelationship.objects.filter(user=user, tenant=tenant2).exists()
        assert Role.objects.filter(id=role1.id).exists()
        assert Role.objects.filter(id=role2.id).exists()

        # Delete membership from tenant2 (authenticated user is owner of tenant2)
        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant2.id, "pk": membership2.id},
            )
        )

        assert response.status_code == status.HTTP_204_NO_CONTENT

        # Verify the membership was deleted
        assert not Membership.objects.filter(id=membership2.id).exists()

        # Verify UserRoleRelationship for tenant2 was deleted
        assert not UserRoleRelationship.objects.filter(
            user=user, tenant=tenant2
        ).exists()

        # Verify UserRoleRelationship for tenant1 is preserved
        assert UserRoleRelationship.objects.filter(user=user, tenant=tenant1).exists()

        # Verify orphaned role2 was deleted (no more user or invitation relationships)
        assert not Role.objects.filter(id=role2.id).exists()

        # Verify role1 is preserved (still has user relationship)
        assert Role.objects.filter(id=role1.id).exists()

        # Verify the user still exists (has other memberships)
        assert User.objects.filter(id=user.id).exists()

    def test_delete_membership_preserves_role_with_invitation_relationship(
        self, authenticated_client, tenants_fixture
    ):
        """Test that roles are not deleted if they have invitation relationships."""
        _, tenant2, _ = tenants_fixture

        # Create a user with membership
        user = User.objects.create_user(
            name="Test User", password=TEST_PASSWORD, email="testuser@test.com"
        )
        membership = Membership.objects.create(
            user=user, tenant=tenant2, role=Membership.RoleChoices.MEMBER
        )

        # Create a role and user relationship
        role = Role.objects.create(
            name="Shared Role", tenant=tenant2, manage_providers=True
        )
        UserRoleRelationship.objects.create(user=user, role=role, tenant=tenant2)

        # Create an invitation with the same role
        invitation = Invitation.objects.create(email="pending@test.com", tenant=tenant2)
        InvitationRoleRelationship.objects.create(
            invitation=invitation, role=role, tenant=tenant2
        )

        # Verify initial state
        assert UserRoleRelationship.objects.filter(user=user, role=role).exists()
        assert InvitationRoleRelationship.objects.filter(
            invitation=invitation, role=role
        ).exists()
        assert Role.objects.filter(id=role.id).exists()

        # Delete the membership
        response = authenticated_client.delete(
            reverse(
                "tenant-membership-detail",
                kwargs={"tenant_pk": tenant2.id, "pk": membership.id},
            )
        )

        assert response.status_code == status.HTTP_204_NO_CONTENT

        # Verify UserRoleRelationship was deleted
        assert not UserRoleRelationship.objects.filter(user=user, role=role).exists()

        # Verify role is preserved because invitation relationship exists
        assert Role.objects.filter(id=role.id).exists()
        assert InvitationRoleRelationship.objects.filter(
            invitation=invitation, role=role
        ).exists()

    def test_tenants_list_no_permissions(
        self, authenticated_client_no_permissions_rbac, tenants_fixture
    ):
        response = authenticated_client_no_permissions_rbac.get(reverse("tenant-list"))
        assert response.status_code == status.HTTP_200_OK

    def test_tenants_retrieve_no_permissions(
        self, authenticated_client_no_permissions_rbac, tenants_fixture
    ):
        tenant1, *_ = tenants_fixture
        response = authenticated_client_no_permissions_rbac.get(
            reverse("tenant-detail", kwargs={"pk": tenant1.id})
        )
        assert response.status_code == status.HTTP_200_OK

    def test_tenants_create_no_permissions(
        self, authenticated_client_no_permissions_rbac, valid_tenant_payload
    ):
        response = authenticated_client_no_permissions_rbac.post(
            reverse("tenant-list"),
            data=valid_tenant_payload,
            format="json",
        )
        assert response.status_code == status.HTTP_201_CREATED

    def test_tenants_partial_update_no_permissions(
        self, authenticated_client_no_permissions_rbac, tenants_fixture
    ):
        tenant1, *_ = tenants_fixture
        payload = {
            "data": {
                "type": "tenants",
                "id": str(tenant1.id),
                "attributes": {"name": "Unauthorized update"},
            },
        }
        response = authenticated_client_no_permissions_rbac.patch(
            reverse("tenant-detail", kwargs={"pk": tenant1.id}),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_403_FORBIDDEN

    @patch("api.v1.views.delete_tenant_task.apply_async")
    def test_tenants_delete_no_permissions(
        self,
        delete_tenant_mock,
        authenticated_client_no_permissions_rbac,
        tenants_fixture,
    ):
        tenant1, *_ = tenants_fixture
        response = authenticated_client_no_permissions_rbac.delete(
            reverse("tenant-detail", kwargs={"pk": tenant1.id})
        )
        assert response.status_code == status.HTTP_403_FORBIDDEN
        delete_tenant_mock.assert_not_called()


@pytest.mark.django_db
class TestMembershipViewSet:
    def test_memberships_list(self, authenticated_client, tenants_fixture):
        user_id = authenticated_client.user.pk
        response = authenticated_client.get(
            reverse("user-membership-list", kwargs={"user_pk": user_id}),
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_memberships_retrieve(self, authenticated_client, tenants_fixture):
        user_id = authenticated_client.user.pk
        list_response = authenticated_client.get(
            reverse("user-membership-list", kwargs={"user_pk": user_id}),
        )
        assert list_response.status_code == status.HTTP_200_OK
        membership = list_response.json()["data"][0]

        response = authenticated_client.get(
            reverse(
                "user-membership-detail",
                kwargs={"user_pk": user_id, "pk": membership["id"]},
            ),
        )
        assert response.status_code == status.HTTP_200_OK
        assert (
            response.json()["data"]["relationships"]["tenant"]["data"]["id"]
            == membership["relationships"]["tenant"]["data"]["id"]
        )
        assert (
            response.json()["data"]["relationships"]["user"]["data"]["id"]
            == membership["relationships"]["user"]["data"]["id"]
        )

    def test_memberships_invalid_retrieve(self, authenticated_client):
        user_id = authenticated_client.user.pk
        response = authenticated_client.get(
            reverse(
                "user-membership-detail",
                kwargs={
                    "user_pk": user_id,
                    "pk": "b91c5eff-13f5-469c-9fd8-917b3a3037b6",
                },
            ),
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @pytest.mark.parametrize(
        "filter_name, filter_value, expected_count",
        [
            ("role", "owner", 1),
            ("role", "member", 1),
            ("date_joined", TODAY, 2),
            ("date_joined.gte", "2024-01-01", 2),
            ("date_joined.lte", "2024-01-01", 0),
        ],
    )
    def test_memberships_filters(
        self,
        authenticated_client,
        tenants_fixture,
        filter_name,
        filter_value,
        expected_count,
    ):
        user_id = authenticated_client.user.pk
        response = authenticated_client.get(
            reverse("user-membership-list", kwargs={"user_pk": user_id}),
            {f"filter[{filter_name}]": filter_value},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == expected_count

    def test_memberships_filters_relationships(
        self, authenticated_client, tenants_fixture
    ):
        user_id = authenticated_client.user.pk
        tenant, *_ = tenants_fixture
        # No filter
        response = authenticated_client.get(
            reverse("user-membership-list", kwargs={"user_pk": user_id}),
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

        # Filter by tenant
        response = authenticated_client.get(
            reverse("user-membership-list", kwargs={"user_pk": user_id}),
            {"filter[tenant]": tenant.id},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1

    @pytest.mark.parametrize(
        "filter_name",
        [
            "role",  # Valid filter, invalid value
            "tenant",  # Valid filter, invalid value
            "invalid",  # Invalid filter
        ],
    )
    def test_memberships_filters_invalid(
        self, authenticated_client, tenants_fixture, filter_name
    ):
        user_id = authenticated_client.user.pk
        response = authenticated_client.get(
            reverse("user-membership-list", kwargs={"user_pk": user_id}),
            {f"filter[{filter_name}]": "whatever"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "sort_field",
        [
            "tenant",
            "role",
            "date_joined",
        ],
    )
    def test_memberships_sort(self, authenticated_client, tenants_fixture, sort_field):
        user_id = authenticated_client.user.pk
        response = authenticated_client.get(
            reverse("user-membership-list", kwargs={"user_pk": user_id}),
            {"sort": sort_field},
        )
        assert response.status_code == status.HTTP_200_OK

    def test_memberships_sort_invalid(self, authenticated_client, tenants_fixture):
        user_id = authenticated_client.user.pk
        response = authenticated_client.get(
            reverse("user-membership-list", kwargs={"user_pk": user_id}),
            {"sort": "invalid"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
class TestProviderViewSet:
    @pytest.fixture(scope="function")
    def create_provider_group_relationship(
        self, tenants_fixture, aws_provider, provider_groups_fixture
    ):
        tenant, *_ = tenants_fixture
        provider1 = aws_provider
        provider_group1, *_ = provider_groups_fixture
        provider_group_membership = ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=provider_group1
        )
        return provider_group_membership

    def test_providers_list(self, authenticated_client, all_provider_types_fixture):
        response = authenticated_client.get(
            reverse("provider-list"), {"page[disable]": "true"}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == len(all_provider_types_fixture)
        assert {item["attributes"]["provider"] for item in data} == {
            provider.provider for provider in all_provider_types_fixture
        }

    def test_providers_filter_provider_type(
        self, authenticated_client, aws_provider_pair
    ):
        response = authenticated_client.get(
            reverse("provider-list"), {"filter[provider_type]": "aws"}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        assert all(item["attributes"]["provider"] == "aws" for item in data)

    def test_providers_filter_provider_type_in(
        self, authenticated_client, aws_provider_pair, gcp_provider
    ):
        response = authenticated_client.get(
            reverse("provider-list"), {"filter[provider_type__in]": "aws,gcp"}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 3
        assert {"aws", "gcp"} >= {item["attributes"]["provider"] for item in data}

    def test_providers_filter_provider_type_invalid(
        self, authenticated_client, aws_provider
    ):
        response = authenticated_client.get(
            reverse("provider-list"), {"filter[provider_type]": "invalid"}
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_providers_filter_provider_groups(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider_pair,
        provider_groups_fixture,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair
        group1, group2, *_ = provider_groups_fixture
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group1
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group2
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider2, provider_group=group2
        )

        response = authenticated_client.get(
            reverse("provider-list"), {"filter[provider_groups]": str(group1.id)}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert [item["id"] for item in data] == [str(provider1.id)]

        response = authenticated_client.get(
            reverse("provider-list"),
            {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
        )
        assert response.status_code == status.HTTP_200_OK
        provider_ids = {item["id"] for item in response.json()["data"]}
        assert provider_ids == {str(provider1.id), str(provider2.id)}
        assert len(response.json()["data"]) == 2

    def test_providers_disable_pagination(
        self, authenticated_client, aws_provider, tenants_fixture
    ):
        tenant, *_ = tenants_fixture
        existing_count = Provider.objects.filter(tenant_id=tenant.id).count()
        target_total = settings.REST_FRAMEWORK["PAGE_SIZE"] + 1
        additional_needed = max(0, target_total - existing_count)

        base_uid = 200000000000
        for index in range(additional_needed):
            Provider.objects.create(
                tenant_id=tenant.id,
                provider=Provider.ProviderChoices.AWS,
                uid=f"{base_uid + index:012d}",
                alias=f"aws_extra_{index}",
            )

        total_providers = Provider.objects.filter(tenant_id=tenant.id).count()

        paginated_response = authenticated_client.get(reverse("provider-list"))
        assert paginated_response.status_code == status.HTTP_200_OK
        paginated_data = paginated_response.json()["data"]
        assert len(paginated_data) == min(
            settings.REST_FRAMEWORK["PAGE_SIZE"], total_providers
        )
        paginated_meta = paginated_response.json().get("meta", {})
        assert "pagination" in paginated_meta
        assert paginated_meta["pagination"]["count"] == total_providers

        unpaginated_response = authenticated_client.get(
            reverse("provider-list"), {"page[disable]": "true"}
        )
        assert unpaginated_response.status_code == status.HTTP_200_OK
        unpaginated_data = unpaginated_response.json()["data"]
        assert len(unpaginated_data) == total_providers
        unpaginated_meta = unpaginated_response.json().get("meta", {})
        assert "pagination" not in unpaginated_meta

    @pytest.mark.parametrize(
        "include_values, expected_resources",
        [
            ("provider_groups", ["provider-groups"]),
        ],
    )
    @pytest.mark.usefixtures("create_provider_group_relationship")
    def test_providers_list_include(
        self,
        include_values,
        expected_resources,
        authenticated_client,
        aws_provider,
    ):
        response = authenticated_client.get(
            reverse("provider-list"), {"include": include_values}
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert "included" in response.json()

        included_data = response.json()["included"]
        for expected_type in expected_resources:
            assert any(d.get("type") == expected_type for d in included_data), (
                f"Expected type '{expected_type}' not found in included data"
            )

    def test_providers_retrieve(self, authenticated_client, aws_provider):
        provider1 = aws_provider
        response = authenticated_client.get(
            reverse("provider-detail", kwargs={"pk": provider1.id}),
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"]["attributes"]["provider"] == provider1.provider
        assert response.json()["data"]["attributes"]["uid"] == provider1.uid
        assert response.json()["data"]["attributes"]["alias"] == provider1.alias

    def test_providers_invalid_retrieve(self, authenticated_client):
        response = authenticated_client.get(
            reverse("provider-detail", kwargs={"pk": "random_id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @pytest.mark.parametrize(
        "provider_json_payload",
        (
            [
                {"provider": "aws", "uid": "111111111111", "alias": "test"},
                {"provider": "gcp", "uid": "a12322-test54321", "alias": "test"},
                {
                    "provider": "gcp",
                    "uid": "example.com:my-project-123456",
                    "alias": "legacy-gcp",
                },
                {
                    "provider": "kubernetes",
                    "uid": "kubernetes-test-123456789",
                    "alias": "test",
                },
                {
                    "provider": "kubernetes",
                    "uid": "arn:aws:eks:us-east-1:111122223333:cluster/test-cluster-long-name-123456789",
                    "alias": "EKS",
                },
                {
                    "provider": "kubernetes",
                    "uid": "gke_aaaa-dev_europe-test1_dev-aaaa-test-cluster-long-name-123456789",
                    "alias": "GKE",
                },
                {
                    "provider": "kubernetes",
                    "uid": "gke_project/cluster-name",
                    "alias": "GKE",
                },
                {
                    "provider": "kubernetes",
                    "uid": "admin@k8s-demo",
                    "alias": "test",
                },
                {
                    "provider": "azure",
                    "uid": "8851db6b-42e5-4533-aa9e-30a32d67e875",
                    "alias": "test",
                },
                {
                    "provider": "m365",
                    "uid": "TestingPro.onmicrosoft.com",
                    "alias": "test",
                },
                {
                    "provider": "m365",
                    "uid": "subdomain.domain.es",
                    "alias": "test",
                },
                {
                    "provider": "m365",
                    "uid": "microsoft.net",
                    "alias": "test",
                },
                {
                    "provider": "m365",
                    "uid": "subdomain1.subdomain2.subdomain3.subdomain4.domain.net",
                    "alias": "test",
                },
                {
                    "provider": "github",
                    "uid": "test-user",
                    "alias": "test",
                },
                {
                    "provider": "github",
                    "uid": "test-organization",
                    "alias": "GitHub Org",
                },
                {
                    "provider": "github",
                    "uid": "prowler-cloud",
                    "alias": "Prowler",
                },
                {
                    "provider": "github",
                    "uid": "microsoft",
                    "alias": "Microsoft",
                },
                {
                    "provider": "github",
                    "uid": "a12345678901234567890123456789012345678",
                    "alias": "Long Username",
                },
                {
                    "provider": "iac",
                    "uid": "https://github.com/user/repo.git",
                    "alias": "Git Repo",
                },
                {
                    "provider": "iac",
                    "uid": "https://gitlab.com/user/project",
                    "alias": "GitLab Repo",
                },
                {
                    "provider": "mongodbatlas",
                    "uid": "64b1d3c0e4b03b1234567890",
                    "alias": "Atlas Organization",
                },
                {
                    "provider": "alibabacloud",
                    "uid": "1234567890123456",
                    "alias": "Alibaba Cloud Account",
                },
                {
                    "provider": "cloudflare",
                    "uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
                    "alias": "Cloudflare Account",
                },
                {
                    "provider": "openstack",
                    "uid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
                    "alias": "OpenStack Project",
                },
                {
                    "provider": "googleworkspace",
                    "uid": "C01234abc",
                    "alias": "Google Workspace Customer",
                },
                {
                    "provider": "googleworkspace",
                    "uid": "C12345678",
                    "alias": "Google Workspace All Digits",
                },
                {
                    "provider": "googleworkspace",
                    "uid": "CABCDEF123",
                    "alias": "Google Workspace Uppercase",
                },
                {
                    "provider": "googleworkspace",
                    "uid": "C12",
                    "alias": "Google Workspace Minimum Length",
                },
                {
                    "provider": "image",
                    "uid": "registry.example.com/prowler/test:latest",
                    "alias": "Container Image",
                },
                {
                    "provider": "okta",
                    "uid": "acme.okta.com",
                    "alias": "Okta Org",
                },
                {
                    "provider": "okta",
                    "uid": "agency.okta-gov.com",
                    "alias": "Okta Gov Org",
                },
                {
                    "provider": "okta",
                    "uid": "agency.okta.mil",
                    "alias": "Okta Mil Org",
                },
            ]
        ),
    )
    def test_providers_create_valid(self, authenticated_client, provider_json_payload):
        response = authenticated_client.post(
            reverse("provider-list"), data=provider_json_payload, format="json"
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert Provider.objects.count() == 1
        assert Provider.objects.get().provider == provider_json_payload["provider"]
        assert Provider.objects.get().uid == provider_json_payload["uid"]
        assert Provider.objects.get().alias == provider_json_payload["alias"]

    @pytest.mark.parametrize(
        "provider_json_payload",
        (
            [
                {"provider": "aws", "uid": "111111111111", "alias": "test"},
                {"provider": "gcp", "uid": "a12322-test54321", "alias": "test"},
                {
                    "provider": "gcp",
                    "uid": "example.com:my-project-123456",
                    "alias": "legacy-gcp",
                },
                {
                    "provider": "kubernetes",
                    "uid": "kubernetes-test-123456789",
                    "alias": "test",
                },
                {
                    "provider": "kubernetes",
                    "uid": "arn:aws:eks:us-east-1:111122223333:cluster/test-cluster-long-name-123456789",
                    "alias": "EKS",
                },
                {
                    "provider": "kubernetes",
                    "uid": "gke_aaaa-dev_europe-test1_dev-aaaa-test-cluster-long-name-123456789",
                    "alias": "GKE",
                },
                {
                    "provider": "kubernetes",
                    "uid": "gke_project/cluster-name",
                    "alias": "GKE",
                },
                {
                    "provider": "kubernetes",
                    "uid": "admin@k8s-demo",
                    "alias": "test",
                },
                {
                    "provider": "azure",
                    "uid": "8851db6b-42e5-4533-aa9e-30a32d67e875",
                    "alias": "test",
                },
                {
                    "provider": "m365",
                    "uid": "TestingPro.onmicrosoft.com",
                    "alias": "test",
                },
                {
                    "provider": "m365",
                    "uid": "subdomain.domain.es",
                    "alias": "test",
                },
                {
                    "provider": "m365",
                    "uid": "microsoft.net",
                    "alias": "test",
                },
                {
                    "provider": "m365",
                    "uid": "subdomain1.subdomain2.subdomain3.subdomain4.domain.net",
                    "alias": "test",
                },
                {
                    "provider": "github",
                    "uid": "test-user",
                    "alias": "test",
                },
                {
                    "provider": "github",
                    "uid": "test-organization",
                    "alias": "GitHub Org",
                },
                {
                    "provider": "github",
                    "uid": "prowler-cloud",
                    "alias": "Prowler",
                },
                {
                    "provider": "github",
                    "uid": "microsoft",
                    "alias": "Microsoft",
                },
                {
                    "provider": "github",
                    "uid": "a12345678901234567890123456789012345678",
                    "alias": "Long Username",
                },
            ]
        ),
    )
    @patch("api.v1.views.Task.objects.get")
    @patch("api.v1.views.delete_provider_task.delay")
    def test_providers_soft_delete(
        self,
        mock_delete_task,
        mock_task_get,
        authenticated_client,
        provider_json_payload,
        tasks_fixture,
    ):
        # Mock the Celery task response
        prowler_task = tasks_fixture[0]
        task_mock = Mock()
        task_mock.id = prowler_task.id
        mock_delete_task.return_value = task_mock
        mock_task_get.return_value = prowler_task

        # 1.Create a provider
        response = authenticated_client.post(
            reverse("provider-list"), data=provider_json_payload, format="json"
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert Provider.objects.count() == 1
        provider_id = response.json()["data"]["id"]

        # 2. Soft delete the provider using the actual API endpoint
        response = authenticated_client.delete(
            reverse("provider-detail", kwargs={"pk": provider_id})
        )
        assert response.status_code == status.HTTP_202_ACCEPTED
        assert Provider.objects.count() == 0
        assert Provider.all_objects.count() == 1

        mock_delete_task.assert_called_once_with(
            provider_id=str(provider_id), tenant_id=ANY
        )

        # 3. Create a provider with the same UID should succeed (since the old one is soft deleted)
        response = authenticated_client.post(
            reverse("provider-list"), data=provider_json_payload, format="json"
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert Provider.objects.count() == 1
        assert Provider.all_objects.count() == 2
        provider_id = response.json()["data"]["id"]

        # 4. Creating another provider with the same UID should fail (duplicate)
        response = authenticated_client.post(
            reverse("provider-list"), data=provider_json_payload, format="json"
        )
        assert response.status_code == status.HTTP_409_CONFLICT
        error = response.json()["errors"][0]
        assert error["detail"] == "Provider already exists."
        assert error["code"] == "conflict"
        assert error["source"]["pointer"] == "/data/attributes/uid"

        mock_delete_task.reset_mock()
        mock_delete_task.return_value = task_mock

        # 5. Delete the second provider
        response = authenticated_client.delete(
            reverse("provider-detail", kwargs={"pk": provider_id})
        )
        assert response.status_code == status.HTTP_202_ACCEPTED
        assert Provider.objects.count() == 0
        assert Provider.all_objects.count() == 2

        # 6. Creating a provider with the same UID should succeed again
        response = authenticated_client.post(
            reverse("provider-list"), data=provider_json_payload, format="json"
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert Provider.objects.count() == 1
        assert Provider.all_objects.count() == 3

    @pytest.mark.parametrize(
        "provider_json_payload, error_code, error_pointer",
        (
            [
                (
                    {"provider": "aws", "uid": "1", "alias": "test"},
                    "min_length",
                    "uid",
                ),
                (
                    {
                        "provider": "aws",
                        "uid": "1111111111111",
                        "alias": "test",
                    },
                    "aws-uid",
                    "uid",
                ),
                (
                    {"provider": "aws", "uid": "aaaaaaaaaaaa", "alias": "test"},
                    "aws-uid",
                    "uid",
                ),
                (
                    {"provider": "gcp", "uid": "1234asdf", "alias": "test"},
                    "gcp-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "kubernetes",
                        "uid": "-1234asdf",
                        "alias": "test",
                    },
                    "kubernetes-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "azure",
                        "uid": "8851db6b-42e5-4533-aa9e-30a32d67e87",
                        "alias": "test",
                    },
                    "azure-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "does-not-exist",
                        "uid": "8851db6b-42e5-4533-aa9e-30a32d67e87",
                        "alias": "test",
                    },
                    "invalid_choice",
                    "provider",
                ),
                (
                    {
                        "provider": "m365",
                        "uid": "https://test.com",
                        "alias": "test",
                    },
                    "m365-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "m365",
                        "uid": "thisisnotadomain",
                        "alias": "test",
                    },
                    "m365-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "m365",
                        "uid": "http://test.com",
                        "alias": "test",
                    },
                    "m365-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "m365",
                        "uid": f"{'a' * 64}.domain.com",
                        "alias": "test",
                    },
                    "m365-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "m365",
                        "uid": f"subdomain.{'a' * 64}.com",
                        "alias": "test",
                    },
                    "m365-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "github",
                        "uid": "-invalid-start",
                        "alias": "test",
                    },
                    "github-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "github",
                        "uid": "invalid@username",
                        "alias": "test",
                    },
                    "github-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "github",
                        "uid": "invalid_username",
                        "alias": "test",
                    },
                    "github-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "github",
                        "uid": "a" * 40,
                        "alias": "test",
                    },
                    "github-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "iac",
                        "uid": "not-a-url",
                        "alias": "test",
                    },
                    "iac-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "iac",
                        "uid": "ftp://invalid-protocol.com/repo",
                        "alias": "test",
                    },
                    "iac-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "iac",
                        "uid": "http://",
                        "alias": "test",
                    },
                    "iac-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "mongodbatlas",
                        "uid": "64b1d3c0e4b03b123456789g",
                        "alias": "test",
                    },
                    "mongodbatlas-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "mongodbatlas",
                        "uid": "1234",
                        "alias": "test",
                    },
                    "mongodbatlas-uid",
                    "uid",
                ),
                # Alibaba Cloud UID validation - too short (not 16 digits)
                (
                    {
                        "provider": "alibabacloud",
                        "uid": "123456789012345",
                        "alias": "test",
                    },
                    "alibabacloud-uid",
                    "uid",
                ),
                # Alibaba Cloud UID validation - too long (not 16 digits)
                (
                    {
                        "provider": "alibabacloud",
                        "uid": "12345678901234567",
                        "alias": "test",
                    },
                    "alibabacloud-uid",
                    "uid",
                ),
                # Alibaba Cloud UID validation - contains non-digits
                (
                    {
                        "provider": "alibabacloud",
                        "uid": "123456789012345a",
                        "alias": "test",
                    },
                    "alibabacloud-uid",
                    "uid",
                ),
                # Cloudflare UID validation - too short (not 32 hex chars)
                (
                    {
                        "provider": "cloudflare",
                        "uid": "abc123",
                        "alias": "test",
                    },
                    "cloudflare-uid",
                    "uid",
                ),
                # Cloudflare UID validation - uppercase hex (must be lowercase)
                (
                    {
                        "provider": "cloudflare",
                        "uid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
                        "alias": "test",
                    },
                    "cloudflare-uid",
                    "uid",
                ),
                # Cloudflare UID validation - non-hex characters
                (
                    {
                        "provider": "cloudflare",
                        "uid": "g1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
                        "alias": "test",
                    },
                    "cloudflare-uid",
                    "uid",
                ),
                # Cloudflare UID validation - too long (33 chars)
                (
                    {
                        "provider": "cloudflare",
                        "uid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e",
                        "alias": "test",
                    },
                    "cloudflare-uid",
                    "uid",
                ),
                # OpenStack UID validation - starts with special character
                (
                    {
                        "provider": "openstack",
                        "uid": "-invalid-project",
                        "alias": "test",
                    },
                    "openstack-uid",
                    "uid",
                ),
                # OpenStack UID validation - too short (below min_length)
                (
                    {
                        "provider": "openstack",
                        "uid": "ab",
                        "alias": "test",
                    },
                    "min_length",
                    "uid",
                ),
                # Vercel UID validation - missing team_ prefix
                (
                    {
                        "provider": "vercel",
                        "uid": "abcdef1234567890abcdef12",
                        "alias": "test",
                    },
                    "vercel-uid",
                    "uid",
                ),
                # Vercel UID validation - too short after prefix
                (
                    {
                        "provider": "vercel",
                        "uid": "team_abc123",
                        "alias": "test",
                    },
                    "vercel-uid",
                    "uid",
                ),
                # Vercel UID validation - contains special characters
                (
                    {
                        "provider": "vercel",
                        "uid": "team_abcdef-1234567890ab",
                        "alias": "test",
                    },
                    "vercel-uid",
                    "uid",
                ),
                # Vercel UID validation - too long (33 chars after prefix)
                (
                    {
                        "provider": "vercel",
                        "uid": "team_abcdefghijklmnopqrstuvwxyz1234567",
                        "alias": "test",
                    },
                    "vercel-uid",
                    "uid",
                ),
                # Google Workspace UID validation - missing 'C' prefix
                (
                    {
                        "provider": "googleworkspace",
                        "uid": "01234abc",
                        "alias": "test",
                    },
                    "googleworkspace-uid",
                    "uid",
                ),
                # Google Workspace UID validation - contains special characters
                (
                    {
                        "provider": "googleworkspace",
                        "uid": "C0123-abc",
                        "alias": "test",
                    },
                    "googleworkspace-uid",
                    "uid",
                ),
                # Google Workspace UID validation - lowercase 'c' prefix
                (
                    {
                        "provider": "googleworkspace",
                        "uid": "c12345678",
                        "alias": "test",
                    },
                    "googleworkspace-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "okta",
                        "uid": "https://acme.okta.com",
                        "alias": "test",
                    },
                    "okta-uid",
                    "uid",
                ),
                (
                    {
                        "provider": "okta",
                        "uid": "acme.example.com",
                        "alias": "test",
                    },
                    "okta-uid",
                    "uid",
                ),
            ]
        ),
    )
    def test_providers_invalid_create(
        self,
        authenticated_client,
        provider_json_payload,
        error_code,
        error_pointer,
    ):
        response = authenticated_client.post(
            reverse("provider-list"), data=provider_json_payload, format="json"
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == error_code
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == f"/data/attributes/{error_pointer}"
        )

    @pytest.mark.parametrize(
        "input_uid,stored_uid",
        [
            ("Acme.okta.com", "acme.okta.com"),
            ("  ACME.OKTA.COM  ", "acme.okta.com"),
            ("Agency.Okta-Gov.com", "agency.okta-gov.com"),
        ],
    )
    def test_providers_create_okta_uid_normalized(
        self, authenticated_client, input_uid, stored_uid
    ):
        response = authenticated_client.post(
            reverse("provider-list"),
            data={"provider": "okta", "uid": input_uid, "alias": "Okta"},
            format="json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert Provider.objects.get().uid == stored_uid

    def test_providers_partial_update(self, authenticated_client, aws_provider):
        provider1 = aws_provider
        new_alias = "This is the new name"
        payload = {
            "data": {
                "type": "providers",
                "id": provider1.id,
                "attributes": {"alias": new_alias},
            },
        }
        response = authenticated_client.patch(
            reverse("provider-detail", kwargs={"pk": provider1.id}),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_200_OK
        provider1.refresh_from_db()
        assert provider1.alias == new_alias

    def test_providers_partial_update_invalid_content_type(
        self,
        authenticated_client,
        aws_provider,
    ):
        provider1 = aws_provider
        response = authenticated_client.patch(
            reverse("provider-detail", kwargs={"pk": provider1.id}),
            data={},
        )
        assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE

    def test_providers_partial_update_invalid_content(
        self,
        authenticated_client,
        aws_provider,
    ):
        provider1 = aws_provider
        new_name = "This is the new name"
        payload = {"alias": new_name}
        response = authenticated_client.patch(
            reverse("provider-detail", kwargs={"pk": provider1.id}),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "attribute_key, attribute_value",
        [
            ("provider", "aws"),
            ("uid", "123456789012"),
        ],
    )
    def test_providers_partial_update_invalid_fields(
        self,
        authenticated_client,
        aws_provider,
        attribute_key,
        attribute_value,
    ):
        provider1 = aws_provider
        payload = {
            "data": {
                "type": "providers",
                "id": provider1.id,
                "attributes": {attribute_key: attribute_value},
            },
        }
        response = authenticated_client.patch(
            reverse("provider-detail", kwargs={"pk": provider1.id}),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @patch("api.v1.views.Task.objects.get")
    @patch("api.v1.views.delete_provider_task.delay")
    def test_providers_delete(
        self,
        mock_delete_task,
        mock_task_get,
        authenticated_client,
        aws_provider,
        tasks_fixture,
    ):
        prowler_task = tasks_fixture[0]
        task_mock = Mock()
        task_mock.id = prowler_task.id
        mock_delete_task.return_value = task_mock
        mock_task_get.return_value = prowler_task

        provider1 = aws_provider
        response = authenticated_client.delete(
            reverse("provider-detail", kwargs={"pk": provider1.id})
        )
        assert response.status_code == status.HTTP_202_ACCEPTED
        mock_delete_task.assert_called_once_with(
            provider_id=str(provider1.id), tenant_id=ANY
        )
        assert "Content-Location" in response.headers
        assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"

    def test_providers_delete_invalid(self, authenticated_client):
        response = authenticated_client.delete(
            reverse("provider-detail", kwargs={"pk": "random_id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @patch("api.v1.views.Task.objects.get")
    @patch("api.v1.views.check_provider_connection_task.delay")
    def test_providers_connection(
        self,
        mock_provider_connection,
        mock_task_get,
        authenticated_client,
        aws_provider,
        tasks_fixture,
    ):
        prowler_task = tasks_fixture[0]
        task_mock = Mock()
        task_mock.id = prowler_task.id
        task_mock.status = "PENDING"
        mock_provider_connection.return_value = task_mock
        mock_task_get.return_value = prowler_task

        provider1 = aws_provider
        assert provider1.connected is None
        assert provider1.connection_last_checked_at is None

        response = authenticated_client.post(
            reverse("provider-connection", kwargs={"pk": provider1.id})
        )
        assert response.status_code == status.HTTP_202_ACCEPTED
        mock_provider_connection.assert_called_once_with(
            provider_id=str(provider1.id), tenant_id=ANY
        )
        assert "Content-Location" in response.headers
        assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"

    def test_providers_connection_invalid_provider(
        self,
        authenticated_client,
    ):
        response = authenticated_client.post(
            reverse("provider-connection", kwargs={"pk": "random_id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @pytest.mark.parametrize(
        "filter_name, filter_value",
        (
            [
                ("uid", "123456789012"),
                ("uid.icontains", "1"),
                ("alias", "aws_testing_1"),
                ("inserted_at", TODAY),
                ("inserted_at.gte", "2024-01-01"),
                ("updated_at.gte", "2024-01-01"),
            ]
        ),
    )
    def test_providers_filters_single_aws_provider(
        self,
        authenticated_client,
        aws_provider,
        filter_name,
        filter_value,
    ):
        response = authenticated_client.get(
            reverse("provider-list"),
            {f"filter[{filter_name}]": filter_value},
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1

    @pytest.mark.parametrize(
        "filter_name, filter_value",
        (
            [
                ("inserted_at.lte", "2024-01-01"),
                ("updated_at.lte", "2024-01-01"),
            ]
        ),
    )
    def test_providers_filters_single_aws_provider_no_results(
        self,
        authenticated_client,
        aws_provider,
        filter_name,
        filter_value,
    ):
        response = authenticated_client.get(
            reverse("provider-list"),
            {f"filter[{filter_name}]": filter_value},
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 0

    @pytest.mark.parametrize(
        "filter_name, filter_value",
        (
            [
                ("provider", "aws"),
                ("alias.icontains", "aws"),
            ]
        ),
    )
    def test_providers_filters_two_aws_providers(
        self,
        authenticated_client,
        aws_provider_pair,
        filter_name,
        filter_value,
    ):
        response = authenticated_client.get(
            reverse("provider-list"),
            {f"filter[{filter_name}]": filter_value},
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_providers_filters_provider_in(
        self,
        authenticated_client,
        azure_provider,
        gcp_provider,
    ):
        response = authenticated_client.get(
            reverse("provider-list"),
            {"filter[provider.in]": "azure,gcp"},
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    @pytest.mark.parametrize(
        "filter_name",
        (
            [
                "provider",  # Valid filter, invalid value
                "invalid",
            ]
        ),
    )
    def test_providers_filters_invalid(self, authenticated_client, filter_name):
        response = authenticated_client.get(
            reverse("provider-list"),
            {f"filter[{filter_name}]": "whatever"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "sort_field",
        (
            [
                "provider",
                "uid",
                "alias",
                "connected",
                "inserted_at",
                "updated_at",
            ]
        ),
    )
    def test_providers_sort(self, authenticated_client, sort_field):
        response = authenticated_client.get(
            reverse("provider-list"), {"sort": sort_field}
        )
        assert response.status_code == status.HTTP_200_OK

    def test_providers_sort_invalid(self, authenticated_client):
        response = authenticated_client.get(
            reverse("provider-list"), {"sort": "invalid"}
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
class TestProviderGroupViewSet:
    def test_provider_group_list(self, authenticated_client, provider_groups_fixture):
        response = authenticated_client.get(reverse("providergroup-list"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(provider_groups_fixture)

    def test_provider_group_retrieve(
        self, authenticated_client, provider_groups_fixture
    ):
        provider_group = provider_groups_fixture[0]
        response = authenticated_client.get(
            reverse("providergroup-detail", kwargs={"pk": provider_group.id})
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data["id"] == str(provider_group.id)
        assert data["attributes"]["name"] == provider_group.name

    def test_provider_group_create(self, authenticated_client):
        data = {
            "data": {
                "type": "provider-groups",
                "attributes": {
                    "name": "Test Provider Group",
                },
            }
        }
        response = authenticated_client.post(
            reverse("providergroup-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        response_data = response.json()["data"]
        assert response_data["attributes"]["name"] == "Test Provider Group"
        assert ProviderGroup.objects.filter(name="Test Provider Group").exists()

    def test_provider_group_create_invalid(self, authenticated_client):
        data = {
            "data": {
                "type": "provider-groups",
                "attributes": {
                    # Name is missing
                },
            }
        }
        response = authenticated_client.post(
            reverse("providergroup-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]
        assert errors[0]["source"]["pointer"] == "/data/attributes/name"

    def test_provider_group_partial_update(
        self, authenticated_client, provider_groups_fixture
    ):
        provider_group = provider_groups_fixture[1]
        data = {
            "data": {
                "id": str(provider_group.id),
                "type": "provider-groups",
                "attributes": {
                    "name": "Updated Provider Group Name",
                },
            }
        }
        response = authenticated_client.patch(
            reverse("providergroup-detail", kwargs={"pk": provider_group.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        provider_group.refresh_from_db()
        assert provider_group.name == "Updated Provider Group Name"

    def test_provider_group_partial_update_invalid(
        self, authenticated_client, provider_groups_fixture
    ):
        provider_group = provider_groups_fixture[2]
        data = {
            "data": {
                "id": str(provider_group.id),
                "type": "provider-groups",
                "attributes": {
                    "name": "",  # Invalid name
                },
            }
        }
        response = authenticated_client.patch(
            reverse("providergroup-detail", kwargs={"pk": provider_group.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]
        assert errors[0]["source"]["pointer"] == "/data/attributes/name"

    def test_provider_group_destroy(
        self, authenticated_client, provider_groups_fixture
    ):
        provider_group = provider_groups_fixture[2]
        response = authenticated_client.delete(
            reverse("providergroup-detail", kwargs={"pk": provider_group.id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert not ProviderGroup.objects.filter(id=provider_group.id).exists()

    def test_provider_group_destroy_invalid(self, authenticated_client):
        response = authenticated_client.delete(
            reverse("providergroup-detail", kwargs={"pk": "non-existent-id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_provider_group_retrieve_not_found(self, authenticated_client):
        response = authenticated_client.get(
            reverse("providergroup-detail", kwargs={"pk": "non-existent-id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_provider_group_list_filters(
        self, authenticated_client, provider_groups_fixture
    ):
        provider_group = provider_groups_fixture[0]
        response = authenticated_client.get(
            reverse("providergroup-list"), {"filter[name]": provider_group.name}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["attributes"]["name"] == provider_group.name

    def test_provider_group_list_sorting(
        self, authenticated_client, provider_groups_fixture
    ):
        response = authenticated_client.get(
            reverse("providergroup-list"), {"sort": "name"}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        names = [item["attributes"]["name"] for item in data]
        assert names == sorted(names)

    def test_provider_group_invalid_method(self, authenticated_client):
        response = authenticated_client.put(reverse("providergroup-list"))
        assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED

    def test_provider_group_create_with_relationships(
        self, authenticated_client, aws_provider_pair, roles_fixture
    ):
        provider1, provider2 = aws_provider_pair
        role1, role2, *_ = roles_fixture

        data = {
            "data": {
                "type": "provider-groups",
                "attributes": {"name": "Test Provider Group with relationships"},
                "relationships": {
                    "providers": {
                        "data": [
                            {"type": "providers", "id": str(provider1.id)},
                            {"type": "providers", "id": str(provider2.id)},
                        ]
                    },
                    "roles": {
                        "data": [
                            {"type": "roles", "id": str(role1.id)},
                            {"type": "roles", "id": str(role2.id)},
                        ]
                    },
                },
            }
        }

        response = authenticated_client.post(
            reverse("providergroup-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_201_CREATED
        response_data = response.json()["data"]
        group = ProviderGroup.objects.get(id=response_data["id"])
        assert group.name == "Test Provider Group with relationships"
        assert set(group.providers.all()) == {provider1, provider2}
        assert set(group.roles.all()) == {role1, role2}

    def test_provider_group_update_relationships(
        self,
        authenticated_client,
        provider_groups_fixture,
        gcp_provider,
        kubernetes_provider,
        roles_fixture,
    ):
        group = provider_groups_fixture[0]
        provider3 = gcp_provider
        provider4 = kubernetes_provider
        role3 = roles_fixture[2]
        role4 = roles_fixture[3]

        data = {
            "data": {
                "id": str(group.id),
                "type": "provider-groups",
                "relationships": {
                    "providers": {
                        "data": [
                            {"type": "providers", "id": str(provider3.id)},
                            {"type": "providers", "id": str(provider4.id)},
                        ]
                    },
                    "roles": {
                        "data": [
                            {"type": "roles", "id": str(role3.id)},
                            {"type": "roles", "id": str(role4.id)},
                        ]
                    },
                },
            }
        }

        response = authenticated_client.patch(
            reverse("providergroup-detail", kwargs={"pk": group.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_200_OK
        group.refresh_from_db()
        assert set(group.providers.all()) == {provider3, provider4}
        assert set(group.roles.all()) == {role3, role4}

    def test_provider_group_clear_relationships(
        self,
        authenticated_client,
        gcp_provider,
        kubernetes_provider,
        provider_groups_fixture,
    ):
        group = provider_groups_fixture[0]
        provider3 = gcp_provider
        provider4 = kubernetes_provider

        data = {
            "data": {
                "id": str(group.id),
                "type": "provider-groups",
                "relationships": {
                    "providers": {
                        "data": [
                            {"type": "providers", "id": str(provider3.id)},
                            {"type": "providers", "id": str(provider4.id)},
                        ]
                    }
                },
            }
        }

        response = authenticated_client.patch(
            reverse("providergroup-detail", kwargs={"pk": group.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_200_OK

        data = {
            "data": {
                "id": str(group.id),
                "type": "provider-groups",
                "relationships": {
                    "providers": {"data": []},  # Removing all providers
                    "roles": {"data": []},  # Removing all roles
                },
            }
        }

        response = authenticated_client.patch(
            reverse("providergroup-detail", kwargs={"pk": group.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_200_OK
        group.refresh_from_db()
        assert group.providers.count() == 0
        assert group.roles.count() == 0

    def test_provider_group_create_with_invalid_relationships(
        self, authenticated_client
    ):
        invalid_provider_id = "non-existent-id"
        data = {
            "data": {
                "type": "provider-groups",
                "attributes": {"name": "Invalid relationships test"},
                "relationships": {
                    "providers": {
                        "data": [{"type": "providers", "id": invalid_provider_id}]
                    }
                },
            }
        }

        response = authenticated_client.post(
            reverse("providergroup-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code in [status.HTTP_400_BAD_REQUEST]


@pytest.mark.django_db
class TestProviderSecretViewSet:
    @staticmethod
    def _oraclecloud_secret(**overrides):
        secret = {
            "user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
            "fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
            "key_content": "test-key-content",
            "tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
        }
        secret.update(overrides)
        return secret

    def _create_oraclecloud_secret(
        self,
        authenticated_client,
        oraclecloud_provider,
        secret,
        name="OCI Secret",
    ):
        data = {
            "data": {
                "type": "provider-secrets",
                "attributes": {
                    "name": name,
                    "secret_type": ProviderSecret.TypeChoices.STATIC,
                    "secret": secret,
                },
                "relationships": {
                    "provider": {
                        "data": {
                            "type": "providers",
                            "id": str(oraclecloud_provider.id),
                        }
                    }
                },
            }
        }
        return authenticated_client.post(
            reverse("providersecret-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )

    def test_provider_secrets_list(self, authenticated_client, provider_secret_fixture):
        response = authenticated_client.get(reverse("providersecret-list"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == min(
            settings.REST_FRAMEWORK["PAGE_SIZE"], len(provider_secret_fixture)
        )
        assert response.json()["meta"]["pagination"]["count"] == len(
            provider_secret_fixture
        )

    def test_provider_secrets_retrieve(
        self, authenticated_client, provider_secret_fixture
    ):
        provider_secret1, *_ = provider_secret_fixture
        response = authenticated_client.get(
            reverse("providersecret-detail", kwargs={"pk": provider_secret1.id}),
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"]["attributes"]["name"] == provider_secret1.name
        assert (
            response.json()["data"]["attributes"]["secret_type"]
            == provider_secret1.secret_type
        )

    def test_provider_secrets_invalid_retrieve(self, authenticated_client):
        response = authenticated_client.get(
            reverse(
                "providersecret-detail",
                kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
            )
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @pytest.mark.parametrize(
        "provider_type, secret_type, secret_data",
        [
            # AWS with STATIC secret
            (
                Provider.ProviderChoices.AWS.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "aws_access_key_id": "value",
                    "aws_secret_access_key": "value",
                    "aws_session_token": "value",
                },
            ),
            # AWS with ROLE secret
            (
                Provider.ProviderChoices.AWS.value,
                ProviderSecret.TypeChoices.ROLE,
                {
                    "role_arn": "arn:aws:iam::123456789012:role/example-role",
                    # Optional fields
                    "external_id": "external-id",
                    "role_session_name": "session-name",
                    "session_duration": 3600,
                    "aws_access_key_id": "value",
                    "aws_secret_access_key": "value",
                    "aws_session_token": "value",
                },
            ),
            # Azure with STATIC secret
            (
                Provider.ProviderChoices.AZURE.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "client_id": "client-id",
                    "client_secret": "client-secret",
                    "tenant_id": "tenant-id",
                },
            ),
            # GCP with STATIC secret
            (
                Provider.ProviderChoices.GCP.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "client_id": "client-id",
                    "client_secret": "client-secret",
                    "refresh_token": "refresh-token",
                },
            ),
            # GCP with Service Account Key secret
            (
                Provider.ProviderChoices.GCP.value,
                ProviderSecret.TypeChoices.SERVICE_ACCOUNT,
                {
                    "service_account_key": {
                        "type": "service_account",
                        "project_id": "project-id",
                        "private_key_id": "private-key-id",
                        "private_key": "private-key",
                        "client_email": "client-email",
                        "client_id": "client-id",
                        "auth_uri": "auth-uri",
                        "token_uri": "token-uri",
                        "auth_provider_x509_cert_url": "auth-provider-x509-cert-url",
                        "client_x509_cert_url": "client-x509-cert-url",
                        "universe_domain": "universe-domain",
                    },
                },
            ),
            # Kubernetes with STATIC secret
            (
                Provider.ProviderChoices.KUBERNETES.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "kubeconfig_content": """
apiVersion: v1
kind: Config
clusters:
  - name: test-cluster
    cluster:
      server: https://kubernetes.example.test
users:
  - name: test-user
    user:
      token: test-token
contexts:
  - name: test-context
    context:
      cluster: test-cluster
      user: test-user
current-context: test-context
""",
                },
            ),
            # M365 client secret credentials
            (
                Provider.ProviderChoices.M365.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "client_id": "client-id",
                    "client_secret": "client-secret",
                    "tenant_id": "tenant-id",
                    "user": "test@domain.com",
                    "password": "supersecret",
                },
            ),
            # M365 certificate credentials (valid base64)
            (
                Provider.ProviderChoices.M365.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "client_id": "client-id",
                    "tenant_id": "tenant-id",
                    "certificate_content": "VGVzdCBjZXJ0aWZpY2F0ZSBjb250ZW50",
                    "user": "test@domain.com",
                    "password": "supersecret",
                },
            ),
            # OCI with API key credentials (with key_content)
            (
                Provider.ProviderChoices.ORACLECLOUD.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
                    "fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
                    "key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-key-content\n-----END RSA PRIVATE KEY-----",
                    "tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
                },
            ),
            # OCI with API key credentials (with key_file)
            (
                Provider.ProviderChoices.ORACLECLOUD.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
                    "fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
                    "key_file": "/path/to/oci_api_key.pem",
                    "tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
                },
            ),
            # OCI with API key credentials (with passphrase)
            (
                Provider.ProviderChoices.ORACLECLOUD.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
                    "fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
                    "key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-encrypted-key\n-----END RSA PRIVATE KEY-----",
                    "tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
                    "pass_phrase": "my-secure-passphrase",
                },
            ),
            # MongoDB Atlas credentials
            (
                Provider.ProviderChoices.MONGODBATLAS.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "atlas_public_key": "public-key",
                    "atlas_private_key": "private-key",
                },
            ),
            # Alibaba Cloud credentials (with access key only)
            (
                Provider.ProviderChoices.ALIBABACLOUD.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "access_key_id": "LTAI5t1234567890abcdef",
                    "access_key_secret": "my-secret-access-key",
                },
            ),
            # Alibaba Cloud credentials (with STS security token)
            (
                Provider.ProviderChoices.ALIBABACLOUD.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "access_key_id": "LTAI5t1234567890abcdef",
                    "access_key_secret": "my-secret-access-key",
                    "security_token": "my-security-token-for-sts",
                },
            ),
            # Alibaba Cloud RAM Role Assumption (minimal required fields)
            (
                Provider.ProviderChoices.ALIBABACLOUD.value,
                ProviderSecret.TypeChoices.ROLE,
                {
                    "role_arn": "acs:ram::1234567890123456:role/ProwlerRole",
                    "access_key_id": "LTAI5t1234567890abcdef",
                    "access_key_secret": "my-secret-access-key",
                },
            ),
            # Alibaba Cloud RAM Role Assumption (with optional role_session_name)
            (
                Provider.ProviderChoices.ALIBABACLOUD.value,
                ProviderSecret.TypeChoices.ROLE,
                {
                    "role_arn": "acs:ram::1234567890123456:role/ProwlerRole",
                    "access_key_id": "LTAI5t1234567890abcdef",
                    "access_key_secret": "my-secret-access-key",
                    "role_session_name": "ProwlerAuditSession",
                },
            ),
            # Cloudflare with API Token
            (
                Provider.ProviderChoices.CLOUDFLARE.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "api_token": "fake-cloudflare-api-token-for-testing",
                },
            ),
            # Cloudflare with API Key + Email
            (
                Provider.ProviderChoices.CLOUDFLARE.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "api_key": "fake-cloudflare-api-key-for-testing",
                    "api_email": "user@example.com",
                },
            ),
            # OpenStack with clouds.yaml content
            (
                Provider.ProviderChoices.OPENSTACK.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "clouds_yaml_content": "clouds:\n  mycloud:\n    auth:\n      auth_url: https://openstack.example.com:5000/v3\n",
                    "clouds_yaml_cloud": "mycloud",
                },
            ),
            # Google Workspace with service account credentials
            (
                Provider.ProviderChoices.GOOGLEWORKSPACE.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "credentials_content": '{"type": "service_account", "project_id": "test-project", "private_key_id": "key123", "private_key": "-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----\\n", "client_email": "test@test-project.iam.gserviceaccount.com", "client_id": "123456789"}',
                    "delegated_user": "admin@example.com",
                },
            ),
            # Vercel with API Token
            (
                Provider.ProviderChoices.VERCEL.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "api_token": "fake-vercel-api-token-for-testing",
                },
            ),
            # Image registry credentials
            (
                Provider.ProviderChoices.IMAGE.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "registry_username": "user",
                    "registry_password": "pass",
                },
            ),
            # Okta with inline private key credentials
            (
                Provider.ProviderChoices.OKTA.value,
                ProviderSecret.TypeChoices.STATIC,
                {
                    "okta_client_id": "0oa123456789abcdef",
                    "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
                    "okta_scopes": [
                        "okta.policies.read",
                        "okta.groups.read",
                    ],
                },
            ),
        ],
    )
    def test_provider_secrets_create_valid(
        self,
        authenticated_client,
        provider_factory,
        provider_type,
        secret_type,
        secret_data,
    ):
        provider = provider_factory(provider_type)

        data = {
            "data": {
                "type": "provider-secrets",
                "attributes": {
                    "name": "My Secret",
                    "secret_type": secret_type,
                    "secret": secret_data,
                },
                "relationships": {
                    "provider": {"data": {"type": "providers", "id": str(provider.id)}}
                },
            }
        }
        response = authenticated_client.post(
            reverse("providersecret-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert ProviderSecret.objects.count() == 1
        provider_secret = ProviderSecret.objects.first()
        assert provider_secret.name == data["data"]["attributes"]["name"]
        assert provider_secret.secret_type == data["data"]["attributes"]["secret_type"]
        assert (
            str(provider_secret.provider.id)
            == data["data"]["relationships"]["provider"]["data"]["id"]
        )

    def test_provider_secrets_create_oraclecloud_without_region_stores_no_region(
        self,
        authenticated_client,
        oraclecloud_provider,
    ):
        response = self._create_oraclecloud_secret(
            authenticated_client,
            oraclecloud_provider,
            self._oraclecloud_secret(),
        )

        assert response.status_code == status.HTTP_201_CREATED
        provider_secret = ProviderSecret.objects.get()
        assert "region" not in provider_secret.secret

    def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
        self,
        authenticated_client,
        oraclecloud_provider,
    ):
        response = self._create_oraclecloud_secret(
            authenticated_client,
            oraclecloud_provider,
            self._oraclecloud_secret(
                key_content="  test-key-content  ", region=" us-ashburn-1 "
            ),
        )

        assert response.status_code == status.HTTP_201_CREATED
        provider_secret = ProviderSecret.objects.get()
        assert provider_secret.secret["key_content"] == "test-key-content"
        assert "region" not in provider_secret.secret

    def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
        self,
        authenticated_client,
        oraclecloud_provider,
    ):
        create_response = self._create_oraclecloud_secret(
            authenticated_client,
            oraclecloud_provider,
            self._oraclecloud_secret(),
        )
        provider_secret = ProviderSecret.objects.get(
            id=create_response.json()["data"]["id"]
        )
        data = {
            "data": {
                "type": "provider-secrets",
                "id": str(provider_secret.id),
                "attributes": {"secret": self._oraclecloud_secret()},
            }
        }

        response = authenticated_client.patch(
            reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_200_OK
        provider_secret.refresh_from_db()
        assert "region" not in provider_secret.secret

    def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
        self,
        authenticated_client,
        oraclecloud_provider,
    ):
        create_response = self._create_oraclecloud_secret(
            authenticated_client,
            oraclecloud_provider,
            self._oraclecloud_secret(),
        )
        provider_secret = ProviderSecret.objects.get(
            id=create_response.json()["data"]["id"]
        )
        data = {
            "data": {
                "type": "provider-secrets",
                "id": str(provider_secret.id),
                "attributes": {
                    "secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
                },
            }
        }

        response = authenticated_client.patch(
            reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_200_OK
        provider_secret.refresh_from_db()
        assert "region" not in provider_secret.secret

    @pytest.mark.parametrize(
        "attributes, error_code, error_pointer",
        (
            [
                (
                    {
                        "name": "testing",
                        "secret_type": "static",
                        "secret": {"invalid": "test"},
                    },
                    "required",
                    "secret/aws_access_key_id",
                ),
                (
                    {
                        "name": "testing",
                        "secret_type": "invalid",
                        "secret": {"invalid": "test"},
                    },
                    "invalid_choice",
                    "secret_type",
                ),
                (
                    {
                        "name": "a" * 151,
                        "secret_type": "static",
                        "secret": {
                            "aws_access_key_id": "value",
                            "aws_secret_access_key": "value",
                            "aws_session_token": "value",
                        },
                    },
                    "max_length",
                    "name",
                ),
            ]
        ),
    )
    def test_provider_secrets_invalid_create(
        self,
        aws_provider,
        authenticated_client,
        attributes,
        error_code,
        error_pointer,
    ):
        provider = aws_provider
        data = {
            "data": {
                "type": "provider-secrets",
                "attributes": attributes,
                "relationships": {
                    "provider": {"data": {"type": "providers", "id": str(provider.id)}}
                },
            }
        }
        response = authenticated_client.post(
            reverse("providersecret-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == error_code
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == f"/data/attributes/{error_pointer}"
        )

    def test_provider_secrets_invalid_create_okta_missing_private_key(
        self,
        okta_provider,
        authenticated_client,
    ):
        data = {
            "data": {
                "type": "provider-secrets",
                "attributes": {
                    "name": "Okta Secret",
                    "secret_type": ProviderSecret.TypeChoices.STATIC,
                    "secret": {
                        "okta_client_id": "0oa123456789abcdef",
                    },
                },
                "relationships": {
                    "provider": {
                        "data": {"type": "providers", "id": str(okta_provider.id)}
                    }
                },
            }
        }

        response = authenticated_client.post(
            reverse("providersecret-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "required"
        assert response.json()["errors"][0]["source"]["pointer"] == (
            "/data/attributes/secret/okta_private_key"
        )

    def test_provider_secrets_partial_update(
        self, authenticated_client, provider_secret_fixture
    ):
        provider_secret, *_ = provider_secret_fixture
        data = {
            "data": {
                "type": "provider-secrets",
                "id": str(provider_secret.id),
                "attributes": {
                    "name": "new_name",
                    "secret": {
                        "aws_access_key_id": "new_value",
                        "aws_secret_access_key": "new_value",
                        "aws_session_token": "new_value",
                    },
                },
                "relationships": {
                    "provider": {
                        "data": {
                            "type": "providers",
                            "id": str(provider_secret.provider.id),
                        }
                    }
                },
            }
        }
        response = authenticated_client.patch(
            reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        provider_secret.refresh_from_db()
        assert provider_secret.name == "new_name"
        for value in provider_secret.secret.values():
            assert value == "new_value"

    def test_provider_secrets_partial_update_invalid_content_type(
        self, authenticated_client, provider_secret_fixture
    ):
        provider_secret, *_ = provider_secret_fixture
        response = authenticated_client.patch(
            reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
            data={},
        )
        assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE

    def test_provider_secrets_partial_update_invalid_content(
        self, authenticated_client, provider_secret_fixture
    ):
        provider_secret, *_ = provider_secret_fixture
        data = {
            "data": {
                "type": "provider-secrets",
                "id": str(provider_secret.id),
                "attributes": {"invalid_secret": "value"},
                "relationships": {
                    "provider": {
                        "data": {
                            "type": "providers",
                            "id": str(provider_secret.provider.id),
                        }
                    }
                },
            }
        }
        response = authenticated_client.patch(
            reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_provider_secrets_delete(
        self,
        authenticated_client,
        provider_secret_fixture,
    ):
        provider_secret, *_ = provider_secret_fixture
        response = authenticated_client.delete(
            reverse("providersecret-detail", kwargs={"pk": provider_secret.id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT

    def test_provider_secrets_delete_invalid(self, authenticated_client):
        response = authenticated_client.delete(
            reverse(
                "providersecret-detail",
                kwargs={"pk": "e67d0283-440f-48d1-b5f8-38d0763474f4"},
            )
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_provider_secrets_filter_name(
        self,
        authenticated_client,
        provider_secret_fixture,
    ):
        response = authenticated_client.get(
            reverse("providersecret-list"),
            {"filter[name]": "aws_testing_1"},
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1

    def test_provider_secrets_filter_name_icontains(
        self,
        authenticated_client,
        provider_secret_fixture,
        provider_factory,
    ):
        provider = provider_factory(
            Provider.ProviderChoices.AWS.value, alias="aws_testing_extra"
        )
        ProviderSecret.objects.create(
            tenant_id=provider.tenant_id,
            provider=provider,
            secret_type=ProviderSecret.TypeChoices.STATIC,
            secret={"key": "value"},
            name=provider.alias,
        )

        response = authenticated_client.get(
            reverse("providersecret-list"),
            {"filter[name.icontains]": "aws"},
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    @pytest.mark.parametrize(
        "filter_name",
        (
            [
                "invalid",
            ]
        ),
    )
    def test_provider_secrets_filters_invalid(self, authenticated_client, filter_name):
        response = authenticated_client.get(
            reverse("providersecret-list"),
            {f"filter[{filter_name}]": "whatever"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "sort_field",
        (
            [
                "name",
                "inserted_at",
                "updated_at",
            ]
        ),
    )
    def test_provider_secrets_sort(self, authenticated_client, sort_field):
        response = authenticated_client.get(
            reverse("providersecret-list"), {"sort": sort_field}
        )
        assert response.status_code == status.HTTP_200_OK

    def test_provider_secrets_sort_invalid(self, authenticated_client):
        response = authenticated_client.get(
            reverse("providersecret-list"), {"sort": "invalid"}
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_provider_secrets_partial_update_with_secret_type(
        self, authenticated_client, provider_secret_fixture
    ):
        provider_secret, *_ = provider_secret_fixture
        data = {
            "data": {
                "type": "provider-secrets",
                "id": str(provider_secret.id),
                "attributes": {
                    "name": "new_name",
                    "secret": {
                        "service_account_key": {},
                    },
                    "secret_type": "service_account",
                },
                "relationships": {
                    "provider": {
                        "data": {
                            "type": "providers",
                            "id": str(provider_secret.provider.id),
                        }
                    }
                },
            }
        }
        response = authenticated_client.patch(
            reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        provider_secret.refresh_from_db()
        assert provider_secret.name == "new_name"
        assert provider_secret.secret == {"service_account_key": {}}

    def test_provider_secrets_partial_update_with_invalid_secret_type(
        self, authenticated_client, provider_secret_fixture
    ):
        provider_secret, *_ = provider_secret_fixture
        data = {
            "data": {
                "type": "provider-secrets",
                "id": str(provider_secret.id),
                "attributes": {
                    "name": "new_name",
                    "secret": {
                        "service_account_key": {},
                    },
                    "secret_type": "static",
                },
                "relationships": {
                    "provider": {
                        "data": {
                            "type": "providers",
                            "id": str(provider_secret.provider.id),
                        }
                    }
                },
            }
        }
        response = authenticated_client.patch(
            reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_provider_secrets_partial_update_without_secret_type_but_different(
        self, authenticated_client, provider_secret_fixture
    ):
        provider_secret, *_ = provider_secret_fixture
        data = {
            "data": {
                "type": "provider-secrets",
                "id": str(provider_secret.id),
                "attributes": {
                    "name": "new_name",
                    "secret": {
                        "service_account_key": {},
                    },
                },
                "relationships": {
                    "provider": {
                        "data": {
                            "type": "providers",
                            "id": str(provider_secret.provider.id),
                        }
                    }
                },
            }
        }
        response = authenticated_client.patch(
            reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_m365_provider_secrets_invalid_certificate_base64(
        self, authenticated_client, m365_provider
    ):
        """Test M365 provider secret creation with invalid base64 certificate content"""
        data = {
            "data": {
                "type": "provider-secrets",
                "attributes": {
                    "name": "M365 Certificate Invalid Base64",
                    "secret_type": "static",
                    "secret": {
                        "client_id": "client-id",
                        "tenant_id": "tenant-id",
                        "certificate_content": "invalid-base64-content!@#$%",
                        "user": "test@domain.com",
                        "password": "supersecret",
                    },
                },
                "relationships": {
                    "provider": {
                        "data": {"type": "providers", "id": str(m365_provider.id)}
                    }
                },
            }
        }
        response = authenticated_client.post(
            reverse("providersecret-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "certificate content is not valid base64 encoded data" in str(
            response.json()
        )


@pytest.mark.django_db
class TestScanViewSet:
    def test_scans_list(self, authenticated_client, scans_fixture):
        response = authenticated_client.get(reverse("scan-list"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(scans_fixture)

    def test_scans_retrieve(self, authenticated_client, scans_fixture):
        scan1, *_ = scans_fixture
        response = authenticated_client.get(
            reverse("scan-detail", kwargs={"pk": scan1.id})
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"]["attributes"]["name"] == scan1.name
        assert response.json()["data"]["relationships"]["provider"]["data"][
            "id"
        ] == str(scan1.provider.id)

    def test_scans_invalid_retrieve(self, authenticated_client):
        response = authenticated_client.get(
            reverse("scan-detail", kwargs={"pk": "random_id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @pytest.mark.parametrize(
        "scan_json_payload, _expected_scanner_args",
        [
            # Case 1: No scanner_args in payload (should use provider's scanner_args)
            (
                {
                    "data": {
                        "type": "scans",
                        "attributes": {
                            "name": "New Scan",
                        },
                        "relationships": {
                            "provider": {
                                "data": {"type": "providers", "id": "provider-id-1"}
                            }
                        },
                    }
                },
                {"key1": "value1", "key2": {"key21": "value21"}},
            ),
            (
                {
                    "data": {
                        "type": "scans",
                        "attributes": {
                            "name": "New Scan",
                            "scanner_args": {
                                "key2": {"key21": "test21"},
                                "key3": "test3",
                            },
                        },
                        "relationships": {
                            "provider": {
                                "data": {"type": "providers", "id": "provider-id-1"}
                            }
                        },
                    }
                },
                {"key1": "value1", "key2": {"key21": "test21"}, "key3": "test3"},
            ),
        ],
    )
    @patch("api.v1.views.enqueue_scan_execution_on_commit")
    def test_scans_create_valid(
        self,
        mock_enqueue_scan_execution,
        authenticated_client,
        scan_json_payload,
        _expected_scanner_args,
        okta_provider,
    ):
        provider5 = okta_provider
        # Provider5 has these scanner_args
        # scanner_args={"key1": "value1", "key2": {"key21": "value21"}}

        # scanner_args will be disabled in the first release
        scan_json_payload["data"]["attributes"].pop("scanner_args", None)

        scan_json_payload["data"]["relationships"]["provider"]["data"]["id"] = str(
            provider5.id
        )

        response = authenticated_client.post(
            reverse("scan-list"),
            data=scan_json_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )

        assert response.status_code == status.HTTP_202_ACCEPTED
        assert Scan.objects.count() == 1

        scan = Scan.objects.get()
        assert scan.name == scan_json_payload["data"]["attributes"]["name"]
        assert scan.provider == provider5
        assert scan.trigger == Scan.TriggerChoices.MANUAL
        mock_enqueue_scan_execution.assert_called_once()
        # assert scan.scanner_args == expected_scanner_args

    @patch("tasks.tasks.perform_scan_task.apply_async")
    def test_scans_create_queues_scan_when_provider_has_active_scan(
        self,
        mock_perform_scan_task,
        authenticated_client,
        aws_provider,
        tenants_fixture,
        django_capture_on_commit_callbacks,
    ):
        tenant, *_ = tenants_fixture
        provider = aws_provider
        task_result = TaskResult.objects.create(
            task_id=str(uuid4()),
            task_name="scan-perform",
            status=states.PENDING,
        )
        prowler_task = Task.objects.create(
            id=task_result.task_id,
            tenant_id=tenant.id,
            task_runner_task=task_result,
        )
        Scan.objects.create(
            name="Active scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.AVAILABLE,
            tenant_id=tenant.id,
            task=prowler_task,
        )

        with django_capture_on_commit_callbacks(execute=True):
            response = authenticated_client.post(
                reverse("scan-list"),
                data={
                    "data": {
                        "type": "scans",
                        "attributes": {"name": "Duplicate Scan"},
                        "relationships": {
                            "provider": {
                                "data": {"type": "providers", "id": str(provider.id)}
                            }
                        },
                    }
                },
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_202_ACCEPTED
        assert response.json()["data"]["id"] != str(prowler_task.id)
        assert Scan.objects.count() == 2
        queued_scan = Scan.objects.exclude(task=prowler_task).get()
        assert queued_scan.trigger == Scan.TriggerChoices.MANUAL
        assert queued_scan.state == StateChoices.AVAILABLE
        assert queued_scan.task.task_runner_task.status == "QUEUED"
        mock_perform_scan_task.assert_not_called()

    @patch("tasks.tasks.perform_scan_task.apply_async")
    def test_scans_create_queues_scan_when_scheduled_scan_is_claimed(
        self,
        mock_perform_scan_task,
        authenticated_client,
        aws_provider,
        tenants_fixture,
        django_capture_on_commit_callbacks,
    ):
        tenant, *_ = tenants_fixture
        provider = aws_provider
        task_result = TaskResult.objects.create(
            task_id=str(uuid4()),
            task_name="scan-perform-scheduled",
            status=states.STARTED,
        )
        prowler_task = Task.objects.create(
            id=task_result.task_id,
            tenant_id=tenant.id,
            task_runner_task=task_result,
        )
        Scan.objects.create(
            name="Claimed scheduled scan",
            provider=provider,
            trigger=Scan.TriggerChoices.SCHEDULED,
            state=StateChoices.SCHEDULED,
            tenant_id=tenant.id,
            task=prowler_task,
        )

        with django_capture_on_commit_callbacks(execute=True):
            response = authenticated_client.post(
                reverse("scan-list"),
                data={
                    "data": {
                        "type": "scans",
                        "attributes": {"name": "Manual Scan"},
                        "relationships": {
                            "provider": {
                                "data": {"type": "providers", "id": str(provider.id)}
                            }
                        },
                    }
                },
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_202_ACCEPTED
        assert response.json()["data"]["id"] != str(prowler_task.id)
        assert Scan.objects.count() == 2
        queued_scan = Scan.objects.exclude(task=prowler_task).get()
        assert queued_scan.trigger == Scan.TriggerChoices.MANUAL
        assert queued_scan.state == StateChoices.AVAILABLE
        assert queued_scan.task.task_runner_task.status == "QUEUED"
        mock_perform_scan_task.assert_not_called()

    @pytest.mark.parametrize(
        "scan_json_payload, error_code",
        [
            (
                {
                    "data": {
                        "type": "scans",
                        "attributes": {
                            "name": "a",
                            "trigger": Scan.TriggerChoices.MANUAL,
                        },
                        "relationships": {
                            "provider": {
                                "data": {"type": "providers", "id": "provider-id-1"}
                            }
                        },
                    }
                },
                "min_length",
            ),
        ],
    )
    def test_scans_invalid_create(
        self,
        authenticated_client,
        scan_json_payload,
        aws_provider,
        error_code,
    ):
        provider1 = aws_provider
        scan_json_payload["data"]["relationships"]["provider"]["data"]["id"] = str(
            provider1.id
        )
        response = authenticated_client.post(
            reverse("scan-list"),
            data=scan_json_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == error_code
        assert (
            response.json()["errors"][0]["source"]["pointer"] == "/data/attributes/name"
        )

    def test_scans_partial_update(self, authenticated_client, scans_fixture):
        scan1, *_ = scans_fixture
        new_name = "Updated Scan Name"
        payload = {
            "data": {
                "type": "scans",
                "id": scan1.id,
                "attributes": {"name": new_name},
            },
        }
        response = authenticated_client.patch(
            reverse("scan-detail", kwargs={"pk": scan1.id}),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_200_OK
        scan1.refresh_from_db()
        assert scan1.name == new_name

    def test_scans_partial_update_invalid_content_type(
        self, authenticated_client, scans_fixture
    ):
        scan1, *_ = scans_fixture
        response = authenticated_client.patch(
            reverse("scan-detail", kwargs={"pk": scan1.id}),
            data={},
        )
        assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE

    def test_scans_partial_update_invalid_content(
        self, authenticated_client, scans_fixture
    ):
        scan1, *_ = scans_fixture
        new_name = "Updated Scan Name"
        payload = {"name": new_name}
        response = authenticated_client.patch(
            reverse("scan-detail", kwargs={"pk": scan1.id}),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "filter_name, filter_value, expected_count",
        (
            [
                ("provider_type", "aws", 3),
                ("provider_type.in", "gcp,azure", 0),
                ("provider_uid", "123456789012", 1),
                ("provider_uid.icontains", "1", 3),
                ("provider_uid.in", "123456789012,123456789013", 3),
                ("provider_alias", "aws_testing_1", 1),
                ("provider_alias.icontains", "aws", 3),
                ("provider_alias.in", "aws_testing_1,aws_testing_2", 3),
                ("name", "Scan 1", 1),
                ("name.icontains", "Scan", 3),
                ("started_at", "2024-01-02", 1),
                ("started_at.gte", "2024-01-01", 3),
                ("started_at.lte", "2024-01-01", 0),
                ("trigger", Scan.TriggerChoices.MANUAL, 1),
                ("state", StateChoices.AVAILABLE, 1),
                ("state", StateChoices.FAILED, 0),
                ("state.in", f"{StateChoices.FAILED},{StateChoices.AVAILABLE}", 1),
                ("trigger", Scan.TriggerChoices.MANUAL, 1),
            ]
        ),
    )
    def test_scans_filters(
        self,
        authenticated_client,
        scans_fixture,
        filter_name,
        filter_value,
        expected_count,
    ):
        response = authenticated_client.get(
            reverse("scan-list"),
            {f"filter[{filter_name}]": filter_value},
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == expected_count

    def test_scans_filter_provider_groups(
        self,
        authenticated_client,
        tenants_fixture,
        scans_fixture,
        provider_groups_fixture,
    ):
        tenant = tenants_fixture[0]
        scan1, scan2, *_ = scans_fixture
        group1, group2, *_ = provider_groups_fixture
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=scan1.provider, provider_group=group1
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=scan1.provider, provider_group=group2
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=scan2.provider, provider_group=group2
        )

        response = authenticated_client.get(
            reverse("scan-list"), {"filter[provider_groups]": str(group1.id)}
        )
        assert response.status_code == status.HTTP_200_OK
        assert {item["id"] for item in response.json()["data"]} == {str(scan1.id)}

        response = authenticated_client.get(
            reverse("scan-list"),
            {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
        )
        assert response.status_code == status.HTTP_200_OK
        scan_ids = {item["id"] for item in response.json()["data"]}
        assert scan_ids == {str(scan1.id), str(scan2.id), str(scans_fixture[2].id)}
        assert len(response.json()["data"]) == 3

    @pytest.mark.parametrize(
        "filter_name",
        [
            "provider",  # Valid filter, invalid value
            "invalid",
        ],
    )
    def test_scans_filters_invalid(self, authenticated_client, filter_name):
        response = authenticated_client.get(
            reverse("scan-list"),
            {f"filter[{filter_name}]": "invalid_value"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_scan_filter_by_provider_id_exact(
        self, authenticated_client, scans_fixture
    ):
        response = authenticated_client.get(
            reverse("scan-list"),
            {"filter[provider]": scans_fixture[0].provider.id},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1

    def test_scan_filter_by_provider_id_in(self, authenticated_client, scans_fixture):
        response = authenticated_client.get(
            reverse("scan-list"),
            {
                "filter[provider.in]": f"{scans_fixture[0].provider.id},{scans_fixture[1].provider.id}",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 3

    def test_scan_filter_by_id_exact(self, authenticated_client, scans_fixture):
        scan1, *_ = scans_fixture
        response = authenticated_client.get(
            reverse("scan-list"),
            {"filter[id]": str(scan1.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == str(scan1.id)

    def test_scan_filter_by_id_in(self, authenticated_client, scans_fixture):
        scan1, scan2, *_ = scans_fixture
        response = authenticated_client.get(
            reverse("scan-list"),
            {"filter[id.in]": f"{scan1.id},{scan2.id}"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        returned_ids = {item["id"] for item in data}
        assert returned_ids == {str(scan1.id), str(scan2.id)}

    def test_scans_filter_state_failed(self, authenticated_client, scans_fixture):
        """Ensure state filter matches only FAILED scans."""
        scan1, *_ = scans_fixture
        failed_scan = Scan.objects.create(
            name="Scan Failed",
            provider=scan1.provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.FAILED,
            tenant_id=scan1.tenant_id,
        )
        response = authenticated_client.get(
            reverse("scan-list"),
            {"filter[state]": StateChoices.FAILED},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == str(failed_scan.id)

    def test_scans_filter_provider_alias_exact(
        self, authenticated_client, scans_fixture
    ):
        """Ensure provider_alias filter returns all scans for that provider."""
        scan1, *_ = scans_fixture
        response = authenticated_client.get(
            reverse("scan-list"),
            {"filter[provider_alias]": scan1.provider.alias},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["relationships"]["provider"]["data"]["id"] == str(
            scan1.provider.id
        )

    @pytest.mark.parametrize(
        "sort_field",
        [
            "name",
            "trigger",
            "inserted_at",
            "updated_at",
        ],
    )
    def test_scans_sort(self, authenticated_client, sort_field):
        response = authenticated_client.get(reverse("scan-list"), {"sort": sort_field})
        assert response.status_code == status.HTTP_200_OK

    def test_scans_sort_invalid(self, authenticated_client):
        response = authenticated_client.get(reverse("scan-list"), {"sort": "invalid"})
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_report_executing(self, authenticated_client, scans_fixture):
        """
        When the scan is still executing (state == EXECUTING), the view should return
        the task data with HTTP 202 and a Content-Location header.
        """
        scan = scans_fixture[0]
        scan.state = StateChoices.EXECUTING
        scan.save()

        task = Task.objects.create(tenant_id=scan.tenant_id)
        dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING}

        scan.task = task
        scan.save()

        with patch(
            "api.v1.views.TaskSerializer",
            return_value=type("DummySerializer", (), {"data": dummy_task_data}),
        ):
            url = reverse("scan-report", kwargs={"pk": scan.id})
            response = authenticated_client.get(url)
            assert response.status_code == status.HTTP_202_ACCEPTED
            assert "Content-Location" in response
            assert dummy_task_data["id"] in response["Content-Location"]

    def test_report_celery_task_executing(self, authenticated_client, scans_fixture):
        """
        When the scan is not executing but a related celery task exists and is running,
        the view should return that task data with HTTP 202.
        """
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED
        scan.output_location = "dummy"
        scan.save()

        task_result = TaskResult.objects.create(
            task_id=str(uuid4()),
            task_name="scan-report",
            task_kwargs={"scan_id": str(scan.id)},
        )
        task = Task.objects.create(
            tenant_id=scan.tenant_id,
            task_runner_task=task_result,
        )
        dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING}

        with patch(
            "api.v1.views.TaskSerializer",
            return_value=type("DummySerializer", (), {"data": dummy_task_data}),
        ):
            url = reverse("scan-report", kwargs={"pk": scan.id})
            response = authenticated_client.get(url)
            assert response.status_code == status.HTTP_202_ACCEPTED
            assert "Content-Location" in response
            assert dummy_task_data["id"] in response["Content-Location"]

    def test_report_no_output_location(self, authenticated_client, scans_fixture):
        """
        If the scan does not have an output_location, the view should return a 404.
        """
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED
        scan.output_location = ""
        scan.save()

        url = reverse("scan-report", kwargs={"pk": scan.id})
        response = authenticated_client.get(url)
        assert response.status_code == status.HTTP_404_NOT_FOUND
        assert (
            response.json()["errors"]["detail"]
            == "The scan has no reports, or the report generation task has not started yet."
        )

    def test_report_s3_no_credentials(
        self, authenticated_client, scans_fixture, monkeypatch
    ):
        """
        When output_location is an S3 URL and get_s3_client() raises a credentials exception,
        the view should return HTTP 403 with the proper error message.
        """
        scan = scans_fixture[0]
        bucket = "test-bucket"
        key = "report.zip"
        scan.output_location = f"s3://{bucket}/{key}"
        scan.state = StateChoices.COMPLETED
        scan.save()

        def fake_get_s3_client():
            raise NoCredentialsError()

        monkeypatch.setattr("api.v1.views.get_s3_client", fake_get_s3_client)

        url = reverse("scan-report", kwargs={"pk": scan.id})
        response = authenticated_client.get(url)
        assert response.status_code == status.HTTP_403_FORBIDDEN
        assert (
            response.json()["errors"]["detail"]
            == "There is a problem with credentials."
        )

    @patch("api.v1.views.ScanViewSet._get_task_status")
    @patch("api.v1.views.get_s3_client")
    @patch("api.v1.views.env.str")
    def test_threatscore_s3_wildcard(
        self,
        mock_env_str,
        mock_get_s3_client,
        mock_get_task_status,
        authenticated_client,
        scans_fixture,
    ):
        """
        When the threatscore endpoint is called with an S3 output_location,
        the view should list objects in S3 using wildcard pattern matching,
        retrieve the matching PDF file, and return it with HTTP 200 and proper headers.
        """
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED
        bucket = "test-bucket"
        zip_key = "tenant-id/scan-id/prowler-output-foo.zip"
        scan.output_location = f"s3://{bucket}/{zip_key}"
        scan.save()

        pdf_key = os.path.join(
            os.path.dirname(zip_key),
            "threatscore",
            "prowler-output-123_threatscore_report.pdf",
        )

        presigned_url = (
            "https://test-bucket.s3.amazonaws.com/"
            "tenant-id/scan-id/threatscore/prowler-output-123_threatscore_report.pdf"
            "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300"
        )
        mock_s3_client = Mock()
        mock_s3_client.list_objects_v2.return_value = {"Contents": [{"Key": pdf_key}]}
        mock_s3_client.generate_presigned_url.return_value = presigned_url

        mock_env_str.return_value = bucket
        mock_get_s3_client.return_value = mock_s3_client
        mock_get_task_status.return_value = None

        url = reverse("scan-threatscore", kwargs={"pk": scan.id})
        response = authenticated_client.get(url)

        assert response.status_code == status.HTTP_302_FOUND
        assert response["Location"] == presigned_url
        mock_s3_client.list_objects_v2.assert_called_once()
        mock_s3_client.generate_presigned_url.assert_called_once_with(
            "get_object",
            Params={
                "Bucket": bucket,
                "Key": pdf_key,
                "ResponseContentDisposition": (
                    'attachment; filename="prowler-output-123_threatscore_report.pdf"'
                ),
                "ResponseContentType": "application/pdf",
            },
            ExpiresIn=300,
        )

    def test_report_s3_success(self, authenticated_client, scans_fixture, monkeypatch):
        """
        When output_location is an S3 URL and the object exists,
        the view should return a 302 redirect to a presigned S3 URL.
        """
        scan = scans_fixture[0]
        bucket = "test-bucket"
        key = "report.zip"
        scan.output_location = f"s3://{bucket}/{key}"
        scan.state = StateChoices.COMPLETED
        scan.save()

        monkeypatch.setattr(
            "api.v1.views.env",
            type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
        )

        presigned_url = (
            "https://test-bucket.s3.amazonaws.com/report.zip"
            "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300"
        )

        class FakeS3Client:
            def head_object(self, Bucket, Key):
                assert Bucket == bucket
                assert Key == key
                return {}

            def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
                assert ClientMethod == "get_object"
                assert Params["Bucket"] == bucket
                assert Params["Key"] == key
                assert Params["ResponseContentDisposition"] == (
                    'attachment; filename="report.zip"'
                )
                assert ExpiresIn == 300
                return presigned_url

        monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())

        url = reverse("scan-report", kwargs={"pk": scan.id})
        response = authenticated_client.get(url)
        assert response.status_code == status.HTTP_302_FOUND
        assert response["Location"] == presigned_url

    def test_report_s3_success_no_local_files(
        self, authenticated_client, scans_fixture, monkeypatch
    ):
        """
        When output_location is a local path and glob.glob returns an empty list,
        the view should return HTTP 404 with detail "The scan has no reports, or the report generation task has not started yet."
        """
        scan = scans_fixture[0]
        scan.output_location = "/tmp/nonexistent_report_pattern.zip"
        scan.state = StateChoices.COMPLETED
        scan.save()
        monkeypatch.setattr("api.v1.views.glob.glob", lambda pattern: [])

        url = reverse("scan-report", kwargs={"pk": scan.id})
        response = authenticated_client.get(url)

        assert response.status_code == 404
        assert (
            response.json()["errors"]["detail"]
            == "The scan has no reports, or the report generation task has not started yet."
        )

    def test_report_local_file(self, authenticated_client, scans_fixture, monkeypatch):
        scan = scans_fixture[0]
        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            base_tmp = tmp_path / "report_local_file"
            base_tmp.mkdir(parents=True, exist_ok=True)

            file_content = b"local zip file content"
            file_path = base_tmp / "report.zip"
            file_path.write_bytes(file_content)

            scan.output_location = str(file_path)
            scan.state = StateChoices.COMPLETED
            scan.save()

            monkeypatch.setattr(
                glob,
                "glob",
                lambda pattern: [str(file_path)] if pattern == str(file_path) else [],
            )

            url = reverse("scan-report", kwargs={"pk": scan.id})
            response = authenticated_client.get(url)
            assert response.status_code == 200
            assert response.content == file_content
            content_disposition = response.get("Content-Disposition")
            assert content_disposition.startswith('attachment; filename="')
            assert f'filename="{file_path.name}"' in content_disposition

    def test_compliance_invalid_framework(self, authenticated_client, scans_fixture):
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED
        scan.output_location = "dummy"
        scan.save()

        url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "invalid"})
        resp = authenticated_client.get(url)
        assert resp.status_code == status.HTTP_404_NOT_FOUND
        assert resp.json()["errors"]["detail"] == "Compliance 'invalid' not found."

    def test_compliance_executing(
        self, authenticated_client, scans_fixture, monkeypatch
    ):
        scan = scans_fixture[0]
        scan.state = StateChoices.EXECUTING
        scan.save()
        task = Task.objects.create(tenant_id=scan.tenant_id)
        scan.task = task
        scan.save()
        dummy = {"id": str(task.id), "state": StateChoices.EXECUTING}

        monkeypatch.setattr(
            "api.v1.views.TaskSerializer",
            lambda *_args, **_kwargs: type("S", (), {"data": dummy}),
        )

        framework = get_compliance_frameworks(scan.provider.provider)[0]
        url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework})
        resp = authenticated_client.get(url)
        assert resp.status_code == status.HTTP_202_ACCEPTED
        assert "Content-Location" in resp
        assert dummy["id"] in resp["Content-Location"]

    def test_compliance_no_output(self, authenticated_client, scans_fixture):
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED
        scan.output_location = ""
        scan.save()

        framework = get_compliance_frameworks(scan.provider.provider)[0]
        url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework})
        resp = authenticated_client.get(url)
        assert resp.status_code == status.HTTP_404_NOT_FOUND
        assert (
            resp.json()["errors"]["detail"]
            == "The scan has no reports, or the report generation task has not started yet."
        )

    def test_compliance_s3_no_credentials(
        self, authenticated_client, scans_fixture, monkeypatch
    ):
        scan = scans_fixture[0]
        bucket = "bucket"
        key = "file.zip"
        scan.output_location = f"s3://{bucket}/{key}"
        scan.state = StateChoices.COMPLETED
        scan.save()

        monkeypatch.setattr(
            "api.v1.views.get_s3_client",
            lambda: (_ for _ in ()).throw(NoCredentialsError()),
        )

        framework = get_compliance_frameworks(scan.provider.provider)[0]
        url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework})
        resp = authenticated_client.get(url)
        assert resp.status_code == status.HTTP_403_FORBIDDEN
        assert resp.json()["errors"]["detail"] == "There is a problem with credentials."

    def test_compliance_s3_success(
        self, authenticated_client, scans_fixture, monkeypatch
    ):
        scan = scans_fixture[0]
        bucket = "bucket"
        prefix = "path/scan.zip"
        scan.output_location = f"s3://{bucket}/{prefix}"
        scan.state = StateChoices.COMPLETED
        scan.save()

        monkeypatch.setattr(
            "api.v1.views.env",
            type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
        )

        match_key = "path/compliance/mitre_attack_aws.csv"
        presigned_url = (
            "https://test-bucket.s3.amazonaws.com/path/compliance/mitre_attack_aws.csv"
            "?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300"
        )

        class FakeS3Client:
            def list_objects_v2(self, Bucket, Prefix):
                del Prefix
                return {"Contents": [{"Key": match_key}]}

            def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
                assert ClientMethod == "get_object"
                assert Params["Key"] == match_key
                assert Params["ResponseContentDisposition"] == (
                    'attachment; filename="mitre_attack_aws.csv"'
                )
                assert ExpiresIn == 300
                return presigned_url

        monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())

        framework = match_key.split("/")[-1].split(".")[0]
        url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework})
        resp = authenticated_client.get(url)
        assert resp.status_code == status.HTTP_302_FOUND
        assert resp["Location"] == presigned_url

    def test_compliance_s3_returns_latest_match(
        self, authenticated_client, scans_fixture, monkeypatch
    ):
        """When several files match, the most recently modified one is served."""
        scan = scans_fixture[0]
        bucket = "bucket"
        scan.output_location = f"s3://{bucket}/path/scan.zip"
        scan.state = StateChoices.COMPLETED
        scan.save()

        monkeypatch.setattr(
            "api.v1.views.env",
            type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
        )

        old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv"
        latest_key = "path/compliance/prowler-output-aws-20240202000000_cis_1.4_aws.csv"

        class FakeS3Client:
            def list_objects_v2(self, Bucket, Prefix):
                del Prefix
                return {
                    "Contents": [
                        {
                            "Key": old_key,
                            "LastModified": datetime(2024, 1, 1, tzinfo=UTC),
                        },
                        {
                            "Key": latest_key,
                            "LastModified": datetime(2024, 2, 2, tzinfo=UTC),
                        },
                    ]
                }

            def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
                assert Params["Key"] == latest_key
                return "https://test-bucket.s3.amazonaws.com/latest"

        monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())

        url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"})
        resp = authenticated_client.get(url)
        assert resp.status_code == status.HTTP_302_FOUND
        assert resp["Location"].endswith("/latest")

    def test_compliance_local_returns_latest_match(
        self, authenticated_client, scans_fixture, monkeypatch
    ):
        """The local branch serves the most recently modified matching file."""
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED

        with tempfile.TemporaryDirectory() as tmp:
            comp_dir = Path(tmp) / "reports" / "compliance"
            comp_dir.mkdir(parents=True, exist_ok=True)

            old_file = comp_dir / "prowler-output-aws-20240101000000_cis_1.4_aws.csv"
            old_file.write_bytes(b"old")
            latest_file = comp_dir / "prowler-output-aws-20240202000000_cis_1.4_aws.csv"
            latest_file.write_bytes(b"latest")
            # Make `latest_file` newer regardless of creation order.
            os.utime(old_file, (1_700_000_000, 1_700_000_000))
            os.utime(latest_file, (1_700_000_100, 1_700_000_100))

            scan.output_location = str(Path(tmp) / "reports" / "scan.zip")
            scan.save()

            monkeypatch.setattr(
                glob,
                "glob",
                lambda p: [str(old_file), str(latest_file)],
            )

            url = reverse(
                "scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}
            )
            resp = authenticated_client.get(url)
            assert resp.status_code == status.HTTP_200_OK
            assert resp.content == b"latest"
            assert resp["Content-Disposition"].endswith(
                f'filename="{latest_file.name}"'
            )

    def test_compliance_s3_not_found(
        self, authenticated_client, scans_fixture, monkeypatch
    ):
        scan = scans_fixture[0]
        bucket = "bucket"
        scan.output_location = f"s3://{bucket}/x/scan.zip"
        scan.state = StateChoices.COMPLETED
        scan.save()

        monkeypatch.setattr(
            "api.v1.views.env",
            type("env", (), {"str": lambda self, *_args, **_kwargs: "test-bucket"})(),
        )

        class FakeS3Client:
            def list_objects_v2(self, Bucket, Prefix):
                del Prefix
                return {"Contents": []}

            def get_object(self, Bucket, Key):
                return {"Body": io.BytesIO(b"ignored")}

        monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())

        url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"})
        resp = authenticated_client.get(url)
        assert resp.status_code == status.HTTP_404_NOT_FOUND
        assert (
            resp.json()["errors"]["detail"]
            == "No compliance file found for name 'cis_1.4_aws'."
        )

    def test_compliance_local_file(
        self, authenticated_client, scans_fixture, monkeypatch
    ):
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED

        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            base = tmp_path / "reports"
            comp_dir = base / "compliance"
            comp_dir.mkdir(parents=True, exist_ok=True)
            fname = comp_dir / "scan_cis.csv"
            fname.write_bytes(b"ignored")

            scan.output_location = str(base / "scan.zip")
            scan.save()

            monkeypatch.setattr(
                glob,
                "glob",
                lambda p: [str(fname)] if p.endswith("*_cis_1.4_aws.csv") else [],
            )

            url = reverse(
                "scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}
            )
            resp = authenticated_client.get(url)
            assert resp.status_code == status.HTTP_200_OK
            cd = resp["Content-Disposition"]
            assert cd.startswith('attachment; filename="')
            assert cd.endswith(f'filename="{fname.name}"')

    def test_cis_no_output(self, authenticated_client, scans_fixture):
        """CIS PDF endpoint must 404 when the scan has no output_location."""
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED
        scan.output_location = ""
        scan.save()

        url = reverse("scan-cis", kwargs={"pk": scan.id})
        resp = authenticated_client.get(url)
        assert resp.status_code == status.HTTP_404_NOT_FOUND
        assert (
            resp.json()["errors"]["detail"]
            == "The scan has no reports, or the CIS report generation task has not started yet."
        )

    def test_cis_local_file(self, authenticated_client, scans_fixture, monkeypatch):
        """CIS PDF endpoint must serve the latest generated PDF."""
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED

        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            base = tmp_path / "reports"
            cis_dir = base / "cis"
            cis_dir.mkdir(parents=True, exist_ok=True)
            fname = cis_dir / "prowler-output-aws-20260101000000_cis_report.pdf"
            fname.write_bytes(b"%PDF-1.4 fake pdf")

            scan.output_location = str(base / "scan.zip")
            scan.save()

            monkeypatch.setattr(
                glob,
                "glob",
                lambda p: [str(fname)] if p.endswith("*_cis_report.pdf") else [],
            )

            url = reverse("scan-cis", kwargs={"pk": scan.id})
            resp = authenticated_client.get(url)
            assert resp.status_code == status.HTTP_200_OK
            assert resp["Content-Type"] == "application/pdf"
            cd = resp["Content-Disposition"]
            assert cd.startswith('attachment; filename="')
            assert cd.endswith(f'filename="{fname.name}"')

    @patch("api.v1.views.TaskSerializer")
    def test__get_task_status_returns_none_if_task_not_executing(
        self, mock_task_serializer, authenticated_client, scans_fixture
    ):
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED
        scan.output_location = "dummy"
        scan.save()

        task_result = TaskResult.objects.create(
            task_id=str(uuid4()),
            task_name="scan-report",
            task_kwargs={"scan_id": str(scan.id)},
        )
        task = Task.objects.create(
            tenant_id=scan.tenant_id,
            task_runner_task=task_result,
        )
        mock_task_serializer.return_value.data = {
            "id": str(task.id),
            "state": StateChoices.COMPLETED,
        }

        url = reverse("scan-report", kwargs={"pk": scan.id})
        response = authenticated_client.get(url)

        assert response.status_code == status.HTTP_404_NOT_FOUND

    @patch("api.v1.views.TaskSerializer")
    def test__get_task_status_finds_task_using_kwargs(
        self, mock_task_serializer, authenticated_client, scans_fixture
    ):
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED
        scan.output_location = "dummy"
        scan.save()

        task_result = TaskResult.objects.create(
            task_id=str(uuid4()),
            task_name="scan-report",
            task_kwargs={"scan_id": str(scan.id)},
        )

        task = Task.objects.create(
            tenant_id=scan.tenant_id,
            task_runner_task=task_result,
        )

        mock_task_serializer.return_value.data = {
            "id": str(task.id),
            "state": StateChoices.EXECUTING,
        }

        url = reverse("scan-report", kwargs={"pk": scan.id})
        response = authenticated_client.get(url)

        assert response.status_code == status.HTTP_202_ACCEPTED
        assert response.data["id"] == str(task.id)

    @patch("api.v1.views.TaskSerializer")
    def test__get_task_status_returns_latest_task(
        self, mock_task_serializer, authenticated_client, scans_fixture
    ):
        """With several scan-report tasks for the scan, the most recent is used."""
        scan = scans_fixture[0]
        scan.state = StateChoices.COMPLETED
        scan.output_location = "dummy"
        scan.save()

        old_task = Task.objects.create(
            tenant_id=scan.tenant_id,
            task_runner_task=TaskResult.objects.create(
                task_id=str(uuid4()),
                task_name="scan-report",
                task_kwargs={"scan_id": str(scan.id)},
            ),
        )
        new_task = Task.objects.create(
            tenant_id=scan.tenant_id,
            task_runner_task=TaskResult.objects.create(
                task_id=str(uuid4()),
                task_name="scan-report",
                task_kwargs={"scan_id": str(scan.id)},
            ),
        )
        # `inserted_at` is `auto_now_add`, and within the test transaction the DB
        # `now()` is constant, so force distinct timestamps to make order_by stable.
        base = datetime(2024, 1, 1, tzinfo=UTC)
        Task.objects.filter(pk=old_task.pk).update(inserted_at=base)
        Task.objects.filter(pk=new_task.pk).update(
            inserted_at=base + timedelta(hours=1)
        )

        mock_task_serializer.side_effect = lambda instance, *_a, **_k: SimpleNamespace(
            data={"id": str(instance.id), "state": StateChoices.EXECUTING}
        )

        url = reverse("scan-report", kwargs={"pk": scan.id})
        response = authenticated_client.get(url)

        assert response.status_code == status.HTTP_202_ACCEPTED
        assert str(new_task.id) in response["Content-Location"]
        assert str(old_task.id) not in response["Content-Location"]

    @patch("api.v1.views.get_s3_client")
    @patch("api.v1.views.sentry_sdk.capture_exception")
    def test_compliance_list_objects_client_error(
        self,
        mock_sentry_capture,
        mock_get_s3_client,
        authenticated_client,
        scans_fixture,
    ):
        scan = scans_fixture[0]
        scan.output_location = "s3://test-bucket/path/to/scan.zip"
        scan.state = StateChoices.COMPLETED
        scan.save()

        fake_client = MagicMock()
        fake_client.list_objects_v2.side_effect = ClientError(
            {"Error": {"Code": "InternalError"}}, "ListObjectsV2"
        )
        mock_get_s3_client.return_value = fake_client

        framework = get_compliance_frameworks(scan.provider.provider)[0]
        url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework})
        response = authenticated_client.get(url)

        assert response.status_code == status.HTTP_502_BAD_GATEWAY
        assert (
            response.json()["errors"]["detail"]
            == "Unable to list compliance files in S3: encountered an AWS error."
        )
        mock_sentry_capture.assert_called()

    @patch("api.v1.views.get_s3_client")
    def test_report_s3_nosuchkey(
        self, mock_get_s3_client, authenticated_client, scans_fixture
    ):
        scan = scans_fixture[0]
        scan.output_location = "s3://test-bucket/report.zip"
        scan.state = StateChoices.COMPLETED
        scan.save()

        fake_client = MagicMock()
        fake_client.head_object.side_effect = ClientError(
            {"Error": {"Code": "NoSuchKey"}}, "HeadObject"
        )
        mock_get_s3_client.return_value = fake_client

        url = reverse("scan-report", kwargs={"pk": scan.id})
        response = authenticated_client.get(url)

        assert response.status_code == status.HTTP_404_NOT_FOUND
        assert (
            response.json()["errors"]["detail"]
            == "The scan has no reports, or the report generation task has not started yet."
        )

    @patch("api.v1.views.get_s3_client")
    def test_report_s3_client_error_other(
        self, mock_get_s3_client, authenticated_client, scans_fixture
    ):
        scan = scans_fixture[0]
        scan.output_location = "s3://test-bucket/report.zip"
        scan.state = StateChoices.COMPLETED
        scan.save()

        fake_client = MagicMock()
        fake_client.head_object.side_effect = ClientError(
            {"Error": {"Code": "AccessDenied"}}, "HeadObject"
        )
        mock_get_s3_client.return_value = fake_client

        url = reverse("scan-report", kwargs={"pk": scan.id})
        response = authenticated_client.get(url)

        assert response.status_code == status.HTTP_403_FORBIDDEN
        assert (
            response.json()["errors"]["detail"]
            == "There is a problem with credentials."
        )


@pytest.mark.django_db
class TestTaskViewSet:
    def test_tasks_list(self, authenticated_client, tasks_fixture):
        response = authenticated_client.get(reverse("task-list"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(tasks_fixture)

    def test_tasks_retrieve(self, authenticated_client, tasks_fixture):
        task1, *_ = tasks_fixture
        response = authenticated_client.get(
            reverse("task-detail", kwargs={"pk": task1.id}),
        )
        assert response.status_code == status.HTTP_200_OK
        assert (
            response.json()["data"]["attributes"]["name"]
            == task1.task_runner_task.task_name
        )

    def test_tasks_invalid_retrieve(self, authenticated_client):
        response = authenticated_client.get(
            reverse("task-detail", kwargs={"pk": "invalid_id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @patch("api.v1.views.AsyncResult", return_value=Mock())
    def test_tasks_revoke(self, mock_async_result, authenticated_client, tasks_fixture):
        _, task2 = tasks_fixture
        response = authenticated_client.delete(
            reverse("task-detail", kwargs={"pk": task2.id})
        )
        assert response.status_code == status.HTTP_202_ACCEPTED
        assert response.headers["Content-Location"] == f"/api/v1/tasks/{task2.id}"
        mock_async_result.return_value.revoke.assert_called_once()

    def test_tasks_invalid_revoke(self, authenticated_client):
        response = authenticated_client.delete(
            reverse("task-detail", kwargs={"pk": "invalid_id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_tasks_revoke_invalid_status(self, authenticated_client, tasks_fixture):
        task1, _ = tasks_fixture
        response = authenticated_client.delete(
            reverse("task-detail", kwargs={"pk": task1.id})
        )
        # Task status is SUCCESS
        assert response.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
class TestAttackPathsScanViewSet:
    @pytest.fixture(autouse=True)
    def _clear_throttle_cache(self):
        from django.core.cache import cache

        cache.clear()

    @staticmethod
    def _run_payload(query_id="aws-rds", parameters=None):
        return {
            "data": {
                "type": "attack-paths-query-run-requests",
                "attributes": {
                    "id": query_id,
                    "parameters": parameters or {},
                },
            }
        }

    def test_attack_paths_scans_list_returns_latest_entry_per_provider(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
        aws_provider_pair,
    ):
        provider = aws_provider
        other_provider = aws_provider_pair[1]

        older_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            state=StateChoices.AVAILABLE,
            progress=10,
        )
        latest_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            state=StateChoices.COMPLETED,
            progress=95,
        )
        other_provider_scan = create_attack_paths_scan(
            other_provider,
            scan=scans_fixture[2],
            state=StateChoices.FAILED,
            progress=50,
        )

        response = authenticated_client.get(reverse("attack-paths-scans-list"))

        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        ids = {item["id"] for item in data}
        assert ids == {str(latest_scan.id), str(other_provider_scan.id)}
        assert str(older_scan.id) not in ids

        provider_entry = next(
            item
            for item in data
            if item["relationships"]["provider"]["data"]["id"] == str(provider.id)
        )

        first_attributes = provider_entry["attributes"]
        assert first_attributes["provider_alias"] == provider.alias
        assert first_attributes["provider_type"] == provider.provider
        assert first_attributes["provider_uid"] == provider.uid

    def test_attack_paths_scans_list_prefers_active_sink_scan_on_rollback(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
        settings,
    ):
        settings.ATTACK_PATHS_SINK_DATABASE = "neo4j"
        provider = aws_provider

        neo4j_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            state=StateChoices.COMPLETED,
            graph_data_ready=True,
            sink_backend="neo4j",
        )
        neptune_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            state=StateChoices.COMPLETED,
            graph_data_ready=True,
            sink_backend="neptune",
        )

        response = authenticated_client.get(reverse("attack-paths-scans-list"))

        assert response.status_code == status.HTTP_200_OK
        ids = {item["id"] for item in response.json()["data"]}
        assert str(neo4j_scan.id) in ids
        assert str(neptune_scan.id) not in ids

    def test_attack_paths_scans_list_falls_back_when_active_sink_has_no_scan(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
        settings,
    ):
        settings.ATTACK_PATHS_SINK_DATABASE = "neptune"
        provider = aws_provider

        legacy_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            state=StateChoices.COMPLETED,
            graph_data_ready=True,
            sink_backend="neo4j",
        )

        response = authenticated_client.get(reverse("attack-paths-scans-list"))

        assert response.status_code == status.HTTP_200_OK
        ids = {item["id"] for item in response.json()["data"]}
        assert str(legacy_scan.id) in ids

    def test_attack_paths_scans_list_respects_provider_group_visibility(
        self,
        authenticated_client_no_permissions_rbac,
        aws_provider,
        create_attack_paths_scan,
        aws_provider_pair,
    ):
        client = authenticated_client_no_permissions_rbac
        limited_user = client.user
        membership = Membership.objects.filter(user=limited_user).first()
        tenant = membership.tenant

        allowed_provider = aws_provider
        denied_provider = aws_provider_pair[1]

        allowed_scan = create_attack_paths_scan(allowed_provider)
        create_attack_paths_scan(denied_provider)

        provider_group = ProviderGroup.objects.create(
            name="limited-group",
            tenant_id=tenant.id,
        )
        ProviderGroupMembership.objects.create(
            tenant_id=tenant.id,
            provider_group=provider_group,
            provider=allowed_provider,
        )
        limited_role = limited_user.roles.first()
        RoleProviderGroupRelationship.objects.create(
            tenant_id=tenant.id,
            role=limited_role,
            provider_group=provider_group,
        )

        response = client.get(reverse("attack-paths-scans-list"))

        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == str(allowed_scan.id)

    def test_attack_paths_scan_retrieve(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            state=StateChoices.COMPLETED,
            progress=80,
        )

        response = authenticated_client.get(
            reverse("attack-paths-scans-detail", kwargs={"pk": attack_paths_scan.id})
        )

        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data["id"] == str(attack_paths_scan.id)
        assert data["relationships"]["provider"]["data"]["id"] == str(provider.id)
        assert data["attributes"]["state"] == StateChoices.COMPLETED

    def test_attack_paths_scan_retrieve_not_found_for_foreign_tenant(
        self, authenticated_client, create_attack_paths_scan
    ):
        other_tenant = Tenant.objects.create(name="Foreign AttackPaths Tenant")
        foreign_provider = Provider.objects.create(
            provider="aws",
            uid="333333333333",
            alias="foreign",
            tenant_id=other_tenant.id,
        )
        foreign_scan = create_attack_paths_scan(foreign_provider)

        response = authenticated_client.get(
            reverse("attack-paths-scans-detail", kwargs={"pk": foreign_scan.id})
        )

        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_attack_paths_queries_returns_catalog(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
        )

        definitions = [
            AttackPathsQueryDefinition(
                id="aws-rds",
                name="RDS inventory",
                short_description="List account RDS assets.",
                description="List account RDS assets",
                provider=provider.provider,
                cypher="MATCH (n) RETURN n",
                parameters=[
                    AttackPathsQueryParameterDefinition(name="ip", label="IP address")
                ],
            )
        ]

        with patch(
            "api.v1.views.get_queries_for_provider", return_value=definitions
        ) as mock_get_queries:
            response = authenticated_client.get(
                reverse(
                    "attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id}
                )
            )

        assert response.status_code == status.HTTP_200_OK
        # TODO: drop the is_migrated argument after Neptune cutover
        mock_get_queries.assert_called_once_with(provider.provider, is_migrated=False)
        payload = response.json()["data"]
        assert len(payload) == 1
        assert payload[0]["id"] == "aws-rds"
        assert payload[0]["attributes"]["name"] == "RDS inventory"
        assert payload[0]["attributes"]["parameters"][0]["name"] == "ip"

    def test_attack_paths_queries_returns_404_when_catalog_missing(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(provider, scan=scans_fixture[0])

        with patch("api.v1.views.get_queries_for_provider", return_value=[]):
            response = authenticated_client.get(
                reverse(
                    "attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id}
                )
            )

        assert response.status_code == status.HTTP_404_NOT_FOUND
        assert "No queries found" in str(response.json())

    def test_run_attack_paths_query_returns_graph(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )
        query_definition = AttackPathsQueryDefinition(
            id="aws-rds",
            name="RDS inventory",
            short_description="List account RDS assets.",
            description="List account RDS assets",
            provider=provider.provider,
            cypher="MATCH (n) RETURN n",
            parameters=[],
        )
        prepared_parameters = {"provider_uid": provider.uid}
        graph_payload = {
            "nodes": [
                {
                    "id": "node-1",
                    "labels": ["AWSAccount"],
                    "properties": {"name": "root"},
                }
            ],
            "relationships": [
                {
                    "id": "rel-1",
                    "label": "OWNS",
                    "source": "node-1",
                    "target": "node-2",
                    "properties": {},
                }
            ],
            "total_nodes": 1,
            "truncated": False,
        }

        expected_db_name = f"db-tenant-{attack_paths_scan.provider.tenant_id}"

        with (
            patch(
                "api.v1.views.get_query_by_id", return_value=query_definition
            ) as mock_get_query,
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value=expected_db_name,
            ) as mock_get_db_name,
            patch(
                "api.v1.views.attack_paths_views_helpers.prepare_parameters",
                return_value=prepared_parameters,
            ) as mock_prepare,
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_query",
                return_value=graph_payload,
            ) as mock_execute,
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-run",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._run_payload("aws-rds"),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_200_OK
        # TODO: drop the is_migrated argument after Neptune cutover
        mock_get_query.assert_called_once_with("aws-rds", is_migrated=False)
        mock_get_db_name.assert_called_once_with(attack_paths_scan.provider.tenant_id)
        provider_id = str(attack_paths_scan.provider_id)
        mock_prepare.assert_called_once_with(
            query_definition,
            {},
            attack_paths_scan.provider.uid,
            provider_id,
        )
        mock_execute.assert_called_once_with(
            expected_db_name,
            query_definition,
            prepared_parameters,
            provider_id,
            scan=attack_paths_scan,
        )
        result = response.json()["data"]
        attributes = result["attributes"]
        assert attributes["nodes"] == graph_payload["nodes"]
        assert attributes["relationships"] == graph_payload["relationships"]

    def test_run_attack_paths_query_returns_text_when_accept_text_plain(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )
        query_definition = AttackPathsQueryDefinition(
            id="aws-rds",
            name="RDS inventory",
            short_description="List account RDS assets.",
            description="List account RDS assets",
            provider=provider.provider,
            cypher="MATCH (n) RETURN n",
            parameters=[],
        )
        graph_payload = {
            "nodes": [
                {
                    "id": "node-1",
                    "labels": ["AWSAccount"],
                    "properties": {"name": "root"},
                }
            ],
            "relationships": [],
            "total_nodes": 1,
            "truncated": False,
        }

        with (
            patch("api.v1.views.get_query_by_id", return_value=query_definition),
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value="db-test",
            ),
            patch(
                "api.v1.views.attack_paths_views_helpers.prepare_parameters",
                return_value={"provider_uid": provider.uid},
            ),
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_query",
                return_value=graph_payload,
            ),
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-run",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._run_payload("aws-rds"),
                content_type=API_JSON_CONTENT_TYPE,
                HTTP_ACCEPT="text/plain",
            )

        assert response.status_code == status.HTTP_200_OK
        assert response["Content-Type"] == "text/plain"
        body = response.content.decode()
        assert "## Nodes (1)" in body
        assert "## Relationships (0)" in body
        assert "## Summary" in body

    def test_run_attack_paths_query_blocks_when_graph_data_not_ready(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            state=StateChoices.EXECUTING,
            graph_data_ready=False,
        )

        response = authenticated_client.post(
            reverse(
                "attack-paths-scans-queries-run", kwargs={"pk": attack_paths_scan.id}
            ),
            data=self._run_payload(),
            content_type=API_JSON_CONTENT_TYPE,
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "not available" in response.json()["errors"][0]["detail"]

    def test_run_attack_paths_query_allows_executing_scan_when_graph_data_ready(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            state=StateChoices.EXECUTING,
            graph_data_ready=True,
        )
        query_definition = AttackPathsQueryDefinition(
            id="aws-test",
            name="Test",
            short_description="Test query.",
            description="Test query",
            provider=provider.provider,
            cypher="MATCH (n) RETURN n",
            parameters=[],
        )

        with (
            patch("api.v1.views.get_query_by_id", return_value=query_definition),
            patch(
                "api.v1.views.attack_paths_views_helpers.prepare_parameters",
                return_value={"provider_uid": provider.uid},
            ),
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_query",
                return_value={
                    "nodes": [{"id": "n1", "labels": ["AWSAccount"], "properties": {}}],
                    "relationships": [],
                    "total_nodes": 1,
                    "truncated": False,
                },
            ),
            patch(
                "api.v1.views.graph_database.get_database_name", return_value="db-test"
            ),
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-run",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._run_payload("aws-test"),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_200_OK

    def test_run_attack_paths_query_allows_failed_scan_when_graph_data_ready(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            state=StateChoices.FAILED,
            graph_data_ready=True,
        )
        query_definition = AttackPathsQueryDefinition(
            id="aws-test",
            name="Test",
            short_description="Test query.",
            description="Test query",
            provider=provider.provider,
            cypher="MATCH (n) RETURN n",
            parameters=[],
        )

        with (
            patch("api.v1.views.get_query_by_id", return_value=query_definition),
            patch(
                "api.v1.views.attack_paths_views_helpers.prepare_parameters",
                return_value={"provider_uid": provider.uid},
            ),
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_query",
                return_value={
                    "nodes": [{"id": "n1", "labels": ["AWSAccount"], "properties": {}}],
                    "relationships": [],
                    "total_nodes": 1,
                    "truncated": False,
                },
            ),
            patch(
                "api.v1.views.graph_database.get_database_name", return_value="db-test"
            ),
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-run",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._run_payload("aws-test"),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_200_OK

    def test_run_attack_paths_query_unknown_query(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        with patch("api.v1.views.get_query_by_id", return_value=None):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-run",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._run_payload("unknown-query"),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "Unknown Attack Paths query" in response.json()["errors"][0]["detail"]

    def test_run_attack_paths_query_returns_404_when_no_nodes_found(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )
        query_definition = AttackPathsQueryDefinition(
            id="aws-empty",
            name="empty",
            short_description="",
            description="",
            provider=provider.provider,
            cypher="MATCH (n) RETURN n",
        )

        with (
            patch("api.v1.views.get_query_by_id", return_value=query_definition),
            patch(
                "api.v1.views.attack_paths_views_helpers.prepare_parameters",
                return_value={"provider_uid": provider.uid},
            ),
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_query",
                return_value={
                    "nodes": [],
                    "relationships": [],
                    "total_nodes": 0,
                    "truncated": False,
                },
            ),
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-run",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._run_payload("aws-empty"),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_404_NOT_FOUND
        payload = response.json()
        if "data" in payload:
            attributes = payload["data"].get("attributes", {})
            assert attributes.get("nodes") == []
            assert attributes.get("relationships") == []
        else:
            assert "errors" in payload

    # -- run_custom_attack_paths_query action ------------------------------------

    @staticmethod
    def _custom_query_payload(query="MATCH (n) RETURN n"):
        return {
            "data": {
                "type": "attack-paths-custom-query-run-requests",
                "attributes": {"query": query},
            }
        }

    def test_run_custom_query_returns_graph(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )
        graph_payload = {
            "nodes": [
                {
                    "id": "node-1",
                    "labels": ["AWSAccount"],
                    "properties": {"name": "root"},
                }
            ],
            "relationships": [],
            "total_nodes": 1,
            "truncated": False,
        }

        with (
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_custom_query",
                return_value=graph_payload,
            ) as mock_execute,
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value="db-test",
            ),
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-custom",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._custom_query_payload(),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_200_OK
        mock_execute.assert_called_once_with(
            "db-test",
            "MATCH (n) RETURN n",
            str(attack_paths_scan.provider_id),
            scan=attack_paths_scan,
        )
        attributes = response.json()["data"]["attributes"]
        assert len(attributes["nodes"]) == 1
        assert attributes["total_nodes"] == 1
        assert attributes["truncated"] is False

    def test_run_custom_query_returns_text_when_accept_text_plain(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )
        graph_payload = {
            "nodes": [
                {
                    "id": "node-1",
                    "labels": ["AWSAccount"],
                    "properties": {"name": "root"},
                }
            ],
            "relationships": [],
            "total_nodes": 1,
            "truncated": False,
        }

        with (
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_custom_query",
                return_value=graph_payload,
            ),
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value="db-test",
            ),
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-custom",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._custom_query_payload(),
                content_type=API_JSON_CONTENT_TYPE,
                HTTP_ACCEPT="text/plain",
            )

        assert response.status_code == status.HTTP_200_OK
        assert response["Content-Type"] == "text/plain"
        body = response.content.decode()
        assert "## Nodes (1)" in body
        assert "## Relationships (0)" in body
        assert "## Summary" in body

    def test_run_custom_query_returns_404_when_no_nodes(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        with (
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_custom_query",
                return_value={
                    "nodes": [],
                    "relationships": [],
                    "total_nodes": 0,
                    "truncated": False,
                },
            ),
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value="db-test",
            ),
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-custom",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._custom_query_payload(),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_run_custom_query_returns_400_when_graph_not_ready(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=False,
        )

        response = authenticated_client.post(
            reverse(
                "attack-paths-scans-queries-custom",
                kwargs={"pk": attack_paths_scan.id},
            ),
            data=self._custom_query_payload(),
            content_type=API_JSON_CONTENT_TYPE,
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "not available" in response.json()["errors"][0]["detail"]

    def test_run_custom_query_returns_403_for_write_query(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        with (
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_custom_query",
                side_effect=PermissionDenied(
                    "Attack Paths query execution failed: read-only queries are enforced"
                ),
            ),
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value="db-test",
            ),
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-custom",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._custom_query_payload("CREATE (n) RETURN n"),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_403_FORBIDDEN

    # -- SSRF blocklist (HTTP level) ----------------------------------------------

    @pytest.mark.parametrize(
        "cypher",
        [
            "LOAD CSV FROM 'http://169.254.169.254/' AS x RETURN x",
            "CALL apoc.load.json('http://evil.com/') YIELD value RETURN value",
            "CALL apoc.import.csv([{fileName: 'f'}], [], {}) YIELD node RETURN node",
            "CALL apoc.export.csv.all('file.csv', {})",
            "CALL apoc.cypher.run('CREATE (n)', {}) YIELD value RETURN value",
            "CALL apoc.systemdb.graph() YIELD nodes RETURN nodes",
        ],
        ids=[
            "LOAD_CSV",
            "apoc.load",
            "apoc.import",
            "apoc.export",
            "apoc.cypher.run",
            "apoc.systemdb",
        ],
    )
    def test_run_custom_query_rejects_ssrf_patterns(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
        cypher,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        with patch(
            "api.v1.views.graph_database.get_database_name",
            return_value="db-test",
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-custom",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._custom_query_payload(cypher),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "blocked" in response.json()["errors"][0]["detail"].lower()

    # -- Cross-tenant isolation ---------------------------------------------------

    def test_run_custom_query_returns_404_for_foreign_tenant(
        self,
        authenticated_client,
        create_attack_paths_scan,
    ):
        from api.models import Provider, Tenant

        foreign_tenant = Tenant.objects.create(name="foreign-tenant")
        foreign_provider = Provider.objects.create(
            tenant=foreign_tenant,
            provider="aws",
            uid="123456789999",
        )
        attack_paths_scan = create_attack_paths_scan(
            foreign_provider,
            graph_data_ready=True,
        )

        with patch(
            "api.v1.views.graph_database.get_database_name",
            return_value="db-test",
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-custom",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._custom_query_payload(),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_cartography_schema_returns_404_for_foreign_tenant(
        self,
        authenticated_client,
        create_attack_paths_scan,
    ):
        from api.models import Provider, Tenant

        foreign_tenant = Tenant.objects.create(name="foreign-tenant-schema")
        foreign_provider = Provider.objects.create(
            tenant=foreign_tenant,
            provider="aws",
            uid="123456789998",
        )
        attack_paths_scan = create_attack_paths_scan(
            foreign_provider,
            graph_data_ready=True,
        )

        response = authenticated_client.get(
            reverse(
                "attack-paths-scans-schema",
                kwargs={"pk": attack_paths_scan.id},
            )
        )

        assert response.status_code == status.HTTP_404_NOT_FOUND

    # -- Authentication / authorization -------------------------------------------

    def test_run_custom_query_returns_401_unauthenticated(
        self,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        from rest_framework.test import APIClient

        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        unauthenticated = APIClient()
        response = unauthenticated.post(
            reverse(
                "attack-paths-scans-queries-custom",
                kwargs={"pk": attack_paths_scan.id},
            ),
            data=self._custom_query_payload(),
            content_type=API_JSON_CONTENT_TYPE,
        )

        assert response.status_code == status.HTTP_401_UNAUTHORIZED

    def test_cartography_schema_returns_401_unauthenticated(
        self,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        from rest_framework.test import APIClient

        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        unauthenticated = APIClient()
        response = unauthenticated.get(
            reverse(
                "attack-paths-scans-schema",
                kwargs={"pk": attack_paths_scan.id},
            )
        )

        assert response.status_code == status.HTTP_401_UNAUTHORIZED

    def test_run_custom_query_returns_403_no_manage_scans(
        self,
        authenticated_client_no_permissions_rbac,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        response = authenticated_client_no_permissions_rbac.post(
            reverse(
                "attack-paths-scans-queries-custom",
                kwargs={"pk": attack_paths_scan.id},
            ),
            data=self._custom_query_payload(),
            content_type=API_JSON_CONTENT_TYPE,
        )

        assert response.status_code == status.HTTP_403_FORBIDDEN

    # -- Error leakage ------------------------------------------------------------

    def test_run_custom_query_does_not_leak_internals_on_error(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        from rest_framework.exceptions import APIException

        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        with (
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_custom_query",
                side_effect=APIException(
                    "Attack Paths query execution failed due to a database error"
                ),
            ),
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value="db-test",
            ),
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-custom",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._custom_query_payload(),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
        body = json.dumps(response.json()).lower()
        for forbidden_term in ["neo4j", "bolt://", "syntaxerror", "db-tenant-"]:
            assert forbidden_term not in body

    # -- Rate limiting (throttle) -------------------------------------------------

    def test_run_custom_query_throttled_after_limit(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        mock_graph = {
            "nodes": [{"id": "n1", "labels": ["Test"], "properties": {}}],
            "relationships": [],
            "total_nodes": 1,
            "truncated": False,
        }

        url = reverse(
            "attack-paths-scans-queries-custom",
            kwargs={"pk": attack_paths_scan.id},
        )
        payload = self._custom_query_payload()

        with (
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_custom_query",
                return_value=mock_graph,
            ),
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value="db-test",
            ),
        ):
            for i in range(11):
                response = authenticated_client.post(
                    url,
                    data=payload,
                    content_type=API_JSON_CONTENT_TYPE,
                )
                if i < 10:
                    assert response.status_code == status.HTTP_200_OK, (
                        f"Request {i + 1} should succeed with 200 OK, got {response.status_code}"
                    )
                else:
                    assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS, (
                        f"Request {i + 1} should be throttled"
                    )

    # -- Timeout simulation -------------------------------------------------------

    def test_run_custom_query_returns_500_on_database_timeout(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        from rest_framework.exceptions import APIException

        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        with (
            patch(
                "api.v1.views.attack_paths_views_helpers.execute_custom_query",
                side_effect=APIException(
                    "Attack Paths query execution failed due to a database error"
                ),
            ),
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value="db-test",
            ),
        ):
            response = authenticated_client.post(
                reverse(
                    "attack-paths-scans-queries-custom",
                    kwargs={"pk": attack_paths_scan.id},
                ),
                data=self._custom_query_payload(),
                content_type=API_JSON_CONTENT_TYPE,
            )

        assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR

    # -- cartography_schema action ------------------------------------------------

    def test_cartography_schema_returns_urls(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        schema_data = {
            "id": "aws-0.129.0",
            "provider": "aws",
            "cartography_version": "0.129.0",
            "schema_url": "https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
            "raw_schema_url": "https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
        }

        with (
            patch(
                "api.v1.views.attack_paths_views_helpers.get_cartography_schema",
                return_value=schema_data,
            ) as mock_get_schema,
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value="db-test",
            ),
        ):
            response = authenticated_client.get(
                reverse(
                    "attack-paths-scans-schema",
                    kwargs={"pk": attack_paths_scan.id},
                )
            )

        assert response.status_code == status.HTTP_200_OK
        mock_get_schema.assert_called_once()
        schema_args = mock_get_schema.call_args[0]
        assert schema_args[:2] == ("db-test", str(attack_paths_scan.provider_id))
        assert schema_args[2].id == attack_paths_scan.id
        attributes = response.json()["data"]["attributes"]
        assert attributes["provider"] == "aws"
        assert attributes["cartography_version"] == "0.129.0"
        assert "schema.md" in attributes["schema_url"]
        assert "raw.githubusercontent.com" in attributes["raw_schema_url"]

    def test_cartography_schema_returns_404_when_no_metadata(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=True,
        )

        with (
            patch(
                "api.v1.views.attack_paths_views_helpers.get_cartography_schema",
                return_value=None,
            ),
            patch(
                "api.v1.views.graph_database.get_database_name",
                return_value="db-test",
            ),
        ):
            response = authenticated_client.get(
                reverse(
                    "attack-paths-scans-schema",
                    kwargs={"pk": attack_paths_scan.id},
                )
            )

        assert response.status_code == status.HTTP_404_NOT_FOUND
        assert "No cartography schema metadata" in str(response.json())

    def test_cartography_schema_returns_400_when_graph_not_ready(
        self,
        authenticated_client,
        aws_provider,
        scans_fixture,
        create_attack_paths_scan,
    ):
        provider = aws_provider
        attack_paths_scan = create_attack_paths_scan(
            provider,
            scan=scans_fixture[0],
            graph_data_ready=False,
        )

        response = authenticated_client.get(
            reverse(
                "attack-paths-scans-schema",
                kwargs={"pk": attack_paths_scan.id},
            )
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
class TestResourceViewSet:
    def test_resources_list_none(self, authenticated_client):
        response = authenticated_client.get(
            reverse("resource-list"), {"filter[updated_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 0

    def test_resources_list_no_date_filter(self, authenticated_client):
        response = authenticated_client.get(reverse("resource-list"))
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "required"

    def test_resources_list(self, authenticated_client, resources_fixture):
        response = authenticated_client.get(
            reverse("resource-list"), {"filter[updated_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(resources_fixture)
        assert "metadata" in response.json()["data"][0]["attributes"]
        assert "details" in response.json()["data"][0]["attributes"]
        assert "partition" in response.json()["data"][0]["attributes"]
        assert "groups" in response.json()["data"][0]["attributes"]

    @pytest.mark.parametrize(
        "include_values, expected_resources",
        [
            ("provider", ["providers"]),
            ("findings", ["findings"]),
            ("provider,findings", ["providers", "findings"]),
        ],
    )
    def test_resources_list_include(
        self,
        include_values,
        expected_resources,
        authenticated_client,
        resources_fixture,
        findings_fixture,
    ):
        response = authenticated_client.get(
            reverse("resource-list"),
            {"include": include_values, "filter[updated_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(resources_fixture)
        assert "included" in response.json()

        included_data = response.json()["included"]
        for expected_type in expected_resources:
            assert any(d.get("type") == expected_type for d in included_data), (
                f"Expected type '{expected_type}' not found in included data"
            )

    @pytest.mark.parametrize(
        "filter_name, filter_value, expected_count",
        (
            [
                (
                    "uid",
                    "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0",
                    1,
                ),
                ("uid.icontains", "i-1234567890abcdef", 3),
                ("name", "My Instance 2", 1),
                ("name.icontains", "ce 2", 1),
                ("region", "eu-west-1", 1),
                ("region.icontains", "west", 1),
                ("service", "ec2", 2),
                ("service.icontains", "ec", 2),
                ("inserted_at.gte", today_after_n_days(-1), 3),
                ("updated_at.gte", today_after_n_days(-1), 3),
                ("updated_at.lte", today_after_n_days(1), 3),
                ("type.icontains", "prowler", 2),
                # provider filters
                ("provider_type", "aws", 3),
                ("provider_type.in", "azure,gcp", 0),
                ("provider_uid", "123456789012", 2),
                ("provider_uid.in", "123456789012", 2),
                ("provider_uid.in", "123456789012,123456789012", 2),
                ("provider_uid.icontains", "1", 3),
                ("provider_alias", "aws_testing_1", 2),
                ("provider_alias.icontains", "aws", 3),
                # tags searching
                ("tag", "key3:value:value", 0),
                ("tag_key", "key3", 1),
                ("tag_value", "value2", 2),
                ("tag", "key3:multi word value3", 1),
                ("tags", "key3:multi word value3", 1),
                ("tags", "multi word", 1),
                # full text search on resource
                ("search", "arn", 3),
                # To improve search efficiency, full text search is not fully applicable
                # ("search", "def1", 1),
                # full text search on resource tags
                ("search", "multi word", 1),
                ("search", "key2", 2),
                # groups filter (ArrayField)
                ("groups", "compute", 2),
                ("groups", "storage", 1),
                ("groups.in", "compute,storage", 3),
            ]
        ),
    )
    def test_resource_filters(
        self,
        authenticated_client,
        resources_fixture,
        filter_name,
        filter_value,
        expected_count,
    ):
        filters = {f"filter[{filter_name}]": filter_value}
        if "updated_at" not in filter_name:
            filters["filter[updated_at]"] = TODAY
        response = authenticated_client.get(
            reverse("resource-list"),
            filters,
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == expected_count

    def test_resource_filter_provider_groups(
        self,
        authenticated_client,
        tenants_fixture,
        resources_fixture,
        provider_groups_fixture,
    ):
        tenant = tenants_fixture[0]
        resource1, resource2, resource3, *_ = resources_fixture
        group1, group2, *_ = provider_groups_fixture
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=resource1.provider, provider_group=group1
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=resource1.provider, provider_group=group2
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=resource3.provider, provider_group=group2
        )

        response = authenticated_client.get(
            reverse("resource-list"),
            {"filter[updated_at]": TODAY, "filter[provider_groups]": str(group1.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2
        assert {item["id"] for item in response.json()["data"]} == {
            str(resource1.id),
            str(resource2.id),
        }

        response = authenticated_client.get(
            reverse("resource-list"),
            {
                "filter[updated_at]": TODAY,
                "filter[provider_groups__in]": f"{group1.id},{group2.id}",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        resource_ids = {item["id"] for item in response.json()["data"]}
        assert resource_ids == {str(resource1.id), str(resource2.id), str(resource3.id)}
        assert len(response.json()["data"]) == 3

    def test_resource_filter_by_scan_id(
        self, authenticated_client, resources_fixture, scans_fixture
    ):
        response = authenticated_client.get(
            reverse("resource-list"),
            {"filter[scan]": scans_fixture[0].id},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_resource_filter_by_scan_id_in(
        self, authenticated_client, resources_fixture, scans_fixture
    ):
        response = authenticated_client.get(
            reverse("resource-list"),
            {"filter[scan.in]": f"{scans_fixture[0].id},{scans_fixture[1].id}"},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 3

    def test_resource_filter_by_provider_id_in(
        self, authenticated_client, resources_fixture
    ):
        response = authenticated_client.get(
            reverse("resource-list"),
            {
                "filter[provider.in]": [
                    resources_fixture[0].provider.id,
                    resources_fixture[1].provider.id,
                ],
                "filter[updated_at]": TODAY,
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    @pytest.mark.parametrize(
        "filter_name",
        (
            [
                "resource",  # Invalid filter name
                "invalid",
            ]
        ),
    )
    def test_resources_filters_invalid(self, authenticated_client, filter_name):
        response = authenticated_client.get(
            reverse("resource-list"),
            {f"filter[{filter_name}]": "whatever"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "sort_field",
        [
            "uid",
            "uid",
            "name",
            "region",
            "service",
            "type",
            "inserted_at",
            "updated_at",
        ],
    )
    def test_resources_sort(self, authenticated_client, sort_field):
        response = authenticated_client.get(
            reverse("resource-list"), {"filter[updated_at]": TODAY, "sort": sort_field}
        )
        assert response.status_code == status.HTTP_200_OK

    def test_resources_sort_invalid(self, authenticated_client):
        response = authenticated_client.get(
            reverse("resource-list"), {"filter[updated_at]": TODAY, "sort": "invalid"}
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"
        assert response.json()["errors"][0]["source"]["pointer"] == "/data"
        assert (
            response.json()["errors"][0]["detail"] == "invalid sort parameter: invalid"
        )

    def test_resources_retrieve(
        self, authenticated_client, tenants_fixture, resources_fixture
    ):
        tenant = tenants_fixture[0]
        resource_1, *_ = resources_fixture
        response = authenticated_client.get(
            reverse("resource-detail", kwargs={"pk": resource_1.id}),
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"]["attributes"]["uid"] == resource_1.uid
        assert response.json()["data"]["attributes"]["name"] == resource_1.name
        assert response.json()["data"]["attributes"]["region"] == resource_1.region
        assert response.json()["data"]["attributes"]["service"] == resource_1.service
        assert response.json()["data"]["attributes"]["type"] == resource_1.type
        assert response.json()["data"]["attributes"]["tags"] == resource_1.get_tags(
            tenant_id=str(tenant.id)
        )

    def test_resources_invalid_retrieve(self, authenticated_client):
        response = authenticated_client.get(
            reverse("resource-detail", kwargs={"pk": "random_id"}),
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @pytest.mark.usefixtures("backfill_scan_metadata_fixture")
    def test_resources_metadata_retrieve(self, authenticated_client, resources_fixture):
        resource_1, *_ = resources_fixture
        response = authenticated_client.get(
            reverse("resource-metadata"),
            {"filter[updated_at]": resource_1.updated_at.strftime("%Y-%m-%d")},
        )
        data = response.json()

        expected_services = {"ec2", "s3"}
        expected_regions = {"us-east-1", "eu-west-1"}
        expected_resource_types = {"prowler-test"}
        expected_groups = {"compute", "storage"}

        assert data["data"]["type"] == "resources-metadata"
        assert data["data"]["id"] is None
        assert set(data["data"]["attributes"]["services"]) == expected_services
        assert set(data["data"]["attributes"]["regions"]) == expected_regions
        assert set(data["data"]["attributes"]["types"]) == expected_resource_types
        assert set(data["data"]["attributes"]["groups"]) == expected_groups

    @pytest.mark.usefixtures("backfill_scan_metadata_fixture")
    def test_resources_metadata_resource_filter_retrieve(
        self, authenticated_client, resources_fixture
    ):
        resource_1, *_ = resources_fixture
        response = authenticated_client.get(
            reverse("resource-metadata"),
            {
                "filter[region]": "eu-west-1",
                "filter[updated_at]": resource_1.updated_at.strftime("%Y-%m-%d"),
            },
        )
        data = response.json()

        expected_services = {"s3"}
        expected_regions = {"eu-west-1"}
        expected_resource_types = {"prowler-test"}

        assert data["data"]["type"] == "resources-metadata"
        assert data["data"]["id"] is None
        assert set(data["data"]["attributes"]["services"]) == expected_services
        assert set(data["data"]["attributes"]["regions"]) == expected_regions
        assert set(data["data"]["attributes"]["types"]) == expected_resource_types

    def test_resources_metadata_future_date(self, authenticated_client):
        response = authenticated_client.get(
            reverse("resource-metadata"),
            {"filter[updated_at]": "2048-01-01"},
        )
        data = response.json()
        assert data["data"]["type"] == "resources-metadata"
        assert data["data"]["id"] is None
        assert data["data"]["attributes"]["services"] == []
        assert data["data"]["attributes"]["regions"] == []
        assert data["data"]["attributes"]["types"] == []
        assert data["data"]["attributes"]["groups"] == []

    def test_resources_metadata_invalid_date(self, authenticated_client):
        response = authenticated_client.get(
            reverse("resource-metadata"),
            {"filter[updated_at]": "2048-01-011"},
        )
        assert response.json() == {
            "errors": [
                {
                    "detail": "Enter a valid date.",
                    "status": "400",
                    "source": {"pointer": "/data/attributes/updated_at"},
                    "code": "invalid",
                }
            ]
        }

    def test_resources_latest(self, authenticated_client, latest_scan_resource):
        response = authenticated_client.get(
            reverse("resource-latest"),
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert (
            response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
        )

    def test_resources_metadata_latest(
        self, authenticated_client, latest_scan_resource
    ):
        response = authenticated_client.get(
            reverse("resource-metadata_latest"),
        )
        assert response.status_code == status.HTTP_200_OK
        attributes = response.json()["data"]["attributes"]

        assert attributes["services"] == [latest_scan_resource.service]
        assert attributes["regions"] == [latest_scan_resource.region]
        assert attributes["types"] == [latest_scan_resource.type]
        assert "groups" in attributes

    def test_resources_latest_filter_by_provider_id(
        self, authenticated_client, latest_scan_resource
    ):
        """Test that provider_id filter works on latest resources endpoint."""
        provider = latest_scan_resource.provider
        response = authenticated_client.get(
            reverse("resource-latest"),
            {"filter[provider_id]": str(provider.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert (
            response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
        )

    def test_resources_latest_filter_by_provider_id_in(
        self, authenticated_client, latest_scan_resource
    ):
        """Test that provider_id__in filter works on latest resources endpoint."""
        provider = latest_scan_resource.provider
        response = authenticated_client.get(
            reverse("resource-latest"),
            {"filter[provider_id__in]": str(provider.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert (
            response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid
        )

    def test_resources_latest_filter_by_provider_id_in_multiple(
        self,
        authenticated_client,
        aws_provider,
        aws_provider_pair,
    ):
        """Test that provider_id__in filter works with multiple provider IDs."""
        provider1, provider2 = aws_provider, aws_provider_pair[1]
        tenant_id = str(provider1.tenant_id)

        # Create completed scans for both providers
        Scan.objects.create(
            name="scan for provider 1",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant_id=tenant_id,
        )
        Scan.objects.create(
            name="scan for provider 2",
            provider=provider2,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant_id=tenant_id,
        )

        # Create resources for each provider
        resource1 = Resource.objects.create(
            tenant_id=tenant_id,
            provider=provider1,
            uid="resource_provider_1",
            name="Resource Provider 1",
            region="us-east-1",
            service="ec2",
            type="instance",
        )
        Resource.objects.create(
            tenant_id=tenant_id,
            provider=provider2,
            uid="resource_provider_2",
            name="Resource Provider 2",
            region="us-west-2",
            service="s3",
            type="bucket",
        )

        # Test filtering by both providers
        response = authenticated_client.get(
            reverse("resource-latest"),
            {"filter[provider_id__in]": f"{provider1.id},{provider2.id}"},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

        # Test filtering by single provider returns only that provider's resource
        response = authenticated_client.get(
            reverse("resource-latest"),
            {"filter[provider_id__in]": str(provider1.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert response.json()["data"][0]["attributes"]["uid"] == resource1.uid

    def test_resources_latest_filter_by_provider_id_no_match(
        self, authenticated_client, latest_scan_resource
    ):
        """Test that provider_id filter returns empty when no match."""
        non_existent_id = str(uuid4())
        response = authenticated_client.get(
            reverse("resource-latest"),
            {"filter[provider_id]": non_existent_id},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 0

    # Events endpoint tests
    def test_events_non_aws_provider(
        self,
        authenticated_client,
        azure_provider,
    ):
        """Test events endpoint rejects non-AWS providers."""
        from api.models import Resource

        resource = Resource.objects.create(
            uid="test-resource-id",
            name="Test Resource",
            type="test-type",
            region="us-east-1",
            service="test-service",
            provider=azure_provider,
            tenant_id=azure_provider.tenant_id,
        )

        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id})
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST

        # Verify JSON:API error structure
        error = response.json()["errors"][0]
        assert error["code"] == "invalid_provider"
        assert error["status"] == "400"  # Must be string per JSON:API spec
        assert error["source"]["pointer"] == "/data/attributes/provider"
        assert "AWS" in error["detail"]

    @pytest.mark.parametrize(
        "lookback_days,expected_status,expected_code,expected_detail_contains",
        [
            ("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"),
            ("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
            ("91", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
            ("-5", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
        ],
    )
    def test_events_invalid_lookback_days(
        self,
        authenticated_client,
        aws_provider,
        lookback_days,
        expected_status,
        expected_code,
        expected_detail_contains,
    ):
        """Test events endpoint validates lookback_days with JSON:API compliant errors."""
        from api.models import Resource

        resource = Resource.objects.create(
            uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
            name="Test Instance",
            type="instance",
            region="us-east-1",
            service="ec2",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id}),
            {"lookback_days": lookback_days},
        )

        assert response.status_code == expected_status

        # Verify JSON:API error structure
        error = response.json()["errors"][0]
        assert error["code"] == expected_code
        assert error["status"] == "400"  # Must be string per JSON:API spec
        assert error["source"]["parameter"] == "lookback_days"
        assert expected_detail_contains in error["detail"]

    @pytest.mark.parametrize(
        "page_size,expected_status,expected_code,expected_detail_contains",
        [
            ("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"),
            ("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
            ("51", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
            ("-1", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
        ],
    )
    def test_events_invalid_page_size(
        self,
        authenticated_client,
        aws_provider,
        page_size,
        expected_status,
        expected_code,
        expected_detail_contains,
    ):
        """Test events endpoint validates page[size] with JSON:API compliant errors."""
        from api.models import Resource

        resource = Resource.objects.create(
            uid="arn:aws:ec2:us-east-1:123456789012:instance/i-pagesize-test",
            name="Test Instance",
            type="instance",
            region="us-east-1",
            service="ec2",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id}),
            {"page[size]": page_size},
        )

        assert response.status_code == expected_status

        # Verify JSON:API error structure
        error = response.json()["errors"][0]
        assert error["code"] == expected_code
        assert error["status"] == "400"  # Must be string per JSON:API spec
        assert error["source"]["parameter"] == "page[size]"
        assert expected_detail_contains in error["detail"]

    @pytest.mark.parametrize(
        "invalid_params,expected_invalid_param",
        [
            ({"filter[service]": "ec2"}, "filter[service]"),
            ({"filter[region]": "us-east-1"}, "filter[region]"),
            ({"sort": "-name"}, "sort"),
            ({"unknown_param": "value"}, "unknown_param"),
            ({"filter[servic]": "ec2"}, "filter[servic]"),  # Typo in filter name
        ],
    )
    def test_events_invalid_query_parameter(
        self,
        authenticated_client,
        aws_provider,
        invalid_params,
        expected_invalid_param,
    ):
        """Test events endpoint rejects unknown query parameters with JSON:API compliant errors."""
        from api.models import Resource

        resource = Resource.objects.create(
            uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
            name="Test Instance",
            type="instance",
            region="us-east-1",
            service="ec2",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id}),
            invalid_params,
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST

        # Verify JSON:API error structure
        errors = response.json()["errors"]
        assert len(errors) >= 1

        # Find the error for our expected invalid param
        error = next(
            (e for e in errors if e["source"]["parameter"] == expected_invalid_param),
            None,
        )
        assert error is not None, (
            f"Expected error for parameter '{expected_invalid_param}'"
        )
        assert error["code"] == "invalid"
        assert error["status"] == "400"  # Must be string per JSON:API spec
        assert expected_invalid_param in error["detail"]

    def test_events_multiple_invalid_query_parameters(
        self,
        authenticated_client,
        aws_provider,
    ):
        """Test events endpoint returns error for first unknown parameter."""
        from api.models import Resource

        resource = Resource.objects.create(
            uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
            name="Test Instance",
            type="instance",
            region="us-east-1",
            service="ec2",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        # Send multiple invalid parameters - only first one triggers error
        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id}),
            {"filter[service]": "ec2", "sort": "-name", "unknown": "value"},
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST

        # Should have one error for the first invalid parameter encountered
        errors = response.json()["errors"]
        assert len(errors) == 1
        assert errors[0]["code"] == "invalid"
        assert errors[0]["status"] == "400"
        assert errors[0]["source"]["parameter"] in {
            "filter[service]",
            "sort",
            "unknown",
        }

    @patch("api.v1.views.initialize_prowler_provider")
    @patch("api.v1.views.CloudTrailTimeline")
    def test_events_success(
        self,
        mock_cloudtrail_timeline,
        mock_initialize_provider,
        authenticated_client,
        aws_provider,
    ):
        """Test successful events retrieval."""
        from api.models import Resource

        # Create test resource
        resource = Resource.objects.create(
            uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test123",
            name="Test EC2 Instance",
            type="instance",
            region="us-east-1",
            service="ec2",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        # Mock provider session
        mock_session = Mock()
        mock_provider = Mock()
        mock_provider._session.current_session = mock_session
        mock_initialize_provider.return_value = mock_provider

        # Mock CloudTrail timeline response - events need event_id for serializer
        mock_timeline_instance = Mock()
        mock_events = [
            {
                "event_id": "event-1-id",
                "event_time": "2024-01-15T10:30:00Z",
                "event_name": "RunInstances",
                "event_source": "ec2.amazonaws.com",
                "actor": "admin@example.com",
                "actor_type": "IAMUser",
                "source_ip_address": "203.0.113.1",
                "user_agent": "aws-cli/2.0.0",
            },
            {
                "event_id": "event-2-id",
                "event_time": "2024-01-16T14:20:00Z",
                "event_name": "StopInstances",
                "event_source": "ec2.amazonaws.com",
                "actor": "operator@example.com",
                "actor_type": "IAMUser",
            },
        ]
        mock_timeline_instance.get_resource_timeline.return_value = mock_events
        mock_cloudtrail_timeline.return_value = mock_timeline_instance

        # Make request with lookback_days parameter
        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id}),
            {"lookback_days": "30"},
        )

        # Assertions - response is wrapped by JSON:API renderer
        assert response.status_code == status.HTTP_200_OK
        response_data = response.json()
        events = response_data["data"]

        assert len(events) == 2

        # Verify JSON:API structure: type and id are present
        assert events[0]["type"] == "resource-events"
        assert events[0]["id"] == "event-1-id"
        assert events[1]["type"] == "resource-events"
        assert events[1]["id"] == "event-2-id"

        # Verify attributes
        assert events[0]["attributes"]["event_name"] == "RunInstances"
        assert events[0]["attributes"]["actor"] == "admin@example.com"
        assert events[1]["attributes"]["event_name"] == "StopInstances"

        # Verify CloudTrail was called with correct parameters
        mock_cloudtrail_timeline.assert_called_once_with(
            session=mock_session,
            lookback_days=30,
            max_results=50,  # Default page size
            write_events_only=True,  # Default: exclude read events
        )
        mock_timeline_instance.get_resource_timeline.assert_called_once_with(
            region=resource.region,
            resource_uid=resource.uid,
        )

    @patch("api.v1.views.initialize_prowler_provider")
    @patch("api.v1.views.CloudTrailTimeline")
    def test_events_default_lookback_days(
        self,
        mock_cloudtrail_timeline,
        mock_initialize_provider,
        authenticated_client,
        aws_provider,
    ):
        """Test events uses default lookback_days (90) when not provided."""
        from api.models import Resource

        resource = Resource.objects.create(
            uid="arn:aws:s3:::test-bucket",
            name="Test Bucket",
            type="bucket",
            region="us-east-1",
            service="s3",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        # Mock provider session
        mock_session = Mock()
        mock_provider = Mock()
        mock_provider._session.current_session = mock_session
        mock_initialize_provider.return_value = mock_provider

        # Mock CloudTrail timeline response
        mock_timeline_instance = Mock()
        mock_timeline_instance.get_resource_timeline.return_value = []
        mock_cloudtrail_timeline.return_value = mock_timeline_instance

        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id})
        )

        assert response.status_code == status.HTTP_200_OK

        # Verify default lookback_days (90) was used
        mock_cloudtrail_timeline.assert_called_once_with(
            session=mock_session,
            lookback_days=90,  # Default
            max_results=50,
            write_events_only=True,
        )

    @patch("api.v1.views.initialize_prowler_provider")
    def test_events_no_credentials_error(
        self,
        mock_initialize_provider,
        authenticated_client,
        aws_provider,
    ):
        """Test events handles missing credentials errors."""
        from api.models import Resource

        resource = Resource.objects.create(
            uid="arn:aws:rds:us-west-2:123456789012:db:test-db",
            name="Test Database",
            type="db-instance",
            region="us-west-2",
            service="rds",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        mock_initialize_provider.side_effect = NoCredentialsError()

        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id})
        )

        # 502 because this is an upstream auth failure, not API auth failure
        assert response.status_code == status.HTTP_502_BAD_GATEWAY

        # Verify JSON:API error structure
        error = response.json()["errors"][0]
        assert error["code"] == "upstream_auth_failed"
        assert error["status"] == "502"  # Must be string per JSON:API spec
        assert "detail" in error

    @patch("api.v1.views.initialize_prowler_provider")
    @patch("api.v1.views.CloudTrailTimeline")
    def test_events_access_denied_error(
        self,
        mock_cloudtrail_timeline,
        mock_initialize_provider,
        authenticated_client,
        aws_provider,
    ):
        """Test events handles AccessDenied errors from AWS."""
        from api.models import Resource

        resource = Resource.objects.create(
            uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func",
            name="Test Function",
            type="function",
            region="eu-west-1",
            service="lambda",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        # Mock provider
        mock_session = Mock()
        mock_provider = Mock()
        mock_provider._session.current_session = mock_session
        mock_initialize_provider.return_value = mock_provider

        # Mock ClientError with AccessDenied
        mock_timeline_instance = Mock()
        mock_timeline_instance.get_resource_timeline.side_effect = ClientError(
            {"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
            "LookupEvents",
        )
        mock_cloudtrail_timeline.return_value = mock_timeline_instance

        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id})
        )

        # AccessDenied returns 502 (upstream error, not user's fault)
        assert response.status_code == status.HTTP_502_BAD_GATEWAY

        # Verify JSON:API error structure
        error = response.json()["errors"][0]
        assert error["code"] == "upstream_access_denied"
        assert error["status"] == "502"  # Must be string per JSON:API spec
        assert "detail" in error

    @patch("api.v1.views.initialize_prowler_provider")
    @patch("api.v1.views.CloudTrailTimeline")
    def test_events_service_unavailable_error(
        self,
        mock_cloudtrail_timeline,
        mock_initialize_provider,
        authenticated_client,
        aws_provider,
    ):
        """Test events handles generic AWS API errors as 503."""
        from api.models import Resource

        resource = Resource.objects.create(
            uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func2",
            name="Test Function 2",
            type="function",
            region="eu-west-1",
            service="lambda",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        # Mock provider
        mock_session = Mock()
        mock_provider = Mock()
        mock_provider._session.current_session = mock_session
        mock_initialize_provider.return_value = mock_provider

        # Mock ClientError with non-AccessDenied error
        mock_timeline_instance = Mock()
        mock_timeline_instance.get_resource_timeline.side_effect = ClientError(
            {"Error": {"Code": "ServiceUnavailable", "Message": "Service unavailable"}},
            "LookupEvents",
        )
        mock_cloudtrail_timeline.return_value = mock_timeline_instance

        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id})
        )

        # Non-AccessDenied errors return 503
        assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE

        # Verify JSON:API error structure
        error = response.json()["errors"][0]
        assert error["code"] == "service_unavailable"
        assert error["status"] == "503"  # Must be string per JSON:API spec
        assert "detail" in error

    @patch("api.v1.views.initialize_prowler_provider")
    def test_events_assume_role_access_denied(
        self,
        mock_initialize_provider,
        authenticated_client,
        aws_provider,
    ):
        """Test events handles AWSAssumeRoleError during provider init.

        This tests the scenario from CLOUD-API-3HJ where the API task role
        cannot assume the customer's ProwlerScan role due to IAM permissions.
        The error happens during initialize_prowler_provider, which wraps
        the ClientError in AWSAssumeRoleError.
        """
        from api.models import Resource
        from prowler.providers.aws.exceptions.exceptions import AWSAssumeRoleError

        resource = Resource.objects.create(
            uid="arn:aws:lambda:eu-west-1:123456789012:function:assume-role-test",
            name="AssumeRole Test Function",
            type="function",
            region="eu-west-1",
            service="lambda",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        # Mock initialize_prowler_provider raising AWSAssumeRoleError
        # (this is what aws_provider.py actually raises when AssumeRole fails)
        original_error = ClientError(
            {
                "Error": {
                    "Code": "AccessDenied",
                    "Message": (
                        "User: arn:aws:sts::123456789012:assumed-role/api-task-role/xxx "
                        "is not authorized to perform: sts:AssumeRole on resource: "
                        "arn:aws:iam::123456789012:role/ProwlerScan"
                    ),
                }
            },
            "AssumeRole",
        )
        mock_initialize_provider.side_effect = AWSAssumeRoleError(
            original_exception=original_error,
            file="aws_provider.py",
        )

        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id})
        )

        # AWSAssumeRoleError returns 502 (upstream auth failure)
        assert response.status_code == status.HTTP_502_BAD_GATEWAY

        # Verify JSON:API error structure
        error = response.json()["errors"][0]
        assert error["code"] == "upstream_access_denied"
        assert error["status"] == "502"
        assert "detail" in error

    def test_events_unauthenticated_returns_401(self, aws_provider):
        """Test events endpoint returns 401 when no credentials are provided.

        This ensures the endpoint follows API conventions where missing authentication
        returns 401 Unauthorized, not 404 Not Found.
        """
        from api.models import Resource
        from rest_framework.test import APIClient

        resource = Resource.objects.create(
            uid="arn:aws:ec2:us-east-1:123456789012:instance/i-unauth-test",
            name="Test Instance",
            type="instance",
            region="us-east-1",
            service="ec2",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        # Use unauthenticated client (no JWT token)
        unauthenticated_client = APIClient()

        response = unauthenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id})
        )

        # Must return 401 Unauthorized, not 404 Not Found
        assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
            f"Expected 401 Unauthorized but got {response.status_code}. "
            "Unauthenticated requests should return 401, not 404."
        )

    def test_events_cross_tenant_returns_404(
        self, authenticated_client, tenants_fixture
    ):
        """Test events endpoint returns 404 for resources in other tenants (RLS).

        Users cannot access resources belonging to other tenants due to
        Row-Level Security. The resource should appear to not exist.
        """
        from api.models import Provider, Resource

        # tenant3 (tenants_fixture[2]) has no membership for the test user
        isolated_tenant = tenants_fixture[2]

        # Create provider in the isolated tenant
        other_tenant_provider = Provider.objects.create(
            provider="aws",
            uid="999999999999",
            alias="other_tenant_aws",
            tenant_id=isolated_tenant.id,
        )

        # Create resource in the OTHER tenant (not the authenticated user's tenant)
        resource = Resource.objects.create(
            uid="arn:aws:ec2:us-east-1:999999999999:instance/i-other-tenant",
            name="Other Tenant Resource",
            type="instance",
            region="us-east-1",
            service="ec2",
            provider=other_tenant_provider,
            tenant_id=isolated_tenant.id,
        )

        response = authenticated_client.get(
            reverse("resource-events", kwargs={"pk": resource.id})
        )

        # RLS hides resources from other tenants - should appear as not found
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_events_expired_token_returns_401(self, aws_provider, tenants_fixture):
        """Test events endpoint returns 401 when JWT token is expired.

        Expired tokens should return 401 Unauthorized, not 404 Not Found.
        This ensures authentication errors are properly distinguished from
        resource not found errors.
        """
        from api.models import Resource
        from rest_framework.test import APIClient

        resource = Resource.objects.create(
            uid="arn:aws:ec2:us-east-1:123456789012:instance/i-expired-test",
            name="Test Instance",
            type="instance",
            region="us-east-1",
            service="ec2",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        # Create an expired JWT token
        tenant = tenants_fixture[0]
        expired_payload = {
            "token_type": "access",
            "exp": datetime.now(UTC) - timedelta(hours=1),  # Expired 1 hour ago
            "iat": datetime.now(UTC) - timedelta(hours=2),
            "jti": str(uuid4()),
            "user_id": str(uuid4()),
            "tenant_id": str(tenant.id),
        }
        expired_token = jwt.encode(
            expired_payload, settings.SECRET_KEY, algorithm="HS256"
        )

        client = APIClient()
        client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}")

        response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))

        # Must return 401 Unauthorized, not 404 Not Found
        assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
            f"Expected 401 Unauthorized but got {response.status_code}. "
            "Expired tokens should return 401, not 404."
        )

    def test_events_invalid_token_returns_401(self, aws_provider):
        """Test events endpoint returns 401 when JWT token is completely invalid.

        Malformed or invalid tokens should return 401 Unauthorized, not 404 Not Found.
        """
        from api.models import Resource
        from rest_framework.test import APIClient

        resource = Resource.objects.create(
            uid="arn:aws:ec2:us-east-1:123456789012:instance/i-invalid-test",
            name="Test Instance",
            type="instance",
            region="us-east-1",
            service="ec2",
            provider=aws_provider,
            tenant_id=aws_provider.tenant_id,
        )

        client = APIClient()

        # Test with completely malformed token
        client.credentials(HTTP_AUTHORIZATION="Bearer not.a.valid.jwt.token")
        response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
        assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
            f"Expected 401 for malformed token but got {response.status_code}"
        )

        # Test with empty bearer token
        client.credentials(HTTP_AUTHORIZATION="Bearer ")
        response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
        assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
            f"Expected 401 for empty bearer token but got {response.status_code}"
        )


@pytest.mark.django_db
class TestFindingViewSet:
    def test_findings_list_none(self, authenticated_client):
        response = authenticated_client.get(
            reverse("finding-list"), {"filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 0

    def test_findings_list_no_date_filter(self, authenticated_client):
        response = authenticated_client.get(reverse("finding-list"))
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "required"

    def test_findings_date_range_too_large(self, authenticated_client):
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[inserted_at.lte]": today_after_n_days(
                    -(settings.FINDINGS_MAX_DAYS_IN_RANGE + 1)
                ),
            },
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"

    def test_findings_updated_at_range_too_large_with_inserted_at_filter(
        self, authenticated_client
    ):
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[updated_at.gte]": today_after_n_days(
                    -(settings.FINDINGS_MAX_DAYS_IN_RANGE + 1)
                ),
                "filter[updated_at.lte]": TODAY,
            },
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"
        assert response.json()["errors"][0]["source"]["pointer"] == (
            "/data/attributes/updated_at"
        )

    def test_findings_list(self, authenticated_client, findings_fixture):
        response = authenticated_client.get(
            reverse("finding-list"), {"filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(findings_fixture)
        assert (
            response.json()["data"][0]["attributes"]["status"]
            == findings_fixture[0].status
        )

    def test_findings_list_inserted_at_accepts_timestamp_precision_filters(
        self, authenticated_client, scans_fixture
    ):
        scan, *_ = scans_fixture

        def create_finding(uid, inserted_at):
            finding = Finding.objects.create(
                id=datetime_to_uuid7(inserted_at),
                tenant_id=scan.tenant_id,
                uid=uid,
                scan=scan,
                status=Status.FAIL,
                status_extended="timestamp precision status",
                impact=Severity.medium,
                severity=Severity.medium,
                check_id="timestamp_precision_check",
                check_metadata={
                    "CheckId": "timestamp_precision_check",
                    "Description": "timestamp precision check",
                    "servicename": "ec2",
                },
                first_seen_at=inserted_at,
            )
            Finding.all_objects.filter(pk=finding.pk).update(
                inserted_at=inserted_at,
                updated_at=inserted_at,
            )
            finding.refresh_from_db()
            return finding

        create_finding(
            "timestamp_precision_early",
            datetime(2026, 1, 15, 10, 30, 0, 100000, tzinfo=UTC),
        )
        late_finding = create_finding(
            "timestamp_precision_late",
            datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC),
        )

        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[inserted_at.gte]": "2026-01-15T10:30:00.150Z",
                "filter[inserted_at.lte]": "2026-01-15T10:30:00.250Z",
            },
        )

        assert response.status_code == status.HTTP_200_OK
        returned_uids = {
            finding["attributes"]["uid"] for finding in response.json()["data"]
        }
        assert returned_uids == {late_finding.uid}

        response = authenticated_client.get(
            reverse("finding-list"),
            {"filter[inserted_at]": "2026-01-15T10:30:00.200Z"},
        )

        assert response.status_code == status.HTTP_200_OK
        returned_uids = {
            finding["attributes"]["uid"] for finding in response.json()["data"]
        }
        assert returned_uids == {late_finding.uid}

    def test_findings_list_updated_at_accepts_timestamp_precision_filters(
        self, authenticated_client, findings_fixture
    ):
        early_finding, late_finding, *_ = findings_fixture
        early_updated_at = datetime(2026, 1, 15, 10, 30, 0, 100000, tzinfo=UTC)
        late_updated_at = datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC)
        Finding.all_objects.filter(pk=early_finding.pk).update(
            updated_at=early_updated_at
        )
        Finding.all_objects.filter(pk=late_finding.pk).update(
            updated_at=late_updated_at
        )

        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[updated_at.gte]": "2026-01-15T10:30:00.150Z",
                "filter[updated_at.lte]": "2026-01-15T10:30:00.250Z",
            },
        )

        assert response.status_code == status.HTTP_200_OK
        returned_uids = {
            finding["attributes"]["uid"] for finding in response.json()["data"]
        }
        assert returned_uids == {late_finding.uid}

        response = authenticated_client.get(
            reverse("finding-list"),
            {"filter[updated_at]": "2026-01-15T10:30:00.200Z"},
        )

        assert response.status_code == status.HTTP_200_OK
        returned_uids = {
            finding["attributes"]["uid"] for finding in response.json()["data"]
        }
        assert returned_uids == {late_finding.uid}

    def test_findings_list_inserted_at_and_updated_at_filters_are_combined(
        self, authenticated_client, scans_fixture
    ):
        scan, *_ = scans_fixture

        def create_finding(uid, inserted_at, updated_at):
            finding = Finding.objects.create(
                id=datetime_to_uuid7(inserted_at),
                tenant_id=scan.tenant_id,
                uid=uid,
                scan=scan,
                status=Status.FAIL,
                status_extended="timestamp precision status",
                impact=Severity.medium,
                severity=Severity.medium,
                check_id="timestamp_precision_check",
                check_metadata={
                    "CheckId": "timestamp_precision_check",
                    "Description": "timestamp precision check",
                    "servicename": "ec2",
                },
                first_seen_at=inserted_at,
            )
            Finding.all_objects.filter(pk=finding.pk).update(
                inserted_at=inserted_at,
                updated_at=updated_at,
            )
            finding.refresh_from_db()
            return finding

        matching_finding = create_finding(
            "timestamp_precision_combined_match",
            datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC),
            datetime(2026, 1, 15, 11, 30, 0, 200000, tzinfo=UTC),
        )
        create_finding(
            "timestamp_precision_combined_inserted_only",
            datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC),
            datetime(2026, 1, 15, 12, 30, 0, 200000, tzinfo=UTC),
        )
        create_finding(
            "timestamp_precision_combined_updated_only",
            datetime(2026, 1, 15, 9, 30, 0, 200000, tzinfo=UTC),
            datetime(2026, 1, 15, 11, 30, 0, 200000, tzinfo=UTC),
        )

        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[inserted_at.gte]": "2026-01-15T10:30:00.150Z",
                "filter[inserted_at.lte]": "2026-01-15T10:30:00.250Z",
                "filter[updated_at.gte]": "2026-01-15T11:30:00.150Z",
                "filter[updated_at.lte]": "2026-01-15T11:30:00.250Z",
            },
        )

        assert response.status_code == status.HTTP_200_OK
        returned_uids = {
            finding["attributes"]["uid"] for finding in response.json()["data"]
        }
        assert returned_uids == {matching_finding.uid}

    def test_findings_list_resource_tags_no_n_plus_one(
        self, authenticated_client, findings_fixture
    ):
        """Listing findings must load every resource's tags in a constant
        number of queries, no matter how many findings/resources are returned.

        This guards ``FindingViewSet._optimize_tags_loading`` against
        regressions that would reintroduce one extra query per resource (the
        N+1 the prefetch was added to remove).
        """
        scan = findings_fixture[0].scan
        tenant_id = findings_fixture[0].tenant_id
        provider = scan.provider

        def _create_finding_with_tagged_resource(index):
            resource = Resource.objects.create(
                tenant_id=tenant_id,
                provider=provider,
                uid=f"arn:aws:ec2:us-east-1:123456789012:instance/n-plus-one-{index}",
                name=f"N+1 Instance {index}",
                region="us-east-1",
                service="ec2",
                type="prowler-test",
            )
            resource.upsert_or_delete_tags(
                [
                    ResourceTag.objects.create(
                        tenant_id=tenant_id,
                        key=f"key-{index}",
                        value=f"value-{index}",
                    )
                ]
            )
            finding = Finding.objects.create(
                tenant_id=tenant_id,
                uid=f"n_plus_one_finding_{index}",
                scan=scan,
                status=Status.FAIL,
                status_extended="n+1 status",
                impact=Severity.medium,
                severity=Severity.medium,
                check_id="test_check_id",
                check_metadata={"CheckId": "test_check_id", "servicename": "ec2"},
                first_seen_at="2024-01-02T00:00:00Z",
            )
            finding.add_resources([resource])
            return finding

        params = {"filter[inserted_at]": TODAY, "include": "resources"}

        # Baseline: the two findings provided by the fixture.
        with CaptureQueriesContext(connection) as baseline:
            response = authenticated_client.get(reverse("finding-list"), params)
        assert response.status_code == status.HTTP_200_OK

        # Add more findings, each with its own resource carrying tags.
        extra_findings = 5
        for index in range(extra_findings):
            _create_finding_with_tagged_resource(index)

        with CaptureQueriesContext(connection) as scaled:
            response = authenticated_client.get(reverse("finding-list"), params)
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(findings_fixture) + extra_findings

        # The query count must not grow with the number of findings/resources.
        assert len(scaled.captured_queries) == len(baseline.captured_queries), (
            "Resource tags are not being prefetched: "
            f"{len(baseline.captured_queries)} queries for {len(findings_fixture)} "
            f"findings vs {len(scaled.captured_queries)} for "
            f"{len(findings_fixture) + extra_findings}. Likely an N+1 regression "
            "in FindingViewSet._optimize_tags_loading."
        )

    @pytest.mark.parametrize(
        "include_values, expected_resources",
        [
            ("resources", ["resources"]),
            ("scan", ["scans"]),
            ("resources,scan.provider", ["resources", "scans", "providers"]),
        ],
    )
    def test_findings_list_include(
        self, include_values, expected_resources, authenticated_client, findings_fixture
    ):
        response = authenticated_client.get(
            reverse("finding-list"),
            {"include": include_values, "filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(findings_fixture)
        assert "included" in response.json()

        included_data = response.json()["included"]
        for expected_type in expected_resources:
            assert any(d.get("type") == expected_type for d in included_data), (
                f"Expected type '{expected_type}' not found in included data"
            )

    @pytest.mark.parametrize(
        "filter_name, filter_value, expected_count",
        (
            [
                ("delta", "new", 1),
                ("provider_type", "aws", 2),
                ("provider_uid", "123456789012", 2),
                (
                    "resource_uid",
                    "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0",
                    1,
                ),
                ("resource_uid.icontains", "i-1234567890abcdef", 2),
                ("resource_name", "My Instance 2", 1),
                ("resource_name.icontains", "ce 2", 1),
                ("region", "eu-west-1", 1),
                ("region.in", "eu-west-1,eu-west-2", 1),
                ("region.icontains", "east", 1),
                ("service", "ec2", 1),
                ("service.in", "ec2,s3", 2),
                ("service.icontains", "ec", 1),
                ("inserted_at", "2024-01-01", 0),
                ("inserted_at.date", "2024-01-01", 0),
                ("inserted_at.gte", today_after_n_days(-1), 2),
                (
                    "inserted_at.lte",
                    today_after_n_days(1),
                    2,
                ),
                ("updated_at.lte", today_after_n_days(-1), 0),
                ("resource_type.icontains", "prowler", 2),
                # full text search on finding
                ("search", "dev-qa", 1),
                ("search", "orange juice", 1),
                # full text search on resource
                ("search", "ec2", 1),
                # full text search on finding tags (disabled for now)
                # ("search", "value2", 2),
                # Temporary disabled until we implement tag filtering in the UI
                # ("resource_tag_key", "key", 2),
                # ("resource_tag_key__in", "key,key2", 2),
                # ("resource_tag_key__icontains", "key", 2),
                # ("resource_tag_value", "value", 2),
                # ("resource_tag_value__in", "value,value2", 2),
                # ("resource_tag_value__icontains", "value", 2),
                # ("resource_tags", "key:value", 2),
                # ("resource_tags", "not:exists", 0),
                # ("resource_tags", "not:exists,key:value", 2),
                ("muted", True, 1),
                ("muted", False, 1),
            ]
        ),
    )
    def test_finding_filters(
        self,
        authenticated_client,
        findings_fixture,
        filter_name,
        filter_value,
        expected_count,
    ):
        filters = {f"filter[{filter_name}]": filter_value}
        if "inserted_at" not in filter_name:
            filters["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(
            reverse("finding-list"),
            filters,
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == expected_count

    def test_finding_filter_by_scan_id(self, authenticated_client, findings_fixture):
        response = authenticated_client.get(
            reverse("finding-list"),
            {"filter[scan]": findings_fixture[0].scan.id},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_finding_filter_by_scan_id_in(self, authenticated_client, findings_fixture):
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[scan.in]": [
                    findings_fixture[0].scan.id,
                    findings_fixture[1].scan.id,
                ]
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_finding_filter_by_provider(self, authenticated_client, findings_fixture):
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[provider]": findings_fixture[0].scan.provider.id,
                "filter[inserted_at]": TODAY,
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_finding_filter_by_provider_id_in(
        self, authenticated_client, findings_fixture
    ):
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[provider.in]": [
                    findings_fixture[0].scan.provider.id,
                    findings_fixture[1].scan.provider.id,
                ],
                "filter[inserted_at]": TODAY,
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_finding_filter_by_provider_id_alias(
        self, authenticated_client, findings_fixture
    ):
        """Test that provider_id filter alias works identically to provider filter."""
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[provider_id]": findings_fixture[0].scan.provider.id,
                "filter[inserted_at]": TODAY,
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_finding_filter_by_provider_id_in_alias(
        self, authenticated_client, findings_fixture
    ):
        """Test that provider_id__in filter alias works identically to provider__in filter."""
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[provider_id__in]": [
                    findings_fixture[0].scan.provider.id,
                    findings_fixture[1].scan.provider.id,
                ],
                "filter[inserted_at]": TODAY,
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_finding_filter_provider_groups(
        self,
        authenticated_client,
        tenants_fixture,
        findings_fixture,
        provider_groups_fixture,
    ):
        tenant = tenants_fixture[0]
        finding1, finding2, *_ = findings_fixture
        group1, group2, *_ = provider_groups_fixture
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=finding1.scan.provider, provider_group=group1
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=finding1.scan.provider, provider_group=group2
        )

        response = authenticated_client.get(
            reverse("finding-list"),
            {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[provider_groups__in]": f"{group1.id},{group2.id}",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    @pytest.mark.parametrize(
        "filter_name",
        (
            [
                "finding",  # Invalid filter name
                "invalid",
            ]
        ),
    )
    def test_findings_filters_invalid(self, authenticated_client, filter_name):
        response = authenticated_client.get(
            reverse("finding-list"),
            {f"filter[{filter_name}]": "whatever"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "sort_field",
        [
            "status",
            "severity",
            "check_id",
            "inserted_at",
            "updated_at",
        ],
    )
    def test_findings_sort(self, authenticated_client, sort_field):
        response = authenticated_client.get(
            reverse("finding-list"), {"sort": sort_field, "filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK

    def test_findings_sort_invalid(self, authenticated_client):
        response = authenticated_client.get(
            reverse("finding-list"), {"sort": "invalid", "filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"
        assert response.json()["errors"][0]["source"]["pointer"] == "/data"
        assert (
            response.json()["errors"][0]["detail"] == "invalid sort parameter: invalid"
        )

    def test_findings_retrieve(self, authenticated_client, findings_fixture):
        finding_1, *_ = findings_fixture
        response = authenticated_client.get(
            reverse("finding-detail", kwargs={"pk": finding_1.id}),
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"]["attributes"]["status"] == finding_1.status
        assert (
            response.json()["data"]["attributes"]["status_extended"]
            == finding_1.status_extended
        )
        assert response.json()["data"]["attributes"]["severity"] == finding_1.severity
        assert response.json()["data"]["attributes"]["check_id"] == finding_1.check_id

        assert response.json()["data"]["relationships"]["scan"]["data"]["id"] == str(
            finding_1.scan.id
        )

        assert response.json()["data"]["relationships"]["resources"]["data"][0][
            "id"
        ] == str(finding_1.resources.first().id)

    def test_findings_retrieve_include_resource_metadata(
        self, authenticated_client, findings_fixture
    ):
        finding_1, *_ = findings_fixture
        resource = finding_1.resources.first()
        resource.metadata = '{"VulnerabilityID": "CVE-2026-0001"}'
        resource.details = "Python 3.12 base image"
        resource.save()

        response = authenticated_client.get(
            reverse("finding-detail", kwargs={"pk": finding_1.id}),
            {"include": "resources"},
        )
        assert response.status_code == status.HTTP_200_OK

        included_resource = next(
            item
            for item in response.json()["included"]
            if item["type"] == "resources" and item["id"] == str(resource.id)
        )
        assert (
            included_resource["attributes"]["metadata"]
            == '{"VulnerabilityID": "CVE-2026-0001"}'
        )
        assert included_resource["attributes"]["details"] == "Python 3.12 base image"

    def test_findings_invalid_retrieve(self, authenticated_client):
        response = authenticated_client.get(
            reverse("finding-detail", kwargs={"pk": "random_id"}),
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @pytest.mark.usefixtures("backfill_scan_metadata_fixture")
    def test_findings_metadata_retrieve(self, authenticated_client, findings_fixture):
        finding_1, *_ = findings_fixture
        response = authenticated_client.get(
            reverse("finding-metadata"),
            {"filter[inserted_at]": finding_1.updated_at.strftime("%Y-%m-%d")},
        )
        data = response.json()

        expected_services = {"ec2", "s3"}
        expected_regions = {"eu-west-1", "us-east-1"}
        # Temporarily disabled until we implement tag filtering in the UI
        # expected_tags = {"key": ["value"], "key2": ["value2"]}
        expected_resource_types = {"prowler-test"}

        assert data["data"]["type"] == "findings-metadata"
        assert data["data"]["id"] is None
        assert set(data["data"]["attributes"]["services"]) == expected_services
        assert set(data["data"]["attributes"]["regions"]) == expected_regions
        assert (
            set(data["data"]["attributes"]["resource_types"]) == expected_resource_types
        )
        # assert data["data"]["attributes"]["tags"] == expected_tags

    @pytest.mark.usefixtures("backfill_scan_metadata_fixture")
    def test_findings_metadata_resource_filter_retrieve(
        self, authenticated_client, findings_fixture
    ):
        finding_1, *_ = findings_fixture
        response = authenticated_client.get(
            reverse("finding-metadata"),
            {
                "filter[region]": "eu-west-1",
                "filter[inserted_at]": finding_1.inserted_at.strftime("%Y-%m-%d"),
            },
        )
        data = response.json()

        expected_services = {"s3"}
        expected_regions = {"eu-west-1"}
        # Temporary disabled until we implement tag filtering in the UI
        # expected_tags = {"key": ["value"], "key2": ["value2"]}
        expected_resource_types = {"prowler-test"}

        assert data["data"]["type"] == "findings-metadata"
        assert data["data"]["id"] is None
        assert set(data["data"]["attributes"]["services"]) == expected_services
        assert set(data["data"]["attributes"]["regions"]) == expected_regions
        assert (
            set(data["data"]["attributes"]["resource_types"]) == expected_resource_types
        )
        # assert data["data"]["attributes"]["tags"] == expected_tags

    def test_findings_metadata_future_date(self, authenticated_client):
        response = authenticated_client.get(
            reverse("finding-metadata"),
            {"filter[inserted_at]": "2048-01-01"},
        )
        data = response.json()
        assert data["data"]["type"] == "findings-metadata"
        assert data["data"]["id"] is None
        assert data["data"]["attributes"]["services"] == []
        assert data["data"]["attributes"]["regions"] == []
        # Temporary disabled until we implement tag filtering in the UI
        # assert data["data"]["attributes"]["tags"] == {}
        assert data["data"]["attributes"]["resource_types"] == []

    def test_findings_metadata_invalid_date(self, authenticated_client):
        response = authenticated_client.get(
            reverse("finding-metadata"),
            {"filter[inserted_at]": "2048-01-011"},
        )
        assert response.json() == {
            "errors": [
                {
                    "detail": "Enter a valid date.",
                    "status": "400",
                    "source": {"pointer": "/data/attributes/inserted_at"},
                    "code": "invalid",
                }
            ]
        }

    @pytest.mark.parametrize(
        "filter_name",
        ["inserted_at", "inserted_at.gte", "inserted_at.lte"],
    )
    def test_findings_metadata_rejects_timestamp_precision_filters(
        self, authenticated_client, filter_name
    ):
        response = authenticated_client.get(
            reverse("finding-metadata"),
            {f"filter[{filter_name}]": "2048-01-01T10:30:00Z"},
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        error = response.json()["errors"][0]
        assert error["detail"] == "Enter a valid date."
        assert error["code"] == "invalid"

    def test_findings_metadata_backfill(
        self, authenticated_client, scans_fixture, findings_fixture
    ):
        scan = scans_fixture[0]
        scan.unique_resource_count = 1
        scan.save()

        with patch(
            "api.v1.views.backfill_scan_resource_summaries_task.apply_async"
        ) as mock_backfill_task:
            response = authenticated_client.get(
                reverse("finding-metadata"),
                {"filter[scan]": str(scan.id)},
            )
        assert response.status_code == status.HTTP_200_OK
        mock_backfill_task.assert_called()

    def test_findings_metadata_backfill_no_resources(
        self, authenticated_client, scans_fixture
    ):
        scan_id = str(scans_fixture[0].id)
        with patch(
            "api.v1.views.backfill_scan_resource_summaries_task.apply_async"
        ) as mock_backfill_task:
            response = authenticated_client.get(
                reverse("finding-metadata"),
                {"filter[scan]": scan_id},
            )
        assert response.status_code == status.HTTP_200_OK
        mock_backfill_task.assert_not_called()

    def test_findings_metadata_latest_backfill(
        self, authenticated_client, scans_fixture, findings_fixture
    ):
        scan = scans_fixture[0]
        scan.unique_resource_count = 1
        scan.save()

        with patch(
            "api.v1.views.backfill_scan_resource_summaries_task.apply_async"
        ) as mock_backfill_task:
            response = authenticated_client.get(reverse("finding-metadata_latest"))
        assert response.status_code == status.HTTP_200_OK
        mock_backfill_task.assert_called()

    def test_findings_metadata_latest_backfill_no_resources(
        self, authenticated_client, scans_fixture
    ):
        with patch(
            "api.v1.views.backfill_scan_resource_summaries_task.apply_async"
        ) as mock_backfill_task:
            response = authenticated_client.get(reverse("finding-metadata_latest"))
        assert response.status_code == status.HTTP_200_OK
        mock_backfill_task.assert_not_called()

    def test_findings_latest(self, authenticated_client, latest_scan_finding):
        response = authenticated_client.get(
            reverse("finding-latest"),
        )
        assert response.status_code == status.HTTP_200_OK
        # The latest scan only has one finding, in comparison with `GET /findings`
        assert len(response.json()["data"]) == 1
        assert (
            response.json()["data"][0]["attributes"]["status"]
            == latest_scan_finding.status
        )

    def test_findings_latest_filter_by_provider_id_alias(
        self, authenticated_client, latest_scan_finding
    ):
        """Test that provider_id filter alias works on latest findings endpoint."""
        response = authenticated_client.get(
            reverse("finding-latest"),
            {"filter[provider_id]": latest_scan_finding.scan.provider.id},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1

    def test_findings_latest_filter_by_provider_id_in_alias(
        self, authenticated_client, latest_scan_finding
    ):
        """Test that provider_id__in filter alias works on latest findings endpoint."""
        response = authenticated_client.get(
            reverse("finding-latest"),
            {"filter[provider_id__in]": str(latest_scan_finding.scan.provider.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1

    def test_findings_metadata_latest(self, authenticated_client, latest_scan_finding):
        response = authenticated_client.get(
            reverse("finding-metadata_latest"),
        )
        assert response.status_code == status.HTTP_200_OK
        attributes = response.json()["data"]["attributes"]

        assert attributes["services"] == latest_scan_finding.resource_services
        assert attributes["regions"] == latest_scan_finding.resource_regions
        assert attributes["resource_types"] == latest_scan_finding.resource_types

    def test_findings_metadata_categories(
        self, authenticated_client, findings_with_categories
    ):
        finding = findings_with_categories
        response = authenticated_client.get(
            reverse("finding-metadata"),
            {"filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d")},
        )
        assert response.status_code == status.HTTP_200_OK
        attributes = response.json()["data"]["attributes"]
        assert set(attributes["categories"]) == {"gen-ai", "security"}

    @pytest.mark.usefixtures("latest_scan_finding_with_categories")
    def test_findings_metadata_latest_categories(self, authenticated_client):
        response = authenticated_client.get(
            reverse("finding-metadata_latest"),
        )
        assert response.status_code == status.HTTP_200_OK
        attributes = response.json()["data"]["attributes"]
        assert set(attributes["categories"]) == {"gen-ai", "iam"}

    @pytest.mark.usefixtures("latest_scan_finding_with_categories")
    def test_findings_metadata_latest_groups(self, authenticated_client):
        response = authenticated_client.get(
            reverse("finding-metadata_latest"),
        )
        assert response.status_code == status.HTTP_200_OK
        attributes = response.json()["data"]["attributes"]
        assert "groups" in attributes
        assert "ai_ml" in attributes["groups"]

    def test_findings_filter_by_category(
        self, authenticated_client, findings_with_categories
    ):
        finding = findings_with_categories
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[category]": "gen-ai",
                "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"),
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert set(response.json()["data"][0]["attributes"]["categories"]) == {
            "gen-ai",
            "security",
        }

    def test_findings_filter_by_category_in(
        self, authenticated_client, findings_with_multiple_categories
    ):
        finding1, _ = findings_with_multiple_categories
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[category__in]": "gen-ai,iam",
                "filter[inserted_at]": finding1.inserted_at.strftime("%Y-%m-%d"),
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_findings_filter_by_category_no_match(
        self, authenticated_client, findings_with_categories
    ):
        finding = findings_with_categories
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[category]": "nonexistent",
                "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"),
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 0

    def test_findings_filter_by_resource_groups(
        self, authenticated_client, findings_with_group
    ):
        finding = findings_with_group
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[resource_groups]": "storage",
                "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"),
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert response.json()["data"][0]["attributes"]["resource_groups"] == "storage"

    def test_findings_filter_by_resource_groups_in(
        self, authenticated_client, findings_with_multiple_groups
    ):
        finding1, _ = findings_with_multiple_groups
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[resource_groups__in]": "storage,security",
                "filter[inserted_at]": finding1.inserted_at.strftime("%Y-%m-%d"),
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 2

    def test_findings_filter_by_resource_groups_no_match(
        self, authenticated_client, findings_with_group
    ):
        finding = findings_with_group
        response = authenticated_client.get(
            reverse("finding-list"),
            {
                "filter[resource_groups]": "nonexistent",
                "filter[inserted_at]": finding.inserted_at.strftime("%Y-%m-%d"),
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 0


@pytest.mark.django_db
class TestJWTFields:
    def test_jwt_fields(self, create_test_user, tenants_fixture):
        from rest_framework.test import APIClient

        client = APIClient()
        data = {
            "data": {
                "type": "tokens",
                "attributes": {"email": TEST_USER, "password": TEST_PASSWORD},
            }
        }
        response = client.post(reverse("token-obtain"), data, format="vnd.api+json")

        assert response.status_code == status.HTTP_200_OK, (
            f"Unexpected status code: {response.status_code}"
        )

        access_token = response.data["attributes"]["access"]
        payload = jwt.decode(access_token, options={"verify_signature": False})

        expected_fields = {
            "typ": "access",
            "aud": "https://api.prowler.com",
            "iss": "https://api.prowler.com",
        }

        # Verify expected fields
        for field in expected_fields:
            assert field in payload, f"The field '{field}' is not in the JWT"
            assert payload[field] == expected_fields[field], (
                f"The value of '{field}' does not match"
            )

        # Verify time fields are integers
        for time_field in ["exp", "iat", "nbf"]:
            assert time_field in payload, f"The field '{time_field}' is not in the JWT"
            assert isinstance(payload[time_field], int), (
                f"The field '{time_field}' is not an integer"
            )

        # Verify identification fields are non-empty strings
        for id_field in ["jti", "sub", "tenant_id"]:
            assert id_field in payload, f"The field '{id_field}' is not in the JWT"
            assert isinstance(payload[id_field], str) and payload[id_field], (
                f"The field '{id_field}' is not a valid string"
            )


@pytest.mark.django_db
class TestInvitationViewSet:
    TOMORROW = datetime.now(UTC) + timedelta(days=1, hours=1)
    TOMORROW_ISO = TOMORROW.isoformat()

    def test_invitations_list(self, authenticated_client, invitations_fixture):
        response = authenticated_client.get(reverse("invitation-list"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(invitations_fixture)

    def test_invitations_retrieve(self, authenticated_client, invitations_fixture):
        invitation1, _ = invitations_fixture
        response = authenticated_client.get(
            reverse(
                "invitation-detail",
                kwargs={"pk": invitation1.id},
            ),
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"]["attributes"]["email"] == invitation1.email
        assert response.json()["data"]["attributes"]["state"] == invitation1.state
        assert response.json()["data"]["attributes"]["token"] == invitation1.token
        assert response.json()["data"]["relationships"]["inviter"]["data"]["id"] == str(
            invitation1.inviter.id
        )

    def test_invitations_invalid_retrieve(self, authenticated_client):
        response = authenticated_client.get(
            reverse(
                "invitation-detail",
                kwargs={
                    "pk": "f498b103-c760-4785-9a3e-e23fafbb7b02",
                },
            ),
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_invitations_create_valid(
        self, authenticated_client, create_test_user, roles_fixture
    ):
        user = create_test_user
        data = {
            "data": {
                "type": "invitations",
                "attributes": {
                    "email": "any_email@prowler.com",
                    "expires_at": self.TOMORROW_ISO,
                },
                "relationships": {
                    "roles": {
                        "data": [{"type": "roles", "id": str(roles_fixture[0].id)}]
                    }
                },
            }
        }
        response = authenticated_client.post(
            reverse("invitation-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert Invitation.objects.count() == 1
        assert (
            response.json()["data"]["attributes"]["email"]
            == data["data"]["attributes"]["email"]
        )
        assert response.json()["data"]["attributes"]["expires_at"] == data["data"][
            "attributes"
        ]["expires_at"].replace("+00:00", "Z")
        assert (
            response.json()["data"]["attributes"]["state"]
            == Invitation.State.PENDING.value
        )
        assert response.json()["data"]["relationships"]["inviter"]["data"]["id"] == str(
            user.id
        )

    @pytest.mark.parametrize(
        "email",
        [
            "invalid_email",
            "invalid_email@",
            # There is a pending invitation with this email
            "testing@prowler.com",
            # User is already a member of the tenant
            TEST_USER,
        ],
    )
    def test_invitations_create_invalid_email(
        self, email, authenticated_client, invitations_fixture
    ):
        data = {
            "data": {
                "type": "invitations",
                "attributes": {
                    "email": email,
                    "expires_at": self.TOMORROW_ISO,
                },
            }
        }
        response = authenticated_client.post(
            reverse("invitation-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == "/data/attributes/email"
        )
        assert response.json()["errors"][1]["code"] == "required"
        assert (
            response.json()["errors"][1]["source"]["pointer"]
            == "/data/relationships/roles"
        )

    def test_invitations_create_invalid_expires_at(
        self, authenticated_client, invitations_fixture
    ):
        data = {
            "data": {
                "type": "invitations",
                "attributes": {
                    "email": "thisisarandomemail@prowler.com",
                    "expires_at": (datetime.now(UTC) + timedelta(hours=23)).isoformat(),
                },
            }
        }
        response = authenticated_client.post(
            reverse("invitation-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == "/data/attributes/expires_at"
        )
        assert response.json()["errors"][1]["code"] == "required"
        assert (
            response.json()["errors"][1]["source"]["pointer"]
            == "/data/relationships/roles"
        )

    def test_invitations_partial_update_valid(
        self, authenticated_client, invitations_fixture, roles_fixture
    ):
        invitation, *_ = invitations_fixture
        role1, role2, *_ = roles_fixture
        new_email = "new_email@prowler.com"
        new_expires_at = datetime.now(UTC) + timedelta(days=7)
        new_expires_at_iso = new_expires_at.isoformat()
        data = {
            "data": {
                "id": str(invitation.id),
                "type": "invitations",
                "attributes": {
                    "email": new_email,
                    "expires_at": new_expires_at_iso,
                },
                "relationships": {
                    "roles": {
                        "data": [
                            {"type": "roles", "id": str(role1.id)},
                            {"type": "roles", "id": str(role2.id)},
                        ]
                    },
                },
            }
        }
        assert invitation.email != new_email
        assert invitation.expires_at != new_expires_at

        response = authenticated_client.patch(
            reverse(
                "invitation-detail",
                kwargs={"pk": str(invitation.id)},
            ),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        invitation.refresh_from_db()

        assert invitation.email == new_email
        assert invitation.expires_at == new_expires_at
        assert invitation.roles.count() == 2

    @pytest.mark.parametrize(
        "email",
        [
            "invalid_email",
            "invalid_email@",
            # There is a pending invitation with this email
            "testing@prowler.com",
            # User is already a member of the tenant
            TEST_USER,
        ],
    )
    def test_invitations_partial_update_invalid_email(
        self, email, authenticated_client, invitations_fixture
    ):
        invitation, *_ = invitations_fixture
        data = {
            "data": {
                "id": str(invitation.id),
                "type": "invitations",
                "attributes": {
                    "email": email,
                    "expires_at": self.TOMORROW_ISO,
                },
            }
        }
        response = authenticated_client.patch(
            reverse(
                "invitation-detail",
                kwargs={"pk": str(invitation.id)},
            ),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == "/data/attributes/email"
        )

    def test_invitations_partial_update_invalid_expires_at(
        self, authenticated_client, invitations_fixture
    ):
        invitation, *_ = invitations_fixture
        data = {
            "data": {
                "id": str(invitation.id),
                "type": "invitations",
                "attributes": {
                    "expires_at": (datetime.now(UTC) + timedelta(hours=23)).isoformat(),
                },
            }
        }
        response = authenticated_client.patch(
            reverse(
                "invitation-detail",
                kwargs={"pk": str(invitation.id)},
            ),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == "/data/attributes/expires_at"
        )

    def test_invitations_partial_update_invalid_content_type(
        self, authenticated_client, invitations_fixture
    ):
        invitation, *_ = invitations_fixture
        response = authenticated_client.patch(
            reverse(
                "invitation-detail",
                kwargs={"pk": str(invitation.id)},
            ),
            data={},
        )
        assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE

    def test_invitations_partial_update_invalid_content(
        self, authenticated_client, invitations_fixture
    ):
        invitation, *_ = invitations_fixture
        response = authenticated_client.patch(
            reverse(
                "invitation-detail",
                kwargs={"pk": str(invitation.id)},
            ),
            data={"email": "invalid_email"},
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_invitations_partial_update_invalid_invitation(self, authenticated_client):
        response = authenticated_client.patch(
            reverse(
                "invitation-detail",
                kwargs={"pk": "54611fc8-b02e-4cc1-aaaa-34acae625629"},
            ),
            data={},
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_invitations_delete(self, authenticated_client, invitations_fixture):
        invitation, *_ = invitations_fixture
        assert invitation.state == Invitation.State.PENDING.value

        response = authenticated_client.delete(
            reverse(
                "invitation-detail",
                kwargs={"pk": str(invitation.id)},
            )
        )
        invitation.refresh_from_db()
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert invitation.state == Invitation.State.REVOKED.value

    def test_invitations_invalid_delete(self, authenticated_client):
        response = authenticated_client.delete(
            reverse(
                "invitation-detail",
                kwargs={"pk": "54611fc8-b02e-4cc1-aaaa-34acae625629"},
            )
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_invitations_invalid_delete_invalid_state(
        self, authenticated_client, invitations_fixture
    ):
        invitation, *_ = invitations_fixture
        invitation.state = Invitation.State.ACCEPTED.value
        invitation.save()

        response = authenticated_client.delete(
            reverse(
                "invitation-detail",
                kwargs={"pk": str(invitation.id)},
            )
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"
        assert response.json()["errors"][0]["source"]["pointer"] == "/data"
        assert (
            response.json()["errors"][0]["detail"]
            == "This invitation cannot be revoked."
        )

    def test_invitations_accept_invitation_new_user(self, client, invitations_fixture):
        invitation, *_ = invitations_fixture

        data = {
            "name": "test",
            "password": "Newpassword123@",
            "email": invitation.email,
        }
        assert invitation.state == Invitation.State.PENDING.value
        assert not User.objects.filter(email__iexact=invitation.email).exists()

        response = client.post(
            reverse("user-list") + f"?invitation_token={invitation.token}",
            data=data,
            format="json",
        )

        invitation.refresh_from_db()
        assert response.status_code == status.HTTP_201_CREATED
        assert User.objects.filter(email__iexact=invitation.email).exists()
        assert invitation.state == Invitation.State.ACCEPTED.value
        assert Membership.objects.filter(
            user__email__iexact=invitation.email, tenant=invitation.tenant
        ).exists()

    def test_invitations_accept_invitation_existing_user(
        self, authenticated_client, create_test_user, tenants_fixture
    ):
        *_, tenant = tenants_fixture
        user = create_test_user

        invitation = Invitation.objects.create(
            tenant=tenant,
            email=TEST_USER,
            inviter=user,
            expires_at=self.TOMORROW,
        )

        data = {"invitation_token": invitation.token}

        assert not Membership.objects.filter(
            user__email__iexact=user.email, tenant=tenant
        ).exists()

        response = authenticated_client.post(
            reverse("invitation-accept"), data=data, format="vnd.api+json"
        )
        assert response.status_code == status.HTTP_201_CREATED
        invitation.refresh_from_db()
        assert Membership.objects.filter(
            user__email__iexact=user.email, tenant=tenant
        ).exists()
        assert invitation.state == Invitation.State.ACCEPTED.value

    def test_invitations_accept_invitation_existing_membership(
        self,
        authenticated_client,
        create_test_user,
        tenants_fixture,
    ):
        *_, tenant = tenants_fixture
        user = create_test_user

        invitation = Invitation.objects.create(
            tenant=tenant,
            email=TEST_USER,
            inviter=user,
            expires_at=self.TOMORROW,
        )
        Membership.objects.create(user=user, tenant=tenant)

        data = {"invitation_token": invitation.token}

        response = authenticated_client.post(
            reverse("invitation-accept"),
            data=data,
            format="vnd.api+json",
        )

        assert response.status_code == status.HTTP_201_CREATED
        invitation.refresh_from_db()
        assert invitation.state == Invitation.State.ACCEPTED.value
        assert (
            Membership.objects.filter(
                user__email__iexact=user.email, tenant=tenant
            ).count()
            == 1
        )

    def test_invitations_accept_invitation_invalid_token(self, authenticated_client):
        data = {"invitation_token": "invalid_token"}

        response = authenticated_client.post(
            reverse("invitation-accept"), data=data, format="vnd.api+json"
        )

        assert response.status_code == status.HTTP_404_NOT_FOUND
        assert response.json()["errors"][0]["code"] == "not_found"

    def test_invitations_accept_invitation_invalid_token_expired(
        self, authenticated_client, invitations_fixture
    ):
        invitation, *_ = invitations_fixture
        invitation.expires_at = datetime.now(UTC) - timedelta(days=1)
        invitation.email = TEST_USER
        invitation.save()

        data = {"invitation_token": invitation.token}

        response = authenticated_client.post(
            reverse("invitation-accept"), data=data, format="vnd.api+json"
        )

        assert response.status_code == status.HTTP_410_GONE

    def test_invitations_accept_invitation_invalid_token_expired_new_user(
        self, client, invitations_fixture
    ):
        new_email = "new_email@prowler.com"
        invitation, *_ = invitations_fixture
        invitation.expires_at = datetime.now(UTC) - timedelta(days=1)
        invitation.email = new_email
        invitation.save()

        data = {
            "name": "test",
            "password": "Newpassword123@",
            "email": new_email,
        }

        response = client.post(
            reverse("user-list") + f"?invitation_token={invitation.token}",
            data=data,
            format="json",
        )

        assert response.status_code == status.HTTP_410_GONE

    def test_invitations_accept_invitation_invalid_token_accepted(
        self, authenticated_client, invitations_fixture
    ):
        invitation, *_ = invitations_fixture
        invitation.state = Invitation.State.ACCEPTED.value
        invitation.email = TEST_USER
        invitation.save()

        data = {"invitation_token": invitation.token}

        response = authenticated_client.post(
            reverse("invitation-accept"), data=data, format="vnd.api+json"
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"
        assert (
            response.json()["errors"][0]["detail"]
            == "This invitation is no longer valid."
        )

    def test_invitations_accept_invitation_invalid_token_revoked(
        self, authenticated_client, invitations_fixture
    ):
        invitation, *_ = invitations_fixture
        invitation.state = Invitation.State.REVOKED.value
        invitation.email = TEST_USER
        invitation.save()

        data = {"invitation_token": invitation.token}

        response = authenticated_client.post(
            reverse("invitation-accept"), data=data, format="vnd.api+json"
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert (
            response.json()["errors"][0]["detail"]
            == "This invitation is no longer valid."
        )

    @pytest.mark.parametrize(
        "filter_name, filter_value, expected_count",
        (
            [
                ("inserted_at", TODAY, 2),
                ("inserted_at.gte", "2024-01-01", 2),
                ("inserted_at.lte", "2024-01-01", 0),
                ("updated_at.gte", "2024-01-01", 2),
                ("updated_at.lte", "2024-01-01", 0),
                ("expires_at.gte", TODAY, 1),
                ("expires_at.lte", TODAY, 1),
                ("expires_at", TODAY, 0),
                ("email", "testing@prowler.com", 2),
                ("email.icontains", "testing", 2),
                ("inviter", "", 2),
            ]
        ),
    )
    def test_invitations_filters(
        self,
        authenticated_client,
        create_test_user,
        invitations_fixture,
        filter_name,
        filter_value,
        expected_count,
    ):
        user = create_test_user
        response = authenticated_client.get(
            reverse("invitation-list"),
            {
                f"filter[{filter_name}]": (
                    filter_value if filter_name != "inviter" else str(user.id)
                )
            },
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == expected_count

    def test_invitations_list_filter_invalid(self, authenticated_client):
        response = authenticated_client.get(
            reverse("invitation-list"),
            {"filter[invalid]": "whatever"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "sort_field",
        [
            "inserted_at",
            "updated_at",
            "expires_at",
            "state",
            "inviter",
        ],
    )
    def test_invitations_sort(self, authenticated_client, sort_field):
        response = authenticated_client.get(
            reverse("invitation-list"),
            {"sort": sort_field},
        )
        assert response.status_code == status.HTTP_200_OK

    def test_invitations_sort_invalid(self, authenticated_client):
        response = authenticated_client.get(
            reverse("invitation-list"),
            {"sort": "invalid"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
class TestRoleViewSet:
    def test_role_list(self, authenticated_client, roles_fixture):
        response = authenticated_client.get(reverse("role-list"))
        assert response.status_code == status.HTTP_200_OK
        assert (
            len(response.json()["data"]) == len(roles_fixture) + 1
        )  # 1 default admin role

    def test_role_retrieve(self, authenticated_client, roles_fixture):
        role = roles_fixture[0]
        response = authenticated_client.get(
            reverse("role-detail", kwargs={"pk": role.id})
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data["id"] == str(role.id)
        assert data["attributes"]["name"] == role.name

    @pytest.mark.parametrize(
        ("permission_state", "index"),
        [("limited", 0), ("unlimited", 2), ("none", 3)],
    )
    def test_role_retrieve_permission_state(
        self, authenticated_client, roles_fixture, permission_state, index
    ):
        role = roles_fixture[index]
        response = authenticated_client.get(
            reverse("role-detail", kwargs={"pk": role.id}),
            {"filter[permission_state]": permission_state},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data["id"] == str(role.id)
        assert data["attributes"]["name"] == role.name
        assert data["attributes"]["permission_state"] == permission_state

    def test_role_create(self, authenticated_client):
        data = {
            "data": {
                "type": "roles",
                "attributes": {
                    "name": "Test Role",
                    "manage_users": "false",
                    "manage_account": "false",
                    "manage_providers": "true",
                    "manage_scans": "true",
                    "unlimited_visibility": "true",
                },
                "relationships": {"provider_groups": {"data": []}},
            }
        }
        response = authenticated_client.post(
            reverse("role-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        response_data = response.json()["data"]
        assert response_data["attributes"]["name"] == "Test Role"
        assert Role.objects.filter(name="Test Role").exists()

    def test_role_provider_groups_create(
        self, authenticated_client, provider_groups_fixture
    ):
        data = {
            "data": {
                "type": "roles",
                "attributes": {
                    "name": "Test Role",
                    "manage_users": "false",
                    "manage_account": "false",
                    "manage_providers": "true",
                    "manage_scans": "true",
                    "unlimited_visibility": "true",
                },
                "relationships": {
                    "provider_groups": {
                        "data": [
                            {"type": "provider-groups", "id": str(provider_group.id)}
                            for provider_group in provider_groups_fixture[:2]
                        ]
                    }
                },
            }
        }
        response = authenticated_client.post(
            reverse("role-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        response_data = response.json()["data"]
        assert response_data["attributes"]["name"] == "Test Role"
        assert Role.objects.filter(name="Test Role").exists()
        relationships = (
            Role.objects.filter(name="Test Role").first().provider_groups.all()
        )
        assert relationships.count() == 2
        for relationship in relationships:
            assert relationship.id in [pg.id for pg in provider_groups_fixture[:2]]

    def test_role_create_invalid(self, authenticated_client):
        data = {
            "data": {
                "type": "roles",
                "attributes": {
                    # Name is missing
                },
            }
        }
        response = authenticated_client.post(
            reverse("role-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]
        assert errors[0]["source"]["pointer"] == "/data/attributes/name"

    def test_admin_role_partial_update(self, authenticated_client, admin_role_fixture):
        role = admin_role_fixture
        data = {
            "data": {
                "id": str(role.id),
                "type": "roles",
                "attributes": {
                    "name": "Updated Role",
                },
            }
        }
        response = authenticated_client.patch(
            reverse("role-detail", kwargs={"pk": role.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        role.refresh_from_db()
        assert role.name != "Updated Role"

    def test_role_partial_update(self, authenticated_client, roles_fixture):
        role = roles_fixture[1]
        data = {
            "data": {
                "id": str(role.id),
                "type": "roles",
                "attributes": {
                    "name": "Updated Role",
                },
            }
        }
        response = authenticated_client.patch(
            reverse("role-detail", kwargs={"pk": role.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        role.refresh_from_db()
        assert role.name == "Updated Role"

    def test_role_partial_update_invalid(self, authenticated_client, roles_fixture):
        role = roles_fixture[2]
        data = {
            "data": {
                "id": str(role.id),
                "type": "roles",
                "attributes": {
                    "name": "",  # Invalid name
                },
            }
        }
        response = authenticated_client.patch(
            reverse("role-detail", kwargs={"pk": role.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]
        assert errors[0]["source"]["pointer"] == "/data/attributes/name"

    def test_role_destroy_admin(self, authenticated_client, admin_role_fixture):
        role = admin_role_fixture
        response = authenticated_client.delete(
            reverse("role-detail", kwargs={"pk": role.id})
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert Role.objects.filter(id=role.id).exists()

    def test_role_destroy(self, authenticated_client, roles_fixture):
        role = roles_fixture[2]
        response = authenticated_client.delete(
            reverse("role-detail", kwargs={"pk": role.id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert not Role.objects.filter(id=role.id).exists()

    def test_role_destroy_invalid(self, authenticated_client):
        response = authenticated_client.delete(
            reverse("role-detail", kwargs={"pk": "non-existent-id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_role_retrieve_not_found(self, authenticated_client):
        response = authenticated_client.get(
            reverse("role-detail", kwargs={"pk": "non-existent-id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_role_list_filters(self, authenticated_client, roles_fixture):
        role = roles_fixture[0]
        response = authenticated_client.get(
            reverse("role-list"), {"filter[name]": role.name}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["attributes"]["name"] == role.name

    def test_role_list_sorting(self, authenticated_client, roles_fixture):
        response = authenticated_client.get(reverse("role-list"), {"sort": "name"})
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        names = [
            item["attributes"]["name"]
            for item in data
            if item["attributes"]["name"] != "admin"
        ]
        assert names == sorted(names, key=lambda v: v.lower())

    def test_role_invalid_method(self, authenticated_client):
        response = authenticated_client.put(reverse("role-list"))
        assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED

    def test_role_create_with_users_and_provider_groups(
        self, authenticated_client, users_fixture, provider_groups_fixture
    ):
        user1, user2, *_ = users_fixture
        pg1, pg2, *_ = provider_groups_fixture

        data = {
            "data": {
                "type": "roles",
                "attributes": {
                    "name": "Role with Users and PGs",
                    "manage_users": "true",
                    "manage_account": "false",
                    "manage_providers": "true",
                    "manage_scans": "false",
                    "unlimited_visibility": "false",
                },
                "relationships": {
                    "users": {
                        "data": [
                            {"type": "users", "id": str(user1.id)},
                            {"type": "users", "id": str(user2.id)},
                        ]
                    },
                    "provider_groups": {
                        "data": [
                            {"type": "provider-groups", "id": str(pg1.id)},
                            {"type": "provider-groups", "id": str(pg2.id)},
                        ]
                    },
                },
            }
        }

        response = authenticated_client.post(
            reverse("role-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        created_role = Role.objects.get(name="Role with Users and PGs")

        assert created_role.users.count() == 2
        assert set(created_role.users.all()) == {user1, user2}

        assert created_role.provider_groups.count() == 2
        assert set(created_role.provider_groups.all()) == {pg1, pg2}

    def test_role_update_relationships(
        self,
        authenticated_client,
        roles_fixture,
        users_fixture,
        provider_groups_fixture,
    ):
        role = roles_fixture[0]
        user3 = users_fixture[2]
        pg3 = provider_groups_fixture[2]

        data = {
            "data": {
                "id": str(role.id),
                "type": "roles",
                "relationships": {
                    "users": {
                        "data": [
                            {"type": "users", "id": str(user3.id)},
                        ]
                    },
                    "provider_groups": {
                        "data": [
                            {"type": "provider-groups", "id": str(pg3.id)},
                        ]
                    },
                },
            }
        }

        response = authenticated_client.patch(
            reverse("role-detail", kwargs={"pk": role.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        role.refresh_from_db()

        assert role.users.count() == 1
        assert role.users.first() == user3
        assert role.provider_groups.count() == 1
        assert role.provider_groups.first() == pg3

    def test_role_clear_relationships(self, authenticated_client, roles_fixture):
        role = roles_fixture[0]
        data = {
            "data": {
                "id": str(role.id),
                "type": "roles",
                "relationships": {
                    "users": {"data": []},  # Clearing all users
                    "provider_groups": {"data": []},  # Clearing all provider groups
                },
            }
        }

        response = authenticated_client.patch(
            reverse("role-detail", kwargs={"pk": role.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        role.refresh_from_db()
        assert role.users.count() == 0
        assert role.provider_groups.count() == 0

    def test_cannot_remove_own_assignment_via_role_update(
        self, authenticated_client, roles_fixture
    ):
        role = roles_fixture[0]
        # Ensure the authenticated user is assigned to this role
        user = User.objects.get(email=TEST_USER)
        if not UserRoleRelationship.objects.filter(user=user, role=role).exists():
            UserRoleRelationship.objects.create(
                user=user, role=role, tenant_id=role.tenant_id
            )

        # Attempt to update role users to exclude the current user
        data = {
            "data": {
                "id": str(role.id),
                "type": "roles",
                "relationships": {"users": {"data": []}},
            }
        }
        response = authenticated_client.patch(
            reverse("role-detail", kwargs={"pk": role.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert (
            "cannot remove their own role"
            in response.json()["errors"][0]["detail"].lower()
        )

    def test_role_create_with_invalid_user_relationship(
        self, authenticated_client, provider_groups_fixture
    ):
        invalid_user_id = "non-existent-user-id"
        pg = provider_groups_fixture[0]

        data = {
            "data": {
                "type": "roles",
                "attributes": {
                    "name": "Invalid Users Role",
                    "manage_users": "false",
                    "manage_account": "false",
                    "manage_providers": "true",
                    "manage_scans": "true",
                    "unlimited_visibility": "true",
                },
                "relationships": {
                    "users": {"data": [{"type": "users", "id": invalid_user_id}]},
                    "provider_groups": {
                        "data": [{"type": "provider-groups", "id": str(pg.id)}]
                    },
                },
            }
        }

        response = authenticated_client.post(
            reverse("role-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )

        assert response.status_code in [status.HTTP_400_BAD_REQUEST]


@pytest.mark.django_db
class TestUserRoleRelationshipViewSet:
    def test_create_relationship(
        self, authenticated_client, roles_fixture, create_test_user
    ):
        data = {
            "data": [
                {"type": "roles", "id": str(role.id)} for role in roles_fixture[:2]
            ]
        }
        response = authenticated_client.post(
            reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = UserRoleRelationship.objects.filter(user=create_test_user.id)
        assert relationships.count() == 4
        # Use set membership instead of positional slicing — QuerySet ordering is
        # non-deterministic without an explicit order_by, which makes slice-based
        # checks intermittently fail.
        added_role_ids = {r.id for r in roles_fixture[:2]}
        relationship_role_ids = {rel.role.id for rel in relationships}
        assert added_role_ids.issubset(relationship_role_ids)

    def test_create_relationship_already_exists(
        self, authenticated_client, roles_fixture, create_test_user_rbac_no_roles
    ):
        data = {
            "data": [
                {"type": "roles", "id": str(role.id)} for role in roles_fixture[:2]
            ]
        }
        setup_response = authenticated_client.post(
            reverse(
                "user-roles-relationship",
                kwargs={"pk": create_test_user_rbac_no_roles.id},
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert setup_response.status_code == status.HTTP_204_NO_CONTENT

        data = {
            "data": [
                {"type": "roles", "id": str(roles_fixture[0].id)},
            ]
        }
        response = authenticated_client.post(
            reverse(
                "user-roles-relationship",
                kwargs={"pk": create_test_user_rbac_no_roles.id},
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]["detail"]
        assert "already associated" in errors

    def test_partial_update_relationship(
        self, authenticated_client, roles_fixture, create_test_user
    ):
        data = {
            "data": [
                {"type": "roles", "id": str(roles_fixture[2].id)},
            ]
        }
        response = authenticated_client.patch(
            reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        tenant = roles_fixture[2].tenant
        relationships = UserRoleRelationship.objects.filter(
            user=create_test_user.id, tenant=tenant
        )
        assert relationships.count() == 1
        assert {rel.role.id for rel in relationships} == {roles_fixture[2].id}
        assert (
            UserRoleRelationship.objects.filter(user=create_test_user.id).count() == 2
        )

        data = {
            "data": [
                {"type": "roles", "id": str(roles_fixture[1].id)},
                {"type": "roles", "id": str(roles_fixture[2].id)},
            ]
        }
        response = authenticated_client.patch(
            reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = UserRoleRelationship.objects.filter(
            user=create_test_user.id, tenant=tenant
        )
        assert relationships.count() == 2
        assert {rel.role.id for rel in relationships} == {
            roles_fixture[1].id,
            roles_fixture[2].id,
        }
        assert (
            UserRoleRelationship.objects.filter(user=create_test_user.id).count() == 3
        )

    def test_partial_update_relationship_preserves_foreign_tenant_roles(
        self, authenticated_client, roles_fixture, tenants_fixture
    ):
        tenant_a, tenant_b, _ = tenants_fixture
        tenant_a_role = roles_fixture[1]
        replacement_role = roles_fixture[2]
        foreign_role = Role.objects.create(
            name=f"foreign-role-{uuid4()}",
            tenant=tenant_b,
            manage_users=False,
            manage_account=False,
            manage_billing=False,
            manage_providers=False,
            manage_integrations=False,
            manage_scans=False,
            unlimited_visibility=False,
        )
        shared_user = User.objects.create_user(
            name="shared_user",
            email=f"shared-user-{uuid4()}@prowler.com",
            password="TmpPass123@",
        )
        Membership.objects.create(user=shared_user, tenant=tenant_a)
        Membership.objects.create(user=shared_user, tenant=tenant_b)
        UserRoleRelationship.objects.create(
            user=shared_user, role=tenant_a_role, tenant=tenant_a
        )
        UserRoleRelationship.objects.create(
            user=shared_user, role=foreign_role, tenant=tenant_b
        )

        data = {"data": [{"type": "roles", "id": str(replacement_role.id)}]}
        response = authenticated_client.patch(
            reverse("user-roles-relationship", kwargs={"pk": shared_user.id}),
            data=data,
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_204_NO_CONTENT
        tenant_a_relationships = UserRoleRelationship.objects.filter(
            user=shared_user, tenant=tenant_a
        )
        assert tenant_a_relationships.count() == 1
        assert {rel.role_id for rel in tenant_a_relationships} == {replacement_role.id}
        assert UserRoleRelationship.objects.filter(
            user=shared_user, tenant=tenant_b, role=foreign_role
        ).exists()
        assert UserRoleRelationship.objects.filter(user=shared_user).count() == 2

    def test_destroy_relationship_other_user(
        self, authenticated_client, roles_fixture, create_test_user, tenants_fixture
    ):
        # Create another user in same tenant and assign a role
        tenant = tenants_fixture[0]
        other_user = User.objects.create_user(
            name="other",
            email="other_user@prowler.com",
            password="TmpPass123@",
        )
        Membership.objects.create(user=other_user, tenant=tenant)
        UserRoleRelationship.objects.create(
            user=other_user, role=roles_fixture[0], tenant_id=tenant.id
        )

        # Delete roles for the other user (allowed)
        response = authenticated_client.delete(
            reverse("user-roles-relationship", kwargs={"pk": other_user.id}),
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = UserRoleRelationship.objects.filter(user=other_user.id)
        assert relationships.count() == 0

    def test_cannot_delete_own_roles(self, authenticated_client, create_test_user):
        # Attempt to delete own roles should be forbidden
        response = authenticated_client.delete(
            reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_prevent_removing_last_manage_account_on_patch(
        self, authenticated_client, roles_fixture, create_test_user, tenants_fixture
    ):
        # roles_fixture[1] has manage_account=False
        limited_role = roles_fixture[1]

        # Ensure there is no other user with MANAGE_ACCOUNT in the tenant
        tenant = tenants_fixture[0]
        # Create a secondary user without MANAGE_ACCOUNT
        user2 = User.objects.create_user(
            name="limited_user",
            email="limited_user@prowler.com",
            password="TmpPass123@",
        )
        Membership.objects.create(user=user2, tenant=tenant)
        UserRoleRelationship.objects.create(
            user=user2, role=limited_role, tenant_id=tenant.id
        )

        # Attempt to switch the only MANAGE_ACCOUNT user to a role without it
        data = {"data": [{"type": "roles", "id": str(limited_role.id)}]}
        response = authenticated_client.patch(
            reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "MANAGE_ACCOUNT" in response.json()["errors"][0]["detail"]

    def test_allow_role_change_when_other_user_has_manage_account_on_patch(
        self, authenticated_client, roles_fixture, create_test_user, tenants_fixture
    ):
        # roles_fixture[1] has manage_account=False, roles_fixture[0] has manage_account=True
        limited_role = roles_fixture[1]
        ma_role = roles_fixture[0]

        tenant = tenants_fixture[0]
        # Create another user with MANAGE_ACCOUNT
        user2 = User.objects.create_user(
            name="ma_user",
            email="ma_user@prowler.com",
            password="TmpPass123@",
        )
        Membership.objects.create(user=user2, tenant=tenant)
        UserRoleRelationship.objects.create(
            user=user2, role=ma_role, tenant_id=tenant.id
        )

        # Now changing the first user's roles to a non-MA role should succeed
        data = {"data": [{"type": "roles", "id": str(limited_role.id)}]}
        response = authenticated_client.patch(
            reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT

    def test_role_destroy_only_manage_account_blocked(
        self, authenticated_client_for_tenant_factory, tenants_fixture
    ):
        # Use a tenant without default admin role (tenant3)
        tenant = tenants_fixture[2]
        user = User.objects.get(email=TEST_USER)
        # Add membership for this tenant
        Membership.objects.create(user=user, tenant=tenant)

        # Create a single MANAGE_ACCOUNT role in this tenant
        only_role = Role.objects.create(
            name="only_ma",
            tenant=tenant,
            manage_users=True,
            manage_account=True,
            manage_billing=False,
            manage_providers=False,
            manage_integrations=False,
            manage_scans=False,
            unlimited_visibility=False,
        )
        # Assign the role to the user
        UserRoleRelationship.objects.create(user=user, role=only_role, tenant=tenant)
        client = authenticated_client_for_tenant_factory(user, tenant)

        # Attempt to delete the only MANAGE_ACCOUNT role
        response = client.delete(reverse("role-detail", kwargs={"pk": only_role.id}))
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert Role.objects.filter(id=only_role.id).exists()

    def test_invalid_provider_group_id(self, authenticated_client, create_test_user):
        invalid_id = "non-existent-id"
        data = {"data": [{"type": "provider-groups", "id": invalid_id}]}
        response = authenticated_client.post(
            reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"][0]["detail"]
        assert "valid UUID" in errors


@pytest.mark.django_db
class TestRoleProviderGroupRelationshipViewSet:
    def test_create_relationship(
        self, authenticated_client, roles_fixture, provider_groups_fixture
    ):
        data = {
            "data": [
                {"type": "provider-groups", "id": str(provider_group.id)}
                for provider_group in provider_groups_fixture[:2]
            ]
        }
        response = authenticated_client.post(
            reverse(
                "role-provider-groups-relationship", kwargs={"pk": roles_fixture[0].id}
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = RoleProviderGroupRelationship.objects.filter(
            role=roles_fixture[0].id
        )
        assert relationships.count() == 2
        for relationship in relationships:
            assert relationship.provider_group.id in [
                pg.id for pg in provider_groups_fixture[:2]
            ]

    def test_create_relationship_already_exists(
        self, authenticated_client, roles_fixture, provider_groups_fixture
    ):
        data = {
            "data": [
                {"type": "provider-groups", "id": str(provider_group.id)}
                for provider_group in provider_groups_fixture[:2]
            ]
        }
        authenticated_client.post(
            reverse(
                "role-provider-groups-relationship", kwargs={"pk": roles_fixture[0].id}
            ),
            data=data,
            content_type="application/vnd.api+json",
        )

        data = {
            "data": [
                {"type": "provider-groups", "id": str(provider_groups_fixture[0].id)},
            ]
        }
        response = authenticated_client.post(
            reverse(
                "role-provider-groups-relationship", kwargs={"pk": roles_fixture[0].id}
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]["detail"]
        assert "already associated" in errors

    def test_partial_update_relationship(
        self, authenticated_client, roles_fixture, provider_groups_fixture
    ):
        data = {
            "data": [
                {"type": "provider-groups", "id": str(provider_groups_fixture[1].id)},
            ]
        }
        response = authenticated_client.patch(
            reverse(
                "role-provider-groups-relationship", kwargs={"pk": roles_fixture[2].id}
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = RoleProviderGroupRelationship.objects.filter(
            role=roles_fixture[2].id
        )
        assert relationships.count() == 1
        assert {rel.provider_group.id for rel in relationships} == {
            provider_groups_fixture[1].id
        }

        data = {
            "data": [
                {"type": "provider-groups", "id": str(provider_groups_fixture[1].id)},
                {"type": "provider-groups", "id": str(provider_groups_fixture[2].id)},
            ]
        }
        response = authenticated_client.patch(
            reverse(
                "role-provider-groups-relationship", kwargs={"pk": roles_fixture[2].id}
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = RoleProviderGroupRelationship.objects.filter(
            role=roles_fixture[2].id
        )
        assert relationships.count() == 2
        assert {rel.provider_group.id for rel in relationships} == {
            provider_groups_fixture[1].id,
            provider_groups_fixture[2].id,
        }

    def test_destroy_relationship(
        self, authenticated_client, roles_fixture, provider_groups_fixture
    ):
        response = authenticated_client.delete(
            reverse(
                "role-provider-groups-relationship", kwargs={"pk": roles_fixture[0].id}
            ),
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = RoleProviderGroupRelationship.objects.filter(
            role=roles_fixture[0].id
        )
        assert relationships.count() == 0

    def test_invalid_provider_group_id(self, authenticated_client, roles_fixture):
        invalid_id = "non-existent-id"
        data = {"data": [{"type": "provider-groups", "id": invalid_id}]}
        response = authenticated_client.post(
            reverse(
                "role-provider-groups-relationship", kwargs={"pk": roles_fixture[1].id}
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"][0]["detail"]
        assert "valid UUID" in errors


@pytest.mark.django_db
class TestProviderGroupMembershipViewSet:
    def test_create_relationship(
        self,
        authenticated_client,
        provider_groups_fixture,
        aws_provider_pair,
    ):
        provider_group, *_ = provider_groups_fixture
        data = {
            "data": [
                {"type": "provider", "id": str(provider.id)}
                for provider in aws_provider_pair
            ]
        }
        response = authenticated_client.post(
            reverse(
                "provider_group-providers-relationship",
                kwargs={"pk": provider_group.id},
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = ProviderGroupMembership.objects.filter(
            provider_group=provider_group.id
        )
        assert relationships.count() == 2
        for relationship in relationships:
            assert relationship.provider.id in [p.id for p in aws_provider_pair]

    def test_create_relationship_already_exists(
        self,
        authenticated_client,
        aws_provider,
        provider_groups_fixture,
        aws_provider_pair,
    ):
        provider_group, *_ = provider_groups_fixture
        data = {
            "data": [
                {"type": "provider", "id": str(provider.id)}
                for provider in aws_provider_pair
            ]
        }
        authenticated_client.post(
            reverse(
                "provider_group-providers-relationship",
                kwargs={"pk": provider_group.id},
            ),
            data=data,
            content_type="application/vnd.api+json",
        )

        data = {
            "data": [
                {"type": "provider", "id": str(aws_provider.id)},
            ]
        }
        response = authenticated_client.post(
            reverse(
                "provider_group-providers-relationship",
                kwargs={"pk": provider_group.id},
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]["detail"]
        assert "already associated" in errors

    def test_partial_update_relationship(
        self,
        authenticated_client,
        provider_groups_fixture,
        aws_provider_pair,
        gcp_provider,
    ):
        provider_group, *_ = provider_groups_fixture
        data = {
            "data": [
                {"type": "provider", "id": str(aws_provider_pair[1].id)},
            ]
        }
        response = authenticated_client.patch(
            reverse(
                "provider_group-providers-relationship",
                kwargs={"pk": provider_group.id},
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = ProviderGroupMembership.objects.filter(
            provider_group=provider_group.id
        )
        assert relationships.count() == 1
        assert {rel.provider.id for rel in relationships} == {aws_provider_pair[1].id}

        data = {
            "data": [
                {"type": "provider", "id": str(aws_provider_pair[1].id)},
                {"type": "provider", "id": str(gcp_provider.id)},
            ]
        }
        response = authenticated_client.patch(
            reverse(
                "provider_group-providers-relationship",
                kwargs={"pk": provider_group.id},
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = ProviderGroupMembership.objects.filter(
            provider_group=provider_group.id
        )
        assert relationships.count() == 2
        assert {rel.provider.id for rel in relationships} == {
            aws_provider_pair[1].id,
            gcp_provider.id,
        }

    def test_destroy_relationship(
        self,
        authenticated_client,
        provider_groups_fixture,
        aws_provider_pair,
    ):
        provider_group, *_ = provider_groups_fixture
        data = {
            "data": [
                {"type": "provider", "id": str(provider.id)}
                for provider in aws_provider_pair
            ]
        }
        response = authenticated_client.post(
            reverse(
                "provider_group-providers-relationship",
                kwargs={"pk": provider_group.id},
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        response = authenticated_client.delete(
            reverse(
                "provider_group-providers-relationship",
                kwargs={"pk": provider_group.id},
            ),
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        relationships = ProviderGroupMembership.objects.filter(
            provider_group=provider_group.id
        )
        assert relationships.count() == 0

    def test_invalid_provider_group_id(
        self, authenticated_client, provider_groups_fixture
    ):
        provider_group, *_ = provider_groups_fixture
        invalid_id = "non-existent-id"
        data = {"data": [{"type": "provider-groups", "id": invalid_id}]}
        response = authenticated_client.post(
            reverse(
                "provider_group-providers-relationship",
                kwargs={"pk": provider_group.id},
            ),
            data=data,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"][0]["detail"]
        assert "valid UUID" in errors


@pytest.mark.django_db
class TestComplianceOverviewViewSet:
    @pytest.fixture(autouse=True)
    def mock_backfill_task(self):
        with patch("api.v1.views.backfill_compliance_summaries_task.delay") as mock:
            yield mock

    def _create_completed_scan(self, provider, name):
        return Scan.objects.create(
            name=name,
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant_id=provider.tenant_id,
            started_at=datetime.now(UTC),
            completed_at=datetime.now(UTC),
        )

    def _create_requirement(
        self,
        scan,
        requirement_id,
        status_choice,
        region="eu-west-1",
        compliance_id="cis_1.4_aws",
    ):
        passed = 1 if status_choice == StatusChoices.PASS else 0
        total = 1 if status_choice != StatusChoices.MANUAL else 0
        return ComplianceRequirementOverview.objects.create(
            tenant_id=scan.tenant_id,
            scan=scan,
            compliance_id=compliance_id,
            framework="CIS-1.4-AWS",
            version="1.4",
            description="CIS AWS Foundations Benchmark v1.4.0",
            region=region,
            requirement_id=requirement_id,
            requirement_status=status_choice,
            passed_checks=passed,
            failed_checks=0
            if status_choice in (StatusChoices.PASS, StatusChoices.MANUAL)
            else 1,
            total_checks=total,
            passed_findings=passed,
            total_findings=total,
        )

    def _create_compliance_summary(
        self,
        scan,
        *,
        passed,
        failed,
        manual=0,
        compliance_id="cis_1.4_aws",
    ):
        return ComplianceOverviewSummary.objects.create(
            tenant_id=scan.tenant_id,
            scan=scan,
            compliance_id=compliance_id,
            requirements_passed=passed,
            requirements_failed=failed,
            requirements_manual=manual,
            total_requirements=passed + failed + manual,
        )

    def _overview_attrs_by_id(self, response):
        assert response.status_code == status.HTTP_200_OK
        return {item["id"]: item["attributes"] for item in response.json()["data"]}

    def _prepare_latest_compliance_data(
        self,
        aws_provider_pair,
        gcp_provider,
    ):
        provider1, provider2 = aws_provider_pair
        provider3 = gcp_provider
        old_scan = self._create_completed_scan(provider1, "old aws compliance scan")
        latest_scan1 = self._create_completed_scan(
            provider1, "latest aws compliance scan 1"
        )
        latest_scan2 = self._create_completed_scan(
            provider2, "latest aws compliance scan 2"
        )
        latest_gcp_scan = self._create_completed_scan(
            provider3, "latest gcp compliance scan"
        )

        self._create_requirement(old_scan, "1.1", StatusChoices.FAIL)
        self._create_requirement(old_scan, "1.2", StatusChoices.FAIL)
        self._create_compliance_summary(old_scan, passed=0, failed=2)

        self._create_requirement(
            latest_scan1, "1.1", StatusChoices.PASS, region="eu-west-1"
        )
        self._create_requirement(
            latest_scan1, "1.2", StatusChoices.PASS, region="eu-west-1"
        )
        self._create_compliance_summary(latest_scan1, passed=2, failed=0)

        self._create_requirement(
            latest_scan2, "1.1", StatusChoices.FAIL, region="us-east-1"
        )
        self._create_requirement(
            latest_scan2, "1.2", StatusChoices.PASS, region="us-east-1"
        )
        self._create_compliance_summary(latest_scan2, passed=1, failed=1)

        self._create_requirement(
            latest_gcp_scan,
            "gcp-1.1",
            StatusChoices.FAIL,
            region="europe-west1",
            compliance_id="cis_1.3_gcp",
        )
        self._create_compliance_summary(
            latest_gcp_scan,
            passed=0,
            failed=1,
            compliance_id="cis_1.3_gcp",
        )

        return old_scan, latest_scan1, latest_scan2, latest_gcp_scan

    def test_compliance_overview_list_none(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        mock_backfill_task,
    ):
        tenant = tenants_fixture[0]
        provider = aws_provider
        scan = Scan.objects.create(
            name="empty-compliance-scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        response = authenticated_client.get(
            reverse("complianceoverview-list"),
            {"filter[scan_id]": str(scan.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 0
        mock_backfill_task.assert_called_once()
        _, kwargs = mock_backfill_task.call_args
        assert kwargs["scan_id"] == str(scan.id)
        assert str(kwargs["tenant_id"]) == str(tenant.id)

    def test_compliance_overview_list(
        self,
        authenticated_client,
        compliance_requirements_overviews_fixture,
        mock_backfill_task,
    ):
        # List compliance overviews with existing data
        requirement_overview1 = compliance_requirements_overviews_fixture[0]
        scan_id = str(requirement_overview1.scan.id)

        response = authenticated_client.get(
            reverse("complianceoverview-list"),
            {"filter[scan_id]": scan_id},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 3  # Three compliance frameworks

        # Check that we get aggregated data for each compliance framework
        framework_ids = [item["id"] for item in data]
        assert "aws_account_security_onboarding_aws" in framework_ids
        assert "cis_1.4_aws" in framework_ids
        assert "mitre_attack_aws" in framework_ids
        # Check structure of response
        for item in data:
            assert "id" in item
            assert "attributes" in item
            attributes = item["attributes"]
            assert "framework" in attributes
            assert "version" in attributes
            assert "requirements_passed" in attributes
            assert "requirements_failed" in attributes
            assert "requirements_manual" in attributes
            assert "total_requirements" in attributes
        mock_backfill_task.assert_called_once()
        _, kwargs = mock_backfill_task.call_args
        assert kwargs["scan_id"] == scan_id

    def test_compliance_overview_list_uses_preaggregated_summaries(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        mock_backfill_task,
    ):
        tenant = tenants_fixture[0]
        provider = aws_provider
        scan = Scan.objects.create(
            name="preaggregated-scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        ComplianceRequirementOverview.objects.create(
            tenant=tenant,
            scan=scan,
            compliance_id="cis_1.4_aws",
            framework="CIS-1.4-AWS",
            version="1.4",
            description="CIS AWS Foundations Benchmark v1.4.0",
            region="eu-west-1",
            requirement_id="framework-metadata",
            requirement_status=StatusChoices.PASS,
            passed_checks=1,
            failed_checks=0,
            total_checks=1,
        )

        ComplianceOverviewSummary.objects.create(
            tenant=tenant,
            scan=scan,
            compliance_id="cis_1.4_aws",
            requirements_passed=5,
            requirements_failed=1,
            requirements_manual=2,
            total_requirements=8,
        )

        response = authenticated_client.get(
            reverse("complianceoverview-list"),
            {"filter[scan_id]": str(scan.id)},
        )

        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        overview = data[0]
        assert overview["id"] == "cis_1.4_aws"
        assert overview["attributes"]["requirements_passed"] == 5
        assert overview["attributes"]["requirements_failed"] == 1
        assert overview["attributes"]["requirements_manual"] == 2
        assert overview["attributes"]["total_requirements"] == 8
        assert "framework" in overview["attributes"]
        assert "version" in overview["attributes"]
        mock_backfill_task.assert_not_called()

    def test_compliance_overview_region_filter_skips_backfill(
        self,
        authenticated_client,
        compliance_requirements_overviews_fixture,
        mock_backfill_task,
    ):
        requirement_overview = compliance_requirements_overviews_fixture[0]
        scan_id = str(requirement_overview.scan.id)

        response = authenticated_client.get(
            reverse("complianceoverview-list"),
            {
                "filter[scan_id]": scan_id,
                "filter[region]": requirement_overview.region,
            },
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) >= 1
        mock_backfill_task.assert_not_called()

    def test_compliance_overview_provider_id_filter_uses_latest_scan(
        self,
        authenticated_client,
        mock_backfill_task,
        aws_provider_pair,
        gcp_provider,
    ):
        _, latest_scan, *_ = self._prepare_latest_compliance_data(
            aws_provider_pair, gcp_provider
        )

        response = authenticated_client.get(
            reverse("complianceoverview-list"),
            {"filter[provider_id]": str(latest_scan.provider_id)},
        )

        attrs_by_id = self._overview_attrs_by_id(response)
        assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 2
        assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 0
        assert "cis_1.3_gcp" not in attrs_by_id
        mock_backfill_task.assert_not_called()

    def test_compliance_overview_provider_id_in_filter_aggregates_latest_scans(
        self,
        authenticated_client,
        aws_provider_pair,
        gcp_provider,
    ):
        _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data(
            aws_provider_pair, gcp_provider
        )

        response = authenticated_client.get(
            reverse("complianceoverview-list"),
            {
                "filter[provider_id__in]": (
                    f"{latest_scan1.provider_id},{latest_scan2.provider_id}"
                )
            },
        )

        attrs_by_id = self._overview_attrs_by_id(response)
        assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1
        assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1
        assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2
        assert "cis_1.3_gcp" not in attrs_by_id

    def test_compliance_overview_provider_type_filter_uses_latest_scans(
        self,
        authenticated_client,
        aws_provider_pair,
        gcp_provider,
    ):
        self._prepare_latest_compliance_data(aws_provider_pair, gcp_provider)

        response = authenticated_client.get(
            reverse("complianceoverview-list"),
            {"filter[provider_type]": Provider.ProviderChoices.AWS.value},
        )

        attrs_by_id = self._overview_attrs_by_id(response)
        assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1
        assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1
        assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2
        assert "cis_1.3_gcp" not in attrs_by_id

    def test_compliance_overview_provider_groups_filters_use_latest_scans(
        self,
        authenticated_client,
        provider_groups_fixture,
        tenants_fixture,
        aws_provider_pair,
        gcp_provider,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair
        group1, group2, *_ = provider_groups_fixture
        _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data(
            aws_provider_pair, gcp_provider
        )
        ProviderGroupMembership.objects.create(
            tenant_id=tenant.id,
            provider=provider1,
            provider_group=group1,
        )
        ProviderGroupMembership.objects.create(
            tenant_id=tenant.id,
            provider=provider2,
            provider_group=group2,
        )

        response = authenticated_client.get(
            reverse("complianceoverview-list"),
            {"filter[provider_groups]": str(group1.id)},
        )

        attrs_by_id = self._overview_attrs_by_id(response)
        assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 2
        assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 0

        response = authenticated_client.get(
            reverse("complianceoverview-list"),
            {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
        )

        attrs_by_id = self._overview_attrs_by_id(response)
        assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1
        assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1
        assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2

    def _assert_latest_provider_scan_task_response(
        self,
        authenticated_client,
        endpoint,
        scan,
        query_params=None,
    ):
        query_params = {**(query_params or {})}
        if not any(key.startswith("filter[provider_") for key in query_params):
            query_params = {
                "filter[provider_id]": str(scan.provider_id),
                **query_params,
            }

        with patch.object(
            ComplianceOverviewViewSet, "get_task_response_if_running"
        ) as mock_task_response:
            mock_task_response.return_value = Response(
                {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED
            )

            response = authenticated_client.get(reverse(endpoint), query_params)

        assert response.status_code == status.HTTP_202_ACCEPTED
        mock_task_response.assert_called_once()
        _, kwargs = mock_task_response.call_args
        assert kwargs["task_name"] == "scan-compliance-overviews"
        assert str(kwargs["task_kwargs"]["tenant_id"]) == str(scan.tenant_id)
        assert str(kwargs["task_kwargs"]["scan_id"]) == str(scan.id)
        assert kwargs["raise_on_not_found"] is False

    def test_compliance_overview_provider_filter_returns_running_task_without_data(
        self,
        authenticated_client,
        aws_provider,
    ):
        scan = self._create_completed_scan(
            aws_provider, "latest scan without compliance data"
        )

        self._assert_latest_provider_scan_task_response(
            authenticated_client,
            "complianceoverview-list",
            scan,
        )

    def test_compliance_overview_provider_filter_returns_running_task_for_partial_data(
        self,
        authenticated_client,
        aws_provider_pair,
    ):
        provider_with_data, provider_without_data = aws_provider_pair
        scan_with_data = self._create_completed_scan(
            provider_with_data, "latest scan with compliance data"
        )
        scan_without_data = self._create_completed_scan(
            provider_without_data, "latest scan without partial compliance data"
        )
        self._create_requirement(scan_with_data, "1.1", StatusChoices.PASS)

        self._assert_latest_provider_scan_task_response(
            authenticated_client,
            "complianceoverview-list",
            scan_without_data,
            {
                "filter[provider_id__in]": (
                    f"{provider_with_data.id},{provider_without_data.id}"
                )
            },
        )

    def test_compliance_overview_provider_filter_empty_response_uses_scan_data_presence(
        self,
        authenticated_client,
        aws_provider,
    ):
        scan = self._create_completed_scan(
            aws_provider, "latest scan with filtered compliance data"
        )
        self._create_requirement(scan, "1.1", StatusChoices.PASS, region="eu-west-1")

        with patch.object(
            ComplianceOverviewViewSet, "get_task_response_if_running"
        ) as mock_task_response:
            mock_task_response.return_value = Response(
                {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED
            )

            response = authenticated_client.get(
                reverse("complianceoverview-list"),
                {
                    "filter[provider_id]": str(scan.provider_id),
                    "filter[region]": "us-east-1",
                },
            )

        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"] == []
        mock_task_response.assert_not_called()

    def test_compliance_overview_metadata_provider_filter_returns_running_task_without_data(
        self,
        authenticated_client,
        aws_provider,
    ):
        scan = self._create_completed_scan(
            aws_provider, "latest scan without compliance metadata"
        )

        self._assert_latest_provider_scan_task_response(
            authenticated_client,
            "complianceoverview-metadata",
            scan,
        )

    def test_compliance_overview_requirements_provider_filter_returns_running_task_without_data(
        self,
        authenticated_client,
        aws_provider,
    ):
        scan = self._create_completed_scan(
            aws_provider, "latest scan without compliance requirements"
        )

        self._assert_latest_provider_scan_task_response(
            authenticated_client,
            "complianceoverview-requirements",
            scan,
            {"filter[compliance_id]": "cis_1.4_aws"},
        )

    def test_compliance_overview_metadata_accepts_provider_filters(
        self,
        authenticated_client,
        aws_provider_pair,
        gcp_provider,
    ):
        _, latest_scan, *_ = self._prepare_latest_compliance_data(
            aws_provider_pair, gcp_provider
        )

        response = authenticated_client.get(
            reverse("complianceoverview-metadata"),
            {"filter[provider_id]": str(latest_scan.provider_id)},
        )

        assert response.status_code == status.HTTP_200_OK
        regions = response.json()["data"]["attributes"]["regions"]
        assert regions == ["eu-west-1"]

    def test_compliance_overview_requirements_accepts_provider_filters(
        self,
        authenticated_client,
        aws_provider_pair,
        gcp_provider,
    ):
        _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data(
            aws_provider_pair, gcp_provider
        )

        response = authenticated_client.get(
            reverse("complianceoverview-requirements"),
            {
                "filter[provider_id__in]": (
                    f"{latest_scan1.provider_id},{latest_scan2.provider_id}"
                ),
                "filter[compliance_id]": "cis_1.4_aws",
            },
        )

        assert response.status_code == status.HTTP_200_OK
        requirements_by_id = {
            item["id"]: item["attributes"] for item in response.json()["data"]
        }
        assert requirements_by_id["1.1"]["status"] == "FAIL"
        assert requirements_by_id["1.2"]["status"] == "PASS"

    def test_compliance_overview_metadata(
        self, authenticated_client, compliance_requirements_overviews_fixture
    ):
        requirement_overview1 = compliance_requirements_overviews_fixture[0]
        scan_id = str(requirement_overview1.scan.id)

        response = authenticated_client.get(
            reverse("complianceoverview-metadata"),
            {"filter[scan_id]": scan_id},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert "attributes" in data
        assert "regions" in data["attributes"]
        assert isinstance(data["attributes"]["regions"], list)

    def test_compliance_overview_requirements(
        self, authenticated_client, compliance_requirements_overviews_fixture
    ):
        requirement_overview1 = compliance_requirements_overviews_fixture[0]
        scan_id = str(requirement_overview1.scan.id)
        compliance_id = requirement_overview1.compliance_id

        response = authenticated_client.get(
            reverse("complianceoverview-requirements"),
            {
                "filter[scan_id]": scan_id,
                "filter[compliance_id]": compliance_id,
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0

        # Check structure of requirements response
        for item in data:
            assert "id" in item
            assert "attributes" in item
            attributes = item["attributes"]
            assert "framework" in attributes
            assert "version" in attributes
            assert "description" in attributes
            assert "status" in attributes

    # TODO: This test may fail randomly because requirements are not ordered
    @pytest.mark.xfail
    def test_compliance_overview_requirements_manual(
        self, authenticated_client, compliance_requirements_overviews_fixture
    ):
        scan_id = str(compliance_requirements_overviews_fixture[0].scan.id)
        # Compliance with a manual requirement
        compliance_id = "aws_account_security_onboarding_aws"

        response = authenticated_client.get(
            reverse("complianceoverview-requirements"),
            {
                "filter[scan_id]": scan_id,
                "filter[compliance_id]": compliance_id,
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data[-1]["attributes"]["status"] == "MANUAL"

    def test_compliance_overview_requirements_missing_scan_id(
        self, authenticated_client
    ):
        response = authenticated_client.get(
            reverse("complianceoverview-requirements"),
            {"filter[compliance_id]": "aws_account_security_onboarding_aws"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_compliance_overview_requirements_missing_compliance_id(
        self, authenticated_client, compliance_requirements_overviews_fixture
    ):
        requirement_overview1 = compliance_requirements_overviews_fixture[0]
        scan_id = str(requirement_overview1.scan.id)

        response = authenticated_client.get(
            reverse("complianceoverview-requirements"),
            {"filter[scan_id]": scan_id},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_compliance_overview_attributes(self, authenticated_client):
        response = authenticated_client.get(
            reverse("complianceoverview-attributes"),
            {"filter[compliance_id]": "aws_account_security_onboarding_aws"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0

        # Check structure of attributes response
        for item in data:
            assert "id" in item
            assert "attributes" in item
            attributes = item["attributes"]
            assert "framework" in attributes
            assert "version" in attributes
            assert "description" in attributes
            assert "attributes" in attributes
            assert "metadata" in attributes["attributes"]
            assert "check_ids" in attributes["attributes"]
            assert "technique_details" not in attributes["attributes"]

    def test_compliance_overview_attributes_technique_details(
        self, authenticated_client
    ):
        response = authenticated_client.get(
            reverse("complianceoverview-attributes"),
            {"filter[compliance_id]": "mitre_attack_aws"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0

        # Check structure of attributes response
        for item in data:
            assert "id" in item
            assert "attributes" in item
            attributes = item["attributes"]
            assert "framework" in attributes
            assert "version" in attributes
            assert "description" in attributes
            assert "attributes" in attributes
            assert "metadata" in attributes["attributes"]
            assert "check_ids" in attributes["attributes"]
            assert "technique_details" in attributes["attributes"]
            assert "tactics" in attributes["attributes"]["technique_details"]
            assert "subtechniques" in attributes["attributes"]["technique_details"]
            assert "platforms" in attributes["attributes"]["technique_details"]
            assert "technique_url" in attributes["attributes"]["technique_details"]

            # Guard against the `_raw_attributes` wrapper leaking through —
            # the UI reads metadata[i].Category / .AWSService directly.
            metadata = attributes["attributes"]["metadata"]
            assert isinstance(metadata, list) and len(metadata) > 0
            first_attr = metadata[0]
            assert isinstance(first_attr, dict)
            assert "_raw_attributes" not in first_attr
            assert "Category" in first_attr
            assert "AWSService" in first_attr

    def test_compliance_overview_attributes_resolves_provider_from_scan(
        self,
        authenticated_client,
        tenants_fixture,
        gcp_provider,
        azure_provider,
    ):
        # csa_ccm_4.0 is a multi-provider universal framework: a single
        # compliance_id whose requirements expose different checks per provider.
        # Passing a scan must return the check IDs for that scan's provider,
        # otherwise the endpoint defaults to the first provider that declares the
        # framework and azure/gcp requirements end up with check IDs that match
        # no findings.
        tenant = tenants_fixture[0]
        assert gcp_provider.provider == Provider.ProviderChoices.GCP.value
        assert azure_provider.provider == Provider.ProviderChoices.AZURE.value

        now = datetime.now(UTC)
        gcp_scan = Scan.objects.create(
            name="gcp scan",
            provider=gcp_provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant_id=tenant.id,
            started_at=now,
            completed_at=now,
        )
        azure_scan = Scan.objects.create(
            name="azure scan",
            provider=azure_provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant_id=tenant.id,
            started_at=now,
            completed_at=now,
        )

        def request_attributes(scan_id=None):
            params = {"filter[compliance_id]": "csa_ccm_4.0"}
            if scan_id is not None:
                params["filter[scan_id]"] = str(scan_id)
            return authenticated_client.get(
                reverse("complianceoverview-attributes"), params
            )

        def collect_check_ids(scan_id=None):
            response = request_attributes(scan_id)
            assert response.status_code == status.HTTP_200_OK
            check_ids = set()
            for item in response.json()["data"]:
                check_ids.update(item["attributes"]["attributes"]["check_ids"])
            return check_ids

        gcp_check_ids = collect_check_ids(gcp_scan.id)
        azure_check_ids = collect_check_ids(azure_scan.id)

        # Each scan resolves to its own provider's checks, and they differ.
        assert gcp_check_ids
        assert azure_check_ids
        assert gcp_check_ids != azure_check_ids

        # The returned check IDs belong to the SDK's per-provider definition.
        from api.compliance import get_prowler_provider_compliance

        def expected_check_ids(provider_type):
            framework = get_prowler_provider_compliance(provider_type)["csa_ccm_4.0"]
            expected = set()
            for requirement in framework.requirements:
                expected.update(requirement.checks.get(provider_type, []))
            return expected

        assert gcp_check_ids <= expected_check_ids(Provider.ProviderChoices.GCP.value)
        assert azure_check_ids <= expected_check_ids(
            Provider.ProviderChoices.AZURE.value
        )

        # An explicit scan_id is authoritative: a non-existent scan must fail
        # closed with 404 instead of silently falling back to another provider.
        missing_response = request_attributes("00000000-0000-0000-0000-000000000000")
        assert missing_response.status_code == status.HTTP_404_NOT_FOUND

        # A malformed scan_id is rejected with 404 as well.
        malformed_response = request_attributes("not-a-uuid")
        assert malformed_response.status_code == status.HTTP_404_NOT_FOUND

        # An empty value (filter[scan_id]=) must not fall back to the legacy
        # provider picker: the explicit (if blank) selector fails closed.
        empty_response = request_attributes("")
        assert empty_response.status_code == status.HTTP_404_NOT_FOUND

        # A scan belonging to another tenant is not visible (RLS), so it must
        # return 404 rather than leaking the fallback provider's check IDs.
        other_tenant = Tenant.objects.create(name="Other Compliance Tenant")
        foreign_provider = Provider.objects.create(
            provider="gcp",
            uid="foreign-gcp-test",
            alias="foreign_gcp",
            tenant_id=other_tenant.id,
        )
        foreign_scan = Scan.objects.create(
            name="foreign scan",
            provider=foreign_provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant_id=other_tenant.id,
            started_at=now,
            completed_at=now,
        )
        foreign_response = request_attributes(foreign_scan.id)
        assert foreign_response.status_code == status.HTTP_404_NOT_FOUND

    def test_compliance_overview_attributes_scan_scoped_by_provider_group(
        self,
        authenticated_client_no_permissions_rbac,
        gcp_provider,
        azure_provider,
    ):
        # A user with limited visibility (no UNLIMITED_VISIBILITY) must only be
        # able to resolve scans for providers in its provider groups. Tenant RLS
        # alone is not enough here: both scans belong to the same tenant, so the
        # endpoint has to scope the scan lookup by provider group, otherwise a
        # restricted user could read another provider's compliance metadata.
        client = authenticated_client_no_permissions_rbac
        limited_user = client.user
        membership = Membership.objects.filter(user=limited_user).first()
        tenant = membership.tenant

        allowed_provider = gcp_provider
        denied_provider = azure_provider
        assert allowed_provider.provider == Provider.ProviderChoices.GCP.value
        assert denied_provider.provider == Provider.ProviderChoices.AZURE.value

        provider_group = ProviderGroup.objects.create(
            name="limited-compliance-group",
            tenant_id=tenant.id,
        )
        ProviderGroupMembership.objects.create(
            tenant_id=tenant.id,
            provider_group=provider_group,
            provider=allowed_provider,
        )
        RoleProviderGroupRelationship.objects.create(
            tenant_id=tenant.id,
            role=limited_user.roles.first(),
            provider_group=provider_group,
        )

        now = datetime.now(UTC)
        allowed_scan = Scan.objects.create(
            name="allowed scan",
            provider=allowed_provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant_id=tenant.id,
            started_at=now,
            completed_at=now,
        )
        denied_scan = Scan.objects.create(
            name="denied scan",
            provider=denied_provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant_id=tenant.id,
            started_at=now,
            completed_at=now,
        )

        def request_attributes(scan_id):
            return client.get(
                reverse("complianceoverview-attributes"),
                {
                    "filter[compliance_id]": "csa_ccm_4.0",
                    "filter[scan_id]": str(scan_id),
                },
            )

        # The scan in the user's provider group resolves normally.
        assert request_attributes(allowed_scan.id).status_code == status.HTTP_200_OK

        # The scan outside the user's provider group is invisible, so it fails
        # closed with 404 instead of leaking the other provider's check IDs.
        assert (
            request_attributes(denied_scan.id).status_code == status.HTTP_404_NOT_FOUND
        )

    def test_compliance_overview_attributes_missing_compliance_id(
        self, authenticated_client
    ):
        response = authenticated_client.get(
            reverse("complianceoverview-attributes"),
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_compliance_overview_attributes_503_while_warming(
        self, authenticated_client
    ):
        from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED

        COMPLIANCE_WARMING_STARTED.set()
        COMPLIANCE_WARMED.clear()
        try:
            response = authenticated_client.get(
                reverse("complianceoverview-attributes"),
                {"filter[compliance_id]": "aws_account_security_onboarding_aws"},
            )
        finally:
            COMPLIANCE_WARMING_STARTED.clear()

        assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
        assert response.json()["errors"][0]["code"] == "compliance_warming"

    def test_compliance_overview_attributes_serves_when_warming_not_started(
        self, authenticated_client
    ):
        # Dev fallback: under runserver warming never runs, so the guard must
        # not refuse — the endpoint lazily loads and serves as before.
        from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED

        COMPLIANCE_WARMING_STARTED.clear()
        COMPLIANCE_WARMED.clear()
        response = authenticated_client.get(
            reverse("complianceoverview-attributes"),
            {"filter[compliance_id]": "aws_account_security_onboarding_aws"},
        )
        assert response.status_code == status.HTTP_200_OK

    def test_compliance_overview_task_management_integration(
        self, authenticated_client, compliance_requirements_overviews_fixture
    ):
        """Test that task management mixin is properly integrated"""
        from unittest.mock import patch

        requirement_overview1 = compliance_requirements_overviews_fixture[0]
        scan_id = str(requirement_overview1.scan.id)

        # Remove existing compliance data so the view falls back to task checks
        scan = requirement_overview1.scan
        ComplianceOverviewSummary.objects.filter(scan=scan).delete()
        ComplianceRequirementOverview.objects.filter(scan=scan).delete()

        # Mock a running task
        with patch.object(
            ComplianceOverviewViewSet, "get_task_response_if_running"
        ) as mock_task_response:
            mock_response = Response(
                {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED
            )
            mock_task_response.return_value = mock_response

            response = authenticated_client.get(
                reverse("complianceoverview-list"),
                {"filter[scan_id]": scan_id},
            )
            assert response.status_code == status.HTTP_202_ACCEPTED
            mock_task_response.assert_called_once()

    def test_compliance_overview_task_failed_exception(
        self, authenticated_client, compliance_requirements_overviews_fixture
    ):
        """Test handling of TaskFailedException"""
        from unittest.mock import patch

        from api.exceptions import TaskFailedException

        requirement_overview1 = compliance_requirements_overviews_fixture[0]
        scan_id = str(requirement_overview1.scan.id)

        # Remove existing compliance data so the view falls back to task checks
        scan = requirement_overview1.scan
        ComplianceOverviewSummary.objects.filter(scan=scan).delete()
        ComplianceRequirementOverview.objects.filter(scan=scan).delete()

        # Mock a failed task
        with patch.object(
            ComplianceOverviewViewSet, "get_task_response_if_running"
        ) as mock_task_response:
            mock_task_response.side_effect = TaskFailedException("Task failed")

            response = authenticated_client.get(
                reverse("complianceoverview-list"),
                {"filter[scan_id]": scan_id},
            )
            assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
            assert "Task failed to generate compliance overview data" in str(
                response.data
            )

    @pytest.mark.parametrize(
        "filter_name, filter_value_attr, expected_count_min",
        [
            ("scan_id", "scan.id", 1),
            ("compliance_id", "compliance_id", 1),
            ("framework", "framework", 1),
            ("version", "version", 1),
            ("region", "region", 1),
            ("region__in", "region", 1),
            ("region.in", "region", 1),
        ],
    )
    def test_compliance_overview_filters(
        self,
        authenticated_client,
        compliance_requirements_overviews_fixture,
        filter_name,
        filter_value_attr,
        expected_count_min,
    ):
        requirement_overview = compliance_requirements_overviews_fixture[0]
        scan_id = str(requirement_overview.scan.id)

        filter_value = requirement_overview
        for attr in filter_value_attr.split("."):
            filter_value = getattr(filter_value, attr)

        filter_value = str(filter_value)

        query_params = {
            "filter[scan_id]": scan_id,
            f"filter[{filter_name}]": filter_value,
        }

        if filter_name == "scan_id":
            query_params = {"filter[scan_id]": filter_value}

        response = authenticated_client.get(
            reverse("complianceoverview-list"),
            query_params,
        )

        assert response.status_code == status.HTTP_200_OK
        response_data = response.json()

        assert len(response_data["data"]) >= expected_count_min

        if response_data["data"]:
            first_item = response_data["data"][0]
            assert "id" in first_item
            assert "type" in first_item
            assert first_item["type"] == "compliance-overviews"
            assert "attributes" in first_item

            attributes = first_item["attributes"]
            assert "framework" in attributes
            assert "version" in attributes
            assert "requirements_passed" in attributes
            assert "requirements_failed" in attributes
            assert "requirements_manual" in attributes
            assert "total_requirements" in attributes

            if filter_name == "compliance_id":
                assert first_item["id"] == filter_value
            elif filter_name == "framework":
                assert attributes["framework"] == filter_value
            elif filter_name == "version":
                assert attributes["version"] == filter_value


@pytest.mark.django_db
class TestOverviewViewSet:
    def test_overview_list_invalid_method(self, authenticated_client):
        response = authenticated_client.put(reverse("overview-list"))
        assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED

    @pytest.mark.usefixtures("scan_summaries_fixture")
    def test_overview_providers_list(self, authenticated_client, resources_fixture):
        response = authenticated_client.get(reverse("overview-providers"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert response.json()["data"][0]["attributes"]["findings"]["total"] == 9
        assert response.json()["data"][0]["attributes"]["findings"]["pass"] == 2
        assert response.json()["data"][0]["attributes"]["findings"]["fail"] == 1
        assert response.json()["data"][0]["attributes"]["findings"]["muted"] == 6
        # Aggregated resources include all AWS providers present in the tenant
        assert response.json()["data"][0]["attributes"]["resources"]["total"] == 3

    @pytest.mark.usefixtures("scan_summaries_fixture")
    def test_overview_providers_aggregates_same_provider_type(
        self,
        authenticated_client,
        resources_fixture,
        tenants_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        _provider1, provider2 = aws_provider_pair

        scan = Scan.objects.create(
            name="overview scan aws account 2",
            provider=provider2,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        ScanSummary.objects.create(
            tenant=tenant,
            scan=scan,
            check_id="check-aws-two",
            service="service-extra",
            severity="medium",
            region="region-extra",
            _pass=3,
            fail=2,
            muted=1,
            total=6,
        )

        Resource.objects.create(
            tenant_id=tenant.id,
            provider=provider2,
            uid="arn:aws:ec2:us-west-2:123456789013:instance/i-aggregation",
            name="Aggregated Instance",
            region="us-west-2",
            service="ec2",
            type="prowler-test",
        )

        response = authenticated_client.get(reverse("overview-providers"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attributes = data[0]["attributes"]

        assert attributes["findings"]["total"] == 15
        assert attributes["findings"]["pass"] == 5
        assert attributes["findings"]["fail"] == 3
        assert attributes["findings"]["muted"] == 7
        assert attributes["resources"]["total"] == 4

    @pytest.mark.usefixtures("scan_summaries_fixture")
    def test_overview_providers_count(
        self,
        authenticated_client,
        resources_fixture,
        aws_provider,
        tenants_fixture,
    ):
        tenant = tenants_fixture[0]

        default_response = authenticated_client.get(reverse("overview-providers"))
        assert default_response.status_code == status.HTTP_200_OK
        default_data = default_response.json()["data"]
        assert len(default_data) == 1
        assert all("count" not in item["attributes"] for item in default_data)
        grouped_response = authenticated_client.get(reverse("overview-providers-count"))
        assert grouped_response.status_code == status.HTTP_200_OK
        grouped_data = grouped_response.json()["data"]
        assert len(grouped_data) >= 1

        aggregated = {
            entry["id"]: entry["attributes"]["count"] for entry in grouped_data
        }
        db_counts = (
            Provider.objects.filter(tenant_id=tenant.id, is_deleted=False)
            .values("provider")
            .annotate(count=Count("id"))
        )
        expected = {row["provider"]: row["count"] for row in db_counts}

        assert aggregated == expected
        for entry in grouped_data:
            assert "findings" not in entry["attributes"]

    def test_overview_providers_count_applies_limited_visibility(
        self,
        authenticated_client_no_permissions_rbac,
        provider_groups_fixture,
        tenants_fixture,
        gcp_provider,
        azure_provider,
    ):
        tenant = tenants_fixture[0]
        client = authenticated_client_no_permissions_rbac
        allowed_provider = gcp_provider
        denied_provider = azure_provider
        provider_group = provider_groups_fixture[0]

        ProviderGroupMembership.objects.create(
            tenant_id=tenant.id,
            provider_group=provider_group,
            provider=allowed_provider,
        )
        RoleProviderGroupRelationship.objects.create(
            tenant_id=tenant.id,
            role=client.user.roles.first(),
            provider_group=provider_group,
        )

        response = client.get(reverse("overview-providers-count"))

        assert response.status_code == status.HTTP_200_OK
        aggregated = {
            entry["id"]: entry["attributes"]["count"]
            for entry in response.json()["data"]
        }
        assert aggregated == {allowed_provider.provider: 1}
        assert denied_provider.provider not in aggregated

    def _create_scan(self, tenant, provider, name, started_at=None):
        scan_started = started_at or datetime.now(UTC) - timedelta(hours=1)
        return Scan.objects.create(
            tenant=tenant,
            provider=provider,
            name=name,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            started_at=scan_started,
            completed_at=scan_started + timedelta(minutes=30),
        )

    def _create_threatscore_snapshot(
        self,
        tenant,
        scan,
        provider,
        *,
        compliance_id,
        overall_score,
        score_delta,
        section_scores,
        critical_requirements,
        total_requirements,
        passed_requirements,
        failed_requirements,
        manual_requirements,
        total_findings,
        passed_findings,
        failed_findings,
    ):
        return ThreatScoreSnapshot.objects.create(
            tenant=tenant,
            scan=scan,
            provider=provider,
            compliance_id=compliance_id,
            overall_score=Decimal(overall_score),
            score_delta=Decimal(score_delta) if score_delta is not None else None,
            section_scores=section_scores,
            critical_requirements=critical_requirements,
            total_requirements=total_requirements,
            passed_requirements=passed_requirements,
            failed_requirements=failed_requirements,
            manual_requirements=manual_requirements,
            total_findings=total_findings,
            passed_findings=passed_findings,
            failed_findings=failed_findings,
        )

    def test_overview_threatscore_returns_weighted_aggregate_snapshot(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        scan1 = self._create_scan(tenant, provider1, "agg-scan-one")
        scan2 = self._create_scan(tenant, provider2, "agg-scan-two")

        snapshot1 = self._create_threatscore_snapshot(
            tenant,
            scan1,
            provider1,
            compliance_id="prowler_threatscore_aws",
            overall_score="80.00",
            score_delta="5.00",
            section_scores={"1. IAM": "70.00", "2. Attack Surface": "60.00"},
            critical_requirements=[
                {
                    "requirement_id": "req_shared",
                    "title": "Shared requirement (preferred)",
                    "section": "1. IAM",
                    "subsection": "Sub IAM",
                    "risk_level": 5,
                    "weight": 150,
                    "passed_findings": 14,
                    "total_findings": 20,
                    "description": "Higher risk duplicate",
                },
                {
                    "requirement_id": "req_unique_one",
                    "title": "Unique provider one",
                    "section": "2. Attack Surface",
                    "subsection": "Sub Attack",
                    "risk_level": 4,
                    "weight": 90,
                    "passed_findings": 20,
                    "total_findings": 30,
                    "description": "Lower risk",
                },
            ],
            total_requirements=120,
            passed_requirements=90,
            failed_requirements=30,
            manual_requirements=0,
            total_findings=100,
            passed_findings=70,
            failed_findings=30,
        )

        snapshot2 = self._create_threatscore_snapshot(
            tenant,
            scan2,
            provider2,
            compliance_id="prowler_threatscore_aws",
            overall_score="20.00",
            score_delta="-2.00",
            section_scores={
                "1. IAM": "10.00",
                "2. Attack Surface": "40.00",
                "3. Logging": "30.00",
            },
            critical_requirements=[
                {
                    "requirement_id": "req_shared",
                    "title": "Shared requirement (secondary)",
                    "section": "1. IAM",
                    "subsection": "Sub IAM",
                    "risk_level": 4,
                    "weight": 120,
                    "passed_findings": 8,
                    "total_findings": 12,
                    "description": "Lower risk duplicate",
                },
                {
                    "requirement_id": "req_unique_two",
                    "title": "Unique provider two",
                    "section": "3. Logging",
                    "subsection": "Sub Logging",
                    "risk_level": 5,
                    "weight": 110,
                    "passed_findings": 6,
                    "total_findings": 10,
                    "description": "Another critical requirement",
                },
            ],
            total_requirements=80,
            passed_requirements=30,
            failed_requirements=50,
            manual_requirements=0,
            total_findings=50,
            passed_findings=15,
            failed_findings=35,
        )

        older_inserted = datetime(2025, 1, 1, 12, 0, tzinfo=UTC)
        newer_inserted = datetime(2025, 1, 2, 12, 0, tzinfo=UTC)
        ThreatScoreSnapshot.objects.filter(id=snapshot1.id).update(
            inserted_at=older_inserted
        )
        ThreatScoreSnapshot.objects.filter(id=snapshot2.id).update(
            inserted_at=newer_inserted
        )
        snapshot2.refresh_from_db()

        response = authenticated_client.get(reverse("overview-threatscore"))

        assert response.status_code == status.HTTP_200_OK
        body = response.json()
        assert len(body["data"]) == 1
        aggregated = body["data"][0]

        assert aggregated["id"] == "n/a"
        assert aggregated["relationships"]["scan"]["data"] is None
        assert aggregated["relationships"]["provider"]["data"] is None

        attrs = aggregated["attributes"]
        assert Decimal(attrs["overall_score"]) == Decimal("60.00")
        assert Decimal(attrs["score_delta"]) == Decimal("2.67")
        assert attrs["inserted_at"] == snapshot2.inserted_at.isoformat().replace(
            "+00:00", "Z"
        )
        assert attrs["total_findings"] == 150
        assert attrs["passed_findings"] == 85
        assert attrs["failed_findings"] == 65
        assert attrs["total_requirements"] == 200
        assert attrs["passed_requirements"] == 120
        assert attrs["failed_requirements"] == 80
        assert attrs["manual_requirements"] == 0

        assert attrs["section_scores"] == {
            "1. IAM": "50.00",
            "2. Attack Surface": "53.33",
            "3. Logging": "30.00",
        }

        expected_critical = [
            {
                "requirement_id": "req_shared",
                "title": "Shared requirement (preferred)",
                "section": "1. IAM",
                "subsection": "Sub IAM",
                "risk_level": 5,
                "weight": 150,
                "passed_findings": 14,
                "total_findings": 20,
                "description": "Higher risk duplicate",
            },
            {
                "requirement_id": "req_unique_two",
                "title": "Unique provider two",
                "section": "3. Logging",
                "subsection": "Sub Logging",
                "risk_level": 5,
                "weight": 110,
                "passed_findings": 6,
                "total_findings": 10,
                "description": "Another critical requirement",
            },
            {
                "requirement_id": "req_unique_one",
                "title": "Unique provider one",
                "section": "2. Attack Surface",
                "subsection": "Sub Attack",
                "risk_level": 4,
                "weight": 90,
                "passed_findings": 20,
                "total_findings": 30,
                "description": "Lower risk",
            },
        ]
        assert attrs["critical_requirements"] == expected_critical

    def test_overview_threatscore_weight_fallback_to_requirements(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        scan1 = self._create_scan(tenant, provider1, "fallback-scan-1")
        scan2 = self._create_scan(tenant, provider2, "fallback-scan-2")

        self._create_threatscore_snapshot(
            tenant,
            scan1,
            provider1,
            compliance_id="prowler_threatscore_aws",
            overall_score="90.00",
            score_delta="4.00",
            section_scores={"1. IAM": "90.00"},
            critical_requirements=[],
            total_requirements=10,
            passed_requirements=8,
            failed_requirements=0,
            manual_requirements=2,
            total_findings=0,
            passed_findings=0,
            failed_findings=0,
        )
        self._create_threatscore_snapshot(
            tenant,
            scan2,
            provider2,
            compliance_id="prowler_threatscore_aws",
            overall_score="50.00",
            score_delta="1.00",
            section_scores={"1. IAM": "40.00"},
            critical_requirements=[],
            total_requirements=12,
            passed_requirements=5,
            failed_requirements=7,
            manual_requirements=0,
            total_findings=10,
            passed_findings=4,
            failed_findings=6,
        )

        response = authenticated_client.get(reverse("overview-threatscore"))
        assert response.status_code == status.HTTP_200_OK
        aggregate = response.json()["data"][0]["attributes"]

        assert Decimal(aggregate["overall_score"]) == Decimal("67.78")
        assert Decimal(aggregate["score_delta"]) == Decimal("2.33")
        assert aggregate["total_findings"] == 10
        assert aggregate["total_requirements"] == 22
        assert aggregate["manual_requirements"] == 2
        assert aggregate["section_scores"] == {"1. IAM": "62.22"}

    def test_overview_threatscore_filter_by_scan_id_returns_snapshot(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
    ):
        tenant = tenants_fixture[0]
        provider1 = aws_provider
        scan = self._create_scan(tenant, provider1, "filter-scan")

        snapshot = self._create_threatscore_snapshot(
            tenant,
            scan,
            provider1,
            compliance_id="prowler_threatscore_aws",
            overall_score="75.00",
            score_delta="3.00",
            section_scores={"1. IAM": "70.00"},
            critical_requirements=[],
            total_requirements=50,
            passed_requirements=30,
            failed_requirements=20,
            manual_requirements=0,
            total_findings=25,
            passed_findings=15,
            failed_findings=10,
        )

        response = authenticated_client.get(
            reverse("overview-threatscore"), {"filter[scan_id]": str(scan.id)}
        )

        assert response.status_code == status.HTTP_200_OK
        body = response.json()
        assert len(body["data"]) == 1
        assert body["data"][0]["id"] == str(snapshot.id)
        assert body["data"][0]["attributes"]["overall_score"] == "75.00"

    def test_overview_threatscore_snapshot_id_returns_specific_snapshot(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
    ):
        tenant = tenants_fixture[0]
        provider1 = aws_provider
        scan = self._create_scan(tenant, provider1, "snapshot-id-scan")

        snapshot = self._create_threatscore_snapshot(
            tenant,
            scan,
            provider1,
            compliance_id="prowler_threatscore_aws",
            overall_score="88.50",
            score_delta=None,
            section_scores={"1. IAM": "80.00"},
            critical_requirements=[],
            total_requirements=60,
            passed_requirements=45,
            failed_requirements=15,
            manual_requirements=0,
            total_findings=30,
            passed_findings=25,
            failed_findings=5,
        )

        response = authenticated_client.get(
            reverse("overview-threatscore"), {"snapshot_id": str(snapshot.id)}
        )

        assert response.status_code == status.HTTP_200_OK
        data = response.json()
        assert data["data"]["id"] == str(snapshot.id)
        assert data["data"]["attributes"]["score_delta"] is None

    def test_overview_threatscore_provider_filter_returns_unaggregated_snapshot(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        scan1 = self._create_scan(tenant, provider1, "provider-filter-scan-1")
        scan2 = self._create_scan(tenant, provider2, "provider-filter-scan-2")

        snapshot1 = self._create_threatscore_snapshot(
            tenant,
            scan1,
            provider1,
            compliance_id="prowler_threatscore_aws",
            overall_score="55.55",
            score_delta="1.10",
            section_scores={"1. IAM": "50.00"},
            critical_requirements=[],
            total_requirements=40,
            passed_requirements=25,
            failed_requirements=15,
            manual_requirements=0,
            total_findings=12,
            passed_findings=7,
            failed_findings=5,
        )
        self._create_threatscore_snapshot(
            tenant,
            scan2,
            provider2,
            compliance_id="prowler_threatscore_aws",
            overall_score="44.44",
            score_delta="0.80",
            section_scores={"1. IAM": "40.00"},
            critical_requirements=[],
            total_requirements=30,
            passed_requirements=18,
            failed_requirements=12,
            manual_requirements=0,
            total_findings=10,
            passed_findings=6,
            failed_findings=4,
        )

        response = authenticated_client.get(
            reverse("overview-threatscore"),
            {"filter[provider_id__in]": str(provider1.id)},
        )

        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == str(snapshot1.id)
        assert data[0]["attributes"]["overall_score"] == "55.55"

    @pytest.mark.usefixtures("scan_summaries_fixture")
    def test_overview_services_list_no_required_filters(self, authenticated_client):
        response = authenticated_client.get(reverse("overview-services"))
        assert response.status_code == status.HTTP_200_OK
        # Should return services from latest scans
        assert len(response.json()["data"]) == 2

    @pytest.mark.usefixtures("scan_summaries_fixture")
    def test_overview_regions_list(self, authenticated_client):
        response = authenticated_client.get(
            reverse("overview-regions"), {"filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        # Only two different regions in the fixture (region1, region2)
        assert len(response.json()["data"]) == 2

        data = response.json()["data"]
        regions = {item["id"]: item["attributes"] for item in data}

        assert "aws:region1" in regions
        assert "aws:region2" in regions

        # region1 has 5 findings (2 pass, 0 fail, 3 muted)
        assert regions["aws:region1"]["total"] == 5
        assert regions["aws:region1"]["pass"] == 2
        assert regions["aws:region1"]["fail"] == 0
        assert regions["aws:region1"]["muted"] == 3

        # region2 has 4 findings (0 pass, 1 fail, 3 muted)
        assert regions["aws:region2"]["total"] == 4
        assert regions["aws:region2"]["pass"] == 0
        assert regions["aws:region2"]["fail"] == 1
        assert regions["aws:region2"]["muted"] == 3

    @pytest.mark.usefixtures("scan_summaries_fixture")
    def test_overview_services_list(self, authenticated_client):
        response = authenticated_client.get(
            reverse("overview-services"), {"filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        # Only two different services
        assert len(response.json()["data"]) == 2
        # Fixed data from the fixture
        service1_data = response.json()["data"][0]
        service2_data = response.json()["data"][1]
        assert service1_data["id"] == "service1"
        assert service2_data["id"] == "service2"

        assert service1_data["attributes"]["total"] == 7
        assert service2_data["attributes"]["total"] == 2

        assert service1_data["attributes"]["pass"] == 1
        assert service2_data["attributes"]["pass"] == 1

        assert service1_data["attributes"]["fail"] == 1
        assert service2_data["attributes"]["fail"] == 0

        assert service1_data["attributes"]["muted"] == 5
        assert service2_data["attributes"]["muted"] == 1

    def test_overview_findings_provider_id_in_filter(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        scan1 = Scan.objects.create(
            name="scan-one",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )
        scan2 = Scan.objects.create(
            name="scan-two",
            provider=provider2,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        ScanSummary.objects.create(
            tenant=tenant,
            scan=scan1,
            check_id="check-provider-one",
            service="service-a",
            severity="high",
            region="region-a",
            _pass=5,
            fail=1,
            muted=2,
            total=8,
            new=5,
            changed=2,
            unchanged=1,
            fail_new=1,
            fail_changed=0,
            pass_new=3,
            pass_changed=2,
            muted_new=1,
            muted_changed=1,
        )

        ScanSummary.objects.create(
            tenant=tenant,
            scan=scan2,
            check_id="check-provider-two",
            service="service-b",
            severity="medium",
            region="region-b",
            _pass=2,
            fail=3,
            muted=1,
            total=6,
            new=3,
            changed=2,
            unchanged=1,
            fail_new=2,
            fail_changed=1,
            pass_new=1,
            pass_changed=1,
            muted_new=1,
            muted_changed=0,
        )

        single_response = authenticated_client.get(
            reverse("overview-findings"),
            {"filter[provider_id__in]": str(provider1.id)},
        )
        assert single_response.status_code == status.HTTP_200_OK
        single_attributes = single_response.json()["data"]["attributes"]
        assert single_attributes["pass"] == 5
        assert single_attributes["fail"] == 1
        assert single_attributes["muted"] == 2
        assert single_attributes["total"] == 8

        combined_response = authenticated_client.get(
            reverse("overview-findings"),
            {"filter[provider_id__in]": f"{provider1.id},{provider2.id}"},
        )
        assert combined_response.status_code == status.HTTP_200_OK
        combined_attributes = combined_response.json()["data"]["attributes"]
        assert combined_attributes["pass"] == 7
        assert combined_attributes["fail"] == 4
        assert combined_attributes["muted"] == 3
        assert combined_attributes["total"] == 14

    def test_overview_findings_provider_groups_filter(
        self,
        authenticated_client,
        tenants_fixture,
        provider_groups_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair
        group1, group2, *_ = provider_groups_fixture
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group1
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group2
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider2, provider_group=group2
        )

        scan1 = Scan.objects.create(
            name="scan-provider-group-one",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )
        scan2 = Scan.objects.create(
            name="scan-provider-group-two",
            provider=provider2,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )
        ScanSummary.objects.create(
            tenant=tenant,
            scan=scan1,
            check_id="check-provider-group-one",
            service="service-a",
            severity="high",
            region="region-a",
            _pass=5,
            fail=1,
            muted=2,
            total=8,
        )
        ScanSummary.objects.create(
            tenant=tenant,
            scan=scan2,
            check_id="check-provider-group-two",
            service="service-b",
            severity="medium",
            region="region-b",
            _pass=2,
            fail=3,
            muted=1,
            total=6,
        )

        response = authenticated_client.get(
            reverse("overview-findings"),
            {"filter[provider_groups]": str(group1.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        attributes = response.json()["data"]["attributes"]
        assert attributes["pass"] == 5
        assert attributes["fail"] == 1
        assert attributes["muted"] == 2
        assert attributes["total"] == 8

        response = authenticated_client.get(
            reverse("overview-findings"),
            {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
        )
        assert response.status_code == status.HTTP_200_OK
        attributes = response.json()["data"]["attributes"]
        assert attributes["pass"] == 7
        assert attributes["fail"] == 4
        assert attributes["muted"] == 3
        assert attributes["total"] == 14

    def test_overview_findings_severity_provider_id_in_filter(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        scan1 = Scan.objects.create(
            name="severity-scan-one",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )
        scan2 = Scan.objects.create(
            name="severity-scan-two",
            provider=provider2,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        # Muted findings should be excluded from severity counts
        ScanSummary.objects.create(
            tenant=tenant,
            scan=scan1,
            check_id="severity-check-one",
            service="service-a",
            severity="high",
            region="region-a",
            _pass=4,
            fail=4,
            muted=3,
            total=11,
        )
        ScanSummary.objects.create(
            tenant=tenant,
            scan=scan1,
            check_id="severity-check-two",
            service="service-a",
            severity="medium",
            region="region-b",
            _pass=2,
            fail=2,
            muted=2,
            total=6,
        )
        ScanSummary.objects.create(
            tenant=tenant,
            scan=scan2,
            check_id="severity-check-three",
            service="service-b",
            severity="critical",
            region="region-c",
            _pass=1,
            fail=2,
            muted=5,
            total=8,
        )

        single_response = authenticated_client.get(
            reverse("overview-findings_severity"),
            {"filter[provider_id__in]": str(provider1.id)},
        )
        assert single_response.status_code == status.HTTP_200_OK
        single_attributes = single_response.json()["data"]["attributes"]
        # Should only count pass + fail, excluding muted (3 muted in high, 2 in medium)
        assert single_attributes["high"] == 8
        assert single_attributes["medium"] == 4
        assert single_attributes["critical"] == 0

        combined_response = authenticated_client.get(
            reverse("overview-findings_severity"),
            {"filter[provider_id__in]": f"{provider1.id},{provider2.id}"},
        )
        assert combined_response.status_code == status.HTTP_200_OK
        combined_attributes = combined_response.json()["data"]["attributes"]
        # Should only count pass + fail, excluding muted (5 muted in critical)
        assert combined_attributes["high"] == 8
        assert combined_attributes["medium"] == 4
        assert combined_attributes["critical"] == 3

    def test_overview_findings_severity_timeseries_requires_date_from(
        self, authenticated_client
    ):
        response = authenticated_client.get(
            reverse("overview-findings_severity_timeseries")
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "date_from" in response.json()["errors"][0]["source"]["pointer"]

    def test_overview_findings_severity_timeseries_invalid_date_format(
        self, authenticated_client
    ):
        response = authenticated_client.get(
            reverse("overview-findings_severity_timeseries"),
            {"filter[date_from]": "invalid-date"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "Enter a valid date." in response.json()["errors"][0]["detail"]

    def test_overview_findings_severity_timeseries_empty_data(
        self, authenticated_client
    ):
        response = authenticated_client.get(
            reverse("overview-findings_severity_timeseries"),
            {
                "filter[date_from]": "2024-01-01",
                "filter[date_to]": "2024-01-03",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        # Should return 3 days with fill-forward (all zeros since no data)
        assert len(data) == 3
        for item in data:
            assert item["attributes"]["critical"] == 0
            assert item["attributes"]["high"] == 0
            assert item["attributes"]["medium"] == 0
            assert item["attributes"]["low"] == 0
            assert item["attributes"]["informational"] == 0
            assert item["attributes"]["muted"] == 0
            assert item["attributes"]["scan_ids"] == []

    def test_overview_findings_severity_timeseries_with_data(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        # Create scan for day 1
        scan1 = Scan.objects.create(
            name="severity-over-time-scan-1",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
            completed_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC),
        )

        # Create scan for day 3
        scan3 = Scan.objects.create(
            name="severity-over-time-scan-3",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
            completed_at=datetime(2024, 1, 3, 12, 0, 0, tzinfo=UTC),
        )

        # Create DailySeveritySummary for day 1
        DailySeveritySummary.objects.create(
            tenant=tenant,
            provider=provider1,
            scan=scan1,
            date=date(2024, 1, 1),
            critical=10,
            high=20,
            medium=30,
            low=40,
            informational=50,
            muted=5,
        )

        # Create DailySeveritySummary for day 3
        DailySeveritySummary.objects.create(
            tenant=tenant,
            provider=provider1,
            scan=scan3,
            date=date(2024, 1, 3),
            critical=15,
            high=25,
            medium=35,
            low=45,
            informational=55,
            muted=10,
        )

        response = authenticated_client.get(
            reverse("overview-findings_severity_timeseries"),
            {
                "filter[date_from]": "2024-01-01",
                "filter[date_to]": "2024-01-03",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 3

        # Day 1 - actual data (id is the date)
        assert data[0]["id"] == "2024-01-01"
        assert data[0]["attributes"]["critical"] == 10
        assert data[0]["attributes"]["high"] == 20
        assert data[0]["attributes"]["scan_ids"] == [str(scan1.id)]

        # Day 2 - fill forward from day 1 (no data for this day)
        assert data[1]["id"] == "2024-01-02"
        assert data[1]["attributes"]["critical"] == 10
        assert data[1]["attributes"]["high"] == 20
        assert data[1]["attributes"]["scan_ids"] == [str(scan1.id)]

        # Day 3 - actual data
        assert data[2]["id"] == "2024-01-03"
        assert data[2]["attributes"]["critical"] == 15
        assert data[2]["attributes"]["high"] == 25
        assert data[2]["attributes"]["scan_ids"] == [str(scan3.id)]

    def test_overview_findings_severity_timeseries_aggregates_providers(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        # Same day, different providers
        scan1 = Scan.objects.create(
            name="severity-over-time-scan-p1",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
            completed_at=datetime(2024, 2, 1, 12, 0, 0, tzinfo=UTC),
        )
        scan2 = Scan.objects.create(
            name="severity-over-time-scan-p2",
            provider=provider2,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
            completed_at=datetime(2024, 2, 1, 14, 0, 0, tzinfo=UTC),
        )

        # Create DailySeveritySummary for provider1
        DailySeveritySummary.objects.create(
            tenant=tenant,
            provider=provider1,
            scan=scan1,
            date=date(2024, 2, 1),
            critical=10,
            high=20,
            medium=30,
            low=40,
            informational=50,
            muted=5,
        )

        # Create DailySeveritySummary for provider2
        DailySeveritySummary.objects.create(
            tenant=tenant,
            provider=provider2,
            scan=scan2,
            date=date(2024, 2, 1),
            critical=5,
            high=10,
            medium=15,
            low=20,
            informational=25,
            muted=3,
        )

        response = authenticated_client.get(
            reverse("overview-findings_severity_timeseries"),
            {
                "filter[date_from]": "2024-02-01",
                "filter[date_to]": "2024-02-01",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1

        # Should aggregate both providers
        assert data[0]["attributes"]["critical"] == 15  # 10 + 5
        assert data[0]["attributes"]["high"] == 30  # 20 + 10
        assert data[0]["attributes"]["medium"] == 45  # 30 + 15
        assert data[0]["attributes"]["low"] == 60  # 40 + 20
        assert data[0]["attributes"]["informational"] == 75  # 50 + 25
        assert data[0]["attributes"]["muted"] == 8  # 5 + 3
        # scan_ids should contain both scans (order may vary)
        assert set(data[0]["attributes"]["scan_ids"]) == {str(scan1.id), str(scan2.id)}

    def test_overview_findings_severity_timeseries_provider_filter(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        scan1 = Scan.objects.create(
            name="severity-over-time-filter-scan-p1",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
            completed_at=datetime(2024, 3, 1, 12, 0, 0, tzinfo=UTC),
        )
        scan2 = Scan.objects.create(
            name="severity-over-time-filter-scan-p2",
            provider=provider2,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
            completed_at=datetime(2024, 3, 1, 14, 0, 0, tzinfo=UTC),
        )

        # Provider 1 - critical=100
        DailySeveritySummary.objects.create(
            tenant=tenant,
            provider=provider1,
            scan=scan1,
            date=date(2024, 3, 1),
            critical=100,
            high=0,
            medium=0,
            low=0,
            informational=0,
            muted=0,
        )

        # Provider 2 - critical=50
        DailySeveritySummary.objects.create(
            tenant=tenant,
            provider=provider2,
            scan=scan2,
            date=date(2024, 3, 1),
            critical=50,
            high=0,
            medium=0,
            low=0,
            informational=0,
            muted=0,
        )

        # Filter by provider1 only
        response = authenticated_client.get(
            reverse("overview-findings_severity_timeseries"),
            {
                "filter[date_from]": "2024-03-01",
                "filter[date_to]": "2024-03-01",
                "filter[provider_id]": str(provider1.id),
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["attributes"]["critical"] == 100  # Only provider1
        assert data[0]["attributes"]["scan_ids"] == [str(scan1.id)]

    def test_overview_attack_surface_no_data(self, authenticated_client):
        response = authenticated_client.get(reverse("overview-attack-surface"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 4
        for item in data:
            assert item["attributes"]["total_findings"] == 0
            assert item["attributes"]["failed_findings"] == 0
            assert item["attributes"]["muted_failed_findings"] == 0

    def test_overview_attack_surface_with_data(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        create_attack_surface_overview,
    ):
        tenant = tenants_fixture[0]
        provider = aws_provider

        scan = Scan.objects.create(
            name="attack-surface-scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        create_attack_surface_overview(
            tenant,
            scan,
            AttackSurfaceOverview.AttackSurfaceTypeChoices.INTERNET_EXPOSED,
            total=20,
            failed=10,
            muted_failed=3,
        )
        create_attack_surface_overview(
            tenant,
            scan,
            AttackSurfaceOverview.AttackSurfaceTypeChoices.SECRETS,
            total=15,
            failed=8,
            muted_failed=2,
        )

        response = authenticated_client.get(reverse("overview-attack-surface"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 4

        results_by_type = {item["id"]: item["attributes"] for item in data}
        assert results_by_type["internet-exposed"]["total_findings"] == 20
        assert results_by_type["internet-exposed"]["failed_findings"] == 10
        assert results_by_type["secrets"]["total_findings"] == 15
        assert results_by_type["secrets"]["failed_findings"] == 8
        assert results_by_type["privilege-escalation"]["total_findings"] == 0
        assert results_by_type["ec2-imdsv1"]["total_findings"] == 0

    def test_overview_attack_surface_provider_filter(
        self,
        authenticated_client,
        tenants_fixture,
        create_attack_surface_overview,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        scan1 = Scan.objects.create(
            name="attack-surface-scan-1",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )
        scan2 = Scan.objects.create(
            name="attack-surface-scan-2",
            provider=provider2,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        create_attack_surface_overview(
            tenant,
            scan1,
            AttackSurfaceOverview.AttackSurfaceTypeChoices.INTERNET_EXPOSED,
            total=10,
            failed=5,
            muted_failed=1,
        )
        create_attack_surface_overview(
            tenant,
            scan2,
            AttackSurfaceOverview.AttackSurfaceTypeChoices.INTERNET_EXPOSED,
            total=20,
            failed=15,
            muted_failed=3,
        )

        response = authenticated_client.get(
            reverse("overview-attack-surface"),
            {"filter[provider_id]": str(provider1.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        results_by_type = {item["id"]: item["attributes"] for item in data}
        assert results_by_type["internet-exposed"]["total_findings"] == 10
        assert results_by_type["internet-exposed"]["failed_findings"] == 5

    @pytest.mark.usefixtures("scan_summaries_fixture")
    def test_overview_services_region_filter(self, authenticated_client):
        response = authenticated_client.get(
            reverse("overview-services"),
            {"filter[region]": "region1"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        service_ids = {item["id"] for item in data}
        assert service_ids == {"service1", "service2"}

    def test_overview_services_provider_type_filter(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        gcp_provider,
    ):
        tenant = tenants_fixture[0]

        aws_scan = Scan.objects.create(
            name="aws-scan",
            provider=aws_provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )
        gcp_scan = Scan.objects.create(
            name="gcp-scan",
            provider=gcp_provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        ScanSummary.objects.create(
            tenant=tenant,
            scan=aws_scan,
            check_id="aws-check",
            service="aws-service",
            severity="high",
            region="us-east-1",
            _pass=5,
            fail=2,
            muted=1,
            total=8,
        )
        ScanSummary.objects.create(
            tenant=tenant,
            scan=gcp_scan,
            check_id="gcp-check",
            service="gcp-service",
            severity="medium",
            region="us-central1",
            _pass=3,
            fail=1,
            muted=0,
            total=4,
        )

        response = authenticated_client.get(
            reverse("overview-services"),
            {"filter[provider_type]": "aws"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        service_ids = [item["id"] for item in data]
        assert "aws-service" in service_ids
        assert "gcp-service" not in service_ids

    @pytest.mark.parametrize(
        "status_filter,_field_to_check",
        [
            ("FAIL", "fail"),
            ("PASS", "_pass"),
        ],
    )
    def test_overview_findings_severity_status_filter(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        status_filter,
        _field_to_check,
    ):
        tenant = tenants_fixture[0]
        provider = aws_provider

        scan = Scan.objects.create(
            name="status-filter-scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        ScanSummary.objects.create(
            tenant=tenant,
            scan=scan,
            check_id="status-check-high",
            service="service-a",
            severity="high",
            region="us-east-1",
            _pass=10,
            fail=5,
            muted=3,
            total=18,
        )
        ScanSummary.objects.create(
            tenant=tenant,
            scan=scan,
            check_id="status-check-medium",
            service="service-a",
            severity="medium",
            region="us-east-1",
            _pass=8,
            fail=2,
            muted=1,
            total=11,
        )

        response = authenticated_client.get(
            reverse("overview-findings_severity"),
            {
                "filter[provider_id]": str(provider.id),
                "filter[status]": status_filter,
            },
        )
        assert response.status_code == status.HTTP_200_OK
        attrs = response.json()["data"]["attributes"]
        if status_filter == "FAIL":
            assert attrs["high"] == 5
            assert attrs["medium"] == 2
        else:
            assert attrs["high"] == 10
            assert attrs["medium"] == 8

    def test_overview_threatscore_compliance_id_filter(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
    ):
        tenant = tenants_fixture[0]
        provider = aws_provider
        scan = self._create_scan(tenant, provider, "compliance-filter-scan")

        self._create_threatscore_snapshot(
            tenant,
            scan,
            provider,
            compliance_id="prowler_threatscore_aws",
            overall_score="75.00",
            score_delta="2.00",
            section_scores={"1. IAM": "70.00"},
            critical_requirements=[],
            total_requirements=50,
            passed_requirements=35,
            failed_requirements=15,
            manual_requirements=0,
            total_findings=30,
            passed_findings=20,
            failed_findings=10,
        )
        self._create_threatscore_snapshot(
            tenant,
            scan,
            provider,
            compliance_id="cis_1.4_aws",
            overall_score="65.00",
            score_delta="1.00",
            section_scores={"1. IAM": "60.00"},
            critical_requirements=[],
            total_requirements=40,
            passed_requirements=25,
            failed_requirements=15,
            manual_requirements=0,
            total_findings=25,
            passed_findings=15,
            failed_findings=10,
        )

        response = authenticated_client.get(
            reverse("overview-threatscore"),
            {"filter[compliance_id]": "prowler_threatscore_aws"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["attributes"]["overall_score"] == "75.00"
        assert data[0]["attributes"]["compliance_id"] == "prowler_threatscore_aws"

    def test_overview_threatscore_provider_type_filter(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        gcp_provider,
    ):
        tenant = tenants_fixture[0]

        aws_scan = self._create_scan(tenant, aws_provider, "aws-threatscore-scan")
        gcp_scan = self._create_scan(tenant, gcp_provider, "gcp-threatscore-scan")

        self._create_threatscore_snapshot(
            tenant,
            aws_scan,
            aws_provider,
            compliance_id="prowler_threatscore_aws",
            overall_score="80.00",
            score_delta="3.00",
            section_scores={"1. IAM": "75.00"},
            critical_requirements=[],
            total_requirements=60,
            passed_requirements=45,
            failed_requirements=15,
            manual_requirements=0,
            total_findings=40,
            passed_findings=30,
            failed_findings=10,
        )
        self._create_threatscore_snapshot(
            tenant,
            gcp_scan,
            gcp_provider,
            compliance_id="prowler_threatscore_gcp",
            overall_score="70.00",
            score_delta="2.00",
            section_scores={"1. IAM": "65.00"},
            critical_requirements=[],
            total_requirements=50,
            passed_requirements=35,
            failed_requirements=15,
            manual_requirements=0,
            total_findings=35,
            passed_findings=25,
            failed_findings=10,
        )

        response = authenticated_client.get(
            reverse("overview-threatscore"),
            {"filter[provider_type]": "aws"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["attributes"]["overall_score"] == "80.00"

    def test_overview_categories_no_data(self, authenticated_client):
        response = authenticated_client.get(reverse("overview-categories"))
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"] == []

    def test_overview_categories_aggregates_by_category_with_severity(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        create_scan_category_summary,
    ):
        tenant = tenants_fixture[0]
        provider = aws_provider

        scan = Scan.objects.create(
            name="categories-scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        create_scan_category_summary(
            tenant,
            scan,
            "iam",
            "high",
            total_findings=20,
            failed_findings=10,
            new_failed_findings=5,
        )
        create_scan_category_summary(
            tenant,
            scan,
            "iam",
            "medium",
            total_findings=15,
            failed_findings=8,
            new_failed_findings=3,
        )
        create_scan_category_summary(
            tenant,
            scan,
            "encryption",
            "critical",
            total_findings=5,
            failed_findings=2,
            new_failed_findings=1,
        )

        response = authenticated_client.get(reverse("overview-categories"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2

        results_by_category = {item["id"]: item["attributes"] for item in data}

        assert results_by_category["iam"]["total_findings"] == 35
        assert results_by_category["iam"]["failed_findings"] == 18
        assert results_by_category["iam"]["new_failed_findings"] == 8
        assert results_by_category["iam"]["severity"]["high"] == 10
        assert results_by_category["iam"]["severity"]["medium"] == 8
        assert results_by_category["iam"]["severity"]["critical"] == 0

        assert results_by_category["encryption"]["total_findings"] == 5
        assert results_by_category["encryption"]["failed_findings"] == 2
        assert results_by_category["encryption"]["severity"]["critical"] == 2

    @pytest.mark.parametrize(
        "filter_key,filter_value_fn,expected_total,expected_failed",
        [
            ("filter[provider_id]", lambda p1, *_: str(p1.id), 10, 5),
            ("filter[provider_type]", lambda *_: "aws", 10, 5),
            ("filter[provider_type__in]", lambda *_: "aws,gcp", 30, 20),
            (
                "filter[provider_groups]",
                lambda p1, _, group1, __: str(group1.id),
                10,
                5,
            ),
            (
                "filter[provider_groups__in]",
                lambda p1, _, group1, group2: f"{group1.id},{group2.id}",
                30,
                20,
            ),
        ],
    )
    def test_overview_categories_filters(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        provider_groups_fixture,
        create_scan_category_summary,
        filter_key,
        filter_value_fn,
        expected_total,
        expected_failed,
        gcp_provider,
    ):
        tenant = tenants_fixture[0]
        provider1 = aws_provider
        group1, group2, *_ = provider_groups_fixture
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group1
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group2
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=gcp_provider, provider_group=group2
        )

        scan1 = Scan.objects.create(
            name="categories-scan-1",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )
        scan2 = Scan.objects.create(
            name="categories-scan-2",
            provider=gcp_provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        create_scan_category_summary(
            tenant, scan1, "iam", "high", total_findings=10, failed_findings=5
        )
        create_scan_category_summary(
            tenant, scan2, "iam", "high", total_findings=20, failed_findings=15
        )

        response = authenticated_client.get(
            reverse("overview-categories"),
            {filter_key: filter_value_fn(provider1, gcp_provider, group1, group2)},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["attributes"]["total_findings"] == expected_total
        assert data[0]["attributes"]["failed_findings"] == expected_failed

    def test_overview_categories_category_filter(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        create_scan_category_summary,
    ):
        tenant = tenants_fixture[0]
        provider = aws_provider

        scan = Scan.objects.create(
            name="category-filter-scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        create_scan_category_summary(
            tenant, scan, "iam", "high", total_findings=10, failed_findings=5
        )
        create_scan_category_summary(
            tenant, scan, "encryption", "medium", total_findings=20, failed_findings=8
        )
        create_scan_category_summary(
            tenant, scan, "logging", "low", total_findings=15, failed_findings=3
        )

        response = authenticated_client.get(
            reverse("overview-categories"),
            {"filter[category__in]": "iam,encryption"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        category_ids = {item["id"] for item in data}
        assert category_ids == {"iam", "encryption"}

    def test_overview_categories_aggregates_multiple_providers(
        self,
        authenticated_client,
        tenants_fixture,
        create_scan_category_summary,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        scan1 = Scan.objects.create(
            name="multi-provider-scan-1",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )
        scan2 = Scan.objects.create(
            name="multi-provider-scan-2",
            provider=provider2,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        create_scan_category_summary(
            tenant,
            scan1,
            "iam",
            "high",
            total_findings=10,
            failed_findings=5,
            new_failed_findings=2,
        )
        create_scan_category_summary(
            tenant,
            scan2,
            "iam",
            "high",
            total_findings=15,
            failed_findings=8,
            new_failed_findings=3,
        )

        response = authenticated_client.get(reverse("overview-categories"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == "iam"
        assert data[0]["attributes"]["total_findings"] == 25
        assert data[0]["attributes"]["failed_findings"] == 13
        assert data[0]["attributes"]["new_failed_findings"] == 5

    def test_overview_groups_no_data(self, authenticated_client):
        response = authenticated_client.get(reverse("overview-resource-groups"))
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"] == []

    def test_overview_groups_aggregates_by_group_with_severity(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        create_scan_resource_group_summary,
    ):
        tenant = tenants_fixture[0]
        provider = aws_provider

        scan = Scan.objects.create(
            name="resource-groups-scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        # resources_count is group-level (same for all severities within a group)
        create_scan_resource_group_summary(
            tenant,
            scan,
            "storage",
            "high",
            total_findings=20,
            failed_findings=10,
            new_failed_findings=5,
            resources_count=8,
        )
        create_scan_resource_group_summary(
            tenant,
            scan,
            "storage",
            "medium",
            total_findings=15,
            failed_findings=7,
            new_failed_findings=3,
            resources_count=8,  # Same as high - group-level count
        )
        create_scan_resource_group_summary(
            tenant,
            scan,
            "security",
            "critical",
            total_findings=10,
            failed_findings=8,
            new_failed_findings=2,
            resources_count=4,
        )

        response = authenticated_client.get(reverse("overview-resource-groups"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2

        storage_data = next(d for d in data if d["id"] == "storage")
        security_data = next(d for d in data if d["id"] == "security")

        assert storage_data["attributes"]["total_findings"] == 35
        assert storage_data["attributes"]["failed_findings"] == 17
        assert storage_data["attributes"]["new_failed_findings"] == 8
        assert (
            storage_data["attributes"]["resources_count"] == 8
        )  # Group-level, not sum
        assert security_data["attributes"]["total_findings"] == 10
        assert security_data["attributes"]["failed_findings"] == 8
        assert security_data["attributes"]["resources_count"] == 4

    @pytest.mark.parametrize(
        "filter_key,filter_value_fn,expected_total,expected_failed",
        [
            ("filter[provider_id]", lambda p1, *_: str(p1.id), 10, 5),
            ("filter[provider_id__in]", lambda p1, p2, *_: f"{p1.id},{p2.id}", 25, 12),
            ("filter[provider_type]", lambda *_: "aws", 10, 5),
            ("filter[provider_type__in]", lambda *_: "aws,gcp", 25, 12),
            (
                "filter[provider_groups]",
                lambda p1, p2, group1, group2: str(group1.id),
                10,
                5,
            ),
            (
                "filter[provider_groups__in]",
                lambda p1, p2, group1, group2: f"{group1.id},{group2.id}",
                25,
                12,
            ),
        ],
    )
    def test_overview_groups_provider_filters(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        provider_groups_fixture,
        create_scan_resource_group_summary,
        filter_key,
        filter_value_fn,
        expected_total,
        expected_failed,
        gcp_provider,
    ):
        tenant = tenants_fixture[0]
        provider1 = aws_provider  # AWS
        group1, group2, *_ = provider_groups_fixture
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group1
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group2
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=gcp_provider, provider_group=group2
        )

        scan1 = Scan.objects.create(
            name="aws-rg-scan",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )
        scan2 = Scan.objects.create(
            name="gcp-rg-scan",
            provider=gcp_provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        create_scan_resource_group_summary(
            tenant, scan1, "storage", "high", total_findings=10, failed_findings=5
        )
        create_scan_resource_group_summary(
            tenant, scan2, "storage", "high", total_findings=15, failed_findings=7
        )

        response = authenticated_client.get(
            reverse("overview-resource-groups"),
            {filter_key: filter_value_fn(provider1, gcp_provider, group1, group2)},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["attributes"]["total_findings"] == expected_total
        assert data[0]["attributes"]["failed_findings"] == expected_failed

    def test_overview_groups_group_filter(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        create_scan_resource_group_summary,
    ):
        tenant = tenants_fixture[0]
        provider = aws_provider

        scan = Scan.objects.create(
            name="rg-filter-scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        create_scan_resource_group_summary(
            tenant, scan, "storage", "high", total_findings=10, failed_findings=5
        )
        create_scan_resource_group_summary(
            tenant, scan, "compute", "medium", total_findings=20, failed_findings=8
        )
        create_scan_resource_group_summary(
            tenant, scan, "security", "low", total_findings=15, failed_findings=3
        )

        response = authenticated_client.get(
            reverse("overview-resource-groups"),
            {"filter[resource_group__in]": "storage,compute"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        group_ids = {item["id"] for item in data}
        assert group_ids == {"storage", "compute"}

    def test_overview_groups_aggregates_multiple_providers(
        self,
        authenticated_client,
        tenants_fixture,
        create_scan_resource_group_summary,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair

        scan1 = Scan.objects.create(
            name="multi-provider-rg-scan-1",
            provider=provider1,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )
        scan2 = Scan.objects.create(
            name="multi-provider-rg-scan-2",
            provider=provider2,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant=tenant,
        )

        create_scan_resource_group_summary(
            tenant,
            scan1,
            "storage",
            "high",
            total_findings=10,
            failed_findings=5,
            new_failed_findings=2,
            resources_count=4,
        )
        create_scan_resource_group_summary(
            tenant,
            scan2,
            "storage",
            "high",
            total_findings=15,
            failed_findings=8,
            new_failed_findings=3,
            resources_count=6,
        )

        response = authenticated_client.get(reverse("overview-resource-groups"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == "storage"
        assert data[0]["attributes"]["total_findings"] == 25
        assert data[0]["attributes"]["failed_findings"] == 13
        assert data[0]["attributes"]["new_failed_findings"] == 5
        assert data[0]["attributes"]["resources_count"] == 10

    @pytest.mark.usefixtures("tenant_compliance_summary_fixture")
    def test_compliance_watchlist_no_filters_uses_tenant_summary(
        self, authenticated_client
    ):
        response = authenticated_client.get(reverse("overview-compliance-watchlist"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]

        assert len(data) == 2

        by_id = {item["id"]: item["attributes"] for item in data}
        assert "aws_cis_2.0" in by_id
        assert by_id["aws_cis_2.0"]["requirements_passed"] == 1
        assert by_id["aws_cis_2.0"]["requirements_failed"] == 2
        assert by_id["aws_cis_2.0"]["requirements_manual"] == 1
        assert by_id["aws_cis_2.0"]["total_requirements"] == 4

        assert "gdpr_aws" in by_id
        assert by_id["gdpr_aws"]["requirements_passed"] == 5
        assert by_id["gdpr_aws"]["requirements_failed"] == 0
        assert by_id["gdpr_aws"]["total_requirements"] == 7

    @pytest.mark.usefixtures("provider_compliance_scores_fixture")
    def test_compliance_watchlist_with_provider_filter_uses_provider_scores(
        self,
        authenticated_client,
        aws_provider,
    ):
        provider1 = aws_provider
        url = f"{reverse('overview-compliance-watchlist')}?filter[provider_id]={provider1.id}"
        response = authenticated_client.get(url)
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]

        assert len(data) == 2
        by_id = {item["id"]: item["attributes"] for item in data}

        assert by_id["aws_cis_2.0"]["requirements_passed"] == 1
        assert by_id["aws_cis_2.0"]["requirements_failed"] == 1
        assert by_id["aws_cis_2.0"]["requirements_manual"] == 1
        assert by_id["aws_cis_2.0"]["total_requirements"] == 3

    @pytest.mark.usefixtures("provider_compliance_scores_fixture")
    def test_compliance_watchlist_fail_dominant_logic(self, authenticated_client):
        response = authenticated_client.get(
            f"{reverse('overview-compliance-watchlist')}?filter[provider_type]=aws"
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]

        by_id = {item["id"]: item["attributes"] for item in data}
        aws_cis = by_id["aws_cis_2.0"]

        assert aws_cis["requirements_failed"] == 2
        assert aws_cis["requirements_passed"] == 0
        assert aws_cis["requirements_manual"] == 1
        assert aws_cis["total_requirements"] == 3

    @pytest.mark.usefixtures("provider_compliance_scores_fixture")
    def test_compliance_watchlist_provider_id_in_filter(
        self,
        authenticated_client,
        aws_provider_pair,
    ):
        provider1, provider2 = aws_provider_pair
        url = (
            f"{reverse('overview-compliance-watchlist')}"
            f"?filter[provider_id__in]={provider1.id},{provider2.id}"
        )
        response = authenticated_client.get(url)
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) >= 1

    @pytest.mark.usefixtures("provider_compliance_scores_fixture")
    def test_compliance_watchlist_provider_groups_filter(
        self,
        authenticated_client,
        provider_groups_fixture,
        tenants_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair
        group1, group2, *_ = provider_groups_fixture
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group1
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group2
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider2, provider_group=group2
        )

        response = authenticated_client.get(
            reverse("overview-compliance-watchlist"),
            {"filter[provider_groups]": str(group1.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        by_id = {item["id"]: item["attributes"] for item in data}
        assert by_id["aws_cis_2.0"]["requirements_passed"] == 1
        assert by_id["aws_cis_2.0"]["requirements_failed"] == 1
        assert by_id["aws_cis_2.0"]["requirements_manual"] == 1

        response = authenticated_client.get(
            reverse("overview-compliance-watchlist"),
            {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        by_id = {item["id"]: item["attributes"] for item in data}
        assert by_id["aws_cis_2.0"]["requirements_passed"] == 0
        assert by_id["aws_cis_2.0"]["requirements_failed"] == 2
        assert by_id["aws_cis_2.0"]["requirements_manual"] == 1

    def test_compliance_watchlist_empty_result(self, authenticated_client):
        response = authenticated_client.get(reverse("overview-compliance-watchlist"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data == []

    @pytest.mark.parametrize(
        "invalid_provider_type",
        ["invalid", "not_a_provider", "AWS", "awss"],
    )
    def test_compliance_watchlist_invalid_provider_type_filter(
        self, authenticated_client, invalid_provider_type
    ):
        url = f"{reverse('overview-compliance-watchlist')}?filter[provider_type]={invalid_provider_type}"
        response = authenticated_client.get(url)
        assert response.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
class TestScheduleViewSet:
    @pytest.mark.parametrize("method", ["get", "post"])
    def test_schedule_invalid_method_list(self, method, authenticated_client):
        response = getattr(authenticated_client, method)(reverse("schedule-list"))
        assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED

    @patch("api.v1.views.Task.objects.get")
    @patch("api.v1.views.schedule_provider_scan")
    def test_schedule_daily(
        self,
        mock_schedule_scan,
        mock_task_get,
        authenticated_client,
        aws_provider,
        tasks_fixture,
    ):
        provider = aws_provider
        prowler_task = tasks_fixture[0]
        mock_schedule_scan.return_value.id = prowler_task.id
        mock_task_get.return_value = prowler_task
        json_payload = {
            "provider_id": str(provider.id),
        }
        response = authenticated_client.post(
            reverse("schedule-daily"), data=json_payload, format="json"
        )
        assert response.status_code == status.HTTP_202_ACCEPTED

    def test_schedule_daily_provider_does_not_exist(self, authenticated_client):
        json_payload = {
            "provider_id": "4846c2f9-84b2-442b-94dd-3082e8eb9584",
        }
        response = authenticated_client.post(
            reverse("schedule-daily"), data=json_payload, format="json"
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @patch("tasks.beat.perform_scheduled_scan_task.apply_async")
    @patch("api.v1.views.Task.objects.get")
    def test_schedule_daily_already_scheduled(
        self,
        mock_task_get,
        mock_apply_async,
        authenticated_client,
        aws_provider,
        tasks_fixture,
    ):
        provider = aws_provider
        prowler_task = tasks_fixture[0]
        mock_task_get.return_value = prowler_task
        mock_apply_async.return_value.id = prowler_task.id
        json_payload = {
            "provider_id": str(provider.id),
        }
        response = authenticated_client.post(
            reverse("schedule-daily"), data=json_payload, format="json"
        )
        assert response.status_code == status.HTTP_202_ACCEPTED

        response = authenticated_client.post(
            reverse("schedule-daily"), data=json_payload, format="json"
        )
        assert response.status_code == status.HTTP_409_CONFLICT


@pytest.mark.django_db
class TestIntegrationViewSet:
    def test_integrations_list(self, authenticated_client, integrations_fixture):
        response = authenticated_client.get(reverse("integration-list"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(integrations_fixture)

    def test_integrations_retrieve(self, authenticated_client, integrations_fixture):
        integration1, *_ = integrations_fixture
        response = authenticated_client.get(
            reverse("integration-detail", kwargs={"pk": integration1.id}),
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"]["id"] == str(integration1.id)
        assert (
            response.json()["data"]["attributes"]["configuration"]
            == integration1.configuration
        )

    def test_integrations_invalid_retrieve(self, authenticated_client):
        response = authenticated_client.get(
            reverse(
                "integration-detail",
                kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
            )
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @pytest.mark.parametrize(
        "include_values, expected_resources",
        [
            ("providers", ["providers"]),
        ],
    )
    def test_integrations_list_include(
        self,
        include_values,
        expected_resources,
        authenticated_client,
        integrations_fixture,
    ):
        response = authenticated_client.get(
            reverse("integration-list"), {"include": include_values}
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == len(integrations_fixture)
        assert "included" in response.json()

        included_data = response.json()["included"]
        for expected_type in expected_resources:
            assert any(d.get("type") == expected_type for d in included_data), (
                f"Expected type '{expected_type}' not found in included data"
            )

    @pytest.mark.parametrize(
        "integration_type, configuration, credentials",
        [
            # Amazon S3 - AWS credentials
            (
                Integration.IntegrationChoices.AMAZON_S3,
                {
                    "bucket_name": "bucket-name",
                    "output_directory": "output-directory",
                },
                {
                    "role_arn": "arn:aws",
                    "external_id": "external-id",
                },
            ),
            # Amazon S3 - No credentials (AWS self-hosted)
            (
                Integration.IntegrationChoices.AMAZON_S3,
                {
                    "bucket_name": "bucket-name",
                    "output_directory": "output-directory",
                },
                {},
            ),
        ],
    )
    def test_integrations_create_valid(
        self,
        authenticated_client,
        aws_provider,
        integration_type,
        configuration,
        credentials,
    ):
        provider = Provider.objects.first()

        data = {
            "data": {
                "type": "integrations",
                "attributes": {
                    "integration_type": integration_type,
                    "configuration": configuration,
                    "credentials": credentials,
                    "enabled": True,
                },
                "relationships": {
                    "providers": {
                        "data": [{"type": "providers", "id": str(provider.id)}]
                    }
                },
            }
        }
        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert Integration.objects.count() == 1
        integration = Integration.objects.first()
        assert integration.configuration == data["data"]["attributes"]["configuration"]
        assert integration.enabled == data["data"]["attributes"]["enabled"]
        assert (
            integration.integration_type
            == data["data"]["attributes"]["integration_type"]
        )
        assert "credentials" not in response.json()["data"]["attributes"]
        assert (
            str(provider.id)
            == data["data"]["relationships"]["providers"]["data"][0]["id"]
        )

    def test_integrations_create_valid_jira(
        self,
        authenticated_client,
    ):
        """Jira integrations are special"""
        data = {
            "data": {
                "type": "integrations",
                "attributes": {
                    "integration_type": Integration.IntegrationChoices.JIRA,
                    "configuration": {},
                    "credentials": {
                        "domain": "prowlerdomain",
                        "api_token": "this-is-an-api-token-for-jira-that-works-for-sure",
                        "user_mail": "testing@prowler.com",
                    },
                    "enabled": True,
                },
            }
        }
        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert Integration.objects.count() == 1
        integration = Integration.objects.first()
        integration_configuration = response.json()["data"]["attributes"][
            "configuration"
        ]
        assert "projects" in integration_configuration
        assert "issue_types" in integration_configuration
        assert "domain" in integration_configuration
        assert integration.enabled == data["data"]["attributes"]["enabled"]
        assert (
            integration.integration_type
            == data["data"]["attributes"]["integration_type"]
        )
        assert "credentials" not in response.json()["data"]["attributes"]

    @pytest.mark.parametrize(
        "domain",
        (
            "169.254.169.254#",
            "internal/service",
            "internal?target",
            "internal\\target",
            "internal:8000",
            "user@internal",
        ),
    )
    def test_integrations_create_jira_rejects_invalid_domain(
        self, authenticated_client, domain
    ):
        data = {
            "data": {
                "type": "integrations",
                "attributes": {
                    "integration_type": Integration.IntegrationChoices.JIRA,
                    "configuration": {},
                    "credentials": {
                        "domain": domain,
                        "api_token": "fake-api-token",
                        "user_mail": "testing@prowler.com",
                    },
                    "enabled": True,
                },
            }
        }

        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert Integration.objects.count() == 0

    def test_integrations_create_valid_relationships(
        self,
        authenticated_client,
        aws_provider_pair,
    ):
        provider1, provider2 = aws_provider_pair

        data = {
            "data": {
                "type": "integrations",
                "attributes": {
                    "integration_type": Integration.IntegrationChoices.AMAZON_S3,
                    "configuration": {
                        "bucket_name": "bucket-name",
                        "output_directory": "output-directory",
                    },
                    "credentials": {
                        "role_arn": "arn:aws",
                        "external_id": "external-id",
                    },
                },
                "relationships": {
                    "providers": {
                        "data": [
                            {"type": "providers", "id": str(provider1.id)},
                            {"type": "providers", "id": str(provider2.id)},
                        ]
                    }
                },
            }
        }
        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert Integration.objects.first().providers.count() == 2

    @pytest.mark.parametrize(
        "attributes, error_code, error_pointer",
        (
            [
                (
                    {
                        "integration_type": "whatever",
                        "configuration": {
                            "bucket_name": "bucket-name",
                            "output_directory": "output-directory",
                        },
                        "credentials": {
                            "role_arn": "arn:aws",
                            "external_id": "external-id",
                        },
                    },
                    "invalid_choice",
                    "integration_type",
                ),
                (
                    {
                        "integration_type": "amazon_s3",
                        "configuration": {},
                        "credentials": {
                            "role_arn": "arn:aws",
                            "external_id": "external-id",
                        },
                    },
                    "required",
                    "bucket_name",
                ),
                (
                    {
                        "integration_type": "amazon_s3",
                        "configuration": {
                            "bucket_name": "bucket_name",
                            "output_directory": "output_directory",
                            "invalid_key": "invalid_value",
                        },
                        "credentials": {
                            "role_arn": "arn:aws",
                            "external_id": "external-id",
                        },
                    },
                    "invalid",
                    None,
                ),
                (
                    {
                        "integration_type": "amazon_s3",
                        "configuration": {
                            "bucket_name": "bucket_name",
                            "output_directory": "output_directory",
                        },
                        "credentials": {"invalid_key": "invalid_key"},
                    },
                    "invalid",
                    None,
                ),
                (
                    {
                        "integration_type": "jira",
                        "configuration": {
                            "projects": ["JIRA"],
                        },
                        "credentials": {"domain": "prowlerdomain"},
                    },
                    "invalid",
                    "configuration",
                ),
                (
                    {
                        "integration_type": "jira",
                        "credentialss": {
                            "domain": "prowlerdomain",
                            "api_token": "api-token",
                            "user_mail": "test@prowler.com",
                        },
                    },
                    "required",
                    "configuration",
                ),
                (
                    {
                        "integration_type": "jira",
                        "configuration": {},
                    },
                    "required",
                    "credentials",
                ),
                (
                    {
                        "integration_type": "jira",
                        "configuration": {},
                        "credentials": {"api_token": "api-token"},
                    },
                    "invalid",
                    "credentials",
                ),
            ]
        ),
    )
    def test_integrations_invalid_create(
        self,
        authenticated_client,
        attributes,
        error_code,
        error_pointer,
    ):
        data = {
            "data": {
                "type": "integrations",
                "attributes": attributes,
            }
        }
        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == error_code
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == f"/data/attributes/{error_pointer}"
            if error_pointer
            else "/data"
        )

    def test_integrations_partial_update(
        self, authenticated_client, integrations_fixture
    ):
        integration, *_ = integrations_fixture
        data = {
            "data": {
                "type": "integrations",
                "id": str(integration.id),
                "attributes": {
                    "credentials": {
                        "aws_access_key_id": "new_value",
                    },
                    # integration_type is `amazon_s3`
                    "configuration": {
                        "bucket_name": "new_bucket_name",
                        "output_directory": "new_output_directory",
                    },
                },
            }
        }
        response = authenticated_client.patch(
            reverse("integration-detail", kwargs={"pk": integration.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        integration.refresh_from_db()
        assert integration.credentials["aws_access_key_id"] == "new_value"
        assert integration.configuration["bucket_name"] == "new_bucket_name"
        assert integration.configuration["output_directory"] == "new_output_directory"

    def test_integrations_partial_update_relationships(
        self, authenticated_client, integrations_fixture
    ):
        integration, *_ = integrations_fixture
        data = {
            "data": {
                "type": "integrations",
                "id": str(integration.id),
                "attributes": {
                    "credentials": {
                        "aws_access_key_id": "new_value",
                    },
                    # integration_type is `amazon_s3`
                    "configuration": {
                        "bucket_name": "new_bucket_name",
                        "output_directory": "new_output_directory",
                    },
                },
                "relationships": {"providers": {"data": []}},
            }
        }

        assert integration.providers.count() > 0
        response = authenticated_client.patch(
            reverse("integration-detail", kwargs={"pk": integration.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        integration.refresh_from_db()
        assert integration.providers.count() == 0

    def test_integrations_partial_update_invalid_content_type(
        self, authenticated_client, integrations_fixture
    ):
        integration, *_ = integrations_fixture
        response = authenticated_client.patch(
            reverse("integration-detail", kwargs={"pk": integration.id}),
            data={},
        )
        assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE

    def test_integrations_partial_update_invalid_content(
        self, authenticated_client, integrations_fixture
    ):
        integration, *_ = integrations_fixture
        data = {
            "data": {
                "type": "integrations",
                "id": str(integration.id),
                "attributes": {"invalid_config": "value"},
            }
        }
        response = authenticated_client.patch(
            reverse("integration-detail", kwargs={"pk": integration.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_integrations_delete(
        self,
        authenticated_client,
        integrations_fixture,
    ):
        integration, *_ = integrations_fixture
        response = authenticated_client.delete(
            reverse("integration-detail", kwargs={"pk": integration.id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT

    def test_integrations_delete_invalid(self, authenticated_client):
        response = authenticated_client.delete(
            reverse(
                "integration-detail",
                kwargs={"pk": "e67d0283-440f-48d1-b5f8-38d0763474f4"},
            )
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @pytest.mark.parametrize(
        "filter_name, filter_value, expected_count",
        (
            [
                ("inserted_at", TODAY, 2),
                ("inserted_at.gte", "2024-01-01", 2),
                ("inserted_at.lte", "2024-01-01", 0),
                ("integration_type", Integration.IntegrationChoices.AMAZON_S3, 2),
                ("integration_type", Integration.IntegrationChoices.SLACK, 0),
                (
                    "integration_type__in",
                    f"{Integration.IntegrationChoices.AMAZON_S3},{Integration.IntegrationChoices.SLACK}",
                    2,
                ),
            ]
        ),
    )
    def test_integrations_filters(
        self,
        authenticated_client,
        integrations_fixture,
        filter_name,
        filter_value,
        expected_count,
    ):
        response = authenticated_client.get(
            reverse("integration-list"),
            {f"filter[{filter_name}]": filter_value},
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == expected_count

    @pytest.mark.parametrize(
        "filter_name",
        (
            [
                "invalid",
            ]
        ),
    )
    def test_integrations_filters_invalid(self, authenticated_client, filter_name):
        response = authenticated_client.get(
            reverse("integration-list"),
            {f"filter[{filter_name}]": "whatever"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_integrations_create_duplicate_amazon_s3(
        self,
        authenticated_client,
        aws_provider,
    ):
        provider = aws_provider

        # Create first S3 integration
        data = {
            "data": {
                "type": "integrations",
                "attributes": {
                    "integration_type": Integration.IntegrationChoices.AMAZON_S3,
                    "configuration": {
                        "bucket_name": "test-bucket",
                        "output_directory": "test-output",
                    },
                    "credentials": {
                        "role_arn": "arn:aws:iam::123456789012:role/test-role",
                        "external_id": "test-external-id",
                    },
                    "enabled": True,
                },
                "relationships": {
                    "providers": {
                        "data": [{"type": "providers", "id": str(provider.id)}]
                    }
                },
            }
        }

        # First creation should succeed
        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED

        # Attempt to create duplicate should return 409
        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_409_CONFLICT
        assert (
            "This integration already exists" in response.json()["errors"][0]["detail"]
        )
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == "/data/attributes/configuration"
        )

    def test_integrations_create_duplicate_jira(self, authenticated_client):
        # Create first JIRA integration
        data = {
            "data": {
                "type": "integrations",
                "attributes": {
                    "integration_type": Integration.IntegrationChoices.JIRA,
                    "configuration": {},
                    "credentials": {
                        "user_mail": "test@example.com",
                        "api_token": "test-api-token",
                        "domain": "prowlerdomain",
                    },
                    "enabled": True,
                },
            }
        }

        # First creation should succeed
        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED

        # Attempt to create duplicate should return 409
        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_409_CONFLICT
        assert (
            "This integration already exists" in response.json()["errors"][0]["detail"]
        )
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == "/data/attributes/configuration"
        )

    def test_integrations_update_jira_configuration_readonly(
        self, authenticated_client
    ):
        # Create JIRA integration first
        create_data = {
            "data": {
                "type": "integrations",
                "attributes": {
                    "integration_type": Integration.IntegrationChoices.JIRA,
                    "configuration": {},
                    "credentials": {
                        "user_mail": "test@example.com",
                        "api_token": "test-api-token",
                        "domain": "initial-domain",
                    },
                    "enabled": True,
                },
            }
        }

        # Create the integration
        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(create_data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        integration_id = response.json()["data"]["id"]

        # Attempt to update configuration - should be ignored/not allowed
        update_data = {
            "data": {
                "type": "integrations",
                "id": integration_id,
                "attributes": {
                    "configuration": {
                        "projects": {"NEW_PROJECT": "New Project"},
                        "issue_types": ["Epic", "Story"],
                        "domain": "malicious-domain",
                    }
                },
            }
        }

        response = authenticated_client.patch(
            reverse("integration-detail", kwargs={"pk": integration_id}),
            data=json.dumps(update_data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_integrations_update_jira_credentials_domain_reflects_in_configuration(
        self, authenticated_client
    ):
        # Create JIRA integration first
        create_data = {
            "data": {
                "type": "integrations",
                "attributes": {
                    "integration_type": Integration.IntegrationChoices.JIRA,
                    "configuration": {},
                    "credentials": {
                        "user_mail": "test@example.com",
                        "api_token": "test-api-token",
                        "domain": "original-domain",
                    },
                    "enabled": True,
                },
            }
        }

        # Create the integration
        response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(create_data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        integration_id = response.json()["data"]["id"]

        # Verify initial domain in configuration
        initial_integration = response.json()["data"]
        assert (
            initial_integration["attributes"]["configuration"]["domain"]
            == "original-domain"
        )

        # Update credentials with new domain
        update_data = {
            "data": {
                "type": "integrations",
                "id": integration_id,
                "attributes": {
                    "credentials": {
                        "user_mail": "updated@example.com",
                        "api_token": "updated-api-token",
                        "domain": "updated-domain",
                    }
                },
            }
        }

        response = authenticated_client.patch(
            reverse("integration-detail", kwargs={"pk": integration_id}),
            data=json.dumps(update_data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK

        # Verify the new domain is reflected in configuration
        updated_integration = response.json()["data"]
        configuration = updated_integration["attributes"]["configuration"]
        assert configuration["domain"] == "updated-domain"

        # Verify other configuration fields are preserved
        assert "projects" in configuration
        assert "issue_types" in configuration

    def test_integrations_update_jira_rejects_invalid_domain(
        self, authenticated_client
    ):
        create_data = {
            "data": {
                "type": "integrations",
                "attributes": {
                    "integration_type": Integration.IntegrationChoices.JIRA,
                    "configuration": {},
                    "credentials": {
                        "user_mail": "test@example.com",
                        "api_token": "fake-api-token",
                        "domain": "original-domain",
                    },
                    "enabled": True,
                },
            }
        }
        create_response = authenticated_client.post(
            reverse("integration-list"),
            data=json.dumps(create_data),
            content_type="application/vnd.api+json",
        )
        assert create_response.status_code == status.HTTP_201_CREATED
        integration_id = create_response.json()["data"]["id"]

        update_data = {
            "data": {
                "type": "integrations",
                "id": integration_id,
                "attributes": {
                    "credentials": {
                        "user_mail": "test@example.com",
                        "api_token": "fake-api-token",
                        "domain": "169.254.169.254#",
                    }
                },
            }
        }
        response = authenticated_client.patch(
            reverse("integration-detail", kwargs={"pk": integration_id}),
            data=json.dumps(update_data),
            content_type="application/vnd.api+json",
        )

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        integration = Integration.objects.get(id=integration_id)
        assert integration.credentials["domain"] == "original-domain"


@pytest.mark.django_db
class TestSAMLTokenValidation:
    def test_valid_token_returns_tokens(self, authenticated_client, create_test_user):
        user = create_test_user
        valid_token_data = {
            "access": "mock_access_token",
            "refresh": "mock_refresh_token",
        }
        saml_token = SAMLToken.objects.create(
            token=valid_token_data,
            user=user,
            expires_at=datetime.now(UTC) + timedelta(seconds=10),
        )

        url = reverse("token-saml")
        response = authenticated_client.post(f"{url}?id={saml_token.id}")

        assert response.status_code == status.HTTP_200_OK
        assert response.json() == {"data": valid_token_data}
        assert not SAMLToken.objects.filter(id=saml_token.id).exists()

    def test_invalid_token_id_returns_404(self, authenticated_client):
        url = reverse("token-saml")
        response = authenticated_client.post(f"{url}?id={str(uuid4())}")

        assert response.status_code == status.HTTP_404_NOT_FOUND
        assert response.json()["errors"]["detail"] == "Invalid token ID."

    def test_expired_token_returns_400(self, authenticated_client, create_test_user):
        user = create_test_user
        expired_token_data = {
            "access": "expired_access_token",
            "refresh": "expired_refresh_token",
        }
        saml_token = SAMLToken.objects.create(
            token=expired_token_data,
            user=user,
            expires_at=datetime.now(UTC) - timedelta(seconds=1),
        )

        url = reverse("token-saml")
        response = authenticated_client.post(f"{url}?id={saml_token.id}")

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"]["detail"] == "Token expired."
        assert SAMLToken.objects.filter(id=saml_token.id).exists()

    def test_token_can_be_used_only_once(self, authenticated_client, create_test_user):
        user = create_test_user
        token_data = {
            "access": "single_use_token",
            "refresh": "single_use_refresh",
        }
        saml_token = SAMLToken.objects.create(
            token=token_data,
            user=user,
            expires_at=datetime.now(UTC) + timedelta(seconds=10),
        )

        url = reverse("token-saml")

        # First use: should succeed
        response1 = authenticated_client.post(f"{url}?id={saml_token.id}")
        assert response1.status_code == status.HTTP_200_OK

        # Second use: should fail (already deleted)
        response2 = authenticated_client.post(f"{url}?id={saml_token.id}")
        assert response2.status_code == status.HTTP_404_NOT_FOUND


@pytest.mark.django_db
class TestCustomSAMLLoginView:
    def test_dispatch_clears_stale_callback_url_when_request_has_none(self):
        request = RequestFactory().get("/api/v1/saml/login/testtenant/")
        request.session = {
            "saml_callback_url": "/invitation/accept?invitation_token=old-token"
        }

        with patch(
            "allauth.socialaccount.providers.saml.views.LoginView.dispatch",
            return_value=JsonResponse({}),
        ):
            response = CustomSAMLLoginView.as_view()(
                request, organization_slug="testtenant"
            )

        assert response.status_code == status.HTTP_200_OK
        assert "saml_callback_url" not in request.session


@pytest.mark.django_db
class TestSAMLInitiateAPIView:
    def test_valid_email_domain_and_certificates(
        self, authenticated_client, saml_setup, monkeypatch
    ):
        monkeypatch.setenv("SAML_PUBLIC_CERT", "fake_cert")
        monkeypatch.setenv("SAML_PRIVATE_KEY", "fake_key")

        url = reverse("api_saml_initiate")
        payload = {"email_domain": saml_setup["email"]}

        response = authenticated_client.post(url, data=payload, format="vnd.api+json")

        assert response.status_code == status.HTTP_302_FOUND
        assert (
            reverse("saml_login", kwargs={"organization_slug": saml_setup["domain"]})
            in response.url
        )
        assert "SAMLRequest" not in response.url

    def test_valid_email_domain_preserves_safe_callback_url(
        self, authenticated_client, saml_setup
    ):
        url = reverse("api_saml_initiate")
        callback_url = "/invitation/accept?invitation_token=test-token"
        payload = {
            "email_domain": saml_setup["email"],
            "callback_url": callback_url,
        }

        response = authenticated_client.post(url, data=payload, format="vnd.api+json")

        assert response.status_code == status.HTTP_302_FOUND
        query_params = parse_qs(urlparse(response.url).query)
        assert query_params["callback_url"] == [callback_url]

    def test_valid_email_domain_rejects_external_callback_url(
        self, authenticated_client, saml_setup
    ):
        url = reverse("api_saml_initiate")
        payload = {
            "email_domain": saml_setup["email"],
            "callback_url": "https://attacker.example/invitation",
        }

        response = authenticated_client.post(url, data=payload, format="vnd.api+json")

        assert response.status_code == status.HTTP_302_FOUND
        query_params = parse_qs(urlparse(response.url).query)
        assert "callback_url" not in query_params

    def test_invalid_email_domain(self, authenticated_client):
        url = reverse("api_saml_initiate")
        payload = {"email_domain": "user@unauthorized.com"}

        response = authenticated_client.post(url, data=payload, format="vnd.api+json")

        assert response.status_code == status.HTTP_403_FORBIDDEN
        assert response.json()["errors"]["detail"] == "Unauthorized domain."


@pytest.mark.django_db
class TestSAMLConfigurationViewSet:
    def test_list_saml_configurations(self, authenticated_client, saml_setup):
        config = SAMLConfiguration.objects.get(
            email_domain=saml_setup["email"].split("@")[-1]
        )
        response = authenticated_client.get(reverse("saml-config-list"))
        assert response.status_code == status.HTTP_200_OK
        assert (
            response.json()["data"][0]["attributes"]["email_domain"]
            == config.email_domain
        )

    def test_retrieve_saml_configuration(self, authenticated_client, saml_setup):
        config = SAMLConfiguration.objects.get(
            email_domain=saml_setup["email"].split("@")[-1]
        )
        response = authenticated_client.get(
            reverse("saml-config-detail", kwargs={"pk": config.id})
        )
        assert response.status_code == status.HTTP_200_OK
        assert (
            response.json()["data"]["attributes"]["metadata_xml"] == config.metadata_xml
        )

    def test_create_saml_configuration(self, authenticated_client, tenants_fixture):
        payload = {
            "email_domain": "newdomain.com",
            "metadata_xml": """<?xml version='1.0' encoding='UTF-8'?>
                <md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
                <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
                    <md:KeyDescriptor use='signing'>
                    <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
                        <ds:X509Data>
                        <ds:X509Certificate>TEST</ds:X509Certificate>
                        </ds:X509Data>
                    </ds:KeyInfo>
                    </md:KeyDescriptor>
                    <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
                    <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
                    <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
                </md:IDPSSODescriptor>
                </md:EntityDescriptor>
            """,
        }
        response = authenticated_client.post(
            reverse("saml-config-list"), data=payload, format="json"
        )
        assert response.status_code == status.HTTP_201_CREATED
        assert SAMLConfiguration.objects.filter(email_domain="newdomain.com").exists()

    def test_update_saml_configuration(self, authenticated_client, saml_setup):
        config = SAMLConfiguration.objects.get(
            email_domain=saml_setup["email"].split("@")[-1]
        )
        payload = {
            "data": {
                "type": "saml-configurations",
                "id": str(config.id),
                "attributes": {
                    "metadata_xml": """<?xml version='1.0' encoding='UTF-8'?>
        <md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
        <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
            <md:KeyDescriptor use='signing'>
            <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
                <ds:X509Data>
                <ds:X509Certificate>TEST2</ds:X509Certificate>
                </ds:X509Data>
            </ds:KeyInfo>
            </md:KeyDescriptor>
            <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
            <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
            <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
        </md:IDPSSODescriptor>
        </md:EntityDescriptor>
        """
                },
            }
        }
        response = authenticated_client.patch(
            reverse("saml-config-detail", kwargs={"pk": config.id}),
            data=payload,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        config.refresh_from_db()
        assert (
            config.metadata_xml.strip()
            == payload["data"]["attributes"]["metadata_xml"].strip()
        )

    def test_delete_saml_configuration(self, authenticated_client, saml_setup):
        config = SAMLConfiguration.objects.get(
            email_domain=saml_setup["email"].split("@")[-1]
        )
        response = authenticated_client.delete(
            reverse("saml-config-detail", kwargs={"pk": config.id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert not SAMLConfiguration.objects.filter(id=config.id).exists()


@pytest.mark.django_db
class TestTenantFinishACSView:
    def test_dispatch_skips_if_user_not_authenticated(self, monkeypatch):
        monkeypatch.setenv("AUTH_URL", "http://localhost")
        request = RequestFactory().get(
            reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
        )
        request.user = type("Anonymous", (), {"is_authenticated": False})()
        request.session = {}

        with patch(
            "allauth.socialaccount.providers.saml.views.get_app_or_404"
        ) as mock_get_app:
            mock_get_app.return_value = SocialApp(
                provider="saml",
                client_id="testtenant",
                name="Test App",
                settings={},
            )

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug="testtenant")

        assert response.status_code in [200, 302]

    def test_dispatch_skips_if_social_app_not_found(self, users_fixture, monkeypatch):
        monkeypatch.setenv("AUTH_URL", "http://localhost")
        request = RequestFactory().get(
            reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
        )
        request.user = users_fixture[0]
        request.session = {}

        with patch(
            "allauth.socialaccount.providers.saml.views.get_app_or_404"
        ) as mock_get_app:
            mock_get_app.return_value = SocialApp(
                provider="saml",
                client_id="testtenant",
                name="Test App",
                settings={},
            )

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug="testtenant")

        assert isinstance(response, JsonResponse) or response.status_code in [200, 302]

    def test_dispatch_sets_user_profile_and_assigns_role_and_creates_token(
        self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
    ):
        monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
        user = create_test_user
        original_name = user.name
        original_company = user.company_name
        user.company_name = "testing_company"
        user.is_authenticate = True

        social_account = SocialAccount(
            user=user,
            provider="saml",
            extra_data={
                "firstName": ["John"],
                "lastName": ["Doe"],
                "organization": ["testing_company"],
                "userType": ["platform_team"],
            },
        )

        request = RequestFactory().get(
            reverse(
                "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
            )
        )
        request.user = user
        callback_url = "/invitation/accept?invitation_token=test-token"
        request.session = {"saml_callback_url": callback_url}

        with (
            patch(
                "allauth.socialaccount.providers.saml.views.get_app_or_404"
            ) as mock_get_app_or_404,
            patch(
                "allauth.socialaccount.models.SocialApp.objects.get"
            ) as mock_socialapp_get,
            patch(
                "allauth.socialaccount.models.SocialAccount.objects.get"
            ) as mock_sa_get,
            patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
            patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
            patch("api.models.User.objects.get") as mock_user_get,
        ):
            mock_get_app_or_404.return_value = MagicMock(
                provider="saml",
                client_id=saml_setup["domain"],
                name="Test App",
                settings={},
            )
            mock_sa_get.return_value = social_account
            mock_socialapp_get.return_value = MagicMock(provider_id="saml")
            mock_saml_domain_get.return_value = SimpleNamespace(
                tenant_id=tenants_fixture[0].id
            )
            mock_saml_config_get.return_value = SimpleNamespace(
                email_domain=saml_setup["domain"], tenant=tenants_fixture[0]
            )
            mock_user_get.return_value = user

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug=saml_setup["domain"])

        assert response.status_code == 302

        expected_callback_host = "localhost"
        parsed_url = urlparse(response.url)
        assert parsed_url.netloc == expected_callback_host
        query_params = parse_qs(parsed_url.query)
        assert "id" in query_params
        assert query_params["callbackUrl"] == [callback_url]

        token_id = query_params["id"][0]
        token_obj = SAMLToken.objects.get(id=token_id)
        assert token_obj.user == user
        assert not token_obj.is_expired()

        user.refresh_from_db()
        assert user.name == "John Doe"
        assert user.company_name == "testing_company"

        role = Role.objects.using(MainRouter.admin_db).get(
            name="platform_team", tenant=tenants_fixture[0]
        )
        assert role.tenant == tenants_fixture[0]
        assert not role.manage_users
        assert not role.manage_account
        assert not role.manage_billing
        assert not role.manage_providers
        assert not role.manage_integrations
        assert not role.manage_scans
        assert role.unlimited_visibility

        assert (
            UserRoleRelationship.objects.using(MainRouter.admin_db)
            .filter(user=user, role=role, tenant_id=tenants_fixture[0].id)
            .exists()
        )

        membership = Membership.objects.using(MainRouter.admin_db).get(
            user=user, tenant=tenants_fixture[0]
        )
        assert membership.role == Membership.RoleChoices.MEMBER
        assert membership.user == user
        assert membership.tenant == tenants_fixture[0]

        user.name = original_name
        user.company_name = original_company
        user.save()

    def test_dispatch_rejects_assertion_email_domain_that_differs_from_slug(
        self, tenants_fixture, saml_setup, monkeypatch
    ):
        monkeypatch.setenv("AUTH_URL", "http://localhost")
        monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
        victim_tenant = tenants_fixture[0]
        attacker_tenant = tenants_fixture[1]
        attacker_domain = "attacker.com"

        SAMLConfiguration.objects.using(MainRouter.admin_db).create(
            email_domain=attacker_domain,
            metadata_xml="""<?xml version='1.0' encoding='UTF-8'?>
                <md:EntityDescriptor entityID='ATTACKER' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
                <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
                    <md:KeyDescriptor use='signing'>
                    <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
                        <ds:X509Data>
                        <ds:X509Certificate>TEST</ds:X509Certificate>
                        </ds:X509Data>
                    </ds:KeyInfo>
                    </md:KeyDescriptor>
                    <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://ATTACKER/sso/saml'/>
                </md:IDPSSODescriptor>
                </md:EntityDescriptor>
            """,
            tenant=attacker_tenant,
        )
        user = User.objects.using(MainRouter.admin_db).create(
            email=f"intruder@{saml_setup['domain']}", name="Intruder"
        )
        social_account = SocialAccount(
            user=user,
            provider="ATTACKER",
            extra_data={
                "firstName": ["Mallory"],
                "lastName": ["Example"],
            },
        )
        request = RequestFactory().get(
            reverse("saml_finish_acs", kwargs={"organization_slug": attacker_domain})
        )
        request.user = user
        request.session = {}

        with (
            patch(
                "allauth.socialaccount.providers.saml.views.get_app_or_404"
            ) as mock_get_app_or_404,
            patch(
                "allauth.socialaccount.models.SocialAccount.objects.get"
            ) as mock_sa_get,
        ):
            mock_get_app_or_404.return_value = MagicMock(
                provider="saml",
                provider_id="ATTACKER",
                client_id=attacker_domain,
                name="Attacker App",
                settings={},
            )
            mock_sa_get.return_value = social_account

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug=attacker_domain)

        assert response.status_code == 302
        assert "sso_saml_failed=true" in response.url
        assert not (
            Membership.objects.using(MainRouter.admin_db)
            .filter(user=user, tenant=victim_tenant)
            .exists()
        )
        assert (
            not SAMLToken.objects.using(MainRouter.admin_db).filter(user=user).exists()
        )

    def test_rollback_saml_user_when_error_occurs(self, users_fixture, monkeypatch):
        """Test that a user is properly deleted when created during SAML flow and an error occurs"""
        monkeypatch.setenv("AUTH_URL", "http://localhost")

        # Create a test user to simulate one created during SAML flow
        test_user = User.objects.using(MainRouter.admin_db).create(
            email="testuser@example.com", name="Test User"
        )

        request = RequestFactory().get(
            reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
        )
        request.user = users_fixture[0]
        request.session = {"saml_user_created": test_user.id}

        # Force an exception to trigger rollback
        with patch(
            "allauth.socialaccount.providers.saml.views.get_app_or_404"
        ) as mock_get_app:
            mock_get_app.side_effect = Exception("Test error")

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug="testtenant")

            # Verify the user was deleted
            assert (
                not User.objects.using(MainRouter.admin_db)
                .filter(id=test_user.id)
                .exists()
            )

            # Verify session was cleaned up
            assert "saml_user_created" not in request.session

            # Verify proper redirect
            assert response.status_code == 302
            assert "sso_saml_failed=true" in response.url

    def test_dispatch_keeps_existing_roles_when_usertype_missing(
        self,
        create_test_user,
        tenants_fixture,
        admin_role_fixture,
        saml_setup,
        settings,
        monkeypatch,
    ):
        """Test that roles are left untouched when the IdP does not send userType"""
        monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
        user = create_test_user
        tenant = tenants_fixture[0]

        admin_role = admin_role_fixture
        UserRoleRelationship.objects.using(MainRouter.admin_db).create(
            user=user, role=admin_role, tenant_id=tenant.id
        )
        roles_before = Role.objects.using(MainRouter.admin_db).count()

        social_account = SocialAccount(
            user=user,
            provider="saml",
            extra_data={
                "firstName": ["John"],
                "lastName": ["Doe"],
                "organization": ["testing_company"],
            },
        )

        request = RequestFactory().get(
            reverse(
                "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
            )
        )
        request.user = user
        request.session = {}

        with (
            patch(
                "allauth.socialaccount.providers.saml.views.get_app_or_404"
            ) as mock_get_app_or_404,
            patch(
                "allauth.socialaccount.models.SocialApp.objects.get"
            ) as mock_socialapp_get,
            patch(
                "allauth.socialaccount.models.SocialAccount.objects.get"
            ) as mock_sa_get,
            patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
            patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
            patch("api.models.User.objects.get") as mock_user_get,
        ):
            mock_get_app_or_404.return_value = MagicMock(
                provider="saml",
                client_id=saml_setup["domain"],
                name="Test App",
                settings={},
            )
            mock_sa_get.return_value = social_account
            mock_socialapp_get.return_value = MagicMock(provider_id="saml")
            mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
            mock_saml_config_get.return_value = SimpleNamespace(
                email_domain=saml_setup["domain"], tenant=tenant
            )
            mock_user_get.return_value = user

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug=saml_setup["domain"])

        assert response.status_code == 302

        # Verify the existing role assignment was not modified
        assert (
            UserRoleRelationship.objects.using(MainRouter.admin_db)
            .filter(user=user, role=admin_role, tenant_id=tenant.id)
            .exists()
        )

        # Verify no new role was created
        assert Role.objects.using(MainRouter.admin_db).count() == roles_before

    def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing(
        self,
        create_test_user,
        tenants_fixture,
        saml_setup,
        settings,
        monkeypatch,
    ):
        """Test that a user without roles gets none assigned when userType is missing"""
        monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
        user = create_test_user
        tenant = tenants_fixture[0]
        roles_before = Role.objects.using(MainRouter.admin_db).count()

        social_account = SocialAccount(
            user=user,
            provider="saml",
            extra_data={
                "firstName": ["John"],
                "lastName": ["Doe"],
                "organization": ["testing_company"],
            },
        )

        request = RequestFactory().get(
            reverse(
                "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
            )
        )
        request.user = user
        request.session = {}

        with (
            patch(
                "allauth.socialaccount.providers.saml.views.get_app_or_404"
            ) as mock_get_app_or_404,
            patch(
                "allauth.socialaccount.models.SocialApp.objects.get"
            ) as mock_socialapp_get,
            patch(
                "allauth.socialaccount.models.SocialAccount.objects.get"
            ) as mock_sa_get,
            patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
            patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
            patch("api.models.User.objects.get") as mock_user_get,
        ):
            mock_get_app_or_404.return_value = MagicMock(
                provider="saml",
                client_id=saml_setup["domain"],
                name="Test App",
                settings={},
            )
            mock_sa_get.return_value = social_account
            mock_socialapp_get.return_value = MagicMock(provider_id="saml")
            mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
            mock_saml_config_get.return_value = SimpleNamespace(
                email_domain=saml_setup["domain"], tenant=tenant
            )
            mock_user_get.return_value = user

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug=saml_setup["domain"])

        assert response.status_code == 302

        # Verify no role was created or assigned
        assert Role.objects.using(MainRouter.admin_db).count() == roles_before
        assert not (
            UserRoleRelationship.objects.using(MainRouter.admin_db)
            .filter(user=user, tenant_id=tenant.id)
            .exists()
        )

        # Membership is still created so the user belongs to the tenant
        assert (
            Membership.objects.using(MainRouter.admin_db)
            .filter(user=user, tenant=tenant)
            .exists()
        )

    def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role(
        self,
        create_test_user,
        tenants_fixture,
        admin_role_fixture,
        saml_setup,
        settings,
        monkeypatch,
    ):
        """Test that a new read-only role is neither created nor assigned if it would remove the last MANAGE_ACCOUNT user"""
        monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
        user = create_test_user
        tenant = tenants_fixture[0]

        admin_role = admin_role_fixture
        UserRoleRelationship.objects.using(MainRouter.admin_db).create(
            user=user, role=admin_role, tenant_id=tenant.id
        )

        social_account = SocialAccount(
            user=user,
            provider="saml",
            extra_data={
                "firstName": ["John"],
                "lastName": ["Doe"],
                "organization": ["testing_company"],
                "userType": ["brand_new_role"],
            },
        )

        request = RequestFactory().get(
            reverse(
                "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
            )
        )
        request.user = user
        request.session = {}

        with (
            patch(
                "allauth.socialaccount.providers.saml.views.get_app_or_404"
            ) as mock_get_app_or_404,
            patch(
                "allauth.socialaccount.models.SocialApp.objects.get"
            ) as mock_socialapp_get,
            patch(
                "allauth.socialaccount.models.SocialAccount.objects.get"
            ) as mock_sa_get,
            patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
            patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
            patch("api.models.User.objects.get") as mock_user_get,
        ):
            mock_get_app_or_404.return_value = MagicMock(
                provider="saml",
                client_id=saml_setup["domain"],
                name="Test App",
                settings={},
            )
            mock_sa_get.return_value = social_account
            mock_socialapp_get.return_value = MagicMock(provider_id="saml")
            mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
            mock_saml_config_get.return_value = SimpleNamespace(
                email_domain=saml_setup["domain"], tenant=tenant
            )
            mock_user_get.return_value = user

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug=saml_setup["domain"])

        assert response.status_code == 302

        # The admin role is still assigned and the new role was not created
        assert (
            UserRoleRelationship.objects.using(MainRouter.admin_db)
            .filter(user=user, role=admin_role, tenant_id=tenant.id)
            .exists()
        )
        assert (
            not Role.objects.using(MainRouter.admin_db)
            .filter(name="brand_new_role", tenant=tenant)
            .exists()
        )

    def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_existing_role(
        self,
        create_test_user,
        tenants_fixture,
        admin_role_fixture,
        roles_fixture,
        saml_setup,
        settings,
        monkeypatch,
    ):
        """Test that role mapping is skipped when it would remove the last MANAGE_ACCOUNT user"""
        monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
        user = create_test_user
        tenant = tenants_fixture[0]

        admin_role = admin_role_fixture
        viewer_role = roles_fixture[3]
        UserRoleRelationship.objects.using(MainRouter.admin_db).create(
            user=user, role=admin_role, tenant_id=tenant.id
        )

        social_account = SocialAccount(
            user=user,
            provider="saml",
            extra_data={
                "firstName": ["John"],
                "lastName": ["Doe"],
                "organization": ["testing_company"],
                "userType": [viewer_role.name],
            },
        )

        request = RequestFactory().get(
            reverse(
                "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
            )
        )
        request.user = user
        request.session = {}

        with (
            patch(
                "allauth.socialaccount.providers.saml.views.get_app_or_404"
            ) as mock_get_app_or_404,
            patch(
                "allauth.socialaccount.models.SocialApp.objects.get"
            ) as mock_socialapp_get,
            patch(
                "allauth.socialaccount.models.SocialAccount.objects.get"
            ) as mock_sa_get,
            patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
            patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
            patch("api.models.User.objects.get") as mock_user_get,
        ):
            mock_get_app_or_404.return_value = MagicMock(
                provider="saml",
                client_id=saml_setup["domain"],
                name="Test App",
                settings={},
            )
            mock_sa_get.return_value = social_account
            mock_socialapp_get.return_value = MagicMock(provider_id="saml")
            mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
            mock_saml_config_get.return_value = SimpleNamespace(
                email_domain=saml_setup["domain"], tenant=tenant
            )
            mock_user_get.return_value = user

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug=saml_setup["domain"])

        assert response.status_code == 302

        assert (
            UserRoleRelationship.objects.using(MainRouter.admin_db)
            .filter(user=user, role=admin_role, tenant_id=tenant.id)
            .exists()
        )
        assert not (
            UserRoleRelationship.objects.using(MainRouter.admin_db)
            .filter(user=user, role=viewer_role, tenant_id=tenant.id)
            .exists()
        )

    def test_dispatch_applies_role_mapping_when_multiple_manage_account_users(
        self,
        create_test_user,
        tenants_fixture,
        admin_role_fixture,
        roles_fixture,
        saml_setup,
        settings,
        monkeypatch,
    ):
        """Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role"""
        monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
        user = create_test_user
        tenant = tenants_fixture[0]

        # Create a second user with manage_account=True
        second_admin = User.objects.using(MainRouter.admin_db).create(
            email="admin2@prowler.com", name="Second Admin"
        )
        admin_role = admin_role_fixture
        viewer_role = roles_fixture[3]
        UserRoleRelationship.objects.using(MainRouter.admin_db).create(
            user=user, role=admin_role, tenant_id=tenant.id
        )
        UserRoleRelationship.objects.using(MainRouter.admin_db).create(
            user=second_admin, role=admin_role, tenant_id=tenant.id
        )

        social_account = SocialAccount(
            user=user,
            provider="saml",
            extra_data={
                "firstName": ["John"],
                "lastName": ["Doe"],
                "organization": ["testing_company"],
                "userType": [viewer_role.name],  # This SHOULD be applied
            },
        )

        request = RequestFactory().get(
            reverse(
                "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
            )
        )
        request.user = user
        request.session = {}

        with (
            patch(
                "allauth.socialaccount.providers.saml.views.get_app_or_404"
            ) as mock_get_app_or_404,
            patch(
                "allauth.socialaccount.models.SocialApp.objects.get"
            ) as mock_socialapp_get,
            patch(
                "allauth.socialaccount.models.SocialAccount.objects.get"
            ) as mock_sa_get,
            patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
            patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
            patch("api.models.User.objects.get") as mock_user_get,
        ):
            mock_get_app_or_404.return_value = MagicMock(
                provider="saml",
                client_id=saml_setup["domain"],
                name="Test App",
                settings={},
            )
            mock_sa_get.return_value = social_account
            mock_socialapp_get.return_value = MagicMock(provider_id="saml")
            mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
            mock_saml_config_get.return_value = SimpleNamespace(
                email_domain=saml_setup["domain"], tenant=tenant
            )
            mock_user_get.return_value = user

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug=saml_setup["domain"])

        assert response.status_code == 302

        # Verify the viewer role was assigned (role mapping was applied)
        assert (
            UserRoleRelationship.objects.using(MainRouter.admin_db)
            .filter(user=user, role=viewer_role, tenant_id=tenant.id)
            .exists()
        )

        # Verify the admin role was removed (replaced by viewer)
        assert not (
            UserRoleRelationship.objects.using(MainRouter.admin_db)
            .filter(user=user, role=admin_role, tenant_id=tenant.id)
            .exists()
        )

    def test_dispatch_applies_role_mapping_for_non_admin_user_with_single_admin(
        self,
        create_test_user,
        tenants_fixture,
        admin_role_fixture,
        roles_fixture,
        saml_setup,
        settings,
        monkeypatch,
    ):
        """Test that role mapping is applied for a non-admin user when a single admin exists"""
        monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
        admin_user = create_test_user
        tenant = tenants_fixture[0]
        non_admin_user = User.objects.using(MainRouter.admin_db).create(
            email="viewer@prowler.com", name="Viewer"
        )

        admin_role = admin_role_fixture
        viewer_role = roles_fixture[3]
        UserRoleRelationship.objects.using(MainRouter.admin_db).create(
            user=admin_user, role=admin_role, tenant_id=tenant.id
        )

        social_account = SocialAccount(
            user=non_admin_user,
            provider="saml",
            extra_data={
                "firstName": ["Jane"],
                "lastName": ["Doe"],
                "organization": ["testing_company"],
                "userType": [viewer_role.name],
            },
        )

        request = RequestFactory().get(
            reverse(
                "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]}
            )
        )
        request.user = non_admin_user
        request.session = {}

        with (
            patch(
                "allauth.socialaccount.providers.saml.views.get_app_or_404"
            ) as mock_get_app_or_404,
            patch(
                "allauth.socialaccount.models.SocialApp.objects.get"
            ) as mock_socialapp_get,
            patch(
                "allauth.socialaccount.models.SocialAccount.objects.get"
            ) as mock_sa_get,
            patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
            patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
            patch("api.models.User.objects.get") as mock_user_get,
        ):
            mock_get_app_or_404.return_value = MagicMock(
                provider="saml",
                client_id=saml_setup["domain"],
                name="Test App",
                settings={},
            )
            mock_sa_get.return_value = social_account
            mock_socialapp_get.return_value = MagicMock(provider_id="saml")
            mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
            mock_saml_config_get.return_value = SimpleNamespace(
                email_domain=saml_setup["domain"], tenant=tenant
            )
            mock_user_get.return_value = non_admin_user

            view = TenantFinishACSView.as_view()
            response = view(request, organization_slug=saml_setup["domain"])

        assert response.status_code == 302

        assert (
            UserRoleRelationship.objects.using(MainRouter.admin_db)
            .filter(user=non_admin_user, role=viewer_role, tenant_id=tenant.id)
            .exists()
        )
        assert (
            UserRoleRelationship.objects.using(MainRouter.admin_db)
            .filter(user=admin_user, role=admin_role, tenant_id=tenant.id)
            .exists()
        )


@pytest.mark.django_db
class TestLighthouseConfigViewSet:
    @pytest.fixture
    def valid_config_payload(self):
        return {
            "data": {
                "type": "lighthouse-configurations",
                "attributes": {
                    "name": "OpenAI",
                    "api_key": "sk-fake-test-key-for-unit-testing-only",
                    "model": "gpt-4o",
                    "temperature": 0.7,
                    "max_tokens": 4000,
                    "business_context": "Test business context",
                    "is_active": True,
                },
            }
        }

    @pytest.fixture
    def invalid_config_payload(self):
        return {
            "data": {
                "type": "lighthouse-configurations",
                "attributes": {
                    "name": "T",  # Too short
                    "api_key": "invalid-key",  # Invalid format
                    "model": "invalid-model",
                    "temperature": 2.0,  # Invalid range
                    "max_tokens": -1,  # Invalid value
                },
            }
        }

    def test_lighthouse_config_list(self, authenticated_client):
        response = authenticated_client.get(reverse("lighthouseconfiguration-list"))
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"] == []

    def test_lighthouse_config_create(self, authenticated_client, valid_config_payload):
        response = authenticated_client.post(
            reverse("lighthouseconfiguration-list"),
            data=valid_config_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_201_CREATED
        data = response.json()["data"]
        assert (
            data["attributes"]["name"]
            == valid_config_payload["data"]["attributes"]["name"]
        )
        assert (
            data["attributes"]["model"]
            == valid_config_payload["data"]["attributes"]["model"]
        )
        assert (
            data["attributes"]["temperature"]
            == valid_config_payload["data"]["attributes"]["temperature"]
        )
        assert (
            data["attributes"]["max_tokens"]
            == valid_config_payload["data"]["attributes"]["max_tokens"]
        )
        assert (
            data["attributes"]["business_context"]
            == valid_config_payload["data"]["attributes"]["business_context"]
        )
        assert (
            data["attributes"]["is_active"]
            == valid_config_payload["data"]["attributes"]["is_active"]
        )
        # Check that API key is masked with asterisks only
        masked_api_key = data["attributes"]["api_key"]
        assert all(c == "*" for c in masked_api_key), (
            "API key should contain only asterisks"
        )

    @pytest.mark.parametrize(
        "field_name, invalid_value",
        [
            ("name", "T"),  # Too short
            ("api_key", "invalid-key"),  # Invalid format
            ("model", "invalid-model"),  # Invalid model
            ("temperature", 2.0),  # Out of range
            ("max_tokens", -1),  # Invalid value
        ],
    )
    def test_lighthouse_config_create_invalid_fields(
        self, authenticated_client, valid_config_payload, field_name, invalid_value
    ):
        """Test that validation fails for various invalid field values"""
        payload = valid_config_payload.copy()
        payload["data"]["attributes"][field_name] = invalid_value

        response = authenticated_client.post(
            reverse("lighthouseconfiguration-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]

        # All field validation errors now follow the same pattern
        assert any(field_name in error["source"]["pointer"] for error in errors)

    def test_lighthouse_config_create_missing_required_fields(
        self, authenticated_client
    ):
        """Test that validation fails when required fields are missing"""
        payload = {"data": {"type": "lighthouse-configurations", "attributes": {}}}

        response = authenticated_client.post(
            reverse("lighthouseconfiguration-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]
        # Check for required fields
        required_fields = ["name", "api_key"]
        for field in required_fields:
            assert any(field in error["source"]["pointer"] for error in errors)

    def test_lighthouse_config_create_duplicate(
        self, authenticated_client, valid_config_payload
    ):
        # Create first config
        response = authenticated_client.post(
            reverse("lighthouseconfiguration-list"),
            data=valid_config_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_201_CREATED

        # Try to create second config for same tenant
        response = authenticated_client.post(
            reverse("lighthouseconfiguration-list"),
            data=valid_config_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert (
            "Lighthouse configuration already exists for this tenant"
            in response.json()["errors"][0]["detail"]
        )

    def test_lighthouse_config_update(
        self, authenticated_client, lighthouse_config_fixture
    ):
        update_payload = {
            "data": {
                "type": "lighthouse-configurations",
                "id": str(lighthouse_config_fixture.id),
                "attributes": {
                    "name": "Updated Config",
                    "model": "gpt-4o-mini",
                    "temperature": 0.5,
                },
            }
        }
        response = authenticated_client.patch(
            reverse(
                "lighthouseconfiguration-detail",
                kwargs={"pk": lighthouse_config_fixture.id},
            ),
            data=update_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data["attributes"]["name"] == "Updated Config"
        assert data["attributes"]["model"] == "gpt-4o-mini"
        assert data["attributes"]["temperature"] == 0.5

    @pytest.mark.parametrize(
        "field_name, invalid_value",
        [
            ("model", "invalid-model"),  # Invalid model name
            ("temperature", 2.5),  # Temperature too high
            ("temperature", -0.5),  # Temperature too low
            ("max_tokens", -1),  # Negative max tokens
            ("max_tokens", 100000),  # Max tokens too high
            ("name", "T"),  # Name too short
            ("api_key", "invalid-key"),  # Invalid API key format
        ],
    )
    def test_lighthouse_config_update_invalid(
        self, authenticated_client, lighthouse_config_fixture, field_name, invalid_value
    ):
        update_payload = {
            "data": {
                "type": "lighthouse-configurations",
                "id": str(lighthouse_config_fixture.id),
                "attributes": {
                    field_name: invalid_value,
                },
            }
        }
        response = authenticated_client.patch(
            reverse(
                "lighthouseconfiguration-detail",
                kwargs={"pk": lighthouse_config_fixture.id},
            ),
            data=update_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]
        assert any(field_name in error["source"]["pointer"] for error in errors)

    def test_lighthouse_config_delete(
        self, authenticated_client, lighthouse_config_fixture
    ):
        config_id = lighthouse_config_fixture.id
        response = authenticated_client.delete(
            reverse("lighthouseconfiguration-detail", kwargs={"pk": config_id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT

        # Verify deletion by checking list endpoint returns no items
        response = authenticated_client.get(reverse("lighthouseconfiguration-list"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 0

    def test_lighthouse_config_list_masked_api_key_default(
        self, authenticated_client, lighthouse_config_fixture
    ):
        """Test that list view returns all fields with masked API key by default"""
        response = authenticated_client.get(reverse("lighthouseconfiguration-list"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        config = data[0]["attributes"]

        # All fields should be present
        assert "name" in config
        assert "model" in config
        assert "temperature" in config
        assert "max_tokens" in config
        assert "business_context" in config
        assert "api_key" in config

        # API key should be masked (asterisks)
        api_key = config["api_key"]
        assert api_key.startswith("*")
        assert all(c == "*" for c in api_key)

    def test_lighthouse_config_unmasked_api_key_single_field(
        self, authenticated_client, lighthouse_config_fixture, valid_config_payload
    ):
        """Test that specifying api_key in fields param returns all fields with unmasked API key"""
        expected_api_key = valid_config_payload["data"]["attributes"]["api_key"]
        response = authenticated_client.get(
            reverse("lighthouseconfiguration-list")
            + "?fields[lighthouse-config]=api_key"
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        config = data[0]["attributes"]

        # All fields should still be present
        assert "name" in config
        assert "model" in config
        assert "temperature" in config
        assert "max_tokens" in config
        assert "business_context" in config
        assert "api_key" in config

        # API key should be unmasked
        assert config["api_key"] == expected_api_key

    @pytest.mark.parametrize(
        "sort_field, expected_count",
        [
            ("name", 1),  # Test sorting by name
            ("-inserted_at", 1),  # Test sorting by inserted_at
        ],
    )
    def test_lighthouse_config_sorting(
        self,
        authenticated_client,
        lighthouse_config_fixture,
        sort_field,
        expected_count,
    ):
        """Test sorting lighthouse configurations by various fields"""
        response = authenticated_client.get(
            reverse("lighthouseconfiguration-list") + f"?sort={sort_field}"
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == expected_count

    @patch("api.v1.views.Task.objects.get")
    @patch("api.v1.views.check_lighthouse_connection_task.delay")
    def test_lighthouse_config_connection(
        self,
        mock_lighthouse_connection,
        mock_task_get,
        authenticated_client,
        lighthouse_config_fixture,
        tasks_fixture,
    ):
        prowler_task = tasks_fixture[0]
        task_mock = Mock()
        task_mock.id = prowler_task.id
        task_mock.status = "PENDING"
        mock_lighthouse_connection.return_value = task_mock
        mock_task_get.return_value = prowler_task

        config_id = lighthouse_config_fixture.id
        assert lighthouse_config_fixture.is_active is True

        response = authenticated_client.post(
            reverse("lighthouseconfiguration-connection", kwargs={"pk": config_id})
        )
        assert response.status_code == status.HTTP_202_ACCEPTED
        mock_lighthouse_connection.assert_called_once_with(
            lighthouse_config_id=str(config_id), tenant_id=ANY
        )
        assert "Content-Location" in response.headers
        assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"

    def test_lighthouse_config_connection_invalid_config(
        self, authenticated_client, lighthouse_config_fixture
    ):
        response = authenticated_client.post(
            reverse("lighthouseconfiguration-connection", kwargs={"pk": "random_id"})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND


@pytest.mark.django_db
class TestProcessorViewSet:
    valid_mutelist_configuration = """Mutelist:
    Accounts:
      '*':
        Checks:
            iam_user_hardware_mfa_enabled:
                Regions:
                    - '*'
                Resources:
                    - '*'
    """

    def test_list_processors(self, authenticated_client, processor_fixture):
        response = authenticated_client.get(reverse("processor-list"))
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1

    def test_retrieve_processor(self, authenticated_client, processor_fixture):
        processor = processor_fixture
        response = authenticated_client.get(
            reverse("processor-detail", kwargs={"pk": processor.id})
        )
        assert response.status_code == status.HTTP_200_OK

    def test_create_processor_valid(self, authenticated_client):
        payload = {
            "data": {
                "type": "processors",
                "attributes": {
                    "processor_type": "mutelist",
                    "configuration": self.valid_mutelist_configuration,
                },
            },
        }
        response = authenticated_client.post(
            reverse("processor-list"),
            data=payload,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED

    @pytest.mark.parametrize(
        "invalid_configuration",
        [
            None,
            "",
            "invalid configuration",
            {"invalid": "configuration"},
        ],
    )
    def test_create_processor_invalid(
        self, authenticated_client, invalid_configuration
    ):
        payload = {
            "data": {
                "type": "processors",
                "attributes": {
                    "processor_type": "mutelist",
                    "configuration": invalid_configuration,
                },
            },
        }
        response = authenticated_client.post(
            reverse("processor-list"),
            data=payload,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_update_processor_valid(self, authenticated_client, processor_fixture):
        processor = processor_fixture
        payload = {
            "data": {
                "type": "processors",
                "id": str(processor.id),
                "attributes": {
                    "configuration": {
                        "Mutelist": {
                            "Accounts": {
                                "1234567890": {
                                    "Checks": {
                                        "iam_user_hardware_mfa_enabled": {
                                            "Regions": ["*"],
                                            "Resources": ["*"],
                                        }
                                    }
                                }
                            }
                        }
                    },
                },
            },
        }
        response = authenticated_client.patch(
            reverse("processor-detail", kwargs={"pk": processor.id}),
            data=payload,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        processor.refresh_from_db()
        assert (
            processor.configuration["Mutelist"]["Accounts"]["1234567890"]
            == payload["data"]["attributes"]["configuration"]["Mutelist"]["Accounts"][
                "1234567890"
            ]
        )

    @pytest.mark.parametrize(
        "invalid_configuration",
        [
            None,
            "",
            "invalid configuration",
            {"invalid": "configuration"},
        ],
    )
    def test_update_processor_invalid(
        self, authenticated_client, processor_fixture, invalid_configuration
    ):
        processor = processor_fixture
        payload = {
            "data": {
                "type": "processors",
                "id": str(processor.id),
                "attributes": {
                    "configuration": invalid_configuration,
                },
            },
        }
        response = authenticated_client.patch(
            reverse("processor-detail", kwargs={"pk": processor.id}),
            data=payload,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_delete_processor(self, authenticated_client, processor_fixture):
        processor = processor_fixture
        response = authenticated_client.delete(
            reverse("processor-detail", kwargs={"pk": processor.id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT
        assert not Processor.objects.filter(id=processor.id).exists()

    def test_processors_filters(self, authenticated_client, processor_fixture):
        response = authenticated_client.get(
            reverse("processor-list"),
            {"filter[processor_type]": "mutelist"},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert response.json()["data"][0]["attributes"]["processor_type"] == "mutelist"

    def test_processors_filters_invalid(self, authenticated_client):
        response = authenticated_client.get(
            reverse("processor-list"),
            {"filter[processor_type]": "invalid"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_processors_create_another_with_same_type(
        self, authenticated_client, processor_fixture
    ):
        pass

        payload = {
            "data": {
                "type": "processors",
                "attributes": {
                    "processor_type": "mutelist",
                    "configuration": self.valid_mutelist_configuration,
                },
            },
        }
        response = authenticated_client.post(
            reverse("processor-list"),
            data=payload,
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
class TestTenantApiKeyViewSet:
    """Tests for TenantAPIKey endpoints."""

    def test_api_keys_list(self, authenticated_client, api_keys_fixture):
        """Test listing all API keys for the tenant."""
        response = authenticated_client.get(reverse("api-key-list"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == len(api_keys_fixture)

    def test_api_keys_list_empty(self, authenticated_client, tenants_fixture):
        """Test listing API keys when none exist returns empty list."""
        response = authenticated_client.get(reverse("api-key-list"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 0
        assert isinstance(data, list)

    def test_api_keys_list_default_ordering(
        self, authenticated_client, api_keys_fixture
    ):
        """Test that API keys are ordered by -created (newest first) by default."""
        response = authenticated_client.get(reverse("api-key-list"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]

        # Verify ordering by comparing inserted_at timestamps
        # (newest should be first since ordering = ["-created"])
        if len(data) >= 2:
            first_date = data[0]["attributes"]["inserted_at"]
            second_date = data[1]["attributes"]["inserted_at"]
            assert first_date >= second_date

    def test_api_keys_list_pagination_page_size(
        self, authenticated_client, api_keys_fixture
    ):
        """Test pagination with custom page size."""
        page_size = 1
        response = authenticated_client.get(
            reverse("api-key-list"), {"page[size]": page_size}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == page_size
        assert response.json()["meta"]["pagination"]["page"] == 1
        assert response.json()["meta"]["pagination"]["pages"] == 3

    def test_api_keys_list_pagination_page_number(
        self, authenticated_client, api_keys_fixture
    ):
        """Test pagination with specific page number."""
        page_size = 1
        page_number = 2
        response = authenticated_client.get(
            reverse("api-key-list"),
            {"page[size]": page_size, "page[number]": page_number},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == page_size
        assert response.json()["meta"]["pagination"]["page"] == page_number

    def test_api_keys_list_pagination_invalid_page(self, authenticated_client):
        """Test pagination with invalid page number returns 404."""
        response = authenticated_client.get(
            reverse("api-key-list"), {"page[number]": 999}
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_api_keys_retrieve(self, authenticated_client, api_keys_fixture):
        """Test retrieving a single API key by ID."""
        api_key = api_keys_fixture[0]
        response = authenticated_client.get(
            reverse("api-key-detail", kwargs={"pk": api_key.id})
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data["id"] == str(api_key.id)
        assert data["attributes"]["name"] == api_key.name
        assert data["attributes"]["prefix"] == api_key.prefix
        assert data["attributes"]["revoked"] == api_key.revoked
        assert "expires_at" in data["attributes"]
        assert "inserted_at" in data["attributes"]
        assert "last_used_at" in data["attributes"]
        # Verify api_key field is NOT in response (only on creation)
        assert "api_key" not in data["attributes"]

    def test_api_keys_retrieve_invalid(self, authenticated_client):
        """Test retrieving non-existent API key returns 404."""
        response = authenticated_client.get(
            reverse(
                "api-key-detail",
                kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
            )
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_api_keys_retrieve_field_mapping(
        self, authenticated_client, api_keys_fixture
    ):
        """Test that field names are correctly mapped (expires_at, inserted_at)."""
        api_key = api_keys_fixture[0]
        response = authenticated_client.get(
            reverse("api-key-detail", kwargs={"pk": api_key.id})
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]["attributes"]

        # Verify field mapping: expires_at -> expiry_date
        assert "expires_at" in data
        assert "expiry_date" not in data

        # Verify field mapping: inserted_at -> created
        assert "inserted_at" in data
        assert "created" not in data

    @pytest.mark.parametrize(
        "api_key_payload",
        (
            [
                {"name": "New API Key"},
            ]
        ),
    )
    def test_api_keys_create_valid(
        self, authenticated_client, create_test_user, api_key_payload
    ):
        data = {
            "data": {
                "type": "api-keys",
                "attributes": api_key_payload,
            }
        }
        response = authenticated_client.post(
            reverse("api-key-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        response_data = response.json()["data"]
        assert "prefix" in response_data["attributes"]
        assert "api_key" in response_data["attributes"]
        assert response_data["attributes"]["api_key"] is not None
        # Verify the raw API key is returned (only on creation)
        assert (
            response_data["attributes"]["prefix"]
            in response_data["attributes"]["api_key"]
        )
        # Verify entity is set to current user
        assert response_data["relationships"]["entity"]["data"]["id"] == str(
            create_test_user.id
        )

    @pytest.mark.parametrize(
        "api_key_payload, error_pointer",
        (
            [
                (
                    {"name": "Invalid Expiry", "expires_at": "not-a-date"},
                    "expires_at",
                ),
                (
                    {"name": ""},
                    "name",
                ),
                (
                    {},
                    "name",
                ),
                (
                    {"name": "AB"},  # Too short (min length is 3)
                    "name",
                ),
            ]
        ),
    )
    def test_api_keys_create_invalid(
        self,
        authenticated_client,
        create_test_user,
        api_key_payload,
        error_pointer,
    ):
        data = {
            "data": {
                "type": "api-keys",
                "attributes": api_key_payload,
            }
        }
        response = authenticated_client.post(
            reverse("api-key-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "errors" in response.json()
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == f"/data/attributes/{error_pointer}"
        )

    def test_api_keys_create_duplicate_name(
        self, authenticated_client, api_keys_fixture
    ):
        """Test creating an API key with a duplicate name fails."""
        # Use the name of an existing API key
        existing_name = api_keys_fixture[0].name
        data = {
            "data": {
                "type": "api-keys",
                "attributes": {
                    "name": existing_name,
                },
            }
        }
        response = authenticated_client.post(
            reverse("api-key-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "errors" in response.json()
        error_detail = response.json()["errors"][0]["detail"]
        assert "already exists" in error_detail.lower()

    def test_api_keys_update_duplicate_name(
        self, authenticated_client, api_keys_fixture
    ):
        """Test updating an API key with a duplicate name fails."""
        # Get two different API keys
        first_api_key = api_keys_fixture[0]
        second_api_key = api_keys_fixture[1]

        # Try to update the second API key to have the same name as the first one
        data = {
            "data": {
                "type": "api-keys",
                "id": str(second_api_key.id),
                "attributes": {
                    "name": first_api_key.name,
                },
            }
        }
        response = authenticated_client.patch(
            reverse("api-key-detail", kwargs={"pk": second_api_key.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "errors" in response.json()
        error_detail = response.json()["errors"][0]["detail"]
        assert "already exists" in error_detail.lower()

    def test_api_keys_create_multiple_unique_prefixes(
        self, authenticated_client, api_keys_fixture
    ):
        """Test creating multiple API keys generates unique prefixes."""
        prefixes = set()
        for i in range(3):
            data = {
                "data": {
                    "type": "api-keys",
                    "attributes": {
                        "name": f"Unique Key {i}",
                    },
                }
            }
            response = authenticated_client.post(
                reverse("api-key-list"),
                data=json.dumps(data),
                content_type="application/vnd.api+json",
            )
            assert response.status_code == status.HTTP_201_CREATED
            prefix = response.json()["data"]["attributes"]["prefix"]
            prefixes.add(prefix)
        # Verify all prefixes are unique
        assert len(prefixes) == 3

    def test_api_keys_create_invalid_content_type(
        self, authenticated_client, create_test_user
    ):
        """Test creating an API key with wrong content type returns 415."""
        data = {"name": "Test Key"}
        response = authenticated_client.post(
            reverse("api-key-list"),
            data=data,
            content_type="application/json",
        )
        assert response.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE

    def test_api_keys_create_malformed_json(
        self, authenticated_client, create_test_user
    ):
        """Test creating an API key with malformed JSON returns 400."""
        response = authenticated_client.post(
            reverse("api-key-list"),
            data="not valid json",
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_api_keys_create_invalid_structure(
        self, authenticated_client, create_test_user
    ):
        """Test creating an API key with invalid JSON:API structure."""
        data = {"invalid": "structure"}
        response = authenticated_client.post(
            reverse("api-key-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "errors" in response.json()

    def test_api_keys_revoke(self, authenticated_client, api_keys_fixture):
        """Test revoking an API key."""
        api_key = api_keys_fixture[0]  # Not revoked
        assert api_key.revoked is False

        response = authenticated_client.delete(
            reverse("api-key-revoke", kwargs={"pk": api_key.id})
        )
        assert response.status_code == status.HTTP_200_OK
        response_data = response.json()["data"]
        assert response_data["attributes"]["revoked"] is True

        # Verify in database
        api_key.refresh_from_db()
        assert api_key.revoked is True

    def test_api_keys_revoke_preserves_created_field(
        self, authenticated_client, api_keys_fixture
    ):
        """Test that revoking an API key preserves the created timestamp."""
        api_key = api_keys_fixture[0]  # Not revoked
        assert api_key.revoked is False

        # Record the original created timestamp
        original_created = api_key.created

        response = authenticated_client.delete(
            reverse("api-key-revoke", kwargs={"pk": api_key.id})
        )
        assert response.status_code == status.HTTP_200_OK

        # Verify in database
        api_key.refresh_from_db()
        assert api_key.revoked is True
        # Verify created field has not changed
        assert api_key.created == original_created

    def test_api_keys_revoke_already_revoked(
        self, authenticated_client, api_keys_fixture
    ):
        """Test revoking an already revoked API key returns validation error."""
        api_key = api_keys_fixture[2]  # Already revoked
        api_key.refresh_from_db()
        assert api_key.revoked is True

        response = authenticated_client.delete(
            reverse("api-key-revoke", kwargs={"pk": api_key.id})
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "already revoked" in response.json()["errors"][0]["detail"]

    def test_api_keys_revoke_nonexistent(self, authenticated_client):
        """Test revoking non-existent API key returns 404."""
        response = authenticated_client.delete(
            reverse(
                "api-key-revoke",
                kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
            )
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_api_keys_destroy_not_allowed(self, authenticated_client, api_keys_fixture):
        """Test that DELETE (destroy) endpoint is disabled."""
        api_key = api_keys_fixture[0]
        response = authenticated_client.delete(
            reverse("api-key-detail", kwargs={"pk": api_key.id})
        )
        assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED

    def test_api_keys_put_not_allowed(self, authenticated_client, api_keys_fixture):
        """Test that PUT is not allowed."""
        api_key = api_keys_fixture[0]
        data = {
            "data": {
                "type": "api-keys",
                "id": str(api_key.id),
                "attributes": {
                    "name": "Updated Name",
                },
            }
        }
        response = authenticated_client.put(
            reverse("api-key-detail", kwargs={"pk": api_key.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED

    @pytest.mark.parametrize(
        "filter_name, filter_value, expected_min_count",
        (
            [
                ("name", "Test API Key 1", 1),
                ("name__icontains", "test", 2),
                ("revoked", "true", 1),
                ("revoked", "false", 2),
                ("inserted_at", TODAY, 1),
                ("inserted_at__gte", "2024-01-01", 3),
                ("inserted_at__lte", "2099-12-31", 3),
                ("expires_at__gte", today_after_n_days(50), 1),
            ]
        ),
    )
    def test_api_keys_filters(
        self,
        authenticated_client,
        api_keys_fixture,
        filter_name,
        filter_value,
        expected_min_count,
    ):
        response = authenticated_client.get(
            reverse("api-key-list"),
            {f"filter[{filter_name}]": filter_value},
        )

        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) >= expected_min_count

    def test_api_keys_filter_combined(self, authenticated_client, api_keys_fixture):
        """Test combining multiple filters."""
        response = authenticated_client.get(
            reverse("api-key-list"),
            {
                "filter[revoked]": "false",
                "filter[name__icontains]": "test",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert all(item["attributes"]["revoked"] is False for item in data)
        assert all("test" in item["attributes"]["name"].lower() for item in data)

    @pytest.mark.parametrize(
        "filter_name",
        (
            [
                "invalid_field",
                "nonexistent",
            ]
        ),
    )
    def test_api_keys_filters_invalid(self, authenticated_client, filter_name):
        response = authenticated_client.get(
            reverse("api-key-list"),
            {f"filter[{filter_name}]": "whatever"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_api_keys_filter_invalid_date_format(self, authenticated_client):
        """Test filtering with invalid date format returns 400."""
        response = authenticated_client.get(
            reverse("api-key-list"),
            {"filter[inserted_at]": "not-a-date"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_api_keys_filter_empty_result(self, authenticated_client, api_keys_fixture):
        """Test filter that returns no results."""
        response = authenticated_client.get(
            reverse("api-key-list"),
            {"filter[name]": "NonExistent Key Name"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 0
        assert isinstance(data, list)

    @pytest.mark.parametrize(
        "sort_field",
        (
            [
                "name",
                "prefix",
                "revoked",
                "inserted_at",
                "expires_at",
                "-name",
                "-inserted_at",
            ]
        ),
    )
    def test_api_keys_sort(self, authenticated_client, api_keys_fixture, sort_field):
        response = authenticated_client.get(
            reverse("api-key-list"), {"sort": sort_field}
        )
        assert response.status_code == status.HTTP_200_OK

    def test_api_keys_sort_invalid(self, authenticated_client):
        """Test invalid sort parameter returns 400."""
        response = authenticated_client.get(
            reverse("api-key-list"),
            {"sort": "invalid_field"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_api_keys_rbac_manage_account_required(
        self, authenticated_client_rbac_manage_users_only, api_keys_fixture
    ):
        """Test that users without MANAGE_ACCOUNT permission are denied."""
        response = authenticated_client_rbac_manage_users_only.get(
            reverse("api-key-list")
        )
        assert response.status_code == status.HTTP_403_FORBIDDEN

    def test_api_keys_rbac_manage_account_allowed(
        self, authenticated_client_rbac_manage_account, tenants_fixture
    ):
        """Test that users with MANAGE_ACCOUNT permission can access API keys."""
        response = authenticated_client_rbac_manage_account.get(reverse("api-key-list"))
        assert response.status_code == status.HTTP_200_OK

    def test_api_keys_rbac_create_requires_permission(
        self, authenticated_client_rbac_manage_users_only
    ):
        """Test that creating API keys requires MANAGE_ACCOUNT permission."""
        data = {
            "data": {
                "type": "api-keys",
                "attributes": {
                    "name": "Test Key",
                },
            }
        }
        response = authenticated_client_rbac_manage_users_only.post(
            reverse("api-key-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_403_FORBIDDEN

    def test_api_keys_rbac_revoke_requires_permission(
        self, authenticated_client_rbac_manage_users_only, api_keys_fixture
    ):
        """Test that revoking API keys requires MANAGE_ACCOUNT permission."""
        api_key = api_keys_fixture[0]
        response = authenticated_client_rbac_manage_users_only.delete(
            reverse("api-key-revoke", kwargs={"pk": api_key.id})
        )
        assert response.status_code == status.HTTP_403_FORBIDDEN

    def test_api_keys_tenant_isolation(
        self, authenticated_client, api_keys_fixture, tenants_fixture
    ):
        """Test that API keys are isolated by tenant (RLS enforcement)."""
        # Create a second tenant with different user

        tenant2 = Tenant.objects.create(name="Another Tenant")
        user2 = User.objects.create_user(
            name="Another User",
            email="another@example.com",
            password=TEST_PASSWORD,
        )
        Membership.objects.create(
            user=user2,
            tenant=tenant2,
            role=Membership.RoleChoices.OWNER,
        )

        # Create API key for tenant2
        TenantAPIKey.objects.create_api_key(
            name="Tenant 2 Key",
            tenant_id=tenant2.id,
            entity=user2,
        )

        # Authenticate as user from tenant 1
        response = authenticated_client.get(reverse("api-key-list"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]

        # Should only see keys from tenant 1
        assert len(data) == len(api_keys_fixture)
        assert all(item["attributes"]["name"] != "Tenant 2 Key" for item in data)

    def test_api_keys_tenant_isolation_retrieve(
        self, authenticated_client, tenants_fixture
    ):
        """Test that retrieving API key from another tenant returns 404."""
        # Create a second tenant with API key
        tenant2 = Tenant.objects.create(name="Another Tenant")
        user2 = User.objects.create_user(
            name="Another User",
            email="another2@example.com",
            password=TEST_PASSWORD,
        )
        Membership.objects.create(
            user=user2,
            tenant=tenant2,
            role=Membership.RoleChoices.OWNER,
        )

        api_key2, _ = TenantAPIKey.objects.create_api_key(
            name="Tenant 2 Key",
            tenant_id=tenant2.id,
            entity=user2,
        )

        # Try to retrieve tenant2's API key as tenant1 user
        response = authenticated_client.get(
            reverse("api-key-detail", kwargs={"pk": api_key2.id})
        )
        # Should return 404 due to RLS filtering
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_api_keys_tenant_isolation_revoke(
        self, authenticated_client, tenants_fixture
    ):
        """Test that revoking API key from another tenant returns 404."""
        # Create a second tenant with API key
        tenant2 = Tenant.objects.create(name="Another Tenant")
        user2 = User.objects.create_user(
            name="Another User",
            email="another3@example.com",
            password=TEST_PASSWORD,
        )
        Membership.objects.create(
            user=user2,
            tenant=tenant2,
            role=Membership.RoleChoices.OWNER,
        )

        api_key2, _ = TenantAPIKey.objects.create_api_key(
            name="Tenant 2 Key",
            tenant_id=tenant2.id,
            entity=user2,
        )

        # Try to revoke tenant2's API key as tenant1 user
        response = authenticated_client.delete(
            reverse("api-key-revoke", kwargs={"pk": api_key2.id})
        )
        # Should return 404 due to RLS filtering
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_api_keys_read_only_fields_on_create(
        self, authenticated_client, create_test_user
    ):
        """Test that read-only fields are ignored during creation."""
        # Note: Fields not in serializer (like 'prefix', 'revoked') will cause 400
        # So we only test that the response has correct read-only values
        data = {
            "data": {
                "type": "api-keys",
                "attributes": {
                    "name": "Test Read-Only",
                },
            }
        }
        response = authenticated_client.post(
            reverse("api-key-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        response_data = response.json()["data"]

        # Verify read-only fields have correct default/auto-generated values
        # Prefix should be auto-generated (not empty, not None)
        assert response_data["attributes"]["prefix"] is not None
        assert len(response_data["attributes"]["prefix"]) > 0

        # Revoked should be False (default)
        assert response_data["attributes"]["revoked"] is False

        # Entity should be set to current user (auto-assigned)
        assert response_data["relationships"]["entity"]["data"]["id"] == str(
            create_test_user.id
        )

    def test_api_keys_entity_relationship_included(
        self, authenticated_client, api_keys_fixture
    ):
        """Test that entity (user) relationship is included correctly."""
        api_key = api_keys_fixture[0]
        response = authenticated_client.get(
            reverse("api-key-detail", kwargs={"pk": api_key.id})
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert "entity" in data["relationships"]
        assert data["relationships"]["entity"]["data"]["type"] == "users"
        assert data["relationships"]["entity"]["data"]["id"] == str(api_key.entity.id)

    def test_api_keys_retrieve_with_entity_include(
        self, authenticated_client, api_keys_fixture
    ):
        """Test retrieving API key with ?include=entity returns user data without memberships."""
        api_key = api_keys_fixture[0]
        response = authenticated_client.get(
            reverse("api-key-detail", kwargs={"pk": api_key.id}),
            {"include": "entity"},
        )
        assert response.status_code == status.HTTP_200_OK
        response_data = response.json()

        # Verify the main data contains the entity relationship
        data = response_data["data"]
        assert "entity" in data["relationships"]
        assert data["relationships"]["entity"]["data"]["type"] == "users"
        assert data["relationships"]["entity"]["data"]["id"] == str(api_key.entity.id)

        # Verify included section exists
        assert "included" in response_data
        assert len(response_data["included"]) == 1

        # Verify included user data
        included_user = response_data["included"][0]
        assert included_user["type"] == "users"
        assert included_user["id"] == str(api_key.entity.id)

        # Refresh entity from database to get current state
        # (in case other tests modified the shared session-scoped user fixture)
        api_key.entity.refresh_from_db()

        # Verify UserIncludeSerializer fields are present
        user_attrs = included_user["attributes"]
        assert "name" in user_attrs
        assert "email" in user_attrs
        assert "company_name" in user_attrs
        assert "date_joined" in user_attrs
        assert user_attrs["name"] == api_key.entity.name
        assert user_attrs["email"] == api_key.entity.email

        # Verify memberships field is NOT included (excluded by UserIncludeSerializer)
        assert "memberships" not in user_attrs

        # Verify roles relationship is present
        assert "relationships" in included_user
        assert "roles" in included_user["relationships"]

    def test_api_keys_entity_auto_assigned_on_create(
        self, authenticated_client, create_test_user
    ):
        """Test that entity is automatically assigned to current user on creation."""
        data = {
            "data": {
                "type": "api-keys",
                "attributes": {
                    "name": "Auto Entity Key",
                },
            }
        }
        response = authenticated_client.post(
            reverse("api-key-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        response_data = response.json()["data"]

        # Entity should be set to authenticated user
        assert response_data["relationships"]["entity"]["data"]["id"] == str(
            create_test_user.id
        )

        # Verify in database
        api_key_id = response_data["id"]
        api_key = TenantAPIKey.objects.get(id=api_key_id)
        assert api_key.entity.id == create_test_user.id

    def test_api_keys_list_response_structure(
        self, authenticated_client, api_keys_fixture
    ):
        """Test that list response follows JSON:API structure."""
        response = authenticated_client.get(reverse("api-key-list"))
        assert response.status_code == status.HTTP_200_OK
        response_data = response.json()

        # Verify top-level structure
        assert "data" in response_data
        assert "meta" in response_data
        assert isinstance(response_data["data"], list)

        # Verify pagination meta
        assert "pagination" in response_data["meta"]
        assert "count" in response_data["meta"]["pagination"]
        assert "page" in response_data["meta"]["pagination"]
        assert "pages" in response_data["meta"]["pagination"]

    def test_api_keys_retrieve_response_structure(
        self, authenticated_client, api_keys_fixture
    ):
        """Test that retrieve response follows JSON:API structure."""
        api_key = api_keys_fixture[0]
        response = authenticated_client.get(
            reverse("api-key-detail", kwargs={"pk": api_key.id})
        )
        assert response.status_code == status.HTTP_200_OK
        response_data = response.json()

        # Verify top-level structure
        assert "data" in response_data
        data = response_data["data"]

        # Verify resource object structure
        assert "type" in data
        assert data["type"] == "api-keys"
        assert "id" in data
        assert "attributes" in data
        assert "relationships" in data

    def test_api_keys_create_response_structure(
        self, authenticated_client, create_test_user
    ):
        """Test that create response follows JSON:API structure."""
        data = {
            "data": {
                "type": "api-keys",
                "attributes": {
                    "name": "Structure Test Key",
                },
            }
        }
        response = authenticated_client.post(
            reverse("api-key-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED
        response_data = response.json()

        # Verify top-level structure
        assert "data" in response_data
        data = response_data["data"]

        # Verify resource object structure
        assert "type" in data
        assert data["type"] == "api-keys"
        assert "id" in data
        assert "attributes" in data
        assert "relationships" in data

        # Verify api_key is included in creation response only
        assert "api_key" in data["attributes"]
        assert data["attributes"]["api_key"] is not None

    def test_api_keys_error_response_structure(self, authenticated_client):
        """Test that error responses follow JSON:API structure."""
        response = authenticated_client.get(
            reverse(
                "api-key-detail",
                kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
            )
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND
        response_data = response.json()

        # Verify error structure
        assert "errors" in response_data
        assert isinstance(response_data["errors"], list)
        assert len(response_data["errors"]) > 0

        # Verify error object structure
        error = response_data["errors"][0]
        assert "detail" in error or "title" in error


@pytest.mark.django_db
class TestLighthouseTenantConfigViewSet:
    """Test Lighthouse tenant configuration endpoint (singleton pattern)"""

    def test_lighthouse_tenant_config_create_via_patch(self, authenticated_client):
        """Test creating a tenant config successfully via PATCH (upsert)"""
        payload = {
            "data": {
                "type": "lighthouse-configurations",
                "attributes": {
                    "business_context": "Test business context for security analysis",
                    "default_provider": "",
                    "default_models": {},
                },
            }
        }
        response = authenticated_client.patch(
            reverse("lighthouse-configurations"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert (
            data["attributes"]["business_context"]
            == "Test business context for security analysis"
        )
        assert data["attributes"]["default_provider"] == ""
        assert data["attributes"]["default_models"] == {}

    def test_lighthouse_tenant_config_upsert_behavior(self, authenticated_client):
        """Test that PATCH creates config if not exists and updates if exists (upsert)"""
        payload = {
            "data": {
                "type": "lighthouse-configurations",
                "attributes": {
                    "business_context": "First config",
                },
            }
        }

        # First PATCH creates the config
        response = authenticated_client.patch(
            reverse("lighthouse-configurations"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_200_OK
        first_data = response.json()["data"]
        assert first_data["attributes"]["business_context"] == "First config"

        # Second PATCH updates the same config (not creating a duplicate)
        payload["data"]["attributes"]["business_context"] = "Updated config"
        response = authenticated_client.patch(
            reverse("lighthouse-configurations"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_200_OK
        second_data = response.json()["data"]
        assert second_data["attributes"]["business_context"] == "Updated config"
        # Verify it's the same config (same ID)
        assert first_data["id"] == second_data["id"]

    @patch("openai.OpenAI")
    def test_lighthouse_tenant_config_retrieve(
        self, mock_openai_client, authenticated_client, tenants_fixture
    ):
        """Test retrieving the singleton tenant config with proper provider and model validation"""

        # Mock OpenAI client and models response
        mock_models_response = Mock()
        mock_models_response.data = [
            Mock(id="gpt-4o"),
            Mock(id="gpt-4o-mini"),
            Mock(id="gpt-5"),
        ]
        mock_openai_client.return_value.models.list.return_value = mock_models_response

        # Create OpenAI provider configuration
        provider_config = LighthouseProviderConfiguration.objects.create(
            tenant_id=tenants_fixture[0].id,
            provider_type="openai",
            credentials=b'{"api_key": "sk-fake-test-key-for-unit-testing-only"}',
            is_active=True,
        )

        # Create provider models (simulating refresh)
        LighthouseProviderModels.objects.create(
            tenant_id=tenants_fixture[0].id,
            provider_configuration=provider_config,
            model_id="gpt-4o",
            default_parameters={},
        )
        LighthouseProviderModels.objects.create(
            tenant_id=tenants_fixture[0].id,
            provider_configuration=provider_config,
            model_id="gpt-4o-mini",
            default_parameters={},
        )

        # Create tenant configuration with valid provider and model
        config = LighthouseTenantConfiguration.objects.create(
            tenant_id=tenants_fixture[0].id,
            business_context="Test context",
            default_provider="openai",
            default_models={"openai": "gpt-4o"},
        )

        # Retrieve and verify the configuration
        response = authenticated_client.get(reverse("lighthouse-configurations"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data["id"] == str(config.id)
        assert data["attributes"]["business_context"] == "Test context"
        assert data["attributes"]["default_provider"] == "openai"
        assert data["attributes"]["default_models"] == {"openai": "gpt-4o"}

    def test_lighthouse_tenant_config_retrieve_not_found(self, authenticated_client):
        """Test GET when config doesn't exist returns 404"""
        response = authenticated_client.get(reverse("lighthouse-configurations"))
        assert response.status_code == status.HTTP_404_NOT_FOUND
        assert "not found" in response.json()["errors"][0]["detail"].lower()

    def test_lighthouse_tenant_config_partial_update(
        self, authenticated_client, tenants_fixture
    ):
        """Test updating tenant config fields"""
        from api.models import LighthouseTenantConfiguration

        # Create config first
        config = LighthouseTenantConfiguration.objects.create(
            tenant_id=tenants_fixture[0].id,
            business_context="Original context",
            default_provider="",
            default_models={},
        )

        # Update it
        payload = {
            "data": {
                "type": "lighthouse-configurations",
                "attributes": {
                    "business_context": "Updated context for cloud security",
                },
            }
        }
        response = authenticated_client.patch(
            reverse("lighthouse-configurations"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_200_OK

        # Verify update
        config.refresh_from_db()
        assert config.business_context == "Updated context for cloud security"

    def test_lighthouse_tenant_config_update_invalid_provider(
        self, authenticated_client, tenants_fixture
    ):
        """Test validation fails when default_provider is not configured and active"""
        from api.models import LighthouseTenantConfiguration

        # Create config first
        LighthouseTenantConfiguration.objects.create(
            tenant_id=tenants_fixture[0].id,
            business_context="Test",
        )

        # Try to set invalid provider
        payload = {
            "data": {
                "type": "lighthouse-configurations",
                "attributes": {
                    "default_provider": "nonexistent-provider",
                },
            }
        }
        response = authenticated_client.patch(
            reverse("lighthouse-configurations"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "provider" in response.json()["errors"][0]["detail"].lower()

    def test_lighthouse_tenant_config_update_invalid_json_format(
        self, authenticated_client, tenants_fixture
    ):
        """Test that invalid JSON payload is rejected"""
        from api.models import LighthouseTenantConfiguration

        # Create config first
        LighthouseTenantConfiguration.objects.create(
            tenant_id=tenants_fixture[0].id,
            business_context="Test",
        )

        # Send invalid JSON
        response = authenticated_client.patch(
            reverse("lighthouse-configurations"),
            data="invalid json",
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST


@pytest.mark.django_db
class TestLighthouseProviderConfigViewSet:
    """Tests for LighthouseProviderConfiguration create validations"""

    def test_invalid_provider_type(self, authenticated_client):
        """Add invalid provider (testprovider) should error"""
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "testprovider",
                    "credentials": {"api_key": "sk-fake-test-key-1234"},
                },
            }
        }
        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp.status_code == status.HTTP_400_BAD_REQUEST

    def test_openai_missing_credentials(self, authenticated_client):
        """OpenAI provider without credentials should error"""
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai",
                },
            }
        }
        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "credentials",
        [
            {},  # empty credentials
            {"token": "sk-fake-test-key-1234"},  # wrong key name
            {"api_key": "ks-invalid-format"},  # wrong format
        ],
    )
    def test_openai_invalid_credentials(self, authenticated_client, credentials):
        """OpenAI provider with invalid credentials should error"""
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai",
                    "credentials": credentials,
                },
            }
        }
        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp.status_code == status.HTTP_400_BAD_REQUEST

    def test_openai_valid_credentials_success(self, authenticated_client):
        """OpenAI provider with valid sk-xxx format should succeed"""
        valid_key = "sk-fake-abc-test-key-xyz"
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai",
                    "credentials": {"api_key": valid_key},
                },
            }
        }
        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp.status_code == status.HTTP_201_CREATED
        data = resp.json()["data"]

        masked_creds = data["attributes"].get("credentials")
        assert masked_creds is not None
        assert "api_key" in masked_creds
        assert masked_creds["api_key"] == ("*" * len(valid_key))

    def test_openai_provider_duplicate_per_tenant(self, authenticated_client):
        """If an OpenAI provider exists for tenant, creating again should error"""
        valid_key = "sk-fake-dup-test-key-456"
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai",
                    "credentials": {"api_key": valid_key},
                },
            }
        }
        # First creation succeeds
        resp1 = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp1.status_code == status.HTTP_201_CREATED

        # Second creation should fail with validation error
        resp2 = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp2.status_code == status.HTTP_400_BAD_REQUEST
        assert "already exists" in str(resp2.json()).lower()

    def test_openai_patch_base_url_and_is_active(self, authenticated_client):
        """After creating, should be able to patch base_url and is_active"""
        valid_key = "sk-fake-patch-test-key-456"
        create_payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai",
                    "credentials": {"api_key": valid_key},
                },
            }
        }
        create_resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=create_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert create_resp.status_code == status.HTTP_201_CREATED
        provider_id = create_resp.json()["data"]["id"]

        patch_payload = {
            "data": {
                "type": "lighthouse-providers",
                "id": provider_id,
                "attributes": {
                    "base_url": "https://api.example.com/v1",
                    "is_active": False,
                },
            }
        }
        patch_resp = authenticated_client.patch(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
            data=patch_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert patch_resp.status_code == status.HTTP_200_OK
        updated = patch_resp.json()["data"]["attributes"]
        assert updated["base_url"] == "https://api.example.com/v1"
        assert updated["is_active"] is False

    def test_openai_patch_invalid_credentials(self, authenticated_client):
        """PATCH with invalid credentials.api_key should error (400)"""
        valid_key = "sk-fake-ok-test-key-456"
        create_payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai",
                    "credentials": {"api_key": valid_key},
                },
            }
        }
        create_resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=create_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert create_resp.status_code == status.HTTP_201_CREATED
        provider_id = create_resp.json()["data"]["id"]

        # Try patch with invalid api_key format
        patch_payload = {
            "data": {
                "type": "lighthouse-providers",
                "id": provider_id,
                "attributes": {
                    "credentials": {"api_key": "ks-invalid-format"},
                },
            }
        }
        patch_resp = authenticated_client.patch(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
            data=patch_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST

    def test_openai_get_masking_and_fields_filter(self, authenticated_client):
        valid_key = "sk-fake-get-test-key-456"
        create_payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai",
                    "credentials": {"api_key": valid_key},
                },
            }
        }
        create_resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=create_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert create_resp.status_code == status.HTTP_201_CREATED
        provider_id = create_resp.json()["data"]["id"]

        # Default GET should return masked credentials
        get_resp = authenticated_client.get(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
        )
        assert get_resp.status_code == status.HTTP_200_OK
        masked = get_resp.json()["data"]["attributes"]["credentials"]["api_key"]
        assert masked == ("*" * len(valid_key))

        # Fields filter should return decrypted credentials structure
        get_full = authenticated_client.get(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
            + "?fields[lighthouse-providers]=credentials"
        )
        assert get_full.status_code == status.HTTP_200_OK
        creds = get_full.json()["data"]["attributes"]["credentials"]
        assert creds["api_key"] == valid_key

    def test_delete_provider_updates_tenant_defaults(
        self, authenticated_client, tenants_fixture
    ):
        """Deleting a provider config should clear tenant default_provider and its default_model entry."""

        tenant = tenants_fixture[0]

        # Create provider configuration to delete
        provider = LighthouseProviderConfiguration.objects.create(
            tenant_id=tenant.id,
            provider_type="openai",
            credentials=b'{"api_key":"sk-fake-test-key-123"}',
            is_active=True,
        )

        # Seed tenant defaults referencing the provider we will delete
        cfg = LighthouseTenantConfiguration.objects.create(
            tenant_id=tenant.id,
            business_context="Test",
            default_provider="openai",
            default_models={"openai": "gpt-4o", "other": "model-x"},
        )

        # Delete via API and validate response
        url = reverse("lighthouse-providers-detail", kwargs={"pk": str(provider.id)})
        resp = authenticated_client.delete(url)
        assert resp.status_code in (
            status.HTTP_204_NO_CONTENT,
            status.HTTP_200_OK,
        )

        # Tenant defaults should be updated
        cfg.refresh_from_db()
        assert cfg.default_provider == ""
        assert "openai" not in cfg.default_models

        # Unrelated entries should remain untouched
        assert cfg.default_models.get("other") == "model-x"

    @pytest.mark.parametrize(
        "credentials",
        [
            {},  # empty credentials
            {
                "access_key_id": "AKIAIOSFODNN7EXAMPLE"
            },  # missing secret_access_key and region
            {
                "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
            },  # missing access_key_id and region
            {
                "access_key_id": "AKIAIOSFODNN7EXAMPLE",
                "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
            },  # missing region
            {  # invalid access_key_id format (not starting with AKIA)
                "access_key_id": "ABCD0123456789ABCDEF",
                "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
                "region": "us-east-1",
            },
            {  # invalid access_key_id format (wrong length)
                "access_key_id": "AKIAIOSFODNN7EXAMPL",
                "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
                "region": "us-east-1",
            },
            {  # invalid secret_access_key format (wrong length)
                "access_key_id": "AKIAIOSFODNN7EXAMPLE",
                "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEK",
                "region": "us-east-1",
            },
            {  # invalid region format
                "access_key_id": "AKIAIOSFODNN7EXAMPLE",
                "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
                "region": "invalid-region",
            },
            {  # invalid region format (uppercase)
                "access_key_id": "AKIAIOSFODNN7EXAMPLE",
                "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
                "region": "US-EAST-1",
            },
        ],
    )
    def test_bedrock_invalid_credentials(self, authenticated_client, credentials):
        """Bedrock provider with invalid credentials should error"""
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "bedrock",
                    "credentials": credentials,
                },
            }
        }
        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp.status_code == status.HTTP_400_BAD_REQUEST

    def test_bedrock_valid_credentials_success(self, authenticated_client):
        """Bedrock provider with valid AWS credentials should succeed and mask credentials"""
        valid_credentials = {
            "access_key_id": "AKIAIOSFODNN7EXAMPLE",
            "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
            "region": "us-east-1",
        }
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "bedrock",
                    "credentials": valid_credentials,
                },
            }
        }
        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp.status_code == status.HTTP_201_CREATED
        data = resp.json()["data"]

        # Verify credentials are returned masked
        masked_creds = data["attributes"].get("credentials")
        assert masked_creds is not None
        assert "access_key_id" in masked_creds
        assert "secret_access_key" in masked_creds
        assert "region" in masked_creds
        # Verify all characters are masked with asterisks
        assert all(c == "*" for c in masked_creds["access_key_id"])
        assert all(c == "*" for c in masked_creds["secret_access_key"])

    def test_bedrock_provider_duplicate_per_tenant(self, authenticated_client):
        """Creating a second Bedrock provider for same tenant should fail"""
        valid_credentials = {
            "access_key_id": "AKIAIOSFODNN7EXAMPLE",
            "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
            "region": "us-west-2",
        }
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "bedrock",
                    "credentials": valid_credentials,
                },
            }
        }
        # First creation succeeds
        resp1 = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp1.status_code == status.HTTP_201_CREATED

        # Second creation should fail with validation error
        resp2 = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp2.status_code == status.HTTP_400_BAD_REQUEST
        assert "already exists" in str(resp2.json()).lower()

    def test_bedrock_patch_credentials_and_fields_filter(self, authenticated_client):
        """PATCH credentials and verify fields filter returns decrypted values"""
        valid_credentials = {
            "access_key_id": "AKIAIOSFODNN7EXAMPLE",
            "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
            "region": "eu-west-1",
        }
        create_payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "bedrock",
                    "credentials": valid_credentials,
                },
            }
        }
        create_resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=create_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert create_resp.status_code == status.HTTP_201_CREATED
        provider_id = create_resp.json()["data"]["id"]

        # Update credentials with new valid ones
        new_credentials = {
            "access_key_id": "AKIAZZZZZZZZZZZZZZZZ",
            "secret_access_key": "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/==",
            "region": "ap-south-1",
        }
        patch_payload = {
            "data": {
                "type": "lighthouse-providers",
                "id": provider_id,
                "attributes": {
                    "credentials": new_credentials,
                    "is_active": False,
                },
            }
        }
        patch_resp = authenticated_client.patch(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
            data=patch_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert patch_resp.status_code == status.HTTP_200_OK
        updated = patch_resp.json()["data"]["attributes"]
        assert updated["is_active"] is False

        # Default GET should return masked credentials
        get_resp = authenticated_client.get(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
        )
        assert get_resp.status_code == status.HTTP_200_OK
        masked = get_resp.json()["data"]["attributes"]["credentials"]
        assert all(c == "*" for c in masked["access_key_id"])
        assert all(c == "*" for c in masked["secret_access_key"])

        # Fields filter should return decrypted credentials
        get_full = authenticated_client.get(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
            + "?fields[lighthouse-providers]=credentials"
        )
        assert get_full.status_code == status.HTTP_200_OK
        creds = get_full.json()["data"]["attributes"]["credentials"]
        assert creds["access_key_id"] == new_credentials["access_key_id"]
        assert creds["secret_access_key"] == new_credentials["secret_access_key"]
        assert creds["region"] == new_credentials["region"]

    def test_bedrock_partial_credential_update(self, authenticated_client):
        """Test partial update of Bedrock credentials (e.g., only region)"""
        # Create provider with full credentials
        initial_credentials = {
            "access_key_id": "AKIAIOSFODNN7EXAMPLE",
            "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
            "region": "us-east-1",
        }
        create_payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "bedrock",
                    "credentials": initial_credentials,
                },
            }
        }
        create_resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=create_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert create_resp.status_code == status.HTTP_201_CREATED
        provider_id = create_resp.json()["data"]["id"]

        # Update only the region field
        partial_update = {
            "region": "eu-west-1",
        }
        patch_payload = {
            "data": {
                "type": "lighthouse-providers",
                "id": provider_id,
                "attributes": {
                    "credentials": partial_update,
                },
            }
        }
        patch_resp = authenticated_client.patch(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
            data=patch_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert patch_resp.status_code == status.HTTP_200_OK

        # Verify credentials with fields filter - region should be updated, keys preserved
        get_full = authenticated_client.get(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
            + "?fields[lighthouse-providers]=credentials"
        )
        assert get_full.status_code == status.HTTP_200_OK
        creds = get_full.json()["data"]["attributes"]["credentials"]

        # Original keys should be preserved
        assert creds["access_key_id"] == initial_credentials["access_key_id"]
        assert creds["secret_access_key"] == initial_credentials["secret_access_key"]
        # Region should be updated
        assert creds["region"] == "eu-west-1"

    def test_bedrock_valid_api_key_credentials_success(self, authenticated_client):
        """Bedrock provider with valid API key + region should succeed and return masked credentials"""
        valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110)
        api_credentials = {
            "api_key": valid_api_key,
            "region": "us-east-1",
        }
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "bedrock",
                    "credentials": api_credentials,
                },
            }
        }
        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp.status_code == status.HTTP_201_CREATED
        data = resp.json()["data"]

        # Verify credentials are returned masked
        masked_creds = data["attributes"].get("credentials")
        assert masked_creds is not None
        assert "api_key" in masked_creds
        assert "region" in masked_creds
        assert all(c == "*" for c in masked_creds["api_key"])

    def test_bedrock_mixed_api_key_and_access_keys_invalid_on_create(
        self, authenticated_client
    ):
        """Bedrock provider with both API key and access keys should fail validation on create"""
        valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110)
        mixed_credentials = {
            "access_key_id": "AKIAIOSFODNN7EXAMPLE",
            "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
            "api_key": valid_api_key,
            "region": "us-east-1",
        }
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "bedrock",
                    "credentials": mixed_credentials,
                },
            }
        }
        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp.status_code == status.HTTP_400_BAD_REQUEST
        error_body = str(resp.json()).lower()
        assert "either access key + secret key or api key" in error_body

    def test_bedrock_cannot_switch_from_api_key_to_access_keys_on_update(
        self, authenticated_client
    ):
        """If created with API key, switching to access keys via update should be rejected"""
        valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110)
        create_payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "bedrock",
                    "credentials": {
                        "api_key": valid_api_key,
                        "region": "us-east-1",
                    },
                },
            }
        }
        create_resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=create_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert create_resp.status_code == status.HTTP_201_CREATED
        provider_id = create_resp.json()["data"]["id"]

        # Attempt to introduce access keys on update
        patch_payload = {
            "data": {
                "type": "lighthouse-providers",
                "id": provider_id,
                "attributes": {
                    "credentials": {
                        "access_key_id": "AKIAIOSFODNN7EXAMPLE",
                        "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
                    },
                },
            }
        }
        patch_resp = authenticated_client.patch(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
            data=patch_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST
        error_body = str(patch_resp.json()).lower()
        assert "cannot change bedrock authentication method from api key" in error_body

    def test_bedrock_cannot_switch_from_access_keys_to_api_key_on_update(
        self, authenticated_client
    ):
        """If created with access keys, switching to API key via update should be rejected"""
        valid_api_key = "ABSKQmVkcm9ja0FQSUtleS" + ("A" * 110)
        initial_credentials = {
            "access_key_id": "AKIAIOSFODNN7EXAMPLE",
            "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
            "region": "us-east-1",
        }
        create_payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "bedrock",
                    "credentials": initial_credentials,
                },
            }
        }
        create_resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=create_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert create_resp.status_code == status.HTTP_201_CREATED
        provider_id = create_resp.json()["data"]["id"]

        # Attempt to introduce API key on update
        patch_payload = {
            "data": {
                "type": "lighthouse-providers",
                "id": provider_id,
                "attributes": {
                    "credentials": {
                        "api_key": valid_api_key,
                    },
                },
            }
        }
        patch_resp = authenticated_client.patch(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
            data=patch_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST
        error_body = str(patch_resp.json()).lower()
        assert (
            "cannot change bedrock authentication method from access key" in error_body
        )

    @pytest.mark.parametrize(
        "attributes",
        [
            pytest.param(
                {
                    "provider_type": "openai_compatible",
                    "credentials": {"api_key": "compat-key"},
                },
                id="missing",
            ),
            pytest.param(
                {
                    "provider_type": "openai_compatible",
                    "credentials": {"api_key": "compat-key"},
                    "base_url": "",
                },
                id="empty",
            ),
        ],
    )
    def test_openai_compatible_missing_base_url(self, authenticated_client, attributes):
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": attributes,
            }
        }

        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp.status_code == status.HTTP_400_BAD_REQUEST
        error_detail = str(resp.json()).lower()
        assert "base_url" in error_detail

    @pytest.mark.parametrize(
        "base_url",
        [
            "https://127.0.0.1/v1",
            "https://169.254.169.254/latest/meta-data",
        ],
    )
    def test_openai_compatible_rejects_internal_base_url_on_create(
        self, authenticated_client, base_url
    ):
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai_compatible",
                    "base_url": base_url,
                    "credentials": {"api_key": "compat-key"},
                },
            }
        }

        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )

        assert resp.status_code == status.HTTP_400_BAD_REQUEST
        assert "base_url" in str(resp.json()).lower()

    def test_openai_compatible_rejects_internal_base_url_on_update(
        self, authenticated_client
    ):
        create_payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai_compatible",
                    "base_url": "https://openrouter.ai/api/v1",
                    "credentials": {"api_key": "compat-key-123"},
                },
            }
        }
        create_resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=create_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert create_resp.status_code == status.HTTP_201_CREATED
        provider_id = create_resp.json()["data"]["id"]

        patch_payload = {
            "data": {
                "type": "lighthouse-providers",
                "id": provider_id,
                "attributes": {
                    "base_url": "https://169.254.169.254/latest/meta-data",
                },
            }
        }

        patch_resp = authenticated_client.patch(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
            data=patch_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )

        assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST
        assert "base_url" in str(patch_resp.json()).lower()

    def test_openai_compatible_invalid_credentials(self, authenticated_client):
        payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai_compatible",
                    "base_url": "https://compat.example/v1",
                    "credentials": {"api_key": ""},
                },
            }
        }

        resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert resp.status_code == status.HTTP_400_BAD_REQUEST
        errors = resp.json().get("errors", [])
        assert any(
            error.get("source", {}).get("pointer")
            == "/data/attributes/credentials/api_key"
            for error in errors
        )
        assert any(
            "may not be blank" in error.get("detail", "").lower() for error in errors
        )

    def test_openai_compatible_patch_credentials_and_fields(self, authenticated_client):
        create_payload = {
            "data": {
                "type": "lighthouse-providers",
                "attributes": {
                    "provider_type": "openai_compatible",
                    "base_url": "https://compat.example/v1",
                    "credentials": {"api_key": "compat-key-123"},
                },
            }
        }

        create_resp = authenticated_client.post(
            reverse("lighthouse-providers-list"),
            data=create_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert create_resp.status_code == status.HTTP_201_CREATED
        provider_id = create_resp.json()["data"]["id"]

        updated_base_url = "https://compat.example/v2"
        updated_api_key = "compat-key-456"
        patch_payload = {
            "data": {
                "type": "lighthouse-providers",
                "id": provider_id,
                "attributes": {
                    "base_url": updated_base_url,
                    "credentials": {"api_key": updated_api_key},
                },
            }
        }

        patch_resp = authenticated_client.patch(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
            data=patch_payload,
            content_type=API_JSON_CONTENT_TYPE,
        )
        assert patch_resp.status_code == status.HTTP_200_OK
        updated_attrs = patch_resp.json()["data"]["attributes"]
        assert updated_attrs["base_url"] == updated_base_url
        assert updated_attrs["credentials"]["api_key"] == "*" * len(updated_api_key)

        get_resp = authenticated_client.get(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
        )
        assert get_resp.status_code == status.HTTP_200_OK
        masked = get_resp.json()["data"]["attributes"]["credentials"]["api_key"]
        assert masked == "*" * len(updated_api_key)

        get_full = authenticated_client.get(
            reverse("lighthouse-providers-detail", kwargs={"pk": provider_id})
            + "?fields[lighthouse-providers]=credentials"
        )
        assert get_full.status_code == status.HTTP_200_OK
        creds = get_full.json()["data"]["attributes"]["credentials"]
        assert creds["api_key"] == updated_api_key


@pytest.mark.django_db
class TestMuteRuleViewSet:
    """Tests for MuteRule endpoints."""

    def test_mute_rules_list(self, authenticated_client, mute_rules_fixture):
        """Test listing all mute rules for the tenant."""
        response = authenticated_client.get(reverse("mute-rule-list"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == len(mute_rules_fixture)

    def test_mute_rules_list_empty(self, authenticated_client, tenants_fixture):
        """Test listing mute rules when none exist returns empty list."""
        response = authenticated_client.get(reverse("mute-rule-list"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 0
        assert isinstance(data, list)

    def test_mute_rules_list_default_ordering(
        self, authenticated_client, mute_rules_fixture
    ):
        """Test that mute rules are ordered by -inserted_at by default."""
        response = authenticated_client.get(reverse("mute-rule-list"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]

        if len(data) >= 2:
            first_date = data[0]["attributes"]["inserted_at"]
            second_date = data[1]["attributes"]["inserted_at"]
            assert first_date >= second_date

    def test_mute_rules_retrieve(self, authenticated_client, mute_rules_fixture):
        """Test retrieving a single mute rule by ID."""
        mute_rule = mute_rules_fixture[0]
        response = authenticated_client.get(
            reverse("mute-rule-detail", kwargs={"pk": mute_rule.id})
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data["id"] == str(mute_rule.id)
        assert data["attributes"]["name"] == mute_rule.name
        assert data["attributes"]["reason"] == mute_rule.reason
        assert data["attributes"]["enabled"] == mute_rule.enabled
        assert "finding_uids" in data["attributes"]
        assert "inserted_at" in data["attributes"]
        assert "updated_at" in data["attributes"]

    def test_mute_rules_retrieve_invalid(self, authenticated_client):
        """Test retrieving non-existent mute rule returns 404."""
        response = authenticated_client.get(
            reverse(
                "mute-rule-detail",
                kwargs={"pk": "f498b103-c760-4785-9a3e-e23fafbb7b02"},
            )
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    @pytest.mark.parametrize(
        "filter_name, filter_value, expected_count",
        (
            [
                ("name", "Test Rule 1", 1),
                ("name.icontains", "rule", 2),
                ("reason.icontains", "security", 1),
                ("enabled", True, 1),
                ("enabled", False, 1),
            ]
        ),
    )
    def test_mute_rule_filters(
        self,
        authenticated_client,
        mute_rules_fixture,
        filter_name,
        filter_value,
        expected_count,
    ):
        """Test filtering mute rules by various fields."""
        filters = {f"filter[{filter_name}]": filter_value}
        response = authenticated_client.get(reverse("mute-rule-list"), filters)
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == expected_count

    def test_mute_rule_filter_by_created_by(
        self, authenticated_client, mute_rules_fixture, create_test_user
    ):
        """Test filtering mute rules by creator."""
        response = authenticated_client.get(
            reverse("mute-rule-list"),
            {"filter[created_by]": create_test_user.id},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2

    def test_mute_rule_search(self, authenticated_client, mute_rules_fixture):
        """Test searching mute rules by name and reason."""
        response = authenticated_client.get(
            reverse("mute-rule-list"), {"filter[search]": "Rule 1"}
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1

    @pytest.mark.parametrize(
        "sort_field, first_index",
        (
            [
                ("name", 0),
                ("-name", 1),
                ("inserted_at", 0),
                ("-inserted_at", 1),
            ]
        ),
    )
    def test_mute_rule_ordering(
        self, authenticated_client, mute_rules_fixture, sort_field, first_index
    ):
        """Test ordering mute rules by various fields."""
        response = authenticated_client.get(
            reverse("mute-rule-list"), {"sort": sort_field}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        assert data[0]["id"] == str(mute_rules_fixture[first_index].id)

    @patch("api.v1.views.chain")
    @patch("api.v1.views.reaggregate_all_finding_group_summaries_task.si")
    @patch("api.v1.views.mute_historical_findings_task.si")
    @patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
    def test_mute_rules_create_valid(
        self,
        _mock_on_commit,
        mock_mute_signature,
        mock_reaggregate_signature,
        mock_chain,
        authenticated_client,
        findings_fixture,
        create_test_user,
    ):
        """Test creating a valid mute rule."""
        finding_ids = [str(findings_fixture[0].id)]
        data = {
            "data": {
                "type": "mute-rules",
                "attributes": {
                    "name": "New Mute Rule",
                    "reason": "Security exception approved",
                    "finding_ids": finding_ids,
                },
            }
        }
        response = authenticated_client.post(
            reverse("mute-rule-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED

        # Verify response contains the created mute rule
        response_data = response.json()["data"]
        assert response_data["type"] == "mute-rules"
        assert response_data["attributes"]["name"] == "New Mute Rule"
        assert response_data["attributes"]["reason"] == "Security exception approved"

        # Verify the finding was immediately muted
        from api.models import Finding

        finding = Finding.objects.get(id=findings_fixture[0].id)
        assert finding.muted is True
        assert finding.muted_at is not None
        assert finding.muted_reason == "Security exception approved"

        # Verify background task chain was called: mute → reaggregate all
        mock_mute_signature.assert_called_once()
        mock_reaggregate_signature.assert_called_once()
        mock_chain.assert_called_once_with(
            mock_mute_signature.return_value,
            mock_reaggregate_signature.return_value,
        )
        mock_chain.return_value.apply_async.assert_called_once()

    @patch("tasks.tasks.mute_historical_findings_task.apply_async")
    def test_mute_rules_create_converts_finding_ids_to_uids(
        self,
        mock_task,
        authenticated_client,
        findings_fixture,
    ):
        """Test that finding_ids are converted to finding UIDs."""
        finding_ids = [str(findings_fixture[0].id), str(findings_fixture[1].id)]
        data = {
            "data": {
                "type": "mute-rules",
                "attributes": {
                    "name": "UID Conversion Test",
                    "reason": "Testing UID conversion",
                    "finding_ids": finding_ids,
                },
            }
        }
        response = authenticated_client.post(
            reverse("mute-rule-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED

        # Verify finding_uids contains the UIDs, not IDs
        from api.models import MuteRule

        mute_rule = MuteRule.objects.get(name="UID Conversion Test")
        expected_uids = [
            findings_fixture[0].uid,
            findings_fixture[1].uid,
        ]
        assert set(mute_rule.finding_uids) == set(expected_uids)

    @patch("tasks.tasks.mute_historical_findings_task.apply_async")
    def test_mute_rules_deduplicates_uids(
        self,
        mock_task,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        scans_fixture,
    ):
        """Test that multiple findings with same UID result in only one UID in the rule."""
        tenant = tenants_fixture[0]
        scan = scans_fixture[0]

        shared_uid = "prowler-aws-dedupe-test-001"

        finding1 = Finding.objects.create(
            tenant=tenant,
            uid=shared_uid,
            scan=scan,
            status=Status.FAIL,
            status_extended="test",
            severity=Severity.high,
            impact=Severity.high,
            check_id="test_check",
            check_metadata={"CheckId": "test_check"},
            raw_result={},
        )

        finding2 = Finding.objects.create(
            tenant=tenant,
            uid=shared_uid,
            scan=scan,
            status=Status.FAIL,
            status_extended="test",
            severity=Severity.high,
            impact=Severity.high,
            check_id="test_check",
            check_metadata={"CheckId": "test_check"},
            raw_result={},
        )

        finding_ids = [str(finding1.id), str(finding2.id)]
        data = {
            "data": {
                "type": "mute-rules",
                "attributes": {
                    "name": "Dedupe Test Rule",
                    "reason": "Testing UID deduplication",
                    "finding_ids": finding_ids,
                },
            }
        }
        response = authenticated_client.post(
            reverse("mute-rule-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED

        from api.models import MuteRule

        mute_rule = MuteRule.objects.get(name="Dedupe Test Rule")
        assert len(mute_rule.finding_uids) == 1
        assert mute_rule.finding_uids[0] == shared_uid

        finding1.refresh_from_db()
        finding2.refresh_from_db()
        assert finding1.muted is True
        assert finding2.muted is True

    @patch("tasks.tasks.mute_historical_findings_task.apply_async")
    def test_mute_rules_create_overlap_detection_active(
        self,
        mock_task,
        authenticated_client,
        mute_rules_fixture,
        findings_fixture,
    ):
        """Test that creating a rule with overlapping UIDs in active rule fails."""
        # mute_rules_fixture[0] is active and has findings_fixture[0] UID
        finding_ids = [str(findings_fixture[0].id)]
        data = {
            "data": {
                "type": "mute-rules",
                "attributes": {
                    "name": "Overlapping Rule",
                    "reason": "This should fail",
                    "finding_ids": finding_ids,
                },
            }
        }
        response = authenticated_client.post(
            reverse("mute-rule-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_409_CONFLICT
        assert "errors" in response.json()
        error_detail = response.json()["errors"][0]["detail"]
        assert (
            "already muted" in error_detail.lower() or "overlap" in error_detail.lower()
        )

    @patch("tasks.tasks.mute_historical_findings_task.apply_async")
    def test_mute_rules_create_no_overlap_with_inactive(
        self,
        mock_task,
        authenticated_client,
        mute_rules_fixture,
        findings_fixture,
    ):
        """Test that disabled rules don't prevent new rules with same UIDs."""
        # mute_rules_fixture[1] is disabled
        # Disable the enabled rule first
        mute_rules_fixture[0].enabled = False
        mute_rules_fixture[0].save()

        finding_ids = [str(findings_fixture[0].id)]
        data = {
            "data": {
                "type": "mute-rules",
                "attributes": {
                    "name": "Non-overlapping Rule",
                    "reason": "Inactive rules don't block",
                    "finding_ids": finding_ids,
                },
            }
        }
        response = authenticated_client.post(
            reverse("mute-rule-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_201_CREATED

    def test_mute_rules_create_invalid_empty_finding_ids(self, authenticated_client):
        """Test creating mute rule with empty finding_ids fails."""
        data = {
            "data": {
                "type": "mute-rules",
                "attributes": {
                    "name": "Valid",
                    "reason": "Valid",
                    "finding_ids": [],
                },
            }
        }
        response = authenticated_client.post(
            reverse("mute-rule-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "errors" in response.json()
        assert (
            response.json()["errors"][0]["source"]["pointer"]
            == "/data/attributes/finding_ids"
        )

    @patch("tasks.tasks.mute_historical_findings_task.apply_async")
    def test_mute_rules_create_invalid_finding_ids(
        self, mock_task, authenticated_client
    ):
        """Test creating mute rule with non-existent finding IDs fails."""
        data = {
            "data": {
                "type": "mute-rules",
                "attributes": {
                    "name": "Invalid Findings",
                    "reason": "This should fail",
                    "finding_ids": ["f498b103-c760-4785-9a3e-e23fafbb7b02"],
                },
            }
        }
        response = authenticated_client.post(
            reverse("mute-rule-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "errors" in response.json()

    def test_mute_rules_create_duplicate_name(
        self, authenticated_client, mute_rules_fixture
    ):
        """Test creating a mute rule with duplicate name fails."""
        existing_name = mute_rules_fixture[0].name
        data = {
            "data": {
                "type": "mute-rules",
                "attributes": {
                    "name": existing_name,
                    "reason": "Duplicate name test",
                    "finding_ids": ["f498b103-c760-4785-9a3e-e23fafbb7b02"],
                },
            }
        }
        response = authenticated_client.post(
            reverse("mute-rule-list"),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "errors" in response.json()

    def test_mute_rules_update_name(self, authenticated_client, mute_rules_fixture):
        """Test updating mute rule name."""
        mute_rule = mute_rules_fixture[0]
        data = {
            "data": {
                "type": "mute-rules",
                "id": str(mute_rule.id),
                "attributes": {
                    "name": "Updated Name",
                },
            }
        }
        response = authenticated_client.patch(
            reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        response_data = response.json()["data"]
        assert response_data["attributes"]["name"] == "Updated Name"

        # Verify database was updated
        mute_rule.refresh_from_db()
        assert mute_rule.name == "Updated Name"

    def test_mute_rules_update_reason(self, authenticated_client, mute_rules_fixture):
        """Test updating mute rule reason."""
        mute_rule = mute_rules_fixture[0]
        data = {
            "data": {
                "type": "mute-rules",
                "id": str(mute_rule.id),
                "attributes": {
                    "reason": "Updated reason for muting",
                },
            }
        }
        response = authenticated_client.patch(
            reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        response_data = response.json()["data"]
        assert response_data["attributes"]["reason"] == "Updated reason for muting"

        mute_rule.refresh_from_db()
        assert mute_rule.reason == "Updated reason for muting"

    def test_mute_rules_update_enabled(self, authenticated_client, mute_rules_fixture):
        """Test disabling a mute rule."""
        mute_rule = mute_rules_fixture[0]
        assert mute_rule.enabled is True

        data = {
            "data": {
                "type": "mute-rules",
                "id": str(mute_rule.id),
                "attributes": {
                    "enabled": False,
                },
            }
        }
        response = authenticated_client.patch(
            reverse("mute-rule-detail", kwargs={"pk": mute_rule.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_200_OK
        response_data = response.json()["data"]
        assert response_data["attributes"]["enabled"] is False

        mute_rule.refresh_from_db()
        assert mute_rule.enabled is False

    def test_mute_rules_update_duplicate_name(
        self, authenticated_client, mute_rules_fixture
    ):
        """Test updating mute rule with duplicate name fails."""
        first_rule = mute_rules_fixture[0]
        second_rule = mute_rules_fixture[1]

        data = {
            "data": {
                "type": "mute-rules",
                "id": str(second_rule.id),
                "attributes": {
                    "name": first_rule.name,
                },
            }
        }
        response = authenticated_client.patch(
            reverse("mute-rule-detail", kwargs={"pk": second_rule.id}),
            data=json.dumps(data),
            content_type="application/vnd.api+json",
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert "errors" in response.json()

    def test_mute_rules_delete(self, authenticated_client, mute_rules_fixture):
        """Test deleting a mute rule."""
        mute_rule = mute_rules_fixture[0]
        response = authenticated_client.delete(
            reverse("mute-rule-detail", kwargs={"pk": mute_rule.id})
        )
        assert response.status_code == status.HTTP_204_NO_CONTENT

        # Verify rule was deleted
        from api.models import MuteRule

        assert not MuteRule.objects.filter(id=mute_rule.id).exists()

    def test_mute_rules_tenant_isolation(
        self, authenticated_client, mute_rules_fixture, tenants_fixture
    ):
        """Test that users can only access mute rules from their tenant."""
        # Create a second tenant with a mute rule
        from api.models import MuteRule, Tenant

        other_tenant = Tenant.objects.create(name="Other Tenant")
        other_rule = MuteRule.objects.create(
            tenant=other_tenant,
            name="Other Tenant Rule",
            reason="Should not be visible",
            finding_uids=["test-uid"],
        )

        # Try to access other tenant's rule
        response = authenticated_client.get(
            reverse("mute-rule-detail", kwargs={"pk": other_rule.id})
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

        # List should only show current tenant's rules
        response = authenticated_client.get(reverse("mute-rule-list"))
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == len(mute_rules_fixture)
        for rule_data in data:
            assert rule_data["id"] != str(other_rule.id)


@pytest.mark.django_db
class TestFindingGroupViewSet:
    """Tests for Finding Groups API - aggregates findings by check_id."""

    def test_finding_groups_requires_date_filter(self, authenticated_client):
        """Test that at least one date filter is required."""
        response = authenticated_client.get(reverse("finding-group-list"))
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "required"

    def test_finding_groups_empty(self, authenticated_client):
        """Test empty list returned when no findings exist."""
        response = authenticated_client.get(
            reverse("finding-group-list"), {"filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 0

    def test_finding_groups_single_check(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that findings with same check_id are grouped correctly."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_id]": "s3_bucket_public_access",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == "s3_bucket_public_access"
        assert data[0]["attributes"]["check_id"] == "s3_bucket_public_access"

    def test_finding_groups_multiple_checks(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that different check_ids produce separate finding groups."""
        response = authenticated_client.get(
            reverse("finding-group-list"), {"filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        # Should have 5 distinct check_ids from fixture
        assert len(data) == 5
        check_ids = {item["id"] for item in data}
        assert "s3_bucket_public_access" in check_ids
        assert "ec2_instance_public_ip" in check_ids
        assert "iam_password_policy" in check_ids
        assert "rds_encryption" in check_ids
        assert "cloudtrail_enabled" in check_ids

    def test_finding_groups_severity_max(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that max severity is returned across all findings in group."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_id]": "s3_bucket_public_access",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        # s3_bucket_public_access has critical and high severity findings
        # Max should be critical
        assert data[0]["attributes"]["severity"] == "critical"

    def test_finding_groups_status_fail_priority(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that FAIL status takes priority over PASS when any non-muted FAIL exists."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_id]": "ec2_instance_public_ip",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        # ec2_instance_public_ip has 1 PASS and 1 FAIL, should aggregate to FAIL
        assert data[0]["attributes"]["status"] == "FAIL"

    def test_finding_groups_region_filter_reaggregates_metrics(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test finding-level filters recompute group metrics from matching findings."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_id]": "ec2_instance_public_ip",
                "filter[region]": "us-east-1",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1

        attrs = data[0]["attributes"]
        assert attrs["status"] == "PASS"
        assert attrs["pass_count"] == 1
        assert attrs["fail_count"] == 0
        assert attrs["resources_total"] == 1
        assert attrs["resources_fail"] == 0
        # check_title / check_description are resolved post-pagination from the
        # summary table, not from the finding's check_metadata.
        assert attrs["check_title"] == "Ensure EC2 instances do not have public IPs"
        assert (
            attrs["check_description"] == "EC2 instances should use private IPs only."
        )

    def test_finding_groups_status_pass_when_no_fail(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that PASS status returned when no non-muted FAIL exists."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[check_id]": "iam_password_policy"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        # iam_password_policy has only PASS findings
        assert data[0]["attributes"]["status"] == "PASS"

    def test_finding_groups_fully_muted_group_is_pass(
        self, authenticated_client, finding_groups_fixture
    ):
        """A fully-muted group reports status=PASS and muted=True.

        rds_encryption has 2 muted FAIL findings. Muted findings are treated
        as resolved/accepted, so the group is no longer actionable and its
        status must be PASS. The `muted` flag is True because every finding
        in the group is muted.
        """
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[check_id]": "rds_encryption"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attrs = data[0]["attributes"]
        assert attrs["status"] == "PASS"
        assert attrs["muted"] is True
        assert attrs["fail_count"] == 0
        assert attrs["fail_muted_count"] == 2
        assert attrs["pass_muted_count"] == 0
        assert attrs["manual_muted_count"] == 0
        assert attrs["muted_count"] == 2
        # Sanity: the per-status muted counts must add up to muted_count.
        assert (
            attrs["pass_muted_count"]
            + attrs["fail_muted_count"]
            + attrs["manual_muted_count"]
            == attrs["muted_count"]
        )

    def test_finding_groups_status_ignores_muted_failures(
        self,
        authenticated_client,
        tenants_fixture,
        scans_fixture,
        resources_fixture,
    ):
        """Muted FAIL findings must not drive the aggregated status.

        When a group mixes one non-muted PASS with one muted FAIL, the
        actionable outcome is PASS: there are no unmuted failures left. The
        aggregated `status` must reflect that (not FAIL), while `muted`
        stays False because the group still has a non-muted finding.
        """
        tenant = tenants_fixture[0]
        scan1, *_ = scans_fixture
        resource1, *_ = resources_fixture

        pass_finding = Finding.objects.create(
            tenant_id=tenant.id,
            uid="fg_mixed_muted_pass",
            scan=scan1,
            delta=None,
            status=Status.PASS,
            severity=Severity.low,
            impact=Severity.low,
            check_id="mixed_muted_check",
            check_metadata={
                "CheckId": "mixed_muted_check",
                "checktitle": "Mixed muted check",
                "Description": "Fixture for muted status aggregation.",
            },
            first_seen_at="2024-01-11T00:00:00Z",
            muted=False,
        )
        pass_finding.add_resources([resource1])

        fail_muted_finding = Finding.objects.create(
            tenant_id=tenant.id,
            uid="fg_mixed_muted_fail",
            scan=scan1,
            delta=None,
            status=Status.FAIL,
            severity=Severity.high,
            impact=Severity.high,
            check_id="mixed_muted_check",
            check_metadata={
                "CheckId": "mixed_muted_check",
                "checktitle": "Mixed muted check",
                "Description": "Fixture for muted status aggregation.",
            },
            first_seen_at="2024-01-12T00:00:00Z",
            muted=True,
        )
        fail_muted_finding.add_resources([resource1])

        # filter[region] forces finding-level aggregation so we exercise the
        # raw-findings path without touching the daily summary fixture.
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_id]": "mixed_muted_check",
                "filter[region]": "us-east-1",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attrs = data[0]["attributes"]
        assert attrs["status"] == "PASS"
        assert attrs["muted"] is False
        assert attrs["pass_count"] == 1
        assert attrs["fail_count"] == 0
        assert attrs["fail_muted_count"] == 1
        assert attrs["muted_count"] == 1

    def test_finding_groups_status_filter(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test finding groups can be filtered by aggregated status."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[status]": "FAIL"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0
        assert all(item["attributes"]["status"] == "FAIL" for item in data)

    def test_finding_groups_status_in_filter(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test finding groups support status__in filter on aggregated status."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[status__in]": "FAIL,PASS"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0
        assert all(item["attributes"]["status"] in {"FAIL", "PASS"} for item in data)

    def test_finding_groups_severity_filter(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test finding groups can be filtered by aggregated severity."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[severity]": "critical"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0
        assert all(item["attributes"]["severity"] == "critical" for item in data)

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    def test_finding_groups_combined_region_and_status_filters(
        self, authenticated_client, finding_groups_fixture, endpoint_name
    ):
        """Test combined region + aggregated status filters."""
        params = {"filter[region]": "us-east-1", "filter[status]": "FAIL"}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)

        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        check_ids = {item["id"] for item in data}
        assert check_ids == {"s3_bucket_public_access", "cloudtrail_enabled"}
        assert all(item["attributes"]["status"] == "FAIL" for item in data)

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    def test_finding_groups_combined_delta_and_severity_filters(
        self, authenticated_client, finding_groups_fixture, endpoint_name
    ):
        """Test combined delta + aggregated severity filters."""
        params = {"filter[delta]": "new", "filter[severity]": "critical"}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)

        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        check_ids = {item["id"] for item in data}
        assert check_ids == {"s3_bucket_public_access", "cloudtrail_enabled"}
        assert all(item["attributes"]["severity"] == "critical" for item in data)

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    @pytest.mark.parametrize(
        "filter_key,filter_value",
        [
            ("status", "INVALID_STATUS"),
            ("severity", "INVALID_SEVERITY"),
        ],
    )
    def test_finding_groups_invalid_status_or_severity_returns_400(
        self,
        authenticated_client,
        finding_groups_fixture,
        endpoint_name,
        filter_key,
        filter_value,
    ):
        """Test invalid aggregated status/severity values are rejected."""
        params = {f"filter[{filter_key}]": filter_value}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    @pytest.mark.parametrize(
        "filter_key,filter_value,expected_detail",
        [
            ("status__in", "FAIL,INVALID_STATUS", "invalid status filter"),
            ("severity__in", "critical,INVALID_SEVERITY", "invalid severity filter"),
        ],
    )
    def test_finding_groups_invalid_in_filters_return_400(
        self,
        authenticated_client,
        finding_groups_fixture,
        endpoint_name,
        filter_key,
        filter_value,
        expected_detail,
    ):
        """Test invalid values in status__in/severity__in are rejected."""
        params = {f"filter[{filter_key}]": filter_value}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)

        assert response.status_code == status.HTTP_400_BAD_REQUEST
        errors = response.json()["errors"]
        assert errors[0]["code"] == "invalid"
        assert expected_detail in errors[0]["detail"]

    @pytest.mark.parametrize(
        "filter_name,filter_value",
        [
            ("region", "__region_does_not_exist__"),
            ("service", "__service_does_not_exist__"),
            ("category", "__category_does_not_exist__"),
            ("resource_groups", "__group_does_not_exist__"),
            ("resource_type", "__type_does_not_exist__"),
            ("scan", "00000000-0000-7000-8000-000000000001"),
        ],
    )
    def test_finding_groups_finding_level_filters_are_applied(
        self,
        authenticated_client,
        finding_groups_fixture,
        filter_name,
        filter_value,
    ):
        """Test finding-level filters are applied in /finding-groups aggregation."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, f"filter[{filter_name}]": filter_value},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 0

    def test_finding_groups_delta_filter_is_applied(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test delta filter is applied in /finding-groups aggregation."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[delta]": "new"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0
        assert all(item["attributes"]["new_count"] > 0 for item in data)

    def test_finding_groups_provider_aggregation(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that impacted_providers contains distinct provider types."""
        response = authenticated_client.get(
            reverse("finding-group-list"), {"filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        # Find the s3_bucket_public_access group
        s3_group = next(
            (item for item in data if item["id"] == "s3_bucket_public_access"), None
        )
        assert s3_group is not None
        # Should have aws provider
        assert "aws" in s3_group["attributes"]["impacted_providers"]

    def test_finding_groups_resource_counts(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test resources_fail and resources_total counts are correct."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_id]": "s3_bucket_public_access",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attrs = data[0]["attributes"]
        # s3_bucket_public_access has 2 FAIL findings on 2 different resources
        assert attrs["resources_fail"] == 2
        assert attrs["resources_total"] == 2

    def test_finding_groups_finding_counts(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test pass_count, fail_count, muted_count are correct."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_id]": "ec2_instance_public_ip",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attrs = data[0]["attributes"]
        # ec2_instance_public_ip has 1 PASS and 1 FAIL (non-muted)
        assert attrs["pass_count"] == 1
        assert attrs["fail_count"] == 1
        assert attrs["muted_count"] == 0

    def test_finding_groups_delta_counts(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test new_count and changed_count are correct."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_id]": "s3_bucket_public_access",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attrs = data[0]["attributes"]
        # s3_bucket_public_access has 1 new and 1 changed finding
        assert attrs["new_count"] == 1
        assert attrs["changed_count"] == 1

    def test_finding_groups_timing(self, authenticated_client, finding_groups_fixture):
        """Test first_seen_at, last_seen_at, and failing_since are returned."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_id]": "s3_bucket_public_access",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attrs = data[0]["attributes"]
        assert "first_seen_at" in attrs
        assert "last_seen_at" in attrs
        assert "failing_since" in attrs
        assert attrs["first_seen_at"] is not None
        assert attrs["last_seen_at"] is not None
        # s3_bucket_public_access has FAIL findings, so failing_since should be set
        assert attrs["failing_since"] is not None

    # Test failing_since for checks without failures
    def test_finding_groups_failing_since_null_when_passing(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test failing_since is null for checks that only have PASS findings."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[check_id]": "iam_password_policy"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attrs = data[0]["attributes"]
        # iam_password_policy has only PASS findings, so failing_since should be null
        assert attrs["failing_since"] is None

    def test_finding_groups_rls_isolation(
        self, authenticated_client, finding_groups_fixture, tenants_fixture
    ):
        """Test that users only see finding groups from their tenant."""
        # Create finding in another tenant
        from api.models import Finding, Provider, Resource, Scan
        from api.rls import Tenant

        other_tenant = Tenant.objects.create(name="Other Tenant")
        other_provider = Provider.objects.create(
            tenant_id=other_tenant.id,
            provider="aws",
            uid="999999999999",  # Valid 12-digit AWS account ID
            alias="Other Account",
        )
        other_scan = Scan.objects.create(
            tenant_id=other_tenant.id,
            name="Other scan",
            provider=other_provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
        )
        other_resource = Resource.objects.create(
            tenant_id=other_tenant.id,
            provider=other_provider,
            uid="other-resource-uid",
            name="Other Resource",
            region="us-west-2",
            service="s3",
            type="bucket",
        )
        other_finding = Finding.objects.create(
            tenant_id=other_tenant.id,
            uid="other_tenant_finding",
            scan=other_scan,
            delta=None,
            status="FAIL",
            severity="critical",
            impact="critical",
            check_id="other_tenant_check",
            check_metadata={"CheckId": "other_tenant_check"},
            first_seen_at="2024-01-02T00:00:00Z",
        )
        other_finding.add_resources([other_resource])

        # Request should not include other tenant's finding groups
        response = authenticated_client.get(
            reverse("finding-group-list"), {"filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        check_ids = {item["id"] for item in data}
        assert "other_tenant_check" not in check_ids

    def test_finding_groups_rbac_unlimited(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that users with unlimited visibility see all finding groups."""
        response = authenticated_client.get(
            reverse("finding-group-list"), {"filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        # Should see all 5 check_ids from the fixture
        assert len(data) == 5

    def test_finding_groups_date_filter_gte(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test filtering by start date."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at.gte]": today_after_n_days(-1)},
        )
        assert response.status_code == status.HTTP_200_OK
        # All fixture findings were created today
        assert len(response.json()["data"]) == 5

    def test_finding_groups_date_filter_lte(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test filtering by end date."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at.lte]": today_after_n_days(1)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 5

    def test_finding_groups_date_filter_range(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test filtering by date range (max 7 days)."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                # Use 6-day range to stay within 7-day max limit
                "filter[inserted_at.gte]": today_after_n_days(-6),
                "filter[inserted_at.lte]": today_after_n_days(0),
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 5

    def test_finding_groups_date_filter_outside_backfill_range_returns_empty(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that older dates return empty results without error."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": today_after_n_days(-60)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 0

    def test_finding_groups_date_filter_max_range(self, authenticated_client):
        """Test that exceeding max date range returns 400."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at.lte]": today_after_n_days(
                    -(settings.FINDINGS_MAX_DAYS_IN_RANGE + 1)
                ),
            },
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST
        assert response.json()["errors"][0]["code"] == "invalid"

    def test_finding_groups_provider_filter(
        self,
        authenticated_client,
        finding_groups_fixture,
        aws_provider,
    ):
        """Test filtering by provider UUID."""
        provider = aws_provider
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[provider_id]": str(provider.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        # Should return finding groups associated with this provider
        # Provider 1 has scan1 with checks: s3_bucket_public_access, ec2_instance_public_ip,
        # iam_password_policy, rds_encryption (4 checks)
        assert len(response.json()["data"]) == 4

    def test_finding_groups_provider_type_filter(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test filtering by provider type."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[provider_type]": "aws"},
        )
        assert response.status_code == status.HTTP_200_OK
        # All fixture findings are from AWS provider
        assert len(response.json()["data"]) == 5

    def test_finding_groups_provider_groups_filter(
        self,
        authenticated_client,
        tenants_fixture,
        finding_groups_fixture,
        provider_groups_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair
        group1, group2, *_ = provider_groups_fixture
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group1
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group2
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider2, provider_group=group2
        )

        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 4

        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[provider_groups__in]": f"{group1.id},{group2.id}",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 5

    def test_finding_groups_check_id_filter(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test filtering by exact check_id."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_id]": "s3_bucket_public_access",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert response.json()["data"][0]["id"] == "s3_bucket_public_access"

    def test_finding_groups_check_id_icontains(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test searching check_ids with icontains."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[check_id.icontains]": "bucket"},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 1
        assert "bucket" in response.json()["data"][0]["id"].lower()

    def test_finding_groups_check_title_icontains(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test searching check titles with icontains."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[check_title.icontains]": "public access",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == "s3_bucket_public_access"

    @pytest.mark.parametrize(
        "extra_filters",
        [
            {},
            {"filter[delta]": "new"},
        ],
        ids=["summary_path", "finding_level_path"],
    )
    @pytest.mark.usefixtures("finding_groups_title_variants_fixture")
    def test_check_title_icontains_includes_all_title_variants(
        self,
        authenticated_client,
        extra_filters,
    ):
        """
        Regression: two providers report the same check_id with different
        checktitle values (e.g. after a Prowler version upgrade).  Filtering
        by check_title__icontains with a term that matches only ONE variant
        must still return the finding group with counts from BOTH providers.

        Parametrized to cover both aggregation paths:
        - summary_path: default, uses _CheckTitleToCheckIdMixin on summaries
        - finding_level_path: filter[delta]=new forces _aggregate_findings via
          CommonFindingFilters (delta is finding-level, not summary-level)
        """
        params = {
            "filter[inserted_at]": TODAY,
            "filter[check_title.icontains]": "Ensure repository",
            **extra_filters,
        }
        response = authenticated_client.get(
            reverse("finding-group-list"),
            params,
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == "github_secret_scanning_enabled"
        attrs = data[0]["attributes"]
        # Both providers' findings must be counted
        assert attrs["fail_count"] == 2, (
            "fail_count must include findings from both providers, "
            "regardless of which title variant matches the search"
        )

    def test_resources_not_found(self, authenticated_client):
        """Test 404 returned for nonexistent check_id."""
        response = authenticated_client.get(
            reverse("finding-group-resources", kwargs={"pk": "nonexistent_check"}),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_resources_list(self, authenticated_client, finding_groups_fixture):
        """Test resources are returned correctly for a finding group."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        # s3_bucket_public_access has 2 findings with 2 different resources
        assert len(data) == 2

    def test_resources_id_matches_resource_id_for_mapped_findings(
        self, authenticated_client, finding_groups_fixture
    ):
        """Findings with a resource expose the resource id as row id (hot path contract)."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {"filter[inserted_at]": TODAY},
        )

        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data, "expected resources in response"

        resource_ids = set(
            ResourceFindingMapping.objects.filter(
                finding__check_id="s3_bucket_public_access",
            ).values_list("resource_id", flat=True)
        )
        finding_ids = set(
            Finding.objects.filter(
                check_id="s3_bucket_public_access",
            ).values_list("id", flat=True)
        )

        returned_ids = {item["id"] for item in data}
        assert returned_ids <= {str(rid) for rid in resource_ids}
        assert returned_ids.isdisjoint({str(fid) for fid in finding_ids})

    def test_resources_fields(self, authenticated_client, finding_groups_fixture):
        """Test resource fields (uid, name, service, region, type) have valid values."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        for item in data:
            resource = item["attributes"]["resource"]
            # All fields must be present and non-empty
            assert resource.get("uid"), "resource.uid must not be empty"
            assert resource.get("name"), "resource.name must not be empty"
            assert resource.get("service"), "resource.service must not be empty"
            assert resource.get("region"), "resource.region must not be empty"
            assert resource.get("type"), "resource.type must not be empty"

    def test_resources_resource_group(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test resource_group is extracted from check_metadata.resourcegroup."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        for item in data:
            resource = item["attributes"]["resource"]
            assert resource["resource_group"] == "storage", (
                "resource_group must be 'storage'"
            )

    def test_resources_name_icontains(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test resource_name__icontains filters resources by name substring."""
        # s3_bucket_public_access has "My Instance 1" and "My Instance 2"
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {
                "filter[inserted_at]": TODAY,
                "filter[resource_name.icontains]": "Instance 1",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert "Instance 1" in data[0]["attributes"]["resource"]["name"]

    def test_resources_provider_info(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test provider info (type, uid, alias) has valid values."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        for item in data:
            provider = item["attributes"]["provider"]
            assert provider.get("type") == "aws", "provider.type must be 'aws'"
            assert provider.get("uid"), "provider.uid must not be empty"
            assert provider.get("alias"), "provider.alias must not be empty"

    def test_resources_status_severity(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test status and severity from latest finding have valid values."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        for item in data:
            attrs = item["attributes"]
            # s3_bucket_public_access has FAIL findings
            assert attrs["status"] == "FAIL", "status must be 'FAIL'"
            # severity must be one of the valid values
            assert attrs["severity"] in [
                "critical",
                "high",
                "medium",
                "low",
                "informational",
            ]

    def test_resources_timing(self, authenticated_client, finding_groups_fixture):
        """Test first_seen_at and last_seen_at are not null."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        for item in data:
            attrs = item["attributes"]
            assert attrs["first_seen_at"] is not None, "first_seen_at must not be null"
            assert attrs["last_seen_at"] is not None, "last_seen_at must not be null"

    def test_resources_filters_applied(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that date filters work on resources endpoint."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {
                "filter[inserted_at.gte]": today_after_n_days(-6),
                "filter[inserted_at.lte]": today_after_n_days(0),
            },
        )
        assert response.status_code == status.HTTP_200_OK
        # Should still return the 2 resources within the date range
        assert len(response.json()["data"]) == 2

    def test_resources_status_filter_returns_empty_not_404(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that filtering by status on a valid check returns empty list, not 404."""
        # s3_bucket_public_access has only FAIL findings, filtering by PASS should return []
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {"filter[inserted_at]": TODAY, "filter[status]": "PASS"},
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"] == []

    def test_resources_nonexistent_check_still_404(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that a truly nonexistent check_id still returns 404."""
        response = authenticated_client.get(
            reverse("finding-group-resources", kwargs={"pk": "totally_fake_check"}),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_404_NOT_FOUND

    def test_resources_sort_by_status_ascending(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test sort=status returns PASS before FAIL."""
        # ec2_instance_public_ip has 1 PASS (resource1) and 1 FAIL (resource2)
        response = authenticated_client.get(
            reverse(
                "finding-group-resources",
                kwargs={"pk": "ec2_instance_public_ip"},
            ),
            {"filter[inserted_at]": TODAY, "sort": "status"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        assert data[0]["attributes"]["status"] == "PASS"
        assert data[1]["attributes"]["status"] == "FAIL"

    def test_resources_sort_by_status_descending(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test sort=-status returns FAIL before PASS."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources",
                kwargs={"pk": "ec2_instance_public_ip"},
            ),
            {"filter[inserted_at]": TODAY, "sort": "-status"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        assert data[0]["attributes"]["status"] == "FAIL"
        assert data[1]["attributes"]["status"] == "PASS"

    def test_resources_sort_invalid_field_returns_400(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that an invalid sort field returns 400."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {"filter[inserted_at]": TODAY, "sort": "invalid_field"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_latest_resources_status_filter_returns_empty_not_404(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test latest resources with status filter on valid check returns empty, not 404."""
        response = authenticated_client.get(
            reverse(
                "finding-group-latest_resources",
                kwargs={"check_id": "s3_bucket_public_access"},
            ),
            {"filter[status]": "PASS"},
        )
        assert response.status_code == status.HTTP_200_OK
        assert response.json()["data"] == []

    def test_latest_resources_sort_by_status(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test latest resources sort=status returns PASS before FAIL."""
        response = authenticated_client.get(
            reverse(
                "finding-group-latest_resources",
                kwargs={"check_id": "ec2_instance_public_ip"},
            ),
            {"sort": "status"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 2
        assert data[0]["attributes"]["status"] == "PASS"
        assert data[1]["attributes"]["status"] == "FAIL"

    def test_resources_nonexistent_check_missing_date_returns_400(
        self, authenticated_client, finding_groups_fixture
    ):
        """Nonexistent check_id with missing required date filter returns 400, not 404."""
        response = authenticated_client.get(
            reverse("finding-group-resources", kwargs={"pk": "totally_fake_check"}),
        )
        # FindingGroupFilter requires inserted_at — validation fires before existence check
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_resources_nonexistent_check_invalid_sort_returns_400(
        self, authenticated_client, finding_groups_fixture
    ):
        """Nonexistent check_id with invalid sort returns 400, not 404."""
        response = authenticated_client.get(
            reverse("finding-group-resources", kwargs={"pk": "totally_fake_check"}),
            {"filter[inserted_at]": TODAY, "sort": "invalid_field"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    def test_resources_empty_sort_falls_back_to_default_order(
        self, authenticated_client, finding_groups_fixture
    ):
        """Degenerate sort values should behave like no sort, not raise 500."""
        all_ids = set()
        for page_num in (1, 2):
            response = authenticated_client.get(
                reverse(
                    "finding-group-resources",
                    kwargs={"pk": "s3_bucket_public_access"},
                ),
                {
                    "filter[inserted_at]": TODAY,
                    "sort": ",",
                    "page[size]": 1,
                    "page[number]": page_num,
                },
            )
            assert response.status_code == status.HTTP_200_OK
            data = response.json()["data"]
            assert len(data) == 1
            all_ids.add(data[0]["id"])
        assert len(all_ids) == 2

    def test_resources_sort_pagination_stability(
        self, authenticated_client, finding_groups_fixture
    ):
        """Sort with small page size returns all resources without duplicates or gaps."""
        # s3_bucket_public_access has 2 resources, both FAIL — they tie on status
        all_ids = set()
        for page_num in (1, 2):
            response = authenticated_client.get(
                reverse(
                    "finding-group-resources",
                    kwargs={"pk": "s3_bucket_public_access"},
                ),
                {
                    "filter[inserted_at]": TODAY,
                    "sort": "status",
                    "page[size]": 1,
                    "page[number]": page_num,
                },
            )
            assert response.status_code == status.HTTP_200_OK
            data = response.json()["data"]
            assert len(data) == 1
            all_ids.add(data[0]["id"])
        # Both pages should return different resources (no duplicates)
        assert len(all_ids) == 2

    def test_latest_resources_nonexistent_check_invalid_sort_returns_400(
        self, authenticated_client, finding_groups_fixture
    ):
        """Nonexistent check_id with invalid sort on latest returns 400, not 404."""
        response = authenticated_client.get(
            reverse(
                "finding-group-latest_resources",
                kwargs={"check_id": "totally_fake_check"},
            ),
            {"sort": "invalid_field"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    # Test provider_id filter actually filters data
    def test_finding_groups_provider_id_filter_actually_filters(
        self,
        authenticated_client,
        finding_groups_fixture,
        aws_provider,
        aws_provider_pair,
    ):
        """
        Test that provider_id filter returns ONLY data from that provider.

        This is a critical test - it verifies the filter doesn't just return 200 OK,
        but actually restricts the data to the specified provider.
        """
        provider1 = aws_provider  # Has scan1 with 4 checks
        provider2 = aws_provider_pair[1]  # Has scan2 with 1 check (cloudtrail_enabled)

        # Get ALL finding groups (without provider filter)
        response_all = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY},
        )
        assert response_all.status_code == status.HTTP_200_OK
        all_check_ids = {item["id"] for item in response_all.json()["data"]}
        assert len(all_check_ids) == 5, "Should have 5 total check_ids"

        # Get finding groups for provider1 only
        response_p1 = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[provider_id]": str(provider1.id)},
        )
        assert response_p1.status_code == status.HTTP_200_OK
        p1_check_ids = {item["id"] for item in response_p1.json()["data"]}
        # Provider1 has scan1 with 4 checks
        assert len(p1_check_ids) == 4, (
            f"Provider1 should have 4 checks, got {len(p1_check_ids)}"
        )
        assert "cloudtrail_enabled" not in p1_check_ids, (
            "cloudtrail_enabled should NOT be in provider1"
        )

        # Get finding groups for provider2 only
        response_p2 = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[provider_id]": str(provider2.id)},
        )
        assert response_p2.status_code == status.HTTP_200_OK
        p2_check_ids = {item["id"] for item in response_p2.json()["data"]}
        # Provider2 has scan2 with 1 check
        assert len(p2_check_ids) == 1, (
            f"Provider2 should have 1 check, got {len(p2_check_ids)}"
        )
        assert "cloudtrail_enabled" in p2_check_ids, (
            "cloudtrail_enabled should be in provider2"
        )

    # Test provider_type filter actually filters data
    def test_finding_groups_provider_type_filter_actually_filters(
        self, authenticated_client, finding_groups_fixture
    ):
        """
        Test that provider_type filter returns ONLY data from that provider type.
        """
        # All fixtures use AWS providers, so filtering by AWS should return all 5
        response_aws = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[provider_type]": "aws"},
        )
        assert response_aws.status_code == status.HTTP_200_OK
        assert len(response_aws.json()["data"]) == 5

        # Filtering by GCP should return 0 (no GCP findings in fixture)
        response_gcp = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[provider_type]": "gcp"},
        )
        assert response_gcp.status_code == status.HTTP_200_OK
        assert len(response_gcp.json()["data"]) == 0, (
            "GCP filter should return 0 results"
        )

    def test_finding_groups_pagination(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test pagination metadata and links."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "page[size]": 2},
        )
        assert response.status_code == status.HTTP_200_OK
        # Should have pagination metadata
        assert "meta" in response.json()
        meta = response.json()["meta"]
        assert "pagination" in meta
        assert "count" in meta["pagination"]

    def test_resources_pagination(self, authenticated_client, finding_groups_fixture):
        """Test pagination on resources endpoint."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"}
            ),
            {"filter[inserted_at]": TODAY, "page[size]": 1},
        )
        assert response.status_code == status.HTTP_200_OK
        assert "meta" in response.json()

    def test_finding_groups_ordering_default(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test default ordering (-fail_count, -severity, check_id)."""
        response = authenticated_client.get(
            reverse("finding-group-list"), {"filter[inserted_at]": TODAY}
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        # First results should have highest fail_count or critical severity
        # s3_bucket_public_access has 2 fails with critical severity
        assert data[0]["id"] in ["s3_bucket_public_access", "cloudtrail_enabled"]

    def test_finding_groups_ordering_custom(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test custom sort parameter."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "sort": "check_id"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        # Results should be in alphabetical order by check_id
        check_ids = [item["id"] for item in data]
        assert check_ids == sorted(check_ids)

    def test_finding_groups_latest_no_date_filter_required(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that /latest endpoint works without date filters."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        # Should return all 5 checks from the fixture
        assert len(data) == 5

    def test_finding_groups_latest_empty(self, authenticated_client):
        """Test /latest returns empty list when no summaries exist."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 0

    def test_finding_groups_latest_provider_id_filter(
        self,
        authenticated_client,
        finding_groups_fixture,
        aws_provider,
        aws_provider_pair,
    ):
        """Test /latest with provider_id filter returns only that provider's data."""
        provider1 = aws_provider  # Has 4 checks
        provider2 = aws_provider_pair[1]  # Has 1 check

        # Filter by provider1
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[provider_id]": str(provider1.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 4
        check_ids = {item["id"] for item in data}
        assert "cloudtrail_enabled" not in check_ids

        # Filter by provider2
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[provider_id]": str(provider2.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == "cloudtrail_enabled"

    def test_finding_groups_latest_status_filter(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test /latest supports status filter on aggregated status."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[status]": "FAIL"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0
        assert all(item["attributes"]["status"] == "FAIL" for item in data)

    def test_finding_groups_latest_region_filter_reaggregates_metrics(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test /latest recomputes metrics from findings matching region filter."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {
                "filter[check_id]": "ec2_instance_public_ip",
                "filter[region]": "us-east-1",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1

        attrs = data[0]["attributes"]
        assert attrs["status"] == "PASS"
        assert attrs["pass_count"] == 1
        assert attrs["fail_count"] == 0
        assert attrs["resources_total"] == 1
        assert attrs["resources_fail"] == 0
        # check_title / check_description are resolved post-pagination from the
        # summary table, not from the finding's check_metadata.
        assert attrs["check_title"] == "Ensure EC2 instances do not have public IPs"
        assert (
            attrs["check_description"] == "EC2 instances should use private IPs only."
        )

    def test_finding_groups_latest_status_in_filter(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test /latest supports status__in filter on aggregated status."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[status__in]": "FAIL,PASS"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0
        assert all(item["attributes"]["status"] in {"FAIL", "PASS"} for item in data)

    def test_finding_groups_latest_severity_filter(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test /latest supports severity filter on aggregated severity."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[severity]": "critical"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0
        assert all(item["attributes"]["severity"] == "critical" for item in data)

    @pytest.mark.parametrize(
        "filter_name,filter_value",
        [
            ("region", "__region_does_not_exist__"),
            ("service", "__service_does_not_exist__"),
            ("category", "__category_does_not_exist__"),
            ("resource_groups", "__group_does_not_exist__"),
            ("resource_type", "__type_does_not_exist__"),
            ("scan", "00000000-0000-7000-8000-000000000001"),
        ],
    )
    def test_finding_groups_latest_finding_level_filters_are_applied(
        self,
        authenticated_client,
        finding_groups_fixture,
        filter_name,
        filter_value,
    ):
        """Test finding-level filters are applied in /finding-groups/latest aggregation."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {f"filter[{filter_name}]": filter_value},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 0

    def test_finding_groups_check_title_filter_applies_with_delta(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test check_title filter is honored when finding-level path is used."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {
                "filter[inserted_at]": TODAY,
                "filter[delta]": "new",
                "filter[check_title.icontains]": "__missing_check_title__",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 0

    def test_finding_groups_latest_check_title_filter_applies_with_delta(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test /latest check_title filter is honored on finding-level path."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {
                "filter[delta]": "new",
                "filter[check_title.icontains]": "__missing_check_title__",
            },
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 0

    def test_finding_groups_latest_delta_filter_is_applied(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test delta filter is applied in /finding-groups/latest aggregation."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[delta]": "new"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0
        assert all(item["attributes"]["new_count"] > 0 for item in data)

    def test_finding_groups_latest_aggregates_latest_per_provider(
        self,
        authenticated_client,
        aws_provider,
        resources_fixture,
        aws_provider_pair,
    ):
        """Test /latest keeps all findings from the latest scan per provider.

        Verifies that when the latest scan produces multiple findings for the
        same check_id (e.g. one per resource), all of them are included in the
        aggregation — not just one.
        """
        provider1 = aws_provider
        provider2 = aws_provider_pair[1]
        resource1 = resources_fixture[0]
        resource2 = resources_fixture[1]
        resource3 = resources_fixture[2]
        check_id = "cross_provider_latest_resources_total"

        latest_scan_provider1 = Scan.objects.create(
            tenant_id=provider1.tenant_id,
            provider=provider1,
            state=StateChoices.COMPLETED,
            trigger=Scan.TriggerChoices.MANUAL,
            completed_at=datetime.now(UTC),
        )

        latest_scan_provider2 = Scan.objects.create(
            tenant_id=provider2.tenant_id,
            provider=provider2,
            state=StateChoices.COMPLETED,
            trigger=Scan.TriggerChoices.MANUAL,
            completed_at=datetime.now(UTC),
        )

        older_scan_provider1 = Scan.objects.create(
            tenant_id=provider1.tenant_id,
            provider=provider1,
            state=StateChoices.COMPLETED,
            trigger=Scan.TriggerChoices.MANUAL,
            completed_at=datetime.now(UTC) - timedelta(days=1),
        )

        # Older scan — these should be excluded from /latest
        Finding.objects.create(
            tenant_id=provider1.tenant_id,
            uid="old_cross_provider_1",
            scan=older_scan_provider1,
            delta="new",
            status="FAIL",
            severity="high",
            impact="high",
            check_id=check_id,
            check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
            first_seen_at=datetime.now(UTC) - timedelta(days=2),
            muted=False,
        )

        # Latest scan provider1: TWO findings (PASS + FAIL) for the same check
        latest_p1_pass = Finding.objects.create(
            tenant_id=provider1.tenant_id,
            uid="latest_cross_provider_1_pass",
            scan=latest_scan_provider1,
            delta="new",
            status="PASS",
            severity="high",
            impact="high",
            check_id=check_id,
            check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
            first_seen_at=datetime.now(UTC) - timedelta(hours=1),
            muted=False,
        )
        latest_p1_pass.add_resources([resource1])

        latest_p1_fail = Finding.objects.create(
            tenant_id=provider1.tenant_id,
            uid="latest_cross_provider_1_fail",
            scan=latest_scan_provider1,
            delta="new",
            status="FAIL",
            severity="high",
            impact="high",
            check_id=check_id,
            check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
            first_seen_at=datetime.now(UTC) - timedelta(hours=1),
            muted=False,
        )
        latest_p1_fail.add_resources([resource2])

        # Latest scan provider2: one finding
        latest_p2 = Finding.objects.create(
            tenant_id=provider2.tenant_id,
            uid="latest_cross_provider_2",
            scan=latest_scan_provider2,
            delta="new",
            status="FAIL",
            severity="high",
            impact="high",
            check_id=check_id,
            check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
            first_seen_at=datetime.now(UTC) - timedelta(hours=1),
            muted=False,
        )
        latest_p2.add_resources([resource3])

        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[check_id]": check_id, "filter[delta]": "new"},
        )

        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attrs = data[0]["attributes"]
        # 3 findings total: 2 from provider1 latest + 1 from provider2 latest
        assert attrs["pass_count"] == 1
        assert attrs["fail_count"] == 2
        assert attrs["resources_total"] == 3
        assert attrs["resources_fail"] == 2

    def test_finding_groups_latest_provider_type_filter(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test /latest with provider_type filter."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[provider_type]": "aws"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        # All providers in fixture are AWS
        assert len(data) == 5

    def test_finding_groups_latest_provider_groups_filter(
        self,
        authenticated_client,
        tenants_fixture,
        finding_groups_fixture,
        provider_groups_fixture,
        aws_provider_pair,
    ):
        tenant = tenants_fixture[0]
        provider1, provider2 = aws_provider_pair
        group1, group2, *_ = provider_groups_fixture
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group1
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider1, provider_group=group2
        )
        ProviderGroupMembership.objects.create(
            tenant=tenant, provider=provider2, provider_group=group2
        )

        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[provider_groups]": str(group1.id)},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 4

        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[provider_groups__in]": f"{group1.id},{group2.id}"},
        )
        assert response.status_code == status.HTTP_200_OK
        assert len(response.json()["data"]) == 5

    def test_finding_groups_latest_check_id_filter(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test /latest with check_id filter."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[check_id]": "s3_bucket_public_access"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        assert data[0]["id"] == "s3_bucket_public_access"

    def test_finding_groups_latest_custom_sort(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test /latest with custom sort parameter."""
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"sort": "check_id"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        check_ids = [item["id"] for item in data]
        assert check_ids == sorted(check_ids)

    def test_finding_groups_latest_sort_by_check_title_not_supported(
        self, authenticated_client, finding_groups_fixture
    ):
        """check_title is not a sortable field for finding groups.

        Titles live in the TOASTed check_metadata blob and are resolved after
        pagination from the summary table, so they cannot drive DB-level
        ordering. Requesting that sort is rejected.
        """
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"sort": "check_title"},
        )
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    @pytest.mark.parametrize(
        "sort_field",
        ["first_seen_at", "-first_seen_at", "last_seen_at", "failing_since"],
    )
    def test_finding_groups_sort_by_time_fields(
        self,
        authenticated_client,
        finding_groups_fixture,
        endpoint_name,
        sort_field,
    ):
        """Test sorting by aggregated time fields (first_seen_at, last_seen_at, failing_since)."""
        params = {"sort": sort_field}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)

        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    def test_finding_groups_sort_by_delta(
        self,
        authenticated_client,
        finding_groups_fixture,
        endpoint_name,
    ):
        """Sort by delta orders by new_count then changed_count (lexicographic)."""
        params = {"sort": "-delta"}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0

        def delta_key(item):
            attrs = item["attributes"]
            return (attrs.get("new_count", 0), attrs.get("changed_count", 0))

        desc_keys = [delta_key(item) for item in data]
        assert desc_keys == sorted(desc_keys, reverse=True)

        # Ascending order produces the inverse arrangement
        params["sort"] = "delta"
        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_200_OK
        asc_keys = [delta_key(item) for item in response.json()["data"]]
        assert asc_keys == sorted(asc_keys)

    def test_finding_groups_latest_ignores_date_filters(
        self, authenticated_client, finding_groups_fixture
    ):
        """Test that /latest ignores any date filters passed in params."""
        # Even with an old date filter, /latest should return current data
        response = authenticated_client.get(
            reverse("finding-group-latest"),
            {"filter[inserted_at]": "2020-01-01"},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        # Should still return data, not filtered by the old date
        assert len(data) == 5

    def test_finding_groups_status_choices_no_muted(
        self, authenticated_client, finding_groups_fixture
    ):
        """Every returned group must have status ∈ {FAIL, PASS, MANUAL}."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        statuses = {item["attributes"]["status"] for item in response.json()["data"]}
        assert statuses, "fixture should produce at least one group"
        assert statuses <= {"FAIL", "PASS", "MANUAL"}
        assert "MUTED" not in statuses

    def test_finding_groups_serializer_exposes_muted_and_manual_count(
        self, authenticated_client, finding_groups_fixture
    ):
        """The /finding-groups payload must expose `muted`, `manual_count` and
        the per-status muted siblings (`pass_muted_count`/`fail_muted_count`/
        `manual_muted_count`)."""
        response = authenticated_client.get(
            reverse("finding-group-list"),
            {"filter[inserted_at]": TODAY, "filter[check_id]": "iam_password_policy"},
        )
        assert response.status_code == status.HTTP_200_OK
        attrs = response.json()["data"][0]["attributes"]
        assert "muted" in attrs and isinstance(attrs["muted"], bool)
        assert "manual_count" in attrs and isinstance(attrs["manual_count"], int)
        assert attrs["muted"] is False  # iam_password_policy has only non-muted PASS
        assert attrs["manual_count"] == 0
        assert attrs["pass_muted_count"] == 0
        assert attrs["fail_muted_count"] == 0
        assert attrs["manual_muted_count"] == 0

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    def test_finding_groups_filter_status_muted_is_rejected(
        self, authenticated_client, finding_groups_fixture, endpoint_name
    ):
        """`filter[status]=MUTED` is no longer a valid status value."""
        params = {"filter[status]": "MUTED"}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_400_BAD_REQUEST

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    def test_finding_groups_filter_muted_true(
        self, authenticated_client, finding_groups_fixture, endpoint_name
    ):
        """`filter[muted]=true` returns only fully-muted groups."""
        params = {"filter[muted]": "true"}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        check_ids = {item["id"] for item in data}
        # Only rds_encryption is fully muted in the fixture
        assert check_ids == {"rds_encryption"}
        assert all(item["attributes"]["muted"] is True for item in data)

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    def test_finding_groups_filter_muted_false(
        self, authenticated_client, finding_groups_fixture, endpoint_name
    ):
        """`filter[muted]=false` returns only groups with actionable findings."""
        params = {"filter[muted]": "false"}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        check_ids = {item["id"] for item in data}
        assert "rds_encryption" not in check_ids
        assert check_ids == {
            "s3_bucket_public_access",
            "ec2_instance_public_ip",
            "iam_password_policy",
            "cloudtrail_enabled",
        }
        assert all(item["attributes"]["muted"] is False for item in data)

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    def test_finding_groups_sort_by_status(
        self, authenticated_client, finding_groups_fixture, endpoint_name
    ):
        """sort=status orders by aggregated status (FAIL > PASS > MANUAL)."""
        priority = {"FAIL": 3, "PASS": 2, "MANUAL": 1}
        params = {"sort": "-status"}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data, "fixture should produce groups"

        desc_keys = [priority[item["attributes"]["status"]] for item in data]
        assert desc_keys == sorted(desc_keys, reverse=True)

        params["sort"] = "status"
        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_200_OK
        asc_keys = [
            priority[item["attributes"]["status"]] for item in response.json()["data"]
        ]
        assert asc_keys == sorted(asc_keys)

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    def test_finding_groups_sort_by_muted(
        self, authenticated_client, finding_groups_fixture, endpoint_name
    ):
        """sort=muted orders by the boolean muted attribute."""
        # Need include_muted=true so the fully-muted group is part of the result
        params = {"sort": "-muted", "filter[include_muted]": "true"}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data, "fixture should produce groups"

        muted_values = [item["attributes"]["muted"] for item in data]
        # Descending boolean: True (1) before False (0)
        assert muted_values == sorted(muted_values, reverse=True)

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    @pytest.mark.parametrize(
        "sort_field",
        [
            "pass_muted_count",
            "fail_muted_count",
            "manual_muted_count",
            "new_fail_count",
            "new_fail_muted_count",
            "new_pass_count",
            "new_pass_muted_count",
            "new_manual_count",
            "new_manual_muted_count",
            "changed_fail_count",
            "changed_fail_muted_count",
            "changed_pass_count",
            "changed_pass_muted_count",
            "changed_manual_count",
            "changed_manual_muted_count",
        ],
    )
    def test_finding_groups_sort_by_counter_fields(
        self,
        authenticated_client,
        finding_groups_fixture,
        endpoint_name,
        sort_field,
    ):
        """All counter fields are accepted as sort parameters (asc and desc)."""
        params = {"sort": f"-{sort_field}"}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) > 0

        desc_values = [item["attributes"][sort_field] for item in data]
        assert desc_values == sorted(desc_values, reverse=True)

        params["sort"] = sort_field
        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_200_OK
        asc_values = [
            item["attributes"][sort_field] for item in response.json()["data"]
        ]
        assert asc_values == sorted(asc_values)

    @pytest.mark.parametrize(
        "endpoint_name", ["finding-group-list", "finding-group-latest"]
    )
    def test_finding_groups_delta_status_breakdown(
        self, authenticated_client, finding_groups_fixture, endpoint_name
    ):
        """`new_*` and `changed_*` counters split by status and mute state.

        s3_bucket_public_access has 1 new FAIL and 1 changed FAIL (both
        non-muted) so the breakdown must reflect exactly that and the totals
        must equal the sum of the buckets.
        """
        params = {"filter[check_id]": "s3_bucket_public_access"}
        if endpoint_name == "finding-group-list":
            params["filter[inserted_at]"] = TODAY

        response = authenticated_client.get(reverse(endpoint_name), params)
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attrs = data[0]["attributes"]

        assert attrs["new_fail_count"] == 1
        assert attrs["new_fail_muted_count"] == 0
        assert attrs["new_pass_count"] == 0
        assert attrs["new_pass_muted_count"] == 0
        assert attrs["new_manual_count"] == 0
        assert attrs["new_manual_muted_count"] == 0
        assert attrs["changed_fail_count"] == 1
        assert attrs["changed_fail_muted_count"] == 0
        assert attrs["changed_pass_count"] == 0
        assert attrs["changed_pass_muted_count"] == 0
        assert attrs["changed_manual_count"] == 0
        assert attrs["changed_manual_muted_count"] == 0

        new_total = (
            attrs["new_fail_count"]
            + attrs["new_fail_muted_count"]
            + attrs["new_pass_count"]
            + attrs["new_pass_muted_count"]
            + attrs["new_manual_count"]
            + attrs["new_manual_muted_count"]
        )
        changed_total = (
            attrs["changed_fail_count"]
            + attrs["changed_fail_muted_count"]
            + attrs["changed_pass_count"]
            + attrs["changed_pass_muted_count"]
            + attrs["changed_manual_count"]
            + attrs["changed_manual_muted_count"]
        )
        # The non-muted variants of the breakdown must sum to the legacy
        # totals (new_count/changed_count are stored as non-muted).
        assert (
            attrs["new_fail_count"]
            + attrs["new_pass_count"]
            + attrs["new_manual_count"]
            == attrs["new_count"]
        )
        assert (
            attrs["changed_fail_count"]
            + attrs["changed_pass_count"]
            + attrs["changed_manual_count"]
            == attrs["changed_count"]
        )
        # And the *full* breakdown (including the muted halves) is exposed
        # so clients can also count muted-only deltas without losing data.
        assert new_total >= attrs["new_count"]
        assert changed_total >= attrs["changed_count"]

    def test_finding_groups_resources_serializer_exposes_muted(
        self, authenticated_client, finding_groups_fixture
    ):
        """The /finding-groups/<id>/resources payload must expose `muted`."""
        response = authenticated_client.get(
            reverse(
                "finding-group-resources",
                kwargs={"pk": "rds_encryption"},
            ),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data, "rds_encryption should expose its resources"
        for item in data:
            attrs = item["attributes"]
            assert "muted" in attrs and isinstance(attrs["muted"], bool)
            # rds_encryption has all muted findings
            assert attrs["muted"] is True
            # Status reflects the underlying check outcome (FAIL), not MUTED
            assert attrs["status"] == "FAIL"

    def test_finding_groups_resources_exposes_finding_id(
        self, authenticated_client, finding_groups_fixture
    ):
        """The /resources payload exposes the most recent matching finding_id.

        rds_encryption has 2 findings, one per resource. Each resource row must
        report the UUID of its corresponding Finding (UUIDv7 ordering means
        Max(finding__id) resolves to the latest snapshot in time).
        """
        response = authenticated_client.get(
            reverse(
                "finding-group-resources",
                kwargs={"pk": "rds_encryption"},
            ),
            {"filter[inserted_at]": TODAY},
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data, "rds_encryption should expose its resources"

        rds_finding_ids = {
            str(f.id) for f in finding_groups_fixture if f.check_id == "rds_encryption"
        }
        assert rds_finding_ids, "fixture sanity"

        for item in data:
            attrs = item["attributes"]
            assert "finding_id" in attrs
            assert attrs["finding_id"] in rds_finding_ids

    def test_finding_groups_latest_resources_exposes_finding_id(
        self, authenticated_client, finding_groups_fixture
    ):
        """The /latest/.../resources payload also exposes finding_id."""
        response = authenticated_client.get(
            reverse(
                "finding-group-latest_resources",
                kwargs={"check_id": "rds_encryption"},
            ),
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert data, "rds_encryption should expose its resources via /latest"

        rds_finding_ids = {
            str(f.id) for f in finding_groups_fixture if f.check_id == "rds_encryption"
        }
        for item in data:
            attrs = item["attributes"]
            assert "finding_id" in attrs
            assert attrs["finding_id"] in rds_finding_ids

    def test_latest_resources_picks_scan_by_completed_at_when_overlap(
        self,
        authenticated_client,
        tenants_fixture,
        aws_provider,
        resources_fixture,
    ):
        """Overlapping scans on the same provider must resolve to the scan
        with the latest completed_at, matching the /latest summary path and
        the daily-summary upsert (keyed on midnight(completed_at)). Picking
        by inserted_at here produced /resources and /latest reading from
        different scans and reporting diverging delta/new counts.
        """
        tenant = tenants_fixture[0]
        provider = aws_provider
        resource = resources_fixture[0]
        check_id = "overlap_regression_check"

        t0 = datetime.now(UTC) - timedelta(hours=5)
        t1 = t0 + timedelta(hours=1)
        t1_end = t1 + timedelta(minutes=30)
        t2 = t0 + timedelta(hours=4)

        scan_long = Scan.objects.create(
            name="long overlap scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant_id=tenant.id,
            started_at=t0,
            completed_at=t2,
        )
        scan_short = Scan.objects.create(
            name="short overlap scan",
            provider=provider,
            trigger=Scan.TriggerChoices.MANUAL,
            state=StateChoices.COMPLETED,
            tenant_id=tenant.id,
            started_at=t1,
            completed_at=t1_end,
        )
        # inserted_at is auto_now_add so override with .update() to recreate
        # the overlap shape: short scan inserted later but completed earlier.
        Scan.all_objects.filter(pk=scan_long.pk).update(inserted_at=t0)
        Scan.all_objects.filter(pk=scan_short.pk).update(inserted_at=t1)
        scan_long.refresh_from_db()
        scan_short.refresh_from_db()

        assert scan_short.inserted_at > scan_long.inserted_at
        assert scan_long.completed_at > scan_short.completed_at

        long_finding = Finding.objects.create(
            tenant_id=tenant.id,
            uid=f"{check_id}_long",
            scan=scan_long,
            delta=None,
            status=Status.FAIL,
            status_extended="long scan finding",
            impact=Severity.high,
            impact_extended="high",
            severity=Severity.high,
            raw_result={"status": Status.FAIL, "severity": Severity.high},
            check_id=check_id,
            check_metadata={
                "CheckId": check_id,
                "checktitle": "Overlap regression",
                "Description": "Overlapping scan regression.",
            },
            first_seen_at=t0,
            muted=False,
        )
        long_finding.add_resources([resource])

        short_finding = Finding.objects.create(
            tenant_id=tenant.id,
            uid=f"{check_id}_short",
            scan=scan_short,
            delta="new",
            status=Status.FAIL,
            status_extended="short scan finding",
            impact=Severity.high,
            impact_extended="high",
            severity=Severity.high,
            raw_result={"status": Status.FAIL, "severity": Severity.high},
            check_id=check_id,
            check_metadata={
                "CheckId": check_id,
                "checktitle": "Overlap regression",
                "Description": "Overlapping scan regression.",
            },
            first_seen_at=t1,
            muted=False,
        )
        short_finding.add_resources([resource])

        response = authenticated_client.get(
            reverse(
                "finding-group-latest_resources",
                kwargs={"check_id": check_id},
            ),
        )
        assert response.status_code == status.HTTP_200_OK
        data = response.json()["data"]
        assert len(data) == 1
        attrs = data[0]["attributes"]
        assert attrs["finding_id"] == str(long_finding.id)
        assert attrs["delta"] is None
