diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..cb6e098 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,6 @@ +import { Module } from '@nestjs/common'; +import { EnvironmentModule } from './config/environment.module'; +import { HealthModule } from './health/health.module'; + +@Module({ imports: [EnvironmentModule, HealthModule] }) +export class AppModule {} diff --git a/src/configure-app.ts b/src/configure-app.ts new file mode 100644 index 0000000..11c5b33 --- /dev/null +++ b/src/configure-app.ts @@ -0,0 +1,13 @@ +import type { INestApplication } from '@nestjs/common'; +import helmet from 'helmet'; +import type { Environment } from './config/environment'; + +export function configureApp( + app: INestApplication, + environment: Environment, +): void { + app.setGlobalPrefix('api/v1'); + app.use(helmet()); + app.enableCors({ origin: environment.CORS_ORIGINS, credentials: true }); + app.enableShutdownHooks(); +} diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts new file mode 100644 index 0000000..36d46bf --- /dev/null +++ b/src/health/health.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get } from '@nestjs/common'; +import { HealthService } from './health.service'; + +@Controller('health') +export class HealthController { + constructor(private readonly health: HealthService) {} + + @Get('live') + live() { + return this.health.live(); + } + + @Get('ready') + ready() { + return this.health.ready(); + } +} diff --git a/src/health/health.module.ts b/src/health/health.module.ts new file mode 100644 index 0000000..b6fbcef --- /dev/null +++ b/src/health/health.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../database/database.module'; +import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; + +@Module({ + imports: [DatabaseModule], + controllers: [HealthController], + providers: [HealthService], +}) +export class HealthModule {} diff --git a/src/health/health.service.ts b/src/health/health.service.ts new file mode 100644 index 0000000..372dfb8 --- /dev/null +++ b/src/health/health.service.ts @@ -0,0 +1,20 @@ +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; + +@Injectable() +export class HealthService { + constructor(private readonly database: DatabaseService) {} + + live() { + return { status: 'ok' }; + } + + async ready() { + try { + await this.database.ping(); + } catch { + throw new ServiceUnavailableException('Service is not ready'); + } + return { status: 'ok' }; + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..651a877 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,21 @@ +import 'reflect-metadata'; +import 'dotenv/config'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { ENVIRONMENT } from './config/environment.module'; +import type { Environment } from './config/environment'; +import { configureApp } from './configure-app'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule); + const environment = app.get(ENVIRONMENT); + configureApp(app, environment); + await app.listen(environment.PORT); +} + +void bootstrap().catch(() => { + console.error( + 'Backend startup failed. Check configuration and database availability.', + ); + process.exitCode = 1; +}); diff --git a/test/health.spec.ts b/test/health.spec.ts new file mode 100644 index 0000000..945c616 --- /dev/null +++ b/test/health.spec.ts @@ -0,0 +1,70 @@ +import 'reflect-metadata'; +import { Test } from '@nestjs/testing'; +import type { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { DatabaseService } from '../src/database/database.service'; +import { ENVIRONMENT } from '../src/config/environment.module'; +import { configureApp } from '../src/configure-app'; +import { parseEnvironment } from '../src/config/environment'; + +describe('health API', () => { + let app: INestApplication; + const database = { ping: jest.fn() }; + + beforeAll(async () => { + const environment = parseEnvironment({ + DATABASE_URL: 'postgresql://localhost/mani', + CORS_ORIGINS: 'https://shop.example.com', + }); + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(DatabaseService) + .useValue(database) + .overrideProvider(ENVIRONMENT) + .useValue(environment) + .compile(); + app = module.createNestApplication(); + configureApp(app, environment); + await app.init(); + }); + afterAll(async () => { + await app.close(); + }); + beforeEach(() => { + database.ping.mockReset(); + }); + + it('reports liveness without requiring the database', async () => { + await request(app.getHttpServer()) + .get('/api/v1/health/live') + .expect(200, { status: 'ok' }); + expect(database.ping).not.toHaveBeenCalled(); + }); + it('checks the database before reporting readiness', async () => { + database.ping.mockResolvedValue(undefined); + await request(app.getHttpServer()) + .get('/api/v1/health/ready') + .expect(200, { status: 'ok' }); + expect(database.ping).toHaveBeenCalledTimes(1); + }); + it('returns 503 without leaking database errors', async () => { + database.ping.mockRejectedValue(new Error('postgresql://secret')); + const response = await request(app.getHttpServer()) + .get('/api/v1/health/ready') + .expect(503); + expect(response.text).not.toContain('secret'); + }); + it('sets security headers and permits only configured browser origins', async () => { + const allowed = await request(app.getHttpServer()) + .get('/api/v1/health/live') + .set('Origin', 'https://shop.example.com'); + expect(allowed.headers['x-content-type-options']).toBe('nosniff'); + expect(allowed.headers['access-control-allow-origin']).toBe( + 'https://shop.example.com', + ); + const denied = await request(app.getHttpServer()) + .get('/api/v1/health/live') + .set('Origin', 'https://untrusted.example.com'); + expect(denied.headers['access-control-allow-origin']).toBeUndefined(); + }); +});