mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(core): Always redact OTel header values in settings responses (#37283)
This commit is contained in:
parent
afc4a54798
commit
4b0de25a08
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
},
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user