Skip to content
Open
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
39 changes: 39 additions & 0 deletions apps/backend/src/aws/s3/aws-s3.module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { AWSS3Module } from './aws-s3.module';

describe('AWSS3Module', () => {
let module: AWSS3Module;

beforeEach(() => {
process.env.AWS_ACCESS_KEY = 'test-access-key';
process.env.AWS_SECRET_KEY = 'test-secret-key';
module = new AWSS3Module();
});

it('should not throw when required env vars are set', () => {
expect(() => module.onModuleInit()).not.toThrow();
});

it('should throw if AWS_ACCESS_KEY is missing', () => {
delete process.env.AWS_ACCESS_KEY;

expect(() => module.onModuleInit()).toThrow(
'Missing required environment variable: AWS_ACCESS_KEY',
);
});

it('should throw if AWS_SECRET_KEY is missing', () => {
delete process.env.AWS_SECRET_KEY;

expect(() => module.onModuleInit()).toThrow(
'Missing required environment variable: AWS_SECRET_KEY',
);
});

it('should throw if an env var is whitespace-only', () => {
process.env.AWS_ACCESS_KEY = ' ';

expect(() => module.onModuleInit()).toThrow(
'Missing required environment variable: AWS_ACCESS_KEY',
);
});
});
17 changes: 15 additions & 2 deletions apps/backend/src/aws/s3/aws-s3.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { Global, Module } from '@nestjs/common';
import { Global, Module, OnModuleInit } from '@nestjs/common';
import { AWSS3Service } from './aws-s3.service';

// Required s3 env values
const REQUIRED_ENV_VARS = ['AWS_ACCESS_KEY', 'AWS_SECRET_KEY'] as const;

@Global()
@Module({
providers: [AWSS3Service],
exports: [AWSS3Service],
})
export class AWSS3Module {}
export class AWSS3Module implements OnModuleInit {
onModuleInit(): void {
for (const name of REQUIRED_ENV_VARS) {
const value = process.env[name];
// Treat unset and empty/whitespace-only values as missing.
if (!value || value.trim().length === 0) {
throw new Error(`Missing required environment variable: ${name}`);
}
}
}
}
16 changes: 0 additions & 16 deletions apps/backend/src/aws/s3/aws-s3.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,6 @@ describe('AWSS3Service', () => {
service['bucketNames'][testBucketEnum] = testBucket;
});

describe('constructor', () => {
it('should throw if AWS_ACCESS_KEY is missing', () => {
delete process.env.AWS_ACCESS_KEY;
expect(() => new AWSS3Service()).toThrow(
'Missing required environment variable: AWS_ACCESS_KEY',
);
});

it('should throw if AWS_SECRET_KEY is missing', () => {
delete process.env.AWS_SECRET_KEY;
expect(() => new AWSS3Service()).toThrow(
'Missing required environment variable: AWS_SECRET_KEY',
);
});
});

describe('upload', () => {
const validInput: S3UploadInput = {
fileBuffer: Buffer.from('test'),
Expand Down
18 changes: 7 additions & 11 deletions apps/backend/src/aws/s3/aws-s3.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,15 @@ export class AWSS3Service {
}
}

const accessKeyId = process.env.AWS_ACCESS_KEY;
const secretAccessKey = process.env.AWS_SECRET_KEY;

if (!accessKeyId) {
throw new Error('Missing required environment variable: AWS_ACCESS_KEY');
}
if (!secretAccessKey) {
throw new Error('Missing required environment variable: AWS_SECRET_KEY');
}

// AWS credentials are validated at module initialization (see AWSS3Module).
// The ?? '' only satisfies the type checker: if either var were missing,
// module init throws and the app never boots, so this client is never used.
this.client = new S3Client({
region: this.region,
credentials: { accessKeyId, secretAccessKey },
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY ?? '',
secretAccessKey: process.env.AWS_SECRET_KEY ?? '',
},
});
}

Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/aws/ses/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,5 @@ If you swap `AWS_SES_SENDER_EMAIL` later, the new address must be verified separ

A boolean env var (`'true'` to enable, anything else — including unset — to disable) that gates real SES dispatch.

- When `SEND_AUTOMATED_EMAILS === 'true'`: `sendEmail` runs DTO validation, then schedules the send through the rate limiter, then calls SES. Returns the `SendEmailCommandOutput` from SES (MessageId + metadata). `AWS_SES_SENDER_EMAIL` must be set at this point, or the send throws.
- When `SEND_AUTOMATED_EMAILS === 'true'`: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SES_SENDER_EMAIL` are validated at module initialization (`EmailsModule.onModuleInit`), so the app fails to boot if any are missing while enabled. `sendEmail` runs DTO validation, schedules the send through the rate limiter, then calls SES. Returns the `SendEmailCommandOutput` from SES (MessageId + metadata).
- When `SEND_AUTOMATED_EMAILS` is unset or any other value: `sendEmail` still runs DTO validation (so a bad payload still throws), then logs a warning (`SEND_AUTOMATED_EMAILS is not "true". Email not sent.`) and returns `void` without contacting SES. Neither `AWS_SES_SENDER_EMAIL` nor the AWS credentials need to be defined — teams not using SES can omit them entirely and the app still boots.
5 changes: 3 additions & 2 deletions apps/backend/src/aws/ses/awsSes.wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ export class AmazonSESWrapper {
* or if SES rejects the send (bad recipient, throttling, unverified sender, quota exceeded).
*/
async sendEmail(dto: SendEmailDTO): Promise<SendEmailCommandOutput> {
const senderEmail = process.env.AWS_SES_SENDER_EMAIL;
if (!senderEmail) throw new Error('AWS_SES_SENDER_EMAIL is not defined');
// Validated at module initialization (see EmailsModule) when SES is enabled;
// sendEmail is only ever reached when SEND_AUTOMATED_EMAILS is 'true', so senderEmail is guaranteed present here.
const senderEmail = process.env.AWS_SES_SENDER_EMAIL ?? '';

const mailOptions: Mail.Options = {
from: senderEmail,
Expand Down
18 changes: 8 additions & 10 deletions apps/backend/src/aws/ses/awsSesClient.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,16 @@ export const AmazonSESClientFactory: Provider<SESv2Client> = {
if (process.env.SEND_AUTOMATED_EMAILS !== 'true') {
return new SESv2Client({});
}
const region = process.env.AWS_REGION;
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;

if (!region) throw new Error('AWS_REGION is not defined');
if (!accessKeyId) throw new Error('AWS_ACCESS_KEY_ID is not defined');
if (!secretAccessKey)
throw new Error('AWS_SECRET_ACCESS_KEY is not defined');

// If email sending is enabled, EmailsModule.onModuleInit() aborts startup
// when these env vars are missing, so a client built with empty-string
// fallbacks is never actually used to send mail.
return new SESv2Client({
region,
credentials: { accessKeyId, secretAccessKey },
region: process.env.AWS_REGION ?? '',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '',
},
});
},
};
85 changes: 85 additions & 0 deletions apps/backend/src/aws/ses/email.module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { EmailsModule } from './email.module';

describe('EmailsModule', () => {
const ENV_VARS = [
'SEND_AUTOMATED_EMAILS',
'AWS_REGION',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SES_SENDER_EMAIL',
] as const;

const REQUIRED_WHEN_ENABLED = [
'AWS_REGION',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SES_SENDER_EMAIL',
] as const;

const originalEnv: Record<string, string | undefined> = {};
let module: EmailsModule;

beforeEach(() => {
for (const name of ENV_VARS) {
originalEnv[name] = process.env[name];
}

// Default to a fully-configured, enabled setup; individual tests override.
process.env.SEND_AUTOMATED_EMAILS = 'true';
process.env.AWS_REGION = 'us-east-2';
process.env.AWS_ACCESS_KEY_ID = 'test-access-key-id';
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-access-key';
process.env.AWS_SES_SENDER_EMAIL = 'sender@example.com';

module = new EmailsModule();
});

afterEach(() => {
for (const name of ENV_VARS) {
if (originalEnv[name] === undefined) {
delete process.env[name];
} else {
process.env[name] = originalEnv[name];
}
}
});

describe('onModuleInit', () => {
it('does not throw when all required env vars are set and enabled', () => {
expect(() => module.onModuleInit()).not.toThrow();
});

it('does not throw when disabled, even if required vars are missing', () => {
process.env.SEND_AUTOMATED_EMAILS = 'false';
for (const name of REQUIRED_WHEN_ENABLED) {
delete process.env[name];
}
expect(() => module.onModuleInit()).not.toThrow();
});

it('does not throw when SEND_AUTOMATED_EMAILS is unset', () => {
delete process.env.SEND_AUTOMATED_EMAILS;
for (const name of REQUIRED_WHEN_ENABLED) {
delete process.env[name];
}
expect(() => module.onModuleInit()).not.toThrow();
});

it.each(REQUIRED_WHEN_ENABLED)(
'throws when enabled and %s is missing',
(name) => {
delete process.env[name];
expect(() => module.onModuleInit()).toThrow(
`Missing required environment variable: ${name}`,
);
},
);

it('throws when enabled and a required var is empty/whitespace-only', () => {
process.env.AWS_SES_SENDER_EMAIL = ' ';
expect(() => module.onModuleInit()).toThrow(
'Missing required environment variable: AWS_SES_SENDER_EMAIL',
);
});
});
});
28 changes: 26 additions & 2 deletions apps/backend/src/aws/ses/email.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
import { Module } from '@nestjs/common';
import { Module, OnModuleInit } from '@nestjs/common';
import { EmailsService } from './email.service';
import { AmazonSESWrapper } from './awsSes.wrapper';
import { AmazonSESClientFactory } from './awsSesClient.factory';

// Env vars required only when SES dispatch is enabled (SEND_AUTOMATED_EMAILS === 'true')
const REQUIRED_ENV_VARS_WHEN_ENABLED = [
'AWS_REGION',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SES_SENDER_EMAIL',
] as const;

@Module({
providers: [AmazonSESWrapper, AmazonSESClientFactory, EmailsService],
exports: [EmailsService],
})
export class EmailsModule {}
export class EmailsModule implements OnModuleInit {
onModuleInit(): void {
// Email sending is disabled: skip validation so teams not using SES can
// boot without any AWS config.
if (process.env.SEND_AUTOMATED_EMAILS?.toLowerCase() !== 'true') {
return;
}

for (const name of REQUIRED_ENV_VARS_WHEN_ENABLED) {
const value = process.env[name];
// Treat unset and empty/whitespace-only values as missing.
if (!value || value.trim().length === 0) {
throw new Error(`Missing required environment variable: ${name}`);
}
}
}
}
Loading