From e4cf5a188d66b1357cc96bfcead250a81f699be5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 13:28:08 +0800 Subject: [PATCH 1/3] feat(accounts): keep operations in context and scope 292 to OAuth accounts --- .github/workflows/downstream-verify.yml | 7 + ...t_repo_codex_lifecycle_integration_test.go | 45 +- .../service/codex_ticket_lifecycle.go | 6 +- .../service/codex_ticket_manual_test.go | 36 ++ ...odex_ticket_account_status_independent.sql | 25 + .../__tests__/integration/data-import.spec.ts | 1 + .../accountOperationIdempotency.spec.ts | 29 ++ .../src/api/accountOperationIdempotency.ts | 24 + frontend/src/api/client.ts | 6 +- .../account/BulkEditAccountModal.vue | 10 +- .../components/account/CreateAccountModal.vue | 9 +- .../__tests__/BulkEditAccountModal.spec.ts | 1 + .../__tests__/CreateAccountModal.spec.ts | 1 + .../AccountOperationConfirmDialog.vue | 29 ++ .../account-jobs/AccountOperationDialog.vue | 48 ++ .../account-jobs/AccountOperationProgress.vue | 144 ++++++ .../admin/account-jobs/AccountTaskDrawer.vue | 441 +++--------------- .../__tests__/AccountTaskDrawer.spec.ts | 218 +++------ .../admin/account/AccountActionMenu.vue | 10 +- .../admin/account/AccountBulkActionsBar.vue | 3 +- .../account/AccountBulkTaxonomyModal.vue | 9 +- .../admin/account/BatchTestAccountModal.vue | 12 +- .../admin/account/CodexTicketHarvestModal.vue | 91 ++-- .../admin/account/ImportDataModal.vue | 10 +- .../AccountActionMenu.spark_shadow.spec.ts | 11 + .../__tests__/AccountBulkActionsBar.spec.ts | 9 + .../AccountBulkTaxonomyModal.spec.ts | 1 + .../__tests__/BatchTestAccountModal.spec.ts | 3 +- .../__tests__/CodexTicketHarvestModal.spec.ts | 37 +- .../account/__tests__/ImportDataModal.spec.ts | 3 +- frontend/src/components/layout/AppHeader.vue | 20 +- frontend/src/components/layout/AppLayout.vue | 4 +- frontend/src/components/layout/AppSidebar.vue | 1 - .../__tests__/useTicketSelection.spec.ts | 40 ++ .../src/composables/useTicketSelection.ts | 31 ++ .../src/i18n/locales/en/admin/accountTasks.ts | 37 +- .../src/i18n/locales/en/admin/accounts.ts | 8 +- .../src/i18n/locales/zh/admin/accountTasks.ts | 41 +- .../src/i18n/locales/zh/admin/accounts.ts | 8 +- frontend/src/router/index.ts | 2 +- .../src/stores/__tests__/accountJobs.spec.ts | 89 ++-- frontend/src/stores/accountJobs.ts | 193 ++++++-- frontend/src/utils/codexTicketEligibility.ts | 15 + frontend/src/views/admin/AccountsView.vue | 168 +++---- .../__tests__/AccountsView.console.spec.ts | 9 +- .../AccountsView.selectAllResults.spec.ts | 19 +- 46 files changed, 1152 insertions(+), 812 deletions(-) create mode 100644 backend/migrations/245_codex_ticket_account_status_independent.sql create mode 100644 frontend/src/api/__tests__/accountOperationIdempotency.spec.ts create mode 100644 frontend/src/api/accountOperationIdempotency.ts create mode 100644 frontend/src/components/admin/account-jobs/AccountOperationConfirmDialog.vue create mode 100644 frontend/src/components/admin/account-jobs/AccountOperationDialog.vue create mode 100644 frontend/src/components/admin/account-jobs/AccountOperationProgress.vue create mode 100644 frontend/src/composables/__tests__/useTicketSelection.spec.ts create mode 100644 frontend/src/composables/useTicketSelection.ts create mode 100644 frontend/src/utils/codexTicketEligibility.ts diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index dcba0bffd250..be465c5ec61d 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -130,6 +130,13 @@ jobs: src/i18n/__tests__/accountConsoleLocales.spec.ts \ src/__tests__/integration/data-import.spec.ts \ src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts \ + src/stores/__tests__/accountJobs.spec.ts \ + src/composables/__tests__/useTicketSelection.spec.ts \ + src/api/__tests__/accountOperationIdempotency.spec.ts \ + src/components/admin/account/__tests__/CodexTicketHarvestModal.spec.ts \ + src/components/admin/account/__tests__/BatchTestAccountModal.spec.ts \ + src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts \ + src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts \ src/components/admin/account/__tests__/AccountConsoleFilters.spec.ts \ src/components/admin/account/__tests__/AccountUsageViews.spec.ts \ src/components/account/__tests__/AccountUsageCell.spec.ts \ diff --git a/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go b/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go index 8f3986ff22e5..e1f5e54cd44a 100644 --- a/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go +++ b/backend/internal/repository/account_repo_codex_lifecycle_integration_test.go @@ -110,14 +110,14 @@ func TestCodexTicketRepositoryConcurrentLeaseAndAccountInvalidation(t *testing.T var lease string require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT lease_id FROM openai_codex_ticket_runtime WHERE account_id=$1 AND model=$2`, a.ID, model).Scan(&lease)) require.Equal(t, claims[0].LeaseID, lease) - // Disable and re-enable before IO completes must still reject its old result. + // Business status changes do not revoke a credential owner's ticket claim. _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET status='disabled' WHERE id=$1`, a.ID) require.NoError(t, err) _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET status='active' WHERE id=$1`, a.ID) require.NoError(t, err) applied, err := r.FinishCodexTicket(ctx, claims[0], successfulTicket(a, model, now), service.CodexTicketResult{Success: true}, now) require.NoError(t, err) - require.False(t, applied) + require.True(t, applied) // A legacy ticket without an identity stamp cannot survive changing owner. raw, _ := json.Marshal(map[string]any{"codex_turn_ticket:" + model: map[string]any{"state": "gAAAAA" + strings.Repeat("B", 286), "length": 292, "expires_at": now.Add(time.Hour)}}) _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET extra=extra||$2::jsonb WHERE id=$1`, a.ID, string(raw)) @@ -129,6 +129,47 @@ func TestCodexTicketRepositoryConcurrentLeaseAndAccountInvalidation(t *testing.T require.NotContains(t, loaded.Extra, "codex_turn_ticket:"+model) } +func TestCodexTicketInactiveAccountsHarvestAndRenewWithoutEnabling(t *testing.T) { + for _, accountType := range []string{service.AccountTypeOAuth, service.AccountTypeSetupToken} { + for _, status := range []string{"disabled", "error"} { + t.Run(accountType+"/"+status, func(t *testing.T) { + ctx := context.Background() + r, a, now := newTicketIntegrationAccount(t) + _, err := integrationDB.ExecContext(ctx, `UPDATE accounts SET type=$2,status=$3,schedulable=false,error_message='keep diagnostic' WHERE id=$1`, a.ID, accountType, status) + require.NoError(t, err) + a, err = r.GetByID(ctx, a.ID) + require.NoError(t, err) + const model = "gpt-6-astra" + claim, err := r.ClaimCodexTicket(ctx, a.ID, model, "manual", 0, true, false, now) + require.NoError(t, err) + ticket := successfulTicket(a, model, now) + applied, err := r.FinishCodexTicket(ctx, claim, ticket, service.CodexTicketResult{Success: true}, now) + require.NoError(t, err) + require.True(t, applied) + renewAt := ticket.ExpiresAt.Add(-time.Minute) + renewal, err := r.ClaimCodexTicket(ctx, a.ID, model, "renewal", 0, false, false, renewAt) + require.NoError(t, err) + applied, err = r.FinishCodexTicket(ctx, renewal, successfulTicket(a, model, renewAt), service.CodexTicketResult{Success: true}, renewAt) + require.NoError(t, err) + require.True(t, applied) + loaded, err := r.GetByID(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, status, loaded.Status) + require.False(t, loaded.Schedulable) + require.Equal(t, "keep diagnostic", loaded.ErrorMessage) + // Deletion still revokes a live claim and rejects the late result. + claim, err = r.ClaimCodexTicket(ctx, a.ID, model, "manual-again", 0, true, true, renewAt) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, `UPDATE accounts SET deleted_at=NOW() WHERE id=$1`, a.ID) + require.NoError(t, err) + applied, err = r.FinishCodexTicket(ctx, claim, successfulTicket(a, model, renewAt), service.CodexTicketResult{Success: true}, renewAt) + require.NoError(t, err) + require.False(t, applied) + }) + } + } +} + func TestCodexTicketRepositoryInterruptedRenewalIsConsumed(t *testing.T) { ctx := context.Background() r, a, now := newTicketIntegrationAccount(t) diff --git a/backend/internal/service/codex_ticket_lifecycle.go b/backend/internal/service/codex_ticket_lifecycle.go index cb9b93ffd282..7945cf927e72 100644 --- a/backend/internal/service/codex_ticket_lifecycle.go +++ b/backend/internal/service/codex_ticket_lifecycle.go @@ -86,14 +86,16 @@ func CodexTicketAccountIdentity(a *Account) string { } func CodexTicketAccountEligible(a *Account) bool { - return isOpenAICodexTicketAccount(a) && a.Status == StatusActive + // Ticket ownership is independent of business scheduling. Administrators may + // prepare and renew tickets while an OAuth-like account is disabled/in error. + return isOpenAICodexTicketAccount(a) } func CodexTicketFailure(code string) CodexTicketResult { messages := map[string]string{ "ticket_disabled": "票据总开关未开启", "ticket_proxy_missing": "未配置打票代理", - "ticket_ineligible": "仅启用状态的 OpenAI OAuth/Setup Token 非影子账号可打票", + "ticket_ineligible": "仅 OpenAI OAuth/Setup Token 非影子账号可打票", "ticket_model_invalid": "模型不在票据配置范围内", "ticket_busy": "该账号和模型已有打票任务", "ticket_interrupted": "上次请求已开始,结果未确认;不会自动重复发送", diff --git a/backend/internal/service/codex_ticket_manual_test.go b/backend/internal/service/codex_ticket_manual_test.go index b89799aa5cf6..6690927a8a54 100644 --- a/backend/internal/service/codex_ticket_manual_test.go +++ b/backend/internal/service/codex_ticket_manual_test.go @@ -191,3 +191,39 @@ func TestCodexTicketManualOnlyOneRequestAndPersistence(t *testing.T) { }) } } + +func TestCodexTicketEligibilityIndependentOfAccountStatus(t *testing.T) { + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} { + for _, status := range []string{StatusActive, "disabled", "error"} { + t.Run(accountType+"/"+status, func(t *testing.T) { + a := ticketTestAccount(41) + a.Type, a.Status, a.Schedulable, a.ErrorMessage = accountType, status, false, "preserve diagnostic" + r := newCodexTicketMemoryStore(a) + var calls atomic.Int64 + upstream := &codexTicketFuncUpstream{do: func(*http.Request) (*http.Response, error) { + calls.Add(1) + resp := codexTicketResponse() + resp.Header.Set(openAICodexTurnStateHeader, fakeCodexTicketState(292)) + return resp, nil + }} + s := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080"}, upstream) + s.accountRepo = r + result := s.HarvestCodexTicket(context.Background(), a.ID, "gpt-6-astra", "manual", 0, false) + require.True(t, result.Success) + require.Equal(t, int64(1), calls.Load()) + require.Equal(t, "ready", r.states["gpt-6-astra"].Phase) + require.Equal(t, status, a.Status) + require.False(t, a.Schedulable) + require.Equal(t, "preserve diagnostic", a.ErrorMessage) + }) + } + } + for _, a := range []*Account{ + {Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + {Platform: PlatformAnthropic, Type: AccountTypeOAuth}, + {Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: func() *int64 { id := int64(1); return &id }()}, + nil, + } { + require.False(t, CodexTicketAccountEligible(a)) + } +} diff --git a/backend/migrations/245_codex_ticket_account_status_independent.sql b/backend/migrations/245_codex_ticket_account_status_independent.sql new file mode 100644 index 000000000000..ebacf8add71b --- /dev/null +++ b/backend/migrations/245_codex_ticket_account_status_independent.sql @@ -0,0 +1,25 @@ +-- Account status controls business scheduling, not ticket preparation/renewal. +-- Preserve deletion and credential-owner invalidation, including in-flight IO. +-- Existing stopped renewals are deliberately not enrolled by this migration. +CREATE OR REPLACE FUNCTION invalidate_codex_ticket_account_claims() RETURNS TRIGGER AS $$ +DECLARE principal_changed BOOLEAN; +BEGIN + principal_changed := NEW.platform IS DISTINCT FROM OLD.platform + OR NEW.type IS DISTINCT FROM OLD.type + OR NEW.parent_account_id IS DISTINCT FROM OLD.parent_account_id + OR COALESCE(NEW.credentials->>'chatgpt_account_id','') IS DISTINCT FROM COALESCE(OLD.credentials->>'chatgpt_account_id','') + OR COALESCE(NEW.credentials->>'chatgpt_user_id','') IS DISTINCT FROM COALESCE(OLD.credentials->>'chatgpt_user_id','') + OR COALESCE(NEW.credentials->>'organization_id','') IS DISTINCT FROM COALESCE(OLD.credentials->>'organization_id','') + OR (COALESCE(NEW.credentials->>'chatgpt_account_id','')='' AND COALESCE(NEW.credentials->>'chatgpt_user_id','')='' AND + COALESCE(NEW.credentials->>'email','') IS DISTINCT FROM COALESCE(OLD.credentials->>'email','')); + IF NEW.deleted_at IS DISTINCT FROM OLD.deleted_at OR principal_changed THEN + UPDATE openai_codex_ticket_runtime SET phase='stopped',next_at=NULL,lease_id='',lease_until=NULL,updated_at=NOW() + WHERE account_id=NEW.id; + NEW.extra := (SELECT COALESCE(jsonb_object_agg(e.key, CASE WHEN e.key LIKE 'codex_ticket_runtime:%' + THEN e.value || '{"phase":"stopped","next_attempt_at":null}'::jsonb ELSE e.value END), '{}'::jsonb) + FROM jsonb_each(CASE WHEN jsonb_typeof(NEW.extra)='object' THEN NEW.extra ELSE '{}'::jsonb END) e + WHERE NOT (principal_changed AND e.key LIKE 'codex_turn_ticket:%')); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/frontend/src/__tests__/integration/data-import.spec.ts b/frontend/src/__tests__/integration/data-import.spec.ts index b38651a029a6..fa67ac9ab9e0 100644 --- a/frontend/src/__tests__/integration/data-import.spec.ts +++ b/frontend/src/__tests__/integration/data-import.spec.ts @@ -1,3 +1,4 @@ +vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { props: ['job', 'show'], template: '
' } })) import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import ImportDataModal from '@/components/admin/account/ImportDataModal.vue' diff --git a/frontend/src/api/__tests__/accountOperationIdempotency.spec.ts b/frontend/src/api/__tests__/accountOperationIdempotency.spec.ts new file mode 100644 index 000000000000..0c82a74102e6 --- /dev/null +++ b/frontend/src/api/__tests__/accountOperationIdempotency.spec.ts @@ -0,0 +1,29 @@ +import { webcrypto } from 'node:crypto' +import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { stabilizeAccountOperationKey, settleAccountOperation, clearAccountOperationKeys } from '../accountOperationIdempotency' +const request = (key: string, ids = [1]) => ({ method: 'post', url: '/admin/accounts/batch-delete', data: { account_ids: ids }, headers: new AxiosHeaders({ 'Idempotency-Key': key }) }) as InternalAxiosRequestConfig +afterEach(() => { clearAccountOperationKeys(); vi.unstubAllGlobals() }) +describe('uncertain account operation submission', () => { + it('reuses the key after a lost response, isolates changed targets and releases confirmed submissions', async () => { + vi.stubGlobal('crypto', webcrypto) + const first = request('first'); await stabilizeAccountOperationKey(first) + const retry = request('new-key'); await stabilizeAccountOperationKey(retry) + expect(retry.headers.get('Idempotency-Key')).toBe('first') + const changed = request('changed', [2]); await stabilizeAccountOperationKey(changed) + expect(changed.headers.get('Idempotency-Key')).toBe('changed') + settleAccountOperation(retry) + const next = request('next'); await stabilizeAccountOperationKey(next) + expect(next.headers.get('Idempotency-Key')).toBe('next') + }) + it('does not retain another administrator session or affect non-operation requests', async () => { + vi.stubGlobal('crypto', webcrypto) + await stabilizeAccountOperationKey(request('old')) + clearAccountOperationKeys() + const next = request('next'); await stabilizeAccountOperationKey(next) + expect(next.headers.get('Idempotency-Key')).toBe('next') + const unrelated = request('unrelated'); unrelated.url = '/admin/settings' + await stabilizeAccountOperationKey(unrelated) + expect(unrelated.headers.get('Idempotency-Key')).toBe('unrelated') + }) +}) diff --git a/frontend/src/api/accountOperationIdempotency.ts b/frontend/src/api/accountOperationIdempotency.ts new file mode 100644 index 000000000000..adda06c05c38 --- /dev/null +++ b/frontend/src/api/accountOperationIdempotency.ts @@ -0,0 +1,24 @@ +import type { InternalAxiosRequestConfig } from 'axios' + +// Keep only unresolved submissions. A timeout may have happened after the +// server committed the operation; retrying that payload must reuse its key. +const unresolved = new Map() +let session = 0 +type OperationConfig = InternalAxiosRequestConfig & { accountOperationSignature?: string } +export async function stabilizeAccountOperationKey(config: OperationConfig): Promise { + const url = String(config.url || '') + const key = config.headers?.get('Idempotency-Key') + if (!key || !/^\/admin\/(accounts(?:\/|$)|account-jobs(?:\/|$))/.test(url)) return + const epoch = session + const payload = `${config.method}:${url}:${typeof config.data === 'string' ? config.data : JSON.stringify(config.data)}` + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)) + if (epoch !== session) throw new Error('Account operation session changed') + const signature = `${epoch}:` + Array.from(new Uint8Array(digest), n => n.toString(16).padStart(2, '0')).join('') + if (!unresolved.has(signature)) unresolved.set(signature, String(key)) + config.headers.set('Idempotency-Key', unresolved.get(signature)!) + config.accountOperationSignature = signature +} +export function settleAccountOperation(config?: OperationConfig): void { + if (config?.accountOperationSignature) unresolved.delete(config.accountOperationSignature) +} +export function clearAccountOperationKeys(): void { unresolved.clear(); session++ } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 245896cd3ecd..57d310e4aae1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -14,6 +14,7 @@ import { } from './adminUIRequest' import { refreshAuthTokens } from './tokenRefresh' import { getAPIBaseURL } from './url' +import { settleAccountOperation, stabilizeAccountOperationKey } from './accountOperationIdempotency' export { buildApiUrl, buildGatewayUrl } from './url' // ==================== Axios Instance Configuration ==================== @@ -39,7 +40,8 @@ const getUserTimezone = (): string => { } apiClient.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { + async (config: InternalAxiosRequestConfig) => { + await stabilizeAccountOperationKey(config) // Attach token from localStorage const token = localStorage.getItem('auth_token') if (token && config.headers) { @@ -80,6 +82,7 @@ apiClient.interceptors.request.use( apiClient.interceptors.response.use( (response: AxiosResponse) => { + settleAccountOperation(response.config) // Unwrap standard API response format { code, message, data } const apiResponse = response.data as ApiResponse if (apiResponse && typeof apiResponse === 'object' && 'code' in apiResponse) { @@ -112,6 +115,7 @@ apiClient.interceptors.response.use( // Handle common errors if (error.response) { const { status, data } = error.response + if (status >= 400 && status < 500 && status !== 408) settleAccountOperation(error.config) const url = String(error.config?.url || '') // Validate `data` shape to avoid HTML error pages breaking our error handling. diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 8d4452fef50f..bb58d1963614 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -1,5 +1,5 @@ - + () const { t } = useI18n() +const operationJob = ref(null) +watch(() => props.show, show => { if (show) operationJob.value = null }) const appStore = useAppStore() // Platform awareness @@ -2318,8 +2320,8 @@ const submitBulkUpdate = async (baseUpdates: Record) => { }) : await adminAPI.accounts.bulkUpdate(props.accountIds, updates) pendingUpdatesForConfirm.value = null + operationJob.value = job emit('updated', job) - handleClose() } catch (error: any) { // 兜底:多平台混合场景下,预检查跳过,由后端 409 触发确认框 if (error.status === 409 && error.error === 'mixed_channel_warning') { diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 2d4a1683ae5e..3ba0257a335f 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1,5 +1,5 @@ - + () +const operationJob = ref(null) +watch(() => props.show, show => { if (show) operationJob.value = null }) const emit = defineEmits<{ close: [] created: [job?: AccountJob] @@ -6773,8 +6776,8 @@ const handleOpenAIImportCodexSession = async (content: string) => { update_existing: true }) + operationJob.value = job emit('created', job) - handleClose() } catch (error: any) { oauthClient.error.value = error.response?.data?.detail || diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 97f314238a09..460444713485 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -1,3 +1,4 @@ +vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { props: ['job', 'show'], template: '
' } })) import { describe, expect, it, vi, beforeEach } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { nextTick } from 'vue' diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 17255f391c96..bb9caf075a4b 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -1,3 +1,4 @@ +vi.mock('@/components/admin/account-jobs/AccountOperationDialog.vue', () => ({ default: { props: ['job', 'show'], template: '
' } })) import { defineComponent, type PropType } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/frontend/src/components/admin/account-jobs/AccountOperationConfirmDialog.vue b/frontend/src/components/admin/account-jobs/AccountOperationConfirmDialog.vue new file mode 100644 index 000000000000..cc8f7913f541 --- /dev/null +++ b/frontend/src/components/admin/account-jobs/AccountOperationConfirmDialog.vue @@ -0,0 +1,29 @@ + + diff --git a/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue b/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue new file mode 100644 index 000000000000..e485eb310bba --- /dev/null +++ b/frontend/src/components/admin/account-jobs/AccountOperationDialog.vue @@ -0,0 +1,48 @@ + + diff --git a/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue b/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue new file mode 100644 index 000000000000..fa9f821a253f --- /dev/null +++ b/frontend/src/components/admin/account-jobs/AccountOperationProgress.vue @@ -0,0 +1,144 @@ + + + diff --git a/frontend/src/components/admin/account-jobs/AccountTaskDrawer.vue b/frontend/src/components/admin/account-jobs/AccountTaskDrawer.vue index 7595a69fe6ec..b45bc4db9057 100644 --- a/frontend/src/components/admin/account-jobs/AccountTaskDrawer.vue +++ b/frontend/src/components/admin/account-jobs/AccountTaskDrawer.vue @@ -1,397 +1,80 @@ - + diff --git a/frontend/src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts b/frontend/src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts index 176a260ff8d3..a738890bf76c 100644 --- a/frontend/src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts +++ b/frontend/src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts @@ -1,175 +1,83 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushPromises, mount } from '@vue/test-utils' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' - -const api = vi.hoisted(() => ({ - list: vi.fn(), - get: vi.fn(), - listItems: vi.fn(), - cancel: vi.fn(), - retryFailed: vi.fn(), - reviewDuplicates: vi.fn(), - mergeDuplicates: vi.fn(), -})) - +const api = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), listItems: vi.fn(), cancel: vi.fn(), retryFailed: vi.fn(), mergeDuplicates: vi.fn() })) vi.mock('@/api/admin/accountJobs', () => ({ default: api })) -vi.mock('vue-i18n', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useI18n: () => ({ t: (key: string) => key }), - } -}) - +vi.mock('@/api/admin/accounts', () => ({ list: vi.fn().mockResolvedValue({ items: [] }) })) +vi.mock('vue-i18n', async () => ({ ...await vi.importActual('vue-i18n'), useI18n: () => ({ t: (key: string) => key }) })) import AccountTaskDrawer from '../AccountTaskDrawer.vue' +import AccountOperationDialog from '../AccountOperationDialog.vue' import { useAccountJobsStore } from '@/stores/accountJobs' import type { AccountJob } from '@/api/admin/accountJobs' - -const reviewJob: AccountJob = { - id: 51, - created_by: 1, - kind: 'account_duplicate_review', - status: 'succeeded', - metadata: { internal_marker: 'MustNotRender' }, - target_count: 2, - processed_count: 2, - succeeded_count: 2, - failed_count: 0, - canceled_count: 0, - attempt: 1, - created_at: '2026-08-21T00:00:00Z', - updated_at: '2026-08-21T00:01:00Z', -} - -describe('AccountTaskDrawer', () => { +const base: AccountJob = { id: 51, created_by: 1, kind: 'account_batch_refresh', status: 'running', metadata: { secret: 'MustNotRender' }, target_count: 2, processed_count: 1, succeeded_count: 1, failed_count: 0, canceled_count: 0, attempt: 1, created_at: '2026-08-21T00:00:00Z', updated_at: '2026-08-21T00:01:00Z' } +let wrappers: VueWrapper[] +const options = { global: { stubs: { Teleport: true, Icon: true } } } +function button(wrapper: VueWrapper, label: string) { return wrapper.findAll('button').find(b => b.text().endsWith(label))! } +describe('account operation presentation', () => { beforeEach(() => { - setActivePinia(createPinia()) - vi.clearAllMocks() - api.mergeDuplicates.mockResolvedValue({ - ...reviewJob, - id: 52, - kind: 'account_duplicate_merge', - status: 'pending', - processed_count: 0, - succeeded_count: 0, - }) - }) - - afterEach(() => { - vi.useRealTimers() + setActivePinia(createPinia()); vi.resetAllMocks(); vi.useFakeTimers(); wrappers = [] + api.get.mockImplementation(async () => ({ ...useAccountJobsStore().currentJob })) + api.list.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20 }) + api.listItems.mockImplementation(async () => ({ items: [...useAccountJobsStore().items], total: useAccountJobsStore().items.length, page: 1, page_size: 20 })) }) - - it('refreshes active tasks automatically and stops after completion', async () => { - vi.useFakeTimers() - const store = useAccountJobsStore() - const runningJob = { ...reviewJob, status: 'running' as const, processed_count: 1 } - api.list.mockResolvedValue({ items: [runningJob], total: 1, page: 1, page_size: 20 }) - api.get.mockResolvedValue(runningJob) - api.listItems.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20 }) - store.track(runningJob) - const wrapper = mount(AccountTaskDrawer, { - global: { stubs: { Teleport: true, RouterLink: true, Icon: true } }, - }) - - await vi.advanceTimersByTimeAsync(3_000) + afterEach(() => { wrappers.forEach(w => w.unmount()); useAccountJobsStore().clear(); vi.useRealTimers() }) + it('keeps execution in the original window and minimizes without canceling', async () => { + const wrapper = mount(AccountOperationDialog, { ...options, props: { show: true, title: 'Refresh', job: base } }); wrappers.push(wrapper) await flushPromises() - expect(api.list.mock.calls.length).toBeGreaterThanOrEqual(1) - expect(api.get.mock.calls.length).toBeGreaterThanOrEqual(1) - expect(api.listItems.mock.calls.length).toBeGreaterThanOrEqual(1) - - api.list.mockResolvedValue({ - items: [{ ...runningJob, status: 'succeeded', processed_count: 2 }], - total: 1, - page: 1, - page_size: 20, - }) - api.get.mockResolvedValue({ ...runningJob, status: 'succeeded', processed_count: 2 }) - const listCallsBeforeCompletion = api.list.mock.calls.length - await vi.advanceTimersByTimeAsync(3_000) - await flushPromises() - const listCallsAfterCompletion = api.list.mock.calls.length - await vi.advanceTimersByTimeAsync(6_000) - expect(api.list.mock.calls.length).toBe(listCallsAfterCompletion) - expect(listCallsAfterCompletion).toBeGreaterThan(listCallsBeforeCompletion) - - await wrapper.get('[data-test="task-refresh"]').trigger('click') + const store = useAccountJobsStore() + expect(store.embeddedOpen).toBe(true) + expect(store.drawerOpen).toBe(false) + expect(wrapper.get('[role="progressbar"]').attributes('aria-valuenow')).toBe('50') + await button(wrapper, '.minimize').trigger('click') + expect(wrapper.emitted('close')).toHaveLength(1) + expect(api.cancel).not.toHaveBeenCalled() + await wrapper.setProps({ show: false }) + const host = mount(AccountTaskDrawer, options); wrappers.push(host) + expect(host.find('[data-test="operation-dock"]').exists()).toBe(true) + api.get.mockResolvedValue({ ...base, status: 'succeeded', processed_count: 2, succeeded_count: 2 }) + await vi.advanceTimersByTimeAsync(3000) + expect(store.activeCount).toBe(0) + expect(store.drawerOpen).toBe(false) + expect(host.text()).toContain('statuses.succeeded') + }) + it('shows stopping until the server confirms a terminal result and retries only failures', async () => { + const store = useAccountJobsStore(); store.track(base) + const wrapper = mount(AccountTaskDrawer, options); wrappers.push(wrapper); await flushPromises() + api.cancel.mockResolvedValue({ ...base, cancel_requested_at: '2026-08-21T00:02:00Z' }) + await button(wrapper, '.cancel').trigger('click'); await flushPromises() + expect(button(wrapper, '.stopping').attributes('disabled')).toBeDefined() + expect(button(wrapper, '.retryFailed')).toBeUndefined() + store.currentJob = { ...base, status: 'partially_succeeded', processed_count: 2, failed_count: 1 } + await store.loadCurrent(base.id) await flushPromises() - expect(api.list.mock.calls.length).toBeGreaterThan(listCallsAfterCompletion) - wrapper.unmount() + expect(api.listItems.mock.calls.at(-1)?.[1].status).toBe('failed') + api.retryFailed.mockResolvedValue({ ...base, id: 52, status: 'pending', target_count: 1, processed_count: 0, retry_of_job_id: 51 }) + await button(wrapper, '.retryFailed').trigger('click'); await flushPromises() + expect(api.retryFailed).toHaveBeenCalledWith(51) + expect(store.currentJob?.id).toBe(52) }) - - it('uses only safe duplicate review metadata to submit a confirmed merge', async () => { + it('validates safe duplicate metadata and requires explicit merge confirmation', async () => { const store = useAccountJobsStore() - store.track(reviewJob) - store.items = [{ - id: 101, - job_id: 51, - ordinal: 1, - action: 'review', - status: 'succeeded', - metadata: { - confirmation_hash: 'opaque-confirmation-token', - accounts: [ - { account_id: 7, name: 'Keep me', group_count: 2, tag_count: 1, configuration_score: 9 }, - { account_id: 8, name: 'Merge me', group_count: 1, tag_count: 0, configuration_score: 4 }, - ], - api_key: 'MustNotRender', - confirmation_fingerprint: 'MustNotRender', - }, - created_at: '2026-08-21T00:00:00Z', - updated_at: '2026-08-21T00:01:00Z', - }] - - const wrapper = mount(AccountTaskDrawer, { - global: { - stubs: { - Teleport: true, - RouterLink: { template: '' }, - Icon: true, - }, - }, - }) - + store.track({ ...base, kind: 'account_duplicate_review', status: 'succeeded', processed_count: 2 }) + store.items = [{ id: 101, job_id: 51, ordinal: 1, status: 'succeeded', metadata: { confirmation_hash: 'opaque-confirmation-token', api_key: 'MustNotRender', accounts: [{ account_id: 7, name: 'Keep me', group_count: 2, tag_count: 1, configuration_score: 9 }, { account_id: 8, name: 'Merge me', group_count: 1, tag_count: 0, configuration_score: 4 }] }, created_at: base.created_at, updated_at: base.updated_at }] + const wrapper = mount(AccountTaskDrawer, options); wrappers.push(wrapper); await flushPromises() expect(wrapper.text()).toContain('Keep me') - expect(wrapper.text()).toContain('Merge me') expect(wrapper.text()).not.toContain('MustNotRender') - expect(wrapper.html()).not.toContain('confirmation_fingerprint') - await wrapper.get('[data-test="duplicate-survivor-7"]').setValue(true) await wrapper.get('[data-test="duplicate-merge-submit"]').trigger('click') - await flushPromises() - - expect(api.mergeDuplicates).toHaveBeenCalledWith({ - survivor_account_id: 7, - loser_account_ids: [8], - confirmation_hash: 'opaque-confirmation-token', - }) + expect(api.mergeDuplicates).not.toHaveBeenCalled() + api.mergeDuplicates.mockResolvedValue({ ...base, id: 52, kind: 'account_duplicate_merge', status: 'pending' }) + await wrapper.get('[data-test="duplicate-merge-submit"]').trigger('click'); await flushPromises() + expect(api.mergeDuplicates).toHaveBeenCalledWith({ survivor_account_id: 7, loser_account_ids: [8], confirmation_hash: 'opaque-confirmation-token' }) expect(store.currentJob?.id).toBe(52) }) - - it('shows progress and permits cancel or retry only in matching states', async () => { - const store = useAccountJobsStore() - store.track({ ...reviewJob, status: 'running', processed_count: 1 }) - api.cancel.mockResolvedValue({ ...reviewJob, status: 'running', cancel_requested_at: '2026-08-21T00:02:00Z' }) - const wrapper = mount(AccountTaskDrawer, { - global: { stubs: { Teleport: true, RouterLink: true, Icon: true } }, - }) - - expect(wrapper.get('[role="progressbar"]').attributes('aria-valuenow')).toBe('50') - expect(wrapper.get('[data-test="task-result-summary"]').text()).toContain('admin.accountTasks.resultSummary') - expect(wrapper.get('[data-test="task-progress-metrics"]').text()).toContain('admin.accountTasks.progressMetrics') - expect(wrapper.find('[data-test="task-cancel"]').exists()).toBe(true) - expect(wrapper.find('[data-test="task-retry"]').exists()).toBe(false) - - store.currentJob = { - ...reviewJob, - status: 'failed', - failed_count: 2, - error_message: 'redacted task failure', - } - await wrapper.vm.$nextTick() - expect(wrapper.find('[data-test="task-cancel"]').exists()).toBe(false) - expect(wrapper.find('[data-test="task-retry"]').exists()).toBe(true) - expect(wrapper.text()).toContain('redacted task failure') + it('keeps expired retry failures actionable without claiming success', async () => { + const store = useAccountJobsStore(); store.track({ ...base, status: 'failed', failed_count: 2 }) + const wrapper = mount(AccountTaskDrawer, options); wrappers.push(wrapper); await flushPromises() + api.retryFailed.mockRejectedValue({ code: 'ACCOUNT_JOB_PAYLOAD_EXPIRED' }) + await button(wrapper, '.retryFailed').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('accountTasks.retryExpired') + expect(button(wrapper, '.retryFailed')).toBeUndefined() + expect(store.currentJob?.status).toBe('failed') }) }) diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue index 408f9e32fad4..c359725e65cf 100644 --- a/frontend/src/components/admin/account/AccountActionMenu.vue +++ b/frontend/src/components/admin/account/AccountActionMenu.vue @@ -9,9 +9,10 @@ :style="menuStyle" @click.stop > -
+

{{ t('common.processing') }}

+
-
+ @@ -73,8 +74,9 @@ import { useResizeObserver, useWindowSize } from '@vueuse/core' import { useI18n } from 'vue-i18n' import { Icon } from '@/components/icons' import type { Account } from '@/types' +import { canManageCodexTickets } from '@/utils/codexTicketEligibility' -const props = defineProps<{ show: boolean; account: Account | null; anchorRect: DOMRect | null }>() +const props = defineProps<{ show: boolean; account: Account | null; anchorRect: DOMRect | null; busy?: boolean }>() const emit = defineEmits(['harvest-tickets', 'close', 'test', 'stats', 'schedule', 'duplicate', 'reauth', 'refresh-token', 'recover-state', 'recover-cindy-balance', 'reset-quota', 'set-privacy', 'create-spark-shadow']) const { t } = useI18n() const menuRef = ref(null) diff --git a/frontend/src/components/admin/account/AccountBulkActionsBar.vue b/frontend/src/components/admin/account/AccountBulkActionsBar.vue index 21333b03e900..018fb6682ddc 100644 --- a/frontend/src/components/admin/account/AccountBulkActionsBar.vue +++ b/frontend/src/components/admin/account/AccountBulkActionsBar.vue @@ -46,7 +46,7 @@