security: keep mobile share content private (Closes #903)
This commit is contained in:
parent
06184f1cb2
commit
fdd3cf03b4
|
|
@ -4089,6 +4089,24 @@
|
||||||
taskOverlayHistory.open('new');
|
taskOverlayHistory.open('new');
|
||||||
return;
|
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();
|
const captureDraft = issueCapture.loadDraft();
|
||||||
issueTemplatePicker.reset(captureDraft);
|
issueTemplatePicker.reset(captureDraft);
|
||||||
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "./",
|
"action": "./share-target",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"enctype": "multipart/form-data",
|
"enctype": "multipart/form-data",
|
||||||
"params": {
|
"params": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/private-data-registry.js');
|
importScripts(BASE + 'static/private-data-registry.js');
|
||||||
importScripts(BASE + 'static/background-issue-sync.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 OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
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_ID = 'shared-image';
|
||||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||||
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
|
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||||
|
const SHARE_TARGET_PATH = BASE + 'share-target';
|
||||||
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
||||||
|
|
||||||
function boundedShareField(form, name, limit) {
|
function boundedShareField(form, name, limit) {
|
||||||
|
|
@ -120,45 +121,46 @@ function boundedShareField(form, name, limit) {
|
||||||
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acceptSharedContent(request) {
|
function sharedContentRedirect(marker) {
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const target = new URL(BASE, self.location.origin);
|
const target = new URL(BASE, self.location.origin);
|
||||||
target.searchParams.set('launch', 'new');
|
target.searchParams.set('launch', 'new');
|
||||||
if (marker) target.searchParams.set('shared', marker);
|
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}});
|
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) {
|
async function fetchNavigation(request) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let timeout;
|
let timeout;
|
||||||
|
|
@ -563,7 +565,7 @@ self.addEventListener('fetch', event => {
|
||||||
const requestUrl = new URL(request.url);
|
const requestUrl = new URL(request.url);
|
||||||
if (
|
if (
|
||||||
request.method === 'POST' && request.mode === 'navigate'
|
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));
|
event.respondWith(acceptSharedContent(request));
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,35 @@
|
||||||
const RECORD_ID = 'shared-image';
|
const RECORD_ID = 'shared-image';
|
||||||
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
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;
|
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)) {
|
if (!['image', 'images'].includes(marker)) {
|
||||||
status('Share one PNG, JPEG, or WebP screenshot.');
|
status('Share one PNG, JPEG, or WebP screenshot.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const value = await store?.get(RECORD_ID);
|
|
||||||
const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
|
|
||||||
if (marker === 'images') {
|
if (marker === 'images') {
|
||||||
if (!attachments?.length || attachments.length > 5 || attachments.some(attachment =>
|
if (!attachments?.length || attachments.length > 5 || attachments.some(attachment =>
|
||||||
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) {
|
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) {
|
||||||
|
|
|
||||||
18
src/main.py
18
src/main.py
|
|
@ -1168,6 +1168,16 @@ def _share_target_login_redirect(request: Request) -> str:
|
||||||
continuation_values.append(("search_repository", repository_values[0]))
|
continuation_values.append(("search_repository", repository_values[0]))
|
||||||
continuation = urlencode(continuation_values)
|
continuation = urlencode(continuation_values)
|
||||||
return f"login?{urlencode({'continue': f'./?{continuation}'})}"
|
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}
|
limits = {"title": 200, "text": 8000, "url": 2048}
|
||||||
if any(
|
if any(
|
||||||
len(request.query_params.get(name, "")) > limit
|
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", "")
|
launch = request.query_params.get("launch", "")
|
||||||
if launch in {"continue", "new", "agenda"}:
|
if launch in {"continue", "new", "agenda"}:
|
||||||
shared.append(("launch", launch))
|
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:
|
if dashboard_auth.application_path(request) != "/" or not shared:
|
||||||
return "login"
|
return "login"
|
||||||
continuation = f"./?{urlencode(shared)}"
|
continuation = f"./?{urlencode(shared)}"
|
||||||
|
|
|
||||||
|
|
@ -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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
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
|
||||||
|
|
|
||||||
|
|
@ -944,33 +944,26 @@ async def test_anonymous_search_preview_preserves_only_valid_search_scope(access
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@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)
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
valid = await client.get(
|
valid = await client.get(
|
||||||
"/",
|
"/",
|
||||||
params={
|
params={"launch": "new", "shared": "bundle"},
|
||||||
"launch": "new",
|
|
||||||
"shared": "image",
|
|
||||||
"title": "Broken checkout",
|
|
||||||
"text": "Steps to reproduce",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
wrong_launch = await client.get(
|
wrong_launch = await client.get(
|
||||||
"/", params={"launch": "agenda", "shared": "image"}
|
"/", params={"launch": "agenda", "shared": "bundle"}
|
||||||
)
|
)
|
||||||
wrong_marker = await client.get(
|
wrong_marker = await client.get(
|
||||||
"/", params={"launch": "new", "shared": "document"}
|
"/", params={"launch": "new", "shared": "document"}
|
||||||
)
|
)
|
||||||
duplicate_marker = await client.get(
|
duplicate_marker = await client.get(
|
||||||
"/?launch=new&shared=image&shared=image"
|
"/?launch=new&shared=bundle&shared=bundle"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert valid.status_code == 303
|
assert valid.status_code == 303
|
||||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||||
"continue": [
|
"continue": ["./?launch=new&shared=bundle"]
|
||||||
"./?title=Broken+checkout&text=Steps+to+reproduce&launch=new&shared=image"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
assert wrong_launch.headers["location"] == "login"
|
assert wrong_launch.headers["location"] == "login"
|
||||||
assert wrong_marker.headers["location"] == "login"
|
assert wrong_marker.headers["location"] == "login"
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
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
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" 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
|
||||||
|
|
|
||||||
|
|
@ -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]))
|
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 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():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "promptStorage:localStorage" in dashboard
|
assert "promptStorage:localStorage" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
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-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -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 ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
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
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
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.js'" in source
|
||||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const vm = require('vm');
|
const vm = require('vm');
|
||||||
const listeners = {{}};
|
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();
|
const storedResponses = new Map();
|
||||||
storedResponses.set(
|
storedResponses.set(
|
||||||
'https://forge.example/dashboard/__offline-session-lease',
|
'https://forge.example/dashboard/__offline-session-lease',
|
||||||
|
|
@ -49,7 +49,10 @@ const context = {{
|
||||||
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
|
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
|
||||||
stackchainPrivateDatabases: require({json.dumps(str(PRIVATE_DATA_REGISTRY))}),
|
stackchainPrivateDatabases: require({json.dumps(str(PRIVATE_DATA_REGISTRY))}),
|
||||||
__STACKCHAIN_SHARED_ATTACHMENT_STORE: {{
|
__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,
|
get: async id => state.sharedRecords[id] || null,
|
||||||
delete: async id => {{ delete state.sharedRecords[id]; }},
|
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():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" 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():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
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/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.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():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.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():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
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.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.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():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
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('text', 'Steps to reproduce');
|
||||||
form.append('url', 'https://example.test/checkout');
|
form.append('url', 'https://example.test/checkout');
|
||||||
form.append('image', image, 'bug.png');
|
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'});
|
Object.defineProperty(request, 'mode', {value:'navigate'});
|
||||||
const response = await dispatch('fetch', request);
|
const response = await dispatch('fetch', request);
|
||||||
const [id, record] = Object.entries(state.sharedRecords)[0];
|
const [id, record] = Object.entries(state.sharedRecords)[0];
|
||||||
process.stdout.write(JSON.stringify({
|
process.stdout.write(JSON.stringify({
|
||||||
status:response.status, location:response.headers.get('Location'), id,
|
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 == {
|
assert result == {
|
||||||
"status": 303,
|
"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",
|
"id": "shared-image",
|
||||||
|
"title": "Broken checkout",
|
||||||
|
"text": "Steps to reproduce",
|
||||||
|
"url": "https://example.test/checkout",
|
||||||
"filename": "bug.png",
|
"filename": "bug.png",
|
||||||
"contentType": "image/png",
|
"contentType": "image/png",
|
||||||
"size": 6,
|
"size": 6,
|
||||||
|
|
@ -255,7 +266,9 @@ def test_image_share_target_stages_ordered_evidence_bundle():
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('image', new Blob(['one'], {type:'image/png'}), 'one.png');
|
form.append('image', new Blob(['one'], {type:'image/png'}), 'one.png');
|
||||||
form.append('image', new Blob(['two'], {type:'image/jpeg'}), 'two.jpg');
|
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'});
|
Object.defineProperty(request, 'mode', {value:'navigate'});
|
||||||
const response = await dispatch('fetch', request);
|
const response = await dispatch('fetch', request);
|
||||||
const record=state.sharedRecords['shared-image'];
|
const record=state.sharedRecords['shared-image'];
|
||||||
|
|
@ -268,12 +281,80 @@ def test_image_share_target_stages_ordered_evidence_bundle():
|
||||||
|
|
||||||
assert result == {
|
assert result == {
|
||||||
"status": 303,
|
"status": 303,
|
||||||
"location": "/dashboard/?launch=new&shared=images",
|
"location": "/dashboard/?launch=new&shared=bundle",
|
||||||
"names": ["one.png", "two.jpg"],
|
"names": ["one.png", "two.jpg"],
|
||||||
"sizes": [3, 3],
|
"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():
|
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||||
result = run_worker_scenario(
|
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():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,4 +68,28 @@ shared.consume({{
|
||||||
assert json.loads(completed.stdout) == {
|
assert json.loads(completed.stdout) == {
|
||||||
"result": False,
|
"result": False,
|
||||||
"calls": ["Share one PNG, JPEG, or WebP screenshot."],
|
"calls": ["Share one PNG, JPEG, or WebP screenshot."],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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."],
|
||||||
|
]}
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
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
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
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
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()["start_url"] == "./"
|
||||||
assert manifest.json()["scope"] == "./"
|
assert manifest.json()["scope"] == "./"
|
||||||
assert manifest.json()["share_target"] == {
|
assert manifest.json()["share_target"] == {
|
||||||
"action": "./", "method": "POST", "enctype": "multipart/form-data",
|
"action": "./share-target", "method": "POST", "enctype": "multipart/form-data",
|
||||||
"params": {
|
"params": {
|
||||||
"title": "title", "text": "text", "url": "url",
|
"title": "title", "text": "text", "url": "url",
|
||||||
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}],
|
"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 "new URL('./', self.location.href).pathname" in worker.text
|
||||||
assert "acceptSharedContent(request)" in worker.text
|
assert "acceptSharedContent(request)" in worker.text
|
||||||
assert "sharedImageCapture.consume" in (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_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 '<script src="static/shared-image-capture.js"></script>' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
assert '<script src="static/shared-image-capture.js"></script>' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user