Files
prowler/api/src/backend/api/tests/test_views.py
T

20978 lines
766 KiB
Python

import glob
import io
import json
import os
import tempfile
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from threading import Event, Lock
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,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
)
from api.compliance import get_compliance_frameworks
from api.db_router import MainRouter
from api.db_utils import rls_transaction
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 celery.utils.saferepr import saferepr
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 close_old_connections, connection, connections
from django.db.models import Count
from django.db.models.signals import pre_delete
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.requires_test_admin_alias
@pytest.mark.django_db(transaction=True, databases=["default", "admin"])
class TestTenantDeletionTransactions:
@patch("api.v1.views.delete_tenant_task.apply_async")
def test_delete_rolls_back_memberships_when_user_cleanup_fails(
self,
delete_tenant_mock,
authenticated_client,
tenants_fixture,
):
assert connections["default"] is not connections["admin"]
_, tenant, _ = tenants_fixture
exclusive_user = User.objects.create_user(
name="exclusive user",
password=TEST_PASSWORD,
email="exclusive-user@example.com",
)
membership = Membership.objects.create(
user=exclusive_user,
tenant=tenant,
role=Membership.RoleChoices.MEMBER,
)
def fail_user_cleanup(*, instance, **kwargs):
if instance.pk == exclusive_user.pk:
raise RuntimeError("Simulated user cleanup failure.")
pre_delete.connect(fail_user_cleanup, sender=User)
try:
with (
patch.object(MainRouter, "admin_db", "admin"),
pytest.raises(RuntimeError, match=r"Simulated user cleanup failure\."),
):
authenticated_client.delete(
reverse("tenant-detail", kwargs={"pk": tenant.id})
)
finally:
pre_delete.disconnect(fail_user_cleanup, sender=User)
assert Membership.objects.using("admin").filter(pk=membership.pk).exists()
delete_tenant_mock.assert_not_called()
@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"]["task_args"] == {
"kwarg1": "value1"
}
assert (
response.json()["data"]["attributes"]["name"]
== task1.task_runner_task.task_name
)
def test_tasks_retrieve_hides_tenant_id(
self, authenticated_client, tasks_fixture, tenants_fixture
):
task, *_ = tasks_fixture
task.task_runner_task.task_kwargs = json.dumps(
repr(
{
"tenant_id": str(tenants_fixture[0].id),
"enabled": True,
"scan_id": None,
"label": "True North",
}
)
)
task.task_runner_task.save(update_fields=["task_kwargs"])
response = authenticated_client.get(
reverse("task-detail", kwargs={"pk": task.id}),
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["task_args"] == {
"enabled": True,
"scan_id": None,
"label": "True North",
}
def test_tasks_retrieve_with_truncated_kwargs_returns_empty_task_args(
self, authenticated_client, tasks_fixture
):
task, *_ = tasks_fixture
kwargs_repr = saferepr(
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
)
assert "..." in kwargs_repr
task.task_runner_task.task_kwargs = json.dumps(kwargs_repr)
task.task_runner_task.save(update_fields=["task_kwargs"])
response = authenticated_client.get(
reverse("task-detail", kwargs={"pk": task.id}),
)
assert response.status_code == status.HTTP_200_OK
assert response.headers["Content-Type"] == API_JSON_CONTENT_TYPE
assert response.json()["data"]["attributes"]["task_args"] == {}
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_expose_outcome(
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-lambda-passrole",
name="Lambda passrole",
short_description="Pass a role to a new Lambda function.",
description="Pass a role to a new Lambda function and run code as it.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
outcome=AttackPathsQueryOutcome.CODE_EXECUTION,
),
AttackPathsQueryDefinition(
id="aws-rds-inventory",
name="RDS inventory",
short_description="List account RDS assets.",
description="List account RDS assets.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
outcome=AttackPathsQueryOutcome.RESOURCE_INVENTORY,
),
AttackPathsQueryDefinition(
id="aws-no-outcome",
name="No outcome",
short_description="A query without an outcome.",
description="A query without an outcome.",
provider=provider.provider,
cypher="MATCH (n) RETURN n",
),
]
with patch("api.v1.views.get_queries_for_provider", return_value=definitions):
response = authenticated_client.get(
reverse(
"attack-paths-scans-queries", kwargs={"pk": attack_paths_scan.id}
)
)
assert response.status_code == status.HTTP_200_OK
outcomes = {
item["id"]: item["attributes"]["outcome"]
for item in response.json()["data"]
}
assert outcomes["aws-lambda-passrole"] == {
"kind": "code_execution",
"label": "Code execution",
"partial": False,
}
assert outcomes["aws-rds-inventory"] == {
"kind": "resource_inventory",
"label": "Resource inventory",
"partial": True,
}
assert outcomes["aws-no-outcome"] is None
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"
)
# Serializing a Jira integration reads `configuration` to add the domain from the
# credentials, and a sparse fieldset can leave that field out of the representation
def test_integrations_list_sparse_fields_without_configuration(
self, authenticated_client, jira_integration_fixture
):
response = authenticated_client.get(
reverse("integration-list"),
{"fields[integrations]": "enabled,integration_type"},
)
assert response.status_code == status.HTTP_200_OK
attributes = response.json()["data"][0]["attributes"]
assert sorted(attributes.keys()) == ["enabled", "integration_type"]
def test_integrations_retrieve_sparse_fields_without_configuration(
self, authenticated_client, jira_integration_fixture
):
response = authenticated_client.get(
reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id}),
{"fields[integrations]": "enabled,integration_type"},
)
assert response.status_code == status.HTTP_200_OK
assert "configuration" not in response.json()["data"]["attributes"]
def test_integrations_partial_update_sparse_fields_without_configuration(
self, authenticated_client, jira_integration_fixture
):
data = {
"data": {
"type": "integrations",
"id": str(jira_integration_fixture.id),
"attributes": {"enabled": False},
}
}
url = reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id})
response = authenticated_client.patch(
f"{url}?fields[integrations]=enabled,integration_type",
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
assert "configuration" not in response.json()["data"]["attributes"]
with rls_transaction(str(jira_integration_fixture.tenant_id)):
jira_integration_fixture.refresh_from_db()
assert jira_integration_fixture.enabled is False
# Omitting `configuration` from the fieldset must not rewrite it, and the
# serialized `domain` must not leak into the stored value
assert jira_integration_fixture.configuration == {
"projects": {"TEST": "Test project"}
}
def test_integrations_retrieve_jira_keeps_domain_in_configuration(
self, authenticated_client, jira_integration_fixture
):
response = authenticated_client.get(
reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id})
)
assert response.status_code == status.HTTP_200_OK
configuration = response.json()["data"]["attributes"]["configuration"]
assert configuration["domain"] == "test"
assert configuration["projects"] == {"TEST": "Test project"}
@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
@pytest.mark.parametrize(
(
"existing_role_attributes",
"existing_suffixes",
"expected_role_name",
"expected_role_created",
),
[
(None, (), "read_only", True),
({"unlimited_visibility": True}, (), "read_only", False),
(
{"manage_users": True, "unlimited_visibility": True},
(
("read_only_0", {"unlimited_visibility": True}),
("read_only_1", {"unlimited_visibility": True}),
),
"read_only_0",
False,
),
(
{"manage_users": True, "unlimited_visibility": True},
(
(
"read_only_0",
{"manage_users": True, "unlimited_visibility": True},
),
("read_only_1", {"unlimited_visibility": True}),
),
"read_only_1",
False,
),
({"unlimited_visibility": False}, (), "read_only_0", True),
],
ids=[
"creates-role",
"reuses-safe-role",
"reuses-first-safe-suffixed-role",
"skips-unsafe-suffixed-role",
"avoids-restricted-visibility",
],
)
def test_dispatch_assigns_read_only_role_when_usertype_missing(
self,
create_test_user,
tenants_fixture,
saml_setup,
settings,
monkeypatch,
existing_role_attributes,
existing_suffixes,
expected_role_name,
expected_role_created,
):
"""Test safe fallback role assignment when userType is missing"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
other_tenant = tenants_fixture[1]
other_tenant_role = Role.objects.using(MainRouter.admin_db).create(
name="read_only",
tenant=other_tenant,
unlimited_visibility=True,
)
other_tenant_relationship = UserRoleRelationship.objects.using(
MainRouter.admin_db
).create(
user=user,
role=other_tenant_role,
tenant=other_tenant,
)
existing_role = None
if existing_role_attributes is not None:
existing_role = Role.objects.using(MainRouter.admin_db).create(
name="read_only",
tenant=tenant,
**existing_role_attributes,
)
for role_name, role_attributes in existing_suffixes:
Role.objects.using(MainRouter.admin_db).create(
name=role_name,
tenant=tenant,
**role_attributes,
)
roles_before = (
Role.objects.using(MainRouter.admin_db).filter(tenant=tenant).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 fallback role was created or reused with read-only access
expected_role_count = roles_before + expected_role_created
assert (
Role.objects.using(MainRouter.admin_db).filter(tenant=tenant).count()
== expected_role_count
)
role = Role.objects.using(MainRouter.admin_db).get(
name=expected_role_name, tenant=tenant
)
if existing_role is not None and expected_role_name == "read_only":
assert role == existing_role
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=tenant.id)
.exists()
)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(
id=other_tenant_relationship.id,
user=user,
role=other_tenant_role,
tenant_id=other_tenant.id,
)
.exists()
)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, tenant_id=tenant.id)
.count()
== 1
)
# Membership is still created so the user belongs to the tenant
assert (
Membership.objects.using(MainRouter.admin_db)
.filter(user=user, tenant=tenant)
.exists()
)
@pytest.mark.django_db(transaction=True)
def test_dispatch_serializes_concurrent_fallback_role_assignment(
self,
create_test_user,
tenants_fixture,
saml_setup,
monkeypatch,
):
"""Test concurrent callbacks assign only one fallback role"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
Role.objects.using(MainRouter.admin_db).create(
name="read_only",
tenant=tenant,
manage_users=True,
unlimited_visibility=True,
)
social_account = SocialAccount(
user=user,
provider="saml",
extra_data={
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
},
)
# Without the user lock, both callbacks reach this query before either
# creates a fallback. With the lock, the first callback times out here
# while the second waits for the transaction to finish.
second_role_check_reached = Event()
concurrent_role_checks_detected = Event()
role_check_count_lock = Lock()
role_check_count = 0
original_role_check = TenantFinishACSView._user_has_tenant_role
def synchronize_role_checks(user_id, tenant_id):
nonlocal role_check_count
with role_check_count_lock:
role_check_count += 1
is_first_role_check = role_check_count == 1
if role_check_count == 2:
second_role_check_reached.set()
if is_first_role_check and second_role_check_reached.wait(timeout=1):
concurrent_role_checks_detected.set()
return original_role_check(user_id, tenant_id)
def dispatch_callback():
close_old_connections()
try:
thread_user = User.objects.using(MainRouter.admin_db).get(pk=user.pk)
request = RequestFactory().get(
reverse(
"saml_finish_acs",
kwargs={"organization_slug": saml_setup["domain"]},
)
)
request.user = thread_user
request.session = {}
response = TenantFinishACSView.as_view()(
request, organization_slug=saml_setup["domain"]
)
return response
finally:
close_old_connections()
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,
patch.object(
TenantFinishACSView,
"_user_has_tenant_role",
side_effect=synchronize_role_checks,
),
):
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.side_effect = lambda *_args, **_kwargs: User.objects.using(
MainRouter.admin_db
).get(pk=user.pk)
with ThreadPoolExecutor(max_workers=2) as executor:
responses = list(executor.map(lambda _: dispatch_callback(), range(2)))
assert role_check_count == 2
assert not concurrent_role_checks_detected.is_set()
for response in responses:
assert response.status_code == status.HTTP_302_FOUND
parsed_redirect = urlparse(response.url)
assert parsed_redirect.path == "/sso-complete"
assert set(parse_qs(parsed_redirect.query)) == {"id"}
relationships = UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
user=user, tenant_id=tenant.id
)
assert relationships.count() == 1
assert relationships.get().role.name == "read_only_0"
assert (
Role.objects.using(MainRouter.admin_db)
.filter(tenant=tenant, name__startswith="read_only_")
.count()
== 1
)
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_with_orphaned_key(
self, authenticated_client, api_keys_fixture
):
"""Test listing keys whose owner was deleted: `entity` is serialized as null."""
orphaned_key = api_keys_fixture[0]
TenantAPIKey.objects.filter(id=orphaned_key.id).update(entity=None)
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)
serialized_key = next(
item for item in data if item["id"] == str(orphaned_key.id)
)
assert serialized_key["relationships"]["entity"]["data"] is None
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