fix(aitable): harden pagination and upload inputs

This commit is contained in:
Dennis
2026-08-07 15:50:35 +08:00
parent 176b217139
commit 48e522ec00
6 changed files with 82 additions and 3 deletions
@@ -21,7 +21,14 @@ import (
const maxAttachmentFileSize = int64(100 * 1024 * 1024)
var attachmentHTTPDo = http.DefaultClient.Do
func rejectAttachmentUploadRedirect(*http.Request, []*http.Request) error {
// Upload credentials are issued for one exact endpoint. Do not let a 3xx
// response redirect local file bytes or the credential to another host or
// to a URL outside validateAttachmentUploadURL's transport policy.
return http.ErrUseLastResponse
}
var attachmentHTTPDo = (&http.Client{CheckRedirect: rejectAttachmentUploadRedirect}).Do
var AttachmentPut = shortcut.Shortcut{
Service: "aitable",
@@ -112,6 +112,35 @@ func TestCrossPlatformCoverageAttachmentPutFailuresDoNotBecomeSuccessE2E(t *test
})
}
func TestCrossPlatformCoverageAttachmentPutRejectsHTTPSRedirectToPlaintextNonLoopbackE2E(t *testing.T) {
filePath := writeAttachmentFixture(t, "redirect-sensitive bytes")
sourceHits := 0
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
sourceHits++
w.Header().Set("Location", "http://example.com/upload")
w.WriteHeader(http.StatusFound)
}))
defer server.Close()
client := server.Client()
client.CheckRedirect = rejectAttachmentUploadRedirect
testseam.Swap(t, &attachmentHTTPDo, client.Do)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{})},
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL + "/put", "fileToken": "ft-redirect"})},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" ||
typed.Cause == nil || !strings.Contains(typed.Cause.Error(), "status 302") {
t.Fatalf("redirect upload = output:%q err:%v", out, err)
}
if sourceHits != 1 || len(caller.calls) != 2 {
t.Fatalf("redirect upload reached an unexpected target: sourceHits=%d calls=%#v", sourceHits, caller.calls)
}
}
func TestCrossPlatformCoverageAttachmentRemoveClearAndSelectiveE2E(t *testing.T) {
t.Run("clear empty write response recovered by empty read-back", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
@@ -324,6 +324,24 @@ func TestCrossPlatformCoverageRecordUpsertJSONScalarSuccessE2E(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecordUpsertRejectsTrailingJSONBeforeMCPE2E(t *testing.T) {
for name, value := range map[string]string{
"trailing garbage": `1 garbage`,
"second JSON value": `1 2`,
"second string value": `true "extra"`,
} {
t.Run(name, func(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+record-upsert-by-key",
"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--key-value-json", value,
"--cells", `{"value":1}`, "--yes")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("trailing JSON = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
}
func TestCrossPlatformCoverageRecordShapeHelpers(t *testing.T) {
if records, ok := findRecords(nil); ok || records != nil {
t.Fatalf("nil records = %#v, %v", records, ok)
@@ -343,7 +361,8 @@ func TestCrossPlatformCoverageRecordShapeHelpers(t *testing.T) {
if err := verifyRecordCells(map[string]any{"recordId": "r"}, map[string]any{"f": 1}); err == nil {
t.Fatal("missing cells must fail")
}
if responseCursor(nil) != "" || responseCursor(map[string]any{"data": map[string]any{"next_cursor": " next "}}) != "next" {
if responseCursor(nil) != "" || responseCursor(map[string]any{"data": map[string]any{"next_cursor": " next "}}) != "next" ||
responseCursor(map[string]any{"result": map[string]any{"cursor": " legacy "}}) != "legacy" {
t.Fatal("responseCursor shape mismatch")
}
}
@@ -54,7 +54,7 @@ func responseCursor(data map[string]any) string {
if data == nil {
return ""
}
for _, key := range []string{"nextCursor", "next_cursor"} {
for _, key := range []string{"nextCursor", "next_cursor", "cursor"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
@@ -6,6 +6,7 @@ package aitable
import (
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
"strings"
@@ -191,6 +192,10 @@ func recordKeyValue(rt *shortcut.RuntimeContext) (any, error) {
if err := decoder.Decode(&value); err != nil {
return nil, apperrors.NewValidation("--key-value-json 不是合法 JSON: " + err.Error())
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return nil, apperrors.NewValidation("--key-value-json 必须只包含一个 JSON 标量")
}
if value == nil || reflect.ValueOf(value).Kind() == reflect.Map || reflect.ValueOf(value).Kind() == reflect.Slice {
return nil, apperrors.NewValidation("--key-value-json 只接受 string/number/bool 标量")
}
@@ -41,6 +41,25 @@ func TestCrossPlatformCoverageRecordBulkPatchPaginatesChunksAndVerifiesE2E(t *te
}
}
func TestCrossPlatformCoverageRecordBulkPatchPaginatesNestedCursorShapeE2E(t *testing.T) {
records := updateFixtureRecords(0, 2, "旧")
patched := updateFixtureRecords(0, 2, "新")
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: mustJSONText(t, map[string]any{"data": map[string]any{"records": records[:1], "hasMore": true, "cursor": "legacy-next"}})},
{text: mustJSONText(t, map[string]any{"data": map[string]any{"records": records[1:], "hasMore": false}})},
{text: `{"updatedCount":2}`},
{text: recordListJSON(t, patched)},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"fldStatus":"新"}`, "--yes")
if err != nil || !strings.Contains(out, `"verifiedCount": 2`) {
t.Fatalf("nested cursor bulk patch = output:%q err:%v", out, err)
}
if len(caller.calls) != 4 || caller.calls[1].args["cursor"] != "legacy-next" || caller.calls[2].tool != "update_records" {
t.Fatalf("nested cursor calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageRecordBulkPatchSelectionBoundsAndEmptyE2E(t *testing.T) {
t.Run("explicit no matches is success", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[]}`}}}