332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { ipcMain } from 'electron'
|
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
|
import type { IPCResult } from '@d3ro/core/errors'
|
|
import type { CheckoutSessionParams, CheckoutSessionResult } from '@d3ro/core/types'
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const invokeFunction = vi.fn()
|
|
const activate = vi.fn()
|
|
const getLicenseService = vi.fn(() => ({ activate }))
|
|
const cloud = {
|
|
isAuthenticated: vi.fn(() => true),
|
|
getUser: vi.fn(() => ({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' })),
|
|
invokeFunction
|
|
}
|
|
return { activate, cloud, getLicenseService, invokeFunction }
|
|
})
|
|
|
|
vi.mock('../../src/main/services/CloudSyncService', () => ({
|
|
getCloudSyncService: () => mocks.cloud
|
|
}))
|
|
|
|
vi.mock('../../src/main/services/LicenseService', () => ({
|
|
getLicenseService: mocks.getLicenseService
|
|
}))
|
|
|
|
import {
|
|
PAYMENT_REQUEST_TIMEOUT_MS,
|
|
registerPaymentHandlers
|
|
} from '../../src/main/ipc/payment-handlers'
|
|
|
|
type CapturedHandler = (...args: unknown[]) => unknown
|
|
|
|
const handlers = new Map<string, CapturedHandler>()
|
|
const validParams: CheckoutSessionParams = {
|
|
tier: 'pro',
|
|
provider: 'stripe'
|
|
}
|
|
|
|
async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise<IPCResult<T>> {
|
|
const handler = handlers.get(channel)
|
|
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
|
|
return (await handler({}, ...args)) as IPCResult<T>
|
|
}
|
|
|
|
function checkoutResponse(url = 'https://checkout.stripe.com/c/pay/cs_test_safe#fragment') {
|
|
return { data: { url }, error: null }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useRealTimers()
|
|
vi.clearAllMocks()
|
|
handlers.clear()
|
|
mocks.cloud.isAuthenticated.mockReturnValue(true)
|
|
mocks.cloud.getUser.mockReturnValue({ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' })
|
|
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: CapturedHandler) => {
|
|
handlers.set(channel, handler)
|
|
})
|
|
registerPaymentHandlers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('desktop payment IPC security boundary', () => {
|
|
it('uses the authenticated Stripe Edge checkout contract and returns only its trusted URL', async () => {
|
|
mocks.invokeFunction.mockResolvedValue(checkoutResponse())
|
|
|
|
const result = await invokeIpc<CheckoutSessionResult>(
|
|
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
|
validParams
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
data: {
|
|
checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_safe#fragment',
|
|
provider: 'stripe',
|
|
status: 'pending'
|
|
}
|
|
})
|
|
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
|
|
const [name, body, options] = mocks.invokeFunction.mock.calls[0]
|
|
expect(name).toBe('stripe-checkout')
|
|
expect(body).toEqual({
|
|
tier: 'pro',
|
|
success_url: 'https://d3ro.chanpaca.net/billing?desktop_checkout=success',
|
|
cancel_url: 'https://d3ro.chanpaca.net/billing?desktop_checkout=cancelled',
|
|
idempotency_key: expect.stringMatching(/^desktop:stripe-checkout:[0-9a-f-]{36}$/)
|
|
})
|
|
expect(options).toMatchObject({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS })
|
|
expect(options.signal).toBeInstanceOf(AbortSignal)
|
|
})
|
|
|
|
it.each(['toss', 'portone', 'arbitrary-provider'])(
|
|
'rejects unsupported provider %s before network I/O',
|
|
async (provider) => {
|
|
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, {
|
|
...validParams,
|
|
provider
|
|
})
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(mocks.invokeFunction).not.toHaveBeenCalled()
|
|
}
|
|
)
|
|
|
|
it.each(['free', 'team', 'enterprise', 'arbitrary-tier'])(
|
|
'rejects renderer-selected tier %s before network I/O',
|
|
async (tier) => {
|
|
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, {
|
|
...validParams,
|
|
tier
|
|
})
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(mocks.invokeFunction).not.toHaveBeenCalled()
|
|
}
|
|
)
|
|
|
|
it.each([
|
|
'http://checkout.stripe.com/c/pay/cs_test_unsafe',
|
|
'https://checkout.stripe.com.evil.test/c/pay/cs_test_unsafe',
|
|
'https://checkout.stripe.com@evil.test/c/pay/cs_test_unsafe',
|
|
'https://user:pass@checkout.stripe.com/c/pay/cs_test_unsafe',
|
|
'https://checkout.stripe.com/portal/cs_test_unsafe'
|
|
])('rejects an untrusted checkout URL: %s', async (url) => {
|
|
mocks.invokeFunction.mockResolvedValue(checkoutResponse(url))
|
|
|
|
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
|
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
it('fails closed on provider errors without exposing provider details', async () => {
|
|
mocks.invokeFunction.mockResolvedValue({
|
|
data: null,
|
|
error: { message: 'sensitive Stripe provider details' }
|
|
})
|
|
|
|
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
error: { message: 'Checkout service rejected the request' }
|
|
})
|
|
expect(JSON.stringify(result)).not.toContain('sensitive Stripe provider details')
|
|
})
|
|
|
|
it('fails closed on network errors without converting them into checkout success', async () => {
|
|
mocks.invokeFunction.mockRejectedValue(new Error('sensitive network details'))
|
|
|
|
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
error: { message: 'Checkout service is unavailable' }
|
|
})
|
|
expect(JSON.stringify(result)).not.toContain('sensitive network details')
|
|
})
|
|
|
|
it('coalesces concurrent and sequential duplicate checkout requests', async () => {
|
|
let resolveProvider: ((value: ReturnType<typeof checkoutResponse>) => void) | undefined
|
|
mocks.invokeFunction.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveProvider = resolve
|
|
})
|
|
)
|
|
|
|
const first = invokeIpc<CheckoutSessionResult>(
|
|
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
|
validParams
|
|
)
|
|
const concurrent = invokeIpc<CheckoutSessionResult>(
|
|
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
|
validParams
|
|
)
|
|
|
|
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
|
|
resolveProvider?.(checkoutResponse())
|
|
const [firstResult, concurrentResult] = await Promise.all([first, concurrent])
|
|
const sequentialResult = await invokeIpc<CheckoutSessionResult>(
|
|
IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION,
|
|
validParams
|
|
)
|
|
|
|
expect(firstResult).toEqual(concurrentResult)
|
|
expect(sequentialResult).toEqual(firstResult)
|
|
expect(mocks.invokeFunction).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('reuses the server idempotency key when a failed checkout is retried', async () => {
|
|
mocks.invokeFunction.mockResolvedValue({
|
|
data: null,
|
|
error: { message: 'provider unavailable' }
|
|
})
|
|
|
|
const first = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
|
const retry = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
|
|
|
expect(first.success).toBe(false)
|
|
expect(retry.success).toBe(false)
|
|
expect(mocks.invokeFunction).toHaveBeenCalledTimes(2)
|
|
expect(mocks.invokeFunction.mock.calls[0][1].idempotency_key).toBe(
|
|
mocks.invokeFunction.mock.calls[1][1].idempotency_key
|
|
)
|
|
})
|
|
|
|
it('aborts and fails closed when checkout exceeds the deadline', async () => {
|
|
vi.useFakeTimers()
|
|
mocks.invokeFunction.mockReturnValue(new Promise(() => undefined))
|
|
|
|
const pending = invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
|
await vi.advanceTimersByTimeAsync(PAYMENT_REQUEST_TIMEOUT_MS)
|
|
const result = await pending
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
error: { message: 'Payment server request timed out' }
|
|
})
|
|
const options = mocks.invokeFunction.mock.calls[0][2]
|
|
expect(options.signal.aborted).toBe(true)
|
|
})
|
|
|
|
it('uses server subscription readback and never mutates the local license', async () => {
|
|
mocks.invokeFunction.mockResolvedValue({
|
|
data: {
|
|
tier: 'pro_plus',
|
|
status: 'active',
|
|
current_period_end: '2099-01-01T00:00:00.000Z'
|
|
},
|
|
error: null
|
|
})
|
|
|
|
const result = await invokeIpc<{ success: boolean; activeTier: string }>(
|
|
IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT,
|
|
{ tier: 'enterprise' }
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
data: { success: true, activeTier: 'pro_plus' }
|
|
})
|
|
expect(mocks.invokeFunction).toHaveBeenCalledWith(
|
|
'payple-manage',
|
|
{ action: 'info' },
|
|
expect.objectContaining({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS })
|
|
)
|
|
expect(mocks.getLicenseService).not.toHaveBeenCalled()
|
|
expect(mocks.activate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not grant an arbitrary renderer tier when the server says free', async () => {
|
|
mocks.invokeFunction.mockResolvedValue({
|
|
data: { tier: 'free', status: 'active', current_period_end: null },
|
|
error: null
|
|
})
|
|
|
|
const result = await invokeIpc<{ success: boolean; activeTier: string }>(
|
|
IPC_CHANNELS.PAYMENT.VERIFY_PAYMENT,
|
|
{ tier: 'enterprise' }
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
data: { success: false, activeTier: 'free' }
|
|
})
|
|
expect(mocks.activate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves prepaid cancellation until expiry and fails closed after expiry', async () => {
|
|
mocks.invokeFunction
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
tier: 'pro',
|
|
status: 'canceled',
|
|
current_period_end: '2099-01-01T00:00:00.000Z'
|
|
},
|
|
error: null
|
|
})
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
tier: 'pro',
|
|
status: 'canceled',
|
|
current_period_end: '2000-01-01T00:00:00.000Z'
|
|
},
|
|
error: null
|
|
})
|
|
|
|
const current = await invokeIpc<{
|
|
tier: string
|
|
valid: boolean
|
|
expiresAt: number | null
|
|
}>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
|
|
const expired = await invokeIpc<{
|
|
tier: string
|
|
valid: boolean
|
|
expiresAt: number | null
|
|
}>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
|
|
|
|
expect(current).toMatchObject({ success: true, data: { tier: 'pro', valid: true } })
|
|
expect(expired).toMatchObject({ success: true, data: { tier: 'free', valid: false } })
|
|
})
|
|
|
|
it('rejects malformed or arbitrary server entitlement data', async () => {
|
|
mocks.invokeFunction.mockResolvedValue({
|
|
data: {
|
|
tier: 'enterprise',
|
|
status: 'active',
|
|
current_period_end: '2099-01-01T00:00:00.000Z'
|
|
},
|
|
error: null
|
|
})
|
|
|
|
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS)
|
|
|
|
expect(result).toMatchObject({
|
|
success: false,
|
|
error: { message: 'Subscription status is unavailable' }
|
|
})
|
|
})
|
|
|
|
it('requires an authenticated user before invoking payment functions', async () => {
|
|
mocks.cloud.isAuthenticated.mockReturnValue(false)
|
|
|
|
const result = await invokeIpc(IPC_CHANNELS.PAYMENT.CREATE_CHECKOUT_SESSION, validParams)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(mocks.invokeFunction).not.toHaveBeenCalled()
|
|
})
|
|
})
|