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
4 changes: 3 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ POSTGRES_DB=vrt_db_dev

# static
STATIC_SERVICE=hdd # hdd | s3 - hdd as default if not provided
# Enter below values if STATIC_SERVICE=s3
# AWSS3Service uses AWS SDK v3 default credential provider chain.
# You can use env vars below, shared AWS config/credentials, IAM role, web identity, or IAM Identity Center.
# AWS_S3_BUCKET_NAME is required when STATIC_SERVICE=s3.
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
Expand Down
174 changes: 174 additions & 0 deletions src/static/aws/s3.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PNG } from 'pngjs';
import { Readable } from 'stream';
import { AWSS3Service } from './s3.service';
import { generateNewImageName } from '../utils';

const mockSend = jest.fn();

jest.mock('@aws-sdk/client-s3', () => ({
S3Client: jest.fn().mockImplementation(() => ({
send: mockSend,
})),
PutObjectCommand: jest.fn().mockImplementation((input) => ({ input, type: 'put' })),
GetObjectCommand: jest.fn().mockImplementation((input) => ({ input, type: 'get' })),
DeleteObjectCommand: jest.fn().mockImplementation((input) => ({ input, type: 'delete' })),
}));

jest.mock('@aws-sdk/s3-request-presigner', () => ({
getSignedUrl: jest.fn(),
}));

jest.mock('../utils', () => ({
generateNewImageName: jest.fn(),
}));

describe('AWSS3Service', () => {
const originalAwsBucket = process.env.AWS_S3_BUCKET_NAME;

beforeEach(() => {
jest.clearAllMocks();
process.env.AWS_S3_BUCKET_NAME = 'vrt-bucket';
});

afterAll(() => {
process.env.AWS_S3_BUCKET_NAME = originalAwsBucket;
});

describe('saveImage', () => {
it('uploads the image buffer and returns the generated image name', async () => {
(generateNewImageName as jest.Mock).mockReturnValue('generated.screenshot.png');
mockSend.mockResolvedValue({});
const service = new AWSS3Service();
const imageBuffer = Buffer.from('png-data');

const result = await service.saveImage('screenshot', imageBuffer);

expect(generateNewImageName).toHaveBeenCalledWith('screenshot');
expect(PutObjectCommand).toHaveBeenCalledWith({
Bucket: 'vrt-bucket',
Key: 'generated.screenshot.png',
ContentType: 'image/png',
Body: imageBuffer,
});
expect(mockSend).toHaveBeenCalledWith({
input: {
Bucket: 'vrt-bucket',
Key: 'generated.screenshot.png',
ContentType: 'image/png',
Body: imageBuffer,
},
type: 'put',
});
expect(result).toBe('generated.screenshot.png');
});

it('wraps upload failures', async () => {
(generateNewImageName as jest.Mock).mockReturnValue('generated.diff.png');
mockSend.mockRejectedValue(new Error('upload failed'));
const service = new AWSS3Service();

await expect(service.saveImage('diff', Buffer.from('png-data'))).rejects.toThrow(
'Could not save file at AWS S3 : Error: upload failed'
);
});
});

describe('getImage', () => {
it('returns null when the file name is missing', async () => {
const service = new AWSS3Service();

await expect(service.getImage('')).resolves.toBeNull();
expect(mockSend).not.toHaveBeenCalled();
});

it('reads the image from S3 and parses it as PNG', async () => {
const service = new AWSS3Service();
const png = new PNG({ width: 1, height: 1 });
const pngBuffer = PNG.sync.write(png);
const stream = {
toArray: jest.fn().mockResolvedValue([pngBuffer.subarray(0, 8), pngBuffer.subarray(8)]),
} as unknown as Readable;
mockSend.mockResolvedValue({ Body: stream });

const result = await service.getImage('baseline.png');

expect(GetObjectCommand).toHaveBeenCalledWith({
Bucket: 'vrt-bucket',
Key: 'baseline.png',
});
expect(result).toMatchObject({ width: 1, height: 1 });
});

it('logs failures and returns undefined when the image cannot be read', async () => {
const service = new AWSS3Service();
const loggerSpy = jest.spyOn((service as any).logger, 'error').mockImplementation();
mockSend.mockRejectedValue(new Error('download failed'));

await expect(service.getImage('baseline.png')).resolves.toBeUndefined();

expect(loggerSpy).toHaveBeenCalledWith(
'Error from read : Cannot get image: baseline.png. Error: download failed'
);
});
});

describe('getImageUrl', () => {
it('returns a signed URL for the requested object', async () => {
const service = new AWSS3Service();
(getSignedUrl as jest.Mock).mockResolvedValue('https://signed-url');

const result = await service.getImageUrl('image.png');

expect(GetObjectCommand).toHaveBeenCalledWith({
Bucket: 'vrt-bucket',
Key: 'image.png',
});
expect(getSignedUrl).toHaveBeenCalledWith(
(service as any).s3Client,
{
input: {
Bucket: 'vrt-bucket',
Key: 'image.png',
},
type: 'get',
},
{ expiresIn: 3600 }
);
expect(result).toBe('https://signed-url');
});
});

describe('deleteImage', () => {
it('returns false when the image name is missing', async () => {
const service = new AWSS3Service();

await expect(service.deleteImage('')).resolves.toBe(false);
expect(mockSend).not.toHaveBeenCalled();
});

it('deletes the object and returns true', async () => {
const service = new AWSS3Service();
mockSend.mockResolvedValue({});

await expect(service.deleteImage('image.png')).resolves.toBe(true);

expect(DeleteObjectCommand).toHaveBeenCalledWith({
Bucket: 'vrt-bucket',
Key: 'image.png',
});
});

it('logs failures and returns false when deletion fails', async () => {
const service = new AWSS3Service();
const loggerSpy = jest.spyOn((service as any).logger, 'log').mockImplementation();
const error = new Error('delete failed');
mockSend.mockRejectedValue(error);

await expect(service.deleteImage('image.png')).resolves.toBe(false);

expect(loggerSpy).toHaveBeenCalledWith('Failed to delete file at AWS S3 for image image.png:', error);
});
});
});
11 changes: 1 addition & 10 deletions src/static/aws/s3.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,12 @@ import { generateNewImageName } from '../utils';

export class AWSS3Service implements Static {
private readonly logger: Logger = new Logger(AWSS3Service.name);
private readonly AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
private readonly AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
private readonly AWS_REGION = process.env.AWS_REGION;
private readonly AWS_S3_BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME;

private s3Client: S3Client;

constructor() {
this.s3Client = new S3Client({
credentials: {
accessKeyId: this.AWS_ACCESS_KEY_ID,
secretAccessKey: this.AWS_SECRET_ACCESS_KEY,
},
region: this.AWS_REGION,
});
this.s3Client = new S3Client();
this.logger.log('AWS S3 service is being used for file storage.');
}

Expand Down
Loading