diff --git a/vault/aws-sm/extensions/vaults/aws_sm.ts b/vault/aws-sm/extensions/vaults/aws_sm.ts index efbafccdf..b7b53db2f 100644 --- a/vault/aws-sm/extensions/vaults/aws_sm.ts +++ b/vault/aws-sm/extensions/vaults/aws_sm.ts @@ -33,9 +33,9 @@ import { GetSecretValueCommand, ListSecretsCommand, PutSecretValueCommand, - ResourceNotFoundException, SecretsManagerClient, -} from "npm:@aws-sdk/client-secrets-manager@3.1010.0"; +} from "npm:@aws-sdk/client-secrets-manager@3.1024.0"; +import { AwsSmOperationError, wrapAwsSmError } from "./aws_sm_errors.ts"; /** * Minimal contract implemented by swamp vault providers. Exported so that @@ -64,7 +64,12 @@ class AwsSmVaultProvider implements VaultProvider { async get(secretKey: string): Promise { const command = new GetSecretValueCommand({ SecretId: secretKey }); - const response = await this.client.send(command); + let response; + try { + response = await this.client.send(command); + } catch (error) { + throw wrapAwsSmError("GetSecretValue", error); + } const secretValue = response.SecretString || (response.SecretBinary @@ -86,14 +91,25 @@ class AwsSmVaultProvider implements VaultProvider { }); await this.client.send(putCommand); } catch (error) { - if (error instanceof ResourceNotFoundException) { - const createCommand = new CreateSecretCommand({ - Name: secretKey, - SecretString: secretValue, - }); - await this.client.send(createCommand); + const wrapped = wrapAwsSmError("PutSecretValue", error); + // The wrapper preserves the SDK error's `name`, so name-matching + // keeps the create-on-missing fallback working without importing + // ResourceNotFoundException from the SDK. + if ( + wrapped instanceof AwsSmOperationError && + wrapped.name === "ResourceNotFoundException" + ) { + try { + const createCommand = new CreateSecretCommand({ + Name: secretKey, + SecretString: secretValue, + }); + await this.client.send(createCommand); + } catch (createError) { + throw wrapAwsSmError("CreateSecret", createError); + } } else { - throw error; + throw wrapped; } } } @@ -104,7 +120,12 @@ class AwsSmVaultProvider implements VaultProvider { do { const command = new ListSecretsCommand({ NextToken: nextToken }); - const response = await this.client.send(command); + let response; + try { + response = await this.client.send(command); + } catch (error) { + throw wrapAwsSmError("ListSecrets", error); + } if (response.SecretList) { for (const secret of response.SecretList) { diff --git a/vault/aws-sm/extensions/vaults/aws_sm_errors.ts b/vault/aws-sm/extensions/vaults/aws_sm_errors.ts new file mode 100644 index 000000000..75a0a8151 --- /dev/null +++ b/vault/aws-sm/extensions/vaults/aws_sm_errors.ts @@ -0,0 +1,214 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +/** + * Error wrapper and credential-classification helpers for the aws-sm vault. + * + * The vault wraps the AWS Secrets Manager SDK; when SSO sessions expire or + * static credentials are rejected, the SDK's surfaced error buries the + * remediation hint in a stack trace. These helpers classify the failure + * and prepend a swamp-flavoured summary line that names the cause and + * points to `aws sso login --profile `. + * + * Mirrors the pattern in datastore/s3/extensions/datastores/_lib/s3_client.ts + * with two deliberate differences: vault-flavoured wording ("Vault session + * expired" vs "Datastore session expired"), and no XML-error-body capture + * middleware (Secrets Manager uses AWS JSON 1.1). + * + * @module + */ + +/** + * Error thrown by the aws-sm vault for SDK failures. Preserves the + * original SDK error's `name` (so existing checks like + * `error.name === "ResourceNotFoundException"` keep working), sets `cause` + * to the original, and exposes HTTP-level detail. + */ +export class AwsSmOperationError extends Error { + override readonly name: string; + readonly httpStatusCode: number | undefined; + readonly code: string | undefined; + readonly requestId: string | undefined; + + constructor( + message: string, + opts: { + name: string; + cause: unknown; + httpStatusCode: number | undefined; + code: string | undefined; + requestId: string | undefined; + }, + ) { + super(message, { cause: opts.cause }); + this.name = opts.name; + this.httpStatusCode = opts.httpStatusCode; + this.code = opts.code; + this.requestId = opts.requestId; + } +} + +/** + * Classification of AWS-SDK-surfaced credential failures. `session-expired` + * means the credential resolver could not produce valid credentials (SSO + * token expired, STS session aged out). `credentials-rejected` means + * credentials were sent to AWS and explicitly rejected. + */ +export type AwsCredentialErrorKind = + | "session-expired" + | "credentials-rejected" + | "other"; + +/** + * Classify an SDK error by its normalized `code` and HTTP `status`. Pure + * function — takes primitives so it can be unit-tested without + * constructing SDK error shapes. + */ +export function classifyAwsCredentialError( + code: string | undefined, + status: number | undefined, +): AwsCredentialErrorKind { + if (code === "CredentialsProviderError" || code === "ExpiredTokenException") { + return "session-expired"; + } + if ( + code === "InvalidAccessKeyId" || + code === "SignatureDoesNotMatch" || + (status === 403 && code === "AccessDenied") + ) { + return "credentials-rejected"; + } + return "other"; +} + +/** + * Derive a normalized error `code` string from an AWS SDK error. The SDK + * surfaces codes in three places: + * + * 1. `e.Code` — JSON-coded error from the service (e.g. `InvalidAccessKeyId`). + * 2. `e.name` — the SDK's class name (e.g. `CredentialsProviderError`, + * `ResourceNotFoundException`). Generic `"Error"` is treated as no signal. + * 3. `e.cause.name` — minified SDK builds wrap the real error class behind a + * generic outer error, with the underscored class name (e.g. + * `_CredentialsProviderError`) on the cause. Strip the leading underscore + * so the classifier can match the canonical name. + * + * At @aws-sdk/client-secrets-manager@3.1024.0, pre-flight credential + * resolution failures surface as `name === "CredentialsProviderError"` + * directly on the outer error with no cause chain — the cause-walk + strip + * remains as defensive coverage for minified builds and older SDK versions. + * + * Pure function — takes a structural shape so it can be unit-tested without + * constructing real SDK errors. + */ +export function deriveAwsErrorCode(e: { + Code?: string; + name?: string; + cause?: unknown; +}): string | undefined { + if (e.Code) return e.Code; + if (e.name && e.name !== "Error") return e.name; + if (e.cause instanceof Error && e.cause.name && e.cause.name !== "Error") { + return e.cause.name.replace(/^_+/, ""); + } + return undefined; +} + +/** + * Render a swamp-flavoured remediation hint for the classified credential + * failure. The hint names the cause in swamp's vocabulary ("vault session + * expired") rather than AWS's ("CredentialsProviderError") and points at a + * concrete next action. Returns `undefined` for `kind === "other"` so the + * caller can fall through to existing generic messaging. + */ +export function formatAwsCredentialHint( + kind: AwsCredentialErrorKind, + awsProfile: string | undefined, +): string | undefined { + if (kind === "session-expired") { + // Wrap the profile name in double quotes inside the single-quoted + // command so the copy-pasted shell command stays valid for profiles + // that contain spaces (uncommon but legal in AWS config). + const cmd = awsProfile + ? `aws sso login --profile "${awsProfile}"` + : `aws sso login`; + return `Vault session expired: your AWS profile's SSO session is no longer valid. Run '${cmd}' to refresh, then retry.`; + } + if (kind === "credentials-rejected") { + const who = awsProfile ? `'${awsProfile}'` : `your AWS profile`; + return `Vault credentials rejected by AWS: verify ${who}, environment variables, or credential provider, then retry.`; + } + return undefined; +} + +/** + * Wrap an SDK error from a Secrets Manager command as an + * `AwsSmOperationError` with status, code, requestId, and a + * credential-remediation hint when applicable. + * + * The Unknown/UnknownError suppression is empirically required: at + * @aws-sdk/client-secrets-manager@3.1024.0, an HTTP 400 response with + * a body lacking `__type` produces `err.name === "Unknown"` and + * `err.message === "UnknownError"`. Without these filters the wrapped + * message would read e.g. "AWS Secrets Manager get failed HTTP 400 + * Unknown — UnknownError" — noisy, with no useful signal. + */ +export function wrapAwsSmError(op: string, err: unknown): Error { + if (!(err instanceof Error)) return new Error(String(err)); + const e = err as Error & { + $metadata?: { httpStatusCode?: number; requestId?: string }; + Code?: string; + }; + const status = e.$metadata?.httpStatusCode; + const requestId = e.$metadata?.requestId; + const code = deriveAwsErrorCode(e); + + const credentialKind = classifyAwsCredentialError(code, status); + const credentialHint = formatAwsCredentialHint( + credentialKind, + Deno.env.get("AWS_PROFILE"), + ); + + const parts: string[] = []; + // Front-load the swamp-flavoured hint so the user sees the cause and + // remediation before the SDK's framing of the failure. + if (credentialHint) parts.push(credentialHint); + parts.push(`AWS Secrets Manager ${op} failed`); + if (status != null) parts.push(`HTTP ${status}`); + if (code && code !== "Unknown") parts.push(code); + const rawMsg = e.message && e.message !== "UnknownError" ? e.message : ""; + if (rawMsg) parts.push(`— ${rawMsg}`); + // The generic 401/403 hint stays as a fallback for non-credential auth + // failures. Skip it when the credential classifier already produced a + // more specific hint. + if ((status === 401 || status === 403) && credentialKind === "other") { + parts.push( + "(check AWS credentials — profile, env vars, or credential provider — then retry)", + ); + } + if (requestId) parts.push(`[requestId=${requestId}]`); + + return new AwsSmOperationError(parts.join(" "), { + name: e.name, + cause: e, + httpStatusCode: status, + code, + requestId, + }); +} diff --git a/vault/aws-sm/extensions/vaults/aws_sm_errors_test.ts b/vault/aws-sm/extensions/vaults/aws_sm_errors_test.ts new file mode 100644 index 000000000..53065bbed --- /dev/null +++ b/vault/aws-sm/extensions/vaults/aws_sm_errors_test.ts @@ -0,0 +1,300 @@ +// Swamp, an Automation Framework +// Copyright (C) 2026 System Initiative, Inc. +// +// This file is part of Swamp. +// +// Swamp is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation, with the Swamp +// Extension and Definition Exception (found in the "COPYING-EXCEPTION" +// file). +// +// Swamp is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with Swamp. If not, see . + +import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; +import { + AwsSmOperationError, + classifyAwsCredentialError, + deriveAwsErrorCode, + formatAwsCredentialHint, + wrapAwsSmError, +} from "./aws_sm_errors.ts"; + +// --- classifyAwsCredentialError --- + +Deno.test("classifyAwsCredentialError: CredentialsProviderError → session-expired", () => { + assertEquals( + classifyAwsCredentialError("CredentialsProviderError", undefined), + "session-expired", + ); +}); + +Deno.test("classifyAwsCredentialError: ExpiredTokenException → session-expired", () => { + assertEquals( + classifyAwsCredentialError("ExpiredTokenException", undefined), + "session-expired", + ); +}); + +Deno.test("classifyAwsCredentialError: InvalidAccessKeyId → credentials-rejected", () => { + assertEquals( + classifyAwsCredentialError("InvalidAccessKeyId", 403), + "credentials-rejected", + ); +}); + +Deno.test("classifyAwsCredentialError: SignatureDoesNotMatch → credentials-rejected", () => { + assertEquals( + classifyAwsCredentialError("SignatureDoesNotMatch", 403), + "credentials-rejected", + ); +}); + +Deno.test("classifyAwsCredentialError: AccessDenied + 403 → credentials-rejected", () => { + assertEquals( + classifyAwsCredentialError("AccessDenied", 403), + "credentials-rejected", + ); +}); + +Deno.test("classifyAwsCredentialError: AccessDenied + 401 → other (status guard)", () => { + assertEquals( + classifyAwsCredentialError("AccessDenied", 401), + "other", + ); +}); + +// Same-shape regression case: a 403 with a non-credential code must NOT +// be misclassified as credentials-rejected. Mirrors the s3 case where +// BucketRegionMismatch + 403 surfaces as a non-credential auth failure. +Deno.test("classifyAwsCredentialError: BucketRegionMismatch + 403 → other", () => { + assertEquals( + classifyAwsCredentialError("BucketRegionMismatch", 403), + "other", + ); +}); + +Deno.test("classifyAwsCredentialError: undefined code + 403 → other", () => { + assertEquals( + classifyAwsCredentialError(undefined, 403), + "other", + ); +}); + +// --- formatAwsCredentialHint --- + +Deno.test("formatAwsCredentialHint: session-expired with profile renders quoted --profile flag", () => { + const hint = formatAwsCredentialHint("session-expired", "demo"); + assert(hint !== undefined); + // Profile is double-quoted so spaces in profile names don't break the + // copy-pasted shell command. + assert(hint.includes(`aws sso login --profile "demo"`)); + assert(hint.startsWith("Vault session expired:")); +}); + +Deno.test("formatAwsCredentialHint: session-expired with multi-word profile stays valid shell", () => { + const hint = formatAwsCredentialHint("session-expired", "my dev profile"); + assert(hint !== undefined); + assert(hint.includes(`aws sso login --profile "my dev profile"`)); +}); + +Deno.test("formatAwsCredentialHint: session-expired without profile renders generic command", () => { + const hint = formatAwsCredentialHint("session-expired", undefined); + assert(hint !== undefined); + assert(hint.includes("aws sso login")); + assert(!hint.includes("--profile")); + assert(hint.startsWith("Vault session expired:")); +}); + +Deno.test("formatAwsCredentialHint: credentials-rejected with profile names it", () => { + const hint = formatAwsCredentialHint("credentials-rejected", "demo"); + assert(hint !== undefined); + assert(hint.includes("'demo'")); + assert(hint.startsWith("Vault credentials rejected by AWS:")); +}); + +Deno.test("formatAwsCredentialHint: credentials-rejected without profile uses generic phrasing", () => { + const hint = formatAwsCredentialHint("credentials-rejected", undefined); + assert(hint !== undefined); + assert(hint.includes("your AWS profile")); +}); + +Deno.test("formatAwsCredentialHint: other → undefined (caller falls through)", () => { + assertEquals( + formatAwsCredentialHint("other", "demo"), + undefined, + ); +}); + +// --- deriveAwsErrorCode --- + +Deno.test("deriveAwsErrorCode: prefers e.Code", () => { + assertEquals( + deriveAwsErrorCode({ + Code: "InvalidAccessKeyId", + name: "Error", + }), + "InvalidAccessKeyId", + ); +}); + +Deno.test("deriveAwsErrorCode: falls back to e.name when set", () => { + assertEquals( + deriveAwsErrorCode({ name: "CredentialsProviderError" }), + "CredentialsProviderError", + ); +}); + +Deno.test("deriveAwsErrorCode: ignores generic 'Error' name", () => { + assertEquals(deriveAwsErrorCode({ name: "Error" }), undefined); +}); + +// Defensive coverage for minified SDK builds where the real class name +// appears as `_CredentialsProviderError` on the cause. +Deno.test("deriveAwsErrorCode: walks cause chain and strips leading underscore", () => { + const inner = new Error("Token is expired"); + inner.name = "_CredentialsProviderError"; + assertEquals( + deriveAwsErrorCode({ name: "Error", cause: inner }), + "CredentialsProviderError", + ); +}); + +Deno.test("deriveAwsErrorCode: strips multiple leading underscores", () => { + const inner = new Error(""); + inner.name = "__ExpiredTokenException"; + assertEquals( + deriveAwsErrorCode({ name: "Error", cause: inner }), + "ExpiredTokenException", + ); +}); + +// --- wrapAwsSmError direct tests --- + +// The behavioural tests in aws_sm_test.ts only exercise error shapes the +// mock HTTP server can produce. Pre-flight credential failures (where the +// SDK's resolver fails before any HTTP request) cannot be reproduced +// against a mock — the SDK throws before reaching the server. These +// direct wrapAwsSmError tests cover the canonical pre-flight shapes so +// the wrapper is bound to the helpers, not just the helpers in isolation. + +// Scrub AWS_PROFILE so a developer's shell env doesn't poison the +// generic-no-profile assertions below. Restored in finally for each +// test that touches it. +function withoutAwsProfile(fn: () => T): T { + const prev = Deno.env.get("AWS_PROFILE"); + Deno.env.delete("AWS_PROFILE"); + try { + return fn(); + } finally { + if (prev !== undefined) Deno.env.set("AWS_PROFILE", prev); + } +} + +// Empirically verified at @aws-sdk/client-secrets-manager@3.1024.0 +// (probe 2026-05-06): a pre-flight credential resolution failure +// surfaces as `name === "CredentialsProviderError"` directly on the +// outer error, no cause chain. +Deno.test("wrapAwsSmError: direct CredentialsProviderError → 'Vault session expired:' prefix", () => { + withoutAwsProfile(() => { + const original = new Error("Could not load credentials from any providers"); + original.name = "CredentialsProviderError"; + + const wrapped = wrapAwsSmError("GetSecretValue", original); + + assert(wrapped instanceof AwsSmOperationError); + assert( + wrapped.message.startsWith("Vault session expired:"), + `expected prefix "Vault session expired:", got: ${wrapped.message}`, + ); + assertEquals(wrapped.cause, original); + assertEquals(wrapped.name, "CredentialsProviderError"); + assertEquals(wrapped.code, "CredentialsProviderError"); + }); +}); + +// Defensive coverage for minified SDK builds and the older #226 +// stack-trace pattern: outer error with cause.name === +// "_CredentialsProviderError". +Deno.test("wrapAwsSmError: cause-chain _CredentialsProviderError → 'Vault session expired:' prefix", () => { + withoutAwsProfile(() => { + const inner = new Error("Token is expired"); + inner.name = "_CredentialsProviderError"; + const outer = new Error("aggregated") as Error & { cause?: unknown }; + outer.cause = inner; + + const wrapped = wrapAwsSmError("GetSecretValue", outer); + + assert(wrapped instanceof AwsSmOperationError); + assert( + wrapped.message.startsWith("Vault session expired:"), + `expected prefix "Vault session expired:", got: ${wrapped.message}`, + ); + assertEquals(wrapped.cause, outer); + // Outer error's name is "Error" — wrapper preserves it as-is. + assertEquals(wrapped.name, "Error"); + assertEquals(wrapped.code, "CredentialsProviderError"); + }); +}); + +// Locks in both noise filters from wrapAwsSmError. Empirically required +// (probe 2026-05-06): an HTTP 400 with body `{}` produces err.name= +// "Unknown" and err.message="UnknownError" at @3.1024.0. Without the +// filters the wrapped message would leak both strings. +Deno.test("wrapAwsSmError: Unknown/UnknownError noise filters strip both from output", () => { + withoutAwsProfile(() => { + const original = new Error("UnknownError") as Error & { + $metadata?: { httpStatusCode?: number }; + }; + original.name = "Unknown"; + original.$metadata = { httpStatusCode: 400 }; + + const wrapped = wrapAwsSmError("GetSecretValue", original); + + assert(wrapped instanceof AwsSmOperationError); + assertEquals(wrapped.httpStatusCode, 400); + assert( + wrapped.message.includes("AWS Secrets Manager GetSecretValue failed"), + ); + assert(wrapped.message.includes("HTTP 400")); + assert( + !wrapped.message.includes("Unknown"), + `expected wrapped message to NOT contain "Unknown", got: ${wrapped.message}`, + ); + assert( + !wrapped.message.includes("UnknownError"), + `expected wrapped message to NOT contain "UnknownError", got: ${wrapped.message}`, + ); + }); +}); + +// AWS_PROFILE is read at wrap time. When set, the session-expired hint +// should embed the profile name in the suggested command. +Deno.test("wrapAwsSmError: reads AWS_PROFILE and embeds it in the hint", () => { + const prev = Deno.env.get("AWS_PROFILE"); + Deno.env.set("AWS_PROFILE", "demo"); + try { + const original = new Error("Could not load credentials from any providers"); + original.name = "CredentialsProviderError"; + + const wrapped = wrapAwsSmError("GetSecretValue", original); + + assert(wrapped.message.includes(`aws sso login --profile "demo"`)); + } finally { + if (prev !== undefined) Deno.env.set("AWS_PROFILE", prev); + else Deno.env.delete("AWS_PROFILE"); + } +}); + +Deno.test("wrapAwsSmError: non-Error throw becomes a plain Error wrapper", () => { + const wrapped = wrapAwsSmError("GetSecretValue", "not an Error instance"); + assert(wrapped instanceof Error); + assert(!(wrapped instanceof AwsSmOperationError)); + assertEquals(wrapped.message, "not an Error instance"); +}); diff --git a/vault/aws-sm/extensions/vaults/aws_sm_test.ts b/vault/aws-sm/extensions/vaults/aws_sm_test.ts index 57ba04e69..345e24a26 100644 --- a/vault/aws-sm/extensions/vaults/aws_sm_test.ts +++ b/vault/aws-sm/extensions/vaults/aws_sm_test.ts @@ -18,6 +18,7 @@ // along with Swamp. If not, see . import { + assert, assertEquals, assertRejects, assertThrows, @@ -27,6 +28,7 @@ import { assertVaultExportConformance, } from "@systeminit/swamp-testing"; import { vault } from "./aws_sm.ts"; +import { AwsSmOperationError } from "./aws_sm_errors.ts"; Deno.test("vault export conforms to VaultProvider contract", () => { assertVaultExportConformance(vault, { @@ -50,8 +52,37 @@ Deno.test("createProvider throws on invalid config", () => { // --- Behavioral tests using a local mock AWS server --- +/** + * Per-target override response for the mock AWS server. When set for a + * given operation (GetSecretValue, PutSecretValue, etc.), the override + * takes precedence over the default secrets-Map behaviour. Tests use + * this to inject error bodies — including malformed bodies without the + * expected `__type` field — without rebuilding the mock server. + */ +interface MockResponse { + status: number; + body: BodyInit | null; + contentType?: string; +} + +interface MockOverrides { + GetSecretValue?: MockResponse; + PutSecretValue?: MockResponse; + CreateSecret?: MockResponse; + ListSecrets?: MockResponse; +} + +function mockResponse(r: MockResponse): Response { + return new Response(r.body, { + status: r.status, + headers: { + "content-type": r.contentType ?? "application/x-amz-json-1.1", + }, + }); +} + /** Start a local HTTP server that simulates AWS Secrets Manager. */ -function startMockAwsServer(): { +function startMockAwsServer(overrides: MockOverrides = {}): { url: string; server: Deno.HttpServer; secrets: Map; @@ -63,6 +94,9 @@ function startMockAwsServer(): { const body = await req.json(); if (target.includes("GetSecretValue")) { + if (overrides.GetSecretValue) { + return mockResponse(overrides.GetSecretValue); + } const val = secrets.get(body.SecretId); if (!val) { return Response.json({ @@ -74,16 +108,21 @@ function startMockAwsServer(): { } if (target.includes("PutSecretValue")) { + if (overrides.PutSecretValue) { + return mockResponse(overrides.PutSecretValue); + } secrets.set(body.SecretId, body.SecretString); return Response.json({}); } if (target.includes("CreateSecret")) { + if (overrides.CreateSecret) return mockResponse(overrides.CreateSecret); secrets.set(body.Name, body.SecretString); return Response.json({ Name: body.Name }); } if (target.includes("ListSecrets")) { + if (overrides.ListSecrets) return mockResponse(overrides.ListSecrets); return Response.json({ SecretList: [...secrets.keys()].map((n) => ({ Name: n })), }); @@ -98,19 +137,27 @@ function startMockAwsServer(): { return { url: `http://localhost:${addr.port}`, server, secrets }; } -/** Run a test with a mock AWS server, setting AWS_ENDPOINT_URL. */ +/** + * Run a test with a mock AWS server, setting AWS_ENDPOINT_URL and fake + * credentials. AWS_PROFILE is scrubbed for the duration of the test so + * a developer's shell env doesn't poison hint assertions (which read + * AWS_PROFILE at wrap time and embed it in the suggested SSO command). + */ async function withMockAws( fn: (secrets: Map) => Promise, + overrides: MockOverrides = {}, ): Promise { - const { url, server, secrets } = startMockAwsServer(); + const { url, server, secrets } = startMockAwsServer(overrides); const originalEndpoint = Deno.env.get("AWS_ENDPOINT_URL"); const originalKey = Deno.env.get("AWS_ACCESS_KEY_ID"); const originalSecret = Deno.env.get("AWS_SECRET_ACCESS_KEY"); + const originalProfile = Deno.env.get("AWS_PROFILE"); Deno.env.set("AWS_ENDPOINT_URL", url); // SDK needs credentials even for a mock endpoint Deno.env.set("AWS_ACCESS_KEY_ID", "test"); Deno.env.set("AWS_SECRET_ACCESS_KEY", "test"); + Deno.env.delete("AWS_PROFILE"); try { return await fn(secrets); @@ -130,6 +177,11 @@ async function withMockAws( } else { Deno.env.delete("AWS_SECRET_ACCESS_KEY"); } + if (originalProfile !== undefined) { + Deno.env.set("AWS_PROFILE", originalProfile); + } else { + Deno.env.delete("AWS_PROFILE"); + } await server.shutdown(); } } @@ -205,3 +257,165 @@ Deno.test({ }); }, }); + +// --- Error-wrapping behavioural tests --- + +Deno.test({ + name: + "aws-sm vault: get on ExpiredTokenException → 'Vault session expired:' prefix", + sanitizeResources: false, + fn: async () => { + await withMockAws(async () => { + const provider = vault.createProvider("test", { region: "us-east-1" }); + const err = await assertRejects(() => provider.get("anything")); + assert(err instanceof AwsSmOperationError); + assertEquals(err.name, "ExpiredTokenException"); + assert( + err.message.startsWith("Vault session expired:"), + `expected "Vault session expired:" prefix, got: ${err.message}`, + ); + }, { + GetSecretValue: { + status: 400, + body: JSON.stringify({ + __type: "ExpiredTokenException", + Message: "The security token included in the request is expired", + }), + }, + }); + }, +}); + +Deno.test({ + name: + "aws-sm vault: get on 403 AccessDenied → 'Vault credentials rejected by AWS:' prefix", + sanitizeResources: false, + fn: async () => { + await withMockAws(async () => { + const provider = vault.createProvider("test", { region: "us-east-1" }); + const err = await assertRejects(() => provider.get("anything")); + assert(err instanceof AwsSmOperationError); + assertEquals(err.httpStatusCode, 403); + assert( + err.message.startsWith("Vault credentials rejected by AWS:"), + `expected "Vault credentials rejected by AWS:" prefix, got: ${err.message}`, + ); + }, { + GetSecretValue: { + status: 403, + body: JSON.stringify({ + __type: "AccessDenied", + Message: "Access denied", + }), + }, + }); + }, +}); + +Deno.test({ + name: + "aws-sm vault: list propagates the wrapper on credential error (covers all four ops)", + sanitizeResources: false, + fn: async () => { + await withMockAws(async () => { + const provider = vault.createProvider("test", { region: "us-east-1" }); + const err = await assertRejects(() => provider.list()); + assert(err instanceof AwsSmOperationError); + assertEquals(err.name, "ExpiredTokenException"); + assert(err.message.startsWith("Vault session expired:")); + }, { + ListSecrets: { + status: 400, + body: JSON.stringify({ + __type: "ExpiredTokenException", + Message: "The security token included in the request is expired", + }), + }, + }); + }, +}); + +Deno.test({ + name: + "aws-sm vault: put initial PutSecretValue propagates the wrapper on credential error", + sanitizeResources: false, + fn: async () => { + await withMockAws(async () => { + const provider = vault.createProvider("test", { region: "us-east-1" }); + const err = await assertRejects(() => provider.put("k", "v")); + assert(err instanceof AwsSmOperationError); + assertEquals(err.name, "ExpiredTokenException"); + assert(err.message.startsWith("Vault session expired:")); + }, { + PutSecretValue: { + status: 400, + body: JSON.stringify({ + __type: "ExpiredTokenException", + Message: "The security token included in the request is expired", + }), + }, + }); + }, +}); + +// Regression guard for the instanceof → name migration in put(): the +// previous implementation used `error instanceof ResourceNotFoundException` +// against the raw SDK error class. After wrapping, the thrown error is +// AwsSmOperationError, so the check became `error.name === ...`. If that +// migration regresses, this test fails because the secret never gets +// created (PutSecretValue rethrows AwsSmOperationError instead of falling +// through to CreateSecret). +Deno.test({ + name: + "aws-sm vault: put fallback to CreateSecret still works after wrapper migration", + sanitizeResources: false, + fn: async () => { + await withMockAws(async (secrets) => { + const provider = vault.createProvider("test", { region: "us-east-1" }); + // No override on CreateSecret → mock writes to secrets Map. + await provider.put("brand-new-key", "the-value"); + assertEquals(secrets.get("brand-new-key"), "the-value"); + }, { + PutSecretValue: { + status: 400, + body: JSON.stringify({ + __type: "ResourceNotFoundException", + Message: "Secret not found", + }), + }, + }); + }, +}); + +// SDK Wrap Defense: a malformed error response (no __type, no Message) +// must still surface as an AwsSmOperationError with HTTP status, and +// the wrapper's noise filters must strip the SDK's "Unknown" / +// "UnknownError" defaults. Empirically verified at +// @aws-sdk/client-secrets-manager@3.1024.0 (probe 2026-05-06): an HTTP +// 400 with body "{}" produces err.name="Unknown" and +// err.message="UnknownError". +Deno.test({ + name: + "aws-sm vault: malformed error body without __type still surfaces a clean message", + sanitizeResources: false, + fn: async () => { + await withMockAws(async () => { + const provider = vault.createProvider("test", { region: "us-east-1" }); + const err = await assertRejects(() => provider.get("anything")); + assert(err instanceof AwsSmOperationError); + assertEquals(err.httpStatusCode, 400); + assert(err.message.includes("AWS Secrets Manager GetSecretValue failed")); + assert(err.message.includes("HTTP 400")); + assert( + !err.message.includes("Unknown"), + `expected wrapped message to NOT contain "Unknown", got: ${err.message}`, + ); + assert( + !err.message.includes("UnknownError"), + `expected wrapped message to NOT contain "UnknownError", got: ${err.message}`, + ); + }, { + GetSecretValue: { status: 400, body: "{}" }, + }); + }, +}); diff --git a/vault/aws-sm/manifest.yaml b/vault/aws-sm/manifest.yaml index 345331977..423701cb5 100644 --- a/vault/aws-sm/manifest.yaml +++ b/vault/aws-sm/manifest.yaml @@ -1,6 +1,6 @@ manifestVersion: 1 name: "@swamp/aws-sm" -version: "2026.04.22.2" +version: "2026.05.06.1" description: | Read and write secrets stored in AWS Secrets Manager.