mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat: secrets manager settings (#8068)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
This commit is contained in:
parent
7a4ea23b9d
commit
04d585513c
565
frontend/__tests__/routes/secrets-settings.test.tsx
Normal file
565
frontend/__tests__/routes/secrets-settings.test.tsx
Normal file
@ -0,0 +1,565 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub, Outlet } from "react-router";
|
||||
import SecretsSettingsScreen from "#/routes/secrets-settings";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
|
||||
const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
|
||||
{
|
||||
name: "My_Secret_1",
|
||||
description: "My first secret",
|
||||
},
|
||||
{
|
||||
name: "My_Secret_2",
|
||||
description: "My second secret",
|
||||
},
|
||||
];
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: Outlet,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
Component: SecretsSettingsScreen,
|
||||
path: "/settings/secrets",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/git",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSecretsSettings = () =>
|
||||
render(<RouterStub initialEntries={["/settings/secrets"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
it("should render the secrets settings screen", () => {
|
||||
renderSecretsSettings();
|
||||
screen.getByTestId("secrets-settings-screen");
|
||||
});
|
||||
|
||||
it("should NOT render a button to connect with git if they havent already in oss", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
|
||||
renderSecretsSettings();
|
||||
|
||||
expect(getConfigSpy).toHaveBeenCalled();
|
||||
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a button to connect with git if they havent already in saas", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
|
||||
renderSecretsSettings();
|
||||
|
||||
expect(getSecretsSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
|
||||
);
|
||||
const button = await screen.findByTestId("connect-git-button");
|
||||
await userEvent.click(button);
|
||||
|
||||
screen.getByTestId("git-settings-screen");
|
||||
});
|
||||
|
||||
it("should render a message if there are no existing secrets", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue([]);
|
||||
renderSecretsSettings();
|
||||
|
||||
await screen.findByTestId("no-secrets-message");
|
||||
});
|
||||
|
||||
it("should render existing secrets", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
|
||||
renderSecretsSettings();
|
||||
|
||||
const secrets = await screen.findAllByTestId("secret-item");
|
||||
expect(secrets).toHaveLength(2);
|
||||
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Secret actions", () => {
|
||||
it("should create a new secret", async () => {
|
||||
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
createSecretSpy.mockResolvedValue(true);
|
||||
renderSecretsSettings();
|
||||
|
||||
// render form & hide items
|
||||
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
await userEvent.click(button);
|
||||
|
||||
const secretForm = screen.getByTestId("add-secret-form");
|
||||
const secrets = screen.queryAllByTestId("secret-item");
|
||||
|
||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
|
||||
expect(secretForm).toBeInTheDocument();
|
||||
expect(secrets).toHaveLength(0);
|
||||
|
||||
// enter details
|
||||
const nameInput = within(secretForm).getByTestId("name-input");
|
||||
const valueInput = within(secretForm).getByTestId("value-input");
|
||||
const descriptionInput =
|
||||
within(secretForm).getByTestId("description-input");
|
||||
|
||||
const submitButton = within(secretForm).getByTestId("submit-button");
|
||||
|
||||
vi.clearAllMocks(); // reset mocks to check for upcoming calls
|
||||
|
||||
await userEvent.type(nameInput, "My_Custom_Secret");
|
||||
await userEvent.type(valueInput, "my-custom-secret-value");
|
||||
await userEvent.type(descriptionInput, "My custom secret description");
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// make POST request
|
||||
expect(createSecretSpy).toHaveBeenCalledWith(
|
||||
"My_Custom_Secret",
|
||||
"my-custom-secret-value",
|
||||
"My custom secret description",
|
||||
);
|
||||
|
||||
// hide form & render items
|
||||
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
|
||||
expect(getSecretsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should edit a secret", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
const updateSecretSpy = vi.spyOn(SecretsService, "updateSecret");
|
||||
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
|
||||
updateSecretSpy.mockResolvedValue(true);
|
||||
renderSecretsSettings();
|
||||
|
||||
// render edit button within a secret list item
|
||||
const secrets = await screen.findAllByTestId("secret-item");
|
||||
const firstSecret = within(secrets[0]);
|
||||
const editButton = firstSecret.getByTestId("edit-secret-button");
|
||||
|
||||
await userEvent.click(editButton);
|
||||
|
||||
// render edit form
|
||||
const editForm = screen.getByTestId("edit-secret-form");
|
||||
|
||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
|
||||
expect(editForm).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
|
||||
|
||||
// enter details
|
||||
const nameInput = within(editForm).getByTestId("name-input");
|
||||
const descriptionInput = within(editForm).getByTestId("description-input");
|
||||
const submitButton = within(editForm).getByTestId("submit-button");
|
||||
|
||||
// should not show value input
|
||||
const valueInput = within(editForm).queryByTestId("value-input");
|
||||
expect(valueInput).not.toBeInTheDocument();
|
||||
|
||||
expect(nameInput).toHaveValue("My_Secret_1");
|
||||
expect(descriptionInput).toHaveValue("My first secret");
|
||||
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "My_Edited_Secret");
|
||||
|
||||
await userEvent.clear(descriptionInput);
|
||||
await userEvent.type(descriptionInput, "My edited secret description");
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// make POST request
|
||||
expect(updateSecretSpy).toHaveBeenCalledWith(
|
||||
"My_Secret_1",
|
||||
"My_Edited_Secret",
|
||||
"My edited secret description",
|
||||
);
|
||||
|
||||
// hide form
|
||||
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
|
||||
|
||||
// optimistic update
|
||||
const updatedSecrets = await screen.findAllByTestId("secret-item");
|
||||
expect(updatedSecrets).toHaveLength(2);
|
||||
expect(updatedSecrets[0]).toHaveTextContent(/my_edited_secret/i);
|
||||
});
|
||||
|
||||
it("should be able to cancel the create or edit form", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
|
||||
renderSecretsSettings();
|
||||
|
||||
// render form & hide items
|
||||
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
await userEvent.click(button);
|
||||
const secretForm = screen.getByTestId("add-secret-form");
|
||||
expect(secretForm).toBeInTheDocument();
|
||||
|
||||
// cancel button
|
||||
const cancelButton = within(secretForm).getByTestId("cancel-button");
|
||||
await userEvent.click(cancelButton);
|
||||
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("add-secret-button")).toBeInTheDocument();
|
||||
|
||||
// render edit button within a secret list item
|
||||
const secrets = await screen.findAllByTestId("secret-item");
|
||||
const firstSecret = within(secrets[0]);
|
||||
const editButton = firstSecret.getByTestId("edit-secret-button");
|
||||
await userEvent.click(editButton);
|
||||
|
||||
// render edit form
|
||||
const editForm = screen.getByTestId("edit-secret-form");
|
||||
expect(editForm).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
|
||||
|
||||
// cancel button
|
||||
const cancelEditButton = within(editForm).getByTestId("cancel-button");
|
||||
await userEvent.click(cancelEditButton);
|
||||
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should undo the optimistic update if the request fails", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
const updateSecretSpy = vi.spyOn(SecretsService, "updateSecret");
|
||||
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
|
||||
updateSecretSpy.mockRejectedValue(new Error("Failed to update secret"));
|
||||
renderSecretsSettings();
|
||||
|
||||
// render edit button within a secret list item
|
||||
const secrets = await screen.findAllByTestId("secret-item");
|
||||
const firstSecret = within(secrets[0]);
|
||||
const editButton = firstSecret.getByTestId("edit-secret-button");
|
||||
|
||||
await userEvent.click(editButton);
|
||||
|
||||
// render edit form
|
||||
const editForm = screen.getByTestId("edit-secret-form");
|
||||
|
||||
expect(editForm).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
|
||||
|
||||
// enter details
|
||||
const nameInput = within(editForm).getByTestId("name-input");
|
||||
const submitButton = within(editForm).getByTestId("submit-button");
|
||||
|
||||
// should not show value input
|
||||
const valueInput = within(editForm).queryByTestId("value-input");
|
||||
expect(valueInput).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "My_Edited_Secret");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// make POST request
|
||||
expect(updateSecretSpy).toHaveBeenCalledWith(
|
||||
"My_Secret_1",
|
||||
"My_Edited_Secret",
|
||||
"My first secret",
|
||||
);
|
||||
|
||||
// hide form
|
||||
expect(screen.queryByTestId("edit-secret-form")).not.toBeInTheDocument();
|
||||
|
||||
// no optimistic update
|
||||
const updatedSecrets = await screen.findAllByTestId("secret-item");
|
||||
expect(updatedSecrets).toHaveLength(2);
|
||||
expect(updatedSecrets[0]).not.toHaveTextContent(/my edited secret/i);
|
||||
});
|
||||
|
||||
it("should remove the secret from the list after deletion", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
|
||||
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
|
||||
deleteSecretSpy.mockResolvedValue(true);
|
||||
renderSecretsSettings();
|
||||
|
||||
// render delete button within a secret list item
|
||||
const secrets = await screen.findAllByTestId("secret-item");
|
||||
const secondSecret = within(secrets[1]);
|
||||
const deleteButton = secondSecret.getByTestId("delete-secret-button");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// confirmation modal
|
||||
const confirmationModal = screen.getByTestId("confirmation-modal");
|
||||
const confirmButton =
|
||||
within(confirmationModal).getByTestId("confirm-button");
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// make DELETE request
|
||||
expect(deleteSecretSpy).toHaveBeenCalledWith("My_Secret_2");
|
||||
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
|
||||
|
||||
// optimistic update
|
||||
expect(screen.queryAllByTestId("secret-item")).toHaveLength(1);
|
||||
expect(screen.queryByText("My_Secret_2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be able to cancel the delete confirmation modal", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
|
||||
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
|
||||
deleteSecretSpy.mockResolvedValue(true);
|
||||
renderSecretsSettings();
|
||||
|
||||
// render delete button within a secret list item
|
||||
const secrets = await screen.findAllByTestId("secret-item");
|
||||
const secondSecret = within(secrets[1]);
|
||||
const deleteButton = secondSecret.getByTestId("delete-secret-button");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// confirmation modal
|
||||
const confirmationModal = screen.getByTestId("confirmation-modal");
|
||||
const cancelButton = within(confirmationModal).getByTestId("cancel-button");
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
// no DELETE request
|
||||
expect(deleteSecretSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should revert the optimistic update if the request fails", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
const deleteSecretSpy = vi.spyOn(SecretsService, "deleteSecret");
|
||||
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
|
||||
deleteSecretSpy.mockRejectedValue(new Error("Failed to delete secret"));
|
||||
renderSecretsSettings();
|
||||
|
||||
// render delete button within a secret list item
|
||||
const secrets = await screen.findAllByTestId("secret-item");
|
||||
const secondSecret = within(secrets[1]);
|
||||
const deleteButton = secondSecret.getByTestId("delete-secret-button");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// confirmation modal
|
||||
const confirmationModal = screen.getByTestId("confirmation-modal");
|
||||
const confirmButton =
|
||||
within(confirmationModal).getByTestId("confirm-button");
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
// make DELETE request
|
||||
expect(deleteSecretSpy).toHaveBeenCalledWith("My_Secret_2");
|
||||
expect(screen.queryByTestId("confirmation-modal")).not.toBeInTheDocument();
|
||||
|
||||
// optimistic update
|
||||
expect(screen.queryAllByTestId("secret-item")).toHaveLength(2);
|
||||
expect(screen.queryByText("My_Secret_2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide the no items message when in form view", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue([]);
|
||||
renderSecretsSettings();
|
||||
|
||||
// render form & hide items
|
||||
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
await userEvent.click(button);
|
||||
|
||||
const secretForm = screen.getByTestId("add-secret-form");
|
||||
expect(secretForm).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not allow spaces in secret names", async () => {
|
||||
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
|
||||
renderSecretsSettings();
|
||||
|
||||
// render form & hide items
|
||||
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
await userEvent.click(button);
|
||||
|
||||
const secretForm = screen.getByTestId("add-secret-form");
|
||||
expect(secretForm).toBeInTheDocument();
|
||||
|
||||
// enter details
|
||||
const nameInput = within(secretForm).getByTestId("name-input");
|
||||
const valueInput = within(secretForm).getByTestId("value-input");
|
||||
const submitButton = within(secretForm).getByTestId("submit-button");
|
||||
|
||||
await userEvent.type(nameInput, "My Custom Secret With Spaces");
|
||||
await userEvent.type(valueInput, "my-custom-secret-value");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// make POST request
|
||||
expect(createSecretSpy).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "MyCustomSecret");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(createSecretSpy).toHaveBeenCalledWith(
|
||||
"MyCustomSecret",
|
||||
"my-custom-secret-value",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow existing secret names", async () => {
|
||||
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE.slice(0, 1));
|
||||
renderSecretsSettings();
|
||||
|
||||
// render form & hide items
|
||||
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
await userEvent.click(button);
|
||||
|
||||
const secretForm = screen.getByTestId("add-secret-form");
|
||||
expect(secretForm).toBeInTheDocument();
|
||||
|
||||
// enter details
|
||||
const nameInput = within(secretForm).getByTestId("name-input");
|
||||
const valueInput = within(secretForm).getByTestId("value-input");
|
||||
const submitButton = within(secretForm).getByTestId("submit-button");
|
||||
|
||||
await userEvent.type(nameInput, "My_Secret_1");
|
||||
await userEvent.type(valueInput, "my-custom-secret-value");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// make POST request
|
||||
expect(createSecretSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/secret already exists/i)).toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "My_Custom_Secret");
|
||||
|
||||
await userEvent.clear(valueInput);
|
||||
await userEvent.type(valueInput, "my-custom-secret-value");
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(createSecretSpy).toHaveBeenCalledWith(
|
||||
"My_Custom_Secret",
|
||||
"my-custom-secret-value",
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not submit whitespace secret names or values", async () => {
|
||||
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
|
||||
renderSecretsSettings();
|
||||
|
||||
// render form & hide items
|
||||
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
await userEvent.click(button);
|
||||
|
||||
const secretForm = screen.getByTestId("add-secret-form");
|
||||
expect(secretForm).toBeInTheDocument();
|
||||
|
||||
// enter details
|
||||
const nameInput = within(secretForm).getByTestId("name-input");
|
||||
const valueInput = within(secretForm).getByTestId("value-input");
|
||||
const submitButton = within(secretForm).getByTestId("submit-button");
|
||||
|
||||
await userEvent.type(nameInput, " ");
|
||||
await userEvent.type(valueInput, "my-custom-secret-value");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// make POST request
|
||||
expect(createSecretSpy).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "My_Custom_Secret");
|
||||
|
||||
await userEvent.clear(valueInput);
|
||||
await userEvent.type(valueInput, " ");
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(createSecretSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not reset ipout values on an invalid submit", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
|
||||
getSecretsSpy.mockResolvedValue(MOCK_GET_SECRETS_RESPONSE);
|
||||
|
||||
renderSecretsSettings();
|
||||
|
||||
// render form & hide items
|
||||
expect(screen.queryByTestId("add-secret-form")).not.toBeInTheDocument();
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
await userEvent.click(button);
|
||||
|
||||
const secretForm = screen.getByTestId("add-secret-form");
|
||||
expect(secretForm).toBeInTheDocument();
|
||||
|
||||
// enter details
|
||||
const nameInput = within(secretForm).getByTestId("name-input");
|
||||
const valueInput = within(secretForm).getByTestId("value-input");
|
||||
const submitButton = within(secretForm).getByTestId("submit-button");
|
||||
|
||||
await userEvent.type(nameInput, MOCK_GET_SECRETS_RESPONSE[0].name);
|
||||
await userEvent.type(valueInput, "my-custom-secret-value");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// make POST request
|
||||
expect(createSecretSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/secret already exists/i)).toBeInTheDocument();
|
||||
|
||||
expect(nameInput).toHaveValue(MOCK_GET_SECRETS_RESPONSE[0].name);
|
||||
expect(valueInput).toHaveValue("my-custom-secret-value");
|
||||
});
|
||||
});
|
||||
@ -79,7 +79,7 @@ describe("Settings Screen", () => {
|
||||
};
|
||||
|
||||
it("should render the navbar", async () => {
|
||||
const sectionsToInclude = ["llm", "git", "application"];
|
||||
const sectionsToInclude = ["llm", "git", "application", "secrets"];
|
||||
const sectionsToExclude = ["api keys", "credits"];
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
@ -110,7 +110,13 @@ describe("Settings Screen", () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
const sectionsToInclude = ["git", "application", "credits", "api keys"];
|
||||
const sectionsToInclude = [
|
||||
"git",
|
||||
"application",
|
||||
"credits",
|
||||
"secrets",
|
||||
"api keys",
|
||||
];
|
||||
const sectionsToExclude = ["llm"];
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
@ -111,6 +111,8 @@ const EXCLUDED_TECHNICAL_STRINGS = [
|
||||
"GitLab API", // Git provider specific terminology
|
||||
"Pull Request", // Git provider specific terminology
|
||||
"GitHub API", // Git provider specific terminology
|
||||
"add-secret-form", // Test ID for secret form
|
||||
"edit-secret-form", // Test ID for secret form
|
||||
];
|
||||
|
||||
function isExcludedTechnicalString(str) {
|
||||
|
||||
@ -1,8 +1,43 @@
|
||||
import { Provider, ProviderToken } from "#/types/settings";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { POSTProviderTokens } from "./secrets-service.types";
|
||||
import {
|
||||
CustomSecret,
|
||||
GetSecretsResponse,
|
||||
POSTProviderTokens,
|
||||
} from "./secrets-service.types";
|
||||
import { Provider, ProviderToken } from "#/types/settings";
|
||||
|
||||
export class SecretsService {
|
||||
static async getSecrets() {
|
||||
const { data } = await openHands.get<GetSecretsResponse>("/api/secrets");
|
||||
return data.custom_secrets;
|
||||
}
|
||||
|
||||
static async createSecret(name: string, value: string, description?: string) {
|
||||
const secret: CustomSecret = {
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
};
|
||||
|
||||
const { status } = await openHands.post("/api/secrets", secret);
|
||||
return status === 201;
|
||||
}
|
||||
|
||||
static async updateSecret(id: string, name: string, description?: string) {
|
||||
const secret: Omit<CustomSecret, "value"> = {
|
||||
name,
|
||||
description,
|
||||
};
|
||||
|
||||
const { status } = await openHands.put(`/api/secrets/${id}`, secret);
|
||||
return status === 200;
|
||||
}
|
||||
|
||||
static async deleteSecret(id: string) {
|
||||
const { status } = await openHands.delete<boolean>(`/api/secrets/${id}`);
|
||||
return status === 200;
|
||||
}
|
||||
|
||||
static async addGitProvider(providers: Record<Provider, ProviderToken>) {
|
||||
const tokens: POSTProviderTokens = {
|
||||
provider_tokens: providers,
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
import { Provider, ProviderToken } from "#/types/settings";
|
||||
|
||||
export type CustomSecret = {
|
||||
name: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export interface GetSecretsResponse {
|
||||
custom_secrets: Omit<CustomSecret, "value">[];
|
||||
}
|
||||
|
||||
export interface POSTProviderTokens {
|
||||
provider_tokens: Record<Provider, ProviderToken>;
|
||||
}
|
||||
|
||||
@ -0,0 +1,202 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCreateSecret } from "#/hooks/mutation/use-create-secret";
|
||||
import { useUpdateSecret } from "#/hooks/mutation/use-update-secret";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BrandButton } from "../brand-button";
|
||||
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import { OptionalTag } from "../optional-tag";
|
||||
|
||||
interface SecretFormProps {
|
||||
mode: "add" | "edit";
|
||||
selectedSecret: string | null;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function SecretForm({
|
||||
mode,
|
||||
selectedSecret,
|
||||
onCancel,
|
||||
}: SecretFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: secrets } = useGetSecrets();
|
||||
const { mutate: createSecret } = useCreateSecret();
|
||||
const { mutate: updateSecret } = useUpdateSecret();
|
||||
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const secretDescription =
|
||||
(mode === "edit" &&
|
||||
selectedSecret &&
|
||||
secrets
|
||||
?.find((secret) => secret.name === selectedSecret)
|
||||
?.description?.trim()) ||
|
||||
"";
|
||||
|
||||
const handleCreateSecret = (
|
||||
name: string,
|
||||
value: string,
|
||||
description?: string,
|
||||
) => {
|
||||
createSecret(
|
||||
{ name, value, description },
|
||||
{
|
||||
onSettled: onCancel,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["secrets"] });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const updateSecretOptimistically = (
|
||||
oldName: string,
|
||||
name: string,
|
||||
description?: string,
|
||||
) => {
|
||||
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
|
||||
["secrets"],
|
||||
(oldSecrets) => {
|
||||
if (!oldSecrets) return [];
|
||||
return oldSecrets.map((secret) => {
|
||||
if (secret.name === oldName) {
|
||||
return {
|
||||
...secret,
|
||||
name,
|
||||
description,
|
||||
};
|
||||
}
|
||||
return secret;
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const revertOptimisticUpdate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["secrets"] });
|
||||
};
|
||||
|
||||
const handleEditSecret = (
|
||||
secretToEdit: string,
|
||||
name: string,
|
||||
description?: string,
|
||||
) => {
|
||||
updateSecretOptimistically(secretToEdit, name, description);
|
||||
updateSecret(
|
||||
{ secretToEdit, name, description },
|
||||
{
|
||||
onSettled: onCancel,
|
||||
onError: revertOptimisticUpdate,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const name = formData.get("secret-name")?.toString();
|
||||
const value = formData.get("secret-value")?.toString().trim();
|
||||
const description = formData.get("secret-description")?.toString();
|
||||
|
||||
if (name) {
|
||||
setError(null);
|
||||
|
||||
const isNameAlreadyUsed = secrets?.some(
|
||||
(secret) => secret.name === name && secret.name !== selectedSecret,
|
||||
);
|
||||
if (isNameAlreadyUsed) {
|
||||
setError("Secret already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "add") {
|
||||
if (!value) {
|
||||
setError(t("SECRETS$SECRET_VALUE_REQUIRED"));
|
||||
return;
|
||||
}
|
||||
|
||||
handleCreateSecret(name, value, description || undefined);
|
||||
} else if (mode === "edit" && selectedSecret) {
|
||||
handleEditSecret(selectedSecret, name, description || undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formTestId = mode === "add" ? "add-secret-form" : "edit-secret-form";
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid={formTestId}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col items-start gap-6"
|
||||
>
|
||||
<SettingsInput
|
||||
testId="name-input"
|
||||
name="secret-name"
|
||||
type="text"
|
||||
label="Name"
|
||||
className="w-[350px]"
|
||||
required
|
||||
defaultValue={mode === "edit" && selectedSecret ? selectedSecret : ""}
|
||||
placeholder="e.g. OpenAI_API_Key"
|
||||
pattern="^\S*$"
|
||||
/>
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
{mode === "add" && (
|
||||
<label className="flex flex-col gap-2.5 w-fit">
|
||||
<span className="text-sm">Value</span>
|
||||
<textarea
|
||||
data-testid="value-input"
|
||||
name="secret-value"
|
||||
required
|
||||
className={cn(
|
||||
"resize-none w-[680px]",
|
||||
"bg-tertiary border border-[#717888] rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
rows={8}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-2.5 w-fit">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Description</span>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<input
|
||||
data-testid="description-input"
|
||||
name="secret-description"
|
||||
defaultValue={secretDescription}
|
||||
className={cn(
|
||||
"resize-none w-[680px]",
|
||||
"bg-tertiary border border-[#717888] rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<BrandButton
|
||||
testId="cancel-button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
<BrandButton testId="submit-button" type="submit" variant="primary">
|
||||
{mode === "add" && t("SECRETS$ADD_SECRET")}
|
||||
{mode === "edit" && t("SECRETS$EDIT_SECRET")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
import { FaPencil, FaTrash } from "react-icons/fa6";
|
||||
|
||||
export function SecretListItemSkeleton() {
|
||||
return (
|
||||
<div className="border-t border-[#717888] last-of-type:border-b max-w-[830px] pr-2.5 py-[13px] flex items-center justify-between">
|
||||
<div className="flex items-center justify-between w-1/3">
|
||||
<span className="skeleton h-4 w-1/2" />
|
||||
<span className="skeleton h-4 w-1/4" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<span className="skeleton h-4 w-4" />
|
||||
<span className="skeleton h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SecretListItemProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function SecretListItem({
|
||||
title,
|
||||
description,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: SecretListItemProps) {
|
||||
return (
|
||||
<tr
|
||||
data-testid="secret-item"
|
||||
className="border-t border-[#717888] last-of-type:border-b max-w-[830px] py-[13px] flex w-full items-center"
|
||||
>
|
||||
<td className="w-1/4 text-sm text-content-2">{title}</td>
|
||||
|
||||
<td className="w-1/2 truncate overflow-hidden whitespace-nowrap text-sm text-content-2 opacity-80 italic">
|
||||
{description || "-"}
|
||||
</td>
|
||||
|
||||
<td className="w-1/4 flex items-center justify-end gap-4">
|
||||
<button
|
||||
data-testid="edit-secret-button"
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={`Edit ${title}`}
|
||||
>
|
||||
<FaPencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
data-testid="delete-secret-button"
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={`Delete ${title}`}
|
||||
>
|
||||
<FaTrash size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@ -14,9 +14,11 @@ interface SettingsInputProps {
|
||||
startContent?: React.ReactNode;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
pattern?: string;
|
||||
}
|
||||
|
||||
export function SettingsInput({
|
||||
@ -32,9 +34,11 @@ export function SettingsInput({
|
||||
startContent,
|
||||
className,
|
||||
onChange,
|
||||
required,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
pattern,
|
||||
}: SettingsInputProps) {
|
||||
return (
|
||||
<label className={cn("flex flex-col gap-2.5 w-fit", className)}>
|
||||
@ -55,6 +59,8 @@ export function SettingsInput({
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
required={required}
|
||||
pattern={pattern}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] h-10 w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
|
||||
45
frontend/src/components/shared/modals/confirmation-modal.tsx
Normal file
45
frontend/src/components/shared/modals/confirmation-modal.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { ModalBackdrop } from "./modal-backdrop";
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
text: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmationModal({
|
||||
text,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmationModalProps) {
|
||||
return (
|
||||
<ModalBackdrop onClose={onCancel}>
|
||||
<div
|
||||
data-testid="confirmation-modal"
|
||||
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary"
|
||||
>
|
||||
<p>{text}</p>
|
||||
<div className="w-full flex gap-2">
|
||||
<BrandButton
|
||||
testId="cancel-button"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
testId="confirm-button"
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
variant="primary"
|
||||
className="grow"
|
||||
>
|
||||
Confirm
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
15
frontend/src/hooks/mutation/use-create-secret.ts
Normal file
15
frontend/src/hooks/mutation/use-create-secret.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
|
||||
export const useCreateSecret = () =>
|
||||
useMutation({
|
||||
mutationFn: ({
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
}: {
|
||||
name: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}) => SecretsService.createSecret(name, value, description),
|
||||
});
|
||||
7
frontend/src/hooks/mutation/use-delete-secret.ts
Normal file
7
frontend/src/hooks/mutation/use-delete-secret.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
|
||||
export const useDeleteSecret = () =>
|
||||
useMutation({
|
||||
mutationFn: (id: string) => SecretsService.deleteSecret(id),
|
||||
});
|
||||
@ -15,6 +15,7 @@ export const useLogout = () => {
|
||||
queryClient.removeQueries({ queryKey: ["tasks"] });
|
||||
queryClient.removeQueries({ queryKey: ["settings"] });
|
||||
queryClient.removeQueries({ queryKey: ["user"] });
|
||||
queryClient.removeQueries({ queryKey: ["secrets"] });
|
||||
|
||||
posthog.reset();
|
||||
await navigate("/");
|
||||
|
||||
15
frontend/src/hooks/mutation/use-update-secret.ts
Normal file
15
frontend/src/hooks/mutation/use-update-secret.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
|
||||
export const useUpdateSecret = () =>
|
||||
useMutation({
|
||||
mutationFn: ({
|
||||
secretToEdit,
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
secretToEdit: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}) => SecretsService.updateSecret(secretToEdit, name, description),
|
||||
});
|
||||
17
frontend/src/hooks/query/use-get-secrets.ts
Normal file
17
frontend/src/hooks/query/use-get-secrets.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { useConfig } from "./use-config";
|
||||
|
||||
export const useGetSecrets = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const isOss = config?.APP_MODE === "oss";
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["secrets"],
|
||||
queryFn: SecretsService.getSecrets,
|
||||
enabled: isOss || providers.length > 0,
|
||||
});
|
||||
};
|
||||
@ -1,5 +1,11 @@
|
||||
// this file generate by script, don't modify it manually!!!
|
||||
export enum I18nKey {
|
||||
SECRETS$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",
|
||||
SECRETS$ADD_SECRET = "SECRETS$ADD_SECRET",
|
||||
SECRETS$EDIT_SECRET = "SECRETS$EDIT_SECRET",
|
||||
SECRETS$NO_SECRETS_FOUND = "SECRETS$NO_SECRETS_FOUND",
|
||||
SECRETS$ADD_NEW_SECRET = "SECRETS$ADD_NEW_SECRET",
|
||||
SECRETS$CONFIRM_DELETE_KEY = "SECRETS$CONFIRM_DELETE_KEY",
|
||||
SETTINGS$MCP_TITLE = "SETTINGS$MCP_TITLE",
|
||||
SETTINGS$MCP_DESCRIPTION = "SETTINGS$MCP_DESCRIPTION",
|
||||
SETTINGS$NAV_MCP = "SETTINGS$NAV_MCP",
|
||||
@ -60,6 +66,7 @@ export enum I18nKey {
|
||||
SETTINGS$NAV_GIT = "SETTINGS$NAV_GIT",
|
||||
SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION",
|
||||
SETTINGS$NAV_CREDITS = "SETTINGS$NAV_CREDITS",
|
||||
SETTINGS$NAV_SECRETS = "SETTINGS$NAV_SECRETS",
|
||||
SETTINGS$NAV_API_KEYS = "SETTINGS$NAV_API_KEYS",
|
||||
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
|
||||
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
|
||||
|
||||
@ -1,4 +1,94 @@
|
||||
{
|
||||
"SECRETS$SECRET_VALUE_REQUIRED": {
|
||||
"en": "Secret value is required",
|
||||
"ja": "シークレット値は必須です",
|
||||
"zh-CN": "密钥值是必需的",
|
||||
"zh-TW": "密鑰值是必需的",
|
||||
"ko-KR": "비밀 값이 필요합니다",
|
||||
"no": "Hemmelig verdi er påkrevd",
|
||||
"it": "Il valore del segreto è obbligatorio",
|
||||
"pt": "O valor do segredo é obrigatório",
|
||||
"es": "El valor del secreto es obligatorio",
|
||||
"ar": "قيمة السر مطلوبة",
|
||||
"fr": "La valeur du secret est requise",
|
||||
"tr": "Gizli değer gereklidir",
|
||||
"de": "Geheimer Wert ist erforderlich"
|
||||
},
|
||||
"SECRETS$ADD_SECRET": {
|
||||
"en": "Add secret",
|
||||
"ja": "シークレットを追加",
|
||||
"zh-CN": "添加密钥",
|
||||
"zh-TW": "添加密鑰",
|
||||
"ko-KR": "비밀 추가",
|
||||
"no": "Legg til hemmelighet",
|
||||
"it": "Aggiungi segreto",
|
||||
"pt": "Adicionar segredo",
|
||||
"es": "Añadir secreto",
|
||||
"ar": "إضافة سر",
|
||||
"fr": "Ajouter un secret",
|
||||
"tr": "Gizli ekle",
|
||||
"de": "Geheimnis hinzufügen"
|
||||
},
|
||||
"SECRETS$EDIT_SECRET": {
|
||||
"en": "Edit secret",
|
||||
"ja": "シークレットを編集",
|
||||
"zh-CN": "编辑密钥",
|
||||
"zh-TW": "編輯密鑰",
|
||||
"ko-KR": "비밀 편집",
|
||||
"no": "Rediger hemmelighet",
|
||||
"it": "Modifica segreto",
|
||||
"pt": "Editar segredo",
|
||||
"es": "Editar secreto",
|
||||
"ar": "تعديل السر",
|
||||
"fr": "Modifier le secret",
|
||||
"tr": "Gizliyi düzenle",
|
||||
"de": "Geheimnis bearbeiten"
|
||||
},
|
||||
"SECRETS$NO_SECRETS_FOUND": {
|
||||
"en": "No secrets found",
|
||||
"ja": "シークレットが見つかりません",
|
||||
"zh-CN": "未找到密钥",
|
||||
"zh-TW": "未找到密鑰",
|
||||
"ko-KR": "비밀을 찾을 수 없습니다",
|
||||
"no": "Ingen hemmeligheter funnet",
|
||||
"it": "Nessun segreto trovato",
|
||||
"pt": "Nenhum segredo encontrado",
|
||||
"es": "No se encontraron secretos",
|
||||
"ar": "لم يتم العثور على أسرار",
|
||||
"fr": "Aucun secret trouvé",
|
||||
"tr": "Gizli bulunamadı",
|
||||
"de": "Keine Geheimnisse gefunden"
|
||||
},
|
||||
"SECRETS$ADD_NEW_SECRET": {
|
||||
"en": "Add a new secret",
|
||||
"ja": "新しいシークレットを追加",
|
||||
"zh-CN": "添加新密钥",
|
||||
"zh-TW": "添加新密鑰",
|
||||
"ko-KR": "새 비밀 추가",
|
||||
"no": "Legg til en ny hemmelighet",
|
||||
"it": "Aggiungi un nuovo segreto",
|
||||
"pt": "Adicionar um novo segredo",
|
||||
"es": "Añadir un nuevo secreto",
|
||||
"ar": "إضافة سر جديد",
|
||||
"fr": "Ajouter un nouveau secret",
|
||||
"tr": "Yeni bir gizli ekle",
|
||||
"de": "Neues Geheimnis hinzufügen"
|
||||
},
|
||||
"SECRETS$CONFIRM_DELETE_KEY": {
|
||||
"en": "Are you sure you want to delete this key?",
|
||||
"ja": "このキーを削除してもよろしいですか?",
|
||||
"zh-CN": "您确定要删除此密钥吗?",
|
||||
"zh-TW": "您確定要刪除此密鑰嗎?",
|
||||
"ko-KR": "이 키를 삭제하시겠습니까?",
|
||||
"no": "Er du sikker på at du vil slette denne nøkkelen?",
|
||||
"it": "Sei sicuro di voler eliminare questa chiave?",
|
||||
"pt": "Tem certeza de que deseja excluir esta chave?",
|
||||
"es": "¿Está seguro de que desea eliminar esta clave?",
|
||||
"ar": "هل أنت متأكد أنك تريد حذف هذا المفتاح؟",
|
||||
"fr": "Êtes-vous sûr de vouloir supprimer cette clé ?",
|
||||
"tr": "Bu anahtarı silmek istediğinizden emin misiniz?",
|
||||
"de": "Sind Sie sicher, dass Sie diesen Schlüssel löschen möchten?"
|
||||
},
|
||||
"SETTINGS$MCP_TITLE": {
|
||||
"en": "Model Context Protocol (MCP)",
|
||||
"ja": "モデルコンテキストプロトコル (MCP)",
|
||||
@ -959,6 +1049,21 @@
|
||||
"de": "Guthaben",
|
||||
"uk": "Кредити"
|
||||
},
|
||||
"SETTINGS$NAV_SECRETS": {
|
||||
"en": "Secrets",
|
||||
"ja": "シークレット",
|
||||
"zh-CN": "机密",
|
||||
"zh-TW": "機密",
|
||||
"ko-KR": "비밀",
|
||||
"no": "Hemmeligheter",
|
||||
"it": "Segreti",
|
||||
"pt": "Segredos",
|
||||
"es": "Secretos",
|
||||
"ar": "أسرار",
|
||||
"fr": "Secrets",
|
||||
"tr": "Sırları",
|
||||
"de": "Geheimnisse"
|
||||
},
|
||||
"SETTINGS$NAV_API_KEYS": {
|
||||
"en": "API Keys",
|
||||
"ja": "APIキー",
|
||||
|
||||
@ -10,6 +10,7 @@ import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
|
||||
import { GitRepository, GitUser } from "#/types/git";
|
||||
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
|
||||
import { SECRETS_HANDLERS } from "./secrets-handlers";
|
||||
|
||||
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
@ -118,6 +119,7 @@ export const handlers = [
|
||||
...STRIPE_BILLING_HANDLERS,
|
||||
...FILE_SERVICE_HANDLERS,
|
||||
...TASK_SUGGESTIONS_HANDLERS,
|
||||
...SECRETS_HANDLERS,
|
||||
...openHandsHandlers,
|
||||
http.get("/api/user/repositories", () => {
|
||||
const data: GitRepository[] = [
|
||||
@ -164,7 +166,7 @@ export const handlers = [
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
STRIPE_PUBLISHABLE_KEY: "",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: mockSaas,
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: mockSaas,
|
||||
},
|
||||
};
|
||||
@ -210,8 +212,6 @@ export const handlers = [
|
||||
HttpResponse.json({ message: "Authenticated" }),
|
||||
),
|
||||
|
||||
http.get("/api/options/config", () => HttpResponse.json({ APP_MODE: "oss" })),
|
||||
|
||||
http.get("/api/conversations", async () => {
|
||||
const values = Array.from(CONVERSATIONS.values());
|
||||
const results: ResultSet<Conversation> = {
|
||||
|
||||
72
frontend/src/mocks/secrets-handlers.ts
Normal file
72
frontend/src/mocks/secrets-handlers.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { CustomSecret, GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
|
||||
const DEFAULT_SECRETS: CustomSecret[] = [
|
||||
{
|
||||
name: "OpenAI_API_Key",
|
||||
value: "test-123",
|
||||
description: "OpenAI API Key",
|
||||
},
|
||||
{
|
||||
name: "Google_Maps_API_Key",
|
||||
value: "test-123",
|
||||
description: "Google Maps API Key",
|
||||
},
|
||||
];
|
||||
|
||||
const secrets = new Map<string, CustomSecret>(
|
||||
DEFAULT_SECRETS.map((secret) => [secret.name, secret]),
|
||||
);
|
||||
|
||||
export const SECRETS_HANDLERS = [
|
||||
http.get("/api/secrets", async () => {
|
||||
const secretsArray = Array.from(secrets.values());
|
||||
const secretsWithoutValue: Omit<CustomSecret, "value">[] = secretsArray.map(
|
||||
({ value, ...rest }) => rest,
|
||||
);
|
||||
|
||||
const data: GetSecretsResponse = {
|
||||
custom_secrets: secretsWithoutValue,
|
||||
};
|
||||
|
||||
return HttpResponse.json(data);
|
||||
}),
|
||||
|
||||
http.post("/api/secrets", async ({ request }) => {
|
||||
const body = (await request.json()) as CustomSecret;
|
||||
if (typeof body === "object" && body && body.name) {
|
||||
secrets.set(body.name, body);
|
||||
return HttpResponse.json(true);
|
||||
}
|
||||
|
||||
return HttpResponse.json(false, { status: 400 });
|
||||
}),
|
||||
|
||||
http.put("/api/secrets/:id", async ({ params, request }) => {
|
||||
const { id } = params;
|
||||
const body = (await request.json()) as Omit<CustomSecret, "value">;
|
||||
|
||||
if (typeof id === "string" && typeof body === "object") {
|
||||
const secret = secrets.get(id);
|
||||
if (secret && body && body.name) {
|
||||
const newSecret: CustomSecret = { ...secret, ...body };
|
||||
secrets.delete(id);
|
||||
secrets.set(body.name, newSecret);
|
||||
return HttpResponse.json(true);
|
||||
}
|
||||
}
|
||||
|
||||
return HttpResponse.json(false, { status: 400 });
|
||||
}),
|
||||
|
||||
http.delete("/api/secrets/:id", async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (typeof id === "string") {
|
||||
secrets.delete(id);
|
||||
return HttpResponse.json(true);
|
||||
}
|
||||
|
||||
return HttpResponse.json(false, { status: 400 });
|
||||
}),
|
||||
];
|
||||
@ -15,6 +15,7 @@ export default [
|
||||
route("git", "routes/git-settings.tsx"),
|
||||
route("app", "routes/app-settings.tsx"),
|
||||
route("billing", "routes/billing.tsx"),
|
||||
route("secrets", "routes/secrets-settings.tsx"),
|
||||
route("api-keys", "routes/api-keys.tsx"),
|
||||
]),
|
||||
route("conversations/:conversationId", "routes/conversation.tsx", [
|
||||
|
||||
151
frontend/src/routes/secrets-settings.tsx
Normal file
151
frontend/src/routes/secrets-settings.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
|
||||
import { useDeleteSecret } from "#/hooks/mutation/use-delete-secret";
|
||||
import { SecretForm } from "#/components/features/settings/secrets-settings/secret-form";
|
||||
import {
|
||||
SecretListItem,
|
||||
SecretListItemSkeleton,
|
||||
} from "#/components/features/settings/secrets-settings/secret-list-item";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal";
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
function SecretsSettingsScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: config } = useConfig();
|
||||
const { data: secrets, isLoading: isLoadingSecrets } = useGetSecrets();
|
||||
const { mutate: deleteSecret } = useDeleteSecret();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const hasProviderSet = providers.length > 0;
|
||||
|
||||
const [view, setView] = React.useState<
|
||||
"list" | "add-secret-form" | "edit-secret-form"
|
||||
>("list");
|
||||
const [selectedSecret, setSelectedSecret] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [confirmationModalIsVisible, setConfirmationModalIsVisible] =
|
||||
React.useState(false);
|
||||
|
||||
const deleteSecretOptimistically = (secret: string) => {
|
||||
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
|
||||
["secrets"],
|
||||
(oldSecrets) => {
|
||||
if (!oldSecrets) return [];
|
||||
return oldSecrets.filter((s) => s.name !== secret);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const revertOptimisticUpdate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["secrets"] });
|
||||
};
|
||||
|
||||
const handleDeleteSecret = (secret: string) => {
|
||||
deleteSecretOptimistically(secret);
|
||||
deleteSecret(secret, {
|
||||
onSettled: () => {
|
||||
setConfirmationModalIsVisible(false);
|
||||
},
|
||||
onError: revertOptimisticUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
const onConfirmDeleteSecret = () => {
|
||||
if (selectedSecret) handleDeleteSecret(selectedSecret);
|
||||
};
|
||||
|
||||
const onCancelDeleteSecret = () => {
|
||||
setConfirmationModalIsVisible(false);
|
||||
};
|
||||
|
||||
const shouldRenderConnectToGitButton = isSaas && !hasProviderSet;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="secrets-settings-screen"
|
||||
className="px-11 py-9 flex flex-col gap-5"
|
||||
>
|
||||
{isLoadingSecrets && view === "list" && (
|
||||
<ul>
|
||||
<SecretListItemSkeleton />
|
||||
<SecretListItemSkeleton />
|
||||
<SecretListItemSkeleton />
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{shouldRenderConnectToGitButton && (
|
||||
<Link to="/settings/git" data-testid="connect-git-button" type="button">
|
||||
<BrandButton type="button" variant="secondary">
|
||||
Connect a Git provider to manage secrets
|
||||
</BrandButton>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{secrets?.length === 0 && view === "list" && (
|
||||
<p data-testid="no-secrets-message">{t("SECRETS$NO_SECRETS_FOUND")}</p>
|
||||
)}
|
||||
|
||||
{view === "list" && (
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{secrets?.map((secret) => (
|
||||
<SecretListItem
|
||||
key={secret.name}
|
||||
title={secret.name}
|
||||
description={secret.description}
|
||||
onEdit={() => {
|
||||
setView("edit-secret-form");
|
||||
setSelectedSecret(secret.name);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setConfirmationModalIsVisible(true);
|
||||
setSelectedSecret(secret.name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!shouldRenderConnectToGitButton && view === "list" && (
|
||||
<BrandButton
|
||||
testId="add-secret-button"
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => setView("add-secret-form")}
|
||||
isDisabled={isLoadingSecrets}
|
||||
>
|
||||
{t("SECRETS$ADD_NEW_SECRET")}
|
||||
</BrandButton>
|
||||
)}
|
||||
|
||||
{(view === "add-secret-form" || view === "edit-secret-form") && (
|
||||
<SecretForm
|
||||
mode={view === "add-secret-form" ? "add" : "edit"}
|
||||
selectedSecret={selectedSecret}
|
||||
onCancel={() => setView("list")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmationModalIsVisible && (
|
||||
<ConfirmationModal
|
||||
text={t("SECRETS$CONFIRM_DELETE_KEY")}
|
||||
onConfirm={onConfirmDeleteSecret}
|
||||
onCancel={onCancelDeleteSecret}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SecretsSettingsScreen;
|
||||
@ -18,6 +18,7 @@ function SettingsScreen() {
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
|
||||
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
|
||||
{ to: "/settings/api-keys", text: t("SETTINGS$NAV_API_KEYS") },
|
||||
];
|
||||
|
||||
@ -26,6 +27,7 @@ function SettingsScreen() {
|
||||
{ to: "/settings/mcp", text: t("SETTINGS$NAV_MCP") },
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
|
||||
];
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@ -57,8 +57,31 @@ class ProviderToken(BaseModel):
|
||||
raise ValueError('Unsupported Provider token type')
|
||||
|
||||
|
||||
class CustomSecret(BaseModel):
|
||||
secret: SecretStr = Field(default_factory=lambda: SecretStr(''))
|
||||
description: str = Field(default='')
|
||||
|
||||
model_config = {
|
||||
'frozen': True, # Makes the entire model immutable
|
||||
'validate_assignment': True,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_value(cls, secret_value: CustomSecret | dict[str, str]) -> CustomSecret:
|
||||
"""Factory method to create a ProviderToken from various input types"""
|
||||
if isinstance(secret_value, CustomSecret):
|
||||
return secret_value
|
||||
elif isinstance(secret_value, dict):
|
||||
secret = secret_value.get('secret')
|
||||
description = secret_value.get('description')
|
||||
return cls(secret=SecretStr(secret), description=description)
|
||||
|
||||
else:
|
||||
raise ValueError('Unsupport Provider token type')
|
||||
|
||||
|
||||
PROVIDER_TOKEN_TYPE = MappingProxyType[ProviderType, ProviderToken]
|
||||
CUSTOM_SECRETS_TYPE = MappingProxyType[str, SecretStr]
|
||||
CUSTOM_SECRETS_TYPE = MappingProxyType[str, CustomSecret]
|
||||
PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA = Annotated[
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
WithJsonSchema({'type': 'object', 'additionalProperties': {'type': 'string'}}),
|
||||
|
||||
@ -2,12 +2,14 @@ from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import CustomSecret
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.integrations.utils import validate_provider_token
|
||||
from openhands.server.settings import (
|
||||
CustomSecretModel,
|
||||
CustomSecretWithoutValueModel,
|
||||
GETCustomSecrets,
|
||||
POSTCustomSecrets,
|
||||
POSTProviderModel,
|
||||
)
|
||||
from openhands.server.user_auth import (
|
||||
@ -188,7 +190,15 @@ async def load_custom_secrets_names(
|
||||
content={'error': 'User secrets not found'},
|
||||
)
|
||||
|
||||
custom_secrets = list(user_secrets.custom_secrets.keys())
|
||||
custom_secrets: list[CustomSecretWithoutValueModel] = []
|
||||
if user_secrets.custom_secrets:
|
||||
for secret_name, secret_value in user_secrets.custom_secrets.items():
|
||||
custom_secret = CustomSecretWithoutValueModel(
|
||||
name=secret_name,
|
||||
description=secret_value.description,
|
||||
)
|
||||
custom_secrets.append(custom_secret)
|
||||
|
||||
return GETCustomSecrets(custom_secrets=custom_secrets)
|
||||
|
||||
except Exception as e:
|
||||
@ -201,7 +211,7 @@ async def load_custom_secrets_names(
|
||||
|
||||
@app.post('/secrets', response_model=dict[str, str])
|
||||
async def create_custom_secret(
|
||||
incoming_secret: POSTCustomSecrets,
|
||||
incoming_secret: CustomSecretModel,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
@ -209,14 +219,20 @@ async def create_custom_secret(
|
||||
if existing_secrets:
|
||||
custom_secrets = dict(existing_secrets.custom_secrets)
|
||||
|
||||
for secret_name, secret_value in incoming_secret.custom_secrets.items():
|
||||
if secret_name in custom_secrets:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'message': f'Secret {secret_name} already exists'},
|
||||
)
|
||||
secret_name = incoming_secret.name
|
||||
secret_value = incoming_secret.value
|
||||
secret_description = incoming_secret.description
|
||||
|
||||
custom_secrets[secret_name] = secret_value
|
||||
if secret_name in custom_secrets:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'message': f'Secret {secret_name} already exists'},
|
||||
)
|
||||
|
||||
custom_secrets[secret_name] = CustomSecret(
|
||||
secret=secret_value,
|
||||
description=secret_description or '',
|
||||
)
|
||||
|
||||
# Create a new UserSecrets that preserves provider tokens
|
||||
updated_user_secrets = UserSecrets(
|
||||
@ -227,7 +243,7 @@ async def create_custom_secret(
|
||||
await secrets_store.store(updated_user_secrets)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
content={'message': 'Secret created successfully'},
|
||||
)
|
||||
except Exception as e:
|
||||
@ -241,7 +257,7 @@ async def create_custom_secret(
|
||||
@app.put('/secrets/{secret_id}', response_model=dict[str, str])
|
||||
async def update_custom_secret(
|
||||
secret_id: str,
|
||||
incoming_secret: POSTCustomSecrets,
|
||||
incoming_secret: CustomSecretWithoutValueModel,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
@ -254,13 +270,23 @@ async def update_custom_secret(
|
||||
content={'error': f'Secret with ID {secret_id} not found'},
|
||||
)
|
||||
|
||||
secret_name = incoming_secret.name
|
||||
secret_description = incoming_secret.description
|
||||
|
||||
custom_secrets = dict(existing_secrets.custom_secrets)
|
||||
custom_secrets.pop(secret_id)
|
||||
existing_secret = custom_secrets.pop(secret_id)
|
||||
|
||||
for secret_name, secret_value in incoming_secret.custom_secrets.items():
|
||||
custom_secrets[secret_name] = secret_value
|
||||
if secret_name != secret_id and secret_name in custom_secrets:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'message': f'Secret {secret_name} already exists'},
|
||||
)
|
||||
|
||||
custom_secrets[secret_name] = CustomSecret(
|
||||
secret=existing_secret.secret,
|
||||
description=secret_description or '',
|
||||
)
|
||||
|
||||
# Create a new UserSecrets that preserves provider tokens
|
||||
updated_secrets = UserSecrets(
|
||||
custom_secrets=custom_secrets,
|
||||
provider_tokens=existing_secrets.provider_tokens,
|
||||
|
||||
@ -6,7 +6,7 @@ from pydantic import (
|
||||
)
|
||||
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
from openhands.integrations.provider import CustomSecret, ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
@ -25,7 +25,7 @@ class POSTCustomSecrets(BaseModel):
|
||||
Adding new custom secret
|
||||
"""
|
||||
|
||||
custom_secrets: dict[str, str | SecretStr] = {}
|
||||
custom_secrets: dict[str, CustomSecret] = {}
|
||||
|
||||
|
||||
class GETSettingsModel(Settings):
|
||||
@ -41,9 +41,26 @@ class GETSettingsModel(Settings):
|
||||
model_config = {'use_enum_values': True}
|
||||
|
||||
|
||||
class CustomSecretWithoutValueModel(BaseModel):
|
||||
"""
|
||||
Custom secret model without value
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CustomSecretModel(CustomSecretWithoutValueModel):
|
||||
"""
|
||||
Custom secret model with value
|
||||
"""
|
||||
|
||||
value: SecretStr
|
||||
|
||||
|
||||
class GETCustomSecrets(BaseModel):
|
||||
"""
|
||||
Custom secrets names
|
||||
"""
|
||||
|
||||
custom_secrets: list[str] | None = None
|
||||
custom_secrets: list[CustomSecretWithoutValueModel] | None = None
|
||||
|
||||
@ -3,9 +3,7 @@ from typing import Any
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
SecretStr,
|
||||
SerializationInfo,
|
||||
field_serializer,
|
||||
model_validator,
|
||||
@ -17,6 +15,7 @@ from openhands.integrations.provider import (
|
||||
CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA,
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA,
|
||||
CustomSecret,
|
||||
ProviderToken,
|
||||
)
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
@ -31,11 +30,11 @@ class UserSecrets(BaseModel):
|
||||
default_factory=lambda: MappingProxyType({})
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
validate_assignment=True,
|
||||
arbitrary_types_allowed=True,
|
||||
)
|
||||
model_config = {
|
||||
'frozen': True,
|
||||
'validate_assignment': True,
|
||||
'arbitrary_types_allowed': True,
|
||||
}
|
||||
|
||||
@field_serializer('provider_tokens')
|
||||
def provider_tokens_serializer(
|
||||
@ -78,12 +77,14 @@ class UserSecrets(BaseModel):
|
||||
expose_secrets = info.context and info.context.get('expose_secrets', False)
|
||||
|
||||
if custom_secrets:
|
||||
for secret_name, secret_key in custom_secrets.items():
|
||||
secrets[secret_name] = (
|
||||
secret_key.get_secret_value()
|
||||
for secret_name, secret_value in custom_secrets.items():
|
||||
secrets[secret_name] = {
|
||||
'secret': secret_value.secret.get_secret_value()
|
||||
if expose_secrets
|
||||
else pydantic_encoder(secret_key)
|
||||
)
|
||||
else pydantic_encoder(secret_value.secret),
|
||||
'description': secret_value.description,
|
||||
}
|
||||
|
||||
return secrets
|
||||
|
||||
@model_validator(mode='before')
|
||||
@ -125,10 +126,10 @@ class UserSecrets(BaseModel):
|
||||
if isinstance(secrets, dict):
|
||||
converted_secrets = {}
|
||||
for key, value in secrets.items():
|
||||
if isinstance(value, str):
|
||||
converted_secrets[key] = SecretStr(value)
|
||||
elif isinstance(value, SecretStr):
|
||||
converted_secrets[key] = value
|
||||
try:
|
||||
converted_secrets[key] = CustomSecret.from_value(value)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
new_data['custom_secrets'] = MappingProxyType(converted_secrets)
|
||||
elif isinstance(secrets, MappingProxyType):
|
||||
|
||||
@ -5,7 +5,11 @@ from typing import Any
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.integrations.provider import (
|
||||
CustomSecret,
|
||||
ProviderToken,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.storage.data_models.user_secrets import UserSecrets
|
||||
|
||||
|
||||
@ -51,8 +55,12 @@ class TestUserSecrets:
|
||||
"""Test adding only custom secrets to the UserSecrets."""
|
||||
# Create custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': 'api-key-123',
|
||||
'DATABASE_PASSWORD': 'db-pass-456',
|
||||
'API_KEY': CustomSecret(
|
||||
secret=SecretStr('api-key-123'), description='API key'
|
||||
),
|
||||
'DATABASE_PASSWORD': CustomSecret(
|
||||
secret=SecretStr('db-pass-456'), description='Database password'
|
||||
),
|
||||
}
|
||||
|
||||
# Initialize the store with custom secrets
|
||||
@ -61,9 +69,11 @@ class TestUserSecrets:
|
||||
# Verify the custom secrets were added correctly
|
||||
assert isinstance(store.custom_secrets, MappingProxyType)
|
||||
assert len(store.custom_secrets) == 2
|
||||
assert store.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
|
||||
assert (
|
||||
store.custom_secrets['DATABASE_PASSWORD'].get_secret_value()
|
||||
store.custom_secrets['API_KEY'].secret.get_secret_value() == 'api-key-123'
|
||||
)
|
||||
assert (
|
||||
store.custom_secrets['DATABASE_PASSWORD'].secret.get_secret_value()
|
||||
== 'db-pass-456'
|
||||
)
|
||||
|
||||
@ -79,10 +89,10 @@ class TestUserSecrets:
|
||||
}
|
||||
|
||||
# Create custom secrets as a MappingProxyType
|
||||
custom_secrets_dict = {'API_KEY': 'api-key-123'}
|
||||
custom_secrets_proxy = MappingProxyType(
|
||||
{key: SecretStr(value) for key, value in custom_secrets_dict.items()}
|
||||
custom_secret = CustomSecret(
|
||||
secret=SecretStr('api-key-123'), description='API key'
|
||||
)
|
||||
custom_secrets_proxy = MappingProxyType({'API_KEY': custom_secret})
|
||||
|
||||
# Test with dict for provider_tokens and MappingProxyType for custom_secrets
|
||||
store1 = UserSecrets(
|
||||
@ -95,7 +105,9 @@ class TestUserSecrets:
|
||||
store1.provider_tokens[ProviderType.GITHUB].token.get_secret_value()
|
||||
== 'github-token-123'
|
||||
)
|
||||
assert store1.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
|
||||
assert (
|
||||
store1.custom_secrets['API_KEY'].secret.get_secret_value() == 'api-key-123'
|
||||
)
|
||||
|
||||
# Test with MappingProxyType for provider_tokens and dict for custom_secrets
|
||||
provider_token = ProviderToken(
|
||||
@ -103,6 +115,11 @@ class TestUserSecrets:
|
||||
)
|
||||
provider_tokens_proxy = MappingProxyType({ProviderType.GITLAB: provider_token})
|
||||
|
||||
# Create custom secrets as a dict
|
||||
custom_secrets_dict = {
|
||||
'API_KEY': {'secret': 'api-key-123', 'description': 'API key'}
|
||||
}
|
||||
|
||||
store2 = UserSecrets(
|
||||
provider_tokens=provider_tokens_proxy, custom_secrets=custom_secrets_dict
|
||||
)
|
||||
@ -113,7 +130,9 @@ class TestUserSecrets:
|
||||
store2.provider_tokens[ProviderType.GITLAB].token.get_secret_value()
|
||||
== 'gitlab-token-456'
|
||||
)
|
||||
assert store2.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
|
||||
assert (
|
||||
store2.custom_secrets['API_KEY'].secret.get_secret_value() == 'api-key-123'
|
||||
)
|
||||
|
||||
def test_model_copy_update_fields(self):
|
||||
"""Test using model_copy to update fields without affecting other fields."""
|
||||
@ -121,7 +140,11 @@ class TestUserSecrets:
|
||||
github_token = ProviderToken(
|
||||
token=SecretStr('github-token-123'), user_id='user1'
|
||||
)
|
||||
custom_secret = {'API_KEY': SecretStr('api-key-123')}
|
||||
custom_secret = {
|
||||
'API_KEY': CustomSecret(
|
||||
secret=SecretStr('api-key-123'), description='API key'
|
||||
)
|
||||
}
|
||||
|
||||
initial_store = UserSecrets(
|
||||
provider_tokens=MappingProxyType({ProviderType.GITHUB: github_token}),
|
||||
@ -152,14 +175,19 @@ class TestUserSecrets:
|
||||
)
|
||||
assert len(updated_store1.custom_secrets) == 1
|
||||
assert (
|
||||
updated_store1.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
|
||||
updated_store1.custom_secrets['API_KEY'].secret.get_secret_value()
|
||||
== 'api-key-123'
|
||||
)
|
||||
|
||||
# Update only custom_secrets
|
||||
updated_custom_secrets = MappingProxyType(
|
||||
{
|
||||
'API_KEY': SecretStr('api-key-123'),
|
||||
'DATABASE_PASSWORD': SecretStr('db-pass-456'),
|
||||
'API_KEY': CustomSecret(
|
||||
secret=SecretStr('api-key-123'), description='API key'
|
||||
),
|
||||
'DATABASE_PASSWORD': CustomSecret(
|
||||
secret=SecretStr('db-pass-456'), description='DB password'
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@ -175,10 +203,11 @@ class TestUserSecrets:
|
||||
)
|
||||
assert len(updated_store2.custom_secrets) == 2
|
||||
assert (
|
||||
updated_store2.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
|
||||
updated_store2.custom_secrets['API_KEY'].secret.get_secret_value()
|
||||
== 'api-key-123'
|
||||
)
|
||||
assert (
|
||||
updated_store2.custom_secrets['DATABASE_PASSWORD'].get_secret_value()
|
||||
updated_store2.custom_secrets['DATABASE_PASSWORD'].secret.get_secret_value()
|
||||
== 'db-pass-456'
|
||||
)
|
||||
|
||||
@ -188,7 +217,11 @@ class TestUserSecrets:
|
||||
github_token = ProviderToken(
|
||||
token=SecretStr('github-token-123'), user_id='user1'
|
||||
)
|
||||
custom_secrets = {'API_KEY': SecretStr('api-key-123')}
|
||||
custom_secrets = {
|
||||
'API_KEY': CustomSecret(
|
||||
secret=SecretStr('api-key-123'), description='API key'
|
||||
)
|
||||
}
|
||||
|
||||
store = UserSecrets(
|
||||
provider_tokens=MappingProxyType({ProviderType.GITHUB: github_token}),
|
||||
@ -209,7 +242,8 @@ class TestUserSecrets:
|
||||
assert serialized_provider_tokens['github']['user_id'] == 'user1'
|
||||
|
||||
# Verify custom secrets are exposed
|
||||
assert serialized_custom_secrets['API_KEY'] == 'api-key-123'
|
||||
assert serialized_custom_secrets['API_KEY']['secret'] == 'api-key-123'
|
||||
assert serialized_custom_secrets['API_KEY']['description'] == 'API key'
|
||||
|
||||
# Test serialization with expose_secrets=False (default)
|
||||
hidden_provider_tokens = store.provider_tokens_serializer(
|
||||
@ -225,8 +259,8 @@ class TestUserSecrets:
|
||||
assert '**' in hidden_provider_tokens['github']['token']
|
||||
|
||||
# Verify custom secrets are hidden
|
||||
assert hidden_custom_secrets['API_KEY'] != 'api-key-123'
|
||||
assert '**' in hidden_custom_secrets['API_KEY']
|
||||
assert hidden_custom_secrets['API_KEY']['secret'] != 'api-key-123'
|
||||
assert '**' in hidden_custom_secrets['API_KEY']['secret']
|
||||
|
||||
def test_initializing_provider_tokens_with_mixed_value_types(self):
|
||||
"""Test initializing provider tokens with both plain strings and SecretStr objects."""
|
||||
@ -278,27 +312,36 @@ class TestUserSecrets:
|
||||
"""Test initializing custom secrets with both plain strings and SecretStr objects."""
|
||||
# Create custom secrets with mixed value types
|
||||
custom_secrets_dict = {
|
||||
'API_KEY': 'api-key-123', # Plain string
|
||||
'DATABASE_PASSWORD': SecretStr('db-pass-456'), # SecretStr
|
||||
'API_KEY': {
|
||||
'secret': 'api-key-123',
|
||||
'description': 'API key',
|
||||
}, # Dict format
|
||||
'DATABASE_PASSWORD': CustomSecret(
|
||||
secret=SecretStr('db-pass-456'), description='DB password'
|
||||
), # CustomSecret object
|
||||
}
|
||||
|
||||
# Initialize the store
|
||||
store = UserSecrets(custom_secrets=custom_secrets_dict)
|
||||
|
||||
# Verify all secrets are converted to SecretStr
|
||||
# Verify all secrets are converted to CustomSecret objects
|
||||
assert isinstance(store.custom_secrets, MappingProxyType)
|
||||
assert len(store.custom_secrets) == 2
|
||||
|
||||
# Check API_KEY (was plain string)
|
||||
assert isinstance(store.custom_secrets['API_KEY'], SecretStr)
|
||||
assert store.custom_secrets['API_KEY'].get_secret_value() == 'api-key-123'
|
||||
|
||||
# Check DATABASE_PASSWORD (was SecretStr)
|
||||
assert isinstance(store.custom_secrets['DATABASE_PASSWORD'], SecretStr)
|
||||
# Check API_KEY (was dict)
|
||||
assert isinstance(store.custom_secrets['API_KEY'], CustomSecret)
|
||||
assert (
|
||||
store.custom_secrets['DATABASE_PASSWORD'].get_secret_value()
|
||||
store.custom_secrets['API_KEY'].secret.get_secret_value() == 'api-key-123'
|
||||
)
|
||||
assert store.custom_secrets['API_KEY'].description == 'API key'
|
||||
|
||||
# Check DATABASE_PASSWORD (was CustomSecret)
|
||||
assert isinstance(store.custom_secrets['DATABASE_PASSWORD'], CustomSecret)
|
||||
assert (
|
||||
store.custom_secrets['DATABASE_PASSWORD'].secret.get_secret_value()
|
||||
== 'db-pass-456'
|
||||
)
|
||||
assert store.custom_secrets['DATABASE_PASSWORD'].description == 'DB password'
|
||||
|
||||
|
||||
# Mock class for SerializationInfo since it's not directly importable
|
||||
|
||||
@ -8,7 +8,11 @@ from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.integrations.provider import (
|
||||
CustomSecret,
|
||||
ProviderToken,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.server.routes.secrets import app as secrets_app
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.data_models.user_secrets import UserSecrets
|
||||
@ -45,8 +49,8 @@ async def test_load_custom_secrets_names(test_client, file_secrets_store):
|
||||
|
||||
# Create initial settings with custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': SecretStr('api-key-value'),
|
||||
'DB_PASSWORD': SecretStr('db-password-value'),
|
||||
'API_KEY': CustomSecret(secret=SecretStr('api-key-value')),
|
||||
'DB_PASSWORD': CustomSecret(secret=SecretStr('db-password-value')),
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
@ -60,20 +64,24 @@ async def test_load_custom_secrets_names(test_client, file_secrets_store):
|
||||
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
print(response)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check the response
|
||||
data = response.json()
|
||||
assert 'custom_secrets' in data
|
||||
assert sorted(data['custom_secrets']) == ['API_KEY', 'DB_PASSWORD']
|
||||
# Extract just the names from the list of custom secrets
|
||||
secret_names = [secret['name'] for secret in data['custom_secrets']]
|
||||
assert sorted(secret_names) == ['API_KEY', 'DB_PASSWORD']
|
||||
|
||||
# Verify that the original settings were not modified
|
||||
stored_settings = await file_secrets_store.load()
|
||||
assert (
|
||||
stored_settings.custom_secrets['API_KEY'].get_secret_value() == 'api-key-value'
|
||||
stored_settings.custom_secrets['API_KEY'].secret.get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
assert (
|
||||
stored_settings.custom_secrets['DB_PASSWORD'].get_secret_value()
|
||||
stored_settings.custom_secrets['DB_PASSWORD'].secret.get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
assert ProviderType.GITHUB in stored_settings.provider_tokens
|
||||
@ -86,7 +94,7 @@ async def test_load_custom_secrets_names_empty(test_client, file_secrets_store):
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
user_secrets = UserSecrets(provider_tokens=provider_tokens)
|
||||
user_secrets = UserSecrets(provider_tokens=provider_tokens, custom_secrets={})
|
||||
|
||||
# Store the initial settings
|
||||
await file_secrets_store.store(user_secrets)
|
||||
@ -115,9 +123,9 @@ async def test_add_custom_secret(test_client, file_secrets_store):
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Make the POST request to add a custom secret
|
||||
add_secret_data = {'custom_secrets': {'API_KEY': 'api-key-value'}}
|
||||
add_secret_data = {'name': 'API_KEY', 'value': 'api-key-value', 'description': None}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify that the settings were stored with the new secret
|
||||
stored_settings = await file_secrets_store.load()
|
||||
@ -125,16 +133,17 @@ async def test_add_custom_secret(test_client, file_secrets_store):
|
||||
# Check that the secret was added
|
||||
assert 'API_KEY' in stored_settings.custom_secrets
|
||||
assert (
|
||||
stored_settings.custom_secrets['API_KEY'].get_secret_value() == 'api-key-value'
|
||||
stored_settings.custom_secrets['API_KEY'].secret.get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_existing_custom_secret(test_client, file_secrets_store):
|
||||
"""Test updating an existing custom secret."""
|
||||
"""Test updating an existing custom secret's name and description (cannot change value once set)."""
|
||||
|
||||
# Create initial settings with a custom secret
|
||||
custom_secrets = {'API_KEY': SecretStr('old-api-key')}
|
||||
custom_secrets = {'API_KEY': CustomSecret(secret=SecretStr('old-api-key'))}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
@ -145,8 +154,11 @@ async def test_update_existing_custom_secret(test_client, file_secrets_store):
|
||||
# Store the initial settings
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Make the POST request to update the custom secret
|
||||
update_secret_data = {'custom_secrets': {'API_KEY': 'new-api-key'}}
|
||||
# Make the PUT request to update the custom secret
|
||||
update_secret_data = {
|
||||
'name': 'API_KEY',
|
||||
'description': None,
|
||||
}
|
||||
response = test_client.put('/api/secrets/API_KEY', json=update_secret_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
@ -155,7 +167,10 @@ async def test_update_existing_custom_secret(test_client, file_secrets_store):
|
||||
|
||||
# Check that the secret was updated
|
||||
assert 'API_KEY' in stored_settings.custom_secrets
|
||||
assert stored_settings.custom_secrets['API_KEY'].get_secret_value() == 'new-api-key'
|
||||
assert (
|
||||
stored_settings.custom_secrets['API_KEY'].secret.get_secret_value()
|
||||
== 'old-api-key'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert ProviderType.GITHUB in stored_settings.provider_tokens
|
||||
@ -166,7 +181,9 @@ async def test_add_multiple_custom_secrets(test_client, file_secrets_store):
|
||||
"""Test adding multiple custom secrets at once."""
|
||||
|
||||
# Create initial settings with one custom secret
|
||||
custom_secrets = {'EXISTING_SECRET': SecretStr('existing-value')}
|
||||
custom_secrets = {
|
||||
'EXISTING_SECRET': CustomSecret(secret=SecretStr('existing-value'))
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
@ -177,15 +194,23 @@ async def test_add_multiple_custom_secrets(test_client, file_secrets_store):
|
||||
# Store the initial settings
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Make the POST request to add multiple custom secrets
|
||||
add_secrets_data = {
|
||||
'custom_secrets': {
|
||||
'API_KEY': 'api-key-value',
|
||||
'DB_PASSWORD': 'db-password-value',
|
||||
}
|
||||
# Make the POST request to add first custom secret
|
||||
add_secret_data1 = {
|
||||
'name': 'API_KEY',
|
||||
'value': 'api-key-value',
|
||||
'description': None,
|
||||
}
|
||||
response = test_client.post('/api/secrets', json=add_secrets_data)
|
||||
assert response.status_code == 200
|
||||
response1 = test_client.post('/api/secrets', json=add_secret_data1)
|
||||
assert response1.status_code == 201
|
||||
|
||||
# Make the POST request to add second custom secret
|
||||
add_secret_data2 = {
|
||||
'name': 'DB_PASSWORD',
|
||||
'value': 'db-password-value',
|
||||
'description': None,
|
||||
}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data2)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify that the settings were stored with the new secrets
|
||||
stored_settings = await file_secrets_store.load()
|
||||
@ -193,18 +218,19 @@ async def test_add_multiple_custom_secrets(test_client, file_secrets_store):
|
||||
# Check that the new secrets were added
|
||||
assert 'API_KEY' in stored_settings.custom_secrets
|
||||
assert (
|
||||
stored_settings.custom_secrets['API_KEY'].get_secret_value() == 'api-key-value'
|
||||
stored_settings.custom_secrets['API_KEY'].secret.get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
assert 'DB_PASSWORD' in stored_settings.custom_secrets
|
||||
assert (
|
||||
stored_settings.custom_secrets['DB_PASSWORD'].get_secret_value()
|
||||
stored_settings.custom_secrets['DB_PASSWORD'].secret.get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
|
||||
# Check that existing secrets were preserved
|
||||
assert 'EXISTING_SECRET' in stored_settings.custom_secrets
|
||||
assert (
|
||||
stored_settings.custom_secrets['EXISTING_SECRET'].get_secret_value()
|
||||
stored_settings.custom_secrets['EXISTING_SECRET'].secret.get_secret_value()
|
||||
== 'existing-value'
|
||||
)
|
||||
|
||||
@ -218,8 +244,8 @@ async def test_delete_custom_secret(test_client, file_secrets_store):
|
||||
|
||||
# Create initial settings with multiple custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': SecretStr('api-key-value'),
|
||||
'DB_PASSWORD': SecretStr('db-password-value'),
|
||||
'API_KEY': CustomSecret(secret=SecretStr('api-key-value')),
|
||||
'DB_PASSWORD': CustomSecret(secret=SecretStr('db-password-value')),
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
@ -244,7 +270,7 @@ async def test_delete_custom_secret(test_client, file_secrets_store):
|
||||
# Check that other secrets were preserved
|
||||
assert 'DB_PASSWORD' in stored_settings.custom_secrets
|
||||
assert (
|
||||
stored_settings.custom_secrets['DB_PASSWORD'].get_secret_value()
|
||||
stored_settings.custom_secrets['DB_PASSWORD'].secret.get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
|
||||
@ -257,7 +283,9 @@ async def test_delete_nonexistent_custom_secret(test_client, file_secrets_store)
|
||||
"""Test deleting a custom secret that doesn't exist."""
|
||||
|
||||
# Create initial settings with a custom secret
|
||||
custom_secrets = {'API_KEY': SecretStr('api-key-value')}
|
||||
custom_secrets = {
|
||||
'API_KEY': CustomSecret(secret=SecretStr('api-key-value'), description='')
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
@ -278,7 +306,8 @@ async def test_delete_nonexistent_custom_secret(test_client, file_secrets_store)
|
||||
# Check that the existing secret was preserved
|
||||
assert 'API_KEY' in stored_settings.custom_secrets
|
||||
assert (
|
||||
stored_settings.custom_secrets['API_KEY'].get_secret_value() == 'api-key-value'
|
||||
stored_settings.custom_secrets['API_KEY'].secret.get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user