1import { adopt } from "@/AdoptPolicy.ts";
2import {
3 GovernanceRoleSafetyError,
4 OrganizationMemberRole,
5 governanceRoleTransition,
6 organizationRoleTransition,
7 sameOrganizationRole,
8 requireGovernanceBaseline,
9 validateGovernanceActor,
10 validateGovernanceScope,
11 validateOrganizationRole,
12 type GovernanceRoleBaseline,
13 type OrganizationRole,
14} from "@/Neon/OrganizationMemberRole.ts";
15import { providers } from "@/Neon/Providers.ts";
16import * as Test from "@/Test/Alchemy";
17import * as SDK from "@distilled.cloud/neon";
18import { expect, test as unit } from "alchemy-test";
19import * as Effect from "effect/Effect";
20
21const baseline: GovernanceRoleBaseline<OrganizationRole> = {
22 fqn: "MemberRole",
23 instanceId: "generation-one",
24 userId: "authorized-user",
25 originalRole: "member",
26 managedRole: "viewer",
27};
28
29const refused = <A, R>(
30 effect: Effect.Effect<A, GovernanceRoleSafetyError, R>,
31) =>
32 effect.pipe(
33 Effect.as(false),
34 Effect.catchTag("NeonGovernanceRoleSafetyError", () =>
35 Effect.succeed(true),
36 ),
37 );
38
39unit.effect(
40 "organization role validation rejects missing explicit scope and unknown roles",
41 () =>
42 Effect.gen(function* () {
43 expect(yield* refused(validateGovernanceScope(undefined))).toBe(true);
44 expect(
45 yield* refused(
46 validateGovernanceScope({ orgId: "", memberId: "member" }),
47 ),
48 ).toBe(true);
49 expect(
50 yield* refused(
51 validateGovernanceScope({ orgId: "org", memberId: " " }),
52 ),
53 ).toBe(true);
54 for (const role of [
55 "admin",
56 "member",
57 "editor",
58 "viewer",
59 "collaborator",
60 ] as const) {
61 yield* validateOrganizationRole(role);
62 }
63 // @ts-expect-error Runtime validation protects JavaScript callers and persisted state.
64 expect(yield* refused(validateOrganizationRole("owner"))).toBe(true);
65 }),
66 {
67 tags: [
68 "unit",
69 "provider:neon",
70 "provider:neon:organizationmemberrole",
71 "local",
72 ],
73 },
74);
75
76unit.effect(
77 "organization baseline is mandatory and bound to the resource generation",
78 () =>
79 Effect.gen(function* () {
80 const owner = { fqn: baseline.fqn, instanceId: baseline.instanceId };
81 expect(
82 yield* refused(
83 requireGovernanceBaseline<OrganizationRole>(undefined, owner),
84 ),
85 ).toBe(true);
86 expect(
87 yield* refused(
88 requireGovernanceBaseline(baseline, { ...owner, fqn: "Other" }),
89 ),
90 ).toBe(true);
91 expect(
92 yield* refused(
93 requireGovernanceBaseline(baseline, {
94 ...owner,
95 instanceId: "replacement",
96 }),
97 ),
98 ).toBe(true);
99 expect(yield* requireGovernanceBaseline(baseline, owner)).toEqual(
100 baseline,
101 );
102 }),
103 {
104 tags: [
105 "unit",
106 "provider:neon",
107 "provider:neon:organizationmemberrole",
108 "local",
109 ],
110 },
111);
112
113unit.effect(
114 "organization role transitions preserve the original baseline and reject external edits",
115 () =>
116 Effect.gen(function* () {
117 expect(
118 yield* governanceRoleTransition(baseline, "viewer", "editor"),
119 ).toBe(true);
120 expect(
121 yield* governanceRoleTransition(baseline, "viewer", "viewer"),
122 ).toBe(false);
123 expect(
124 yield* refused(governanceRoleTransition(baseline, "admin", "editor")),
125 ).toBe(true);
126 expect(
127 yield* refused(governanceRoleTransition(baseline, "editor", "editor")),
128 ).toBe(true);
129 expect(baseline.originalRole).toBe("member");
130 }),
131 {
132 tags: [
133 "unit",
134 "provider:neon",
135 "provider:neon:organizationmemberrole",
136 "local",
137 ],
138 },
139);
140
141unit.effect(
142 "organization restoration is exact, idempotent and refuses out-of-band roles",
143 () =>
144 Effect.gen(function* () {
145 expect(
146 yield* governanceRoleTransition(baseline, "viewer", "member", true),
147 ).toBe(true);
148 expect(
149 yield* governanceRoleTransition(baseline, "member", "member", true),
150 ).toBe(false);
151 expect(
152 yield* refused(
153 governanceRoleTransition(baseline, "editor", "member", true),
154 ),
155 ).toBe(true);
156 }),
157 {
158 tags: [
159 "unit",
160 "provider:neon",
161 "provider:neon:organizationmemberrole",
162 "local",
163 ],
164 },
165);
166
167unit.effect(
168 "governance refuses changes to the current actor or an unknown identity",
169 () =>
170 Effect.gen(function* () {
171 expect(yield* refused(validateGovernanceActor("actor", "actor"))).toBe(
172 true,
173 );
174 expect(yield* refused(validateGovernanceActor("", "actor"))).toBe(true);
175 expect(yield* refused(validateGovernanceActor("member", ""))).toBe(true);
176 yield* validateGovernanceActor("authorized-member", "administrator");
177 }),
178 {
179 tags: [
180 "unit",
181 "provider:neon",
182 "provider:neon:organizationmemberrole",
183 "local",
184 ],
185 },
186);
187
188unit.effect(
189 "organization roles accept Neon's legacy member/editor alias without changing permissions",
190 () =>
191 Effect.gen(function* () {
192 const legacy = { ...baseline, managedRole: "member" as const };
193 expect(sameOrganizationRole("member", "editor")).toBe(true);
194 expect(sameOrganizationRole("viewer", "editor")).toBe(false);
195 expect(
196 yield* organizationRoleTransition(legacy, "editor", "member"),
197 ).toBe(false);
198 expect(
199 yield* organizationRoleTransition(legacy, "editor", "viewer"),
200 ).toBe(true);
201 expect(
202 yield* organizationRoleTransition(baseline, "editor", "member", true),
203 ).toBe(false);
204 expect(
205 yield* refused(organizationRoleTransition(legacy, "admin", "editor")),
206 ).toBe(true);
207 expect(legacy.originalRole).toBe("member");
208 }),
209 {
210 tags: [
211 "unit",
212 "provider:neon",
213 "provider:neon:organizationmemberrole",
214 "local",
215 ],
216 },
217);
218
219const { test } = Test.make({ providers: providers() });
220
221const context = {
222 id: "MemberRole",
223 fqn: baseline.fqn,
224 instanceId: baseline.instanceId,
225 oldBindings: [],
226 newBindings: [],
227 bindings: [],
228 session: {
229 emit: () => Effect.void,
230 done: () => Effect.void,
231 note: () => Effect.void,
232 },
233};
234
235const noCloudCredentials = Effect.die(
236 "Safety-only governance tests must not issue Neon requests",
237);
238
239test.provider(
240 "organization role identity changes are refused by diff before cloud I/O",
241 () =>
242 Effect.gen(function* () {
243 const provider = yield* OrganizationMemberRole.Provider;
244 const olds = {
245 orgId: "org-original",
246 memberId: "member-original",
247 role: "viewer" as const,
248 };
249 const owned = { ...olds, baseline };
250 for (const output of [undefined, owned]) {
251 for (const news of [
252 { ...olds, orgId: "org-other" },
253 { ...olds, memberId: "member-other" },
254 ]) {
255 expect(
256 yield* refused(provider.diff!({ ...context, olds, news, output })),
257 ).toBe(true);
258 }
259 expect(
260 yield* provider.diff!({
261 ...context,
262 olds,
263 news: { ...olds, role: "editor" },
264 output,
265 }),
266 ).toBeUndefined();
267 }
268 }).pipe(Effect.provideService(SDK.Credentials, noCloudCredentials)),
269 {
270 tags: ["provider:neon", "provider:neon:organizationmemberrole", "live"],
271 timeout: 120_000,
272 },
273);
274
275test.provider(
276 "organization read reconcile and delete reject missing baselines before cloud I/O",
277 () =>
278 Effect.gen(function* () {
279 const provider = yield* OrganizationMemberRole.Provider;
280 const news = {
281 orgId: "org-original",
282 memberId: "member-original",
283 role: "viewer" as const,
284 };
285 for (const olds of [undefined, news]) {
286 expect(
287 yield* refused(
288 provider.reconcile({ ...context, news, olds, output: undefined }),
289 ),
290 ).toBe(true);
291 }
292 const output = { ...news, baseline };
293 yield* Effect.sync(() => Reflect.deleteProperty(output, "baseline"));
294 expect(
295 yield* refused(provider.read!({ ...context, olds: news, output })),
296 ).toBe(true);
297 expect(
298 yield* refused(
299 provider.reconcile({ ...context, news, olds: news, output }),
300 ),
301 ).toBe(true);
302 expect(
303 yield* refused(provider.delete({ ...context, olds: news, output })),
304 ).toBe(true);
305 }).pipe(Effect.provideService(SDK.Credentials, noCloudCredentials)),
306 {
307 tags: ["provider:neon", "provider:neon:organizationmemberrole", "live"],
308 timeout: 120_000,
309 },
310);
311
312const orgId = process.env.NEON_GOVERNANCE_TEST_ORG_ID;
313const memberId = process.env.NEON_GOVERNANCE_TEST_MEMBER_ID;
314// Opt in separately because an organization role affects access beyond test-owned projects.
315const authorized =
316 process.env.NEON_GOVERNANCE_TEST_ALLOW_ORG_ROLE_CHANGE === "1";
317
318test.provider.skipIf(!orgId || !memberId || !authorized)(
319 "live organization role requires adoption and restores the authorized non-admin member exactly",
320 (stack) =>
321 Effect.gen(function* () {
322 yield* stack.destroy();
323 if (!orgId || !memberId || !authorized)
324 return yield* new GovernanceRoleSafetyError({
325 message: "Explicit governance fixture authorization is required",
326 });
327 const request = { org_id: orgId, member_id: memberId };
328 const original = yield* SDK.getOrganizationMember(request);
329 const actor = yield* SDK.getCurrentUserInfo({});
330 if (original.role === "admin" || !original.joined_at)
331 return yield* new GovernanceRoleSafetyError({
332 message:
333 "Governance test fixture must be an active, non-admin member",
334 });
335 yield* validateGovernanceActor(original.user_id, actor.id);
336 const first: OrganizationRole =
337 original.role === "viewer" ? "editor" : "viewer";
338 const second: OrganizationRole = first === "viewer" ? "editor" : "viewer";
339 const application = (role: OrganizationRole, takeOwnership: boolean) =>
340 Effect.gen(function* () {
341 const access = yield* OrganizationMemberRole("AuthorizedMemberRole", {
342 orgId,
343 memberId,
344 role,
345 }).pipe(adopt(takeOwnership));
346 return { access };
347 });
348 expect(
349 yield* stack.deploy(application(first, false)).pipe(
350 Effect.as(false),
351 Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)),
352 ),
353 ).toBe(true);
354 const initial = yield* stack.deploy(application(first, true));
355 expect(initial.access.baseline.originalRole).toBe(original.role);
356 expect((yield* SDK.getOrganizationMember(request)).role).toBe(first);
357 const updated = yield* stack.deploy(application(second, false));
358 expect(updated.access.baseline.originalRole).toBe(original.role);
359 expect((yield* SDK.getOrganizationMember(request)).role).toBe(second);
360 yield* stack.destroy();
361 const restored = yield* SDK.getOrganizationMember(request);
362 expect(sameOrganizationRole(restored.role, original.role)).toBe(true);
363 expect(restored.id).toBe(original.id);
364 expect(restored.user_id).toBe(original.user_id);
365 yield* stack.destroy();
366 }),
367 {
368 tags: ["provider:neon", "provider:neon:organizationmemberrole", "live"],
369 timeout: 120_000,
370 },
371);