// @vitest-environment jsdom /** * test/push.test.ts — N-push-ui: push subscribe/permission UI * * Covers: PushSupportStatus states, fetchVapidKey, checkPushSupport, * subscribePush, unsubscribePush, mountPushToggle, isPushMuted/setPushMuted. * * SEC-H8: isSecureContext gating. * AC-A1.1, AC-A1.5: all support states; insecure context hidden/no crash; ≥80%. * Review #12: localStorage mute is in-app-only (not server DND). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // ── Browser API helpers ─────────────────────────────────────────────────────── function setSecureContext(secure: boolean): void { Object.defineProperty(window, 'isSecureContext', { value: secure, writable: true, configurable: true, }) } interface MockPushSubscription { endpoint: string unsubscribe: ReturnType toJSON: ReturnType getKey: ReturnType } function makeMockSubscription(endpoint = 'https://push.example.com/sub'): MockPushSubscription { return { endpoint, unsubscribe: vi.fn().mockResolvedValue(true), toJSON: vi.fn().mockReturnValue({ endpoint, keys: { p256dh: 'pub', auth: 'auth' } }), getKey: vi.fn().mockReturnValue(null), } } function makeMockRegistration(subscription: MockPushSubscription | null = null) { return { pushManager: { getSubscription: vi.fn().mockResolvedValue(subscription), subscribe: vi.fn().mockResolvedValue(makeMockSubscription()), }, } } function setServiceWorker(registration: ReturnType | null = null) { Object.defineProperty(navigator, 'serviceWorker', { value: { getRegistration: vi.fn().mockResolvedValue(registration), }, writable: true, configurable: true, }) } function setNotification(permission: 'default' | 'granted' | 'denied') { vi.stubGlobal('Notification', { permission, requestPermission: vi.fn().mockResolvedValue(permission), }) } function removeNotification() { // Simulate browsers without Notification API Object.defineProperty(window, 'Notification', { value: undefined, writable: true, configurable: true, }) } function setPushManagerPresent(present: boolean) { Object.defineProperty(window, 'PushManager', { value: present ? class PushManager {} : undefined, writable: true, configurable: true, }) } function mockFetch(body: unknown, status = 200) { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: status >= 200 && status < 300, status, json: vi.fn().mockResolvedValue(body), }), ) } function mockFetchError() { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))) } // ── Setup: default "all supported" environment ──────────────────────────────── beforeEach(() => { vi.restoreAllMocks() setSecureContext(true) setPushManagerPresent(true) setNotification('default') setServiceWorker(makeMockRegistration(null)) mockFetch({ publicKey: 'dGVzdA' /* base64 "test" */ }) // Clear localStorage try { localStorage.clear() } catch { // ignore } }) afterEach(() => { vi.restoreAllMocks() }) // ── Dynamic import (after stubs are set up) ────────────────────────────────── // We import at module level so vitest transpiles the TS file. // The module reads globals at call-time, not at import time, so this is safe. import { fetchVapidKey, checkPushSupport, subscribePush, unsubscribePush, mountPushToggle, isPushMuted, setPushMuted, } from '../public/push.js' // ─────────────────────────── isPushMuted / setPushMuted ────────────────────── describe('isPushMuted / setPushMuted (A1-FR9 in-app-only)', () => { it('defaults to false when nothing is stored', () => { expect(isPushMuted()).toBe(false) }) it('returns true after setPushMuted(true)', () => { setPushMuted(true) expect(isPushMuted()).toBe(true) }) it('returns false after setPushMuted(false) clears the value', () => { setPushMuted(true) setPushMuted(false) expect(isPushMuted()).toBe(false) }) }) // ─────────────────────────── fetchVapidKey ─────────────────────────────────── describe('fetchVapidKey', () => { it('returns the publicKey string on a 200 response', async () => { mockFetch({ publicKey: 'my-vapid-public-key' }) const key = await fetchVapidKey() expect(key).toBe('my-vapid-public-key') }) it('returns null when server returns 503 (push disabled)', async () => { mockFetch({}, 503) const key = await fetchVapidKey() expect(key).toBeNull() }) it('returns null on network error', async () => { mockFetchError() const key = await fetchVapidKey() expect(key).toBeNull() }) it('returns null when response body is missing publicKey', async () => { mockFetch({ wrong: 'shape' }) const key = await fetchVapidKey() expect(key).toBeNull() }) it('returns null when publicKey is not a string', async () => { mockFetch({ publicKey: 123 }) const key = await fetchVapidKey() expect(key).toBeNull() }) }) // ─────────────────────────── checkPushSupport ──────────────────────────────── describe('checkPushSupport — insecure-context (SEC-H8)', () => { it("returns 'insecure-context' when window.isSecureContext is false", async () => { setSecureContext(false) const status = await checkPushSupport('some-vapid-key') expect(status).toBe('insecure-context') }) }) describe('checkPushSupport — unsupported', () => { it("returns 'unsupported' when serviceWorker is absent", async () => { Object.defineProperty(navigator, 'serviceWorker', { value: undefined, writable: true, configurable: true, }) const status = await checkPushSupport('some-key') expect(status).toBe('unsupported') }) it("returns 'unsupported' when PushManager is absent", async () => { setPushManagerPresent(false) const status = await checkPushSupport('some-key') expect(status).toBe('unsupported') }) it("returns 'unsupported' when Notification is absent", async () => { removeNotification() const status = await checkPushSupport('some-key') expect(status).toBe('unsupported') }) }) describe('checkPushSupport — vapid-missing', () => { it("returns 'vapid-missing' when vapidKey is null", async () => { const status = await checkPushSupport(null) expect(status).toBe('vapid-missing') }) }) describe('checkPushSupport — permission-denied', () => { it("returns 'permission-denied' when Notification.permission is 'denied'", async () => { setNotification('denied') const status = await checkPushSupport('some-key') expect(status).toBe('permission-denied') }) }) describe('checkPushSupport — subscribed', () => { it("returns 'subscribed' when SW registration has an active subscription", async () => { setServiceWorker(makeMockRegistration(makeMockSubscription())) const status = await checkPushSupport('some-key') expect(status).toBe('subscribed') }) }) describe('checkPushSupport — available', () => { it("returns 'available' when all checks pass and no subscription exists", async () => { // Default: secure, supported, no subscription, permission default const status = await checkPushSupport('some-key') expect(status).toBe('available') }) it("returns 'available' when permission is 'granted' but no subscription yet", async () => { setNotification('granted') const status = await checkPushSupport('some-key') expect(status).toBe('available') }) it("returns 'available' when SW registration returns null", async () => { setServiceWorker(null) const status = await checkPushSupport('some-key') expect(status).toBe('available') }) it("returns 'available' when pushManager.getSubscription throws", async () => { const reg = makeMockRegistration(null) reg.pushManager.getSubscription.mockRejectedValue(new Error('SW error')) setServiceWorker(reg) const status = await checkPushSupport('some-key') expect(status).toBe('available') }) }) describe('checkPushSupport — priority ordering', () => { it('insecure-context wins over unsupported', async () => { setSecureContext(false) setPushManagerPresent(false) expect(await checkPushSupport('key')).toBe('insecure-context') }) it('insecure-context wins over vapid-missing', async () => { setSecureContext(false) expect(await checkPushSupport(null)).toBe('insecure-context') }) it('unsupported wins over vapid-missing', async () => { setPushManagerPresent(false) expect(await checkPushSupport(null)).toBe('unsupported') }) }) // ─────────────────────────── subscribePush ─────────────────────────────────── describe('subscribePush', () => { it('returns null when Notification.requestPermission is denied', async () => { vi.stubGlobal('Notification', { permission: 'default', requestPermission: vi.fn().mockResolvedValue('denied'), }) mockFetch({}, 200) const result = await subscribePush('some-key') expect(result).toBeNull() }) it('returns null when no SW registration is found', async () => { vi.stubGlobal('Notification', { permission: 'default', requestPermission: vi.fn().mockResolvedValue('granted'), }) setServiceWorker(null) const result = await subscribePush('some-key') expect(result).toBeNull() }) it('posts subscription to /push/subscribe and returns it on success', async () => { vi.stubGlobal('Notification', { permission: 'default', requestPermission: vi.fn().mockResolvedValue('granted'), }) const mockSub = makeMockSubscription() const reg = makeMockRegistration(null) reg.pushManager.subscribe.mockResolvedValue(mockSub) setServiceWorker(reg) mockFetch({}, 201) const result = await subscribePush('dGVzdA') // base64 "test" expect(result).toBe(mockSub) expect(vi.mocked(fetch)).toHaveBeenCalledWith( '/push/subscribe', expect.objectContaining({ method: 'POST' }), ) }) it('unsubscribes from browser and returns null when server rejects', async () => { vi.stubGlobal('Notification', { permission: 'default', requestPermission: vi.fn().mockResolvedValue('granted'), }) const mockSub = makeMockSubscription() const reg = makeMockRegistration(null) reg.pushManager.subscribe.mockResolvedValue(mockSub) setServiceWorker(reg) mockFetch({}, 400) const result = await subscribePush('dGVzdA') expect(result).toBeNull() expect(mockSub.unsubscribe).toHaveBeenCalled() }) it('returns null on exception (e.g. pushManager.subscribe throws)', async () => { vi.stubGlobal('Notification', { permission: 'default', requestPermission: vi.fn().mockResolvedValue('granted'), }) const reg = makeMockRegistration(null) reg.pushManager.subscribe.mockRejectedValue(new Error('blocked')) setServiceWorker(reg) const result = await subscribePush('dGVzdA') expect(result).toBeNull() }) }) // ─────────────────────────── unsubscribePush ───────────────────────────────── describe('unsubscribePush', () => { it('calls DELETE /push/subscribe and browser unsubscribe', async () => { const mockSub = makeMockSubscription() mockFetch({}, 204) await unsubscribePush(mockSub as unknown as PushSubscription) expect(vi.mocked(fetch)).toHaveBeenCalledWith( '/push/subscribe', expect.objectContaining({ method: 'DELETE' }), ) expect(mockSub.unsubscribe).toHaveBeenCalled() }) it('still calls browser unsubscribe even when server DELETE fails', async () => { const mockSub = makeMockSubscription() mockFetchError() await unsubscribePush(mockSub as unknown as PushSubscription) expect(mockSub.unsubscribe).toHaveBeenCalled() }) it('does not throw when browser unsubscribe throws', async () => { const mockSub = makeMockSubscription() mockSub.unsubscribe.mockRejectedValue(new Error('browser error')) mockFetch({}, 204) await expect( unsubscribePush(mockSub as unknown as PushSubscription), ).resolves.toBeUndefined() }) }) // ─────────────────────────── mountPushToggle ───────────────────────────────── describe('mountPushToggle — vapid-missing', () => { it('hides the container when vapid key is missing (503)', async () => { mockFetch({}, 503) // fetchVapidKey returns null const container = document.createElement('div') mountPushToggle(container) // Wait for async init await new Promise((r) => setTimeout(r, 0)) expect(container.style.display).toBe('none') expect(container.children.length).toBe(0) }) }) describe('mountPushToggle — insecure-context (SEC-H8)', () => { it('shows grayed bell with HTTPS/Tailscale hint, does not crash', async () => { setSecureContext(false) // fetchVapidKey might succeed, but checkPushSupport returns insecure-context mockFetch({ publicKey: 'some-key' }) const container = document.createElement('div') mountPushToggle(container) await new Promise((r) => setTimeout(r, 0)) // Container should NOT be hidden expect(container.style.display).not.toBe('none') // Should show a hint about HTTPS const hint = container.querySelector('.push-hint') expect(hint).not.toBeNull() expect(hint?.textContent).toMatch(/https|tailscale/i) }) }) describe('mountPushToggle — available', () => { it('renders a functional toggle button', async () => { mockFetch({ publicKey: 'some-key' }) // No existing subscription const container = document.createElement('div') mountPushToggle(container) await new Promise((r) => setTimeout(r, 0)) const btn = container.querySelector('button.push-toggle-btn') expect(btn).not.toBeNull() expect(btn?.getAttribute('aria-pressed')).toBe('false') }) }) describe('mountPushToggle — subscribed', () => { it('shows toggle as active (aria-pressed=true) when subscription exists', async () => { mockFetch({ publicKey: 'some-key' }) setServiceWorker(makeMockRegistration(makeMockSubscription())) const container = document.createElement('div') mountPushToggle(container) await new Promise((r) => setTimeout(r, 0)) const btn = container.querySelector('button.push-toggle-btn') expect(btn).not.toBeNull() expect(btn?.getAttribute('aria-pressed')).toBe('true') }) }) describe('mountPushToggle — permission-denied', () => { it('shows grayed bell with blocked hint', async () => { setNotification('denied') mockFetch({ publicKey: 'some-key' }) const container = document.createElement('div') mountPushToggle(container) await new Promise((r) => setTimeout(r, 0)) expect(container.style.display).not.toBe('none') const hint = container.querySelector('.push-hint') expect(hint).not.toBeNull() }) }) describe('mountPushToggle — onChange callback', () => { it('calls onChange with the detected status', async () => { mockFetch({}, 503) const onChange = vi.fn() const container = document.createElement('div') mountPushToggle(container, { onChange }) await new Promise((r) => setTimeout(r, 0)) expect(onChange).toHaveBeenCalledWith('vapid-missing') }) }) describe('mountPushToggle — unsupported browser', () => { it('shows grayed bell with unsupported hint when PushManager absent', async () => { setPushManagerPresent(false) mockFetch({ publicKey: 'some-key' }) const container = document.createElement('div') mountPushToggle(container) await new Promise((r) => setTimeout(r, 0)) expect(container.style.display).not.toBe('none') const hint = container.querySelector('.push-hint') expect(hint).not.toBeNull() }) })