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

fiduswriter / fiduswriter / 30812065708

03 Aug 2026 12:04PM UTC coverage: 88.175% (-0.1%) from 88.287%
30812065708

push

github

johanneswilm
e2ee improved robustness

10961 of 12431 relevant lines covered (88.17%)

5.78 hits per line

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

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

5
from testing.live_server 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
        doc_id = 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
        # Snapshot the current DB salt so we can detect when the server
385
        # has committed the re-encrypted snapshot after the password change.
386
        old_salt = Document.objects.get(id=doc_id).e2ee_salt
1✔
387

388
        # Open File menu
389
        self.driver.find_element(
1✔
390
            By.CSS_SELECTOR, ".header-menu:nth-child(1) > .header-nav-item"
391
        ).click()
392

393
        # Wait for the pulldown to be populated before reading items.
394
        WebDriverWait(self.driver, self.wait_time).until(
1✔
395
            EC.presence_of_element_located(
396
                (By.CSS_SELECTOR, "li > .fw-pulldown-item")
397
            )
398
        )
399

400
        # Click "Change password"
401
        menu_items = self.driver.find_elements(
1✔
402
            By.CSS_SELECTOR, "li > .fw-pulldown-item"
403
        )
404
        change_pass_item = None
1✔
405
        for item in menu_items:
1✔
406
            if "Change password" in item.text:
1✔
407
                change_pass_item = item
1✔
408
                break
1✔
409
        self.assertIsNotNone(
1✔
410
            change_pass_item, "Change password menu item should exist"
411
        )
412
        change_pass_item.click()
1✔
413

414
        # Wait for change password dialog
415
        WebDriverWait(self.driver, self.wait_time).until(
1✔
416
            EC.presence_of_element_located(
417
                (By.ID, "e2ee-current-password-input")
418
            )
419
        )
420

421
        # Enter current and new passwords
422
        # The current password field may be prefilled from sessionStorage,
423
        # so clear it first before entering the test password.
424
        current_pass_input = self.driver.find_element(
1✔
425
            By.ID, "e2ee-current-password-input"
426
        )
427
        current_pass_input.clear()
1✔
428
        current_pass_input.send_keys(old_password)
1✔
429
        self.driver.find_element(By.ID, "e2ee-new-password-input").send_keys(
1✔
430
            new_password
431
        )
432
        self.driver.find_element(
1✔
433
            By.ID, "e2ee-confirm-password-input"
434
        ).send_keys(new_password)
435

436
        # Plant a MutationObserver *before* clicking so we cannot miss the
437
        # success or error alert even if it appears and disappears between two
438
        # Selenium polls. The observer sets persistent JS flags the moment any
439
        # .alerts-success or .alerts-error node is inserted anywhere under
440
        # document.body.
441
        self.driver.execute_script(
1✔
442
            """
443
            window._e2eeSuccessAlertSeen = false;
444
            window._e2eeErrorAlertSeen = false;
445
            window._e2eeAlertText = '';
446
            (new MutationObserver(function(mutations) {
447
                mutations.forEach(function(m) {
448
                    m.addedNodes.forEach(function(node) {
449
                        if (node.nodeType === 1 && node.classList) {
450
                            if (node.classList.contains('alerts-success')) {
451
                                window._e2eeSuccessAlertSeen = true;
452
                            }
453
                            if (node.classList.contains('alerts-error')) {
454
                                window._e2eeErrorAlertSeen = true;
455
                                window._e2eeAlertText = node.textContent || '';
456
                            }
457
                        }
458
                    });
459
                });
460
            })).observe(document.body, {childList: true, subtree: true});
461
        """
462
        )
463

464
        # Click Change Password
465
        WebDriverWait(self.driver, self.wait_time).until(
1✔
466
            EC.element_to_be_clickable(
467
                (By.CSS_SELECTOR, ".ui-dialog .fw-dark")
468
            )
469
        ).click()
470

471
        # Wait for the flag set by the MutationObserver above.
472
        # Password change involves deriving the new key at 600 000 iterations,
473
        # which can take 20-30 s on a slow CI runner, so use a generous timeout.
474
        try:
1✔
475
            WebDriverWait(self.driver, self.wait_time * 6).until(
1✔
476
                lambda d: d.execute_script(
477
                    "return window._e2eeSuccessAlertSeen === true || "
478
                    "window._e2eeErrorAlertSeen === true"
479
                )
480
            )
481
        except TimeoutException:
×
482
            self.fail(
×
483
                "Password change did not produce a success or error alert "
484
                "within the timeout."
485
            )
486

487
        error_seen = self.driver.execute_script(
1✔
488
            "return window._e2eeErrorAlertSeen"
489
        )
490
        error_text = self.driver.execute_script("return window._e2eeAlertText")
1✔
491
        if error_seen:
1✔
492
            self.fail(f"Password change failed with error alert: {error_text}")
×
493

494
        # Now poll the database until the server consumer has received the
495
        # WebSocket snapshot and committed the new salt.  This is the
496
        # authoritative signal that re-opening the document will use the
497
        # new key rather than the old one.
498
        WebDriverWait(self.driver, self.wait_time).until(
1✔
499
            lambda _: Document.objects.get(id=doc_id).e2ee_salt != old_salt
500
        )
501

502
        # Verify the document still loads and content is preserved
503
        self.driver.get(self.base_url)
1✔
504
        WebDriverWait(self.driver, self.wait_time).until(
1✔
505
            EC.presence_of_element_located(
506
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
507
            )
508
        )
509

510
        # Clear sessionStorage so we test the new password entry flow
511
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
512

513
        self.driver.find_element(
1✔
514
            By.CSS_SELECTOR, ".fw-contents tbody tr a.fw-data-table-title"
515
        ).click()
516

517
        # Enter NEW password
518
        WebDriverWait(self.driver, self.wait_time).until(
1✔
519
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
520
        )
521

522
        self.driver.find_element(By.ID, "e2ee-password-input").send_keys(
1✔
523
            new_password
524
        )
525
        self.driver.find_element(
1✔
526
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
527
        ).click()
528

529
        WebDriverWait(self.driver, self.wait_time).until(
1✔
530
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
531
        )
532

533
        title_text = self.driver.execute_script(
1✔
534
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
535
        )
536
        self.assertIn("Change Pass", title_text)
1✔
537

538
    def test_session_storage_skips_password_dialog(self):
1✔
539
        """
540
        Test that reopening an E2EE document in the same browser session
541
        does not prompt for the password again when the key is cached in
542
        sessionStorage.
543
        """
544
        password = "SessionPass1"
1✔
545
        self.create_e2ee_document_via_ui(password=password)
1✔
546
        self.add_title_and_body(title="Session Test", body="session content")
1✔
547

548
        # Navigate away to overview
549
        self.driver.get(self.base_url)
1✔
550
        WebDriverWait(self.driver, self.wait_time).until(
1✔
551
            EC.presence_of_element_located(
552
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
553
            )
554
        )
555

556
        # Click on the document to reopen it
557
        self.driver.find_element(
1✔
558
            By.CSS_SELECTOR, ".fw-contents tbody tr a.fw-data-table-title"
559
        ).click()
560

561
        # The editor should load directly without a password dialog
562
        # because the key is cached in sessionStorage.
563
        WebDriverWait(self.driver, self.wait_time).until(
1✔
564
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
565
        )
566

567
        # Give the decrypted document content a moment to render
568
        time.sleep(1)
1✔
569

570
        # Verify the content is visible
571
        title_text = self.driver.execute_script(
1✔
572
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
573
        )
574
        self.assertIn("Session Test", title_text)
1✔
575

576
    def test_logout_clears_e2ee_session_storage(self):
1✔
577
        """
578
        Test that logging out via the UI clears all E2EE-related data
579
        from sessionStorage.
580
        """
581
        password = "LogoutPass1"
1✔
582
        self.create_e2ee_document_via_ui(password=password)
1✔
583
        self.add_title_and_body(title="Logout Test", body="content here")
1✔
584

585
        # Verify that E2EE data was stored in sessionStorage
586
        e2ee_keys_before = self.driver.execute_script(
1✔
587
            "return Object.keys(sessionStorage).filter(k => k.startsWith('e2ee_'));"
588
        )
589
        self.assertTrue(
1✔
590
            len(e2ee_keys_before) > 0,
591
            "E2EE items should be in sessionStorage after creating/opening document",
592
        )
593

594
        # Close the editor and return to overview so the preferences menu is available
595
        self.driver.find_element(By.ID, "close-document-top").click()
1✔
596
        WebDriverWait(self.driver, self.wait_time).until(
1✔
597
            EC.element_to_be_clickable((By.ID, "preferences-btn"))
598
        )
599

600
        # Open the user preferences pulldown and click logout
601
        self.driver.find_element(By.ID, "preferences-btn").click()
1✔
602
        WebDriverWait(self.driver, self.wait_time).until(
1✔
603
            EC.element_to_be_clickable(
604
                (By.XPATH, '//*[normalize-space()="Log out"]')
605
            )
606
        ).click()
607

608
        # Wait for redirect to login page
609
        WebDriverWait(self.driver, self.wait_time).until(
1✔
610
            EC.presence_of_element_located((By.ID, "id-login"))
611
        )
612

613
        # Verify that no E2EE items remain in sessionStorage
614
        e2ee_keys_after = self.driver.execute_script(
1✔
615
            "return Object.keys(sessionStorage).filter(k => k.startsWith('e2ee_'));"
616
        )
617
        self.assertEqual(
1✔
618
            len(e2ee_keys_after),
619
            0,
620
            f"E2EE sessionStorage items should be cleared after logout, found: {e2ee_keys_after}",
621
        )
622

623

624
@override_settings(E2EE_MODE="enabled")
1✔
625
class E2EEAccessRightsTest(SeleniumHelper, ChannelsLiveServerTestCase):
1✔
626
    """
627
    Tests for E2EE-specific access rights behavior:
628
    - Warning banner in share dialog
629
    - Share link creation with password in URL fragment
630
    - Filtered access rights dropdown
631
    """
632

633
    fixtures = [
1✔
634
        "initial_documenttemplates.json",
635
        "initial_styles.json",
636
    ]
637

638
    @classmethod
1✔
639
    def setUpClass(cls):
1✔
640
        super().setUpClass()
1✔
641
        driver_data = cls.get_drivers(1)
1✔
642
        cls.driver = driver_data["drivers"][0]
1✔
643
        cls.client = driver_data["clients"][0]
1✔
644
        cls.driver.implicitly_wait(driver_data["wait_time"])
1✔
645
        cls.wait_time = driver_data["wait_time"]
1✔
646

647
    @classmethod
1✔
648
    def tearDownClass(cls):
1✔
649
        cls.driver.quit()
1✔
650
        super().tearDownClass()
1✔
651

652
    def setUp(self):
1✔
653
        self.base_url = self.live_server_url
1✔
654
        self.user = self.create_user(
1✔
655
            username="E2EEOwner", email="owner@test.com", passtext="testpass"
656
        )
657
        self.login_user(self.user, self.driver, self.client)
1✔
658
        return super().setUp()
1✔
659

660
    def tearDown(self):
1✔
661
        self.driver.execute_script("window.localStorage.clear()")
1✔
662
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
663
        super().tearDown()
1✔
664
        if "coverage" in sys.modules.keys():
1✔
665
            time.sleep(self.wait_time / 3)
1✔
666

667
    def create_e2ee_document_via_ui(self, password="SecurePass123"):
1✔
668
        """Helper to create an E2EE document through the UI."""
669
        self.driver.get(self.base_url)
1✔
670
        WebDriverWait(self.driver, self.wait_time).until(
1✔
671
            EC.element_to_be_clickable(
672
                (By.CSS_SELECTOR, ".new_document button")
673
            )
674
        ).click()
675

676
        # Encryption choice dialog
677
        WebDriverWait(self.driver, self.wait_time).until(
1✔
678
            EC.presence_of_element_located((By.CSS_SELECTOR, ".ui-dialog"))
679
        )
680
        self.driver.find_element(By.ID, "e2ee").click()
1✔
681
        self.driver.find_element(
1✔
682
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
683
        ).click()
684

685
        # After clicking Create, we may get a passphrase setup offer dialog
686
        time.sleep(1)
1✔
687
        try:
1✔
688
            # Look for "Skip for Now" button which would indicate passphrase offer dialog
689
            skip_buttons = self.driver.find_elements(
1✔
690
                By.CSS_SELECTOR, ".ui-dialog-buttonpane .fw-button"
691
            )
692
            for btn in skip_buttons:
1✔
693
                if "Skip" in btn.text:
1✔
694
                    # This is the passphrase offer dialog, skip it
695
                    btn.click()
1✔
696
                    time.sleep(0.5)
1✔
697
                    break
1✔
698
        except Exception:
×
699
            # No passphrase offer dialog, that's fine
700
            pass
×
701

702
        WebDriverWait(self.driver, self.wait_time).until(
1✔
703
            EC.presence_of_element_located((By.ID, "e2ee-new-password-input"))
704
        )
705
        self.driver.find_element(By.ID, "e2ee-new-password-input").send_keys(
1✔
706
            password
707
        )
708
        self.driver.find_element(
1✔
709
            By.ID, "e2ee-confirm-password-input"
710
        ).send_keys(password)
711
        self.driver.find_element(
1✔
712
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
713
        ).click()
714

715
        WebDriverWait(self.driver, self.wait_time).until(
1✔
716
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
717
        )
718

719
        doc_id = int(
1✔
720
            self.driver.current_url.split("/document/")[1].split("/")[0]
721
        )
722
        return doc_id
1✔
723

724
    def test_share_dialog_shows_e2ee_warning(self):
1✔
725
        """
726
        Test that the access rights dialog shows an E2EE warning banner
727
        when sharing an encrypted document.
728
        """
729
        self.create_e2ee_document_via_ui()
1✔
730

731
        # Open File menu → Share
732
        self.driver.find_element(
1✔
733
            By.CSS_SELECTOR, ".header-menu:nth-child(1) > .header-nav-item"
734
        ).click()
735
        time.sleep(0.5)
1✔
736
        self.driver.find_element(
1✔
737
            By.CSS_SELECTOR, "li:nth-child(1) > .fw-pulldown-item"
738
        ).click()
739

740
        # Wait for access rights dialog
741
        WebDriverWait(self.driver, self.wait_time).until(
1✔
742
            EC.presence_of_element_located((By.ID, "access-rights-dialog"))
743
        )
744

745
        # Check for E2EE warning banner
746
        warning = self.driver.find_element(
1✔
747
            By.CSS_SELECTOR, ".e2ee-access-rights-warning"
748
        )
749
        self.assertIsNotNone(warning)
1✔
750
        self.assertIn("secure channel", warning.text)
1✔
751

752
    def test_share_link_with_password(self):
1✔
753
        """
754
        Test creating a share link that includes the document password
755
        in the URL fragment.
756
        """
757
        doc_password = "DocPass123"
1✔
758
        self.create_e2ee_document_via_ui(password=doc_password)
1✔
759

760
        # Open File menu → Share
761
        self.driver.find_element(
1✔
762
            By.CSS_SELECTOR, ".header-menu:nth-child(1) > .header-nav-item"
763
        ).click()
764
        time.sleep(0.5)
1✔
765
        self.driver.find_element(
1✔
766
            By.CSS_SELECTOR, "li:nth-child(1) > .fw-pulldown-item"
767
        ).click()
768

769
        # Wait for dialog
770
        WebDriverWait(self.driver, self.wait_time).until(
1✔
771
            EC.presence_of_element_located((By.ID, "access-rights-dialog"))
772
        )
773

774
        # Switch to "Share link" tab
775
        self.driver.find_element(
1✔
776
            By.CSS_SELECTOR, ".ui-tabs-nav .tab-link:nth-child(2) a"
777
        ).click()
778
        time.sleep(0.5)
1✔
779

780
        # Click "Create new share link"
781
        self.driver.find_element(By.ID, "create-share-token-btn").click()
1✔
782

783
        # Wait for create share token dialog
784
        WebDriverWait(self.driver, self.wait_time).until(
1✔
785
            EC.presence_of_element_located(
786
                (By.ID, "create-share-token-dialog")
787
            )
788
        )
789

790
        # Verify password field exists for E2EE documents
791
        pass_input = self.driver.find_element(By.ID, "share-token-password")
1✔
792
        self.assertIsNotNone(pass_input)
1✔
793

794
        # Enter password to include in link
795
        pass_input.send_keys(doc_password)
1✔
796

797
        # Create the link
798
        self.driver.find_element(
1✔
799
            By.CSS_SELECTOR,
800
            "#create-share-token-dialog ~ .ui-dialog-buttonpane .fw-dark",
801
        ).click()
802

803
        # Wait for the link to appear in the list
804
        WebDriverWait(self.driver, self.wait_time).until(
1✔
805
            EC.presence_of_element_located(
806
                (By.CSS_SELECTOR, ".share-token-row")
807
            )
808
        )
809

810
        # Verify the URL contains the password fragment
811
        url_input = self.driver.find_element(
1✔
812
            By.CSS_SELECTOR, ".share-token-url-input"
813
        )
814
        share_url = url_input.get_attribute("value")
1✔
815
        self.assertIn("#?password=", share_url)
1✔
816
        self.assertIn(doc_password, share_url)
1✔
817

818

819
@override_settings(E2EE_MODE="enabled")
1✔
820
class E2EECollaborationTest(EditorHelper, ChannelsLiveServerTestCase):
1✔
821
    """
822
    Tests for E2EE document collaboration between two browser sessions.
823
    """
824

825
    fixtures = [
1✔
826
        "initial_documenttemplates.json",
827
        "initial_styles.json",
828
    ]
829

830
    @classmethod
1✔
831
    def setUpClass(cls):
1✔
832
        super().setUpClass()
1✔
833
        driver_data = cls.get_drivers(2)
1✔
834
        cls.driver = driver_data["drivers"][0]
1✔
835
        cls.driver2 = driver_data["drivers"][1]
1✔
836
        cls.client = driver_data["clients"][0]
1✔
837
        cls.client2 = driver_data["clients"][1]
1✔
838
        cls.wait_time = driver_data["wait_time"]
1✔
839

840
    @classmethod
1✔
841
    def tearDownClass(cls):
1✔
842
        cls.driver.quit()
1✔
843
        cls.driver2.quit()
1✔
844
        super().tearDownClass()
1✔
845

846
    def setUp(self):
1✔
847
        self.user = self.create_user(
1✔
848
            username="E2EEWriter", email="writer@test.com", passtext="testpass"
849
        )
850
        self.login_user(self.user, self.driver, self.client)
1✔
851
        self.login_user(self.user, self.driver2, self.client2)
1✔
852
        super().setUp()
1✔
853

854
    def tearDown(self):
1✔
855
        super().tearDown()
1✔
856
        if "coverage" in sys.modules.keys():
1✔
857
            time.sleep(self.wait_time / 3)
1✔
858

859
    def create_e2ee_document_and_load_in_both(self, password="CollabPass1"):
1✔
860
        """
861
        Create an E2EE document in driver1 and load it in both drivers.
862
        Returns the Document object.
863
        """
864
        # Create via UI in driver1
865
        self.driver.get(self.live_server_url)
1✔
866
        WebDriverWait(self.driver, self.wait_time).until(
1✔
867
            EC.element_to_be_clickable(
868
                (By.CSS_SELECTOR, ".new_document button")
869
            )
870
        ).click()
871

872
        # Encryption choice dialog
873
        WebDriverWait(self.driver, self.wait_time).until(
1✔
874
            EC.presence_of_element_located((By.CSS_SELECTOR, ".ui-dialog"))
875
        )
876
        self.driver.find_element(By.ID, "e2ee").click()
1✔
877
        self.driver.find_element(
1✔
878
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
879
        ).click()
880

881
        time.sleep(1)  # Allow async operations to complete
1✔
882

883
        # Check if passphrase setup offer dialog appears and skip it
884
        try:
1✔
885
            skip_button = WebDriverWait(self.driver, 2).until(
1✔
886
                EC.element_to_be_clickable(
887
                    (By.XPATH, "//button[contains(text(), 'Skip for Now')]")
888
                )
889
            )
890
            skip_button.click()
1✔
891
        except TimeoutException:
×
892
            # Dialog didn't appear, proceed normally
893
            pass
×
894

895
        WebDriverWait(self.driver, self.wait_time).until(
1✔
896
            EC.presence_of_element_located((By.ID, "e2ee-new-password-input"))
897
        )
898
        self.driver.find_element(By.ID, "e2ee-new-password-input").send_keys(
1✔
899
            password
900
        )
901
        self.driver.find_element(
1✔
902
            By.ID, "e2ee-confirm-password-input"
903
        ).send_keys(password)
904
        self.driver.find_element(
1✔
905
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
906
        ).click()
907

908
        WebDriverWait(self.driver, self.wait_time).until(
1✔
909
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
910
        )
911

912
        doc_id = int(
1✔
913
            self.driver.current_url.split("/document/")[1].split("/")[0]
914
        )
915
        doc = Document.objects.get(id=doc_id)
1✔
916

917
        # Load in driver2 - will need password
918
        self.driver2.get(f"{self.live_server_url}/document/{doc_id}/")
1✔
919

920
        # Wait for password dialog in driver2
921
        WebDriverWait(self.driver2, self.wait_time).until(
1✔
922
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
923
        )
924
        self.driver2.find_element(By.ID, "e2ee-password-input").send_keys(
1✔
925
            password
926
        )
927
        self.driver2.find_element(
1✔
928
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
929
        ).click()
930

931
        WebDriverWait(self.driver2, self.wait_time).until(
1✔
932
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
933
        )
934

935
        return doc
1✔
936

937
    def test_e2ee_typing_collaboration(self):
1✔
938
        """
939
        Test that typing in an E2EE document is synchronized between
940
        two browsers.
941
        """
942
        self.create_e2ee_document_and_load_in_both(password="SyncPass1")
1✔
943

944
        # Type in driver1
945
        title_input = self.driver.find_element(By.CLASS_NAME, "doc-title")
1✔
946
        title_input.click()
1✔
947
        title_input.send_keys("Collaborative Title")
1✔
948

949
        # Type in driver2 body
950
        body_input2 = self.driver2.find_element(By.CLASS_NAME, "doc-body")
1✔
951
        body_input2.click()
1✔
952
        body_input2.send_keys("Hello from browser 2")
1✔
953

954
        # Wait for sync
955
        time.sleep(2)
1✔
956
        self.wait_for_doc_sync(self.driver, self.driver2)
1✔
957

958
        # Verify both see the same content
959
        title1 = self.driver.execute_script(
1✔
960
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
961
        )
962
        title2 = self.driver2.execute_script(
1✔
963
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
964
        )
965
        self.assertEqual(title1, title2)
1✔
966

967
        body1 = self.get_contents(self.driver)
1✔
968
        body2 = self.get_contents(self.driver2)
1✔
969
        self.assertEqual(body1, body2)
1✔
970
        self.assertIn("Hello from browser 2", body1)
1✔
971

972
    def test_e2ee_snapshot_persists_content(self):
1✔
973
        """
974
        Test that content typed in an E2EE document is persisted
975
        and can be retrieved after reload.
976
        """
977
        password = "PersistPass1"
1✔
978
        doc = self.create_e2ee_document_and_load_in_both(password=password)
1✔
979

980
        # Type content in driver1
981
        body_input = self.driver.find_element(By.CLASS_NAME, "doc-body")
1✔
982
        body_input.click()
1✔
983
        body_input.send_keys("Persistent encrypted text")
1✔
984

985
        # Wait for snapshot to be saved
986
        time.sleep(3)
1✔
987

988
        # Reload driver2
989
        self.driver2.get(f"{self.live_server_url}/document/{doc.id}/")
1✔
990

991
        # Clear sessionStorage on driver2 so we test password re-entry
992
        self.driver2.execute_script("window.sessionStorage.clear()")
1✔
993

994
        # Re-enter password
995
        WebDriverWait(self.driver2, self.wait_time).until(
1✔
996
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
997
        )
998
        self.driver2.find_element(By.ID, "e2ee-password-input").send_keys(
1✔
999
            password
1000
        )
1001
        self.driver2.find_element(
1✔
1002
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
1003
        ).click()
1004

1005
        WebDriverWait(self.driver2, self.wait_time).until(
1✔
1006
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
1007
        )
1008

1009
        # Verify content persisted
1010
        body_text = self.driver2.execute_script(
1✔
1011
            "return window.theApp.page.view.state.doc.child(5).textContent;"
1012
        )
1013
        self.assertIn("Persistent encrypted text", body_text)
1✔
1014

1015

1016
@override_settings(E2EE_MODE="enabled")
1✔
1017
@override_settings(E2EE_MODE="enabled")
1✔
1018
class E2EEPersonalPassphraseTest(SeleniumHelper, ChannelsLiveServerTestCase):
1✔
1019
    """
1020
    Tests for Personal Passphrase & User-Level Key Management feature.
1021
    Tests the UI flow for setting up personal passphrases and creating E2EE documents.
1022
    """
1023

1024
    fixtures = [
1✔
1025
        "initial_documenttemplates.json",
1026
        "initial_styles.json",
1027
    ]
1028

1029
    @classmethod
1✔
1030
    def setUpClass(cls):
1✔
1031
        super().setUpClass()
1✔
1032
        driver_data = cls.get_drivers(1)
1✔
1033
        cls.driver = driver_data["drivers"][0]
1✔
1034
        cls.client = driver_data["clients"][0]
1✔
1035
        cls.driver.implicitly_wait(driver_data["wait_time"])
1✔
1036
        cls.wait_time = driver_data["wait_time"]
1✔
1037

1038
    @classmethod
1✔
1039
    def tearDownClass(cls):
1✔
1040
        cls.driver.quit()
1✔
1041
        super().tearDownClass()
1✔
1042

1043
    def setUp(self):
1✔
1044
        self.base_url = self.live_server_url
1✔
1045
        self.user = self.create_user(
1✔
1046
            username="PassphraseUser",
1047
            email="passphrase@test.com",
1048
            passtext="testpass",
1049
        )
1050
        self.login_user(self.user, self.driver, self.client)
1✔
1051
        return super().setUp()
1✔
1052

1053
    def tearDown(self):
1✔
1054
        self.driver.execute_script("window.localStorage.clear()")
1✔
1055
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
1056
        super().tearDown()
1✔
1057
        if "coverage" in sys.modules.keys():
1✔
1058
            time.sleep(self.wait_time / 3)
1✔
1059

1060
    def test_passphrase_setup_offer_appears_on_e2ee_creation(self):
1✔
1061
        """
1062
        Test that when creating a new E2EE document, users are offered
1063
        to set up a personal passphrase if they don't have one yet.
1064
        """
1065
        self.driver.get(self.base_url)
1✔
1066

1067
        # Click "Create new document"
1068
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1069
            EC.element_to_be_clickable(
1070
                (By.CSS_SELECTOR, ".new_document button")
1071
            )
1072
        ).click()
1073

1074
        # Encryption choice dialog
1075
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1076
            EC.presence_of_element_located((By.CSS_SELECTOR, ".ui-dialog"))
1077
        )
1078
        self.driver.find_element(By.ID, "e2ee").click()
1✔
1079
        self.driver.find_element(
1✔
1080
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
1081
        ).click()
1082

1083
        # Wait for passphrase setup offer dialog
1084
        time.sleep(1)
1✔
1085
        dialog_body = WebDriverWait(self.driver, self.wait_time).until(
1✔
1086
            EC.presence_of_element_located(
1087
                (By.CSS_SELECTOR, ".ui-dialog-content")
1088
            )
1089
        )
1090

1091
        # Check that the passphrase setup offer is shown
1092
        self.assertIn(
1✔
1093
            "personal passphrase",
1094
            dialog_body.text,
1095
            "Should offer to set up personal passphrase",
1096
        )
1097

1098
        # Verify there's a "Set Up Passphrase" button
1099
        buttons = self.driver.find_elements(
1✔
1100
            By.CSS_SELECTOR, ".ui-dialog-buttonpane .fw-button"
1101
        )
1102
        button_texts = [b.text for b in buttons]
1✔
1103
        self.assertTrue(
1✔
1104
            any("Set Up Passphrase" in t for t in button_texts),
1105
            "Should have 'Set Up Passphrase' button",
1106
        )
1107
        self.assertTrue(
1✔
1108
            any("Skip" in t for t in button_texts), "Should have 'Skip' button"
1109
        )
1110

1111
        # Click "Skip for Now"
1112
        for btn in buttons:
1✔
1113
            if "Skip" in btn.text:
1✔
1114
                btn.click()
1✔
1115
                break
1✔
1116

1117
        time.sleep(1)
1✔
1118

1119
        # Should then proceed to password dialog
1120
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1121
            EC.presence_of_element_located((By.ID, "e2ee-new-password-input"))
1122
        )
1123

1124
    def _wait_for_dialog_or_alert(self, dialog_id, message):
1✔
1125
        """
1126
        Wait for a dialog to appear, failing fast with a clear message if an
1127
        error alert is shown instead. Crypto-heavy flows can take a while on
1128
        slow CI runners, so use a generous timeout.
1129
        """
1130
        try:
1✔
1131
            WebDriverWait(self.driver, self.wait_time * 5).until(
1✔
1132
                EC.presence_of_element_located((By.ID, dialog_id))
1133
            )
1134
        except TimeoutException:
×
1135
            alerts = self.driver.find_elements(
×
1136
                By.CSS_SELECTOR, ".alerts-error"
1137
            )
1138
            alert_texts = [a.text for a in alerts if a.text]
×
1139
            if alert_texts:
×
1140
                self.fail(
×
1141
                    f"{message} failed with error alert: {alert_texts[0]}"
1142
                )
1143
            self.fail(f"{message} timed out waiting for dialog #{dialog_id}")
×
1144

1145
    def _complete_passphrase_setup(self, passphrase="MySecurePassphrase123"):
1✔
1146
        """
1147
        Helper to go through the full passphrase setup UI flow.
1148
        Returns the document ID of the E2EE document created after setup.
1149
        """
1150
        from user.models import UserEncryptionKey
1✔
1151

1152
        self.driver.get(self.base_url)
1✔
1153

1154
        # Click "Create new document"
1155
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1156
            EC.element_to_be_clickable(
1157
                (By.CSS_SELECTOR, ".new_document button")
1158
            )
1159
        ).click()
1160

1161
        # Encryption choice dialog
1162
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1163
            EC.presence_of_element_located((By.CSS_SELECTOR, ".ui-dialog"))
1164
        )
1165
        self.driver.find_element(By.ID, "e2ee").click()
1✔
1166
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1167
            EC.element_to_be_clickable(
1168
                (By.CSS_SELECTOR, ".ui-dialog .fw-dark")
1169
            )
1170
        ).click()
1171

1172
        # Wait for passphrase setup offer dialog and click "Set Up Passphrase"
1173
        setup_btn = WebDriverWait(self.driver, self.wait_time).until(
1✔
1174
            EC.element_to_be_clickable(
1175
                (
1176
                    By.XPATH,
1177
                    "//div[contains(@class, 'ui-dialog')]//button["
1178
                    "contains(text(), 'Set Up Passphrase')]",
1179
                )
1180
            )
1181
        )
1182
        setup_btn.click()
1✔
1183

1184
        # Wait for passphrase setup dialog
1185
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1186
            EC.presence_of_element_located((By.ID, "e2ee-setup-passphrase"))
1187
        )
1188

1189
        # Enter passphrase and confirmation
1190
        self.driver.find_element(By.ID, "e2ee-passphrase-input").send_keys(
1✔
1191
            passphrase
1192
        )
1193
        self.driver.find_element(
1✔
1194
            By.ID, "e2ee-confirm-passphrase-input"
1195
        ).send_keys(passphrase)
1196

1197
        # Click "Set Up Encryption"
1198
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1199
            EC.element_to_be_clickable(
1200
                (
1201
                    By.CSS_SELECTOR,
1202
                    "#e2ee-setup-passphrase ~ .ui-dialog-buttonpane .fw-dark",
1203
                )
1204
            )
1205
        ).click()
1206

1207
        # Wait for recovery key dialog. Setup involves PBKDF2 at 600k
1208
        # iterations plus several encryption operations, so this can be slow.
1209
        self._wait_for_dialog_or_alert(
1✔
1210
            "e2ee-recovery-key",
1211
            "Passphrase setup",
1212
        )
1213

1214
        # Verify a recovery key was generated and shown
1215
        recovery_key = self.driver.find_element(
1✔
1216
            By.ID, "e2ee-recovery-key-value"
1217
        ).text
1218
        self.assertTrue(
1✔
1219
            len(recovery_key) > 0,
1220
            "Recovery key should be displayed after setup",
1221
        )
1222

1223
        # Verify UserEncryptionKey was persisted in the backend.
1224
        # This catches regressions where the frontend wraps the payload in a
1225
        # "data" field or the backend reads from the wrong key.
1226
        key_record = UserEncryptionKey.objects.filter(user=self.user).first()
1✔
1227
        self.assertIsNotNone(
1✔
1228
            key_record,
1229
            "UserEncryptionKey should be created after passphrase setup",
1230
        )
1231
        self.assertTrue(len(key_record.public_key) > 0)
1✔
1232
        self.assertTrue(len(key_record.encrypted_master_key) > 0)
1✔
1233
        self.assertTrue(len(key_record.encrypted_private_key) > 0)
1✔
1234
        self.assertTrue(len(key_record.user_salt) > 0)
1✔
1235

1236
        # Click "I have saved it"
1237
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1238
            EC.element_to_be_clickable(
1239
                (
1240
                    By.CSS_SELECTOR,
1241
                    "#e2ee-recovery-key ~ .ui-dialog-buttonpane .fw-dark",
1242
                )
1243
            )
1244
        ).click()
1245

1246
        # Wait for editor to load (the E2EE document is created automatically)
1247
        try:
1✔
1248
            WebDriverWait(self.driver, self.wait_time * 3).until(
1✔
1249
                EC.presence_of_element_located(
1250
                    (By.CLASS_NAME, "editor-toolbar")
1251
                )
1252
            )
1253
        except TimeoutException:
×
1254
            current_url = self.driver.current_url
×
1255
            body_text = self.driver.find_element(By.TAG_NAME, "body").text[
×
1256
                :500
1257
            ]
1258
            dialogs = self.driver.find_elements(
×
1259
                By.CSS_SELECTOR, ".ui-dialog-content"
1260
            )
1261
            dialog_titles = [
×
1262
                d.get_attribute("id") or d.text[:60] for d in dialogs
1263
            ]
1264
            has_key = self.driver.execute_script(
×
1265
                "return (async () => {"
1266
                "  try {"
1267
                "    const r = await fetch('/api/user/encryption_key/', "
1268
                "      {headers: {'X-Requested-With': 'XMLHttpRequest'}});"
1269
                "    const j = await r.json();"
1270
                "    return j.has_key;"
1271
                "  } catch (e) { return e.message; }"
1272
                "})();"
1273
            )
1274
            session_keys = self.driver.execute_script(
×
1275
                "return Object.keys(sessionStorage).filter(k => k.startsWith('e2ee_'));"
1276
            )
1277
            self.fail(
×
1278
                f"Editor did not load after passphrase setup. "
1279
                f"URL: {current_url}, body: {body_text}, "
1280
                f"dialogs: {dialog_titles}, api_has_key: {has_key}, "
1281
                f"session_keys: {session_keys}"
1282
            )
1283

1284
        doc_id = int(
1✔
1285
            self.driver.current_url.split("/document/")[1].split("/")[0]
1286
        )
1287

1288
        return doc_id
1✔
1289

1290
    def test_passphrase_setup_creates_encryption_keys(self):
1✔
1291
        """
1292
        Test completing the passphrase setup flow creates a valid
1293
        UserEncryptionKey record on the server.
1294
        """
1295
        doc_id = self._complete_passphrase_setup()
1✔
1296

1297
        # The E2EE document created after setup should have a DocumentEncryptionKey
1298
        # stored for the owner (encrypted with the master key).
1299
        dek = DocumentEncryptionKey.objects.filter(
1✔
1300
            document_id=doc_id, holder=self.user
1301
        ).first()
1302
        self.assertIsNotNone(
1✔
1303
            dek,
1304
            "DocumentEncryptionKey should be created for passphrase E2EE document",
1305
        )
1306
        self.assertTrue(dek.encrypted_with_master_key)
1✔
1307

1308
    def test_unlock_e2ee_document_with_passphrase(self):
1✔
1309
        """
1310
        Test that after setting up a passphrase, logging out clears the
1311
        cached keys and the user can unlock documents by entering the
1312
        passphrase again.
1313
        """
1314
        passphrase = "UnlockPassphrase456"
1✔
1315
        self._complete_passphrase_setup(passphrase=passphrase)
1✔
1316

1317
        # Add content so we can verify decryption after unlock
1318
        title_el = self.driver.find_element(By.CSS_SELECTOR, ".doc-title")
1✔
1319
        title_el.click()
1✔
1320
        title_el.send_keys("Passphrase Unlock Test")
1✔
1321
        body_el = self.driver.find_element(By.CSS_SELECTOR, ".doc-body")
1✔
1322
        body_el.click()
1✔
1323
        body_el.send_keys("Unlocked body content")
1✔
1324
        # Allow time for encryption and snapshot to be saved
1325
        time.sleep(3)
1✔
1326

1327
        # Log out via the UI so sessionStorage is cleared
1328
        self.driver.find_element(By.ID, "close-document-top").click()
1✔
1329
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1330
            EC.element_to_be_clickable((By.ID, "preferences-btn"))
1331
        )
1332
        self.driver.find_element(By.ID, "preferences-btn").click()
1✔
1333
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1334
            EC.element_to_be_clickable(
1335
                (By.XPATH, '//*[normalize-space()="Log out"]')
1336
            )
1337
        ).click()
1338

1339
        # Wait for redirect to login page
1340
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1341
            EC.presence_of_element_located((By.ID, "id-login"))
1342
        )
1343

1344
        # Log in again
1345
        self.login_user(self.user, self.driver, self.client)
1✔
1346

1347
        # Clear sessionStorage to force passphrase entry (login should already
1348
        # have cleared it, but be explicit to ensure we test the unlock flow).
1349
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
1350

1351
        # Navigate to overview and open the document
1352
        self.driver.get(self.base_url)
1✔
1353
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1354
            EC.presence_of_element_located(
1355
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
1356
            )
1357
        )
1358
        self.driver.find_element(
1✔
1359
            By.CSS_SELECTOR, ".fw-contents tbody tr a.fw-data-table-title"
1360
        ).click()
1361

1362
        # Passphrase unlock dialog should appear
1363
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1364
            EC.presence_of_element_located((By.ID, "e2ee-enter-passphrase"))
1365
        )
1366

1367
        # Enter passphrase and unlock
1368
        self.driver.find_element(By.ID, "e2ee-passphrase-input").send_keys(
1✔
1369
            passphrase
1370
        )
1371
        self.driver.find_element(
1✔
1372
            By.CSS_SELECTOR,
1373
            "#e2ee-enter-passphrase ~ .ui-dialog-buttonpane .fw-dark",
1374
        ).click()
1375

1376
        # Wait for editor to load
1377
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1378
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
1379
        )
1380

1381
        # Verify content is decrypted and visible
1382
        title_text = self.driver.execute_script(
1✔
1383
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
1384
        )
1385
        self.assertIn("Passphrase Unlock Test", title_text)
1✔
1386

1387
    def test_user_encryption_key_model_persists(self):
1✔
1388
        """
1389
        Test that the UserEncryptionKey model correctly stores and retrieves
1390
        user encryption data.
1391
        """
1392
        from user.models import UserEncryptionKey
1✔
1393
        import json
1✔
1394
        import base64
1✔
1395

1396
        # Create a UserEncryptionKey record
1397
        public_key = json.dumps(
1✔
1398
            {"kty": "RSA", "n": "test_n_value", "e": "AQAB"}
1399
        )
1400
        user_salt = b"1234567890123456"
1✔
1401
        encrypted_data = base64.b64encode(b"encrypted_test_data").decode()
1✔
1402

1403
        key_record = UserEncryptionKey.objects.create(
1✔
1404
            user=self.user,
1405
            public_key=public_key,
1406
            encrypted_master_key=encrypted_data,
1407
            encrypted_private_key=encrypted_data,
1408
            user_salt=user_salt,
1409
            user_iterations=600000,
1410
            encrypted_master_key_backup=encrypted_data,
1411
        )
1412

1413
        # Verify it was created and can be retrieved
1414
        self.assertIsNotNone(key_record.id)
1✔
1415
        retrieved = UserEncryptionKey.objects.get(user=self.user)
1✔
1416
        self.assertEqual(retrieved.user_iterations, 600000)
1✔
1417
        self.assertEqual(len(retrieved.user_salt), 16)
1✔
1418
        self.assertEqual(retrieved.public_key, public_key)
1✔
1419

1420
    def test_document_encryption_key_model_persists(self):
1✔
1421
        """
1422
        Test that DocumentEncryptionKey can track whether DEK is encrypted
1423
        with master key or public key.
1424
        """
1425
        from document.models import Document, DocumentEncryptionKey
1✔
1426
        import base64
1✔
1427

1428
        # Create an E2EE document
1429
        doc = Document.objects.create(
1✔
1430
            title="DEK Test Doc",
1431
            owner=self.user,
1432
            template_id=1,
1433
            e2ee=True,
1434
            e2ee_salt=b"salt1234567890ab",
1435
            e2ee_iterations=600000,
1436
        )
1437

1438
        encrypted_dek = base64.b64encode(b"encrypted_dek_data").decode()
1✔
1439

1440
        # Create DEK record encrypted with master key
1441
        DocumentEncryptionKey.objects.create(
1✔
1442
            document=doc,
1443
            holder=self.user,
1444
            encrypted_key=encrypted_dek,
1445
            encrypted_with_master_key=True,
1446
        )
1447

1448
        # Verify it was saved
1449
        retrieved = DocumentEncryptionKey.objects.get(document=doc)
1✔
1450
        self.assertTrue(retrieved.encrypted_with_master_key)
1✔
1451

1452
    def test_document_encryption_key_public_key_mode(self):
1✔
1453
        """
1454
        Test that DocumentEncryptionKey can be encrypted with public key
1455
        (for shared documents).
1456
        """
1457
        from document.models import Document, DocumentEncryptionKey
1✔
1458
        import base64
1✔
1459

1460
        doc = Document.objects.create(
1✔
1461
            title="Shared DEK Test Doc",
1462
            owner=self.user,
1463
            template_id=1,
1464
            e2ee=True,
1465
            e2ee_salt=b"salt1234567890ab",
1466
            e2ee_iterations=600000,
1467
        )
1468

1469
        encrypted_dek = base64.b64encode(b"public_key_encrypted_dek").decode()
1✔
1470

1471
        # Create DEK record encrypted with public key
1472
        DocumentEncryptionKey.objects.create(
1✔
1473
            document=doc,
1474
            holder=self.user,
1475
            encrypted_key=encrypted_dek,
1476
            encrypted_with_master_key=False,
1477
        )
1478

1479
        # Verify it tracks public key encryption
1480
        retrieved = DocumentEncryptionKey.objects.get(document=doc)
1✔
1481
        self.assertFalse(retrieved.encrypted_with_master_key)
1✔
1482

1483
    def test_bulk_get_user_document_encryption_keys(self):
1✔
1484
        """Test fetching all DocumentEncryptionKeys for a user."""
1485
        from django.contrib.auth import get_user_model
1✔
1486

1487
        User = get_user_model()
1✔
1488
        user2 = User.objects.create_user(
1✔
1489
            username="user2", email="user2@test.com", password="testpass"
1490
        )
1491

1492
        # Create multiple E2EE documents
1493
        doc1 = Document.objects.create(
1✔
1494
            title="Doc 1",
1495
            owner=self.user,
1496
            template_id=1,
1497
            e2ee=True,
1498
            e2ee_salt=b"salt1111111111ab",
1499
            e2ee_iterations=600000,
1500
        )
1501

1502
        doc2 = Document.objects.create(
1✔
1503
            title="Doc 2",
1504
            owner=self.user,
1505
            template_id=1,
1506
            e2ee=True,
1507
            e2ee_salt=b"salt2222222222ab",
1508
            e2ee_iterations=600000,
1509
        )
1510

1511
        # Create DEK records for the user
1512
        DocumentEncryptionKey.objects.create(
1✔
1513
            document=doc1,
1514
            holder=self.user,
1515
            encrypted_key=base64.b64encode(b"dek1").decode(),
1516
            encrypted_with_master_key=True,
1517
        )
1518

1519
        DocumentEncryptionKey.objects.create(
1✔
1520
            document=doc2,
1521
            holder=self.user,
1522
            encrypted_key=base64.b64encode(b"dek2").decode(),
1523
            encrypted_with_master_key=True,
1524
        )
1525

1526
        # Create DEK for other user (should not be returned)
1527
        DocumentEncryptionKey.objects.create(
1✔
1528
            document=doc1,
1529
            holder=user2,
1530
            encrypted_key=base64.b64encode(b"dek_other").decode(),
1531
            encrypted_with_master_key=False,
1532
        )
1533

1534
        # Call the endpoint
1535
        response = self.client.get(
1✔
1536
            "/api/document/encryption_key/get_all/",
1537
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
1538
        )
1539

1540
        self.assertEqual(response.status_code, 200)
1✔
1541
        data = response.json()
1✔
1542
        self.assertIn("keys", data)
1✔
1543

1544
        # Should have 2 keys (only for self.user)
1545
        self.assertEqual(len(data["keys"]), 2)
1✔
1546

1547
        # Verify document IDs
1548
        doc_ids = {key["document_id"] for key in data["keys"]}
1✔
1549
        self.assertEqual(doc_ids, {doc1.id, doc2.id})
1✔
1550

1551
        # Verify master key flag
1552
        for key in data["keys"]:
1✔
1553
            self.assertTrue(key["encrypted_with_master_key"])
1✔
1554

1555
    def test_automatic_key_sharing_with_passphrase_user(self):
1✔
1556
        """Test that sharing with a passphrase-enabled user does not create
1557
        an empty placeholder DEK. The frontend is responsible for encrypting
1558
        and saving the DEK with the recipient's public key."""
1559
        from django.contrib.auth import get_user_model
1✔
1560
        from user.models import UserEncryptionKey
1✔
1561

1562
        User = get_user_model()
1✔
1563
        recipient = User.objects.create_user(
1✔
1564
            username="recipient",
1565
            email="recipient@test.com",
1566
            password="testpass",
1567
        )
1568

1569
        # Create an E2EE document owned by self.user
1570
        doc = Document.objects.create(
1✔
1571
            title="E2EE Doc",
1572
            owner=self.user,
1573
            template_id=1,
1574
            e2ee=True,
1575
            e2ee_salt=b"salt1234567890ab",
1576
            e2ee_iterations=600000,
1577
        )
1578

1579
        # Create DEK for owner
1580
        DocumentEncryptionKey.objects.create(
1✔
1581
            document=doc,
1582
            holder=self.user,
1583
            encrypted_key=base64.b64encode(b"owner_dek").decode(),
1584
            encrypted_with_master_key=True,
1585
        )
1586

1587
        # Give recipient encryption keys (passphrase setup)
1588
        UserEncryptionKey.objects.create(
1✔
1589
            user=recipient, public_key='{"kty":"RSA"}'
1590
        )
1591

1592
        # Now share the document with recipient
1593
        import json
1✔
1594

1595
        response = self.client.post(
1✔
1596
            "/api/document/save_access_rights/",
1597
            json.dumps(
1598
                {
1599
                    "document_ids": [doc.id],
1600
                    "access_rights": [
1601
                        {
1602
                            "holder": {"id": recipient.id, "type": "user"},
1603
                            "rights": "read",
1604
                        }
1605
                    ],
1606
                }
1607
            ),
1608
            content_type="application/json",
1609
            HTTP_X_REQUESTED_WITH="XMLHttpRequest",
1610
        )
1611

1612
        self.assertEqual(response.status_code, 201)
1✔
1613

1614
        # Verify no placeholder DocumentEncryptionKey was created for recipient.
1615
        # The frontend must explicitly encrypt the DEK with the recipient's
1616
        # public key and call the document encryption key API.
1617
        recipient_deks = DocumentEncryptionKey.objects.filter(
1✔
1618
            document=doc, holder=recipient
1619
        )
1620
        self.assertEqual(recipient_deks.count(), 0)
1✔
1621

1622
        # Verify the access right was created
1623
        from django.contrib.contenttypes.models import ContentType
1✔
1624

1625
        user_ct = ContentType.objects.get(app_label="user", model="user")
1✔
1626
        ar = AccessRight.objects.filter(
1✔
1627
            document=doc, holder_id=recipient.id, holder_type=user_ct
1628
        )
1629
        self.assertEqual(ar.count(), 1)
1✔
1630

1631
    def test_passphrase_sharing_scenario(self):
1✔
1632
        """Test the complete sharing scenario:
1633
        - User A (passphrase) creates E2EE document
1634
        - A shares with C (passphrase) via public key encryption
1635
        - A shares with D (no passphrase) and sees password dialog
1636
        - A creates share link with password in URL
1637
        - D opens document with password
1638
        - Guest opens share link automatically
1639
        """
1640
        from django.contrib.auth import get_user_model
1✔
1641
        from user.models import UserEncryptionKey
1✔
1642

1643
        User = get_user_model()
1✔
1644

1645
        # Create users
1646
        user_a = self.user  # Already created in setUp
1✔
1647
        user_c = User.objects.create_user(
1✔
1648
            username="user_c",
1649
            email="c@test.com",
1650
            password="testpass",
1651
        )
1652
        user_d = User.objects.create_user(
1✔
1653
            username="user_d",
1654
            email="d@test.com",
1655
            password="testpass",
1656
        )
1657

1658
        # Add C and D as A's contacts
1659
        user_a.contacts.add(user_c, user_d)
1✔
1660

1661
        # Generate real crypto keys in the browser
1662
        keys = self.driver.execute_script(
1✔
1663
            """
1664
            return (async function() {
1665
                const aKeyPair = await crypto.subtle.generateKey(
1666
                    {name: "ECDH", namedCurve: "P-256"},
1667
                    true,
1668
                    ["deriveKey"]
1669
                );
1670
                const cKeyPair = await crypto.subtle.generateKey(
1671
                    {name: "ECDH", namedCurve: "P-256"},
1672
                    true,
1673
                    ["deriveKey"]
1674
                );
1675
                const masterKey = await crypto.subtle.generateKey(
1676
                    {name: "AES-GCM", length: 256},
1677
                    true,
1678
                    ["encrypt", "decrypt"]
1679
                );
1680
                const aPublicJwk = await crypto.subtle.exportKey("jwk", aKeyPair.publicKey);
1681
                const aPrivateJwk = await crypto.subtle.exportKey("jwk", aKeyPair.privateKey);
1682
                const cPublicJwk = await crypto.subtle.exportKey("jwk", cKeyPair.publicKey);
1683
                const masterRaw = await crypto.subtle.exportKey("raw", masterKey);
1684
                const masterBase64 = btoa(String.fromCharCode(...new Uint8Array(masterRaw)));
1685
                return {
1686
                    aPublicJwk: JSON.stringify(aPublicJwk),
1687
                    aPrivateJwk: JSON.stringify(aPrivateJwk),
1688
                    cPublicJwk: JSON.stringify(cPublicJwk),
1689
                    masterKeyBase64: masterBase64
1690
                };
1691
            })();
1692
        """
1693
        )
1694

1695
        # Create UserEncryptionKey records for A and C
1696
        UserEncryptionKey.objects.create(
1✔
1697
            user=user_a,
1698
            public_key=keys["aPublicJwk"],
1699
            encrypted_master_key="dummy_encrypted_mk",
1700
            encrypted_private_key="dummy_encrypted_sk",
1701
            user_salt=b"1234567890123456",
1702
            user_iterations=600000,
1703
            encrypted_master_key_backup="dummy_backup",
1704
        )
1705
        UserEncryptionKey.objects.create(
1✔
1706
            user=user_c,
1707
            public_key=keys["cPublicJwk"],
1708
            encrypted_master_key="dummy_encrypted_mk_c",
1709
            encrypted_private_key="dummy_encrypted_sk_c",
1710
            user_salt=b"1234567890123456",
1711
            user_iterations=600000,
1712
            encrypted_master_key_backup="dummy_backup_c",
1713
        )
1714

1715
        # Navigate to base URL and inject A's master key into sessionStorage
1716
        self.driver.get(self.base_url)
1✔
1717
        self.driver.execute_script(
1✔
1718
            "sessionStorage.setItem('e2ee_master_key', arguments[0]);"
1719
            + "sessionStorage.setItem('e2ee_private_key', arguments[1]);",
1720
            keys["masterKeyBase64"],
1721
            keys["aPrivateJwk"],
1722
        )
1723

1724
        # --- Step 1: A creates E2EE document with passphrase ---
1725
        self.driver.get(self.base_url)
1✔
1726
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1727
            EC.element_to_be_clickable(
1728
                (By.CSS_SELECTOR, ".new_document button")
1729
            )
1730
        ).click()
1731

1732
        # Encryption choice dialog
1733
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1734
            EC.presence_of_element_located((By.CSS_SELECTOR, ".ui-dialog"))
1735
        )
1736
        self.driver.find_element(By.ID, "e2ee").click()
1✔
1737
        self.driver.find_element(
1✔
1738
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
1739
        ).click()
1740

1741
        # Wait for editor to load (passphrase mode creates doc immediately)
1742
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1743
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
1744
        )
1745

1746
        # Add title and body so we can verify content later
1747
        title_el = self.driver.find_element(By.CSS_SELECTOR, ".doc-title")
1✔
1748
        title_el.click()
1✔
1749
        title_el.send_keys("Passphrase Share Test")
1✔
1750
        body_el = self.driver.find_element(By.CSS_SELECTOR, ".doc-body")
1✔
1751
        body_el.click()
1✔
1752
        body_el.send_keys("Shared content")
1✔
1753
        time.sleep(3)
1✔
1754

1755
        # Extract document ID from URL
1756
        url = self.driver.current_url
1✔
1757
        doc_id = int(url.split("/document/")[1].split("/")[0])
1✔
1758

1759
        # --- Step 2: A shares with C (passphrase) and D (no passphrase) ---
1760
        # Open File menu
1761
        self.driver.find_element(
1✔
1762
            By.CSS_SELECTOR, ".header-menu:nth-child(1) > .header-nav-item"
1763
        ).click()
1764
        time.sleep(0.5)
1✔
1765
        self.driver.find_element(
1✔
1766
            By.CSS_SELECTOR, "li:nth-child(1) > .fw-pulldown-item"
1767
        ).click()
1768

1769
        # Wait for share dialog
1770
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1771
            EC.presence_of_element_located((By.ID, "access-rights-dialog"))
1772
        )
1773

1774
        # Click on C in contacts list
1775
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1776
            EC.presence_of_element_located(
1777
                (By.CSS_SELECTOR, f".fw-checkable-td[data-id='{user_c.id}']")
1778
            )
1779
        ).click()
1780

1781
        # Click on D in contacts list
1782
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1783
            EC.presence_of_element_located(
1784
                (By.CSS_SELECTOR, f".fw-checkable-td[data-id='{user_d.id}']")
1785
            )
1786
        ).click()
1787

1788
        # Click Add button to add selected contacts to collaborators
1789
        self.driver.find_element(By.ID, "add-share-contact").click()
1✔
1790
        time.sleep(0.5)
1✔
1791

1792
        # Click Submit
1793
        self.driver.find_element(
1✔
1794
            By.CSS_SELECTOR,
1795
            "#access-rights-dialog ~ .ui-dialog-buttonpane .fw-dark",
1796
        ).click()
1797

1798
        # Wait for "Share Document Password" dialog to appear (for D)
1799
        password_dialog = WebDriverWait(self.driver, self.wait_time).until(
1✔
1800
            EC.presence_of_element_located(
1801
                (By.CSS_SELECTOR, "#share-password-dialog")
1802
            )
1803
        )
1804
        # The dialog content element IS #share-password-dialog itself
1805
        self.assertIn(
1✔
1806
            "don't have passphrase encryption",
1807
            password_dialog.text,
1808
            "Should show non-passphrase users in password share dialog",
1809
        )
1810

1811
        # Close the password dialog
1812
        self.driver.find_element(
1✔
1813
            By.CSS_SELECTOR,
1814
            "#share-password-dialog ~ .ui-dialog-buttonpane .fw-dark",
1815
        ).click()
1816
        time.sleep(1)
1✔
1817

1818
        # --- Step 3: Verify backend state for C ---
1819
        # C should have a DocumentEncryptionKey (encrypted with public key)
1820
        c_keys = DocumentEncryptionKey.objects.filter(
1✔
1821
            document_id=doc_id, holder=user_c
1822
        )
1823
        self.assertEqual(
1✔
1824
            c_keys.count(), 1, "C should have an encrypted document password"
1825
        )
1826
        self.assertFalse(c_keys.first().encrypted_with_master_key)
1✔
1827

1828
        # D should NOT have a DocumentEncryptionKey (password shared directly)
1829
        d_keys = DocumentEncryptionKey.objects.filter(
1✔
1830
            document_id=doc_id, holder=user_d
1831
        )
1832
        self.assertEqual(
1✔
1833
            d_keys.count(), 0, "D should not have a DocumentEncryptionKey"
1834
        )
1835

1836
        # --- Step 4: Create share link ---
1837
        # Open File menu again
1838
        self.driver.find_element(
1✔
1839
            By.CSS_SELECTOR, ".header-menu:nth-child(1) > .header-nav-item"
1840
        ).click()
1841
        time.sleep(0.5)
1✔
1842
        self.driver.find_element(
1✔
1843
            By.CSS_SELECTOR, "li:nth-child(1) > .fw-pulldown-item"
1844
        ).click()
1845

1846
        # Wait for share dialog
1847
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1848
            EC.presence_of_element_located((By.ID, "access-rights-dialog"))
1849
        )
1850

1851
        # Switch to Share link tab
1852
        self.driver.find_element(
1✔
1853
            By.CSS_SELECTOR, ".ui-tabs-nav .tab-link:nth-child(2) a"
1854
        ).click()
1855
        time.sleep(0.5)
1✔
1856

1857
        # Click Create new share link
1858
        self.driver.find_element(By.ID, "create-share-token-btn").click()
1✔
1859

1860
        # Wait for create dialog
1861
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1862
            EC.presence_of_element_located(
1863
                (By.ID, "create-share-token-dialog")
1864
            )
1865
        )
1866

1867
        # Verify password field is prefilled (auto-generated document password)
1868
        pass_input = self.driver.find_element(By.ID, "share-token-password")
1✔
1869
        prefilled_password = pass_input.get_attribute("value")
1✔
1870
        self.assertTrue(
1✔
1871
            len(prefilled_password) >= 43,
1872
            "Password field should be prefilled with document password",
1873
        )
1874

1875
        # Create the link
1876
        self.driver.find_element(
1✔
1877
            By.CSS_SELECTOR,
1878
            "#create-share-token-dialog ~ .ui-dialog-buttonpane .fw-dark",
1879
        ).click()
1880

1881
        # Wait for link to appear
1882
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1883
            EC.presence_of_element_located(
1884
                (By.CSS_SELECTOR, ".share-token-row")
1885
            )
1886
        )
1887

1888
        # Verify URL contains password fragment
1889
        url_input = self.driver.find_element(
1✔
1890
            By.CSS_SELECTOR, ".share-token-url-input"
1891
        )
1892
        share_url = url_input.get_attribute("value")
1✔
1893
        self.assertIn("#?password=", share_url)
1✔
1894
        from urllib.parse import unquote
1✔
1895

1896
        self.assertIn(prefilled_password, unquote(share_url))
1✔
1897

1898
        # Store share URL for later
1899
        share_link = share_url
1✔
1900

1901
        # Close share dialog
1902
        self.driver.find_element(
1✔
1903
            By.CSS_SELECTOR,
1904
            "#access-rights-dialog ~ .ui-dialog-buttonpane .fw-light",
1905
        ).click()
1906
        time.sleep(0.5)
1✔
1907

1908
        # --- Step 5: D opens document with password ---
1909
        # Log out A
1910
        self.logout_user(self.driver, self.client)
1✔
1911

1912
        # Log in as D
1913
        self.login_user(user_d, self.driver, self.client)
1✔
1914

1915
        # Clear sessionStorage so D has to enter password manually
1916
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
1917

1918
        # Navigate to overview
1919
        self.driver.get(self.base_url)
1✔
1920
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1921
            EC.presence_of_element_located(
1922
                (By.CSS_SELECTOR, ".fw-contents tbody tr")
1923
            )
1924
        )
1925

1926
        # Click on the document
1927
        self.driver.find_element(
1✔
1928
            By.CSS_SELECTOR, ".fw-contents tbody tr a.fw-data-table-title"
1929
        ).click()
1930

1931
        # Wait for password dialog
1932
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1933
            EC.presence_of_element_located((By.ID, "e2ee-password-input"))
1934
        )
1935

1936
        # Enter the document password
1937
        self.driver.find_element(By.ID, "e2ee-password-input").send_keys(
1✔
1938
            prefilled_password
1939
        )
1940
        self.driver.find_element(
1✔
1941
            By.CSS_SELECTOR, ".ui-dialog .fw-dark"
1942
        ).click()
1943

1944
        # Wait for editor to load
1945
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1946
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
1947
        )
1948

1949
        # Verify content is accessible
1950
        title_text = self.driver.execute_script(
1✔
1951
            "return window.theApp.page.view.state.doc.firstChild.textContent;"
1952
        )
1953
        self.assertIn("Passphrase Share Test", title_text)
1✔
1954

1955
        # --- Step 6: Guest opens share link ---
1956
        # Log out D
1957
        self.logout_user(self.driver, self.client)
1✔
1958

1959
        # Clear all storage
1960
        self.driver.execute_script("window.localStorage.clear()")
1✔
1961
        self.driver.execute_script("window.sessionStorage.clear()")
1✔
1962

1963
        # Navigate to share link
1964
        self.driver.get(share_link)
1✔
1965

1966
        # Wait for editor to load (password is in URL fragment, should auto-decrypt)
1967
        WebDriverWait(self.driver, self.wait_time).until(
1✔
1968
            EC.presence_of_element_located((By.CLASS_NAME, "editor-toolbar"))
1969
        )
1970

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

© 2026 Coveralls, Inc