Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ jobs:
run: |
docker compose exec accounts bash -c 'uv run ruff check'

- name: Accounts format check
run: |
docker compose exec accounts bash -c 'uv run ruff format --check'

- name: Test with pytest
run: |
docker compose exec accounts bash -c 'uv run coverage run --source="thunderbird_accounts" manage.py test thunderbird_accounts'
Expand Down
38 changes: 14 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,43 +272,33 @@ Feature flags are stored in `localStorage` and read at runtime to toggle UI beha
| --- | ------ | ----------- |
| `feature.show-connect-now` | `true` | Shows the "Connect Now" action card on the desktop panel, which launches Thunderbird Desktop via a custom protocol URL. |

## Running tests
## Running tests and linting checks

Make sure that the containers are already running.

To run all tests:
To run the tests:

```shell
./run-tests.sh thunderbird_accounts
```

this will execute the following command for you:

```shell
docker compose exec accounts uv run python manage.py test thunderbird_accounts
```
# run-tests will execute the following command:
# docker compose exec accounts uv run python manage.py test thunderbird_accounts

To run tests for a specific module:
# Run all tests:
./run-tests.sh thunderbird_accounts

```shell
# Run a specific test:
./run-tests.sh thunderbird_accounts.mail.tests
```

Additionally, you can run tests with coverage to help you identify functions that aren't covered by functional or unit tests.

To do so run the following command:

```shell
# Run tests with coverage. Report is storged in `./coverage/htmlcov`
./runs-tests-and-generate-coverage.sh thunderbird_accounts
```
Please see the [E2E tests README](./test/e2e/README.md) for how to run E2E tests.

The data for coverage is stored in the `coverage` folder (which is volume mounted.) It generates a detailed html report and a text report to display in-console.
Aside from the tests, you should run the following commands to check locally:

The html report is stored in `coverage/htmlcov`.

## Running the E2E tests

Please see the [E2E tests README](./test/e2e/README.md).
```shell
uv run ruff check
uv run ruff format --check
```


## Accessing the Flower Web Interface
Expand Down
3 changes: 2 additions & 1 deletion src/thunderbird_accounts/authentication/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ class SignUpThrottle(UserRateThrottle):
class CanISignUpThrottle(UserRateThrottle):
scope = 'can_i_sign_up'


class CanISignUpResponses(StrEnum):
WAIT_LIST = 'wait-list'
LOGIN = 'login'
SIGN_UP = 'sign-up'


@api_view(['POST'])
def get_user_profile(request: Request):
if not request.user:
Expand All @@ -58,7 +60,6 @@ def can_i_sign_up(request: Request):
elif is_in_allow_list:
go_to = CanISignUpResponses.SIGN_UP


return Response({'go_to': go_to})


Expand Down
1 change: 1 addition & 0 deletions src/thunderbird_accounts/authentication/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class User(AbstractUser, BaseModel):
:param avatar_url: Avatar URL from oidc profile
:param timezone: The user's timezone
"""

USERNAME_MIN_LENGTH = 3
USERNAME_MAX_LENGTH = 150

Expand Down
2 changes: 1 addition & 1 deletion src/thunderbird_accounts/authentication/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class UserCacheSerializer(serializers.ModelSerializer):
"""
Version 1
Formats a user model to the json format below:

.. code-block:: json

{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,4 +535,3 @@ def test_backfills_only_eligible_users_when_multiple_selected(self, mock_keycloa
self.assertEqual(has_recovery.recovery_email, 'existing@example.com')
self.assertIsNone(missing_oidc.recovery_email)
mock_keycloak.get_user.assert_called_once_with('oidc-missing-recovery')

2 changes: 1 addition & 1 deletion src/thunderbird_accounts/authentication/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ def setUp(self):
self.wait_list = [
'348b8787-ddf0-4ab9-931e-db388f8770c0@example.com',
'153ff958-dabb-4afc-94ab-3b1b3b5ff051@example.com',
'23fd4ed0-85d9-462f-b9cb+82a72f51c477@example.com' # Subaddressing test
'23fd4ed0-85d9-462f-b9cb+82a72f51c477@example.com', # Subaddressing test
]
self.existing_user = User.objects.create(
recovery_email='e9fc8f36-8d9c-4e8c-9af2-2a7e2901b036@example.com',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,4 @@ def test_filter_users_by_claims_with_fallback_with_duplicates(self):
with self.assertRaises(User.DoesNotExist):
user.refresh_from_db()

settings.OIDC_FALLBACK_MATCH_BY_EMAIL = _original_setting
settings.OIDC_FALLBACK_MATCH_BY_EMAIL = _original_setting
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@

from django.test import TestCase, override_settings

from thunderbird_accounts.authentication.models import AllowListEntry, User
from thunderbird_accounts.authentication.reserved import is_reserved, servers, support
from thunderbird_accounts.authentication.utils import is_email_in_allow_list


class IsReservedUnitTests(TestCase):
def test_brand_names(self):
for brand in ['thunderbird', 'thundermail', 'tbpro', 'mozilla', 'firefox', 'help', 'support', 'mzla']:
Expand Down
3 changes: 1 addition & 2 deletions src/thunderbird_accounts/authentication/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ def test_bulk_import_rejects_short_dsc_discount_ids(self):
messages = [str(message) for message in get_messages(response.wsgi_request)]
self.assertTrue(
any(
message.startswith('dsc_123 is not a valid discount id. Use the full Paddle id')
for message in messages
message.startswith('dsc_123 is not a valid discount id. Use the full Paddle id') for message in messages
)
)
28 changes: 21 additions & 7 deletions src/thunderbird_accounts/core/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,10 +562,14 @@ def test_needs_tos_acceptance_false_when_all_docs_accepted(self):
)

LegalDocumentResponse.objects.create(
user=self.user, document=tos, action=LegalDocumentResponse.Action.ACCEPTED,
user=self.user,
document=tos,
action=LegalDocumentResponse.Action.ACCEPTED,
)
LegalDocumentResponse.objects.create(
user=self.user, document=privacy, action=LegalDocumentResponse.Action.ACCEPTED,
user=self.user,
document=privacy,
action=LegalDocumentResponse.Action.ACCEPTED,
)

response = self._login_and_get_home()
Expand All @@ -587,7 +591,9 @@ def test_needs_tos_acceptance_true_when_partially_accepted(self):
)

LegalDocumentResponse.objects.create(
user=self.user, document=tos, action=LegalDocumentResponse.Action.ACCEPTED,
user=self.user,
document=tos,
action=LegalDocumentResponse.Action.ACCEPTED,
)

response = self._login_and_get_home()
Expand All @@ -603,7 +609,9 @@ def test_needs_tos_acceptance_true_when_only_declined(self):
)

LegalDocumentResponse.objects.create(
user=self.user, document=tos, action=LegalDocumentResponse.Action.DECLINED,
user=self.user,
document=tos,
action=LegalDocumentResponse.Action.DECLINED,
)

response = self._login_and_get_home()
Expand All @@ -627,13 +635,19 @@ def test_needs_tos_acceptance_false_with_duplicate_acceptances(self):

# Force duplicate responses
LegalDocumentResponse.objects.create(
user=self.user, document=tos, action=LegalDocumentResponse.Action.ACCEPTED,
user=self.user,
document=tos,
action=LegalDocumentResponse.Action.ACCEPTED,
)
LegalDocumentResponse.objects.create(
user=self.user, document=tos, action=LegalDocumentResponse.Action.ACCEPTED,
user=self.user,
document=tos,
action=LegalDocumentResponse.Action.ACCEPTED,
)
LegalDocumentResponse.objects.create(
user=self.user, document=privacy, action=LegalDocumentResponse.Action.ACCEPTED,
user=self.user,
document=privacy,
action=LegalDocumentResponse.Action.ACCEPTED,
)

response = self._login_and_get_home()
Expand Down
5 changes: 2 additions & 3 deletions src/thunderbird_accounts/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@

from django.conf import settings


def get_feature_flags() -> dict:
"""Return a list of feature flags that the server has enabled via env or db.
This is for the frontend, but the frontend might have its own list of features
so we prefix everything with `be_` (for backend) to avoid collision."""
return {
'be_allowList': settings.USE_ALLOW_LIST
}
return {'be_allowList': settings.USE_ALLOW_LIST}


def get_absolute_url(path: str) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ class Command(BaseCommand):
help = 'Converts legal document markdown files under assets/legal/ to HTML.'

def handle(self, *args, **options):
legal_app_path = apps.get_app_config("legal").path
legal_app_path = apps.get_app_config('legal').path

legal_dir_in = Path(settings.ASSETS_ROOT, 'legal')
legal_dir_out = Path(legal_app_path, 'templates')
legal_dir_out = Path(legal_app_path, 'templates')

if not legal_dir_in.exists() or not legal_dir_out.exists():
self.stderr.write(
Expand All @@ -48,7 +48,7 @@ def handle(self, *args, **options):
for md_file in md_files:
file_name = md_file.stem
# Retrieve the last two folders (as of writing this it's 'privacy' -or- 'tos' and 'v1.0')
type, version = md_file.resolve().parent.parts[-2:]
type, version = md_file.resolve().parent.parts[-2:]
html_file = Path(legal_dir_out, type, version, f'{file_name}.html')
html_file.parent.mkdir(parents=True, exist_ok=True)
md.reset()
Expand Down
8 changes: 6 additions & 2 deletions src/thunderbird_accounts/legal/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ class DocumentType(models.TextChoices):
PRIVACY = 'privacy', _('Privacy Policy')

document_type = models.CharField(max_length=20, choices=DocumentType.choices)
version = models.CharField(max_length=50, help_text=_('Version number of the document, e.g. "1.0"'),)
version = models.CharField(
max_length=50,
help_text=_('Version number of the document, e.g. "1.0"'),
)
is_current = models.BooleanField(default=False)
content_path = models.CharField(
max_length=255,
Expand All @@ -34,7 +37,8 @@ def __str__(self):
def save(self, *args, **kwargs):
if self.is_current:
LegalDocument.objects.filter(
document_type=self.document_type, is_current=True,
document_type=self.document_type,
is_current=True,
).exclude(pk=self.pk).update(is_current=False)
super().save(*args, **kwargs)

Expand Down
16 changes: 6 additions & 10 deletions src/thunderbird_accounts/legal/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ def setUp(self):

class ReadLegalContentTestCase(TestCase):
def setUp(self):
self.legal_app_path = apps.get_app_config("legal").path
self.legal_app_path = apps.get_app_config('legal').path
self.version = 'v999.0'

self.content_dir = Path(self.legal_app_path, 'templates', 'tos', self.version).resolve()
self.content_dir.mkdir(parents=True, exist_ok=True)

def tearDown(self):
import shutil

if self.content_dir.exists():
shutil.rmtree(self.content_dir)

Expand Down Expand Up @@ -283,9 +284,7 @@ def test_duplicate_accept_does_not_create_new_responses(self):
self.assertEqual(len(data2['responses']), 0)

self.assertEqual(
LegalDocumentResponse.objects.filter(
user=self.user, action=LegalDocumentResponse.Action.ACCEPTED
).count(),
LegalDocumentResponse.objects.filter(user=self.user, action=LegalDocumentResponse.Action.ACCEPTED).count(),
2,
)

Expand Down Expand Up @@ -313,9 +312,7 @@ def test_decline_still_creates_duplicate_responses(self):
self.assertEqual(response2.status_code, 200)

self.assertEqual(
LegalDocumentResponse.objects.filter(
user=self.user, action=LegalDocumentResponse.Action.DECLINED
).count(),
LegalDocumentResponse.objects.filter(user=self.user, action=LegalDocumentResponse.Action.DECLINED).count(),
2,
)

Expand Down Expand Up @@ -383,6 +380,7 @@ def test_decline_with_subscription_sets_warning_message(self):
self.assertEqual(response.status_code, 200)

from django.contrib.messages import get_messages

messages = list(get_messages(response.wsgi_request))
self.assertEqual(len(messages), 1)
self.assertIn('contact support', str(messages[0]).lower())
Expand Down Expand Up @@ -470,9 +468,7 @@ def test_decline_response_is_cascade_deleted_with_user(self, mock_kc_request, mo
with self.assertRaises(User.DoesNotExist):
self.user.refresh_from_db()

self.assertFalse(
LegalDocumentResponse.objects.filter(user_id=self.user.pk).exists()
)
self.assertFalse(LegalDocumentResponse.objects.filter(user_id=self.user.pk).exists())

@patch('thunderbird_accounts.authentication.utils.sentry_sdk')
def test_still_deletes_db_user_on_keycloak_failure(self, mock_sentry, mock_kc_request, mock_delete_principal):
Expand Down
4 changes: 1 addition & 3 deletions src/thunderbird_accounts/legal/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ def _read_legal_content(content_path: str, locale: str) -> str:
doc_path = Path(legal_templates_path, content_path, f'{locale}.html').resolve()
default_path = Path(legal_templates_path, content_path, f'{settings.DEFAULT_LANGUAGE}.html').resolve()

if not doc_path.is_relative_to(legal_templates_path) or not default_path.is_relative_to(
legal_templates_path
):
if not doc_path.is_relative_to(legal_templates_path) or not default_path.is_relative_to(legal_templates_path):
sentry_sdk.set_context(
'legal_content_paths',
{
Expand Down
2 changes: 1 addition & 1 deletion src/thunderbird_accounts/mail/autodiscover_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def exchange_autodiscover_endpoint_exists(hostname: str, domain_name: str) -> bo
headers=headers,
timeout=AUTODISCOVER_PROBE_TIMEOUT,
allow_redirects=False,
stream=True, # enable us to only download the first few bytes
stream=True, # enable us to only download the first few bytes
)
break
except requests.RequestException as e:
Expand Down
2 changes: 1 addition & 1 deletion src/thunderbird_accounts/mail/dns.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,5 +423,5 @@ def check_stale_dns_records(cust_domain: str) -> list[dict]:
except Exception as e:
logging.warning(f'SRV lookup failed for {autodiscover_srv_name}: {e}')

logging.debug("stale DNS records that should be deleted %s", stale_records)
logging.debug('stale DNS records that should be deleted %s', stale_records)
return stale_records
7 changes: 2 additions & 5 deletions src/thunderbird_accounts/mail/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,7 @@ def publish_hosted_dkim_dns_records(self, domain_name: str):
phase = 'initialize'
dkim_dns_records = []
hosted_records = []
expected_record_count = len(
{selector for selector in settings.STALWART_DKIM_ALGO_SELECTORS.values() if selector}
)
expected_record_count = len({selector for selector in settings.STALWART_DKIM_ALGO_SELECTORS.values() if selector})

try:
phase = 'fetch_stalwart_dkim_dns_records'
Expand All @@ -201,8 +199,7 @@ def publish_hosted_dkim_dns_records(self, domain_name: str):
phase = 'validate_hosted_record_count'
if len(hosted_records) < expected_record_count:
reason = (
f'Expected {expected_record_count} hosted DKIM records for {domain_name}, '
f'got {len(hosted_records)}'
f'Expected {expected_record_count} hosted DKIM records for {domain_name}, got {len(hosted_records)}'
)
raise HostedDkimPublishRetry(
domain_name,
Expand Down
2 changes: 1 addition & 1 deletion src/thunderbird_accounts/mail/tests/test_dkim.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def test_skips_retired_signatures(self):
def test_raises_for_missing_signature_stage(self):
public_pem, _expected_public_key = _ed25519_public_key()

with self.assertRaisesRegex(RuntimeError, "unexpected stage None"):
with self.assertRaisesRegex(RuntimeError, 'unexpected stage None'):
dkim_signatures_to_dns_records(
'example.com',
[{'id': 'sig-1', 'selector': 'tm3', 'publicKey': public_pem}],
Expand Down
4 changes: 1 addition & 3 deletions src/thunderbird_accounts/mail/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,7 @@ def test_deletes_hosted_dkim_records_from_cloudflare(self, cloudflare_client_moc
)
@patch('thunderbird_accounts.mail.tasks.sentry_sdk.set_context')
@patch('thunderbird_accounts.mail.tasks.CloudflareDNSClient')
def test_retries_with_context_when_cloudflare_delete_fails(
self, cloudflare_client_mock, set_sentry_context_mock
):
def test_retries_with_context_when_cloudflare_delete_fails(self, cloudflare_client_mock, set_sentry_context_mock):
cloudflare_client_mock.return_value.delete_txt_records.side_effect = RuntimeError('Cloudflare unavailable')

with self.assertRaises(HostedDkimDeleteRetry) as cm:
Expand Down
Loading
Loading