mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
chore(skills): Improve Django and DRF skills (#9831)
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
This commit is contained in:
@@ -6,7 +6,7 @@ description: >
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "1.0"
|
||||
version: "1.1.0"
|
||||
scope: [root, api]
|
||||
auto_invoke:
|
||||
- "Writing Prowler API tests"
|
||||
@@ -17,115 +17,136 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
## Critical Rules
|
||||
|
||||
- ALWAYS use `response.json()["data"]` not `response.data`
|
||||
- ALWAYS use `content_type = "application/vnd.api+json"` in requests
|
||||
- ALWAYS test cross-tenant isolation with `other_tenant_provider` fixture
|
||||
- ALWAYS use `content_type = "application/vnd.api+json"` for PATCH/PUT requests
|
||||
- ALWAYS use `format="vnd.api+json"` for POST requests
|
||||
- ALWAYS test cross-tenant isolation - RLS returns 404, NOT 403
|
||||
- NEVER skip RLS isolation tests when adding new endpoints
|
||||
- NEVER use realistic-looking API keys in tests (TruffleHog will flag them)
|
||||
- ALWAYS mock BOTH `.delay()` AND `Task.objects.get` for async task tests
|
||||
|
||||
---
|
||||
|
||||
## 1. JSON:API Format (Critical)
|
||||
## 1. Fixture Dependency Chain
|
||||
|
||||
```python
|
||||
content_type = "application/vnd.api+json"
|
||||
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "providers", # Plural, kebab-case
|
||||
"id": str(resource.id), # Required for PATCH
|
||||
"attributes": {"alias": "updated"},
|
||||
}
|
||||
}
|
||||
|
||||
response.json()["data"]["attributes"]["alias"]
|
||||
```
|
||||
create_test_user (session) ─► tenants_fixture (function) ─► authenticated_client
|
||||
│
|
||||
└─► providers_fixture ─► scans_fixture ─► findings_fixture
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. RLS Isolation Tests
|
||||
|
||||
```python
|
||||
def test_cross_tenant_access_denied(self, authenticated_client, other_tenant_provider):
|
||||
"""User cannot see resources from other tenants."""
|
||||
response = authenticated_client.get(
|
||||
reverse("provider-detail", args=[other_tenant_provider.id])
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. RBAC Tests
|
||||
|
||||
```python
|
||||
def test_unlimited_visibility_sees_all(self, authenticated_client_admin, providers_fixture):
|
||||
response = authenticated_client_admin.get(reverse("provider-list"))
|
||||
assert len(response.json()["data"]) == len(providers_fixture)
|
||||
|
||||
def test_limited_visibility_sees_only_assigned(self, authenticated_client_limited):
|
||||
# User with unlimited_visibility=False sees only providers in their provider_groups
|
||||
pass
|
||||
|
||||
def test_permission_required(self, authenticated_client_readonly):
|
||||
response = authenticated_client_readonly.post(reverse("provider-list"), ...)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Managers (objects vs all_objects)
|
||||
|
||||
```python
|
||||
def test_objects_excludes_deleted(self):
|
||||
deleted_provider = Provider.objects.create(..., is_deleted=True)
|
||||
assert deleted_provider not in Provider.objects.all()
|
||||
assert deleted_provider in Provider.all_objects.all()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Celery Task Tests
|
||||
|
||||
```python
|
||||
@patch("tasks.tasks.perform_prowler_scan")
|
||||
def test_task_success(self, mock_scan):
|
||||
mock_scan.return_value = {"findings_count": 100}
|
||||
result = perform_scan_task(tenant_id="...", scan_id="...", provider_id="...")
|
||||
assert result["findings_count"] == 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Key Fixtures
|
||||
### Key Fixtures
|
||||
|
||||
| Fixture | Description |
|
||||
|---------|-------------|
|
||||
| `create_test_user` | Session user (dev@prowler.com) |
|
||||
| `tenants_fixture` | 3 tenants (2 with membership, 1 isolated) |
|
||||
| `providers_fixture` | Providers in tenant 1 |
|
||||
| `other_tenant_provider` | Provider in isolated tenant (RLS tests) |
|
||||
| `authenticated_client` | Client with JWT for tenant 1 |
|
||||
| `create_test_user` | Session user (`dev@prowler.com`) |
|
||||
| `tenants_fixture` | 3 tenants: [0],[1] have membership, [2] isolated |
|
||||
| `authenticated_client` | JWT client for tenant[0] |
|
||||
| `providers_fixture` | 9 providers in tenant[0] |
|
||||
| `tasks_fixture` | 2 Celery tasks with TaskResult |
|
||||
|
||||
### RBAC Fixtures
|
||||
|
||||
| Fixture | Permissions |
|
||||
|---------|-------------|
|
||||
| `authenticated_client_rbac` | All permissions (admin) |
|
||||
| `authenticated_client_rbac_noroles` | Membership but NO roles |
|
||||
| `authenticated_client_no_permissions_rbac` | All permissions = False |
|
||||
|
||||
---
|
||||
|
||||
## 7. Fake Secrets in Tests (TruffleHog)
|
||||
|
||||
CI runs TruffleHog to detect leaked secrets. Use obviously fake values:
|
||||
## 2. JSON:API Requests
|
||||
|
||||
### POST (Create)
|
||||
```python
|
||||
# BAD - TruffleHog will flag these patterns:
|
||||
api_key = "sk-test1234567890T3BlbkFJtest1234567890" # OpenAI pattern
|
||||
api_key = "AKIA..." # AWS pattern
|
||||
|
||||
# GOOD - clearly fake values:
|
||||
api_key = "sk-fake-test-key-for-unit-testing-only"
|
||||
api_key = "fake-aws-key-for-testing"
|
||||
response = client.post(
|
||||
reverse("provider-list"),
|
||||
data={"data": {"type": "providers", "attributes": {...}}},
|
||||
format="vnd.api+json", # NOT content_type!
|
||||
)
|
||||
```
|
||||
|
||||
**Patterns to avoid:**
|
||||
- `sk-*T3BlbkFJ*` (OpenAI)
|
||||
- `AKIA[A-Z0-9]{16}` (AWS Access Key)
|
||||
- `ghp_*` or `gho_*` (GitHub tokens)
|
||||
### PATCH (Update)
|
||||
```python
|
||||
response = client.patch(
|
||||
reverse("provider-detail", kwargs={"pk": provider.id}),
|
||||
data={"data": {"type": "providers", "id": str(provider.id), "attributes": {...}}},
|
||||
content_type="application/vnd.api+json", # NOT format!
|
||||
)
|
||||
```
|
||||
|
||||
### Reading Responses
|
||||
```python
|
||||
data = response.json()["data"]
|
||||
attrs = data["attributes"]
|
||||
errors = response.json()["errors"] # For 400 responses
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. RLS Isolation (Cross-Tenant)
|
||||
|
||||
**RLS returns 404, NOT 403** - the resource is invisible, not forbidden.
|
||||
|
||||
```python
|
||||
def test_cross_tenant_access_denied(self, authenticated_client, tenants_fixture):
|
||||
other_tenant = tenants_fixture[2] # Isolated tenant
|
||||
foreign_provider = Provider.objects.create(tenant_id=other_tenant.id, ...)
|
||||
|
||||
response = authenticated_client.get(reverse("provider-detail", args=[foreign_provider.id]))
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND # NOT 403!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Celery Task Testing
|
||||
|
||||
### Testing Strategies
|
||||
|
||||
| Strategy | Use For |
|
||||
|----------|---------|
|
||||
| Mock `.delay()` + `Task.objects.get` | Testing views that trigger tasks |
|
||||
| `task.apply()` | Synchronous task logic testing |
|
||||
| Mock `chain`/`group` | Testing Canvas orchestration |
|
||||
| Mock `connection` | Testing `@set_tenant` decorator |
|
||||
| Mock `apply_async` | Testing Beat scheduled tasks |
|
||||
|
||||
### Why NOT `task_always_eager`
|
||||
|
||||
| Problem | Impact |
|
||||
|---------|--------|
|
||||
| No task serialization | Misses argument type errors |
|
||||
| No broker interaction | Hides connection issues |
|
||||
| Different execution context | `self.request` behaves differently |
|
||||
|
||||
**Instead, use:** `task.apply()` for sync execution, mocking for isolation.
|
||||
|
||||
> **Full examples:** See [assets/api_test.py](assets/api_test.py) for `TestCeleryTaskLogic`, `TestCeleryCanvas`, `TestSetTenantDecorator`, `TestBeatScheduling`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fake Secrets (TruffleHog)
|
||||
|
||||
```python
|
||||
# BAD - TruffleHog flags these:
|
||||
api_key = "sk-test1234567890T3BlbkFJtest1234567890"
|
||||
|
||||
# GOOD - obviously fake:
|
||||
api_key = "sk-fake-test-key-for-unit-testing-only"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Response Status Codes
|
||||
|
||||
| Scenario | Code |
|
||||
|----------|------|
|
||||
| Successful GET | 200 |
|
||||
| Successful POST | 201 |
|
||||
| Async operation (DELETE/scan trigger) | 202 |
|
||||
| Sync DELETE | 204 |
|
||||
| Validation error | 400 |
|
||||
| Missing permission (RBAC) | 403 |
|
||||
| RLS isolation / not found | 404 |
|
||||
|
||||
---
|
||||
|
||||
@@ -134,11 +155,13 @@ api_key = "fake-aws-key-for-testing"
|
||||
```bash
|
||||
cd api && poetry run pytest -x --tb=short
|
||||
cd api && poetry run pytest -k "test_provider"
|
||||
cd api && poetry run pytest -k "TestRBAC"
|
||||
cd api && poetry run pytest api/src/backend/api/tests/test_rbac.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Documentation**: See [references/test-api-docs.md](references/test-api-docs.md) for local file paths and documentation
|
||||
- **Full Examples**: See [assets/api_test.py](assets/api_test.py) for complete test patterns
|
||||
- **Fixture Reference**: See [references/test-api-docs.md](references/test-api-docs.md)
|
||||
- **Fixture Source**: `api/src/backend/conftest.py`
|
||||
|
||||
371
skills/prowler-test-api/assets/api_test.py
Normal file
371
skills/prowler-test-api/assets/api_test.py
Normal file
@@ -0,0 +1,371 @@
|
||||
# Example: Prowler API Test Patterns
|
||||
# Source: api/src/backend/api/tests/test_views.py
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from conftest import (
|
||||
API_JSON_CONTENT_TYPE,
|
||||
TEST_PASSWORD,
|
||||
TEST_USER,
|
||||
get_api_tokens,
|
||||
get_authorization_header,
|
||||
)
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from api.models import Provider, Scan, StateChoices
|
||||
from api.rls import Tenant
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProviderViewSet:
|
||||
"""Example API tests for Provider endpoints."""
|
||||
|
||||
def test_list_providers(self, authenticated_client, providers_fixture):
|
||||
"""GET list returns all providers for authenticated tenant."""
|
||||
response = authenticated_client.get(reverse("provider-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == len(providers_fixture)
|
||||
|
||||
def test_create_provider(self, authenticated_client):
|
||||
"""POST with JSON:API format creates provider."""
|
||||
response = authenticated_client.post(
|
||||
reverse("provider-list"),
|
||||
data={
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"attributes": {
|
||||
"provider": "aws",
|
||||
"uid": "123456789012",
|
||||
"alias": "my-aws-account",
|
||||
},
|
||||
}
|
||||
},
|
||||
format="vnd.api+json", # Use format= for POST
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["data"]["attributes"]["uid"] == "123456789012"
|
||||
|
||||
def test_update_provider(self, authenticated_client, providers_fixture):
|
||||
"""PATCH with JSON:API format updates provider."""
|
||||
provider = providers_fixture[0]
|
||||
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(provider.id), # ID required for PATCH
|
||||
"attributes": {"alias": "updated-alias"},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.patch(
|
||||
reverse("provider-detail", kwargs={"pk": provider.id}),
|
||||
data=payload,
|
||||
content_type="application/vnd.api+json", # Use content_type= for PATCH
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"]["attributes"]["alias"] == "updated-alias"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRLSIsolation:
|
||||
"""Example RLS cross-tenant isolation tests."""
|
||||
|
||||
def test_cross_tenant_access_returns_404(
|
||||
self, authenticated_client, tenants_fixture
|
||||
):
|
||||
"""User cannot see resources from other tenants - returns 404 NOT 403."""
|
||||
# Create resource in tenant user has NO access to (tenant[2] is isolated)
|
||||
other_tenant = tenants_fixture[2]
|
||||
foreign_provider = Provider.objects.create(
|
||||
provider="aws",
|
||||
uid="999888777666",
|
||||
alias="foreign_provider",
|
||||
tenant_id=other_tenant.id,
|
||||
)
|
||||
|
||||
# Try to access - should get 404 (not 403!)
|
||||
response = authenticated_client.get(
|
||||
reverse("provider-detail", args=[foreign_provider.id])
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_list_excludes_other_tenants(
|
||||
self, authenticated_client, providers_fixture, tenants_fixture
|
||||
):
|
||||
"""List endpoints only return resources from user's tenants."""
|
||||
# Create provider in isolated tenant
|
||||
other_tenant = tenants_fixture[2]
|
||||
Provider.objects.create(
|
||||
provider="aws",
|
||||
uid="foreign123",
|
||||
tenant_id=other_tenant.id,
|
||||
)
|
||||
|
||||
response = authenticated_client.get(reverse("provider-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Should only see providers_fixture (9 providers in tenant[0])
|
||||
assert len(response.json()["data"]) == len(providers_fixture)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRBACPermissions:
|
||||
"""Example RBAC permission tests."""
|
||||
|
||||
def test_requires_permission(self, authenticated_client_no_permissions_rbac):
|
||||
"""Users without manage_providers cannot create providers."""
|
||||
response = authenticated_client_no_permissions_rbac.post(
|
||||
reverse("provider-list"),
|
||||
data={
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"attributes": {"provider": "aws", "uid": "123456789012"},
|
||||
}
|
||||
},
|
||||
format="vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_user_with_no_roles_denied(self, authenticated_client_rbac_noroles):
|
||||
"""User with membership but no roles gets 403."""
|
||||
response = authenticated_client_rbac_noroles.get(reverse("user-list"))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_admin_sees_all(self, authenticated_client_rbac, providers_fixture):
|
||||
"""Admin with unlimited_visibility=True sees all providers."""
|
||||
response = authenticated_client_rbac.get(reverse("provider-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAsyncOperations:
|
||||
"""Example async task tests - mock BOTH .delay() AND Task.objects.get."""
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.delete_provider_task.delay")
|
||||
def test_delete_provider_returns_202(
|
||||
self,
|
||||
mock_delete_task,
|
||||
mock_task_get,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
tasks_fixture,
|
||||
):
|
||||
"""DELETE returns 202 Accepted with Content-Location header."""
|
||||
provider = providers_fixture[0]
|
||||
prowler_task = tasks_fixture[0]
|
||||
|
||||
# Mock the Celery task
|
||||
task_mock = Mock()
|
||||
task_mock.id = prowler_task.id
|
||||
mock_delete_task.return_value = task_mock
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse("provider-detail", kwargs={"pk": provider.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert "Content-Location" in response.headers
|
||||
assert f"/api/v1/tasks/{prowler_task.id}" in response.headers["Content-Location"]
|
||||
|
||||
# Verify task was called
|
||||
mock_delete_task.assert_called_once()
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.perform_scan_task.delay")
|
||||
def test_trigger_scan_returns_202(
|
||||
self,
|
||||
mock_scan_task,
|
||||
mock_task_get,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
tasks_fixture,
|
||||
):
|
||||
"""POST to scan trigger returns 202 with task location."""
|
||||
provider = providers_fixture[0]
|
||||
prowler_task = tasks_fixture[0]
|
||||
|
||||
task_mock = Mock()
|
||||
task_mock.id = prowler_task.id
|
||||
mock_scan_task.return_value = task_mock
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("provider-scan", kwargs={"pk": provider.id}),
|
||||
format="vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestJSONAPIResponses:
|
||||
"""Example JSON:API response handling."""
|
||||
|
||||
def test_read_single_resource(self, authenticated_client, providers_fixture):
|
||||
"""Read data from single resource response."""
|
||||
provider = providers_fixture[0]
|
||||
response = authenticated_client.get(
|
||||
reverse("provider-detail", kwargs={"pk": provider.id})
|
||||
)
|
||||
|
||||
data = response.json()["data"]
|
||||
attrs = data["attributes"]
|
||||
resource_id = data["id"]
|
||||
|
||||
assert resource_id == str(provider.id)
|
||||
assert attrs["provider"] == provider.provider
|
||||
|
||||
def test_read_list_response(self, authenticated_client, providers_fixture):
|
||||
"""Read data from list response."""
|
||||
response = authenticated_client.get(reverse("provider-list"))
|
||||
|
||||
items = response.json()["data"]
|
||||
assert len(items) == len(providers_fixture)
|
||||
|
||||
def test_read_relationships(self, authenticated_client, scans_fixture):
|
||||
"""Read relationship data."""
|
||||
scan = scans_fixture[0]
|
||||
response = authenticated_client.get(
|
||||
reverse("scan-detail", kwargs={"pk": scan.id})
|
||||
)
|
||||
|
||||
data = response.json()["data"]
|
||||
relationships = data["relationships"]
|
||||
provider_rel = relationships["provider"]["data"]
|
||||
|
||||
assert provider_rel["type"] == "providers"
|
||||
assert provider_rel["id"] == str(scan.provider_id)
|
||||
|
||||
def test_error_response(self, authenticated_client):
|
||||
"""Read error response structure."""
|
||||
response = authenticated_client.post(
|
||||
reverse("user-list"),
|
||||
data={"email": "invalid"}, # Missing required fields
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
errors = response.json()["errors"]
|
||||
# Error has source.pointer and detail
|
||||
assert "source" in errors[0]
|
||||
assert "detail" in errors[0]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSoftDelete:
|
||||
"""Example soft-delete manager tests."""
|
||||
|
||||
def test_objects_excludes_soft_deleted(self, providers_fixture):
|
||||
"""Default manager excludes soft-deleted records."""
|
||||
provider = providers_fixture[0]
|
||||
provider.is_deleted = True
|
||||
provider.save()
|
||||
|
||||
# objects manager excludes deleted
|
||||
assert provider not in Provider.objects.all()
|
||||
|
||||
# all_objects includes deleted
|
||||
assert provider in Provider.all_objects.all()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CELERY TASK TESTING
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCeleryTaskLogic:
|
||||
"""Example: Testing Celery task logic directly with apply()."""
|
||||
|
||||
def test_task_logic_directly(self, tenants_fixture, providers_fixture):
|
||||
"""Use apply() for synchronous execution without Celery worker."""
|
||||
from tasks.tasks import check_provider_connection_task
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
|
||||
# Execute task synchronously (no broker needed)
|
||||
result = check_provider_connection_task.apply(
|
||||
kwargs={"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
|
||||
)
|
||||
|
||||
assert result.successful()
|
||||
assert result.result["connected"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCeleryCanvas:
|
||||
"""Example: Testing Canvas (chain/group) task orchestration."""
|
||||
|
||||
@patch("tasks.tasks.chain")
|
||||
@patch("tasks.tasks.group")
|
||||
def test_post_scan_workflow(self, mock_group, mock_chain, tenants_fixture):
|
||||
"""Mock chain/group to verify task orchestration."""
|
||||
from tasks.tasks import _perform_scan_complete_tasks
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
# Mock chain.apply_async
|
||||
mock_chain_instance = Mock()
|
||||
mock_chain.return_value = mock_chain_instance
|
||||
|
||||
_perform_scan_complete_tasks(str(tenant.id), "scan-123", "provider-456")
|
||||
|
||||
# Verify chain was called
|
||||
assert mock_chain.called
|
||||
mock_chain_instance.apply_async.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSetTenantDecorator:
|
||||
"""Example: Testing @set_tenant decorator behavior."""
|
||||
|
||||
@patch("api.decorators.connection")
|
||||
def test_sets_rls_context(self, mock_conn, tenants_fixture, providers_fixture):
|
||||
"""Verify @set_tenant sets RLS context via SET_CONFIG_QUERY."""
|
||||
from tasks.tasks import check_provider_connection_task
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
|
||||
# Call task with tenant_id - decorator sets RLS and pops it
|
||||
check_provider_connection_task.apply(
|
||||
kwargs={"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
|
||||
)
|
||||
|
||||
# Verify SET_CONFIG_QUERY was executed
|
||||
mock_conn.cursor.return_value.__enter__.return_value.execute.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBeatScheduling:
|
||||
"""Example: Testing Beat scheduled task creation."""
|
||||
|
||||
@patch("tasks.beat.perform_scheduled_scan_task.apply_async")
|
||||
def test_schedule_provider_scan(self, mock_apply, providers_fixture):
|
||||
"""Verify periodic task is created with correct settings."""
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
|
||||
from tasks.beat import schedule_provider_scan
|
||||
|
||||
provider = providers_fixture[0]
|
||||
mock_apply.return_value = Mock(id="task-123")
|
||||
|
||||
schedule_provider_scan(provider)
|
||||
|
||||
# Verify periodic task created
|
||||
assert PeriodicTask.objects.filter(
|
||||
name=f"scan-perform-scheduled-{provider.id}"
|
||||
).exists()
|
||||
|
||||
# Verify immediate execution with countdown
|
||||
mock_apply.assert_called_once()
|
||||
call_kwargs = mock_apply.call_args
|
||||
assert call_kwargs.kwargs.get("countdown") == 5
|
||||
@@ -1,18 +1,214 @@
|
||||
# API Test Documentation
|
||||
# API Test Documentation Reference
|
||||
|
||||
## Local Documentation
|
||||
## File Locations
|
||||
|
||||
For API testing patterns, see:
|
||||
| Type | Path |
|
||||
|------|------|
|
||||
| Central fixtures | `api/src/backend/conftest.py` |
|
||||
| API unit tests | `api/src/backend/api/tests/` |
|
||||
| Integration tests | `api/src/backend/api/tests/integration/` |
|
||||
| Task tests | `api/src/backend/tasks/tests/` |
|
||||
| Dev fixtures (JSON) | `api/src/backend/api/fixtures/dev/` |
|
||||
|
||||
- `api/src/backend/conftest.py` - All fixtures
|
||||
- `api/src/backend/api/tests/` - API tests
|
||||
- `api/src/backend/tasks/tests/` - Task tests
|
||||
---
|
||||
|
||||
## Contents
|
||||
## Fixture Dependency Graph
|
||||
|
||||
The documentation covers:
|
||||
- JSON:API format for requests/responses
|
||||
- RLS isolation test patterns
|
||||
- RBAC permission tests
|
||||
- Celery task mocking
|
||||
- Test fixtures and their usage
|
||||
```
|
||||
create_test_user (session)
|
||||
│
|
||||
└─► tenants_fixture (function)
|
||||
│
|
||||
├─► set_user_admin_roles_fixture
|
||||
│ │
|
||||
│ └─► authenticated_client
|
||||
│ └─► (most API tests use this)
|
||||
│
|
||||
├─► providers_fixture
|
||||
│ └─► scans_fixture
|
||||
│ └─► findings_fixture
|
||||
│
|
||||
└─► RBAC fixtures (create their own tenants/users):
|
||||
├─► create_test_user_rbac
|
||||
│ └─► authenticated_client_rbac
|
||||
│
|
||||
├─► create_test_user_rbac_no_roles
|
||||
│ └─► authenticated_client_rbac_noroles
|
||||
│
|
||||
├─► create_test_user_rbac_limited
|
||||
│ └─► authenticated_client_no_permissions_rbac
|
||||
│
|
||||
├─► create_test_user_rbac_manage_account
|
||||
│ └─► authenticated_client_rbac_manage_account
|
||||
│
|
||||
└─► create_test_user_rbac_manage_users_only
|
||||
└─► authenticated_client_rbac_manage_users_only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test File Contents
|
||||
|
||||
### `api/src/backend/api/tests/test_views.py`
|
||||
|
||||
Main ViewSet tests covering:
|
||||
- `TestUserViewSet` - User CRUD, password validation, deletion cascades
|
||||
- `TestTenantViewSet` - Tenant operations
|
||||
- `TestProviderViewSet` - Provider CRUD, async deletion, connection testing
|
||||
- `TestScanViewSet` - Scan trigger, list, filter
|
||||
- `TestFindingViewSet` - Finding queries, filters
|
||||
- `TestResourceViewSet` - Resource listing with tags
|
||||
- `TestTaskViewSet` - Celery task status
|
||||
- `TestIntegrationViewSet` - S3/Security Hub integrations
|
||||
- `TestComplianceOverviewViewSet` - Compliance data
|
||||
- And many more...
|
||||
|
||||
### `api/src/backend/api/tests/test_rbac.py`
|
||||
|
||||
RBAC permission tests covering:
|
||||
- Permission checks for each ViewSet
|
||||
- Role-based access patterns
|
||||
- `unlimited_visibility` behavior
|
||||
- Provider group visibility filtering
|
||||
- Self-access patterns (`/me` endpoint)
|
||||
|
||||
### `api/src/backend/api/tests/integration/test_rls_transaction.py`
|
||||
|
||||
RLS enforcement tests:
|
||||
- `rls_transaction` context manager
|
||||
- Invalid UUID validation
|
||||
- Custom parameter names
|
||||
|
||||
### `api/src/backend/api/tests/integration/test_providers.py`
|
||||
|
||||
Provider integration tests:
|
||||
- Delete + recreate flow with async tasks
|
||||
- End-to-end provider lifecycle
|
||||
|
||||
### `api/src/backend/api/tests/integration/test_authentication.py`
|
||||
|
||||
Authentication tests:
|
||||
- JWT token flow
|
||||
- API key authentication
|
||||
- Social login (SAML, OAuth)
|
||||
- Cross-tenant token isolation
|
||||
|
||||
---
|
||||
|
||||
## Key Test Classes and Their Fixtures
|
||||
|
||||
### Standard API Tests
|
||||
|
||||
```python
|
||||
@pytest.mark.django_db
|
||||
class TestProviderViewSet:
|
||||
def test_list(self, authenticated_client, providers_fixture):
|
||||
# authenticated_client has JWT for tenant[0]
|
||||
# providers_fixture has 9 providers in tenant[0]
|
||||
...
|
||||
```
|
||||
|
||||
### RBAC Tests
|
||||
|
||||
```python
|
||||
@pytest.mark.django_db
|
||||
class TestProviderRBAC:
|
||||
def test_with_permission(self, authenticated_client_rbac, ...):
|
||||
# Has all permissions
|
||||
...
|
||||
|
||||
def test_without_permission(self, authenticated_client_no_permissions_rbac, ...):
|
||||
# Has no permissions (all False)
|
||||
...
|
||||
```
|
||||
|
||||
### Cross-Tenant Tests
|
||||
|
||||
```python
|
||||
@pytest.mark.django_db
|
||||
class TestCrossTenantIsolation:
|
||||
def test_cannot_access_other_tenant(self, authenticated_client, tenants_fixture):
|
||||
other_tenant = tenants_fixture[2] # Isolated tenant
|
||||
# Create resource in other_tenant
|
||||
# Try to access with authenticated_client
|
||||
# Expect 404
|
||||
```
|
||||
|
||||
### Async Task Tests
|
||||
|
||||
```python
|
||||
@pytest.mark.django_db
|
||||
class TestAsyncOperations:
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.some_task.delay")
|
||||
def test_async_operation(self, mock_task, mock_task_get, tasks_fixture, ...):
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_task.return_value = Mock(id=prowler_task.id)
|
||||
mock_task_get.return_value = prowler_task
|
||||
# Execute and verify 202 response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constants Available from conftest
|
||||
|
||||
```python
|
||||
from conftest import (
|
||||
API_JSON_CONTENT_TYPE, # "application/vnd.api+json"
|
||||
NO_TENANT_HTTP_STATUS, # status.HTTP_401_UNAUTHORIZED
|
||||
TEST_USER, # "dev@prowler.com"
|
||||
TEST_PASSWORD, # "testing_psswd"
|
||||
TODAY, # str(datetime.today().date())
|
||||
today_after_n_days, # Function: (n: int) -> str
|
||||
get_api_tokens, # Function: (client, email, password, tenant_id?) -> (access, refresh)
|
||||
get_authorization_header, # Function: (token) -> {"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Full test suite
|
||||
cd api && poetry run pytest
|
||||
|
||||
# Fast fail on first error
|
||||
cd api && poetry run pytest -x
|
||||
|
||||
# Short traceback
|
||||
cd api && poetry run pytest --tb=short
|
||||
|
||||
# Specific file
|
||||
cd api && poetry run pytest api/src/backend/api/tests/test_views.py
|
||||
|
||||
# Pattern match
|
||||
cd api && poetry run pytest -k "Provider"
|
||||
|
||||
# Verbose with print output
|
||||
cd api && poetry run pytest -v -s
|
||||
|
||||
# With coverage
|
||||
cd api && poetry run pytest --cov=api --cov-report=html
|
||||
|
||||
# Parallel execution
|
||||
cd api && poetry run pytest -n auto
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## pytest Configuration
|
||||
|
||||
From `api/pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "config.settings"
|
||||
python_files = "test_*.py"
|
||||
addopts = "--reuse-db"
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Uses `--reuse-db` for faster test runs
|
||||
- Settings from `config.settings`
|
||||
- Test files must match `test_*.py`
|
||||
|
||||
Reference in New Issue
Block a user