├── .gitignore ├── A ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── db.sqlite3 ├── manage.py └── tracking ├── __init__.py ├── admin.py ├── app_settings.py ├── apps.py ├── base_mixins.py ├── base_models.py ├── migrations ├── 0001_initial.py └── __init__.py ├── mixins.py ├── models.py ├── tests ├── __init__.py ├── test_mixins.py ├── test_models.py ├── urls.py └── views.py ├── urls.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | -------------------------------------------------------------------------------- /A/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirbigg/django-testing/12ff95ac5765917363324fa5017620f94347a423/A/__init__.py -------------------------------------------------------------------------------- /A/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for A project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /A/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for A project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.0. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.0/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-h&!eq@$bwhrpi^1s!y=@4xlh1kve&2a(u@-#a(cd3e%vwdhh^d' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'rest_framework', 41 | 'tracking.apps.TrackingConfig', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'A.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [BASE_DIR / 'templates'], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = 'A.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': 'django.db.backends.sqlite3', 81 | 'NAME': BASE_DIR / 'db.sqlite3', 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 92 | }, 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/4.0/topics/i18n/ 107 | 108 | LANGUAGE_CODE = 'en-us' 109 | 110 | TIME_ZONE = 'UTC' 111 | 112 | USE_I18N = True 113 | 114 | USE_TZ = True 115 | 116 | 117 | # Static files (CSS, JavaScript, Images) 118 | # https://docs.djangoproject.com/en/4.0/howto/static-files/ 119 | 120 | STATIC_URL = 'static/' 121 | 122 | # Default primary key field type 123 | # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field 124 | 125 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 126 | 127 | import sys 128 | import logging 129 | 130 | if len(sys.argv) > 1 and sys.argv[1] == 'test': 131 | logging.disable(logging.CRITICAL) -------------------------------------------------------------------------------- /A/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | 5 | urlpatterns = [ 6 | path('admin/', admin.site.urls), 7 | path('', include('tracking.urls', namespace='tracking')), 8 | ] 9 | -------------------------------------------------------------------------------- /A/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for A project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirbigg/django-testing/12ff95ac5765917363324fa5017620f94347a423/db.sqlite3 -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'A.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /tracking/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirbigg/django-testing/12ff95ac5765917363324fa5017620f94347a423/tracking/__init__.py -------------------------------------------------------------------------------- /tracking/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import APIRequestLog 3 | 4 | 5 | class APIRequestLogAdmin(admin.ModelAdmin): 6 | list_display = ( 7 | "id", 8 | "requested_at", 9 | "response_ms", 10 | "status_code", 11 | "user", 12 | "view_method", 13 | "path", 14 | "remote_addr", 15 | "host", 16 | "query_params", 17 | ) 18 | 19 | admin.site.register(APIRequestLog, APIRequestLogAdmin) 20 | -------------------------------------------------------------------------------- /tracking/app_settings.py: -------------------------------------------------------------------------------- 1 | class AppSettings: 2 | def __init__(self, prefix): 3 | self.prefix = prefix 4 | 5 | def _setting(self, name, dflt): 6 | from django.conf import settings 7 | 8 | return getattr(settings, self.prefix + name, dflt) 9 | 10 | @property 11 | def PATH_LENGTH(self): 12 | return self._setting('PATH_LENGTH', 200) 13 | 14 | @property 15 | def DECODE_REQUEST_BODY(self): 16 | return self._setting('DECODE_REQUEST_BODY', True) 17 | 18 | app_settings = AppSettings('DRF_TRACKING_') 19 | -------------------------------------------------------------------------------- /tracking/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TrackingConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'tracking' 7 | -------------------------------------------------------------------------------- /tracking/base_mixins.py: -------------------------------------------------------------------------------- 1 | from django.utils.timezone import now 2 | import ipaddress 3 | import traceback 4 | import logging 5 | import ast 6 | from .app_settings import app_settings 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class BaseLoggingMixin: 12 | logging_methods = '__all__' 13 | sensitive_fields = {} 14 | CLEANED_SUBSTITUTE = '*****************' 15 | 16 | def __init__(self, *args, **kwargs): 17 | assert isinstance(self.CLEANED_SUBSTITUTE, str), 'CLEANED_SUBSTITUTE must be a string.' 18 | super().__init__(*args, **kwargs) 19 | 20 | def initial(self, request, *args, **kwargs): 21 | self.log = {'requested_at':now()} 22 | if not getattr(self, 'decode_request_body', app_settings.DECODE_REQUEST_BODY): 23 | self.log['data'] = '' 24 | else: 25 | self.log['data'] = request.data 26 | return super().initial(request, *args, **kwargs) 27 | 28 | def handle_exception(self, exc): 29 | response = super().handle_exception(exc) 30 | self.log['errors'] = traceback.format_exc() 31 | return response 32 | 33 | def finalize_response(self, request, response, *args, **kwargs): 34 | response = super().finalize_response(request, response, *args, **kwargs) 35 | if self.should_log(request, response): 36 | user = self._get_user(request) 37 | if response.streaming: 38 | rendered_content = None 39 | elif hasattr(response, 'rendered_content'): 40 | rendered_content = response.rendered_content 41 | else: 42 | rendered_content = response.getvalue() 43 | self.log.update({ 44 | 'remote_addr': self._get_ip_address(request), 45 | 'view': self._get_view_name(request), 46 | 'view_method': self._get_view_method(request), 47 | 'path': self._get_path(request), 48 | 'host': request.get_host(), 49 | 'method': request.method, 50 | 'user': user, 51 | 'username_persistent': user.get_username() if user else 'Anonymous', 52 | 'response_ms': self._get_response_ms(), 53 | 'status_code': response.status_code, 54 | 'query_params': self._clean_data(request.query_params.dict()), 55 | 'response': self._clean_data(rendered_content) 56 | }) 57 | try: 58 | self.handle_log() 59 | except Exception: 60 | logger.exception('Logging API call raise exception!') 61 | return response 62 | 63 | def handle_log(self): 64 | raise NotImplementedError 65 | 66 | def _get_ip_address(self, request): 67 | ipaddr = request.META.get('HTTP_X_FORWARDED_FOR', None) 68 | if ipaddr: 69 | ipaddr = ipaddr.split(',')[0] 70 | else: 71 | ipaddr = request.META.get('REMOTE_ADDR', '').split(',')[0] 72 | 73 | possibles = (ipaddr.lstrip('[').split(']')[0], ipaddr.split(':')[0]) 74 | 75 | for addr in possibles: 76 | try: 77 | return str(ipaddress.ip_address(addr)) 78 | except: 79 | pass 80 | 81 | return ipaddr 82 | 83 | def _get_view_name(self, request): 84 | method = request.method.lower() 85 | try: 86 | attribute = getattr(self, method) 87 | return (type(attribute.__self__).__module__ + "." + type(attribute.__self__).__name__) 88 | except AttributeError: 89 | return None 90 | 91 | def _get_view_method(self, request): 92 | if hasattr(self, 'action'): 93 | return self.action or None 94 | return request.method.lower() 95 | 96 | def _get_path(self, request): 97 | return request.path[:app_settings.PATH_LENGTH] 98 | 99 | def _get_user(self, request): 100 | user = request.user 101 | if user.is_anonymous: 102 | return None 103 | return user 104 | 105 | def _get_response_ms(self): 106 | response_timedelta = now() - self.log['requested_at'] 107 | response_ms = int(response_timedelta.total_seconds() * 1000) 108 | return max(response_ms, 0) 109 | 110 | def should_log(self, request, response): 111 | return ( 112 | self.logging_methods == '__all__' or request.method in self.logging_methods 113 | ) 114 | 115 | def _clean_data(self, data): 116 | if isinstance(data, bytes): 117 | data = data.decode(errors='replace') 118 | 119 | if isinstance(data, list): 120 | return [self._clean_data(d) for d in data] 121 | 122 | if isinstance(data, dict): 123 | SENSITIVE_FIELDS = {'api', 'token', 'key', 'secret', 'password', 'signature'} 124 | if self.sensitive_fields: 125 | SENSITIVE_FIELDS = SENSITIVE_FIELDS | {field.lower() for field in self.sensitive_fields} 126 | 127 | for key, value in data.items(): 128 | try: 129 | value = ast.literal_eval(value) 130 | except (ValueError, SyntaxError): 131 | pass 132 | 133 | if isinstance(value, (list, dict)): 134 | data[key] = self._clean_data(value) 135 | if key.lower() in SENSITIVE_FIELDS: 136 | data[key] = self.CLEANED_SUBSTITUTE 137 | return data 138 | -------------------------------------------------------------------------------- /tracking/base_models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.conf import settings 3 | 4 | 5 | class BaseAPIRequestLog(models.Model): 6 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) 7 | username_persistent = models.CharField(max_length=getattr(settings, 'DRF_TRACKING_USERNAME_LENGTH', 200), null=True, blank=True) 8 | requested_at = models.DateTimeField(db_index=True) 9 | response_ms = models.PositiveIntegerField(default=0) 10 | path = models.CharField(max_length=getattr(settings, 'DRF_TACKING_PATH_LENGTH', 200), db_index=True, help_text='url path') 11 | view = models.CharField(max_length=getattr(settings, 'DRF_TRACKING_VIEW_LENGTH', 200), null=True, blank=True, db_index=True, help_text='method called by this endpoint') 12 | view_method = models.CharField(max_length=getattr(settings, 'DRF_TRACKING_VIEW_METHOD_LENGTH', 200), null=True, blank=True, db_index=True) 13 | remote_addr = models.GenericIPAddressField() 14 | host = models.URLField() 15 | method = models.CharField(max_length=10) 16 | query_params = models.TextField(null=True, blank=True) 17 | data = models.TextField(null=True, blank=True) 18 | response = models.TextField(null=True, blank=True) 19 | errors = models.TextField(null=True, blank=True) 20 | status_code = models.PositiveIntegerField(null=True, blank=True, db_index=True) 21 | 22 | class Meta: 23 | abstract = True 24 | verbose_name = 'API Request Log' 25 | 26 | def __str__(self): 27 | return '{} {}'.format(self.method, self.path) 28 | -------------------------------------------------------------------------------- /tracking/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.2 on 2022-11-01 08:05 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='APIRequestLog', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('username_persistent', models.CharField(blank=True, max_length=200, null=True)), 22 | ('requested_at', models.DateTimeField(db_index=True)), 23 | ('response_ms', models.PositiveIntegerField(default=0)), 24 | ('path', models.CharField(db_index=True, help_text='url path', max_length=200)), 25 | ('view', models.CharField(blank=True, db_index=True, help_text='method called by this endpoint', max_length=200, null=True)), 26 | ('view_method', models.CharField(blank=True, db_index=True, max_length=200, null=True)), 27 | ('remote_addr', models.GenericIPAddressField()), 28 | ('host', models.URLField()), 29 | ('method', models.CharField(max_length=10)), 30 | ('query_params', models.TextField(blank=True, null=True)), 31 | ('data', models.TextField(blank=True, null=True)), 32 | ('response', models.TextField(blank=True, null=True)), 33 | ('errors', models.TextField(blank=True, null=True)), 34 | ('status_code', models.PositiveIntegerField(blank=True, db_index=True, null=True)), 35 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 36 | ], 37 | options={ 38 | 'verbose_name': 'API Request Log', 39 | 'abstract': False, 40 | }, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /tracking/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirbigg/django-testing/12ff95ac5765917363324fa5017620f94347a423/tracking/migrations/__init__.py -------------------------------------------------------------------------------- /tracking/mixins.py: -------------------------------------------------------------------------------- 1 | from .base_mixins import BaseLoggingMixin 2 | from .models import APIRequestLog 3 | 4 | class LoggingMixin(BaseLoggingMixin): 5 | def handle_log(self): 6 | APIRequestLog(**self.log).save() 7 | -------------------------------------------------------------------------------- /tracking/models.py: -------------------------------------------------------------------------------- 1 | from .base_models import BaseAPIRequestLog 2 | 3 | 4 | class APIRequestLog(BaseAPIRequestLog): 5 | pass 6 | -------------------------------------------------------------------------------- /tracking/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirbigg/django-testing/12ff95ac5765917363324fa5017620f94347a423/tracking/tests/__init__.py -------------------------------------------------------------------------------- /tracking/tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import datetime 3 | 4 | from rest_framework.test import APITestCase, APIRequestFactory 5 | from tracking.models import APIRequestLog 6 | from tracking.mixins import BaseLoggingMixin 7 | from django.test import override_settings 8 | from django.contrib.auth.models import User 9 | from .views import MockLoggingView 10 | from unittest import mock 11 | 12 | 13 | @override_settings(ROOT_URLCONF='tracking.tests.urls') 14 | class TestLoggingMixin(APITestCase): 15 | def test_nologging_no_log_created(self): 16 | self.client.get('/no-logging/') 17 | self.assertEqual(APIRequestLog.objects.all().count(), 0) 18 | 19 | def test_logging_creates_log(self): 20 | self.client.get('/logging/') 21 | self.assertEqual(APIRequestLog.objects.all().count(), 1) 22 | 23 | def test_log_path(self): 24 | self.client.get('/logging/') 25 | log = APIRequestLog.objects.first() 26 | self.assertEqual(log.path, '/logging/') 27 | 28 | def test_log_ip_remote(self): 29 | request = APIRequestFactory().get('/logging/') 30 | request.META['REMOTE_ADDR'] = '127.0.0.9' 31 | MockLoggingView.as_view()(request).render() 32 | log = APIRequestLog.objects.first() 33 | self.assertEqual(log.remote_addr, '127.0.0.9') 34 | 35 | def test_log_ip_remote_list(self): 36 | request = APIRequestFactory().get('/logging/') 37 | request.META['REMOTE_ADDR'] = '127.0.0.9, 128.1.1.9' 38 | MockLoggingView.as_view()(request).render() 39 | log = APIRequestLog.objects.first() 40 | self.assertEqual(log.remote_addr, '127.0.0.9') 41 | 42 | def test_log_ip_remote_v4_with_port(self): 43 | request = APIRequestFactory().get('/logging/') 44 | request.META['REMOTE_ADDR'] = '127.0.0.9:1234' 45 | MockLoggingView.as_view()(request).render() 46 | log = APIRequestLog.objects.first() 47 | self.assertEqual(log.remote_addr, '127.0.0.9') 48 | 49 | def test_log_ip_remote_v6(self): 50 | request = APIRequestFactory().get('/logging/') 51 | request.META['REMOTE_ADDR'] = '2001:0db8:85a3:0000:0000:8a2e:0370:7334' 52 | MockLoggingView.as_view()(request).render() 53 | log = APIRequestLog.objects.first() 54 | self.assertEqual(log.remote_addr, '2001:db8:85a3::8a2e:370:7334') 55 | 56 | def test_log_ip_remote_v6_loopback(self): 57 | request = APIRequestFactory().get('/logging/') 58 | request.META['REMOTE_ADDR'] = '::1' 59 | MockLoggingView.as_view()(request).render() 60 | log = APIRequestLog.objects.first() 61 | self.assertEqual(log.remote_addr, '::1') 62 | 63 | def test_log_ip_remote_v6_with_port(self): 64 | request = APIRequestFactory().get('/logging/') 65 | request.META['REMOTE_ADDR'] = '[::1]:1234' 66 | MockLoggingView.as_view()(request).render() 67 | log = APIRequestLog.objects.first() 68 | self.assertEqual(log.remote_addr, '::1') 69 | 70 | def test_log_ip_xforwarded(self): 71 | request = APIRequestFactory().get('/logging/') 72 | request.META['HTTP_X_FORWARDED_FOR'] = '127.0.0.8' 73 | MockLoggingView.as_view()(request).render() 74 | log = APIRequestLog.objects.first() 75 | self.assertEqual(log.remote_addr, '127.0.0.8') 76 | 77 | def test_log_ip_xforwarded_list(self): 78 | request = APIRequestFactory().get('/logging/') 79 | request.META['HTTP_X_FORWARDED_FOR'] = '127.0.0.8, 128.1.1.9' 80 | MockLoggingView.as_view()(request).render() 81 | log = APIRequestLog.objects.first() 82 | self.assertEqual(log.remote_addr, '127.0.0.8') 83 | 84 | def test_log_host(self): 85 | self.client.get('/logging/') 86 | log = APIRequestLog.objects.first() 87 | self.assertEqual(log.host, 'testserver') 88 | 89 | def test_log_method(self): 90 | self.client.get('/logging/') 91 | log = APIRequestLog.objects.first() 92 | self.assertEqual(log.method, 'GET') 93 | 94 | def test_log_status(self): 95 | self.client.get('/logging/') 96 | log = APIRequestLog.objects.first() 97 | self.assertEqual(log.status_code, 200) 98 | 99 | def test_logging_explicit(self): 100 | self.client.get('/explicit-logging/') 101 | self.client.post('/explicit-logging/') 102 | self.assertEqual(APIRequestLog.objects.all().count(), 1) 103 | 104 | def test_custom_check_logging(self): 105 | self.client.get('/custom-check-logging/') 106 | self.client.post('/custom-check-logging/') 107 | self.assertEqual(APIRequestLog.objects.all().count(), 1) 108 | 109 | def test_log_anon_user(self): 110 | self.client.get('/logging/') 111 | log = APIRequestLog.objects.first() 112 | self.assertEqual(log.user, None) 113 | 114 | def test_log_auth_user(self): 115 | User.objects.create_user(username='myname', password='secret') 116 | user = User.objects.get(username='myname') 117 | 118 | self.client.login(username='myname', password='secret') 119 | self.client.get('/session-auth-logging/') 120 | 121 | log = APIRequestLog.objects.first() 122 | self.assertEqual(log.user, user) 123 | 124 | def test_log_params(self): 125 | self.client.get('/logging/', {'p1':'a', 'another':'2'}) 126 | log = APIRequestLog.objects.first() 127 | self.assertEqual(ast.literal_eval(log.query_params), {'p1':'a', 'another':'2'}) 128 | 129 | def test_log_params_cleaned_from_personal_list(self): 130 | self.client.get('/sensitive-fields-logging/', {'api':'1234', 'capitalized':'12345', 'my_field':'123456'}) 131 | log = APIRequestLog.objects.first() 132 | self.assertEqual(ast.literal_eval(log.query_params), { 133 | 'api': BaseLoggingMixin.CLEANED_SUBSTITUTE, 134 | 'capitalized': '12345', 135 | 'my_field': BaseLoggingMixin.CLEANED_SUBSTITUTE 136 | }) 137 | 138 | def test_invalid_cleaned_substitute_fails(self): 139 | with self.assertRaises(AssertionError): 140 | self.client.get('/invalid-cleaned-substitute-logging/') 141 | 142 | @mock.patch('tracking.models.APIRequestLog.save') 143 | def test_log_doesnt_prevent_api_call_if_log_save_fails(self, mock_save): 144 | mock_save.side_effect = Exception('db failure') 145 | response = self.client.get('/logging/') 146 | self.assertEqual(response.status_code, 200) 147 | self.assertEqual(APIRequestLog.objects.all().count(), 0) 148 | 149 | @override_settings(USE_TZ = False) 150 | @mock.patch('tracking.base_mixins.now') 151 | def test_log_doesnt_fail_with_negative_response_ms(self, mock_now): 152 | mock_now.side_effect = [ 153 | datetime.datetime(2017, 12, 1, 10, 0, 10), 154 | datetime.datetime(2017, 12, 1, 10, 0, 0) 155 | ] 156 | self.client.get('/logging/') 157 | log = APIRequestLog.objects.first() 158 | self.assertEqual(log.response_ms, 0) 159 | -------------------------------------------------------------------------------- /tracking/tests/test_models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirbigg/django-testing/12ff95ac5765917363324fa5017620f94347a423/tracking/tests/test_models.py -------------------------------------------------------------------------------- /tracking/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from . import views as test_views 3 | 4 | 5 | urlpatterns = [ 6 | path('no-logging/', test_views.MockNoLoggingView.as_view()), 7 | path('logging/', test_views.MockLoggingView.as_view()), 8 | path('explicit-logging/', test_views.MockExplicitLoggingView.as_view()), 9 | path('custom-check-logging/', test_views.MockCustomCheckLoggingView.as_view()), 10 | path('session-auth-logging/', test_views.MockSessionAuthLoggingView.as_view()), 11 | path('sensitive-fields-logging/', test_views.MockSensitiveFieldsLoggingView.as_view()), 12 | path('invalid-cleaned-substitute-logging/', test_views.MockInvalidCleanedSubstituteLoggingView.as_view()), 13 | ] -------------------------------------------------------------------------------- /tracking/tests/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.views import APIView 2 | from rest_framework.response import Response 3 | from tracking.mixins import LoggingMixin 4 | from rest_framework.permissions import IsAuthenticated 5 | from rest_framework.authentication import SessionAuthentication 6 | 7 | 8 | class MockNoLoggingView(APIView): 9 | def get(self, request): 10 | return Response('no logging') 11 | 12 | 13 | class MockLoggingView(LoggingMixin, APIView): 14 | def get(self, request): 15 | return Response('with logging') 16 | 17 | 18 | class MockExplicitLoggingView(LoggingMixin, APIView): 19 | logging_methods = ['POST'] 20 | 21 | def get(self, request): 22 | return Response('no logging') 23 | 24 | def post(self, request): 25 | return Response('with logging') 26 | 27 | 28 | class MockCustomCheckLoggingView(LoggingMixin, APIView): 29 | def should_log(self, request, response): 30 | return 'log' in response.data 31 | 32 | def get(self, request): 33 | return Response('with logging') 34 | 35 | def post(self, request): 36 | return Response('no recording') 37 | 38 | 39 | class MockSessionAuthLoggingView(LoggingMixin, APIView): 40 | authentication_classes = (SessionAuthentication,) 41 | permission_classes = (IsAuthenticated,) 42 | 43 | def get(self, request): 44 | return Response('with session auth logging') 45 | 46 | 47 | class MockSensitiveFieldsLoggingView(LoggingMixin, APIView): 48 | sensitive_fields = {'mY_fiEld'} 49 | 50 | def get(self, request): 51 | return Response('with logging') 52 | 53 | 54 | class MockInvalidCleanedSubstituteLoggingView(LoggingMixin, APIView): 55 | CLEANED_SUBSTITUTE = 1 56 | -------------------------------------------------------------------------------- /tracking/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from . import views 3 | 4 | 5 | app_name = 'tracking' 6 | urlpatterns = [ 7 | path('', views.Home.as_view()), 8 | ] -------------------------------------------------------------------------------- /tracking/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.views import APIView 2 | from rest_framework.response import Response 3 | from .mixins import LoggingMixin 4 | 5 | 6 | class Home(LoggingMixin, APIView): 7 | def post(self, request): 8 | return Response('hello') 9 | --------------------------------------------------------------------------------