• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

fiduswriter / fiduswriter / 25608187511

09 May 2026 06:08PM UTC coverage: 88.854% (+0.01%) from 88.841%
25608187511

push

github

web-flow
Merge pull request #1389 from fiduswriter/feature/split-packages

Split frontend and backend, first steps

10212 of 11493 relevant lines covered (88.85%)

5.56 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.75
fiduswriter/document/tests/test_e2ee.py
1
import time
1✔
2
import sys
1✔
3
import base64
1✔
4

5
from testing.channels_patch import ChannelsLiveServerTestCase
1✔
6
from testing.selenium_helper import SeleniumHelper
1✔
7
from selenium.webdriver.common.by import By
1✔
8
from selenium.webdriver.support.wait import WebDriverWait
1✔
9
from selenium.webdriver.support import expected_conditions as EC
1✔
10
from selenium.common.exceptions import TimeoutException
1✔
11

12
from django.test import override_settings
1✔
13

14
from document.models import Document, DocumentEncryptionKey, AccessRight
1✔
15
from document.tests.editor_helper import EditorHelper
1✔
16

17

18
@override_settings(E2EE_MODE="enabled")
1✔
19
class E2EEBasicTest(SeleniumHelper, ChannelsLiveServerTestCase):
1✔
20
    """
21
    Basic E2EE tests covering document creation, opening, password entry,
22
    password change, and document list indicators.
23
    """
24

25
    fixtures = [
1✔
26
        "initial_documenttemplates.json",
27
        "initial_styles.json",
28
    ]
29

30
    @classmethod
1✔
31
    def setUpClass(cls):
1✔
32
        super().setUpClass()
1✔
33
        driver_data = cls.get_drivers(1)
1✔
34
        cls.driver = driver_data["drivers"][0]
1✔
35
        cls.client = driver_data["clients"][0]
1✔
36
        cls.driver.implicitly_wait(driver_data["wait_time"])
1✔
37
        cls.wait_time = driver_data["wait_time"]
1✔
38

39
    @classmethod
1✔
40
    def tearDownClass(cls):
1✔
41
        cls.driver.quit()
1✔
42
        super().tearDownClass()
1✔
43

44
    def setUp(self):
1✔
45
        self.base_url = self.live_server_url
1✔
46
        self.user = self.create_user(
1✔
47
            username="E2EEUser", email="e2ee@test.com", passtext="testpass"
48
        )
49
        self.login_user(self.user, self.driver, self.client)
1✔
50
        return super().setUp()
1✔
51

52
    def tearDown(self):
1✔
53
        self.driver.execute_script("window.localStorage.clear()")
1✔
54
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
55
        super().tearDown()
1✔
56
        if "coverage" in sys.modules.keys():
1✔
57
            time.sleep(self.wait_time / 3)
1✔
58

59
    def create_e2ee_document_via_ui(self, password="SecurePass123"):
1✔
60
        """
61
        Create a new E2EE document through the UI.
62
        The frontend has E2EE_MODE baked in as "enabled", so we must
63
        interact with the encryption-choice dialog.
64
        Returns the document ID from the URL.
65
        """
66
        self.driver.get(self.base_url)
1✔
67
        # Click "Create new document" on the overview
68
        WebDriverWait(self.driver, self.wait_time).until(
1✔
69
            EC.element_to_be_clickable(
70
                (By.CSS_SELECTOR, ".new_document button")
71
            )
72
        ).click()
73

74
        # Wait for and interact with encryption choice dialog
75
        WebDriverWait(self.driver, self.wait_time).until(
1✔
76
            EC.presence_of_element_located((By.CSS_SELECTOR, ".ui-dialog"))
77
        )
78
        # Select "Encrypted" radio button
79
        self.driver.find_element(By.ID, "e2ee").click()
1✔
80
        # Click "Create"
81
        self.driver.find_element(
1✔
82
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
83
        ).click()
84

85
        # After clicking Create, we may get a passphrase setup offer dialog
86
        # or directly the password dialog. Try to handle the passphrase offer first.
87
        time.sleep(1)
1✔
88
        try:
1✔
89
            # Look for "Skip for Now" button which would indicate passphrase offer dialog
90
            skip_buttons = self.driver.find_elements(
1✔
91
                By.CSS_SELECTOR, ".ui-dialog-buttonpane .fw-button"
92
            )
93
            for btn in skip_buttons:
1✔
94
                if "Skip" in btn.text:
1✔
95
                    # This is the passphrase offer dialog, skip it
96
                    btn.click()
1✔
97
                    time.sleep(0.5)
1✔
98
                    break
1✔
99
        except Exception:
×
100
            # No passphrase offer dialog, that's fine
101
            pass
×
102

103
        # Now wait for the password creation dialog
104
        WebDriverWait(self.driver, self.wait_time).until(
1✔
105
            EC.presence_of_element_located((By.ID, "e2ee-new-password-input")),
106
            message="Should show E2EE password dialog",
107
        )
108

109
        # Enter password and confirmation
110
        self.driver.find_element(By.ID, "e2ee-new-password-input").send_keys(
1✔
111
            password
112
        )
113
        self.driver.find_element(
1✔
114
            By.ID, "e2ee-confirm-password-input"
115
        ).send_keys(password)
116

117
        # Click "Create Encrypted Document"
118
        self.driver.find_element(
1✔
119
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
120
        ).click()
121

122
        # Wait for editor to load
123
        WebDriverWait(self.driver, self.wait_time).until(
1✔
124
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
125
        )
126

127
        # Extract document ID from URL
128
        url = self.driver.current_url
1✔
129
        doc_id = int(url.split("/document/")[1].split("/")[0])
1✔
130
        return doc_id
1✔
131

132
    def add_title_and_body(
1✔
133
        self, title="E2EE Test", body="Encrypted body text"
134
    ):
135
        """Add title and body text to the current document."""
136
        title_el = self.driver.find_element(By.CSS_SELECTOR, ".doc-title")
1✔
137
        title_el.click()
1✔
138
        title_el.send_keys(title)
1✔
139

140
        body_el = self.driver.find_element(By.CSS_SELECTOR, ".doc-body")
1✔
141
        body_el.click()
1✔
142
        body_el.send_keys(body)
1✔
143
        # Allow time for encryption, sync, and snapshot to be saved
144
        time.sleep(3)
1✔
145

146
    def test_create_e2ee_document(self):
1✔
147
        """
148
        Test creating a new E2EE document.
149
        The password dialog should appear, and after entering a password
150
        the editor should load.
151
        """
152
        doc_id = self.create_e2ee_document_via_ui(password="MyE2EEPass1")
1✔
153

154
        # Verify editor loaded
155
        toolbar = self.driver.find_element(By.CLASS_NAME, "editor-toolbar")
1✔
156
        self.assertIsNotNone(toolbar)
1✔
157

158
        # Verify document exists in DB with e2ee=True
159
        doc = Document.objects.get(id=doc_id)
1✔
160
        self.assertTrue(doc.e2ee)
1✔
161
        self.assertIsNotNone(doc.e2ee_salt)
1✔
162
        self.assertEqual(doc.e2ee_iterations, 600000)
1✔
163

164
    def test_open_e2ee_document_with_password(self):
1✔
165
        """
166
        Test opening an existing E2EE document by entering the password.
167
        """
168
        password = "OpenDocPass1"
1✔
169
        self.create_e2ee_document_via_ui(password=password)
1✔
170
        self.add_title_and_body(title="Secret Title", body="Secret content")
1✔
171

172
        # Navigate away to overview
173
        self.driver.get(self.base_url)
1✔
174
        WebDriverWait(self.driver, self.wait_time).until(
1✔
175
            EC.presence_of_element_located(
176
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
177
            )
178
        )
179

180
        # Clear sessionStorage so we can test the password entry flow
181
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
182

183
        # Click on the document to reopen it
184
        self.driver.find_element(
1✔
185
            By.CSS_SELECTOR, ".fw-contents tbody tr a.fw-data-table-title"
186
        ).click()
187

188
        # Wait for the password entry dialog
189
        WebDriverWait(self.driver, self.wait_time).until(
1✔
190
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
191
        )
192

193
        # Enter the password
194
        self.driver.find_element(By.ID, "e2ee-password-input").send_keys(
1✔
195
            password
196
        )
197
        self.driver.find_element(
1✔
198
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
199
        ).click()
200

201
        # Wait for editor to load
202
        WebDriverWait(self.driver, self.wait_time).until(
1✔
203
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
204
        )
205

206
        # Verify the content is visible
207
        title_text = self.driver.execute_script(
1✔
208
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
209
        )
210
        self.assertIn("Secret Title", title_text)
1✔
211

212
    def test_open_e2ee_document_wrong_password(self):
1✔
213
        """
214
        Test that entering the wrong password shows an error dialog
215
        with Retry and Cancel options.
216
        """
217
        password = "RightPass1"
1✔
218
        self.create_e2ee_document_via_ui(password=password)
1✔
219
        self.add_title_and_body(title="Wrong Pass Test", body="body text")
1✔
220

221
        # Wait for the initial encrypted snapshot to be saved so that
222
        # the document content is actually encrypted in the DB.
223
        time.sleep(3)
1✔
224

225
        # Navigate away and back
226
        self.driver.get(self.base_url)
1✔
227
        WebDriverWait(self.driver, self.wait_time).until(
1✔
228
            EC.presence_of_element_located(
229
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
230
            )
231
        )
232

233
        # Clear sessionStorage so the password dialog appears
234
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
235

236
        self.driver.find_element(
1✔
237
            By.CSS_SELECTOR, ".fw-contents tbody tr a.fw-data-table-title"
238
        ).click()
239

240
        # Wait for password dialog
241
        WebDriverWait(self.driver, self.wait_time).until(
1✔
242
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
243
        )
244

245
        # Enter wrong password
246
        self.driver.find_element(By.ID, "e2ee-password-input").send_keys(
1✔
247
            "WrongPass1"
248
        )
249
        self.driver.find_element(
1✔
250
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
251
        ).click()
252

253
        # Wait for the error dialog
254
        WebDriverWait(self.driver, self.wait_time).until(
1✔
255
            EC.presence_of_element_located((By.ID, "e2ee-decryption-failed"))
256
        )
257

258
        # Verify both Retry and Cancel buttons exist
259
        buttons = self.driver.find_elements(
1✔
260
            By.CSS_SELECTOR,
261
            "#e2ee-decryption-failed ~ .ui-dialog-buttonpane .fw-button",
262
        )
263
        button_texts = [b.text for b in buttons]
1✔
264
        self.assertTrue(
1✔
265
            any("Retry" in t for t in button_texts),
266
            "Error dialog should have a Retry button",
267
        )
268
        self.assertTrue(
1✔
269
            any("Cancel" in t for t in button_texts),
270
            "Error dialog should have a Cancel button",
271
        )
272

273
        # Click Cancel to go back to overview
274
        for b in buttons:
1✔
275
            if "Cancel" in b.text:
1✔
276
                b.click()
1✔
277
                break
1✔
278

279
        # Should be back on overview
280
        WebDriverWait(self.driver, self.wait_time).until(
1✔
281
            EC.presence_of_element_located((By.CSS_SELECTOR, ".fw-contents"))
282
        )
283

284
    def test_cancel_password_dialog(self):
1✔
285
        """
286
        Test clicking Cancel on the password entry dialog navigates back.
287
        """
288
        password = "CancelTest1"
1✔
289
        self.create_e2ee_document_via_ui(password=password)
1✔
290

291
        # Navigate away and back
292
        self.driver.get(self.base_url)
1✔
293
        WebDriverWait(self.driver, self.wait_time).until(
1✔
294
            EC.presence_of_element_located(
295
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
296
            )
297
        )
298

299
        # Clear sessionStorage so the password dialog appears
300
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
301

302
        self.driver.find_element(
1✔
303
            By.CSS_SELECTOR, ".fw-contents tbody tr a.fw-data-table-title"
304
        ).click()
305

306
        # Wait for password dialog
307
        WebDriverWait(self.driver, self.wait_time).until(
1✔
308
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
309
        )
310

311
        # Click Cancel
312
        buttons = self.driver.find_elements(
1✔
313
            By.CSS_SELECTOR,
314
            "#e2ee-enter-password ~ .ui-dialog-buttonpane .fw-button",
315
        )
316
        for b in buttons:
1✔
317
            if "Cancel" in b.text:
1✔
318
                b.click()
1✔
319
                break
1✔
320

321
        # Should be back on overview
322
        WebDriverWait(self.driver, self.wait_time).until(
1✔
323
            EC.presence_of_element_located((By.CSS_SELECTOR, ".fw-contents"))
324
        )
325

326
    def test_document_list_shows_encrypted_indicator(self):
1✔
327
        """
328
        Test that E2EE documents show a lock icon in the document overview.
329
        When the key is available in sessionStorage, the real title is shown
330
        and the e2ee-encrypted-title class is not present.
331
        """
332
        self.create_e2ee_document_via_ui()
1✔
333

334
        self.driver.get(self.base_url)
1✔
335
        WebDriverWait(self.driver, self.wait_time).until(
1✔
336
            EC.presence_of_element_located(
337
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
338
            )
339
        )
340

341
        # Check for lock icon
342
        lock_icons = self.driver.find_elements(
1✔
343
            By.CSS_SELECTOR, ".e2ee-doc-indicator"
344
        )
345
        self.assertEqual(len(lock_icons), 1, "Should show one lock icon")
1✔
346

347
        # When the key is in sessionStorage, the real title is shown
348
        # without the e2ee-encrypted-title styling.
349
        encrypted_titles = self.driver.find_elements(
1✔
350
            By.CSS_SELECTOR, ".e2ee-encrypted-title"
351
        )
352
        self.assertEqual(
1✔
353
            len(encrypted_titles),
354
            0,
355
            "Should not show encrypted-title class when key is available",
356
        )
357

358
        # Clear sessionStorage and refresh — now the placeholder should appear
359
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
360
        self.driver.get(self.base_url)
1✔
361
        WebDriverWait(self.driver, self.wait_time).until(
1✔
362
            EC.presence_of_element_located(
363
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
364
            )
365
        )
366
        encrypted_titles = self.driver.find_elements(
1✔
367
            By.CSS_SELECTOR, ".e2ee-encrypted-title"
368
        )
369
        self.assertEqual(
1✔
370
            len(encrypted_titles),
371
            1,
372
            "Should show encrypted-title class when key is not available",
373
        )
374

375
    def test_password_change(self):
1✔
376
        """
377
        Test changing the document password via the File menu.
378
        """
379
        old_password = "OldPass123"
1✔
380
        new_password = "NewPass456"
1✔
381
        self.create_e2ee_document_via_ui(password=old_password)
1✔
382
        self.add_title_and_body(title="Change Pass", body="content here")
1✔
383

384
        # Open File menu
385
        self.driver.find_element(
1✔
386
            By.CSS_SELECTOR, ".header-menu:nth-child(1) > .header-nav-item"
387
        ).click()
388
        time.sleep(0.5)
1✔
389

390
        # Click "Change password"
391
        menu_items = self.driver.find_elements(
1✔
392
            By.CSS_SELECTOR, "li > .fw-pulldown-item"
393
        )
394
        change_pass_item = None
1✔
395
        for item in menu_items:
1✔
396
            if "Change password" in item.text:
1✔
397
                change_pass_item = item
1✔
398
                break
1✔
399
        self.assertIsNotNone(
1✔
400
            change_pass_item, "Change password menu item should exist"
401
        )
402
        change_pass_item.click()
1✔
403

404
        # Wait for change password dialog
405
        WebDriverWait(self.driver, self.wait_time).until(
1✔
406
            EC.presence_of_element_located(
407
                (By.ID, "e2ee-current-password-input")
408
            )
409
        )
410

411
        # Enter current and new passwords
412
        # The current password field may be prefilled from sessionStorage,
413
        # so clear it first before entering the test password.
414
        current_pass_input = self.driver.find_element(
1✔
415
            By.ID, "e2ee-current-password-input"
416
        )
417
        current_pass_input.clear()
1✔
418
        current_pass_input.send_keys(old_password)
1✔
419
        self.driver.find_element(By.ID, "e2ee-new-password-input").send_keys(
1✔
420
            new_password
421
        )
422
        self.driver.find_element(
1✔
423
            By.ID, "e2ee-confirm-password-input"
424
        ).send_keys(new_password)
425

426
        # Click Change Password
427
        self.driver.find_element(
1✔
428
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
429
        ).click()
430

431
        # Wait for dialog to close. The re-encryption snapshot is sent
432
        # asynchronously via WebSocket. Give it time to reach the server.
433
        time.sleep(4)
1✔
434

435
        # Verify the document still loads and content is preserved
436
        self.driver.get(self.base_url)
1✔
437
        WebDriverWait(self.driver, self.wait_time).until(
1✔
438
            EC.presence_of_element_located(
439
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
440
            )
441
        )
442

443
        # Clear sessionStorage so we test the new password entry flow
444
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
445

446
        self.driver.find_element(
1✔
447
            By.CSS_SELECTOR, ".fw-contents tbody tr a.fw-data-table-title"
448
        ).click()
449

450
        # Enter NEW password
451
        WebDriverWait(self.driver, self.wait_time).until(
1✔
452
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
453
        )
454

455
        self.driver.find_element(By.ID, "e2ee-password-input").send_keys(
1✔
456
            new_password
457
        )
458
        self.driver.find_element(
1✔
459
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
460
        ).click()
461

462
        # Wait for either editor to load or error dialog to appear
463
        time.sleep(3)
1✔
464
        error_msg = self.driver.execute_script(
1✔
465
            "return window.lastE2EEDecryptError || null;"
466
        )
467
        if error_msg:
1✔
468
            print(f"JS DECRYPT ERROR: {error_msg}")
×
469

470
        WebDriverWait(self.driver, self.wait_time).until(
1✔
471
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
472
        )
473

474
        title_text = self.driver.execute_script(
1✔
475
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
476
        )
477
        self.assertIn("Change Pass", title_text)
1✔
478

479
    def test_session_storage_skips_password_dialog(self):
1✔
480
        """
481
        Test that reopening an E2EE document in the same browser session
482
        does not prompt for the password again when the key is cached in
483
        sessionStorage.
484
        """
485
        password = "SessionPass1"
1✔
486
        self.create_e2ee_document_via_ui(password=password)
1✔
487
        self.add_title_and_body(title="Session Test", body="session content")
1✔
488

489
        # Navigate away to overview
490
        self.driver.get(self.base_url)
1✔
491
        WebDriverWait(self.driver, self.wait_time).until(
1✔
492
            EC.presence_of_element_located(
493
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
494
            )
495
        )
496

497
        # Click on the document to reopen it
498
        self.driver.find_element(
1✔
499
            By.CSS_SELECTOR, ".fw-contents tbody tr a.fw-data-table-title"
500
        ).click()
501

502
        # The editor should load directly without a password dialog
503
        # because the key is cached in sessionStorage.
504
        WebDriverWait(self.driver, self.wait_time).until(
1✔
505
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
506
        )
507

508
        # Give the decrypted document content a moment to render
509
        time.sleep(1)
1✔
510

511
        # Verify the content is visible
512
        title_text = self.driver.execute_script(
1✔
513
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
514
        )
515
        self.assertIn("Session Test", title_text)
1✔
516

517
    def test_logout_clears_e2ee_session_storage(self):
1✔
518
        """
519
        Test that logging out via the UI clears all E2EE-related data
520
        from sessionStorage.
521
        """
522
        password = "LogoutPass1"
1✔
523
        self.create_e2ee_document_via_ui(password=password)
1✔
524
        self.add_title_and_body(title="Logout Test", body="content here")
1✔
525

526
        # Verify that E2EE data was stored in sessionStorage
527
        e2ee_keys_before = self.driver.execute_script(
1✔
528
            "return Object.keys(sessionStorage).filter(k => k.startsWith('e2ee_'));"
529
        )
530
        self.assertTrue(
1✔
531
            len(e2ee_keys_before) > 0,
532
            "E2EE items should be in sessionStorage after creating/opening document",
533
        )
534

535
        # Close the editor and return to overview so the preferences menu is available
536
        self.driver.find_element(By.ID, "close-document-top").click()
1✔
537
        WebDriverWait(self.driver, self.wait_time).until(
1✔
538
            EC.element_to_be_clickable((By.ID, "preferences-btn"))
539
        )
540

541
        # Open the user preferences pulldown and click logout
542
        self.driver.find_element(By.ID, "preferences-btn").click()
1✔
543
        WebDriverWait(self.driver, self.wait_time).until(
1✔
544
            EC.element_to_be_clickable(
545
                (By.XPATH, '//*[normalize-space()="Log out"]')
546
            )
547
        ).click()
548

549
        # Wait for redirect to login page
550
        WebDriverWait(self.driver, self.wait_time).until(
1✔
551
            EC.presence_of_element_located((By.ID, "id-login"))
552
        )
553

554
        # Verify that no E2EE items remain in sessionStorage
555
        e2ee_keys_after = self.driver.execute_script(
1✔
556
            "return Object.keys(sessionStorage).filter(k => k.startsWith('e2ee_'));"
557
        )
558
        self.assertEqual(
1✔
559
            len(e2ee_keys_after),
560
            0,
561
            f"E2EE sessionStorage items should be cleared after logout, found: {e2ee_keys_after}",
562
        )
563

564

565
@override_settings(E2EE_MODE="enabled")
1✔
566
class E2EEAccessRightsTest(SeleniumHelper, ChannelsLiveServerTestCase):
1✔
567
    """
568
    Tests for E2EE-specific access rights behavior:
569
    - Warning banner in share dialog
570
    - Share link creation with password in URL fragment
571
    - Filtered access rights dropdown
572
    """
573

574
    fixtures = [
1✔
575
        "initial_documenttemplates.json",
576
        "initial_styles.json",
577
    ]
578

579
    @classmethod
1✔
580
    def setUpClass(cls):
1✔
581
        super().setUpClass()
1✔
582
        driver_data = cls.get_drivers(1)
1✔
583
        cls.driver = driver_data["drivers"][0]
1✔
584
        cls.client = driver_data["clients"][0]
1✔
585
        cls.driver.implicitly_wait(driver_data["wait_time"])
1✔
586
        cls.wait_time = driver_data["wait_time"]
1✔
587

588
    @classmethod
1✔
589
    def tearDownClass(cls):
1✔
590
        cls.driver.quit()
1✔
591
        super().tearDownClass()
1✔
592

593
    def setUp(self):
1✔
594
        self.base_url = self.live_server_url
1✔
595
        self.user = self.create_user(
1✔
596
            username="E2EEOwner", email="owner@test.com", passtext="testpass"
597
        )
598
        self.login_user(self.user, self.driver, self.client)
1✔
599
        return super().setUp()
1✔
600

601
    def tearDown(self):
1✔
602
        self.driver.execute_script("window.localStorage.clear()")
1✔
603
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
604
        super().tearDown()
1✔
605
        if "coverage" in sys.modules.keys():
1✔
606
            time.sleep(self.wait_time / 3)
1✔
607

608
    def create_e2ee_document_via_ui(self, password="SecurePass123"):
1✔
609
        """Helper to create an E2EE document through the UI."""
610
        self.driver.get(self.base_url)
1✔
611
        WebDriverWait(self.driver, self.wait_time).until(
1✔
612
            EC.element_to_be_clickable(
613
                (By.CSS_SELECTOR, ".new_document button")
614
            )
615
        ).click()
616

617
        # Encryption choice dialog
618
        WebDriverWait(self.driver, self.wait_time).until(
1✔
619
            EC.presence_of_element_located((By.CSS_SELECTOR, ".ui-dialog"))
620
        )
621
        self.driver.find_element(By.ID, "e2ee").click()
1✔
622
        self.driver.find_element(
1✔
623
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
624
        ).click()
625

626
        # After clicking Create, we may get a passphrase setup offer dialog
627
        time.sleep(1)
1✔
628
        try:
1✔
629
            # Look for "Skip for Now" button which would indicate passphrase offer dialog
630
            skip_buttons = self.driver.find_elements(
1✔
631
                By.CSS_SELECTOR, ".ui-dialog-buttonpane .fw-button"
632
            )
633
            for btn in skip_buttons:
1✔
634
                if "Skip" in btn.text:
1✔
635
                    # This is the passphrase offer dialog, skip it
636
                    btn.click()
1✔
637
                    time.sleep(0.5)
1✔
638
                    break
1✔
639
        except Exception:
×
640
            # No passphrase offer dialog, that's fine
641
            pass
×
642

643
        WebDriverWait(self.driver, self.wait_time).until(
1✔
644
            EC.presence_of_element_located((By.ID, "e2ee-new-password-input"))
645
        )
646
        self.driver.find_element(By.ID, "e2ee-new-password-input").send_keys(
1✔
647
            password
648
        )
649
        self.driver.find_element(
1✔
650
            By.ID, "e2ee-confirm-password-input"
651
        ).send_keys(password)
652
        self.driver.find_element(
1✔
653
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
654
        ).click()
655

656
        WebDriverWait(self.driver, self.wait_time).until(
1✔
657
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
658
        )
659

660
        doc_id = int(
1✔
661
            self.driver.current_url.split("/document/")[1].split("/")[0]
662
        )
663
        return doc_id
1✔
664

665
    def test_share_dialog_shows_e2ee_warning(self):
1✔
666
        """
667
        Test that the access rights dialog shows an E2EE warning banner
668
        when sharing an encrypted document.
669
        """
670
        self.create_e2ee_document_via_ui()
1✔
671

672
        # Open File menu → Share
673
        self.driver.find_element(
1✔
674
            By.CSS_SELECTOR, ".header-menu:nth-child(1) > .header-nav-item"
675
        ).click()
676
        time.sleep(0.5)
1✔
677
        self.driver.find_element(
1✔
678
            By.CSS_SELECTOR, "li:nth-child(1) > .fw-pulldown-item"
679
        ).click()
680

681
        # Wait for access rights dialog
682
        WebDriverWait(self.driver, self.wait_time).until(
1✔
683
            EC.presence_of_element_located((By.ID, "access-rights-dialog"))
684
        )
685

686
        # Check for E2EE warning banner
687
        warning = self.driver.find_element(
1✔
688
            By.CSS_SELECTOR, ".e2ee-access-rights-warning"
689
        )
690
        self.assertIsNotNone(warning)
1✔
691
        self.assertIn("secure channel", warning.text)
1✔
692

693
    def test_share_link_with_password(self):
1✔
694
        """
695
        Test creating a share link that includes the document password
696
        in the URL fragment.
697
        """
698
        doc_password = "DocPass123"
1✔
699
        self.create_e2ee_document_via_ui(password=doc_password)
1✔
700

701
        # Open File menu → Share
702
        self.driver.find_element(
1✔
703
            By.CSS_SELECTOR, ".header-menu:nth-child(1) > .header-nav-item"
704
        ).click()
705
        time.sleep(0.5)
1✔
706
        self.driver.find_element(
1✔
707
            By.CSS_SELECTOR, "li:nth-child(1) > .fw-pulldown-item"
708
        ).click()
709

710
        # Wait for dialog
711
        WebDriverWait(self.driver, self.wait_time).until(
1✔
712
            EC.presence_of_element_located((By.ID, "access-rights-dialog"))
713
        )
714

715
        # Switch to "Share link" tab
716
        self.driver.find_element(
1✔
717
            By.CSS_SELECTOR, ".ui-tabs-nav .tab-link:nth-child(2) a"
718
        ).click()
719
        time.sleep(0.5)
1✔
720

721
        # Click "Create new share link"
722
        self.driver.find_element(By.ID, "create-share-token-btn").click()
1✔
723

724
        # Wait for create share token dialog
725
        WebDriverWait(self.driver, self.wait_time).until(
1✔
726
            EC.presence_of_element_located(
727
                (By.ID, "create-share-token-dialog")
728
            )
729
        )
730

731
        # Verify password field exists for E2EE documents
732
        pass_input = self.driver.find_element(By.ID, "share-token-password")
1✔
733
        self.assertIsNotNone(pass_input)
1✔
734

735
        # Enter password to include in link
736
        pass_input.send_keys(doc_password)
1✔
737

738
        # Create the link
739
        self.driver.find_element(
1✔
740
            By.CSS_SELECTOR,
741
            "#create-share-token-dialog ~ .ui-dialog-buttonpane .fw-dark",
742
        ).click()
743

744
        # Wait for the link to appear in the list
745
        WebDriverWait(self.driver, self.wait_time).until(
1✔
746
            EC.presence_of_element_located(
747
                (By.CSS_SELECTOR, ".share-token-row")
748
            )
749
        )
750

751
        # Verify the URL contains the password fragment
752
        url_input = self.driver.find_element(
1✔
753
            By.CSS_SELECTOR, ".share-token-url-input"
754
        )
755
        share_url = url_input.get_attribute("value")
1✔
756
        self.assertIn("#?password=", share_url)
1✔
757
        self.assertIn(doc_password, share_url)
1✔
758

759

760
@override_settings(E2EE_MODE="enabled")
1✔
761
class E2EECollaborationTest(EditorHelper, ChannelsLiveServerTestCase):
1✔
762
    """
763
    Tests for E2EE document collaboration between two browser sessions.
764
    """
765

766
    fixtures = [
1✔
767
        "initial_documenttemplates.json",
768
        "initial_styles.json",
769
    ]
770

771
    @classmethod
1✔
772
    def setUpClass(cls):
1✔
773
        super().setUpClass()
1✔
774
        driver_data = cls.get_drivers(2)
1✔
775
        cls.driver = driver_data["drivers"][0]
1✔
776
        cls.driver2 = driver_data["drivers"][1]
1✔
777
        cls.client = driver_data["clients"][0]
1✔
778
        cls.client2 = driver_data["clients"][1]
1✔
779
        cls.wait_time = driver_data["wait_time"]
1✔
780

781
    @classmethod
1✔
782
    def tearDownClass(cls):
1✔
783
        cls.driver.quit()
1✔
784
        cls.driver2.quit()
1✔
785
        super().tearDownClass()
1✔
786

787
    def setUp(self):
1✔
788
        self.user = self.create_user(
1✔
789
            username="E2EEWriter", email="writer@test.com", passtext="testpass"
790
        )
791
        self.login_user(self.user, self.driver, self.client)
1✔
792
        self.login_user(self.user, self.driver2, self.client2)
1✔
793
        super().setUp()
1✔
794

795
    def tearDown(self):
1✔
796
        super().tearDown()
1✔
797
        if "coverage" in sys.modules.keys():
1✔
798
            time.sleep(self.wait_time / 3)
1✔
799

800
    def create_e2ee_document_and_load_in_both(self, password="CollabPass1"):
1✔
801
        """
802
        Create an E2EE document in driver1 and load it in both drivers.
803
        Returns the Document object.
804
        """
805
        # Create via UI in driver1
806
        self.driver.get(self.live_server_url)
1✔
807
        WebDriverWait(self.driver, self.wait_time).until(
1✔
808
            EC.element_to_be_clickable(
809
                (By.CSS_SELECTOR, ".new_document button")
810
            )
811
        ).click()
812

813
        # Encryption choice dialog
814
        WebDriverWait(self.driver, self.wait_time).until(
1✔
815
            EC.presence_of_element_located((By.CSS_SELECTOR, ".ui-dialog"))
816
        )
817
        self.driver.find_element(By.ID, "e2ee").click()
1✔
818
        self.driver.find_element(
1✔
819
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
820
        ).click()
821

822
        time.sleep(1)  # Allow async operations to complete
1✔
823

824
        # Check if passphrase setup offer dialog appears and skip it
825
        try:
1✔
826
            skip_button = WebDriverWait(self.driver, 2).until(
1✔
827
                EC.element_to_be_clickable(
828
                    (By.XPATH, "//button[contains(text(), 'Skip for Now')]")
829
                )
830
            )
831
            skip_button.click()
1✔
832
        except TimeoutException:
×
833
            # Dialog didn't appear, proceed normally
834
            pass
×
835

836
        WebDriverWait(self.driver, self.wait_time).until(
1✔
837
            EC.presence_of_element_located((By.ID, "e2ee-new-password-input"))
838
        )
839
        self.driver.find_element(By.ID, "e2ee-new-password-input").send_keys(
1✔
840
            password
841
        )
842
        self.driver.find_element(
1✔
843
            By.ID, "e2ee-confirm-password-input"
844
        ).send_keys(password)
845
        self.driver.find_element(
1✔
846
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
847
        ).click()
848

849
        WebDriverWait(self.driver, self.wait_time).until(
1✔
850
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
851
        )
852

853
        doc_id = int(
1✔
854
            self.driver.current_url.split("/document/")[1].split("/")[0]
855
        )
856
        doc = Document.objects.get(id=doc_id)
1✔
857

858
        # Load in driver2 - will need password
859
        self.driver2.get(f"{self.live_server_url}/document/{doc_id}/")
1✔
860

861
        # Wait for password dialog in driver2
862
        WebDriverWait(self.driver2, self.wait_time).until(
1✔
863
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
864
        )
865
        self.driver2.find_element(By.ID, "e2ee-password-input").send_keys(
1✔
866
            password
867
        )
868
        self.driver2.find_element(
1✔
869
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
870
        ).click()
871

872
        WebDriverWait(self.driver2, self.wait_time).until(
1✔
873
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
874
        )
875

876
        return doc
1✔
877

878
    def test_e2ee_typing_collaboration(self):
1✔
879
        """
880
        Test that typing in an E2EE document is synchronized between
881
        two browsers.
882
        """
883
        self.create_e2ee_document_and_load_in_both(password="SyncPass1")
1✔
884

885
        # Type in driver1
886
        title_input = self.driver.find_element(By.CLASS_NAME, "doc-title")
1✔
887
        title_input.click()
1✔
888
        title_input.send_keys("Collaborative Title")
1✔
889

890
        # Type in driver2 body
891
        body_input2 = self.driver2.find_element(By.CLASS_NAME, "doc-body")
1✔
892
        body_input2.click()
1✔
893
        body_input2.send_keys("Hello from browser 2")
1✔
894

895
        # Wait for sync
896
        time.sleep(2)
1✔
897
        self.wait_for_doc_sync(self.driver, self.driver2)
1✔
898

899
        # Verify both see the same content
900
        title1 = self.driver.execute_script(
1✔
901
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
902
        )
903
        title2 = self.driver2.execute_script(
1✔
904
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
905
        )
906
        self.assertEqual(title1, title2)
1✔
907

908
        body1 = self.get_contents(self.driver)
1✔
909
        body2 = self.get_contents(self.driver2)
1✔
910
        self.assertEqual(body1, body2)
1✔
911
        self.assertIn("Hello from browser 2", body1)
1✔
912

913
    def test_e2ee_snapshot_persists_content(self):
1✔
914
        """
915
        Test that content typed in an E2EE document is persisted
916
        and can be retrieved after reload.
917
        """
918
        password = "PersistPass1"
1✔
919
        doc = self.create_e2ee_document_and_load_in_both(password=password)
1✔
920

921
        # Type content in driver1
922
        body_input = self.driver.find_element(By.CLASS_NAME, "doc-body")
1✔
923
        body_input.click()
1✔
924
        body_input.send_keys("Persistent encrypted text")
1✔
925

926
        # Wait for snapshot to be saved
927
        time.sleep(3)
1✔
928

929
        # Reload driver2
930
        self.driver2.get(f"{self.live_server_url}/document/{doc.id}/")
1✔
931

932
        # Clear sessionStorage on driver2 so we test password re-entry
933
        self.driver2.execute_script("window.sessionStorage.clear()")
1✔
934

935
        # Re-enter password
936
        WebDriverWait(self.driver2, self.wait_time).until(
1✔
937
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
938
        )
939
        self.driver2.find_element(By.ID, "e2ee-password-input").send_keys(
1✔
940
            password
941
        )
942
        self.driver2.find_element(
1✔
943
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
944
        ).click()
945

946
        WebDriverWait(self.driver2, self.wait_time).until(
1✔
947
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
948
        )
949

950
        # Verify content persisted
951
        body_text = self.driver2.execute_script(
1✔
952
            "return window.theApp.page.view.state.doc.child(5).textContent;"
953
        )
954
        self.assertIn("Persistent encrypted text", body_text)
1✔
955

956

957
@override_settings(E2EE_MODE="enabled")
1✔
958
@override_settings(E2EE_MODE="enabled")
1✔
959
class E2EEPersonalPassphraseTest(SeleniumHelper, ChannelsLiveServerTestCase):
1✔
960
    """
961
    Tests for Personal Passphrase & User-Level Key Management feature.
962
    Tests the UI flow for setting up personal passphrases and creating E2EE documents.
963
    """
964

965
    fixtures = [
1✔
966
        "initial_documenttemplates.json",
967
        "initial_styles.json",
968
    ]
969

970
    @classmethod
1✔
971
    def setUpClass(cls):
1✔
972
        super().setUpClass()
1✔
973
        driver_data = cls.get_drivers(1)
1✔
974
        cls.driver = driver_data["drivers"][0]
1✔
975
        cls.client = driver_data["clients"][0]
1✔
976
        cls.driver.implicitly_wait(driver_data["wait_time"])
1✔
977
        cls.wait_time = driver_data["wait_time"]
1✔
978

979
    @classmethod
1✔
980
    def tearDownClass(cls):
1✔
981
        cls.driver.quit()
1✔
982
        super().tearDownClass()
1✔
983

984
    def setUp(self):
1✔
985
        self.base_url = self.live_server_url
1✔
986
        self.user = self.create_user(
1✔
987
            username="PassphraseUser",
988
            email="passphrase@test.com",
989
            passtext="testpass",
990
        )
991
        self.login_user(self.user, self.driver, self.client)
1✔
992
        return super().setUp()
1✔
993

994
    def tearDown(self):
1✔
995
        self.driver.execute_script("window.localStorage.clear()")
1✔
996
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
997
        super().tearDown()
1✔
998
        if "coverage" in sys.modules.keys():
1✔
999
            time.sleep(self.wait_time / 3)
1✔
1000

1001
    def test_passphrase_setup_offer_appears_on_e2ee_creation(self):
1✔
1002
        """
1003
        Test that when creating a new E2EE document, users are offered
1004
        to set up a personal passphrase if they don't have one yet.
1005
        """
1006
        self.driver.get(self.base_url)
1✔
1007

1008
        # Click "Create new document"
1009
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1010
            EC.element_to_be_clickable(
1011
                (By.CSS_SELECTOR, ".new_document button")
1012
            )
1013
        ).click()
1014

1015
        # Encryption choice dialog
1016
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1017
            EC.presence_of_element_located((By.CSS_SELECTOR, ".ui-dialog"))
1018
        )
1019
        self.driver.find_element(By.ID, "e2ee").click()
1✔
1020
        self.driver.find_element(
1✔
1021
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
1022
        ).click()
1023

1024
        # Wait for passphrase setup offer dialog
1025
        time.sleep(1)
1✔
1026
        dialog_body = WebDriverWait(self.driver, self.wait_time).until(
1✔
1027
            EC.presence_of_element_located(
1028
                (By.CSS_SELECTOR, ".ui-dialog-content")
1029
            )
1030
        )
1031

1032
        # Check that the passphrase setup offer is shown
1033
        self.assertIn(
1✔
1034
            "personal passphrase",
1035
            dialog_body.text,
1036
            "Should offer to set up personal passphrase",
1037
        )
1038

1039
        # Verify there's a "Set Up Passphrase" button
1040
        buttons = self.driver.find_elements(
1✔
1041
            By.CSS_SELECTOR, ".ui-dialog-buttonpane .fw-button"
1042
        )
1043
        button_texts = [b.text for b in buttons]
1✔
1044
        self.assertTrue(
1✔
1045
            any("Set Up Passphrase" in t for t in button_texts),
1046
            "Should have 'Set Up Passphrase' button",
1047
        )
1048
        self.assertTrue(
1✔
1049
            any("Skip" in t for t in button_texts), "Should have 'Skip' button"
1050
        )
1051

1052
        # Click "Skip for Now"
1053
        for btn in buttons:
1✔
1054
            if "Skip" in btn.text:
1✔
1055
                btn.click()
1✔
1056
                break
1✔
1057

1058
        time.sleep(1)
1✔
1059

1060
        # Should then proceed to password dialog
1061
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1062
            EC.presence_of_element_located((By.ID, "e2ee-new-password-input"))
1063
        )
1064

1065
    def test_user_encryption_key_model_persists(self):
1✔
1066
        """
1067
        Test that the UserEncryptionKey model correctly stores and retrieves
1068
        user encryption data.
1069
        """
1070
        from user.models import UserEncryptionKey
1✔
1071
        import json
1✔
1072
        import base64
1✔
1073

1074
        # Create a UserEncryptionKey record
1075
        public_key = json.dumps(
1✔
1076
            {"kty": "RSA", "n": "test_n_value", "e": "AQAB"}
1077
        )
1078
        user_salt = b"1234567890123456"
1✔
1079
        encrypted_data = base64.b64encode(b"encrypted_test_data").decode()
1✔
1080

1081
        key_record = UserEncryptionKey.objects.create(
1✔
1082
            user=self.user,
1083
            public_key=public_key,
1084
            encrypted_master_key=encrypted_data,
1085
            encrypted_private_key=encrypted_data,
1086
            user_salt=user_salt,
1087
            user_iterations=600000,
1088
            encrypted_master_key_backup=encrypted_data,
1089
        )
1090

1091
        # Verify it was created and can be retrieved
1092
        self.assertIsNotNone(key_record.id)
1✔
1093
        retrieved = UserEncryptionKey.objects.get(user=self.user)
1✔
1094
        self.assertEqual(retrieved.user_iterations, 600000)
1✔
1095
        self.assertEqual(len(retrieved.user_salt), 16)
1✔
1096
        self.assertEqual(retrieved.public_key, public_key)
1✔
1097

1098
    def test_document_encryption_key_model_persists(self):
1✔
1099
        """
1100
        Test that DocumentEncryptionKey can track whether DEK is encrypted
1101
        with master key or public key.
1102
        """
1103
        from document.models import Document, DocumentEncryptionKey
1✔
1104
        import base64
1✔
1105

1106
        # Create an E2EE document
1107
        doc = Document.objects.create(
1✔
1108
            title="DEK Test Doc",
1109
            owner=self.user,
1110
            template_id=1,
1111
            e2ee=True,
1112
            e2ee_salt=b"salt1234567890ab",
1113
            e2ee_iterations=600000,
1114
        )
1115

1116
        encrypted_dek = base64.b64encode(b"encrypted_dek_data").decode()
1✔
1117

1118
        # Create DEK record encrypted with master key
1119
        DocumentEncryptionKey.objects.create(
1✔
1120
            document=doc,
1121
            holder=self.user,
1122
            encrypted_key=encrypted_dek,
1123
            encrypted_with_master_key=True,
1124
        )
1125

1126
        # Verify it was saved
1127
        retrieved = DocumentEncryptionKey.objects.get(document=doc)
1✔
1128
        self.assertTrue(retrieved.encrypted_with_master_key)
1✔
1129

1130
    def test_document_encryption_key_public_key_mode(self):
1✔
1131
        """
1132
        Test that DocumentEncryptionKey can be encrypted with public key
1133
        (for shared documents).
1134
        """
1135
        from document.models import Document, DocumentEncryptionKey
1✔
1136
        import base64
1✔
1137

1138
        doc = Document.objects.create(
1✔
1139
            title="Shared DEK Test Doc",
1140
            owner=self.user,
1141
            template_id=1,
1142
            e2ee=True,
1143
            e2ee_salt=b"salt1234567890ab",
1144
            e2ee_iterations=600000,
1145
        )
1146

1147
        encrypted_dek = base64.b64encode(b"public_key_encrypted_dek").decode()
1✔
1148

1149
        # Create DEK record encrypted with public key
1150
        DocumentEncryptionKey.objects.create(
1✔
1151
            document=doc,
1152
            holder=self.user,
1153
            encrypted_key=encrypted_dek,
1154
            encrypted_with_master_key=False,
1155
        )
1156

1157
        # Verify it tracks public key encryption
1158
        retrieved = DocumentEncryptionKey.objects.get(document=doc)
1✔
1159
        self.assertFalse(retrieved.encrypted_with_master_key)
1✔
1160

1161
    def test_bulk_get_user_document_encryption_keys(self):
1✔
1162
        """Test fetching all DocumentEncryptionKeys for a user."""
1163
        from django.contrib.auth import get_user_model
1✔
1164

1165
        User = get_user_model()
1✔
1166
        user2 = User.objects.create_user(
1✔
1167
            username="user2", email="user2@test.com", password="testpass"
1168
        )
1169

1170
        # Create multiple E2EE documents
1171
        doc1 = Document.objects.create(
1✔
1172
            title="Doc 1",
1173
            owner=self.user,
1174
            template_id=1,
1175
            e2ee=True,
1176
            e2ee_salt=b"salt1111111111ab",
1177
            e2ee_iterations=600000,
1178
        )
1179

1180
        doc2 = Document.objects.create(
1✔
1181
            title="Doc 2",
1182
            owner=self.user,
1183
            template_id=1,
1184
            e2ee=True,
1185
            e2ee_salt=b"salt2222222222ab",
1186
            e2ee_iterations=600000,
1187
        )
1188

1189
        # Create DEK records for the user
1190
        DocumentEncryptionKey.objects.create(
1✔
1191
            document=doc1,
1192
            holder=self.user,
1193
            encrypted_key=base64.b64encode(b"dek1").decode(),
1194
            encrypted_with_master_key=True,
1195
        )
1196

1197
        DocumentEncryptionKey.objects.create(
1✔
1198
            document=doc2,
1199
            holder=self.user,
1200
            encrypted_key=base64.b64encode(b"dek2").decode(),
1201
            encrypted_with_master_key=True,
1202
        )
1203

1204
        # Create DEK for other user (should not be returned)
1205
        DocumentEncryptionKey.objects.create(
1✔
1206
            document=doc1,
1207
            holder=user2,
1208
            encrypted_key=base64.b64encode(b"dek_other").decode(),
1209
            encrypted_with_master_key=False,
1210
        )
1211

1212
        # Call the endpoint
1213
        response = self.client.get(
1✔
1214
            "/api/document/encryption_key/get_all/",
1215
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
1216
        )
1217

1218
        self.assertEqual(response.status_code, 200)
1✔
1219
        data = response.json()
1✔
1220
        self.assertIn("keys", data)
1✔
1221

1222
        # Should have 2 keys (only for self.user)
1223
        self.assertEqual(len(data["keys"]), 2)
1✔
1224

1225
        # Verify document IDs
1226
        doc_ids = {key["document_id"] for key in data["keys"]}
1✔
1227
        self.assertEqual(doc_ids, {doc1.id, doc2.id})
1✔
1228

1229
        # Verify master key flag
1230
        for key in data["keys"]:
1✔
1231
            self.assertTrue(key["encrypted_with_master_key"])
1✔
1232

1233
    def test_automatic_key_sharing_with_passphrase_user(self):
1✔
1234
        """Test that sharing with a passphrase-enabled user does not create
1235
        an empty placeholder DEK. The frontend is responsible for encrypting
1236
        and saving the DEK with the recipient's public key."""
1237
        from django.contrib.auth import get_user_model
1✔
1238
        from user.models import UserEncryptionKey
1✔
1239

1240
        User = get_user_model()
1✔
1241
        recipient = User.objects.create_user(
1✔
1242
            username="recipient",
1243
            email="recipient@test.com",
1244
            password="testpass",
1245
        )
1246

1247
        # Create an E2EE document owned by self.user
1248
        doc = Document.objects.create(
1✔
1249
            title="E2EE Doc",
1250
            owner=self.user,
1251
            template_id=1,
1252
            e2ee=True,
1253
            e2ee_salt=b"salt1234567890ab",
1254
            e2ee_iterations=600000,
1255
        )
1256

1257
        # Create DEK for owner
1258
        DocumentEncryptionKey.objects.create(
1✔
1259
            document=doc,
1260
            holder=self.user,
1261
            encrypted_key=base64.b64encode(b"owner_dek").decode(),
1262
            encrypted_with_master_key=True,
1263
        )
1264

1265
        # Give recipient encryption keys (passphrase setup)
1266
        UserEncryptionKey.objects.create(
1✔
1267
            user=recipient, public_key='{"kty":"RSA"}'
1268
        )
1269

1270
        # Now share the document with recipient
1271
        import json
1✔
1272

1273
        response = self.client.post(
1✔
1274
            "/api/document/save_access_rights/",
1275
            json.dumps(
1276
                {
1277
                    "document_ids": [doc.id],
1278
                    "access_rights": [
1279
                        {
1280
                            "holder": {"id": recipient.id, "type": "user"},
1281
                            "rights": "read",
1282
                        }
1283
                    ],
1284
                }
1285
            ),
1286
            content_type="application/json",
1287
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
1288
        )
1289

1290
        self.assertEqual(response.status_code, 201)
1✔
1291

1292
        # Verify no placeholder DocumentEncryptionKey was created for recipient.
1293
        # The frontend must explicitly encrypt the DEK with the recipient's
1294
        # public key and call the document encryption key API.
1295
        recipient_deks = DocumentEncryptionKey.objects.filter(
1✔
1296
            document=doc, holder=recipient
1297
        )
1298
        self.assertEqual(recipient_deks.count(), 0)
1✔
1299

1300
        # Verify the access right was created
1301
        from django.contrib.contenttypes.models import ContentType
1✔
1302

1303
        user_ct = ContentType.objects.get(app_label="user", model="user")
1✔
1304
        ar = AccessRight.objects.filter(
1✔
1305
            document=doc, holder_id=recipient.id, holder_type=user_ct
1306
        )
1307
        self.assertEqual(ar.count(), 1)
1✔
1308

1309
    def test_passphrase_sharing_scenario(self):
1✔
1310
        """Test the complete sharing scenario:
1311
        - User A (passphrase) creates E2EE document
1312
        - A shares with C (passphrase) via public key encryption
1313
        - A shares with D (no passphrase) and sees password dialog
1314
        - A creates share link with password in URL
1315
        - D opens document with password
1316
        - Guest opens share link automatically
1317
        """
1318
        from django.contrib.auth import get_user_model
1✔
1319
        from user.models import UserEncryptionKey
1✔
1320

1321
        User = get_user_model()
1✔
1322

1323
        # Create users
1324
        user_a = self.user  # Already created in setUp
1✔
1325
        user_c = User.objects.create_user(
1✔
1326
            username="user_c",
1327
            email="c@test.com",
1328
            password="testpass",
1329
        )
1330
        user_d = User.objects.create_user(
1✔
1331
            username="user_d",
1332
            email="d@test.com",
1333
            password="testpass",
1334
        )
1335

1336
        # Add C and D as A's contacts
1337
        user_a.contacts.add(user_c, user_d)
1✔
1338

1339
        # Generate real crypto keys in the browser
1340
        keys = self.driver.execute_script(
1✔
1341
            """
1342
            return (async function() {
1343
                const aKeyPair = await crypto.subtle.generateKey(
1344
                    {name: "ECDH", namedCurve: "P-256"},
1345
                    true,
1346
                    ["deriveKey"]
1347
                );
1348
                const cKeyPair = await crypto.subtle.generateKey(
1349
                    {name: "ECDH", namedCurve: "P-256"},
1350
                    true,
1351
                    ["deriveKey"]
1352
                );
1353
                const masterKey = await crypto.subtle.generateKey(
1354
                    {name: "AES-GCM", length: 256},
1355
                    true,
1356
                    ["encrypt", "decrypt"]
1357
                );
1358
                const aPublicJwk = await crypto.subtle.exportKey("jwk", aKeyPair.publicKey);
1359
                const aPrivateJwk = await crypto.subtle.exportKey("jwk", aKeyPair.privateKey);
1360
                const cPublicJwk = await crypto.subtle.exportKey("jwk", cKeyPair.publicKey);
1361
                const masterRaw = await crypto.subtle.exportKey("raw", masterKey);
1362
                const masterBase64 = btoa(String.fromCharCode(...new Uint8Array(masterRaw)));
1363
                return {
1364
                    aPublicJwk: JSON.stringify(aPublicJwk),
1365
                    aPrivateJwk: JSON.stringify(aPrivateJwk),
1366
                    cPublicJwk: JSON.stringify(cPublicJwk),
1367
                    masterKeyBase64: masterBase64
1368
                };
1369
            })();
1370
        """
1371
        )
1372

1373
        # Create UserEncryptionKey records for A and C
1374
        UserEncryptionKey.objects.create(
1✔
1375
            user=user_a,
1376
            public_key=keys["aPublicJwk"],
1377
            encrypted_master_key="dummy_encrypted_mk",
1378
            encrypted_private_key="dummy_encrypted_sk",
1379
            user_salt=b"1234567890123456",
1380
            user_iterations=600000,
1381
            encrypted_master_key_backup="dummy_backup",
1382
        )
1383
        UserEncryptionKey.objects.create(
1✔
1384
            user=user_c,
1385
            public_key=keys["cPublicJwk"],
1386
            encrypted_master_key="dummy_encrypted_mk_c",
1387
            encrypted_private_key="dummy_encrypted_sk_c",
1388
            user_salt=b"1234567890123456",
1389
            user_iterations=600000,
1390
            encrypted_master_key_backup="dummy_backup_c",
1391
        )
1392

1393
        # Navigate to base URL and inject A's master key into sessionStorage
1394
        self.driver.get(self.base_url)
1✔
1395
        self.driver.execute_script(
1✔
1396
            "sessionStorage.setItem('e2ee_master_key', arguments[0]);"
1397
            + "sessionStorage.setItem('e2ee_private_key', arguments[1]);",
1398
            keys["masterKeyBase64"],
1399
            keys["aPrivateJwk"],
1400
        )
1401

1402
        # --- Step 1: A creates E2EE document with passphrase ---
1403
        self.driver.get(self.base_url)
1✔
1404
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1405
            EC.element_to_be_clickable(
1406
                (By.CSS_SELECTOR, ".new_document button")
1407
            )
1408
        ).click()
1409

1410
        # Encryption choice dialog
1411
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1412
            EC.presence_of_element_located((By.CSS_SELECTOR, ".ui-dialog"))
1413
        )
1414
        self.driver.find_element(By.ID, "e2ee").click()
1✔
1415
        self.driver.find_element(
1✔
1416
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
1417
        ).click()
1418

1419
        # Wait for editor to load (passphrase mode creates doc immediately)
1420
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1421
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
1422
        )
1423

1424
        # Add title and body so we can verify content later
1425
        title_el = self.driver.find_element(By.CSS_SELECTOR, ".doc-title")
1✔
1426
        title_el.click()
1✔
1427
        title_el.send_keys("Passphrase Share Test")
1✔
1428
        body_el = self.driver.find_element(By.CSS_SELECTOR, ".doc-body")
1✔
1429
        body_el.click()
1✔
1430
        body_el.send_keys("Shared content")
1✔
1431
        time.sleep(3)
1✔
1432

1433
        # Extract document ID from URL
1434
        url = self.driver.current_url
1✔
1435
        doc_id = int(url.split("/document/")[1].split("/")[0])
1✔
1436

1437
        # --- Step 2: A shares with C (passphrase) and D (no passphrase) ---
1438
        # Open File menu
1439
        self.driver.find_element(
1✔
1440
            By.CSS_SELECTOR, ".header-menu:nth-child(1) > .header-nav-item"
1441
        ).click()
1442
        time.sleep(0.5)
1✔
1443
        self.driver.find_element(
1✔
1444
            By.CSS_SELECTOR, "li:nth-child(1) > .fw-pulldown-item"
1445
        ).click()
1446

1447
        # Wait for share dialog
1448
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1449
            EC.presence_of_element_located((By.ID, "access-rights-dialog"))
1450
        )
1451

1452
        # Click on C in contacts list
1453
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1454
            EC.presence_of_element_located(
1455
                (By.CSS_SELECTOR, f".fw-checkable-td[data-id='{user_c.id}']")
1456
            )
1457
        ).click()
1458

1459
        # Click on D in contacts list
1460
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1461
            EC.presence_of_element_located(
1462
                (By.CSS_SELECTOR, f".fw-checkable-td[data-id='{user_d.id}']")
1463
            )
1464
        ).click()
1465

1466
        # Click Add button to add selected contacts to collaborators
1467
        self.driver.find_element(By.ID, "add-share-contact").click()
1✔
1468
        time.sleep(0.5)
1✔
1469

1470
        # Click Submit
1471
        self.driver.find_element(
1✔
1472
            By.CSS_SELECTOR,
1473
            "#access-rights-dialog ~ .ui-dialog-buttonpane .fw-dark",
1474
        ).click()
1475

1476
        # Wait for "Share Document Password" dialog to appear (for D)
1477
        password_dialog = WebDriverWait(self.driver, self.wait_time).until(
1✔
1478
            EC.presence_of_element_located(
1479
                (By.CSS_SELECTOR, "#share-password-dialog")
1480
            )
1481
        )
1482
        # The dialog content element IS #share-password-dialog itself
1483
        self.assertIn(
1✔
1484
            "don't have passphrase encryption",
1485
            password_dialog.text,
1486
            "Should show non-passphrase users in password share dialog",
1487
        )
1488

1489
        # Close the password dialog
1490
        self.driver.find_element(
1✔
1491
            By.CSS_SELECTOR,
1492
            "#share-password-dialog ~ .ui-dialog-buttonpane .fw-dark",
1493
        ).click()
1494
        time.sleep(1)
1✔
1495

1496
        # --- Step 3: Verify backend state for C ---
1497
        # C should have a DocumentEncryptionKey (encrypted with public key)
1498
        c_keys = DocumentEncryptionKey.objects.filter(
1✔
1499
            document_id=doc_id, holder=user_c
1500
        )
1501
        self.assertEqual(
1✔
1502
            c_keys.count(), 1, "C should have an encrypted document password"
1503
        )
1504
        self.assertFalse(c_keys.first().encrypted_with_master_key)
1✔
1505

1506
        # D should NOT have a DocumentEncryptionKey (password shared directly)
1507
        d_keys = DocumentEncryptionKey.objects.filter(
1✔
1508
            document_id=doc_id, holder=user_d
1509
        )
1510
        self.assertEqual(
1✔
1511
            d_keys.count(), 0, "D should not have a DocumentEncryptionKey"
1512
        )
1513

1514
        # --- Step 4: Create share link ---
1515
        # Open File menu again
1516
        self.driver.find_element(
1✔
1517
            By.CSS_SELECTOR, ".header-menu:nth-child(1) > .header-nav-item"
1518
        ).click()
1519
        time.sleep(0.5)
1✔
1520
        self.driver.find_element(
1✔
1521
            By.CSS_SELECTOR, "li:nth-child(1) > .fw-pulldown-item"
1522
        ).click()
1523

1524
        # Wait for share dialog
1525
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1526
            EC.presence_of_element_located((By.ID, "access-rights-dialog"))
1527
        )
1528

1529
        # Switch to Share link tab
1530
        self.driver.find_element(
1✔
1531
            By.CSS_SELECTOR, ".ui-tabs-nav .tab-link:nth-child(2) a"
1532
        ).click()
1533
        time.sleep(0.5)
1✔
1534

1535
        # Click Create new share link
1536
        self.driver.find_element(By.ID, "create-share-token-btn").click()
1✔
1537

1538
        # Wait for create dialog
1539
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1540
            EC.presence_of_element_located(
1541
                (By.ID, "create-share-token-dialog")
1542
            )
1543
        )
1544

1545
        # Verify password field is prefilled (auto-generated document password)
1546
        pass_input = self.driver.find_element(By.ID, "share-token-password")
1✔
1547
        prefilled_password = pass_input.get_attribute("value")
1✔
1548
        self.assertTrue(
1✔
1549
            len(prefilled_password) >= 43,
1550
            "Password field should be prefilled with document password",
1551
        )
1552

1553
        # Create the link
1554
        self.driver.find_element(
1✔
1555
            By.CSS_SELECTOR,
1556
            "#create-share-token-dialog ~ .ui-dialog-buttonpane .fw-dark",
1557
        ).click()
1558

1559
        # Wait for link to appear
1560
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1561
            EC.presence_of_element_located(
1562
                (By.CSS_SELECTOR, ".share-token-row")
1563
            )
1564
        )
1565

1566
        # Verify URL contains password fragment
1567
        url_input = self.driver.find_element(
1✔
1568
            By.CSS_SELECTOR, ".share-token-url-input"
1569
        )
1570
        share_url = url_input.get_attribute("value")
1✔
1571
        self.assertIn("#?password=", share_url)
1✔
1572
        from urllib.parse import unquote
1✔
1573

1574
        self.assertIn(prefilled_password, unquote(share_url))
1✔
1575

1576
        # Store share URL for later
1577
        share_link = share_url
1✔
1578

1579
        # Close share dialog
1580
        self.driver.find_element(
1✔
1581
            By.CSS_SELECTOR,
1582
            "#access-rights-dialog ~ .ui-dialog-buttonpane .fw-light",
1583
        ).click()
1584
        time.sleep(0.5)
1✔
1585

1586
        # --- Step 5: D opens document with password ---
1587
        # Log out A
1588
        self.logout_user(self.driver, self.client)
1✔
1589

1590
        # Log in as D
1591
        self.login_user(user_d, self.driver, self.client)
1✔
1592

1593
        # Clear sessionStorage so D has to enter password manually
1594
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
1595

1596
        # Navigate to overview
1597
        self.driver.get(self.base_url)
1✔
1598
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1599
            EC.presence_of_element_located(
1600
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
1601
            )
1602
        )
1603

1604
        # Click on the document
1605
        self.driver.find_element(
1✔
1606
            By.CSS_SELECTOR, ".fw-contents tbody tr a.fw-data-table-title"
1607
        ).click()
1608

1609
        # Wait for password dialog
1610
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1611
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
1612
        )
1613

1614
        # Enter the document password
1615
        self.driver.find_element(By.ID, "e2ee-password-input").send_keys(
1✔
1616
            prefilled_password
1617
        )
1618
        self.driver.find_element(
1✔
1619
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
1620
        ).click()
1621

1622
        # Wait for editor to load
1623
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1624
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
1625
        )
1626

1627
        # Verify content is accessible
1628
        title_text = self.driver.execute_script(
1✔
1629
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
1630
        )
1631
        self.assertIn("Passphrase Share Test", title_text)
1✔
1632

1633
        # --- Step 6: Guest opens share link ---
1634
        # Log out D
1635
        self.logout_user(self.driver, self.client)
1✔
1636

1637
        # Clear all storage
1638
        self.driver.execute_script("window.localStorage.clear()")
1✔
1639
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
1640

1641
        # Navigate to share link
1642
        self.driver.get(share_link)
1✔
1643

1644
        # Wait for editor to load (password is in URL fragment, should auto-decrypt)
1645
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1646
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
1647
        )
1648

1649
        # Verify content is accessible
1650
        title_text = self.driver.execute_script(
1✔
1651
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
1652
        )
1653
        self.assertIn("Passphrase Share Test", title_text)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc