Files
seaweedfs/test/s3tables/table-buckets/rename_rest_test.go
T
f71c0fe523 fix(s3tables): route REST RenameTable and refuse unrouted S3 Tables requests (#12446)
* 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>
2026-11-06 09:32:13 -08:00

117 lines
4.6 KiB
Go

package s3tables
import (
"context"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
// emptyObjectBucket deletes every object in bucket.
func emptyObjectBucket(t *testing.T, ctx context.Context, client *s3.Client, bucket string) {
t.Helper()
pages := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{Bucket: aws.String(bucket)})
for pages.HasMorePages() {
page, err := pages.NextPage(ctx)
require.NoError(t, err)
for _, o := range page.Contents {
_, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(bucket), Key: o.Key})
require.NoError(t, err)
}
}
}
// The AWS SDK sends RenameTable as PUT /tables/<arn>/<ns>/<name>/rename. With
// no route for it the gateway ran it as a PutObject into an S3 bucket named
// "tables" and answered 2xx, so the rename reported success while the table
// kept its old name. The bucket is created up front so that failure shows as
// an object landing in it.
func TestRenameTableREST(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ctx := context.Background()
tables := NewS3TablesClient(sharedCluster.s3Endpoint, testRegion, testAccessKey, testSecretKey)
objects := newObjectClient(t)
const decoyBucket = "tables"
if _, err := objects.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(decoyBucket)}); err != nil {
require.Equal(t, "BucketAlreadyOwnedByYou", apiErrorCode(err), "create the %q bucket: %v", decoyBucket, err)
}
emptyObjectBucket(t, ctx, objects, decoyBucket)
defer func() {
emptyObjectBucket(t, ctx, objects, decoyBucket)
_, _ = objects.DeleteBucket(ctx, &s3.DeleteBucketInput{Bucket: aws.String(decoyBucket)})
}()
created, err := tables.CreateTableBucket("rename-rest-"+randomString(8), nil)
require.NoError(t, err)
defer tables.DeleteTableBucket(created.ARN)
bucketARN := created.ARN
const namespace, otherNamespace = "rename_ns", "rename_other"
for _, ns := range []string{namespace, otherNamespace} {
_, err := tables.CreateNamespace(bucketARN, []string{ns})
require.NoError(t, err)
defer tables.DeleteNamespace(bucketARN, []string{ns})
}
// Each subtest owns its source table, so any one of them runs alone.
createSource := func(t *testing.T, name string) string {
resp, err := tables.CreateTable(bucketARN, []string{namespace}, name, "ICEBERG", nil, nil)
require.NoError(t, err)
return resp.VersionToken
}
t.Run("newName only", func(t *testing.T) {
createSource(t, "src")
defer tables.DeleteTable(bucketARN, []string{namespace}, "src")
defer tables.DeleteTable(bucketARN, []string{namespace}, "dst")
require.NoError(t, tables.RenameTable(bucketARN, []string{namespace}, "src", "", "dst", ""))
got, err := tables.GetTable(bucketARN, []string{namespace}, "dst")
require.NoError(t, err, "the renamed table must resolve under its new name")
assert.Equal(t, "dst", got.Name)
_, err = tables.GetTable(bucketARN, []string{namespace}, "src")
require.Error(t, err)
assert.Contains(t, err.Error(), s3tables.ErrCodeNoSuchTable)
list, err := objects.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: aws.String(decoyBucket)})
require.NoError(t, err)
assert.Empty(t, list.Contents, "the rename must not store anything in the %q bucket", decoyBucket)
})
t.Run("stale versionToken", func(t *testing.T) {
token := createSource(t, "stale_src")
defer tables.DeleteTable(bucketARN, []string{namespace}, "stale_src")
defer tables.DeleteTable(bucketARN, []string{otherNamespace}, "stale_moved")
err := tables.RenameTable(bucketARN, []string{namespace}, "stale_src", otherNamespace, "stale_moved", token+"-stale")
require.Error(t, err)
assert.Contains(t, err.Error(), s3tables.ErrCodeConflict)
_, err = tables.GetTable(bucketARN, []string{namespace}, "stale_src")
require.NoError(t, err, "a refused rename must leave the source in place")
})
t.Run("newNamespaceName and newName with the current versionToken", func(t *testing.T) {
token := createSource(t, "tok_src")
defer tables.DeleteTable(bucketARN, []string{namespace}, "tok_src")
defer tables.DeleteTable(bucketARN, []string{otherNamespace}, "moved")
require.NoError(t, tables.RenameTable(bucketARN, []string{namespace}, "tok_src", otherNamespace, "moved", token))
got, err := tables.GetTable(bucketARN, []string{otherNamespace}, "moved")
require.NoError(t, err)
assert.Equal(t, "moved", got.Name)
_, err = tables.GetTable(bucketARN, []string{namespace}, "tok_src")
require.Error(t, err)
assert.Contains(t, err.Error(), s3tables.ErrCodeNoSuchTable)
})
}