From fb58ba30b2883bef98f7e31390db09740b9d6caf Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 01:00:32 +0000 Subject: [PATCH] feat: lazy-load security center (Closes #605) --- frontend/feature-loader.js | 3 +- frontend/index.html | 1 + frontend/security-center.js | 185 ++++++++++++++++++++ frontend/service-worker.js | 1 + frontend/session.js | 206 +++-------------------- src/frontend_bundle.py | 1 + tests/test_dashboard_session_frontend.py | 11 +- tests/test_feature_loader.py | 19 +++ tests/test_frontend_bundle.py | 17 +- tests/test_security_center.py | 49 ++++++ tests/test_service_worker.py | 1 + 11 files changed, 308 insertions(+), 186 deletions(-) create mode 100644 frontend/security-center.js create mode 100644 tests/test_security_center.py diff --git a/frontend/feature-loader.js b/frontend/feature-loader.js index 34f0559..1a21b87 100644 --- a/frontend/feature-loader.js +++ b/frontend/feature-loader.js @@ -47,7 +47,8 @@ function createFeatureLoader({ document, urls, timeoutMs = 10000 }) { } catch (_error) { if (status) { const label = name === 'issue-capture' ? 'Issue capture' : - name === 'pull-workflow' ? 'Pull workspace' : name.replace(/-/g, ' '); + name === 'pull-workflow' ? 'Pull workspace' : + name === 'security-center' ? 'Security center' : name.replace(/-/g, ' '); status.textContent = label + ' could not load. ' + retryLabel; } return false; diff --git a/frontend/index.html b/frontend/index.html index d66e605..de52bb2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -831,6 +831,7 @@ + diff --git a/frontend/security-center.js b/frontend/security-center.js new file mode 100644 index 0000000..2a91e3d --- /dev/null +++ b/frontend/security-center.js @@ -0,0 +1,185 @@ +(function (root, factory) { + if (typeof module !== 'undefined' && module.exports) module.exports = factory; + else root.attachSecurityCenter = boundary => factory({ root, boundary }); +})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) { + const devicesButton = root.document.getElementById('active-devices'); + const devicesSheet = root.document.getElementById('active-devices-sheet'); + const devicesList = root.document.getElementById('active-devices-list'); + const devicesStatus = root.document.getElementById('active-devices-status'); + const passkeysList = root.document.getElementById('enrolled-passkeys-list'); + const passkeysStatus = root.document.getElementById('enrolled-passkeys-status'); + const enrollPasskey = root.document.getElementById('enroll-passkey'); + const activityList = root.document.getElementById('security-activity-list'); + const activityStatus = root.document.getElementById('security-activity-status'); + const loadMoreActivity = root.document.getElementById('load-more-security-activity'); + let activityCursor = null; + + const renderDevices = async () => { + devicesStatus.textContent = 'Loading active devices…'; + devicesList.replaceChildren(); + try { + const devices = await boundary.listActiveDevices(); + devices.forEach(device => { + const row = root.document.createElement('article'); + row.className = 'active-device'; + const details = root.document.createElement('div'); + const label = root.document.createElement('strong'); + label.textContent = device.device_label; + const timing = root.document.createElement('span'); + timing.className = 'small muted'; + timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`; + details.append(label, timing); + if (device.current) { + const current = root.document.createElement('span'); + current.className = 'active-device-current'; + current.textContent = 'This device'; + details.append(current); + } + const revoke = root.document.createElement('button'); + revoke.type = 'button'; + revoke.textContent = device.current ? 'Sign out' : 'Revoke'; + revoke.addEventListener('click', async () => { + if (device.current) await boundary.signOut(); + else if (await boundary.revokeActiveDevice(device)) await renderDevices(); + }); + row.append(details, revoke); + devicesList.append(row); + }); + devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.'; + } catch (_error) { + devicesStatus.textContent = 'Active devices could not be loaded. Try again.'; + } + }; + + const renderPasskeys = async () => { + if (!passkeysList || !passkeysStatus) return; + passkeysStatus.textContent = 'Loading enrolled passkeys…'; + passkeysList.replaceChildren(); + try { + const enrolled = await boundary.listPasskeys(); + enrolled.forEach(passkey => { + const row = root.document.createElement('article'); + row.className = 'enrolled-passkey'; + const details = root.document.createElement('div'); + const label = root.document.createElement('strong'); + label.textContent = passkey.device_label; + const timing = root.document.createElement('span'); + timing.className = 'small muted'; + const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session'); + timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`; + details.append(label, timing); + const remove = root.document.createElement('button'); + remove.type = 'button'; + remove.textContent = 'Remove passkey'; + remove.addEventListener('click', async () => { + remove.disabled = true; + passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`; + try { + const outcome = await boundary.revokePasskey(passkey); + if (outcome) { + passkeysStatus.textContent = outcome.current_session + ? 'Passkey removed. This session remains active; keep your recovery token available.' + : 'Passkey removed.'; + await renderPasskeys(); + if (outcome.session_revoked) await renderDevices(); + } else { + remove.disabled = false; + passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`; + } + } catch (_error) { + passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.'; + remove.disabled = false; + } + }); + row.append(details, remove); + passkeysList.append(row); + }); + passkeysStatus.textContent = enrolled.length + ? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}` + : 'No passkeys enrolled.'; + } catch (_error) { + passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.'; + } + }; + + const renderSecurityActivity = async (append = false) => { + if (!activityList || !activityStatus || !loadMoreActivity) return; + activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…'; + loadMoreActivity.hidden = true; + if (!append) { + activityCursor = null; + activityList.replaceChildren(); + } + try { + const page = await boundary.listSecurityEvents(append ? activityCursor : null); + if (!append) { + page.authentication_alerts.forEach(alert => { + const row = root.document.createElement('article'); + row.className = 'security-event authentication-alert'; + const formatted = boundary.formatAuthenticationAlert(alert); + const title = root.document.createElement('strong'); + title.textContent = formatted.title; + const details = root.document.createElement('span'); + details.className = 'small muted'; + details.textContent = formatted.detail; + row.append(title, details); + activityList.append(row); + }); + } + const labels = { + sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled', + device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked', + issue_closed: 'Issue closed', pull_merged: 'Pull request merged', + pull_review_approved: 'Pull request approved', + pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged', + }; + page.events.forEach(event => { + const row = root.document.createElement('article'); + row.className = 'security-event'; + const title = root.document.createElement('strong'); + title.textContent = labels[event.kind] || 'Security event'; + const details = root.document.createElement('span'); + details.className = 'small muted'; + const context = [event.device_label, event.method, event.target, + event.status === 'pending' ? 'Outcome confirmation pending' : null] + .filter(value => typeof value === 'string' && value).join(' · '); + details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`; + row.append(title, details); + activityList.append(row); + }); + activityCursor = page.next_cursor; + activityStatus.textContent = activityList.children.length + ? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}` + : 'No security activity yet.'; + loadMoreActivity.textContent = 'Load older activity'; + loadMoreActivity.hidden = !activityCursor; + } catch (_error) { + activityStatus.textContent = 'Security activity could not be loaded.'; + loadMoreActivity.textContent = 'Retry activity'; + loadMoreActivity.hidden = false; + } + }; + + const open = () => { + if (!devicesSheet) return; + devicesSheet.hidden = false; + root.document.getElementById('close-active-devices')?.focus(); + return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]); + }; + devicesButton?.addEventListener('click', open); + loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor))); + enrollPasskey?.addEventListener('click', async () => { + enrollPasskey.disabled = true; + devicesStatus.textContent = 'Waiting for your device passkey…'; + try { + await boundary.enrollPasskey(); + devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.'; + await renderPasskeys(); + } catch (_error) { + devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.'; + } finally { + enrollPasskey.disabled = false; + } + }); + return { open, renderDevices, renderPasskeys, renderSecurityActivity }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index a7db818..b7efc84 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -14,6 +14,7 @@ const SHELL = [ BASE + 'static/icons/stackchain-512.png', BASE + 'static/session.js', BASE + 'static/feature-loader.js', + BASE + 'static/security-center.js', BASE + 'static/markdown.js', BASE + 'static/commands.js', BASE + 'static/search-preview.js', diff --git a/frontend/session.js b/frontend/session.js index f6564f8..d16452f 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -36,192 +36,42 @@ if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices()); const devicesButton = root.document.getElementById('active-devices'); const devicesSheet = root.document.getElementById('active-devices-sheet'); - const devicesList = root.document.getElementById('active-devices-list'); const devicesStatus = root.document.getElementById('active-devices-status'); - const passkeysList = root.document.getElementById('enrolled-passkeys-list'); - const passkeysStatus = root.document.getElementById('enrolled-passkeys-status'); const closeDevices = root.document.getElementById('close-active-devices'); - const enrollPasskey = root.document.getElementById('enroll-passkey'); - const activityList = root.document.getElementById('security-activity-list'); - const activityStatus = root.document.getElementById('security-activity-status'); - const loadMoreActivity = root.document.getElementById('load-more-security-activity'); - let activityCursor = null; - const renderDevices = async () => { - devicesStatus.textContent = 'Loading active devices…'; - devicesList.replaceChildren(); - try { - const devices = await boundary.listActiveDevices(); - devices.forEach(device => { - const row = root.document.createElement('article'); - row.className = 'active-device'; - const details = root.document.createElement('div'); - const label = root.document.createElement('strong'); - label.textContent = device.device_label; - const timing = root.document.createElement('span'); - timing.className = 'small muted'; - timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`; - details.append(label, timing); - if (device.current) { - const current = root.document.createElement('span'); - current.className = 'active-device-current'; - current.textContent = 'This device'; - details.append(current); - } - const revoke = root.document.createElement('button'); - revoke.type = 'button'; - revoke.textContent = device.current ? 'Sign out' : 'Revoke'; - revoke.addEventListener('click', async () => { - if (device.current) await boundary.signOut(); - else if (await boundary.revokeActiveDevice(device)) await renderDevices(); - }); - row.append(details, revoke); - devicesList.append(row); - }); - devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.'; - } catch (_error) { - devicesStatus.textContent = 'Active devices could not be loaded. Try again.'; - } - }; - const renderPasskeys = async () => { - if (!passkeysList || !passkeysStatus) return; - passkeysStatus.textContent = 'Loading enrolled passkeys…'; - passkeysList.replaceChildren(); - try { - const enrolled = await boundary.listPasskeys(); - enrolled.forEach(passkey => { - const row = root.document.createElement('article'); - row.className = 'enrolled-passkey'; - const details = root.document.createElement('div'); - const label = root.document.createElement('strong'); - label.textContent = passkey.device_label; - const timing = root.document.createElement('span'); - timing.className = 'small muted'; - const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session'); - timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`; - details.append(label, timing); - const remove = root.document.createElement('button'); - remove.type = 'button'; - remove.textContent = 'Remove passkey'; - remove.addEventListener('click', async () => { - remove.disabled = true; - passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`; - try { - const outcome = await boundary.revokePasskey(passkey); - if (outcome) { - passkeysStatus.textContent = outcome.current_session - ? 'Passkey removed. This session remains active; keep your recovery token available.' - : 'Passkey removed.'; - await renderPasskeys(); - if (outcome.session_revoked) await renderDevices(); - } else { - remove.disabled = false; - passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`; - } - } catch (_error) { - passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.'; - remove.disabled = false; - } - }); - row.append(details, remove); - passkeysList.append(row); - }); - passkeysStatus.textContent = enrolled.length - ? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}` - : 'No passkeys enrolled.'; - } catch (_error) { - passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.'; - } - }; - const renderSecurityActivity = async (append = false) => { - if (!activityList || !activityStatus || !loadMoreActivity) return; - activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…'; - loadMoreActivity.hidden = true; - if (!append) { - activityCursor = null; - activityList.replaceChildren(); - } - try { - const page = await boundary.listSecurityEvents(append ? activityCursor : null); - if (!append) { - page.authentication_alerts.forEach(alert => { - const row = root.document.createElement('article'); - row.className = 'security-event authentication-alert'; - const formatted = boundary.formatAuthenticationAlert(alert); - const title = root.document.createElement('strong'); - title.textContent = formatted.title; - const details = root.document.createElement('span'); - details.className = 'small muted'; - details.textContent = formatted.detail; - row.append(title, details); - activityList.append(row); - }); - } - const labels = { - sign_in: 'Signed in', - sign_out: 'Signed out', - passkey_enrolled: 'Passkey enrolled', - device_revoked: 'Device access revoked', - all_sessions_revoked: 'All device access revoked', - issue_closed: 'Issue closed', - pull_merged: 'Pull request merged', - pull_review_approved: 'Pull request approved', - pull_review_changes_requested: 'Changes requested', - gitea_time_logged: 'Gitea time logged', - }; - page.events.forEach(event => { - const row = root.document.createElement('article'); - row.className = 'security-event'; - const title = root.document.createElement('strong'); - title.textContent = labels[event.kind] || 'Security event'; - const details = root.document.createElement('span'); - details.className = 'small muted'; - const context = [ - event.device_label, - event.method, - event.target, - event.status === 'pending' ? 'Outcome confirmation pending' : null, - ].filter(value => typeof value === 'string' && value).join(' · '); - details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`; - row.append(title, details); - activityList.append(row); - }); - activityCursor = page.next_cursor; - activityStatus.textContent = activityList.children.length - ? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}` - : 'No security activity yet.'; - loadMoreActivity.textContent = 'Load older activity'; - loadMoreActivity.hidden = !activityCursor; - } catch (_error) { - activityStatus.textContent = 'Security activity could not be loaded.'; - loadMoreActivity.textContent = 'Retry activity'; - loadMoreActivity.hidden = false; - } - }; - loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor))); - if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => { + const securityFeatures = root.createFeatureLoader({ + document: root.document, + urls: { + 'security-center': root.document.querySelector( + 'meta[name="stackchain-feature-security-center"]' + )?.content || '', + }, + }); + let loadingSecurityCenter = false; + const openSecurityCenter = async () => { + if (loadingSecurityCenter) return; + loadingSecurityCenter = true; devicesSheet.hidden = false; closeDevices?.focus(); - renderDevices(); - renderPasskeys(); - renderSecurityActivity(); - }); + try { + await securityFeatures.run('security-center', { + trigger: devicesButton, + status: devicesStatus, + retryLabel: 'Tap Active devices to retry.', + }, () => { + devicesButton.removeEventListener('click', openSecurityCenter); + root.attachSecurityCenter(boundary).open(); + }); + } finally { + loadingSecurityCenter = false; + } + }; + if (devicesButton && devicesSheet) { + devicesButton.addEventListener('click', openSecurityCenter); + } if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => { devicesSheet.hidden = true; devicesButton?.focus(); }); - enrollPasskey?.addEventListener('click', async () => { - enrollPasskey.disabled = true; - devicesStatus.textContent = 'Waiting for your device passkey…'; - try { - await boundary.enrollPasskey(); - devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.'; - await renderPasskeys(); - } catch (_error) { - devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.'; - } finally { - enrollPasskey.disabled = false; - } - }); boundary.refreshOfflineLease().then(valid => { if (valid) boundary.resumeQueuedWork(); }); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 19876a6..49c8bff 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -26,6 +26,7 @@ FEATURE_SOURCES = { "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), "push-notifications": ("static/push-notifications.js",), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), + "security-center": ("static/security-center.js",), "today-timer": ( "static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js", "static/today-rollover.js", diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 6ac51b8..29d832b 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard ROOT = Path(__file__).resolve().parents[1] SESSION_JS = ROOT / "frontend" / "session.js" +SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js" def run_session_scenario(scenario: str) -> dict: @@ -753,7 +754,7 @@ process.stdout.write(JSON.stringify(state)); def test_security_activity_renders_authentication_alerts_before_journal_events(): - source = SESSION_JS.read_text() + source = SECURITY_CENTER_JS.read_text() alerts = source.index("page.authentication_alerts.forEach") events = source.index("page.events.forEach") @@ -765,14 +766,14 @@ def test_security_activity_renders_authentication_alerts_before_journal_events() def test_security_activity_explains_pending_outcome_confirmation(): - source = SESSION_JS.read_text() + source = SECURITY_CENTER_JS.read_text() assert "Outcome confirmation pending" in source assert "event.status === 'pending'" in source def test_security_activity_labels_passkey_enrollment_without_html_rendering(): - source = SESSION_JS.read_text() + source = SECURITY_CENTER_JS.read_text() assert "passkey_enrolled: 'Passkey enrolled'" in source assert "title.textContent = labels[event.kind]" in source @@ -780,7 +781,7 @@ def test_security_activity_labels_passkey_enrollment_without_html_rendering(): def test_security_activity_labels_consequential_pull_review_decisions(): - source = SESSION_JS.read_text() + source = SECURITY_CENTER_JS.read_text() assert "pull_review_approved: 'Pull request approved'" in source assert "pull_review_changes_requested: 'Changes requested'" in source @@ -818,6 +819,6 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out(): def test_security_activity_names_confirmed_gitea_time_logging(): - source = SESSION_JS.read_text() + source = SECURITY_CENTER_JS.read_text() assert "gitea_time_logged: 'Gitea time logged'" in source diff --git a/tests/test_feature_loader.py b/tests/test_feature_loader.py index 965d95f..0982f4b 100644 --- a/tests/test_feature_loader.py +++ b/tests/test_feature_loader.py @@ -89,3 +89,22 @@ console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appen "opened": 1, "appends": 2, } + + +def test_security_center_failure_is_accessible_and_retryable_from_active_devices(): + result = run_loader(""" +const trigger={disabled:false}; const status={textContent:''}; let opened=0; +const loader=createFeatureLoader({document, urls:{'security-center':'feature-security.js'}, timeoutMs:100}); +const failed=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;}); +state.node.onerror(); const first=await failed; const failedStatus=status.textContent; +const retry=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;}); +state.node.onload(); const second=await retry; +console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends})); +""") + assert result == { + "first": False, + "second": True, + "failedStatus": "Security center could not load. Tap Active devices to retry.", + "opened": 1, + "appends": 2, + } diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index d7c6431..75b6c2d 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -49,7 +49,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert set(first.feature_bundles) == { "comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup", - "today-timer", + "today-timer", "security-center", } assert ( f'\n' @@ -63,12 +63,19 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert b"function createReviewController" not in first.runtime_bytes assert b"function createPullSheet" in pull_workflow.runtime_bytes assert b"function createReviewController" in pull_workflow.runtime_bytes + security_center = first.feature_bundles["security-center"] + assert b"function attachSecurityCenter" not in first.runtime_bytes + assert b"function attachSecurityCenter" in security_center.runtime_bytes + assert b"gitea_time_logged" not in first.runtime_bytes + assert b"gitea_time_logged" in security_center.runtime_bytes # The recap adds only startup wiring; its UI remains in the lazy Today bundle. - assert len(first.runtime_gzip_bytes) <= 96 * 1024 + assert len(first.runtime_gzip_bytes) <= 95 * 1024 assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source + assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html + assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source changed_frontend = tmp_path / "frontend" shutil.copytree(FRONTEND, changed_frontend) @@ -85,6 +92,12 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert pull_changed.runtime_name == first.runtime_name assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name + security_source = changed_frontend / "security-center.js" + security_source.write_text(security_source.read_text() + "\n// security-center-only change\n") + security_changed = build_frontend(changed_frontend) + assert security_changed.runtime_name == first.runtime_name + assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name + def test_shipped_browser_bundles_are_valid_javascript(tmp_path): build = build_frontend(FRONTEND) diff --git a/tests/test_security_center.py b/tests/test_security_center.py new file mode 100644 index 0000000..84417d7 --- /dev/null +++ b/tests/test_security_center.py @@ -0,0 +1,49 @@ +import json +import subprocess +from pathlib import Path + + +SECURITY_CENTER = Path(__file__).parents[1] / "frontend" / "security-center.js" + + +def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable(): + harness = f""" +const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))}); +const calls=[]; +const element=()=>({{ + hidden:true, disabled:false, textContent:'', children:[], + addEventListener(){{}}, focus(){{this.focused=true;}}, + replaceChildren(){{this.children=[];}}, append(value){{this.children.push(value);}}, +}}); +const ids={{ + 'active-devices':element(), 'active-devices-sheet':element(), + 'active-devices-list':element(), 'active-devices-status':element(), + 'enrolled-passkeys-list':element(), 'enrolled-passkeys-status':element(), + 'enroll-passkey':element(), 'security-activity-list':element(), + 'security-activity-status':element(), 'load-more-security-activity':element(), + 'close-active-devices':element(), +}}; +const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}}; +const boundary={{ + listActiveDevices:async()=>{{calls.push('devices');return[];}}, + listPasskeys:async()=>{{calls.push('passkeys');return[];}}, + listSecurityEvents:async()=>{{calls.push('activity');return{{events:[],authentication_alerts:[],next_cursor:null}};}}, +}}; +(async()=>{{ + const controller=attachSecurityCenter({{root,boundary}}); + const loading=controller.open(); + const awaitable=Boolean(loading&&typeof loading.then==='function'); + if (loading) await loading; + console.log(JSON.stringify({{calls,awaitable,hidden:ids['active-devices-sheet'].hidden,focused:ids['close-active-devices'].focused||false}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + completed = subprocess.run( + ["node", "-e", harness], check=True, capture_output=True, text=True + ) + + assert json.loads(completed.stdout) == { + "calls": ["devices", "passkeys", "activity"], + "awaitable": True, + "hidden": False, + "focused": True, + } diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 971fb9b..f049c0a 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -661,6 +661,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/icons/stackchain-512.png", "/dashboard/static/session.js", "/dashboard/static/feature-loader.js", + "/dashboard/static/security-center.js", "/dashboard/static/markdown.js", "/dashboard/static/commands.js", "/dashboard/static/search-preview.js",