Test the browser web-push subscription lifecycle - `pushManager.subscribe({ userVisibleOnly, applicationServerKey })` per [W3C Push API][w3c-push] returning a `PushSubscription` with `endpoint` + `keys.p256dh` + `keys.auth` + optional `expirationTime`; the `pushsubscriptionchange` service-worker event on refresh / revoke / expiry; the `push` event delivery with `PushMessageData`; VAPID auth per RFC 8292 (ES256 JWT, `aud` / `exp` ≤ 24h / `sub`); RFC 8030 push-service responses (201 Created, 410 Gone for expired endpoints, 413 Payload Too Large, 429); and `unsubscribe()` cleanup. Use when a PWA ships web-push and the subscription lifecycle needs release-gate coverage - scoped to the browser Push API, not to cross-channel delivery over native APNs / FCM.
72
90%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Companion detail for web-push-tests. The browser-side subscription lifecycle
(subscribe, getSubscription, push event, pushsubscriptionchange, unsubscribe)
is the runnable core in SKILL.md; this file holds the server-side protocol
tests those steps reference.
410 Gone for expired endpointsPer [rfc8030], 410 Gone is "Returned when the push service ceases retry
delivery before advertised expiration." In practice it means the subscription
is no longer valid - the application server must delete the endpoint from its
store. Test the cleanup path:
import { describe, it, expect, vi } from 'vitest';
import webpush from 'web-push';
describe('subscription cleanup', () => {
it('deletes the subscription record on 410 Gone', async () => {
vi.spyOn(webpush, 'sendNotification').mockRejectedValue({
statusCode: 410,
body: 'Gone',
} as any);
const db = { delete: vi.fn() };
await sendAndPrune(db, { endpoint: 'https://push.example/abc', keys: { p256dh: '...', auth: '...' } }, { title: 'x' });
expect(db.delete).toHaveBeenCalledWith('https://push.example/abc');
});
});The relevant push-service responses per [rfc8030]:
| Code | Per [rfc8030] |
|---|---|
201 Created | "Indicates accepted push messages without delivery confirmation requests" |
410 Gone | "Returned when the push service ceases retry delivery before advertised expiration" - delete endpoint |
413 Payload Too Large | "may be returned for entity bodies exceeding size limits (though not for bodies ≤ 4096 bytes)" - split or shrink payload |
429 Too Many Requests | "Used to reject requests exceeding rate limits" - back off |
Per [rfc8292], the application server signs a JWT with ECDSA on P-256 (the
ES256 algorithm) and sends:
Authorization: vapid t=<JWT>, k=<base64url(public key, X9.62 uncompressed)>The JWT must include three claims per [rfc8292]:
| Claim | Constraint |
|---|---|
aud | "MUST include the Unicode serialization of the origin of the push resource URL" |
exp | "MUST NOT be more than 24 hours from the time of the request" |
sub | "SHOULD include a contact URI for the application server as either a 'mailto:' (email) or an 'https:' URI" |
Test the server-side VAPID generation:
import { decode } from 'jsonwebtoken';
it('VAPID JWT has aud, exp ≤ 24h, mailto sub', () => {
const auth = generateVapidAuth('https://updates.push.services.mozilla.com', 'mailto:ops@example.com');
// Parse t=<jwt>, k=<key>
const tMatch = auth.match(/t=([^,]+)/);
expect(tMatch).toBeTruthy();
const payload = decode(tMatch![1]) as any;
expect(payload.aud).toBe('https://updates.push.services.mozilla.com');
expect(payload.exp - Math.floor(Date.now() / 1000)).toBeLessThanOrEqual(24 * 3600);
expect(payload.sub).toMatch(/^(mailto:|https:)/);
});Source references: