updated tests.py again

This commit is contained in:
Raymond Jessop 2025-04-18 20:07:09 -05:00
parent a159b0e054
commit 42fd9353f5
6 changed files with 40 additions and 175 deletions

View File

@ -1,143 +0,0 @@
# Generated by Django 5.2 on 2025-04-18 22:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('sites', '0002_alter_domain_unique'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BlackListedDomains',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('domain', models.CharField(db_index=True, max_length=255, unique=True)),
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)),
],
options={
'verbose_name': 'Blacklisted Domain',
'verbose_name_plural': 'Blacklisted Domains',
},
),
migrations.CreateModel(
name='SESStat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(db_index=True, unique=True)),
('delivery_attempts', models.PositiveIntegerField()),
('bounces', models.PositiveIntegerField()),
('complaints', models.PositiveIntegerField()),
('rejects', models.PositiveIntegerField()),
],
options={
'verbose_name': 'SES Statistic',
'verbose_name_plural': 'SES Statistics',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='AwsSesSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_key', models.CharField(blank=True, max_length=255, null=True)),
('secret_key', models.CharField(blank=True, max_length=255, null=True)),
('region_name', models.CharField(blank=True, max_length=255, null=True)),
('region_endpoint', models.CharField(blank=True, max_length=255, null=True)),
('site', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='awssessettings', to='sites.site')),
],
options={
'verbose_name': 'AWS SES Settings',
'verbose_name_plural': 'AWS SES Settings',
},
),
migrations.CreateModel(
name='AwsSesUserAddon',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('unsubscribe', models.BooleanField(default=False)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='aws_ses', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'AWS SES User Addon',
'verbose_name_plural': 'AWS SES User Addons',
},
),
migrations.CreateModel(
name='BounceRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)),
('email', models.EmailField(db_index=True, max_length=254)),
('bounce_type', models.CharField(blank=True, max_length=255, null=True)),
('bounce_sub_type', models.CharField(blank=True, max_length=255, null=True)),
('reporting_mta', models.CharField(blank=True, max_length=255, null=True)),
('status', models.CharField(blank=True, max_length=255, null=True)),
('action', models.CharField(blank=True, max_length=255, null=True)),
('feedback_id', models.TextField(blank=True, null=True)),
('diagnostic_code', models.CharField(blank=True, max_length=2048, null=True)),
('cleared', models.BooleanField(default=False)),
],
options={
'verbose_name': 'Bounce Record',
'verbose_name_plural': 'Bounce Records',
'indexes': [models.Index(fields=['email', 'timestamp'], name='django_aws__email_6bb737_idx')],
},
),
migrations.CreateModel(
name='ComplaintRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)),
('email', models.EmailField(db_index=True, max_length=254)),
('sub_type', models.CharField(blank=True, max_length=255, null=True)),
('feedback_id', models.TextField(blank=True, null=True)),
('feedback_type', models.CharField(blank=True, max_length=255, null=True)),
],
options={
'verbose_name': 'Complaint Record',
'verbose_name_plural': 'Complaint Records',
'indexes': [models.Index(fields=['email', 'timestamp'], name='django_aws__email_36ac11_idx')],
},
),
migrations.CreateModel(
name='SendRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)),
('source', models.EmailField(max_length=254)),
('destination', models.EmailField(db_index=True, max_length=254)),
('subject', models.TextField(blank=True, max_length=998, null=True)),
('message_id', models.TextField(blank=True, max_length=255, null=True)),
('aws_process_time', models.IntegerField(default=0)),
('smtp_response', models.CharField(blank=True, max_length=255, null=True)),
('status', models.CharField(blank=True, choices=[('Send', 'Send'), ('Delivery', 'Delivery')], max_length=20, null=True)),
],
options={
'verbose_name': 'Send Record',
'verbose_name_plural': 'Send Records',
'indexes': [models.Index(fields=['destination', 'timestamp'], name='django_aws__destina_e0db33_idx')],
},
),
migrations.CreateModel(
name='UnknownRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)),
('event_type', models.CharField(blank=True, max_length=255, null=True)),
('aws_data', models.TextField(blank=True, null=True)),
],
options={
'verbose_name': 'Unknown Record',
'verbose_name_plural': 'Unknown Records',
'indexes': [models.Index(fields=['event_type', 'timestamp'], name='django_aws__event_t_ca3517_idx')],
},
),
]

View File

@ -11,6 +11,7 @@ from django.dispatch import receiver
from django.urls import reverse
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.core.signing import Signer, BadSignature
User = get_user_model()
logger = logging.getLogger(__name__)
@ -67,25 +68,34 @@ class AwsSesUserAddon(models.Model):
email_field = self.user.get_email_field_name()
return getattr(self.user, email_field, '') or ''
def unsubscribe_hash_generator(self):
"""Generate a secure hash for unsubscribe verification."""
email = self.get_email()
message = f"{self.user.pk}{email}".encode()
return hmac.new(
settings.SECRET_KEY.encode(),
message,
hashlib.sha256
).hexdigest()
def generate_unsubscribe_token(self):
"""Generate a signed token for unsubscribe verification."""
signer = Signer()
value = f"{self.user.pk}:{self.get_email()}"
return signer.sign(value)
def check_unsubscribe_hash(self, hash_value):
"""Verify an unsubscribe hash."""
return hmac.compare_digest(self.unsubscribe_hash_generator(), hash_value)
def verify_unsubscribe_token(self, token):
"""Verify a signed unsubscribe token.
Args:
token (str): The signed token to verify.
Returns:
bool: True if the token is valid, False otherwise.
"""
signer = Signer()
try:
value = signer.unsign(token)
pk, email = value.split(':')
return str(self.user.pk) == pk and self.get_email() == email
except BadSignature:
return False
def unsubscribe_url_generator(self):
"""Generate a secure unsubscribe URL."""
uuid = urlsafe_base64_encode(force_bytes(str(self.user.pk)))
hash_value = self.unsubscribe_hash_generator()
return reverse('django_aws_ses:aws_ses_unsubscribe', kwargs={"uuid": uuid, "hash": hash_value})
"""Generate a secure unsubscribe URL with a signed token."""
uuid = urlsafe_base64_encode(str(self.user.pk).encode())
token = self.generate_unsubscribe_token()
return reverse('django_aws_ses:aws_ses_unsubscribe', kwargs={"uuid": uuid, "token": token})
class SESStat(models.Model):
"""Daily statistics for AWS SES email sending."""

View File

@ -7,7 +7,6 @@ from django.utils.http import urlsafe_base64_encode
from django.utils.encoding import force_bytes
from unittest.mock import patch, Mock
import json
import uuid
from .backends import SESBackend
from .views import handle_bounce, HandleUnsubscribe
@ -131,8 +130,8 @@ class DjangoAwsSesTests(TestCase):
def test_unsubscribe_confirmation(self):
"""Test unsubscribe confirmation page and action."""
uuid_b64 = urlsafe_base64_encode(str(self.user.pk).encode())
hash_value = self.ses_addon.unsubscribe_hash_generator()
url = reverse('django_aws_ses:aws_ses_unsubscribe', kwargs={'uuid': uuid_b64, 'hash': hash_value})
token = self.ses_addon.generate_unsubscribe_token()
url = reverse('django_aws_ses:aws_ses_unsubscribe', kwargs={'uuid': uuid_b64, 'token': token})
# Test GET (confirmation page)
response = self.client.get(url)
@ -141,7 +140,7 @@ class DjangoAwsSesTests(TestCase):
self.assertFalse(self.ses_addon.unsubscribe)
# Test POST (unsubscribe)
response = self.client.post(url, {'action': 'unsubscribe'}, follow=True)
response = self.client.post(url, {'action': 'unsubscribe'})
self.ses_addon.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'You have been unsubscribed')
@ -152,10 +151,10 @@ class DjangoAwsSesTests(TestCase):
self.ses_addon.unsubscribe = True
self.ses_addon.save()
uuid_b64 = urlsafe_base64_encode(str(self.user.pk).encode())
hash_value = self.ses_addon.unsubscribe_hash_generator()
url = reverse('django_aws_ses:aws_ses_unsubscribe', kwargs={'uuid': uuid_b64, 'hash': hash_value})
token = self.ses_addon.generate_unsubscribe_token()
url = reverse('django_aws_ses:aws_ses_unsubscribe', kwargs={'uuid': uuid_b64, 'token': token})
response = self.client.post(url, {'action': 'resubscribe'}, follow=True)
response = self.client.post(url, {'action': 'resubscribe'})
self.ses_addon.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'You have been re-subscribed')

View File

@ -12,5 +12,5 @@ app_name = "django_aws_ses"
urlpatterns = [
path('status/', dashboard, name='aws_ses_status'),
path('bounce/', csrf_exempt(handle_bounce),name='aws_ses_bounce'),
path('unsubscribe/<str:uuid>/<str:hash>/', HandleUnsubscribe.as_view(), name='aws_ses_unsubscribe')
path('unsubscribe/<str:uuid>/<str:token>/', HandleUnsubscribe.as_view(), name='aws_ses_unsubscribe')
]

View File

@ -13,7 +13,7 @@ from django.utils.encoding import force_bytes, force_str
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from django.views.decorators.http import require_POST
from django.views.generic.base import TemplateView
from django.views.decorators.csrf import csrf_protect
from django.core.signing import Signer, BadSignature
from . import settings
from . import signals
@ -308,7 +308,7 @@ class HandleUnsubscribe(TemplateView):
def get(self, request, *args, **kwargs):
"""Show confirmation page for unsubscribe or re-subscribe."""
uuid = self.kwargs['uuid']
hash_value = self.kwargs['hash']
token = self.kwargs['token']
try:
uuid = force_str(urlsafe_base64_decode(uuid))
@ -322,18 +322,17 @@ class HandleUnsubscribe(TemplateView):
except AwsSesUserAddon.DoesNotExist:
ses = AwsSesUserAddon.objects.create(user=user)
if not user or not ses.check_unsubscribe_hash(hash_value):
logger.warning(f"Invalid unsubscribe hash for user: {user.email}")
if not user or not ses.verify_unsubscribe_token(token):
logger.warning(f"Invalid token for user: {user.email}")
return redirect(settings.HOME_URL)
self.user_email = user.email
return super().get(request, *args, **kwargs)
@csrf_protect
def post(self, request, *args, **kwargs):
"""Process unsubscribe or re-subscribe request."""
uuid = self.kwargs['uuid']
hash_value = self.kwargs['hash']
token = self.kwargs['token']
action = request.POST.get('action')
try:
@ -348,8 +347,8 @@ class HandleUnsubscribe(TemplateView):
except AwsSesUserAddon.DoesNotExist:
ses = AwsSesUserAddon.objects.create(user=user)
if not user or not ses.check_unsubscribe_hash(hash_value):
logger.warning(f"Invalid unsubscribe hash for user: {user.email}")
if not user or not ses.verify_unsubscribe_token(token):
logger.warning(f"Invalid token for user: {user.email}")
return redirect(settings.HOME_URL)
if action == 'unsubscribe':