From cd1bfafe72d36770f4abe5debe6428ded6f6516c Mon Sep 17 00:00:00 2001 From: "n8n-cat-bot[bot]" <283985454+n8n-cat-bot[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:49:59 +0000 Subject: [PATCH] refactor(API): Migrate POST /settings/otel/test-trace to the decorator pattern (no-changelog) (#39716) Co-authored-by: n8n-cat-bot[bot] Co-authored-by: Uddish Verma Co-authored-by: Claude Fable 5.1 Co-authored-by: Declan Carroll --- packages/@n8n/api-types/src/dto/index.ts | 4 + .../update-otel-settings.dto.test.ts | 20 +++++ .../dto/otel/otel-test-trace-public.dto.ts | 50 +++++++++++ .../otel/otel-test-trace-public.openapi.ts | 43 ++++++++++ .../src/dto/otel/update-otel-settings.dto.ts | 1 + packages/cli/eslint.config.mjs | 1 - packages/cli/oxlint.config.mts | 1 - packages/cli/src/public-api/types.ts | 9 -- .../v1/controllers/otel.public.controller.ts | 22 +++++ .../v1/handlers/otel/otel.handler.ts | 34 -------- .../spec/paths/settings.otel.test-trace.yml | 32 ------- .../spec/schemas/otel-test-trace-result.yml | 15 ---- .../otel/spec/schemas/otel-test-trace.yml | 56 ------------- .../spec/paths/testOtelTrace.generated.yml | 84 +++++++++++++++++++ .../v1/openapi.decorator-routes.generated.yml | 3 + packages/cli/src/public-api/v1/openapi.yml | 2 - .../public-api/otel-settings.test.ts | 59 +++++++++++++ 17 files changed, 286 insertions(+), 150 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/otel/otel-test-trace-public.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/otel/otel-test-trace-public.openapi.ts delete mode 100644 packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts delete mode 100644 packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.test-trace.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace-result.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace.yml create mode 100644 packages/cli/src/public-api/v1/handlers/settings/spec/paths/testOtelTrace.generated.yml diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index c2f7787c97d..8e978cc7046 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -598,6 +598,10 @@ export { OtelSettingsQueryPublicDto, UpdateOtelSettingsPublicDto, } from './otel/otel-settings-public.dto'; +export { + OtelTestTraceRequestPublicDto, + OtelTestTraceResultPublicDto, +} from './otel/otel-test-trace-public.dto'; export { PromotionChangesDto, diff --git a/packages/@n8n/api-types/src/dto/otel/__tests__/update-otel-settings.dto.test.ts b/packages/@n8n/api-types/src/dto/otel/__tests__/update-otel-settings.dto.test.ts index 26e59999901..ee2a0b8102b 100644 --- a/packages/@n8n/api-types/src/dto/otel/__tests__/update-otel-settings.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/otel/__tests__/update-otel-settings.dto.test.ts @@ -96,6 +96,16 @@ describe('UpdateOtelSettingsDto', () => { expect(result.data.exporterEndpoint).toBe(exporterEndpoint); }); + it('trims whitespace around the exporter endpoint', () => { + const result = UpdateOtelSettingsDto.safeParse({ + ...validSettings, + exporterEndpoint: ' http://localhost:4318 ', + }); + + assert(result.success); + expect(result.data.exporterEndpoint).toBe('http://localhost:4318'); + }); + it.each(['localhost:4318', 'grpc://host:4317', 'ftp://x'])( 'rejects %p as an exporter endpoint', (exporterEndpoint) => { @@ -198,6 +208,16 @@ describe('TestOtelTraceDto', () => { expect(result.data.exporterEndpoint).toBe('https://collector.example.com:4317'); }); + it('trims whitespace around the exporter endpoint', () => { + const result = TestOtelTraceDto.safeParse({ + ...validConnection, + exporterEndpoint: ' http://localhost:4318 ', + }); + + assert(result.success); + expect(result.data.exporterEndpoint).toBe('http://localhost:4318'); + }); + it('rejects a non-http exporter endpoint scheme', () => { const result = TestOtelTraceDto.safeParse({ ...validConnection, diff --git a/packages/@n8n/api-types/src/dto/otel/otel-test-trace-public.dto.ts b/packages/@n8n/api-types/src/dto/otel/otel-test-trace-public.dto.ts new file mode 100644 index 00000000000..954f099e44d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/otel/otel-test-trace-public.dto.ts @@ -0,0 +1,50 @@ +import '../../openapi-extend'; + +import { z } from 'zod'; + +import { + otelTestTraceRequestFieldDocs, + otelTestTraceRequestDocs, + otelTestTraceResultFieldDocs, + otelTestTraceResultDocs, +} from './otel-test-trace-public.openapi'; +import { TestOtelTraceDto } from './test-otel-trace.dto'; +import { Z } from '../../zod-class'; + +const connectionFields = TestOtelTraceDto.schema.shape; + +export class OtelTestTraceRequestPublicDto extends Z.class( + { + exporterProtocol: connectionFields.exporterProtocol.openapi( + otelTestTraceRequestFieldDocs.exporterProtocol, + ), + exporterEndpoint: connectionFields.exporterEndpoint.openapi( + otelTestTraceRequestFieldDocs.exporterEndpoint, + ), + exporterTracingPath: connectionFields.exporterTracingPath.openapi( + otelTestTraceRequestFieldDocs.exporterTracingPath, + ), + exporterServiceName: connectionFields.exporterServiceName.openapi( + otelTestTraceRequestFieldDocs.exporterServiceName, + ), + exporterHeaders: connectionFields.exporterHeaders.openapi( + otelTestTraceRequestFieldDocs.exporterHeaders, + ), + startupConnectivityTimeoutMs: connectionFields.startupConnectivityTimeoutMs.openapi( + otelTestTraceRequestFieldDocs.startupConnectivityTimeoutMs, + ), + }, + { strict: true }, +) { + static schema = super.schema.openapi(otelTestTraceRequestDocs); +} + +export class OtelTestTraceResultPublicDto extends Z.class( + { + success: z.boolean().openapi(otelTestTraceResultFieldDocs.success), + error: z.string().optional().openapi(otelTestTraceResultFieldDocs.error), + }, + { strict: true }, +) { + static schema = super.schema.openapi(otelTestTraceResultDocs); +} diff --git a/packages/@n8n/api-types/src/dto/otel/otel-test-trace-public.openapi.ts b/packages/@n8n/api-types/src/dto/otel/otel-test-trace-public.openapi.ts new file mode 100644 index 00000000000..ce4542a6447 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/otel/otel-test-trace-public.openapi.ts @@ -0,0 +1,43 @@ +import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi'; + +import { otelSettingsPublicFieldDocs } from './otel-settings-public.openapi'; + +export const otelTestTraceRequestDocs = { + description: + 'The connection details to test against an OTLP collector. Fields managed declaratively via environment variables are overridden with their effective value before the test is sent.', +} satisfies ZodOpenAPIMetadata; + +export const otelTestTraceResultDocs = { + description: 'The outcome of the test connection to the OTLP collector.', +} satisfies ZodOpenAPIMetadata; + +export const otelTestTraceRequestFieldDocs = { + exporterProtocol: { + description: + 'The wire protocol used to send the test span. Collectors conventionally serve OTLP/HTTP on port 4318 and OTLP/gRPC on port 4317. The endpoint scheme (`http://` or `https://`) controls TLS for both protocols. Optional: omitting it selects `http/protobuf`, unless an environment variable manages this field, which then supplies the value.', + example: 'http/protobuf', + }, + exporterEndpoint: otelSettingsPublicFieldDocs.exporterEndpoint, + exporterTracingPath: otelSettingsPublicFieldDocs.exporterTracingPath, + exporterServiceName: { + description: 'The `service.name` resource attribute reported on the test span.', + example: 'n8n', + }, + exporterHeaders: otelSettingsPublicFieldDocs.exporterHeaders, + startupConnectivityTimeoutMs: { + description: 'How long, in milliseconds, to wait for the collector to respond.', + example: 2000, + }, +} as const satisfies Record; + +export const otelTestTraceResultFieldDocs = { + success: { + description: 'Whether the test span was accepted by the collector.', + example: true, + }, + error: { + description: + 'The error reported by the collector or exporter. Present only when `success` is false.', + example: 'Failed to connect: 401 Unauthorized', + }, +} as const satisfies Record; diff --git a/packages/@n8n/api-types/src/dto/otel/update-otel-settings.dto.ts b/packages/@n8n/api-types/src/dto/otel/update-otel-settings.dto.ts index 60766c8ca12..238665d4068 100644 --- a/packages/@n8n/api-types/src/dto/otel/update-otel-settings.dto.ts +++ b/packages/@n8n/api-types/src/dto/otel/update-otel-settings.dto.ts @@ -10,6 +10,7 @@ export const otlpProtocolSchema = z.enum(OTLP_PROTOCOLS); export const exporterEndpointSchema = z .string() + .trim() .url() .regex(/^https?:\/\//i, 'Endpoint must start with http:// or https://. The scheme selects TLS.'); diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index 9b5f15ff6ae..80a0cbeb004 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -141,7 +141,6 @@ export default defineConfig( './src/public-api/v1/handlers/ldap/ldap.handler.ts', './src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts', './src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts', - './src/public-api/v1/handlers/otel/otel.handler.ts', './src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts', './src/public-api/v1/handlers/workflows/workflows.handler.ts', ], diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index 68f7186edbb..6c4d82b2652 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -135,7 +135,6 @@ export default defineConfig({ './src/public-api/v1/handlers/ldap/ldap.handler.ts', './src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts', './src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts', - './src/public-api/v1/handlers/otel/otel.handler.ts', './src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts', './src/public-api/v1/handlers/workflows/workflows.handler.ts', ], diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index f73ff034c1d..200f95b4e5e 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -1,7 +1,6 @@ import type { UpdateDataTableRowDto, PublicCreateDestination, - TestOtelTraceDto, UpdateSamlConfigurationDto, UpdateLdapConfigurationDto, LdapSyncDto, @@ -166,14 +165,6 @@ export declare namespace SsoSamlRequest { type Update = AuthenticatedRequest<{}, {}, UpdateSamlConfigurationDto>; } -// ---------------------------------- -// /settings/otel -// ---------------------------------- - -export declare namespace OtelSettingsRequest { - type Test = AuthenticatedRequest<{}, {}, TestOtelTraceDto>; -} - // ---------------------------------- // /settings/ldap // ---------------------------------- diff --git a/packages/cli/src/public-api/v1/controllers/otel.public.controller.ts b/packages/cli/src/public-api/v1/controllers/otel.public.controller.ts index a11131c857f..7a2d2e8a8f1 100644 --- a/packages/cli/src/public-api/v1/controllers/otel.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/otel.public.controller.ts @@ -1,6 +1,8 @@ import { OtelSettingsPublicDto, OtelSettingsQueryPublicDto, + OtelTestTraceRequestPublicDto, + OtelTestTraceResultPublicDto, UpdateOtelSettingsPublicDto, } from '@n8n/api-types'; import { ModuleRegistry } from '@n8n/backend-common'; @@ -14,6 +16,7 @@ import { ApiTags, Body, Get, + Post, PublicApiController, Put, Query, @@ -89,4 +92,23 @@ export class OtelPublicController { return toOtelSettingsResponse(updated); } + + @Post('/test-trace') + @ApiKeyScope('otel:manage') + @ApiSummary('Test the connection to an OTLP collector') + @ApiDescription( + 'Send a single test span to the given OTLP collector and report whether it was accepted. This tests the supplied connection details without changing the stored configuration. Fields managed declaratively via environment variables are overridden with their effective value before the test is sent. Requires the `otel:manage` scope.', + ) + @ApiTags(tags) + @ApiResponse(200, OtelTestTraceResultPublicDto) + async testOtelTrace( + _req: AuthenticatedRequest, + _res: Response, + @Body body: OtelTestTraceRequestPublicDto, + @Query _query: OtelSettingsQueryPublicDto, + ): Promise { + const connection = this.settingsService.resolveTestConnection(body); + + return await this.otelService.sendTestTrace(connection); + } } diff --git a/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts b/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts deleted file mode 100644 index d3dc7a0a1cb..00000000000 --- a/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { TestOtelTraceDto } from '@n8n/api-types'; -import { Container } from '@n8n/di'; -import { BadRequestError } from '@n8n/errors'; - -import { OtelSettingsService } from '@/modules/otel/otel-settings.service'; -import { OtelService } from '@/modules/otel/otel.service'; - -import type { OtelSettingsRequest } from '../../../types'; -import type { PublicAPIEndpoint } from '../../shared/handler.types'; -import { apiKeyHasScopeWithGlobalScopeFallback } from '../../shared/middlewares/global.middleware'; - -type OtelHandlers = { - testOtelTrace: PublicAPIEndpoint; -}; - -const otelHandlers: OtelHandlers = { - testOtelTrace: [ - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'otel:manage' }), - async (req, res) => { - const payload = TestOtelTraceDto.safeParse(req.body); - if (!payload.success) { - throw new BadRequestError(payload.error.errors[0]?.message ?? 'Invalid request body'); - } - - const settingsService = Container.get(OtelSettingsService); - const connection = settingsService.resolveTestConnection(payload.data); - const result = await Container.get(OtelService).sendTestTrace(connection); - - return res.json(result); - }, - ], -}; - -export = otelHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.test-trace.yml b/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.test-trace.yml deleted file mode 100644 index cacc92e48e9..00000000000 --- a/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.test-trace.yml +++ /dev/null @@ -1,32 +0,0 @@ -post: - x-eov-operation-id: testOtelTrace - x-required-scope: otel:manage - x-eov-operation-handler: v1/handlers/otel/otel.handler - tags: - - SettingsOtel - summary: Test the connection to an OTLP collector - description: > - Send a single test span to the given OTLP collector and report whether it was accepted. This - tests the supplied connection details without changing the stored configuration. Fields managed - declaratively via environment variables are overridden with their effective value before the - test is sent. Requires the `otel:manage` scope. - requestBody: - description: The connection details to test. - required: true - content: - application/json: - schema: - $ref: '../schemas/otel-test-trace.yml' - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/otel-test-trace-result.yml' - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' diff --git a/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace-result.yml b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace-result.yml deleted file mode 100644 index 6b914b53bee..00000000000 --- a/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace-result.yml +++ /dev/null @@ -1,15 +0,0 @@ -type: object -additionalProperties: false -description: The outcome of the test connection to the OTLP collector. -required: - - success -properties: - success: - type: boolean - description: Whether the test span was accepted by the collector. - example: true - error: - type: string - description: > - The error reported by the collector or exporter. Present only when `success` is false. - example: 'Failed to connect: 401 Unauthorized' diff --git a/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace.yml b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace.yml deleted file mode 100644 index a47c4917f6b..00000000000 --- a/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace.yml +++ /dev/null @@ -1,56 +0,0 @@ -type: object -additionalProperties: false -description: > - The connection details to test against an OTLP collector. Fields managed declaratively via - environment variables are overridden with their effective value before the test is sent. -required: - - exporterEndpoint - - exporterTracingPath - - exporterServiceName - - exporterHeaders - - startupConnectivityTimeoutMs -properties: - exporterProtocol: - type: string - enum: - - http/protobuf - - grpc - default: http/protobuf - description: > - The wire protocol used to send the test span. Collectors conventionally serve OTLP/HTTP on - port 4318 and OTLP/gRPC on port 4317. The endpoint scheme (`http://` or `https://`) - controls TLS for both protocols. Optional: omitting it selects `http/protobuf`, unless an - environment variable manages this field, which then supplies the value. - example: http/protobuf - exporterEndpoint: - type: string - format: uri - description: > - The base URL of the OTLP collector to export traces to. The value must be an `http://` or - `https://` URL, because the scheme selects TLS — for gRPC, `https://` uses SSL and - `http://` is plaintext. - example: http://localhost:4318 - exporterTracingPath: - type: string - description: > - The path appended to the endpoint for the OTLP traces signal. Ignored when - `exporterProtocol` is `grpc`, which takes no URL path. - example: /v1/traces - exporterServiceName: - type: string - minLength: 1 - description: The `service.name` resource attribute reported on the test span. - example: n8n - exporterHeaders: - type: string - description: > - Additional headers sent to the OTLP collector, as a single string of comma-separated - `key=value` pairs (e.g. `authorization=Bearer my-token,x-tenant-id=acme`). Whitespace - around each key and value is trimmed; a value may contain spaces but not commas. Use an - empty string when unused. - example: authorization=Bearer my-token,x-tenant-id=acme - startupConnectivityTimeoutMs: - type: integer - minimum: 0 - description: How long, in milliseconds, to wait for the collector to respond. - example: 2000 diff --git a/packages/cli/src/public-api/v1/handlers/settings/spec/paths/testOtelTrace.generated.yml b/packages/cli/src/public-api/v1/handlers/settings/spec/paths/testOtelTrace.generated.yml new file mode 100644 index 00000000000..2c714dcdff9 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/settings/spec/paths/testOtelTrace.generated.yml @@ -0,0 +1,84 @@ +operationId: testOtelTrace +tags: + - SettingsOtel +summary: Test the connection to an OTLP collector +description: Send a single test span to the given OTLP collector and report whether it was accepted. This tests the supplied connection details without changing the stored configuration. Fields managed declaratively via environment variables are overridden with their effective value before the test is sent. Requires the `otel:manage` scope. +x-required-scope: otel:manage +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + exporterProtocol: + type: string + enum: + - http/protobuf + - grpc + default: http/protobuf + description: 'The wire protocol used to send the test span. Collectors conventionally serve OTLP/HTTP on port 4318 and OTLP/gRPC on port 4317. The endpoint scheme (`http://` or `https://`) controls TLS for both protocols. Optional: omitting it selects `http/protobuf`, unless an environment variable manages this field, which then supplies the value.' + example: http/protobuf + exporterEndpoint: + type: string + format: uri + pattern: ^https?:\/\/ + description: The base URL of the OTLP collector to export traces to. The value must be an `http://` or `https://` URL, because the scheme selects TLS — for gRPC, `https://` uses SSL and `http://` is plaintext. + example: http://localhost:4318 + exporterTracingPath: + type: string + description: The path appended to the endpoint for the OTLP traces signal. Ignored when `exporterProtocol` is `grpc`, which takes no URL path. + example: /v1/traces + exporterServiceName: + type: string + minLength: 1 + description: The `service.name` resource attribute reported on the test span. + example: n8n + exporterHeaders: + type: string + description: Additional headers sent to the OTLP collector, as a single string of comma-separated `key=value` pairs (e.g. `authorization=Bearer my-token,x-tenant-id=acme`). Whitespace around each key and value is trimmed; a value may contain spaces but not commas. Use an empty string when unused. + example: authorization=Bearer my-token,x-tenant-id=acme + startupConnectivityTimeoutMs: + type: integer + minimum: 0 + description: How long, in milliseconds, to wait for the collector to respond. + example: 2000 + required: + - exporterEndpoint + - exporterTracingPath + - exporterServiceName + - exporterHeaders + - startupConnectivityTimeoutMs + additionalProperties: false + description: The connection details to test against an OTLP collector. Fields managed declaratively via environment variables are overridden with their effective value before the test is sent. +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + description: Whether the test span was accepted by the collector. + example: true + error: + type: string + description: The error reported by the collector or exporter. Present only when `success` is false. + example: 'Failed to connect: 401 Unauthorized' + required: + - success + additionalProperties: false + description: The outcome of the test connection to the OTLP collector. + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 2ceeea0bf53..7c1caf0658f 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -176,6 +176,9 @@ paths: $ref: ./handlers/settings/spec/paths/getOtelSettings.generated.yml put: $ref: ./handlers/settings/spec/paths/updateOtelSettings.generated.yml + /settings/otel/test-trace: + post: + $ref: ./handlers/settings/spec/paths/testOtelTrace.generated.yml /projects: get: $ref: ./handlers/projects/spec/paths/getProjects.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 04619452ec2..016b981d9bb 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -83,8 +83,6 @@ paths: $ref: './handlers/ldap/spec/paths/settings.ldap.yml' /settings/ldap/sync: $ref: './handlers/ldap/spec/paths/settings.ldap.sync.yml' - /settings/otel/test-trace: - $ref: './handlers/otel/spec/paths/settings.otel.test-trace.yml' /settings/sso/saml: $ref: './handlers/sso-saml/spec/paths/settings.sso.saml.yml' /workflows/{id}/{versionId}: diff --git a/packages/cli/test/integration/public-api/otel-settings.test.ts b/packages/cli/test/integration/public-api/otel-settings.test.ts index 22ab512cf80..321e2c32367 100644 --- a/packages/cli/test/integration/public-api/otel-settings.test.ts +++ b/packages/cli/test/integration/public-api/otel-settings.test.ts @@ -248,6 +248,19 @@ describe('OpenTelemetry settings in Public API', () => { expect(replaced.body.exporterProtocol).toBe('http/protobuf'); }); + it('stores the exporter endpoint without surrounding whitespace', async () => { + const response = await testServer + .publicApiAgentFor(owner) + .put('/settings/otel') + .send({ ...validSettings, exporterEndpoint: ` ${validSettings.exporterEndpoint} ` }); + + expect(response.status).toBe(200); + expect(response.body.exporterEndpoint).toBe(validSettings.exporterEndpoint); + + const read = await testServer.publicApiAgentFor(owner).get('/settings/otel'); + expect(read.body.exporterEndpoint).toBe(validSettings.exporterEndpoint); + }); + it('rejects an unsupported exporter protocol with 400', async () => { const response = await testServer .publicApiAgentFor(owner) @@ -529,6 +542,22 @@ describe('OpenTelemetry settings in Public API', () => { expect(response.body).toEqual({ success: false, error: '401 Unauthorized' }); }); + it('sends the exporter endpoint to the collector without surrounding whitespace', async () => { + const sendTestTrace = vi + .spyOn(Container.get(OtelService), 'sendTestTrace') + .mockResolvedValue({ success: true }); + + const response = await testServer + .publicApiAgentFor(owner) + .post('/settings/otel/test-trace') + .send({ ...testConnection, exporterEndpoint: ` ${testConnection.exporterEndpoint} ` }); + + expect(response.status).toBe(200); + expect(sendTestTrace).toHaveBeenCalledWith( + expect.objectContaining({ exporterEndpoint: testConnection.exporterEndpoint }), + ); + }); + it('rejects a partial body with 400', async () => { const response = await testServer .publicApiAgentFor(owner) @@ -538,6 +567,20 @@ describe('OpenTelemetry settings in Public API', () => { expect(response.status).toBe(400); }); + it('rejects an unknown field with 400', async () => { + const sendTestTrace = vi + .spyOn(Container.get(OtelService), 'sendTestTrace') + .mockResolvedValue({ success: true }); + + const response = await testServer + .publicApiAgentFor(owner) + .post('/settings/otel/test-trace') + .send({ ...testConnection, unknown: true }); + + expect(response.status).toBe(400); + expect(sendTestTrace).not.toHaveBeenCalled(); + }); + it('accepts a connection body written before exporterProtocol existed', async () => { const sendTestTrace = vi .spyOn(Container.get(OtelService), 'sendTestTrace') @@ -563,5 +606,21 @@ describe('OpenTelemetry settings in Public API', () => { expect(response.status).toBe(401); }); + + it('rejects with 403 when the API key lacks the otel:manage scope', async () => { + const scopedOwner = await createOwnerWithApiKey({ scopes: ['workflow:read'] }); + const sendTestTrace = vi + .spyOn(Container.get(OtelService), 'sendTestTrace') + .mockResolvedValue({ success: true }); + + const response = await testServer + .publicApiAgentFor(scopedOwner) + .post('/settings/otel/test-trace') + .send(testConnection); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ message: 'Forbidden' }); + expect(sendTestTrace).not.toHaveBeenCalled(); + }); }); });