Files
cc-switch/tests/components/EditProviderDialog.test.tsx
Thefool bbe8bb93ab fix(codex): reconcile edit form key with live bearer token (#6534)
* fix(codex): keep provider's own key on switch-away backfill

When switching away from a Codex provider, cc-switch backfills the
outgoing provider's stored config from the live ~/.codex files so that
in-app changes are captured. restore_codex_provider_token_for_backfill
decides what to persist as the provider's auth.OPENAI_API_KEY:

- When the live config.toml carries a per-provider
  experimental_bearer_token, that token is provider-scoped and is lifted
  back into the stored auth. Correct.

- When it does NOT (the default preserve_codex_official_auth_on_switch
  = false mode keeps the active key in the shared auth.json), the
  function returned early and the caller adopted the live auth.json
  wholesale as the provider's stored auth.

The problem: auth.json is a single-slot shared file with no provider
identity; it always holds the most-recently-activated provider's key.
Any time it holds ANOTHER provider's key (after a proxy-takeover
backup/restore cycle, an in-app ChatGPT login overwriting auth.json,
a cloud-sync current/live divergence, or any current-vs-live mismatch),
the switch-away backfill overwrote the outgoing provider's stored key
with another's. Repeated switches made keys silently converge across
providers that share a base URL, surfacing as "model xxx not found"
because the wrong key reached a model it was not entitled to.

Fix: in the no-bearer branch, preserve the provider's own DB-stored
auth instead of adopting the shared live auth.json. Live config.toml
changes (model/base_url/mcp/env) are still captured by the caller; only
the credential slot is no longer taken from the shared file. This
mirrors the #6277 restore-side rule of never letting the shared live
credential clobber per-provider storage.

Adds two regression tests:
- backfill_keeps_provider_own_key_when_live_auth_holds_another_key
  (the failing case under the old code)
- backfill_restores_key_from_live_bearer_token_when_present
  (positive control for the already-correct bearer path)

Fixes #6414.

* Revert "fix(codex): keep provider's own key on switch-away backfill"

This reverts commit 4ddf304eca458a1eadcf93f5851579c7dde94828.

* fix(codex): reconcile edit form key with live bearer token

In bearer-token mode (preserveCodexOfficialAuthOnSwitch enabled — used to
keep a ChatGPT login while routing third-party providers), switching to a
provider writes its key to config.toml's experimental_bearer_token and
preserves the shared ~/.codex/auth.json. auth.json is a single shared slot
with no provider identity, so it may hold ANOTHER provider's stale key
(left over from a previous default-mode switch or a different provider).

When the user then opened the edit dialog for the current provider,
EditProviderDialog loaded liveSettings as the form base, and
useCodexConfigState initialized codexAuth from the stale auth.json while
pickCodexApiKey preferred auth.OPENAI_API_KEY over the bearer token. The
form therefore displayed — and on save persisted — the stale key back
into the provider's DB record. Repeated edits made keys silently converge
across providers that share a base URL, surfacing as "model xxx not found"
once the wrong key reached a model it was not entitled to (#6414).

Fix: when loading Codex config into the form, lift the config's
experimental_bearer_token into auth.OPENAI_API_KEY (when present and
differing) so both the displayed key and the saved auth carry the
correct per-provider key. This mirrors the backend
restore_codex_provider_token_for_backfill lift and is a no-op when the
config has no bearer (default mode keeps auth.json as the active key
slot, and manual live auth edits are preserved exactly — covered by
existing tests).

Adds 4 regression tests in tests/hooks/useCodexConfigState.bearer.test.ts.

Fixes #6414.

* style: prettier-format useCodexConfigState.ts

Fixes the frontend CI "Check formatting" failure on the previous commit.
Pure formatting (collapses a multi-line call to one line); no behavior
change.

* fix(codex): scope bearer reconciliation to live edits
2026-08-26 17:21:30 +08:00

563 lines
16 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 type { Provider } from "@/types";
const apiMocks = vi.hoisted(() => ({
getCurrent: vi.fn(),
getLiveProviderSettings: vi.fn(),
getOpenClawLiveProvider: vi.fn(),
}));
let mockFormReady = true;
let mockCodexManagedAccountSelected = false;
let submitReadyCallbacks: Array<(isReady: boolean) => void> = [];
vi.mock("@/lib/api", () => ({
providersApi: {
getCurrent: apiMocks.getCurrent,
},
vscodeApi: {
getLiveProviderSettings: apiMocks.getLiveProviderSettings,
},
openclawApi: {
getLiveProvider: apiMocks.getOpenClawLiveProvider,
},
}));
vi.mock("@/components/common/FullScreenPanel", () => ({
FullScreenPanel: ({
isOpen,
children,
footer,
}: {
isOpen: boolean;
children: React.ReactNode;
footer?: React.ReactNode;
}) =>
isOpen ? (
<div>
<div>{children}</div>
<div>{footer}</div>
</div>
) : null,
}));
vi.mock("@/components/providers/forms/ProviderForm", () => ({
ProviderForm: ({
initialData,
onSubmit,
onSubmitReadyChange,
onManageAuthAccounts,
isProxyTakeover,
}: {
initialData: {
name?: string;
websiteUrl?: string;
notes?: string;
settingsConfig?: Record<string, unknown>;
meta?: Record<string, unknown>;
icon?: string;
iconColor?: string;
};
onSubmit: (values: {
name: string;
websiteUrl: string;
notes?: string;
settingsConfig: string;
meta?: Record<string, unknown>;
icon?: string;
iconColor?: string;
}) => void;
onSubmitReadyChange?: (isReady: boolean) => void;
onManageAuthAccounts?: (target: "codex_oauth") => void;
isProxyTakeover?: boolean;
appId?: string;
}) => {
useEffect(() => {
if (onSubmitReadyChange) {
submitReadyCallbacks.push(onSubmitReadyChange);
onSubmitReadyChange(mockFormReady);
}
}, [onSubmitReadyChange]);
return (
<form
id="provider-form"
onSubmit={(event) => {
event.preventDefault();
onSubmit({
name: initialData.name ?? "",
websiteUrl: initialData.websiteUrl ?? "",
notes: initialData.notes,
settingsConfig: JSON.stringify(initialData.settingsConfig ?? {}),
meta: mockCodexManagedAccountSelected
? {
...(initialData.meta ?? {}),
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
}
: initialData.meta,
icon: initialData.icon,
iconColor: initialData.iconColor,
});
}}
>
<output data-testid="settings-config">
{JSON.stringify(initialData.settingsConfig ?? {})}
</output>
<output data-testid="is-proxy-takeover">
{isProxyTakeover ? "true" : "false"}
</output>
<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,
}));
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
describe("EditProviderDialog", () => {
beforeEach(() => {
mockFormReady = true;
mockCodexManagedAccountSelected = false;
submitReadyCallbacks = [];
apiMocks.getCurrent.mockReset();
apiMocks.getLiveProviderSettings.mockReset();
apiMocks.getOpenClawLiveProvider.mockReset();
});
it("保留 Codex 数据库中的 modelCatalog,避免 live 配置缺字段时清空模型映射", async () => {
const dbModelCatalog = {
models: [
{
model: "deepseek-v4-flash",
displayName: "DeepSeek V4 Flash",
contextWindow: 1000000,
},
],
};
const provider: Provider = {
id: "deepseek",
name: "DeepSeek",
category: "aggregator",
settingsConfig: {
auth: {
OPENAI_API_KEY: "db-key",
},
config: 'model_provider = "custom"\nmodel = "deepseek-v4-flash"\n',
modelCatalog: dbModelCatalog,
},
};
const liveSettings = {
auth: {
OPENAI_API_KEY: "live-key",
},
config: 'model_provider = "custom"\nmodel = "deepseek-v4-pro"\n',
};
const handleSubmit = vi.fn().mockResolvedValue(undefined);
apiMocks.getCurrent.mockResolvedValue(provider.id);
apiMocks.getLiveProviderSettings.mockResolvedValue(liveSettings);
render(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={handleSubmit}
appId="codex"
/>,
);
await waitFor(() => {
expect(
JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"),
).toEqual({
...liveSettings,
modelCatalog: dbModelCatalog,
});
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
expect(handleSubmit.mock.calls[0][0].provider.settingsConfig).toEqual({
...liveSettings,
modelCatalog: dbModelCatalog,
});
});
it("uses the current Codex live bearer with the stored provider auth template", async () => {
const provider: Provider = {
id: "provider-a",
name: "Provider A",
category: "custom",
settingsConfig: {
auth: {
OPENAI_API_KEY: "sk-db-stale",
provider_note: "keep-me",
},
config:
'model_provider = "custom"\n[model_providers.custom]\nbase_url = "https://proxy.example/v1"\n',
},
};
const liveSettings = {
// Shared auth.json belongs to another provider / official login cache.
auth: {
OPENAI_API_KEY: "sk-shared-other-provider",
tokens: { account_id: "shared-account" },
},
config:
'model_provider = "custom"\n[model_providers.custom]\nbase_url = "https://proxy.example/v1"\nexperimental_bearer_token = "sk-provider-a"\n',
};
const handleSubmit = vi.fn().mockResolvedValue(undefined);
apiMocks.getCurrent.mockResolvedValue(provider.id);
apiMocks.getLiveProviderSettings.mockResolvedValue(liveSettings);
render(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={handleSubmit}
appId="codex"
/>,
);
const expectedSettings = {
...liveSettings,
auth: {
OPENAI_API_KEY: "sk-provider-a",
provider_note: "keep-me",
},
};
await waitFor(() => {
expect(
JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"),
).toEqual(expectedSettings);
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
expect(handleSubmit.mock.calls[0][0].provider.settingsConfig).toEqual(
expectedSettings,
);
});
it("does not convert an OAuth-only Codex provider into an API-key provider", async () => {
const provider: Provider = {
id: "oauth-provider",
name: "OAuth Provider",
category: "custom",
settingsConfig: {
auth: {
auth_mode: "chatgpt",
tokens: { account_id: "stored-account" },
},
config: 'model_provider = "custom"\n',
},
};
const liveSettings = {
auth: {
auth_mode: "chatgpt",
tokens: { account_id: "live-account" },
},
config:
'model_provider = "custom"\nexperimental_bearer_token = "sk-route-only"\n',
};
apiMocks.getCurrent.mockResolvedValue(provider.id);
apiMocks.getLiveProviderSettings.mockResolvedValue(liveSettings);
render(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
appId="codex"
/>,
);
await waitFor(() => {
expect(
JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"),
).toEqual(liveSettings);
});
});
it("does not let a stored bearer override a non-current Codex provider auth", async () => {
const provider: Provider = {
id: "provider-a",
name: "Provider A",
category: "custom",
settingsConfig: {
auth: { OPENAI_API_KEY: "sk-db-authoritative" },
config:
'model_provider = "custom"\nexperimental_bearer_token = "sk-leftover-live"\n',
},
};
const handleSubmit = vi.fn().mockResolvedValue(undefined);
apiMocks.getCurrent.mockResolvedValue("provider-b");
render(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={handleSubmit}
appId="codex"
/>,
);
await waitFor(() => expect(apiMocks.getCurrent).toHaveBeenCalledTimes(1));
expect(apiMocks.getLiveProviderSettings).not.toHaveBeenCalled();
expect(
JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"),
).toEqual(provider.settingsConfig);
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
expect(handleSubmit.mock.calls[0][0].provider.settingsConfig).toEqual(
provider.settingsConfig,
);
});
it("代理接管中编辑 Codex 供应商时展示数据库配置而不是读取 live 代理配置", async () => {
const provider: Provider = {
id: "deepseek",
name: "DeepSeek",
category: "custom",
settingsConfig: {
auth: {
OPENAI_API_KEY: "db-key",
},
config:
'model_provider = "custom"\n[model_providers.custom]\nbase_url = "https://api.deepseek.com/v1"\n',
},
};
apiMocks.getCurrent.mockResolvedValue(provider.id);
apiMocks.getLiveProviderSettings.mockResolvedValue({
auth: {
OPENAI_API_KEY: "PROXY_MANAGED",
},
config:
'model_provider = "custom"\n[model_providers.custom]\nbase_url = "http://127.0.0.1:15721/v1"\nexperimental_bearer_token = "PROXY_MANAGED"\n',
});
render(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
appId="codex"
isProxyTakeover
/>,
);
await waitFor(() => {
expect(screen.getByTestId("is-proxy-takeover").textContent).toBe("true");
});
expect(apiMocks.getLiveProviderSettings).not.toHaveBeenCalled();
expect(
JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"),
).toEqual(provider.settingsConfig);
});
it("clears the nested auth panel before the dialog reopens", async () => {
const provider: Provider = {
id: "official",
name: "OpenAI Official",
settingsConfig: { auth: {}, config: "" },
};
const props = {
provider,
onOpenChange: vi.fn(),
onSubmit: vi.fn(),
appId: "codex" as const,
};
const { rerender } = render(<EditProviderDialog open {...props} />);
fireEvent.click(screen.getByRole("button", { name: "manage-auth" }));
expect(screen.getByTestId("auth-settings-panel")).toHaveTextContent(
"codex_oauth",
);
rerender(<EditProviderDialog open={false} {...props} />);
rerender(<EditProviderDialog open {...props} />);
await waitFor(() => {
expect(
screen.queryByTestId("auth-settings-panel"),
).not.toBeInTheDocument();
});
});
it("keeps an unbound Codex Official provider ID unchanged", async () => {
apiMocks.getCurrent.mockResolvedValue(null);
const onSubmit = vi.fn();
const provider: Provider = {
id: "legacy-unbound-official",
name: "Legacy OpenAI Official",
category: "official",
settingsConfig: { auth: {}, config: "" },
};
render(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={onSubmit}
appId="codex"
/>,
);
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
originalId: "legacy-unbound-official",
provider: expect.objectContaining({ id: "legacy-unbound-official" }),
}),
);
});
it("keeps the fixed Codex provider ID when an account is bound", async () => {
mockCodexManagedAccountSelected = true;
apiMocks.getCurrent.mockResolvedValue(null);
const onSubmit = vi.fn();
const provider: Provider = {
id: "codex-official",
name: "OpenAI Official",
category: "official",
settingsConfig: { auth: {}, config: "" },
};
render(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={onSubmit}
appId="codex"
/>,
);
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const submitted = onSubmit.mock.calls[0][0];
expect(submitted.originalId).toBe("codex-official");
expect(submitted.provider.id).toBe("codex-official");
expect(submitted.provider.meta?.authBinding).toEqual({
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
});
});
it("编辑 Pi 供应商时保留通用元数据", async () => {
const provider: Provider = {
id: "pi-provider",
name: "Pi Provider",
settingsConfig: {
baseUrl: "https://api.example.com/v1",
models: [{ id: "model" }],
},
meta: {
isPartner: true,
endpointAutoSelect: true,
custom_endpoints: {
"https://failover.example.com/v1": {
url: "https://failover.example.com/v1",
addedAt: 1,
},
},
},
};
const handleSubmit = vi.fn().mockResolvedValue(undefined);
render(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={handleSubmit}
appId="pi"
/>,
);
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
expect(handleSubmit.mock.calls[0][0].provider.meta).toMatchObject({
isPartner: true,
});
expect(handleSubmit.mock.calls[0][0]).not.toHaveProperty(
"expectedSettingsConfig",
);
});
it("重新打开 Pi 编辑表单后忽略上一轮的就绪回调", async () => {
const provider: Provider = {
id: "pi-provider",
name: "Pi Provider",
settingsConfig: { models: [{ id: "model" }] },
};
const props = {
provider,
onOpenChange: vi.fn(),
onSubmit: vi.fn(),
appId: "pi" as const,
};
const { rerender } = render(<EditProviderDialog open {...props} />);
const saveButton = await screen.findByRole("button", {
name: "common.save",
});
await waitFor(() => expect(saveButton).toBeEnabled());
const staleCallback = submitReadyCallbacks.at(-1);
expect(staleCallback).toBeDefined();
rerender(<EditProviderDialog open={false} {...props} />);
mockFormReady = false;
rerender(<EditProviderDialog open {...props} />);
const reopenedButton = await screen.findByRole("button", {
name: "common.save",
});
await waitFor(() => expect(reopenedButton).toBeDisabled());
act(() => staleCallback?.(true));
expect(reopenedButton).toBeDisabled();
});
});