feat: validate IM idempotency key length (#1797)

Previously, keys longer than the OpenAPI uuid limit were sent to the server and returned a generic field validation failed error. This change rejects overlong keys locally with a typed validation error that identifies
--idempotency-key and the 50-character limit.
This commit is contained in:
91-enjoy
2026-07-13 20:46:51 +08:00
committed by GitHub
parent fc8d212a4f
commit 21bfa84edd
5 changed files with 99 additions and 4 deletions

View File

@@ -504,6 +504,84 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("validateIdempotencyKey empty string passes", func(t *testing.T) {
if err := validateIdempotencyKey(""); err != nil {
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
}
})
t.Run("validateIdempotencyKey 50 chars passes", func(t *testing.T) {
if err := validateIdempotencyKey(strings.Repeat("a", 50)); err != nil {
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
}
})
t.Run("validateIdempotencyKey 51 chars fails", func(t *testing.T) {
err := validateIdempotencyKey(strings.Repeat("a", 51))
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
t.Fatalf("validateIdempotencyKey() error = %v", err)
}
})
t.Run("validateIdempotencyKey 50 Chinese chars passes", func(t *testing.T) {
if err := validateIdempotencyKey(strings.Repeat("中", 50)); err != nil {
t.Fatalf("validateIdempotencyKey() unexpected error = %v", err)
}
})
t.Run("validateIdempotencyKey 51 Chinese chars fails", func(t *testing.T) {
err := validateIdempotencyKey(strings.Repeat("中", 51))
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
t.Fatalf("validateIdempotencyKey() error = %v", err)
}
})
t.Run("ImMessagesSend idempotency key too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
"idempotency-key": strings.Repeat("a", 51),
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend idempotency key valid", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
"idempotency-key": "my-key-001",
}, nil)
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err)
}
})
t.Run("ImMessagesReply idempotency key too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "om_123",
"text": "hello",
"idempotency-key": strings.Repeat("b", 51),
}, nil)
err := ImMessagesReply.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--idempotency-key exceeds the maximum of 50 characters") {
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
}
})
t.Run("ImMessagesReply idempotency key valid", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "om_123",
"text": "hello",
"idempotency-key": "reply-key-001",
}, nil)
if err := ImMessagesReply.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesReply.Validate() unexpected error = %v", err)
}
})
t.Run("ImMessagesReply invalid message id", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "bad_id",

View File

@@ -35,7 +35,7 @@ var ImMessagesReply = common.Shortcut{
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: audioMessageInputDesc},
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
{Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
@@ -85,6 +85,7 @@ var ImMessagesReply = common.Shortcut{
content := runtime.Str("content")
text := runtime.Str("text")
markdown := runtime.Str("markdown")
idempotencyKey := runtime.Str("idempotency-key")
imageKey := runtime.Str("image")
fileKey := runtime.Str("file")
videoKey := runtime.Str("video")
@@ -114,6 +115,9 @@ var ImMessagesReply = common.Shortcut{
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
}
if err := validateIdempotencyKey(idempotencyKey); err != nil {
return err
}
if content != "" && !json.Valid([]byte(content)) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
}

View File

@@ -32,7 +32,7 @@ var ImMessagesSend = common.Shortcut{
{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
{Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"},
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
@@ -97,6 +97,7 @@ var ImMessagesSend = common.Shortcut{
content := runtime.Str("content")
text := runtime.Str("text")
markdown := runtime.Str("markdown")
idempotencyKey := runtime.Str("idempotency-key")
imageKey := runtime.Str("image")
fileKey := runtime.Str("file")
videoKey := runtime.Str("video")
@@ -135,6 +136,9 @@ var ImMessagesSend = common.Shortcut{
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, msg)
}
if err := validateIdempotencyKey(idempotencyKey); err != nil {
return err
}
if content != "" && !json.Valid([]byte(content)) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
}
@@ -211,6 +215,15 @@ var ImMessagesSend = common.Shortcut{
},
}
const maxIdempotencyKeyChars = 50
func validateIdempotencyKey(value string) error {
if chars := len([]rune(value)); chars > maxIdempotencyKeyChars {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotency-key exceeds the maximum of %d characters (got %d)", maxIdempotencyKeyChars, chars).WithParam("--idempotency-key")
}
return nil
}
// isMediaKey returns true if the value looks like an existing API key rather than a local file path.
func isMediaKey(value string) bool {
return strings.HasPrefix(value, "img_") || strings.HasPrefix(value, "file_")

View File

@@ -188,7 +188,7 @@ lark-cli im +messages-reply --message-id om_xxx --msg-type interactive --content
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`) |
| `--audio <path\|url\|key>` | One content option | Voice-message audio key, URL, or cwd-relative local path. Local paths and URLs must be Opus (`.opus` or Ogg Opus `.ogg`) |
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
| `--idempotency-key <key>` | No | Idempotency key, max 50 characters; the same key sends only one reply within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
| `--dry-run` | No | Print the request only, do not execute it |

View File

@@ -191,7 +191,7 @@ lark-cli im +messages-send --chat-id oc_xxx --msg-type interactive --content '<c
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
| `--audio <path\|url\|key>` | One content option | Voice-message audio key, URL, or cwd-relative local path. Local paths and URLs must be Opus (`.opus` or Ogg Opus `.ogg`) |
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
| `--idempotency-key <key>` | No | Idempotency key, max 50 characters; the same key sends only one message within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
| `--dry-run` | No | Print the request only, do not execute it |