252 lines
8.4 KiB
TypeScript
252 lines
8.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { IPCResult } from '@d3ro/core/errors'
|
|
import type { CheckoutSessionResult, SubscriptionStatusResult } from '@d3ro/core/types'
|
|
import {
|
|
CheckoutFlowController,
|
|
type CheckoutFlowPorts,
|
|
isTrustedStripeCheckoutUrl
|
|
} from '../../src/renderer/components/payment/checkout-flow'
|
|
|
|
type CreateResult = IPCResult<CheckoutSessionResult>
|
|
type StatusResult = IPCResult<SubscriptionStatusResult>
|
|
|
|
const trustedUrl = 'https://checkout.stripe.com/c/pay/cs_test_renderer_safe#fragment'
|
|
const checkoutSuccess: CreateResult = {
|
|
success: true,
|
|
data: { checkoutUrl: trustedUrl, provider: 'stripe', status: 'pending' }
|
|
}
|
|
const openSuccess: IPCResult<void> = { success: true, data: undefined }
|
|
const activeSubscription: StatusResult = {
|
|
success: true,
|
|
data: { tier: 'pro_plus', valid: true, expiresAt: Date.parse('2099-01-01T00:00:00.000Z') }
|
|
}
|
|
const ipcFailure = {
|
|
success: false as const,
|
|
error: { code: 999, message: 'private provider details' }
|
|
}
|
|
|
|
function deferred<T>(): {
|
|
promise: Promise<T>
|
|
resolve: (value: T) => void
|
|
} {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>((done) => {
|
|
resolve = done
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
describe('renderer checkout flow', () => {
|
|
let createCheckoutSession: ReturnType<typeof vi.fn>
|
|
let openExternal: ReturnType<typeof vi.fn>
|
|
let getSubscriptionStatus: ReturnType<typeof vi.fn>
|
|
let controller: CheckoutFlowController
|
|
|
|
beforeEach(() => {
|
|
createCheckoutSession = vi.fn().mockResolvedValue(checkoutSuccess)
|
|
openExternal = vi.fn().mockResolvedValue(openSuccess)
|
|
getSubscriptionStatus = vi.fn().mockResolvedValue(activeSubscription)
|
|
const ports: CheckoutFlowPorts = {
|
|
createCheckoutSession,
|
|
openExternal,
|
|
getSubscriptionStatus
|
|
}
|
|
controller = new CheckoutFlowController(ports)
|
|
})
|
|
|
|
it('opens only the authenticated IPC checkout result and waits for server confirmation', async () => {
|
|
const opened = await controller.start('pro')
|
|
|
|
expect(opened).toBe(true)
|
|
expect(createCheckoutSession).toHaveBeenCalledWith({ tier: 'pro', provider: 'stripe' })
|
|
expect(openExternal).toHaveBeenCalledWith({ url: trustedUrl })
|
|
expect(getSubscriptionStatus).not.toHaveBeenCalled()
|
|
expect(controller.getState()).toEqual({
|
|
phase: 'awaiting',
|
|
error: null,
|
|
verifiedTier: null
|
|
})
|
|
})
|
|
|
|
it('fails closed when checkout IPC returns a provider error', async () => {
|
|
createCheckoutSession.mockResolvedValue(ipcFailure)
|
|
|
|
await controller.start('pro')
|
|
|
|
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'checkout-failed' })
|
|
expect(openExternal).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fails closed when checkout IPC throws a network error', async () => {
|
|
createCheckoutSession.mockRejectedValue(new Error('private network details'))
|
|
|
|
await controller.start('pro')
|
|
|
|
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'network-failed' })
|
|
expect(openExternal).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
checkoutUrl: 'http://checkout.stripe.com/c/pay/cs_test_bad',
|
|
provider: 'stripe',
|
|
status: 'pending'
|
|
},
|
|
{ checkoutUrl: 'https://evil.test/c/pay/cs_test_bad', provider: 'stripe', status: 'pending' },
|
|
{ checkoutUrl: trustedUrl, provider: 'toss', status: 'pending' },
|
|
{ checkoutUrl: trustedUrl, provider: 'stripe', status: 'completed' }
|
|
])('rejects an unsafe or forged checkout response %#', async (data) => {
|
|
createCheckoutSession.mockResolvedValue({ success: true, data })
|
|
|
|
await controller.start('pro')
|
|
|
|
expect(controller.getState()).toMatchObject({
|
|
phase: 'idle',
|
|
error: 'unsafe-checkout-response'
|
|
})
|
|
expect(openExternal).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fails closed when the OS cannot open the checkout page', async () => {
|
|
openExternal.mockResolvedValue(ipcFailure)
|
|
|
|
await controller.start('pro')
|
|
|
|
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'open-failed' })
|
|
})
|
|
|
|
it('fails closed when opening the checkout page throws', async () => {
|
|
openExternal.mockRejectedValue(new Error('private shell details'))
|
|
|
|
await controller.start('pro')
|
|
|
|
expect(controller.getState()).toMatchObject({ phase: 'idle', error: 'open-failed' })
|
|
})
|
|
|
|
it('coalesces duplicate checkout clicks into one IPC request', async () => {
|
|
const pending = deferred<CreateResult>()
|
|
createCheckoutSession.mockReturnValue(pending.promise)
|
|
|
|
const first = controller.start('pro')
|
|
const duplicate = controller.start('pro_plus')
|
|
|
|
expect(await duplicate).toBe(false)
|
|
expect(createCheckoutSession).toHaveBeenCalledTimes(1)
|
|
pending.resolve(checkoutSuccess)
|
|
expect(await first).toBe(true)
|
|
expect(openExternal).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('invalidates a pending checkout when the user closes the modal', async () => {
|
|
const pending = deferred<CreateResult>()
|
|
createCheckoutSession.mockReturnValue(pending.promise)
|
|
|
|
const start = controller.start('pro')
|
|
controller.cancel()
|
|
pending.resolve(checkoutSuccess)
|
|
|
|
expect(await start).toBe(false)
|
|
expect(openExternal).not.toHaveBeenCalled()
|
|
expect(controller.getState()).toEqual({ phase: 'idle', error: null, verifiedTier: null })
|
|
})
|
|
|
|
it('does not treat an opened or cancelled provider page as payment success', async () => {
|
|
await controller.start('pro')
|
|
getSubscriptionStatus.mockResolvedValue({
|
|
success: true,
|
|
data: { tier: 'free', valid: false, expiresAt: null }
|
|
})
|
|
|
|
const tier = await controller.verify()
|
|
|
|
expect(tier).toBeNull()
|
|
expect(controller.getState()).toMatchObject({ phase: 'awaiting', error: 'not-confirmed' })
|
|
})
|
|
|
|
it('fails closed when subscription readback returns an IPC error', async () => {
|
|
await controller.start('pro')
|
|
getSubscriptionStatus.mockResolvedValue(ipcFailure)
|
|
|
|
const tier = await controller.verify()
|
|
|
|
expect(tier).toBeNull()
|
|
expect(controller.getState()).toMatchObject({
|
|
phase: 'awaiting',
|
|
error: 'verification-failed'
|
|
})
|
|
})
|
|
|
|
it('fails closed when subscription readback throws a network error', async () => {
|
|
await controller.start('pro')
|
|
getSubscriptionStatus.mockRejectedValue(new Error('private subscription details'))
|
|
|
|
const tier = await controller.verify()
|
|
|
|
expect(tier).toBeNull()
|
|
expect(controller.getState()).toMatchObject({ phase: 'awaiting', error: 'network-failed' })
|
|
})
|
|
|
|
it('rejects an arbitrary paid tier even when a forged response claims it is valid', async () => {
|
|
await controller.start('pro')
|
|
getSubscriptionStatus.mockResolvedValue({
|
|
success: true,
|
|
data: { tier: 'enterprise', valid: true, expiresAt: null }
|
|
})
|
|
|
|
const tier = await controller.verify()
|
|
|
|
expect(tier).toBeNull()
|
|
expect(controller.getState()).toMatchObject({
|
|
phase: 'awaiting',
|
|
error: 'invalid-entitlement'
|
|
})
|
|
})
|
|
|
|
it('returns and displays only the server-read subscription tier', async () => {
|
|
await controller.start('pro')
|
|
|
|
const tier = await controller.verify()
|
|
|
|
expect(tier).toBe('pro_plus')
|
|
expect(controller.getState()).toEqual({
|
|
phase: 'complete',
|
|
error: null,
|
|
verifiedTier: 'pro_plus'
|
|
})
|
|
})
|
|
|
|
it('coalesces duplicate subscription verification clicks', async () => {
|
|
await controller.start('pro')
|
|
const pending = deferred<StatusResult>()
|
|
getSubscriptionStatus.mockReturnValue(pending.promise)
|
|
|
|
const first = controller.verify()
|
|
const duplicate = controller.verify()
|
|
|
|
expect(await duplicate).toBeNull()
|
|
expect(getSubscriptionStatus).toHaveBeenCalledTimes(1)
|
|
pending.resolve(activeSubscription)
|
|
expect(await first).toBe('pro_plus')
|
|
})
|
|
|
|
it('ignores a successful subscription response after cancellation', async () => {
|
|
await controller.start('pro')
|
|
const pending = deferred<StatusResult>()
|
|
getSubscriptionStatus.mockReturnValue(pending.promise)
|
|
|
|
const verification = controller.verify()
|
|
controller.cancel()
|
|
pending.resolve(activeSubscription)
|
|
|
|
expect(await verification).toBeNull()
|
|
expect(controller.getState()).toEqual({ phase: 'idle', error: null, verifiedTier: null })
|
|
})
|
|
|
|
it('accepts only the trusted Stripe HTTPS checkout origin and path', () => {
|
|
expect(isTrustedStripeCheckoutUrl(trustedUrl)).toBe(true)
|
|
expect(isTrustedStripeCheckoutUrl('https://checkout.stripe.com/portal/cs_test_bad')).toBe(false)
|
|
expect(
|
|
isTrustedStripeCheckoutUrl('https://user:pass@checkout.stripe.com/c/pay/cs_test_bad')
|
|
).toBe(false)
|
|
})
|
|
})
|