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
26 changes: 26 additions & 0 deletions apps/backend/src/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ 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()
export class HealthController {
constructor(
private readonly healthService: HealthService,
private readonly contractHealthService: ContractHealthService,
private readonly smokeEndpointService: SmokeEndpointService,
) {}

@Get('health')
Expand Down Expand Up @@ -56,4 +59,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;
}
}
3 changes: 2 additions & 1 deletion apps/backend/src/health/health.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

@Module({
imports: [
Expand All @@ -18,6 +19,6 @@ import { HealthService } from './health.service';
StellarModule,
],
controllers: [HealthController],
providers: [HealthService, ContractHealthService],
providers: [HealthService, ContractHealthService, SmokeEndpointService],
})
export class HealthModule {}
90 changes: 90 additions & 0 deletions apps/backend/src/health/smoke-endpoint.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
111 changes: 111 additions & 0 deletions apps/backend/src/health/smoke-endpoint.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
97 changes: 97 additions & 0 deletions apps/backend/src/health/smoke-endpoint.service.ts
Original file line number Diff line number Diff line change
@@ -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<SmokeEndpointReport> {
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<SmokeContractCheck[]> {
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,
};
});
}
}
Loading
Loading