d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
246 lines
7.5 KiB
TypeScript
246 lines
7.5 KiB
TypeScript
/**
|
|
* Tests for Azure Blob Storage client
|
|
*
|
|
* @vitest-environment node
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
mockUpload,
|
|
mockDownload,
|
|
mockDelete,
|
|
mockDeleteIfExists,
|
|
mockGetBlockBlobClient,
|
|
mockGetContainerClient,
|
|
mockFromConnectionString,
|
|
mockStorageSharedKeyCredential,
|
|
mockGenerateBlobSASQueryParameters,
|
|
mockBlobSASPermissionsParse,
|
|
} = vi.hoisted(() => ({
|
|
mockUpload: vi.fn(),
|
|
mockDownload: vi.fn(),
|
|
mockDelete: vi.fn(),
|
|
mockDeleteIfExists: vi.fn(),
|
|
mockGetBlockBlobClient: vi.fn(),
|
|
mockGetContainerClient: vi.fn(),
|
|
mockFromConnectionString: vi.fn(),
|
|
mockStorageSharedKeyCredential: vi.fn(),
|
|
mockGenerateBlobSASQueryParameters: vi.fn(),
|
|
mockBlobSASPermissionsParse: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@azure/storage-blob', () => ({
|
|
BlobServiceClient: {
|
|
fromConnectionString: mockFromConnectionString,
|
|
},
|
|
StorageSharedKeyCredential: mockStorageSharedKeyCredential,
|
|
generateBlobSASQueryParameters: mockGenerateBlobSASQueryParameters,
|
|
BlobSASPermissions: {
|
|
parse: mockBlobSASPermissionsParse,
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/lib/uploads/config', () => ({
|
|
BLOB_CONFIG: {
|
|
accountName: 'testaccount',
|
|
accountKey: 'testkey',
|
|
connectionString:
|
|
'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net',
|
|
containerName: 'testcontainer',
|
|
},
|
|
}))
|
|
|
|
import {
|
|
deleteFromBlob,
|
|
downloadFromBlob,
|
|
getPresignedUrl,
|
|
parseConnectionString,
|
|
uploadToBlob,
|
|
} from '@/lib/uploads/providers/blob/client'
|
|
import { sanitizeFilenameForMetadata } from '@/lib/uploads/utils/file-utils'
|
|
|
|
describe('Azure Blob Storage Client', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
|
|
mockBlobSASPermissionsParse.mockReturnValue('r')
|
|
|
|
mockGetBlockBlobClient.mockReturnValue({
|
|
upload: mockUpload,
|
|
download: mockDownload,
|
|
delete: mockDelete,
|
|
deleteIfExists: mockDeleteIfExists,
|
|
url: 'https://test.blob.core.windows.net/container/test-file',
|
|
})
|
|
|
|
mockGetContainerClient.mockReturnValue({
|
|
getBlockBlobClient: mockGetBlockBlobClient,
|
|
})
|
|
|
|
mockFromConnectionString.mockReturnValue({
|
|
getContainerClient: mockGetContainerClient,
|
|
})
|
|
|
|
mockGenerateBlobSASQueryParameters.mockReturnValue({
|
|
toString: () => 'sv=2021-06-08&se=2023-01-01T00%3A00%3A00Z&sr=b&sp=r&sig=test',
|
|
})
|
|
})
|
|
|
|
describe('uploadToBlob', () => {
|
|
it('should upload a file to Azure Blob Storage', async () => {
|
|
const testBuffer = Buffer.from('test file content')
|
|
const fileName = 'test-file.txt'
|
|
const contentType = 'text/plain'
|
|
|
|
mockUpload.mockResolvedValueOnce({})
|
|
|
|
const result = await uploadToBlob(testBuffer, fileName, contentType)
|
|
|
|
expect(mockUpload).toHaveBeenCalledWith(testBuffer, testBuffer.length, {
|
|
blobHTTPHeaders: {
|
|
blobContentType: contentType,
|
|
},
|
|
metadata: {
|
|
originalName: encodeURIComponent(fileName),
|
|
uploadedAt: expect.any(String),
|
|
},
|
|
})
|
|
|
|
expect(result).toEqual({
|
|
path: expect.stringContaining('/api/files/serve/'),
|
|
key: expect.stringContaining(fileName.replace(/\s+/g, '-')),
|
|
name: fileName,
|
|
size: testBuffer.length,
|
|
type: contentType,
|
|
})
|
|
})
|
|
|
|
it('should handle custom blob configuration', async () => {
|
|
const testBuffer = Buffer.from('test file content')
|
|
const fileName = 'test-file.txt'
|
|
const contentType = 'text/plain'
|
|
const customConfig = {
|
|
containerName: 'customcontainer',
|
|
accountName: 'customaccount',
|
|
accountKey: 'customkey',
|
|
}
|
|
|
|
mockUpload.mockResolvedValueOnce({})
|
|
|
|
const result = await uploadToBlob(testBuffer, fileName, contentType, customConfig)
|
|
|
|
expect(mockGetContainerClient).toHaveBeenCalledWith('customcontainer')
|
|
expect(result.name).toBe(fileName)
|
|
expect(result.type).toBe(contentType)
|
|
})
|
|
})
|
|
|
|
describe('downloadFromBlob', () => {
|
|
it('should download a file from Azure Blob Storage', async () => {
|
|
const testKey = 'test-file-key'
|
|
const testContent = Buffer.from('downloaded content')
|
|
|
|
const mockReadableStream = {
|
|
on: vi.fn((event, callback) => {
|
|
if (event === 'data') {
|
|
callback(testContent)
|
|
} else if (event === 'end') {
|
|
callback()
|
|
}
|
|
}),
|
|
off: vi.fn(() => mockReadableStream),
|
|
}
|
|
|
|
mockDownload.mockResolvedValueOnce({
|
|
readableStreamBody: mockReadableStream,
|
|
})
|
|
|
|
const result = await downloadFromBlob(testKey)
|
|
|
|
expect(mockGetBlockBlobClient).toHaveBeenCalledWith(testKey)
|
|
expect(mockDownload).toHaveBeenCalled()
|
|
expect(result).toEqual(testContent)
|
|
})
|
|
|
|
it('should destroy the opened stream when content length exceeds the limit', async () => {
|
|
const mockDestroy = vi.fn()
|
|
const mockReadableStream = {
|
|
destroy: mockDestroy,
|
|
on: vi.fn(() => mockReadableStream),
|
|
}
|
|
|
|
mockDownload.mockResolvedValueOnce({
|
|
readableStreamBody: mockReadableStream,
|
|
contentLength: 1024,
|
|
})
|
|
|
|
await expect(downloadFromBlob('large-file-key', undefined, 10)).rejects.toThrow(
|
|
'storage download exceeds maximum size'
|
|
)
|
|
expect(mockDestroy).toHaveBeenCalledWith(expect.any(Error))
|
|
})
|
|
})
|
|
|
|
describe('deleteFromBlob', () => {
|
|
it('should delete a file from Azure Blob Storage', async () => {
|
|
const testKey = 'test-file-key'
|
|
|
|
mockDeleteIfExists.mockResolvedValueOnce({})
|
|
|
|
await deleteFromBlob(testKey)
|
|
|
|
expect(mockGetBlockBlobClient).toHaveBeenCalledWith(testKey)
|
|
expect(mockDeleteIfExists).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('getPresignedUrl', () => {
|
|
it('should generate a presigned URL for Azure Blob Storage', async () => {
|
|
const testKey = 'test-file-key'
|
|
const expiresIn = 3600
|
|
|
|
const result = await getPresignedUrl(testKey, expiresIn)
|
|
|
|
expect(mockGetBlockBlobClient).toHaveBeenCalledWith(testKey)
|
|
expect(mockGenerateBlobSASQueryParameters).toHaveBeenCalled()
|
|
expect(result).toContain('https://test.blob.core.windows.net/container/test-file')
|
|
expect(result).toContain('sv=2021-06-08')
|
|
})
|
|
})
|
|
|
|
describe('parseConnectionString', () => {
|
|
it('extracts accountName and accountKey from a well-formed connection string', () => {
|
|
const result = parseConnectionString(
|
|
'DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=mykey123;EndpointSuffix=core.windows.net'
|
|
)
|
|
expect(result).toEqual({ accountName: 'myaccount', accountKey: 'mykey123' })
|
|
})
|
|
|
|
it('throws when AccountName is missing', () => {
|
|
expect(() =>
|
|
parseConnectionString('DefaultEndpointsProtocol=https;AccountKey=mykey123')
|
|
).toThrow('Cannot extract account name from connection string')
|
|
})
|
|
|
|
it('throws when AccountKey is missing', () => {
|
|
expect(() =>
|
|
parseConnectionString('DefaultEndpointsProtocol=https;AccountName=myaccount')
|
|
).toThrow('Cannot extract account key from connection string')
|
|
})
|
|
})
|
|
|
|
describe('sanitizeFilenameForMetadata', () => {
|
|
const testCases = [
|
|
{ input: 'test file.txt', expected: 'test file.txt' },
|
|
{ input: 'test"file.txt', expected: 'testfile.txt' },
|
|
{ input: 'test\\file.txt', expected: 'testfile.txt' },
|
|
{ input: 'test file.txt', expected: 'test file.txt' },
|
|
{ input: '', expected: 'file' },
|
|
]
|
|
|
|
it.each(testCases)('should sanitize "$input" to "$expected"', ({ input, expected }) => {
|
|
expect(sanitizeFilenameForMetadata(input)).toBe(expected)
|
|
})
|
|
})
|
|
})
|