230 lines
9.2 KiB
TypeScript
230 lines
9.2 KiB
TypeScript
import {
|
|
buildPushNotification,
|
|
isServiceRoleAuthorization,
|
|
isServiceRoleApiKey,
|
|
parsePushRequest,
|
|
PushContractError,
|
|
readFcmConfig,
|
|
sendFcmMessage,
|
|
} from './push-contract.ts'
|
|
|
|
function assert(condition: boolean, message: string): asserts condition {
|
|
if (!condition) throw new Error(message)
|
|
}
|
|
|
|
function assertPushError(action: () => unknown, code: string, status: number): void {
|
|
let actual: unknown
|
|
try {
|
|
action()
|
|
} catch (error) {
|
|
actual = error
|
|
}
|
|
assert(actual instanceof PushContractError, `expected PushContractError for ${code}`)
|
|
assert(actual.code === code, `expected ${code}, received ${actual.code}`)
|
|
assert(actual.status === status, `expected status ${status}, received ${actual.status}`)
|
|
}
|
|
|
|
const resourceId = '11111111-2222-4333-8444-555555555555'
|
|
const fcmConfig = {
|
|
clientEmail: 'firebase@example.iam.gserviceaccount.com',
|
|
privateKey: 'unused-in-injected-test',
|
|
projectId: 'd3ro-test-project',
|
|
tokenUri: 'https://oauth2.googleapis.com/token',
|
|
}
|
|
|
|
Deno.test('push input accepts only event_type and resource_id', () => {
|
|
const parsed = parsePushRequest({
|
|
event_type: 'transcription.completed',
|
|
resource_id: resourceId,
|
|
})
|
|
assert(parsed.eventType === 'transcription.completed', 'event must be preserved')
|
|
assert(parsed.resourceId === resourceId, 'resource must be preserved')
|
|
|
|
assertPushError(
|
|
() => parsePushRequest({
|
|
event_type: 'transcription.completed',
|
|
resource_id: resourceId,
|
|
title: 'attacker supplied',
|
|
}),
|
|
'invalid_push_request',
|
|
400,
|
|
)
|
|
assertPushError(
|
|
() => parsePushRequest({
|
|
event_type: 'meeting.comment.created',
|
|
resource_id: resourceId,
|
|
}),
|
|
'unsupported_push_event',
|
|
400,
|
|
)
|
|
})
|
|
|
|
Deno.test('notification templates expose only the mobile route allowlist', () => {
|
|
const history = buildPushNotification('transcription.completed', resourceId, 'ko')
|
|
assert(history.data.route === 'HistoryDetail', 'history route required')
|
|
assert(history.data.history_id === resourceId, 'history id must be server-derived')
|
|
assert(Object.keys(history.data).sort().join(',') === 'event_type,history_id,resource_id,route,schema_version', 'history data must be exact')
|
|
|
|
const subscription = buildPushNotification('billing.status.changed', resourceId, 'en')
|
|
assert(subscription.data.route === 'ProPaywall', 'billing route required')
|
|
assert(subscription.data.subscription_id === resourceId, 'subscription id must be server-derived')
|
|
|
|
const invite = buildPushNotification('team.invite.created', resourceId, 'en', {
|
|
inviteToken: 'abcdefghijklmnopqrstuvwx_123456',
|
|
teamId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
|
})
|
|
assert(invite.data.route === 'InviteAccept', 'invite route required')
|
|
assert(invite.data.team_id === 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', 'team id required')
|
|
assert(invite.data.invite_token === 'abcdefghijklmnopqrstuvwx_123456', 'server token required')
|
|
assert(!('title' in invite.data) && !('body' in invite.data), 'arbitrary copy cannot enter data')
|
|
})
|
|
|
|
Deno.test('FCM configuration fails closed when credentials are absent or inconsistent', () => {
|
|
assertPushError(() => readFcmConfig(() => undefined), 'fcm_not_configured', 503)
|
|
const credentials = JSON.stringify({
|
|
client_email: 'firebase@example.iam.gserviceaccount.com',
|
|
private_key: 'key',
|
|
project_id: 'first-project',
|
|
})
|
|
assertPushError(
|
|
() => readFcmConfig((name) => name === 'FCM_SERVICE_ACCOUNT_JSON' ? credentials : 'second-project'),
|
|
'fcm_credentials_invalid',
|
|
503,
|
|
)
|
|
const redirectedTokenEndpoint = JSON.stringify({
|
|
client_email: 'firebase@example.iam.gserviceaccount.com',
|
|
private_key: 'key',
|
|
project_id: 'first-project',
|
|
token_uri: 'https://attacker.example/token',
|
|
})
|
|
assertPushError(
|
|
() => readFcmConfig((name) => name === 'FCM_SERVICE_ACCOUNT_JSON' ? redirectedTokenEndpoint : undefined),
|
|
'fcm_credentials_invalid',
|
|
503,
|
|
)
|
|
})
|
|
|
|
Deno.test('FCM HTTP v1 request uses OAuth bearer auth and a fixed payload', async () => {
|
|
let requestedUrl = ''
|
|
let requestedInit: RequestInit | undefined
|
|
const fetchImpl: typeof fetch = (input, init) => {
|
|
requestedUrl = String(input)
|
|
requestedInit = init
|
|
return Promise.resolve(new Response(JSON.stringify({
|
|
name: 'projects/d3ro-test-project/messages/123',
|
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
|
}
|
|
const notification = buildPushNotification('transcription.completed', resourceId, 'en')
|
|
const result = await sendFcmMessage('registered-device-token-123456789', notification, {
|
|
fetchImpl,
|
|
config: fcmConfig,
|
|
getAccessToken: () => Promise.resolve('short-lived-oauth-token'),
|
|
})
|
|
|
|
assert(requestedUrl === 'https://fcm.googleapis.com/v1/projects/d3ro-test-project/messages:send', 'HTTP v1 endpoint required')
|
|
const headers = new Headers(requestedInit?.headers)
|
|
assert(headers.get('Authorization') === 'Bearer short-lived-oauth-token', 'OAuth bearer required')
|
|
const payload = JSON.parse(String(requestedInit?.body)) as Record<string, unknown>
|
|
const message = payload.message as Record<string, unknown>
|
|
assert(message.token === 'registered-device-token-123456789', 'registration token must target one device')
|
|
assert(JSON.stringify(message.data) === JSON.stringify(notification.data), 'only fixed server data must be sent')
|
|
assert(!('notification' in message), 'FCM payload must be data-only so native validation runs first')
|
|
const android = message.android as Record<string, unknown>
|
|
assert(android.priority === 'high', 'user-visible event must be high priority')
|
|
assert(android.ttl === '3600s', 'transcription TTL must be bounded')
|
|
assert(android.collapse_key === `transcription:${resourceId}`, 'collapse key must derive from event and resource')
|
|
assert(android.restricted_package_name === 'com.d3ro.voice', 'package target must be fixed')
|
|
assert(result.messageId.endsWith('/123'), 'provider message id must normalize')
|
|
})
|
|
|
|
Deno.test('FCM UNREGISTERED response is classified for stale-token deletion', async () => {
|
|
const fetchImpl: typeof fetch = () => Promise.resolve(new Response(JSON.stringify({
|
|
error: {
|
|
code: 404,
|
|
status: 'NOT_FOUND',
|
|
details: [{
|
|
'@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError',
|
|
errorCode: 'UNREGISTERED',
|
|
}],
|
|
},
|
|
}), { status: 404, headers: { 'Content-Type': 'application/json' } }))
|
|
let actual: unknown
|
|
try {
|
|
await sendFcmMessage(
|
|
'registered-device-token-123456789',
|
|
buildPushNotification('transcription.completed', resourceId, 'en'),
|
|
{
|
|
fetchImpl,
|
|
config: fcmConfig,
|
|
getAccessToken: () => Promise.resolve('short-lived-oauth-token'),
|
|
},
|
|
)
|
|
} catch (error) {
|
|
actual = error
|
|
}
|
|
assert(actual instanceof PushContractError, 'provider error must normalize')
|
|
assert(actual.code === 'fcm_registration_stale', 'unregistered token must be stale')
|
|
assert(actual.staleRegistration, 'stale flag must permit server-owned deletion')
|
|
})
|
|
|
|
Deno.test('FCM sender rejects any data key outside the mobile allowlist', async () => {
|
|
const notification = buildPushNotification('transcription.completed', resourceId, 'en')
|
|
notification.data.title = 'attacker-controlled copy'
|
|
let actual: unknown
|
|
try {
|
|
await sendFcmMessage('registered-device-token-123456789', notification, {
|
|
fetchImpl: () => Promise.reject(new Error('fetch must not run')),
|
|
config: fcmConfig,
|
|
getAccessToken: () => Promise.resolve('short-lived-oauth-token'),
|
|
})
|
|
} catch (error) {
|
|
actual = error
|
|
}
|
|
assert(actual instanceof PushContractError, 'outbound validation must reject')
|
|
assert(actual.code === 'invalid_push_payload', 'extra outbound keys must fail closed')
|
|
})
|
|
|
|
Deno.test('internal dispatcher auth requires an exact service-role bearer', async () => {
|
|
const legacySecret = 'service-role-secret-with-high-entropy-123456'
|
|
const secret = 'sb_secret_service-role-secret-with-high-entropy-123456'
|
|
assert(
|
|
await isServiceRoleAuthorization(`Bearer ${legacySecret}`, legacySecret),
|
|
'exact service bearer must authenticate',
|
|
)
|
|
assert(
|
|
!(await isServiceRoleAuthorization(`Bearer ${legacySecret}x`, legacySecret)),
|
|
'near match must be rejected',
|
|
)
|
|
assert(
|
|
!(await isServiceRoleAuthorization(`bearer ${legacySecret}`, legacySecret)),
|
|
'non-canonical scheme must be rejected',
|
|
)
|
|
assert(
|
|
!(await isServiceRoleAuthorization(null, legacySecret)),
|
|
'missing authorization must be rejected',
|
|
)
|
|
|
|
const readEnv = (name: string): string | undefined => {
|
|
if (name === 'SUPABASE_SECRET_KEYS') {
|
|
return JSON.stringify({
|
|
default: secret,
|
|
rotated: 'sb_secret_another-service-secret-1234567890',
|
|
publishable: 'sb_publishable_must-never-authenticate-1234567890',
|
|
})
|
|
}
|
|
return undefined
|
|
}
|
|
assert(await isServiceRoleApiKey(secret, readEnv), 'exact modern secret API key must authenticate')
|
|
assert(
|
|
!(await isServiceRoleApiKey('sb_publishable_not_a_service_secret', readEnv)),
|
|
'publishable API key must not authenticate as a service',
|
|
)
|
|
assert(
|
|
!(await isServiceRoleApiKey('sb_publishable_must-never-authenticate-1234567890', readEnv)),
|
|
'a publishable value in the runtime key map must still be rejected',
|
|
)
|
|
assert(
|
|
!(await isServiceRoleApiKey(secret, () => '{malformed')),
|
|
'malformed runtime secret map must fail closed',
|
|
)
|
|
})
|