feat: mark Web Push updates read (Closes #561)
This commit is contained in:
1 parent
320d6348ca
commit
b21888fd7a
4 files changed
+187
-17
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user