Files
cc-switch/tests/components/ProviderForm.codexManagedAccount.test.tsx
T
a2e22f3302 Add managed OAuth account selection for providers (#3879)
* Add managed ChatGPT account binding for Codex

* Consolidate managed account entry points

* Clarify unmanaged Codex login option

* fix(provider-form): show Codex FAST mode toggle in account-select mode

The FAST mode switch was gated on mode === "manage", but the Claude and
Claude Desktop Codex OAuth forms render CodexOAuthSection in select mode
while still passing onFastModeChange. The toggle was therefore hidden:
new providers couldn't enable FAST mode and existing ones couldn't turn
it off, even though the backend still honors meta.codexFastMode. Gate the
toggle on onFastModeChange presence instead, restoring the pre-refactor
behavior without affecting the manage panel or native Codex form (which
do not pass the handler).

* fix(i18n): localize Codex managed-account none option label

ProviderForm passed a hardcoded Chinese string as codexOauthNoneOptionLabel,
so en/ja/zh-TW users saw Chinese text in the Codex account selector's
"none" option. Add codexOauth.noneOptionLabel to all four locales and
resolve it via t().

* feat(codex): write managed ChatGPT id_token into live auth + reauth prompt

Bind the selected ChatGPT account's id_token into the Codex official
managed auth.json so it matches a native browser login for the fields
that drive behavior (auth_mode, account/plan/email via id_token claims).

- Persist id_token on CodexAccountData; capture at login and on refresh
  (empty string treated as missing). Add get_valid_token_and_id_token_for_account.
- codex_managed_oauth_live_auth now writes tokens.id_token when available.
- Keep the managed-vs-native safety marker intact: extract_codex_managed_oauth_auth
  tolerates id_token but still rejects native logins (which carry refresh_token /
  top-level last_refresh), so cc-switch never clears a real browser login.
  refresh_token and last_refresh are intentionally NOT written for this reason.
- Surface reauth_required (account has no stored id_token) through
  GitHubAccount -> ManagedAuthAccount -> TS. Legacy accounts get a styled
  amber prompt + one-click re-login (device flow) in CodexOAuthSection,
  flagged in both the selector dropdown and the select-mode hint.
- i18n: reauth strings added for en / ja / zh / zh-TW.

Verified: pnpm typecheck + build:renderer green; cross-model review of Rust
correctness, the managed-vs-native safety invariant, and the id_token lifecycle.

* style(codex): satisfy prettier format check for reauth UI

* fix(i18n): add missing managed-account selector keys

The managed OAuth account selector referenced codexOauth.chatgptAccount,
codexOauth.manageAccounts, copilot.githubAccount, copilot.manageAccounts and
providerForm.providerKeyStatusLoading with Chinese defaultValue fallbacks, so
the en/ja/zh-TW UIs rendered Chinese text. Add the translations across all four
locales.

Addresses review feedback on #3879.

* fix(providers): don't clear managed OAuth binding on status-query failure

useManagedAuth now exposes isStatusSuccess. The account-clear effect in the
Codex/Copilot selector sections only unbinds when the status query has
*successfully* loaded and the bound account is genuinely gone. Previously a
failed or still-pending query yielded an empty accounts array, which silently
cleared the selection; the subsequent save then dropped providerType/authBinding
and turned a managed provider into a broken unbound config.

Addresses review feedback on #3879.

* fix(codex-oauth): make managed accounts safe & usable with the bare CLI

Addresses review feedback on #3879. These backend changes are deeply
intertwined across shared files, so they are committed together.

Refreshable managed auth (bare `codex` support):
- Write a full, native-shaped auth.json bundle (tokens.refresh_token + top-level
  last_refresh) instead of an access-only token, so the Codex CLI can self-refresh
  and the managed account keeps working past ~1h without a proxy.
- last_refresh reflects the access token's real obtained-at time (not write time),
  so the CLI doesn't treat a cached token as freshly refreshed.
- Read back the CLI-rotated refresh_token from ~/.codex/auth.json before writing
  (account_id-matched, chatgpt-mode guarded) so re-switching doesn't clobber the
  CLI's valid login with a stale refresh_token.

Never persist managed tokens at rest:
- Backfill of a managed provider always replaces live auth with the stored
  placeholder (even on marker mismatch), so native tokens can't leak into the
  provider's DB config.
- Sanitize managed auth out of Live backups on the initial/strict snapshot paths
  and in update_live_backup_from_provider_inner before serialization.

Concurrency / blocking:
- Drop the redundant outer Arc<RwLock<CodexOAuthManager>> (all methods are &self)
  so token refresh no longer holds a coarse lock across the network; give OAuth
  token/device requests a 30s timeout instead of the shared 600s client default.
- Add a persistence lock and linearize the access-token cache under a consistent
  accounts -> access_tokens order (existence-checked reads/writes; remove/clear
  clear the cache atomically) to prevent stale or resurrected cache entries.

Switch ordering:
- Preflight the managed token before committing the current provider on
  switch/add/update, so a failed token fetch can't leave DB/UI pointing at a
  provider whose live config was never written.

Cleanup:
- Remove the now-dead content-fingerprint marker machinery; managed writes are
  now shape-identical to a native login, so ownership is judged by account_id.

Known, documented limitations (B-scheme, narrow & recoverable): a remove+re-login
of the same account within an in-flight refresh window (ABA), a login authorized
after logout, and switch-away cleanup being unable to distinguish our write from a
user's native login of the same account.

* fix(codex-oauth): keep managed token in restore backup; fix failing tests

CI caught three test failures from the previous commit. Root cause: the Live
backup (proxy_live_backup) is local restore state that is replayed to
~/.codex/auth.json when proxy takeover ends, so it must contain the real auth to
restore a working login. Stripping it (the earlier "don't leak into backup"
change) broke restore and over-stripped a user's native login.

- Revert the backup auth stripping: remove sanitize_codex_backup_auth from the
  initial/strict snapshot paths and the forced auth={} in
  update_live_backup_from_provider_inner. The managed token belongs in the
  restore backup (the refresh_token is already persisted by CodexOAuthManager, so
  this is not a new exposure).
- Restore the ownership marker (account_id + access_token fingerprint) so backup
  cleanup can tell our managed write from a user's native `codex login` of the
  same account. extract now also tolerates the full-bundle shape (refresh_token +
  last_refresh) so it fingerprints ①'s refreshable writes. clear_codex_auth_in_backup
  and clear_codex_live_auth_for_managed_account use the marker again; the
  account_id-only helper is dropped.
- Fix the adopt unit test: adopt now invalidates the cached access token, so the
  test asserts the stored refresh_token/id_token were updated and the cache was
  cleared instead of reading it back through get_valid_token_bundle (which would
  trigger a network refresh).

Token stays out of the exported provider settings_config (backfill strip) — only
the local restore backup keeps it, matching pre-existing behavior.

* fix(codex): harden managed OAuth credential lifecycle

* fix: preserve Codex restore auth state

* feat(codex): support multiple official accounts

* feat(codex): refine official account login selection

Keep native Codex login separate from managed ChatGPT accounts, migrate provider identity safely when switching modes, and cover the account-selection states with regression tests.

* style(codex): satisfy clippy for login guard

---------

Co-authored-by: Jason <farion1231@gmail.com>
Co-authored-by: YUZHEthefool <2804776511@qq.com>
2026-08-16 10:45:41 +08:00

611 lines
19 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { createTestQueryClient } from "../utils/testQueryClient";
const authState = vi.hoisted(() => ({
codexReauthRequired: false,
}));
const toastMocks = vi.hoisted(() => ({
error: vi.fn(),
}));
const providerApiMocks = vi.hoisted(() => ({
getAll: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
error: toastMocks.error,
success: vi.fn(),
},
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
providersApi: {
...actual.providersApi,
getAll: (...args: unknown[]) => providerApiMocks.getAll(...args),
},
};
});
vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
CodexOAuthSection: ({
onAccountSelect,
onSelectionConfirmed,
onSelectionInvalidated,
allowUnboundSelection = true,
nativeLoginOnly = false,
requireExplicitSelection = false,
}: {
onAccountSelect?: (accountId: string | null) => void;
onSelectionConfirmed?: () => void;
onSelectionInvalidated?: () => void;
allowUnboundSelection?: boolean;
nativeLoginOnly?: boolean;
requireExplicitSelection?: boolean;
}) => (
<div>
<output data-testid="native-login-only">
{nativeLoginOnly ? "true" : "false"}
</output>
<output data-testid="explicit-selection-required">
{requireExplicitSelection ? "true" : "false"}
</output>
<button
type="button"
disabled={nativeLoginOnly}
onClick={() => {
onSelectionConfirmed?.();
onAccountSelect?.("acct-managed");
}}
>
select-managed-account
</button>
{allowUnboundSelection && (
<button
type="button"
onClick={() => {
onSelectionConfirmed?.();
onAccountSelect?.(null);
}}
>
select-native-login
</button>
)}
<button
type="button"
onClick={() => {
onSelectionInvalidated?.();
onAccountSelect?.(null);
}}
>
invalidate-selected-account
</button>
</div>
),
}));
vi.mock("@/components/providers/forms/CodexConfigEditor", () => ({
default: () => <div data-testid="codex-config-editor" />,
}));
vi.mock("@/components/providers/forms/ProviderAdvancedConfig", () => ({
ProviderAdvancedConfig: () => <div data-testid="advanced-config" />,
}));
vi.mock("@/components/providers/forms/hooks", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/components/providers/forms/hooks")>();
return {
...actual,
useCopilotAuth: () => ({
isAuthenticated: false,
isStatusSuccess: true,
isStatusError: false,
accounts: [],
}),
useCodexOauth: () => ({
isAuthenticated: true,
isStatusSuccess: true,
isStatusError: false,
defaultAccountId: "acct-managed",
accounts: [
{
id: "acct-managed",
login: "user@example.com",
is_default: true,
reauth_required: authState.codexReauthRequired,
requires_reauth: false,
},
],
}),
useXaiOauth: () => ({
isAuthenticated: false,
accounts: [],
}),
useCommonConfigSnippet: () => ({
useCommonConfig: false,
commonConfigSnippet: "",
commonConfigError: null,
isLoading: false,
isExtracting: false,
handleCommonConfigToggle: vi.fn(),
handleCommonConfigSnippetChange: vi.fn(),
handleExtract: vi.fn(),
}),
useCodexCommonConfig: () => ({
useCommonConfig: false,
commonConfigSnippet: "",
commonConfigError: null,
handleCommonConfigToggle: vi.fn(),
handleCommonConfigSnippetChange: vi.fn(),
isExtracting: false,
handleExtract: vi.fn(),
clearCommonConfigError: vi.fn(),
}),
useGeminiCommonConfig: () => ({
useCommonConfig: false,
commonConfigSnippet: "",
commonConfigError: null,
handleCommonConfigToggle: vi.fn(),
handleCommonConfigSnippetChange: vi.fn(),
isExtracting: false,
handleExtract: vi.fn(),
clearCommonConfigError: vi.fn(),
}),
};
});
vi.mock("@/lib/query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/query")>();
return {
...actual,
useSettingsQuery: () => ({
data: { commonConfigConfirmed: true },
}),
};
});
function renderCodexForm(onSubmit: (values: ProviderFormValues) => void) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<ProviderForm
appId="codex"
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
/>
</QueryClientProvider>,
);
}
function renderClaudeCodexForm(onSubmit: (values: ProviderFormValues) => void) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<ProviderForm
appId="claude"
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
initialData={{
name: "Claude via Codex OAuth",
category: "third_party",
settingsConfig: { env: {} },
meta: { providerType: "codex_oauth" },
}}
/>
</QueryClientProvider>,
);
}
describe("ProviderForm Codex Official managed account", () => {
beforeEach(() => {
authState.codexReauthRequired = false;
toastMocks.error.mockReset();
providerApiMocks.getAll.mockReset().mockResolvedValue({
"codex-official": {
id: "codex-official",
name: "OpenAI Official",
settingsConfig: { auth: {}, config: "" },
category: "official",
},
});
});
it("persists the selected managed account while stripping OAuth secrets", async () => {
const onSubmit = vi.fn();
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
fireEvent.click(
await screen.findByRole("button", { name: "select-managed-account" }),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const submitted = onSubmit.mock.calls[0][0] as ProviderFormValues;
expect(submitted).toEqual(
expect.objectContaining({
name: "OpenAI Official (user@example.com)",
presetId: "codex-0",
presetCategory: "official",
meta: expect.objectContaining({
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
}),
}),
);
expect(JSON.parse(submitted.settingsConfig)).toEqual({
auth: {},
config: "",
});
});
it("requires a managed account when adding another Official card", async () => {
const onSubmit = vi.fn();
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
await screen.findByRole("button", { name: "select-managed-account" });
expect(
screen.queryByRole("button", { name: "select-native-login" }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() =>
expect(toastMocks.error).toHaveBeenCalledWith("请先选择登录方式"),
);
expect(onSubmit).not.toHaveBeenCalled();
});
it("offers the current-login option only when the fixed card is missing", async () => {
providerApiMocks.getAll.mockResolvedValueOnce({});
const onSubmit = vi.fn();
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
const nativeLogin = await screen.findByRole("button", {
name: "select-native-login",
});
fireEvent.click(nativeLogin);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const submitted = onSubmit.mock.calls[0][0] as ProviderFormValues;
expect(submitted.presetCategory).toBe("official");
expect(submitted.meta?.authBinding).toBeUndefined();
});
it("does not silently create the current-login card before a choice", async () => {
providerApiMocks.getAll.mockResolvedValueOnce({});
const onSubmit = vi.fn();
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
await waitFor(() =>
expect(
screen.getByTestId("explicit-selection-required"),
).toHaveTextContent("true"),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() =>
expect(toastMocks.error).toHaveBeenCalledWith("请先选择登录方式"),
);
expect(onSubmit).not.toHaveBeenCalled();
});
it("requires a new choice after the selected account disappears", async () => {
providerApiMocks.getAll.mockResolvedValueOnce({});
const onSubmit = vi.fn();
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
fireEvent.click(
await screen.findByRole("button", { name: "select-managed-account" }),
);
fireEvent.click(
screen.getByRole("button", { name: "invalidate-selected-account" }),
);
await waitFor(() =>
expect(
screen.getByTestId("explicit-selection-required"),
).toHaveTextContent("true"),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() =>
expect(toastMocks.error).toHaveBeenCalledWith("请先选择登录方式"),
);
expect(onSubmit).not.toHaveBeenCalled();
});
it("allows the fixed Official card to switch to a managed account", async () => {
const queryClient = createTestQueryClient();
const onSubmit = vi.fn();
render(
<QueryClientProvider client={queryClient}>
<ProviderForm
appId="codex"
providerId="codex-official"
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
initialData={{
name: "OpenAI Official",
category: "official",
settingsConfig: { auth: {}, config: "" },
}}
/>
</QueryClientProvider>,
);
expect(screen.getByTestId("native-login-only")).toHaveTextContent("false");
expect(
screen.getByRole("button", { name: "select-managed-account" }),
).toBeEnabled();
expect(
screen.getByRole("button", { name: "select-native-login" }),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "select-managed-account" }),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0].meta?.authBinding).toEqual({
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
});
});
it("does not silently strip a legacy binding from the fixed card", async () => {
const queryClient = createTestQueryClient();
const onSubmit = vi.fn();
render(
<QueryClientProvider client={queryClient}>
<ProviderForm
appId="codex"
providerId="codex-official"
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
initialData={{
name: "OpenAI Official",
category: "official",
settingsConfig: { auth: {}, config: "" },
meta: {
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
}}
/>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0].meta?.authBinding).toEqual({
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
});
});
it("does not offer the current-login option on a managed Official card", () => {
const queryClient = createTestQueryClient();
render(
<QueryClientProvider client={queryClient}>
<ProviderForm
appId="codex"
providerId="managed-official"
submitLabel="save-provider"
onSubmit={vi.fn()}
onCancel={vi.fn()}
initialData={{
name: "OpenAI Official (user@example.com)",
category: "official",
settingsConfig: { auth: {}, config: "" },
meta: {
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
}}
/>
</QueryClientProvider>,
);
expect(screen.getByTestId("native-login-only")).toHaveTextContent("false");
expect(
screen.queryByRole("button", { name: "select-native-login" }),
).not.toBeInTheDocument();
});
it("allows a managed Official card to switch back when no native card exists", async () => {
providerApiMocks.getAll.mockResolvedValueOnce({});
const queryClient = createTestQueryClient();
const onSubmit = vi.fn();
render(
<QueryClientProvider client={queryClient}>
<ProviderForm
appId="codex"
providerId="managed-official"
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
initialData={{
name: "OpenAI Official (user@example.com)",
category: "official",
settingsConfig: { auth: {}, config: "" },
meta: {
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
}}
/>
</QueryClientProvider>,
);
fireEvent.click(
await screen.findByRole("button", { name: "select-native-login" }),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toEqual(
expect.objectContaining({
codexNativeLoginSelected: true,
meta: expect.not.objectContaining({
authBinding: expect.anything(),
}),
}),
);
});
it("requires a managed account for a legacy card when native login already exists", async () => {
const queryClient = createTestQueryClient();
const onSubmit = vi.fn();
render(
<QueryClientProvider client={queryClient}>
<ProviderForm
appId="codex"
providerId="legacy-unbound-official"
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
initialData={{
name: "Legacy OpenAI Official",
category: "official",
settingsConfig: { auth: {}, config: "" },
}}
/>
</QueryClientProvider>,
);
expect(screen.getByTestId("native-login-only")).toHaveTextContent("false");
expect(
screen.queryByRole("button", { name: "select-native-login" }),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() =>
expect(toastMocks.error).toHaveBeenCalledWith("请先选择登录方式"),
);
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.click(
screen.getByRole("button", { name: "select-managed-account" }),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0].meta?.authBinding).toEqual({
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
});
});
it("allows a legacy card to become the native-login card when none exists", async () => {
providerApiMocks.getAll.mockResolvedValueOnce({});
const queryClient = createTestQueryClient();
const onSubmit = vi.fn();
render(
<QueryClientProvider client={queryClient}>
<ProviderForm
appId="codex"
providerId="legacy-unbound-official"
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
initialData={{
name: "Legacy OpenAI Official",
category: "official",
settingsConfig: { auth: {}, config: "" },
}}
/>
</QueryClientProvider>,
);
const nativeLogin = await screen.findByRole("button", {
name: "select-native-login",
});
expect(screen.getByTestId("explicit-selection-required")).toHaveTextContent(
"true",
);
fireEvent.click(nativeLogin);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toEqual(
expect.objectContaining({
codexNativeLoginSelected: true,
meta: expect.not.objectContaining({
authBinding: expect.anything(),
}),
}),
);
});
it("blocks saving a managed account that requires reauthentication", async () => {
authState.codexReauthRequired = true;
const onSubmit = vi.fn();
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
fireEvent.click(
await screen.findByRole("button", { name: "select-managed-account" }),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() =>
expect(toastMocks.error).toHaveBeenCalledWith(
"已绑定账号不存在或需要重新登录",
),
);
expect(onSubmit).not.toHaveBeenCalled();
});
it("blocks the reauth-required default account when no account is selected", async () => {
authState.codexReauthRequired = true;
const onSubmit = vi.fn();
renderClaudeCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() =>
expect(toastMocks.error).toHaveBeenCalledWith(
"已绑定账号不存在或需要重新登录",
),
);
expect(onSubmit).not.toHaveBeenCalled();
});
});