Files
coder/site/src/api/api.test.ts
T
Bobby HoandTracy Johnson 4245e4e378 feat: expose dynamic client registration in deployment settings (#27480)
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>
2026-08-03 08:29:35 -07:00

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,
);
});
});
});