diff --git a/application/single_app/config.py b/application/single_app/config.py index 140c40b08..b56b998d7 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -98,7 +98,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.028" +VERSION = "0.261.029" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/static/js/workspace/group-documents-sharing.js b/application/single_app/static/js/workspace/group-documents-sharing.js index 2bb70ea3a..cea85e569 100644 --- a/application/single_app/static/js/workspace/group-documents-sharing.js +++ b/application/single_app/static/js/workspace/group-documents-sharing.js @@ -76,6 +76,20 @@ function setupGroupShareEventListeners() { } } +window.bindGroupDocumentShareButton = function(container, doc) { + const shareButton = container.querySelector('.group-document-share-btn'); + if (!shareButton) { + return; + } + + const documentId = doc.id; + const fileName = doc.file_name || ''; + shareButton.addEventListener('click', function(event) { + event.preventDefault(); + window.shareGroupDocument(documentId, fileName); + }); +}; + // Main function to open share modal window.shareGroupDocument = function(documentId, fileName) { currentGroupDocumentId = documentId; diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index 68b120eaa..b715d2486 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -4140,7 +4140,7 @@ if (canShare) { const shareCount = Array.isArray(doc.shared_group_ids) ? doc.shared_group_ids.length : 0; dropdownItems += ` -
  • +
  • Share ${shareCount}
  • `; @@ -4199,8 +4199,7 @@
    Group document
    -
    ${escapeGroupHtml(truncateGroupDocumentText(displayTitle, 60))}
    - ${subtitle ? `
    ${escapeGroupHtml(subtitle)}
    ` : ''} +
    ${statusBadge}
    @@ -4220,6 +4219,18 @@
    ${escapeGro `; + const titleElement = column.querySelector('.card-title'); + titleElement.title = displayTitle; + titleElement.textContent = truncateGroupDocumentText(displayTitle, 60); + if (subtitle) { + const subtitleElement = document.createElement('div'); + subtitleElement.className = 'document-item-card__subtitle'; + subtitleElement.title = subtitle; + subtitleElement.textContent = subtitle; + titleElement.after(subtitleElement); + } + window.bindGroupDocumentShareButton(column, doc); + return column; } @@ -6204,7 +6215,7 @@
    if (canManage && access.isOwnerGroup) { const canShare = (groupStatus === 'active' || groupStatus === 'upload_disabled'); if (canShare) { - actionsDropdown += `
  • Share${doc.shared_group_ids ? doc.shared_group_ids.length : 0}
  • `; + actionsDropdown += `
  • Share${doc.shared_group_ids ? doc.shared_group_ids.length : 0}
  • `; } const canDelete = (groupStatus === 'active' || groupStatus === 'upload_disabled'); if (canDelete) { @@ -8293,6 +8305,9 @@
    // xss-check: ignore reviewed folder view shell assembled from local escaped table/card builders. container.innerHTML = html; + container.querySelectorAll('#group-folder-docs-table tbody tr').forEach((row, index) => { + window.bindGroupDocumentShareButton(row, docs[index]); + }); wireGroupBackButton(container); if (groupCurrentView === 'folders-cards' && docs.length > 0) { renderGroupFolderDocumentCards(docs); diff --git a/docs/explanation/fixes/GROUP_DOCUMENT_FILENAME_XSS_FIX.md b/docs/explanation/fixes/GROUP_DOCUMENT_FILENAME_XSS_FIX.md new file mode 100644 index 000000000..d443ce698 --- /dev/null +++ b/docs/explanation/fixes/GROUP_DOCUMENT_FILENAME_XSS_FIX.md @@ -0,0 +1,65 @@ +# Group Document Filename XSS Fix (v0.261.029) + +Fixed in version: **0.261.029** + +Related advisory: [GHSA-qwcw-r653-j8c6](https://github.com/microsoft/simplechat/security/advisories/GHSA-qwcw-r653-j8c6). +The application patch version is recorded in `application/single_app/config.py`. + +## Issue + +A stored group document filename could become executable JavaScript when an eligible group member clicked Share. The vulnerable controls appeared in ordinary list rows, document cards, and folder list rows. Folder cards reused the same affected card renderer. + +The card renderer also inserted filenames and document titles into tooltip attributes without a safe attribute boundary. This allowed a separate event-handler injection path on hover, including for ordinary group Users who could not share documents. + +The paths remained present in the inspected `v0.261.001`, `v0.261.027`, and `0.261.028` revisions. Earlier [sharing-modal hardening](v0.241.022/STORED_XSS_SHARE_ACTIVITY_AND_MASKING_FIX.md) protected content inside the modal but did not repair the controls that opened it. + +## Root Cause + +Share controls interpolated the stored filename into an inline `onclick` handler. HTML entity escaping was insufficient because the browser decoded the entities before compiling the JavaScript handler. + +Card headings and filename subtitles used an HTML text escaper inside double-quoted `title` attributes. That escaper did not encode quotation marks, allowing data to escape the intended attribute. + +## Changes + +| File | Change | +|---|---| +| `application/single_app/templates/group_workspaces.html` | Removes all three filename-bearing inline Share handlers, binds each newly rendered control, and populates card titles/subtitles through DOM properties. | +| `application/single_app/static/js/workspace/group-documents-sharing.js` | Adds a shared event-binding helper that captures the original document ID and filename as data and passes them directly to the existing sharing modal. | +| `functional_tests/test_group_document_filename_xss.py` | Guards the rendering boundaries, all three binding locations, and the minimum implementation version. | +| `ui_tests/test_group_document_filename_xss_rendering.py` | Exercises actual rendered controls, tooltips, permissions, refreshes, and polling in an isolated browser. | +| `application/single_app/config.py` | Increments the application patch version from `0.261.028` to `0.261.029`. | + +The Share links now contain static action markup rather than executable document data. Their event listeners prevent link navigation and invoke the existing modal with the unchanged filename. Card text uses `textContent`; tooltips use the DOM `title` property. + +Bindings are installed when cards and ordinary rows are created and after folder table markup is inserted. Polling completion uses the same ordinary row renderer, so replacement rows receive the same safe behavior. + +## Behavior and Impact + +Original filenames remain unchanged in storage, API responses, visible text, tooltips, and sharing dialogs. No document migration or filename rewriting is required, and previously stored filenames receive the same protection as new uploads. + +Existing Share permissions, owning-group restrictions, processing/error checks, group status restrictions, and share-count badges are preserved. The fix does not change CSP, introduce browser dependencies, or add settings or routes. The event-binding helper is served from the existing local sharing asset. + +## Validation + +Before the fix, four focused browser cases executed the injected marker: ordinary-list and folder-list Share clicks, and ordinary-card and folder-card hover. After the fix, all 74 browser scenarios pass: + +- Share clicks in list, card, folder-list, and folder-card views preserve literal filenames containing quotes, HTML-like text, entities, backslashes, and Unicode. +- Card heading, subtitle, and document-title tooltips remain inert for ordinary Users at desktop and mobile widths. +- Owner, Admin, DocumentManager, and User roles retain their existing behavior, including receiving-group restrictions and active, upload-disabled, locked, inactive, processing, and error states. +- Refreshes and view changes select the correct document without duplicate handlers; polling completion binds the replacement row. +- Empty filenames retain their previous fallback behavior. + +The existing sharing-modal functional regression also passes. That regression alone does not cover the launcher vulnerability because it checks the modal's rendering rather than clicking a filename-bearing launcher. + +Run the focused coverage with: + +```powershell +python -B -m pytest -q .\functional_tests\test_group_document_filename_xss.py .\ui_tests\test_group_document_filename_xss_rendering.py ".\functional_tests\test_stored_xss_share_activity_and_masking_fix.py::test_document_share_modals_use_safe_rendering_and_delegated_clicks" +node --check .\application\single_app\static\js\workspace\group-documents-sharing.js +``` + +### Scope and limitations + +Browser coverage uses the real template rendering functions, access/processing checks, fetch/refresh/polling paths, local sharing asset, Bootstrap, and modal markup. Unrelated metadata, selection, and generated-artifact helpers are stubbed. Every request is intercepted; the suite does not upload live documents, require authentication, or provision Azure resources. This is isolated Chromium coverage, not an authenticated Azure deployment test. + +The complete historical sharing/activity/masking script has separate pre-existing failures involving chat source expectations and an exact historical version assertion. Those unrelated checks were not changed by this fix. diff --git a/functional_tests/test_group_document_filename_xss.py b/functional_tests/test_group_document_filename_xss.py new file mode 100644 index 000000000..97aa32393 --- /dev/null +++ b/functional_tests/test_group_document_filename_xss.py @@ -0,0 +1,58 @@ +# test_group_document_filename_xss.py +""" +Regression guards for group document filename rendering. +Version: 0.261.029 +Implemented in: 0.261.029 + +These guards cover Share launcher wiring and card tooltip boundaries. +Behavioral coverage lives in ui_tests/test_group_document_filename_xss_rendering.py. +""" + +from pathlib import Path +import re +import unittest + +from test_support.versioning import assert_app_version_at_least + + +ROOT = Path(__file__).resolve().parents[1] +TEMPLATE = ROOT / "application" / "single_app" / "templates" / "group_workspaces.html" + + +class GroupDocumentFilenameRenderingTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.source = TEMPLATE.read_text(encoding="utf-8") + + def test_share_launchers_do_not_compile_document_data_as_javascript(self): + handlers = re.findall(r'\bonclick="([^"]*)"', self.source) + share_handlers = [handler for handler in handlers if "shareGroupDocument(" in handler] + self.assertEqual(share_handlers, [], "Share must use a DOM event listener.") + self.assertEqual( + self.source.count('class="dropdown-item group-document-share-btn"'), + 3, + "Cards, ordinary rows, and folder rows must use inert Share controls.", + ) + + def test_share_binding_covers_cards_rows_and_folder_insertion(self): + for binding in ( + "window.bindGroupDocumentShareButton(column, doc)", + "window.bindGroupDocumentShareButton(docRow, doc)", + "window.bindGroupDocumentShareButton(row, docs[index])", + ): + with self.subTest(binding=binding): + self.assertIn(binding, self.source) + + def test_card_titles_are_not_interpolated_into_html_attributes(self): + unsafe_attributes = re.findall( + r'title="\$\{[^}\n]*\b(?:displayTitle|subtitle)\b[^}\n]*\}"', + self.source, + ) + self.assertEqual(unsafe_attributes, [], "Card tooltips must use DOM properties.") + + def test_implementation_version_is_present(self): + assert_app_version_at_least("0.261.029") + + +if __name__ == "__main__": + unittest.main() diff --git a/ui_tests/test_group_document_filename_xss_rendering.py b/ui_tests/test_group_document_filename_xss_rendering.py new file mode 100644 index 000000000..8c9e16e90 --- /dev/null +++ b/ui_tests/test_group_document_filename_xss_rendering.py @@ -0,0 +1,382 @@ +# test_group_document_filename_xss_rendering.py +""" +Browser regressions for group document filename rendering. +Version: 0.261.029 +Implemented in: 0.261.029 + +Run the real template renderers, access/processing checks, fetch/refresh/polling +paths, local sharing asset, Bootstrap, and sharing modal in an isolated browser. +Only unrelated metadata, selection, and generated-artifact UI helpers are stubbed. +Every request is intercepted; no Azure resource, login, or live document is used. +""" + +from pathlib import Path +import re +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import expect + + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +TEMPLATE = APP_ROOT / "templates" / "group_workspaces.html" +OWNER_GROUP = "owner-group" +DOCUMENT_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +VIEWS = ("list", "cards", "folder-list", "folder-cards") +INLINE_PAYLOAD = "x');window.__filenameXss += 1//a.txt" +ATTRIBUTE_PAYLOAD = 'x" onmouseover="window.__filenameXss += 1" data-tail="a.txt' +BENIGN_FILENAME = 'O\'Reilly "report" & \\ " \u00e9\u8cc7\u6599.txt' +HTML_FILENAME = '.txt' + +FUNCTION_NAMES = ( + "escapeHtml", + "escapeGroupHtml", + "truncateGroupDocumentText", + "getGroupDocumentProcessingState", + "getGroupDocumentIcon", + "getGroupDocumentAccess", + "getGroupDocumentSummaryText", + "isPendingGeneratedArtifactDocument", + "createGroupDocumentCard", + "renderGroupDocumentCards", + "renderGroupDocumentRow", + "renderGroupDocumentsEmptyState", + "renderGroupDocumentsErrorState", + "renderCurrentGroupDocuments", + "fetchGroupDocuments", + "refreshCurrentGroupDocumentView", + "pollGroupDocumentStatus", + "buildGroupBreadcrumbHtml", + "wireGroupBackButton", + "buildGroupFolderDocumentsTable", + "buildGroupFolderDocumentsCardsHtml", + "renderGroupFolderDocumentCards", + "renderGroupFolderContents", + "renderGroupFolderPagination", + "wireGroupFolderGeneratedArtifactApproveButtons", +) + +HARNESS_STATE = """ +const groupDocumentsTableBody = document.querySelector('#group-documents-table tbody'); +const groupDocumentsCardView = document.getElementById('group-documents-card-view'); +const groupDocsPaginationContainer = null; +const groupSelectedDocuments = new Set(); +const groupActivePolls = new Set(); +const groupWorkspaceTags = []; +let activeGroupId = 'owner-group'; +let userRoleInActiveGroup = 'Owner'; +let groupSelectionMode = false; +let groupCurrentView = 'list'; +let groupCurrentFolder = null; +let groupCurrentFolderType = null; +let groupFolderCurrentPage = 1; +let groupFolderPageSize = 20; +let groupFolderSortBy = '_ts'; +let groupFolderSortOrder = 'desc'; +let groupFolderSearchTerm = ''; +let groupDocsCurrentPage = 1; +let groupDocsPageSize = 20; +let groupDocsSortBy = '_ts'; +let groupDocsSortOrder = 'desc'; +let groupDocsSearchTerm = ''; +let groupDocsClassificationFilter = ''; +let groupDocsAuthorFilter = ''; +let groupDocsKeywordsFilter = ''; +let groupDocsAbstractFilter = ''; +let groupDocsTagsFilter = ''; +let groupLastFetchedDocs = []; +let groupLastFetchedDocsError = null; +let groupHasFetchedDocuments = false; +let groupFileDownloadsEnabled = false; +let groupFileDownloadEnabledGroupIds = []; + +const getGroupDocumentMetaPills = () => ''; +const getGroupClassificationBadge = () => ''; +const getGroupDocumentCitationTooltip = () => 'Standard citations'; +const getGroupDocumentSyncBadgeHtml = () => ''; +const getGroupDocumentSyncDetailsHtml = () => ''; +const getGroupDocumentReprocessDropdownItems = () => ''; +const renderGroupTagBadges = () => ''; +const supportsGroupExtractionModeChange = () => false; +const canDownloadGroupDocuments = () => false; +const prependGroupGeneratedArtifactActionButtons = () => {}; +const syncGroupSelectionModeUI = () => {}; +const refreshGroupSelectionState = () => {}; +const renderGroupDocsPaginationControls = () => {}; + +window.__filenameXss = 0; +window.__pollCallbacks = new Map(); +window.setInterval = (callback) => { + const id = Symbol('poll'); + window.__pollCallbacks.set(id, callback); + return id; +}; +window.clearInterval = (id) => window.__pollCallbacks.delete(id); +""" + + +def _template_function(source, name): + """Extract a complete top-level function using the template's indentation.""" + start = re.search(rf"^ (?:async )?function {re.escape(name)}\(", source, re.MULTILINE) + if start is None: + raise ValueError(f"Group template function not found: {name}") + end = source.index("\n }", start.end()) + len("\n }") + return source[start.start():end] + + +def _document(filename=BENIGN_FILENAME, **changes): + document = { + "id": DOCUMENT_ID, + "group_id": OWNER_GROUP, + "file_name": filename, + "title": "", + "status": "Processing Complete", + "percentage_complete": 100, + "shared_group_ids": ["shared-one", "shared-two"], + "tags": [], + } + document.update(changes) + return document + + +@pytest.fixture(scope="module") +def group_render_sources(): + source = TEMPLATE.read_text(encoding="utf-8") + modal_start = source.index("") + modal_end = source.index("", modal_start) + modal = source[modal_start:modal_end] + functions = "\n".join(_template_function(source, name) for name in FUNCTION_NAMES) + return modal, f"{HARNESS_STATE}\n{functions}" + + +@pytest.fixture +def group_page(page, group_render_sources): + modal, script = group_render_sources + state = {"documents": [], "shared_requests": [], "unexpected_requests": [], "errors": []} + page.on("pageerror", lambda error: state["errors"].append(str(error))) + page.emulate_media(reduced_motion="reduce") + + def handle_request(route): + path = urlsplit(route.request.url).path + if path == "/": + route.fulfill( + content_type="text/html", + body=f""" + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + {modal} + """, + ) + elif path == "/api/group_documents": + route.fulfill(json={ + "documents": state["documents"], + "page": 1, + "page_size": 20, + "total_count": len(state["documents"]), + }) + elif path.startswith("/api/group_documents/") and path.endswith("/shared-groups"): + state["shared_requests"].append(path.split("/")[3]) + route.fulfill(json={"shared_groups": []}) + elif path.startswith("/api/group_documents/"): + document_id = path.split("/")[3] + documents = [doc for doc in state["documents"] if doc["id"] == document_id] + if not documents: + raise AssertionError(f"Unexpected document request: {path}") + route.fulfill(json=documents[0]) + else: + state["unexpected_requests"].append(route.request.url) + route.abort() + + page.route("**/*", handle_request) + page.goto("https://simplechat.test/") + page.add_style_tag(path=str(APP_ROOT / "static" / "css" / "bootstrap.min.css")) + page.add_script_tag(path=str(APP_ROOT / "static" / "js" / "bootstrap" / "bootstrap.bundle.min.js")) + page.add_script_tag(path=str(APP_ROOT / "static" / "js" / "workspace" / "group-documents-sharing.js")) + page.add_script_tag(content=script) + page.evaluate("initializeGroupSharing()") + + yield page, state + + assert state["errors"] == [] + assert state["unexpected_requests"] == [] + + +def _render(group_page, view, documents, role="Owner", status="active"): + page, state = group_page + state["documents"] = documents + page.evaluate( + """({view, role, status}) => { + userRoleInActiveGroup = role; + window.currentGroupStatus = status; + const folderView = view.startsWith('folder-'); + groupCurrentView = view === 'folder-list' ? 'grid' + : view === 'folder-cards' ? 'folders-cards' : view; + groupCurrentFolder = folderView ? '__untagged__' : null; + groupCurrentFolderType = folderView ? 'tag' : null; + document.getElementById('group-documents-list-view') + .classList.toggle('d-none', view !== 'list'); + groupDocumentsCardView.classList.toggle('d-none', view !== 'cards'); + document.getElementById('group-documents-grid-view') + .classList.toggle('d-none', !folderView); + refreshCurrentGroupDocumentView(); + }""", + {"view": view, "role": role, "status": status}, + ) + selectors = { + "list": "#group-documents-table .document-row", + "cards": "#group-documents-card-view .document-item-card", + "folder-list": "#group-folder-docs-table tbody tr", + "folder-cards": "#group-folder-documents-card-view .document-item-card", + } + rows = page.locator(selectors[view]) + expect(rows).to_have_count(len(documents)) + return rows + + +def _share_link(row): + return row.locator("a.dropdown-item").filter(has_text=re.compile(r"^\s*Share")) + + +def _click_share(group_page, row, document): + page, state = group_page + previous_requests = len(state["shared_requests"]) + share = _share_link(row) + expect(share.locator(".badge")).to_have_text(str(len(document["shared_group_ids"]))) + row.locator(".dropdown-toggle").click() + with page.expect_response(f"**/api/group_documents/{document['id']}/shared-groups") as response: + share.click() + response.value.finished() + expect(page.locator("#groupShareDocumentModal")).to_be_visible() + marker = page.evaluate("window.__filenameXss") + assert marker == 0, "The rendered Share control executed filename content." + expect(page.locator("#groupShareDocumentName")).to_have_text(document.get("file_name") or "") + inline_handler = share.get_attribute("onclick") + assert inline_handler is None + assert state["shared_requests"][previous_requests:] == [document["id"]] + page.locator("#groupShareDocumentModal").get_by_role("button", name="Close", exact=True).last.click() + expect(page.locator("#groupShareDocumentModal")).to_be_hidden() + + +@pytest.mark.ui +@pytest.mark.parametrize("view", VIEWS) +@pytest.mark.parametrize( + "filename", + [INLINE_PAYLOAD, ATTRIBUTE_PAYLOAD, BENIGN_FILENAME, HTML_FILENAME], + ids=["inline-payload", "attribute-payload", "literal-filename", "html-filename"], +) +def test_rendered_share_preserves_inert_filename(group_page, view, filename): + document = _document(filename) + rows = _render(group_page, view, [document]) + _click_share(group_page, rows.first, document) + + +@pytest.mark.ui +@pytest.mark.parametrize("view", ["cards", "folder-cards"]) +@pytest.mark.parametrize("field", ["filename-heading", "filename-subtitle", "document-title"]) +@pytest.mark.parametrize("width", [390, 1440], ids=["mobile", "desktop"]) +def test_card_tooltips_remain_inert_for_ordinary_users(group_page, view, field, width): + page, _ = group_page + page.set_viewport_size({"width": width, "height": 900}) + document = _document(ATTRIBUTE_PAYLOAD) + selector = ".card-title" + if field == "filename-subtitle": + document["title"] = "Document title" + selector = ".document-item-card__subtitle" + elif field == "document-title": + document["file_name"] = "document.txt" + document["title"] = ATTRIBUTE_PAYLOAD + rows = _render(group_page, view, [document], role="User") + expect(_share_link(rows.first)).to_have_count(0) + text = rows.first.locator(selector) + text.hover() + marker = page.evaluate("window.__filenameXss") + assert marker == 0, "Hovering the document text executed injected filename/title content." + expect(text).to_have_attribute("title", ATTRIBUTE_PAYLOAD) + expected_text = ATTRIBUTE_PAYLOAD + if selector == ".card-title" and len(expected_text) > 60: + expected_text = f"{expected_text[:60].rstrip()}\u2026" + expect(text).to_have_text(expected_text) + injected_handler = text.get_attribute("onmouseover") + assert injected_handler is None + + +@pytest.mark.ui +@pytest.mark.parametrize("view", VIEWS) +@pytest.mark.parametrize( + "role,status,owner,processing,can_share", + [ + ("Owner", "active", True, "complete", True), + ("Admin", "active", True, "complete", True), + ("DocumentManager", "active", True, "complete", True), + ("User", "active", True, "complete", False), + ("Owner", "upload_disabled", True, "complete", True), + ("Owner", "locked", True, "complete", False), + ("Owner", "inactive", True, "complete", False), + ("Owner", "active", False, "complete", False), + ("Owner", "active", True, "processing", False), + ("Owner", "active", True, "error", False), + ], +) +def test_share_preserves_role_ownership_and_status_gating( + group_page, view, role, status, owner, processing, can_share +): + document = _document() + if not owner: + document["group_id"] = "another-group" + document["shared_group_ids"] = [f"{OWNER_GROUP},approved"] + if processing != "complete": + document["status"] = "Processing" if processing == "processing" else "Error" + document["percentage_complete"] = 25 + rows = _render(group_page, view, [document], role=role, status=status) + expect(_share_link(rows.first)).to_have_count(1 if can_share else 0) + if can_share: + _click_share(group_page, rows.first, document) + + +@pytest.mark.ui +def test_refresh_and_view_changes_rebind_each_document_once(group_page): + page, state = group_page + documents = [ + _document(INLINE_PAYLOAD), + _document(BENIGN_FILENAME, id="bbbbbbbb-cccc-dddd-eeee-ffffffffffff", shared_group_ids=[]), + ] + for view in VIEWS: + rows = _render(group_page, view, documents) + _click_share(group_page, rows.nth(0), documents[0]) + documents = list(reversed(documents)) + rows = _render(group_page, view, documents) + _click_share(group_page, rows.nth(0), documents[0]) + assert len(state["shared_requests"]) == 8 + marker = page.evaluate("window.__filenameXss") + assert marker == 0 + + +@pytest.mark.ui +def test_polling_completion_binds_the_replacement_row(group_page): + page, state = group_page + processing = _document(INLINE_PAYLOAD, status="Processing", percentage_complete=25) + rows = _render(group_page, "list", [processing]) + expect(_share_link(rows.first)).to_have_count(0) + complete = _document(INLINE_PAYLOAD) + state["documents"] = [complete] + page.evaluate("() => window.__pollCallbacks.forEach(callback => callback())") + expect(_share_link(rows.first)).to_have_count(1) + _click_share(group_page, rows.first, complete) + + +@pytest.mark.ui +@pytest.mark.parametrize("view", VIEWS) +def test_empty_filename_keeps_existing_fallback(group_page, view): + document = _document(None) + rows = _render(group_page, view, [document]) + _click_share(group_page, rows.first, document)