Skip to content

Commit 5c80e49

Browse files
committed
add e2e tests for clientbridge
1 parent 259910d commit 5c80e49

24 files changed

Lines changed: 673 additions & 131 deletions

File tree

Makefile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ logs: ## display all services logs (follow mode)
135135
.PHONY: logs
136136

137137
start: ## start all development services
138-
@$(COMPOSE) up --force-recreate --build -d frontend-dev backend-dev worker-dev mta-in --wait
138+
@$(COMPOSE) up --force-recreate --build -d frontend-dev backend-dev worker-dev mta-in client-bridge --wait
139139
.PHONY: start
140140

141141
start-minimal: ## start minimal services (backend, frontend, keycloak and DB)
@@ -361,21 +361,21 @@ logs-e2e: ## Show logs from e2e services
361361

362362
test-e2e-bare: ## Run e2e tests in headless mode
363363
@echo "$(BLUE)\n\n| 🎭 Running E2E tests... \n$(RESET)"
364-
$(COMPOSE_E2E) run --rm --service-ports runner npm run test -- $(args)
364+
$(COMPOSE_E2E) run --rm --service-ports e2e-runner npm run test -- $(args)
365365
@echo "$(GREEN)> 🎭 E2E tests completed!$(RESET)\n"
366366
.PHONY: test-e2e-bare
367367

368368
test-e2e-ui-bare: ## Run e2e tests in UI mode
369369
@echo "$(BLUE)\n\n| 🎭 Running E2E tests in UI mode... \n$(RESET)"
370370
# Note: || true allows graceful exit when user closes the UI
371-
@$(COMPOSE_E2E) run --rm --service-ports runner npm run test:ui || true
371+
@$(COMPOSE_E2E) run --rm --service-ports e2e-runner npm run test:ui || true
372372
@echo "$(GREEN)> 🎭 You killed the UI!$(RESET)\n"
373373
.PHONY: test-e2e-ui-bare
374374

375375
test-e2e-dev-bare: ## Run e2e tests in UI mode with dev frontend
376376
@echo "$(BLUE)\n\n| 🎭 Running E2E tests in dev mode... \n$(RESET)"
377377
# Note: || true allows graceful exit when user closes the UI
378-
E2E_PROFILE=dev $(COMPOSE_E2E) --profile dev run --rm --service-ports runner npm run test:ui || true
378+
E2E_PROFILE=dev $(COMPOSE_E2E) --profile dev run --rm --service-ports e2e-runner npm run test:ui || true
379379
@echo "$(GREEN)> 🎭 You killed the UI!$(RESET)\n"
380380
.PHONY: test-e2e-dev-bare
381381

compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ services:
121121
mailcatcher:
122122
condition: service_started
123123

124+
# Minimal backend service for development tasks that don't require all service dependencies
124125
backend-db:
125126
extends: backend-base
126127
profiles:

env.d/development/backend.defaults

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ FEATURE_AI_SUMMARY=False
101101
FEATURE_AI_AUTOLABELS=False
102102

103103
# Client bridge (IMAP/SMTP email client access)
104-
FEATURE_CLIENTBRIDGE=False
104+
FEATURE_CLIENTBRIDGE=True
105+
FEATURE_MAILBOX_ADMIN_CHANNELS=widget,client-bridge
106+
CLIENTBRIDGE_PUBLIC_CONFIG={"imap_host":"localhost","imap_port":8919,"imap_security":"PLAIN","smtp_host":"localhost","smtp_port":8920,"smtp_security":"PLAIN"}
105107

106108
# Third-party services
107109
# Drive - https://github.com/suitenumerique/drive

env.d/development/backend.e2e

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,9 @@ AWS_S3_DOMAIN_REPLACE=
3030
# Email configuration (use mailcatcher from main services or disable)
3131
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
3232

33+
# Client bridge (IMAP/SMTP email client access)
34+
FEATURE_CLIENTBRIDGE=True
35+
CLIENTBRIDGE_API_SECRET=e2e-shared-secret-clientbridge-at-least-32-bytes
36+
3337
# Debug
3438
DJANGO_DEBUG=True
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
MESSAGES_API_BASE_URL=http://backend:8000/api/v1.0/
2+
CLIENTBRIDGE_API_SECRET=e2e-shared-secret-clientbridge-at-least-32-bytes

src/backend/.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ extension-pkg-whitelist=pypff
77

88
# Add files or directories to the blacklist. They should be base names, not
99
# paths.
10-
ignore=migrations
10+
ignore=migrations,.venv
1111

1212
# Add files or directories matching the regex patterns to the blacklist. The
1313
# regex matches against base names, not paths.

src/backend/core/api/serializers.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,7 @@ class Meta:
911911
"is_trashed",
912912
"is_archived",
913913
"has_attachments",
914+
"mime_id",
914915
"signature",
915916
"stmsg_headers",
916917
]
@@ -1499,9 +1500,9 @@ class Meta:
14991500
read_only_fields = ["id", "mailbox", "maildomain", "created_at", "updated_at"]
15001501

15011502
# Keys in settings that should be moved to encrypted_settings, per channel type.
1502-
ENCRYPTED_SETTINGS_KEYS = {
1503-
"client-bridge": ["password"],
1504-
}
1503+
# Note: client-bridge is NOT listed here — its passwords are always
1504+
# server-generated (on create or via rotate-password), never user-supplied.
1505+
ENCRYPTED_SETTINGS_KEYS = {}
15051506

15061507
def _move_sensitive_settings(self, validated_data):
15071508
"""Move sensitive keys from settings to encrypted_settings."""
@@ -1529,19 +1530,15 @@ def _move_sensitive_settings(self, validated_data):
15291530
return validated_data
15301531

15311532
def create(self, validated_data):
1532-
validated_data = self._move_sensitive_settings(validated_data)
15331533
if validated_data.get("type") == "client-bridge":
1534+
# Server-generated password — never accept user-supplied ones
15341535
password = generate_base58_password()
1535-
validated_data.setdefault("encrypted_settings", {})
1536-
validated_data["encrypted_settings"]["password"] = password
1537-
# Remove any user-supplied password from settings
1538-
if "settings" in validated_data and "password" in (
1539-
validated_data["settings"] or {}
1540-
):
1541-
validated_data["settings"].pop("password")
1536+
settings_data = validated_data.get("settings") or {}
1537+
settings_data.pop("password", None)
1538+
validated_data["settings"] = settings_data
1539+
validated_data["encrypted_settings"] = {"password": password}
15421540
# Set default role if not provided
1543-
validated_data.setdefault("settings", {})
1544-
validated_data["settings"].setdefault("role", "sender")
1541+
settings_data.setdefault("role", "sender")
15451542
# Validate role
15461543
role = validated_data["settings"]["role"]
15471544
if role not in enums.CLIENT_BRIDGE_ROLES:
@@ -1556,15 +1553,18 @@ def create(self, validated_data):
15561553
# Stash password so the view can return it once
15571554
instance._generated_password = password # noqa: SLF001 # pylint: disable=protected-access
15581555
return instance
1556+
validated_data = self._move_sensitive_settings(validated_data)
15591557
return super().create(validated_data)
15601558

15611559
def update(self, instance, validated_data):
1562-
validated_data = self._move_sensitive_settings(validated_data)
15631560
channel_type = validated_data.get("type") or (
15641561
instance.type if instance else None
15651562
)
15661563
if channel_type == "client-bridge":
1564+
# Strip any user-supplied password — passwords can only be
1565+
# changed via the dedicated rotate-password endpoint.
15671566
settings_data = validated_data.get("settings") or {}
1567+
settings_data.pop("password", None)
15681568
if "role" in settings_data:
15691569
if settings_data["role"] not in enums.CLIENT_BRIDGE_ROLES:
15701570
raise serializers.ValidationError(
@@ -1574,6 +1574,10 @@ def update(self, instance, validated_data):
15741574
}
15751575
}
15761576
)
1577+
# Prevent encrypted_settings from being overwritten
1578+
validated_data.pop("encrypted_settings", None)
1579+
else:
1580+
validated_data = self._move_sensitive_settings(validated_data)
15771581
return super().update(instance, validated_data)
15781582

15791583
def validate_settings(self, value):

src/backend/core/api/viewsets/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,8 @@ def get(self, request):
231231

232232
# Client-bridge connection settings
233233
if settings.FEATURE_CLIENTBRIDGE and settings.CLIENTBRIDGE_PUBLIC_CONFIG:
234-
dict_settings["CLIENTBRIDGE_PUBLIC_CONFIG"] = settings.CLIENTBRIDGE_PUBLIC_CONFIG
234+
dict_settings["CLIENTBRIDGE_PUBLIC_CONFIG"] = (
235+
settings.CLIENTBRIDGE_PUBLIC_CONFIG
236+
)
235237

236238
return drf.response.Response(dict_settings)

src/backend/core/urls.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -248,17 +248,6 @@
248248
ProvisioningMailDomainView.as_view(),
249249
name="provisioning-maildomains",
250250
),
251-
# Client-bridge endpoints (IMAP/SMTP auth and message submission)
252-
path(
253-
f"api/{settings.API_VERSION}/client-bridge/auth/",
254-
ClientBridgeAuthView.as_view(),
255-
name="client-bridge-auth",
256-
),
257-
path(
258-
f"api/{settings.API_VERSION}/client-bridge/submit/",
259-
ClientBridgeSubmitView.as_view(),
260-
name="client-bridge-submit",
261-
),
262251
# Alias for MTA check endpoint
263252
path(
264253
f"api/{settings.API_VERSION}/mta/check-recipients/",
@@ -273,6 +262,20 @@
273262
),
274263
]
275264

265+
if settings.FEATURE_CLIENTBRIDGE:
266+
urlpatterns += [
267+
path(
268+
f"api/{settings.API_VERSION}/client-bridge/auth/",
269+
ClientBridgeAuthView.as_view(),
270+
name="client-bridge-auth",
271+
),
272+
path(
273+
f"api/{settings.API_VERSION}/client-bridge/submit/",
274+
ClientBridgeSubmitView.as_view(),
275+
name="client-bridge-submit",
276+
),
277+
]
278+
276279
if settings.DRIVE_CONFIG.get("base_url"):
277280
urlpatterns += [
278281
path(

src/backend/e2e/management/commands/e2e_demo.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
for E2E testing across different browsers (chromium, firefox, webkit).
66
"""
77

8+
from email.mime.text import MIMEText
9+
from email.utils import format_datetime
10+
11+
from django.conf import settings
812
from django.core.management.base import BaseCommand
913
from django.db import transaction
1014
from django.utils import timezone
@@ -21,6 +25,7 @@
2125
BROWSERS = ["chromium", "firefox", "webkit"]
2226
DOMAIN_NAME = "example.local"
2327
SHARED_MAILBOX_LOCAL_PART = "shared.e2e"
28+
CLIENTBRIDGE_APP_PASSWORD = "e2e-client-bridge-password" # noqa: S105
2429

2530

2631
class Command(BaseCommand):
@@ -122,6 +127,18 @@ def handle(self, *args, **options):
122127
for browser in BROWSERS:
123128
self._create_outbox_test_data(domain, browser)
124129

130+
# Step 7: Create client-bridge channels and IMAP test data
131+
if settings.FEATURE_CLIENTBRIDGE:
132+
self.stdout.write(
133+
"\n-- 6/6 📧 Creating client-bridge channels and IMAP test data"
134+
)
135+
for _user, mailbox in regular_users:
136+
self._create_clientbridge_channel(mailbox)
137+
self._create_clientbridge_channel(shared_mailbox)
138+
# Create IMAP test messages on the first regular user's mailbox
139+
_first_user, first_mailbox = regular_users[0]
140+
self._create_imap_test_messages(first_mailbox, domain)
141+
125142
def _create_user_with_mailbox(
126143
self, email, domain, is_domain_admin=False, is_superuser=False
127144
):
@@ -387,3 +404,122 @@ def _create_thread_with_message(self, mailbox, sender_contact, subject, recipien
387404
thread.update_stats()
388405

389406
return thread
407+
408+
def _create_clientbridge_channel(self, mailbox):
409+
"""Create a client-bridge channel with a known password for e2e testing."""
410+
# Use the first user with admin access to this mailbox
411+
access = models.MailboxAccess.objects.filter(
412+
mailbox=mailbox, role=MailboxRoleChoices.ADMIN
413+
).first()
414+
if not access:
415+
self.stdout.write(
416+
self.style.WARNING(
417+
f" No admin user found for {mailbox}, skipping channel"
418+
)
419+
)
420+
return
421+
422+
_channel, created = models.Channel.objects.get_or_create(
423+
mailbox=mailbox,
424+
type="client-bridge",
425+
defaults={
426+
"name": f"E2E client-bridge ({mailbox})",
427+
"user": access.user,
428+
"settings": {"role": "sender"},
429+
"encrypted_settings": {"password": CLIENTBRIDGE_APP_PASSWORD},
430+
},
431+
)
432+
if created:
433+
self.stdout.write(
434+
self.style.SUCCESS(f" Created client-bridge channel for {mailbox}")
435+
)
436+
else:
437+
self.stdout.write(
438+
self.style.SUCCESS(
439+
f" Client-bridge channel already exists for {mailbox}"
440+
)
441+
)
442+
443+
@staticmethod
444+
def _make_eml(subject, sender_email, recipient_email, body, sent_at):
445+
"""Build a minimal RFC 5322 message and return raw bytes."""
446+
msg = MIMEText(body, "plain")
447+
msg["Subject"] = subject
448+
msg["From"] = sender_email
449+
msg["To"] = recipient_email
450+
msg["Date"] = format_datetime(sent_at)
451+
return msg.as_bytes()
452+
453+
def _create_imap_test_messages(self, mailbox, domain):
454+
"""Create messages for IMAP read/unread e2e testing.
455+
456+
Creates two threads with proper EML blobs so the client-bridge
457+
can serve envelope data (subject, from, date) over IMAP.
458+
"""
459+
sender_email = f"imap-sender@{domain.name}"
460+
recipient_email = str(mailbox)
461+
sender_contact, _ = models.Contact.objects.get_or_create(
462+
email=sender_email,
463+
mailbox=mailbox,
464+
defaults={"name": "IMAP Test Sender"},
465+
)
466+
467+
# Thread 1: an unread message
468+
now = timezone.now()
469+
thread1 = models.Thread.objects.create(subject="IMAP unread test")
470+
models.ThreadAccess.objects.create(
471+
thread=thread1,
472+
mailbox=mailbox,
473+
role=ThreadAccessRoleChoices.EDITOR,
474+
read_at=None, # No read_at → all messages unread
475+
)
476+
eml1 = self._make_eml(
477+
"IMAP unread test",
478+
sender_email,
479+
recipient_email,
480+
"This message should appear as unread in IMAP.",
481+
now,
482+
)
483+
blob1 = mailbox.create_blob(content=eml1, content_type="message/rfc822")
484+
models.Message.objects.create(
485+
thread=thread1,
486+
sender=sender_contact,
487+
subject="IMAP unread test",
488+
is_sender=False,
489+
is_draft=False,
490+
sent_at=now,
491+
blob=blob1,
492+
)
493+
thread1.update_stats()
494+
495+
# Thread 2: a read message
496+
sent_at2 = now - timezone.timedelta(minutes=5)
497+
thread2 = models.Thread.objects.create(subject="IMAP read test")
498+
models.ThreadAccess.objects.create(
499+
thread=thread2,
500+
mailbox=mailbox,
501+
role=ThreadAccessRoleChoices.EDITOR,
502+
read_at=now, # read_at set → messages before this are read
503+
)
504+
eml2 = self._make_eml(
505+
"IMAP read test",
506+
sender_email,
507+
recipient_email,
508+
"This message should appear as read in IMAP.",
509+
sent_at2,
510+
)
511+
blob2 = mailbox.create_blob(content=eml2, content_type="message/rfc822")
512+
models.Message.objects.create(
513+
thread=thread2,
514+
sender=sender_contact,
515+
subject="IMAP read test",
516+
is_sender=False,
517+
is_draft=False,
518+
sent_at=sent_at2,
519+
blob=blob2,
520+
)
521+
thread2.update_stats()
522+
523+
self.stdout.write(
524+
self.style.SUCCESS(f" Created IMAP test messages for {mailbox}")
525+
)

0 commit comments

Comments
 (0)