diff --git a/lambda/user-bot/README.md b/lambda/user-bot/README.md index 235f1f8..8e3530f 100644 --- a/lambda/user-bot/README.md +++ b/lambda/user-bot/README.md @@ -1,8 +1,6 @@ # user-bot -A Lambda that gives each new IAM user a temporary AWS console password and sends it to them as a Slack DM, with sign-in instructions. Tracked in [#209](https://github.com/hackforla/devops-security/issues/209). - -> **Not sending DMs yet.** The deployed function uses `StubMessageSender`, which sends nothing and logs who would have been messaged. `SlackMessageSender` is written and tested, but switching to it needs a Slack app, its bot token, a secret the function can read, and permission to read it. None of these exist yet. Until then, the bot resets the password and the new user does not receive it, which is no worse than before: the password Terraform generates was never sent to anyone either. +A Lambda that gives each new IAM user a temporary AWS console password and sends it to them as a Slack DM, with sign-in instructions. Built in [#209](https://github.com/hackforla/devops-security/issues/209); connected to Slack in [#212](https://github.com/hackforla/devops-security/issues/212). ## When it runs @@ -23,9 +21,12 @@ It reads the user's tags and **does nothing at all** (no password change, no mes When both hold, it: -1. generates a 20-character password containing all four character classes; -2. sets it with `UpdateLoginProfile` and `PasswordResetRequired: true`, so the user must replace it at first sign-in; -3. sends the user a message with the sign-in page, their IAM user name and the temporary password. +1. reads the Slack bot token (see [The Slack token](#the-slack-token)). **If that fails, it stops here and the user's password is not touched;** +2. generates a 20-character password containing all four character classes; +3. sets it with `UpdateLoginProfile` and `PasswordResetRequired: true`, so the user must replace it at first sign-in; +4. DMs the user, as the Slack app, with the sign-in page, their IAM user name and the temporary password. + +Step 1 comes before the reset on purpose. Reading the token at send time would mean a bad token leaves the user with a new password that nobody received. Every skip is logged with its reason and the user name. **The password is never logged**, on any path; the tests assert this. @@ -33,19 +34,44 @@ The `managed-by` check is also enforced by IAM. The execution role may call `Upd If sending fails after the password was changed, the function throws. Lambda's asynchronous retry then runs it again from the start, which sets a fresh password and sends again. +## The Slack token + +The bot posts as a Slack app with the `chat:write` bot scope, and the app's **Messages tab** is turned on so that users can see the DM thread. Its bot token (`xoxb-…`) is stored in the SSM Parameter Store `SecureString` **`/user-bot/slack-bot-token`**, in us-east-1. The function finds it through its `SLACK_TOKEN_PARAMETER` environment variable. + +Terraform (`terraform/user-bot.tf`) creates the parameter with a placeholder through the write-only `value_wo`. The real token is set by hand, so it never appears in git **or in Terraform state**. Read the comment above that resource before changing it: bumping `value_wo_version` writes the placeholder back over the real token. + +To set or rotate the token, run this from your own terminal. It reads the token from a prompt, so it stays out of your shell history: + +```bash +read -rs SLACK_TOKEN && MSYS_NO_PATHCONV=1 aws ssm put-parameter --region us-east-1 \ + --name /user-bot/slack-bot-token --type SecureString --overwrite --value "$SLACK_TOKEN"; unset SLACK_TOKEN +``` + +`MSYS_NO_PATHCONV=1` only matters in Git Bash on Windows, which would otherwise rewrite `/user-bot/...` into a Windows path. + +The function keeps the token for the life of an execution environment, so a rotated token is picked up as environments are recycled. To pick it up at once, redeploy: run **Deploy user-bot Lambda** from the Actions tab. + +If the token is missing, unreadable, or still the placeholder (anything not starting `xoxb-`), every user the bot would act on is **refused**: +- the password is left unchanged; +- an error naming the parameter is logged (never its value); +- the invocation fails, so it shows up in the function's error metrics. + +A failed read is not cached, so the next new user is tried again. + ## Layout ``` src/ - index.ts Lambda entry point; wires the handler to the stub sender + index.ts Lambda entry point; wires the handler to SlackMessageSender handler.ts the logic above + slack-token.ts reads and caches the token from SSM password.ts password generation message.ts the message text and sign-in URL logger.ts JSON-line logger senders/ types.ts MessageSender interface slack.ts SlackMessageSender (chat.postMessage) - stub.ts StubMessageSender (logs only) + stub.ts StubMessageSender (logs only; not deployed, kept for local runs and tests) test/ Vitest unit tests; AWS is mocked with aws-sdk-client-mock, Slack by mocking fetch ``` @@ -69,7 +95,7 @@ Two halves, each with its own workflow: | | Managed by | Runs when | |---|---|---| -| The function's configuration, execution role, log group, EventBridge rule, and the deploy role | `terraform/user-bot.tf` and `terraform/aws-gha-oidc-providers.tf`, via the existing `terraform-plan.yaml` / `terraform-apply.yaml` | a `.tf` file changes | +| The function's configuration, execution role, log group, EventBridge rule, the token parameter (not its value), and the deploy role | `terraform/user-bot.tf` and `terraform/aws-gha-oidc-providers.tf`, via the existing `terraform-plan.yaml` / `terraform-apply.yaml` | a `.tf` file changes | | The function's **code** | `.github/workflows/user-bot-deploy.yml` | a change under `lambda/user-bot/` merges to `main` | Pull requests touching `lambda/user-bot/` run `.github/workflows/user-bot-test.yml`, which typechecks, tests and builds with no AWS credentials. diff --git a/lambda/user-bot/package-lock.json b/lambda/user-bot/package-lock.json index 3be6d3b..b8b1d51 100644 --- a/lambda/user-bot/package-lock.json +++ b/lambda/user-bot/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@aws-sdk/client-iam": "^3.1146.0" + "@aws-sdk/client-iam": "^3.1146.0", + "@aws-sdk/client-ssm": "^3.1146.0" }, "devDependencies": { "@types/aws-lambda": "^8.10.164", @@ -38,6 +39,24 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-ssm": { + "version": "3.1146.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1146.0.tgz", + "integrity": "sha512-Qx29jzZEVPpxUxzRNs1wwHg2qtOmHcwa3p1Bbufixg7SnyLg3zdLbhbK6oeeRnFDPBdlcjacURI/5JY+aSmu/Q==", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { "version": "3.978.1", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", diff --git a/lambda/user-bot/package.json b/lambda/user-bot/package.json index c9f740c..0c58414 100644 --- a/lambda/user-bot/package.json +++ b/lambda/user-bot/package.json @@ -10,7 +10,8 @@ "test": "vitest run" }, "dependencies": { - "@aws-sdk/client-iam": "^3.1146.0" + "@aws-sdk/client-iam": "^3.1146.0", + "@aws-sdk/client-ssm": "^3.1146.0" }, "devDependencies": { "@types/aws-lambda": "^8.10.164", diff --git a/lambda/user-bot/src/handler.ts b/lambda/user-bot/src/handler.ts index f9b1413..e152c8a 100644 --- a/lambda/user-bot/src/handler.ts +++ b/lambda/user-bot/src/handler.ts @@ -49,7 +49,11 @@ export type Result = export interface Dependencies { iam: IAMClient; - sender: MessageSender; + // Called after the tag checks and before the password is touched. Anything the + // sender needs that can fail -- reading the Slack token, above all -- belongs in + // here, so that a failure leaves the user's password unchanged instead of resetting + // it to something nobody receives. + getSender: () => Promise; logger: Logger; generatePassword?: () => string; } @@ -74,7 +78,7 @@ function errorName(error: unknown): string { } export function createHandler(deps: Dependencies) { - const { iam, sender, logger } = deps; + const { iam, getSender, logger } = deps; const generatePassword = deps.generatePassword ?? defaultGeneratePassword; function skip(reason: SkipReason, userName?: string): Result { @@ -123,6 +127,23 @@ export function createHandler(deps: Dependencies) { return skip("invalid-slack-id", userName); } + // Resolved before the password is reset, not when sending. If the Slack token is + // missing, unreadable or still the placeholder, the user keeps their current + // password. Errors from here are logged with their message, so getSender must never + // put a secret in one (SlackTokenError names the parameter, not its value). + let sender: MessageSender; + try { + sender = await getSender(); + } catch (error) { + const detail = error instanceof Error ? error.message : undefined; + logger.error("No message sender available; password left unchanged", { + userName, + error: errorName(error), + detail, + }); + throw new Error(`No message sender available for ${userName}: ${errorName(error)}`); + } + const password = generatePassword(); try { diff --git a/lambda/user-bot/src/index.ts b/lambda/user-bot/src/index.ts index 9344059..96762aa 100644 --- a/lambda/user-bot/src/index.ts +++ b/lambda/user-bot/src/index.ts @@ -1,15 +1,19 @@ import { IAMClient } from "@aws-sdk/client-iam"; +import { SSMClient } from "@aws-sdk/client-ssm"; import { createHandler } from "./handler"; import { consoleLogger } from "./logger"; -import { StubMessageSender } from "./senders/stub"; +import { SlackMessageSender } from "./senders/slack"; +import { createSlackTokenLoader } from "./slack-token"; + +// SLACK_TOKEN_PARAMETER names the SSM SecureString holding the Slack bot token; it is +// set by terraform/user-bot.tf. The token is read on the first invocation that needs to +// send, not at import, so a missing token surfaces as a logged refusal for that user +// rather than as a cold-start crash -- and always before their password is touched. +const loadSlackToken = createSlackTokenLoader(new SSMClient({}), process.env.SLACK_TOKEN_PARAMETER); -// Wired to the stub sender on purpose. Switching to SlackMessageSender needs a Slack -// app, its bot token, a secret the function can read, and permission to read it -- -// none of which exist yet. See the "Out of scope" section of -// hackforla/devops-security#209. export const handler = createHandler({ iam: new IAMClient({}), - sender: new StubMessageSender(consoleLogger), + getSender: async () => new SlackMessageSender(await loadSlackToken()), logger: consoleLogger, }); diff --git a/lambda/user-bot/src/senders/stub.ts b/lambda/user-bot/src/senders/stub.ts index 080e540..11aff96 100644 --- a/lambda/user-bot/src/senders/stub.ts +++ b/lambda/user-bot/src/senders/stub.ts @@ -1,9 +1,9 @@ import type { Logger } from "../logger"; import type { DirectMessage, MessageSender } from "./types"; -// What the deployed Lambda uses until it is switched to SlackMessageSender, which -// needs a Slack app and bot token that do not exist yet. It sends nothing and logs -// who would have been messaged, never the message itself. +// Not deployed: index.ts wires the Lambda to SlackMessageSender. Kept for local runs and +// tests, where sending a real DM is unwanted. It sends nothing and logs who would have +// been messaged, never the message itself. export class StubMessageSender implements MessageSender { constructor(private readonly logger: Logger) {} diff --git a/lambda/user-bot/src/slack-token.ts b/lambda/user-bot/src/slack-token.ts new file mode 100644 index 0000000..24596e1 --- /dev/null +++ b/lambda/user-bot/src/slack-token.ts @@ -0,0 +1,54 @@ +import { GetParameterCommand, type SSMClient } from "@aws-sdk/client-ssm"; + +// Slack bot tokens start with this. Terraform creates the parameter holding a +// placeholder, so this is also how a token that was never set is caught. +export const SLACK_BOT_TOKEN_PREFIX = "xoxb-"; + +// Messages name the parameter and the failure, never the value. +export class SlackTokenError extends Error { + override name = "SlackTokenError"; +} + +function errorName(error: unknown): string { + return error instanceof Error ? error.name : "UnknownError"; +} + +// Returns a function that reads the token from SSM Parameter Store once per execution +// environment and reuses it. A failed read is not cached, so the next invocation tries +// again rather than failing forever on a rejected promise; that matters right after the +// token is first set or rotated. +export function createSlackTokenLoader( + ssm: SSMClient, + parameterName: string | undefined, +): () => Promise { + let cached: Promise | undefined; + + async function load(): Promise { + if (!parameterName) { + throw new SlackTokenError("SLACK_TOKEN_PARAMETER is not set"); + } + + let value: string | undefined; + try { + const output = await ssm.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true })); + value = output.Parameter?.Value; + } catch (error) { + throw new SlackTokenError(`could not read ${parameterName}: ${errorName(error)}`); + } + + if (!value?.startsWith(SLACK_BOT_TOKEN_PREFIX)) { + throw new SlackTokenError( + `${parameterName} does not hold a Slack bot token; it may still be the placeholder Terraform created`, + ); + } + return value; + } + + return () => { + cached ??= load().catch((error: unknown) => { + cached = undefined; + throw error; + }); + return cached; + }; +} diff --git a/lambda/user-bot/test/handler.test.ts b/lambda/user-bot/test/handler.test.ts index 3939eff..6d44fe7 100644 --- a/lambda/user-bot/test/handler.test.ts +++ b/lambda/user-bot/test/handler.test.ts @@ -24,14 +24,15 @@ function setup(tags: { Key: string; Value: string }[] = [MANAGED, SLACK]) { const sent: DirectMessage[] = []; const sender: MessageSender = { send: vi.fn(async (message) => void sent.push(message)) }; + const getSender = vi.fn(async () => sender); const logger = captureLogger(); const deps: Dependencies = { iam: new IAMClient({ region: "us-east-1" }), - sender, + getSender, logger, generatePassword: () => TEST_PASSWORD, }; - return { handler: createHandler(deps), sender, sent, logger }; + return { handler: createHandler(deps), sender, getSender, sent, logger }; } beforeEach(() => { @@ -91,13 +92,15 @@ describe("skips without touching the password or sending anything", () => { ]; it.each(cases)("when %s", async (_name, tags, reason) => { - const { handler, sender, logger } = setup(tags); + const { handler, sender, getSender, logger } = setup(tags); const result = await handler(createLoginProfileEvent()); expect(result).toEqual({ outcome: "skipped", reason, userName: "new.member" }); expect(iamMock.commandCalls(UpdateLoginProfileCommand)).toHaveLength(0); expect(sender.send).not.toHaveBeenCalled(); + // A skipped user does not even cause the Slack token to be read. + expect(getSender).not.toHaveBeenCalled(); expect(logger.lines).toContainEqual( expect.objectContaining({ fields: expect.objectContaining({ reason, userName: "new.member" }) }), ); @@ -148,6 +151,45 @@ describe("skips without touching the password or sending anything", () => { }); }); +describe("when no sender is available (e.g. the Slack token cannot be read)", () => { + it("throws before touching the password, and sends nothing", async () => { + const { handler, getSender, sender, logger } = setup(); + const failure = new Error("/user-bot/slack-bot-token does not hold a Slack bot token"); + failure.name = "SlackTokenError"; + getSender.mockRejectedValueOnce(failure); + + const error = await handler(createLoginProfileEvent()).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("SlackTokenError"); + expect(iamMock.commandCalls(UpdateLoginProfileCommand)).toHaveLength(0); + expect(sender.send).not.toHaveBeenCalled(); + expect(logger.lines).toContainEqual( + expect.objectContaining({ + level: "error", + fields: expect.objectContaining({ userName: "new.member", error: "SlackTokenError" }), + }), + ); + }); + + it("resolves the sender only after the tag checks pass, and before the reset", async () => { + const { handler, getSender } = setup(); + const order: string[] = []; + getSender.mockImplementationOnce(async () => { + order.push("getSender"); + return { send: async () => void order.push("send") }; + }); + iamMock.on(UpdateLoginProfileCommand).callsFake(() => { + order.push("UpdateLoginProfile"); + return {}; + }); + + await handler(createLoginProfileEvent()); + + expect(order).toEqual(["getSender", "UpdateLoginProfile", "send"]); + }); +}); + describe("failures", () => { it("rethrows when ListUserTags fails for another reason, and changes nothing", async () => { const { handler, sender } = setup(); @@ -193,7 +235,7 @@ describe("the password never reaches a log", () => { const generated: string[] = []; const handler = createHandler({ iam: new IAMClient({ region: "us-east-1" }), - sender: { send: async () => {} }, + getSender: async () => ({ send: async () => {} }), logger, generatePassword: () => { const password = generatePassword(); diff --git a/lambda/user-bot/test/index.test.ts b/lambda/user-bot/test/index.test.ts new file mode 100644 index 0000000..1948a60 --- /dev/null +++ b/lambda/user-bot/test/index.test.ts @@ -0,0 +1,85 @@ +// Exercises src/index.ts -- the wiring that is actually deployed -- with SSM, IAM and +// Slack's HTTP API all mocked. Each test imports a fresh copy of the module, because +// the token cache lives in module scope. +import { IAMClient, ListUserTagsCommand, UpdateLoginProfileCommand } from "@aws-sdk/client-iam"; +import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm"; +import { mockClient } from "aws-sdk-client-mock"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SLACK_POST_MESSAGE_URL } from "../src/senders/slack"; +import { createLoginProfileEvent, TEST_SLACK_ID } from "./helpers"; + +const iamMock = mockClient(IAMClient); +const ssmMock = mockClient(SSMClient); +const TOKEN = "xoxb-real-token-do-not-log"; + +async function freshHandler() { + vi.resetModules(); + const { handler } = await import("../src/index"); + return handler; +} + +let fetchMock: ReturnType; +let logged: string[]; + +beforeEach(() => { + iamMock.reset(); + ssmMock.reset(); + iamMock.on(ListUserTagsCommand).resolves({ + Tags: [ + { Key: "managed-by", Value: "terraform-devops-security" }, + { Key: "slack_id", Value: TEST_SLACK_ID }, + ], + }); + iamMock.on(UpdateLoginProfileCommand).resolves({}); + vi.stubEnv("SLACK_TOKEN_PARAMETER", "/user-bot/slack-bot-token"); + fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + logged = []; + vi.spyOn(console, "log").mockImplementation((line: unknown) => void logged.push(String(line))); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("deployed handler", () => { + it("reads the token, resets the password, and DMs the user through Slack", async () => { + ssmMock.on(GetParameterCommand).resolves({ Parameter: { Value: TOKEN } }); + const handler = await freshHandler(); + + const result = await handler(createLoginProfileEvent()); + + expect(result).toEqual({ outcome: "sent", userName: "new.member" }); + expect(iamMock.commandCalls(UpdateLoginProfileCommand)).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]! as [string, RequestInit]; + expect(url).toBe(SLACK_POST_MESSAGE_URL); + expect((init.headers as Record).Authorization).toBe(`Bearer ${TOKEN}`); + expect(JSON.parse(init.body as string).channel).toBe(TEST_SLACK_ID); + + const password = iamMock.commandCalls(UpdateLoginProfileCommand)[0]!.args[0].input.Password!; + expect(logged.join("\n")).not.toContain(password); + expect(logged.join("\n")).not.toContain(TOKEN); + }); + + it("refuses while the parameter still holds Terraform's placeholder: password untouched", async () => { + ssmMock.on(GetParameterCommand).resolves({ Parameter: { Value: "placeholder-set-by-hand" } }); + const handler = await freshHandler(); + + await expect(handler(createLoginProfileEvent())).rejects.toThrow("SlackTokenError"); + expect(iamMock.commandCalls(UpdateLoginProfileCommand)).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refuses when SLACK_TOKEN_PARAMETER is unset: password untouched", async () => { + vi.stubEnv("SLACK_TOKEN_PARAMETER", ""); + const handler = await freshHandler(); + + await expect(handler(createLoginProfileEvent())).rejects.toThrow("SlackTokenError"); + expect(ssmMock.calls()).toHaveLength(0); + expect(iamMock.commandCalls(UpdateLoginProfileCommand)).toHaveLength(0); + }); +}); diff --git a/lambda/user-bot/test/slack-token.test.ts b/lambda/user-bot/test/slack-token.test.ts new file mode 100644 index 0000000..3796f85 --- /dev/null +++ b/lambda/user-bot/test/slack-token.test.ts @@ -0,0 +1,98 @@ +import { GetParameterCommand, ParameterNotFound, SSMClient } from "@aws-sdk/client-ssm"; +import { mockClient } from "aws-sdk-client-mock"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { createSlackTokenLoader, SlackTokenError } from "../src/slack-token"; + +const ssmMock = mockClient(SSMClient); +const PARAMETER = "/user-bot/slack-bot-token"; +const TOKEN = "xoxb-real-token-do-not-log"; + +beforeEach(() => { + ssmMock.reset(); +}); + +// Not a default parameter: loader(undefined) would then silently use PARAMETER. +function loader(...args: [name?: string | undefined]) { + return createSlackTokenLoader(new SSMClient({ region: "us-east-1" }), args.length ? args[0] : PARAMETER); +} + +async function failureOf(promise: Promise): Promise { + const error = await promise.then( + () => undefined, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(SlackTokenError); + expect((error as Error).message).not.toContain(TOKEN); + return error as SlackTokenError; +} + +describe("createSlackTokenLoader", () => { + it("reads the parameter with decryption and returns the token", async () => { + ssmMock.on(GetParameterCommand).resolves({ Parameter: { Value: TOKEN } }); + + await expect(loader()()).resolves.toBe(TOKEN); + expect(ssmMock.commandCalls(GetParameterCommand)[0]!.args[0].input).toEqual({ + Name: PARAMETER, + WithDecryption: true, + }); + }); + + it("reads SSM once and reuses the token across invocations", async () => { + ssmMock.on(GetParameterCommand).resolves({ Parameter: { Value: TOKEN } }); + const load = loader(); + + await load(); + await load(); + + expect(ssmMock.commandCalls(GetParameterCommand)).toHaveLength(1); + }); + + it("does not cache a failure, so the next invocation reads again", async () => { + ssmMock + .on(GetParameterCommand) + .resolvesOnce({ Parameter: { Value: "placeholder-set-by-hand" } }) + .resolvesOnce({ Parameter: { Value: TOKEN } }); + const load = loader(); + + await failureOf(load()); + await expect(load()).resolves.toBe(TOKEN); + expect(ssmMock.commandCalls(GetParameterCommand)).toHaveLength(2); + }); + + it("fails when SLACK_TOKEN_PARAMETER is not set, without calling SSM", async () => { + const error = await failureOf(loader(undefined)()); + expect(error.message).toContain("SLACK_TOKEN_PARAMETER"); + expect(ssmMock.calls()).toHaveLength(0); + }); + + it("fails when the parameter does not exist", async () => { + ssmMock.on(GetParameterCommand).rejects(new ParameterNotFound({ message: "not found", $metadata: {} })); + const error = await failureOf(loader()()); + expect(error.message).toContain("ParameterNotFound"); + }); + + it("fails when access is denied", async () => { + const denied = new Error("not authorized"); + denied.name = "AccessDeniedException"; + ssmMock.on(GetParameterCommand).rejects(denied); + const error = await failureOf(loader()()); + expect(error.message).toContain("AccessDeniedException"); + }); + + it.each([ + ["still the Terraform placeholder", "placeholder-set-by-hand"], + ["a user token rather than a bot token", "xoxp-user-token"], + ["empty", ""], + ])("fails when the value is %s", async (_name, value) => { + ssmMock.on(GetParameterCommand).resolves({ Parameter: { Value: value } }); + const error = await failureOf(loader()()); + expect(error.message).toContain("does not hold a Slack bot token"); + expect(error.message).not.toContain(value || "\u0000"); + }); + + it("fails when SSM returns no value at all", async () => { + ssmMock.on(GetParameterCommand).resolves({}); + await failureOf(loader()()); + }); +});