Skip to content
Closed
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
4 changes: 4 additions & 0 deletions src/lib/server/storage/hdfs-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import type { StoragePage, StorageMetadata } from '$lib/storage/types.js';
export class HDFSStorageProvider implements StorageProvider {
constructor(_config: HDFSConfig) {}

listContainers(): Promise<string[]> {
throw new Error('HDFS not implemented');
}

listObjects(
_prefix: string,
_pageSize: number,
Expand Down
1 change: 1 addition & 0 deletions src/lib/server/storage/preview/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const mockLog = { info: vi.fn(), debug: vi.fn(), warn: vi.fn() } as unknown as p

function makeProvider(overrides: Partial<StorageProvider> = {}): StorageProvider {
return {
listContainers: vi.fn(),
listObjects: vi.fn(),
getObject: vi
.fn()
Expand Down
2 changes: 2 additions & 0 deletions src/lib/server/storage/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface ObjectDownload {

/** Backend-agnostic interface for a bucket-scoped storage provider. */
export interface StorageProvider {
/** List all buckets accessible with the current connection credentials. */
listContainers(): Promise<string[]>;
/**
* List objects using cursor-based pagination. `continuationToken` is the
* provider-specific opaque token returned from a previous call. When
Expand Down
181 changes: 181 additions & 0 deletions src/lib/server/storage/s3-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,3 +509,184 @@ describe('S3StorageProvider constructor', () => {
);
});
});

// ---------------------------------------------------------------------------
// listContainers
// ---------------------------------------------------------------------------

describe('S3StorageProvider.listContainers', () => {
let provider: S3StorageProvider;
let send: ReturnType<typeof vi.fn>;

beforeEach(() => {
({ provider, send } = makeProvider());
});

it('returns bucket names', async () => {
send.mockResolvedValue({ Buckets: [{ Name: 'a' }, { Name: 'b' }] });
expect(await provider.listContainers()).toEqual(['a', 'b']);
});

it('filters out buckets with no name', async () => {
send.mockResolvedValue({ Buckets: [{ Name: 'a' }, { Name: undefined }, { Name: '' }] });
expect(await provider.listContainers()).toEqual(['a']);
});

it('handles null Buckets in response', async () => {
send.mockResolvedValue({ Buckets: null });
expect(await provider.listContainers()).toEqual([]);
});

it('maps AccessDenied S3 error to HTTP 403', async () => {
send.mockRejectedValue(makeS3Error('AccessDenied', 403));
await expect(provider.listContainers()).rejects.toMatchObject({ status: 403 });
});

it('re-throws non-S3 errors', async () => {
const err = new Error('network');
send.mockRejectedValue(err);
await expect(provider.listContainers()).rejects.toThrow('network');
});
});

// ---------------------------------------------------------------------------
// Error handling — S3ServiceException → HTTP error mapping
// ---------------------------------------------------------------------------

describe('S3StorageProvider error handling', () => {
let provider: S3StorageProvider;
let send: ReturnType<typeof vi.fn>;

beforeEach(() => {
({ provider, send } = makeProvider());
});

it('listObjects: maps AccessDenied to HTTP 403', async () => {
send.mockRejectedValue(makeS3Error('AccessDenied', 403));
await expect(provider.listObjects('', 10)).rejects.toMatchObject({ status: 403 });
});

it('listObjects: maps NoSuchBucket to HTTP 404', async () => {
send.mockRejectedValue(makeS3Error('NoSuchBucket', 404));
await expect(provider.listObjects('', 10)).rejects.toMatchObject({ status: 404 });
});

it('listObjects: re-throws non-S3 errors', async () => {
send.mockRejectedValue(new Error('timeout'));
await expect(provider.listObjects('', 10)).rejects.toThrow('timeout');
});

it('getObject: maps NoSuchKey to HTTP 404', async () => {
send.mockRejectedValue(makeS3Error('NoSuchKey', 404));
await expect(provider.getObject('k')).rejects.toMatchObject({ status: 404 });
});

it('getObject: maps AccessDenied to HTTP 403', async () => {
send.mockRejectedValue(makeS3Error('AccessDenied', 403));
await expect(provider.getObject('k')).rejects.toMatchObject({ status: 403 });
});

it('getObject: re-throws non-S3 errors', async () => {
send.mockRejectedValue(new Error('gone'));
await expect(provider.getObject('k')).rejects.toThrow('gone');
});

it('getObjectRange: maps S3 error to HTTP error', async () => {
send.mockRejectedValue(makeS3Error('NoSuchKey', 404));
await expect(provider.getObjectRange('k', 0, 100)).rejects.toMatchObject({ status: 404 });
});

it('getObjectRange: re-throws non-S3 errors', async () => {
send.mockRejectedValue(new Error('network'));
await expect(provider.getObjectRange('k', 0, 100)).rejects.toThrow('network');
});

it('getMetadata: maps NoSuchKey to HTTP 404', async () => {
send.mockRejectedValue(makeS3Error('NoSuchKey', 404));
await expect(provider.getMetadata('k')).rejects.toMatchObject({ status: 404 });
});

it('getMetadata: re-throws non-S3 errors', async () => {
send.mockRejectedValue(new Error('gone'));
await expect(provider.getMetadata('k')).rejects.toThrow('gone');
});

it('putObject: maps AccessDenied to HTTP 403', async () => {
mockUploadDone.mockRejectedValue(makeS3Error('AccessDenied', 403));
MockUpload.mockImplementationOnce(function (this: { done: typeof mockUploadDone }) {
this.done = mockUploadDone;
});
await expect(provider.putObject('k', Buffer.from('x'), 'text/plain')).rejects.toMatchObject({
status: 403
});
});

it('putObject: re-throws non-S3 errors', async () => {
mockUploadDone.mockRejectedValue(new Error('disk full'));
MockUpload.mockImplementationOnce(function (this: { done: typeof mockUploadDone }) {
this.done = mockUploadDone;
});
await expect(provider.putObject('k', Buffer.from('x'), 'text/plain')).rejects.toThrow(
'disk full'
);
});

it('deleteObjects: maps AccessDenied to HTTP 403', async () => {
send.mockRejectedValue(makeS3Error('AccessDenied', 403));
await expect(provider.deleteObjects(['file.txt'])).rejects.toMatchObject({ status: 403 });
});

it('deleteObjects: re-throws non-S3 errors', async () => {
send.mockRejectedValue(new Error('network'));
await expect(provider.deleteObjects(['file.txt'])).rejects.toThrow('network');
});
});

// ---------------------------------------------------------------------------
// deleteObjects — directory expansion
// ---------------------------------------------------------------------------

describe('S3StorageProvider.deleteObjects directory expansion', () => {
let provider: S3StorageProvider;
let send: ReturnType<typeof vi.fn>;

beforeEach(() => {
({ provider, send } = makeProvider());
});

it('expands directory prefixes and deletes all contained keys', async () => {
// First call: ListObjectsV2 for dir/
send.mockResolvedValueOnce({
Contents: [{ Key: 'dir/a.txt' }, { Key: 'dir/b.txt' }],
IsTruncated: false
});
// Second call: DeleteObjects
send.mockResolvedValueOnce({ Errors: [] });

const result = await provider.deleteObjects(['file.txt', 'dir/']);

const deleteCall = send.mock.calls[1][0];
expect(deleteCall.input.Delete.Objects).toEqual([
{ Key: 'file.txt' },
{ Key: 'dir/a.txt' },
{ Key: 'dir/b.txt' }
]);
expect(result).toEqual({ failed: [] });
});

it('uses directory key itself when no children found', async () => {
send.mockResolvedValueOnce({ IsTruncated: false }); // listAllKeys returns []
send.mockResolvedValueOnce({ Errors: [] }); // deleteObjects

await provider.deleteObjects(['empty/']);

const deleteCall = send.mock.calls[1][0];
expect(deleteCall.input.Delete.Objects).toEqual([{ Key: 'empty/' }]);
});

it('returns empty result for no keys', async () => {
const result = await provider.deleteObjects([]);
expect(result).toEqual({ failed: [] });
expect(send).not.toHaveBeenCalled();
});
});
Loading
Loading