177 lines
7.2 KiB
TypeScript
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',
|
|
)
|
|
})
|