Skip to content

Commit 6f966a6

Browse files
authored
fix(appsync): appsync Event API integration assertion tests (#33572)
### Reason for this change Improved assertion tests for newly added AppSync Event API + Channel namespace constructs. Improved documentation to show no channel namespace is created by default, one must be explicitly defined. ### Description of changes * Added assertions to the following integration tests: * `integ.appsync-event-api.ts` * `integ.appsync-eventapi-api-key-auth.ts` * `integ.appsync-eventapi-cognito-auth.ts` * `integ.appsync-eventapi-grants.ts` * `integ.appsync-eventapi-iam-auth.ts` * `integ.appsync-eventapi-lambda-auth.ts` To validate Cognito authorization, I needed to include the following package `@aws-sdk/client-cognito-identity-provider` which is why `yarn.lock` is updated in this PR. - Added channel namespace usage to all Event API examples in `README.md`. ### Describe any new or updated permissions being added N/A ### Description of how you validated changes Integration tests all run successfully with passed assertion tests. ### Checklist - [X] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent e07a89c commit 6f966a6

File tree

61 files changed

+335281
-12296
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+335281
-12296
lines changed

packages/@aws-cdk-testing/framework-integ/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@aws-sdk/client-acm": "3.632.0",
3636
"@aws-sdk/client-rds": "3.632.0",
3737
"@aws-sdk/client-s3": "3.632.0",
38+
"@aws-sdk/client-cognito-identity-provider": "3.632.0",
3839
"axios": "1.7.8",
3940
"delay": "5.0.0"
4041
},
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
function enrichEvent(event) {
2+
return {
3+
id: event.id,
4+
payload: {
5+
...event.payload,
6+
newField: 'newField'
7+
}
8+
}
9+
}
110
export function onPublish(ctx) {
2-
return ctx.events.filter((event) => event.payload.odds > 0)
11+
return ctx.events.map(enrichEvent);
312
}

packages/@aws-cdk-testing/framework-integ/test/aws-appsync/test/integ-assets/eventapi-grant-assertion/index.js

+190-29
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import { HttpRequest } from '@smithy/protocol-http'
44
import { SignatureV4 } from '@smithy/signature-v4'
55
import { fromNodeProviderChain } from '@aws-sdk/credential-providers'
66
import { Sha256 } from '@aws-crypto/sha256-js'
7+
import {
8+
CognitoIdentityProviderClient,
9+
SignUpCommand,
10+
AdminConfirmSignUpCommand,
11+
AdminDeleteUserCommand,
12+
AdminInitiateAuthCommand,
13+
} from '@aws-sdk/client-cognito-identity-provider';
714

815
// The default headers to to sign the request
916
const DEFAULT_HEADERS = {
@@ -16,6 +23,56 @@ const AWS_APPSYNC_EVENTS_SUBPROTOCOL = 'aws-appsync-event-ws';
1623
const realtimeUrl = process.env.EVENT_API_REALTIME_URL;
1724
const httpUrl = process.env.EVENT_API_HTTP_URL;
1825
const region = process.env.AWS_REGION;
26+
const API_KEY = process.env.API_KEY;
27+
const USER_POOL_ID = process.env.USER_POOL_ID;
28+
const CLIENT_ID = process.env.CLIENT_ID;
29+
const { username, password } = generateUsernamePassword(12);
30+
31+
const cognitoClient = new CognitoIdentityProviderClient();
32+
33+
/**
34+
* Utility function for generating a temporary password
35+
* @param {int} length
36+
* @returns
37+
*/
38+
function generateUsernamePassword(length) {
39+
const uppercaseChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
40+
const lowercaseChars = 'abcdefghijklmnopqrstuvwxyz';
41+
const numberChars = '0123456789';
42+
const specialChars = '!@#$%&';
43+
const allChars = uppercaseChars + lowercaseChars + numberChars + specialChars;
44+
45+
// Ensure length is at least 4 to accommodate required characters
46+
const actualLength = Math.max(length, 4);
47+
48+
// Start with one character from each required set
49+
let password = [
50+
uppercaseChars.charAt(Math.floor(Math.random() * uppercaseChars.length)),
51+
lowercaseChars.charAt(Math.floor(Math.random() * lowercaseChars.length)),
52+
numberChars.charAt(Math.floor(Math.random() * numberChars.length)),
53+
specialChars.charAt(Math.floor(Math.random() * specialChars.length))
54+
];
55+
56+
// Fill the rest with random characters
57+
for (let i = 4; i < actualLength; i++) {
58+
const randomIndex = Math.floor(Math.random() * allChars.length);
59+
password.push(allChars.charAt(randomIndex));
60+
}
61+
62+
// Shuffle the password array to randomize character positions
63+
for (let i = password.length - 1; i > 0; i--) {
64+
const j = Math.floor(Math.random() * (i + 1));
65+
[password[i], password[j]] = [password[j], password[i]];
66+
}
67+
68+
let username = '';
69+
for (let i = 0; i < 6; i++) {
70+
const randomIndex = Math.floor(Math.random() * lowercaseChars.length);
71+
username += lowercaseChars.charAt(randomIndex);
72+
}
73+
74+
return { username, password: password.join('') };
75+
}
1976

2077
/**
2178
* Returns a signed authorization object
@@ -33,7 +90,7 @@ async function signWithAWSV4(httpDomain, region, body) {
3390
sha256: Sha256,
3491
})
3592

36-
const url = new URL(`https://${httpDomain}/event`)
93+
const url = new URL(`${httpDomain}`)
3794
const request = new HttpRequest({
3895
method: 'POST',
3996
headers: {
@@ -55,13 +112,11 @@ async function signWithAWSV4(httpDomain, region, body) {
55112

56113
/**
57114
* Returns a header value for the SubProtocol header
58-
* @param {string} httpDomain the AppSync Event API HTTP domain
59-
* @param {string} region the AWS region of your API
115+
* @param {string} authHeaders the authorization headers
60116
* @returns string a header string
61117
*/
62-
async function getAuthProtocolForIAM(httpDomain, region) {
63-
const signed = await signWithAWSV4(httpDomain, region)
64-
const based64UrlHeader = btoa(JSON.stringify(signed))
118+
function getAuthProtocolForIAM(authHeaders) {
119+
const based64UrlHeader = btoa(JSON.stringify(authHeaders))
65120
.replace(/\+/g, '-') // Convert '+' to '-'
66121
.replace(/\//g, '_') // Convert '/' to '_'
67122
.replace(/=+$/, '') // Remove padding `=`
@@ -78,26 +133,114 @@ function sleep(ms) {
78133
return new Promise(resolve => setTimeout(resolve, ms));
79134
}
80135

136+
/**
137+
* Helper function for creating a Cognito user and confirming the user
138+
* The function also deletes the user after the test is complete
139+
* and it initiates and auth flow to get the ID token for testing the
140+
* Event API auth flow with Cognito.
141+
* @param {string} action - CREATE, DELETE, AUTH
142+
* @returns
143+
*/
144+
async function cognitoUserConfiguration(action) {
145+
switch (action) {
146+
case 'CREATE':
147+
const signUpUserInput = {
148+
ClientId: CLIENT_ID,
149+
Username: username,
150+
Password: password,
151+
};
152+
const signUpCommand = new SignUpCommand(signUpUserInput);
153+
await cognitoClient.send(signUpCommand);
154+
const confirmSignUpInput = {
155+
UserPoolId: USER_POOL_ID,
156+
Username: username,
157+
};
158+
const confirmSignUpCommand = new AdminConfirmSignUpCommand(confirmSignUpInput);
159+
await cognitoClient.send(confirmSignUpCommand);
160+
return {};
161+
case 'DELETE':
162+
const deleteUserInput = {
163+
UserPoolId: USER_POOL_ID,
164+
Username: username,
165+
};
166+
const deleteUserCommand = new AdminDeleteUserCommand(deleteUserInput);
167+
await cognitoClient.send(deleteUserCommand);
168+
return;
169+
case 'AUTH':
170+
const authInput = {
171+
UserPoolId: USER_POOL_ID,
172+
ClientId: CLIENT_ID,
173+
AuthFlow: 'ADMIN_USER_PASSWORD_AUTH',
174+
AuthParameters: {
175+
USERNAME: username,
176+
PASSWORD: password,
177+
},
178+
};
179+
const authCommand = new AdminInitiateAuthCommand(authInput);
180+
const authRes = await cognitoClient.send(authCommand);
181+
return authRes.AuthenticationResult.IdToken;
182+
}
183+
}
184+
185+
/**
186+
* Returns the appropriate headers depending on the auth mode selected
187+
* @param {*} authMode - IAM, API_KEY, LAMBDA, USER_POOL, OIDC
188+
* @param {*} event - the event payload for Publish operations, null by default
189+
* @param {*} authToken - the token for LAMBDA auth modes
190+
* @returns
191+
*/
192+
async function getPublishAuthHeader(authMode, event={}, authToken='') {
193+
const url = new URL(`${httpUrl}`)
194+
const headers = {
195+
host: url.hostname,
196+
};
197+
198+
switch (authMode) {
199+
case 'IAM':
200+
return await signWithAWSV4(httpUrl, region, JSON.stringify(event));
201+
case 'API_KEY':
202+
return {
203+
'x-api-key': `${API_KEY}`,
204+
...headers,
205+
}
206+
case 'USER_POOL':
207+
return {
208+
'Authorization': await cognitoUserConfiguration('AUTH'),
209+
...headers,
210+
}
211+
case 'LAMBDA':
212+
return {
213+
'Authorization': authToken,
214+
...headers,
215+
}
216+
default:
217+
throw new Error(`Unknown auth mode ${authMode}`)
218+
}
219+
}
220+
81221
/**
82222
* Initiates a subscription to a channel and returns the response
83223
*
84224
* @param {string} channel the channel to subscribe to
225+
* @param {string} authMode the authorization mode for the request
226+
* @param {string} authToken the token used for Lambda auth mode
85227
* @param {boolean} triggerPub whether to also publish in the method
86228
* @returns {Object}
87229
*/
88-
async function subscribe(channel, triggerPub=false) {
230+
async function subscribe(channel, authMode, authToken, triggerPub=false) {
89231
const response = {};
90-
const auth = await getAuthProtocolForIAM(httpUrl, region)
232+
const authHeader = await getPublishAuthHeader(authMode, {}, authToken);
233+
const auth = getAuthProtocolForIAM(authHeader);
91234
const socket = await new Promise((resolve, reject) => {
92235
const socket = new WebSocket(
93-
`wss://${realtimeUrl}/event/realtime`,
236+
`${realtimeUrl}`,
94237
[AWS_APPSYNC_EVENTS_SUBPROTOCOL, auth],
95238
{ headers: { ...DEFAULT_HEADERS } },
96239
)
97240

98241
socket.onopen = () => {
99242
socket.send(JSON.stringify({ type: 'connection_init' }))
100-
console.log("Initialize connection");
243+
console.log('Initialize connection');
101244
resolve(socket)
102245
}
103246

@@ -113,18 +256,18 @@ async function subscribe(channel, triggerPub=false) {
113256
console.log('Data received');
114257
response.pubStatusCode = 200;
115258
response.pubMsg = JSON.parse(payload.event).message;
116-
} else if (payload.type === "subscribe_error") {
259+
} else if (payload.type === 'subscribe_error') {
117260
console.log(payload);
118-
if (payload.errors.some((error) => error.errorType === "UnauthorizedException")) {
119-
console.log("Error received");
261+
if (payload.errors.some((error) => error.errorType === 'UnauthorizedException')) {
262+
console.log('Error received');
120263
response.statusCode = 401;
121-
response.msg = "UnauthorizedException";
264+
response.msg = 'UnauthorizedException';
122265
} else if (payload.errors.some(error => error.errorType === 'AccessDeniedException')) {
123266
console.log('Error received');
124267
response.statusCode = 403;
125268
response.msg = 'Forbidden';
126269
} else {
127-
console.log("Error received");
270+
console.log('Error received');
128271
response.statusCode = 400;
129272
response.msg = payload.errors[0].errorType;
130273
}
@@ -138,12 +281,12 @@ async function subscribe(channel, triggerPub=false) {
138281
type: 'subscribe',
139282
id: crypto.randomUUID(),
140283
channel: subChannel,
141-
authorization: await signWithAWSV4(httpUrl, region, JSON.stringify({ channel: subChannel })),
284+
authorization: await getPublishAuthHeader(authMode, { channel: subChannel }, authToken),
142285
}));
143286

144287
if (triggerPub) {
145288
await sleep(1000);
146-
await publish(channel);
289+
await publish(channel, authMode, authToken);
147290
}
148291
await sleep(3000);
149292
return response;
@@ -153,19 +296,21 @@ async function subscribe(channel, triggerPub=false) {
153296
* Publishes to a channel and returns the response
154297
*
155298
* @param {string} channel the channel to publish to
299+
* @param {string} authMode the auth mode to use for publishing
300+
* @param {string} authToken the auth token to use for Lambda auth mode
156301
* @returns {Object}
157302
*/
158-
async function publish(channel) {
303+
async function publish(channel, authMode, authToken) {
159304
const event = {
160-
"channel": `/${channel}/test`,
161-
"events": [
305+
'channel': `/${channel}/test`,
306+
'events': [
162307
JSON.stringify({message:'Hello World!'})
163308
]
164309
}
165310

166-
const response = await fetch(`https://${httpUrl}/event`, {
311+
const response = await fetch(`${httpUrl}`, {
167312
method: 'POST',
168-
headers: await signWithAWSV4(httpUrl, region, JSON.stringify(event)),
313+
headers: await getPublishAuthHeader(authMode, event, authToken),
169314
body: JSON.stringify(event)
170315
});
171316

@@ -190,18 +335,34 @@ async function publish(channel) {
190335
exports.handler = async function(event) {
191336
const pubSubAction = event.action;
192337
const channel = event.channel;
338+
const authMode = event.authMode;
339+
const authToken = event.authToken ?? '';
340+
const isCustomEndpoint = event.customEndpoint ?? false;
193341

342+
// If custom endpoint, wait for 60 seconds for DNS to propagate
343+
if (isCustomEndpoint) {
344+
await sleep(60000);
345+
}
346+
347+
if (authMode === 'USER_POOL') {
348+
await cognitoUserConfiguration('CREATE');
349+
}
350+
351+
let res;
194352
if (pubSubAction === 'publish') {
195-
const res = await publish(channel);
353+
res = await publish(channel, authMode, authToken);
196354
console.log(res);
197-
return res;
198355
} else if (pubSubAction === 'subscribe') {
199-
const res = await subscribe(channel, false);
356+
res = await subscribe(channel, authMode, authToken, false);
200357
console.log(res);
201-
return res;
202358
} else if (pubSubAction === 'pubSub') {
203-
const res = await subscribe(channel, true);
359+
res = await subscribe(channel, authMode, authToken, true);
204360
console.log(res);
205-
return res;
206361
}
207-
};
362+
363+
if (authMode === 'USER_POOL') {
364+
await cognitoUserConfiguration('DELETE');
365+
}
366+
367+
return res;
368+
};

packages/@aws-cdk-testing/framework-integ/test/aws-appsync/test/integ.appsync-event-api.js.snapshot/appsync-event-api-stack.assets.json

+18-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)