feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
230
server/supabase/functions/_shared/push-contract.test.ts
Normal file
230
server/supabase/functions/_shared/push-contract.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
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',
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue