mirror of
https://github.com/coder/coder.git
synced 2026-10-31 19:36:41 +08:00
Adds the admin-controlled OAuth2 Dynamic Client Registration setting landed by #27316 (`GET`/`PUT /api/v2/oauth2-provider/settings`) to the OAuth2 Applications deployment settings page, since it was previously only reachable via the API or `coder oauth2-provider dcr enable|disable`. The page is now tabbed, **Applications** and **Settings**, so DCR has a home that further OAuth2 settings can share (an Initial Access Token setting is a likely next one). The active tab is backed by a `tab` search param, so `?tab=settings` links straight to it, and an unpermitted deep link falls back to **Applications** rather than selecting nothing. On the Settings tab, DCR renders as a titled section with a description, an `Enabled` badge when active, and an Enable/Disable button. Enabling opens a confirmation dialog, since it lets any OAuth2 client self-register against the deployment without prior admin approval (RFC 7591). Disabling is immediate, no confirmation. The control is a button rather than a switch on design feedback: a switch reads as an immediate on/off flip, which conflicts with a confirmation dialog standing in front of it, and it left the only explanation of the risk inside a dialog that disappears. A button carries the confirmation step without misrepresenting what a click costs, the always-visible description explains the setting on the page, and the `Enabled` badge gives the active state a persistent indicator. The layout follows Tracy's mockup on `tj/oauth2-apps-pagination`; the apps-table pagination work that shares that branch is deliberately not included here. Visibility and editability are gated on the same `ResourceDeploymentConfig` RBAC checks the endpoint itself enforces (`viewDeploymentConfig` / `editDeploymentConfig`), not a separate hardcoded check. The view takes the settings values as one optional `settings` prop, absent when the viewer lacks `viewDeploymentConfig`, so "cannot view" is the shape of the prop rather than a flag the caller keeps consistent with the values beside it, and the tab is not rendered at all. Closes https://github.com/coder/coder/issues/27432 ## Where this sits in the request path ```mermaid sequenceDiagram autonumber actor Admin participant View as OAuth2AppsSettingsPageView<br/>(Tabs + Enable/Disable + Dialog) participant Page as OAuth2AppsSettingsPage<br/>(React Query) participant S as coderd Note over Page: On mount Page->>S: GET /api/v2/oauth2-provider/settings S-->>Page: { dynamic_client_registration_enabled } Page-->>View: settings: { dynamicClientRegistrationEnabled, canEdit, ... } Note over Admin,View: Admin opens the Settings tab and enables DCR Admin->>View: click "Enable" View->>View: open confirmation dialog<br/>(no request sent yet) Admin->>View: click Confirm View->>Page: settings.onDynamicClientRegistrationChange(true) Page->>S: PUT /api/v2/oauth2-provider/settings<br/>{dynamic_client_registration_enabled: true} S-->>Page: 200 OK (audited) Page->>S: GET /api/v2/oauth2-provider/settings (refetch) S-->>Page: { dynamic_client_registration_enabled: true } Page-->>View: section shows the "Enabled" badge and a Disable button Note over Admin,View: Admin disables DCR Admin->>View: click "Disable" View->>Page: onDynamicClientRegistrationChange(false)<br/>(no dialog, disable is immediate) Page->>S: PUT ... {dynamic_client_registration_enabled: false} S-->>Page: 200 OK (audited) ``` ## Files changed All 10 files are hand-written; nothing in this PR is `make gen` output. | File | What changed | |---|---| | `site/src/api/api.ts` | New `getOAuth2ProviderSettings`/`putOAuth2ProviderSettings` methods, thin typed wrappers around the two endpoints #27316 added to `main`. | | `site/src/api/api.test.ts` | Covers both methods against the request they issue and the error they propagate. | | `site/src/api/queries/oauth2.ts` | A `getSettings` query and a `putSettings` mutation that invalidates the settings key on success. Both the app and settings keys now derive from a shared `oauth2ProviderKey` constant. | | `site/src/api/queries/oauth2.test.ts` | 4 tests: the key nesting, both delegations, and that a successful update invalidates the settings key without touching app queries. | | `.../OAuth2AppsSettingsPage.tsx` | Wires query and mutation into the page and passes the settings values down as one object, or omits it entirely without `viewDeploymentConfig`. The apps error stays its own prop, since the view gates the applications empty state on it. | | `.../OAuth2AppsSettingsPageView.tsx` | `Tabs` splitting Applications from Settings. The settings tab distinguishes loading, failed, and a value the server omitted rather than rendering nothing, and the header's "Add application" action is scoped to the applications tab. | | `.../OAuth2AppsSettingsPageView.stories.tsx` | 14 stories, covering the tab wiring, both permission boundaries, the header action's scope, and the settings tab's loading, fetch-error, update-error, and value-omitted states. | | `.../DynamicClientRegistrationSetting.tsx` | The section itself: heading, description including what disabling does not undo, `Enabled` badge, a permission explanation when the viewer cannot edit, and one button that confirms only in the enable direction. | | `.../DynamicClientRegistrationSetting.stories.tsx` | 11 stories, including focus surviving an in-flight request and the dialog ignoring a value that changes underneath it. | | `docs/admin/integrations/oauth2-provider.md` | Adds the web UI route to the DCR section, which previously enumerated only the CLI and the management API. | ## Suggested review order Follows the direction data actually flows, from the raw HTTP call up to the rendered section. 1. **`site/src/api/api.ts`**: the two new methods. Confirms they match the `codersdk.OAuth2ProviderSettings` shape #27316 landed and sit next to the existing OAuth2 app methods they mirror. 2. **`site/src/api/queries/oauth2.ts`**: the query/mutation pair. The mutation's `onSuccess` → `invalidateQueries` is the one detail worth double-checking: it's what makes the on-screen state catch up with what was just saved, rather than trusting the PUT payload. 3. **`OAuth2AppsSettingsPage.tsx`**: the container. Check the two separate permission gates (`viewDeploymentConfig` on the query's `enabled` option, `editDeploymentConfig` on the button's editability) match the RBAC the backend enforces. 4. **`OAuth2AppsSettingsPageView.tsx`**: the tabs and the settings tab's four states. The `settings` prop being optional is what hides the tab; the error inside the tab is deliberately separate from the page-level `error`, which gates the applications empty state. 5. **`DynamicClientRegistrationSetting.tsx`**: the section. Two things worth reading closely: the enable path opens the dialog while the disable path calls straight through, and lacking permission uses the native `disabled` attribute while an in-flight request uses `aria-disabled`, so a keyboard user is not blurred mid-flip. 6. **The two story files**: read last, as they exercise everything above without a real server. The dialog stories query `canvasElement.ownerDocument.body` rather than `canvasElement`, since the dialog renders into a portal attached to `<body>`. ## Deliberately not in this PR - **ENG-3116**: the applications list cannot distinguish self-registered clients from admin-created ones. Surfacing that needs a new field on `codersdk.OAuth2ProviderApp`, which is an API addition this PR does not need. - **ENG-3118**: reusing the shared `EnabledBadge` and `SettingsHeader` primitives for this section. Both hinge on what the mockup intends, and the badge in particular is a visible change either here or on the four other pages that share it. ## Screenshots Default (disabled): <img width="1676" height="497" alt="image" src="https://github.com/user-attachments/assets/cfa60266-8678-410e-9577-16ef474491e3" /> Enabling (confirmation dialog): <img width="1661" height="558" alt="image" src="https://github.com/user-attachments/assets/a7d54fdd-f65d-4fec-9ed9-3bfdcfdae5be" /> Enabled: <img width="1666" height="559" alt="image" src="https://github.com/user-attachments/assets/d39251c2-771c-4608-81c2-dda151b35c3d" /> --------- Co-authored-by: Tracy Johnson <tracy@coder.com>
663 lines
19 KiB
TypeScript
663 lines
19 KiB
TypeScript
import {
|
|
MockProvisionerJob,
|
|
MockStoppedWorkspace,
|
|
MockTemplate,
|
|
MockTemplateVersion2,
|
|
MockWorkspace,
|
|
MockWorkspaceBuild,
|
|
} from "#/testHelpers/entities";
|
|
import { API, getURLWithSearchParams, ParameterValidationError } from "./api";
|
|
import type * as TypesGen from "./typesGenerated";
|
|
|
|
const axiosInstance = API.getAxiosInstance();
|
|
|
|
describe("api.ts", () => {
|
|
describe("login", () => {
|
|
it("should return LoginResponse", async () => {
|
|
// given
|
|
const loginResponse: TypesGen.LoginWithPasswordResponse = {
|
|
session_token: "abc_123_test",
|
|
};
|
|
|
|
vi.spyOn(axiosInstance, "post").mockResolvedValueOnce({
|
|
data: loginResponse,
|
|
});
|
|
|
|
// when
|
|
const result = await API.login("test", "123");
|
|
|
|
// then
|
|
expect(axiosInstance.post).toHaveBeenCalled();
|
|
expect(result).toStrictEqual(loginResponse);
|
|
});
|
|
|
|
it("should throw an error on 401", async () => {
|
|
// given
|
|
// ..ensure that we await our expect assertion in async/await test
|
|
expect.assertions(1);
|
|
const expectedError = {
|
|
message: "Validation failed",
|
|
errors: [{ field: "email", code: "email" }],
|
|
};
|
|
const axiosMockPost = vi.fn().mockImplementationOnce(() => {
|
|
return Promise.reject(expectedError);
|
|
});
|
|
axiosInstance.post = axiosMockPost;
|
|
|
|
try {
|
|
await API.login("test", "123");
|
|
} catch (error) {
|
|
expect(error).toStrictEqual(expectedError);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("logout", () => {
|
|
it("should return without erroring", async () => {
|
|
// given
|
|
const axiosMockPost = vi.fn().mockImplementationOnce(() => {
|
|
return Promise.resolve();
|
|
});
|
|
axiosInstance.post = axiosMockPost;
|
|
|
|
// when
|
|
await API.logout();
|
|
|
|
// then
|
|
expect(axiosMockPost).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should throw an error on 500", async () => {
|
|
// given
|
|
// ..ensure that we await our expect assertion in async/await test
|
|
expect.assertions(1);
|
|
const expectedError = {
|
|
message: "Failed to logout.",
|
|
};
|
|
const axiosMockPost = vi.fn().mockImplementationOnce(() => {
|
|
return Promise.reject(expectedError);
|
|
});
|
|
|
|
axiosInstance.post = axiosMockPost;
|
|
|
|
try {
|
|
await API.logout();
|
|
} catch (error) {
|
|
expect(error).toStrictEqual(expectedError);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("getApiKey", () => {
|
|
it("should return APIKeyResponse", async () => {
|
|
// given
|
|
const apiKeyResponse: TypesGen.GenerateAPIKeyResponse = {
|
|
key: "abc_123_test",
|
|
};
|
|
const axiosMockPost = vi.fn().mockImplementationOnce(() => {
|
|
return Promise.resolve({ data: apiKeyResponse });
|
|
});
|
|
|
|
axiosInstance.post = axiosMockPost;
|
|
|
|
// when
|
|
const result = await API.getApiKey();
|
|
|
|
// then
|
|
expect(axiosMockPost).toHaveBeenCalled();
|
|
expect(result).toStrictEqual(apiKeyResponse);
|
|
});
|
|
|
|
it("should throw an error on 401", async () => {
|
|
// given
|
|
// ..ensure that we await our expect assertion in async/await test
|
|
expect.assertions(1);
|
|
const expectedError = {
|
|
message: "No Cookie!",
|
|
};
|
|
const axiosMockPost = vi.fn().mockImplementationOnce(() => {
|
|
return Promise.reject(expectedError);
|
|
});
|
|
|
|
axiosInstance.post = axiosMockPost;
|
|
|
|
try {
|
|
await API.getApiKey();
|
|
} catch (error) {
|
|
expect(error).toStrictEqual(expectedError);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("getURLWithSearchParams - workspaces", () => {
|
|
it.each<[string, TypesGen.WorkspaceFilter | undefined, string]>([
|
|
["/api/v2/workspaces", undefined, "/api/v2/workspaces"],
|
|
|
|
["/api/v2/workspaces", { q: "" }, "/api/v2/workspaces"],
|
|
[
|
|
"/api/v2/workspaces",
|
|
{ q: "owner:1" },
|
|
"/api/v2/workspaces?q=owner%3A1",
|
|
],
|
|
|
|
[
|
|
"/api/v2/workspaces",
|
|
{ q: "owner:me" },
|
|
"/api/v2/workspaces?q=owner%3Ame",
|
|
],
|
|
])("Workspaces - getURLWithSearchParams(%p, %p) returns %p", (basePath, filter, expected) => {
|
|
expect(getURLWithSearchParams(basePath, filter)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("getURLWithSearchParams - users", () => {
|
|
it.each<[string, TypesGen.UsersRequest | undefined, string]>([
|
|
["/api/v2/users", undefined, "/api/v2/users"],
|
|
[
|
|
"/api/v2/users",
|
|
{ q: "status:active" },
|
|
"/api/v2/users?q=status%3Aactive",
|
|
],
|
|
["/api/v2/users", { q: "" }, "/api/v2/users"],
|
|
])("Users - getURLWithSearchParams(%p, %p) returns %p", (basePath, filter, expected) => {
|
|
expect(getURLWithSearchParams(basePath, filter)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("AI spend requests", () => {
|
|
const window = {
|
|
period_start: "2026-07-01T00:00:00Z",
|
|
period_end: "2026-08-01T00:00:00Z",
|
|
};
|
|
|
|
// Each endpoint's request, URL path, and response for the given IDs.
|
|
const endpoints = [
|
|
{
|
|
name: "getOrganizationGroupsAISpend",
|
|
path: "/api/v2/organizations/my-org/groups/ai/spend",
|
|
request: (ids: string[]) =>
|
|
API.getOrganizationGroupsAISpend("my-org", ids),
|
|
response: (ids: string[]) => ({
|
|
...window,
|
|
groups: ids.map((id) => ({
|
|
group_id: id,
|
|
spend_micros: 0,
|
|
budget: null,
|
|
})),
|
|
}),
|
|
},
|
|
{
|
|
name: "getGroupMembersAISpend",
|
|
path: "/api/v2/groups/group-1/members/ai/spend",
|
|
request: (ids: string[]) => API.getGroupMembersAISpend("group-1", ids),
|
|
response: (ids: string[]) => ({
|
|
...window,
|
|
members: ids.map((id) => ({
|
|
user_id: id,
|
|
effective_group_id: null,
|
|
group_budget: null,
|
|
group_spend_micros: 0,
|
|
})),
|
|
}),
|
|
},
|
|
];
|
|
|
|
afterEach(() => {
|
|
// The suite doesn't auto-restore mocks; don't leak the stubs.
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe.each(endpoints)("$name", ({ path, request, response }) => {
|
|
it("rejects an empty ID list without sending a request", async () => {
|
|
const getSpy = vi
|
|
.spyOn(axiosInstance, "get")
|
|
.mockResolvedValue({ data: {} });
|
|
|
|
await expect(request([])).rejects.toThrow(/must not be empty/);
|
|
expect(getSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sends a single request for up to 100 IDs", async () => {
|
|
const ids = Array.from({ length: 25 }, (_, i) => `id-${i}`);
|
|
const getSpy = vi
|
|
.spyOn(axiosInstance, "get")
|
|
.mockResolvedValueOnce({ data: response(ids) });
|
|
|
|
const result = await request(ids);
|
|
|
|
expect(getSpy).toHaveBeenCalledTimes(1);
|
|
expect(getSpy.mock.calls[0][0]).toContain(path);
|
|
expect(getSpy.mock.calls[0][0]).toContain(
|
|
encodeURIComponent(ids.join(",")),
|
|
);
|
|
expect(result).toStrictEqual(response(ids));
|
|
});
|
|
|
|
it("batches requests of 100 IDs and merges the results", async () => {
|
|
const ids = Array.from({ length: 150 }, (_, i) => `id-${i}`);
|
|
const getSpy = vi
|
|
.spyOn(axiosInstance, "get")
|
|
.mockResolvedValueOnce({ data: response(ids.slice(0, 100)) })
|
|
.mockResolvedValueOnce({ data: response(ids.slice(100)) });
|
|
|
|
const result = await request(ids);
|
|
|
|
expect(getSpy).toHaveBeenCalledTimes(2);
|
|
expect(getSpy.mock.calls[0][0]).toContain(
|
|
encodeURIComponent(ids.slice(0, 100).join(",")),
|
|
);
|
|
expect(getSpy.mock.calls[1][0]).toContain(
|
|
encodeURIComponent(ids.slice(100).join(",")),
|
|
);
|
|
expect(result).toStrictEqual(response(ids));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("update", () => {
|
|
describe("given a running workspace", () => {
|
|
it("stops with current version before starting with the latest version", async () => {
|
|
vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
|
|
...MockWorkspaceBuild,
|
|
transition: "stop",
|
|
});
|
|
vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
|
|
...MockWorkspaceBuild,
|
|
template_version_id: MockTemplateVersion2.id,
|
|
transition: "start",
|
|
});
|
|
vi.spyOn(API, "getTemplate").mockResolvedValueOnce({
|
|
...MockTemplate,
|
|
active_version_id: MockTemplateVersion2.id,
|
|
});
|
|
await API.updateWorkspace(MockWorkspace);
|
|
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
|
|
transition: "stop",
|
|
log_level: undefined,
|
|
});
|
|
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
|
|
transition: "start",
|
|
template_version_id: MockTemplateVersion2.id,
|
|
rich_parameter_values: [],
|
|
});
|
|
});
|
|
|
|
it("fails when the server returns parameter validation errors", async () => {
|
|
const validationErrors = [
|
|
{ field: "first_parameter", detail: "First parameter is required" },
|
|
{
|
|
field: "second_parameter",
|
|
detail: "Second parameter is not valid",
|
|
},
|
|
];
|
|
|
|
vi.spyOn(API, "postWorkspaceBuild")
|
|
// First postWorkspaceBuild call is for the stop, which should succeed
|
|
.mockResolvedValueOnce({
|
|
...MockWorkspaceBuild,
|
|
transition: "stop",
|
|
})
|
|
// Second postWorkspaceBuild call is for the start, which should fail
|
|
// with validation errors.
|
|
.mockRejectedValueOnce({
|
|
isAxiosError: true,
|
|
response: {
|
|
status: 400,
|
|
data: {
|
|
message: "Invalid build parameters",
|
|
validations: validationErrors,
|
|
},
|
|
},
|
|
});
|
|
vi.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
|
|
|
|
let error: unknown;
|
|
try {
|
|
await API.updateWorkspace(MockWorkspace);
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(ParameterValidationError);
|
|
if (!(error instanceof ParameterValidationError)) {
|
|
throw new Error("expected a ParameterValidationError");
|
|
}
|
|
expect(error.versionId).toBe(MockTemplate.active_version_id);
|
|
expect(error.validations).toEqual(validationErrors);
|
|
});
|
|
|
|
it("succeeds when the server accepts the build parameters", async () => {
|
|
vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
|
|
...MockWorkspaceBuild,
|
|
transition: "stop",
|
|
});
|
|
vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
|
|
...MockWorkspaceBuild,
|
|
template_version_id: MockTemplate.active_version_id,
|
|
transition: "start",
|
|
});
|
|
vi.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate);
|
|
await API.updateWorkspace(MockWorkspace);
|
|
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
|
|
transition: "stop",
|
|
log_level: undefined,
|
|
});
|
|
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
|
|
transition: "start",
|
|
template_version_id: MockTemplate.active_version_id,
|
|
rich_parameter_values: [],
|
|
});
|
|
});
|
|
});
|
|
describe("given a stopped workspace", () => {
|
|
it("creates a build with start and the latest template", async () => {
|
|
vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce(
|
|
MockWorkspaceBuild,
|
|
);
|
|
vi.spyOn(API, "getTemplate").mockResolvedValueOnce({
|
|
...MockTemplate,
|
|
active_version_id: MockTemplateVersion2.id,
|
|
});
|
|
await API.updateWorkspace(MockStoppedWorkspace);
|
|
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(
|
|
MockStoppedWorkspace.id,
|
|
{
|
|
transition: "start",
|
|
template_version_id: MockTemplateVersion2.id,
|
|
rich_parameter_values: [],
|
|
},
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("changeWorkspaceVersion", () => {
|
|
it("stops workspace before changing version if running", async () => {
|
|
vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({
|
|
...MockWorkspaceBuild,
|
|
transition: "stop",
|
|
});
|
|
vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({
|
|
...MockProvisionerJob,
|
|
status: "succeeded",
|
|
});
|
|
vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
|
|
...MockWorkspaceBuild,
|
|
template_version_id: MockTemplateVersion2.id,
|
|
transition: "start",
|
|
});
|
|
|
|
await API.changeWorkspaceVersion(MockWorkspace, MockTemplateVersion2.id);
|
|
|
|
expect(API.stopWorkspace).toHaveBeenCalledWith(MockWorkspace.id);
|
|
expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
|
|
transition: "start",
|
|
template_version_id: MockTemplateVersion2.id,
|
|
rich_parameter_values: [],
|
|
});
|
|
});
|
|
|
|
it("does not stop workspace if already stopped", async () => {
|
|
vi.spyOn(API, "stopWorkspace");
|
|
vi.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({
|
|
...MockWorkspaceBuild,
|
|
template_version_id: MockTemplateVersion2.id,
|
|
transition: "start",
|
|
});
|
|
|
|
await API.changeWorkspaceVersion(
|
|
MockStoppedWorkspace,
|
|
MockTemplateVersion2.id,
|
|
);
|
|
|
|
expect(API.stopWorkspace).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects if stop is canceled", async () => {
|
|
vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({
|
|
...MockWorkspaceBuild,
|
|
transition: "stop",
|
|
});
|
|
vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({
|
|
...MockProvisionerJob,
|
|
status: "canceled",
|
|
});
|
|
vi.spyOn(API, "postWorkspaceBuild");
|
|
|
|
await expect(
|
|
API.changeWorkspaceVersion(MockWorkspace, MockTemplateVersion2.id),
|
|
).rejects.toThrow("Workspace stop was canceled");
|
|
expect(API.postWorkspaceBuild).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("chat configuration endpoints", () => {
|
|
it.each<[string, () => Promise<unknown>, unknown]>([
|
|
[
|
|
"/api/experimental/chats/models",
|
|
() => API.experimental.getChatModels(),
|
|
{
|
|
providers: [],
|
|
},
|
|
],
|
|
[
|
|
"/api/experimental/chats/model-configs",
|
|
() => API.experimental.getChatModelConfigs(),
|
|
[],
|
|
],
|
|
])("returns response data for %s", async (path, request, responseData) => {
|
|
vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({
|
|
data: responseData,
|
|
});
|
|
|
|
const result = await request();
|
|
|
|
expect(axiosInstance.get).toHaveBeenCalledWith(path);
|
|
expect(result).toStrictEqual(responseData);
|
|
});
|
|
|
|
it.each<[string, () => Promise<unknown>]>([
|
|
[
|
|
"/api/experimental/chats/models",
|
|
() => API.experimental.getChatModels(),
|
|
],
|
|
[
|
|
"/api/experimental/chats/model-configs",
|
|
() => API.experimental.getChatModelConfigs(),
|
|
],
|
|
])("rethrows axios errors for %s", async (path, request) => {
|
|
const expectedError = new Error("request failed");
|
|
vi.spyOn(axiosInstance, "get").mockRejectedValueOnce(expectedError);
|
|
|
|
await expect(request()).rejects.toBe(expectedError);
|
|
expect(axiosInstance.get).toHaveBeenCalledWith(path);
|
|
});
|
|
});
|
|
|
|
describe("user secrets endpoints", () => {
|
|
const userId = "me";
|
|
const secretName = "EXAMPLE_TOKEN";
|
|
const secretNameWithPathChars = "foo%2Fbar value";
|
|
const userSecret: TypesGen.UserSecret = {
|
|
id: "00000000-0000-0000-0000-000000000001",
|
|
name: secretName,
|
|
description: "Example token for tests",
|
|
env_name: secretName,
|
|
file_path: "",
|
|
enabled: true,
|
|
created_at: "2026-05-04T00:00:00Z",
|
|
updated_at: "2026-05-04T00:00:00Z",
|
|
};
|
|
|
|
it("lists user secrets with the correct method and URL", async () => {
|
|
const axiosMockGet = vi.fn().mockResolvedValueOnce({
|
|
data: [userSecret],
|
|
});
|
|
axiosInstance.get = axiosMockGet;
|
|
|
|
const result = await API.getUserSecrets(userId);
|
|
|
|
expect(axiosMockGet).toHaveBeenCalledWith("/api/v2/users/me/secrets");
|
|
expect(result).toStrictEqual([userSecret]);
|
|
});
|
|
|
|
it("gets a user secret with the correct method and URL", async () => {
|
|
const axiosMockGet = vi.fn().mockResolvedValueOnce({
|
|
data: userSecret,
|
|
});
|
|
axiosInstance.get = axiosMockGet;
|
|
|
|
const result = await API.getUserSecret(userId, secretNameWithPathChars);
|
|
|
|
expect(axiosMockGet).toHaveBeenCalledWith(
|
|
"/api/v2/users/me/secrets/foo%252Fbar%20value",
|
|
);
|
|
expect(result).toStrictEqual(userSecret);
|
|
});
|
|
|
|
it("creates a user secret with the correct method and URL", async () => {
|
|
const request: TypesGen.CreateUserSecretRequest = {
|
|
name: secretName,
|
|
value: "",
|
|
description: "Example token for tests",
|
|
env_name: secretName,
|
|
};
|
|
const axiosMockPost = vi.fn().mockResolvedValueOnce({
|
|
data: userSecret,
|
|
});
|
|
axiosInstance.post = axiosMockPost;
|
|
|
|
const result = await API.createUserSecret(userId, request);
|
|
|
|
expect(axiosMockPost).toHaveBeenCalledWith(
|
|
"/api/v2/users/me/secrets",
|
|
request,
|
|
);
|
|
expect(result).toStrictEqual(userSecret);
|
|
});
|
|
|
|
it("updates a user secret with the correct method and URL", async () => {
|
|
const request: TypesGen.UpdateUserSecretRequest = {
|
|
description: "Updated example token for tests",
|
|
};
|
|
const updatedSecret: TypesGen.UserSecret = {
|
|
...userSecret,
|
|
description: "Updated example token for tests",
|
|
updated_at: "2026-05-04T00:01:00Z",
|
|
};
|
|
const axiosMockPatch = vi.fn().mockResolvedValueOnce({
|
|
data: updatedSecret,
|
|
});
|
|
axiosInstance.patch = axiosMockPatch;
|
|
|
|
const result = await API.updateUserSecret(
|
|
userId,
|
|
secretNameWithPathChars,
|
|
request,
|
|
);
|
|
|
|
expect(axiosMockPatch).toHaveBeenCalledWith(
|
|
"/api/v2/users/me/secrets/foo%252Fbar%20value",
|
|
request,
|
|
);
|
|
expect(result).toStrictEqual(updatedSecret);
|
|
});
|
|
|
|
it("deletes a user secret with the correct method and URL", async () => {
|
|
const axiosMockDelete = vi.fn().mockResolvedValueOnce(undefined);
|
|
axiosInstance.delete = axiosMockDelete;
|
|
|
|
await API.deleteUserSecret(userId, secretNameWithPathChars);
|
|
|
|
expect(axiosMockDelete).toHaveBeenCalledWith(
|
|
"/api/v2/users/me/secrets/foo%252Fbar%20value",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("chat ACL endpoints", () => {
|
|
const chatId = "chat-1";
|
|
const chatACL: TypesGen.ChatACL = {
|
|
users: [],
|
|
groups: [],
|
|
};
|
|
|
|
it("gets a chat ACL", async () => {
|
|
vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({
|
|
data: chatACL,
|
|
});
|
|
|
|
const result = await API.experimental.getChatACL(chatId);
|
|
|
|
expect(axiosInstance.get).toHaveBeenCalledWith(
|
|
`/api/experimental/chats/${chatId}/acl`,
|
|
);
|
|
expect(result).toStrictEqual(chatACL);
|
|
});
|
|
|
|
it("updates a chat ACL", async () => {
|
|
const request: TypesGen.UpdateChatACL = {
|
|
user_roles: { "user-1": "read" },
|
|
};
|
|
|
|
vi.spyOn(axiosInstance, "patch").mockResolvedValueOnce({});
|
|
|
|
await API.experimental.updateChatACL(chatId, request);
|
|
|
|
expect(axiosInstance.patch).toHaveBeenCalledWith(
|
|
`/api/experimental/chats/${chatId}/acl`,
|
|
request,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("oauth2 provider settings", () => {
|
|
const settings: TypesGen.OAuth2ProviderSettings = {
|
|
dynamic_client_registration_enabled: true,
|
|
};
|
|
|
|
it("gets oauth2 provider settings", async () => {
|
|
vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({
|
|
data: settings,
|
|
});
|
|
|
|
const result = await API.getOAuth2ProviderSettings();
|
|
|
|
expect(axiosInstance.get).toHaveBeenCalledWith(
|
|
"/api/v2/oauth2-provider/settings",
|
|
);
|
|
expect(result).toStrictEqual(settings);
|
|
});
|
|
|
|
it("propagates errors when getting oauth2 provider settings", async () => {
|
|
const expectedError = new Error("request failed");
|
|
vi.spyOn(axiosInstance, "get").mockRejectedValueOnce(expectedError);
|
|
|
|
await expect(API.getOAuth2ProviderSettings()).rejects.toBe(expectedError);
|
|
});
|
|
|
|
it("updates oauth2 provider settings", async () => {
|
|
vi.spyOn(axiosInstance, "put").mockResolvedValueOnce({
|
|
data: settings,
|
|
});
|
|
|
|
const result = await API.putOAuth2ProviderSettings(settings);
|
|
|
|
expect(axiosInstance.put).toHaveBeenCalledWith(
|
|
"/api/v2/oauth2-provider/settings",
|
|
settings,
|
|
);
|
|
expect(result).toStrictEqual(settings);
|
|
});
|
|
|
|
it("propagates errors when updating oauth2 provider settings", async () => {
|
|
const expectedError = new Error("request failed");
|
|
vi.spyOn(axiosInstance, "put").mockRejectedValueOnce(expectedError);
|
|
|
|
await expect(API.putOAuth2ProviderSettings(settings)).rejects.toBe(
|
|
expectedError,
|
|
);
|
|
});
|
|
});
|
|
});
|