feat: mark Web Push updates read (Closes #561)
CI / lint (pull_request) Successful in 1m12s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 09:57:05 +00:00
1 parent 320d6348ca
commit b21888fd7a
4 files changed
+187 -17

No files matched your search

+1
View File
@@ -141,6 +141,7 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
"body": "Tap to review it in Stackchain.",
"route": "#/my-work/update/42",
"tag": "stackchain-update-42",
"notification_id": 42,
}
assert "private/repo" not in json.dumps(sent)
assert "Secret title" not in json.dumps(sent)
+136 -4
View File
@@ -41,6 +41,7 @@ const context = {{
self: {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{
@@ -104,10 +105,11 @@ async function dispatchMessage(data, ports = []) {{
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route) {{
async function dispatchNotificationClick(route, action = '', notificationId = null) {{
let pending;
listeners.notificationclick({{
notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}},
action,
notification: {{data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}},
waitUntil: promise => {{ pending = promise; }},
}});
if (pending) await pending;
@@ -386,7 +388,7 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
"""
await dispatchPush({
title:'New work update', body:'Tap to review it in Stackchain.',
tag:'stackchain-update-42', route:'#/my-work/update/42',
tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42,
repository:'must-not-render',
});
await dispatchNotificationClick('#/my-work/update/42');
@@ -399,7 +401,11 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
"options": {
"body": "Tap to review it in Stackchain.",
"tag": "stackchain-update-42",
"data": {"route": "#/my-work/update/42"},
"actions": [{"action": "mark-read", "title": "Mark read"}],
"data": {
"route": "#/my-work/update/42",
"notificationId": 42,
},
},
}]
assert result["opened"] == [
@@ -408,6 +414,132 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
assert "must-not-render" not in json.dumps(result["notifications"])
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
result = run_worker_scenario(
"""
const calls = [];
context.fetch = async (request, options = {}) => {
const url = String(request.url || request);
const headers = new Headers(options.headers || {});
calls.push({url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token')});
if (url.endsWith('/api/v1/session')) {
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
status:200, headers:{'Content-Type':'application/json'},
});
}
return new Response(JSON.stringify({ok:true}), {
status:200, headers:{'Content-Type':'application/json'},
});
};
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
process.stdout.write(JSON.stringify({state,calls}));
"""
)
assert result["calls"] == [
{
"url": "https://forge.example/dashboard/api/v1/session",
"method": "GET",
"csrf": None,
},
{
"url": "https://forge.example/dashboard/api/v1/notifications/42/read",
"method": "PATCH",
"csrf": "session-proof",
},
]
assert result["state"]["notificationClosed"] is True
assert result["state"]["opened"] == []
assert result["state"]["focused"] == []
def test_push_mark_read_failure_opens_existing_update_reader():
result = run_worker_scenario(
"""
state.clientList = [{
url:'https://forge.example/dashboard/',
navigate:async function(url){ this.url=url; },
focus:async function(){ state.focused.push(this.url); },
}];
context.fetch = async (request, options = {}) => {
const url = String(request.url || request);
if (url.endsWith('/api/v1/session')) {
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
status:200, headers:{'Content-Type':'application/json'},
});
}
return new Response(JSON.stringify({error:'unavailable'}), {
status:503, headers:{'Content-Type':'application/json'},
});
};
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
process.stdout.write(JSON.stringify(state));
"""
)
assert result["focused"] == [
"https://forge.example/dashboard/#/my-work/update/42"
]
assert result["opened"] == []
assert result["notificationClosed"] is True
def test_push_mark_read_rejects_mismatched_notification_identity():
result = run_worker_scenario(
"""
const calls = [];
context.fetch = async (request, options = {}) => {
calls.push({url:String(request.url || request), method:String(options.method || 'GET')});
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
status:200, headers:{'Content-Type':'application/json'},
});
};
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 43);
process.stdout.write(JSON.stringify({state,calls}));
"""
)
assert result["calls"] == []
assert result["state"].get("notificationClosed") is not True
assert result["state"]["opened"] == []
def test_push_mark_read_timeout_aborts_request_and_opens_update_reader():
result = run_worker_scenario(
"""
state.clientList = [{
url:'https://forge.example/dashboard/',
navigate:async function(url){ this.url=url; },
focus:async function(){ state.focused.push(this.url); },
}];
context.fetch = async (request, options = {}) => {
const url = String(request.url || request);
if (url.endsWith('/api/v1/session')) {
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
status:200, headers:{'Content-Type':'application/json'},
});
}
return new Promise((resolve, reject) => {
options.signal?.addEventListener('abort', () => {
state.actionFetchAborted = true;
reject(new Error('aborted'));
}, {once:true});
});
};
dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
await new Promise(resolve => setTimeout(resolve, 50));
process.stdout.write(JSON.stringify(state));
process.exit(0);
"""
)
assert result["actionFetchAborted"] is True
assert result["focused"] == [
"https://forge.example/dashboard/#/my-work/update/42"
]
assert result["notificationClosed"] is True
def test_background_mutations_obtain_session_bound_csrf_proof():
source = WORKER.read_text()