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

177 lines
7.2 KiB
TypeScript

import {
acknowledgeGooglePlaySubscription,
googlePlayAccountId,
GooglePlayVerificationError,
normalizeGooglePlaySubscription,
verifyGooglePlaySubscriptionPayload,
} from './google-play.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
const futureExpiry = '2030-01-01T00:00:00Z'
const pastExpiry = '2020-01-01T00:00:00Z'
function standardBase64(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
Deno.test('active Google Play subscription grants the mapped tier', () => {
const normalized = normalizeGooglePlaySubscription({
startTime: '2029-12-01T00:00:00Z',
subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE',
acknowledgementState: 'ACKNOWLEDGEMENT_STATE_PENDING',
linkedPurchaseToken: 'old-linked-purchase-token',
lineItems: [{
productId: 'd3ro_voice_pro_monthly',
expiryTime: futureExpiry,
latestSuccessfulOrderId: 'GPA.1234-5678-9012-34567',
autoRenewingPlan: { autoRenewEnabled: true },
}],
}, 'd3ro_voice_pro_monthly', Date.parse('2029-12-15T00:00:00Z'))
assert(normalized.tier === 'pro', 'product must map to pro')
assert(normalized.entitled, 'active future subscription must be entitled')
assert(normalized.purchaseState === 'purchased', 'active subscription must be purchased')
assert(normalized.autoRenewing, 'auto renewal must be preserved')
assert(!normalized.acknowledged, 'pending acknowledgement must be preserved')
assert(normalized.linkedPurchaseToken === 'old-linked-purchase-token', 'linked token must be preserved')
assert(normalized.storeTransactionId === 'GPA.1234-5678-9012-34567', 'line-item order id must be preserved')
})
Deno.test('out-of-app re-subscription requires a verified prior account binding', async () => {
const userId = '11111111-2222-3333-4444-555555555555'
const accountId = await googlePlayAccountId(userId)
const base = {
subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE',
lineItems: [{ productId: 'd3ro_voice_pro_monthly', expiryTime: futureExpiry }],
}
const byExpiredAccount = await verifyGooglePlaySubscriptionPayload(
userId,
'd3ro_voice_pro_monthly',
{
...base,
outOfAppPurchaseContext: {
expiredExternalAccountIdentifiers: { obfuscatedExternalAccountId: accountId },
},
},
)
assert(byExpiredAccount.entitled, 'expired account id should bind the re-subscription')
const byExpiredToken = await verifyGooglePlaySubscriptionPayload(
userId,
'd3ro_voice_pro_monthly',
{
...base,
outOfAppPurchaseContext: { expiredPurchaseToken: 'known-expired-token' },
},
(token) => Promise.resolve(token === 'known-expired-token'),
)
assert(byExpiredToken.entitled, 'server-owned expired token should bind the re-subscription')
let rejected = false
try {
await verifyGooglePlaySubscriptionPayload(
userId,
'd3ro_voice_pro_monthly',
{ ...base, outOfAppPurchaseContext: { expiredPurchaseToken: 'unknown-token' } },
() => Promise.resolve(false),
)
} catch (error) {
rejected = error instanceof GooglePlayVerificationError
&& error.code === 'purchase_account_mismatch'
}
assert(rejected, 'unowned out-of-app purchase must fail closed')
})
Deno.test('canceled subscription keeps access only until its verified expiry', () => {
const beforeExpiry = normalizeGooglePlaySubscription({
subscriptionState: 'SUBSCRIPTION_STATE_CANCELED',
lineItems: [{ productId: 'd3ro_voice_pro_plus_monthly', expiryTime: futureExpiry }],
}, 'd3ro_voice_pro_plus_monthly', Date.parse('2029-12-15T00:00:00Z'))
assert(beforeExpiry.purchaseState === 'cancelled', 'state must be cancelled')
assert(beforeExpiry.entitled, 'canceled subscription retains prepaid access')
const afterExpiry = normalizeGooglePlaySubscription({
subscriptionState: 'SUBSCRIPTION_STATE_CANCELED',
lineItems: [{ productId: 'd3ro_voice_pro_plus_monthly', expiryTime: pastExpiry }],
}, 'd3ro_voice_pro_plus_monthly', Date.parse('2029-12-15T00:00:00Z'))
assert(!afterExpiry.entitled, 'expired canceled subscription must not grant access')
})
Deno.test('unknown and mismatched products fail closed', () => {
for (const productId of ['unknown_product', 'd3ro_voice_pro_monthly']) {
let error: unknown
try {
normalizeGooglePlaySubscription({
subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE',
lineItems: [{ productId: 'different_product', expiryTime: futureExpiry }],
}, productId)
} catch (candidate) {
error = candidate
}
assert(error instanceof GooglePlayVerificationError, 'invalid product must fail explicitly')
}
})
Deno.test('obfuscated account id is deterministic and contains no raw UUID', async () => {
const userId = '11111111-2222-3333-4444-555555555555'
const accountId = await googlePlayAccountId(userId)
assert(accountId.length === 64, 'account hash must be SHA-256 hex')
assert(!accountId.includes(userId), 'raw user id must not be exposed to the store')
assert(accountId === await googlePlayAccountId(userId), 'account hash must be stable')
})
Deno.test('Google Play subscription is acknowledged by the server API', async () => {
const keyPair = await crypto.subtle.generateKey({
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 2048,
publicExponent: Uint8Array.from([1, 0, 1]),
hash: 'SHA-256',
}, true, ['sign', 'verify'])
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey))
const privateKeyBase64 = standardBase64(privateKey).match(/.{1,64}/g)?.join('\n') ?? ''
const serviceAccount = {
client_email: 'play-verifier@example.iam.gserviceaccount.com',
private_key: `-----BEGIN PRIVATE KEY-----\n${privateKeyBase64}\n-----END PRIVATE KEY-----\n`,
token_uri: 'https://oauth2.googleapis.com/token',
}
Deno.env.set('GOOGLE_PLAY_SERVICE_ACCOUNT_JSON', JSON.stringify(serviceAccount))
Deno.env.set('GOOGLE_PLAY_PACKAGE_NAME', 'com.d3ro.voice')
const requestedUrls: string[] = []
const fakeFetch: typeof fetch = (input, init) => {
const url = String(input)
requestedUrls.push(url)
if (url === 'https://oauth2.googleapis.com/token') {
return Promise.resolve(new Response(JSON.stringify({ access_token: 'fixture-access-token', expires_in: 3600 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
}
const requestInit = init as { method?: string; headers?: unknown } | undefined
assert(requestInit?.method === 'POST', 'acknowledgement must use POST')
assert(requestInit.headers !== undefined, 'acknowledgement must be authenticated')
return Promise.resolve(new Response(null, { status: 204 }))
}
try {
await acknowledgeGooglePlaySubscription(
'd3ro_voice_pro_monthly',
'fixture-purchase-token',
fakeFetch,
)
} finally {
Deno.env.delete('GOOGLE_PLAY_SERVICE_ACCOUNT_JSON')
Deno.env.delete('GOOGLE_PLAY_PACKAGE_NAME')
}
assert(requestedUrls.length === 2, 'OAuth and acknowledge endpoints must both be called')
assert(
requestedUrls[1].endsWith('/purchases/subscriptions/d3ro_voice_pro_monthly/tokens/fixture-purchase-token:acknowledge'),
'acknowledge endpoint must bind package, product and purchase token',
)
})