d3ro-voice/server/supabase/functions/_shared/push-contract.test.ts
2026-08-29 18:33:45 +09:00

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',
)
})