1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as appsync from "@distilled.cloud/aws/appsync";
4import { expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import * as Schedule from "effect/Schedule";
9import { fileURLToPath } from "node:url";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13const authorizerPath = fileURLToPath(
14 new URL("./authorizer-handler.ts", import.meta.url),
15);
16
17class ApiStillExists extends Data.TaggedError("ApiStillExists")<{
18 readonly apiId: string;
19}> {}
20
21/** Poll until `getGraphqlApi` returns the typed NotFoundException. */
22const assertApiDeleted = (apiId: string) =>
23 appsync.getGraphqlApi({ apiId }).pipe(
24 Effect.flatMap(() => Effect.fail(new ApiStillExists({ apiId }))),
25 Effect.retry({
26 while: (e) => e._tag === "ApiStillExists",
27 schedule: Schedule.max([
28 Schedule.fixed("2 seconds"),
29 Schedule.recurs(10),
30 ]),
31 }),
32 Effect.catchTag("NotFoundException", () => Effect.void),
33 );
34
35const SCHEMA_V1 = `
36type Query {
37 hello: String
38}
39schema { query: Query }
40`;
41
42const SCHEMA_V2 = `
43type Query {
44 hello: String
45 version: Int
46}
47schema { query: Query }
48`;
49
50test.provider(
51 "create, update, delete GraphQL API with schema and api key",
52 (stack) =>
53 Effect.gen(function* () {
54 yield* stack.destroy();
55
56 const deployApi = (schema: string, xray: boolean) =>
57 stack.deploy(
58 Effect.gen(function* () {
59 const api = yield* AWS.AppSync.GraphqlApi("LifecycleApi", {
60 schema,
61 xrayEnabled: xray,
62 queryDepthLimit: xray ? 5 : undefined,
63 environmentVariables: xray
64 ? { STAGE: "updated", EXTRA: "1" }
65 : { STAGE: "initial" },
66 });
67 const key = yield* AWS.AppSync.ApiKey("Key", {
68 api,
69 description: "test key",
70 });
71 return {
72 apiId: api.apiId,
73 apiArn: api.apiArn,
74 name: api.name,
75 graphqlUrl: api.graphqlUrl,
76 authenticationType: api.authenticationType,
77 keyId: key.id,
78 };
79 }),
80 );
81
82 const out = yield* deployApi(SCHEMA_V1, false);
83
84 expect(out.apiId).toBeTruthy();
85 expect(out.graphqlUrl).toContain("appsync-api");
86 expect(out.authenticationType).toBe("API_KEY");
87 expect(Redacted.value(out.keyId)).toMatch(/^da2-/);
88
89 // Out-of-band verification via distilled.
90 const remote = yield* appsync.getGraphqlApi({ apiId: out.apiId });
91 expect(remote.graphqlApi?.name).toBe(out.name);
92 // Internal Alchemy tags brand the API.
93 expect(Object.keys(remote.graphqlApi?.tags ?? {}).length).toBeGreaterThan(
94 0,
95 );
96
97 // Schema applied and settled.
98 const schemaStatus = yield* appsync.getSchemaCreationStatus({
99 apiId: out.apiId,
100 });
101 expect(["SUCCESS", "ACTIVE"]).toContain(schemaStatus.status);
102
103 // Environment variables applied (ctx.env in resolver code).
104 const envVars = yield* appsync.getGraphqlApiEnvironmentVariables({
105 apiId: out.apiId,
106 });
107 expect(envVars.environmentVariables).toEqual({ STAGE: "initial" });
108
109 // The API key is registered on the API.
110 const keyId = Redacted.value(out.keyId);
111 const keys = yield* appsync.listApiKeys({ apiId: out.apiId });
112 expect(keys.apiKeys?.map((k) => k.id)).toContain(keyId);
113 expect(keys.apiKeys?.find((k) => k.id === keyId)?.description).toBe(
114 "test key",
115 );
116
117 // Update in place: schema v2 + xray + query depth limit.
118 const updated = yield* deployApi(SCHEMA_V2, true);
119 expect(updated.apiId).toBe(out.apiId);
120 expect(Redacted.value(updated.keyId)).toBe(keyId);
121
122 const afterUpdate = yield* appsync.getGraphqlApi({ apiId: out.apiId });
123 expect(afterUpdate.graphqlApi?.xrayEnabled).toBe(true);
124 expect(afterUpdate.graphqlApi?.queryDepthLimit).toBe(5);
125
126 // The environment-variable map is replaced wholesale on update.
127 const envVarsAfter = yield* appsync.getGraphqlApiEnvironmentVariables({
128 apiId: out.apiId,
129 });
130 expect(envVarsAfter.environmentVariables).toEqual({
131 STAGE: "updated",
132 EXTRA: "1",
133 });
134
135 yield* stack.destroy();
136 yield* assertApiDeleted(out.apiId);
137 }),
138 { tags: ["provider:aws", "provider:aws:appsync", "live"], timeout: 240_000 },
139);
140
141test.provider(
142 "authentication modes: AWS_LAMBDA primary with API_KEY + Cognito additional",
143 (stack) =>
144 Effect.gen(function* () {
145 yield* stack.destroy();
146
147 const out = yield* stack.deploy(
148 Effect.gen(function* () {
149 const authorizer = yield* AWS.Lambda.Function("Authorizer", {
150 main: authorizerPath,
151 handler: "handler",
152 isExternal: true,
153 functionUrl: false,
154 });
155 const pool = yield* AWS.Cognito.UserPool("AuthPool", {});
156 const api = yield* AWS.AppSync.GraphqlApi("AuthApi", {
157 authenticationType: "AWS_LAMBDA",
158 lambdaAuthorizerConfig: {
159 authorizerUri: authorizer.functionArn,
160 // Duration.Input prop — sent to AWS as whole seconds.
161 authorizerResultTtl: "10 minutes",
162 },
163 additionalAuthenticationProviders: [
164 { authenticationType: "API_KEY" },
165 {
166 authenticationType: "AMAZON_COGNITO_USER_POOLS",
167 userPoolConfig: { userPoolId: pool.userPoolId },
168 },
169 ],
170 schema: SCHEMA_V1,
171 });
172 yield* AWS.Lambda.Permission("AppSyncInvokeAuthorizer", {
173 functionName: authorizer.functionName,
174 principal: "appsync.amazonaws.com",
175 action: "lambda:InvokeFunction",
176 sourceArn: api.apiArn,
177 });
178 return {
179 apiId: api.apiId,
180 authenticationType: api.authenticationType,
181 authorizerArn: authorizer.functionArn,
182 };
183 }),
184 );
185
186 expect(out.authenticationType).toBe("AWS_LAMBDA");
187
188 const remote = yield* appsync.getGraphqlApi({ apiId: out.apiId });
189 expect(remote.graphqlApi?.authenticationType).toBe("AWS_LAMBDA");
190 expect(remote.graphqlApi?.lambdaAuthorizerConfig?.authorizerUri).toBe(
191 out.authorizerArn,
192 );
193 expect(
194 remote.graphqlApi?.lambdaAuthorizerConfig?.authorizerResultTtlInSeconds,
195 ).toBe(600);
196 expect(
197 remote.graphqlApi?.additionalAuthenticationProviders
198 ?.map((p) => p.authenticationType)
199 .sort(),
200 ).toEqual(["AMAZON_COGNITO_USER_POOLS", "API_KEY"].sort());
201
202 yield* stack.destroy();
203 yield* assertApiDeleted(out.apiId);
204 }),
205 {
206 tags: [
207 "provider:aws",
208 "provider:aws:appsync",
209 "provider:aws:cognito",
210 "provider:aws:lambda",
211 "live",
212 ],
213 timeout: 240_000,
214 },
215);
216
217// ApiCache instances bill hourly and take ~10-20 minutes to provision —
218// props are fully implemented but the live path is gated.
219test.provider.skipIf(!process.env.AWS_TEST_APPSYNC_CACHE)(
220 "api cache lifecycle (hourly billing — gated)",
221 (stack) =>
222 Effect.gen(function* () {
223 yield* stack.destroy();
224
225 const out = yield* stack.deploy(
226 Effect.gen(function* () {
227 const api = yield* AWS.AppSync.GraphqlApi("CachedApi", {
228 schema: SCHEMA_V1,
229 cache: {
230 type: "SMALL",
231 behavior: "FULL_REQUEST_CACHING",
232 ttl: "60 seconds",
233 },
234 });
235 return { apiId: api.apiId };
236 }),
237 );
238
239 // Cache exists (status may still be CREATING — we don't wait).
240 const cache = yield* appsync.getApiCache({ apiId: out.apiId });
241 expect(cache.apiCache?.apiCachingBehavior).toBe("FULL_REQUEST_CACHING");
242 expect(cache.apiCache?.ttl).toBe(60);
243
244 yield* stack.destroy();
245 yield* assertApiDeleted(out.apiId);
246 }),
247 { tags: ["provider:aws", "provider:aws:appsync", "live"], timeout: 240_000 },
248);