feat(Users): #PRWLR-4717 add /users endpoints and basic auth (#43)

* feat(SimpleJWT): PRWLR-4717 add djangorestframework-simplejwt dep

* feat(SimpleJWT): PRWLR-4717 add basic configuration and env variables

* feat(Users): PRWLR-4717 add model and security constraints

* feat(Users): PRWLR-4717 add serializers

* feat(Users): PRWLR-4717 add views

* test(Conftest): PRWLR-4717 add user and authenticated client fixtures

* fix(Unit tests): PRWLR-4717 add automated authentication to all unit tests

* fix(Authentication): PRWLR-4717 add authentication class and update tests

* test(Users): PRWLR-4717 add unit tests

* test(Users): PRWLR-4717 add integration tests

* chore(Schema): PRWLR-4717 update API schema

* fix(User): PRWLR-4717 fix password validation

* feat(Validators): PRWLR-4717 add MaxLength password validator

* fix(User): PRWLR-4717 update User model to delete admin fields

* chore(Serializers): PRWLR-4717 add docstrings and update serializers

* chore(Fixtures): PRWLR-4717 add dev user

* chore(Users): PRWLR-4717 raise DRF NotFound instead of returning response
This commit is contained in:
Víctor Fernández Poyatos
2024-09-18 10:19:44 +02:00
committed by GitHub
parent 6a341b88f0
commit 9ffde34198
25 changed files with 1674 additions and 235 deletions
+4
View File
@@ -11,6 +11,10 @@ DJANGO_LOGGING_FORMATTER=[ndjson|human_readable]
# applies to both Django and Celery Workers
DJANGO_LOGGING_LEVEL=INFO
DJANGO_WORKERS=4 # Defaults to the maximum available based on CPU cores if not set.
# Token lifetime is in minutes
DJANGO_ACCESS_TOKEN_LIFETIME=30
DJANGO_REFRESH_TOKEN_LIFETIME=1440
DJANGO_TOKEN_SIGNING_KEY=S3cret
DJANGO_CACHE_MAX_AGE=3600
DJANGO_STALE_WHILE_REVALIDATE=60
+1 -1
View File
@@ -119,7 +119,7 @@ jobs:
- name: Safety
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true'
run: |
poetry run safety check --ignore 70612
poetry run safety check --ignore 70612,66963
- name: Vulture
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true'
run: |
+1 -1
View File
@@ -79,7 +79,7 @@ repos:
- id: safety
name: safety
description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities"
entry: bash -c 'poetry run safety check --ignore 70612'
entry: bash -c 'poetry run safety check --ignore 70612,66963'
language: system
- id: vulture
+4 -2
View File
@@ -197,10 +197,12 @@ Fixtures are used to populate the database with initial development data.
```console
poetry shell
# For dev tenants
python manage.py loaddata api/fixtures/dev_tenants.json --database admin
# For dev users
python manage.py loaddata api/fixtures/0_dev_users.json --database admin
```
> The default credentials are `prowler_dev:thisisapassword123`
## Run tests
Note that the tests will fail if you use the same `.env` file as the development environment.
Generated
+25 -1
View File
@@ -1441,6 +1441,30 @@ django-filter = ["django-filter (>=2.4)"]
django-polymorphic = ["django-polymorphic (>=3.0)"]
openapi = ["pyyaml (>=5.4)", "uritemplate (>=3.0.1)"]
[[package]]
name = "djangorestframework-simplejwt"
version = "5.3.1"
description = "A minimal JSON Web Token authentication plugin for Django REST Framework"
optional = false
python-versions = ">=3.8"
files = [
{file = "djangorestframework_simplejwt-5.3.1-py3-none-any.whl", hash = "sha256:381bc966aa46913905629d472cd72ad45faa265509764e20ffd440164c88d220"},
{file = "djangorestframework_simplejwt-5.3.1.tar.gz", hash = "sha256:6c4bd37537440bc439564ebf7d6085e74c5411485197073f508ebdfa34bc9fae"},
]
[package.dependencies]
django = ">=3.2"
djangorestframework = ">=3.12"
pyjwt = ">=1.7.1,<3"
[package.extras]
crypto = ["cryptography (>=3.3.1)"]
dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "flake8", "freezegun", "ipython", "isort", "pep8", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel"]
doc = ["Sphinx (>=1.6.5,<2)", "sphinx_rtd_theme (>=0.1.9)"]
lint = ["flake8", "isort", "pep8"]
python-jose = ["python-jose (==3.3.0)"]
test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"]
[[package]]
name = "dnspython"
version = "2.6.1"
@@ -4770,4 +4794,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.11,<3.13"
content-hash = "739edc3fdfe9c9ac007914b97a33ae822b550b9d453d3d50677be54fd6caef4a"
content-hash = "bb69d8edb8b1e71769eb678b4104f5e7bd82ba548b94084e97a03c2e1215cda7"
+1
View File
@@ -20,6 +20,7 @@ django-filter = "24.2"
django-guid = "3.5.0"
djangorestframework = "3.15.2"
djangorestframework-jsonapi = "7.0.2"
djangorestframework-simplejwt = "^5.3.1"
drf-spectacular = "0.27.2"
drf-spectacular-jsonapi = "0.4.1"
gunicorn = "23.0.0"
+4
View File
@@ -1,6 +1,8 @@
import uuid
from django.db import transaction, connection
from rest_framework import permissions
from rest_framework.authentication import BasicAuthentication
from rest_framework.exceptions import NotAuthenticated
from rest_framework.filters import SearchFilter
from rest_framework_json_api import filters
@@ -11,6 +13,8 @@ from api.filters import CustomDjangoFilterBackend
class BaseViewSet(ModelViewSet):
authentication_classes = [BasicAuthentication]
permission_classes = [permissions.IsAuthenticated]
filter_backends = [
filters.QueryParameterValidationFilter,
filters.OrderingFilter,
+12
View File
@@ -1,6 +1,7 @@
from contextlib import contextmanager
from django.conf import settings
from django.contrib.auth.models import BaseUserManager
from django.db import models
from psycopg2 import connect as psycopg2_connect
from psycopg2.extensions import new_type, register_type, register_adapter, AsIs
@@ -37,6 +38,17 @@ def psycopg_connection(database_alias: str):
psycopg2_connection.close()
class CustomUserManager(BaseUserManager):
def create_user(self, username, email, password=None, **extra_fields):
if not email:
raise ValueError("The email field must be set")
email = self.normalize_email(email)
user = self.model(username=username.strip(), email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
# Postgres Enums
+14
View File
@@ -0,0 +1,14 @@
[
{
"model": "api.user",
"pk": "8b38e2eb-6689-4f1e-a4ba-95b275130200",
"fields": {
"password": "pbkdf2_sha256$720000$vA62S78kog2c2ytycVQdke$Fp35GVLLMyy5fUq3krSL9I02A+ocQ+RVa4S22LIAO5s=",
"last_login": null,
"username": "prowler_dev",
"email": "dev@prowler.com",
"is_active": true,
"date_joined": "2024-09-17T09:04:20.850Z"
}
}
]
+52 -1
View File
@@ -1,6 +1,10 @@
import uuid
from functools import partial
import django.contrib.auth.models
import django.contrib.auth.validators
import django.contrib.postgres.indexes
import django.contrib.postgres.search
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
@@ -49,7 +53,10 @@ class Migration(migrations.Migration):
# Required for our kind of `RunPython` operations
atomic = False
dependencies = [("django_celery_results", "0011_taskresult_periodic_task_name")]
dependencies = [
("django_celery_results", "0011_taskresult_periodic_task_name"),
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.RunSQL(
@@ -73,6 +80,50 @@ class Migration(migrations.Migration):
GRANT SELECT ON django_migrations TO {DB_PROWLER_USER};
"""
),
migrations.CreateModel(
name="User",
fields=[
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"username",
models.CharField(
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
),
),
("email", models.EmailField(max_length=254, unique=True)),
("is_active", models.BooleanField(default=True)),
("date_joined", models.DateTimeField(auto_now_add=True)),
],
options={
"db_table": "users",
},
),
migrations.AddConstraint(
model_name="user",
constraint=api.rls.BaseSecurityConstraint(
name="statements_on_user",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
# Create and register State type
migrations.RunPython(
StateEnumMigration.create_enum_type,
+38 -3
View File
@@ -1,6 +1,8 @@
import re
from uuid import uuid4, UUID
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.core.validators import MinLengthValidator
@@ -9,10 +11,18 @@ from django.utils.translation import gettext_lazy as _
from django_celery_results.models import TaskResult
from uuid6 import uuid7
from api.db_utils import ProviderEnumField, StateEnumField, ScanTriggerEnumField
from api.db_utils import (
ProviderEnumField,
StateEnumField,
ScanTriggerEnumField,
CustomUserManager,
)
from api.exceptions import ModelValidationError
from api.rls import RowLevelSecurityConstraint
from api.rls import RowLevelSecurityProtectedModel
from api.rls import (
RowLevelSecurityProtectedModel,
RowLevelSecurityConstraint,
BaseSecurityConstraint,
)
class StateChoices(models.TextChoices):
@@ -24,6 +34,31 @@ class StateChoices(models.TextChoices):
CANCELLED = "cancelled", _("Cancelled")
class User(AbstractBaseUser):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
username = models.CharField(
max_length=150, unique=True, validators=[UnicodeUsernameValidator()]
)
email = models.EmailField(max_length=254, unique=True)
is_active = models.BooleanField(default=True)
date_joined = models.DateTimeField(auto_now_add=True, editable=False)
USERNAME_FIELD = "username"
REQUIRED_FIELDS = ["email"]
objects = CustomUserManager()
class Meta:
db_table = "users"
constraints = [
BaseSecurityConstraint(
name="statements_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
)
]
class Provider(RowLevelSecurityProtectedModel):
class ProviderChoices(models.TextChoices):
AWS = "aws", _("AWS")
+47
View File
@@ -26,6 +26,8 @@ class Tenant(models.Model):
# TODO Add abstract class for non-RLS models
class RowLevelSecurityConstraint(models.BaseConstraint):
"""Model constraint to enforce row-level security on a tenant based model, in addition to the least privileges."""
rls_sql_query = """
ALTER TABLE %(table_name)s ENABLE ROW LEVEL SECURITY;
ALTER TABLE %(table_name)s FORCE ROW LEVEL SECURITY;
@@ -114,6 +116,51 @@ class RowLevelSecurityConstraint(models.BaseConstraint):
raise ValidationError(f"{model.__name__} does not have a tenant_id field.")
class BaseSecurityConstraint(models.BaseConstraint):
"""Model constraint to grant the least privileges to the API database user."""
grant_sql_query = """
GRANT {statement} ON %(table_name)s TO %(db_user)s;
"""
drop_sql_query = """
REVOKE ALL ON TABLE %(table_name) TO %(db_user)s;
"""
def __init__(self, name: str, statements: list | None = None) -> None:
super().__init__(name=name)
self.statements = statements or ["SELECT"]
def create_sql(self, model: Any, schema_editor: Any) -> Any:
grant_queries = ""
for statement in self.statements:
grant_queries = (
f"{grant_queries}{self.grant_sql_query.format(statement=statement)}"
)
return Statement(
grant_queries,
table_name=model._meta.db_table,
db_user=DB_USER,
)
def remove_sql(self, model: Any, schema_editor: Any) -> Any:
return Statement(
self.drop_sql_query,
table_name=Table(model._meta.db_table, schema_editor.quote_name),
db_user=DB_USER,
)
def __eq__(self, other: object) -> bool:
if isinstance(other, BaseSecurityConstraint):
return self.name == other.name
return super().__eq__(other)
def deconstruct(self) -> tuple[str, tuple, dict]:
path, args, kwargs = super().deconstruct()
return path, args, kwargs
class RowLevelSecurityProtectedModel(models.Model):
tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import base64
import pytest
from django.urls import reverse
from rest_framework.test import APIClient
@pytest.mark.django_db
def test_basic_authentication(providers_fixture, tenant_header):
client = APIClient()
test_user = "test_user"
test_password = "test_password"
credentials = base64.b64encode(f"{test_user}:{test_password}".encode()).decode()
# Check that a 401 is returned when no basic authentication is provided
no_auth_response = client.get(reverse("provider-list"), headers=tenant_header)
assert no_auth_response.status_code == 401
# Check that we can create a new user without any kind of authentication
user_creation_response = client.post(
reverse("user-list"),
data={
"data": {
"type": "User",
"attributes": {
"username": test_user,
"password": test_password,
"email": "thisisnotimportant@prowler.com",
},
}
},
format="vnd.api+json",
)
assert user_creation_response.status_code == 201
# Check that using our new user's credentials we can authenticate and get the providers
auth_response = client.get(
reverse("provider-list"),
headers=tenant_header,
HTTP_AUTHORIZATION=f"Basic {credentials}",
)
assert auth_response.status_code == 200
assert len(auth_response.json()["data"]) == len(providers_fixture)
@@ -1,13 +1,12 @@
import pytest
from django.urls import reverse
from rest_framework.test import APIClient
@pytest.mark.django_db
def test_check_resources_between_different_tenants(
enforce_test_user_db_connection, tenants_fixture
enforce_test_user_db_connection, authenticated_api_client, tenants_fixture
):
client = APIClient()
client = authenticated_api_client
tenant1 = str(tenants_fixture[0].id)
tenant2 = str(tenants_fixture[1].id)
File diff suppressed because it is too large Load Diff
+65 -1
View File
@@ -1,10 +1,11 @@
import json
from django.contrib.auth.password_validation import validate_password
from drf_spectacular.utils import extend_schema_field
from rest_framework_json_api import serializers
from rest_framework_json_api.serializers import ValidationError
from api.models import StateChoices, Provider, Scan, Task, Resource, ResourceTag
from api.models import StateChoices, User, Provider, Scan, Task, Resource, ResourceTag
from api.rls import Tenant
from api.utils import merge_dicts
@@ -40,6 +41,68 @@ class StateEnumSerializerField(serializers.ChoiceField):
super().__init__(**kwargs)
# Users
class UserSerializer(BaseSerializerV1):
"""
Serializer for the User model.
"""
class Meta:
model = User
fields = [
"id",
"username",
"email",
"date_joined",
]
class UserCreateSerializer(BaseWriteSerializer):
password = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ["username", "password", "email"]
def validate_password(self, value):
user = User(**{k: v for k, v in self.initial_data.items() if k != "type"})
validate_password(value, user=user)
return value
def create(self, validated_data):
password = validated_data.pop("password")
user = User(**validated_data)
validate_password(password, user=user)
user.set_password(password)
user.save()
return user
class UserUpdateSerializer(BaseWriteSerializer):
password = serializers.CharField(write_only=True, required=False)
class Meta:
model = User
fields = ["id", "password", "email"]
extra_kwargs = {
"id": {"read_only": True},
}
def validate_password(self, value):
validate_password(value, user=self.instance)
return value
def update(self, instance, validated_data):
password = validated_data.pop("password", None)
if password:
validate_password(password, user=instance)
instance.set_password(password)
return super().update(instance, validated_data)
# Tasks
class TaskBase(serializers.Serializer):
state_mapping = {
@@ -322,6 +385,7 @@ class ResourceSerializer(RLSSerializer):
"type_",
"tags",
"provider",
"url",
]
extra_kwargs = {
"id": {"read_only": True},
+2
View File
@@ -4,6 +4,7 @@ from rest_framework import routers
from api.v1.views import (
SchemaView,
UserViewSet,
TenantViewSet,
ProviderViewSet,
ScanViewSet,
@@ -13,6 +14,7 @@ from api.v1.views import (
router = routers.DefaultRouter(trailing_slash=False)
router.register(r"users", UserViewSet, basename="user")
router.register(r"tenants", TenantViewSet, basename="tenant")
router.register(r"providers", ProviderViewSet, basename="provider")
router.register(r"scans", ScanViewSet, basename="scan")
+88 -6
View File
@@ -1,19 +1,19 @@
from celery.result import AsyncResult
from django.conf import settings as django_settings
from django.contrib.postgres.search import SearchQuery
from django.db.models import F
from django.db.models import Q
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.contrib.postgres.search import SearchQuery
from django.db.models import Q
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.utils import extend_schema, extend_schema_view
from drf_spectacular.views import SpectacularAPIView
from rest_framework import status
from rest_framework import status, permissions
from rest_framework.decorators import action
from rest_framework.exceptions import MethodNotAllowed, NotFound
from rest_framework.generics import get_object_or_404
from rest_framework_json_api.views import Response
from celery.result import AsyncResult
from api.base_views import BaseRLSViewSet, BaseViewSet
from api.filters import (
@@ -23,9 +23,12 @@ from api.filters import (
TaskFilter,
ResourceFilter,
)
from api.models import Provider, Scan, Task, Resource
from api.models import User, Provider, Scan, Task, Resource
from api.rls import Tenant
from api.v1.serializers import (
UserSerializer,
UserCreateSerializer,
UserUpdateSerializer,
ProviderSerializer,
ProviderCreateSerializer,
ProviderUpdateSerializer,
@@ -58,6 +61,85 @@ class SchemaView(SpectacularAPIView):
return super().get(request, *args, **kwargs)
@extend_schema_view(
create=extend_schema(
summary="Register a new user",
description="Create a new user account by providing the necessary registration details.",
),
partial_update=extend_schema(
summary="Update the current user's information",
description="Partially update the authenticated user's information.",
),
destroy=extend_schema(
summary="Delete the current user's account",
description="Remove the authenticated user's account from the system.",
),
me=extend_schema(
summary="Retrieve the current user's information",
description="Fetch detailed information about the authenticated user.",
),
)
@method_decorator(CACHE_DECORATOR, name="list")
class UserViewSet(BaseViewSet):
serializer_class = UserSerializer
http_method_names = ["get", "post", "patch", "delete"]
ordering = ["id"]
ordering_fields = []
def get_queryset(self):
return User.objects.filter(id=self.request.user.id)
def get_permissions(self):
if self.action == "create":
permission_classes = [permissions.AllowAny]
else:
permission_classes = self.permission_classes
return [permission() for permission in permission_classes]
def get_serializer_class(self):
if self.action == "create":
return UserCreateSerializer
elif self.action == "partial_update":
return UserUpdateSerializer
else:
return UserSerializer
@extend_schema(exclude=True)
def list(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
@extend_schema(exclude=True)
def retrieve(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
@action(detail=False, methods=["get"], url_name="me")
def me(self, request):
user = self.get_queryset().first()
serializer = UserSerializer(user, context=self.get_serializer_context())
return Response(
data=serializer.data,
status=status.HTTP_200_OK,
)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(
data=request.data, context=self.get_serializer_context()
)
serializer.is_valid(raise_exception=True)
user = serializer.save()
return Response(data=UserSerializer(user).data, status=status.HTTP_201_CREATED)
def partial_update(self, request, *args, **kwargs):
if kwargs["pk"] != str(request.user.id):
raise NotFound(detail="User was not found.")
return super().partial_update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
if kwargs["pk"] != str(request.user.id):
raise NotFound(detail="User was not found.")
return super().destroy(request, *args, **kwargs)
@extend_schema_view(
list=extend_schema(
summary="List all tenants",
+22
View File
@@ -0,0 +1,22 @@
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
class MaximumLengthValidator:
def __init__(self, max_length=72):
self.max_length = max_length
def validate(self, password, user=None):
if len(password) > self.max_length:
raise ValidationError(
_(
"This password is too long. It must contain no more than %(max_length)d characters."
),
code="password_too_long",
params={"max_length": self.max_length},
)
def get_help_text(self):
return _(
f"Your password must contain no more than {self.max_length} characters."
)
+25
View File
@@ -1,3 +1,5 @@
from datetime import timedelta
from config.custom_logging import LOGGING # noqa
from config.env import BASE_DIR, env # noqa
from config.settings.celery import * # noqa
@@ -110,12 +112,21 @@ DATABASE_ROUTERS = ["api.db_router.MainRouter"]
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_USER_MODEL = "api.User"
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {"min_length": 12},
},
{
"NAME": "api.validators.MaximumLengthValidator",
"OPTIONS": {
"max_length": 72,
},
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
@@ -125,6 +136,20 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(
minutes=env.int("DJANGO_ACCESS_TOKEN_LIFETIME", 30)
),
"REFRESH_TOKEN_LIFETIME": timedelta(
minutes=env.int("DJANGO_REFRESH_TOKEN_LIFETIME", 60 * 24)
),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": True,
"ALGORITHM": "HS256",
"SIGNING_KEY": env.str("DJANGO_TOKEN_SIGNING_KEY", "S3cret"),
"AUTH_HEADER_TYPES": ("Bearer",),
}
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
+41 -8
View File
@@ -1,24 +1,35 @@
import logging
import base64
import pytest
from django.conf import settings
from django.db import connections as django_connections
from rest_framework import status
from django.db import connections as django_connections, connection as django_connection
from django_celery_results.models import TaskResult
from api.models import Provider, Resource, ResourceTag, Scan, StateChoices, Task
from rest_framework.test import APIClient
from rest_framework import status
from api.models import User, Provider, Resource, ResourceTag, Scan, StateChoices, Task
from api.rls import Tenant
API_JSON_CONTENT_TYPE = "application/vnd.api+json"
# TODO Change to 401 when authentication/authorization is implemented
NO_TENANT_HTTP_STATUS = status.HTTP_403_FORBIDDEN
NO_TENANT_HTTP_STATUS = status.HTTP_401_UNAUTHORIZED
TEST_USER = "testing_user"
TEST_PASSWORD = "testing_psswd"
TEST_CREDENTIALS = f"{TEST_USER}:{TEST_PASSWORD}"
TEST_BASE64_CREDENTIALS = base64.b64encode(TEST_CREDENTIALS.encode()).decode()
@pytest.fixture(scope="module")
def enforce_test_user_db_connection(django_db_setup, django_db_blocker):
"""Ensure tests use the test user for database connections."""
test_user = "test"
test_password = "test"
with django_db_blocker.unblock():
test_user = "test"
test_password = "test"
with django_connection.cursor() as cursor:
# Required for testing purposes using APIClient
cursor.execute(f"GRANT ALL PRIVILEGES ON django_session TO {test_user};")
original_user = settings.DATABASES["default"]["USER"]
original_password = settings.DATABASES["default"]["PASSWORD"]
@@ -43,6 +54,28 @@ def disable_logging():
logging.disable(logging.CRITICAL)
@pytest.fixture(scope="session", autouse=True)
def create_test_user(django_db_setup, django_db_blocker):
with django_db_blocker.unblock():
user = User.objects.create_user(
username=TEST_USER, password=TEST_PASSWORD, email="testing@gmail.com"
)
return user
@pytest.fixture
def authenticated_client(create_test_user, client):
client.defaults["HTTP_AUTHORIZATION"] = f"Basic {TEST_BASE64_CREDENTIALS}"
return client
@pytest.fixture
def authenticated_api_client(create_test_user):
client = APIClient()
client.defaults["HTTP_AUTHORIZATION"] = f"Basic {TEST_BASE64_CREDENTIALS}"
return client
@pytest.fixture
def tenants_fixture():
tenant1 = Tenant.objects.create(