Skip to content

Commit 4f16dde

Browse files
bndct-devopsCopilot
andcommitted
fix: harden web push subscription flow
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9dff5f6 commit 4f16dde

3 files changed

Lines changed: 75 additions & 19 deletions

File tree

backend/tests/test_api.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,39 @@ def test_auth_allows_access_with_valid_token(auth_client):
346346

347347

348348
def test_auth_unprotected_paths_always_accessible(auth_client):
349-
"""/health and /api/auth/status are always accessible without a token."""
349+
"""/health, auth status, and the VAPID public key stay public."""
350350
client, _ = auth_client
351351
assert client.get("/health").status_code == 200
352352
assert client.get("/api/auth/status").status_code == 200
353+
vapid_res = client.get("/api/push/vapid-public-key")
354+
assert vapid_res.status_code == 200
355+
assert isinstance(vapid_res.json()["publicKey"], str)
356+
assert len(vapid_res.json()["publicKey"]) > 20
357+
358+
359+
def test_push_mutation_endpoints_require_auth(auth_client):
360+
"""Push subscribe/schedule/cancel stay protected when instance auth is enabled."""
361+
client, _ = auth_client
362+
363+
subscribe_res = client.post(
364+
"/api/push/subscribe",
365+
json={
366+
"profileId": 1,
367+
"endpoint": "https://push.example.test/subscription",
368+
"p256dh": "test-p256dh",
369+
"auth": "test-auth",
370+
},
371+
)
372+
assert subscribe_res.status_code == 401
373+
374+
schedule_res = client.post(
375+
"/api/push/schedule",
376+
json={"profileId": 1, "delayMs": 1000, "title": "Rest done", "body": "Time to lift"},
377+
)
378+
assert schedule_res.status_code == 401
379+
380+
cancel_res = client.post("/api/push/cancel", json={"profileId": 1})
381+
assert cancel_res.status_code == 401
353382

354383

355384
def test_auth_change_password(auth_client):

frontend/src/ActiveWorkoutView.jsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,27 @@ export default function ActiveWorkoutView({ workout, exercises, sessionSets, onF
7575
typeof Notification !== 'undefined' ? Notification.permission : 'unavailable'
7676
)
7777

78-
// Subscribe to web push if permission already granted (handles app updates)
78+
const ensurePushSubscription = React.useCallback(async (requestPermission = false) => {
79+
if (typeof Notification === 'undefined') return false
80+
let permission = Notification.permission
81+
if (permission === 'default' && requestPermission) {
82+
try {
83+
permission = await Notification.requestPermission()
84+
} catch {
85+
permission = 'default'
86+
}
87+
}
88+
setNotifPerm(permission)
89+
if (permission !== 'granted') return false
90+
return subscribePush(workout?.profile_id)
91+
}, [workout?.profile_id])
92+
93+
// Subscribe to web push if permission is already granted (handles app updates)
7994
React.useEffect(() => {
8095
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
81-
subscribePush(workout?.profile_id).catch(() => {})
96+
ensurePushSubscription(false).catch(() => {})
8297
}
83-
}, [workout?.profile_id])
98+
}, [ensurePushSubscription])
8499

85100
async function openHistory(exId, exName) {
86101
setHistorySheet({ exId, name: exName, data: null })
@@ -110,19 +125,16 @@ export default function ActiveWorkoutView({ workout, exercises, sessionSets, onF
110125
}
111126
}
112127

113-
function startRestTimer(dur) {
128+
async function startRestTimer(dur) {
114129
navigator.vibrate?.(20)
115130
restEndRef.current = Date.now() + dur * 1000
116131
setRestLeft(dur)
117132
setRestRunning(true)
118-
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
119-
Notification.requestPermission().then(p => {
120-
setNotifPerm(p)
121-
if (p === 'granted') subscribePush(workout?.profile_id).catch(() => {})
122-
}).catch(() => {})
123-
}
124133
scheduleSwNotif(dur * 1000)
125-
schedulePush(workout?.profile_id, dur * 1000, 'lifty', 'Rest done — time to lift!')
134+
const pushReady = await ensurePushSubscription(true)
135+
if (pushReady) {
136+
schedulePush(workout?.profile_id, dur * 1000, 'lifty', 'Rest done — time to lift!')
137+
}
126138
}
127139
function stopRestTimer() {
128140
setRestLeft(null)
@@ -406,7 +418,7 @@ export default function ActiveWorkoutView({ workout, exercises, sessionSets, onF
406418
</div>
407419
{/* Notification permission status */}
408420
{notifPerm === 'default' && (
409-
<div onClick={() => Notification.requestPermission().then(p => setNotifPerm(p)).catch(() => {})}
421+
<div onClick={() => ensurePushSubscription(true).catch(() => {})}
410422
style={{ marginTop: 10, fontSize: '0.72rem', opacity: 0.85, cursor: 'pointer', textDecoration: 'underline' }}>
411423
Tap to enable alarm notification
412424
</div>

frontend/src/api.js

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@ export async function getExerciseHistory(exerciseId, profileId, limit = 30) {
280280

281281
export async function getPushVapidKey() {
282282
const res = await authFetch(base + '/api/push/vapid-public-key')
283+
if (!res.ok) {
284+
console.warn('[push] failed to fetch VAPID public key', res.status)
285+
return null
286+
}
283287
const data = await res.json()
284288
return data.publicKey
285289
}
@@ -294,12 +298,13 @@ export async function subscribePush(profileId) {
294298
const padding = '='.repeat((4 - publicKey.length % 4) % 4)
295299
const base64 = (publicKey + padding).replace(/-/g, '+').replace(/_/g, '/')
296300
const rawKey = Uint8Array.from(atob(base64), c => c.charCodeAt(0))
297-
const sub = await reg.pushManager.subscribe({
301+
const existing = await reg.pushManager.getSubscription()
302+
const sub = existing || await reg.pushManager.subscribe({
298303
userVisibleOnly: true,
299304
applicationServerKey: rawKey,
300305
})
301306
const json = sub.toJSON()
302-
await authFetch(base + '/api/push/subscribe', {
307+
const res = await authFetch(base + '/api/push/subscribe', {
303308
method: 'POST',
304309
headers: { 'Content-Type': 'application/json' },
305310
body: JSON.stringify({
@@ -309,6 +314,10 @@ export async function subscribePush(profileId) {
309314
auth: json.keys.auth,
310315
}),
311316
})
317+
if (!res.ok) {
318+
console.warn('[push] failed to store subscription', res.status)
319+
return false
320+
}
312321
return true
313322
} catch (e) {
314323
console.warn('[push] subscribe failed', e)
@@ -318,20 +327,26 @@ export async function subscribePush(profileId) {
318327

319328
export async function schedulePush(profileId, delayMs, title, body) {
320329
try {
321-
await authFetch(base + '/api/push/schedule', {
330+
const res = await authFetch(base + '/api/push/schedule', {
322331
method: 'POST',
323332
headers: { 'Content-Type': 'application/json' },
324333
body: JSON.stringify({ profileId, delayMs, title, body }),
325334
})
326-
} catch (_) {}
335+
if (!res.ok) console.warn('[push] schedule failed', res.status)
336+
} catch (e) {
337+
console.warn('[push] schedule failed', e)
338+
}
327339
}
328340

329341
export async function cancelPush(profileId) {
330342
try {
331-
await authFetch(base + '/api/push/cancel', {
343+
const res = await authFetch(base + '/api/push/cancel', {
332344
method: 'POST',
333345
headers: { 'Content-Type': 'application/json' },
334346
body: JSON.stringify({ profileId }),
335347
})
336-
} catch (_) {}
348+
if (!res.ok) console.warn('[push] cancel failed', res.status)
349+
} catch (e) {
350+
console.warn('[push] cancel failed', e)
351+
}
337352
}

0 commit comments

Comments
 (0)