fix(core): Always redact OTel header values in settings responses (#37283)

This commit is contained in:
Garrit Franke 2026-09-04 15:43:57 +00:00 committed by GitHub
parent afc4a54798
commit 4b0de25a08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 451 additions and 27 deletions

View File

@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/unbound-method */
import type { Logger } from '@n8n/backend-common';
import type { Settings, SettingsRepository } from '@n8n/db';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
import { tmpdir } from 'os';
import { join } from 'path';
import { mock } from 'vitest-mock-extended';
import type { OtelConnectionParams } from '../otel-settings.service';
@ -8,6 +13,7 @@ import { OtelConfig } from '../otel.config';
describe('OtelSettingsService', () => {
const settingsRepository = mock<SettingsRepository>();
const logger = mock<Logger>();
const config = new OtelConfig(); // env-defaults (no env vars set in test runtime)
let service: OtelSettingsService;
@ -21,7 +27,7 @@ describe('OtelSettingsService', () => {
for (const key of Object.keys(process.env)) {
if (key.startsWith('N8N_OTEL_')) delete process.env[key];
}
service = new OtelSettingsService(config, settingsRepository);
service = new OtelSettingsService(config, settingsRepository, logger);
});
afterAll(() => {
@ -29,6 +35,43 @@ describe('OtelSettingsService', () => {
});
describe('loadSettings', () => {
it('warns and falls back to defaults when persisted exporterHeaders is not a string', async () => {
settingsRepository.findByKey.mockResolvedValue({
value: JSON.stringify({ exporterHeaders: 123 }),
} as Settings);
await service.loadSettings();
const result = service.getSettings();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('non-string exporterHeaders'),
);
expect(result.exporterHeaders).toBe('');
});
it('warns and falls back to defaults when the persisted row is not a settings object', async () => {
settingsRepository.findByKey.mockResolvedValue({ value: '"a-string"' } as Settings);
await service.loadSettings();
const result = service.getSettings();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('not a settings object'));
expect(result.exporterServiceName).toBe('n8n');
});
it('warns and falls back to defaults when the persisted row contains invalid JSON', async () => {
settingsRepository.findByKey.mockResolvedValue({ value: 'not-valid-json' } as Settings);
await service.loadSettings();
const result = service.getSettings();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('invalid JSON'),
expect.objectContaining({ error: expect.any(String) }),
);
expect(result.exporterEndpoint).toBe('http://localhost:4318');
});
it('returns defaults when DB row is absent and no env vars are set', async () => {
settingsRepository.findByKey.mockResolvedValue(null);
@ -84,7 +127,7 @@ describe('OtelSettingsService', () => {
const configWithEnv = new OtelConfig();
configWithEnv.exporterEndpoint = 'https://from-env';
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository);
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger);
await serviceWithEnv.loadSettings();
const result = serviceWithEnv.getSettings();
@ -125,7 +168,7 @@ describe('OtelSettingsService', () => {
const configWithEnv = new OtelConfig();
configWithEnv.exporterProtocol = 'grpc';
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository);
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger);
await serviceWithEnv.loadSettings();
const result = serviceWithEnv.getSettings();
@ -144,7 +187,7 @@ describe('OtelSettingsService', () => {
const configWithEnv = new OtelConfig();
configWithEnv.enabled = true;
configWithEnv.exporterEndpoint = 'https://from-env';
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository);
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger);
await serviceWithEnv.loadSettings();
const result = serviceWithEnv.getSettings();
@ -154,6 +197,88 @@ describe('OtelSettingsService', () => {
expect(result.envManagedFields).toContain('enabled');
expect(result.envManagedFields).toContain('exporterEndpoint');
});
it('masks env-managed exporterHeaders in the settings response', async () => {
settingsRepository.findByKey.mockResolvedValue(null);
process.env.N8N_OTEL_EXPORTER_OTLP_HEADERS = 'authorization=Bearer secret-token';
const configWithEnv = new OtelConfig();
configWithEnv.exporterHeaders = 'authorization=Bearer secret-token';
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger);
await serviceWithEnv.loadSettings();
const result = serviceWithEnv.getSettings();
expect(result.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`);
expect(result.exporterHeaders).not.toContain('secret-token');
expect(result.envManagedFields).toContain('exporterHeaders');
});
it('returns an empty string when no headers are set', async () => {
settingsRepository.findByKey.mockResolvedValue(null);
await service.loadSettings();
const result = service.getSettings();
expect(result.exporterHeaders).toBe('');
});
it('masks stored exporterHeaders even when not env-managed', async () => {
settingsRepository.findByKey.mockResolvedValue({
value: JSON.stringify({ exporterHeaders: 'x-api-key=stored' }),
} as Settings);
await service.loadSettings();
const result = service.getSettings();
expect(result.exporterHeaders).toBe(`x-api-key=${CREDENTIAL_BLANKING_VALUE}`);
expect(result.exporterHeaders).not.toContain('stored');
expect(result.envManagedFields).not.toContain('exporterHeaders');
});
describe('values supplied via the _FILE env variant', () => {
// Temp dir backing the _FILE test; removed in afterEach
let tempHeadersDir: string | undefined;
afterEach(() => {
if (tempHeadersDir) {
rmSync(tempHeadersDir, { recursive: true, force: true });
tempHeadersDir = undefined;
}
});
it('treats headers from N8N_OTEL_EXPORTER_OTLP_HEADERS_FILE as env-managed and masks them', async () => {
const dir = mkdtempSync(join(tmpdir(), 'otel-headers-'));
tempHeadersDir = dir;
const headersFile = join(dir, 'headers');
writeFileSync(headersFile, 'authorization=Bearer file-secret');
process.env.N8N_OTEL_EXPORTER_OTLP_HEADERS_FILE = headersFile;
settingsRepository.findByKey.mockResolvedValue(null);
// Mirror what the config factory does when it reads the _FILE variant
// (covered by @n8n/config's own decorator tests)
const configFromFile = new OtelConfig();
configFromFile.exporterHeaders = 'authorization=Bearer file-secret';
const serviceFromFile = new OtelSettingsService(configFromFile, settingsRepository, logger);
await serviceFromFile.loadSettings();
const result = serviceFromFile.getSettings();
expect(result.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`);
expect(result.envManagedFields).toContain('exporterHeaders');
});
it('does not treat an empty _FILE variable as env-managed', async () => {
process.env.N8N_OTEL_EXPORTER_OTLP_HEADERS_FILE = '';
settingsRepository.findByKey.mockResolvedValue(null);
await service.loadSettings();
const result = service.getSettings();
expect(result.envManagedFields).not.toContain('exporterHeaders');
expect(result.exporterHeaders).toBe('');
});
});
});
describe('getSettings', () => {
@ -216,7 +341,7 @@ describe('OtelSettingsService', () => {
const configWithEnv = new OtelConfig();
configWithEnv.exporterEndpoint = 'https://from-env';
configWithEnv.exporterProtocol = 'grpc';
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository);
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger);
settingsRepository.findByKey.mockResolvedValue(null);
const incoming: OtelConfig = {
@ -233,6 +358,37 @@ describe('OtelSettingsService', () => {
expect(saved.exporterProtocol).toBe('grpc');
expect(saved.enabled).toBe(settings.enabled);
});
it('keeps the stored value per key when blanked values are echoed back', async () => {
settingsRepository.findByKey.mockResolvedValue({
value: JSON.stringify({ ...settings, exporterHeaders: 'a=1,b=2' }),
} as Settings);
await service.saveSettings({
...settings,
exporterHeaders: `a=${CREDENTIAL_BLANKING_VALUE},c=3`,
});
const saved = JSON.parse(
(settingsRepository.save.mock.calls[0]?.[0] as { value: string }).value,
) as OtelConfig;
// 'a' keeps its stored value, 'b' is dropped (absent), 'c' is new
expect(saved.exporterHeaders).toBe('a=1,c=3');
});
it('drops a blanked pair whose key has no stored value', async () => {
settingsRepository.findByKey.mockResolvedValue(null);
await service.saveSettings({
...settings,
exporterHeaders: `z=${CREDENTIAL_BLANKING_VALUE}`,
});
const saved = JSON.parse(
(settingsRepository.save.mock.calls[0]?.[0] as { value: string }).value,
) as OtelConfig;
expect(saved.exporterHeaders).toBe('');
});
});
describe('resolveTestConnection', () => {
@ -249,11 +405,26 @@ describe('OtelSettingsService', () => {
expect(service.resolveTestConnection(incoming)).toEqual(incoming);
});
it('resolves blanked values against the stored ones for test connections', async () => {
settingsRepository.findByKey.mockResolvedValue({
value: JSON.stringify({ exporterHeaders: 'x-api-key=stored,x-other=2' }),
} as Settings);
await service.loadSettings();
const result = service.resolveTestConnection({
...incoming,
exporterHeaders: `x-api-key=${CREDENTIAL_BLANKING_VALUE}`,
});
expect(result.exporterHeaders).toBe('x-api-key=stored');
});
it('overrides env-managed fields with the canonical env-var value', () => {
process.env.N8N_OTEL_EXPORTER_OTLP_ENDPOINT = 'https://from-env';
const configWithEnv = new OtelConfig();
configWithEnv.exporterEndpoint = 'https://from-env';
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository);
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger);
const result = serviceWithEnv.resolveTestConnection({
...incoming,
@ -268,7 +439,7 @@ describe('OtelSettingsService', () => {
process.env.N8N_OTEL_EXPORTER_OTLP_PROTOCOL = 'grpc';
const configWithEnv = new OtelConfig();
configWithEnv.exporterProtocol = 'grpc';
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository);
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger);
const result = serviceWithEnv.resolveTestConnection({
...incoming,

View File

@ -1,6 +1,8 @@
import { Logger } from '@n8n/backend-common';
import { SettingsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { jsonParse } from 'n8n-workflow';
import { isRecord } from '@n8n/utils/is-record';
import { CREDENTIAL_BLANKING_VALUE, jsonParse } from 'n8n-workflow';
import { OtelConfig } from './otel.config';
import { OTEL_ENV_VARS } from './otel.constants';
@ -30,11 +32,18 @@ export class OtelSettingsService {
constructor(
private readonly config: OtelConfig,
private readonly settingsRepository: SettingsRepository,
private readonly logger: Logger,
) {}
getSettings(): OtelSettingsResponse {
if (!this.currentSettings) throw new Error('OTel settings not yet initialized');
return { ...this.currentSettings, envManagedFields: this.envManagedFields };
return {
...this.currentSettings,
// Header keys stay visible; values never leave the server. A blanked
// value marks a stored value; an empty value means unset.
exporterHeaders: this.redactHeaders(this.currentSettings.exporterHeaders),
envManagedFields: this.envManagedFields,
};
}
/**
@ -56,18 +65,52 @@ export class OtelSettingsService {
private async getPersistedSettings(): Promise<Partial<OtelConfig> | undefined> {
const row = await this.settingsRepository.findByKey(OTEL_SETTINGS_KEY);
if (!row?.value) return undefined;
return jsonParse<Partial<OtelConfig>>(row.value, { fallbackValue: undefined });
return this.parsePersisted(row?.value);
}
private parsePersisted(value: string | null | undefined): Partial<OtelConfig> | undefined {
if (!value) return undefined;
try {
const persisted: unknown = jsonParse(value);
// The DB row is not shape-checked, so validate before it reaches any consumer
if (!isRecord(persisted)) {
this.logger.warn(
'Persisted OTel settings are not a settings object; using defaults instead',
);
return undefined;
}
const settings = persisted as Partial<OtelConfig>;
if ('exporterHeaders' in settings && typeof settings.exporterHeaders !== 'string') {
this.logger.warn(
'Persisted OTel settings contain a non-string exporterHeaders value; using the default instead',
);
delete settings.exporterHeaders;
}
return settings;
} catch (error) {
this.logger.warn('Persisted OTel settings contain invalid JSON; using defaults instead', {
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
async saveSettings(incoming: OtelConfig): Promise<void> {
const existing = await this.settingsRepository.findByKey(OTEL_SETTINGS_KEY);
const persisted = this.parsePersisted(existing?.value);
// Env-var fields always win — override any frontend-submitted values with
// the canonical env-var value so the DB stays consistent even if a client
// sends a stale or tampered payload.
const sanitized = this.buildConfig((key) =>
this.isEnvManaged(key) ? this.config[key] : incoming[key],
);
const existing = await this.settingsRepository.findByKey(OTEL_SETTINGS_KEY);
// Blanked values echoed back by a client mean "keep the stored value"
if (!this.isEnvManaged('exporterHeaders')) {
sanitized.exporterHeaders = this.resolveHeaders(
incoming.exporterHeaders,
persisted?.exporterHeaders ?? '',
);
}
const value = JSON.stringify(sanitized);
if (existing) {
existing.value = value;
@ -88,13 +131,69 @@ export class OtelSettingsService {
exporterEndpoint: pick('exporterEndpoint'),
exporterTracingPath: pick('exporterTracingPath'),
exporterServiceName: pick('exporterServiceName'),
exporterHeaders: pick('exporterHeaders'),
// A client that echoes a redacted response cannot supply stored values,
// so blanked values resolve against the effective settings
exporterHeaders: this.isEnvManaged('exporterHeaders')
? this.config.exporterHeaders
: this.resolveHeaders(
incoming.exporterHeaders,
this.currentSettings?.exporterHeaders ?? '',
),
startupConnectivityTimeoutMs: pick('startupConnectivityTimeoutMs'),
};
}
/** Keys stay visible in API responses; header values never leave the server. */
private redactHeaders(headers: string): string {
return this.serializeHeaderPairs(
this.parseHeaderPairs(headers).map(({ key, value }) => ({
key,
value: value ? CREDENTIAL_BLANKING_VALUE : '',
})),
);
}
/**
* Resolves client-submitted headers against stored ones: a blanked value
* means "keep the stored value for this key"; a blanked value with no
* stored counterpart has nothing to keep, so the pair is dropped.
*/
private resolveHeaders(incoming: string, stored: string): string {
const storedByKey = new Map(this.parseHeaderPairs(stored).map((p) => [p.key, p.value]));
return this.serializeHeaderPairs(
this.parseHeaderPairs(incoming).flatMap(({ key, value }) => {
if (value !== CREDENTIAL_BLANKING_VALUE) return [{ key, value }];
const storedValue = storedByKey.get(key);
return storedValue === undefined ? [] : [{ key, value: storedValue }];
}),
);
}
private parseHeaderPairs(headers: string): Array<{ key: string; value: string }> {
if (!headers.trim()) return [];
return headers
.split(',')
.map((pair) => {
const idx = pair.indexOf('=');
return idx === -1
? { key: pair.trim(), value: '' }
: { key: pair.slice(0, idx).trim(), value: pair.slice(idx + 1).trim() };
})
.filter((p) => p.key);
}
private serializeHeaderPairs(pairs: Array<{ key: string; value: string }>): string {
return pairs
.filter((p) => p.key.trim())
.map((p) => `${p.key}=${p.value}`)
.join(',');
}
private isEnvManaged(key: keyof OtelConfig): boolean {
return process.env[OTEL_ENV_VARS[key]] !== undefined;
const envVar = OTEL_ENV_VARS[key];
// Mirror readEnv() in @n8n/config: a value supplied via `${envVar}_FILE`
// is env-managed in the same way as one set directly
return process.env[envVar] !== undefined || !!process.env[`${envVar}_FILE`];
}
private buildConfig(pick: <K extends keyof OtelConfig>(key: K) => OtelConfig[K]): OtelConfig {

View File

@ -24,9 +24,10 @@ const otelHandlers: OtelHandlers = {
getOtelSettings: [
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'otel:manage' }),
async (_req, res) => {
const settings = await Container.get(OtelSettingsService).loadSettings();
const settingsService = Container.get(OtelSettingsService);
await settingsService.loadSettings();
return res.json(toOtelSettingsResponse(settings));
return res.json(toOtelSettingsResponse(settingsService.getSettings()));
},
],

View File

@ -1,6 +1,7 @@
import { testDb } from '@n8n/backend-test-utils';
import { SettingsRepository, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
import { vi } from 'vitest';
import { OtelSettingsService, OTEL_SETTINGS_KEY } from '@/modules/otel/otel-settings.service';
@ -119,7 +120,10 @@ describe('OpenTelemetry settings in Public API', () => {
.send(validSettings);
expect(response.status).toBe(200);
expect(response.body).toMatchObject(validSettings);
expect(response.body).toMatchObject({
...validSettings,
exporterHeaders: `authorization=${CREDENTIAL_BLANKING_VALUE}`,
});
});
it('takes effect the same way as the UI (write via public API, read via internal API)', async () => {
@ -129,7 +133,10 @@ describe('OpenTelemetry settings in Public API', () => {
const internal = await testServer.authAgentFor(owner).get('/otel/settings');
expect(internal.status).toBe(200);
expect(internal.body.data).toMatchObject(validSettings);
expect(internal.body.data).toMatchObject({
...validSettings,
exporterHeaders: `authorization=${CREDENTIAL_BLANKING_VALUE}`,
});
});
it('reads back a configuration written through the internal API (public API is a faithful stand-in)', async () => {
@ -138,7 +145,10 @@ describe('OpenTelemetry settings in Public API', () => {
const publicRead = await testServer.publicApiAgentFor(owner).get('/settings/otel');
expect(publicRead.status).toBe(200);
expect(publicRead.body).toMatchObject(validSettings);
expect(publicRead.body).toMatchObject({
...validSettings,
exporterHeaders: `authorization=${CREDENTIAL_BLANKING_VALUE}`,
});
});
it('toggles enabled both ways', async () => {
@ -170,7 +180,7 @@ describe('OpenTelemetry settings in Public API', () => {
expect(putResponse.status).toBe(200);
expect(putResponse.body.exporterServiceName).toBe('n8n-updated');
expect(putResponse.body.exporterHeaders).toBe(validSettings.exporterHeaders);
expect(putResponse.body.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`);
});
it('switches the exporter protocol to gRPC and back', async () => {
@ -339,6 +349,79 @@ describe('OpenTelemetry settings in Public API', () => {
});
});
describe('GET with env-managed exporterHeaders', () => {
const ENV_HEADERS = 'authorization=Bearer env-managed-token';
let originalHeaders: string;
beforeEach(async () => {
process.env[OTEL_ENV_VARS.exporterHeaders] = ENV_HEADERS;
originalHeaders = Container.get(OtelConfig).exporterHeaders;
Container.get(OtelConfig).exporterHeaders = ENV_HEADERS;
await Container.get(OtelSettingsService).loadSettings();
});
afterEach(async () => {
delete process.env[OTEL_ENV_VARS.exporterHeaders];
Container.get(OtelConfig).exporterHeaders = originalHeaders;
await Container.get(OtelSettingsService).loadSettings();
});
it('internal API returns the blanking placeholder for exporterHeaders', async () => {
const response = await testServer.authAgentFor(owner).get('/otel/settings');
expect(response.status).toBe(200);
expect(response.body.data.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`);
expect(response.body.data.envManagedFields).toContain('exporterHeaders');
});
it('public API returns the blanking placeholder for exporterHeaders', async () => {
const response = await testServer.publicApiAgentFor(owner).get('/settings/otel');
expect(response.status).toBe(200);
expect(response.body.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`);
});
it('accepts a GET response body echoed straight back (clean round-trip)', async () => {
const getResponse = await testServer.publicApiAgentFor(owner).get('/settings/otel');
expect(getResponse.status).toBe(200);
const putResponse = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send(getResponse.body);
expect(putResponse.status).toBe(200);
expect(putResponse.body.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`);
});
});
describe('GET with headers supplied via the _FILE env variant', () => {
let originalHeaders: string;
beforeEach(async () => {
// Simulate `N8N_OTEL_EXPORTER_OTLP_HEADERS_FILE` being set: mark it
// env-managed and pin the file-supplied value on the (singleton) config.
process.env[`${OTEL_ENV_VARS.exporterHeaders}_FILE`] = '/run/secrets/otel-headers';
originalHeaders = Container.get(OtelConfig).exporterHeaders;
Container.get(OtelConfig).exporterHeaders = 'authorization=Bearer file-managed-token';
await Container.get(OtelSettingsService).loadSettings();
});
afterEach(async () => {
delete process.env[`${OTEL_ENV_VARS.exporterHeaders}_FILE`];
Container.get(OtelConfig).exporterHeaders = originalHeaders;
await Container.get(OtelSettingsService).loadSettings();
});
it('internal API returns the blanking placeholder for exporterHeaders', async () => {
const response = await testServer.authAgentFor(owner).get('/otel/settings');
expect(response.status).toBe(200);
expect(response.body.data.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`);
expect(response.body.data.envManagedFields).toContain('exporterHeaders');
});
});
describe('PUT /settings/otel with an env-managed exporter protocol', () => {
let originalProtocol: OtelConfig['exporterProtocol'];

View File

@ -3300,6 +3300,7 @@
"settings.opentelemetry.exporterHeaders.keyPlaceholder": "Key",
"settings.opentelemetry.exporterHeaders.valuePlaceholder": "Value",
"settings.opentelemetry.exporterHeaders.addHeader": "Add header",
"settings.opentelemetry.exporterHeaders.hiddenValuePlaceholder": "Configured value not shown",
"settings.opentelemetry.exporterTracingPath.label": "Trace path",
"settings.opentelemetry.exporterTracingPath.description": "Appended to the endpoint. /v1/traces is the OTLP default.",
"settings.opentelemetry.exporterTracingPath.placeholder": "/v1/traces",

View File

@ -475,6 +475,49 @@ describe('SettingsOpenTelemetryView', () => {
});
});
it('renders header keys with masked values when stored headers are redacted', async () => {
getOtelSettingsMock.mockResolvedValue(
makeSettings({
exporterHeaders: 'authorization=__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6',
envManagedFields: [],
}),
);
const { getAllByTestId, getByTestId } = render();
await waitFor(() => {
expect(getAllByTestId('otel-header-key')).toHaveLength(1);
});
expect(getByTestId('otel-header-key')).toHaveValue('authorization');
// The blanked value renders empty with a hint instead of the placeholder text
expect(getByTestId('otel-header-value')).toHaveValue('');
expect(getByTestId('otel-header-value')).toHaveAttribute(
'placeholder',
'Configured value not shown',
);
// Field stays editable when not env-managed, so adding a header can replace the set
expect(getByTestId('otel-header-add')).toBeEnabled();
});
it('renders header keys with masked, disabled values when headers are env-managed', async () => {
getOtelSettingsMock.mockResolvedValue(
makeSettings({
exporterHeaders: 'authorization=__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6',
envManagedFields: ['exporterHeaders'],
}),
);
const { getAllByTestId, getByTestId } = render();
await waitFor(() => {
expect(getAllByTestId('otel-header-key')).toHaveLength(1);
});
expect(getByTestId('otel-header-key')).toHaveValue('authorization');
expect(getByTestId('otel-header-key')).toBeDisabled();
expect(getByTestId('otel-header-value')).toHaveValue('');
expect(getByTestId('otel-header-value')).toBeDisabled();
});
// ── exporter protocol ─────────────────────────────────────────────────────
it('shows the saved protocol in the select', async () => {

View File

@ -24,7 +24,11 @@ import { useSettingsStore } from '@n8n/stores/settings.store';
import { computed, ref, watch, onMounted } from 'vue';
import { onBeforeRouteLeave, type NavigationGuardNext } from 'vue-router';
import { OTEL_FIELD_ENV_VARS, OTEL_TEST_SPAN_NAME } from './otel.constants';
import {
CREDENTIALS_BLANKING_VALUE,
OTEL_FIELD_ENV_VARS,
OTEL_TEST_SPAN_NAME,
} from './otel.constants';
import { useOtelStore, headersStringToPairs, headersPairsToString } from './otel.store';
import { createSampleRateFormat, isOtlpProtocol } from './otel.utils';
import OtelSettingsRow from './OtelSettingsRow.vue';
@ -49,7 +53,7 @@ const showUnsavedChangesDialog = ref(false);
const pendingNext = ref<NavigationGuardNext | null>(null);
function syncHeaderPairsFromStore() {
headerPairs.value = headersStringToPairs(otelStore.settings.exporterHeaders);
headerPairs.value = headerPairsFromStore();
}
function syncHeaderPairsToStore() {
@ -77,6 +81,23 @@ function isEnvManaged(field: keyof typeof OTEL_FIELD_ENV_VARS): boolean {
return otelStore.envManagedFields.includes(field);
}
// Keys come back from the API as-is; values come back blanked, so the value
// inputs below render them empty until the user types a replacement. The pair
// keeps the blanked value in state, so an untouched save preserves the stored one.
function headerPairsFromStore(): Array<{ key: string; value: string }> {
return headersStringToPairs(otelStore.settings.exporterHeaders ?? '');
}
function headerValueDisplay(pair: { key: string; value: string }): string {
return pair.value === CREDENTIALS_BLANKING_VALUE ? '' : pair.value;
}
function headerValuePlaceholder(pair: { key: string; value: string }): string {
return pair.value === CREDENTIALS_BLANKING_VALUE
? i18n.baseText('settings.opentelemetry.exporterHeaders.hiddenValuePlaceholder')
: i18n.baseText('settings.opentelemetry.exporterHeaders.valuePlaceholder');
}
// State-first copy: the row tells the admin whether tracing is live right now,
// instead of describing what the disabled state would mean hypothetically.
const statusDescription = computed(() =>
@ -234,7 +255,7 @@ watch(
(newVal) => {
const currentString = headersPairsToString(headerPairs.value);
if (newVal !== currentString) {
headerPairs.value = headersStringToPairs(newVal ?? '');
headerPairs.value = headerPairsFromStore();
}
},
);
@ -513,10 +534,8 @@ watch(
size="small"
>
<N8nInput
:model-value="pair.value"
:placeholder="
i18n.baseText('settings.opentelemetry.exporterHeaders.valuePlaceholder')
"
:model-value="headerValueDisplay(pair)"
:placeholder="headerValuePlaceholder(pair)"
:disabled="isEnvManaged('exporterHeaders')"
data-test-id="otel-header-value"
@update:model-value="(v: string) => onHeaderChange(index, 'value', v)"

View File

@ -4,6 +4,13 @@ export { OTLP_PROTOCOLS, type OtlpProtocol };
export const OTEL_STORE = 'otel';
/**
* Mirrors CREDENTIAL_BLANKING_VALUE from n8n-workflow (not a dependency of this
* module) — the placeholder the API returns in place of each header value.
* Keys come back as-is; only values are blanked.
*/
export const CREDENTIALS_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6';
/**
* Route name for the OpenTelemetry settings page. Owned by this module rather
* than by the shared `VIEWS` enum, so the module can be packaged without the