feat(FN-1196): support API-key providers in dashboard auth flow
- Wrap dashboard auth storage with API-key provider helpers derived from model registry providers - Normalize provider display names and bridge set/clear/has API-key operations to AuthStorage credentials - Expand route and onboarding tests for mixed OAuth/API-key states and API-key-authenticated setup paths - Stabilize assignment-trigger heartbeat timing test by replacing fixed delays with waitFor assertions
This commit is contained in:
@@ -584,6 +584,27 @@ describe("App auto-open Settings on unauthenticated", () => {
|
||||
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats authenticated API-key providers as valid auth for onboarding checks", async () => {
|
||||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" },
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
|
||||
],
|
||||
});
|
||||
(fetchGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
defaultProvider: "openrouter",
|
||||
defaultModelId: "gpt-4o",
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(screen.queryByText("Settings")).toBeNull();
|
||||
expect(screen.queryByText("Set Up AI Provider")).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-opens onboarding when providers are authenticated but default model is missing", async () => {
|
||||
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
providers: [
|
||||
|
||||
@@ -93,6 +93,18 @@ describe("ModelOnboardingModal", () => {
|
||||
expect(screen.getByTestId("onboarding-apikey-save-openai")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders OAuth and API key providers at the same time", async () => {
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
||||
expect(screen.getByText("OpenAI")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Login")).toBeTruthy();
|
||||
expect(screen.getByTestId("onboarding-apikey-save-openai")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables Continue button when no providers are authenticated", async () => {
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
@@ -103,6 +115,30 @@ describe("ModelOnboardingModal", () => {
|
||||
expect(screen.getByText("Continue →").closest("button")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("supports mixed auth states across OAuth and API key providers", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
|
||||
{ id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" },
|
||||
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("onboarding-auth-status-openrouter")).toBeTruthy();
|
||||
expect(screen.getByTestId("onboarding-auth-status-openai")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("onboarding-auth-status-openrouter").textContent).toContain("Key saved");
|
||||
expect(screen.getByTestId("onboarding-auth-status-openai").textContent).toContain("No API key");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Choose Default Model")).toBeTruthy();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it("initiates OAuth login when Login is clicked", async () => {
|
||||
const mockWindowOpen = vi.fn();
|
||||
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
|
||||
@@ -137,6 +173,41 @@ describe("ModelOnboardingModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-advances to model selection after API key authentication", async () => {
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
|
||||
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
|
||||
{ id: "openai", name: "OpenAI", authenticated: true, type: "api_key" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("onboarding-apikey-input-openai"), {
|
||||
target: { value: "sk-api-key" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSaveApiKey).toHaveBeenCalledWith("openai", "sk-api-key");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Choose Default Model")).toBeTruthy();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
it("shows Save button as disabled when API key input is empty", async () => {
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -2440,6 +2440,31 @@ describe("GET /auth/status", () => {
|
||||
expect(authStorage.reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes oauth and model-registry-derived API key providers in one response", async () => {
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
{ id: "github-copilot", name: "GitHub Copilot" },
|
||||
]);
|
||||
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "openrouter", name: "OpenRouter" },
|
||||
{ id: "kimi-coding", name: "Kimi" },
|
||||
{ id: "acme-extension", name: "Acme Extension" },
|
||||
]);
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic");
|
||||
(authStorage.hasApiKey as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "acme-extension");
|
||||
|
||||
const res = await GET(buildApp(), "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.providers).toEqual([
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth" },
|
||||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
|
||||
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
|
||||
{ id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns unauthenticated status", async () => {
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
@@ -2646,6 +2671,21 @@ describe("POST /auth/api-key", () => {
|
||||
expect(res.body.error).toContain("apiKey is required");
|
||||
});
|
||||
|
||||
it("accepts API key providers discovered from model registry-backed auth storage", async () => {
|
||||
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "acme-extension", name: "Acme Extension" },
|
||||
]);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "acme-extension",
|
||||
apiKey: "acme-secret-key",
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(authStorage.setApiKey).toHaveBeenCalledWith("acme-extension", "acme-secret-key");
|
||||
});
|
||||
|
||||
it("returns 400 for unknown provider", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
|
||||
provider: "unknown-provider",
|
||||
|
||||
Reference in New Issue
Block a user