diff --git a/src/config/swagger.config.spec.ts b/src/config/swagger.config.spec.ts new file mode 100644 index 0000000..b5e5324 --- /dev/null +++ b/src/config/swagger.config.spec.ts @@ -0,0 +1,12 @@ +import { createSwaggerConfig } from './swagger.config'; + +describe('createSwaggerConfig', () => { + // Regression test for issue #104: Swagger UI returned "Unauthorized" because the + // X-API-Key scheme was defined but never applied — no operation declared a security + // requirement, so Swagger UI never sent the key. The fix applies it globally. + it('applies the X-API-Key security scheme as a global requirement', () => { + const config = createSwaggerConfig(); + + expect(config.security).toContainEqual({ 'X-API-Key': [] }); + }); +}); diff --git a/src/config/swagger.config.ts b/src/config/swagger.config.ts new file mode 100644 index 0000000..db379f6 --- /dev/null +++ b/src/config/swagger.config.ts @@ -0,0 +1,32 @@ +import { DocumentBuilder, OpenAPIObject } from '@nestjs/swagger'; + +/** + * Security scheme name for the API key, used both when defining the scheme and + * when applying it as a global requirement so Swagger UI sends the header. + */ +export const API_KEY_SECURITY_SCHEME = 'X-API-Key'; + +/** + * Builds the OpenAPI document configuration for the OpenWA API. + */ +export function createSwaggerConfig(): Omit { + return ( + new DocumentBuilder() + .setTitle('OpenWA API') + .setDescription('Open Source WhatsApp API Gateway - Free, Self-Hosted HTTP API') + .setVersion('0.1.6') + .addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, API_KEY_SECURITY_SCHEME) + // Apply the scheme globally so Swagger UI sends the key with every request + // (mirrors the global ApiKeyGuard). Without this, "Authorize" is cosmetic. + .addSecurityRequirements(API_KEY_SECURITY_SCHEME) + .addTag('sessions', 'WhatsApp session management') + .addTag('messages', 'Send and manage messages') + .addTag('webhooks', 'Webhook configuration') + .addTag('contacts', 'Contact management') + .addTag('groups', 'Group management') + .addTag('labels', 'Label management (WhatsApp Business)') + .addTag('channels', 'Channel/Newsletter management') + .addTag('health', 'Health check endpoints') + .build() + ); +} diff --git a/src/main.ts b/src/main.ts index 61966fb..d598990 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,10 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { SwaggerModule } from '@nestjs/swagger'; import helmet from 'helmet'; import { AppModule } from './app.module'; import { ShutdownService } from './common/services/shutdown.service'; +import { createSwaggerConfig } from './config/swagger.config'; import * as dotenv from 'dotenv'; import * as fs from 'fs'; import * as path from 'path'; @@ -141,20 +142,7 @@ async function bootstrap() { ); // Swagger documentation - const config = new DocumentBuilder() - .setTitle('OpenWA API') - .setDescription('Open Source WhatsApp API Gateway - Free, Self-Hosted HTTP API') - .setVersion('0.1.6') - .addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'X-API-Key') - .addTag('sessions', 'WhatsApp session management') - .addTag('messages', 'Send and manage messages') - .addTag('webhooks', 'Webhook configuration') - .addTag('contacts', 'Contact management') - .addTag('groups', 'Group management') - .addTag('labels', 'Label management (WhatsApp Business)') - .addTag('channels', 'Channel/Newsletter management') - .addTag('health', 'Health check endpoints') - .build(); + const config = createSwaggerConfig(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, document); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 3d723f3..dc15780 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -1,12 +1,11 @@ import { Controller, Get, Post, Put, Delete, Body, Param, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { CreateApiKeyDto, UpdateApiKeyDto, ApiKeyResponseDto, ApiKeyCreatedResponseDto } from './dto'; import { RequireRole } from './decorators/auth.decorators'; import { ApiKeyRole } from './entities/api-key.entity'; @ApiTags('auth') -@ApiBearerAuth() @Controller('auth/api-keys') export class AuthController { constructor(private readonly authService: AuthService) {} diff --git a/src/modules/catalog/catalog.controller.ts b/src/modules/catalog/catalog.controller.ts index 5fece45..d2ecf98 100644 --- a/src/modules/catalog/catalog.controller.ts +++ b/src/modules/catalog/catalog.controller.ts @@ -1,10 +1,9 @@ import { Controller, Get, Post, Param, Body, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { CatalogService } from './catalog.service'; import { SendProductDto, SendCatalogDto, ProductQueryDto } from './dto/send-product.dto'; @ApiTags('Catalog') -@ApiBearerAuth() @Controller('sessions/:sessionId') export class CatalogController { constructor(private readonly catalogService: CatalogService) {} diff --git a/src/modules/plugins/plugins.controller.ts b/src/modules/plugins/plugins.controller.ts index 870bcce..4792fec 100644 --- a/src/modules/plugins/plugins.controller.ts +++ b/src/modules/plugins/plugins.controller.ts @@ -1,10 +1,9 @@ import { Controller, Get, Post, Put, Param, Body, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { PluginsService } from './plugins.service'; import { PluginDto, PluginConfigDto } from './dto/plugin.dto'; @ApiTags('plugins') -@ApiBearerAuth() @Controller('plugins') export class PluginsController { constructor(private readonly pluginsService: PluginsService) {} diff --git a/src/modules/stats/stats.controller.ts b/src/modules/stats/stats.controller.ts index 803e507..edde544 100644 --- a/src/modules/stats/stats.controller.ts +++ b/src/modules/stats/stats.controller.ts @@ -1,10 +1,9 @@ import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { StatsService } from './stats.service'; import { StatsQueryDto } from './dto/stats-query.dto'; @ApiTags('Statistics') -@ApiBearerAuth() @Controller('stats') export class StatsController { constructor(private readonly statsService: StatsService) {} diff --git a/src/modules/status/status.controller.ts b/src/modules/status/status.controller.ts index 34574b1..5840695 100644 --- a/src/modules/status/status.controller.ts +++ b/src/modules/status/status.controller.ts @@ -1,11 +1,10 @@ import { Controller, Get, Post, Delete, Param, Body } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { StatusService } from './status.service'; import { SendTextStatusDto } from './dto/send-text-status.dto'; import { SendImageStatusDto, SendVideoStatusDto } from './dto/send-media-status.dto'; @ApiTags('Status') -@ApiBearerAuth() @Controller('sessions/:sessionId/status') export class StatusController { constructor(private readonly statusService: StatusService) {}