1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import GrafanaTestFunctionLive, { GrafanaTestFunction } from "./handler";
11import GrafanaWorkspaceTestFunctionLive, {
12 GrafanaWorkspaceTestFunction,
13} from "./workspace-handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "GrafanaBindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy.
21const readinessPolicy = Schedule.max([
22 Schedule.fixed("2 seconds"),
23 Schedule.recurs(75),
24]);
25
26class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
27 readonly status: number;
28 readonly body: string;
29}> {}
30
31// The fixture occasionally answers a transient 5xx under load (cold re-init,
32// IAM propagation on the freshly attached policy that the handler's
33// `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine 4xx surfaces
34// immediately.
35const send = (request: HttpClientRequest.HttpClientRequest) =>
36 HttpClient.execute(request).pipe(
37 Effect.flatMap((response) =>
38 response.status >= 500
39 ? response.text.pipe(
40 Effect.flatMap((body) =>
41 Effect.fail(
42 new TransientUpstream({ status: response.status, body }),
43 ),
44 ),
45 )
46 : Effect.succeed(response),
47 ),
48 Effect.retry({
49 while: (e) => e._tag === "TransientUpstream",
50 schedule: Schedule.max([
51 Schedule.exponential("500 millis"),
52 Schedule.recurs(6),
53 ]),
54 }),
55 );
56
57const getJson = (baseUrl: string, path: string) =>
58 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
59 Effect.flatMap((r) => r.json),
60 );
61
62const postJson = (baseUrl: string, path: string) =>
63 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
64 Effect.flatMap((r) => r.json),
65 );
66
67const awaitReady = (baseUrl: string) =>
68 HttpClient.get(`${baseUrl}/bindings`).pipe(
69 Effect.flatMap((response) =>
70 response.status === 200
71 ? Effect.succeed(response)
72 : Effect.fail(new Error(`Function not ready: ${response.status}`)),
73 ),
74 Effect.retry({ schedule: readinessPolicy }),
75 );
76
77let baseUrl: string;
78
79describe.sequential(
80 "Grafana Bindings",
81 {
82 tags: [
83 "provider:aws",
84 "provider:aws:grafana",
85 "provider:aws:lambda",
86 "live",
87 ],
88 },
89 () => {
90 beforeAll(
91 Effect.gen(function* () {
92 yield* Effect.logInfo(
93 "Grafana test setup: destroying previous resources",
94 );
95 yield* sharedStack.destroy();
96
97 yield* Effect.logInfo("Grafana test setup: deploying fixture");
98 const attrs = yield* sharedStack.deploy(
99 Effect.gen(function* () {
100 return yield* GrafanaTestFunction;
101 }).pipe(Effect.provide(GrafanaTestFunctionLive)),
102 );
103
104 expect(attrs.functionUrl).toBeTruthy();
105 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
106 yield* awaitReady(baseUrl);
107 }),
108 { timeout: 240_000 },
109 );
110
111 afterAll(
112 Effect.gen(function* () {
113 yield* sharedStack.destroy();
114 }),
115 { timeout: 120_000 },
116 );
117
118 describe("binding registration", () => {
119 test.provider("the account-level capability initializes", (_stack) =>
120 Effect.gen(function* () {
121 const response = (yield* getJson(baseUrl, "/bindings")) as {
122 bound: string[];
123 };
124 expect(response.bound).toEqual(["listVersions"]);
125 }),
126 );
127 });
128
129 describe("ListVersions", () => {
130 test.provider(
131 "lists the Grafana versions available for new workspaces",
132 (_stack) =>
133 Effect.gen(function* () {
134 const response = (yield* getJson(baseUrl, "/versions")) as {
135 versions: string[];
136 };
137 expect(response.versions.length).toBeGreaterThan(0);
138 for (const version of response.versions) {
139 expect(version).toMatch(/^\d+(\.\d+)*$/);
140 }
141 }),
142 );
143 });
144
145 // GATED: creating the workspace the workspace-scoped bindings attach to is
146 // asynchronous (several minutes to reach ACTIVE). Run with
147 // AWS_TEST_GRAFANA=1 — same gate as the Workspace lifecycle test.
148 describe("workspace-scoped bindings (gated)", () => {
149 test.provider.skipIf(!process.env.AWS_TEST_GRAFANA)(
150 "auth/config reads, permissions, and the service-account + token round-trip",
151 (stack) =>
152 Effect.gen(function* () {
153 yield* stack.destroy();
154
155 const attrs = yield* stack.deploy(
156 Effect.gen(function* () {
157 return yield* GrafanaWorkspaceTestFunction;
158 }).pipe(Effect.provide(GrafanaWorkspaceTestFunctionLive)),
159 );
160 const url = attrs.functionUrl!.replace(/\/+$/, "");
161 yield* awaitReady(url);
162
163 const bindings = (yield* getJson(url, "/bindings")) as {
164 bound: string[];
165 };
166 expect(bindings.bound).toHaveLength(15);
167 expect(bindings.bound).toContain("createToken");
168 expect(bindings.bound).toContain("associateLicense");
169
170 const auth = (yield* getJson(url, "/auth")) as {
171 providers: string[];
172 samlStatus?: string;
173 };
174 expect(auth.providers).toContain("SAML");
175
176 const config = (yield* getJson(url, "/config")) as {
177 hasConfiguration: boolean;
178 grafanaVersion?: string;
179 };
180 expect(config.hasConfiguration).toBe(true);
181
182 const permissions = (yield* getJson(url, "/permissions")) as {
183 count?: number;
184 errorTag?: string;
185 };
186 if (permissions.errorTag) {
187 // SSO-permission listing on a SAML-only workspace may be
188 // rejected with a typed error — still proves binding + IAM.
189 expect([
190 "ValidationException",
191 "AccessDeniedException",
192 ]).toContain(permissions.errorTag);
193 } else {
194 expect(permissions.count).toBeGreaterThanOrEqual(0);
195 }
196
197 const accounts = (yield* getJson(url, "/service-accounts")) as {
198 count: number;
199 };
200 expect(accounts.count).toBe(0);
201
202 const roundtrip = (yield* postJson(
203 url,
204 "/service-account-roundtrip",
205 )) as {
206 serviceAccountId: string;
207 grafanaRole: string;
208 keyPrefix: string;
209 keyLength: number;
210 tokenCount: number;
211 };
212 expect(roundtrip.grafanaRole).toBe("EDITOR");
213 // Grafana service-account tokens are `glsa_...` secrets; the
214 // binding returns the key Redacted and the fixture unwraps it.
215 expect(roundtrip.keyPrefix).toBe("glsa_");
216 expect(roundtrip.keyLength).toBeGreaterThan(10);
217 expect(roundtrip.tokenCount).toBe(1);
218
219 yield* stack.destroy();
220 }),
221 { timeout: 900_000 },
222 );
223 });
224 },
225);