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:
gsxdsm
2026-04-08 13:54:30 -07:00
parent 8c0d30d206
commit 8354a86a82
6 changed files with 242 additions and 7 deletions

View File

@@ -128,9 +128,22 @@ vi.mock("@fusion/engine", async (importOriginal) => {
// ── Mock @mariozechner/pi-coding-agent ──────────────────────────────
const mockAuthStorage = { getAuth: vi.fn(), setAuth: vi.fn(), getApiKey: vi.fn() };
const mockAuthStorage = {
getAuth: vi.fn(),
setAuth: vi.fn(),
getApiKey: vi.fn(),
reload: vi.fn(),
getOAuthProviders: vi.fn().mockReturnValue([{ id: "anthropic", name: "Anthropic" }]),
hasAuth: vi.fn().mockReturnValue(false),
login: vi.fn(),
logout: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
get: vi.fn(),
};
const mockModelRegistry = {
getModels: vi.fn().mockResolvedValue([]),
getAll: vi.fn().mockReturnValue([]),
registerProvider: vi.fn(),
refresh: vi.fn(),
};
@@ -173,15 +186,20 @@ describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
});
it("passes authStorage and modelRegistry to createServer", async () => {
it("passes wrapped authStorage and modelRegistry to createServer", async () => {
const { createServer } = await import("@fusion/dashboard");
await runDashboard(0, {});
expect(createServer).toHaveBeenCalledTimes(1);
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(serverOpts).toHaveProperty("authStorage", mockAuthStorage);
expect(serverOpts).toHaveProperty("modelRegistry", mockModelRegistry);
expect(serverOpts.authStorage).toBeDefined();
expect(serverOpts.authStorage).not.toBe(mockAuthStorage);
expect(serverOpts.authStorage.getApiKeyProviders).toBeTypeOf("function");
expect(serverOpts.authStorage.setApiKey).toBeTypeOf("function");
expect(serverOpts.authStorage.clearApiKey).toBeTypeOf("function");
expect(serverOpts.authStorage.hasApiKey).toBeTypeOf("function");
});
it("creates AuthStorage via AuthStorage.create()", async () => {

View File

@@ -113,6 +113,82 @@ function cleanupMergedTaskArtifacts(cwd: string, task: Pick<TaskDetail, "id" | "
}
}
type LoginCallbacks = Parameters<AuthStorage["login"]>[1];
interface DashboardAuthStorage {
reload(): void;
getOAuthProviders(): Array<{ id: string; name: string }>;
hasAuth(provider: string): boolean;
login(providerId: string, callbacks: LoginCallbacks): Promise<void>;
logout(provider: string): void;
getApiKeyProviders(): Array<{ id: string; name: string }>;
setApiKey(providerId: string, apiKey: string): void;
clearApiKey(providerId: string): void;
hasApiKey(providerId: string): boolean;
}
function getProviderDisplayName(providerId: string): string {
const knownProviderNames: Record<string, string> = {
openrouter: "OpenRouter",
"kimi-coding": "Kimi",
};
if (knownProviderNames[providerId]) {
return knownProviderNames[providerId];
}
return providerId
.split(/[-_]+/)
.filter(Boolean)
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(" ");
}
function wrapAuthStorageWithApiKeyProviders(
authStorage: AuthStorage,
modelRegistry: ModelRegistry,
): DashboardAuthStorage {
return {
reload: () => authStorage.reload(),
getOAuthProviders: () =>
authStorage
.getOAuthProviders()
.map((provider) => ({ id: provider.id, name: provider.name })),
hasAuth: (provider) => authStorage.hasAuth(provider),
login: (providerId, callbacks) =>
authStorage.login(providerId as Parameters<AuthStorage["login"]>[0], callbacks),
logout: (provider) => authStorage.logout(provider),
getApiKeyProviders: () => {
const oauthProviderIds = new Set(
authStorage.getOAuthProviders().map((provider) => provider.id),
);
const providers = new Map<string, string>();
for (const model of modelRegistry.getAll()) {
const providerId = model.provider;
if (!providerId || oauthProviderIds.has(providerId) || providers.has(providerId)) {
continue;
}
providers.set(providerId, getProviderDisplayName(providerId));
}
return Array.from(providers, ([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name),
);
},
setApiKey: (providerId, apiKey) => {
authStorage.set(providerId, { type: "api_key", key: apiKey });
},
clearApiKey: (providerId) => {
authStorage.remove(providerId);
},
hasApiKey: (providerId) => {
const credential = authStorage.get(providerId);
return credential?.type === "api_key" || authStorage.hasAuth(providerId);
},
};
}
export async function processPullRequestMergeTask(
store: TaskStore,
cwd: string,
@@ -606,8 +682,16 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
//
const missionAutopilot = new MissionAutopilot(store, store.getMissionStore());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
// Start the web server with AI merge, auth, and model registry wired in
const app = createServer(store, { onMerge, authStorage, modelRegistry, automationStore, missionAutopilot });
const app = createServer(store, {
onMerge,
authStorage: dashboardAuthStorage,
modelRegistry,
automationStore,
missionAutopilot,
});
function dispose(): void {
if (disposed) return;

View File

@@ -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: [

View File

@@ -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()} />);

View File

@@ -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",

View File

@@ -2438,10 +2438,11 @@ describe("HeartbeatTriggerScheduler", () => {
eventStore.emit("agent:assigned", agent, "FN-001");
// Wait for async event handler
await new Promise((resolve) => setTimeout(resolve, 10));
// Allow asynchronous assignment listeners to run in heavily loaded test environments.
await vi.waitFor(() => {
expect(callback).toHaveBeenCalledOnce();
}, { timeout: 1000 });
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith("agent-test", "assignment", {
taskId: "FN-001",
wakeReason: "assignment",