Files
coder/site/src/api/api.test.ts
T
Zach 85984ff142 feat: add enable/disable support for user secrets (#27537)
Users can now disable a secret to stop it from being injected into
workspaces without deleting it, and re-enable it later. Disabled secrets
stay visible and editable everywhere they already appear.

An enabled secret must have at least one injection target; a secret with
no target can be stored only while disabled. Existing target-less secrets
are migrated to disabled to preserve current behavior.

Support spans the REST API, SDK, CLI, dashboard, and audit log.
2026-07-28 09:58:33 -06:00

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