mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
test(qa): validate Discord Convex credential payloads (#70910)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
121
qa/convex-credential-broker/convex/payload-validation.ts
Normal file
121
qa/convex-credential-broker/convex/payload-validation.ts
Normal 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;
|
||||
}
|
||||
56
test/qa-convex-credential-payload-validation.test.ts
Normal file
56
test/qa-convex-credential-payload-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user