test(qa): validate Discord Convex credential payloads (#70910)

This commit is contained in:
Patrick Erichsen
2026-04-23 20:35:54 -07:00
committed by GitHub
parent ae609e0249
commit 88fb6518c2
4 changed files with 195 additions and 45 deletions

View File

@@ -60,6 +60,10 @@ pnpm openclaw qa credentials add \
--kind telegram \
--payload-file qa/telegram-credential.json
pnpm openclaw qa credentials add \
--kind discord \
--payload-file qa/discord-credential.json
pnpm openclaw qa credentials list --kind telegram
pnpm openclaw qa credentials remove --credential-id <credential-id>
@@ -140,6 +144,14 @@ For `kind: "telegram"`, broker `admin/add` validates that payload includes:
- non-empty `driverToken`
- non-empty `sutToken`
For `kind: "discord"`, broker `admin/add` validates that payload includes:
- `guildId` as a Discord snowflake string
- `channelId` as a Discord snowflake string
- non-empty `driverBotToken`
- non-empty `sutBotToken`
- `sutApplicationId` as a Discord snowflake string
Admin list (default redacted):
```bash

View File

@@ -2,6 +2,7 @@ import { httpRouter } from "convex/server";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { httpAction } from "./_generated/server";
import { normalizeCredentialPayloadForKind } from "./payload-validation";
type ActorRole = "ci" | "maintainer";
@@ -201,50 +202,6 @@ function optionalListStatus(body: Record<string, unknown>, key: string) {
return value;
}
function requirePayloadString(payload: Record<string, unknown>, key: string, kind: string): string {
const raw = payload[key];
if (typeof raw !== "string") {
throw new BrokerHttpError(
400,
"INVALID_PAYLOAD",
`Credential payload for kind "${kind}" must include "${key}" as a string.`,
);
}
const value = raw.trim();
if (!value) {
throw new BrokerHttpError(
400,
"INVALID_PAYLOAD",
`Credential payload for kind "${kind}" must include a non-empty "${key}" value.`,
);
}
return value;
}
function normalizeCredentialPayloadForKind(kind: string, payload: Record<string, unknown>) {
if (kind !== "telegram") {
return payload;
}
const groupId = requirePayloadString(payload, "groupId", "telegram");
if (!/^-?\d+$/u.test(groupId)) {
throw new BrokerHttpError(
400,
"INVALID_PAYLOAD",
'Credential payload for kind "telegram" must include a numeric "groupId" string.',
);
}
const driverToken = requirePayloadString(payload, "driverToken", "telegram");
const sutToken = requirePayloadString(payload, "sutToken", "telegram");
return {
groupId,
driverToken,
sutToken,
} satisfies Record<string, unknown>;
}
function parseActorRole(body: Record<string, unknown>) {
const actorRole = requireString(body, "actorRole");
if (actorRole !== "ci" && actorRole !== "maintainer") {
@@ -396,7 +353,11 @@ http.route({
assertMaintainerAdminAuth(parseBearerToken(request));
const body = await parseJsonObject(request);
const kind = requireString(body, "kind");
const payload = normalizeCredentialPayloadForKind(kind, requireObject(body, "payload"));
const payload = normalizeCredentialPayloadForKind(
kind,
requireObject(body, "payload"),
(httpStatus, code, message) => new BrokerHttpError(httpStatus, code, message),
);
const result = await ctx.runMutation(internal.credentials.addCredentialSet, {
kind,
payload,

View File

@@ -0,0 +1,121 @@
export class CredentialPayloadValidationError extends Error {
code: string;
httpStatus: number;
constructor(httpStatus: number, code: string, message: string) {
super(message);
this.name = "CredentialPayloadValidationError";
this.httpStatus = httpStatus;
this.code = code;
}
}
type PayloadValidationFailureFactory = (httpStatus: number, code: string, message: string) => Error;
const DISCORD_SNOWFLAKE_RE = /^\d{17,20}$/u;
const TELEGRAM_CHAT_ID_RE = /^-?\d+$/u;
function createCredentialPayloadValidationError(httpStatus: number, code: string, message: string) {
return new CredentialPayloadValidationError(httpStatus, code, message);
}
function throwPayloadError(createFailure: PayloadValidationFailureFactory, message: string): never {
throw createFailure(400, "INVALID_PAYLOAD", message);
}
function requirePayloadString(
payload: Record<string, unknown>,
key: string,
kind: string,
createFailure: PayloadValidationFailureFactory,
): string {
const raw = payload[key];
if (typeof raw !== "string") {
throwPayloadError(
createFailure,
`Credential payload for kind "${kind}" must include "${key}" as a string.`,
);
}
const value = raw.trim();
if (!value) {
throwPayloadError(
createFailure,
`Credential payload for kind "${kind}" must include a non-empty "${key}" value.`,
);
}
return value;
}
function requireDiscordSnowflakePayloadString(
payload: Record<string, unknown>,
key: string,
createFailure: PayloadValidationFailureFactory,
) {
const value = requirePayloadString(payload, key, "discord", createFailure);
if (!DISCORD_SNOWFLAKE_RE.test(value)) {
throwPayloadError(
createFailure,
`Credential payload for kind "discord" must include "${key}" as a Discord snowflake string.`,
);
}
return value;
}
function normalizeTelegramCredentialPayload(
payload: Record<string, unknown>,
createFailure: PayloadValidationFailureFactory,
) {
const groupId = requirePayloadString(payload, "groupId", "telegram", createFailure);
if (!TELEGRAM_CHAT_ID_RE.test(groupId)) {
throwPayloadError(
createFailure,
'Credential payload for kind "telegram" must include a numeric "groupId" string.',
);
}
const driverToken = requirePayloadString(payload, "driverToken", "telegram", createFailure);
const sutToken = requirePayloadString(payload, "sutToken", "telegram", createFailure);
return {
groupId,
driverToken,
sutToken,
} satisfies Record<string, unknown>;
}
function normalizeDiscordCredentialPayload(
payload: Record<string, unknown>,
createFailure: PayloadValidationFailureFactory,
) {
const guildId = requireDiscordSnowflakePayloadString(payload, "guildId", createFailure);
const channelId = requireDiscordSnowflakePayloadString(payload, "channelId", createFailure);
const sutApplicationId = requireDiscordSnowflakePayloadString(
payload,
"sutApplicationId",
createFailure,
);
const driverBotToken = requirePayloadString(payload, "driverBotToken", "discord", createFailure);
const sutBotToken = requirePayloadString(payload, "sutBotToken", "discord", createFailure);
return {
guildId,
channelId,
driverBotToken,
sutBotToken,
sutApplicationId,
} satisfies Record<string, unknown>;
}
export function normalizeCredentialPayloadForKind(
kind: string,
payload: Record<string, unknown>,
createFailure: PayloadValidationFailureFactory = createCredentialPayloadValidationError,
) {
if (kind === "telegram") {
return normalizeTelegramCredentialPayload(payload, createFailure);
}
if (kind === "discord") {
return normalizeDiscordCredentialPayload(payload, createFailure);
}
return payload;
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
CredentialPayloadValidationError,
normalizeCredentialPayloadForKind,
} from "../qa/convex-credential-broker/convex/payload-validation.js";
describe("QA Convex credential payload validation", () => {
it("normalizes Discord credential payloads", () => {
expect(
normalizeCredentialPayloadForKind("discord", {
guildId: " 1496962067029299350 ",
channelId: "1496962068027281447",
driverBotToken: " driver-token ",
sutBotToken: "sut-token",
sutApplicationId: "1496963665587601428",
ignored: true,
}),
).toEqual({
guildId: "1496962067029299350",
channelId: "1496962068027281447",
driverBotToken: "driver-token",
sutBotToken: "sut-token",
sutApplicationId: "1496963665587601428",
});
});
it("rejects malformed Discord snowflakes", () => {
expect(() =>
normalizeCredentialPayloadForKind("discord", {
guildId: "not-a-snowflake",
channelId: "1496962068027281447",
driverBotToken: "driver-token",
sutBotToken: "sut-token",
sutApplicationId: "1496963665587601428",
}),
).toThrow(CredentialPayloadValidationError);
});
it("rejects empty Discord bot tokens", () => {
expect(() =>
normalizeCredentialPayloadForKind("discord", {
guildId: "1496962067029299350",
channelId: "1496962068027281447",
driverBotToken: " ",
sutBotToken: "sut-token",
sutApplicationId: "1496963665587601428",
}),
).toThrow(/driverBotToken/u);
});
it("keeps unknown credential kinds pass-through-compatible", () => {
const payload = { anything: true };
expect(normalizeCredentialPayloadForKind("future-kind", payload)).toBe(payload);
});
});