feat(dashboard): per-key model restrictions, pin header routing, combo side-panel picker
- Endpoint: per-API-key model allowlist (schema v3) enforced on chat (403) and /v1/models; Full-access toggle + multi-select picker in Keys UI. - Providers: honor x-connection-id in /v1/chat/completions — pinned requests no longer rotate to another account on failure. - Providers: strategy saves merge into stored enabled:false override; Test All groups match grid sections; 1-by-1 skips disabled connections. - Dashboard: provider-card toggle syncs from server on failure; grid toggles always visible; connection rows get clear-✕ for stale error banners. - Combo editor: on desktop (xl+) the Add-Model picker opens as a floating side panel beside the untouched combo popup instead of stacking on top; mobile keeps the full-screen overlay. - Long API-key overflow fixed in key rows + provider model sections.
This commit is contained in:
115
tests/unit/chat-connection-pin.test.js
Normal file
115
tests/unit/chat-connection-pin.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Unit tests for x-connection-id pinning in src/sse/handlers/chat.js
|
||||
*
|
||||
* Covers:
|
||||
* - the dashboard per-key test header is forwarded to getProviderCredentials
|
||||
* as preferredConnectionId (same contract as embeddings/images/video)
|
||||
* - a pinned request does NOT rotate to another account on failure
|
||||
* - an unpinned request keeps rotating (regression guard)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
getProviderCredentials: vi.fn(),
|
||||
markAccountUnavailable: vi.fn(async () => ({ shouldFallback: true })),
|
||||
clearAccountError: vi.fn(async () => {}),
|
||||
extractApiKey: vi.fn(() => null),
|
||||
isValidApiKey: vi.fn(async () => true),
|
||||
}));
|
||||
const tokenMocks = vi.hoisted(() => ({
|
||||
checkAndRefreshToken: vi.fn(async (_p, creds) => creds),
|
||||
updateProviderCredentials: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@/sse/services/auth.js", () => authMocks);
|
||||
vi.mock("@/sse/services/tokenRefresh.js", () => tokenMocks);
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getSettings: vi.fn(async () => ({ requireApiKey: false })),
|
||||
getComboByName: vi.fn(async () => null),
|
||||
getModelAliases: vi.fn(async () => ({})),
|
||||
getCustomModels: vi.fn(async () => []),
|
||||
getProviderNodes: vi.fn(async () => []),
|
||||
getProviderConnections: vi.fn(async () => []),
|
||||
updateProviderCredentials: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/sse/utils/logger.js", () => ({
|
||||
info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), maskKey: (k) => k,
|
||||
}));
|
||||
// chatCore would perform the upstream call; stub success so the loop exits.
|
||||
vi.mock("open-sse/handlers/chatCore.js", () => ({
|
||||
handleChatCore: vi.fn(async ({ credentials }) => ({
|
||||
success: true,
|
||||
response: new Response(JSON.stringify({ servedBy: credentials.connectionId }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
vi.mock("open-sse/services/combo.js", () => ({
|
||||
getComboModels: vi.fn(async () => null),
|
||||
resetComboRotation: vi.fn(),
|
||||
detectRequiredCapabilities: vi.fn(() => new Set()),
|
||||
augmentModelsWithCapacityAdapter: vi.fn((m) => m),
|
||||
withCapacityAdapterStripping: vi.fn((fn) => fn),
|
||||
getActiveAdapterStrategy: vi.fn(() => "fallback"),
|
||||
}));
|
||||
|
||||
import { handleChat } from "@/sse/handlers/chat.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
const makeRequest = (body, headers = {}) =>
|
||||
new Request("http://localhost/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const account = (id) => ({
|
||||
connectionId: id,
|
||||
connectionName: `acc-${id}`,
|
||||
accessToken: "tok",
|
||||
refreshToken: "ref",
|
||||
authType: "oauth",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
authMocks.getProviderCredentials.mockReset();
|
||||
authMocks.getProviderCredentials.mockImplementation(async (_p, exclude) =>
|
||||
exclude.size === 0 ? account("conn-A") : null,
|
||||
);
|
||||
authMocks.markAccountUnavailable.mockClear();
|
||||
authMocks.clearAccountError.mockClear();
|
||||
tokenMocks.checkAndRefreshToken.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("chat x-connection-id pinning", () => {
|
||||
it("forwards x-connection-id as preferredConnectionId", async () => {
|
||||
const res = await handleChat(
|
||||
makeRequest({ model: "prov/m1", messages: [{ role: "user", content: "hi" }] }, { "x-connection-id": "conn-B" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(authMocks.getProviderCredentials).toHaveBeenCalledWith(
|
||||
"prov",
|
||||
expect.anything(),
|
||||
"m1",
|
||||
expect.objectContaining({ preferredConnectionId: "conn-B" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes null when no pin header is present", async () => {
|
||||
await handleChat(makeRequest({ model: "prov/m1", messages: [{ role: "user", content: "hi" }] }));
|
||||
expect(authMocks.getProviderCredentials).toHaveBeenCalledWith(
|
||||
"prov",
|
||||
expect.anything(),
|
||||
"m1",
|
||||
expect.objectContaining({ preferredConnectionId: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user