package lance import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gorilla/mux" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables" "github.com/seaweedfs/seaweedfs/weed/s3api/s3tables/s3tablestest" ) // openAuthenticator stands in for a gateway with no IAM configured, which is the // mode the namespace falls open in. type openAuthenticator struct{} func (openAuthenticator) AuthenticateRequest(*http.Request) (string, interface{}, s3err.ErrorCode) { return s3tables.DefaultAccountID, nil, s3err.ErrNone } func (openAuthenticator) DefaultAllow() bool { return true } type testHarness struct { router *mux.Router filer *s3tablestest.MemFiler admin *s3tables.Manager server *Server } func newTestHarness(t *testing.T) *testHarness { t.Helper() filer := s3tablestest.Start(t) server := NewServer(s3tables.NewManagerClient(filer.Client), openAuthenticator{}) server.SetS3Endpoint("http://127.0.0.1:8333") server.SetS3Region(s3tables.DefaultRegion) router := mux.NewRouter().SkipClean(true) server.RegisterRoutes(router) // Table buckets are created by an operator, not by a namespace client, so // the harness seeds them the way the shell and admin console would. admin := s3tables.NewManager() admin.SetTrusted(true) return &testHarness{router: router, filer: filer, admin: admin, server: server} } // createBucket seeds a table bucket the Lance namespace can then be pointed at. // An empty format strips the declaration afterwards, which is the only way to // get the shape a bucket made before formats existed has: one that still // accepts either format. func (h *testHarness) createBucket(t *testing.T, name, format string) { t.Helper() var resp s3tables.CreateTableBucketResponse declared := format if declared == "" { declared = s3tables.FormatIceberg } err := h.admin.Execute(t.Context(), s3tables.NewManagerClient(h.filer.Client), "CreateTableBucket", &s3tables.CreateTableBucketRequest{Name: name, Format: declared}, &resp, "") if err != nil { t.Fatalf("create table bucket %s: %v", name, err) } if format == "" { h.undeclareBucket(t, name) } } // undeclareBucket removes a bucket's format from its metadata, ageing it back to // what the filer holds for a bucket created before the field existed. func (h *testHarness) undeclareBucket(t *testing.T, name string) { t.Helper() entry := h.filer.Get(s3tables.TablesPath, name) if entry == nil { t.Fatalf("table bucket %s is not in the filer", name) } var metadata map[string]any if err := json.Unmarshal(entry.Extended[s3tables.ExtendedKeyMetadata], &metadata); err != nil { t.Fatalf("read bucket metadata: %v", err) } delete(metadata, "format") updated, err := json.Marshal(metadata) if err != nil { t.Fatalf("write bucket metadata: %v", err) } extended := map[string][]byte{} for key, value := range entry.Extended { extended[key] = value } extended[s3tables.ExtendedKeyMetadata] = updated h.filer.Put(s3tables.TablesPath, name, extended) } func (h *testHarness) bucketARN(t *testing.T, name string) string { t.Helper() arn, err := s3tables.BuildBucketARN(s3tables.DefaultRegion, s3tables.DefaultAccountID, name) if err != nil { t.Fatalf("build arn: %v", err) } return arn } func (h *testHarness) do(t *testing.T, method, target, body string) *httptest.ResponseRecorder { t.Helper() var reader *strings.Reader if body == "" { reader = strings.NewReader("") } else { reader = strings.NewReader(body) } req := httptest.NewRequest(method, target, reader) recorder := httptest.NewRecorder() h.router.ServeHTTP(recorder, req) return recorder } func (h *testHarness) mustDo(t *testing.T, method, target, body string, want int) *httptest.ResponseRecorder { t.Helper() recorder := h.do(t, method, target, body) if recorder.Code != want { t.Fatalf("%s %s = %d (%s), want %d", method, target, recorder.Code, recorder.Body.String(), want) } return recorder } func decode[T any](t *testing.T, recorder *httptest.ResponseRecorder) T { t.Helper() var out T if err := json.Unmarshal(recorder.Body.Bytes(), &out); err != nil { t.Fatalf("decode %s: %v", recorder.Body.String(), err) } return out } // The lifecycle a Lance client drives: create the namespace, declare the table // before any data exists, resolve it to a location, then deregister and bring it // back by registering the same location. func TestTableLifecycle(t *testing.T) { h := newTestHarness(t) h.createBucket(t, "analytics", s3tables.FormatLance) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK) declared := decode[DeclareTableResponse](t, h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare", `{}`, http.StatusOK)) if declared.Location != "s3://analytics/sales/orders" { t.Fatalf("declared location = %q", declared.Location) } // The endpoint is plaintext, so a client that does not get allow_http fails // with what looks like a credential error. if declared.StorageOptions["aws_endpoint"] != "http://127.0.0.1:8333" || declared.StorageOptions["allow_http"] != "true" { t.Fatalf("storage options = %v", declared.StorageOptions) } described := decode[DescribeTableResponse](t, h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/describe?check_declared=true", `{}`, http.StatusOK)) if described.Location != declared.Location { t.Fatalf("described location = %q, want %q", described.Location, declared.Location) } if described.IsOnlyDeclared == nil || !*described.IsOnlyDeclared { t.Fatalf("is_only_declared = %v, want true for a table with no data", described.IsOnlyDeclared) } listed := decode[ListTablesResponse](t, h.mustDo(t, http.MethodGet, "/v1/namespace/analytics$sales/table/list", "", http.StatusOK)) if len(listed.Tables) != 1 || listed.Tables[0] != "analytics$sales$orders" { t.Fatalf("listed tables = %v, want the full identifier", listed.Tables) } h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/exists", `{}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/deregister", `{}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/exists", `{}`, http.StatusNotFound) afterDrop := decode[ListTablesResponse](t, h.mustDo(t, http.MethodGet, "/v1/namespace/analytics$sales/table/list", "", http.StatusOK)) if len(afterDrop.Tables) != 0 { t.Fatalf("deregistered table still listed: %v", afterDrop.Tables) } // Deregistering preserves the data. The catalog entry is the dataset // directory, so dropping it would take the dataset with it. if h.filer.Get(s3tables.GetNamespacePath("analytics", "sales"), "orders") == nil { t.Fatal("deregister deleted the dataset directory") } h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/register", `{"location":"s3://analytics/sales/orders"}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/exists", `{}`, http.StatusOK) // Dropping is the operation that does remove the data. h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/drop", `{}`, http.StatusOK) if h.filer.Get(s3tables.GetNamespacePath("analytics", "sales"), "orders") != nil { t.Fatal("drop left the dataset directory behind") } } // Repointing a registered name at another dataset must not take the dataset it // used to name with it. func TestRegisterOverwriteKeepsTheOldDataset(t *testing.T) { h := newTestHarness(t) h.createBucket(t, "analytics", s3tables.FormatLance) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare", `{}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$archive/declare", `{}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/register", `{"location":"s3://analytics/sales/archive","mode":"Overwrite"}`, http.StatusOK) described := decode[DescribeTableResponse](t, h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/describe", `{}`, http.StatusOK)) if described.Location != "s3://analytics/sales/archive" { t.Fatalf("location after repointing = %q", described.Location) } if h.filer.Get(s3tables.GetNamespacePath("analytics", "sales"), "orders") == nil { t.Fatal("repointing deleted the dataset the name used to hold") } } // A required list field answers empty rather than null, which a generated // client may decode differently. func TestListAllTablesIsNeverNull(t *testing.T) { h := newTestHarness(t) h.createBucket(t, "analytics", s3tables.FormatLance) body := h.mustDo(t, http.MethodGet, "/v1/table", "", http.StatusOK).Body.String() if strings.Contains(body, `"tables":null`) { t.Fatalf("ListAllTables returned null for a required field: %s", body) } } func TestNamespaceListing(t *testing.T) { h := newTestHarness(t) h.createBucket(t, "analytics", s3tables.FormatLance) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$finance/create", `{}`, http.StatusOK) roots := decode[ListNamespacesResponse](t, h.mustDo(t, http.MethodGet, "/v1/namespace/$/list", "", http.StatusOK)) if len(roots.Namespaces) != 1 || roots.Namespaces[0] != "analytics" { t.Fatalf("root listing = %v, want the table buckets", roots.Namespaces) } children := decode[ListNamespacesResponse](t, h.mustDo(t, http.MethodGet, "/v1/namespace/analytics/list", "", http.StatusOK)) if len(children.Namespaces) != 2 { t.Fatalf("bucket listing = %v, want two namespaces", children.Namespaces) } h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/exists", `{}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$nope/exists", `{}`, http.StatusNotFound) // Fail mode reports a missing namespace as 400 on this operation, which is // what the spec asks for; Skip reports success. h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$nope/drop", `{}`, http.StatusBadRequest) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$nope/drop", `{"mode":"Skip"}`, http.StatusNoContent) } // Storage flattens a namespace's parts, so creating a$b without a would leave // an intermediate that listing derives from the name and describe denies // exists. The spec asks for NamespaceNotFound, which keeps them consistent. func TestCreateNamespaceRequiresItsParent(t *testing.T) { h := newTestHarness(t) h.createBucket(t, "analytics", s3tables.FormatLance) recorder := h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$missing$child/create", `{}`, http.StatusNotFound) if got := decode[errorResponse](t, recorder); got.Code != codeNamespaceNotFound { t.Fatalf("error code = %d, want %d", got.Code, codeNamespaceNotFound) } // With the parent in place the child is fine, and both are then listed and // describable. h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$parent/create", `{}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$parent$child/create", `{}`, http.StatusOK) listed := decode[ListNamespacesResponse](t, h.mustDo(t, http.MethodGet, "/v1/namespace/analytics/list", "", http.StatusOK)) for _, name := range listed.Namespaces { path := "/v1/namespace/analytics$" + name h.mustDo(t, http.MethodPost, path+"/exists", `{}`, http.StatusOK) } } func TestCreateNamespaceModes(t *testing.T) { h := newTestHarness(t) h.createBucket(t, "analytics", s3tables.FormatLance) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusConflict) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{"mode":"ExistOk"}`, http.StatusOK) // snake_case is the other spelling the spec accepts. h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{"mode":"exist_ok"}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/namespace/$/create", `{}`, http.StatusBadRequest) } // A Lance client must never resolve an Iceberg table's location, or it would // write a dataset over a table another engine owns. func TestIcebergTablesAreInvisible(t *testing.T) { h := newTestHarness(t) // Undeclared, because a bucket that declares one format cannot hold the // other - and mixing is exactly what this test needs to prove is hidden. h.createBucket(t, "analytics", "") h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK) h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$vectors/declare", `{}`, http.StatusOK) manager := h.admin var created s3tables.CreateTableResponse err := manager.Execute(t.Context(), s3tables.NewManagerClient(h.filer.Client), "CreateTable", &s3tables.CreateTableRequest{ TableBucketARN: h.bucketARN(t, "analytics"), Namespace: []string{"sales"}, Name: "ledger", Format: s3tables.FormatIceberg, MetadataLocation: "s3://analytics/sales/ledger/metadata/v1.metadata.json", }, &created, "") if err != nil { t.Fatalf("create iceberg table: %v", err) } listed := decode[ListTablesResponse](t, h.mustDo(t, http.MethodGet, "/v1/namespace/analytics$sales/table/list", "", http.StatusOK)) if len(listed.Tables) != 1 || listed.Tables[0] != "analytics$sales$vectors" { t.Fatalf("listing = %v, want only the lance table", listed.Tables) } h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$ledger/describe", `{}`, http.StatusNotFound) h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$ledger/exists", `{}`, http.StatusNotFound) // Declaring over the Iceberg table must not quietly succeed and hand the // Lance client a directory another format owns. recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$ledger/declare", `{}`, http.StatusConflict) if got := decode[errorResponse](t, recorder); got.Code != codeTableAlreadyExists { t.Fatalf("error code = %d, want %d", got.Code, codeTableAlreadyExists) } } // The route and the body naming different objects is a bad request, not a silent // preference for one of them. func TestRouteAndBodyMustAgree(t *testing.T) { h := newTestHarness(t) h.createBucket(t, "analytics", s3tables.FormatLance) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK) recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare", `{"id":["analytics","sales","other"]}`, http.StatusBadRequest) if got := decode[errorResponse](t, recorder); got.Code != codeInvalidInput { t.Fatalf("error code = %d, want %d", got.Code, codeInvalidInput) } } // A client-supplied location must resolve inside its own bucket. Without this, // a "../"-laden or cross-bucket location escapes when the marker path is joined // under /buckets, letting the request reach another tenant's namespace, or // outside /buckets entirely, and auto-create the parent directories on the way. func TestLocationOutsideBucketIsRejected(t *testing.T) { cases := []struct { name string location string }{ {"another bucket", "s3://victim/secret"}, {"traversal into another bucket", "s3://analytics/../victim/secret"}, {"traversal above the buckets root", "s3://analytics/../../etc/cron.d"}, {"not an s3 uri", "../../etc/cron.d"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { dir := s3tables.TableDataDirFromMetadataLocation(tc.location) body := `{"location":"` + tc.location + `"}` // declare writes .lance-reserved at the location: rejection must leave // nothing behind at the escaped directory. t.Run("declare", func(t *testing.T) { h := newTestHarness(t) h.createBucket(t, "analytics", s3tables.FormatLance) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK) recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/declare", body, http.StatusBadRequest) if got := decode[errorResponse](t, recorder); got.Code != codeInvalidInput { t.Fatalf("error code = %d, want %d", got.Code, codeInvalidInput) } if dir != "" && h.filer.Get(dir, reservedMarker) != nil { t.Fatalf("declare wrote %s into %s despite rejection", reservedMarker, dir) } }) // register removes .lance-deregistered at the location: rejection must // leave a victim's marker in place rather than un-hiding their table. t.Run("register", func(t *testing.T) { h := newTestHarness(t) h.createBucket(t, "analytics", s3tables.FormatLance) h.mustDo(t, http.MethodPost, "/v1/namespace/analytics$sales/create", `{}`, http.StatusOK) if dir != "" { h.filer.PutFile(dir, deregisteredMarker, time.Unix(1, 0)) } recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/register", body, http.StatusBadRequest) if got := decode[errorResponse](t, recorder); got.Code != codeInvalidInput { t.Fatalf("error code = %d, want %d", got.Code, codeInvalidInput) } if dir != "" && h.filer.Get(dir, deregisteredMarker) == nil { t.Fatalf("register removed %s from %s despite rejection", deregisteredMarker, dir) } }) }) } } // The data plane needs Lance format support that does not exist in Go, so it // answers with the spec's own Unsupported code rather than a bare 404. func TestDataPlaneIsUnsupported(t *testing.T) { h := newTestHarness(t) recorder := h.mustDo(t, http.MethodPost, "/v1/table/analytics$sales$orders/query", `{}`, http.StatusNotImplemented) if got := decode[errorResponse](t, recorder); got.Code != codeUnsupported { t.Fatalf("error code = %d, want %d", got.Code, codeUnsupported) } }