1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as appsync from "@distilled.cloud/aws/appsync";
4import * as iam from "@distilled.cloud/aws/iam";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7
8const { test } = Test.make({ providers: AWS.providers() });
9
10const SCHEMA = `
11type Query {
12 hello: String
13}
14schema { query: Query }
15`;
16
17test.provider(
18 "NONE and DynamoDB data sources (auto-created service role)",
19 (stack) =>
20 Effect.gen(function* () {
21 yield* stack.destroy();
22
23 const deploy = (description: string) =>
24 stack.deploy(
25 Effect.gen(function* () {
26 const api = yield* AWS.AppSync.GraphqlApi("DSApi", {
27 schema: SCHEMA,
28 });
29 const none = yield* AWS.AppSync.DataSource("LocalDS", {
30 api,
31 type: "NONE",
32 description,
33 });
34 const table = yield* AWS.DynamoDB.Table("DSTable", {
35 partitionKey: "pk",
36 attributes: { pk: "S" },
37 billingMode: "PAY_PER_REQUEST",
38 });
39 const ddb = yield* AWS.AppSync.DataSource("TableDS", {
40 api,
41 type: "AMAZON_DYNAMODB",
42 dynamodbConfig: { tableName: table.tableName },
43 });
44 return {
45 apiId: api.apiId,
46 noneName: none.name,
47 noneDescription: description,
48 ddbName: ddb.name,
49 ddbRoleName: ddb.roleName,
50 ddbServiceRoleArn: ddb.serviceRoleArn,
51 tableName: table.tableName,
52 tableArn: table.tableArn,
53 };
54 }),
55 );
56
57 const out = yield* deploy("v1");
58
59 // Data source names satisfy AppSync's [_A-Za-z][_0-9A-Za-z]* rule.
60 expect(out.noneName).toMatch(/^[_A-Za-z][_0-9A-Za-z]*$/);
61 expect(out.ddbName).toMatch(/^[_A-Za-z][_0-9A-Za-z]*$/);
62
63 // Out-of-band verification via distilled.
64 const none = yield* appsync.getDataSource({
65 apiId: out.apiId,
66 name: out.noneName,
67 });
68 expect(none.dataSource?.type).toBe("NONE");
69 expect(none.dataSource?.description).toBe("v1");
70
71 const ddb = yield* appsync.getDataSource({
72 apiId: out.apiId,
73 name: out.ddbName,
74 });
75 expect(ddb.dataSource?.type).toBe("AMAZON_DYNAMODB");
76 expect(ddb.dataSource?.dynamodbConfig?.tableName).toBe(out.tableName);
77 expect(ddb.dataSource?.serviceRoleArn).toBe(out.ddbServiceRoleArn);
78
79 // The auto-created role trusts appsync.amazonaws.com and grants
80 // least-privilege access to the table.
81 expect(out.ddbRoleName).toBeTruthy();
82 const role = yield* iam.getRole({ RoleName: out.ddbRoleName! });
83 expect(
84 decodeURIComponent(role.Role.AssumeRolePolicyDocument ?? ""),
85 ).toContain("appsync.amazonaws.com");
86 const policy = yield* iam.getRolePolicy({
87 RoleName: out.ddbRoleName!,
88 PolicyName: `${out.ddbRoleName}-policy`,
89 });
90 const policyDocument = decodeURIComponent(policy.PolicyDocument);
91 expect(policyDocument).toContain(out.tableArn);
92 expect(policyDocument).toContain("dynamodb:Query");
93
94 // Update the NONE data source description in place (same name).
95 const updated = yield* deploy("v2");
96 expect(updated.noneName).toBe(out.noneName);
97 const afterUpdate = yield* appsync.getDataSource({
98 apiId: out.apiId,
99 name: out.noneName,
100 });
101 expect(afterUpdate.dataSource?.description).toBe("v2");
102
103 yield* stack.destroy();
104
105 // Both the data source's API and the managed role are gone.
106 const roleGone = yield* iam.getRole({ RoleName: out.ddbRoleName! }).pipe(
107 Effect.map(() => false),
108 Effect.catchTag("NoSuchEntityException", () => Effect.succeed(true)),
109 );
110 expect(roleGone).toBe(true);
111 const apiGone = yield* appsync.getGraphqlApi({ apiId: out.apiId }).pipe(
112 Effect.map(() => false),
113 Effect.catchTag("NotFoundException", () => Effect.succeed(true)),
114 );
115 expect(apiGone).toBe(true);
116 }),
117 {
118 tags: [
119 "provider:aws",
120 "provider:aws:appsync",
121 "provider:aws:dynamodb",
122 "provider:aws:iam",
123 "live",
124 ],
125 timeout: 240_000,
126 },
127);