From fdd3cf03b4bf1a827a78aec5258dc0860bc8c97e Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 16:02:35 +0000 Subject: [PATCH] security: keep mobile share content private (Closes #903) --- frontend/dashboard.js | 18 ++++ frontend/manifest.webmanifest | 2 +- frontend/service-worker.js | 72 +++++++------- frontend/shared-image-capture.js | 27 ++++- src/main.py | 18 ++-- tests/test_comment_next.py | 2 +- tests/test_dashboard_auth.py | 17 +--- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 115 ++++++++++++++++++---- tests/test_shared_image_capture.py | 26 ++++- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- tests/test_work_pages.py | 5 +- 18 files changed, 231 insertions(+), 87 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 5e0d11c..efd1452 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4089,6 +4089,24 @@ taskOverlayHistory.open('new'); return; } + if (!sharedImageHandled && sharedImageMarker === 'bundle') { + sharedImageHandled = true; + if (createIssueAttachmentController.state()) { + qs('#create-issue-attachment-status').textContent = 'Remove the current screenshot before adding the shared screenshots.'; + } else { + await sharedImageCapture.consume({ + marker:sharedImageMarker, + store:unfiledAttachmentStore, + restore:value=>createIssueAttachmentController.restore(value), + restoreContent:content=>{ + sharedLaunchState = issueCapture.stageSharedContent(content); + sharedLaunchHandled = true; + }, + status:message=>{ qs('#create-issue-attachment-status').textContent = message; }, + }); + } + clearSharedLaunchUrl(); + } const captureDraft = issueCapture.loadDraft(); issueTemplatePicker.reset(captureDraft); const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean); diff --git a/frontend/manifest.webmanifest b/frontend/manifest.webmanifest index 8c5f1d2..793f8f0 100644 --- a/frontend/manifest.webmanifest +++ b/frontend/manifest.webmanifest @@ -36,7 +36,7 @@ } ], "share_target": { - "action": "./", + "action": "./share-target", "method": "POST", "enctype": "multipart/form-data", "params": { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 993ff86..f5eef15 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v105'; +const CACHE = 'stackchain-dashboard-shell-v106'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; @@ -113,6 +113,7 @@ const OPTIONAL_FEATURES = [ const SHARED_IMAGE_ID = 'shared-image'; const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024; +const SHARE_TARGET_PATH = BASE + 'share-target'; const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore(); function boundedShareField(form, name, limit) { @@ -120,45 +121,46 @@ function boundedShareField(form, name, limit) { return typeof value === 'string' ? value.trim().slice(0, limit) : ''; } -async function acceptSharedContent(request) { - const form = await request.formData(); - const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0); - let marker = ''; - await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {}); - if (images.length > 5) marker = 'multiple'; - else if (images.length > 0) { - const supported = images.every(image => - SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES - ); - if (!supported) { - marker = 'unsupported'; - } else if (images.length === 1) { - const image = images[0]; - await sharedAttachmentStore.put(SHARED_IMAGE_ID, { - filename:String(image.name || 'shared-screenshot').slice(0, 255), - contentType:String(image.type), blob:image, - }); - marker = 'image'; - } else { - await sharedAttachmentStore.put(SHARED_IMAGE_ID, { - attachments:images.map(image => ({ - filename:String(image.name || 'shared-screenshot').slice(0, 255), - contentType:String(image.type), blob:image, - })), - }); - marker = 'images'; - } - } +function sharedContentRedirect(marker) { const target = new URL(BASE, self.location.origin); target.searchParams.set('launch', 'new'); if (marker) target.searchParams.set('shared', marker); - for (const [name, limit] of [['title', 255], ['text', 10000], ['url', 2048]]) { - const value = boundedShareField(form, name, limit); - if (value) target.searchParams.set(name, value); - } return new Response(null, {status:303, headers:{Location:target.pathname + target.search}}); } +async function acceptSharedContent(request) { + if (request.headers.get('Sec-Fetch-Site') === 'cross-site') { + return new Response('Cross-site share submissions are not accepted.', { + status:403, + headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}, + }); + } + const form = await request.formData(); + const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0); + const content = { + title:boundedShareField(form, 'title', 255), + text:boundedShareField(form, 'text', 10000), + url:boundedShareField(form, 'url', 2048), + }; + if (images.length > 5) return sharedContentRedirect('multiple'); + const supported = images.every(image => + SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES + ); + const totalBytes = images.reduce((total, image) => total + image.size, 0); + if (!supported || totalBytes > MAX_SHARED_IMAGE_BYTES) return sharedContentRedirect('unsupported'); + if (!images.length && !Object.values(content).some(Boolean)) return sharedContentRedirect('unsupported'); + const attachments = images.map(image => ({ + filename:String(image.name || 'shared-screenshot').slice(0, 255), + contentType:String(image.type), blob:image, + })); + try { + await sharedAttachmentStore.put(SHARED_IMAGE_ID, {...content, attachments}); + } catch (_error) { + return sharedContentRedirect('unavailable'); + } + return sharedContentRedirect('bundle'); +} + async function fetchNavigation(request) { const controller = new AbortController(); let timeout; @@ -563,7 +565,7 @@ self.addEventListener('fetch', event => { const requestUrl = new URL(request.url); if ( request.method === 'POST' && request.mode === 'navigate' - && requestUrl.origin === self.location.origin && requestUrl.pathname === BASE + && requestUrl.origin === self.location.origin && requestUrl.pathname === SHARE_TARGET_PATH ) { event.respondWith(acceptSharedContent(request)); return; diff --git a/frontend/shared-image-capture.js b/frontend/shared-image-capture.js index 7c1b3b1..7787dfe 100644 --- a/frontend/shared-image-capture.js +++ b/frontend/shared-image-capture.js @@ -8,14 +8,35 @@ const RECORD_ID = 'shared-image'; const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); - async function consume({marker, store, restore, status = () => {}}) { + async function consume({marker, store, restore, restoreContent = () => {}, status = () => {}}) { if (!marker) return false; + const value = await store?.get(RECORD_ID); + const attachments = Array.isArray(value?.attachments) ? value.attachments : null; + if (marker === 'bundle') { + const content = { + title:typeof value?.title === 'string' ? value.title.slice(0, 255) : '', + text:typeof value?.text === 'string' ? value.text.slice(0, 10000) : '', + url:typeof value?.url === 'string' ? value.url.slice(0, 2048) : '', + }; + const invalidAttachments = !attachments || attachments.length > 5 || attachments.some(attachment => + !attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || ''))); + if (invalidAttachments || (!attachments.length && !Object.values(content).some(Boolean))) { + status('The shared content is unavailable. Share it again.'); + return false; + } + restoreContent(content); + if (attachments.length) restore(attachments); + await store.delete(RECORD_ID); + const noun = attachments.length === 1 ? 'screenshot' : attachments.length > 1 ? 'screenshots' : 'content'; + status(attachments.length + ? 'Shared content and ' + (attachments.length > 1 ? attachments.length + ' ' : '') + noun + ' ready to file with this issue.' + : 'Shared content ready to file with this issue.'); + return true; + } if (!['image', 'images'].includes(marker)) { status('Share one PNG, JPEG, or WebP screenshot.'); return false; } - const value = await store?.get(RECORD_ID); - const attachments = Array.isArray(value?.attachments) ? value.attachments : null; if (marker === 'images') { if (!attachments?.length || attachments.length > 5 || attachments.some(attachment => !attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) { diff --git a/src/main.py b/src/main.py index d3dfe64..dc7c2a0 100644 --- a/src/main.py +++ b/src/main.py @@ -1168,6 +1168,16 @@ def _share_target_login_redirect(request: Request) -> str: continuation_values.append(("search_repository", repository_values[0])) continuation = urlencode(continuation_values) return f"login?{urlencode({'continue': f'./?{continuation}'})}" + image_markers = request.query_params.getlist("shared") + if image_markers: + if ( + set(request.query_params.keys()) != {"launch", "shared"} + or request.query_params.getlist("launch") != ["new"] + or image_markers != ["bundle"] + ): + return "login" + continuation = urlencode([("launch", "new"), ("shared", "bundle")]) + return f"login?{urlencode({'continue': f'./?{continuation}'})}" limits = {"title": 200, "text": 8000, "url": 2048} if any( len(request.query_params.get(name, "")) > limit @@ -1182,14 +1192,6 @@ def _share_target_login_redirect(request: Request) -> str: launch = request.query_params.get("launch", "") if launch in {"continue", "new", "agenda"}: shared.append(("launch", launch)) - image_markers = request.query_params.getlist("shared") - if image_markers: - if ( - image_markers != ["image"] - or request.query_params.getlist("launch") != ["new"] - ): - return "login" - shared.append(("shared", "image")) if dashboard_auth.application_path(request) != "/" or not shared: return "login" continuation = f"./?{urlencode(shared)}" diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index f3f8401..e7838bd 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v105" in worker + assert "stackchain-dashboard-shell-v106" in worker diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index b4537ee..ce15ea8 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -944,33 +944,26 @@ async def test_anonymous_search_preview_preserves_only_valid_search_scope(access @pytest.mark.anyio -async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control): +async def test_anonymous_shared_bundle_preserves_only_opaque_sign_in_continuation(access_control): transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: valid = await client.get( "/", - params={ - "launch": "new", - "shared": "image", - "title": "Broken checkout", - "text": "Steps to reproduce", - }, + params={"launch": "new", "shared": "bundle"}, ) wrong_launch = await client.get( - "/", params={"launch": "agenda", "shared": "image"} + "/", params={"launch": "agenda", "shared": "bundle"} ) wrong_marker = await client.get( "/", params={"launch": "new", "shared": "document"} ) duplicate_marker = await client.get( - "/?launch=new&shared=image&shared=image" + "/?launch=new&shared=bundle&shared=bundle" ) assert valid.status_code == 303 assert parse_qs(urlsplit(valid.headers["location"]).query) == { - "continue": [ - "./?title=Broken+checkout&text=Steps+to+reproduce&launch=new&shared=image" - ] + "continue": ["./?launch=new&shared=bundle"] } assert wrong_launch.headers["location"] == "login" assert wrong_marker.headers["location"] == "login" diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 72002d0..ea15a8f 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index b6f86df..8ea3b99 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v105" in worker + assert "stackchain-dashboard-shell-v106" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 562247c..5158cac 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v105" in worker + assert "stackchain-dashboard-shell-v106" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 7df47d2..719db40 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v105" in worker + assert "stackchain-dashboard-shell-v106" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 15de727..575a692 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v105" in service_worker + assert "stackchain-dashboard-shell-v106" in service_worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index fb3ade6..a356af6 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index ae92c30..bde5014 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; +const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; const storedResponses = new Map(); storedResponses.set( 'https://forge.example/dashboard/__offline-session-lease', @@ -49,7 +49,10 @@ const context = {{ __STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15, stackchainPrivateDatabases: require({json.dumps(str(PRIVATE_DATA_REGISTRY))}), __STACKCHAIN_SHARED_ATTACHMENT_STORE: {{ - put: async (id, value) => {{ state.sharedRecords[id] = value; }}, + put: async (id, value) => {{ + if (state.failSharedPut) throw new Error('quota'); + state.sharedRecords[id] = value; + }}, get: async id => state.sharedRecords[id] || null, delete: async id => {{ delete state.sharedRecords[id]; }}, }}, @@ -152,7 +155,7 @@ async function dispatchPush(payload) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -161,14 +164,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -176,7 +179,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -184,14 +187,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -200,21 +203,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/update-ownership.js'" in source @@ -228,21 +231,29 @@ def test_image_share_target_stages_one_supported_image_and_redirects_to_capture( form.append('text', 'Steps to reproduce'); form.append('url', 'https://example.test/checkout'); form.append('image', image, 'bug.png'); - const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form}); + const request = new Request('https://forge.example/dashboard/share-target', { + method:'POST', body:form, headers:{'Sec-Fetch-Site':'none'}, + }); Object.defineProperty(request, 'mode', {value:'navigate'}); const response = await dispatch('fetch', request); const [id, record] = Object.entries(state.sharedRecords)[0]; process.stdout.write(JSON.stringify({ status:response.status, location:response.headers.get('Location'), id, - filename:record.filename, contentType:record.contentType, size:record.blob.size, + title:record.title, text:record.text, url:record.url, + filename:record.attachments[0].filename, + contentType:record.attachments[0].contentType, + size:record.attachments[0].blob.size, })); """ ) assert result == { "status": 303, - "location": "/dashboard/?launch=new&shared=image&title=Broken+checkout&text=Steps+to+reproduce&url=https%3A%2F%2Fexample.test%2Fcheckout", + "location": "/dashboard/?launch=new&shared=bundle", "id": "shared-image", + "title": "Broken checkout", + "text": "Steps to reproduce", + "url": "https://example.test/checkout", "filename": "bug.png", "contentType": "image/png", "size": 6, @@ -255,7 +266,9 @@ def test_image_share_target_stages_ordered_evidence_bundle(): const form = new FormData(); form.append('image', new Blob(['one'], {type:'image/png'}), 'one.png'); form.append('image', new Blob(['two'], {type:'image/jpeg'}), 'two.jpg'); - const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form}); + const request = new Request('https://forge.example/dashboard/share-target', { + method:'POST', body:form, headers:{'Sec-Fetch-Site':'same-origin'}, + }); Object.defineProperty(request, 'mode', {value:'navigate'}); const response = await dispatch('fetch', request); const record=state.sharedRecords['shared-image']; @@ -268,12 +281,80 @@ def test_image_share_target_stages_ordered_evidence_bundle(): assert result == { "status": 303, - "location": "/dashboard/?launch=new&shared=images", + "location": "/dashboard/?launch=new&shared=bundle", "names": ["one.png", "two.jpg"], "sizes": [3, 3], } +def test_share_target_rejects_cross_site_posts_without_touching_staged_bundle(): + result = run_worker_scenario( + """ + state.sharedRecords['shared-image']={title:'Keep me',text:'private',url:'',attachments:[]}; + const form=new FormData();form.append('title','Replace me'); + const request=new Request('https://forge.example/dashboard/share-target',{ + method:'POST',body:form,headers:{'Sec-Fetch-Site':'cross-site'}, + }); + Object.defineProperty(request,'mode',{value:'navigate'}); + const response=await dispatch('fetch',request); + process.stdout.write(JSON.stringify({status:response.status,record:state.sharedRecords['shared-image']})); +""" + ) + + assert result == { + "status": 403, + "record": {"title": "Keep me", "text": "private", "url": "", "attachments": []}, + } + + +def test_invalid_share_target_preserves_the_previous_atomic_bundle(): + result = run_worker_scenario( + """ + state.sharedRecords['shared-image']={title:'Keep me',text:'private',url:'',attachments:[]}; + const form=new FormData(); + form.append('image',new Blob(['payload'],{type:'application/pdf'}),'secret.pdf'); + const request=new Request('https://forge.example/dashboard/share-target',{ + method:'POST',body:form,headers:{'Sec-Fetch-Site':'none'}, + }); + Object.defineProperty(request,'mode',{value:'navigate'}); + const response=await dispatch('fetch',request); + process.stdout.write(JSON.stringify({location:response.headers.get('Location'),record:state.sharedRecords['shared-image']})); +""" + ) + + assert result == { + "location": "/dashboard/?launch=new&shared=unsupported", + "record": {"title": "Keep me", "text": "private", "url": "", "attachments": []}, + } + + +def test_share_target_storage_failure_preserves_bundle_and_returns_recovery_marker(): + result = run_worker_scenario( + """ + state.sharedRecords['shared-image']={title:'Keep me',text:'private',url:'',attachments:[]}; + state.failSharedPut=true; + const form=new FormData();form.append('title','New private content'); + const request=new Request('https://forge.example/dashboard/share-target',{ + method:'POST',body:form,headers:{'Sec-Fetch-Site':'none'}, + }); + Object.defineProperty(request,'mode',{value:'navigate'}); + const response=await dispatch('fetch',request); + process.stdout.write(JSON.stringify({location:response.headers.get('Location'),record:state.sharedRecords['shared-image']})); +""" + ) + + assert result == { + "location": "/dashboard/?launch=new&shared=unavailable", + "record": {"title": "Keep me", "text": "private", "url": "", "attachments": []}, + } + + +def test_share_target_manifest_uses_dedicated_admission_path(): + manifest = json.loads((WORKER.parent / "manifest.webmanifest").read_text()) + + assert manifest["share_target"]["action"] == "./share-target" + + def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(): result = run_worker_scenario( """ @@ -800,7 +881,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_shared_image_capture.py b/tests/test_shared_image_capture.py index c69122d..efce5a4 100644 --- a/tests/test_shared_image_capture.py +++ b/tests/test_shared_image_capture.py @@ -68,4 +68,28 @@ shared.consume({{ assert json.loads(completed.stdout) == { "result": False, "calls": ["Share one PNG, JPEG, or WebP screenshot."], - } \ No newline at end of file + } + + +def test_private_share_bundle_restores_metadata_and_ordered_images_once(): + script = f""" +const shared=require({json.dumps(str(MODULE))});const calls=[]; +const store={{get:async()=>({{ + title:'Broken checkout',text:'Steps to reproduce',url:'https://example.test/private', + attachments:[{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}}], +}}),delete:async id=>calls.push(['delete',id])}}; +(async()=>{{const result=await shared.consume({{ + marker:'bundle',store, + restore:value=>calls.push(['restore',value.map(x=>x.filename)]), + restoreContent:value=>calls.push(['content',value]), + status:value=>calls.push(['status',value]), +}});process.stdout.write(JSON.stringify({{result,calls}}));}})(); +""" + output = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout) + + assert output == {"result": True, "calls": [ + ["content", {"title": "Broken checkout", "text": "Steps to reproduce", "url": "https://example.test/private"}], + ["restore", ["one.png"]], + ["delete", "shared-image"], + ["status", "Shared content and screenshot ready to file with this issue."], + ]} diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 5dd7720..e3e5ab1 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v105';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v106';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 895af8d..6f98a9b 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v105" in source + assert "stackchain-dashboard-shell-v106" in source assert "BASE + 'static/today-sync.js'" in source diff --git a/tests/test_work_pages.py b/tests/test_work_pages.py index 32c044f..59e50a7 100644 --- a/tests/test_work_pages.py +++ b/tests/test_work_pages.py @@ -103,7 +103,7 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da assert manifest.json()["start_url"] == "./" assert manifest.json()["scope"] == "./" assert manifest.json()["share_target"] == { - "action": "./", "method": "POST", "enctype": "multipart/form-data", + "action": "./share-target", "method": "POST", "enctype": "multipart/form-data", "params": { "title": "title", "text": "text", "url": "url", "files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}], @@ -137,6 +137,9 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da assert "new URL('./', self.location.href).pathname" in worker.text assert "acceptSharedContent(request)" in worker.text assert "sharedImageCapture.consume" in (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text() + dashboard = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text() + assert "restoreContent:content=>" in dashboard + assert "issueCapture.stageSharedContent(content)" in dashboard assert '' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()