mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-07 04:27:49 +01:00
* s3tables: route REST RenameTable and refuse unrouted S3 Tables requests
PUT /tables/<arn>/<ns>/<name>/rename had no route, so it fell through to the
/{bucket} routers and ran as a PutObject into a bucket named "tables",
answering 2xx while the table kept its name.
- Answer any s3tables-signed request no S3 Tables route takes with 404
UnknownOperationException, before the health, domain and bucket routers.
- Route the AWS RenameTable REST call (newNamespaceName, newName, both
optional; versionToken).
- Honor versionToken on RenameTable, checked against the snapshot the claim
asserts so a commit between the two reads cannot slip a stale token through.
* s3tables: match the credential scope's service, not a region named s3tables
The unrouted-request fallback widens what a false match costs: an S3
client whose region is named s3tables had every request answered as an
unknown S3 Tables operation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables: refuse an empty newName or newNamespaceName on REST rename
An empty field was read as omitted, so {"newNamespaceName":"other",
"newName":""} moved the table under its old name instead of failing
validation the way AWS does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables: check the rename version token after the snapshot comparison
Once the separately read metadata is known to equal the claimed snapshot,
the token compares against the parsed metadata; no second decode of the
snapshot bytes is needed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables test: cover policy and tag changes in the rename snapshot compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* s3tables test: give each REST rename subtest its own source table
The token subtests renamed the table the first subtest produced, so run
alone they failed on a missing table instead of testing the token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
4.1 KiB
Go
149 lines
4.1 KiB
Go
package s3api
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gorilla/mux"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
)
|
|
|
|
// TestBuildRenameTableRequest covers the AWS RenameTable REST shape: the path
|
|
// names the source and the body only the destination, either half of which
|
|
// may be omitted to keep the source's.
|
|
func TestBuildRenameTableRequest(t *testing.T) {
|
|
const encodedARN = "arn%3Aaws%3As3tables%3Aus-east-1%3A123456789012%3Abucket%2Ftest-bucket"
|
|
cases := []struct {
|
|
name string
|
|
arn string
|
|
namespace string
|
|
table string
|
|
body string
|
|
want *s3tables.RenameTableRequest
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "newName only keeps the namespace",
|
|
body: `{"newName":"t2"}`,
|
|
want: &s3tables.RenameTableRequest{DestNamespace: []string{"ns"}, DestName: "t2"},
|
|
},
|
|
{
|
|
name: "newNamespaceName only keeps the name",
|
|
body: `{"newNamespaceName":"other"}`,
|
|
want: &s3tables.RenameTableRequest{DestNamespace: []string{"other"}, DestName: "t"},
|
|
},
|
|
{
|
|
name: "both destination fields",
|
|
body: `{"newNamespaceName":"other","newName":"t2"}`,
|
|
want: &s3tables.RenameTableRequest{DestNamespace: []string{"other"}, DestName: "t2"},
|
|
},
|
|
{
|
|
name: "percent-encoded ARN is decoded",
|
|
arn: encodedARN,
|
|
body: `{"newName":"t2"}`,
|
|
want: &s3tables.RenameTableRequest{DestNamespace: []string{"ns"}, DestName: "t2"},
|
|
},
|
|
{
|
|
name: "versionToken is passed through",
|
|
body: `{"newName":"t2","versionToken":"tok-1"}`,
|
|
want: &s3tables.RenameTableRequest{DestNamespace: []string{"ns"}, DestName: "t2", VersionToken: "tok-1"},
|
|
},
|
|
{
|
|
name: "empty body",
|
|
body: "",
|
|
wantErr: "newNamespaceName or newName is required",
|
|
},
|
|
{
|
|
name: "empty object",
|
|
body: `{}`,
|
|
wantErr: "newNamespaceName or newName is required",
|
|
},
|
|
{
|
|
// The X-Amz-Target field names must not be read as the REST ones:
|
|
// a body naming only destName renames nothing it asked for.
|
|
name: "SeaweedFS-shaped body",
|
|
body: `{"destNamespace":["other"],"destName":"t2"}`,
|
|
wantErr: "newNamespaceName or newName is required",
|
|
},
|
|
{
|
|
name: "invalid newName",
|
|
body: `{"newName":"bad/name"}`,
|
|
wantErr: "invalid newName",
|
|
},
|
|
{
|
|
name: "invalid newNamespaceName",
|
|
body: `{"newNamespaceName":".."}`,
|
|
wantErr: "invalid newNamespaceName",
|
|
},
|
|
{
|
|
name: "empty newName beside a valid newNamespaceName",
|
|
body: `{"newNamespaceName":"other","newName":""}`,
|
|
wantErr: "invalid newName",
|
|
},
|
|
{
|
|
name: "empty newNamespaceName beside a valid newName",
|
|
body: `{"newNamespaceName":"","newName":"t2"}`,
|
|
wantErr: "invalid newNamespaceName",
|
|
},
|
|
{
|
|
name: "source namespace with ..",
|
|
namespace: "..",
|
|
body: `{"newName":"t2"}`,
|
|
wantErr: "invalid path parameter namespace",
|
|
},
|
|
{
|
|
name: "invalid source name",
|
|
table: "..",
|
|
body: `{"newName":"t2"}`,
|
|
wantErr: "invalid path parameter name",
|
|
},
|
|
{
|
|
name: "malformed body",
|
|
body: `{"newName":`,
|
|
wantErr: "unexpected end of JSON input",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
arn, namespace, table := testTableBucketARN, "ns", "t"
|
|
if tc.arn != "" {
|
|
arn = tc.arn
|
|
}
|
|
if tc.namespace != "" {
|
|
namespace = tc.namespace
|
|
}
|
|
if tc.table != "" {
|
|
table = tc.table
|
|
}
|
|
req := httptest.NewRequest(http.MethodPut, "/tables/x/ns/t/rename", strings.NewReader(tc.body))
|
|
req = mux.SetURLVars(req, map[string]string{"tableBucketARN": arn, "namespace": namespace, "name": table})
|
|
|
|
got, err := buildRenameTableRequest(req)
|
|
if tc.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatalf("got %+v, want an error containing %q", got, tc.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %q, want it to contain %q", err.Error(), tc.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
want := *tc.want
|
|
want.TableBucketARN = testTableBucketARN
|
|
want.SourceNamespace = []string{"ns"}
|
|
want.SourceName = "t"
|
|
if !reflect.DeepEqual(got, &want) {
|
|
t.Fatalf("got %+v, want %+v", got, &want)
|
|
}
|
|
})
|
|
}
|
|
}
|