1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8
9import EKSClusterTestFunctionLive, {
10 EKSClusterTestFunction,
11} from "./fixtures/cluster-bindings-handler.ts";
12
13const testOptions = { providers: AWS.providers() };
14const { test } = Test.make(testOptions);
15const slowStack = Core.scratchStack(testOptions, "EKSClusterBindings");
16
17// The cluster-scoped bindings (DescribeCluster, the List*/Describe*
18// sub-resource operations, insights, updates) need a real EKS control plane
19// (~10 minutes to provision) — gated behind the same standing-infrastructure
20// envs as Cluster.test.ts: a cluster IAM role (AWS_TEST_EKS_ROLE_ARN) and at
21// least two subnets (AWS_TEST_EKS_SUBNET_IDS, comma-separated). An account
22// with that standing infrastructure runs this unchanged.
23test.provider.skipIf(
24 !process.env.AWS_TEST_EKS_ROLE_ARN || !process.env.AWS_TEST_EKS_SUBNET_IDS,
25)(
26 "cluster-scoped bindings against a live control plane",
27 () =>
28 Effect.gen(function* () {
29 yield* slowStack.destroy();
30
31 yield* Effect.gen(function* () {
32 const { functionUrl } = yield* slowStack.deploy(
33 Effect.gen(function* () {
34 return yield* EKSClusterTestFunction;
35 }).pipe(Effect.provide(EKSClusterTestFunctionLive)),
36 );
37 expect(functionUrl).toBeTruthy();
38 const baseUrl = functionUrl!.replace(/\/+$/, "");
39
40 const get = (path: string) =>
41 HttpClient.get(`${baseUrl}${path}`).pipe(
42 Effect.retry({
43 schedule: Schedule.max([
44 Schedule.exponential("500 millis"),
45 Schedule.recurs(10),
46 ]),
47 }),
48 Effect.flatMap((r) => r.json),
49 );
50
51 const bindings = (yield* get("/bindings")) as { bound: string[] };
52 expect(bindings.bound).toHaveLength(22);
53
54 // DescribeCluster returns the live control-plane connection info.
55 const cluster = (yield* get("/cluster")) as {
56 status?: string;
57 endpointPresent: boolean;
58 caPresent: boolean;
59 };
60 expect(cluster.status).toBe("ACTIVE");
61 expect(cluster.endpointPresent).toBe(true);
62 expect(cluster.caPresent).toBe(true);
63
64 // Every enumeration succeeds (a fresh control plane has no
65 // sub-resources) — proves each grant + cluster-name injection +
66 // response decode.
67 const lists = (yield* get("/lists")) as Record<string, number>;
68 for (const key of [
69 "nodegroups",
70 "addons",
71 "fargateProfiles",
72 "podIdentityAssociations",
73 "accessEntries",
74 "insights",
75 "updates",
76 "capabilities",
77 "identityProviderConfigs",
78 ]) {
79 expect(lists[key]).toBeGreaterThanOrEqual(0);
80 }
81
82 // Nonexistent sub-resources surface the services' typed tags — an
83 // IAM gap or a broken cluster-name injection would surface
84 // AccessDeniedException / an opaque 500 instead.
85 const probes = (yield* get("/probes")) as Record<string, string>;
86 expect(probes.nodegroupTag).toBe("ResourceNotFoundException");
87 expect(probes.addonTag).toBe("ResourceNotFoundException");
88 expect(probes.fargateProfileTag).toBe("ResourceNotFoundException");
89 expect([
90 "ResourceNotFoundException",
91 "InvalidParameterException",
92 ]).toContain(probes.podIdentityTag);
93 expect([
94 "ResourceNotFoundException",
95 "InvalidRequestException",
96 ]).toContain(probes.accessEntryTag);
97 expect([
98 "ResourceNotFoundException",
99 "InvalidParameterException",
100 ]).toContain(probes.updateTag);
101 expect([
102 "ResourceNotFoundException",
103 "InvalidParameterException",
104 ]).toContain(probes.insightTag);
105 expect([
106 "ResourceNotFoundException",
107 "InvalidRequestException",
108 ]).toContain(probes.associatedPoliciesTag);
109 expect([
110 "ResourceNotFoundException",
111 "InvalidParameterException",
112 ]).toContain(probes.capabilityTag);
113 expect([
114 "ResourceNotFoundException",
115 "InvalidParameterException",
116 ]).toContain(probes.identityProviderConfigTag);
117
118 // Insights refresh: kick an on-demand refresh and read its status
119 // back. A refresh already in flight surfaces the typed
120 // InvalidRequestException — either outcome proves both grants.
121 const refresh = (yield* get("/insights-refresh")) as {
122 startTag: string;
123 describeTag: string;
124 };
125 expect(typeof refresh.startTag).toBe("string");
126 expect(refresh.startTag.length).toBeGreaterThan(0);
127 expect(typeof refresh.describeTag).toBe("string");
128 expect(refresh.describeTag.length).toBeGreaterThan(0);
129 }).pipe(Effect.ensuring(slowStack.destroy().pipe(Effect.orDie)));
130 }),
131 // cluster create (~10 min) + probes + delete (~10 min) in one test.
132 {
133 tags: ["provider:aws", "provider:aws:eks", "provider:aws:lambda", "live"],
134 timeout: 2_400_000,
135 },
136);