mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-18 22:15:06 +08:00
* 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>
350 lines
9.6 KiB
TypeScript
350 lines
9.6 KiB
TypeScript
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from "@testing-library/react";
|
|
import { useEffect } from "react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
|
import type { ProviderFormValues } from "@/components/providers/forms/ProviderForm";
|
|
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
|
|
|
vi.mock("@/components/ui/dialog", () => ({
|
|
Dialog: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DialogHeader: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
|
<h1>{children}</h1>
|
|
),
|
|
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
|
<p>{children}</p>
|
|
),
|
|
DialogFooter: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
}));
|
|
|
|
let mockFormValues: ProviderFormValues;
|
|
let mockFormReady = true;
|
|
let submitReadyCallbacks: Array<(isReady: boolean) => void> = [];
|
|
|
|
vi.mock("@/components/providers/forms/ProviderForm", () => ({
|
|
ProviderForm: ({
|
|
onSubmit,
|
|
onSubmitReadyChange,
|
|
onManageAuthAccounts,
|
|
}: {
|
|
onSubmit: (values: ProviderFormValues) => void;
|
|
onSubmitReadyChange?: (isReady: boolean) => void;
|
|
onManageAuthAccounts?: (target: "codex_oauth") => void;
|
|
}) => {
|
|
useEffect(() => {
|
|
if (onSubmitReadyChange) {
|
|
submitReadyCallbacks.push(onSubmitReadyChange);
|
|
onSubmitReadyChange(mockFormReady);
|
|
}
|
|
}, [onSubmitReadyChange]);
|
|
return (
|
|
<form
|
|
id="provider-form"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
onSubmit(mockFormValues);
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => onManageAuthAccounts?.("codex_oauth")}
|
|
>
|
|
manage-auth
|
|
</button>
|
|
</form>
|
|
);
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/providers/AuthSettingsPanel", () => ({
|
|
AuthSettingsPanel: ({ target }: { target: string | null }) =>
|
|
target ? <div data-testid="auth-settings-panel">{target}</div> : null,
|
|
}));
|
|
|
|
describe("AddProviderDialog", () => {
|
|
beforeEach(() => {
|
|
mockFormReady = true;
|
|
submitReadyCallbacks = [];
|
|
mockFormValues = {
|
|
name: "Test Provider",
|
|
websiteUrl: "https://provider.example.com",
|
|
settingsConfig: JSON.stringify({ env: {}, config: {} }),
|
|
meta: {
|
|
custom_endpoints: {
|
|
"https://api.new-endpoint.com": {
|
|
url: "https://api.new-endpoint.com",
|
|
addedAt: 1,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
it("使用 ProviderForm 返回的自定义端点", async () => {
|
|
const handleSubmit = vi.fn().mockResolvedValue(undefined);
|
|
const handleOpenChange = vi.fn();
|
|
|
|
render(
|
|
<AddProviderDialog
|
|
open
|
|
onOpenChange={handleOpenChange}
|
|
appId="claude"
|
|
onSubmit={handleSubmit}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", {
|
|
name: "common.add",
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
|
|
|
const submitted = handleSubmit.mock.calls[0][0];
|
|
expect(submitted.meta?.custom_endpoints).toEqual(
|
|
mockFormValues.meta?.custom_endpoints,
|
|
);
|
|
expect(handleOpenChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("在缺少自定义端点时回退到配置中的 baseUrl", async () => {
|
|
const handleSubmit = vi.fn().mockResolvedValue(undefined);
|
|
|
|
mockFormValues = {
|
|
name: "Base URL Provider",
|
|
websiteUrl: "",
|
|
settingsConfig: JSON.stringify({
|
|
env: { ANTHROPIC_BASE_URL: "https://claude.base" },
|
|
config: {},
|
|
}),
|
|
};
|
|
|
|
render(
|
|
<AddProviderDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
appId="claude"
|
|
onSubmit={handleSubmit}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", {
|
|
name: "common.add",
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
|
|
|
const submitted = handleSubmit.mock.calls[0][0];
|
|
expect(submitted.meta?.custom_endpoints).toEqual({
|
|
"https://claude.base": {
|
|
url: "https://claude.base",
|
|
addedAt: expect.any(Number),
|
|
lastUsed: undefined,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("submits the managed account selected from the Codex Official preset", async () => {
|
|
const handleSubmit = vi.fn().mockResolvedValue(undefined);
|
|
const officialPresetIndex = codexProviderPresets.findIndex(
|
|
(preset) =>
|
|
preset.category === "official" && preset.providerType === "codex_oauth",
|
|
);
|
|
expect(officialPresetIndex).toBeGreaterThanOrEqual(0);
|
|
|
|
mockFormValues = {
|
|
name: "OpenAI Official",
|
|
websiteUrl: "https://chatgpt.com/codex",
|
|
settingsConfig: JSON.stringify({ auth: {}, config: "" }),
|
|
presetId: `codex-${officialPresetIndex}`,
|
|
presetCategory: "official",
|
|
meta: {
|
|
providerType: "codex_oauth",
|
|
authBinding: {
|
|
source: "managed_account",
|
|
authProvider: "codex_oauth",
|
|
accountId: "acct-managed",
|
|
},
|
|
},
|
|
};
|
|
|
|
render(
|
|
<AddProviderDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
appId="codex"
|
|
onSubmit={handleSubmit}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "common.add" }));
|
|
|
|
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
|
expect(handleSubmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
category: "official",
|
|
ensureCodexOfficialSeed: true,
|
|
meta: expect.objectContaining({
|
|
authBinding: {
|
|
source: "managed_account",
|
|
authProvider: "codex_oauth",
|
|
accountId: "acct-managed",
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("clears the nested auth panel before the dialog reopens", async () => {
|
|
const props = {
|
|
onOpenChange: vi.fn(),
|
|
appId: "codex" as const,
|
|
onSubmit: vi.fn(),
|
|
};
|
|
const { rerender } = render(<AddProviderDialog open {...props} />);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "manage-auth" }));
|
|
expect(screen.getByTestId("auth-settings-panel")).toHaveTextContent(
|
|
"codex_oauth",
|
|
);
|
|
|
|
rerender(<AddProviderDialog open={false} {...props} />);
|
|
rerender(<AddProviderDialog open {...props} />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.queryByTestId("auth-settings-panel"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("新建 Grok Build 自定义供应商时不补默认 Grok 图标", async () => {
|
|
const handleSubmit = vi.fn().mockResolvedValue(undefined);
|
|
|
|
mockFormValues = {
|
|
name: "tes 1",
|
|
websiteUrl: "",
|
|
icon: "",
|
|
iconColor: "",
|
|
settingsConfig: JSON.stringify({
|
|
config: `[models]
|
|
default = "grok-4.5"
|
|
|
|
[model."grok-4.5"]
|
|
model = "grok-4.5"
|
|
base_url = "https://grok.example.com/v1"
|
|
name = "tes 1"
|
|
api_key = "secret"
|
|
api_backend = "responses"
|
|
context_window = 500000
|
|
`,
|
|
}),
|
|
};
|
|
|
|
render(
|
|
<AddProviderDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
appId="grokbuild"
|
|
onSubmit={handleSubmit}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "common.add" }));
|
|
|
|
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
|
|
|
const submitted = handleSubmit.mock.calls[0][0];
|
|
expect(submitted.icon).toBeUndefined();
|
|
expect(submitted.iconColor).toBeUndefined();
|
|
});
|
|
|
|
it("Pi 添加供应商时仅提交供应商目录", async () => {
|
|
const handleSubmit = vi.fn().mockResolvedValue(undefined);
|
|
mockFormValues = {
|
|
name: "Pi Provider",
|
|
providerKey: "pi-provider",
|
|
websiteUrl: "",
|
|
settingsConfig: JSON.stringify({
|
|
baseUrl: "https://api.example.com/v1",
|
|
models: [
|
|
{ id: "selected-model", name: "Selected" },
|
|
{ id: "other-model", name: "Other" },
|
|
],
|
|
}),
|
|
meta: {
|
|
isPartner: true,
|
|
endpointAutoSelect: true,
|
|
custom_endpoints: {
|
|
"https://failover.example.com/v1": {
|
|
url: "https://failover.example.com/v1",
|
|
addedAt: 1,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
render(
|
|
<AddProviderDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
appId="pi"
|
|
onSubmit={handleSubmit}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "common.add" }));
|
|
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
|
expect(handleSubmit.mock.calls[0][0]).toMatchObject({
|
|
providerKey: "pi-provider",
|
|
meta: { isPartner: true },
|
|
});
|
|
expect(handleSubmit.mock.calls[0][0]).not.toHaveProperty(
|
|
"piActivateModelId",
|
|
);
|
|
});
|
|
|
|
it("重新打开 Pi 表单后忽略上一轮的就绪回调", async () => {
|
|
const props = {
|
|
onOpenChange: vi.fn(),
|
|
appId: "pi" as const,
|
|
onSubmit: vi.fn(),
|
|
};
|
|
const { rerender } = render(<AddProviderDialog open {...props} />);
|
|
|
|
const addButton = await screen.findByRole("button", { name: "common.add" });
|
|
await waitFor(() => expect(addButton).toBeEnabled());
|
|
const staleCallback = submitReadyCallbacks.at(-1);
|
|
expect(staleCallback).toBeDefined();
|
|
|
|
rerender(<AddProviderDialog open={false} {...props} />);
|
|
mockFormReady = false;
|
|
rerender(<AddProviderDialog open {...props} />);
|
|
const reopenedButton = await screen.findByRole("button", {
|
|
name: "common.add",
|
|
});
|
|
await waitFor(() => expect(reopenedButton).toBeDisabled());
|
|
|
|
act(() => staleCallback?.(true));
|
|
expect(reopenedButton).toBeDisabled();
|
|
});
|
|
});
|