@miosa/sdk 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +84 -8
- package/dist/index.js +90 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +9 -2
- package/src/resources/admin.ts +11 -0
- package/src/resources/api-keys.ts +16 -0
- package/src/resources/custom_domains.ts +1 -1
- package/src/resources/deployments.test.ts +55 -0
- package/src/resources/deployments.ts +30 -1
- package/src/resources/governance.test.ts +355 -0
- package/src/resources/governance.ts +528 -0
- package/src/resources/sandboxes.test.ts +26 -0
- package/src/resources/sandboxes.ts +31 -0
- package/src/resources/tenant.ts +27 -0
- package/src/resources/webhooks.ts +69 -1
- package/src/types.ts +5 -5
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -122,6 +122,7 @@ export type {
|
|
|
122
122
|
DeploymentData,
|
|
123
123
|
DeploymentId,
|
|
124
124
|
DeploymentListParams,
|
|
125
|
+
DeploymentProduct,
|
|
125
126
|
DeploymentReleaseData,
|
|
126
127
|
DeploymentReleaseId,
|
|
127
128
|
DeploymentServiceData,
|
|
@@ -134,6 +135,7 @@ export type {
|
|
|
134
135
|
DeploymentVersionId,
|
|
135
136
|
DeploymentVersionKind,
|
|
136
137
|
DeploymentVersionState,
|
|
138
|
+
DockerDeployCreateParams,
|
|
137
139
|
ExternalAttribution,
|
|
138
140
|
PublishFromSandboxParams,
|
|
139
141
|
PublishParams,
|
|
@@ -216,7 +218,7 @@ export type {
|
|
|
216
218
|
HealthCheckCreateParams,
|
|
217
219
|
HealthCheckUpdateParams,
|
|
218
220
|
} from "./resources/health-checks.js";
|
|
219
|
-
export { Webhooks } from "./resources/webhooks.js";
|
|
221
|
+
export { verifySignature, Webhooks } from "./resources/webhooks.js";
|
|
220
222
|
export type {
|
|
221
223
|
WebhookId,
|
|
222
224
|
WebhookDeliveryId,
|
|
@@ -285,7 +287,12 @@ export type {
|
|
|
285
287
|
|
|
286
288
|
// P2 resources
|
|
287
289
|
export { Tenant } from "./resources/tenant.js";
|
|
288
|
-
export type {
|
|
290
|
+
export type {
|
|
291
|
+
BrandingData,
|
|
292
|
+
PreviewDomainData,
|
|
293
|
+
TenantBrandingUpdateParams,
|
|
294
|
+
TenantPlan,
|
|
295
|
+
} from "./resources/tenant.js";
|
|
289
296
|
export { Regions } from "./resources/regions.js";
|
|
290
297
|
export type {
|
|
291
298
|
RegionData,
|
package/src/resources/admin.ts
CHANGED
|
@@ -345,4 +345,15 @@ export class Admin {
|
|
|
345
345
|
model_id: modelId,
|
|
346
346
|
});
|
|
347
347
|
}
|
|
348
|
+
|
|
349
|
+
/** POST /api/v1/admin/impersonate — returns {token, expires_at}. */
|
|
350
|
+
impersonate(
|
|
351
|
+
externalUserId: string,
|
|
352
|
+
options: { ttlSec?: number } = {},
|
|
353
|
+
): Promise<{ token: string; expires_at: string }> {
|
|
354
|
+
return this.http.post("/admin/impersonate", {
|
|
355
|
+
external_user_id: externalUserId,
|
|
356
|
+
ttl_sec: options.ttlSec ?? 3600,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
348
359
|
}
|
|
@@ -113,6 +113,22 @@ export class ApiKeys {
|
|
|
113
113
|
return unwrap<ApiKeyCreateResult>(data);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** POST /api/v1/api-keys/scoped — L2 delegation token bound to one external user. */
|
|
117
|
+
async createScoped(params: {
|
|
118
|
+
externalUserId: string;
|
|
119
|
+
scopes: string[];
|
|
120
|
+
expiresAt?: string;
|
|
121
|
+
}): Promise<ApiKeyCreateResult> {
|
|
122
|
+
const body = stripUndefined({
|
|
123
|
+
external_user_id: params.externalUserId,
|
|
124
|
+
scopes: params.scopes,
|
|
125
|
+
expires_at: params.expiresAt,
|
|
126
|
+
});
|
|
127
|
+
return unwrap<ApiKeyCreateResult>(
|
|
128
|
+
await this.http.post<unknown>("/api-keys/scoped", body),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
116
132
|
async delete(keyId: string): Promise<void> {
|
|
117
133
|
await this.http.delete<unknown>(`/api-keys/${keyId}`);
|
|
118
134
|
}
|
|
@@ -41,7 +41,7 @@ export interface CustomDomainRegisterParams {
|
|
|
41
41
|
* // 1. Register the domain
|
|
42
42
|
* const domain = await computer.domains.register("app.example.com");
|
|
43
43
|
* console.log(domain.instructions);
|
|
44
|
-
* // => "Add a CNAME record: app.example.com → <slug>.sandbox
|
|
44
|
+
* // => "Add a CNAME record: app.example.com → <slug>.sandbox.miosa.ai"
|
|
45
45
|
*
|
|
46
46
|
* // 2. Add the CNAME in your DNS registrar, then...
|
|
47
47
|
*
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { HttpClient } from "../http.js";
|
|
3
|
+
import { Deployments } from "./deployments.js";
|
|
4
|
+
|
|
5
|
+
const mockRequest = vi.fn();
|
|
6
|
+
|
|
7
|
+
function makeHttp(): HttpClient {
|
|
8
|
+
const http = {} as HttpClient;
|
|
9
|
+
http.request = mockRequest;
|
|
10
|
+
return http;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("Deployments", () => {
|
|
18
|
+
it("createDockerDeploy() marks the deployment for Docker Deploy", async () => {
|
|
19
|
+
mockRequest.mockResolvedValue({
|
|
20
|
+
data: {
|
|
21
|
+
id: "dep_123",
|
|
22
|
+
tenant_id: "ten_123",
|
|
23
|
+
name: "Clinic Intake",
|
|
24
|
+
slug: "clinic-intake",
|
|
25
|
+
state: "pending",
|
|
26
|
+
deployment_product: "docker_deploy",
|
|
27
|
+
docker_deploy_host_id: "ddh_123",
|
|
28
|
+
metadata: { deployment_product: "docker_deploy", client: "clinic-iq" },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const deployment = await new Deployments(makeHttp()).createDockerDeploy({
|
|
33
|
+
name: "Clinic Intake",
|
|
34
|
+
repoUrl: "https://github.com/clinic-iq/intake",
|
|
35
|
+
externalWorkspaceId: "dr-smith",
|
|
36
|
+
externalProjectId: "lead-magnet",
|
|
37
|
+
metadata: { client: "clinic-iq" },
|
|
38
|
+
idempotencyKey: "idem-123",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(mockRequest).toHaveBeenCalledWith("/deployments", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
body: {
|
|
44
|
+
name: "Clinic Intake",
|
|
45
|
+
repo_url: "https://github.com/clinic-iq/intake",
|
|
46
|
+
metadata: { client: "clinic-iq", deployment_product: "docker_deploy" },
|
|
47
|
+
external_workspace_id: "dr-smith",
|
|
48
|
+
external_project_id: "lead-magnet",
|
|
49
|
+
},
|
|
50
|
+
headers: { "Idempotency-Key": "idem-123" },
|
|
51
|
+
});
|
|
52
|
+
expect(deployment.deployment_product).toBe("docker_deploy");
|
|
53
|
+
expect(deployment.docker_deploy_host_id).toBe("ddh_123");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -50,6 +50,8 @@ export type DeploymentVersionState =
|
|
|
50
50
|
|
|
51
51
|
export type DeploymentSourceType = "repo" | "sandbox" | "upload";
|
|
52
52
|
|
|
53
|
+
export type DeploymentProduct = "miosa_deploy" | "docker_deploy";
|
|
54
|
+
|
|
53
55
|
export type DeploymentServiceType =
|
|
54
56
|
| "static_web"
|
|
55
57
|
| "web"
|
|
@@ -107,6 +109,8 @@ export interface DeploymentData {
|
|
|
107
109
|
auto_deploy?: boolean;
|
|
108
110
|
custom_domain_id?: string | null;
|
|
109
111
|
linked_database_id?: string | null;
|
|
112
|
+
deployment_product?: DeploymentProduct | string | null;
|
|
113
|
+
docker_deploy_host_id?: string | null;
|
|
110
114
|
metadata?: Record<string, unknown>;
|
|
111
115
|
external_workspace_id?: string | null;
|
|
112
116
|
external_user_id?: string | null;
|
|
@@ -119,7 +123,7 @@ export interface DeploymentData {
|
|
|
119
123
|
export type DeploymentDatabaseRequest =
|
|
120
124
|
| boolean
|
|
121
125
|
| {
|
|
122
|
-
engine?: "postgresql" | "mysql" | "redis";
|
|
126
|
+
engine?: "postgresql" | "mysql" | "redis" | "qdrant";
|
|
123
127
|
size?: "xs" | "small" | "medium" | "large";
|
|
124
128
|
storage_mb?: number;
|
|
125
129
|
region?: string;
|
|
@@ -281,6 +285,8 @@ export interface DeploymentCreateParams extends ExternalAttribution {
|
|
|
281
285
|
idempotencyKey?: string;
|
|
282
286
|
}
|
|
283
287
|
|
|
288
|
+
export interface DockerDeployCreateParams extends DeploymentCreateParams {}
|
|
289
|
+
|
|
284
290
|
export interface DeploymentUpdateParams {
|
|
285
291
|
name?: string;
|
|
286
292
|
branch?: string;
|
|
@@ -421,6 +427,15 @@ function stripUndefined(
|
|
|
421
427
|
);
|
|
422
428
|
}
|
|
423
429
|
|
|
430
|
+
function dockerDeployMetadata(
|
|
431
|
+
metadata: Record<string, unknown> | undefined,
|
|
432
|
+
): Record<string, unknown> {
|
|
433
|
+
return {
|
|
434
|
+
...(metadata ?? {}),
|
|
435
|
+
deployment_product: "docker_deploy",
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
424
439
|
// ── Sub-resources ──────────────────────────────────────────────────────────
|
|
425
440
|
|
|
426
441
|
export class DeploymentVersions {
|
|
@@ -627,6 +642,20 @@ export class Deployments {
|
|
|
627
642
|
return unwrap(data) as DeploymentData;
|
|
628
643
|
}
|
|
629
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Create a deployment that runs on the workspace's dedicated Docker Deploy
|
|
647
|
+
* runtime. It uses the same /deployments API as MIOSA Deploy, but marks the
|
|
648
|
+
* deployment so the control plane attaches it to the workspace Docker host.
|
|
649
|
+
*/
|
|
650
|
+
async createDockerDeploy(
|
|
651
|
+
params: DockerDeployCreateParams,
|
|
652
|
+
): Promise<DeploymentData> {
|
|
653
|
+
return this.create({
|
|
654
|
+
...params,
|
|
655
|
+
metadata: dockerDeployMetadata(params.metadata),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
630
659
|
async update(
|
|
631
660
|
deploymentId: string,
|
|
632
661
|
params: DeploymentUpdateParams,
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { HttpClient } from "../http.js";
|
|
3
|
+
import {
|
|
4
|
+
BillingResource,
|
|
5
|
+
BulkResource,
|
|
6
|
+
ExternalUsers,
|
|
7
|
+
GovernanceTenant,
|
|
8
|
+
GovernanceWorkspaces,
|
|
9
|
+
} from "./governance.js";
|
|
10
|
+
import { ApiKeys } from "./api-keys.js";
|
|
11
|
+
import { Admin } from "./admin.js";
|
|
12
|
+
|
|
13
|
+
const mockGet = vi.fn();
|
|
14
|
+
const mockPost = vi.fn();
|
|
15
|
+
const mockPut = vi.fn();
|
|
16
|
+
const mockPatch = vi.fn();
|
|
17
|
+
const mockDelete = vi.fn();
|
|
18
|
+
const mockStream = vi.fn();
|
|
19
|
+
const mockRequest = vi.fn();
|
|
20
|
+
|
|
21
|
+
function makeHttp(): HttpClient {
|
|
22
|
+
return {
|
|
23
|
+
get: mockGet,
|
|
24
|
+
post: mockPost,
|
|
25
|
+
put: mockPut,
|
|
26
|
+
patch: mockPatch,
|
|
27
|
+
delete: mockDelete,
|
|
28
|
+
request: mockRequest,
|
|
29
|
+
stream: mockStream,
|
|
30
|
+
} as unknown as HttpClient;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.resetAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── GovernanceTenant.policy ───────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe("GovernanceTenant.policy", () => {
|
|
40
|
+
it("get calls GET /tenant/policy and unwraps data", async () => {
|
|
41
|
+
mockGet.mockResolvedValue({ data: { quotas: { max_sandboxes: 10 } } });
|
|
42
|
+
const t = new GovernanceTenant(makeHttp());
|
|
43
|
+
const result = await t.policy.get();
|
|
44
|
+
expect(mockGet).toHaveBeenCalledWith("/tenant/policy");
|
|
45
|
+
expect((result as Record<string, unknown>).quotas).toBeDefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("set calls PUT /tenant/policy with body", async () => {
|
|
49
|
+
mockPut.mockResolvedValue({ data: { quotas: { max_sandboxes: 5 } } });
|
|
50
|
+
const t = new GovernanceTenant(makeHttp());
|
|
51
|
+
await t.policy.set({ quotas: { max_sandboxes: 5 } });
|
|
52
|
+
expect(mockPut).toHaveBeenCalledWith("/tenant/policy", {
|
|
53
|
+
quotas: { max_sandboxes: 5 },
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("delete calls DELETE /tenant/policy", async () => {
|
|
58
|
+
mockDelete.mockResolvedValue(null);
|
|
59
|
+
const t = new GovernanceTenant(makeHttp());
|
|
60
|
+
await t.policy.delete();
|
|
61
|
+
expect(mockDelete).toHaveBeenCalledWith("/tenant/policy");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── GovernanceTenant.members ──────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe("GovernanceTenant.members", () => {
|
|
68
|
+
it("list returns members array", async () => {
|
|
69
|
+
mockGet.mockResolvedValue({
|
|
70
|
+
data: [{ id: "m1", email: "a@b.com", role: "admin" }],
|
|
71
|
+
});
|
|
72
|
+
const t = new GovernanceTenant(makeHttp());
|
|
73
|
+
const members = await t.members.list();
|
|
74
|
+
expect(members).toHaveLength(1);
|
|
75
|
+
expect(members[0].role).toBe("admin");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("invite posts email and role", async () => {
|
|
79
|
+
mockPost.mockResolvedValue({ data: { id: "m2", role: "developer" } });
|
|
80
|
+
const t = new GovernanceTenant(makeHttp());
|
|
81
|
+
const result = await t.members.invite("x@y.com", "developer");
|
|
82
|
+
expect(mockPost).toHaveBeenCalledWith("/tenant/members", {
|
|
83
|
+
email: "x@y.com",
|
|
84
|
+
role: "developer",
|
|
85
|
+
});
|
|
86
|
+
expect(result.role).toBe("developer");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("updateRole patches member role", async () => {
|
|
90
|
+
mockPatch.mockResolvedValue({ data: { id: "m1", role: "admin" } });
|
|
91
|
+
const t = new GovernanceTenant(makeHttp());
|
|
92
|
+
await t.members.updateRole("m1", "admin");
|
|
93
|
+
expect(mockPatch).toHaveBeenCalledWith("/tenant/members/m1/role", {
|
|
94
|
+
role: "admin",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("remove deletes member", async () => {
|
|
99
|
+
mockDelete.mockResolvedValue(null);
|
|
100
|
+
const t = new GovernanceTenant(makeHttp());
|
|
101
|
+
await t.members.remove("m1");
|
|
102
|
+
expect(mockDelete).toHaveBeenCalledWith("/tenant/members/m1");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("transferOwnership posts new_owner_user_id", async () => {
|
|
106
|
+
mockPost.mockResolvedValue({ data: { transferred: true } });
|
|
107
|
+
const t = new GovernanceTenant(makeHttp());
|
|
108
|
+
await t.members.transferOwnership("user_new");
|
|
109
|
+
expect(mockPost).toHaveBeenCalledWith("/tenant/transfer-ownership", {
|
|
110
|
+
new_owner_user_id: "user_new",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── GovernanceWorkspaces ──────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
describe("GovernanceWorkspaces", () => {
|
|
118
|
+
it("list returns workspace array", async () => {
|
|
119
|
+
mockGet.mockResolvedValue({ data: [{ id: "ws_1", name: "alpha" }] });
|
|
120
|
+
const gw = new GovernanceWorkspaces(makeHttp());
|
|
121
|
+
const items = await gw.list();
|
|
122
|
+
expect(items).toHaveLength(1);
|
|
123
|
+
expect(items[0].id).toBe("ws_1");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("create posts name", async () => {
|
|
127
|
+
mockPost.mockResolvedValue({ data: { id: "ws_2", name: "beta" } });
|
|
128
|
+
const gw = new GovernanceWorkspaces(makeHttp());
|
|
129
|
+
await gw.create("beta");
|
|
130
|
+
expect(mockPost).toHaveBeenCalledWith("/workspaces", { name: "beta" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("workspace(id).policy.get calls correct path", async () => {
|
|
134
|
+
mockGet.mockResolvedValue({ data: { quotas: {} } });
|
|
135
|
+
const gw = new GovernanceWorkspaces(makeHttp());
|
|
136
|
+
await gw.workspace("ws_1").policy.get();
|
|
137
|
+
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws_1/policy");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("workspace(id).policy.set calls PUT", async () => {
|
|
141
|
+
mockPut.mockResolvedValue({ data: {} });
|
|
142
|
+
const gw = new GovernanceWorkspaces(makeHttp());
|
|
143
|
+
await gw.workspace("ws_1").policy.set({ quotas: { max_sandboxes: 3 } });
|
|
144
|
+
expect(mockPut).toHaveBeenCalledWith("/workspaces/ws_1/policy", {
|
|
145
|
+
quotas: { max_sandboxes: 3 },
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("workspace(id).members.list calls correct path", async () => {
|
|
150
|
+
mockGet.mockResolvedValue({ data: [{ id: "m1", role: "developer" }] });
|
|
151
|
+
const gw = new GovernanceWorkspaces(makeHttp());
|
|
152
|
+
const members = await gw.workspace("ws_1").members.list();
|
|
153
|
+
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws_1/members");
|
|
154
|
+
expect(members[0].role).toBe("developer");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("workspace(id).members.invite posts correctly", async () => {
|
|
158
|
+
mockPost.mockResolvedValue({ data: { id: "m2" } });
|
|
159
|
+
const gw = new GovernanceWorkspaces(makeHttp());
|
|
160
|
+
await gw.workspace("ws_1").members.invite("bob@acme.com", "viewer");
|
|
161
|
+
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws_1/members", {
|
|
162
|
+
email: "bob@acme.com",
|
|
163
|
+
role: "viewer",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("workspace(id).transfer posts resource_ids and target", async () => {
|
|
168
|
+
mockPost.mockResolvedValue({ data: { transferred: 2 } });
|
|
169
|
+
const gw = new GovernanceWorkspaces(makeHttp());
|
|
170
|
+
await gw.workspace("ws_1").transfer(["sbx_1", "sbx_2"], "ws_2");
|
|
171
|
+
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws_1/transfer", {
|
|
172
|
+
resource_ids: ["sbx_1", "sbx_2"],
|
|
173
|
+
target_workspace_id: "ws_2",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── ExternalUsers.policy ──────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
describe("ExternalUsers.policy", () => {
|
|
181
|
+
it("get calls correct path", async () => {
|
|
182
|
+
mockGet.mockResolvedValue({ data: { lifecycle: {} } });
|
|
183
|
+
const eu = new ExternalUsers(makeHttp());
|
|
184
|
+
await eu.call("alice").policy.get();
|
|
185
|
+
expect(mockGet).toHaveBeenCalledWith("/external-users/alice/policy");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("effective returns typed EffectivePolicyDoc with source fields", async () => {
|
|
189
|
+
mockGet.mockResolvedValue({
|
|
190
|
+
lifecycle: {
|
|
191
|
+
default_idle_timeout_sec: { value: 600, source: "user" },
|
|
192
|
+
default_timeout_sec: { value: 86400, source: "tenant" },
|
|
193
|
+
},
|
|
194
|
+
quotas: {
|
|
195
|
+
max_sandboxes: { value: 5, source: "workspace" },
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
const eu = new ExternalUsers(makeHttp());
|
|
199
|
+
const eff = await eu.call("alice").policy.effective();
|
|
200
|
+
expect(eff.lifecycle["default_idle_timeout_sec"]?.value).toBe(600);
|
|
201
|
+
expect(eff.lifecycle["default_idle_timeout_sec"]?.source).toBe("user");
|
|
202
|
+
expect(eff.quotas["max_sandboxes"]?.value).toBe(5);
|
|
203
|
+
expect(eff.quotas["max_sandboxes"]?.source).toBe("workspace");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── BulkResource ──────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
describe("BulkResource", () => {
|
|
210
|
+
it("sandboxes.pause posts ids", async () => {
|
|
211
|
+
mockPost.mockResolvedValue({ queued: 3, job_id: "job_1" });
|
|
212
|
+
const bulk = new BulkResource(makeHttp());
|
|
213
|
+
const result = await bulk.sandboxes.pause({
|
|
214
|
+
ids: ["sbx_1", "sbx_2", "sbx_3"],
|
|
215
|
+
});
|
|
216
|
+
expect(mockPost).toHaveBeenCalledWith("/bulk/sandboxes/pause", {
|
|
217
|
+
ids: ["sbx_1", "sbx_2", "sbx_3"],
|
|
218
|
+
});
|
|
219
|
+
expect(result.job_id).toBe("job_1");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("sandboxes.destroy posts filter", async () => {
|
|
223
|
+
mockPost.mockResolvedValue({ queued: 5, job_id: "job_2" });
|
|
224
|
+
const bulk = new BulkResource(makeHttp());
|
|
225
|
+
const result = await bulk.sandboxes.destroy({ filter: { state: "idle" } });
|
|
226
|
+
expect(mockPost).toHaveBeenCalledWith("/bulk/sandboxes/destroy", {
|
|
227
|
+
filter: { state: "idle" },
|
|
228
|
+
});
|
|
229
|
+
expect(result.queued).toBe(5);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("policy.apply posts tier + ids + policy", async () => {
|
|
233
|
+
mockPost.mockResolvedValue({ queued: 10, job_id: "job_3" });
|
|
234
|
+
const bulk = new BulkResource(makeHttp());
|
|
235
|
+
await bulk.policy.apply({
|
|
236
|
+
tier: "external_user",
|
|
237
|
+
idsOrFilter: ["u1", "u2"],
|
|
238
|
+
policy: { quotas: { max_sandboxes: 3 } },
|
|
239
|
+
});
|
|
240
|
+
expect(mockPost).toHaveBeenCalledWith("/bulk/policy/apply", {
|
|
241
|
+
tier: "external_user",
|
|
242
|
+
ids: ["u1", "u2"],
|
|
243
|
+
policy: { quotas: { max_sandboxes: 3 } },
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("jobs.get returns job status", async () => {
|
|
248
|
+
mockGet.mockResolvedValue({
|
|
249
|
+
data: { id: "job_1", status: "completed", processed: 3 },
|
|
250
|
+
});
|
|
251
|
+
const bulk = new BulkResource(makeHttp());
|
|
252
|
+
const job = await bulk.jobs.get("job_1");
|
|
253
|
+
expect(job.status).toBe("completed");
|
|
254
|
+
expect(job.processed).toBe(3);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ── BillingResource ───────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
describe("BillingResource", () => {
|
|
261
|
+
it("invoices.list returns array", async () => {
|
|
262
|
+
mockGet.mockResolvedValue({
|
|
263
|
+
data: [{ id: "inv_1", amount: 5000 }],
|
|
264
|
+
});
|
|
265
|
+
const billing = new BillingResource(makeHttp());
|
|
266
|
+
const invoices = await billing.invoices.list();
|
|
267
|
+
expect(invoices[0].id).toBe("inv_1");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("invoices.get returns single invoice", async () => {
|
|
271
|
+
mockGet.mockResolvedValue({
|
|
272
|
+
data: { id: "inv_1", amount: 5000, line_items: [] },
|
|
273
|
+
});
|
|
274
|
+
const billing = new BillingResource(makeHttp());
|
|
275
|
+
const inv = await billing.invoices.get("inv_1");
|
|
276
|
+
expect(inv.id).toBe("inv_1");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("paymentMethods returns list", async () => {
|
|
280
|
+
mockGet.mockResolvedValue({
|
|
281
|
+
data: [{ id: "pm_1", brand: "visa", last4: "4242" }],
|
|
282
|
+
});
|
|
283
|
+
const billing = new BillingResource(makeHttp());
|
|
284
|
+
const methods = await billing.paymentMethods();
|
|
285
|
+
expect(methods[0].brand).toBe("visa");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("upcoming returns preview", async () => {
|
|
289
|
+
mockGet.mockResolvedValue({
|
|
290
|
+
data: { amount: 1200, period_end: "2026-06-01" },
|
|
291
|
+
});
|
|
292
|
+
const billing = new BillingResource(makeHttp());
|
|
293
|
+
const upcoming = await billing.upcoming();
|
|
294
|
+
expect((upcoming as Record<string, unknown>).amount).toBe(1200);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── ApiKeys.createScoped ──────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
describe("ApiKeys.createScoped", () => {
|
|
301
|
+
it("posts to /api-keys/scoped with correct body", async () => {
|
|
302
|
+
mockPost.mockResolvedValue({
|
|
303
|
+
data: { id: "key_1", token: "msk_l2_abc" },
|
|
304
|
+
});
|
|
305
|
+
const keys = new ApiKeys(makeHttp());
|
|
306
|
+
const result = await keys.createScoped({
|
|
307
|
+
externalUserId: "alice-42",
|
|
308
|
+
scopes: ["sandboxes:read", "sandboxes:exec"],
|
|
309
|
+
expiresAt: "2026-12-31T00:00:00Z",
|
|
310
|
+
});
|
|
311
|
+
expect(mockPost).toHaveBeenCalledWith("/api-keys/scoped", {
|
|
312
|
+
external_user_id: "alice-42",
|
|
313
|
+
scopes: ["sandboxes:read", "sandboxes:exec"],
|
|
314
|
+
expires_at: "2026-12-31T00:00:00Z",
|
|
315
|
+
});
|
|
316
|
+
expect(result.token).toBe("msk_l2_abc");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("omits expires_at when not provided", async () => {
|
|
320
|
+
mockPost.mockResolvedValue({ data: { id: "key_2" } });
|
|
321
|
+
const keys = new ApiKeys(makeHttp());
|
|
322
|
+
await keys.createScoped({
|
|
323
|
+
externalUserId: "bob",
|
|
324
|
+
scopes: ["sandboxes:read"],
|
|
325
|
+
});
|
|
326
|
+
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
|
327
|
+
expect(body["expires_at"]).toBeUndefined();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ── Admin.impersonate ─────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
describe("Admin.impersonate", () => {
|
|
334
|
+
it("posts to /admin/impersonate with external_user_id and ttl_sec", async () => {
|
|
335
|
+
mockPost.mockResolvedValue({
|
|
336
|
+
token: "msi_abc",
|
|
337
|
+
expires_at: "2026-06-01T00:00:00Z",
|
|
338
|
+
});
|
|
339
|
+
const admin = new Admin(makeHttp());
|
|
340
|
+
const result = await admin.impersonate("alice-42", { ttlSec: 1800 });
|
|
341
|
+
expect(mockPost).toHaveBeenCalledWith("/admin/impersonate", {
|
|
342
|
+
external_user_id: "alice-42",
|
|
343
|
+
ttl_sec: 1800,
|
|
344
|
+
});
|
|
345
|
+
expect(result.token).toBe("msi_abc");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("defaults ttl_sec to 3600", async () => {
|
|
349
|
+
mockPost.mockResolvedValue({ token: "msi_xyz" });
|
|
350
|
+
const admin = new Admin(makeHttp());
|
|
351
|
+
await admin.impersonate("bob");
|
|
352
|
+
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
|
353
|
+
expect(body["ttl_sec"]).toBe(3600);
|
|
354
|
+
});
|
|
355
|
+
});
|