Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 96 additions & 2 deletions browser_tests/tests/dialogs/creditsTile.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { Page, Request } from '@playwright/test'

import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
import type {
BillingOpStatusResponse,
BillingStatusResponse,
CreateTopupResponse
} from '@/platform/workspace/api/workspaceApi'

import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TopUpCreditsDialog } from '@e2e/fixtures/components/TopUpCreditsDialog'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
import {
mockWorkspaceTokenMint,
workspace
Expand Down Expand Up @@ -315,3 +321,91 @@ test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
).toBeVisible()
})
})

test.describe('Top-up 3DS verification', { tag: '@cloud' }, () => {
test.describe.configure({ timeout: 60_000 })

let operationPollRequests: Request[]
let topupDialog: TopUpCreditsDialog

test.beforeEach(async ({ page }) => {
operationPollRequests = []
await page.addInitScript(() => {
window.open = (url, target, features) => {
document.documentElement.dataset.openedUrl = String(url)
document.documentElement.dataset.openedTarget = target ?? ''
document.documentElement.dataset.openedFeatures = features ?? ''
return window
}
})
await new CloudWorkspaceMockHelper(page).setup()
await page.route('**/api/settings/**', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({}))
})
await page.route('**/api/prompt', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
})
await page.route('**/api/queue', (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
})
await page.route('**/api/billing/topup', (route) =>
route.fulfill(
jsonRoute({
billing_op_id: 'topup-3ds-operation',
topup_id: 'topup-3ds-operation',
status: 'pending',
amount_cents: 5000
} satisfies CreateTopupResponse)
)
)
await page.route('**/api/billing/ops/topup-3ds-operation', (route) => {
operationPollRequests.push(route.request())
return route.fulfill(
jsonRoute({
id: 'topup-3ds-operation',
status: 'pending',
started_at: '2026-07-31T00:00:00Z',
action_url: 'https://verify.example/topup-3ds'
} satisfies BillingOpStatusResponse)
)
})

const settingsDialog = await openSettings(page)
await settingsDialog
.getByRole('combobox', { name: 'Search Settings...' })
.focus()
await page.keyboard.press('Escape')
await expect(settingsDialog).toBeHidden()
topupDialog = new TopUpCreditsDialog(page)
await topupDialog.open()
})

test('opens verification when the top-up operation requires authentication', async ({
page
}) => {
await topupDialog.root.getByRole('button', { name: 'Add credits' }).click()

await expect.poll(() => operationPollRequests.length).toBeGreaterThan(0)
const verificationButton = topupDialog.root.getByRole('button', {
name: 'Complete verification'
})
await expect(verificationButton).toBeVisible()

await verificationButton.click()

await expect
.poll(() => page.locator('html').getAttribute('data-opened-url'))
.toBe('https://verify.example/topup-3ds')
await expect(page.locator('html')).toHaveAttribute(
'data-opened-target',
'_blank'
)
await expect(page.locator('html')).toHaveAttribute(
'data-opened-features',
'noopener,noreferrer'
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const mockTrackTopUpPurchase = vi.fn()
const mockTrackBillingEvent = vi.fn()
const mockCanTopUp = vi.hoisted(() => ({ value: true }))
const mockShouldUseWorkspaceBilling = vi.hoisted(() => ({ value: true }))
const mockTopupActionOperation = vi.hoisted(() => ({
value: undefined as { actionUrl: string } | undefined
}))

vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
Expand All @@ -31,13 +34,17 @@ vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
useBillingOperationStore: () => ({
hasPendingOperations: true,
isAddingCredits: false,
get topupActionOperation() {
return mockTopupActionOperation.value
},
startOperation: mockStartOperation
})
}))

vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
__v_isRef: true,
get value() {
return { canTopUp: mockCanTopUp.value }
}
Expand Down Expand Up @@ -96,7 +103,10 @@ const i18n = createI18n({
messages: {
en: {
g: { close: 'Close' },
subscription: { addCredits: 'Add credits' },
subscription: {
addCredits: 'Add credits',
preview: { completeVerification: 'Complete verification' }
},
credits: {
topUp: {
addMoreCredits: 'Add more credits',
Expand Down Expand Up @@ -156,6 +166,7 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
vi.clearAllMocks()
mockCanTopUp.value = true
mockShouldUseWorkspaceBilling.value = true
mockTopupActionOperation.value = undefined
mockFetchBalance.mockResolvedValue(undefined)
mockFetchStatus.mockResolvedValue(undefined)
})
Expand All @@ -166,6 +177,38 @@ describe('TopUpCreditsDialogContentWorkspace', () => {
expect(screen.getByRole('button', { name: 'Add credits' })).toBeEnabled()
})

it('opens topup verification without exposing its URL', async () => {
const actionUrl = 'https://verify.example/sensitive-token'
const open = vi.spyOn(window, 'open').mockReturnValue({} as Window)
mockTopupActionOperation.value = { actionUrl }

const { container } = renderDialog()

expect(container.innerHTML).not.toContain(actionUrl)
await userEvent.click(
screen.getByRole('button', { name: 'Complete verification' })
)
expect(open).toHaveBeenCalledWith(
actionUrl,
'_blank',
'noopener,noreferrer'
)
open.mockRestore()
})

it('hides topup verification after permission is revoked', () => {
mockCanTopUp.value = false
mockTopupActionOperation.value = {
actionUrl: 'https://verify.example/sensitive-token'
}

renderDialog()

expect(
screen.queryByRole('button', { name: 'Complete verification' })
).not.toBeInTheDocument()
})

it('refreshes both balance and status after a completed top-up', async () => {
mockTopup.mockResolvedValue(topupResponse('completed'))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,27 @@
</p>

<div class="flex flex-col gap-8 p-8">
<Button
:disabled="!isValidAmount || loading || isPolling"
:loading="loading || isPolling"
variant="primary"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('subscription.addCredits') }}
</Button>
<div class="flex flex-col gap-2">
<Button
v-if="topupActionUrl && permissions.canTopUp"
variant="primary"
size="lg"
class="h-10 justify-center"
@click="openTopupVerification"
>
{{ $t('subscription.preview.completeVerification') }}
</Button>
<Button
:disabled="!isValidAmount || loading || isPolling"
:loading="loading || isPolling"
:variant="topupActionUrl ? 'tertiary' : 'primary'"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
<div class="flex items-center justify-center gap-1">
<a
:href="pricingUrl"
Expand Down Expand Up @@ -184,6 +195,9 @@ const { permissions } = useWorkspaceUI()

const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.isAddingCredits)
const topupActionUrl = computed(
() => billingOperationStore.topupActionOperation?.actionUrl ?? null
)

// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
Expand Down Expand Up @@ -245,6 +259,11 @@ function handlePresetClick(amount: number) {
selectedPreset.value = amount
}

function openTopupVerification() {
if (!topupActionUrl.value) return
window.open(topupActionUrl.value, '_blank', 'noopener,noreferrer')
}

function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
Expand Down
45 changes: 45 additions & 0 deletions src/platform/workspace/stores/billingOperationStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,26 @@ describe('billingOperationStore', () => {
expect(store.getOperation('op-1')?.actionUrl).toBeNull()
})

it('exposes topup actions only for the active workspace', async () => {
const actionUrl = 'https://verify.example/sensitive-token'
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString(),
action_url: actionUrl
})

const store = useBillingOperationStore()
void store.startOperation('op-1', 'topup')
await vi.advanceTimersByTimeAsync(0)

expect(store.topupActionOperation?.actionUrl).toBe(actionUrl)

mockActiveWorkspaceId.value = 'workspace-2'

expect(store.topupActionOperation).toBeUndefined()
})

it('only exposes subscription actions for the active workspace', async () => {
const actionUrl = 'https://verify.example/sensitive-token'
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
Expand Down Expand Up @@ -737,6 +757,31 @@ describe('billingOperationStore', () => {
summary: 'billingOperation.topupTimeout'
})
})

it('keeps polling a topup while authentication is required', async () => {
const actionUrl = 'https://verify.example/sensitive-token'
vi.mocked(workspaceApi.getBillingOpStatus).mockResolvedValue({
id: 'op-1',
status: 'pending',
started_at: new Date().toISOString(),
action_url: actionUrl
})

const store = useBillingOperationStore()
void store.startOperation('op-1', 'topup')

await vi.advanceTimersByTimeAsync(121_000)

expect(store.getOperation('op-1')).toMatchObject({
status: 'pending',
actionUrl,
authenticationRequiredSeen: true
})
expect(mockToastAdd).not.toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupTimeout'
})
})
})

describe('cancel operations', () => {
Expand Down
23 changes: 18 additions & 5 deletions src/platform/workspace/stores/billingOperationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const ACTION_REQUIRED_INTERVAL_MS = 30_000
const BACKOFF_MULTIPLIER = 1.5
const TIMEOUT_MS = 120_000
const SUBSCRIPTION_ACTION_DISCOVERY_TIMEOUT_MS = 5 * 60_000
const SUBSCRIPTION_AUTHENTICATION_TIMEOUT_MS = 23 * 60 * 60_000
const AUTHENTICATION_TIMEOUT_MS = 23 * 60 * 60_000

type OperationType = 'subscription' | 'topup' | 'cancel'
type OperationStatus = 'pending' | 'succeeded' | 'failed' | 'timeout'
Expand Down Expand Up @@ -97,6 +97,16 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
)
)

const topupActionOperation = computed(() =>
[...operations.value.values()].find(
(op) =>
op.status === 'pending' &&
op.type === 'topup' &&
op.workspaceId === workspaceStore.activeWorkspaceId &&
op.actionUrl !== null
)
)

function getOperation(opId: string) {
return operations.value.get(opId)
}
Expand Down Expand Up @@ -228,10 +238,12 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {

function hasTimedOut(operation: BillingOperation): boolean {
const elapsed = Date.now() - operation.startedAt
if (operation.type !== 'subscription') return elapsed > TIMEOUT_MS
return operation.authenticationRequiredSeen
? elapsed > SUBSCRIPTION_AUTHENTICATION_TIMEOUT_MS
: elapsed > SUBSCRIPTION_ACTION_DISCOVERY_TIMEOUT_MS
if (operation.type !== 'cancel' && operation.authenticationRequiredSeen) {
return elapsed > AUTHENTICATION_TIMEOUT_MS
}
return operation.type === 'subscription'
? elapsed > SUBSCRIPTION_ACTION_DISCOVERY_TIMEOUT_MS
: elapsed > TIMEOUT_MS
}

function stopIfTimedOut(opId: string, operation: BillingOperation): boolean {
Expand Down Expand Up @@ -498,6 +510,7 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
isSettingUp,
isAddingCredits,
subscriptionActionOperation,
topupActionOperation,
getOperation,
startOperation,
clearOperation
Expand Down
Loading