@intentius/chant-lexicon-aws 0.44.9 → 0.44.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@ import {
19
19
  xmlError,
20
20
  type AwsReadHttp,
21
21
  } from "./read-client";
22
+ import { EMPTY_PAYLOAD_SHA256 } from "./sigv4";
22
23
 
23
24
  const respond = (text: string, status = 200): ReturnType<AwsReadHttp> => Promise.resolve({ status, text });
24
25
 
@@ -210,25 +211,107 @@ describe("the region an endpoint override cannot carry in its host", () => {
210
211
  // snapshot recorded that region as holding nothing.
211
212
  test("the request names the region in its credential scope", async () => {
212
213
  const { http, calls } = recording(() => respond(stackXml));
213
- await describeStackResources("web", { endpoint: "http://localhost:4566", region: "us-west-1", http });
214
+ await describeStackResources("web", { endpoint: "http://localhost:4566", region: "us-west-1", http, env: {} });
214
215
  expect(calls[0].headers.authorization).toContain("/us-west-1/cloudformation/aws4_request");
215
216
  });
216
217
 
217
218
  test("Cloud Control carries it too, scoped to its own service", async () => {
218
219
  const { http, calls } = recording(() => respond(JSON.stringify({ ResourceDescription: {} })));
219
- await getResource("AWS::EC2::VPC", "vpc-01", { endpoint: "http://localhost:4566", region: "eu-west-1", http });
220
+ await getResource("AWS::EC2::VPC", "vpc-01", { endpoint: "http://localhost:4566", region: "eu-west-1", http, env: {} });
220
221
  expect(calls[0].headers.authorization).toContain("/eu-west-1/cloudcontrolapi/aws4_request");
221
222
  });
222
223
 
223
224
  test("no region, no header — nothing invents one", async () => {
224
225
  const { http, calls } = recording(() => respond(stackXml));
225
- await describeStackResources("web", { endpoint: "http://localhost:4566", http });
226
+ await describeStackResources("web", { endpoint: "http://localhost:4566", http, env: {} });
226
227
  expect(calls[0].headers.authorization).toBeUndefined();
227
228
  });
228
229
 
229
230
  test("it is a scope, not a signature — the placeholder says so", async () => {
230
231
  const { http, calls } = recording(() => respond(stackXml));
231
- await describeStackResources("web", { region: "us-west-1", http });
232
+ await describeStackResources("web", { region: "us-west-1", http, env: {} });
232
233
  expect(calls[0].headers.authorization).toContain("Signature=unsigned");
233
234
  });
234
235
  });
236
+
237
+ /**
238
+ * The signing decision, as the client makes it. `./sigv4.test.ts` proves the
239
+ * signature is correct against AWS's own vectors; these prove the client only
240
+ * produces one when it should.
241
+ */
242
+ describe("SigV4 on the read path", () => {
243
+ const credentials = { accessKeyId: "AKIDEXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" };
244
+ const now = new Date("2015-08-30T12:36:00Z");
245
+
246
+ test("credentials and a real host produce a signed request, not the placeholder", async () => {
247
+ const { http, calls } = recording(() => respond(stackXml));
248
+ await describeStackResources("web", { region: "us-west-1", credentials, now, http, env: {} });
249
+
250
+ const auth = calls[0].headers.authorization;
251
+ expect(auth).toContain("Credential=AKIDEXAMPLE/20150830/us-west-1/cloudformation/aws4_request");
252
+ expect(auth).not.toContain("Signature=unsigned");
253
+ expect(auth).toMatch(/Signature=[0-9a-f]{64}$/);
254
+ expect(calls[0].headers["x-amz-date"]).toBe("20150830T123600Z");
255
+ // The body hash, not the empty-payload constant — the Query call has a body.
256
+ expect(calls[0].headers["x-amz-content-sha256"]).toMatch(/^[0-9a-f]{64}$/);
257
+ expect(calls[0].headers["x-amz-content-sha256"]).not.toBe(EMPTY_PAYLOAD_SHA256);
258
+ // `host` is signed but never emitted: fetch computes it and forbids the override.
259
+ expect(calls[0].headers.host).toBeUndefined();
260
+ expect(auth).toContain("SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date");
261
+ });
262
+
263
+ test("an endpoint override is not signed, so the emulator lanes need no credentials", async () => {
264
+ const { http, calls } = recording(() => respond(stackXml));
265
+ await describeStackResources("web", {
266
+ endpoint: "http://localhost:4566",
267
+ region: "us-west-1",
268
+ credentials,
269
+ http,
270
+ env: {},
271
+ });
272
+ expect(calls[0].headers.authorization).toContain("Signature=unsigned");
273
+ expect(calls[0].headers["x-amz-date"]).toBeUndefined();
274
+ });
275
+
276
+ test("signEndpointOverride signs one anyway — for an override that is real AWS", async () => {
277
+ const { http, calls } = recording(() => respond(stackXml));
278
+ await describeStackResources("web", {
279
+ endpoint: "https://vpce-1234.cloudformation.us-west-1.vpce.amazonaws.com",
280
+ region: "us-west-1",
281
+ credentials,
282
+ signEndpointOverride: true,
283
+ now,
284
+ http,
285
+ env: {},
286
+ });
287
+ expect(calls[0].headers.authorization).toMatch(/Signature=[0-9a-f]{64}$/);
288
+ });
289
+
290
+ test("no credentials leaves the old unsigned path exactly as it was", async () => {
291
+ const { http, calls } = recording(() => respond(stackXml));
292
+ await describeStackResources("web", { region: "us-west-1", http, env: {} });
293
+ expect(calls[0].headers.authorization).toContain("Signature=unsigned");
294
+ expect(calls[0].headers["x-amz-date"]).toBeUndefined();
295
+ expect(calls[0].headers["x-amz-content-sha256"]).toBeUndefined();
296
+ });
297
+
298
+ test("the environment is the fallback source, and Cloud Control signs from it too", async () => {
299
+ const { http, calls } = recording(() => respond(JSON.stringify({ ResourceDescription: {} })));
300
+ await getResource("AWS::EC2::VPC", "vpc-01", {
301
+ region: "eu-west-1",
302
+ now,
303
+ http,
304
+ env: { AWS_ACCESS_KEY_ID: "AKIDENV", AWS_SECRET_ACCESS_KEY: "s", AWS_SESSION_TOKEN: "tok" },
305
+ });
306
+ expect(calls[0].headers.authorization).toContain("Credential=AKIDENV/20150830/eu-west-1/cloudcontrolapi/");
307
+ expect(calls[0].headers["x-amz-security-token"]).toBe("tok");
308
+ expect(calls[0].headers["x-amz-target"]).toBe("CloudApiService.GetResource");
309
+ });
310
+
311
+ test("signing without a named region falls back to the same default the host does", async () => {
312
+ const { http, calls } = recording(() => respond(stackXml));
313
+ await describeStackResources("web", { credentials, now, http, env: {} });
314
+ expect(calls[0].url).toBe("https://cloudformation.us-east-1.amazonaws.com/");
315
+ expect(calls[0].headers.authorization).toContain("/20150830/us-east-1/cloudformation/aws4_request");
316
+ });
317
+ });
@@ -19,12 +19,23 @@
19
19
  *
20
20
  * ## Signing
21
21
  *
22
- * Requests are unsigned, exactly like `awsApply`'s. That is what makes the
23
- * emulator-first lanes work with no credential plumbing, and it is the reason
24
- * this is scoped to the Floci lanes (#1198) rather than announced as a
25
- * real-cloud read path: real AWS rejects an unsigned request. The CLI path
26
- * remains available for a signed read until SigV4 lands here.
22
+ * Requests are signed with SigV4 (#1686) when credentials resolve see
23
+ * `./sigv4.ts`, which holds the implementation so that cedar's AVP client can
24
+ * adopt the same one rather than growing a second. Two cases stay unsigned, and
25
+ * both are deliberate:
26
+ *
27
+ * - **No credentials.** The caller's existing no-credentials path is
28
+ * untouched: the request still goes out carrying only the credential scope
29
+ * of {@link regionScope}, which is what the emulator lanes have always
30
+ * sent.
31
+ * - **An endpoint override.** Floci does not verify signatures, and signing
32
+ * against it would mean every local lane suddenly needs credentials to read
33
+ * what it just deployed. `signEndpointOverride` opts back in for an
34
+ * override that *is* real AWS — a VPC endpoint, a signing proxy.
27
35
  */
36
+ import { resolveCredentials, signRequest, type AwsCredentialSource } from "./sigv4";
37
+
38
+ export type { AwsCredentials, AwsCredentialResolver, AwsCredentialSource } from "./sigv4";
28
39
 
29
40
  const DEFAULT_REGION = "us-east-1";
30
41
  const CFN_API_VERSION = "2010-05-15";
@@ -62,6 +73,18 @@ export interface AwsReadClientOptions {
62
73
  region?: string;
63
74
  http?: AwsReadHttp;
64
75
  signal?: AbortSignal;
76
+ /**
77
+ * What to sign with: literal credentials, or a resolver that decides. Omitted,
78
+ * the environment answers; when it has nothing, the request goes out unsigned
79
+ * exactly as it did before signing existed.
80
+ */
81
+ credentials?: AwsCredentialSource;
82
+ /** Environment the credential fallback reads. Defaults to `process.env`; injectable for tests. */
83
+ env?: Record<string, string | undefined>;
84
+ /** Sign even against an endpoint override — for an override that is real AWS. */
85
+ signEndpointOverride?: boolean;
86
+ /** Signing clock. Injected by tests so a signature is reproducible. */
87
+ now?: Date;
65
88
  }
66
89
 
67
90
  /** Service host for `service`, honouring an endpoint override. */
@@ -84,15 +107,19 @@ export function serviceUrl(service: string, endpoint?: string, region = DEFAULT_
84
107
  * an empty observation and the snapshot recorded the region as holding nothing.
85
108
  * A three-region estate snapshotted as one region and nothing said so.
86
109
  *
87
- * This is NOT SigV4. The signature is a placeholder and real AWS rejects it
88
- * as it already rejects every request from this module, which is unsigned by
89
- * design (see the header comment). It carries the scope, nothing more, and it
90
- * must not be mistaken for the signed read path that would replace it.
110
+ * This is NOT SigV4. The signature is a placeholder and real AWS rejects it. It
111
+ * is what an unsigned request still has to carry so an emulator learns the
112
+ * region, and it is only ever sent when no credentials resolved the moment
113
+ * they do, {@link requestHeaders} sends a real signature instead.
91
114
  */
92
- function regionScope(service: string, region?: string): Record<string, string> {
115
+ function regionScope(
116
+ service: string,
117
+ region: string | undefined,
118
+ env: Record<string, string | undefined>,
119
+ ): Record<string, string> {
93
120
  if (!region) return {};
94
121
  const day = new Date().toISOString().slice(0, 10).replace(/-/g, "");
95
- const key = process.env.AWS_ACCESS_KEY_ID || "chant";
122
+ const key = env.AWS_ACCESS_KEY_ID || "chant";
96
123
  return {
97
124
  authorization:
98
125
  `AWS4-HMAC-SHA256 Credential=${key}/${day}/${region}/${service}/aws4_request, ` +
@@ -100,6 +127,44 @@ function regionScope(service: string, region?: string): Record<string, string> {
100
127
  };
101
128
  }
102
129
 
130
+ /**
131
+ * The headers one request goes out with — signed when there is something to
132
+ * sign with and the target is real AWS, scope-only otherwise.
133
+ *
134
+ * Signing needs a region even when the caller named none, because the scope
135
+ * string has a slot for one; it borrows the same `us-east-1` default that
136
+ * {@link serviceUrl} already used to build the host, so the signature agrees
137
+ * with the endpoint it is sent to.
138
+ *
139
+ * Exported because this decision — sign, or carry the scope and no signature —
140
+ * belongs to the lexicon's read transport rather than to any one API on it.
141
+ * `agentcore/trace-fetch.ts` reads `bedrock-agentcore` through the same seam,
142
+ * and a second copy of this would be a second place for the emulator carve-out
143
+ * to drift.
144
+ */
145
+ export function requestHeaders(
146
+ service: string,
147
+ url: string,
148
+ body: string,
149
+ base: Record<string, string>,
150
+ options: AwsReadClientOptions,
151
+ ): Record<string, string> {
152
+ const env = options.env ?? process.env;
153
+ const credentials = resolveCredentials(options.credentials, env);
154
+ const signable = credentials && (!options.endpoint || options.signEndpointOverride === true);
155
+ if (!signable) return { ...base, ...regionScope(service, options.region, env) };
156
+ return signRequest({
157
+ method: "POST",
158
+ url,
159
+ headers: base,
160
+ body,
161
+ service,
162
+ region: options.region ?? DEFAULT_REGION,
163
+ credentials,
164
+ ...(options.now ? { now: options.now } : {}),
165
+ });
166
+ }
167
+
103
168
  /* ── CloudFormation Query ─────────────────────────────────────────────────── */
104
169
 
105
170
  /**
@@ -162,10 +227,13 @@ export async function cfnQuery(
162
227
  const res = await http(
163
228
  url,
164
229
  {
165
- headers: {
166
- "content-type": "application/x-www-form-urlencoded",
167
- ...regionScope("cloudformation", options.region),
168
- },
230
+ headers: requestHeaders(
231
+ "cloudformation",
232
+ url,
233
+ body,
234
+ { "content-type": "application/x-www-form-urlencoded" },
235
+ options,
236
+ ),
169
237
  body,
170
238
  },
171
239
  options.signal,
@@ -238,15 +306,21 @@ async function cloudControl(
238
306
  ): Promise<Record<string, unknown>> {
239
307
  const http = options.http ?? defaultHttp;
240
308
  const url = serviceUrl("cloudcontrolapi", options.endpoint, options.region);
309
+ const payloadJson = JSON.stringify(payload);
241
310
  const res = await http(
242
311
  url,
243
312
  {
244
- headers: {
245
- "content-type": "application/x-amz-json-1.0",
246
- "x-amz-target": `${CLOUD_CONTROL_TARGET_PREFIX}.${operation}`,
247
- ...regionScope("cloudcontrolapi", options.region),
248
- },
249
- body: JSON.stringify(payload),
313
+ headers: requestHeaders(
314
+ "cloudcontrolapi",
315
+ url,
316
+ payloadJson,
317
+ {
318
+ "content-type": "application/x-amz-json-1.0",
319
+ "x-amz-target": `${CLOUD_CONTROL_TARGET_PREFIX}.${operation}`,
320
+ },
321
+ options,
322
+ ),
323
+ body: payloadJson,
250
324
  },
251
325
  options.signal,
252
326
  );
@@ -0,0 +1,260 @@
1
+ /**
2
+ * SigV4 (#1686), checked against AWS's own published vectors.
3
+ *
4
+ * A signing implementation that is only tested against itself proves nothing —
5
+ * it will happily agree with its own mistake. Three of the fixtures below are
6
+ * values AWS publishes: the derived signing key from the documentation's
7
+ * "deriving the signing key" example, and the canonical request hash and
8
+ * signature of `get-vanilla` from the `aws-sig-v4-test-suite`. If a refactor
9
+ * breaks canonicalization, those three stop matching.
10
+ *
11
+ * Nothing here touches the network; there is nothing to touch. Signing is a
12
+ * pure function of the request and the clock.
13
+ */
14
+ import { describe, test, expect } from "vitest";
15
+ import { createHmac } from "node:crypto";
16
+ import {
17
+ EMPTY_PAYLOAD_SHA256,
18
+ amzDate,
19
+ canonicalHeaders,
20
+ canonicalRequest,
21
+ resolveCredentials,
22
+ sha256Hex,
23
+ signRequest,
24
+ signingKey,
25
+ stringToSign,
26
+ } from "./sigv4";
27
+
28
+ /** The example secret AWS uses throughout its Signature Version 4 documentation. */
29
+ const SECRET = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
30
+ const ACCESS_KEY = "AKIDEXAMPLE";
31
+
32
+ describe("the published vectors", () => {
33
+ test("the signing key matches the documented derivation", () => {
34
+ // AWS, "Examples of how to derive a signing key for Signature Version 4":
35
+ // 20120215 / us-east-1 / iam.
36
+ expect(signingKey(SECRET, "20120215", "us-east-1", "iam").toString("hex")).toBe(
37
+ "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d",
38
+ );
39
+ });
40
+
41
+ test("get-vanilla canonicalizes and signs exactly as the test suite says", () => {
42
+ const { canonical, signed } = canonicalRequest(
43
+ "GET",
44
+ new URL("https://example.amazonaws.com/"),
45
+ { host: "example.amazonaws.com", "x-amz-date": "20150830T123600Z" },
46
+ EMPTY_PAYLOAD_SHA256,
47
+ );
48
+
49
+ expect(canonical).toBe(
50
+ [
51
+ "GET",
52
+ "/",
53
+ "",
54
+ "host:example.amazonaws.com",
55
+ "x-amz-date:20150830T123600Z",
56
+ "",
57
+ "host;x-amz-date",
58
+ EMPTY_PAYLOAD_SHA256,
59
+ ].join("\n"),
60
+ );
61
+ expect(signed).toBe("host;x-amz-date");
62
+
63
+ const scope = "20150830/us-east-1/service/aws4_request";
64
+ const sts = stringToSign("20150830T123600Z", scope, canonical);
65
+ expect(sts).toBe(
66
+ "AWS4-HMAC-SHA256\n20150830T123600Z\n" +
67
+ `${scope}\nbb579772317eb040ac9ed261061d46c1f17a8133879d6129b6e1c25292927e63`,
68
+ );
69
+
70
+ const key = signingKey(SECRET, "20150830", "us-east-1", "service");
71
+ expect(createHmac("sha256", key).update(sts, "utf8").digest("hex")).toBe(
72
+ "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31",
73
+ );
74
+ });
75
+
76
+ test("the empty-payload constant is what SHA-256 of nothing actually is", () => {
77
+ expect(sha256Hex("")).toBe(EMPTY_PAYLOAD_SHA256);
78
+ });
79
+ });
80
+
81
+ describe("header canonicalization", () => {
82
+ test("names lowercase and sort, values trim and collapse inner runs", () => {
83
+ expect(
84
+ canonicalHeaders({
85
+ "X-Amz-Date": "20150830T123600Z",
86
+ "Content-Type": " application/json ",
87
+ Host: "example.amazonaws.com",
88
+ "My-Header": "a b\tc",
89
+ }),
90
+ ).toEqual({
91
+ canonical:
92
+ "content-type:application/json\nhost:example.amazonaws.com\n" +
93
+ "my-header:a b c\nx-amz-date:20150830T123600Z\n",
94
+ signed: "content-type;host;my-header;x-amz-date",
95
+ });
96
+ });
97
+
98
+ test("sorting is by byte, so an uppercase name still sorts by its lowercase form", () => {
99
+ expect(canonicalHeaders({ Zeta: "1", alpha: "2", MIDDLE: "3" }).signed).toBe("alpha;middle;zeta");
100
+ });
101
+
102
+ test("the unsignable headers are left out — signing what a proxy rewrites is an intermittent 403", () => {
103
+ const { signed } = canonicalHeaders({
104
+ authorization: "stale",
105
+ "user-agent": "chant",
106
+ "x-amzn-trace-id": "Root=1-x",
107
+ connection: "keep-alive",
108
+ expect: "100-continue",
109
+ host: "example.amazonaws.com",
110
+ });
111
+ expect(signed).toBe("host");
112
+ });
113
+
114
+ test("a bodyless request hashes to the empty-payload constant, not to a hash of nothing-in-particular", () => {
115
+ const headers = signRequest({
116
+ method: "POST",
117
+ url: "https://cloudformation.us-east-1.amazonaws.com/",
118
+ headers: {},
119
+ body: "",
120
+ service: "cloudformation",
121
+ region: "us-east-1",
122
+ credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET },
123
+ now: new Date("2015-08-30T12:36:00Z"),
124
+ });
125
+ expect(headers["x-amz-content-sha256"]).toBe(EMPTY_PAYLOAD_SHA256);
126
+ });
127
+
128
+ test("the query string sorts by name then value, RFC 3986 encoded", () => {
129
+ const { canonical } = canonicalRequest(
130
+ "GET",
131
+ new URL("https://example.amazonaws.com/?b=2&a=z&a=a&c=hi%20there*"),
132
+ { host: "example.amazonaws.com" },
133
+ EMPTY_PAYLOAD_SHA256,
134
+ );
135
+ expect(canonical.split("\n")[2]).toBe("a=a&a=z&b=2&c=hi%20there%2A");
136
+ });
137
+
138
+ test("amzDate is ISO-8601 basic, which is what X-Amz-Date wants", () => {
139
+ expect(amzDate(new Date("2015-08-30T12:36:00.123Z"))).toBe("20150830T123600Z");
140
+ });
141
+ });
142
+
143
+ describe("signRequest", () => {
144
+ const credentials = { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET };
145
+ const now = new Date("2015-08-30T12:36:00Z");
146
+
147
+ test("adds its own headers, keeps the caller's, and never emits host", () => {
148
+ const headers = signRequest({
149
+ method: "POST",
150
+ url: "https://cloudformation.us-west-2.amazonaws.com/",
151
+ headers: { "content-type": "application/x-www-form-urlencoded" },
152
+ body: "Action=DescribeStacks",
153
+ service: "cloudformation",
154
+ region: "us-west-2",
155
+ credentials,
156
+ now,
157
+ });
158
+
159
+ expect(headers["content-type"]).toBe("application/x-www-form-urlencoded");
160
+ expect(headers["x-amz-date"]).toBe("20150830T123600Z");
161
+ expect(headers["x-amz-content-sha256"]).toBe(sha256Hex("Action=DescribeStacks"));
162
+ expect(headers.host).toBeUndefined();
163
+ // A fixed clock makes the whole header a fixture; the primitives it is
164
+ // composed from are the ones checked against AWS's vectors above.
165
+ expect(headers.authorization).toBe(
166
+ "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-west-2/cloudformation/aws4_request, " +
167
+ "SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date, " +
168
+ "Signature=3160e6fcf0cce7e8ff03c128e5e2bb6572ca59e843d3a88c26e45203288fc462",
169
+ );
170
+ });
171
+
172
+ test("host is signed even though it is not emitted — a different host is a different signature", () => {
173
+ const request = {
174
+ method: "POST",
175
+ headers: {},
176
+ body: "",
177
+ service: "cloudformation",
178
+ region: "us-east-1",
179
+ credentials,
180
+ now,
181
+ } as const;
182
+ const one = signRequest({ ...request, url: "https://cloudformation.us-east-1.amazonaws.com/" });
183
+ const other = signRequest({ ...request, url: "https://cloudformation.us-east-2.amazonaws.com/" });
184
+ expect(one.authorization).not.toBe(other.authorization);
185
+ expect(one.authorization).toContain("SignedHeaders=host;x-amz-content-sha256;x-amz-date");
186
+ });
187
+
188
+ test("a session token is both sent and signed", () => {
189
+ const headers = signRequest({
190
+ method: "POST",
191
+ url: "https://cloudcontrolapi.us-east-1.amazonaws.com/",
192
+ headers: {},
193
+ body: "{}",
194
+ service: "cloudcontrolapi",
195
+ region: "us-east-1",
196
+ credentials: { ...credentials, sessionToken: "FwoGZXIvYXdzEXAMPLE" },
197
+ now,
198
+ });
199
+ expect(headers["x-amz-security-token"]).toBe("FwoGZXIvYXdzEXAMPLE");
200
+ expect(headers.authorization).toContain("x-amz-security-token");
201
+ });
202
+
203
+ test("the same request at a different second is a different signature", () => {
204
+ const request = {
205
+ method: "POST",
206
+ url: "https://cloudformation.us-east-1.amazonaws.com/",
207
+ headers: {},
208
+ body: "",
209
+ service: "cloudformation",
210
+ region: "us-east-1",
211
+ credentials,
212
+ } as const;
213
+ expect(signRequest({ ...request, now }).authorization).not.toBe(
214
+ signRequest({ ...request, now: new Date("2015-08-30T12:36:01Z") }).authorization,
215
+ );
216
+ });
217
+ });
218
+
219
+ describe("credential resolution", () => {
220
+ const env = { AWS_ACCESS_KEY_ID: "AKIDENV", AWS_SECRET_ACCESS_KEY: "envsecret", AWS_SESSION_TOKEN: "envtoken" };
221
+
222
+ test("explicit credentials beat the environment", () => {
223
+ expect(resolveCredentials({ accessKeyId: "AKIDEXPLICIT", secretAccessKey: "s" }, env)).toEqual({
224
+ accessKeyId: "AKIDEXPLICIT",
225
+ secretAccessKey: "s",
226
+ });
227
+ });
228
+
229
+ test("the environment answers when nothing was passed, session token included", () => {
230
+ expect(resolveCredentials(undefined, env)).toEqual({
231
+ accessKeyId: "AKIDENV",
232
+ secretAccessKey: "envsecret",
233
+ sessionToken: "envtoken",
234
+ });
235
+ });
236
+
237
+ test("no session token in the environment means no session token in the result", () => {
238
+ expect(resolveCredentials(undefined, { AWS_ACCESS_KEY_ID: "a", AWS_SECRET_ACCESS_KEY: "b" })).toEqual({
239
+ accessKeyId: "a",
240
+ secretAccessKey: "b",
241
+ });
242
+ });
243
+
244
+ test("a half-set environment is absent, not a signature that cannot verify", () => {
245
+ expect(resolveCredentials(undefined, { AWS_ACCESS_KEY_ID: "a" })).toBeUndefined();
246
+ expect(resolveCredentials(undefined, { AWS_SECRET_ACCESS_KEY: "b" })).toBeUndefined();
247
+ });
248
+
249
+ test("an empty environment is absent", () => {
250
+ expect(resolveCredentials(undefined, {})).toBeUndefined();
251
+ });
252
+
253
+ test("a resolver decides, and its refusal is not overruled by the environment", () => {
254
+ expect(resolveCredentials(() => ({ accessKeyId: "AKIDRESOLVED", secretAccessKey: "s" }), env)).toEqual({
255
+ accessKeyId: "AKIDRESOLVED",
256
+ secretAccessKey: "s",
257
+ });
258
+ expect(resolveCredentials(() => undefined, env)).toBeUndefined();
259
+ });
260
+ });