import { createECDH, randomBytes } from 'node:crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { applyPollResult } from '../src/services/nowPlaying'; import { sendPush } from '../src/services/notifications'; import { bearer, createUser, follow, useTestApp } from './helpers'; const app = useTestApp(); afterEach(() => vi.unstubAllGlobals()); const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null }); const inbox = async (token: string) => (await app().inject({ url: '/notifications', headers: bearer(token) })).json(); const react = (token: string, listenId: string, emoji: string) => app().inject({ method: 'PUT', url: `/listens/${listenId}/reaction`, headers: bearer(token), payload: { emoji } }); /** Alice plays a song; Bob follows her. */ async function setup() { const alice = await createUser(app(), 'alice'); const bob = await createUser(app(), 'bob'); await follow(app(), bob.token, 'alice'); await applyPollResult(alice.user.id, 'lastfm', song('Teardrop')); const feed = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json(); return { alice, bob, listenId: feed[0].listenId as string, trackId: feed[0].track.id as string }; } describe('notifications', () => { it('tells you about a new follower once, even if they unfollow and follow again', async () => { const { alice, bob } = await setup(); await app().inject({ method: 'DELETE', url: '/users/alice/follow', headers: bearer(bob.token) }); await follow(app(), bob.token, 'alice'); const list = await inbox(alice.token); expect(list.unread).toBe(1); expect(list.notifications).toEqual([expect.objectContaining({ type: 'follow', actor: expect.objectContaining({ username: 'bob' }), read: false })]); }); it('a reaction is one notification that changes with the emoji, and goes if taken back', async () => { const { alice, bob, listenId } = await setup(); await app().inject({ method: 'POST', url: '/notifications/read', headers: bearer(alice.token), payload: {} }); await react(bob.token, listenId, '🔥'); await react(bob.token, listenId, '😂'); let list = await inbox(alice.token); const reactions = list.notifications.filter((n: { type: string }) => n.type === 'reaction'); expect(reactions).toEqual([expect.objectContaining({ emoji: '😂', track: expect.objectContaining({ title: 'Teardrop' }) })]); expect(list.unread).toBe(1); await app().inject({ method: 'DELETE', url: `/listens/${listenId}/reaction`, headers: bearer(bob.token) }); list = await inbox(alice.token); expect(list.notifications.filter((n: { type: string }) => n.type === 'reaction')).toEqual([]); }); it('sends songs only to people who follow you', async () => { const { alice, bob, trackId } = await setup(); const send = (token: string, to: string[]) => app().inject({ method: 'POST', url: '/shares', headers: bearer(token), payload: { trackId, to, message: 'you need this' } }); // Bob follows Alice, so Alice can send to Bob, but Bob can't send to Alice. expect((await send(alice.token, ['bob', 'nobody'])).json()).toEqual({ sentTo: ['bob'] }); expect((await send(bob.token, ['alice'])).statusCode).toBe(400); const list = await inbox(bob.token); expect(list.notifications[0]).toMatchObject({ type: 'share', message: 'you need this', actor: { username: 'alice' }, track: { title: 'Teardrop' }, }); await app().inject({ method: 'POST', url: '/notifications/read', headers: bearer(bob.token), payload: {} }); expect((await inbox(bob.token)).unread).toBe(0); }); it('blocking removes notifications between the two', async () => { const { alice } = await setup(); expect((await inbox(alice.token)).notifications).toHaveLength(1); await app().inject({ method: 'POST', url: '/users/bob/block', headers: bearer(alice.token), payload: {} }); expect((await inbox(alice.token)).notifications).toHaveLength(0); }); it('saves notification settings', async () => { const { alice } = await setup(); const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false, reply: false } } })).json(); expect(me.notifyPrefs).toEqual({ reaction: true, comment: true, reply: false, follow: false, share: true }); }); }); describe('push', () => { // A real P-256 key pair, as a browser or the UnifiedPush connector makes. const ecdh = createECDH('prime256v1'); ecdh.generateKeys(); const keys = { p256dh: ecdh.getPublicKey('base64url'), auth: randomBytes(16).toString('base64url') }; const subscribe = (token: string, endpoint: string) => app().inject({ method: 'PUT', url: '/me/push', headers: bearer(token), payload: { kind: 'unifiedpush', endpoint, keys } }); it('refuses push endpoints on private networks', async () => { const alice = await createUser(app(), 'alice'); expect((await subscribe(alice.token, 'http://127.0.0.1:8080/up/abc')).statusCode).toBe(400); expect((await subscribe(alice.token, 'https://93.184.216.34/up/abc')).statusCode).toBe(200); }); it('sends an encrypted, signed push, respects settings, and forgets endpoints that are gone', async () => { const { alice } = await setup(); await subscribe(alice.token, 'https://93.184.216.34/up/abc'); const id = (await inbox(alice.token)).notifications[0].id; const sent: { url: string; headers: Record; body: Buffer }[] = []; let status = 201; vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { sent.push({ url, headers: init.headers as Record, body: init.body as Buffer }); return new Response(null, { status }); }); await sendPush(id); expect(sent).toHaveLength(1); expect(sent[0]!.url).toBe('https://93.184.216.34/up/abc'); expect(sent[0]!.headers['Content-Encoding']).toBe('aes128gcm'); expect(sent[0]!.headers.Authorization).toMatch(/^vapid t=/); expect(sent[0]!.body.toString()).not.toContain('bob'); // encrypted await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false } } }); await sendPush(id); expect(sent).toHaveLength(1); await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: true } } }); status = 410; await sendPush(id); expect(sent).toHaveLength(2); await sendPush(id); expect(sent).toHaveLength(2); // subscription removed }); it('serves the public key', async () => { const res = await app().inject({ url: '/push/key' }); expect(res.json().publicKey).toMatch(/^[A-Za-z0-9_-]{80,}$/); }); });