1import { requestGuarded, runStreamGuarded } from "../rpc/client.ts";
2import { EnvironmentSupervisor } from "../connection/supervisor.ts";
3import * as SubscriptionRef from "effect/SubscriptionRef";
4import * as Option from "effect/Option";
5import * as Stream from "effect/Stream";
6import type { RpcSession } from "../rpc/session.ts";
7import { describe, expect, it } from "@effect/vitest";
8import { vi } from "vite-plus/test";
9import {
10 AuthOrchestrationOperateScope,
11 AuthTerminalOperateScope,
12 AuthFilesystemReadScope,
13 AuthOrchestrationReadScope,
14 AuthSettingsWriteScope,
15 AuthSourceControlWriteScope,
16 ThreadId,
17 EnvironmentId,
18 ScheduledTaskId,
19 WS_METHODS,
20 type AuthSessionState,
21} from "@t3tools/contracts";
22import * as Cause from "effect/Cause";
23import * as Effect from "effect/Effect";
24import * as Fiber from "effect/Fiber";
25import * as Layer from "effect/Layer";
26import * as TestClock from "effect/testing/TestClock";
27import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity";
28import { EnvironmentRegistry } from "../connection/registry.ts";
29import { createCommandPermissions } from "./commandPermissions.ts";
30import { createEnvironmentRpcCommand } from "./runtime.ts";
31
32vi.mock("./session.ts", () => ({
33 createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }),
34}));
35const sessions = Atom.family((_id: EnvironmentId) =>
36 Atom.make<AsyncResult.AsyncResult<AuthSessionState, string>>(AsyncResult.initial()),
37);
38const env = EnvironmentId.make("target");
39const other = EnvironmentId.make("other");
40const grant = (allowed: boolean): AuthSessionState => ({
41 authenticated: true,
42 auth: {
43 policy: "remote-reachable",
44 bootstrapMethods: [],
45 sessionMethods: [],
46 sessionCookieName: "test",
47 },
48 scopes: allowed ? [AuthOrchestrationOperateScope] : [],
49 permissions: allowed ? [AuthOrchestrationOperateScope] : [],
50});
51const runtime = Atom.runtime(
52 Layer.succeed(EnvironmentRegistry, {
53 run: (_id: EnvironmentId, effect: Effect.Effect<unknown>) => effect,
54 } as unknown as EnvironmentRegistry["Service"]),
55);
56const permissions = createCommandPermissions(runtime, WS_METHODS.scheduledTasksDelete);
57const setup = Effect.gen(function* () {
58 const registry = AtomRegistry.make();
59 registry.mount(sessions(env));
60 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
61 return registry;
62});
63
64describe("command permissions", () => {
65 it.effect("requires settings access on the selected UI profile environment", () =>
66 Effect.scoped(
67 Effect.gen(function* () {
68 const registry = yield* setup;
69 registry.set(
70 sessions(env),
71 AsyncResult.success({
72 ...grant(false),
73 scopes: [AuthSettingsWriteScope],
74 permissions: [AuthSettingsWriteScope],
75 }),
76 );
77 registry.set(sessions(other), AsyncResult.success(grant(true)));
78 const command = createCommandPermissions(runtime, WS_METHODS.uiSettingsPatch);
79 expect(registry.get(command.permissionAtom(env))).toBe(true);
80 yield* command.authorize(registry, env);
81 expect(registry.get(command.permissionAtom(other))).toBe(false);
82 expect((yield* command.authorize(registry, other).pipe(Effect.flip))._tag).toBe(
83 "EnvironmentAuthorizationError",
84 );
85 }),
86 ),
87 );
88
89 it.effect("checks destination source-control grants for Jujutsu and Mergebot actions", () =>
90 Effect.scoped(
91 Effect.gen(function* () {
92 const registry = yield* setup;
93 registry.set(
94 sessions(env),
95 AsyncResult.success({
96 ...grant(false),
97 scopes: [AuthSourceControlWriteScope],
98 permissions: [AuthSourceControlWriteScope],
99 }),
100 );
101 registry.set(sessions(other), AsyncResult.success(grant(true)));
102 for (const method of [
103 WS_METHODS.jjDescribe,
104 WS_METHODS.jjSquash,
105 WS_METHODS.jjPush,
106 WS_METHODS.mergebotOpenPullRequest,
107 WS_METHODS.mergebotSetAutoMerge,
108 WS_METHODS.mergebotUpdatePullRequest,
109 WS_METHODS.mergebotDeployProduction,
110 ]) {
111 const command = createCommandPermissions(runtime, method);
112 expect(registry.get(command.permissionAtom(env))).toBe(true);
113 yield* command.authorize(registry, env);
114 expect(registry.get(command.permissionAtom(other))).toBe(false);
115 expect((yield* command.authorize(registry, other).pipe(Effect.flip))._tag).toBe(
116 "EnvironmentAuthorizationError",
117 );
118 }
119 }),
120 ),
121 );
122
123 it.effect("checks destination grants before creating a managed workspace", () =>
124 Effect.scoped(
125 Effect.gen(function* () {
126 const registry = yield* setup;
127 const create = createCommandPermissions(runtime, WS_METHODS.managedWorkspacesCreate);
128 registry.set(sessions(env), AsyncResult.success(grant(false)));
129 expect(registry.get(create.permissionAtom(env))).toBe(false);
130 expect((yield* create.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
131 "EnvironmentAuthorizationError",
132 );
133 registry.set(sessions(env), AsyncResult.success(grant(true)));
134 yield* create.authorize(registry, env);
135 registry.set(sessions(other), AsyncResult.success(grant(false)));
136 expect(registry.get(create.permissionAtom(other))).toBe(false);
137 }),
138 ),
139 );
140
141 it.effect("requires destination read grants for dev status and terminal authority to start", () =>
142 Effect.scoped(
143 Effect.gen(function* () {
144 const registry = yield* setup;
145 const status = createCommandPermissions(runtime, WS_METHODS.devServerStatus);
146 const start = createCommandPermissions(runtime, WS_METHODS.devServerStart);
147 const readGrant = {
148 ...grant(false),
149 scopes: [AuthFilesystemReadScope, AuthOrchestrationReadScope],
150 permissions: [AuthFilesystemReadScope, AuthOrchestrationReadScope],
151 };
152 registry.set(sessions(env), AsyncResult.success(readGrant));
153 expect(registry.get(status.permissionAtom(env))).toBe(true);
154 expect(registry.get(start.permissionAtom(env))).toBe(false);
155 expect((yield* start.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
156 "EnvironmentAuthorizationError",
157 );
158 registry.set(
159 sessions(env),
160 AsyncResult.success({
161 ...readGrant,
162 scopes: [...readGrant.scopes, AuthTerminalOperateScope],
163 permissions: [...readGrant.permissions, AuthTerminalOperateScope],
164 }),
165 );
166 yield* start.authorize(registry, env);
167 registry.set(sessions(other), AsyncResult.success(grant(false)));
168 expect(registry.get(status.permissionAtom(other))).toBe(false);
169 expect(registry.get(start.permissionAtom(other))).toBe(false);
170 }),
171 ),
172 );
173
174 it.effect("requires the destination settings grant to run storage cleanup", () =>
175 Effect.scoped(
176 Effect.gen(function* () {
177 const registry = yield* setup;
178 const cleanup = createCommandPermissions(runtime, WS_METHODS.serverRunStorageCleanup);
179 registry.set(sessions(env), AsyncResult.success(grant(true)));
180 expect(registry.get(cleanup.permissionAtom(env))).toBe(false);
181 expect((yield* cleanup.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
182 "EnvironmentAuthorizationError",
183 );
184 registry.set(
185 sessions(env),
186 AsyncResult.success({
187 ...grant(true),
188 scopes: [AuthSettingsWriteScope],
189 permissions: [AuthSettingsWriteScope],
190 }),
191 );
192 expect(registry.get(cleanup.permissionAtom(env))).toBe(true);
193 yield* cleanup.authorize(registry, env);
194 registry.set(sessions(other), AsyncResult.success(grant(false)));
195 expect(registry.get(cleanup.permissionAtom(other))).toBe(false);
196 }),
197 ),
198 );
199
200 it.effect("requires the destination settings grant to check OTLP endpoints", () =>
201 Effect.scoped(
202 Effect.gen(function* () {
203 const registry = yield* setup;
204 const check = createCommandPermissions(runtime, WS_METHODS.serverCheckOtlpEndpoint);
205 registry.set(sessions(env), AsyncResult.success(grant(true)));
206 expect(registry.get(check.permissionAtom(env))).toBe(false);
207 registry.set(
208 sessions(env),
209 AsyncResult.success({
210 ...grant(false),
211 scopes: [AuthSettingsWriteScope],
212 permissions: [AuthSettingsWriteScope],
213 }),
214 );
215 expect(registry.get(check.permissionAtom(env))).toBe(true);
216 yield* check.authorize(registry, env);
217 }),
218 ),
219 );
220
221 it.effect("uses the target grant for both availability and execution", () =>
222 Effect.scoped(
223 Effect.gen(function* () {
224 const registry = yield* setup;
225 registry.set(sessions(env), AsyncResult.success(grant(true)));
226 expect(registry.get(permissions.permissionAtom(env))).toBe(true);
227 expect(permissions.permissionAtom(env)).toBe(permissions.permissionAtom(env));
228 yield* permissions.authorize(registry, env);
229 registry.set(sessions(other), AsyncResult.success(grant(false)));
230 expect(registry.get(permissions.permissionAtom(other))).toBe(false);
231 const denied = yield* permissions.authorize(registry, other).pipe(Effect.flip);
232 expect(denied._tag).toBe("EnvironmentAuthorizationError");
233 }),
234 ),
235 );
236 it.effect("waits for an initial grant", () =>
237 Effect.scoped(
238 Effect.gen(function* () {
239 const registry = yield* setup;
240 const fiber = yield* permissions.authorize(registry, env).pipe(Effect.forkChild);
241 yield* Effect.yieldNow;
242 registry.set(sessions(env), AsyncResult.success(grant(true)));
243 yield* Fiber.join(fiber);
244 }),
245 ),
246 );
247 it.effect("bounds a session that never loads", () =>
248 Effect.scoped(
249 Effect.gen(function* () {
250 const registry = yield* setup;
251 const fiber = yield* permissions
252 .authorize(registry, env)
253 .pipe(Effect.flip, Effect.forkChild);
254 yield* TestClock.adjust("6 seconds");
255 expect((yield* Fiber.join(fiber))._tag).toBe("EnvironmentAuthorizationError");
256 }),
257 ),
258 );
259 it.effect(
260 "denies failed and unauthenticated sessions, but accepts a cached refreshing grant",
261 () =>
262 Effect.scoped(
263 Effect.gen(function* () {
264 const registry = yield* setup;
265 registry.set(sessions(env), AsyncResult.failure(Cause.fail("offline")));
266 expect((yield* permissions.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
267 "EnvironmentAuthorizationError",
268 );
269 registry.set(
270 sessions(env),
271 AsyncResult.success({ ...grant(true), authenticated: false }),
272 );
273 expect((yield* permissions.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
274 "EnvironmentAuthorizationError",
275 );
276 registry.set(sessions(env), AsyncResult.waiting(AsyncResult.success(grant(true))));
277 yield* permissions.authorize(registry, env);
278 }),
279 ),
280 );
281 it("rechecks permission after waiting in a serial command lane", async () => {
282 const registry = AtomRegistry.make();
283 const unmount = registry.mount(sessions(env));
284 registry.set(sessions(env), AsyncResult.success(grant(true)));
285 let release!: () => void;
286 let started!: () => void;
287 const entered = new Promise<void>((resolve) => {
288 started = resolve;
289 });
290 const gate = new Promise<void>((resolve) => {
291 release = resolve;
292 });
293 let calls = 0;
294 const command = createEnvironmentRpcCommand(runtime, {
295 label: "test.delete",
296 tag: WS_METHODS.scheduledTasksDelete,
297 concurrency: { mode: "serial", key: () => "target" },
298 execute: () =>
299 Effect.promise(async () => {
300 calls++;
301 started();
302 await gate;
303 return { id: ScheduledTaskId.make("task") };
304 }),
305 });
306 const target = { environmentId: env, input: { id: ScheduledTaskId.make("task") } };
307 try {
308 const first = command.run(registry, target);
309 await entered;
310 const second = command.run(registry, target);
311 registry.set(sessions(env), AsyncResult.success(grant(false)));
312 release();
313 expect((await first)._tag).toBe("Success");
314 expect((await second)._tag).toBe("Failure");
315 expect(calls).toBe(1);
316 } finally {
317 unmount();
318 registry.dispose();
319 }
320 });
321});
322
323it.effect(
324 "requires source control alone for git, and both grants when attaching a worktree to a thread",
325 () =>
326 Effect.scoped(
327 Effect.gen(function* () {
328 const registry = yield* setup;
329 registry.set(sessions(env), AsyncResult.success(grant(true)));
330 const git = createCommandPermissions(runtime, WS_METHODS.vcsInit);
331 expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe(
332 AuthSourceControlWriteScope,
333 );
334 registry.set(
335 sessions(env),
336 AsyncResult.success({
337 ...grant(false),
338 scopes: [AuthSourceControlWriteScope],
339 permissions: [AuthSourceControlWriteScope],
340 }),
341 );
342 yield* git.authorize(registry, env);
343 const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread);
344 const input = {
345 cwd: "/repo",
346 reference: "123",
347 mode: "worktree",
348 threadId: ThreadId.make("thread"),
349 };
350 expect(registry.get(prepare.permissionAtom(env, input))).toBe(false);
351 expect(
352 (yield* prepare.authorize(registry, env, input).pipe(Effect.flip)).requiredScope,
353 ).toBe(AuthOrchestrationOperateScope);
354 yield* prepare.authorize(registry, env, { ...input, threadId: undefined });
355 registry.set(
356 sessions(env),
357 AsyncResult.success({
358 ...grant(true),
359 scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
360 permissions: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope],
361 }),
362 );
363 expect(registry.get(prepare.permissionAtom(env, input))).toBe(true);
364 yield* prepare.authorize(registry, env, input);
365 }),
366 ),
367);
368
369it.effect("honors exact empty permissions and preserves legacy parent grants", () =>
370 Effect.scoped(
371 Effect.gen(function* () {
372 const registry = yield* setup;
373 const git = createCommandPermissions(runtime, WS_METHODS.vcsInit);
374 registry.set(sessions(env), AsyncResult.success({ ...grant(true), permissions: [] }));
375 expect(registry.get(git.permissionAtom(env))).toBe(false);
376 const denied = yield* git.authorize(registry, env).pipe(Effect.flip);
377 expect(denied).toMatchObject({
378 requiredPermission: AuthSourceControlWriteScope,
379 requiredScope: AuthOrchestrationOperateScope,
380 });
381 const { permissions: _exact, ...legacy } = grant(true);
382 registry.set(sessions(env), AsyncResult.success(legacy));
383 expect(registry.get(git.permissionAtom(env))).toBe(true);
384 yield* git.authorize(registry, env);
385 }),
386 ),
387);
388
389it.effect("rejects protected unary and streamed RPCs outside a guarded command", () =>
390 Effect.gen(function* () {
391 let writes = 0;
392 const session = {
393 client: {
394 [WS_METHODS.scheduledTasksDelete]: () =>
395 Effect.sync(() => {
396 writes++;
397 return { id: ScheduledTaskId.make("task") };
398 }),
399 [WS_METHODS.gitRunStackedAction]: () =>
400 Stream.fromEffect(
401 Effect.sync(() => {
402 writes++;
403 }),
404 ),
405 },
406 } as unknown as RpcSession;
407 const supervisor = {
408 target: { environmentId: env, label: "target" },
409 session: yield* SubscriptionRef.make(Option.some(session)),
410 } as unknown as EnvironmentSupervisor["Service"];
411 const unary = yield* requestGuarded(WS_METHODS.scheduledTasksDelete, {
412 id: ScheduledTaskId.make("task"),
413 }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor), Effect.flip);
414 expect(unary._tag).toBe("EnvironmentAuthorizationError");
415 const streamed = yield* runStreamGuarded(WS_METHODS.gitRunStackedAction, {
416 actionId: "test-action",
417 cwd: "/repo",
418 action: "commit",
419 }).pipe(Stream.runDrain, Effect.provideService(EnvironmentSupervisor, supervisor), Effect.flip);
420 expect(streamed._tag).toBe("EnvironmentAuthorizationError");
421 expect(writes).toBe(0);
422 }),
423);