From 7534236f825c2c3de7bd9262578923f087458ca4 Mon Sep 17 00:00:00 2001 From: "Adrian N." <7558172+adriannieto@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:32:11 +0100 Subject: [PATCH] Remove S3Client IAM static credentials to allow other auth methods If credentials are specified in S3Client other auth methods like IDSA will not work, as this disables the default behaviour of the AWS SDK client (https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html) With this change we should be able to remove the need of hardcoded credentials when accesing S3 in favor of more secure IDSA. --- src/static/aws/s3.service.spec.ts | 172 ++++++++++++++++++++++++++++++ src/static/aws/s3.service.ts | 11 +- 2 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 src/static/aws/s3.service.spec.ts diff --git a/src/static/aws/s3.service.spec.ts b/src/static/aws/s3.service.spec.ts new file mode 100644 index 0000000..35c32e9 --- /dev/null +++ b/src/static/aws/s3.service.spec.ts @@ -0,0 +1,172 @@ +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); + }); + }); +}); diff --git a/src/static/aws/s3.service.ts b/src/static/aws/s3.service.ts index 42a7a4c..50da45b 100644 --- a/src/static/aws/s3.service.ts +++ b/src/static/aws/s3.service.ts @@ -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.'); }