File size: 10,579 Bytes
fd2c364 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BACKENDS_STORAGE_KEY } from "#/api/backend-registry/storage";
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import ApiKeyEntryScreen from "#/components/features/backends/api-key-entry-screen";
// ββ Mocks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const getSettingsMock = vi.fn();
vi.mock("@openhands/typescript-client/clients", () => ({
SettingsClient: vi.fn(function SettingsClientMock() {
return { getSettings: getSettingsMock };
}),
// ServerClient needed by useBackendsHealth (imported transitively)
ServerClient: vi.fn(function ServerClientMock() {
return { getServerInfo: vi.fn().mockResolvedValue({ version: "1.28.0" }) };
}),
}));
// Stub cloud org service used by ActiveBackendProvider
vi.mock("#/api/cloud/organization-service.api", () => ({
getCurrentCloudApiKey: vi.fn().mockResolvedValue({
orgId: null,
isLegacyKey: true,
}),
}));
// Capture reload calls without crashing jsdom
const reloadMock = vi.fn();
// ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function renderScreen() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>
<ApiKeyEntryScreen />
</ActiveBackendProvider>
</QueryClientProvider>,
);
}
/** Fill the required fields: name and api key. */
async function fillRequiredFields(
user: ReturnType<typeof userEvent.setup>,
{ name = "My Server", apiKey = "some-key" } = {},
) {
await user.type(screen.getByTestId("api-key-entry-name"), name);
await user.type(screen.getByTestId("api-key-entry-api-key"), apiKey);
}
// ββ Setup / teardown βββββββββββββββββββββββββββββββββββββββββββββββββ
const ORIGINAL_LOCATION = window.location;
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
getSettingsMock.mockReset();
// Replace window.location with a spy-able version
Object.defineProperty(window, "location", {
configurable: true,
value: {
...ORIGINAL_LOCATION,
origin: "http://localhost:8000",
hostname: "localhost",
reload: reloadMock,
},
});
reloadMock.mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.unstubAllEnvs();
Object.defineProperty(window, "location", {
configurable: true,
value: ORIGINAL_LOCATION,
});
});
// ββ Tests ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
describe("ApiKeyEntryScreen", () => {
// @spec β UI: name + host (disabled) + api key + connect
it("renders name, host (disabled), api key, and connect button", () => {
renderScreen();
// Name field
const nameInput = screen.getByTestId("api-key-entry-name");
expect(nameInput).toBeInTheDocument();
expect(nameInput).toHaveValue("");
// Host field β pre-filled from window.location.origin, disabled
const hostInput = screen.getByTestId("api-key-entry-host");
expect(hostInput).toBeInTheDocument();
expect(hostInput).toBeDisabled();
expect(hostInput).toHaveValue("http://localhost:8000");
// API key field
expect(screen.getByTestId("api-key-entry-api-key")).toBeInTheDocument();
// Connect button
expect(screen.getByTestId("api-key-entry-submit")).toBeInTheDocument();
});
// @spec β API key field always starts empty (stale key wipe)
it("starts with an empty api key even when localStorage has a stale key", () => {
// Seed the backend registry with the stale key
window.localStorage.setItem(
BACKENDS_STORAGE_KEY,
JSON.stringify([
{
id: "default-local",
name: "Local",
host: "http://localhost:8000",
apiKey: "old-stale-key-from-previous-session",
kind: "local",
},
]),
);
renderScreen();
expect(screen.getByTestId("api-key-entry-api-key")).toHaveValue("");
});
// @spec β Connect button requires both name and api key
it("disables Connect when name or api key is empty", async () => {
renderScreen();
const user = userEvent.setup();
const submit = screen.getByTestId("api-key-entry-submit");
// Both empty
expect(submit).toBeDisabled();
// Only api key filled β still disabled
await user.type(screen.getByTestId("api-key-entry-api-key"), "key");
expect(submit).toBeDisabled();
// Name also filled β enabled
await user.type(screen.getByTestId("api-key-entry-name"), "Server");
expect(submit).not.toBeDisabled();
});
// @spec β Valid key: validates against GET /api/settings, persists, reloads
it("validates the key before persisting and reloading", async () => {
getSettingsMock.mockResolvedValueOnce({ llm_model: "test" });
renderScreen();
const user = userEvent.setup();
await fillRequiredFields(user, { apiKey: "correct-key" });
await user.click(screen.getByTestId("api-key-entry-submit"));
await waitFor(() => {
expect(getSettingsMock).toHaveBeenCalledTimes(1);
});
// Key persisted to backend registry storage
const stored = JSON.parse(
window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]",
);
expect(stored[0].apiKey).toBe("correct-key");
// Page reloaded
expect(reloadMock).toHaveBeenCalled();
});
// @spec β 401 shows "Invalid API key", does NOT persist or reload
it("shows 'Invalid API key' when the key is rejected with 401", async () => {
getSettingsMock.mockRejectedValueOnce(
Object.assign(new Error("Unauthorized"), {
name: "HttpError",
status: 401,
}),
);
renderScreen();
const user = userEvent.setup();
await fillRequiredFields(user, { apiKey: "wrong-key" });
await user.click(screen.getByTestId("api-key-entry-submit"));
// Error status appears with "Invalid" text
await waitFor(() => {
expect(screen.getByTestId("api-key-entry-status")).toBeInTheDocument();
});
expect(screen.getByTestId("api-key-entry-status")).toHaveClass(
"text-red-400",
);
expect(screen.getByTestId("api-key-entry-status").textContent).toContain(
"AUTH$INVALID_KEY",
);
// Rejected key NOT persisted.
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toContain(
"wrong-key",
);
// Page NOT reloaded
expect(reloadMock).not.toHaveBeenCalled();
});
// @spec β Non-401 errors show the actual error message, not "Invalid key"
it("shows 'Connection failed' with detail for non-auth errors (e.g. 500)", async () => {
getSettingsMock.mockRejectedValueOnce(
Object.assign(new Error("HTTP 500: Internal Server Error"), {
name: "HttpError",
status: 500,
}),
);
renderScreen();
const user = userEvent.setup();
await fillRequiredFields(user, { apiKey: "correct-key" });
await user.click(screen.getByTestId("api-key-entry-submit"));
await waitFor(() => {
expect(screen.getByTestId("api-key-entry-status")).toBeInTheDocument();
});
const statusText =
screen.getByTestId("api-key-entry-status").textContent ?? "";
// Shows "Connection failed" prefix, NOT "Invalid API key"
expect(statusText).toContain("AUTH$CONNECTION_FAILED");
expect(statusText).toContain("500");
expect(statusText).not.toContain("AUTH$INVALID_KEY");
// Rejected key NOT persisted.
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toContain(
"correct-key",
);
expect(reloadMock).not.toHaveBeenCalled();
});
// @spec β Retry flow: wrong key β error β correct key β success
it("allows retry after a failed attempt", async () => {
getSettingsMock
.mockRejectedValueOnce(
Object.assign(new Error("Unauthorized"), {
name: "HttpError",
status: 401,
}),
)
.mockResolvedValueOnce({ llm_model: "test" });
renderScreen();
const user = userEvent.setup();
const apiKeyInput = screen.getByTestId("api-key-entry-api-key");
// First attempt β wrong key
await fillRequiredFields(user, { apiKey: "wrong-key" });
await user.click(screen.getByTestId("api-key-entry-submit"));
await waitFor(() => {
expect(screen.getByTestId("api-key-entry-status")).toHaveClass(
"text-red-400",
);
});
// Retry β correct key
await user.clear(apiKeyInput);
await user.type(apiKeyInput, "correct-key");
await user.click(screen.getByTestId("api-key-entry-submit"));
await waitFor(() => {
expect(reloadMock).toHaveBeenCalled();
});
const stored = JSON.parse(
window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]",
);
expect(stored[0].apiKey).toBe("correct-key");
});
// @spec β Stale key in localStorage does not contaminate the new key
it("persists only the freshly-entered key, not the stale one", async () => {
getSettingsMock.mockResolvedValueOnce({ llm_model: "test" });
renderScreen();
const user = userEvent.setup();
// API key field is empty β stale key not visible
const apiKeyInput = screen.getByTestId("api-key-entry-api-key");
expect(apiKeyInput).toHaveValue("");
// Enter fresh key
await fillRequiredFields(user, { apiKey: "fresh-key-BBBB" });
await user.click(screen.getByTestId("api-key-entry-submit"));
await waitFor(() => {
expect(reloadMock).toHaveBeenCalled();
});
// Stored key is the NEW one, not old + new concatenated
const stored = JSON.parse(
window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]",
);
expect(stored[0].apiKey).toBe("fresh-key-BBBB");
});
});
|