1import { adopt } from "@/AdoptPolicy.ts";
2import {
3 GovernanceRoleSafetyError,
4 governanceRoleTransition,
5 requireGovernanceBaseline,
6 validateGovernanceActor,
7 type GovernanceRoleBaseline,
8} from "@/Neon/OrganizationMemberRole.ts";
9import { Project } from "@/Neon/Project.ts";
10import {
11 ProjectMemberRole,
12 projectMemberDirectRole,
13 validateProjectGovernanceRole,
14 type ProjectGovernanceRole,
15} from "@/Neon/ProjectMemberRole.ts";
16import { providers } from "@/Neon/Providers.ts";
17import * as Test from "@/Test/Alchemy";
18import * as SDK from "@distilled.cloud/neon";
19import { expect, test as unit } from "alchemy-test";
20import * as Effect from "effect/Effect";
21
22const member = {
23 member_id: "authorized-member",
24 user_id: "authorized-user",
25 org_role: "editor" as const,
26};
27const refused = <A, R>(
28 effect: Effect.Effect<A, GovernanceRoleSafetyError, R>,
29) =>
30 effect.pipe(
31 Effect.as(false),
32 Effect.catchTag("NeonGovernanceRoleSafetyError", () =>
33 Effect.succeed(true),
34 ),
35 );
36
37unit.effect(
38 "project role state distinguishes explicit grants from inherited effective access",
39 () =>
40 Effect.gen(function* () {
41 expect(
42 yield* projectMemberDirectRole({
43 ...member,
44 grant_source: "org_role_default",
45 project_role: "editor",
46 effective_project_permission: "EDITOR",
47 org_default_project_permission: "EDITOR",
48 }),
49 ).toBeNull();
50 expect(
51 yield* projectMemberDirectRole({
52 ...member,
53 grant_source: "unassigned",
54 }),
55 ).toBeNull();
56 expect(
57 yield* projectMemberDirectRole({
58 ...member,
59 grant_source: "explicit",
60 explicit_project_permission: "VIEWER",
61 project_role: "editor",
62 effective_project_permission: "EDITOR",
63 }),
64 ).toBe("viewer");
65 expect(
66 yield* projectMemberDirectRole({
67 ...member,
68 grant_source: "explicit",
69 explicit_project_permission: "EDITOR",
70 }),
71 ).toBe("editor");
72 expect(
73 yield* projectMemberDirectRole({
74 ...member,
75 grant_source: "explicit",
76 explicit_project_permission: "ADMIN",
77 }),
78 ).toBe("admin");
79 }),
80 {
81 tags: ["unit", "provider:neon", "provider:neon:projectmemberrole", "local"],
82 },
83);
84
85unit.effect(
86 "project role state refuses missing, inconsistent and org-admin grant evidence",
87 () =>
88 Effect.gen(function* () {
89 expect(yield* refused(projectMemberDirectRole(member))).toBe(true);
90 expect(
91 yield* refused(
92 projectMemberDirectRole({ ...member, grant_source: "explicit" }),
93 ),
94 ).toBe(true);
95 expect(
96 yield* refused(
97 projectMemberDirectRole({
98 ...member,
99 grant_source: "org_role_default",
100 explicit_project_permission: "VIEWER",
101 }),
102 ),
103 ).toBe(true);
104 expect(
105 yield* refused(
106 projectMemberDirectRole({
107 ...member,
108 org_role: "admin",
109 grant_source: "org_admin_override",
110 project_role: "admin",
111 }),
112 ),
113 ).toBe(true);
114 expect(
115 yield* refused(
116 projectMemberDirectRole({
117 ...member,
118 grant_source: "org_admin_override",
119 explicit_project_permission: "VIEWER",
120 }),
121 ),
122 ).toBe(true);
123 }),
124 {
125 tags: [
126 "unit",
127 "provider:neon",
128 "provider:neon:organizationmemberrole",
129 "provider:neon:projectmemberrole",
130 "local",
131 ],
132 },
133);
134
135unit.effect(
136 "project restoration removes only an owned direct grant and preserves adopted roles",
137 () =>
138 Effect.gen(function* () {
139 const baseline: GovernanceRoleBaseline<ProjectGovernanceRole | null> = {
140 fqn: "ProjectRole",
141 instanceId: "generation",
142 userId: member.user_id,
143 originalRole: null,
144 managedRole: "editor",
145 };
146 expect(
147 yield* governanceRoleTransition(baseline, "editor", null, true),
148 ).toBe(true);
149 expect(yield* governanceRoleTransition(baseline, null, null, true)).toBe(
150 false,
151 );
152 expect(
153 yield* refused(
154 governanceRoleTransition(baseline, "viewer", null, true),
155 ),
156 ).toBe(true);
157 const adopted = { ...baseline, originalRole: "viewer" as const };
158 expect(
159 yield* governanceRoleTransition(adopted, "editor", "viewer", true),
160 ).toBe(true);
161 expect(
162 yield* governanceRoleTransition(adopted, "viewer", "viewer", true),
163 ).toBe(false);
164 expect(
165 yield* refused(governanceRoleTransition(adopted, null, "viewer", true)),
166 ).toBe(true);
167 expect(
168 yield* refused(
169 requireGovernanceBaseline<ProjectGovernanceRole | null>(
170 undefined,
171 baseline,
172 ),
173 ),
174 ).toBe(true);
175 }),
176 {
177 tags: [
178 "unit",
179 "provider:neon",
180 "provider:neon:organizationmemberrole",
181 "provider:neon:projectmemberrole",
182 "local",
183 ],
184 },
185);
186
187unit.effect(
188 "project role validation rejects unknown persisted permissions",
189 () =>
190 Effect.gen(function* () {
191 for (const role of [null, "viewer", "editor", "admin"] as const)
192 yield* validateProjectGovernanceRole(role);
193 // @ts-expect-error Runtime validation protects JavaScript callers and persisted state.
194 expect(yield* refused(validateProjectGovernanceRole("owner"))).toBe(true);
195 }),
196 {
197 tags: [
198 "unit",
199 "provider:neon",
200 "provider:neon:organizationmemberrole",
201 "provider:neon:projectmemberrole",
202 "local",
203 ],
204 },
205);
206
207const { test } = Test.make({ providers: providers() });
208
209const baseline: GovernanceRoleBaseline<ProjectGovernanceRole | null> = {
210 fqn: "ProjectRole",
211 instanceId: "project-role-generation",
212 userId: member.user_id,
213 originalRole: null,
214 managedRole: "viewer",
215};
216const context = {
217 id: "ProjectRole",
218 fqn: baseline.fqn,
219 instanceId: baseline.instanceId,
220 oldBindings: [],
221 newBindings: [],
222 bindings: [],
223 session: {
224 emit: () => Effect.void,
225 done: () => Effect.void,
226 note: () => Effect.void,
227 },
228};
229const noCloudCredentials = Effect.die(
230 "Safety-only governance tests must not issue Neon requests",
231);
232
233test.provider(
234 "project grant identity changes are refused by diff before cloud I/O",
235 () =>
236 Effect.gen(function* () {
237 const provider = yield* ProjectMemberRole.Provider;
238 const olds = {
239 orgId: "org-original",
240 memberId: "member-original",
241 project: { projectId: "project-original" },
242 role: "viewer" as const,
243 };
244 const owned = {
245 orgId: olds.orgId,
246 memberId: olds.memberId,
247 projectId: olds.project.projectId,
248 role: olds.role,
249 baseline,
250 };
251 for (const output of [undefined, owned]) {
252 for (const news of [
253 { ...olds, orgId: "org-other" },
254 { ...olds, memberId: "member-other" },
255 { ...olds, project: { projectId: "project-other" } },
256 ]) {
257 expect(
258 yield* refused(provider.diff!({ ...context, olds, news, output })),
259 ).toBe(true);
260 }
261 expect(
262 yield* provider.diff!({
263 ...context,
264 olds,
265 news: { ...olds, role: "editor" },
266 output,
267 }),
268 ).toBeUndefined();
269 }
270 }).pipe(Effect.provideService(SDK.Credentials, noCloudCredentials)),
271 {
272 tags: [
273 "provider:neon",
274 "provider:neon:organizationmemberrole",
275 "provider:neon:projectmemberrole",
276 "live",
277 ],
278 timeout: 120_000,
279 },
280);
281
282test.provider(
283 "project grant read reconcile and delete reject missing baselines before cloud I/O",
284 () =>
285 Effect.gen(function* () {
286 const provider = yield* ProjectMemberRole.Provider;
287 const news = {
288 orgId: "org-original",
289 memberId: "member-original",
290 project: { projectId: "project-original" },
291 role: "viewer" as const,
292 };
293 for (const olds of [undefined, news]) {
294 expect(
295 yield* refused(
296 provider.reconcile({ ...context, news, olds, output: undefined }),
297 ),
298 ).toBe(true);
299 }
300 const output = {
301 orgId: news.orgId,
302 memberId: news.memberId,
303 projectId: news.project.projectId,
304 role: news.role,
305 baseline,
306 };
307 yield* Effect.sync(() => Reflect.deleteProperty(output, "baseline"));
308 expect(
309 yield* refused(provider.read!({ ...context, olds: news, output })),
310 ).toBe(true);
311 expect(
312 yield* refused(
313 provider.reconcile({ ...context, news, olds: news, output }),
314 ),
315 ).toBe(true);
316 expect(
317 yield* refused(provider.delete({ ...context, olds: news, output })),
318 ).toBe(true);
319 }).pipe(Effect.provideService(SDK.Credentials, noCloudCredentials)),
320 {
321 tags: [
322 "provider:neon",
323 "provider:neon:organizationmemberrole",
324 "provider:neon:projectmemberrole",
325 "live",
326 ],
327 timeout: 120_000,
328 },
329);
330
331const orgId = process.env.NEON_GOVERNANCE_TEST_ORG_ID;
332const memberId = process.env.NEON_GOVERNANCE_TEST_MEMBER_ID;
333
334test.provider.skipIf(!orgId || !memberId)(
335 "live direct project grants restore absence and an adopted explicit grant on a test-owned project",
336 (stack) =>
337 Effect.gen(function* () {
338 yield* stack.destroy();
339 if (!orgId || !memberId)
340 return yield* new GovernanceRoleSafetyError({
341 message: "Explicit governance fixture authorization is required",
342 });
343 const membership = yield* SDK.getOrganizationMember({
344 org_id: orgId,
345 member_id: memberId,
346 });
347 if (membership.role === "admin" || !membership.joined_at)
348 return yield* new GovernanceRoleSafetyError({
349 message:
350 "Governance test fixture must be an active, non-admin member",
351 });
352 yield* validateGovernanceActor(
353 membership.user_id,
354 (yield* SDK.getCurrentUserInfo({})).id,
355 );
356 const base = Effect.gen(function* () {
357 const project = yield* Project("GovernanceProject", {
358 orgId,
359 region: "aws-us-east-2",
360 });
361 return { project };
362 });
363 const initial = yield* stack.deploy(base);
364 const projectId = initial.project.projectId;
365 const request = { project_id: projectId, member_id: memberId };
366 const observeGrant = Effect.gen(function* () {
367 let cursor: string | undefined;
368 for (let page = 0; page < 10; page++) {
369 const response = yield* SDK.listProjectMembers({
370 project_id: projectId,
371 limit: 500,
372 cursor,
373 });
374 const current = response.project_members.find(
375 (entry) => entry.member_id === memberId,
376 );
377 if (current) return yield* projectMemberDirectRole(current);
378 if (!response.pagination?.next) break;
379 cursor = response.pagination.next;
380 }
381 return yield* new GovernanceRoleSafetyError({
382 message:
383 "Authorized fixture member is not visible; project-role management and ADMIN access are required",
384 });
385 });
386 expect(yield* observeGrant).toBeNull();
387 const application = (
388 role: ProjectGovernanceRole,
389 takeOwnership = false,
390 ) =>
391 Effect.gen(function* () {
392 yield* base;
393 const access = yield* ProjectMemberRole("AuthorizedProjectGrant", {
394 orgId,
395 memberId,
396 project: { projectId },
397 role,
398 }).pipe(adopt(takeOwnership));
399 return { access };
400 });
401 const created = yield* stack.deploy(application("viewer"));
402 expect(created.access.baseline.originalRole).toBeNull();
403 expect(yield* observeGrant).toBe("viewer");
404 const updated = yield* stack.deploy(application("editor"));
405 expect(updated.access.baseline.originalRole).toBeNull();
406 expect(yield* observeGrant).toBe("editor");
407 yield* stack.deploy(base);
408 expect(yield* observeGrant).toBeNull();
409
410 yield* SDK.setProjectMemberRole({
411 ...request,
412 role: "viewer",
413 confirm_self_demotion: false,
414 });
415 expect(
416 yield* stack.deploy(application("editor")).pipe(
417 Effect.as(false),
418 Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)),
419 ),
420 ).toBe(true);
421 const adopted = yield* stack.deploy(application("editor", true));
422 expect(adopted.access.baseline.originalRole).toBe("viewer");
423 expect(yield* observeGrant).toBe("editor");
424 yield* stack.deploy(base);
425 expect(yield* observeGrant).toBe("viewer");
426 expect(
427 (yield* SDK.getOrganizationMember({
428 org_id: orgId,
429 member_id: memberId,
430 })).role,
431 ).toBe(membership.role);
432 yield* stack.destroy();
433 expect(
434 yield* SDK.getProject({ project_id: projectId }).pipe(
435 Effect.as(false),
436 Effect.catchTag("NotFound", () => Effect.succeed(true)),
437 ),
438 ).toBe(true);
439 yield* stack.destroy();
440 }),
441 {
442 tags: [
443 "provider:neon",
444 "provider:neon:organizationmemberrole",
445 "provider:neon:project",
446 "provider:neon:projectmemberrole",
447 "live",
448 ],
449 timeout: 120_000,
450 },
451);