diff --git a/.changeset/fn-8713-auth-status-query.md b/.changeset/fn-8713-auth-status-query.md new file mode 100644 index 0000000000..d16cb3ca37 --- /dev/null +++ b/.changeset/fn-8713-auth-status-query.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep auth status available when credential-instance query data is absent. +category: fix +dev: Treats dangling requested instances as unauthenticated without credential fallback. diff --git a/packages/dashboard/src/__tests__/register-auth-routes-hermes-additive.test.ts b/packages/dashboard/src/__tests__/register-auth-routes-hermes-additive.test.ts index 487e5c1270..53cb2b2e41 100644 --- a/packages/dashboard/src/__tests__/register-auth-routes-hermes-additive.test.ts +++ b/packages/dashboard/src/__tests__/register-auth-routes-hermes-additive.test.ts @@ -1,4 +1,7 @@ /* +FNXC:ProviderAuth 2026-08-01-19:10: +FN-8713 requires auth-status direct-handler fixtures with no `query` to behave like an empty Express query. A well-formed requested credential instance that is absent must report only that provider as unauthenticated, never borrow its default or sibling credential. + FNXC:ProviderAuth 2026-07-07-08:20: FN-7630 (GitHub #1931) item 3 coordination: the Hermes Runtime connection must never CAUSE the /api/auth/status provider surface to shrink. The @@ -15,7 +18,11 @@ import { describe, expect, it, vi } from "vitest"; import type { Router } from "express"; import { registerAuthRoutes } from "../routes/register-auth-routes.js"; -function setup(oauthProviders: Array<{ id: string; name: string }>, apiKeyProviders: Array<{ id: string; name: string }>) { +function setup( + oauthProviders: Array<{ id: string; name: string }>, + apiKeyProviders: Array<{ id: string; name: string }>, + authStorageOverrides: Record = {}, +) { const getHandlers = new Map void }) => Promise>(); const postHandlers = new Map(); const router = { @@ -36,6 +43,7 @@ function setup(oauthProviders: Array<{ id: string; name: string }>, apiKeyProvid hasAuth: vi.fn(() => false), hasApiKey: vi.fn(() => false), get: vi.fn(() => undefined), + ...authStorageOverrides, }; const rethrowAsApiError = (err: unknown) => { @@ -58,10 +66,17 @@ function setup(oauthProviders: Array<{ id: string; name: string }>, apiKeyProvid return { handler: getHandlers.get("/auth/status")!, authStorage }; } -async function callStatus(handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) { +async function callStatus( + handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise, + req: unknown = { headers: {} }, +) { const json = vi.fn(); - await handler({ headers: {} }, { json }); - return json.mock.calls[0][0] as { providers: Array<{ id: string; name: string }> }; + await handler(req, { json }); + return json.mock.calls[0][0] as { + providers: Array<{ id: string; name: string; authenticated: boolean; instanceId?: string; instances?: unknown[] }>; + ghCli: { available: boolean; authenticated: boolean }; + gitCli: { available: boolean }; + }; } describe("FN-7630: Hermes runtime additive — /api/auth/status", () => { @@ -105,4 +120,48 @@ describe("FN-7630: Hermes runtime additive — /api/auth/status", () => { expect(withHermes.providers.some((p) => p.id === id)).toBe(true); } }); + + it("treats omitted or empty query data as no selection while valid and dangling targets stay isolated", async () => { + const { handler } = setup( + [{ id: "github-copilot", name: "GitHub Copilot" }], + [{ id: "openai", name: "OpenAI" }], + { + hasApiKey: vi.fn(() => true), + getDefaultInstance: vi.fn((providerId: string) => providerId === "openai" + ? { providerId, instanceId: "default" } + : undefined), + listInstances: vi.fn((providerId: string) => providerId === "openai" + ? [{ providerId, instanceId: "default" }, { providerId, instanceId: "work" }] + : []), + getInstance: vi.fn((ref: { providerId: string; instanceId: string }) => ref.providerId === "openai" && ["default", "work"].includes(ref.instanceId) + ? { type: "api_key", key: ref.instanceId === "work" ? "sk-work-12345678" : "sk-default-87654321" } + : undefined), + }, + ); + + for (const query of [undefined, {}, { provider: undefined, instance: undefined }, { provider: "openai", instance: " " }]) { + const response = await callStatus(handler, query === undefined ? { headers: {} } : { headers: {}, query }); + expect(response.providers.map((provider) => provider.id)).toEqual(expect.arrayContaining(["github-copilot", "openai"])); + expect(response.ghCli).toEqual(expect.objectContaining({ available: expect.any(Boolean) })); + expect(response.gitCli).toEqual(expect.objectContaining({ available: expect.any(Boolean) })); + } + + const defaultTarget = await callStatus(handler, { headers: {}, query: { provider: "openai", instance: "default" } }); + expect(defaultTarget.providers.find((provider) => provider.id === "openai")).toMatchObject({ authenticated: true, instanceId: "default" }); + + const validTarget = await callStatus(handler, { headers: {}, query: { provider: "openai", instance: "work" } }); + expect(validTarget.providers.find((provider) => provider.id === "openai")).toMatchObject({ authenticated: true, instanceId: "work" }); + expect(validTarget.providers.find((provider) => provider.id === "github-copilot")).toMatchObject({ authenticated: false }); + + const danglingTarget = await callStatus(handler, { headers: {}, query: { provider: "openai", instance: "missing" } }); + expect(danglingTarget.providers.find((provider) => provider.id === "openai")).toMatchObject({ + authenticated: false, + instanceId: "missing", + instances: [], + }); + expect(danglingTarget.providers.find((provider) => provider.id === "github-copilot")).toMatchObject({ authenticated: false }); + + await expect(callStatus(handler, { headers: {}, query: { instance: "work" } })).rejects.toThrow("instance requires provider"); + await expect(callStatus(handler, { headers: {}, query: { provider: "openai", instance: "invalid instance" } })).rejects.toThrow("instance must be a valid provider instance id"); + }); }); diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index a2a949e5d4..30e2064ae1 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -636,8 +636,13 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { try { const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined; const storage = getAuthStorage(); - const requestedProvider = typeof req.query.provider === "string" ? req.query.provider : undefined; - const rawRequestedInstance = typeof req.query.instance === "string" ? req.query.instance : undefined; + /* + FNXC:ProviderAuth 2026-08-01-19:10: + FN-8713: Express normally supplies `req.query`, but direct route consumers can omit it. Treat an absent query as empty while retaining instance validation; a well-formed dangling instance stays unauthenticated below and must never fall back to the provider default. + */ + const query = req.query ?? {}; + const requestedProvider = typeof query.provider === "string" ? query.provider : undefined; + const rawRequestedInstance = typeof query.instance === "string" ? query.instance : undefined; if (rawRequestedInstance?.trim() && !requestedProvider) throw badRequest("instance requires provider"); if (rawRequestedInstance?.trim() && !isValidProviderInstanceId(rawRequestedInstance.trim())) throw badRequest("instance must be a valid provider instance id"); if (rawRequestedInstance?.trim() && requestedProvider && syntheticCliProviderIds.has(requestedProvider)) throw badRequest("CLI providers do not support credential instances");