diff --git a/packages/core/src/submodules/account-id-endpoint/account-id-endpoint.integ.spec.ts b/packages/core/src/submodules/account-id-endpoint/account-id-endpoint.integ.spec.ts new file mode 100644 index 0000000000000..478b6ef9abb04 --- /dev/null +++ b/packages/core/src/submodules/account-id-endpoint/account-id-endpoint.integ.spec.ts @@ -0,0 +1,116 @@ +import { requireRequestsFrom } from "@aws-sdk/aws-util-test/src"; +import { DynamoDB } from "@aws-sdk/client-dynamodb"; +import type { AccountIdEndpointMode } from "@aws-sdk/core/account-id-endpoint"; +import type { AwsCredentialIdentity } from "@smithy/types"; +import { afterEach, beforeEach, describe, expect, test as it } from "vitest"; + +describe("account id endpoint", () => { + const region = "us-west-2"; + + beforeEach(async () => { + delete process.env.AWS_ACCOUNT_ID_ENDPOINT_MODE; + }); + + afterEach(async () => { + delete process.env.AWS_ACCOUNT_ID_ENDPOINT_MODE; + }); + + describe("when credentials have account id", () => { + const credentials: AwsCredentialIdentity = { + accessKeyId: "INTEG_TEST", + secretAccessKey: "INTEG_TEST", + accountId: "123456789012", + }; + + it("should default to resolving endpoint with account id when it is available in the client credentials", async () => { + const ddb = new DynamoDB({ + region, + credentials, + }); + requireRequestsFrom(ddb).toMatch({ + hostname: /123456789012.ddb.us-west-2.amazonaws.com/, + }); + await ddb.listTables(); + }); + + describe("config values", () => { + it.each([ + ["dynamodb", "disabled"], + ["123456789012.ddb", "preferred"], + ["123456789012.ddb", "required"], + ])( + "should match prefix '%s' for when accountId endpoint mode is '%s' in config", + async (expectedHostnamePrefix, mode) => { + const ddb = new DynamoDB({ + region, + credentials, + accountIdEndpointMode: mode as AccountIdEndpointMode, + }); + requireRequestsFrom(ddb).toMatch({ + hostname: `${expectedHostnamePrefix}.${region}.amazonaws.com`, + }); + await ddb.listTables(); + } + ); + }); + + describe("ENV values", () => { + it.each([ + ["dynamodb", "disabled"], + ["123456789012.ddb", "preferred"], + ["123456789012.ddb", "required"], + ])( + "should match prefix '%s' for when accountId endpoint mode is '%s' in ENV", + async (expectedHostnamePrefix, mode) => { + process.env.AWS_ACCOUNT_ID_ENDPOINT_MODE = mode; + const ddb = new DynamoDB({ + region, + credentials, + }); + requireRequestsFrom(ddb).toMatch({ + hostname: `${expectedHostnamePrefix}.${region}.amazonaws.com`, + }); + await ddb.listTables(); + } + ); + }); + }); + + describe("when credentials do not have account id", () => { + const credentials = { + accessKeyId: "INTEG_TEST", + secretAccessKey: "INTEG_TEST", + }; + + it("it follows that the hostname will not have it either", async () => { + const ddb = new DynamoDB({ + region, + credentials, + }); + requireRequestsFrom(ddb).toMatch({ + hostname: /dynamodb.us-west-2.amazonaws.com/, + }); + await ddb.listTables(); + }); + + it("will fail if required by ENV", async () => { + process.env.AWS_ACCOUNT_ID_ENDPOINT_MODE = "required"; + const ddb = new DynamoDB({ + region, + credentials, + }); + const error = await ddb.listTables().catch((e) => e); + expect(error.name).toEqual("EndpointError"); + }); + + it("will fail if required by config", async () => { + const ddb = new DynamoDB({ + region, + credentials, + accountIdEndpointMode: "required", + }); + const error = await ddb.listTables().catch((e) => e); + expect(error.name).toEqual("EndpointError"); + }); + }); +}); diff --git a/packages/core/src/submodules/client/pagination.integ.spec.ts b/packages/core/src/submodules/client/pagination.integ.spec.ts new file mode 100644 index 0000000000000..ef11ce41a7355 --- /dev/null +++ b/packages/core/src/submodules/client/pagination.integ.spec.ts @@ -0,0 +1,92 @@ +import { requireRequestsFrom } from "@aws-sdk/aws-util-test/src"; +import { DynamoDB, paginateScan, ScanCommandInput } from "@aws-sdk/client-dynamodb"; +import { HttpResponse } from "@smithy/protocol-http"; +import { describe, expect, test as it } from "vitest"; + +describe("pagination", () => { + it("makes sequential requests using a pagination token", async () => { + const ddb = new DynamoDB({ + credentials: { + accessKeyId: "INTEG_TEST", + secretAccessKey: "INTEG_TEST", + }, + region: "us-west-2", + }); + + requireRequestsFrom(ddb) + .toMatch( + { + hostname: /dynamodb/, + body(b) { + expect(b).toContain("TableName"); + expect(b).not.toContain("ExclusiveStartKey"); + }, + }, + { + hostname: /dynamodb/, + body: /ExclusiveStartKey/, + } + ) + .respondWith( + new HttpResponse({ + statusCode: 200, + headers: {}, + body: Buffer.from( + JSON.stringify({ + Items: [ + { + id: { S: "1" }, + name: { S: "Item 1" }, + }, + { + id: { S: "2" }, + name: { S: "Item 2" }, + }, + ], + Count: 2, + ScannedCount: 2, + LastEvaluatedKey: { + id: { S: "2" }, + }, + }) + ), + }), + new HttpResponse({ + statusCode: 200, + headers: {}, + body: Buffer.from( + JSON.stringify({ + Items: [ + { + id: { S: "3" }, + name: { S: "Item 3" }, + }, + { + id: { S: "4" }, + name: { S: "Item 4" }, + }, + ], + Count: 2, + ScannedCount: 2, + }) + ), + }) + ); + + const requestParams: ScanCommandInput = { + TableName: "test", + }; + + let pages = 0; + for await (const page of paginateScan({ client: ddb }, requestParams)) { + void page; + pages += 1; + } + + expect(pages).toEqual(2); + expect(requestParams.ExclusiveStartKey).toEqual({ + id: { S: "2" }, + }); + expect.assertions(7); + }); +}); diff --git a/packages/core/src/submodules/protocols/lazy-json-string.integ.spec.ts b/packages/core/src/submodules/protocols/lazy-json-string.integ.spec.ts new file mode 100644 index 0000000000000..8f98d01fd6fa8 --- /dev/null +++ b/packages/core/src/submodules/protocols/lazy-json-string.integ.spec.ts @@ -0,0 +1,42 @@ +import { requireRequestsFrom } from "@aws-sdk/aws-util-test/src"; +import { Schemas } from "@aws-sdk/client-schemas"; +import { LazyJsonString } from "@smithy/core/serde"; +import { describe, expect, test as it } from "vitest"; + +describe(LazyJsonString.name, () => { + it("should auto-serialize fields to JSON", async () => { + const client = new Schemas({ + region: "us-west-2", + }); + + let request = 0; + + requireRequestsFrom(client).toMatch({ + body(b) { + if (request === 0) { + expect(b).toEqual(`{"Policy":"this is a plain string"}`); + request += 1; + } else if (request === 1) { + expect(b).toEqual(`{"Policy":"{\\"this\\":\\"is a json string\\"}"}`); + request += 1; + } else if (request === 2) { + expect(b).toEqual(`{"Policy":"{\\"message\\":\\"this is a js object\\"}"}`); + request += 1; + } + }, + }); + + await client.putResourcePolicy({ + Policy: "this is a plain string", + }); + await client.putResourcePolicy({ + Policy: `{"this":"is a json string"}`, + }); + await client.putResourcePolicy({ + Policy: { + message: "this is a js object", + }, + }); + expect.assertions(3); + }); +}); diff --git a/packages/core/src/submodules/protocols/request-compression.integ.spec.ts b/packages/core/src/submodules/protocols/request-compression.integ.spec.ts new file mode 100644 index 0000000000000..b39f1a5cb35b7 --- /dev/null +++ b/packages/core/src/submodules/protocols/request-compression.integ.spec.ts @@ -0,0 +1,123 @@ +import { requireRequestsFrom } from "@aws-sdk/aws-util-test/src"; +import { type MetricDatum, CloudWatch } from "@aws-sdk/client-cloudwatch"; +import type { AwsCredentialIdentity } from "@smithy/types"; +import { describe, test as it } from "vitest"; + +describe("request compression", () => { + const credentials: AwsCredentialIdentity = { + accessKeyId: "INTEG_TEST", + secretAccessKey: "INTEG_TEST", + }; + + const smallMetricData: MetricDatum[] = [ + { + MetricName: "TestMetric1", + Value: 42, + Unit: "Count", + Timestamp: new Date(0), + Dimensions: [ + { + Name: "Environment", + Value: "Test", + }, + ], + }, + ]; + + const largeMetricData = Array.from({ length: 1000 }).map(() => smallMetricData[0]); + + it("should not compress small payloads", async () => { + const cw = new CloudWatch({ + credentials, + region: "us-west-2", + }); + + requireRequestsFrom(cw).toMatch({ + headers: { + "content-encoding": /undefined/, + }, + }); + + await cw.putMetricData({ + Namespace: "TestMetrics", + MetricData: smallMetricData, + }); + }); + + it("should compress larger payloads", async () => { + const cw = new CloudWatch({ + credentials, + region: "us-west-2", + }); + + requireRequestsFrom(cw).toMatch({ + headers: { + "content-encoding": /^gzip$/, + }, + }); + + await cw.putMetricData({ + Namespace: "TestMetrics", + MetricData: largeMetricData, + }); + }); + + describe("compression configuration", () => { + it("can be shut off", async () => { + const cw = new CloudWatch({ + credentials, + disableRequestCompression: true, + region: "us-west-2", + }); + + requireRequestsFrom(cw).toMatch({ + headers: { + "content-encoding": /undefined/, + }, + }); + + await cw.putMetricData({ + Namespace: "TestMetrics", + MetricData: largeMetricData, + }); + }); + + it("should compress payloads barely beyond the specified limit", async () => { + const cw = new CloudWatch({ + credentials, + requestMinCompressionSizeBytes: 277_419, + region: "us-west-2", + }); + + requireRequestsFrom(cw).toMatch({ + headers: { + "content-encoding": /^gzip$/, + }, + }); + + await cw.putMetricData({ + Namespace: "TestMetrics", + MetricData: largeMetricData, + }); + }); + + it("should not compress payloads barely below the specified limit", async () => { + const cw = new CloudWatch({ + credentials, + requestMinCompressionSizeBytes: 277_420, + region: "us-west-2", + }); + + requireRequestsFrom(cw).toMatch({ + headers: { + "content-encoding": /undefined/, + }, + }); + + await cw.putMetricData({ + Namespace: "TestMetrics", + MetricData: largeMetricData, + }); + }); + }); +}); diff --git a/private/aws-util-test/package.json b/private/aws-util-test/package.json index 00cce57223178..fe344c5bf2543 100644 --- a/private/aws-util-test/package.json +++ b/private/aws-util-test/package.json @@ -3,7 +3,8 @@ "version": "3.848.0", "private": true, "scripts": { - "build": "concurrently 'yarn:build:cjs' 'yarn:build:types'", + "build": "concurrently 'yarn:build:cjs' 'yarn:build:es' 'yarn:build:types'", + "build:es": "tsc -p tsconfig.es.json", "build:cjs": "tsc -p tsconfig.cjs.json", "build:include:deps": "lerna run --scope $npm_package_name --include-dependencies build", "build:types": "tsc -p tsconfig.types.json", @@ -14,6 +15,7 @@ "test:integration:watch": "yarn g:vitest watch -c vitest.config.integ.js" }, "main": "./dist-cjs/index.js", + "module": "./dist-es/index.js", "types": "./dist-types/index.d.ts", "sideEffects": false, "dependencies": { diff --git a/private/aws-util-test/src/requests/test-http-handler.ts b/private/aws-util-test/src/requests/test-http-handler.ts index f4bbe7126e2ff..7fdb9077e5ce0 100644 --- a/private/aws-util-test/src/requests/test-http-handler.ts +++ b/private/aws-util-test/src/requests/test-http-handler.ts @@ -34,12 +34,17 @@ export type HttpRequestMatcher = { */ export class TestHttpHandler implements HttpHandler { private static WATCHER = Symbol("TestHttpHandler_WATCHER"); + + public readonly matchers: HttpRequestMatcher[]; + private originalSend?: Function; private originalRequestHandler?: RequestHandler; private client?: Client; + private responseQueue: HttpResponse[] = []; private assertions = 0; - public constructor(public readonly matcher: HttpRequestMatcher) { + public constructor(...matchers: HttpRequestMatcher[]) { + this.matchers = matchers; const RESERVED_ENVIRONMENT_VARIABLES = { AWS_DEFAULT_REGION: 1, AWS_REGION: 1, @@ -66,15 +71,15 @@ export class TestHttpHandler implements HttpHandler { /** * @param client - to watch for requests. - * @param matcher - optional override of this instance's matchers. + * @param matchers - optional override of this instance's matchers. * * Temporarily hooks the client.send call to check the outgoing request. */ - public watch(client: Client, matcher: HttpRequestMatcher = this.matcher) { + public watch(client: Client): TestHttpHandler { this.client = client; this.originalRequestHandler = client.config.requestHandler; - client.config.requestHandler = new TestHttpHandler(matcher); + client.config.requestHandler = this; if (!(client as any)[TestHttpHandler.WATCHER]) { (client as any)[TestHttpHandler.WATCHER] = true; const originalSend = (this.originalSend = client.send as any); @@ -87,6 +92,16 @@ export class TestHttpHandler implements HttpHandler { }); }; } + + return this; + } + + /** + * @param httpResponses - to enqueue for mock responses. + */ + public respondWith(...httpResponses: HttpResponse[]): TestHttpHandler { + this.responseQueue.push(...httpResponses); + return this; } /** @@ -97,7 +112,7 @@ export class TestHttpHandler implements HttpHandler { request: HttpRequest, handlerOptions?: HttpHandlerOptions ): Promise> { - const m = this.matcher; + const m = this.matchers.length > 1 ? this.matchers.shift()! : this.matchers[0]; if (m.log) { console.log(request); @@ -117,6 +132,18 @@ export class TestHttpHandler implements HttpHandler { throw new Error("Request handled with no assertions, empty matcher?"); } + if (this.responseQueue.length > 1) { + return { + response: this.responseQueue.shift()!, + }; + } else { + if (this.responseQueue.length === 1) { + return { + response: this.responseQueue[0], + }; + } + } + throw new TestHttpHandlerSuccess(); } @@ -204,8 +231,8 @@ export class TestHttpHandlerSuccess extends Error { */ export const requireRequestsFrom = (client: Client) => { return { - toMatch(matcher: HttpRequestMatcher) { - return new TestHttpHandler(matcher).watch(client); + toMatch(...matchers: HttpRequestMatcher[]) { + return new TestHttpHandler(...matchers).watch(client); }, }; }; diff --git a/private/aws-util-test/tsconfig.es.json b/private/aws-util-test/tsconfig.es.json new file mode 100644 index 0000000000000..e79855e63865f --- /dev/null +++ b/private/aws-util-test/tsconfig.es.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "outDir": "dist-es", + "module": "esnext", + "moduleResolution": "bundler", + "noCheck": true + } +}