273 lines
9.4 KiB
TypeScript
273 lines
9.4 KiB
TypeScript
jest.mock('../src/lib/supabase', () => ({
|
|
supabase: { rpc: jest.fn() },
|
|
}))
|
|
|
|
jest.mock('../src/features/notifications/notification-native', () => ({
|
|
deleteNativePushRegistration: jest.fn(),
|
|
getNativeNotificationStatus: jest.fn(),
|
|
getNativePushRegistrationId: jest.fn(),
|
|
}))
|
|
|
|
const mockedSupabase = jest.requireMock('../src/lib/supabase').supabase as {
|
|
rpc: jest.Mock
|
|
}
|
|
const mockedNative = jest.requireMock('../src/features/notifications/notification-native') as {
|
|
deleteNativePushRegistration: jest.Mock
|
|
getNativeNotificationStatus: jest.Mock
|
|
getNativePushRegistrationId: jest.Mock
|
|
}
|
|
|
|
import type { RegisteredDevice } from '../src/features/devices/device-service'
|
|
import {
|
|
notificationNavigationTarget,
|
|
parseNotificationPayload,
|
|
NotificationContractError,
|
|
} from '../src/features/notifications/notification-contract'
|
|
import {
|
|
detachPushRegistrationForLogout,
|
|
registerPushRegistration,
|
|
synchronizePushRegistration,
|
|
} from '../src/features/notifications/notification-service'
|
|
|
|
const USER_ID = '11111111-1111-4111-8111-111111111111'
|
|
const DEVICE_ID = '22222222-2222-4222-8222-222222222222'
|
|
const HISTORY_ID = '33333333-3333-4333-8333-333333333333'
|
|
const TEAM_ID = '44444444-4444-4444-8444-444444444444'
|
|
const INVITE_ID = '55555555-5555-4555-8555-555555555555'
|
|
const SUBSCRIPTION_ID = '66666666-6666-4666-8666-666666666666'
|
|
const INVITE_TOKEN = 'Abcdefghijklmnopqrstuvwxyz_12345'
|
|
const REGISTRATION_ID = `fcm_${'a'.repeat(128)}`
|
|
|
|
const device: RegisteredDevice = {
|
|
id: DEVICE_ID,
|
|
userId: USER_ID,
|
|
installationId: '77777777-7777-4777-8777-777777777777',
|
|
platform: 'android',
|
|
deviceName: 'Pixel 7',
|
|
appVersion: '1.0.0',
|
|
osVersion: 'Android 14',
|
|
pushToken: null,
|
|
lastSeenAt: '2026-08-21T00:00:00.000Z',
|
|
revokedAt: null,
|
|
createdAt: '2026-08-21T00:00:00.000Z',
|
|
updatedAt: '2026-08-21T00:00:00.000Z',
|
|
}
|
|
|
|
describe('notification payload allowlist', () => {
|
|
test('maps transcription completion only to its matching history row', () => {
|
|
const parsed = parseNotificationPayload({
|
|
schema_version: '1',
|
|
event_type: 'transcription.completed',
|
|
resource_id: HISTORY_ID,
|
|
route: 'HistoryDetail',
|
|
history_id: HISTORY_ID,
|
|
})
|
|
expect(notificationNavigationTarget(parsed)).toEqual({
|
|
name: 'HistoryDetail',
|
|
params: { historyId: HISTORY_ID },
|
|
})
|
|
})
|
|
|
|
test('maps invitation and billing events to fixed internal routes', () => {
|
|
const invitation = parseNotificationPayload({
|
|
schema_version: '1',
|
|
event_type: 'team.invite.created',
|
|
resource_id: INVITE_ID,
|
|
route: 'InviteAccept',
|
|
team_id: TEAM_ID,
|
|
invite_token: INVITE_TOKEN,
|
|
})
|
|
expect(notificationNavigationTarget(invitation)).toEqual({
|
|
name: 'InviteAccept',
|
|
params: { token: INVITE_TOKEN },
|
|
})
|
|
|
|
const billing = parseNotificationPayload({
|
|
schema_version: '1',
|
|
event_type: 'billing.status.changed',
|
|
resource_id: SUBSCRIPTION_ID,
|
|
route: 'ProPaywall',
|
|
subscription_id: SUBSCRIPTION_ID,
|
|
})
|
|
expect(notificationNavigationTarget(billing)).toEqual({ name: 'ProPaywall' })
|
|
})
|
|
|
|
test('rejects caller-authored text, unknown routes, and cross-resource ids', () => {
|
|
expect(() => parseNotificationPayload({
|
|
schema_version: '1',
|
|
event_type: 'transcription.completed',
|
|
resource_id: HISTORY_ID,
|
|
route: 'HistoryDetail',
|
|
history_id: HISTORY_ID,
|
|
body: 'arbitrary phishing text',
|
|
})).toThrow(NotificationContractError)
|
|
|
|
expect(() => parseNotificationPayload({
|
|
schema_version: '1',
|
|
event_type: 'transcription.completed',
|
|
resource_id: HISTORY_ID,
|
|
route: 'ExternalWebView',
|
|
history_id: HISTORY_ID,
|
|
})).toThrow('invalid-payload')
|
|
|
|
expect(() => parseNotificationPayload({
|
|
schema_version: '1',
|
|
event_type: 'transcription.completed',
|
|
resource_id: HISTORY_ID,
|
|
route: 'HistoryDetail',
|
|
history_id: TEAM_ID,
|
|
})).toThrow('invalid-payload')
|
|
})
|
|
|
|
test('rejects unsupported event types instead of inventing a route', () => {
|
|
expect(() => parseNotificationPayload({
|
|
schema_version: '1',
|
|
event_type: 'meeting.comment.created',
|
|
resource_id: HISTORY_ID,
|
|
route: 'MeetingDetail',
|
|
})).toThrow('unsupported-event')
|
|
})
|
|
})
|
|
|
|
describe('device-bound push registration', () => {
|
|
beforeEach(() => {
|
|
mockedSupabase.rpc.mockReset()
|
|
mockedNative.deleteNativePushRegistration.mockReset()
|
|
mockedNative.getNativeNotificationStatus.mockReset()
|
|
mockedNative.getNativePushRegistrationId.mockReset()
|
|
})
|
|
|
|
test('does not fabricate or upload a token when Firebase config is absent', async () => {
|
|
mockedNative.getNativeNotificationStatus.mockResolvedValue({
|
|
configured: false,
|
|
provider: 'fcm',
|
|
reason: 'firebase-config-missing',
|
|
permission: 'granted',
|
|
registrationId: null,
|
|
fullSyncRequired: false,
|
|
})
|
|
|
|
await expect(synchronizePushRegistration(USER_ID, device)).resolves.toMatchObject({
|
|
status: 'configuration-required',
|
|
registeredAt: null,
|
|
})
|
|
expect(mockedNative.getNativePushRegistrationId).not.toHaveBeenCalled()
|
|
expect(mockedSupabase.rpc).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('waits for Android notification permission before requesting a registration', async () => {
|
|
mockedNative.getNativeNotificationStatus.mockResolvedValue({
|
|
configured: true,
|
|
provider: 'fcm',
|
|
reason: 'configured',
|
|
permission: 'denied',
|
|
registrationId: null,
|
|
fullSyncRequired: false,
|
|
})
|
|
|
|
await expect(synchronizePushRegistration(USER_ID, device)).resolves.toMatchObject({
|
|
status: 'permission-required',
|
|
})
|
|
expect(mockedNative.getNativePushRegistrationId).not.toHaveBeenCalled()
|
|
expect(mockedSupabase.rpc).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('registers the real FCM identifier only through the device-owned RPC', async () => {
|
|
mockedNative.getNativeNotificationStatus.mockResolvedValue({
|
|
configured: true,
|
|
provider: 'fcm',
|
|
reason: 'configured',
|
|
permission: 'granted',
|
|
registrationId: null,
|
|
fullSyncRequired: false,
|
|
})
|
|
mockedNative.getNativePushRegistrationId.mockResolvedValue(REGISTRATION_ID)
|
|
mockedSupabase.rpc.mockResolvedValue({
|
|
data: {
|
|
device_id: DEVICE_ID,
|
|
provider: 'fcm',
|
|
registered_at: '2026-08-21T00:00:00.000Z',
|
|
},
|
|
error: null,
|
|
})
|
|
|
|
await expect(synchronizePushRegistration(USER_ID, device)).resolves.toMatchObject({
|
|
status: 'registered',
|
|
registeredAt: '2026-08-21T00:00:00.000Z',
|
|
})
|
|
expect(mockedSupabase.rpc).toHaveBeenCalledWith('register_push_registration', {
|
|
push_device_id: DEVICE_ID,
|
|
push_provider: 'fcm',
|
|
registration_id: REGISTRATION_ID,
|
|
})
|
|
})
|
|
|
|
test('rejects revoked and cross-user devices before writing', async () => {
|
|
await expect(registerPushRegistration(
|
|
USER_ID,
|
|
{ ...device, revokedAt: '2026-08-21T00:00:00.000Z' },
|
|
REGISTRATION_ID,
|
|
)).rejects.toMatchObject({ code: 'invalid-device' })
|
|
await expect(registerPushRegistration(
|
|
'88888888-8888-4888-8888-888888888888',
|
|
device,
|
|
REGISTRATION_ID,
|
|
)).rejects.toMatchObject({ code: 'invalid-device' })
|
|
expect(mockedSupabase.rpc).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('detaches the server registration and deletes the native token before logout', async () => {
|
|
mockedSupabase.rpc.mockResolvedValue({
|
|
data: { device_id: DEVICE_ID, removed: true },
|
|
error: null,
|
|
})
|
|
mockedNative.deleteNativePushRegistration.mockResolvedValue(undefined)
|
|
|
|
await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).resolves.toBeUndefined()
|
|
expect(mockedSupabase.rpc).toHaveBeenCalledWith('unregister_push_registration', {
|
|
push_device_id: DEVICE_ID,
|
|
})
|
|
expect(mockedNative.deleteNativePushRegistration).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('allows logout after native invalidation when server cleanup fails', async () => {
|
|
mockedSupabase.rpc.mockResolvedValue({
|
|
data: null,
|
|
error: { code: '503', message: 'network unavailable' },
|
|
})
|
|
mockedNative.deleteNativePushRegistration.mockResolvedValue(undefined)
|
|
|
|
await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).resolves.toBeUndefined()
|
|
expect(mockedNative.deleteNativePushRegistration).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('allows logout after server cleanup when native invalidation fails', async () => {
|
|
mockedSupabase.rpc.mockResolvedValue({
|
|
data: { device_id: DEVICE_ID, removed: true },
|
|
error: null,
|
|
})
|
|
mockedNative.deleteNativePushRegistration.mockRejectedValue(new Error('firebase unavailable'))
|
|
|
|
await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).resolves.toBeUndefined()
|
|
})
|
|
|
|
test('fails closed when both push cleanup boundaries fail', async () => {
|
|
mockedSupabase.rpc.mockResolvedValue({
|
|
data: null,
|
|
error: { code: '503', message: 'network unavailable' },
|
|
})
|
|
mockedNative.deleteNativePushRegistration.mockRejectedValue(new Error('firebase unavailable'))
|
|
|
|
await expect(detachPushRegistrationForLogout(USER_ID, DEVICE_ID)).rejects.toMatchObject({
|
|
code: 'native',
|
|
})
|
|
})
|
|
|
|
test('invalidates the native token while device registration is unavailable', async () => {
|
|
mockedNative.deleteNativePushRegistration.mockResolvedValue(undefined)
|
|
|
|
await expect(detachPushRegistrationForLogout(USER_ID, null)).resolves.toBeUndefined()
|
|
expect(mockedSupabase.rpc).not.toHaveBeenCalled()
|
|
expect(mockedNative.deleteNativePushRegistration).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|