Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b3dacea
s3 module throws error on startup if missing env variables
chnnick Jul 22, 2026
c649251
if SEND_AUTOMATED_EMAILS env variable is set to true, throws errors if
chnnick Jul 22, 2026
6705b1b
clarification comment on factory for potential bad env variables in
chnnick Jul 22, 2026
6611134
make dummy client case insensitive
chnnick Aug 17, 2026
c381b3c
optional check for SEND_AUTOMATED_EMAILS env var
chnnick Aug 25, 2026
d142e24
warn when missing any ses/s3-related env vars, list missing vars in
chnnick Aug 25, 2026
1935c1f
require specific name for buckets, clarification in documentation
chnnick Aug 25, 2026
e66c74f
change name of email test, checks for warnings instead of errors. CHecks
chnnick Aug 25, 2026
f5d2d8e
s3 tests
chnnick Aug 25, 2026
103d279
rename emailsmodule to sesmodule
chnnick Aug 25, 2026
98940b0
add instructions for required env variable handling + naming, rename SES
chnnick Aug 25, 2026
b2f8197
update ses module name within users
chnnick Aug 25, 2026
a464e2e
match optional + lower case check in module to service
chnnick Aug 27, 2026
444d9dd
normalize aws env variables
chnnick Sep 3, 2026
9e2028d
typed bucket in getImageData method, updated tests to pass newly typed
chnnick Sep 3, 2026
d5400d0
s3 enabled flag
chnnick Sep 3, 2026
f92c1b5
tests for new s3 enabled flag,
chnnick Sep 3, 2026
ba32e14
utils function for getMissingEnvVars, added 140-cognito's isNonEmptyEnv
chnnick Sep 5, 2026
25cda18
comment update don't reference other modules
chnnick Sep 5, 2026
5df70a8
change SEND_AUTOMATED_EMAILS name to SES_ENABLED
chnnick Sep 5, 2026
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
37 changes: 25 additions & 12 deletions apps/backend/src/aws/s3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,59 @@ A global NestJS module providing S3 file upload and retrieval via `AWSS3Service`
Add these variables to `.env` and `example.env`:

```
S3_ENABLED=true
AWS_REGION=us-east-2
AWS_ACCESS_KEY=your-access-key-id
AWS_SECRET_KEY=your-secret-access-key
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
# one entry per bucket — see "Adding a New Bucket" below
AWS_MY_BUCKET_NAME=my-bucket-name
```

`AWS_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` are shared across every AWS module in this app (S3, SES, …) and use the AWS SDK's standard names — define them once and don't rename them per-service. Only `S3_ENABLED` and the bucket vars are S3-specific.

Import `AWSS3Module` once in your root `AppModule`. Because the module is `@Global()`, `AWSS3Service` is injectable in all feature modules without additional imports.

The service throws at startup if any bucket env var is unset, so misconfiguration is caught immediately rather than at runtime.
Every bucket env var **must** follow the `AWS_<BUCKET>_BUCKET_NAME` format, where `<BUCKET>` is the `S3Buckets` enum member verbatim. `AWSS3Service` builds its bucket lookup from that convention, so a name that doesn't match will never be found.

## `S3_ENABLED`

`S3_ENABLED` gates the startup config check.SES_ENABLED When it is anything other than `'true'` (case-insensitive), `AWSS3Module.onModuleInit` skips validation entirely and logs `S3 disabled: …`, so a project that doesn't use S3 boots without AWS config and without a warning on every startup.

When it is `'true'`, `onModuleInit` logs a warning listing any env var in its `REQUIRED_ENV_VARS_WHEN_ENABLED` list that is unset or blank.

The flag only controls that check — it does not disable `AWSS3Service`. Calls to `upload()` / `getImageData()` still reach AWS and fail there if credentials are absent.

## Adding a New Bucket

**1. Add an env var** in `.env` and `example.env`:
**1. Add an env var** in `.env` and `example.env`, named `AWS_<BUCKET>_BUCKET_NAME`:

```
AWS_MY_BUCKET_NAME=my-bucket-name
```

**2. Add an entry to the `s3Buckets` enum** (`types/s3Buckets.ts`):
**2. Add an entry to the `S3Buckets` enum** (`types/s3Buckets.ts`), matching the middle of that env var name:

```typescript
export enum s3Buckets {
export enum S3Buckets {
MY_BUCKET = 'MY_BUCKET',
}
```

**3. Add a mapping to `mapBucket`** (`aws-s3.service.ts`):
**3. Add the env var name to `REQUIRED_ENV_VARS_WHEN_ENABLED`** (`aws-s3.module.ts`), so a missing value is reported at startup when `S3_ENABLED=true`:

```typescript
const bucketNames: Record<s3Buckets, string> = {
[s3Buckets.MY_BUCKET]: process.env.AWS_MY_BUCKET_NAME,
};
const REQUIRED_ENV_VARS_WHEN_ENABLED = [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_MY_BUCKET_NAME',
] as const;
```

Because `mapBucket` uses a `Record<s3Buckets, string>`, TypeScript will produce a compile error if you add an enum entry without adding the corresponding mapping — catching missed steps at build time.
No change to `aws-s3.service.ts` is needed: its constructor resolves `process.env['AWS_' + bucket + '_BUCKET_NAME']` for every member of `S3Buckets`. A bucket whose env var is missing resolves to `''`, and any `upload()` to it throws `Missing required environment variable for S3 bucket: MY_BUCKET`.

## Required IAM Permissions

The credentials supplied via `AWS_ACCESS_KEY` / `AWS_SECRET_KEY` must have:
The credentials supplied via `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` must have:

| Permission | Required by |
|---|---|
Expand Down
120 changes: 120 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,120 @@
import { AWSS3Module } from './aws-s3.module';

describe('AWSS3Module', () => {
const ENV_VARS = [
'S3_ENABLED',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
] as const;

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

const originalEnv: Record<string, string | undefined> = {};
let module: AWSS3Module;
let warnSpy: jest.SpyInstance;
let logSpy: jest.SpyInstance;

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

// Default to a fully-configured, enabled setup; individual tests override.
process.env.S3_ENABLED = 'true';
process.env.AWS_ACCESS_KEY_ID = 'test-access-key';
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-key';

module = new AWSS3Module();

warnSpy = jest
.spyOn(module['logger'], 'warn')
.mockImplementation(() => undefined);
logSpy = jest
.spyOn(module['logger'], 'log')
.mockImplementation(() => undefined);
});

afterEach(() => {
jest.restoreAllMocks();

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

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

expect(warnSpy).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith('S3 enabled');
});

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

module.onModuleInit();

expect(warnSpy).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('S3 disabled'),
);
});

it('does not warn when S3_ENABLED is unset', () => {
delete process.env.S3_ENABLED;
for (const name of REQUIRED_WHEN_ENABLED) {
delete process.env[name];
}

module.onModuleInit();

expect(warnSpy).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('S3 disabled'),
);
});

it.each(REQUIRED_WHEN_ENABLED)(
'warns when enabled and %s is missing',
(name) => {
delete process.env[name];

expect(() => module.onModuleInit()).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(name));
},
);

it('warns when enabled and a required var is empty/whitespace-only', () => {
process.env.AWS_ACCESS_KEY_ID = ' ';

expect(() => module.onModuleInit()).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('AWS_ACCESS_KEY_ID'),
);
});

it('lists every missing env var in a single warning', () => {
for (const name of REQUIRED_WHEN_ENABLED) {
delete process.env[name];
}

module.onModuleInit();

expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY'),
);
});
});
});
38 changes: 36 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,43 @@
import { Global, Module } from '@nestjs/common';
import { Global, Logger, Module, OnModuleInit } from '@nestjs/common';
import { AWSS3Service } from './aws-s3.service';
import { getMissingEnvVars } from '../../utils/env';

// Env vars required only when S3 is enabled (S3_ENABLED === 'true').
// The credentials are the shared AWS ones (also used by the SES module).
// Add one entry per bucket here:
const REQUIRED_ENV_VARS_WHEN_ENABLED = [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
] as const;

@Global()
@Module({
providers: [AWSS3Service],
exports: [AWSS3Service],
})
export class AWSS3Module {}
export class AWSS3Module implements OnModuleInit {
private readonly logger = new Logger(AWSS3Module.name);

onModuleInit(): void {
// S3 is disabled: skip validation so teams not using S3 can boot without
// any AWS config (and without a warning on every startup).
if (process.env.S3_ENABLED?.toLowerCase() !== 'true') {
this.logger.log(
'S3 disabled: S3_ENABLED is not "true". Uploads and downloads will fail.',
);
return;
}

const missing = getMissingEnvVars(REQUIRED_ENV_VARS_WHEN_ENABLED);

if (missing.length > 0) {
this.logger.warn(
`S3 enabled but not fully configured: missing env vars (${missing.join(
', ',
)}). S3 uploads and downloads will fail.`,
);
} else {
this.logger.log('S3 enabled');
}
}
}
38 changes: 14 additions & 24 deletions apps/backend/src/aws/s3/aws-s3.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,19 @@ describe('AWSS3Service', () => {
let service: AWSS3Service;

beforeEach(() => {
process.env.AWS_ACCESS_KEY = 'test-access-key';
process.env.AWS_SECRET_KEY = 'test-secret-key';
process.env.AWS_ACCESS_KEY_ID = 'test-access-key';
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-key';
process.env.AWS_REGION = region;
process.env.AWS_TEST_BUCKET_NAME = testBucket;

s3Mock.reset();
service = new AWSS3Service();

// The constructor resolves AWS_<BUCKET>_BUCKET_NAME for every member of
// S3Buckets, but the scaffold enum is empty — inject the sentinel by hand.
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 Expand Up @@ -174,15 +160,19 @@ describe('AWSS3Service', () => {
} as unknown as GetObjectCommandOutput['Body'],
});

const result = await service.getImageData('photo.jpg', testBucket);
const result = await service.getImageData('photo.jpg', testBucketEnum);

const commandCall = s3Mock.call(0);
expect((commandCall.args[0] as GetObjectCommand).input.Bucket).toBe(
testBucket,
);
expect(result).toBe(imageBytes);
});

it('should return null when response body is missing', async () => {
s3Mock.on(GetObjectCommand).resolves({ Body: undefined });

const result = await service.getImageData('photo.jpg', testBucket);
const result = await service.getImageData('photo.jpg', testBucketEnum);

expect(result).toBeNull();
});
Expand All @@ -192,7 +182,7 @@ describe('AWSS3Service', () => {
.on(GetObjectCommand)
.rejects(new NoSuchKey({ message: 'Not found', $metadata: {} }));

const result = await service.getImageData('missing.jpg', testBucket);
const result = await service.getImageData('missing.jpg', testBucketEnum);

expect(result).toBeNull();
});
Expand All @@ -213,19 +203,19 @@ describe('AWSS3Service', () => {
.spyOn(service['logger'], 'error')
.mockImplementation(() => undefined);

const result = await service.getImageData('photo.jpg', testBucket);
const result = await service.getImageData('photo.jpg', testBucketEnum);

expect(result).toBeNull();
expect(loggerErrorSpy).toHaveBeenCalledWith(
`S3 error retrieving object: key=photo.jpg, bucket=${testBucket}, error=Access denied`,
`S3 error retrieving object: key=photo.jpg, bucket=${testBucketEnum}, error=Access denied`,
);
});

it('should rethrow non-S3 errors', async () => {
s3Mock.on(GetObjectCommand).rejects(new Error('network error'));

await expect(
service.getImageData('photo.jpg', testBucket),
service.getImageData('photo.jpg', testBucketEnum),
).rejects.toThrow('network error');
});
});
Expand Down
35 changes: 15 additions & 20 deletions apps/backend/src/aws/s3/aws-s3.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,31 +41,23 @@ export class AWSS3Service {
constructor() {
this.region = process.env.AWS_REGION ?? 'us-east-2';

// Add one entry per bucket in s3Buckets enum.
// Example: [s3Buckets.DOCUMENTS]: process.env.AWS_DOCUMENTS_BUCKET_NAME,
// Every bucket in the S3Buckets enum is read from an env var named AWS_<BUCKET>_BUCKET_NAME:
// - e.g. S3Buckets.DOCUMENTS reads AWS_DOCUMENTS_BUCKET_NAME. Add each of those names to REQUIRED_ENV_VARS_WHEN_ENABLED
this.bucketNames = {} as Record<S3Buckets, string>;

for (const bucket of Object.values(S3Buckets) as unknown as S3Buckets[]) {
if (!this.bucketNames[bucket]) {
throw new Error(
`Missing required environment variable for S3 bucket: ${bucket}`,
);
}
}

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');
this.bucketNames[bucket] = process.env[`AWS_${bucket}_BUCKET_NAME`] ?? '';
}

// AWS credentials are checked at module initialization (see AWSS3Module),
// which warns rather than throws. The ?? '' keeps the client constructible
// when they are absent; requests against it then fail at the AWS call.
this.client = new S3Client({
region: this.region,
credentials: { accessKeyId, secretAccessKey },
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '',
},
});
}

Expand Down Expand Up @@ -126,10 +118,13 @@ export class AWSS3Service {

async getImageData(
objectKey: string,
bucket: string,
bucket: S3Buckets,
): Promise<Uint8Array | null> {
try {
const command = new GetObjectCommand({ Bucket: bucket, Key: objectKey });
const command = new GetObjectCommand({
Bucket: this.bucketNames[bucket],
Key: objectKey,
});
const response = await this.client.send(command);
if (!response.Body) {
return null;
Expand Down
Loading
Loading