From 80209b97ac5f6cd2ce5ce61122aac920b30eaf4e Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sat, 30 May 2026 09:27:01 +0000 Subject: [PATCH 1/6] test: lock down Slack command adapter behaviour Phase 0 safety net before the dependency migrations. The Slack handler had zero coverage; these characterise the exact seam every Bolt / serverless-express / Express upgrade will touch, so later phases can prove they're behaviour preserving: - handle-command: acks before responding, maps destination -> response_type (channel -> in_channel, user -> ephemeral), the ephemeral error fallback, and redaction of token/trigger_id/response_url from logs - command-parsers: parseUnlock force rule + empty input, getFirstParam In-memory fakes (no mocks) and no DynamoDB, so they run under plain jest. Verified: jest 14/14, tsc --noEmit, eslint clean. --- test/command-parsers.test.ts | 55 +++++++++++++ test/handle-command.test.ts | 150 +++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 test/command-parsers.test.ts create mode 100644 test/handle-command.test.ts diff --git a/test/command-parsers.test.ts b/test/command-parsers.test.ts new file mode 100644 index 0000000..01c6a89 --- /dev/null +++ b/test/command-parsers.test.ts @@ -0,0 +1,55 @@ +import { + parseUnlock, + getFirstParam, +} from "../src/handlers/slack/command-parsers"; + +describe("parseUnlock", () => { + test("parses a resource with no options as not forced", () => { + expect(parseUnlock("staging")).toEqual({ + resource: "staging", + force: false, + }); + }); + + test("parses the force option", () => { + expect(parseUnlock("staging force")).toEqual({ + resource: "staging", + force: true, + }); + }); + + test("treats an unrecognised second word as not forced", () => { + expect(parseUnlock("staging please")).toEqual({ + resource: "staging", + force: false, + }); + }); + + test("only the exact lowercase word 'force' enables force", () => { + expect(parseUnlock("staging FORCE")).toEqual({ + resource: "staging", + force: false, + }); + }); + + test("returns an empty resource for empty input", () => { + expect(parseUnlock("")).toEqual({ + resource: "", + force: false, + }); + }); +}); + +describe("getFirstParam", () => { + test("returns the first space-separated word", () => { + expect(getFirstParam("staging extra args")).toEqual("staging"); + }); + + test("returns the whole string when there is a single word", () => { + expect(getFirstParam("staging")).toEqual("staging"); + }); + + test("returns an empty string for empty input", () => { + expect(getFirstParam("")).toEqual(""); + }); +}); diff --git a/test/handle-command.test.ts b/test/handle-command.test.ts new file mode 100644 index 0000000..442e442 --- /dev/null +++ b/test/handle-command.test.ts @@ -0,0 +1,150 @@ +import { + LogLevel, + Logger, + SlackCommandMiddlewareArgs, + SlashCommand, +} from "@slack/bolt"; +import handleCommand from "../src/handlers/slack/handle-command"; +import { Response } from "../src/lock-bot"; + +const aSlashCommand = ( + overrides: Partial = {} +): SlashCommand => ({ + token: "verification-token", + command: "/lock", + text: "dev", + response_url: "https://hooks.slack.com/commands/T012345WXYZ/0000/aaaa", + trigger_id: "0000000000.000000000.aaaaaaaaaaaaaaaaaaaaaaaa", + user_id: "U012345MNOP", + user_name: "connor", + team_id: "T012345WXYZ", + team_domain: "our-team", + channel_id: "C012345ABCD", + channel_name: "general", + api_app_id: "A012345678", + ...overrides, +}); + +// Drives handle-command with in-memory fakes (no mocks) that record what the +// adapter does with the bot's Response: how it acknowledges, what it sends back, +// and what it logs. This is the seam every Bolt / serverless-express migration +// touches, so locking it down first lets later upgrades prove they're behaviour +// preserving. +const setup = (getResponse: (command: SlashCommand) => Promise) => { + const order: string[] = []; + const responded: unknown[] = []; + const infoCalls: unknown[][] = []; + const errorCalls: unknown[][] = []; + const noop = () => { + /* logger output is irrelevant to these tests */ + }; + const logger: Logger = { + debug: noop, + info: (...msg) => { + infoCalls.push(msg); + }, + warn: noop, + error: (...msg) => { + errorCalls.push(msg); + }, + setLevel: noop, + getLevel: () => LogLevel.DEBUG, + setName: noop, + }; + const command = aSlashCommand(); + const args: SlackCommandMiddlewareArgs & { logger: Logger } = { + payload: command, + command, + body: command, + say: async () => { + throw new Error("say is not used by the slash command handler"); + }, + respond: async (message) => { + order.push("respond"); + responded.push(message); + }, + ack: async () => { + order.push("ack"); + }, + logger, + }; + const invoke = () => handleCommand(getResponse)(args); + return { invoke, order, responded, infoCalls, errorCalls }; +}; + +describe("handleCommand", () => { + test("acknowledges the command before responding", async () => { + const { invoke, order } = setup(async () => ({ + message: "anything", + destination: "user", + })); + await invoke(); + expect(order).toEqual(["ack", "respond"]); + }); + + test("sends a channel response as an in_channel message", async () => { + const { invoke, responded } = setup(async () => ({ + message: "<@Connor> has locked `dev` 🔒", + destination: "channel", + })); + await invoke(); + expect(responded).toEqual([ + { + text: "<@Connor> has locked `dev` 🔒", + response_type: "in_channel", + }, + ]); + }); + + test("sends a user response as an ephemeral message", async () => { + const { invoke, responded } = setup(async () => ({ + message: "No active locks in this channel 🔓", + destination: "user", + })); + await invoke(); + expect(responded).toEqual([ + { + text: "No active locks in this channel 🔓", + response_type: "ephemeral", + }, + ]); + }); + + test("responds with an ephemeral fallback when getting the response throws", async () => { + const { invoke, responded, errorCalls } = setup(async () => { + throw new Error("DynamoDB is unavailable"); + }); + await invoke(); + expect(responded).toEqual([ + { + text: + "❌ Oops, something went wrong!\n" + + "Contact support@lockbot.app if this issue persists.", + response_type: "ephemeral", + }, + ]); + expect(errorCalls).toHaveLength(1); + }); + + test("still acknowledges the command when getting the response throws", async () => { + const { invoke, order } = setup(async () => { + throw new Error("DynamoDB is unavailable"); + }); + await invoke(); + expect(order).toEqual(["ack", "respond"]); + }); + + test("logs the command without the token, trigger_id or response_url", async () => { + const { invoke, infoCalls } = setup(async () => ({ + message: "anything", + destination: "user", + })); + await invoke(); + const [firstMessage, commandProps] = infoCalls[0]; + expect(firstMessage).toEqual("Command received."); + expect(commandProps).not.toHaveProperty("token"); + expect(commandProps).not.toHaveProperty("trigger_id"); + expect(commandProps).not.toHaveProperty("response_url"); + expect(commandProps).toMatchObject({ command: "/lock", text: "dev" }); + }); +}); From 674c1cf365da70cd93c024598c9f592eb9fd5d6d Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sat, 30 May 2026 09:36:25 +0000 Subject: [PATCH 2/6] test: characterise OAuth installation store Extract the inline installationStore from infra.ts into a testable factory and pin its behaviour against DynamoDB Local. The OAuth install path was the single biggest untested surface and is exactly what the Bolt 3->4 and AWS SDK v2->v3 migrations touch. - src/handlers/slack/installation-store.ts: factory, behaviour identical to the old inline store; infra.ts now delegates to it - test/utils.ts: recreateInstallationsTable (the installations table was never created by the test harness) - test/installation-store.test.ts: store/fetch round-trip, enterprise store + fetch rejection, and the current throw-on-unknown-team behaviour (pinned so the SDK v3 migration must consciously change it) Verified vs DynamoDB Local (Docker): 18/18 new + 64/64 existing suites, tsc --noEmit, eslint. Saw the enterprise assertion go red before green. --- src/handlers/slack/infra.ts | 57 ++--------------- src/handlers/slack/installation-store.ts | 63 +++++++++++++++++++ test/installation-store.test.ts | 80 ++++++++++++++++++++++++ test/utils.ts | 23 +++++++ 4 files changed, 171 insertions(+), 52 deletions(-) create mode 100644 src/handlers/slack/installation-store.ts create mode 100644 test/installation-store.test.ts diff --git a/src/handlers/slack/infra.ts b/src/handlers/slack/infra.ts index 2b0f8fb..999ae0e 100644 --- a/src/handlers/slack/infra.ts +++ b/src/handlers/slack/infra.ts @@ -5,6 +5,7 @@ import LockBot from "../../lock-bot"; import DynamoDBLockRepo from "../../storage/dynamodb-lock-repo"; import TokenAuthorizer from "../../token-authorizer"; import DynamoDBAccessTokenRepo from "../../storage/dynamodb-token-repo"; +import createInstallationStore from "./installation-store"; const documentClient = new DocumentClient(); @@ -20,58 +21,10 @@ export const expressReceiver = new ExpressReceiver({ stateSecret: env.get("STATE_SECRET").required().asString(), scopes: ["commands"], processBeforeResponse: true, - installationStore: { - storeInstallation: async (installation, logger) => { - if (installation.isEnterpriseInstall && installation.enterprise) { - logger?.error("Enterprise storeInstallation attempt failed."); - throw new Error("Enterprise installation not supported"); - } else if (installation.team) { - await documentClient - .put({ - TableName: installationsTableName, - Item: { - Team: installation.team.id, - Installation: installation, - }, - }) - .promise(); - const { team, user, bot } = installation; - logger?.info("Installation stored.", { - team, - userId: user.id, - botScopes: bot?.scopes, - }); - } else { - throw new Error("Failed to store installation"); - } - }, - fetchInstallation: async (installQuery, logger) => { - if ( - installQuery.isEnterpriseInstall && - installQuery.enterpriseId !== undefined - ) { - logger?.error("Enterprise fetchInstallation attempt failed."); - throw new Error("Enterprise installation not supported"); - } else if (installQuery.teamId !== undefined) { - const result = await documentClient - .get({ - TableName: installationsTableName, - Key: { Team: installQuery.teamId }, - }) - .promise(); - const installation = result.Item?.Installation; - const { team, user, bot } = installation; - logger?.info("Installation fetched.", { - team, - userId: user.id, - botScopes: bot?.scopes, - }); - return Promise.resolve(installation); - } else { - throw new Error("Failed to fetch installation"); - } - }, - }, + installationStore: createInstallationStore( + documentClient, + installationsTableName + ), }); export const app = new App({ diff --git a/src/handlers/slack/installation-store.ts b/src/handlers/slack/installation-store.ts new file mode 100644 index 0000000..6f7a662 --- /dev/null +++ b/src/handlers/slack/installation-store.ts @@ -0,0 +1,63 @@ +import { DocumentClient } from "aws-sdk/clients/dynamodb"; +import { InstallationStore } from "@slack/bolt"; + +// Extracted verbatim from infra.ts so the store can be exercised against +// DynamoDB Local without booting the whole Slack receiver (which requires every +// Slack env var and a live AWS client). Behaviour is unchanged. +const createInstallationStore = ( + documentClient: DocumentClient, + installationsTableName: string +): InstallationStore => ({ + storeInstallation: async (installation, logger) => { + if (installation.isEnterpriseInstall && installation.enterprise) { + logger?.error("Enterprise storeInstallation attempt failed."); + throw new Error("Enterprise installation not supported"); + } else if (installation.team) { + await documentClient + .put({ + TableName: installationsTableName, + Item: { + Team: installation.team.id, + Installation: installation, + }, + }) + .promise(); + const { team, user, bot } = installation; + logger?.info("Installation stored.", { + team, + userId: user.id, + botScopes: bot?.scopes, + }); + } else { + throw new Error("Failed to store installation"); + } + }, + fetchInstallation: async (installQuery, logger) => { + if ( + installQuery.isEnterpriseInstall && + installQuery.enterpriseId !== undefined + ) { + logger?.error("Enterprise fetchInstallation attempt failed."); + throw new Error("Enterprise installation not supported"); + } else if (installQuery.teamId !== undefined) { + const result = await documentClient + .get({ + TableName: installationsTableName, + Key: { Team: installQuery.teamId }, + }) + .promise(); + const installation = result.Item?.Installation; + const { team, user, bot } = installation; + logger?.info("Installation fetched.", { + team, + userId: user.id, + botScopes: bot?.scopes, + }); + return Promise.resolve(installation); + } else { + throw new Error("Failed to fetch installation"); + } + }, +}); + +export default createInstallationStore; diff --git a/test/installation-store.test.ts b/test/installation-store.test.ts new file mode 100644 index 0000000..ecd3046 --- /dev/null +++ b/test/installation-store.test.ts @@ -0,0 +1,80 @@ +import { DocumentClient } from "aws-sdk/clients/dynamodb"; +import { Installation, InstallationQuery } from "@slack/bolt"; +import createInstallationStore from "../src/handlers/slack/installation-store"; +import { recreateInstallationsTable } from "./utils"; + +const installationsTableName = "dev-lockbot-installations"; + +const aTeamInstallation = (): Installation<"v2", false> => ({ + team: { id: "T012345WXYZ", name: "our-team" }, + enterprise: undefined, + user: { id: "U012345MNOP", token: undefined, scopes: undefined }, + bot: { + token: "xoxb-fake-bot-token", + scopes: ["commands"], + id: "B012345BOT0", + userId: "U012345BOT0", + }, + isEnterpriseInstall: false, +}); + +describe("DynamoDB installation store", () => { + let store: ReturnType; + beforeEach(async () => { + await recreateInstallationsTable(installationsTableName); + store = createInstallationStore( + new DocumentClient({ + region: "localhost", + endpoint: "http://localhost:8000", + }), + installationsTableName + ); + }); + + test("stores an installation and fetches it back by team", async () => { + const installation = aTeamInstallation(); + await store.storeInstallation(installation); + + const query: InstallationQuery = { + teamId: "T012345WXYZ", + enterpriseId: undefined, + isEnterpriseInstall: false, + }; + expect(await store.fetchInstallation(query)).toEqual(installation); + }); + + test("rejects storing an enterprise installation", async () => { + const enterpriseInstallation: Installation<"v2", true> = { + team: undefined, + enterprise: { id: "E012345ENT0", name: "big-corp" }, + user: { id: "U012345MNOP", token: undefined, scopes: undefined }, + isEnterpriseInstall: true, + }; + await expect( + store.storeInstallation(enterpriseInstallation) + ).rejects.toThrow("Enterprise installation not supported"); + }); + + test("rejects fetching an enterprise installation", async () => { + const query: InstallationQuery = { + teamId: undefined, + enterpriseId: "E012345ENT0", + isEnterpriseInstall: true, + }; + await expect(store.fetchInstallation(query)).rejects.toThrow( + "Enterprise installation not supported" + ); + }); + + test("throws when fetching a team with no stored installation", async () => { + const query: InstallationQuery = { + teamId: "T999999NONE", + enterpriseId: undefined, + isEnterpriseInstall: false, + }; + // Current behaviour: fetchInstallation destructures the (undefined) result, + // so an unknown team throws rather than returning undefined. Pinned here so + // the AWS SDK v3 migration has to consciously decide whether to keep it. + await expect(store.fetchInstallation(query)).rejects.toThrow(); + }); +}); diff --git a/test/utils.ts b/test/utils.ts index 0da7921..29b08cc 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -54,3 +54,26 @@ export const recreateAccessTokenTable = async ( .promise(); } }; + +export const recreateInstallationsTable = async ( + installationsTableName: string +) => { + const db = new DynamoDB(options); + try { + await db.deleteTable({ TableName: installationsTableName }).promise(); + } catch (error) { + // No problem if the table doesn't exist + } finally { + await db + .createTable({ + TableName: installationsTableName, + AttributeDefinitions: [{ AttributeName: "Team", AttributeType: "S" }], + KeySchema: [{ AttributeName: "Team", KeyType: "HASH" }], + ProvisionedThroughput: { + ReadCapacityUnits: 4, + WriteCapacityUnits: 2, + }, + }) + .promise(); + } +}; From f0119e3126750299b7e8157b8f34720cce7f1417 Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sat, 30 May 2026 09:49:03 +0000 Subject: [PATCH 3/6] test: characterise swagger handler Extract the swagger Express app from index.ts into an injectable factory and supertest it in-process. index.ts keeps requiring swagger.html / openapi.json (webpack text-loader) and reading SERVERLESS_STAGE, then passes them in, so the test needs no html transform, no env and no serverless-offline. Pins: HTML at /api-docs, the OpenAPI doc at both /openapi.json and /api-docs/openapi.json, and the stage-aware servers field. The stage is now applied to a copy, so the handler no longer mutates the required openapi.json module (asserted). Verified: swagger 5/5 in-process; 87/87 runnable suites green vs DynamoDB Local; tsc --noEmit; eslint. --- src/handlers/swagger/app.ts | 33 ++++++++++++++++++++++++ src/handlers/swagger/index.ts | 17 +++---------- test/swagger-app.test.ts | 48 +++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 src/handlers/swagger/app.ts create mode 100644 test/swagger-app.test.ts diff --git a/src/handlers/swagger/app.ts b/src/handlers/swagger/app.ts new file mode 100644 index 0000000..5f444f1 --- /dev/null +++ b/src/handlers/swagger/app.ts @@ -0,0 +1,33 @@ +import express from "express"; + +// Extracted from index.ts so the routes can be exercised in-process with +// supertest, without webpack's html text-loader or the SERVERLESS_STAGE env +// read. index.ts still owns requiring swagger.html / openapi.json and reading +// the stage, then hands them in here. The stage-aware `servers` field is now +// applied to a copy rather than mutating the caller's document. +const createSwaggerApp = ( + stage: string, + swaggerHtml: string, + openApiJson: Record +) => { + const openApiWithServers = { + ...openApiJson, + servers: [{ url: `/${stage}` }], + }; + + const app = express(); + + app.get("/api-docs", async (req, res) => { + res.set("Content-Type", "text/html; charset=utf-8"); + res.send(swaggerHtml); + }); + + app.get(["/openapi.json", "/api-docs/openapi.json"], async (req, res) => { + res.set("Content-Type", "application/json; charset=utf-8"); + res.send(openApiWithServers); + }); + + return app; +}; + +export default createSwaggerApp; diff --git a/src/handlers/swagger/index.ts b/src/handlers/swagger/index.ts index 7436ef7..3dafa7a 100644 --- a/src/handlers/swagger/index.ts +++ b/src/handlers/swagger/index.ts @@ -1,23 +1,12 @@ -import express from "express"; import * as env from "env-var"; import serverlessExpress from "@vendia/serverless-express"; +import createSwaggerApp from "./app"; +// swagger.html is inlined as text by webpack's text-loader (webpack.config.js). const swaggerHtml = require("./swagger.html"); const openApiJson = require("./openapi.json"); const stage = env.get("SERVERLESS_STAGE").required().asString(); -openApiJson.servers = [{ url: `/${stage}` }]; - -const app = express(); - -app.get("/api-docs", async (req, res) => { - res.set("Content-Type", "text/html; charset=utf-8"); - res.send(swaggerHtml); -}); - -app.get(["/openapi.json", "/api-docs/openapi.json"], async (req, res) => { - res.set("Content-Type", "application/json; charset=utf-8"); - res.send(openApiJson); -}); +const app = createSwaggerApp(stage, swaggerHtml, openApiJson); exports.handler = serverlessExpress({ app }); diff --git a/test/swagger-app.test.ts b/test/swagger-app.test.ts new file mode 100644 index 0000000..8c0df25 --- /dev/null +++ b/test/swagger-app.test.ts @@ -0,0 +1,48 @@ +import request from "supertest"; +import createSwaggerApp from "../src/handlers/swagger/app"; + +const swaggerHtml = + 'Swagger UI'; + +const anOpenApiDocument = () => ({ + openapi: "3.0.1", + info: { title: "Lockbot", version: "1.0.0" }, + paths: {}, +}); + +describe("swagger app", () => { + test("serves the Swagger UI HTML at /api-docs", async () => { + const app = createSwaggerApp("dev", swaggerHtml, anOpenApiDocument()); + const response = await request(app).get("/api-docs"); + expect(response.status).toEqual(200); + expect(response.type).toEqual("text/html"); + expect(response.text).toEqual(swaggerHtml); + }); + + test("serves the OpenAPI document at /openapi.json", async () => { + const app = createSwaggerApp("dev", swaggerHtml, anOpenApiDocument()); + const response = await request(app).get("/openapi.json"); + expect(response.status).toEqual(200); + expect(response.type).toEqual("application/json"); + expect(response.body.openapi).toEqual("3.0.1"); + }); + + test("also serves the OpenAPI document at /api-docs/openapi.json", async () => { + const app = createSwaggerApp("dev", swaggerHtml, anOpenApiDocument()); + const response = await request(app).get("/api-docs/openapi.json"); + expect(response.status).toEqual(200); + expect(response.body.info.title).toEqual("Lockbot"); + }); + + test("injects the stage into the OpenAPI servers field", async () => { + const app = createSwaggerApp("prod", swaggerHtml, anOpenApiDocument()); + const response = await request(app).get("/openapi.json"); + expect(response.body.servers).toEqual([{ url: "/prod" }]); + }); + + test("does not mutate the caller's OpenAPI document", async () => { + const document = anOpenApiDocument(); + createSwaggerApp("dev", swaggerHtml, document); + expect(document).not.toHaveProperty("servers"); + }); +}); From 28f67f73a61b01e01283343369df1997152d4436 Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sat, 30 May 2026 13:06:14 +0000 Subject: [PATCH 4/6] test: trim migration-narrative comments to durable intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phase-0 characterisation commits left comments framed around the act of change ("extracted verbatim", "now applied to a copy rather than") and around unstarted future work ("the AWS SDK v3 migration", "every Bolt / serverless-express migration touches"). Both go stale: diff-relative language is meaningless once the old code is gone, and forward references mislead once the migration lands. That context — these characterisation tests are a safety net before the Bolt / serverless-express / AWS SDK v3 migrations — belongs here in the history, not in source that outlives it. Kept only the durable nuggets in-code: - installation-store.test.ts: why an unknown team throws (accidental destructure of undefined) — explains a non-obvious assertion. - handle-command.test.ts: what the setup helper records. Dropped the swagger/app.ts copy-not-mutate note (already pinned by a test) and the "extracted from" headers entirely. --- src/handlers/slack/installation-store.ts | 3 --- src/handlers/swagger/app.ts | 5 ----- test/handle-command.test.ts | 4 +--- test/installation-store.test.ts | 5 ++--- 4 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/handlers/slack/installation-store.ts b/src/handlers/slack/installation-store.ts index 6f7a662..387099e 100644 --- a/src/handlers/slack/installation-store.ts +++ b/src/handlers/slack/installation-store.ts @@ -1,9 +1,6 @@ import { DocumentClient } from "aws-sdk/clients/dynamodb"; import { InstallationStore } from "@slack/bolt"; -// Extracted verbatim from infra.ts so the store can be exercised against -// DynamoDB Local without booting the whole Slack receiver (which requires every -// Slack env var and a live AWS client). Behaviour is unchanged. const createInstallationStore = ( documentClient: DocumentClient, installationsTableName: string diff --git a/src/handlers/swagger/app.ts b/src/handlers/swagger/app.ts index 5f444f1..4fc71ae 100644 --- a/src/handlers/swagger/app.ts +++ b/src/handlers/swagger/app.ts @@ -1,10 +1,5 @@ import express from "express"; -// Extracted from index.ts so the routes can be exercised in-process with -// supertest, without webpack's html text-loader or the SERVERLESS_STAGE env -// read. index.ts still owns requiring swagger.html / openapi.json and reading -// the stage, then hands them in here. The stage-aware `servers` field is now -// applied to a copy rather than mutating the caller's document. const createSwaggerApp = ( stage: string, swaggerHtml: string, diff --git a/test/handle-command.test.ts b/test/handle-command.test.ts index 442e442..82078bd 100644 --- a/test/handle-command.test.ts +++ b/test/handle-command.test.ts @@ -27,9 +27,7 @@ const aSlashCommand = ( // Drives handle-command with in-memory fakes (no mocks) that record what the // adapter does with the bot's Response: how it acknowledges, what it sends back, -// and what it logs. This is the seam every Bolt / serverless-express migration -// touches, so locking it down first lets later upgrades prove they're behaviour -// preserving. +// and what it logs. const setup = (getResponse: (command: SlashCommand) => Promise) => { const order: string[] = []; const responded: unknown[] = []; diff --git a/test/installation-store.test.ts b/test/installation-store.test.ts index ecd3046..9c70b3a 100644 --- a/test/installation-store.test.ts +++ b/test/installation-store.test.ts @@ -72,9 +72,8 @@ describe("DynamoDB installation store", () => { enterpriseId: undefined, isEnterpriseInstall: false, }; - // Current behaviour: fetchInstallation destructures the (undefined) result, - // so an unknown team throws rather than returning undefined. Pinned here so - // the AWS SDK v3 migration has to consciously decide whether to keep it. + // fetchInstallation destructures the (undefined) result, so an unknown team + // throws rather than returning undefined. await expect(store.fetchInstallation(query)).rejects.toThrow(); }); }); From 903a9aae7eb26a8168e5619ffbc44c63bcf9e2aa Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sun, 31 May 2026 20:25:16 +0000 Subject: [PATCH 5/6] ci: bump runners from ubuntu-20.04 to ubuntu-latest GitHub retired the ubuntu-20.04 runner image, so build, deploy and CodeQL jobs fail to start. Move all four to ubuntu-latest so the safety-net test suite can actually run on PRs. --- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/deploy.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7012c74..68addae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ on: jobs: build: name: Build and test - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d72a391..83e8ded 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -9,7 +9,7 @@ on: jobs: analyse: name: Analyse - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4fcc814..59646d0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,7 +7,7 @@ on: jobs: deploy-dev: name: Deploy to dev - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest environment: dev steps: - uses: actions/checkout@v2 @@ -26,7 +26,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} deploy-prod: name: Deploy to prod - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest environment: prod steps: - uses: actions/checkout@v2 From b026d6276814cc509e0b17f317efdc56c0108004 Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sun, 31 May 2026 20:43:57 +0000 Subject: [PATCH 6/6] ci: give DynamoDB Local non-empty creds DynamoDB Local now validates credentials and rejects the empty '' values with UnrecognizedClientException, failing every dynamodb repo test. Older images ignored creds; the latest image pulled by rrainn/dynamodb-action no longer does. Use non-empty placeholders. --- .github/workflows/build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 68addae..3e5ae8b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,9 @@ jobs: - name: Setup AWS credentials run: | mkdir ~/.aws - echo -e "[default]\naws_access_key_id=''\naws_secret_access_key=''" > ~/.aws/credentials + # DynamoDB Local now validates credentials and rejects empty/invalid + # ones with UnrecognizedClientException, so use non-empty placeholders. + echo -e "[default]\naws_access_key_id=fakeMyKeyId\naws_secret_access_key=fakeSecretAccessKey" > ~/.aws/credentials - name: Setup Serverless Offline & Run Tests run: yarn ci - name: Coveralls