diff --git a/.env.example b/.env.example index c86ee33..6846da9 100644 --- a/.env.example +++ b/.env.example @@ -26,26 +26,37 @@ VAULT_TOKEN= # Or leave empty and it will be read from ~/.config/vault/root-token # =========================================================================== -# Docker Network IP Addresses (172.20.0.0/16) +# Docker Network IP Addresses (4-Tier Segmentation) # =========================================================================== -# Static IP addresses for services in the dev-services network +# Static IP addresses for services across 4 network segments: +# - vault-network: 172.20.1.0/24 (secrets management) +# - data-network: 172.20.2.0/24 (databases, redis, rabbitmq) +# - app-network: 172.20.3.0/24 (forgejo, reference APIs) +# - observability-network: 172.20.4.0/24 (prometheus, grafana, loki) +# # Change these if you need custom IP assignments or have conflicts # -# Default assignments: -POSTGRES_IP=172.20.0.10 -PGBOUNCER_IP=172.20.0.11 -MYSQL_IP=172.20.0.12 -REDIS_1_IP=172.20.0.13 -RABBITMQ_IP=172.20.0.14 -MONGODB_IP=172.20.0.15 -REDIS_2_IP=172.20.0.16 -REDIS_3_IP=172.20.0.17 -FORGEJO_IP=172.20.0.20 -VAULT_IP=172.20.0.21 -REFERENCE_API_IP=172.20.0.100 -PROMETHEUS_IP=172.20.0.101 -GRAFANA_IP=172.20.0.102 -LOKI_IP=172.20.0.103 +# Vault Network (172.20.1.x): +VAULT_IP=172.20.1.5 + +# Data Network (172.20.2.x): +POSTGRES_IP=172.20.2.10 +PGBOUNCER_IP=172.20.2.11 +MYSQL_IP=172.20.2.12 +REDIS_1_IP=172.20.2.13 +RABBITMQ_IP=172.20.2.14 +MONGODB_IP=172.20.2.15 +REDIS_2_IP=172.20.2.16 +REDIS_3_IP=172.20.2.17 + +# App Network (172.20.3.x): +FORGEJO_IP=172.20.3.20 +REFERENCE_API_IP=172.20.3.100 + +# Observability Network (172.20.4.x): +PROMETHEUS_IP=172.20.4.10 +GRAFANA_IP=172.20.4.20 +LOKI_IP=172.20.4.30 # =========================================================================== # TLS Configuration (Enabled by Default) diff --git a/docs/VAULT.md b/docs/VAULT.md index aac6a23..58ed9ba 100644 --- a/docs/VAULT.md +++ b/docs/VAULT.md @@ -303,6 +303,57 @@ vault write -f -field=secret_id auth/approle/role/reference-api/secret-id > ~/.c - AppRole implementation: `reference-apps/fastapi/app/services/vault.py` - Docker configuration: `docker-compose.yml` (line 879) +#### AppRole Secret ID Renewal + +**⚠️ IMPORTANT:** AppRole secret_ids have a **30-day TTL**. Services will fail to authenticate after expiry. + +**Check Secret ID Expiry:** +```bash +export VAULT_ADDR=http://localhost:8200 +export VAULT_TOKEN=$(cat ~/.config/vault/root-token) + +# Check remaining TTL for a service (e.g., reference-api) +vault write auth/approle/role/reference-api/secret-id-accessor/lookup \ + secret_id_accessor=$(cat ~/.config/vault/approles/reference-api/secret-id-accessor 2>/dev/null) +``` + +**Renew Secret IDs (Before Expiry):** +```bash +# Renew all AppRole secret_ids +./scripts/vault-approle-bootstrap.sh --renew-secrets + +# Or renew for a specific service +SERVICE=reference-api +vault write -f -field=secret_id auth/approle/role/${SERVICE}/secret-id \ + > ~/.config/vault/approles/${SERVICE}/secret-id + +# Save the accessor for future lookups +vault write -f -field=secret_id_accessor auth/approle/role/${SERVICE}/secret-id \ + > ~/.config/vault/approles/${SERVICE}/secret-id-accessor + +# Restart the service to pick up new credentials +docker compose restart ${SERVICE} +``` + +**Automated Renewal (Recommended for Production):** + +Add to crontab to renew secret_ids weekly: +```bash +# Edit crontab +crontab -e + +# Add this line (runs every Sunday at 3 AM) +0 3 * * 0 cd /path/to/devstack-core && ./scripts/vault-approle-bootstrap.sh --renew-secrets >> /var/log/vault-approle-renewal.log 2>&1 +``` + +**Renewal Timeline:** +| Event | TTL Remaining | Action | +|-------|---------------|--------| +| Created | 30 days | Normal operation | +| Week 3 | 7-14 days | Consider renewal | +| Week 4 | < 7 days | **Renew immediately** | +| Expired | 0 days | Service authentication fails | + ### SSL/TLS Certificate Management **TLS Implementation: Pre-Generated Certificates with Vault-Based Configuration** diff --git a/reference-apps/fastapi-api-first/app/main.py b/reference-apps/fastapi-api-first/app/main.py index 3bff3b6..0874aaa 100644 --- a/reference-apps/fastapi-api-first/app/main.py +++ b/reference-apps/fastapi-api-first/app/main.py @@ -14,8 +14,10 @@ from slowapi.errors import RateLimitExceeded from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST import logging +import sys import time import uuid +from pythonjsonlogger import jsonlogger from app.routers import ( health_checks, @@ -30,12 +32,19 @@ from app.middleware.cache import cache_manager from app.services.vault import vault_client -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +# Configure structured JSON logging (matches code-first implementation) +logHandler = logging.StreamHandler(sys.stdout) +formatter = jsonlogger.JsonFormatter( + '%(asctime)s %(name)s %(levelname)s %(message)s %(request_id)s %(method)s %(path)s %(status_code)s %(duration_ms)s' ) +logHandler.setFormatter(formatter) logger = logging.getLogger(__name__) +logger.addHandler(logHandler) +logger.setLevel(logging.INFO) + +# Disable default basicConfig +logging.getLogger().handlers.clear() +logging.getLogger().addHandler(logHandler) # Prometheus metrics http_requests_total = Counter( @@ -68,7 +77,7 @@ # Create FastAPI app app = FastAPI( title="DevStack Core - Reference API (API-First)", - version="1.0.0", + version="1.1.0", description="API-First implementation generated from OpenAPI specification", docs_url="/docs", redoc_url="/redoc", @@ -112,10 +121,10 @@ async def metrics_middleware(request: Request, call_next): request.state.request_id = request_id method = request.method - path = request.url.path + endpoint = request.url.path # Track in-progress requests - http_requests_in_progress.labels(method=method, endpoint=path).inc() + http_requests_in_progress.labels(method=method, endpoint=endpoint).inc() start_time = time.time() @@ -126,23 +135,58 @@ async def metrics_middleware(request: Request, call_next): # Record metrics http_requests_total.labels( method=method, - endpoint=path, + endpoint=endpoint, status=response.status_code ).inc() http_request_duration_seconds.labels( method=method, - endpoint=path + endpoint=endpoint ).observe(duration) + # Log request with structured data (matches code-first implementation) + logger.info( + "HTTP request completed", + extra={ + "request_id": request_id, + "method": method, + "path": endpoint, + "status_code": response.status_code, + "duration_ms": round(duration * 1000, 2) + } + ) + # Add headers response.headers["X-Request-ID"] = request_id response.headers["X-Response-Time"] = f"{duration:.3f}s" return response + except Exception as e: + # Record error metrics + duration = time.time() - start_time + http_requests_total.labels( + method=method, + endpoint=endpoint, + status=500 + ).inc() + + # Log error with structured data + logger.error( + f"Request failed: {str(e)}", + extra={ + "request_id": request_id, + "method": method, + "path": endpoint, + "status_code": 500, + "duration_ms": round(duration * 1000, 2) + }, + exc_info=True + ) + raise + finally: - http_requests_in_progress.labels(method=method, endpoint=path).dec() + http_requests_in_progress.labels(method=method, endpoint=endpoint).dec() # Include routers @@ -158,7 +202,7 @@ async def metrics_middleware(request: Request, call_next): async def startup_event(): """Application startup event handler.""" # Set app info metric - app_info.labels(version="1.0.0", name="api-first").set(1) + app_info.labels(version="1.1.0", name="api-first").set(1) # Initialize response caching with Redis try: @@ -171,10 +215,14 @@ async def startup_event(): logger.error(f"Failed to initialize cache: {e}") logger.warning("Application will continue without caching") - logger.info("Starting API-First FastAPI application...") - logger.info(f"Debug mode: {settings.DEBUG}") - logger.info(f"Vault address: {settings.VAULT_ADDR}") - logger.info(f"Redis cache enabled: {cache_manager.enabled}") + logger.info( + "Starting DevStack Core Reference API (API-First)", + extra={ + "vault_address": settings.VAULT_ADDR, + "redis_cache_enabled": cache_manager.enabled, + "version": "1.1.0" + } + ) logger.info("Application ready") @@ -195,7 +243,7 @@ async def root(request: Request): """ return { "name": "DevStack Core Reference API", - "version": "1.0.0", + "version": "1.1.0", "description": "Reference implementation for infrastructure integration", "docs": "/docs", "health": "/health/all", @@ -253,7 +301,8 @@ async def root(request: Request): @app.get("/metrics") -async def metrics(): +@limiter.limit("1000/minute") # High limit for metrics scraping +async def metrics(request: Request): """Prometheus metrics endpoint""" return Response( content=generate_latest(), diff --git a/reference-apps/fastapi/requirements.txt b/reference-apps/fastapi/requirements.txt index 5bd6a5e..3cbf3f8 100644 --- a/reference-apps/fastapi/requirements.txt +++ b/reference-apps/fastapi/requirements.txt @@ -28,7 +28,7 @@ python-json-logger==4.0.0 prometheus-client==0.23.1 # Testing -pytest==9.0.1 # pytest-asyncio 1.2.0 requires pytest<9 +pytest==9.0.1 pytest-asyncio==1.3.0 pytest-cov==7.0.0 pytest-mock==3.15.1 diff --git a/reference-apps/golang/go.mod b/reference-apps/golang/go.mod index aaad581..36f01ec 100644 --- a/reference-apps/golang/go.mod +++ b/reference-apps/golang/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/vault/api v1.22.0 github.com/jackc/pgx/v5 v5.7.6 + github.com/prometheus/client_golang v1.19.1 github.com/rabbitmq/amqp091-go v1.10.0 github.com/redis/go-redis/v9 v9.17.2 github.com/sirupsen/logrus v1.9.3 @@ -16,6 +17,7 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -53,6 +55,9 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect diff --git a/reference-apps/golang/go.sum b/reference-apps/golang/go.sum index 1c9cbba..e1cb259 100644 --- a/reference-apps/golang/go.sum +++ b/reference-apps/golang/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -110,6 +112,14 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= diff --git a/reference-apps/golang/internal/config/config.go b/reference-apps/golang/internal/config/config.go index ac84bea..43f0212 100644 --- a/reference-apps/golang/internal/config/config.go +++ b/reference-apps/golang/internal/config/config.go @@ -57,7 +57,7 @@ func Load() *Config { return &Config{ // Application Environment: getEnv("ENVIRONMENT", "development"), - Debug: getEnvBool("DEBUG", true), + Debug: getEnvBool("DEBUG", false), HTTPPort: getEnv("HTTP_PORT", "8002"), HTTPSPort: getEnv("HTTPS_PORT", "8445"), EnableTLS: getEnvBool("GOLANG_API_ENABLE_TLS", false), diff --git a/reference-apps/nodejs/jest.config.js b/reference-apps/nodejs/jest.config.js index a3fafba..fe1ff05 100644 --- a/reference-apps/nodejs/jest.config.js +++ b/reference-apps/nodejs/jest.config.js @@ -1,7 +1,25 @@ +/** + * Jest Configuration + */ + module.exports = { testEnvironment: 'node', - coverageDirectory: 'coverage', - collectCoverageFrom: ['src/**/*.js'], testMatch: ['**/tests/**/*.test.js'], + collectCoverageFrom: [ + 'src/**/*.js', + '!src/index.js' // Exclude main entry point from coverage + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + coverageThreshold: { + global: { + branches: 50, + functions: 50, + lines: 50, + statements: 50 + } + }, + setupFilesAfterEnv: ['./tests/setup.js'], + testTimeout: 10000, verbose: true }; diff --git a/reference-apps/nodejs/tests/config.test.js b/reference-apps/nodejs/tests/config.test.js new file mode 100644 index 0000000..30fa172 --- /dev/null +++ b/reference-apps/nodejs/tests/config.test.js @@ -0,0 +1,92 @@ +/** + * Configuration Tests + * + * Tests for configuration loading and validation. + */ + +describe('Configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should load default configuration', () => { + const config = require('../src/config'); + + expect(config.http.port).toBe(8003); + expect(config.http.host).toBe('0.0.0.0'); + expect(config.vault.address).toBe('http://vault:8200'); + expect(config.postgres.host).toBe('postgres'); + expect(config.mysql.host).toBe('mysql'); + expect(config.mongodb.host).toBe('mongodb'); + expect(config.redis.host).toBe('redis-1'); + expect(config.rabbitmq.host).toBe('rabbitmq'); + }); + + it('should load custom port from environment', () => { + process.env.HTTP_PORT = '9000'; + jest.resetModules(); + const config = require('../src/config'); + + expect(config.http.port).toBe(9000); + }); + + it('should load custom Vault address from environment', () => { + process.env.VAULT_ADDR = 'http://custom-vault:8200'; + jest.resetModules(); + const config = require('../src/config'); + + expect(config.vault.address).toBe('http://custom-vault:8200'); + }); + + it('should have correct Redis cluster nodes', () => { + const config = require('../src/config'); + + expect(config.redis.nodes).toHaveLength(3); + expect(config.redis.nodes[0]).toEqual({ host: 'redis-1', port: 6379 }); + expect(config.redis.nodes[1]).toEqual({ host: 'redis-2', port: 6379 }); + expect(config.redis.nodes[2]).toEqual({ host: 'redis-3', port: 6379 }); + }); + + it('should have correct application metadata', () => { + const config = require('../src/config'); + + expect(config.app.name).toBe('DevStack Core Node.js Reference API'); + expect(config.app.language).toBe('Node.js'); + expect(config.app.framework).toBe('Express'); + expect(config.app.version).toBeDefined(); + }); + + it('should enable debug in development', () => { + process.env.NODE_ENV = 'development'; + jest.resetModules(); + const config = require('../src/config'); + + expect(config.debug).toBe(true); + }); + + it('should respect DEBUG environment variable', () => { + process.env.NODE_ENV = 'production'; + process.env.DEBUG = 'true'; + jest.resetModules(); + const config = require('../src/config'); + + expect(config.debug).toBe(true); + }); + + it('should parse HTTPS configuration', () => { + process.env.HTTPS_PORT = '8446'; + process.env.NODEJS_API_ENABLE_TLS = 'true'; + jest.resetModules(); + const config = require('../src/config'); + + expect(config.https.port).toBe(8446); + expect(config.https.enabled).toBe(true); + }); +}); diff --git a/reference-apps/nodejs/tests/health.test.js b/reference-apps/nodejs/tests/health.test.js index 464feef..8969581 100644 --- a/reference-apps/nodejs/tests/health.test.js +++ b/reference-apps/nodejs/tests/health.test.js @@ -1,53 +1,384 @@ +/** + * Health Check Route Tests + * + * Tests for health check endpoints without requiring actual service connections. + */ + const request = require('supertest'); -const baseURL = process.env.TEST_URL || 'http://localhost:8003'; +// Mock all external dependencies +jest.mock('../src/services/vault', () => ({ + vaultClient: { + healthCheck: jest.fn(), + getSecret: jest.fn(), + isAuthenticated: jest.fn().mockReturnValue(true) + } +})); + +jest.mock('pg', () => ({ + Client: jest.fn().mockImplementation(() => ({ + connect: jest.fn(), + query: jest.fn(), + end: jest.fn() + })) +})); + +jest.mock('mysql2/promise', () => ({ + createConnection: jest.fn() +})); + +jest.mock('mongodb', () => ({ + MongoClient: jest.fn().mockImplementation(() => ({ + connect: jest.fn(), + db: jest.fn(() => ({ + admin: jest.fn(() => ({ + serverInfo: jest.fn() + })) + })), + close: jest.fn() + })) +})); -describe('Health Check Endpoints', () => { - test('GET / returns API information', async () => { - const response = await request(baseURL).get('/'); - expect(response.status).toBe(200); - expect(response.body.name).toBe('DevStack Core Node.js Reference API'); - expect(response.body.language).toBe('Node.js'); +jest.mock('redis', () => ({ + createClient: jest.fn(() => ({ + connect: jest.fn(), + info: jest.fn(), + sendCommand: jest.fn(), + quit: jest.fn() + })) +})); + +jest.mock('amqplib', () => ({ + connect: jest.fn() +})); + +const app = require('../src/index'); +const { vaultClient } = require('../src/services/vault'); +const { Client: PgClient } = require('pg'); +const mysql = require('mysql2/promise'); +const { MongoClient } = require('mongodb'); +const { createClient } = require('redis'); +const amqp = require('amqplib'); + +describe('Health Routes', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - test('GET /health/ returns simple health', async () => { - const response = await request(baseURL).get('/health/'); - expect(response.status).toBe(200); - expect(response.body.status).toBe('healthy'); + describe('GET /health', () => { + it('should return healthy status', async () => { + const response = await request(app) + .get('/health') + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('status', 'healthy'); + expect(response.body).toHaveProperty('timestamp'); + expect(response.body).toHaveProperty('uptime'); + }); + + it('should return uptime as a number', async () => { + const response = await request(app).get('/health').expect(200); + expect(typeof response.body.uptime).toBe('number'); + }); }); - test('GET /health/vault returns vault status', async () => { - const response = await request(baseURL).get('/health/vault'); - expect([200, 503]).toContain(response.status); - expect(response.body).toHaveProperty('status'); + describe('GET /health/vault', () => { + it('should return healthy when Vault is accessible', async () => { + vaultClient.healthCheck.mockResolvedValue({ + status: 'healthy', + initialized: true, + sealed: false + }); + + const response = await request(app) + .get('/health/vault') + .expect(200); + + expect(response.body.status).toBe('healthy'); + }); + + it('should return unhealthy when Vault check fails', async () => { + vaultClient.healthCheck.mockRejectedValue(new Error('Connection refused')); + + const response = await request(app) + .get('/health/vault') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); }); - test('GET /health/all returns aggregate health', async () => { - const response = await request(baseURL).get('/health/all'); - expect([200, 503]).toContain(response.status); - expect(response.body).toHaveProperty('services'); - expect(response.body.services).toHaveProperty('vault'); - expect(response.body.services).toHaveProperty('postgres'); - expect(response.body.services).toHaveProperty('redis'); + describe('GET /health/postgres', () => { + it('should return healthy when PostgreSQL is accessible', async () => { + vaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockClient = { + connect: jest.fn().mockResolvedValue(), + query: jest.fn().mockResolvedValue({ + rows: [{ version: 'PostgreSQL 15.4 on x86_64' }] + }), + end: jest.fn().mockResolvedValue() + }; + PgClient.mockImplementation(() => mockClient); + + const response = await request(app) + .get('/health/postgres') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body).toHaveProperty('version'); + }); + + it('should return unhealthy when PostgreSQL is not accessible', async () => { + vaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockClient = { + connect: jest.fn().mockRejectedValue(new Error('Connection refused')), + end: jest.fn().mockResolvedValue() + }; + PgClient.mockImplementation(() => mockClient); + + const response = await request(app) + .get('/health/postgres') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); }); -}); -describe('Vault Integration', () => { - test('GET /examples/vault/secret/postgres returns credentials', async () => { - const response = await request(baseURL).get('/examples/vault/secret/postgres'); - expect([200, 503]).toContain(response.status); - if (response.status === 200) { - expect(response.body).toHaveProperty('service', 'postgres'); - expect(response.body).toHaveProperty('secrets'); - } + describe('GET /health/mysql', () => { + it('should return healthy when MySQL is accessible', async () => { + vaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockConnection = { + query: jest.fn().mockResolvedValue([[{ version: '8.0.35' }]]), + end: jest.fn().mockResolvedValue() + }; + mysql.createConnection.mockResolvedValue(mockConnection); + + const response = await request(app) + .get('/health/mysql') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body.version).toBe('8.0.35'); + }); + + it('should return unhealthy when MySQL is not accessible', async () => { + vaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + mysql.createConnection.mockRejectedValue(new Error('Connection refused')); + + const response = await request(app) + .get('/health/mysql') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); }); -}); -describe('Metrics', () => { - test('GET /metrics endpoint is accessible', async () => { - const response = await request(baseURL).get('/metrics'); - expect(response.status).toBe(200); - // Content type should be text/plain for Prometheus format - expect(response.headers['content-type']).toContain('text/plain'); + describe('GET /health/mongodb', () => { + it('should return healthy when MongoDB is accessible', async () => { + vaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockClient = { + connect: jest.fn().mockResolvedValue(), + db: jest.fn(() => ({ + admin: jest.fn(() => ({ + serverInfo: jest.fn().mockResolvedValue({ version: '7.0.4' }) + })) + })), + close: jest.fn().mockResolvedValue() + }; + MongoClient.mockImplementation(() => mockClient); + + const response = await request(app) + .get('/health/mongodb') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body.version).toBe('7.0.4'); + }); + + it('should return unhealthy when MongoDB is not accessible', async () => { + vaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockClient = { + connect: jest.fn().mockRejectedValue(new Error('Connection refused')), + close: jest.fn().mockResolvedValue() + }; + MongoClient.mockImplementation(() => mockClient); + + const response = await request(app) + .get('/health/mongodb') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); + }); + + describe('GET /health/redis', () => { + it('should return healthy when Redis is accessible', async () => { + vaultClient.getSecret.mockResolvedValue({ password: 'testpass' }); + + const mockClient = { + connect: jest.fn().mockResolvedValue(), + info: jest.fn().mockResolvedValue('redis_version:7.2.4\r\n'), + sendCommand: jest.fn().mockResolvedValue('cluster_state:ok\r\n'), + quit: jest.fn().mockResolvedValue() + }; + createClient.mockReturnValue(mockClient); + + const response = await request(app) + .get('/health/redis') + .expect(200); + + expect(response.body.status).toBe('healthy'); + }); + + it('should return unhealthy when Redis is not accessible', async () => { + vaultClient.getSecret.mockResolvedValue({ password: 'testpass' }); + + const mockClient = { + connect: jest.fn().mockRejectedValue(new Error('Connection refused')), + quit: jest.fn().mockResolvedValue() + }; + createClient.mockReturnValue(mockClient); + + const response = await request(app) + .get('/health/redis') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); + }); + + describe('GET /health/rabbitmq', () => { + it('should return healthy when RabbitMQ is accessible', async () => { + vaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass' + }); + + const mockChannel = { + close: jest.fn().mockResolvedValue() + }; + const mockConnection = { + createChannel: jest.fn().mockResolvedValue(mockChannel), + close: jest.fn().mockResolvedValue() + }; + amqp.connect.mockResolvedValue(mockConnection); + + const response = await request(app) + .get('/health/rabbitmq') + .expect(200); + + expect(response.body.status).toBe('healthy'); + }); + + it('should return unhealthy when RabbitMQ is not accessible', async () => { + vaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass' + }); + + amqp.connect.mockRejectedValue(new Error('Connection refused')); + + const response = await request(app) + .get('/health/rabbitmq') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); + }); + + describe('GET /health/all', () => { + it('should return aggregate health status', async () => { + // Mock all services as healthy + vaultClient.healthCheck.mockResolvedValue({ status: 'healthy' }); + vaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockPgClient = { + connect: jest.fn().mockResolvedValue(), + query: jest.fn().mockResolvedValue({ rows: [{ version: 'PostgreSQL 15.4' }] }), + end: jest.fn().mockResolvedValue() + }; + PgClient.mockImplementation(() => mockPgClient); + + const mockMysqlConnection = { + query: jest.fn().mockResolvedValue([[{ version: '8.0.35' }]]), + end: jest.fn().mockResolvedValue() + }; + mysql.createConnection.mockResolvedValue(mockMysqlConnection); + + const mockMongoClient = { + connect: jest.fn().mockResolvedValue(), + db: jest.fn(() => ({ + admin: jest.fn(() => ({ + serverInfo: jest.fn().mockResolvedValue({ version: '7.0.4' }) + })) + })), + close: jest.fn().mockResolvedValue() + }; + MongoClient.mockImplementation(() => mockMongoClient); + + const mockRedisClient = { + connect: jest.fn().mockResolvedValue(), + info: jest.fn().mockResolvedValue('redis_version:7.2.4\r\n'), + sendCommand: jest.fn().mockResolvedValue('cluster_state:ok\r\n'), + quit: jest.fn().mockResolvedValue() + }; + createClient.mockReturnValue(mockRedisClient); + + const mockRabbitChannel = { close: jest.fn().mockResolvedValue() }; + const mockRabbitConnection = { + createChannel: jest.fn().mockResolvedValue(mockRabbitChannel), + close: jest.fn().mockResolvedValue() + }; + amqp.connect.mockResolvedValue(mockRabbitConnection); + + const response = await request(app) + .get('/health/all') + .expect(200); + + expect(response.body).toHaveProperty('status'); + expect(response.body).toHaveProperty('services'); + expect(response.body).toHaveProperty('timestamp'); + expect(response.body.services).toHaveProperty('vault'); + expect(response.body.services).toHaveProperty('postgres'); + expect(response.body.services).toHaveProperty('mysql'); + expect(response.body.services).toHaveProperty('mongodb'); + expect(response.body.services).toHaveProperty('redis'); + expect(response.body.services).toHaveProperty('rabbitmq'); + }); }); }); diff --git a/reference-apps/nodejs/tests/index.test.js b/reference-apps/nodejs/tests/index.test.js new file mode 100644 index 0000000..d3e6c5a --- /dev/null +++ b/reference-apps/nodejs/tests/index.test.js @@ -0,0 +1,97 @@ +/** + * Main Application Tests + * + * Tests for the Express application entry point and core functionality. + */ + +const request = require('supertest'); + +// Mock the services before requiring app +jest.mock('../src/services/vault', () => ({ + vaultClient: { + healthCheck: jest.fn(), + getSecret: jest.fn(), + isAuthenticated: jest.fn().mockReturnValue(true) + } +})); + +const app = require('../src/index'); + +describe('Application Entry Point', () => { + describe('GET /', () => { + it('should return API information', async () => { + const response = await request(app) + .get('/') + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('name'); + expect(response.body).toHaveProperty('version'); + expect(response.body).toHaveProperty('language', 'Node.js'); + expect(response.body).toHaveProperty('framework', 'Express'); + expect(response.body).toHaveProperty('endpoints'); + }); + + it('should include all expected endpoints in response', async () => { + const response = await request(app).get('/').expect(200); + + const { endpoints } = response.body; + expect(endpoints).toHaveProperty('health', '/health'); + expect(endpoints).toHaveProperty('vault_examples', '/examples/vault'); + expect(endpoints).toHaveProperty('database_examples', '/examples/database'); + expect(endpoints).toHaveProperty('cache_examples', '/examples/cache'); + expect(endpoints).toHaveProperty('messaging_examples', '/examples/messaging'); + expect(endpoints).toHaveProperty('metrics', '/metrics'); + }); + + it('should include redis cluster endpoints', async () => { + const response = await request(app).get('/').expect(200); + + expect(response.body).toHaveProperty('redis_cluster'); + expect(response.body.redis_cluster).toHaveProperty('nodes'); + expect(response.body.redis_cluster).toHaveProperty('slots'); + expect(response.body.redis_cluster).toHaveProperty('info'); + }); + }); + + describe('GET /metrics', () => { + it('should return Prometheus metrics', async () => { + const response = await request(app) + .get('/metrics') + .expect(200); + + expect(response.text).toContain('# HELP'); + expect(response.text).toContain('# TYPE'); + }); + }); + + describe('404 Handler', () => { + it('should return 404 for unknown routes', async () => { + const response = await request(app) + .get('/nonexistent/route') + .expect('Content-Type', /json/) + .expect(404); + + expect(response.body).toHaveProperty('error', 'Not Found'); + expect(response.body).toHaveProperty('message'); + }); + + it('should include request method in 404 response', async () => { + const response = await request(app) + .post('/nonexistent') + .expect(404); + + expect(response.body.message).toContain('POST'); + }); + }); +}); + +describe('Security Headers', () => { + it('should include security headers from helmet', async () => { + const response = await request(app).get('/').expect(200); + + // Helmet adds various security headers + expect(response.headers).toHaveProperty('x-content-type-options'); + expect(response.headers).toHaveProperty('x-frame-options'); + }); +}); diff --git a/reference-apps/nodejs/tests/setup.js b/reference-apps/nodejs/tests/setup.js new file mode 100644 index 0000000..7220d61 --- /dev/null +++ b/reference-apps/nodejs/tests/setup.js @@ -0,0 +1,17 @@ +/** + * Jest Test Setup + * + * Global setup for all tests. + */ + +// Set test environment +process.env.NODE_ENV = 'test'; + +// Increase timeout for async operations +jest.setTimeout(10000); + +// Global afterAll to close any open handles +afterAll(async () => { + // Give time for any async operations to complete + await new Promise(resolve => setTimeout(resolve, 500)); +}); diff --git a/reference-apps/typescript-api-first/jest.config.js b/reference-apps/typescript-api-first/jest.config.js index 4dbc5ef..cb6697a 100644 --- a/reference-apps/typescript-api-first/jest.config.js +++ b/reference-apps/typescript-api-first/jest.config.js @@ -1,12 +1,30 @@ +/** + * Jest Configuration for TypeScript + */ + module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/tests'], - testMatch: ['**/*.test.ts'], + testMatch: ['**/tests/**/*.test.ts'], collectCoverageFrom: [ 'src/**/*.ts', - '!src/**/*.d.ts', + '!src/index.ts' ], - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - verbose: true + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + coverageThreshold: { + global: { + branches: 50, + functions: 50, + lines: 50, + statements: 50 + } + }, + setupFilesAfterEnv: ['./tests/setup.ts'], + testTimeout: 10000, + verbose: true, + moduleFileExtensions: ['ts', 'js', 'json'], + transform: { + '^.+\\.ts$': 'ts-jest' + } }; diff --git a/reference-apps/typescript-api-first/package.json b/reference-apps/typescript-api-first/package.json index 787fe27..c695d67 100644 --- a/reference-apps/typescript-api-first/package.json +++ b/reference-apps/typescript-api-first/package.json @@ -42,8 +42,10 @@ "@types/jest": "^30.0.0", "@types/node": "^24.10.1", "@types/pg": "^8.10.9", + "@types/supertest": "^6.0.2", "@types/uuid": "^11.0.0", "jest": "^30.2.0", + "supertest": "^7.1.4", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.3.3" diff --git a/reference-apps/typescript-api-first/tests/config.test.ts b/reference-apps/typescript-api-first/tests/config.test.ts new file mode 100644 index 0000000..bbd20b8 --- /dev/null +++ b/reference-apps/typescript-api-first/tests/config.test.ts @@ -0,0 +1,82 @@ +/** + * Configuration Tests + * + * Tests for configuration loading and validation. + */ + +describe('Configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should load default configuration', async () => { + const { default: config } = await import('../src/config'); + + expect(config.http.port).toBe(8004); + expect(config.http.host).toBe('0.0.0.0'); + expect(config.vault.address).toBe('http://vault:8200'); + expect(config.postgres.host).toBe('postgres'); + expect(config.mysql.host).toBe('mysql'); + expect(config.mongodb.host).toBe('mongodb'); + expect(config.redis.host).toBe('redis-1'); + expect(config.rabbitmq.host).toBe('rabbitmq'); + }); + + it('should load custom port from environment', async () => { + process.env.HTTP_PORT = '9000'; + jest.resetModules(); + const { default: config } = await import('../src/config'); + + expect(config.http.port).toBe(9000); + }); + + it('should load custom Vault address from environment', async () => { + process.env.VAULT_ADDR = 'http://custom-vault:8200'; + jest.resetModules(); + const { default: config } = await import('../src/config'); + + expect(config.vault.address).toBe('http://custom-vault:8200'); + }); + + it('should have correct Redis cluster nodes', async () => { + const { default: config } = await import('../src/config'); + + expect(config.redis.nodes).toHaveLength(3); + expect(config.redis.nodes[0]).toEqual({ host: 'redis-1', port: 6379 }); + expect(config.redis.nodes[1]).toEqual({ host: 'redis-2', port: 6379 }); + expect(config.redis.nodes[2]).toEqual({ host: 'redis-3', port: 6379 }); + }); + + it('should have correct application metadata', async () => { + const { default: config } = await import('../src/config'); + + expect(config.app.name).toBe('DevStack Core TypeScript API-First Reference API'); + expect(config.app.language).toBe('TypeScript'); + expect(config.app.framework).toBe('Express'); + expect(config.app.version).toBeDefined(); + }); + + it('should enable debug in development', async () => { + process.env.NODE_ENV = 'development'; + jest.resetModules(); + const { default: config } = await import('../src/config'); + + expect(config.debug).toBe(true); + }); + + it('should respect DEBUG environment variable', async () => { + process.env.NODE_ENV = 'production'; + process.env.DEBUG = 'true'; + jest.resetModules(); + const { default: config } = await import('../src/config'); + + expect(config.debug).toBe(true); + }); +}); diff --git a/reference-apps/typescript-api-first/tests/health.test.ts b/reference-apps/typescript-api-first/tests/health.test.ts new file mode 100644 index 0000000..df611b7 --- /dev/null +++ b/reference-apps/typescript-api-first/tests/health.test.ts @@ -0,0 +1,390 @@ +/** + * Health Check Route Tests + * + * Tests for health check endpoints without requiring actual service connections. + */ + +import request from 'supertest'; + +// Mock all external dependencies +jest.mock('../src/services/vault', () => ({ + vaultClient: { + healthCheck: jest.fn(), + getSecret: jest.fn(), + isAuthenticated: jest.fn().mockReturnValue(true) + } +})); + +jest.mock('pg', () => ({ + Client: jest.fn().mockImplementation(() => ({ + connect: jest.fn(), + query: jest.fn(), + end: jest.fn() + })) +})); + +jest.mock('mysql2/promise', () => ({ + createConnection: jest.fn() +})); + +jest.mock('mongodb', () => ({ + MongoClient: jest.fn().mockImplementation(() => ({ + connect: jest.fn(), + db: jest.fn(() => ({ + admin: jest.fn(() => ({ + serverInfo: jest.fn() + })) + })), + close: jest.fn() + })) +})); + +jest.mock('redis', () => ({ + createClient: jest.fn(() => ({ + connect: jest.fn(), + info: jest.fn(), + sendCommand: jest.fn(), + quit: jest.fn() + })) +})); + +jest.mock('amqplib', () => ({ + connect: jest.fn() +})); + +import app from '../src/index'; +import { vaultClient } from '../src/services/vault'; +import { Client as PgClient } from 'pg'; +import mysql from 'mysql2/promise'; +import { MongoClient } from 'mongodb'; +import { createClient } from 'redis'; +import amqp from 'amqplib'; + +const mockVaultClient = vaultClient as jest.Mocked; +const mockPgClient = PgClient as jest.MockedClass; +const mockMysql = mysql as jest.Mocked; +const mockMongoClient = MongoClient as jest.MockedClass; +const mockCreateClient = createClient as jest.MockedFunction; +const mockAmqp = amqp as jest.Mocked; + +describe('Health Routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /health', () => { + it('should return healthy status', async () => { + const response = await request(app) + .get('/health') + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('status', 'healthy'); + expect(response.body).toHaveProperty('timestamp'); + expect(response.body).toHaveProperty('uptime'); + }); + + it('should return uptime as a number', async () => { + const response = await request(app).get('/health').expect(200); + expect(typeof response.body.uptime).toBe('number'); + }); + }); + + describe('GET /health/vault', () => { + it('should return healthy when Vault is accessible', async () => { + mockVaultClient.healthCheck.mockResolvedValue({ + status: 'healthy', + initialized: true, + sealed: false + }); + + const response = await request(app) + .get('/health/vault') + .expect(200); + + expect(response.body.status).toBe('healthy'); + }); + + it('should return unhealthy when Vault check fails', async () => { + mockVaultClient.healthCheck.mockRejectedValue(new Error('Connection refused')); + + const response = await request(app) + .get('/health/vault') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); + }); + + describe('GET /health/postgres', () => { + it('should return healthy when PostgreSQL is accessible', async () => { + mockVaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockClient = { + connect: jest.fn().mockResolvedValue(undefined), + query: jest.fn().mockResolvedValue({ + rows: [{ version: 'PostgreSQL 15.4 on x86_64' }] + }), + end: jest.fn().mockResolvedValue(undefined) + }; + mockPgClient.mockImplementation(() => mockClient as any); + + const response = await request(app) + .get('/health/postgres') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body).toHaveProperty('version'); + }); + + it('should return unhealthy when PostgreSQL is not accessible', async () => { + mockVaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockClient = { + connect: jest.fn().mockRejectedValue(new Error('Connection refused')), + end: jest.fn().mockResolvedValue(undefined) + }; + mockPgClient.mockImplementation(() => mockClient as any); + + const response = await request(app) + .get('/health/postgres') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); + }); + + describe('GET /health/mysql', () => { + it('should return healthy when MySQL is accessible', async () => { + mockVaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockConnection = { + query: jest.fn().mockResolvedValue([[{ version: '8.0.35' }]]), + end: jest.fn().mockResolvedValue(undefined) + }; + mockMysql.createConnection.mockResolvedValue(mockConnection as any); + + const response = await request(app) + .get('/health/mysql') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body.version).toBe('8.0.35'); + }); + + it('should return unhealthy when MySQL is not accessible', async () => { + mockVaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + mockMysql.createConnection.mockRejectedValue(new Error('Connection refused')); + + const response = await request(app) + .get('/health/mysql') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); + }); + + describe('GET /health/mongodb', () => { + it('should return healthy when MongoDB is accessible', async () => { + mockVaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockClient = { + connect: jest.fn().mockResolvedValue(undefined), + db: jest.fn(() => ({ + admin: jest.fn(() => ({ + serverInfo: jest.fn().mockResolvedValue({ version: '7.0.4' }) + })) + })), + close: jest.fn().mockResolvedValue(undefined) + }; + mockMongoClient.mockImplementation(() => mockClient as any); + + const response = await request(app) + .get('/health/mongodb') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body.version).toBe('7.0.4'); + }); + + it('should return unhealthy when MongoDB is not accessible', async () => { + mockVaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockClient = { + connect: jest.fn().mockRejectedValue(new Error('Connection refused')), + close: jest.fn().mockResolvedValue(undefined) + }; + mockMongoClient.mockImplementation(() => mockClient as any); + + const response = await request(app) + .get('/health/mongodb') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); + }); + + describe('GET /health/redis', () => { + it('should return healthy when Redis is accessible', async () => { + mockVaultClient.getSecret.mockResolvedValue({ password: 'testpass' }); + + const mockClient = { + connect: jest.fn().mockResolvedValue(undefined), + info: jest.fn().mockResolvedValue('redis_version:7.2.4\r\n'), + sendCommand: jest.fn().mockResolvedValue('cluster_state:ok\r\n'), + quit: jest.fn().mockResolvedValue(undefined) + }; + mockCreateClient.mockReturnValue(mockClient as any); + + const response = await request(app) + .get('/health/redis') + .expect(200); + + expect(response.body.status).toBe('healthy'); + }); + + it('should return unhealthy when Redis is not accessible', async () => { + mockVaultClient.getSecret.mockResolvedValue({ password: 'testpass' }); + + const mockClient = { + connect: jest.fn().mockRejectedValue(new Error('Connection refused')), + quit: jest.fn().mockResolvedValue(undefined) + }; + mockCreateClient.mockReturnValue(mockClient as any); + + const response = await request(app) + .get('/health/redis') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); + }); + + describe('GET /health/rabbitmq', () => { + it('should return healthy when RabbitMQ is accessible', async () => { + mockVaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass' + }); + + const mockChannel = { + close: jest.fn().mockResolvedValue(undefined) + }; + const mockConnection = { + createChannel: jest.fn().mockResolvedValue(mockChannel), + close: jest.fn().mockResolvedValue(undefined) + }; + mockAmqp.connect.mockResolvedValue(mockConnection as any); + + const response = await request(app) + .get('/health/rabbitmq') + .expect(200); + + expect(response.body.status).toBe('healthy'); + }); + + it('should return unhealthy when RabbitMQ is not accessible', async () => { + mockVaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass' + }); + + mockAmqp.connect.mockRejectedValue(new Error('Connection refused')); + + const response = await request(app) + .get('/health/rabbitmq') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + }); + }); + + describe('GET /health/all', () => { + it('should return aggregate health status', async () => { + mockVaultClient.healthCheck.mockResolvedValue({ status: 'healthy' }); + mockVaultClient.getSecret.mockResolvedValue({ + user: 'testuser', + password: 'testpass', + database: 'testdb' + }); + + const mockPg = { + connect: jest.fn().mockResolvedValue(undefined), + query: jest.fn().mockResolvedValue({ rows: [{ version: 'PostgreSQL 15.4' }] }), + end: jest.fn().mockResolvedValue(undefined) + }; + mockPgClient.mockImplementation(() => mockPg as any); + + const mockMysqlConn = { + query: jest.fn().mockResolvedValue([[{ version: '8.0.35' }]]), + end: jest.fn().mockResolvedValue(undefined) + }; + mockMysql.createConnection.mockResolvedValue(mockMysqlConn as any); + + const mockMongo = { + connect: jest.fn().mockResolvedValue(undefined), + db: jest.fn(() => ({ + admin: jest.fn(() => ({ + serverInfo: jest.fn().mockResolvedValue({ version: '7.0.4' }) + })) + })), + close: jest.fn().mockResolvedValue(undefined) + }; + mockMongoClient.mockImplementation(() => mockMongo as any); + + const mockRedis = { + connect: jest.fn().mockResolvedValue(undefined), + info: jest.fn().mockResolvedValue('redis_version:7.2.4\r\n'), + sendCommand: jest.fn().mockResolvedValue('cluster_state:ok\r\n'), + quit: jest.fn().mockResolvedValue(undefined) + }; + mockCreateClient.mockReturnValue(mockRedis as any); + + const mockRabbitChannel = { close: jest.fn().mockResolvedValue(undefined) }; + const mockRabbitConn = { + createChannel: jest.fn().mockResolvedValue(mockRabbitChannel), + close: jest.fn().mockResolvedValue(undefined) + }; + mockAmqp.connect.mockResolvedValue(mockRabbitConn as any); + + const response = await request(app) + .get('/health/all') + .expect(200); + + expect(response.body).toHaveProperty('status'); + expect(response.body).toHaveProperty('services'); + expect(response.body).toHaveProperty('timestamp'); + expect(response.body.services).toHaveProperty('vault'); + expect(response.body.services).toHaveProperty('postgres'); + expect(response.body.services).toHaveProperty('mysql'); + expect(response.body.services).toHaveProperty('mongodb'); + expect(response.body.services).toHaveProperty('redis'); + expect(response.body.services).toHaveProperty('rabbitmq'); + }); + }); +}); diff --git a/reference-apps/typescript-api-first/tests/index.test.ts b/reference-apps/typescript-api-first/tests/index.test.ts new file mode 100644 index 0000000..8ed4b3c --- /dev/null +++ b/reference-apps/typescript-api-first/tests/index.test.ts @@ -0,0 +1,96 @@ +/** + * Main Application Tests + * + * Tests for the Express application entry point and core functionality. + */ + +import request from 'supertest'; + +// Mock the vault service before importing app +jest.mock('../src/services/vault', () => ({ + vaultClient: { + healthCheck: jest.fn(), + getSecret: jest.fn(), + isAuthenticated: jest.fn().mockReturnValue(true) + } +})); + +import app from '../src/index'; + +describe('Application Entry Point', () => { + describe('GET /', () => { + it('should return API information', async () => { + const response = await request(app) + .get('/') + .expect('Content-Type', /json/) + .expect(200); + + expect(response.body).toHaveProperty('name'); + expect(response.body).toHaveProperty('version'); + expect(response.body).toHaveProperty('language', 'TypeScript'); + expect(response.body).toHaveProperty('framework', 'Express'); + expect(response.body).toHaveProperty('endpoints'); + }); + + it('should include all expected endpoints in response', async () => { + const response = await request(app).get('/').expect(200); + + const { endpoints } = response.body; + expect(endpoints).toHaveProperty('health', '/health'); + expect(endpoints).toHaveProperty('vault_examples', '/examples/vault'); + expect(endpoints).toHaveProperty('database_examples', '/examples/database'); + expect(endpoints).toHaveProperty('cache_examples', '/examples/cache'); + expect(endpoints).toHaveProperty('messaging_examples', '/examples/messaging'); + expect(endpoints).toHaveProperty('metrics', '/metrics'); + }); + + it('should include redis cluster endpoints', async () => { + const response = await request(app).get('/').expect(200); + + expect(response.body).toHaveProperty('redis_cluster'); + expect(response.body.redis_cluster).toHaveProperty('nodes'); + expect(response.body.redis_cluster).toHaveProperty('slots'); + expect(response.body.redis_cluster).toHaveProperty('info'); + }); + }); + + describe('GET /metrics', () => { + it('should return Prometheus metrics', async () => { + const response = await request(app) + .get('/metrics') + .expect(200); + + expect(response.text).toContain('# HELP'); + expect(response.text).toContain('# TYPE'); + }); + }); + + describe('404 Handler', () => { + it('should return 404 for unknown routes', async () => { + const response = await request(app) + .get('/nonexistent/route') + .expect('Content-Type', /json/) + .expect(404); + + expect(response.body).toHaveProperty('error', 'Not Found'); + expect(response.body).toHaveProperty('message'); + }); + + it('should include request method in 404 response', async () => { + const response = await request(app) + .post('/nonexistent') + .expect(404); + + expect(response.body.message).toContain('POST'); + }); + }); +}); + +describe('Security Headers', () => { + it('should include security headers from helmet', async () => { + const response = await request(app).get('/').expect(200); + + expect(response.headers).toHaveProperty('x-content-type-options'); + expect(response.headers).toHaveProperty('x-frame-options'); + }); +}); diff --git a/reference-apps/typescript-api-first/tests/setup.ts b/reference-apps/typescript-api-first/tests/setup.ts new file mode 100644 index 0000000..87f5133 --- /dev/null +++ b/reference-apps/typescript-api-first/tests/setup.ts @@ -0,0 +1,16 @@ +/** + * Jest Test Setup for TypeScript + * + * Global setup for all tests. + */ + +// Set test environment +process.env.NODE_ENV = 'test'; + +// Increase timeout for async operations +jest.setTimeout(10000); + +// Global afterAll to close any open handles +afterAll(async () => { + await new Promise(resolve => setTimeout(resolve, 500)); +}); diff --git a/scripts/generate-certificates.sh b/scripts/generate-certificates.sh index 998659f..5d72600 100755 --- a/scripts/generate-certificates.sh +++ b/scripts/generate-certificates.sh @@ -630,7 +630,7 @@ main() { # Check required environment variables if [ -z "$VAULT_TOKEN" ]; then - error "VAULT_TOKEN environment variable is required" + error "VAULT_TOKEN not set and ~/.config/vault/root-token not found. Run './devstack vault-init' first or export VAULT_TOKEN." fi # Wait for Vault