1import * as AWS from "@/AWS";
2import { Permission } from "@/AWS/RAM";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as ram from "@distilled.cloud/aws/ram";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19class PermissionStillLive extends Data.TaggedError("PermissionStillLive") {}
20
21const readPermission = Effect.fn(function* (arn: string) {
22 const detail = yield* ram.getPermission({ permissionArn: arn }).pipe(
23 Effect.map((r) => r.permission),
24 Effect.catchTag("UnknownResourceException", () =>
25 Effect.succeed(undefined),
26 ),
27 );
28 return detail && detail.status !== "DELETING" && detail.status !== "DELETED"
29 ? detail
30 : undefined;
31});
32
33const assertDeleted = Effect.fn(function* (arn: string) {
34 yield* readPermission(arn).pipe(
35 Effect.flatMap((p) =>
36 p === undefined ? Effect.void : Effect.fail(new PermissionStillLive()),
37 ),
38 Effect.retry({
39 while: (e) => e instanceof PermissionStillLive,
40 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(15)]),
41 }),
42 );
43});
44
45const permissionStack = (props: {
46 actions: string[];
47 tags?: Record<string, string>;
48}) =>
49 Effect.gen(function* () {
50 const permission = yield* Permission("TestPermission", {
51 resourceType: "appsync:Apis",
52 policyTemplate: { actions: props.actions },
53 tags: props.tags,
54 });
55 return { permission };
56 });
57
58test.provider(
59 "create, publish new policy version, update tags, delete",
60 (stack) =>
61 Effect.gen(function* () {
62 yield* stack.destroy();
63
64 // 1. Create a least-privilege AppSync API permission (appsync:Apis is
65 // one of the resource types supporting customer managed permissions).
66 const { permission } = yield* stack.deploy(
67 permissionStack({
68 actions: ["appsync:SourceGraphQL"],
69 tags: { team: "platform" },
70 }),
71 );
72
73 expect(permission.permissionArn).toMatch(/^arn:aws:ram:/);
74 expect(permission.resourceType).toBe("appsync:Apis");
75 const arn = permission.permissionArn;
76 const initialVersion = permission.version;
77
78 // 2. Out-of-band: the permission is ATTACHABLE with our policy + tags.
79 const live = yield* readPermission(arn);
80 expect(live?.status).toBe("ATTACHABLE");
81 const policy = JSON.parse(live?.permission ?? "{}") as {
82 Action?: string | string[];
83 };
84 expect(policy.Action).toContain("appsync:SourceGraphQL");
85 expect(
86 live?.tags?.some((t) => t.key === "team" && t.value === "platform"),
87 ).toBe(true);
88 expect(live?.tags?.some((t) => t.key === "alchemy::id")).toBe(true);
89
90 // 3. Update in place: grow the policy (new default version), add a
91 // tag. ARN is stable; the default version advances.
92 const { permission: updated } = yield* stack.deploy(
93 permissionStack({
94 actions: ["appsync:SourceGraphQL", "appsync:GraphQL"],
95 tags: { team: "platform", env: "prod" },
96 }),
97 );
98 expect(updated.permissionArn).toEqual(arn);
99 expect(updated.version).not.toEqual(initialVersion);
100
101 // 4. Out-of-band: the default version carries the grown policy and
102 // the new tag; the superseded version was cleaned up.
103 const live2 = yield* readPermission(arn);
104 const policy2 = JSON.parse(live2?.permission ?? "{}") as {
105 Action?: string | string[];
106 };
107 expect(policy2.Action).toContain("appsync:GraphQL");
108 expect(
109 live2?.tags?.some((t) => t.key === "env" && t.value === "prod"),
110 ).toBe(true);
111
112 // listPermissionVersions keeps DELETED versions in the listing — only
113 // the new default remains live.
114 const versions = yield* ram
115 .listPermissionVersions({ permissionArn: arn })
116 .pipe(Effect.map((r) => r.permissions ?? []));
117 const liveVersions = versions.filter(
118 (v) => v.status !== "DELETED" && v.status !== "DELETING",
119 );
120 expect(liveVersions).toHaveLength(1);
121 expect(liveVersions[0]?.version).toEqual(updated.version);
122
123 // 5. Re-deploy with identical props: a no-op — no new version.
124 const { permission: same } = yield* stack.deploy(
125 permissionStack({
126 actions: ["appsync:SourceGraphQL", "appsync:GraphQL"],
127 tags: { team: "platform", env: "prod" },
128 }),
129 );
130 expect(same.version).toEqual(updated.version);
131
132 // 6. Delete and confirm gone.
133 yield* stack.destroy();
134 yield* assertDeleted(arn);
135 }).pipe(logLevel),
136 { tags: ["provider:aws", "provider:aws:ram", "live"], timeout: 240_000 },
137);
138
139test.provider(
140 "list enumerates the deployed permission",
141 (stack) =>
142 Effect.gen(function* () {
143 yield* stack.destroy();
144
145 const { permission } = yield* stack.deploy(
146 permissionStack({ actions: ["appsync:SourceGraphQL"] }),
147 );
148
149 const provider = yield* Provider.findProvider(Permission);
150 const all = yield* provider.list();
151 expect(
152 all.some((x) => x.permissionArn === permission.permissionArn),
153 ).toBe(true);
154
155 yield* stack.destroy();
156 yield* assertDeleted(permission.permissionArn);
157 }).pipe(logLevel),
158 { tags: ["provider:aws", "provider:aws:ram", "live"], timeout: 240_000 },
159);