From 4d878700a7588fca716423e0728f2b25d12b9fa2 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 21 Nov 2025 17:05:59 +0200 Subject: [PATCH 01/11] feat(storage): install iceberg-js and add getCatalog --- package-lock.json | 10 ++ packages/core/storage-js/package.json | 1 + .../src/packages/StorageAnalyticsClient.ts | 91 +++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/package-lock.json b/package-lock.json index cbd579a63..1ff108d24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17800,6 +17800,15 @@ "node": ">=10.18" } }, + "node_modules/iceberg-js": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.7.0.tgz", + "integrity": "sha512-7VZYpt4OiOYh3w7yv04lAd8AjmAZAMamP7I83Ml/zDT4lGd7Lnb5ibrJ45euIgx0Qb4DEkud6+4OTq+IS/Wf0g==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -33223,6 +33232,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { + "iceberg-js": "^0.7.0", "tslib": "2.8.1" }, "devDependencies": { diff --git a/packages/core/storage-js/package.json b/packages/core/storage-js/package.json index 31d882c57..c5229d42b 100644 --- a/packages/core/storage-js/package.json +++ b/packages/core/storage-js/package.json @@ -37,6 +37,7 @@ "docs:json": "typedoc --json docs/v2/spec.json --entryPoints src/index.ts --entryPoints src/packages/* --excludePrivate --excludeExternals --excludeProtected" }, "dependencies": { + "iceberg-js": "^0.7.0", "tslib": "2.8.1" }, "devDependencies": { diff --git a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts index 2120e21a5..27c9099fc 100644 --- a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts +++ b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts @@ -1,3 +1,4 @@ +import { IcebergRestCatalog } from 'iceberg-js' import { DEFAULT_HEADERS } from '../lib/constants' import { isStorageError, StorageError } from '../lib/errors' import { Fetch, get, post, remove } from '../lib/fetch' @@ -261,4 +262,94 @@ export default class StorageAnalyticsClient { throw error } } + + /** + * @alpha + * + * Get an Iceberg REST Catalog client configured for a specific analytics bucket + * Use this to perform advanced table and namespace operations within the bucket + * The returned client provides full access to the Apache Iceberg REST Catalog API + * + * **Public alpha:** This API is part of a public alpha release and may not be available to your account type. + * + * @category Analytics Buckets + * @param bucketName - The name of the analytics bucket (warehouse) to connect to + * @returns Configured IcebergRestCatalog instance for advanced Iceberg operations + * + * @example Get catalog and create table + * ```js + * // First, create an analytics bucket + * const { data: bucket, error: bucketError } = await supabase + * .storage + * .analytics + * .createBucket('analytics-data') + * + * // Get the Iceberg catalog for that bucket + * const catalog = supabase.storage.analytics.getCatalog('analytics-data') + * + * // Create a namespace + * await catalog.createNamespace({ namespace: ['default'] }) + * + * // Create a table with schema + * await catalog.createTable( + * { namespace: ['default'] }, + * { + * name: 'events', + * schema: { + * type: 'struct', + * fields: [ + * { id: 1, name: 'id', type: 'long', required: true }, + * { id: 2, name: 'timestamp', type: 'timestamp', required: true }, + * { id: 3, name: 'user_id', type: 'string', required: false } + * ], + * 'schema-id': 0, + * 'identifier-field-ids': [1] + * }, + * 'partition-spec': { + * 'spec-id': 0, + * fields: [] + * }, + * 'write-order': { + * 'order-id': 0, + * fields: [] + * }, + * properties: { + * 'write.format.default': 'parquet' + * } + * } + * ) + * ``` + * + * @example List tables in namespace + * ```js + * const catalog = supabase.storage.analytics.getCatalog('analytics-data') + * + * // List all tables in the default namespace + * const tables = await catalog.listTables({ namespace: ['default'] }) + * console.log(tables) // [{ namespace: ['default'], name: 'events' }] + * ``` + * + * @remarks + * This method provides a bridge between Supabase's bucket management and the standard + * Apache Iceberg REST Catalog API. The bucket name maps to the Iceberg warehouse parameter. + * All authentication and configuration is handled automatically using your Supabase credentials. + * + * For advanced Iceberg operations beyond bucket management, you can also install and use + * the `iceberg-js` package directly with manual configuration. + */ + getCatalog(bucketName: string): IcebergRestCatalog { + // Construct the Iceberg REST Catalog URL + // The base URL is /storage/v1/iceberg, we need /storage/v1/iceberg/v1 for the catalog API + const catalogUrl = `${this.url}/v1` + + return new IcebergRestCatalog({ + baseUrl: catalogUrl, + catalogName: bucketName, // Maps to the warehouse parameter in Supabase's implementation + auth: { + type: 'custom', + getHeaders: async () => this.headers, + }, + fetch: this.fetch, + }) + } } From 6d9db1543a36809356cd49581c24ada1fe36e4e4 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 21 Nov 2025 17:12:26 +0200 Subject: [PATCH 02/11] test(storage): iceberg integration tests --- .../core/storage-js/infra/docker-compose.yml | 55 ++++ .../test/analytics-catalog-api.spec.ts | 300 ++++++++++++++++++ packages/core/storage-js/test/helpers.ts | 13 + 3 files changed, 368 insertions(+) create mode 100644 packages/core/storage-js/test/analytics-catalog-api.spec.ts diff --git a/packages/core/storage-js/infra/docker-compose.yml b/packages/core/storage-js/infra/docker-compose.yml index dc3801638..e3a512658 100644 --- a/packages/core/storage-js/infra/docker-compose.yml +++ b/packages/core/storage-js/infra/docker-compose.yml @@ -81,5 +81,60 @@ services: - IMGPROXY_LOCAL_FILESYSTEM_ROOT=/ - IMGPROXY_USE_ETAG=true - IMGPROXY_ENABLE_WEBP_DETECTION=true + + # Iceberg REST Catalog services for analytics buckets testing + minio: + image: minio/minio + container_name: supabase-iceberg-minio + ports: + - '9000:9000' + - '9001:9001' + networks: + default: + aliases: + - warehouse--table-s3.minio + healthcheck: + test: timeout 5s bash -c ':> /dev/tcp/127.0.0.1/9000' || exit 1 + interval: 5s + timeout: 20s + retries: 10 + environment: + MINIO_ROOT_USER: supa-storage + MINIO_ROOT_PASSWORD: secret1234 + MINIO_DOMAIN: minio + command: server --console-address ":9001" /data + volumes: + - minio-data:/data + + minio-setup: + image: minio/mc + container_name: supabase-iceberg-minio-setup + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set supa-minio http://minio:9000 supa-storage secret1234; + /usr/bin/mc mb supa-minio/warehouse--table-s3; + /usr/bin/mc policy set public supa-minio/warehouse--table-s3; + exit 0; + " + + iceberg-rest: + image: tabulario/iceberg-rest + container_name: supabase-iceberg-rest + depends_on: + - minio-setup + ports: + - 8181:8181 + environment: + - AWS_ACCESS_KEY_ID=supa-storage + - AWS_SECRET_ACCESS_KEY=secret1234 + - AWS_REGION=us-east-1 + - CATALOG_WAREHOUSE=s3://warehouse--table-s3/ + - CATALOG_IO__IMPL=org.apache.iceberg.aws.s3.S3FileIO + - CATALOG_S3_ENDPOINT=http://minio:9000 + volumes: assets-volume: + minio-data: diff --git a/packages/core/storage-js/test/analytics-catalog-api.spec.ts b/packages/core/storage-js/test/analytics-catalog-api.spec.ts new file mode 100644 index 000000000..7d53fd84e --- /dev/null +++ b/packages/core/storage-js/test/analytics-catalog-api.spec.ts @@ -0,0 +1,300 @@ +/** + * Integration tests for StorageAnalyticsClient.getCatalog() + * Tests the integration with Iceberg REST Catalog for analytics buckets + * + * These tests require Docker infrastructure to be running (via test:infra script) + * - Iceberg REST Catalog at http://localhost:8181 + * - MinIO for S3-compatible storage backend + */ + +import { IcebergError } from 'iceberg-js' +import { createAnalyticsTestClient, generateTestName } from './helpers' + +describe('Analytics Catalog API Integration Tests', () => { + const client = createAnalyticsTestClient() + let testBucketName: string + + beforeEach(() => { + // Generate unique bucket name for this test + testBucketName = generateTestName('test-warehouse') + }) + + describe('getCatalog', () => { + it('should return a configured IcebergRestCatalog instance', () => { + const catalog = client.getCatalog(testBucketName) + expect(catalog).toBeDefined() + expect(typeof catalog.listNamespaces).toBe('function') + expect(typeof catalog.createNamespace).toBe('function') + expect(typeof catalog.createTable).toBe('function') + }) + + it('should support listing namespaces', async () => { + const catalog = client.getCatalog(testBucketName) + const namespaces = await catalog.listNamespaces() + expect(Array.isArray(namespaces)).toBe(true) + }) + }) + + describe('Iceberg operations via getCatalog', () => { + const testNamespace = 'test' + const testTableName = 'users' + + beforeEach(async () => { + const catalog = client.getCatalog(testBucketName) + + // Cleanup: Drop test resources if they exist (to make tests idempotent) + try { + await catalog.dropTable({ namespace: [testNamespace], name: testTableName }) + } catch (error) { + if (!(error instanceof IcebergError && error.status === 404)) { + throw error + } + } + + try { + await catalog.dropNamespace({ namespace: [testNamespace] }) + } catch (error) { + if (!(error instanceof IcebergError && error.status === 404)) { + throw error + } + } + }) + + afterEach(async () => { + const catalog = client.getCatalog(testBucketName) + + // Cleanup after test + try { + await catalog.dropTable({ namespace: [testNamespace], name: testTableName }) + } catch (error) { + // Ignore cleanup errors + } + + try { + await catalog.dropNamespace({ namespace: [testNamespace] }) + } catch (error) { + // Ignore cleanup errors + } + }) + + it('should create and list namespaces', async () => { + const catalog = client.getCatalog(testBucketName) + + // Create namespace + await catalog.createNamespace( + { namespace: [testNamespace] }, + { properties: { owner: 'storage-js-test' } } + ) + + // List namespaces + const namespaces = await catalog.listNamespaces() + const createdNamespace = namespaces.find( + (ns) => ns.namespace.length === 1 && ns.namespace[0] === testNamespace + ) + expect(createdNamespace).toBeDefined() + }) + + it('should load namespace metadata', async () => { + const catalog = client.getCatalog(testBucketName) + + // Create namespace with properties + await catalog.createNamespace( + { namespace: [testNamespace] }, + { properties: { owner: 'storage-js-test', description: 'Test namespace' } } + ) + + // Load metadata + const metadata = await catalog.loadNamespaceMetadata({ namespace: [testNamespace] }) + expect(metadata.properties).toBeDefined() + expect(metadata.properties?.owner).toBe('storage-js-test') + }) + + it('should create and list tables', async () => { + const catalog = client.getCatalog(testBucketName) + + // Create namespace first + await catalog.createNamespace({ namespace: [testNamespace] }) + + // Create table with schema + const tableMetadata = await catalog.createTable( + { namespace: [testNamespace] }, + { + name: testTableName, + schema: { + type: 'struct', + fields: [ + { id: 1, name: 'id', type: 'long', required: true }, + { id: 2, name: 'name', type: 'string', required: true }, + { id: 3, name: 'email', type: 'string', required: false }, + ], + 'schema-id': 0, + 'identifier-field-ids': [1], + }, + 'partition-spec': { + 'spec-id': 0, + fields: [], + }, + 'write-order': { + 'order-id': 0, + fields: [], + }, + properties: { + 'write.format.default': 'parquet', + }, + } + ) + + expect(tableMetadata.location).toBeDefined() + expect(tableMetadata['table-uuid']).toBeDefined() + + // List tables in namespace + const tables = await catalog.listTables({ namespace: [testNamespace] }) + const createdTable = tables.find( + (t) => t.namespace[0] === testNamespace && t.name === testTableName + ) + expect(createdTable).toBeDefined() + }) + + it('should load table metadata', async () => { + const catalog = client.getCatalog(testBucketName) + + // Create namespace and table + await catalog.createNamespace({ namespace: [testNamespace] }) + await catalog.createTable( + { namespace: [testNamespace] }, + { + name: testTableName, + schema: { + type: 'struct', + fields: [ + { id: 1, name: 'id', type: 'long', required: true }, + { id: 2, name: 'name', type: 'string', required: true }, + ], + 'schema-id': 0, + 'identifier-field-ids': [1], + }, + 'partition-spec': { 'spec-id': 0, fields: [] }, + 'write-order': { 'order-id': 0, fields: [] }, + } + ) + + // Load table metadata + const loadedTable = await catalog.loadTable({ + namespace: [testNamespace], + name: testTableName, + }) + + expect(loadedTable['table-uuid']).toBeDefined() + expect(loadedTable.location).toBeDefined() + expect(loadedTable.schemas).toBeDefined() + expect(Array.isArray(loadedTable.schemas)).toBe(true) + expect(loadedTable.schemas.length).toBeGreaterThan(0) + }) + + it('should update table properties', async () => { + const catalog = client.getCatalog(testBucketName) + + // Create namespace and table + await catalog.createNamespace({ namespace: [testNamespace] }) + await catalog.createTable( + { namespace: [testNamespace] }, + { + name: testTableName, + schema: { + type: 'struct', + fields: [{ id: 1, name: 'id', type: 'long', required: true }], + 'schema-id': 0, + 'identifier-field-ids': [1], + }, + 'partition-spec': { 'spec-id': 0, fields: [] }, + 'write-order': { 'order-id': 0, fields: [] }, + } + ) + + // Update table properties + const updatedTable = await catalog.updateTable( + { namespace: [testNamespace], name: testTableName }, + { + properties: { + 'read.split.target-size': '134217728', + 'write.parquet.compression-codec': 'snappy', + }, + } + ) + + expect(updatedTable['table-uuid']).toBeDefined() + expect(updatedTable.properties).toBeDefined() + }) + + it('should drop tables', async () => { + const catalog = client.getCatalog(testBucketName) + + // Create namespace and table + await catalog.createNamespace({ namespace: [testNamespace] }) + await catalog.createTable( + { namespace: [testNamespace] }, + { + name: testTableName, + schema: { + type: 'struct', + fields: [{ id: 1, name: 'id', type: 'long', required: true }], + 'schema-id': 0, + 'identifier-field-ids': [1], + }, + 'partition-spec': { 'spec-id': 0, fields: [] }, + 'write-order': { 'order-id': 0, fields: [] }, + } + ) + + // Drop the table + await catalog.dropTable({ namespace: [testNamespace], name: testTableName }) + + // Verify table is gone + const tables = await catalog.listTables({ namespace: [testNamespace] }) + const foundTable = tables.find( + (t) => t.namespace[0] === testNamespace && t.name === testTableName + ) + expect(foundTable).toBeUndefined() + }) + + it('should drop namespaces', async () => { + const catalog = client.getCatalog(testBucketName) + + // Create namespace + await catalog.createNamespace({ namespace: [testNamespace] }) + + // Drop the namespace + await catalog.dropNamespace({ namespace: [testNamespace] }) + + // Try to load the namespace (should fail with 404) + await expect(catalog.loadNamespaceMetadata({ namespace: [testNamespace] })).rejects.toThrow() + }) + + it('should handle errors gracefully', async () => { + const catalog = client.getCatalog(testBucketName) + + // Try to load non-existent table + await expect( + catalog.loadTable({ namespace: ['nonexistent'], name: 'nonexistent' }) + ).rejects.toThrow(IcebergError) + + // Try to create table in non-existent namespace + await expect( + catalog.createTable( + { namespace: ['nonexistent'] }, + { + name: 'test', + schema: { + type: 'struct', + fields: [{ id: 1, name: 'id', type: 'long', required: true }], + 'schema-id': 0, + 'identifier-field-ids': [1], + }, + 'partition-spec': { 'spec-id': 0, fields: [] }, + 'write-order': { 'order-id': 0, fields: [] }, + } + ) + ).rejects.toThrow(IcebergError) + }) + }) +}) diff --git a/packages/core/storage-js/test/helpers.ts b/packages/core/storage-js/test/helpers.ts index 901e9c94a..963950682 100644 --- a/packages/core/storage-js/test/helpers.ts +++ b/packages/core/storage-js/test/helpers.ts @@ -5,6 +5,7 @@ /// import { StorageVectorsClient } from '../src/lib/vectors' +import StorageAnalyticsClient from '../src/packages/StorageAnalyticsClient' import { createMockFetch, resetMockStorage } from './mock-server' import { getTestConfig } from './setup' @@ -32,6 +33,18 @@ export function createTestClient(): StorageVectorsClient { }) } +/** + * Create a StorageAnalyticsClient for testing analytics buckets with Iceberg + * Points directly to the Iceberg REST Catalog at http://localhost:8181 + */ +export function createAnalyticsTestClient(): StorageAnalyticsClient { + // For analytics tests, we always use the Docker infrastructure (no mock server) + // The Iceberg REST Catalog runs at http://localhost:8181 + return new StorageAnalyticsClient('http://localhost:8181', { + // No authentication required for local Iceberg REST Catalog + }) +} + /** * Setup before each test */ From 15325356a8e18638077b2749eb824aeea17dee44 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 21 Nov 2025 17:33:41 +0200 Subject: [PATCH 03/11] test(storage): remove integration test of iceberg-js --- .../test/analytics-catalog-api.spec.ts | 300 ------------------ .../test/analytics-catalog-e2e.spec.ts | 232 ++++++++++++++ .../test/analytics-getcatalog.test.ts | 57 ++++ 3 files changed, 289 insertions(+), 300 deletions(-) delete mode 100644 packages/core/storage-js/test/analytics-catalog-api.spec.ts create mode 100644 packages/core/storage-js/test/analytics-catalog-e2e.spec.ts create mode 100644 packages/core/storage-js/test/analytics-getcatalog.test.ts diff --git a/packages/core/storage-js/test/analytics-catalog-api.spec.ts b/packages/core/storage-js/test/analytics-catalog-api.spec.ts deleted file mode 100644 index 7d53fd84e..000000000 --- a/packages/core/storage-js/test/analytics-catalog-api.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Integration tests for StorageAnalyticsClient.getCatalog() - * Tests the integration with Iceberg REST Catalog for analytics buckets - * - * These tests require Docker infrastructure to be running (via test:infra script) - * - Iceberg REST Catalog at http://localhost:8181 - * - MinIO for S3-compatible storage backend - */ - -import { IcebergError } from 'iceberg-js' -import { createAnalyticsTestClient, generateTestName } from './helpers' - -describe('Analytics Catalog API Integration Tests', () => { - const client = createAnalyticsTestClient() - let testBucketName: string - - beforeEach(() => { - // Generate unique bucket name for this test - testBucketName = generateTestName('test-warehouse') - }) - - describe('getCatalog', () => { - it('should return a configured IcebergRestCatalog instance', () => { - const catalog = client.getCatalog(testBucketName) - expect(catalog).toBeDefined() - expect(typeof catalog.listNamespaces).toBe('function') - expect(typeof catalog.createNamespace).toBe('function') - expect(typeof catalog.createTable).toBe('function') - }) - - it('should support listing namespaces', async () => { - const catalog = client.getCatalog(testBucketName) - const namespaces = await catalog.listNamespaces() - expect(Array.isArray(namespaces)).toBe(true) - }) - }) - - describe('Iceberg operations via getCatalog', () => { - const testNamespace = 'test' - const testTableName = 'users' - - beforeEach(async () => { - const catalog = client.getCatalog(testBucketName) - - // Cleanup: Drop test resources if they exist (to make tests idempotent) - try { - await catalog.dropTable({ namespace: [testNamespace], name: testTableName }) - } catch (error) { - if (!(error instanceof IcebergError && error.status === 404)) { - throw error - } - } - - try { - await catalog.dropNamespace({ namespace: [testNamespace] }) - } catch (error) { - if (!(error instanceof IcebergError && error.status === 404)) { - throw error - } - } - }) - - afterEach(async () => { - const catalog = client.getCatalog(testBucketName) - - // Cleanup after test - try { - await catalog.dropTable({ namespace: [testNamespace], name: testTableName }) - } catch (error) { - // Ignore cleanup errors - } - - try { - await catalog.dropNamespace({ namespace: [testNamespace] }) - } catch (error) { - // Ignore cleanup errors - } - }) - - it('should create and list namespaces', async () => { - const catalog = client.getCatalog(testBucketName) - - // Create namespace - await catalog.createNamespace( - { namespace: [testNamespace] }, - { properties: { owner: 'storage-js-test' } } - ) - - // List namespaces - const namespaces = await catalog.listNamespaces() - const createdNamespace = namespaces.find( - (ns) => ns.namespace.length === 1 && ns.namespace[0] === testNamespace - ) - expect(createdNamespace).toBeDefined() - }) - - it('should load namespace metadata', async () => { - const catalog = client.getCatalog(testBucketName) - - // Create namespace with properties - await catalog.createNamespace( - { namespace: [testNamespace] }, - { properties: { owner: 'storage-js-test', description: 'Test namespace' } } - ) - - // Load metadata - const metadata = await catalog.loadNamespaceMetadata({ namespace: [testNamespace] }) - expect(metadata.properties).toBeDefined() - expect(metadata.properties?.owner).toBe('storage-js-test') - }) - - it('should create and list tables', async () => { - const catalog = client.getCatalog(testBucketName) - - // Create namespace first - await catalog.createNamespace({ namespace: [testNamespace] }) - - // Create table with schema - const tableMetadata = await catalog.createTable( - { namespace: [testNamespace] }, - { - name: testTableName, - schema: { - type: 'struct', - fields: [ - { id: 1, name: 'id', type: 'long', required: true }, - { id: 2, name: 'name', type: 'string', required: true }, - { id: 3, name: 'email', type: 'string', required: false }, - ], - 'schema-id': 0, - 'identifier-field-ids': [1], - }, - 'partition-spec': { - 'spec-id': 0, - fields: [], - }, - 'write-order': { - 'order-id': 0, - fields: [], - }, - properties: { - 'write.format.default': 'parquet', - }, - } - ) - - expect(tableMetadata.location).toBeDefined() - expect(tableMetadata['table-uuid']).toBeDefined() - - // List tables in namespace - const tables = await catalog.listTables({ namespace: [testNamespace] }) - const createdTable = tables.find( - (t) => t.namespace[0] === testNamespace && t.name === testTableName - ) - expect(createdTable).toBeDefined() - }) - - it('should load table metadata', async () => { - const catalog = client.getCatalog(testBucketName) - - // Create namespace and table - await catalog.createNamespace({ namespace: [testNamespace] }) - await catalog.createTable( - { namespace: [testNamespace] }, - { - name: testTableName, - schema: { - type: 'struct', - fields: [ - { id: 1, name: 'id', type: 'long', required: true }, - { id: 2, name: 'name', type: 'string', required: true }, - ], - 'schema-id': 0, - 'identifier-field-ids': [1], - }, - 'partition-spec': { 'spec-id': 0, fields: [] }, - 'write-order': { 'order-id': 0, fields: [] }, - } - ) - - // Load table metadata - const loadedTable = await catalog.loadTable({ - namespace: [testNamespace], - name: testTableName, - }) - - expect(loadedTable['table-uuid']).toBeDefined() - expect(loadedTable.location).toBeDefined() - expect(loadedTable.schemas).toBeDefined() - expect(Array.isArray(loadedTable.schemas)).toBe(true) - expect(loadedTable.schemas.length).toBeGreaterThan(0) - }) - - it('should update table properties', async () => { - const catalog = client.getCatalog(testBucketName) - - // Create namespace and table - await catalog.createNamespace({ namespace: [testNamespace] }) - await catalog.createTable( - { namespace: [testNamespace] }, - { - name: testTableName, - schema: { - type: 'struct', - fields: [{ id: 1, name: 'id', type: 'long', required: true }], - 'schema-id': 0, - 'identifier-field-ids': [1], - }, - 'partition-spec': { 'spec-id': 0, fields: [] }, - 'write-order': { 'order-id': 0, fields: [] }, - } - ) - - // Update table properties - const updatedTable = await catalog.updateTable( - { namespace: [testNamespace], name: testTableName }, - { - properties: { - 'read.split.target-size': '134217728', - 'write.parquet.compression-codec': 'snappy', - }, - } - ) - - expect(updatedTable['table-uuid']).toBeDefined() - expect(updatedTable.properties).toBeDefined() - }) - - it('should drop tables', async () => { - const catalog = client.getCatalog(testBucketName) - - // Create namespace and table - await catalog.createNamespace({ namespace: [testNamespace] }) - await catalog.createTable( - { namespace: [testNamespace] }, - { - name: testTableName, - schema: { - type: 'struct', - fields: [{ id: 1, name: 'id', type: 'long', required: true }], - 'schema-id': 0, - 'identifier-field-ids': [1], - }, - 'partition-spec': { 'spec-id': 0, fields: [] }, - 'write-order': { 'order-id': 0, fields: [] }, - } - ) - - // Drop the table - await catalog.dropTable({ namespace: [testNamespace], name: testTableName }) - - // Verify table is gone - const tables = await catalog.listTables({ namespace: [testNamespace] }) - const foundTable = tables.find( - (t) => t.namespace[0] === testNamespace && t.name === testTableName - ) - expect(foundTable).toBeUndefined() - }) - - it('should drop namespaces', async () => { - const catalog = client.getCatalog(testBucketName) - - // Create namespace - await catalog.createNamespace({ namespace: [testNamespace] }) - - // Drop the namespace - await catalog.dropNamespace({ namespace: [testNamespace] }) - - // Try to load the namespace (should fail with 404) - await expect(catalog.loadNamespaceMetadata({ namespace: [testNamespace] })).rejects.toThrow() - }) - - it('should handle errors gracefully', async () => { - const catalog = client.getCatalog(testBucketName) - - // Try to load non-existent table - await expect( - catalog.loadTable({ namespace: ['nonexistent'], name: 'nonexistent' }) - ).rejects.toThrow(IcebergError) - - // Try to create table in non-existent namespace - await expect( - catalog.createTable( - { namespace: ['nonexistent'] }, - { - name: 'test', - schema: { - type: 'struct', - fields: [{ id: 1, name: 'id', type: 'long', required: true }], - 'schema-id': 0, - 'identifier-field-ids': [1], - }, - 'partition-spec': { 'spec-id': 0, fields: [] }, - 'write-order': { 'order-id': 0, fields: [] }, - } - ) - ).rejects.toThrow(IcebergError) - }) - }) -}) diff --git a/packages/core/storage-js/test/analytics-catalog-e2e.spec.ts b/packages/core/storage-js/test/analytics-catalog-e2e.spec.ts new file mode 100644 index 000000000..cb387502f --- /dev/null +++ b/packages/core/storage-js/test/analytics-catalog-e2e.spec.ts @@ -0,0 +1,232 @@ +/** + * E2E tests for StorageAnalyticsClient.getCatalog() against real Supabase backend + * + * These tests verify the complete production flow: + * StorageAnalyticsClient -> getCatalog(bucketName) -> Iceberg operations + * + * IMPORTANT: These tests require a real Supabase project with analytics buckets enabled. + * They are SKIPPED by default unless environment variables are provided. + * + * Required environment variables: + * - SUPABASE_ANALYTICS_URL: Iceberg REST Catalog URL (e.g., https://xxx.supabase.co/storage/v1/iceberg) + * - SUPABASE_SERVICE_KEY: Service role key for authentication + * - SUPABASE_ANALYTICS_BUCKET: Name of an existing analytics bucket to test against + * + * Example: + * ```bash + * export SUPABASE_ANALYTICS_URL="https://yourproject.supabase.co/storage/v1/iceberg" + * export SUPABASE_SERVICE_KEY="your-service-key" + * export SUPABASE_ANALYTICS_BUCKET="your-analytics-bucket" + * npm run test:suite -- analytics-catalog-e2e.spec.ts + * ``` + */ + +import { IcebergError } from 'iceberg-js' +import StorageAnalyticsClient from '../src/packages/StorageAnalyticsClient' +import { generateTestName } from './helpers' + +// Read environment variables +const ANALYTICS_URL = process.env.SUPABASE_ANALYTICS_URL +const SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY +const BUCKET_NAME = process.env.SUPABASE_ANALYTICS_BUCKET + +// Skip all tests if environment variables are not set +const describeOrSkip = + ANALYTICS_URL && SERVICE_KEY && BUCKET_NAME ? describe : describe.skip + +describeOrSkip('Analytics Catalog E2E (Real Supabase)', () => { + let client: StorageAnalyticsClient + const testNamespace = generateTestName('test-ns') + const testTableName = 'users' + + beforeAll(() => { + // Create StorageAnalyticsClient with real Supabase credentials + client = new StorageAnalyticsClient(ANALYTICS_URL!, { + Authorization: `Bearer ${SERVICE_KEY}`, + }) + }) + + beforeEach(async () => { + // Get catalog using the production getCatalog() method + const catalog = client.getCatalog(BUCKET_NAME!) + + // Cleanup: Drop test resources if they exist (to make tests idempotent) + try { + await catalog.dropTable({ namespace: [testNamespace], name: testTableName }) + } catch (error) { + if (!(error instanceof IcebergError && error.status === 404)) { + throw error + } + } + + try { + await catalog.dropNamespace({ namespace: [testNamespace] }) + } catch (error) { + if (!(error instanceof IcebergError && error.status === 404)) { + throw error + } + } + }) + + afterEach(async () => { + const catalog = client.getCatalog(BUCKET_NAME!) + + // Cleanup after test + try { + await catalog.dropTable({ namespace: [testNamespace], name: testTableName }) + } catch (error) { + // Ignore cleanup errors + } + + try { + await catalog.dropNamespace({ namespace: [testNamespace] }) + } catch (error) { + // Ignore cleanup errors + } + }) + + it('should get catalog and list namespaces', async () => { + // This tests the full production flow: getCatalog() returns configured catalog + const catalog = client.getCatalog(BUCKET_NAME!) + + const namespaces = await catalog.listNamespaces() + expect(Array.isArray(namespaces)).toBe(true) + }) + + it('should create and list namespaces via getCatalog', async () => { + const catalog = client.getCatalog(BUCKET_NAME!) + + // Create namespace + await catalog.createNamespace( + { namespace: [testNamespace] }, + { properties: { owner: 'storage-js-e2e-test' } } + ) + + // List namespaces + const namespaces = await catalog.listNamespaces() + const createdNamespace = namespaces.find( + (ns) => ns.namespace.length === 1 && ns.namespace[0] === testNamespace + ) + expect(createdNamespace).toBeDefined() + }) + + it('should create table and perform operations via getCatalog', async () => { + const catalog = client.getCatalog(BUCKET_NAME!) + + // Create namespace + await catalog.createNamespace({ namespace: [testNamespace] }) + + // Create table + const tableMetadata = await catalog.createTable( + { namespace: [testNamespace] }, + { + name: testTableName, + schema: { + type: 'struct', + fields: [ + { id: 1, name: 'id', type: 'long', required: true }, + { id: 2, name: 'name', type: 'string', required: true }, + { id: 3, name: 'email', type: 'string', required: false }, + ], + 'schema-id': 0, + 'identifier-field-ids': [1], + }, + 'partition-spec': { + 'spec-id': 0, + fields: [], + }, + 'write-order': { + 'order-id': 0, + fields: [], + }, + properties: { + 'write.format.default': 'parquet', + }, + } + ) + + expect(tableMetadata.location).toBeDefined() + expect(tableMetadata['table-uuid']).toBeDefined() + + // List tables + const tables = await catalog.listTables({ namespace: [testNamespace] }) + const createdTable = tables.find( + (t) => t.namespace[0] === testNamespace && t.name === testTableName + ) + expect(createdTable).toBeDefined() + + // Load table metadata + const loadedTable = await catalog.loadTable({ + namespace: [testNamespace], + name: testTableName, + }) + expect(loadedTable['table-uuid']).toBe(tableMetadata['table-uuid']) + + // Update table properties + const updatedTable = await catalog.updateTable( + { namespace: [testNamespace], name: testTableName }, + { + properties: { + 'read.split.target-size': '134217728', + }, + } + ) + expect(updatedTable.properties).toBeDefined() + }) + + it('should handle errors gracefully via getCatalog', async () => { + const catalog = client.getCatalog(BUCKET_NAME!) + + // Try to load non-existent table + await expect( + catalog.loadTable({ namespace: ['nonexistent'], name: 'nonexistent' }) + ).rejects.toThrow(IcebergError) + + // Try to create table in non-existent namespace + await expect( + catalog.createTable( + { namespace: ['nonexistent'] }, + { + name: 'test', + schema: { + type: 'struct', + fields: [{ id: 1, name: 'id', type: 'long', required: true }], + 'schema-id': 0, + 'identifier-field-ids': [1], + }, + 'partition-spec': { 'spec-id': 0, fields: [] }, + 'write-order': { 'order-id': 0, fields: [] }, + } + ) + ).rejects.toThrow(IcebergError) + }) + + it('should work with throwOnError chain', async () => { + // Test that getCatalog() works after throwOnError() + const catalog = client.throwOnError().getCatalog(BUCKET_NAME!) + + const namespaces = await catalog.listNamespaces() + expect(Array.isArray(namespaces)).toBe(true) + }) +}) + +// Display helpful message when tests are skipped +if (!ANALYTICS_URL || !SERVICE_KEY || !BUCKET_NAME) { + console.log(` +╔═══════════════════════════════════════════════════════════════════════════════╗ +║ E2E Analytics Catalog Tests SKIPPED ║ +║ ║ +║ These tests require a real Supabase project with analytics buckets. ║ +║ To run these tests, set the following environment variables: ║ +║ ║ +║ SUPABASE_ANALYTICS_URL - Iceberg REST Catalog URL ║ +║ SUPABASE_SERVICE_KEY - Service role key for authentication ║ +║ SUPABASE_ANALYTICS_BUCKET - Existing analytics bucket name ║ +║ ║ +║ Example: ║ +║ export SUPABASE_ANALYTICS_URL="https://xxx.supabase.co/storage/v1/iceberg" ║ +║ export SUPABASE_SERVICE_KEY="your-service-key" ║ +║ export SUPABASE_ANALYTICS_BUCKET="your-bucket-name" ║ +╚═══════════════════════════════════════════════════════════════════════════════╝ + `) +} diff --git a/packages/core/storage-js/test/analytics-getcatalog.test.ts b/packages/core/storage-js/test/analytics-getcatalog.test.ts new file mode 100644 index 000000000..80a1f8870 --- /dev/null +++ b/packages/core/storage-js/test/analytics-getcatalog.test.ts @@ -0,0 +1,57 @@ +/** + * Unit tests for StorageAnalyticsClient.getCatalog() method + * Tests that the method returns a properly configured IcebergRestCatalog instance + */ + +import { IcebergRestCatalog } from 'iceberg-js' +import StorageAnalyticsClient from '../src/packages/StorageAnalyticsClient' + +describe('StorageAnalyticsClient.getCatalog()', () => { + it('should return an IcebergRestCatalog instance', () => { + const client = new StorageAnalyticsClient( + 'https://example.supabase.co/storage/v1/iceberg', + { + Authorization: 'Bearer test-token', + } + ) + + const catalog = client.getCatalog('my-analytics-bucket') + + expect(catalog).toBeInstanceOf(IcebergRestCatalog) + }) + + it('should return different instances for different bucket names', () => { + const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {}) + + const catalog1 = client.getCatalog('bucket-1') + const catalog2 = client.getCatalog('bucket-2') + + expect(catalog1).toBeInstanceOf(IcebergRestCatalog) + expect(catalog2).toBeInstanceOf(IcebergRestCatalog) + expect(catalog1).not.toBe(catalog2) // Different instances + }) + + it('should work with minimal configuration', () => { + const client = new StorageAnalyticsClient('http://localhost:8181', {}) + + const catalog = client.getCatalog('test-warehouse') + + expect(catalog).toBeInstanceOf(IcebergRestCatalog) + expect(typeof catalog.listNamespaces).toBe('function') + expect(typeof catalog.createNamespace).toBe('function') + expect(typeof catalog.createTable).toBe('function') + expect(typeof catalog.listTables).toBe('function') + expect(typeof catalog.loadTable).toBe('function') + expect(typeof catalog.updateTable).toBe('function') + expect(typeof catalog.dropTable).toBe('function') + expect(typeof catalog.dropNamespace).toBe('function') + }) + + it('should work when called from throwOnError chain', () => { + const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {}) + + const catalog = client.throwOnError().getCatalog('my-bucket') + + expect(catalog).toBeInstanceOf(IcebergRestCatalog) + }) +}) From 41750e063e2da2d0626bf2b776ce3023d3771bc4 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 21 Nov 2025 17:47:05 +0200 Subject: [PATCH 04/11] test(storage): remove e2e test --- packages/core/storage-js/README.md | 64 +++++ .../core/storage-js/infra/docker-compose.yml | 55 ----- .../test/analytics-catalog-e2e.spec.ts | 232 ------------------ .../test/analytics-getcatalog.test.ts | 9 +- packages/core/storage-js/test/helpers.ts | 13 - 5 files changed, 67 insertions(+), 306 deletions(-) delete mode 100644 packages/core/storage-js/test/analytics-catalog-e2e.spec.ts diff --git a/packages/core/storage-js/README.md b/packages/core/storage-js/README.md index eeac521ca..a1160a2c1 100644 --- a/packages/core/storage-js/README.md +++ b/packages/core/storage-js/README.md @@ -485,6 +485,70 @@ if (error) { > **Note:** A bucket cannot be deleted if it contains data. You must empty the bucket first. +#### Get Iceberg Catalog for Advanced Operations + +For advanced operations like creating tables, namespaces, and querying Iceberg metadata, use the `getCatalog()` method to get a configured [iceberg-js](https://github.com/supabase/iceberg-js) client: + +```typescript +// Get an Iceberg REST Catalog client for your analytics bucket +const catalog = analytics.getCatalog('analytics-data') + +// Create a namespace +await catalog.createNamespace({ namespace: ['default'] }, { properties: { owner: 'data-team' } }) + +// Create a table with schema +await catalog.createTable( + { namespace: ['default'] }, + { + name: 'events', + schema: { + type: 'struct', + fields: [ + { id: 1, name: 'id', type: 'long', required: true }, + { id: 2, name: 'timestamp', type: 'timestamp', required: true }, + { id: 3, name: 'user_id', type: 'string', required: false }, + ], + 'schema-id': 0, + 'identifier-field-ids': [1], + }, + 'partition-spec': { + 'spec-id': 0, + fields: [], + }, + 'write-order': { + 'order-id': 0, + fields: [], + }, + properties: { + 'write.format.default': 'parquet', + }, + } +) + +// List tables in namespace +const tables = await catalog.listTables({ namespace: ['default'] }) +console.log(tables) // [{ namespace: ['default'], name: 'events' }] + +// Load table metadata +const table = await catalog.loadTable({ namespace: ['default'], name: 'events' }) + +// Update table properties +await catalog.updateTable( + { namespace: ['default'], name: 'events' }, + { properties: { 'read.split.target-size': '134217728' } } +) + +// Drop table +await catalog.dropTable({ namespace: ['default'], name: 'events' }) + +// Drop namespace +await catalog.dropNamespace({ namespace: ['default'] }) +``` + +**Returns:** `IcebergRestCatalog` instance from [iceberg-js](https://github.com/supabase/iceberg-js) + +> **Note:** The `getCatalog()` method returns an Iceberg REST Catalog client that provides full access to the Apache Iceberg REST API. For complete documentation of available operations, see the [iceberg-js documentation](https://supabase.github.io/iceberg-js/). + ### Error Handling Analytics buckets use the same error handling pattern as the rest of the Storage SDK: diff --git a/packages/core/storage-js/infra/docker-compose.yml b/packages/core/storage-js/infra/docker-compose.yml index e3a512658..dc3801638 100644 --- a/packages/core/storage-js/infra/docker-compose.yml +++ b/packages/core/storage-js/infra/docker-compose.yml @@ -81,60 +81,5 @@ services: - IMGPROXY_LOCAL_FILESYSTEM_ROOT=/ - IMGPROXY_USE_ETAG=true - IMGPROXY_ENABLE_WEBP_DETECTION=true - - # Iceberg REST Catalog services for analytics buckets testing - minio: - image: minio/minio - container_name: supabase-iceberg-minio - ports: - - '9000:9000' - - '9001:9001' - networks: - default: - aliases: - - warehouse--table-s3.minio - healthcheck: - test: timeout 5s bash -c ':> /dev/tcp/127.0.0.1/9000' || exit 1 - interval: 5s - timeout: 20s - retries: 10 - environment: - MINIO_ROOT_USER: supa-storage - MINIO_ROOT_PASSWORD: secret1234 - MINIO_DOMAIN: minio - command: server --console-address ":9001" /data - volumes: - - minio-data:/data - - minio-setup: - image: minio/mc - container_name: supabase-iceberg-minio-setup - depends_on: - minio: - condition: service_healthy - entrypoint: > - /bin/sh -c " - /usr/bin/mc alias set supa-minio http://minio:9000 supa-storage secret1234; - /usr/bin/mc mb supa-minio/warehouse--table-s3; - /usr/bin/mc policy set public supa-minio/warehouse--table-s3; - exit 0; - " - - iceberg-rest: - image: tabulario/iceberg-rest - container_name: supabase-iceberg-rest - depends_on: - - minio-setup - ports: - - 8181:8181 - environment: - - AWS_ACCESS_KEY_ID=supa-storage - - AWS_SECRET_ACCESS_KEY=secret1234 - - AWS_REGION=us-east-1 - - CATALOG_WAREHOUSE=s3://warehouse--table-s3/ - - CATALOG_IO__IMPL=org.apache.iceberg.aws.s3.S3FileIO - - CATALOG_S3_ENDPOINT=http://minio:9000 - volumes: assets-volume: - minio-data: diff --git a/packages/core/storage-js/test/analytics-catalog-e2e.spec.ts b/packages/core/storage-js/test/analytics-catalog-e2e.spec.ts deleted file mode 100644 index cb387502f..000000000 --- a/packages/core/storage-js/test/analytics-catalog-e2e.spec.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * E2E tests for StorageAnalyticsClient.getCatalog() against real Supabase backend - * - * These tests verify the complete production flow: - * StorageAnalyticsClient -> getCatalog(bucketName) -> Iceberg operations - * - * IMPORTANT: These tests require a real Supabase project with analytics buckets enabled. - * They are SKIPPED by default unless environment variables are provided. - * - * Required environment variables: - * - SUPABASE_ANALYTICS_URL: Iceberg REST Catalog URL (e.g., https://xxx.supabase.co/storage/v1/iceberg) - * - SUPABASE_SERVICE_KEY: Service role key for authentication - * - SUPABASE_ANALYTICS_BUCKET: Name of an existing analytics bucket to test against - * - * Example: - * ```bash - * export SUPABASE_ANALYTICS_URL="https://yourproject.supabase.co/storage/v1/iceberg" - * export SUPABASE_SERVICE_KEY="your-service-key" - * export SUPABASE_ANALYTICS_BUCKET="your-analytics-bucket" - * npm run test:suite -- analytics-catalog-e2e.spec.ts - * ``` - */ - -import { IcebergError } from 'iceberg-js' -import StorageAnalyticsClient from '../src/packages/StorageAnalyticsClient' -import { generateTestName } from './helpers' - -// Read environment variables -const ANALYTICS_URL = process.env.SUPABASE_ANALYTICS_URL -const SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY -const BUCKET_NAME = process.env.SUPABASE_ANALYTICS_BUCKET - -// Skip all tests if environment variables are not set -const describeOrSkip = - ANALYTICS_URL && SERVICE_KEY && BUCKET_NAME ? describe : describe.skip - -describeOrSkip('Analytics Catalog E2E (Real Supabase)', () => { - let client: StorageAnalyticsClient - const testNamespace = generateTestName('test-ns') - const testTableName = 'users' - - beforeAll(() => { - // Create StorageAnalyticsClient with real Supabase credentials - client = new StorageAnalyticsClient(ANALYTICS_URL!, { - Authorization: `Bearer ${SERVICE_KEY}`, - }) - }) - - beforeEach(async () => { - // Get catalog using the production getCatalog() method - const catalog = client.getCatalog(BUCKET_NAME!) - - // Cleanup: Drop test resources if they exist (to make tests idempotent) - try { - await catalog.dropTable({ namespace: [testNamespace], name: testTableName }) - } catch (error) { - if (!(error instanceof IcebergError && error.status === 404)) { - throw error - } - } - - try { - await catalog.dropNamespace({ namespace: [testNamespace] }) - } catch (error) { - if (!(error instanceof IcebergError && error.status === 404)) { - throw error - } - } - }) - - afterEach(async () => { - const catalog = client.getCatalog(BUCKET_NAME!) - - // Cleanup after test - try { - await catalog.dropTable({ namespace: [testNamespace], name: testTableName }) - } catch (error) { - // Ignore cleanup errors - } - - try { - await catalog.dropNamespace({ namespace: [testNamespace] }) - } catch (error) { - // Ignore cleanup errors - } - }) - - it('should get catalog and list namespaces', async () => { - // This tests the full production flow: getCatalog() returns configured catalog - const catalog = client.getCatalog(BUCKET_NAME!) - - const namespaces = await catalog.listNamespaces() - expect(Array.isArray(namespaces)).toBe(true) - }) - - it('should create and list namespaces via getCatalog', async () => { - const catalog = client.getCatalog(BUCKET_NAME!) - - // Create namespace - await catalog.createNamespace( - { namespace: [testNamespace] }, - { properties: { owner: 'storage-js-e2e-test' } } - ) - - // List namespaces - const namespaces = await catalog.listNamespaces() - const createdNamespace = namespaces.find( - (ns) => ns.namespace.length === 1 && ns.namespace[0] === testNamespace - ) - expect(createdNamespace).toBeDefined() - }) - - it('should create table and perform operations via getCatalog', async () => { - const catalog = client.getCatalog(BUCKET_NAME!) - - // Create namespace - await catalog.createNamespace({ namespace: [testNamespace] }) - - // Create table - const tableMetadata = await catalog.createTable( - { namespace: [testNamespace] }, - { - name: testTableName, - schema: { - type: 'struct', - fields: [ - { id: 1, name: 'id', type: 'long', required: true }, - { id: 2, name: 'name', type: 'string', required: true }, - { id: 3, name: 'email', type: 'string', required: false }, - ], - 'schema-id': 0, - 'identifier-field-ids': [1], - }, - 'partition-spec': { - 'spec-id': 0, - fields: [], - }, - 'write-order': { - 'order-id': 0, - fields: [], - }, - properties: { - 'write.format.default': 'parquet', - }, - } - ) - - expect(tableMetadata.location).toBeDefined() - expect(tableMetadata['table-uuid']).toBeDefined() - - // List tables - const tables = await catalog.listTables({ namespace: [testNamespace] }) - const createdTable = tables.find( - (t) => t.namespace[0] === testNamespace && t.name === testTableName - ) - expect(createdTable).toBeDefined() - - // Load table metadata - const loadedTable = await catalog.loadTable({ - namespace: [testNamespace], - name: testTableName, - }) - expect(loadedTable['table-uuid']).toBe(tableMetadata['table-uuid']) - - // Update table properties - const updatedTable = await catalog.updateTable( - { namespace: [testNamespace], name: testTableName }, - { - properties: { - 'read.split.target-size': '134217728', - }, - } - ) - expect(updatedTable.properties).toBeDefined() - }) - - it('should handle errors gracefully via getCatalog', async () => { - const catalog = client.getCatalog(BUCKET_NAME!) - - // Try to load non-existent table - await expect( - catalog.loadTable({ namespace: ['nonexistent'], name: 'nonexistent' }) - ).rejects.toThrow(IcebergError) - - // Try to create table in non-existent namespace - await expect( - catalog.createTable( - { namespace: ['nonexistent'] }, - { - name: 'test', - schema: { - type: 'struct', - fields: [{ id: 1, name: 'id', type: 'long', required: true }], - 'schema-id': 0, - 'identifier-field-ids': [1], - }, - 'partition-spec': { 'spec-id': 0, fields: [] }, - 'write-order': { 'order-id': 0, fields: [] }, - } - ) - ).rejects.toThrow(IcebergError) - }) - - it('should work with throwOnError chain', async () => { - // Test that getCatalog() works after throwOnError() - const catalog = client.throwOnError().getCatalog(BUCKET_NAME!) - - const namespaces = await catalog.listNamespaces() - expect(Array.isArray(namespaces)).toBe(true) - }) -}) - -// Display helpful message when tests are skipped -if (!ANALYTICS_URL || !SERVICE_KEY || !BUCKET_NAME) { - console.log(` -╔═══════════════════════════════════════════════════════════════════════════════╗ -║ E2E Analytics Catalog Tests SKIPPED ║ -║ ║ -║ These tests require a real Supabase project with analytics buckets. ║ -║ To run these tests, set the following environment variables: ║ -║ ║ -║ SUPABASE_ANALYTICS_URL - Iceberg REST Catalog URL ║ -║ SUPABASE_SERVICE_KEY - Service role key for authentication ║ -║ SUPABASE_ANALYTICS_BUCKET - Existing analytics bucket name ║ -║ ║ -║ Example: ║ -║ export SUPABASE_ANALYTICS_URL="https://xxx.supabase.co/storage/v1/iceberg" ║ -║ export SUPABASE_SERVICE_KEY="your-service-key" ║ -║ export SUPABASE_ANALYTICS_BUCKET="your-bucket-name" ║ -╚═══════════════════════════════════════════════════════════════════════════════╝ - `) -} diff --git a/packages/core/storage-js/test/analytics-getcatalog.test.ts b/packages/core/storage-js/test/analytics-getcatalog.test.ts index 80a1f8870..0be779fac 100644 --- a/packages/core/storage-js/test/analytics-getcatalog.test.ts +++ b/packages/core/storage-js/test/analytics-getcatalog.test.ts @@ -8,12 +8,9 @@ import StorageAnalyticsClient from '../src/packages/StorageAnalyticsClient' describe('StorageAnalyticsClient.getCatalog()', () => { it('should return an IcebergRestCatalog instance', () => { - const client = new StorageAnalyticsClient( - 'https://example.supabase.co/storage/v1/iceberg', - { - Authorization: 'Bearer test-token', - } - ) + const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', { + Authorization: 'Bearer test-token', + }) const catalog = client.getCatalog('my-analytics-bucket') diff --git a/packages/core/storage-js/test/helpers.ts b/packages/core/storage-js/test/helpers.ts index 963950682..901e9c94a 100644 --- a/packages/core/storage-js/test/helpers.ts +++ b/packages/core/storage-js/test/helpers.ts @@ -5,7 +5,6 @@ /// import { StorageVectorsClient } from '../src/lib/vectors' -import StorageAnalyticsClient from '../src/packages/StorageAnalyticsClient' import { createMockFetch, resetMockStorage } from './mock-server' import { getTestConfig } from './setup' @@ -33,18 +32,6 @@ export function createTestClient(): StorageVectorsClient { }) } -/** - * Create a StorageAnalyticsClient for testing analytics buckets with Iceberg - * Points directly to the Iceberg REST Catalog at http://localhost:8181 - */ -export function createAnalyticsTestClient(): StorageAnalyticsClient { - // For analytics tests, we always use the Docker infrastructure (no mock server) - // The Iceberg REST Catalog runs at http://localhost:8181 - return new StorageAnalyticsClient('http://localhost:8181', { - // No authentication required for local Iceberg REST Catalog - }) -} - /** * Setup before each test */ From 565d068e061d83dead856570714336ed876e0ef4 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 21 Nov 2025 17:57:27 +0200 Subject: [PATCH 05/11] fix(storage): url issue --- .../storage-js/src/packages/StorageAnalyticsClient.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts index 27c9099fc..c5a3c9861 100644 --- a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts +++ b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts @@ -339,11 +339,11 @@ export default class StorageAnalyticsClient { */ getCatalog(bucketName: string): IcebergRestCatalog { // Construct the Iceberg REST Catalog URL - // The base URL is /storage/v1/iceberg, we need /storage/v1/iceberg/v1 for the catalog API - const catalogUrl = `${this.url}/v1` - + // The base URL is /storage/v1/iceberg + // Note: IcebergRestCatalog from iceberg-js automatically adds /v1/ prefix to API paths + // so we should NOT append /v1 here (it would cause double /v1/v1/ in the URL) return new IcebergRestCatalog({ - baseUrl: catalogUrl, + baseUrl: this.url, catalogName: bucketName, // Maps to the warehouse parameter in Supabase's implementation auth: { type: 'custom', From 2160280ae56b81a44c574ce12a97a7d76dfb1356 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 21 Nov 2025 18:21:16 +0200 Subject: [PATCH 06/11] docs(storage): more tsdoc examples --- .../src/packages/StorageAnalyticsClient.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts index c5a3c9861..01a3a4531 100644 --- a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts +++ b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts @@ -329,11 +329,72 @@ export default class StorageAnalyticsClient { * console.log(tables) // [{ namespace: ['default'], name: 'events' }] * ``` * + * @example Working with namespaces + * ```js + * const catalog = supabase.storage.analytics.getCatalog('analytics-data') + * + * // List all namespaces + * const namespaces = await catalog.listNamespaces() + * + * // Create namespace with properties + * await catalog.createNamespace( + * { namespace: ['production'] }, + * { properties: { owner: 'data-team', env: 'prod' } } + * ) + * ``` + * + * @example Cleanup operations + * ```js + * const catalog = supabase.storage.analytics.getCatalog('analytics-data') + * + * // Drop table with purge option (removes all data) + * await catalog.dropTable( + * { namespace: ['default'], name: 'events' }, + * { purge: true } + * ) + * + * // Drop namespace (must be empty) + * await catalog.dropNamespace({ namespace: ['default'] }) + * ``` + * + * @example Error handling with catalog operations + * ```js + * import { IcebergError } from 'iceberg-js' + * + * const catalog = supabase.storage.analytics.getCatalog('analytics-data') + * + * try { + * await catalog.dropTable({ namespace: ['default'], name: 'events' }, { purge: true }) + * } catch (error) { + * // Handle 404 errors (resource not found) + * const is404 = + * (error instanceof IcebergError && error.status === 404) || + * error?.status === 404 || + * error?.details?.error?.code === 404 + * + * if (is404) { + * console.log('Table does not exist') + * } else { + * throw error // Re-throw other errors + * } + * } + * ``` + * * @remarks * This method provides a bridge between Supabase's bucket management and the standard * Apache Iceberg REST Catalog API. The bucket name maps to the Iceberg warehouse parameter. * All authentication and configuration is handled automatically using your Supabase credentials. * + * **Error Handling**: Operations may throw `IcebergError` from the iceberg-js library. + * Always handle 404 errors gracefully when checking for resource existence. + * + * **Cleanup Operations**: When using `dropTable`, the `purge: true` option permanently + * deletes all table data. Without it, the table is marked as deleted but data remains. + * + * **Library Dependency**: The returned catalog is an instance of `IcebergRestCatalog` + * from iceberg-js. For complete API documentation and advanced usage, refer to the + * [iceberg-js documentation](https://github.com/apache/iceberg-javascript). + * * For advanced Iceberg operations beyond bucket management, you can also install and use * the `iceberg-js` package directly with manual configuration. */ From 41423989b4bcd94e85e1288b0d66449a93e22a01 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 21 Nov 2025 18:56:35 +0200 Subject: [PATCH 07/11] fix(storage): rename getCatalog to fromCatalog --- packages/core/storage-js/README.md | 6 +++--- .../src/packages/StorageAnalyticsClient.ts | 12 ++++++------ .../storage-js/test/analytics-getcatalog.test.ts | 14 +++++++------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/core/storage-js/README.md b/packages/core/storage-js/README.md index a1160a2c1..66c262ca1 100644 --- a/packages/core/storage-js/README.md +++ b/packages/core/storage-js/README.md @@ -487,11 +487,11 @@ if (error) { #### Get Iceberg Catalog for Advanced Operations -For advanced operations like creating tables, namespaces, and querying Iceberg metadata, use the `getCatalog()` method to get a configured [iceberg-js](https://github.com/supabase/iceberg-js) client: +For advanced operations like creating tables, namespaces, and querying Iceberg metadata, use the `fromCatalog()` method to get a configured [iceberg-js](https://github.com/supabase/iceberg-js) client: ```typescript // Get an Iceberg REST Catalog client for your analytics bucket -const catalog = analytics.getCatalog('analytics-data') +const catalog = analytics.fromCatalog('analytics-data') // Create a namespace await catalog.createNamespace({ namespace: ['default'] }, { properties: { owner: 'data-team' } }) @@ -547,7 +547,7 @@ await catalog.dropNamespace({ namespace: ['default'] }) **Returns:** `IcebergRestCatalog` instance from [iceberg-js](https://github.com/supabase/iceberg-js) -> **Note:** The `getCatalog()` method returns an Iceberg REST Catalog client that provides full access to the Apache Iceberg REST API. For complete documentation of available operations, see the [iceberg-js documentation](https://supabase.github.io/iceberg-js/). +> **Note:** The `fromCatalog()` method returns an Iceberg REST Catalog client that provides full access to the Apache Iceberg REST API. For complete documentation of available operations, see the [iceberg-js documentation](https://supabase.github.io/iceberg-js/). ### Error Handling diff --git a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts index 01a3a4531..59a39998d 100644 --- a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts +++ b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts @@ -285,7 +285,7 @@ export default class StorageAnalyticsClient { * .createBucket('analytics-data') * * // Get the Iceberg catalog for that bucket - * const catalog = supabase.storage.analytics.getCatalog('analytics-data') + * const catalog = supabase.storage.analytics.fromCatalog('analytics-data') * * // Create a namespace * await catalog.createNamespace({ namespace: ['default'] }) @@ -322,7 +322,7 @@ export default class StorageAnalyticsClient { * * @example List tables in namespace * ```js - * const catalog = supabase.storage.analytics.getCatalog('analytics-data') + * const catalog = supabase.storage.analytics.fromCatalog('analytics-data') * * // List all tables in the default namespace * const tables = await catalog.listTables({ namespace: ['default'] }) @@ -331,7 +331,7 @@ export default class StorageAnalyticsClient { * * @example Working with namespaces * ```js - * const catalog = supabase.storage.analytics.getCatalog('analytics-data') + * const catalog = supabase.storage.analytics.fromCatalog('analytics-data') * * // List all namespaces * const namespaces = await catalog.listNamespaces() @@ -345,7 +345,7 @@ export default class StorageAnalyticsClient { * * @example Cleanup operations * ```js - * const catalog = supabase.storage.analytics.getCatalog('analytics-data') + * const catalog = supabase.storage.analytics.fromCatalog('analytics-data') * * // Drop table with purge option (removes all data) * await catalog.dropTable( @@ -361,7 +361,7 @@ export default class StorageAnalyticsClient { * ```js * import { IcebergError } from 'iceberg-js' * - * const catalog = supabase.storage.analytics.getCatalog('analytics-data') + * const catalog = supabase.storage.analytics.fromCatalog('analytics-data') * * try { * await catalog.dropTable({ namespace: ['default'], name: 'events' }, { purge: true }) @@ -398,7 +398,7 @@ export default class StorageAnalyticsClient { * For advanced Iceberg operations beyond bucket management, you can also install and use * the `iceberg-js` package directly with manual configuration. */ - getCatalog(bucketName: string): IcebergRestCatalog { + fromCatalog(bucketName: string): IcebergRestCatalog { // Construct the Iceberg REST Catalog URL // The base URL is /storage/v1/iceberg // Note: IcebergRestCatalog from iceberg-js automatically adds /v1/ prefix to API paths diff --git a/packages/core/storage-js/test/analytics-getcatalog.test.ts b/packages/core/storage-js/test/analytics-getcatalog.test.ts index 0be779fac..c076c2fa6 100644 --- a/packages/core/storage-js/test/analytics-getcatalog.test.ts +++ b/packages/core/storage-js/test/analytics-getcatalog.test.ts @@ -1,18 +1,18 @@ /** - * Unit tests for StorageAnalyticsClient.getCatalog() method + * Unit tests for StorageAnalyticsClient.fromCatalog() method * Tests that the method returns a properly configured IcebergRestCatalog instance */ import { IcebergRestCatalog } from 'iceberg-js' import StorageAnalyticsClient from '../src/packages/StorageAnalyticsClient' -describe('StorageAnalyticsClient.getCatalog()', () => { +describe('StorageAnalyticsClient.fromCatalog()', () => { it('should return an IcebergRestCatalog instance', () => { const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', { Authorization: 'Bearer test-token', }) - const catalog = client.getCatalog('my-analytics-bucket') + const catalog = client.fromCatalog('my-analytics-bucket') expect(catalog).toBeInstanceOf(IcebergRestCatalog) }) @@ -20,8 +20,8 @@ describe('StorageAnalyticsClient.getCatalog()', () => { it('should return different instances for different bucket names', () => { const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {}) - const catalog1 = client.getCatalog('bucket-1') - const catalog2 = client.getCatalog('bucket-2') + const catalog1 = client.fromCatalog('bucket-1') + const catalog2 = client.fromCatalog('bucket-2') expect(catalog1).toBeInstanceOf(IcebergRestCatalog) expect(catalog2).toBeInstanceOf(IcebergRestCatalog) @@ -31,7 +31,7 @@ describe('StorageAnalyticsClient.getCatalog()', () => { it('should work with minimal configuration', () => { const client = new StorageAnalyticsClient('http://localhost:8181', {}) - const catalog = client.getCatalog('test-warehouse') + const catalog = client.fromCatalog('test-warehouse') expect(catalog).toBeInstanceOf(IcebergRestCatalog) expect(typeof catalog.listNamespaces).toBe('function') @@ -47,7 +47,7 @@ describe('StorageAnalyticsClient.getCatalog()', () => { it('should work when called from throwOnError chain', () => { const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {}) - const catalog = client.throwOnError().getCatalog('my-bucket') + const catalog = client.throwOnError().fromCatalog('my-bucket') expect(catalog).toBeInstanceOf(IcebergRestCatalog) }) From 711938aa53733fbc7e1083846fad9c3e104d279a Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 21 Nov 2025 19:04:56 +0200 Subject: [PATCH 08/11] fix(storage): security fix by depthfirst Co-authored-by: depthfirst-app[bot] <184448029+depthfirst-app[bot]@users.noreply.github.com> --- .../src/packages/StorageAnalyticsClient.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts index 59a39998d..e012b1ebe 100644 --- a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts +++ b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts @@ -399,6 +399,11 @@ export default class StorageAnalyticsClient { * the `iceberg-js` package directly with manual configuration. */ fromCatalog(bucketName: string): IcebergRestCatalog { + // Validate bucket name to prevent path traversal attacks + if (bucketName.includes('..') || bucketName.includes('/') || bucketName.includes('\\')) { + throw new StorageError('Invalid bucket name: must not contain path traversal sequences') + } + // Construct the Iceberg REST Catalog URL // The base URL is /storage/v1/iceberg // Note: IcebergRestCatalog from iceberg-js automatically adds /v1/ prefix to API paths @@ -412,5 +417,12 @@ export default class StorageAnalyticsClient { }, fetch: this.fetch, }) + } + auth: { + type: 'custom', + getHeaders: async () => this.headers, + }, + fetch: this.fetch, + }) } } From f3131ec6e92c509ad559876114ff5bc411a9eac8 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 21 Nov 2025 19:27:33 +0200 Subject: [PATCH 09/11] fix(storage): add correct validation --- packages/core/storage-js/src/lib/helpers.ts | 44 +++++ .../src/packages/StorageAnalyticsClient.ts | 22 +-- .../test/analytics-getcatalog.test.ts | 92 +++++++++ packages/core/storage-js/test/helpers.test.ts | 174 +++++++++++++++++- 4 files changed, 318 insertions(+), 14 deletions(-) diff --git a/packages/core/storage-js/src/lib/helpers.ts b/packages/core/storage-js/src/lib/helpers.ts index 37cbd2520..6a0161b9e 100644 --- a/packages/core/storage-js/src/lib/helpers.ts +++ b/packages/core/storage-js/src/lib/helpers.ts @@ -46,3 +46,47 @@ export const isPlainObject = (value: object): boolean => { !(Symbol.iterator in value) ) } + +/** + * Validates if a given bucket name is valid according to Supabase Storage API rules + * Mirrors backend validation from: storage/src/storage/limits.ts:isValidBucketName() + * + * Rules: + * - Length: 1-100 characters + * - Allowed characters: alphanumeric (a-z, A-Z, 0-9), underscore (_), and safe special characters + * - Safe special characters: ! - . * ' ( ) space & $ @ = ; : + , ? + * - Forbidden: path separators (/, \), path traversal (..), leading/trailing whitespace + * + * AWS S3 Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html + * + * @param bucketName - The bucket name to validate + * @returns true if valid, false otherwise + */ +export const isValidBucketName = (bucketName: string): boolean => { + if (!bucketName || typeof bucketName !== 'string') { + return false + } + + // Check length constraints (1-100 characters) + if (bucketName.length === 0 || bucketName.length > 100) { + return false + } + + // Check for leading/trailing whitespace + if (bucketName.trim() !== bucketName) { + return false + } + + // Explicitly reject path separators (security) + // Note: Consecutive periods (..) are allowed by backend - the AWS restriction + // on relative paths applies to object keys, not bucket names + if (bucketName.includes('/') || bucketName.includes('\\')) { + return false + } + + // Validate against allowed character set + // Pattern matches backend regex: /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/ + // This explicitly excludes path separators (/, \) and other problematic characters + const bucketNameRegex = /^[\w!.\*'() &$@=;:+,?-]+$/ + return bucketNameRegex.test(bucketName) +} diff --git a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts index e012b1ebe..45cf2728c 100644 --- a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts +++ b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts @@ -2,7 +2,7 @@ import { IcebergRestCatalog } from 'iceberg-js' import { DEFAULT_HEADERS } from '../lib/constants' import { isStorageError, StorageError } from '../lib/errors' import { Fetch, get, post, remove } from '../lib/fetch' -import { resolveFetch } from '../lib/helpers' +import { isValidBucketName, resolveFetch } from '../lib/helpers' import { AnalyticBucket } from '../lib/types' /** @@ -393,17 +393,20 @@ export default class StorageAnalyticsClient { * * **Library Dependency**: The returned catalog is an instance of `IcebergRestCatalog` * from iceberg-js. For complete API documentation and advanced usage, refer to the - * [iceberg-js documentation](https://github.com/apache/iceberg-javascript). + * [iceberg-js documentation](https://supabase.github.io/iceberg-js/). * * For advanced Iceberg operations beyond bucket management, you can also install and use * the `iceberg-js` package directly with manual configuration. */ fromCatalog(bucketName: string): IcebergRestCatalog { - // Validate bucket name to prevent path traversal attacks - if (bucketName.includes('..') || bucketName.includes('/') || bucketName.includes('\\')) { - throw new StorageError('Invalid bucket name: must not contain path traversal sequences') + // Validate bucket name using same rules as Supabase Storage API backend + if (!isValidBucketName(bucketName)) { + throw new StorageError( + 'Invalid bucket name: File, folder, and bucket names must follow AWS object key naming guidelines ' + + 'and should avoid the use of any other characters.' + ) } - + // Construct the Iceberg REST Catalog URL // The base URL is /storage/v1/iceberg // Note: IcebergRestCatalog from iceberg-js automatically adds /v1/ prefix to API paths @@ -417,12 +420,5 @@ export default class StorageAnalyticsClient { }, fetch: this.fetch, }) - } - auth: { - type: 'custom', - getHeaders: async () => this.headers, - }, - fetch: this.fetch, - }) } } diff --git a/packages/core/storage-js/test/analytics-getcatalog.test.ts b/packages/core/storage-js/test/analytics-getcatalog.test.ts index c076c2fa6..f69e5ef0b 100644 --- a/packages/core/storage-js/test/analytics-getcatalog.test.ts +++ b/packages/core/storage-js/test/analytics-getcatalog.test.ts @@ -5,6 +5,7 @@ import { IcebergRestCatalog } from 'iceberg-js' import StorageAnalyticsClient from '../src/packages/StorageAnalyticsClient' +import { StorageError } from '../src/lib/errors' describe('StorageAnalyticsClient.fromCatalog()', () => { it('should return an IcebergRestCatalog instance', () => { @@ -51,4 +52,95 @@ describe('StorageAnalyticsClient.fromCatalog()', () => { expect(catalog).toBeInstanceOf(IcebergRestCatalog) }) + + describe('bucket name validation', () => { + let client: StorageAnalyticsClient + + beforeEach(() => { + client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {}) + }) + + describe('valid bucket names', () => { + it('should accept simple alphanumeric names', () => { + expect(() => client.fromCatalog('analytics-data')).not.toThrow() + expect(() => client.fromCatalog('bucket123')).not.toThrow() + expect(() => client.fromCatalog('MyBucket')).not.toThrow() + }) + + it('should accept names with safe special characters', () => { + expect(() => client.fromCatalog('my-bucket_2024')).not.toThrow() + expect(() => client.fromCatalog('data.backup')).not.toThrow() + expect(() => client.fromCatalog("bucket's-data")).not.toThrow() + expect(() => client.fromCatalog('bucket (2024)')).not.toThrow() + }) + + it('should accept real-world bucket names from examples', () => { + expect(() => client.fromCatalog('embeddings-prod')).not.toThrow() + expect(() => client.fromCatalog('user_uploads')).not.toThrow() + expect(() => client.fromCatalog('public-assets')).not.toThrow() + }) + }) + + describe('invalid bucket names', () => { + it('should reject empty or null bucket names', () => { + expect(() => client.fromCatalog('')).toThrow(StorageError) + expect(() => client.fromCatalog(null as any)).toThrow(StorageError) + expect(() => client.fromCatalog(undefined as any)).toThrow(StorageError) + }) + + it('should reject path traversal with slashes', () => { + expect(() => client.fromCatalog('../etc/passwd')).toThrow(StorageError) + expect(() => client.fromCatalog('bucket/../other')).toThrow(StorageError) + // Note: '..' alone is valid (just two periods), only with slashes is it path traversal + }) + + it('should reject names with path separators', () => { + expect(() => client.fromCatalog('bucket/nested')).toThrow(StorageError) + expect(() => client.fromCatalog('/bucket')).toThrow(StorageError) + expect(() => client.fromCatalog('bucket/')).toThrow(StorageError) + expect(() => client.fromCatalog('bucket\\nested')).toThrow(StorageError) + expect(() => client.fromCatalog('path\\to\\bucket')).toThrow(StorageError) + }) + + it('should reject names with leading or trailing whitespace', () => { + expect(() => client.fromCatalog(' bucket')).toThrow(StorageError) + expect(() => client.fromCatalog('bucket ')).toThrow(StorageError) + expect(() => client.fromCatalog(' bucket ')).toThrow(StorageError) + }) + + it('should reject names exceeding 100 characters', () => { + const tooLongName = 'a'.repeat(101) + expect(() => client.fromCatalog(tooLongName)).toThrow(StorageError) + }) + + it('should reject names with unsafe special characters', () => { + expect(() => client.fromCatalog('bucket{name}')).toThrow(StorageError) + expect(() => client.fromCatalog('bucket[name]')).toThrow(StorageError) + expect(() => client.fromCatalog('bucket')).toThrow(StorageError) + expect(() => client.fromCatalog('bucket#name')).toThrow(StorageError) + expect(() => client.fromCatalog('bucket%name')).toThrow(StorageError) + }) + + it('should provide clear error messages', () => { + try { + client.fromCatalog('bucket/nested') + fail('Should have thrown an error') + } catch (error) { + expect(error).toBeInstanceOf(StorageError) + expect((error as StorageError).message).toContain('Invalid bucket name') + expect((error as StorageError).message).toContain('AWS object key naming guidelines') + } + }) + }) + + describe('URL encoding behavior', () => { + it('should reject strings with percent signs (URL encoding)', () => { + // The % character is not in the allowed character set, so URL-encoded + // strings will be rejected. This is correct behavior - users should + // pass unencoded bucket names. + const encodedSlash = 'bucket%2Fnested' + expect(() => client.fromCatalog(encodedSlash)).toThrow(StorageError) + }) + }) + }) }) diff --git a/packages/core/storage-js/test/helpers.test.ts b/packages/core/storage-js/test/helpers.test.ts index 9a738b845..c35e8eab1 100644 --- a/packages/core/storage-js/test/helpers.test.ts +++ b/packages/core/storage-js/test/helpers.test.ts @@ -1,4 +1,10 @@ -import { resolveFetch, resolveResponse, recursiveToCamel, isPlainObject } from '../src/lib/helpers' +import { + resolveFetch, + resolveResponse, + recursiveToCamel, + isPlainObject, + isValidBucketName, +} from '../src/lib/helpers' describe('Helpers', () => { describe('resolveFetch', () => { @@ -134,4 +140,170 @@ describe('Helpers', () => { expect(isPlainObject(obj)).toBe(false) }) }) + + describe('isValidBucketName', () => { + describe('valid bucket names', () => { + test('accepts simple alphanumeric names', () => { + expect(isValidBucketName('bucket')).toBe(true) + expect(isValidBucketName('bucket123')).toBe(true) + expect(isValidBucketName('MyBucket')).toBe(true) + expect(isValidBucketName('bucket_name')).toBe(true) + }) + + test('accepts names with hyphens and underscores', () => { + expect(isValidBucketName('my-bucket')).toBe(true) + expect(isValidBucketName('my_bucket')).toBe(true) + expect(isValidBucketName('my-bucket_123')).toBe(true) + }) + + test('accepts names with safe special characters', () => { + expect(isValidBucketName('bucket.data')).toBe(true) + expect(isValidBucketName("bucket's-data")).toBe(true) + expect(isValidBucketName('bucket (2024)')).toBe(true) + expect(isValidBucketName('bucket*')).toBe(true) + expect(isValidBucketName('bucket!')).toBe(true) + }) + + test('accepts names with multiple safe special characters', () => { + expect(isValidBucketName('bucket!@$')).toBe(true) + expect(isValidBucketName('data+analytics')).toBe(true) + expect(isValidBucketName('file,list')).toBe(true) + expect(isValidBucketName('query?params')).toBe(true) + expect(isValidBucketName('user@domain')).toBe(true) + expect(isValidBucketName('key=value')).toBe(true) + expect(isValidBucketName('item;separator')).toBe(true) + expect(isValidBucketName('path:colon')).toBe(true) + }) + + test('accepts names with spaces', () => { + expect(isValidBucketName('my bucket')).toBe(true) + expect(isValidBucketName('bucket name 2024')).toBe(true) + }) + + test('accepts maximum length names (100 characters)', () => { + const maxLengthName = 'a'.repeat(100) + expect(isValidBucketName(maxLengthName)).toBe(true) + }) + + test('accepts real-world examples', () => { + expect(isValidBucketName('analytics-data')).toBe(true) + expect(isValidBucketName('user_uploads')).toBe(true) + expect(isValidBucketName('public-assets')).toBe(true) + expect(isValidBucketName('embeddings-prod')).toBe(true) + expect(isValidBucketName('avatars')).toBe(true) + }) + }) + + describe('invalid bucket names', () => { + test('rejects empty or null/undefined names', () => { + expect(isValidBucketName('')).toBe(false) + expect(isValidBucketName(null as any)).toBe(false) + expect(isValidBucketName(undefined as any)).toBe(false) + }) + + test('rejects non-string values', () => { + expect(isValidBucketName(123 as any)).toBe(false) + expect(isValidBucketName({} as any)).toBe(false) + expect(isValidBucketName([] as any)).toBe(false) + }) + + test('rejects names exceeding 100 characters', () => { + const tooLongName = 'a'.repeat(101) + expect(isValidBucketName(tooLongName)).toBe(false) + }) + + test('rejects names with leading whitespace', () => { + expect(isValidBucketName(' bucket')).toBe(false) + expect(isValidBucketName(' bucket')).toBe(false) + expect(isValidBucketName('\tbucket')).toBe(false) + }) + + test('rejects names with trailing whitespace', () => { + expect(isValidBucketName('bucket ')).toBe(false) + expect(isValidBucketName('bucket ')).toBe(false) + expect(isValidBucketName('bucket\t')).toBe(false) + }) + + test('rejects names with both leading and trailing whitespace', () => { + expect(isValidBucketName(' bucket ')).toBe(false) + expect(isValidBucketName(' bucket ')).toBe(false) + }) + + test('rejects names with forward slash (path separator)', () => { + expect(isValidBucketName('bucket/nested')).toBe(false) + expect(isValidBucketName('/bucket')).toBe(false) + expect(isValidBucketName('bucket/')).toBe(false) + expect(isValidBucketName('path/to/bucket')).toBe(false) + }) + + test('rejects names with backslash (Windows path separator)', () => { + expect(isValidBucketName('bucket\\nested')).toBe(false) + expect(isValidBucketName('\\bucket')).toBe(false) + expect(isValidBucketName('bucket\\')).toBe(false) + expect(isValidBucketName('path\\to\\bucket')).toBe(false) + }) + + test('rejects path traversal with slashes', () => { + // Note: '..' alone is allowed (just two periods), but with slashes it's path traversal + expect(isValidBucketName('../bucket')).toBe(false) + expect(isValidBucketName('bucket/..')).toBe(false) + expect(isValidBucketName('../../etc/passwd')).toBe(false) + }) + + test('rejects names with unsafe special characters', () => { + expect(isValidBucketName('bucket{name}')).toBe(false) + expect(isValidBucketName('bucket[name]')).toBe(false) + expect(isValidBucketName('bucket')).toBe(false) + expect(isValidBucketName('bucket>name')).toBe(false) + expect(isValidBucketName('bucket#name')).toBe(false) + expect(isValidBucketName('bucket%name')).toBe(false) + expect(isValidBucketName('bucket|name')).toBe(false) + expect(isValidBucketName('bucket^name')).toBe(false) + expect(isValidBucketName('bucket~name')).toBe(false) + expect(isValidBucketName('bucket`name')).toBe(false) + }) + + test('rejects double quotes', () => { + expect(isValidBucketName('bucket"name')).toBe(false) + expect(isValidBucketName('"bucket"')).toBe(false) + }) + + test('rejects newlines and control characters', () => { + expect(isValidBucketName('bucket\nname')).toBe(false) + expect(isValidBucketName('bucket\rname')).toBe(false) + expect(isValidBucketName('bucket\tname')).toBe(false) + }) + }) + + describe('edge cases and AWS S3 compatibility', () => { + test('accepts single character names', () => { + expect(isValidBucketName('a')).toBe(true) + expect(isValidBucketName('1')).toBe(true) + expect(isValidBucketName('_')).toBe(true) + }) + + test('accepts names with consecutive special characters', () => { + expect(isValidBucketName('bucket--name')).toBe(true) + expect(isValidBucketName('bucket__name')).toBe(true) + expect(isValidBucketName('bucket..name')).toBe(true) + }) + + test('handles period-only segments correctly', () => { + // Single period is allowed as a character + expect(isValidBucketName('.')).toBe(true) + // Multiple periods are allowed + expect(isValidBucketName('...')).toBe(true) + // But path traversal with slashes is not + expect(isValidBucketName('./')).toBe(false) + expect(isValidBucketName('../')).toBe(false) + }) + + test('matches backend validation for common patterns', () => { + // These should match the backend regex: /^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/ + expect(isValidBucketName('test_bucket-123')).toBe(true) + expect(isValidBucketName('my.great_photos-2014')).toBe(true) + expect(isValidBucketName('data (backup)')).toBe(true) + }) + }) + }) }) From 7e0321207608282890df25775a1adfdf427a959a Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 25 Nov 2025 14:28:28 +0200 Subject: [PATCH 10/11] feat(storage): update iceberg-js to latest --- package-lock.json | 8 ++++---- packages/core/storage-js/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ff108d24..d35bbce3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17801,9 +17801,9 @@ } }, "node_modules/iceberg-js": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.7.0.tgz", - "integrity": "sha512-7VZYpt4OiOYh3w7yv04lAd8AjmAZAMamP7I83Ml/zDT4lGd7Lnb5ibrJ45euIgx0Qb4DEkud6+4OTq+IS/Wf0g==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.0.tgz", + "integrity": "sha512-kmgmea2nguZEvRqW79gDqNXyxA3OS5WIgMVffrHpqXV4F/J4UmNIw2vstixioLTNSkd5rFB8G0s3Lwzogm6OFw==", "license": "MIT", "engines": { "node": ">=20.0.0" @@ -33232,7 +33232,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "iceberg-js": "^0.7.0", + "iceberg-js": "^0.8.0", "tslib": "2.8.1" }, "devDependencies": { diff --git a/packages/core/storage-js/package.json b/packages/core/storage-js/package.json index c5229d42b..60778cedd 100644 --- a/packages/core/storage-js/package.json +++ b/packages/core/storage-js/package.json @@ -37,7 +37,7 @@ "docs:json": "typedoc --json docs/v2/spec.json --entryPoints src/index.ts --entryPoints src/packages/* --excludePrivate --excludeExternals --excludeProtected" }, "dependencies": { - "iceberg-js": "^0.7.0", + "iceberg-js": "^0.8.0", "tslib": "2.8.1" }, "devDependencies": { From 926d6cbc4ead2af9681d5fc5241423dad7cca32c Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 25 Nov 2025 15:36:12 +0200 Subject: [PATCH 11/11] docs(storage): correct the examples --- packages/core/storage-js/README.md | 6 +- .../src/packages/StorageAnalyticsClient.ts | 12 +-- .../test/analytics-getcatalog.test.ts | 76 +++++++++---------- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/packages/core/storage-js/README.md b/packages/core/storage-js/README.md index 66c262ca1..f90168e81 100644 --- a/packages/core/storage-js/README.md +++ b/packages/core/storage-js/README.md @@ -487,11 +487,11 @@ if (error) { #### Get Iceberg Catalog for Advanced Operations -For advanced operations like creating tables, namespaces, and querying Iceberg metadata, use the `fromCatalog()` method to get a configured [iceberg-js](https://github.com/supabase/iceberg-js) client: +For advanced operations like creating tables, namespaces, and querying Iceberg metadata, use the `from()` method to get a configured [iceberg-js](https://github.com/supabase/iceberg-js) client: ```typescript // Get an Iceberg REST Catalog client for your analytics bucket -const catalog = analytics.fromCatalog('analytics-data') +const catalog = analytics.from('analytics-data') // Create a namespace await catalog.createNamespace({ namespace: ['default'] }, { properties: { owner: 'data-team' } }) @@ -547,7 +547,7 @@ await catalog.dropNamespace({ namespace: ['default'] }) **Returns:** `IcebergRestCatalog` instance from [iceberg-js](https://github.com/supabase/iceberg-js) -> **Note:** The `fromCatalog()` method returns an Iceberg REST Catalog client that provides full access to the Apache Iceberg REST API. For complete documentation of available operations, see the [iceberg-js documentation](https://supabase.github.io/iceberg-js/). +> **Note:** The `from()` method returns an Iceberg REST Catalog client that provides full access to the Apache Iceberg REST API. For complete documentation of available operations, see the [iceberg-js documentation](https://supabase.github.io/iceberg-js/). ### Error Handling diff --git a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts index 45cf2728c..47fb4309f 100644 --- a/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts +++ b/packages/core/storage-js/src/packages/StorageAnalyticsClient.ts @@ -285,7 +285,7 @@ export default class StorageAnalyticsClient { * .createBucket('analytics-data') * * // Get the Iceberg catalog for that bucket - * const catalog = supabase.storage.analytics.fromCatalog('analytics-data') + * const catalog = supabase.storage.analytics.from('analytics-data') * * // Create a namespace * await catalog.createNamespace({ namespace: ['default'] }) @@ -322,7 +322,7 @@ export default class StorageAnalyticsClient { * * @example List tables in namespace * ```js - * const catalog = supabase.storage.analytics.fromCatalog('analytics-data') + * const catalog = supabase.storage.analytics.from('analytics-data') * * // List all tables in the default namespace * const tables = await catalog.listTables({ namespace: ['default'] }) @@ -331,7 +331,7 @@ export default class StorageAnalyticsClient { * * @example Working with namespaces * ```js - * const catalog = supabase.storage.analytics.fromCatalog('analytics-data') + * const catalog = supabase.storage.analytics.from('analytics-data') * * // List all namespaces * const namespaces = await catalog.listNamespaces() @@ -345,7 +345,7 @@ export default class StorageAnalyticsClient { * * @example Cleanup operations * ```js - * const catalog = supabase.storage.analytics.fromCatalog('analytics-data') + * const catalog = supabase.storage.analytics.from('analytics-data') * * // Drop table with purge option (removes all data) * await catalog.dropTable( @@ -361,7 +361,7 @@ export default class StorageAnalyticsClient { * ```js * import { IcebergError } from 'iceberg-js' * - * const catalog = supabase.storage.analytics.fromCatalog('analytics-data') + * const catalog = supabase.storage.analytics.from('analytics-data') * * try { * await catalog.dropTable({ namespace: ['default'], name: 'events' }, { purge: true }) @@ -398,7 +398,7 @@ export default class StorageAnalyticsClient { * For advanced Iceberg operations beyond bucket management, you can also install and use * the `iceberg-js` package directly with manual configuration. */ - fromCatalog(bucketName: string): IcebergRestCatalog { + from(bucketName: string): IcebergRestCatalog { // Validate bucket name using same rules as Supabase Storage API backend if (!isValidBucketName(bucketName)) { throw new StorageError( diff --git a/packages/core/storage-js/test/analytics-getcatalog.test.ts b/packages/core/storage-js/test/analytics-getcatalog.test.ts index f69e5ef0b..27e0280d4 100644 --- a/packages/core/storage-js/test/analytics-getcatalog.test.ts +++ b/packages/core/storage-js/test/analytics-getcatalog.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for StorageAnalyticsClient.fromCatalog() method + * Unit tests for StorageAnalyticsClient.from() method * Tests that the method returns a properly configured IcebergRestCatalog instance */ @@ -7,13 +7,13 @@ import { IcebergRestCatalog } from 'iceberg-js' import StorageAnalyticsClient from '../src/packages/StorageAnalyticsClient' import { StorageError } from '../src/lib/errors' -describe('StorageAnalyticsClient.fromCatalog()', () => { +describe('StorageAnalyticsClient.from()', () => { it('should return an IcebergRestCatalog instance', () => { const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', { Authorization: 'Bearer test-token', }) - const catalog = client.fromCatalog('my-analytics-bucket') + const catalog = client.from('my-analytics-bucket') expect(catalog).toBeInstanceOf(IcebergRestCatalog) }) @@ -21,8 +21,8 @@ describe('StorageAnalyticsClient.fromCatalog()', () => { it('should return different instances for different bucket names', () => { const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {}) - const catalog1 = client.fromCatalog('bucket-1') - const catalog2 = client.fromCatalog('bucket-2') + const catalog1 = client.from('bucket-1') + const catalog2 = client.from('bucket-2') expect(catalog1).toBeInstanceOf(IcebergRestCatalog) expect(catalog2).toBeInstanceOf(IcebergRestCatalog) @@ -32,7 +32,7 @@ describe('StorageAnalyticsClient.fromCatalog()', () => { it('should work with minimal configuration', () => { const client = new StorageAnalyticsClient('http://localhost:8181', {}) - const catalog = client.fromCatalog('test-warehouse') + const catalog = client.from('test-warehouse') expect(catalog).toBeInstanceOf(IcebergRestCatalog) expect(typeof catalog.listNamespaces).toBe('function') @@ -48,7 +48,7 @@ describe('StorageAnalyticsClient.fromCatalog()', () => { it('should work when called from throwOnError chain', () => { const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {}) - const catalog = client.throwOnError().fromCatalog('my-bucket') + const catalog = client.throwOnError().from('my-bucket') expect(catalog).toBeInstanceOf(IcebergRestCatalog) }) @@ -62,68 +62,68 @@ describe('StorageAnalyticsClient.fromCatalog()', () => { describe('valid bucket names', () => { it('should accept simple alphanumeric names', () => { - expect(() => client.fromCatalog('analytics-data')).not.toThrow() - expect(() => client.fromCatalog('bucket123')).not.toThrow() - expect(() => client.fromCatalog('MyBucket')).not.toThrow() + expect(() => client.from('analytics-data')).not.toThrow() + expect(() => client.from('bucket123')).not.toThrow() + expect(() => client.from('MyBucket')).not.toThrow() }) it('should accept names with safe special characters', () => { - expect(() => client.fromCatalog('my-bucket_2024')).not.toThrow() - expect(() => client.fromCatalog('data.backup')).not.toThrow() - expect(() => client.fromCatalog("bucket's-data")).not.toThrow() - expect(() => client.fromCatalog('bucket (2024)')).not.toThrow() + expect(() => client.from('my-bucket_2024')).not.toThrow() + expect(() => client.from('data.backup')).not.toThrow() + expect(() => client.from("bucket's-data")).not.toThrow() + expect(() => client.from('bucket (2024)')).not.toThrow() }) it('should accept real-world bucket names from examples', () => { - expect(() => client.fromCatalog('embeddings-prod')).not.toThrow() - expect(() => client.fromCatalog('user_uploads')).not.toThrow() - expect(() => client.fromCatalog('public-assets')).not.toThrow() + expect(() => client.from('embeddings-prod')).not.toThrow() + expect(() => client.from('user_uploads')).not.toThrow() + expect(() => client.from('public-assets')).not.toThrow() }) }) describe('invalid bucket names', () => { it('should reject empty or null bucket names', () => { - expect(() => client.fromCatalog('')).toThrow(StorageError) - expect(() => client.fromCatalog(null as any)).toThrow(StorageError) - expect(() => client.fromCatalog(undefined as any)).toThrow(StorageError) + expect(() => client.from('')).toThrow(StorageError) + expect(() => client.from(null as any)).toThrow(StorageError) + expect(() => client.from(undefined as any)).toThrow(StorageError) }) it('should reject path traversal with slashes', () => { - expect(() => client.fromCatalog('../etc/passwd')).toThrow(StorageError) - expect(() => client.fromCatalog('bucket/../other')).toThrow(StorageError) + expect(() => client.from('../etc/passwd')).toThrow(StorageError) + expect(() => client.from('bucket/../other')).toThrow(StorageError) // Note: '..' alone is valid (just two periods), only with slashes is it path traversal }) it('should reject names with path separators', () => { - expect(() => client.fromCatalog('bucket/nested')).toThrow(StorageError) - expect(() => client.fromCatalog('/bucket')).toThrow(StorageError) - expect(() => client.fromCatalog('bucket/')).toThrow(StorageError) - expect(() => client.fromCatalog('bucket\\nested')).toThrow(StorageError) - expect(() => client.fromCatalog('path\\to\\bucket')).toThrow(StorageError) + expect(() => client.from('bucket/nested')).toThrow(StorageError) + expect(() => client.from('/bucket')).toThrow(StorageError) + expect(() => client.from('bucket/')).toThrow(StorageError) + expect(() => client.from('bucket\\nested')).toThrow(StorageError) + expect(() => client.from('path\\to\\bucket')).toThrow(StorageError) }) it('should reject names with leading or trailing whitespace', () => { - expect(() => client.fromCatalog(' bucket')).toThrow(StorageError) - expect(() => client.fromCatalog('bucket ')).toThrow(StorageError) - expect(() => client.fromCatalog(' bucket ')).toThrow(StorageError) + expect(() => client.from(' bucket')).toThrow(StorageError) + expect(() => client.from('bucket ')).toThrow(StorageError) + expect(() => client.from(' bucket ')).toThrow(StorageError) }) it('should reject names exceeding 100 characters', () => { const tooLongName = 'a'.repeat(101) - expect(() => client.fromCatalog(tooLongName)).toThrow(StorageError) + expect(() => client.from(tooLongName)).toThrow(StorageError) }) it('should reject names with unsafe special characters', () => { - expect(() => client.fromCatalog('bucket{name}')).toThrow(StorageError) - expect(() => client.fromCatalog('bucket[name]')).toThrow(StorageError) - expect(() => client.fromCatalog('bucket')).toThrow(StorageError) - expect(() => client.fromCatalog('bucket#name')).toThrow(StorageError) - expect(() => client.fromCatalog('bucket%name')).toThrow(StorageError) + expect(() => client.from('bucket{name}')).toThrow(StorageError) + expect(() => client.from('bucket[name]')).toThrow(StorageError) + expect(() => client.from('bucket')).toThrow(StorageError) + expect(() => client.from('bucket#name')).toThrow(StorageError) + expect(() => client.from('bucket%name')).toThrow(StorageError) }) it('should provide clear error messages', () => { try { - client.fromCatalog('bucket/nested') + client.from('bucket/nested') fail('Should have thrown an error') } catch (error) { expect(error).toBeInstanceOf(StorageError) @@ -139,7 +139,7 @@ describe('StorageAnalyticsClient.fromCatalog()', () => { // strings will be rejected. This is correct behavior - users should // pass unencoded bucket names. const encodedSlash = 'bucket%2Fnested' - expect(() => client.fromCatalog(encodedSlash)).toThrow(StorageError) + expect(() => client.from(encodedSlash)).toThrow(StorageError) }) }) })