diff --git a/apps/backend/src/aws/s3/aws-s3.module.spec.ts b/apps/backend/src/aws/s3/aws-s3.module.spec.ts new file mode 100644 index 000000000..1336efb9d --- /dev/null +++ b/apps/backend/src/aws/s3/aws-s3.module.spec.ts @@ -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', + ); + }); +}); diff --git a/apps/backend/src/aws/s3/aws-s3.module.ts b/apps/backend/src/aws/s3/aws-s3.module.ts index a3a6a2482..8f40f5b4d 100644 --- a/apps/backend/src/aws/s3/aws-s3.module.ts +++ b/apps/backend/src/aws/s3/aws-s3.module.ts @@ -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}`); + } + } + } +} diff --git a/apps/backend/src/aws/s3/aws-s3.service.spec.ts b/apps/backend/src/aws/s3/aws-s3.service.spec.ts index 0e7659d08..661925dde 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.spec.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.spec.ts @@ -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'), diff --git a/apps/backend/src/aws/s3/aws-s3.service.ts b/apps/backend/src/aws/s3/aws-s3.service.ts index f4da46362..928fade60 100644 --- a/apps/backend/src/aws/s3/aws-s3.service.ts +++ b/apps/backend/src/aws/s3/aws-s3.service.ts @@ -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 ?? '', + }, }); } diff --git a/apps/backend/src/aws/ses/README.md b/apps/backend/src/aws/ses/README.md index 3841717d9..b2a6e63c3 100644 --- a/apps/backend/src/aws/ses/README.md +++ b/apps/backend/src/aws/ses/README.md @@ -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. diff --git a/apps/backend/src/aws/ses/awsSes.wrapper.ts b/apps/backend/src/aws/ses/awsSes.wrapper.ts index c3893bbd3..2714f8096 100644 --- a/apps/backend/src/aws/ses/awsSes.wrapper.ts +++ b/apps/backend/src/aws/ses/awsSes.wrapper.ts @@ -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 { - 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, diff --git a/apps/backend/src/aws/ses/awsSesClient.factory.ts b/apps/backend/src/aws/ses/awsSesClient.factory.ts index ea288070f..b38989d5c 100644 --- a/apps/backend/src/aws/ses/awsSesClient.factory.ts +++ b/apps/backend/src/aws/ses/awsSesClient.factory.ts @@ -15,18 +15,16 @@ export const AmazonSESClientFactory: Provider = { 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 ?? '', + }, }); }, }; diff --git a/apps/backend/src/aws/ses/email.module.spec.ts b/apps/backend/src/aws/ses/email.module.spec.ts new file mode 100644 index 000000000..0c1359b55 --- /dev/null +++ b/apps/backend/src/aws/ses/email.module.spec.ts @@ -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 = {}; + 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', + ); + }); + }); +}); diff --git a/apps/backend/src/aws/ses/email.module.ts b/apps/backend/src/aws/ses/email.module.ts index a6cd1bd12..9f74fa3c1 100644 --- a/apps/backend/src/aws/ses/email.module.ts +++ b/apps/backend/src/aws/ses/email.module.ts @@ -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}`); + } + } + } +}