fix(cli): validate message numeric options

This commit is contained in:
Peter Steinberger
2026-05-27 08:02:56 -04:00
parent 7efbaf7dba
commit 5fdaf6b49b
2 changed files with 142 additions and 0 deletions

View File

@@ -306,6 +306,116 @@ describe("runMessageAction", () => {
expect(exitMock).not.toHaveBeenCalledWith(0);
});
it.each([
[
"poll duration hours",
"poll",
{
channel: "discord",
target: "123",
pollQuestion: "ship?",
pollOption: ["yes", "no"],
pollDurationHours: "1.5",
},
"--poll-duration-hours",
],
[
"poll duration seconds",
"poll",
{
channel: "telegram",
target: "123",
pollQuestion: "ship?",
pollOption: ["yes", "no"],
pollDurationSeconds: "60s",
},
"--poll-duration-seconds",
],
[
"timeout duration",
"timeout",
{ guildId: "g", userId: "u", durationMin: "5m" },
"--duration-min",
],
["ban delete days", "ban", { guildId: "g", userId: "u", deleteDays: "7d" }, "--delete-days"],
["read limit", "read", { channel: "discord", target: "123", limit: "10x" }, "--limit"],
["search limit", "search", { guildId: "g", query: "hello", limit: "10x" }, "--limit"],
["pins limit", "list-pins", { channel: "discord", target: "123", limit: "10x" }, "--limit"],
[
"reactions limit",
"reactions",
{ channel: "discord", target: "123", messageId: "m", limit: "10x" },
"--limit",
],
[
"thread auto archive minutes",
"thread-create",
{
channel: "discord",
target: "123",
threadName: "ops",
autoArchiveMin: "60m",
},
"--auto-archive-min",
],
["thread list limit", "thread-list", { guildId: "g", limit: "10x" }, "--limit"],
])("rejects malformed numeric CLI option for %s", async (_name, action, opts, flag) => {
const runMessageAction = createRunMessageAction();
await expect(runMessageAction(action, opts)).rejects.toThrow("exit");
const kind = flag === "--delete-days" ? "non-negative" : "positive";
expect(errorMock).toHaveBeenCalledWith(`Error: ${flag} must be a ${kind} integer.`);
expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled();
expect(messageCommandMock).not.toHaveBeenCalled();
expect(exitMock).toHaveBeenCalledWith(1);
expect(exitMock).not.toHaveBeenCalledWith(0);
});
it.each([
["pollDurationHours", "0", "--poll-duration-hours"],
["pollDurationSeconds", "-1", "--poll-duration-seconds"],
["durationMin", "", "--duration-min"],
["deleteDays", Number.NaN, "--delete-days"],
["limit", 1.2, "--limit"],
["autoArchiveMin", null, "--auto-archive-min"],
])("rejects non-positive or non-integer %s values", async (key, value, flag) => {
const runMessageAction = createRunMessageAction();
await expect(
runMessageAction("send", {
...baseSendOptions,
[key]: value,
}),
).rejects.toThrow("exit");
const kind = flag === "--delete-days" ? "non-negative" : "positive";
expect(errorMock).toHaveBeenCalledWith(`Error: ${flag} must be a ${kind} integer.`);
expect(messageCommandMock).not.toHaveBeenCalled();
expect(exitMock).toHaveBeenCalledWith(1);
});
it("allows zero delete-days for no-history Discord bans", async () => {
const runMessageAction = createRunMessageAction();
await expect(
runMessageAction("ban", {
guildId: "g",
userId: "u",
deleteDays: "0",
}),
).rejects.toThrow("exit");
expect(errorMock).not.toHaveBeenCalled();
expectMessageCommandOptions({
action: "ban",
guildId: "g",
userId: "u",
deleteDays: "0",
});
expect(exitMock).toHaveBeenCalledWith(0);
});
it("runs gateway_stop hooks before exit when registered", async () => {
hasHooksMock.mockReturnValueOnce(true);
await runSendAction();

View File

@@ -8,6 +8,10 @@ import { resolveMessageSecretScope } from "../../../cli/message-secret-scope.js"
import { messageCommand } from "../../../commands/message.js";
import { danger, setVerbose } from "../../../globals.js";
import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-target.js";
import {
parseStrictNonNegativeInteger,
parseStrictPositiveInteger,
} from "../../../infra/parse-finite-number.js";
import { runGlobalGatewayStopSafely } from "../../../plugins/hook-runner-global.js";
import { defaultRuntime } from "../../../runtime.js";
import { runCommandWithRuntime } from "../../cli-utils.js";
@@ -25,6 +29,14 @@ const GATEWAY_STOP_TIMEOUT_MS = 2500;
const ACTIONS_WITHOUT_STOP_HOOKS = new Set(["read"]);
const ACTIONS_REQUIRING_CONFIGURED_CHANNEL_PRELOAD = new Set(["broadcast"]);
const CHANNEL_MESSAGE_ACTION_NAME_SET = new Set<string>(CHANNEL_MESSAGE_ACTION_NAMES);
const STRICT_POSITIVE_INTEGER_OPTIONS = new Map([
["pollDurationHours", "--poll-duration-hours"],
["pollDurationSeconds", "--poll-duration-seconds"],
["durationMin", "--duration-min"],
["limit", "--limit"],
["autoArchiveMin", "--auto-archive-min"],
]);
const STRICT_NON_NEGATIVE_INTEGER_OPTIONS = new Map([["deleteDays", "--delete-days"]]);
type MessagePluginLoadOptions = { scope: PluginRegistryScope; onlyChannelIds?: string[] };
type MessagePluginPreloadPlan =
@@ -39,6 +51,25 @@ function normalizeMessageOptions(opts: Record<string, unknown>): Record<string,
};
}
function validateMessageNumericOptions(opts: Record<string, unknown>): void {
for (const [key, flag] of STRICT_POSITIVE_INTEGER_OPTIONS) {
if (opts[key] === undefined) {
continue;
}
if (parseStrictPositiveInteger(opts[key]) === undefined) {
throw new Error(`${flag} must be a positive integer.`);
}
}
for (const [key, flag] of STRICT_NON_NEGATIVE_INTEGER_OPTIONS) {
if (opts[key] === undefined) {
continue;
}
if (parseStrictNonNegativeInteger(opts[key]) === undefined) {
throw new Error(`${flag} must be a non-negative integer.`);
}
}
}
async function runPluginStopHooks(): Promise<void> {
let timeout: NodeJS.Timeout | null = null;
const hookRun = runGlobalGatewayStopSafely({
@@ -128,6 +159,7 @@ export function createMessageCliHelpers(
await runCommandWithRuntime(
defaultRuntime,
async () => {
validateMessageNumericOptions(opts);
const preloadPlan = resolveMessagePluginPreloadPlan(action, opts);
if (preloadPlan.preload) {
ensurePluginRegistryLoaded(preloadPlan.loadOptions);