mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 03:19:49 +00:00
Defense-in-depth safety net for #81628: even with the cron-tool fix in place, any other code path that ever produces a 33-char LINE-shaped recipient missing its leading capital (C/U/R) would otherwise hit the LINE API and return HTTP 400 with no permanent-error signal, causing delivery-recovery to retry five times before moving the entry to failed/. normalizeTarget now throws "Recipient is not a valid LINE id ..." when the post-strip value looks like a LINE id but the case was lost. The message matches the existing /recipient is not a valid/i pattern in delivery-queue-recovery's PERMANENT_ERROR_PATTERNS, so recovery moves the entry to failed/ on the first attempt instead of silently retrying. Short fixtures (length < 33) are left alone so existing tests using "U123", "line:user:1", etc. keep working.
454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
const {
|
||
pushMessageMock,
|
||
replyMessageMock,
|
||
showLoadingAnimationMock,
|
||
getProfileMock,
|
||
MessagingApiClientMock,
|
||
requireRuntimeConfigMock,
|
||
resolveLineAccountMock,
|
||
resolveLineChannelAccessTokenMock,
|
||
recordChannelActivityMock,
|
||
logVerboseMock,
|
||
resolvePinnedHostnameWithPolicyMock,
|
||
} = vi.hoisted(() => {
|
||
const pushMessageMock = vi.fn();
|
||
const replyMessageMock = vi.fn();
|
||
const showLoadingAnimationMock = vi.fn();
|
||
const getProfileMock = vi.fn();
|
||
const MessagingApiClientMock = vi.fn(function () {
|
||
return {
|
||
pushMessage: pushMessageMock,
|
||
replyMessage: replyMessageMock,
|
||
showLoadingAnimation: showLoadingAnimationMock,
|
||
getProfile: getProfileMock,
|
||
};
|
||
});
|
||
const requireRuntimeConfigMock = vi.fn((cfg: unknown) => cfg ?? {});
|
||
const resolveLineAccountMock = vi.fn(() => ({ accountId: "default" }));
|
||
const resolveLineChannelAccessTokenMock = vi.fn(() => "line-token");
|
||
const recordChannelActivityMock = vi.fn();
|
||
const logVerboseMock = vi.fn();
|
||
const resolvePinnedHostnameWithPolicyMock = vi.fn();
|
||
return {
|
||
pushMessageMock,
|
||
replyMessageMock,
|
||
showLoadingAnimationMock,
|
||
getProfileMock,
|
||
MessagingApiClientMock,
|
||
requireRuntimeConfigMock,
|
||
resolveLineAccountMock,
|
||
resolveLineChannelAccessTokenMock,
|
||
recordChannelActivityMock,
|
||
logVerboseMock,
|
||
resolvePinnedHostnameWithPolicyMock,
|
||
};
|
||
});
|
||
|
||
vi.mock("@line/bot-sdk", () => ({
|
||
messagingApi: { MessagingApiClient: MessagingApiClientMock },
|
||
}));
|
||
|
||
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", () => ({
|
||
requireRuntimeConfig: requireRuntimeConfigMock,
|
||
}));
|
||
|
||
vi.mock("./accounts.js", () => ({
|
||
resolveLineAccount: resolveLineAccountMock,
|
||
}));
|
||
|
||
vi.mock("./channel-access-token.js", () => ({
|
||
resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock,
|
||
}));
|
||
|
||
vi.mock("openclaw/plugin-sdk/channel-activity-runtime", () => ({
|
||
recordChannelActivity: recordChannelActivityMock,
|
||
}));
|
||
|
||
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
|
||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
|
||
"openclaw/plugin-sdk/runtime-env",
|
||
);
|
||
return {
|
||
...actual,
|
||
logVerbose: logVerboseMock,
|
||
};
|
||
});
|
||
|
||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||
resolvePinnedHostnameWithPolicy: resolvePinnedHostnameWithPolicyMock,
|
||
}));
|
||
|
||
let sendModule: typeof import("./send.js");
|
||
|
||
const LINE_TEST_CFG = {
|
||
channels: {
|
||
line: {
|
||
accounts: {
|
||
default: {},
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
describe("LINE send helpers", () => {
|
||
const fixedSentAt = 1_800_000_000_000;
|
||
|
||
beforeAll(async () => {
|
||
sendModule = await import("./send.js");
|
||
});
|
||
|
||
afterAll(() => {
|
||
vi.doUnmock("@line/bot-sdk");
|
||
vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime");
|
||
vi.doUnmock("./accounts.js");
|
||
vi.doUnmock("./channel-access-token.js");
|
||
vi.doUnmock("openclaw/plugin-sdk/channel-activity-runtime");
|
||
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
|
||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||
vi.resetModules();
|
||
});
|
||
|
||
beforeEach(() => {
|
||
vi.setSystemTime(fixedSentAt);
|
||
pushMessageMock.mockReset();
|
||
replyMessageMock.mockReset();
|
||
showLoadingAnimationMock.mockReset();
|
||
getProfileMock.mockReset();
|
||
MessagingApiClientMock.mockReset();
|
||
requireRuntimeConfigMock.mockClear();
|
||
resolveLineAccountMock.mockReset();
|
||
resolveLineChannelAccessTokenMock.mockReset();
|
||
recordChannelActivityMock.mockReset();
|
||
logVerboseMock.mockReset();
|
||
resolvePinnedHostnameWithPolicyMock.mockReset();
|
||
|
||
MessagingApiClientMock.mockImplementation(function () {
|
||
return {
|
||
pushMessage: pushMessageMock,
|
||
replyMessage: replyMessageMock,
|
||
showLoadingAnimation: showLoadingAnimationMock,
|
||
getProfile: getProfileMock,
|
||
};
|
||
});
|
||
requireRuntimeConfigMock.mockImplementation((cfg: unknown) => cfg ?? LINE_TEST_CFG);
|
||
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
|
||
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
|
||
resolvePinnedHostnameWithPolicyMock.mockResolvedValue({
|
||
hostname: "example.com",
|
||
addresses: ["93.184.216.34"],
|
||
});
|
||
pushMessageMock.mockResolvedValue({});
|
||
replyMessageMock.mockResolvedValue({});
|
||
showLoadingAnimationMock.mockResolvedValue({});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it("limits quick reply items to 13", () => {
|
||
const labels = Array.from({ length: 20 }, (_, index) => `Option ${index + 1}`);
|
||
const quickReply = sendModule.createQuickReplyItems(labels);
|
||
|
||
expect(quickReply.items).toHaveLength(13);
|
||
});
|
||
|
||
it("pushes images via normalized LINE target", async () => {
|
||
const result = await sendModule.pushImageMessage(
|
||
"line:user:U123",
|
||
"https://example.com/original.jpg",
|
||
undefined,
|
||
{ cfg: LINE_TEST_CFG, verbose: true },
|
||
);
|
||
|
||
expect(pushMessageMock).toHaveBeenCalledWith({
|
||
to: "U123",
|
||
messages: [
|
||
{
|
||
type: "image",
|
||
originalContentUrl: "https://example.com/original.jpg",
|
||
previewImageUrl: "https://example.com/original.jpg",
|
||
},
|
||
],
|
||
});
|
||
expect(recordChannelActivityMock).toHaveBeenCalledWith({
|
||
channel: "line",
|
||
accountId: "default",
|
||
direction: "outbound",
|
||
});
|
||
expect(logVerboseMock).toHaveBeenCalledWith("line: pushed image to U123");
|
||
expect(result).toEqual({
|
||
chatId: "U123",
|
||
messageId: "push",
|
||
receipt: {
|
||
parts: [
|
||
{
|
||
index: 0,
|
||
kind: "media",
|
||
platformMessageId: "push",
|
||
raw: {
|
||
channel: "line",
|
||
chatId: "U123",
|
||
conversationId: "U123",
|
||
messageId: "push",
|
||
meta: { messageCount: 1 },
|
||
},
|
||
threadId: "U123",
|
||
},
|
||
],
|
||
platformMessageIds: ["push"],
|
||
primaryPlatformMessageId: "push",
|
||
raw: [
|
||
{
|
||
channel: "line",
|
||
chatId: "U123",
|
||
conversationId: "U123",
|
||
messageId: "push",
|
||
meta: { messageCount: 1 },
|
||
},
|
||
],
|
||
sentAt: fixedSentAt,
|
||
threadId: "U123",
|
||
},
|
||
});
|
||
});
|
||
|
||
it("replies when reply token is provided", async () => {
|
||
const result = await sendModule.sendMessageLine("line:group:C1", "Hello", {
|
||
cfg: LINE_TEST_CFG,
|
||
replyToken: "reply-token",
|
||
mediaUrl: "https://example.com/media.jpg",
|
||
verbose: true,
|
||
});
|
||
|
||
expect(replyMessageMock).toHaveBeenCalledTimes(1);
|
||
expect(pushMessageMock).not.toHaveBeenCalled();
|
||
expect(replyMessageMock).toHaveBeenCalledWith({
|
||
replyToken: "reply-token",
|
||
messages: [
|
||
{
|
||
type: "image",
|
||
originalContentUrl: "https://example.com/media.jpg",
|
||
previewImageUrl: "https://example.com/media.jpg",
|
||
},
|
||
{
|
||
type: "text",
|
||
text: "Hello",
|
||
},
|
||
],
|
||
});
|
||
expect(logVerboseMock).toHaveBeenCalledWith("line: replied to C1");
|
||
expect(result).toEqual({
|
||
chatId: "C1",
|
||
messageId: "reply",
|
||
receipt: {
|
||
parts: [
|
||
{
|
||
index: 0,
|
||
kind: "media",
|
||
platformMessageId: "reply",
|
||
raw: {
|
||
channel: "line",
|
||
chatId: "C1",
|
||
conversationId: "C1",
|
||
messageId: "reply",
|
||
meta: { messageCount: 2 },
|
||
},
|
||
threadId: "C1",
|
||
},
|
||
],
|
||
platformMessageIds: ["reply"],
|
||
primaryPlatformMessageId: "reply",
|
||
raw: [
|
||
{
|
||
channel: "line",
|
||
chatId: "C1",
|
||
conversationId: "C1",
|
||
messageId: "reply",
|
||
meta: { messageCount: 2 },
|
||
},
|
||
],
|
||
sentAt: fixedSentAt,
|
||
threadId: "C1",
|
||
},
|
||
});
|
||
});
|
||
|
||
it("sends video with explicit image preview URL", async () => {
|
||
await sendModule.sendMessageLine("line:user:U100", "Video", {
|
||
cfg: LINE_TEST_CFG,
|
||
mediaUrl: "https://example.com/video.mp4",
|
||
mediaKind: "video",
|
||
previewImageUrl: "https://example.com/preview.jpg",
|
||
trackingId: "track-1",
|
||
});
|
||
|
||
expect(pushMessageMock).toHaveBeenCalledWith({
|
||
to: "U100",
|
||
messages: [
|
||
{
|
||
type: "video",
|
||
originalContentUrl: "https://example.com/video.mp4",
|
||
previewImageUrl: "https://example.com/preview.jpg",
|
||
trackingId: "track-1",
|
||
},
|
||
{
|
||
type: "text",
|
||
text: "Video",
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
it("throws when video preview URL is missing", async () => {
|
||
await expect(
|
||
sendModule.sendMessageLine("line:user:U200", "Video", {
|
||
cfg: LINE_TEST_CFG,
|
||
mediaUrl: "https://example.com/video.mp4",
|
||
mediaKind: "video",
|
||
}),
|
||
).rejects.toThrow(/require previewimageurl/i);
|
||
});
|
||
|
||
it("blocks private-network media URLs before calling LINE", async () => {
|
||
resolvePinnedHostnameWithPolicyMock.mockRejectedValueOnce(
|
||
new Error("SSRF blocked private network target"),
|
||
);
|
||
|
||
await expect(
|
||
sendModule.sendMessageLine("line:user:U200", "Image", {
|
||
cfg: LINE_TEST_CFG,
|
||
mediaUrl: "https://127.0.0.1/image.jpg",
|
||
}),
|
||
).rejects.toThrow(/private network/i);
|
||
|
||
expect(pushMessageMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("omits trackingId for non-user destinations", async () => {
|
||
await sendModule.sendMessageLine("line:group:C100", "Video", {
|
||
cfg: LINE_TEST_CFG,
|
||
mediaUrl: "https://example.com/video.mp4",
|
||
mediaKind: "video",
|
||
previewImageUrl: "https://example.com/preview.jpg",
|
||
trackingId: "track-group",
|
||
});
|
||
|
||
expect(pushMessageMock).toHaveBeenCalledWith({
|
||
to: "C100",
|
||
messages: [
|
||
{
|
||
type: "video",
|
||
originalContentUrl: "https://example.com/video.mp4",
|
||
previewImageUrl: "https://example.com/preview.jpg",
|
||
},
|
||
{
|
||
type: "text",
|
||
text: "Video",
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
it("throws when push messages are empty", async () => {
|
||
await expect(sendModule.pushMessagesLine("U123", [], { cfg: LINE_TEST_CFG })).rejects.toThrow(
|
||
"Message must be non-empty for LINE sends",
|
||
);
|
||
});
|
||
|
||
it("rejects lowercased LINE-shaped recipients (#81628 safety net)", async () => {
|
||
// 33-char value with lowercase leading char — what an upstream session-key
|
||
// fragment looked like before the cron-tool fix. LINE rejects with HTTP 400
|
||
// anyway; throwing locally keeps the failure permanent so delivery-recovery
|
||
// moves the entry to failed/ immediately instead of silently retrying 5×.
|
||
await expect(
|
||
sendModule.pushMessagesLine(
|
||
"cabcdef0123456789abcdef0123456789",
|
||
[{ type: "text", text: "hello" }],
|
||
{ cfg: LINE_TEST_CFG },
|
||
),
|
||
).rejects.toThrow(/Recipient is not a valid LINE id/);
|
||
expect(pushMessageMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("accepts case-exact LINE recipients with the leading capital preserved", async () => {
|
||
await sendModule.pushMessagesLine(
|
||
"Cabcdef0123456789abcdef0123456789",
|
||
[{ type: "text", text: "hello" }],
|
||
{ cfg: LINE_TEST_CFG },
|
||
);
|
||
expect(pushMessageMock).toHaveBeenCalledWith({
|
||
to: "Cabcdef0123456789abcdef0123456789",
|
||
messages: [{ type: "text", text: "hello" }],
|
||
});
|
||
});
|
||
|
||
it("logs HTTP body when push fails", async () => {
|
||
const err = new Error("LINE push failed") as Error & {
|
||
status: number;
|
||
statusText: string;
|
||
body: string;
|
||
};
|
||
err.status = 400;
|
||
err.statusText = "Bad Request";
|
||
err.body = "invalid flex payload";
|
||
pushMessageMock.mockRejectedValueOnce(err);
|
||
|
||
await expect(
|
||
sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }], {
|
||
cfg: LINE_TEST_CFG,
|
||
}),
|
||
).rejects.toThrow("LINE push failed");
|
||
|
||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||
"line: push message failed (400 Bad Request): invalid flex payload",
|
||
);
|
||
});
|
||
|
||
it("caches profile results by default", async () => {
|
||
getProfileMock.mockResolvedValue({
|
||
displayName: "Peter",
|
||
pictureUrl: "https://example.com/peter.jpg",
|
||
});
|
||
|
||
const first = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG });
|
||
const second = await sendModule.getUserProfile("U-cache", { cfg: LINE_TEST_CFG });
|
||
|
||
expect(first).toEqual({
|
||
displayName: "Peter",
|
||
pictureUrl: "https://example.com/peter.jpg",
|
||
});
|
||
expect(second).toEqual(first);
|
||
expect(getProfileMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("continues when loading animation is unsupported", async () => {
|
||
showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported"));
|
||
|
||
await expect(
|
||
sendModule.showLoadingAnimation("line:room:R1", { cfg: LINE_TEST_CFG }),
|
||
).resolves.toBeUndefined();
|
||
|
||
expect(logVerboseMock).toHaveBeenCalledWith(
|
||
"line: loading animation failed (non-fatal): Error: unsupported",
|
||
);
|
||
});
|
||
|
||
it("pushes quick-reply text and caps to 13 buttons", async () => {
|
||
await sendModule.pushTextMessageWithQuickReplies(
|
||
"U-quick",
|
||
"Pick one",
|
||
Array.from({ length: 20 }, (_, index) => `Choice ${index + 1}`),
|
||
{ cfg: LINE_TEST_CFG },
|
||
);
|
||
|
||
expect(pushMessageMock).toHaveBeenCalledTimes(1);
|
||
const firstCall = pushMessageMock.mock.calls.at(0) as [
|
||
{ messages: Array<{ quickReply?: { items: unknown[] } }> },
|
||
];
|
||
expect(firstCall[0].messages[0].quickReply?.items).toHaveLength(13);
|
||
});
|
||
});
|