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
252
apps/desktop/tests/unit/checkout-flow.spec.ts
Normal file
252
apps/desktop/tests/unit/checkout-flow.spec.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue