@miosa/sdk 1.0.0 → 1.2.0

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.
@@ -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
+ });