Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 43 additions & 39 deletions packages/storage/src/lib/bucket/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ export async function createBucket(
}
}

if (
options?.sourceBucketSnapshot &&
options.sourceBucketSnapshot !== '' &&
(!options?.sourceBucketName || options.sourceBucketName === '')
) {
return {
error: new Error(
'sourceBucketName is required when sourceBucketSnapshot is provided'
),
};
}

const command = new CreateBucketCommand({
Bucket: bucketName,
});
Expand All @@ -85,20 +97,27 @@ export async function createBucket(
(next) => async (args) => {
const req = args.request as HttpRequest;

// Disable directory listing by default
req.headers[TigrisHeaders.ACL_LIST_OBJECTS] = 'false';

// Set storage class
if (options?.defaultTier) {
req.headers[TigrisHeaders.STORAGE_CLASS] = options.defaultTier;
}

// Set consistency level
if (options?.consistency === 'strict') {
req.headers[TigrisHeaders.CONSISTENT] = 'true';
}

// Set regions
if (options?.region && options?.region !== undefined) {
req.headers[TigrisHeaders.REGIONS] = Array.isArray(options.region)
? options.region.join(',')
: options.region;
}

// Set locations
if (
options?.locations &&
options?.locations !== undefined &&
Expand All @@ -111,47 +130,32 @@ export async function createBucket(
: options.locations.values;
}

const result = await next(args);
return result;
},
{
step: 'build',
}
);
// Set snapshot enabled
if (options?.enableSnapshot) {
req.headers[TigrisHeaders.SNAPSHOT_ENABLED] = 'true';
}

if (options?.enableSnapshot) {
command.middlewareStack.add(
(next) => async (args) => {
(args.request as HttpRequest).headers[TigrisHeaders.SNAPSHOT_ENABLED] =
'true';
return next(args);
},
{ step: 'build' }
);
}
// Set fork source bucket
if (options?.sourceBucketName && options.sourceBucketName !== '') {
req.headers[TigrisHeaders.FORK_SOURCE_BUCKET] =
options.sourceBucketName;
}

if (options?.sourceBucketName && options.sourceBucketName !== '') {
const sourceBucketName = options.sourceBucketName;
command.middlewareStack.add(
(next) => async (args) => {
(args.request as HttpRequest).headers[
TigrisHeaders.FORK_SOURCE_BUCKET
] = sourceBucketName;

if (
options?.sourceBucketSnapshot &&
options.sourceBucketSnapshot !== ''
) {
(args.request as HttpRequest).headers[
TigrisHeaders.FORK_SOURCE_BUCKET_SNAPSHOT
] = options.sourceBucketSnapshot;
}

return next(args);
},
{ step: 'build' }
);
}
// Set fork source bucket snapshot
if (
options?.sourceBucketName &&
options.sourceBucketName !== '' &&
options?.sourceBucketSnapshot &&
options.sourceBucketSnapshot !== ''
) {
req.headers[TigrisHeaders.FORK_SOURCE_BUCKET_SNAPSHOT] =
options.sourceBucketSnapshot;
}

return await next(args);
},
{ step: 'build' }
);

try {
return tigrisClient
Expand Down
173 changes: 173 additions & 0 deletions packages/storage/src/test/bucket-create.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, it, expect, afterEach } from 'vitest';
import { createBucket } from '../lib/bucket/create';
import { listBuckets } from '../lib/bucket/list';
import { removeBucket } from '../lib/bucket/remove';
import { shouldSkipIntegrationTests } from './setup';
import { config } from '../lib/config';

const skipTests = shouldSkipIntegrationTests();

const testBucket = (suffix: string) =>
`test-create-${suffix}-${Date.now()}`.toLowerCase();

describe.skipIf(skipTests)('createBucket Integration Tests', () => {
const bucketsToCleanup: string[] = [];

afterEach(async () => {
for (const bucket of bucketsToCleanup) {
await removeBucket(bucket, { force: true, config });
}
bucketsToCleanup.length = 0;
});

it('should create a bucket and be listable', async () => {
const name = testBucket('basic');
bucketsToCleanup.push(name);

const result = await createBucket(name, { config });

expect(result.error).toBeUndefined();
expect(result.data).toBeDefined();
expect(result.data?.hasForks).toBe(false);
expect(result.data?.isSnapshotEnabled).toBe(false);

const { data } = await listBuckets({ config });
expect(data?.buckets.some((b) => b.name === name)).toBe(true);
});

it('should create a public bucket with directory listing disabled', async () => {
const name = testBucket('public');
bucketsToCleanup.push(name);

const result = await createBucket(name, {
access: 'public',
config,
});

expect(result.error).toBeUndefined();
expect(result.data).toBeDefined();

// Verify directory listing is disabled: unauthenticated ListObjects
// should return 403 even though the bucket is public
const endpoint = config.endpoint || 'https://t3.storage.dev';
const resp = await fetch(`${endpoint}/${name}?list-type=2`);
expect(resp.status).toBe(403);
});

it('should create a bucket with snapshot enabled', async () => {
const name = testBucket('snap');
bucketsToCleanup.push(name);

const result = await createBucket(name, {
enableSnapshot: true,
config,
});

expect(result.error).toBeUndefined();
expect(result.data?.isSnapshotEnabled).toBe(true);
});

it('should create a fork from a source bucket', async () => {
const sourceName = testBucket('fork-src');
const forkName = testBucket('fork-dst');
bucketsToCleanup.push(sourceName, forkName);

await createBucket(sourceName, {
enableSnapshot: true,
config,
});

const result = await createBucket(forkName, {
sourceBucketName: sourceName,
config,
});

expect(result.error).toBeUndefined();
expect(result.data?.sourceBucketName).toBe(sourceName);
});

it('should accept all location types', async () => {
const locations = [
{
suffix: 'single',
opts: { type: 'single' as const, values: 'iad' as const },
},
{
suffix: 'multi',
opts: { type: 'multi' as const, values: 'usa' as const },
},
{
suffix: 'dual',
opts: {
type: 'dual' as const,
values: ['iad', 'fra'] as ['iad', 'fra'],
},
},
{ suffix: 'global', opts: { type: 'global' as const } },
];

for (const { suffix, opts } of locations) {
const name = testBucket(`loc-${suffix}`);
bucketsToCleanup.push(name);

const result = await createBucket(name, {
locations: opts,
config,
});

expect(result.error, `${suffix} location failed`).toBeUndefined();
expect(result.data).toBeDefined();
}
});

it('should create a bucket with all options combined', async () => {
const name = testBucket('combined');
bucketsToCleanup.push(name);

const result = await createBucket(name, {
access: 'public',
defaultTier: 'STANDARD_IA',
enableSnapshot: true,
locations: { type: 'single', values: 'iad' },
config,
});

expect(result.error).toBeUndefined();
expect(result.data).toBeDefined();
expect(result.data?.isSnapshotEnabled).toBe(true);
});

// ── Validation errors ──

it('should return error for invalid region', async () => {
const result = await createBucket(testBucket('bad-region'), {
region: 'invalid-region',
config,
});

expect(result.error).toBeDefined();
expect(result.error?.message).toContain('Invalid regions specified');
});

it('should return error for invalid location', async () => {
const result = await createBucket(testBucket('bad-loc'), {
locations: { type: 'single', values: 'invalid' as 'iad' },
config,
});

expect(result.error).toBeDefined();
expect(result.error?.message).toContain('Invalid single-region location');
});

it('should return error when sourceBucketSnapshot is provided without sourceBucketName', async () => {
const result = await createBucket(testBucket('snap-no-src'), {
sourceBucketSnapshot: 'snap-123',
config,
});

expect(result.error).toBeDefined();
expect(result.error?.message).toBe(
'sourceBucketName is required when sourceBucketSnapshot is provided'
);
});
});
Loading