diff --git a/apps/backend/src/health/health.controller.ts b/apps/backend/src/health/health.controller.ts index ebdec8f4..40d6fecf 100644 --- a/apps/backend/src/health/health.controller.ts +++ b/apps/backend/src/health/health.controller.ts @@ -9,6 +9,8 @@ import { import type { Response } from 'express'; import { ContractHealthService } from './contract-health.service'; import { HealthService } from './health.service'; +import { SmokeEndpointService } from './smoke-endpoint.service'; +import { SmokeEndpointReport } from './smoke-endpoint.dto'; @ApiTags('health') @Controller() @@ -16,6 +18,7 @@ export class HealthController { constructor( private readonly healthService: HealthService, private readonly contractHealthService: ContractHealthService, + private readonly smokeEndpointService: SmokeEndpointService, ) {} @Get('health') @@ -57,6 +60,27 @@ export class HealthController { return healthReport; } + @Get('smoke') + @ApiOperation({ + summary: 'Deployment smoke endpoint for CI/Vercel checks', + description: + 'Verifies environment variables are present and contract IDs are reachable. ' + + 'Returns machine-readable status suitable for CI monitoring. ' + + 'Safe to expose publicly - no secrets are leaked.', + }) + @ApiOkResponse({ + description: 'Smoke check passed - all dependencies ready', + type: SmokeEndpointReport, + }) + @ApiServiceUnavailableResponse({ + description: 'Smoke check failed - missing or unreachable dependencies', + }) + async getSmoke(@Res({ passthrough: true }) response: Response) { + const smokeReport = await this.smokeEndpointService.getSmokeReport(); + + response.status(smokeReport.status === 'ok' ? 200 : 503); + + return smokeReport; @Get('health/latency') @ApiOperation({ summary: diff --git a/apps/backend/src/health/health.module.ts b/apps/backend/src/health/health.module.ts index e8f38d4a..39e706ee 100644 --- a/apps/backend/src/health/health.module.ts +++ b/apps/backend/src/health/health.module.ts @@ -6,6 +6,7 @@ import { StellarModule } from '../stellar/stellar.module'; import { ContractHealthService } from './contract-health.service'; import { HealthController } from './health.controller'; import { HealthService } from './health.service'; +import { SmokeEndpointService } from './smoke-endpoint.service'; import { LatencyBudgetHealthService } from './latency-budget.health.service'; @Module({ @@ -19,6 +20,7 @@ import { LatencyBudgetHealthService } from './latency-budget.health.service'; StellarModule, ], controllers: [HealthController], + providers: [HealthService, ContractHealthService, SmokeEndpointService], providers: [HealthService, ContractHealthService, LatencyBudgetHealthService], }) export class HealthModule {} diff --git a/apps/backend/src/health/smoke-endpoint.dto.ts b/apps/backend/src/health/smoke-endpoint.dto.ts new file mode 100644 index 00000000..7c9d3fbd --- /dev/null +++ b/apps/backend/src/health/smoke-endpoint.dto.ts @@ -0,0 +1,90 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SmokeEnvVarCheck { + @ApiProperty({ + description: 'Environment variable name', + example: 'STELLAR_HORIZON_URL', + }) + name: string; + + @ApiProperty({ + description: 'Whether the environment variable is set', + example: true, + }) + configured: boolean; + + @ApiProperty({ + description: 'Current value (redacted if sensitive)', + example: 'https://horizon-testnet.stellar.org', + nullable: true, + }) + value?: string; +} + +export class SmokeContractCheck { + @ApiProperty({ + description: 'Contract name', + example: 'lumenToken', + }) + name: string; + + @ApiProperty({ + description: 'Environment variable name', + example: 'STELLAR_CONTRACT_LUMEN_TOKEN', + }) + envVar: string; + + @ApiProperty({ + description: 'Whether the contract ID is configured', + example: true, + }) + configured: boolean; + + @ApiProperty({ + description: 'Contract reachability status', + enum: ['reachable', 'misconfigured', 'unreachable'], + example: 'reachable', + }) + status: 'reachable' | 'misconfigured' | 'unreachable'; + + @ApiProperty({ + description: 'Redacted contract ID for verification', + example: 'CDLZF...T7GY6', + nullable: true, + }) + contractId?: string; +} + +export class SmokeEndpointReport { + @ApiProperty({ + description: 'Overall smoke test status', + enum: ['ok', 'error'], + example: 'ok', + }) + status: 'ok' | 'error'; + + @ApiProperty({ + description: 'Network being checked', + enum: ['testnet', 'mainnet'], + example: 'testnet', + }) + network: 'testnet' | 'mainnet'; + + @ApiProperty({ + description: 'When the smoke test was performed', + example: '2024-01-15T10:30:00.000Z', + }) + checkedAt: string; + + @ApiProperty({ + description: 'Required environment variable checks', + type: [SmokeEnvVarCheck], + }) + envVars: SmokeEnvVarCheck[]; + + @ApiProperty({ + description: 'Contract reachability checks', + type: [SmokeContractCheck], + }) + contracts: SmokeContractCheck[]; +} \ No newline at end of file diff --git a/apps/backend/src/health/smoke-endpoint.service.spec.ts b/apps/backend/src/health/smoke-endpoint.service.spec.ts new file mode 100644 index 00000000..2549ef26 --- /dev/null +++ b/apps/backend/src/health/smoke-endpoint.service.spec.ts @@ -0,0 +1,111 @@ +const mockContractHealthService = { + getContractHealthReport: jest.fn(), +}; + +jest.mock('../lib/config', () => ({ + config: { + stellar: { + network: 'testnet', + contracts: { + lumenToken: null, + crowdfundVault: null, + projectRegistry: null, + contributorRegistry: null, + matchingPool: null, + treasury: null, + }, + }, + }, +})); + +import { SmokeEndpointService } from './smoke-endpoint.service'; + +describe('SmokeEndpointService', () => { + let service: SmokeEndpointService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new SmokeEndpointService(mockContractHealthService as any); + }); + + describe('getSmokeReport', () => { + it('returns smoke report with env var checks and contract checks', async () => { + mockContractHealthService.getContractHealthReport.mockResolvedValue({ + status: 'ok', + contracts: [ + { + name: 'lumenToken', + envVar: 'STELLAR_CONTRACT_LUMEN_TOKEN', + configured: true, + status: 'reachable', + contractId: 'CDLZF...T7GY6', + readMethods: [], + }, + ], + }); + + const report = await service.getSmokeReport(); + + expect(report.status).toBe('ok'); + expect(report.network).toBe('testnet'); + expect(report.checkedAt).toBeDefined(); + expect(report.envVars).toHaveLength(4); + expect(report.envVars[0].name).toBe('STELLAR_NETWORK'); + expect(report.contracts).toHaveLength(6); + }); + + it('returns error status when env vars are missing', async () => { + mockContractHealthService.getContractHealthReport.mockResolvedValue({ + status: 'ok', + contracts: [], + }); + + const report = await service.getSmokeReport(); + + expect(report.status).toBe('error'); + expect(report.envVars.some((e) => !e.configured)).toBe(true); + }); + + it('returns error status when contracts are misconfigured', async () => { + mockContractHealthService.getContractHealthReport.mockResolvedValue({ + status: 'error', + contracts: [ + { + name: 'lumenToken', + envVar: 'STELLAR_CONTRACT_LUMEN_TOKEN', + configured: false, + status: 'misconfigured', + contractId: undefined, + readMethods: [], + }, + ], + }); + + const report = await service.getSmokeReport(); + + expect(report.status).toBe('error'); + }); + + it('returns error status when contracts are unreachable', async () => { + mockContractHealthService.getContractHealthReport.mockResolvedValue({ + status: 'error', + contracts: [ + { + name: 'lumenToken', + envVar: 'STELLAR_CONTRACT_LUMEN_TOKEN', + configured: true, + status: 'unreachable', + contractId: 'CDLZF...T7GY6', + readMethods: [], + message: 'Soroban RPC healthcheck source account is unavailable', + }, + ], + }); + + const report = await service.getSmokeReport(); + + expect(report.status).toBe('error'); + expect(report.contracts[0].status).toBe('unreachable'); + }); + }); +}); \ No newline at end of file diff --git a/apps/backend/src/health/smoke-endpoint.service.ts b/apps/backend/src/health/smoke-endpoint.service.ts new file mode 100644 index 00000000..170cafd8 --- /dev/null +++ b/apps/backend/src/health/smoke-endpoint.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@nestjs/common'; +import { config } from '../lib/config'; +import { ContractHealthService } from './contract-health.service'; +import { SmokeEndpointReport, SmokeEnvVarCheck, SmokeContractCheck } from './smoke-endpoint.dto'; + +const REQUIRED_ENV_VARS_FOR_SMOKE = [ + { name: 'STELLAR_NETWORK', sensitive: false }, + { name: 'STELLAR_HORIZON_URL', sensitive: false }, + { name: 'STELLAR_SOROBAN_RPC_URL', sensitive: false }, + { name: 'STELLAR_SERVER_SECRET', sensitive: true }, +] as const; + +const CONTRACT_CHECKS = [ + { name: 'lumenToken', envVar: 'STELLAR_CONTRACT_LUMEN_TOKEN' }, + { name: 'crowdfundVault', envVar: 'STELLAR_CONTRACT_CROWDFUND_VAULT' }, + { name: 'projectRegistry', envVar: 'STELLAR_CONTRACT_PROJECT_REGISTRY' }, + { name: 'contributorRegistry', envVar: 'STELLAR_CONTRACT_CONTRIBUTOR_REGISTRY' }, + { name: 'matchingPool', envVar: 'STELLAR_CONTRACT_MATCHING_POOL' }, + { name: 'treasury', envVar: 'STELLAR_CONTRACT_TREASURY' }, +] as const; + +@Injectable() +export class SmokeEndpointService { + constructor(private readonly contractHealthService: ContractHealthService) {} + + async getSmokeReport(): Promise { + const envVars = this.checkRequiredEnvVars(); + const contracts = await this.checkContracts(); + + const hasMisconfiguredContracts = contracts.some( + (c) => c.status === 'misconfigured', + ); + const allEnvVarsConfigured = envVars.every((e) => e.configured); + const hasUnreachableContracts = contracts.some( + (c) => c.status === 'unreachable', + ); + + const status = + allEnvVarsConfigured && !hasMisconfiguredContracts && !hasUnreachableContracts + ? 'ok' + : 'error'; + + return { + status, + network: config.stellar.network, + checkedAt: new Date().toISOString(), + envVars, + contracts, + }; + } + + private checkRequiredEnvVars(): SmokeEnvVarCheck[] { + return REQUIRED_ENV_VARS_FOR_SMOKE.map((check) => { + const value = process.env[check.name]; + const configured = typeof value === 'string' && value.trim().length > 0; + + return { + name: check.name, + configured, + value: configured + ? check.sensitive + ? '[REDACTED]' + : value + : undefined, + }; + }); + } + + private async checkContracts(): Promise { + const contractHealthReport = + await this.contractHealthService.getContractHealthReport(); + + return CONTRACT_CHECKS.map((contractCheck): SmokeContractCheck => { + const found = contractHealthReport.contracts.find( + (c) => c.name === contractCheck.name, + ); + + if (!found) { + return { + name: contractCheck.name, + envVar: contractCheck.envVar, + configured: false, + status: 'misconfigured', + contractId: undefined, + }; + } + + return { + name: found.name, + envVar: found.envVar, + configured: found.configured, + status: found.status, + contractId: found.contractId, + }; + }); + } +} \ No newline at end of file diff --git a/apps/backend/test/smoke.e2e-spec.ts b/apps/backend/test/smoke.e2e-spec.ts new file mode 100644 index 00000000..e1f0254b --- /dev/null +++ b/apps/backend/test/smoke.e2e-spec.ts @@ -0,0 +1,180 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { Server } from 'http'; +import request from 'supertest'; +import { HealthController } from '../src/health/health.controller'; +import { HealthService } from '../src/health/health.service'; +import { SmokeEndpointService } from '../src/health/smoke-endpoint.service'; +import { SmokeEndpointReport } from '../src/health/smoke-endpoint.dto'; + +describe('Smoke Endpoint (e2e)', () => { + let app: INestApplication; + let smokeEndpointService: { getSmokeReport: jest.Mock }; + + const getHttpServer = (): Server => app.getHttpServer() as Server; + + beforeAll(async () => { + smokeEndpointService = { + getSmokeReport: jest.fn(), + }; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { + provide: HealthService, + useValue: { + getHealthReport: jest.fn(), + }, + }, + { + provide: require('../src/health/contract-health.service').ContractHealthService, + useValue: { + getContractHealthReport: jest.fn(), + }, + }, + { + provide: SmokeEndpointService, + useValue: smokeEndpointService, + }, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('GET /smoke returns ok status when all checks pass', async () => { + const report: SmokeEndpointReport = { + status: 'ok', + network: 'testnet', + checkedAt: new Date().toISOString(), + envVars: [ + { name: 'STELLAR_NETWORK', configured: true, value: 'testnet' }, + { name: 'STELLAR_HORIZON_URL', configured: true, value: 'https://horizon-testnet.stellar.org' }, + { name: 'STELLAR_SOROBAN_RPC_URL', configured: true, value: 'https://soroban-testnet.stellar.org' }, + { name: 'STELLAR_SERVER_SECRET', configured: true, value: '[REDACTED]' }, + ], + contracts: [ + { name: 'lumenToken', envVar: 'STELLAR_CONTRACT_LUMEN_TOKEN', configured: true, status: 'reachable', contractId: 'CDLZF...T7GY6' }, + ], + }; + + smokeEndpointService.getSmokeReport.mockResolvedValue(report); + + const response = await request(getHttpServer()) + .get('/smoke') + .expect(200) + .expect('Content-Type', /json/); + + const body = response.body as SmokeEndpointReport; + + expect(body.status).toBe('ok'); + expect(body.envVars).toHaveLength(4); + expect(body.contracts).toBeDefined(); + }); + + it('returns 503 when env vars are missing', async () => { + const report: SmokeEndpointReport = { + status: 'error', + network: 'testnet', + checkedAt: new Date().toISOString(), + envVars: [ + { name: 'STELLAR_NETWORK', configured: true, value: 'testnet' }, + { name: 'STELLAR_HORIZON_URL', configured: true, value: 'https://horizon-testnet.stellar.org' }, + { name: 'STELLAR_SOROBAN_RPC_URL', configured: false }, + { name: 'STELLAR_SERVER_SECRET', configured: false }, + ], + contracts: [], + }; + + smokeEndpointService.getSmokeReport.mockResolvedValue(report); + + await request(getHttpServer()) + .get('/smoke') + .expect(503); + }); + + it('returns 503 when contracts are misconfigured', async () => { + const report: SmokeEndpointReport = { + status: 'error', + network: 'testnet', + checkedAt: new Date().toISOString(), + envVars: [ + { name: 'STELLAR_NETWORK', configured: true, value: 'testnet' }, + { name: 'STELLAR_HORIZON_URL', configured: true }, + { name: 'STELLAR_SOROBAN_RPC_URL', configured: true }, + { name: 'STELLAR_SERVER_SECRET', configured: true, value: '[REDACTED]' }, + ], + contracts: [ + { name: 'lumenToken', envVar: 'STELLAR_CONTRACT_LUMEN_TOKEN', configured: false, status: 'misconfigured' }, + ], + }; + + smokeEndpointService.getSmokeReport.mockResolvedValue(report); + + await request(getHttpServer()) + .get('/smoke') + .expect(503); + }); + + it('does not expose secrets in response', async () => { + const report: SmokeEndpointReport = { + status: 'ok', + network: 'testnet', + checkedAt: new Date().toISOString(), + envVars: [ + { name: 'STELLAR_NETWORK', configured: true, value: 'testnet' }, + { name: 'STELLAR_HORIZON_URL', configured: true, value: 'https://horizon-testnet.stellar.org' }, + { name: 'STELLAR_SOROBAN_RPC_URL', configured: true }, + { name: 'STELLAR_SERVER_SECRET', configured: true, value: '[REDACTED]' }, + ], + contracts: [], + }; + + smokeEndpointService.getSmokeReport.mockResolvedValue(report); + + const response = await request(getHttpServer()) + .get('/smoke') + .expect(200); + + const serialized = JSON.stringify(response.body); + + expect(serialized).not.toMatch(/[A-Z0-9]{56}/); + }); + + it('returns machine-readable status for CI consumption', async () => { + const report: SmokeEndpointReport = { + status: 'ok', + network: 'testnet', + checkedAt: '2024-01-15T10:30:00.000Z', + envVars: [ + { name: 'STELLAR_NETWORK', configured: true, value: 'testnet' }, + { name: 'STELLAR_HORIZON_URL', configured: true, value: 'https://horizon-testnet.stellar.org' }, + { name: 'STELLAR_SOROBAN_RPC_URL', configured: true }, + { name: 'STELLAR_SERVER_SECRET', configured: true, value: '[REDACTED]' }, + ], + contracts: [], + }; + + smokeEndpointService.getSmokeReport.mockResolvedValue(report); + + const response = await request(getHttpServer()) + .get('/smoke') + .expect(200); + + expect(response.body.status).toBe('ok'); + expect(response.body.network).toBe('testnet'); + expect(typeof response.body.checkedAt).toBe('string'); + expect(Array.isArray(response.body.envVars)).toBe(true); + expect(Array.isArray(response.body.contracts)).toBe(true); + }); +}); \ No newline at end of file