From a59ba1cad4a3d1dba0baa9a88ce6e6a2a19665f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Mon, 8 Jun 2026 14:19:17 +0200 Subject: [PATCH 1/3] feat: allow custom HTTP headers on every request Add a `headers` config option and a repeatable `-H/--extra-header` CLI flag, merged and applied to every Tolgee API request (including login), so the CLI can talk to a platform behind a custom auth/proxy layer. Headers are additive: a Tolgee API key is still required for commands that hit the API. The user-agent, x-api-key and content-type headers are reserved and ignored, so custom values cannot break authentication or multipart uploads. Closes #180 --- schema.json | 7 + src/cli.ts | 8 +- src/client/ApiClient.ts | 19 ++- src/commands/login.ts | 72 +++++----- src/config/tolgeerc.ts | 41 ++++++ src/options.ts | 16 +++ src/schema.d.ts | 8 ++ src/utils/headers.ts | 85 ++++++++++++ .../invalidTolgeeRcHeaderDuplicate/.tolgeerc | 6 + .../invalidTolgeeRcHeaderName/.tolgeerc | 5 + .../invalidTolgeeRcHeaderType/.tolgeerc | 5 + .../invalidTolgeeRcHeaderValue/.tolgeerc | 5 + .../validTolgeeRcHeaders/.tolgeerc | 8 ++ .../validTolgeeRcHeadersEmpty/.tolgeerc | 5 + test/e2e/headers.test.ts | 44 +++++++ test/unit/apiClient.test.ts | 124 ++++++++++++++++++ test/unit/config.test.ts | 67 ++++++++++ test/unit/headers.test.ts | 88 +++++++++++++ test/unit/login.test.ts | 68 ++++++++++ 19 files changed, 646 insertions(+), 35 deletions(-) create mode 100644 src/utils/headers.ts create mode 100644 test/__fixtures__/invalidTolgeeRcHeaderDuplicate/.tolgeerc create mode 100644 test/__fixtures__/invalidTolgeeRcHeaderName/.tolgeerc create mode 100644 test/__fixtures__/invalidTolgeeRcHeaderType/.tolgeerc create mode 100644 test/__fixtures__/invalidTolgeeRcHeaderValue/.tolgeerc create mode 100644 test/__fixtures__/validTolgeeRcHeaders/.tolgeerc create mode 100644 test/__fixtures__/validTolgeeRcHeadersEmpty/.tolgeerc create mode 100644 test/e2e/headers.test.ts create mode 100644 test/unit/apiClient.test.ts create mode 100644 test/unit/headers.test.ts create mode 100644 test/unit/login.test.ts diff --git a/schema.json b/schema.json index 3511a87..36ee03b 100644 --- a/schema.json +++ b/schema.json @@ -44,6 +44,13 @@ "description": "Override parser detection.", "enum": ["react", "vue", "svelte", "ngx"] }, + "headers": { + "description": "Custom HTTP headers added to every request to the Tolgee API, e.g. when the platform is behind a custom auth/proxy layer.\n\nThe `user-agent`, `x-api-key` and `content-type` headers are reserved and ignored/overridden by the CLI. Custom headers are additive to the Tolgee API key (which is still required).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "push": { "type": "object", "properties": { diff --git a/src/cli.ts b/src/cli.ts index fac6bc6..54201e7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { CONFIG_OPT, DEFAULT_NAMESPACE, EXTRACTOR, + EXTRA_HEADER, FILE_PATTERNS, FORMAT_OPT, STRICT_NAMESPACE, @@ -41,6 +42,7 @@ import BranchCommand from './commands/branch.js'; import MergeCommand from './commands/merge.js'; import { getSingleOption } from './utils/getSingleOption.js'; +import { mergeHeaders } from './utils/headers.js'; import { Schema } from './schema.js'; import { createTolgeeClient } from './client/TolgeeClient.js'; import { projectIdFromKey } from './client/ApiClient.js'; @@ -150,6 +152,7 @@ const preHandler = (config: Schema) => : config.projectId !== undefined ? Number(config.projectId) : undefined, + headers: mergeHeaders(config.headers, opts.extraHeader), }); cmd.setOptionValue('client', client); @@ -191,9 +194,12 @@ async function run() { program.addOption(STRICT_NAMESPACE.default(config.strictNamespace ?? true)); program.addOption(STRICT_NAMESPACE_NEGATION); program.addOption(DEFAULT_NAMESPACE.default(config.defaultNamespace)); + program.addOption(EXTRA_HEADER); // Register commands - program.addCommand(Login); + program.addCommand( + Login(config).configureHelp({ showGlobalOptions: true }) + ); program.addCommand(Logout); program.addCommand( PushCommand(config).configureHelp({ showGlobalOptions: true }) diff --git a/src/client/ApiClient.ts b/src/client/ApiClient.ts index 1b23fbd..630caf9 100644 --- a/src/client/ApiClient.ts +++ b/src/client/ApiClient.ts @@ -6,6 +6,10 @@ import { API_KEY_PAK_PREFIX, USER_AGENT } from '../constants.js'; import { getApiKeyInformation } from './getApiKeyInformation.js'; import { debug, isDebugEnabled } from '../utils/logger.js'; import { errorFromLoadable } from './errorFromLoadable.js'; +import { normalizeHeaderKeys } from '../utils/headers.js'; + +// Headers the CLI controls and that custom headers must never override. +const RESERVED_HEADERS = ['user-agent', 'content-type', 'x-api-key']; async function parseResponse(response: Response, parseAs: ParseAs) { // handle empty content @@ -55,6 +59,7 @@ export type ApiClientProps = { apiKey?: string; projectId?: number | undefined; autoThrow?: boolean; + headers?: Record; }; export function createApiClient({ @@ -62,12 +67,24 @@ export function createApiClient({ apiKey, projectId, autoThrow = false, + headers, }: ApiClientProps) { const computedProjectId = projectId ?? (apiKey ? projectIdFromKey(apiKey) : undefined); + + const custom = normalizeHeaderKeys(headers); + const ignored = RESERVED_HEADERS.filter((name) => name in custom); + if (ignored.length) { + debug(`[HTTP] Ignoring reserved custom header(s): ${ignored.join(', ')}`); + } + for (const name of ignored) { + delete custom[name]; + } + const apiClient = createClient({ baseUrl, headers: { + ...custom, 'user-agent': USER_AGENT, 'x-api-key': apiKey, }, @@ -110,7 +127,7 @@ export function createApiClient({ return getApiKeyInformation(apiClient, apiKey!); }, getSettings(): ApiClientProps { - return { baseUrl, apiKey, projectId, autoThrow }; + return { baseUrl, apiKey, projectId, autoThrow, headers }; }, }; } diff --git a/src/commands/login.ts b/src/commands/login.ts index 3cc975b..e067f22 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -10,39 +10,44 @@ import { debug, exitWithError, success } from '../utils/logger.js'; import { createTolgeeClient } from '../client/TolgeeClient.js'; import { printApiKeyLists } from '../utils/apiKeyList.js'; import { getStackTrace } from '../utils/getStackTrace.js'; +import { mergeHeaders } from '../utils/headers.js'; +import { Schema } from '../schema.js'; type Options = { apiUrl: URL; all: boolean; list: boolean; + extraHeader?: string[]; }; -async function loginHandler(this: Command, key?: string) { - const opts: Options = this.optsWithGlobals(); +const loginHandler = (config: Schema) => + async function (this: Command, key?: string) { + const opts: Options = this.optsWithGlobals(); - if (opts.list) { - printApiKeyLists(); - return; - } else if (!key) { - exitWithError('Missing argument [API Key]'); - } + if (opts.list) { + printApiKeyLists(); + return; + } else if (!key) { + exitWithError('Missing argument [API Key]'); + } - debug( - `Logging in with API key ${key?.slice(0, 5)}...${key?.slice(-4)}.\n${getStackTrace()}` - ); + debug( + `Logging in with API key ${key?.slice(0, 5)}...${key?.slice(-4)}.\n${getStackTrace()}` + ); - const keyInfo = await createTolgeeClient({ - baseUrl: opts.apiUrl.toString(), - apiKey: key, - }).getApiKeyInfo(); + const keyInfo = await createTolgeeClient({ + baseUrl: opts.apiUrl.toString(), + apiKey: key, + headers: mergeHeaders(config.headers, opts.extraHeader), + }).getApiKeyInfo(); - await saveApiKey(opts.apiUrl, keyInfo); - success( - keyInfo.type === 'PAK' - ? `Logged in as ${keyInfo.username} on ${ansi.blue(opts.apiUrl.hostname)} for project ${ansi.blue(String(keyInfo.project.id))} (${keyInfo.project.name}). Welcome back!` - : `Logged in as ${keyInfo.username} on ${ansi.blue(opts.apiUrl.hostname)}. Welcome back!` - ); -} + await saveApiKey(opts.apiUrl, keyInfo); + success( + keyInfo.type === 'PAK' + ? `Logged in as ${keyInfo.username} on ${ansi.blue(opts.apiUrl.hostname)} for project ${ansi.blue(String(keyInfo.project.id))} (${keyInfo.project.name}). Welcome back!` + : `Logged in as ${keyInfo.username} on ${ansi.blue(opts.apiUrl.hostname)}. Welcome back!` + ); + }; async function logoutHandler(this: Command) { const opts: Options = this.optsWithGlobals(); @@ -59,17 +64,18 @@ async function logoutHandler(this: Command) { success(`You're now logged out of ${opts.apiUrl.hostname}.`); } -export const Login = new Command() - .name('login') - .description( - 'Login to Tolgee with an API key. You can be logged into multiple Tolgee instances at the same time by using --api-url' - ) - .option('-l, --list', 'List existing api keys') - .argument( - '[API Key]', - 'The API key. Can be either a personal access token, or a project key' - ) - .action(loginHandler); +export const Login = (config: Schema) => + new Command() + .name('login') + .description( + 'Login to Tolgee with an API key. You can be logged into multiple Tolgee instances at the same time by using --api-url' + ) + .option('-l, --list', 'List existing api keys') + .argument( + '[API Key]', + 'The API key. Can be either a personal access token, or a project key' + ) + .action(loginHandler(config)); export const Logout = new Command() .name('logout') diff --git a/src/config/tolgeerc.ts b/src/config/tolgeerc.ts index 2bfa2c8..98f0572 100644 --- a/src/config/tolgeerc.ts +++ b/src/config/tolgeerc.ts @@ -11,6 +11,11 @@ import { dirname, join, resolve } from 'node:path'; import { error, exitWithError } from '../utils/logger.js'; import type { Schema } from '../schema.js'; import { valueToArray } from '../utils/valueToArray.js'; +import { + normalizeHeaderName, + validateHeaderName, + validateHeaderValue, +} from '../utils/headers.js'; import { Ajv } from 'ajv'; const explorer = cosmiconfig('tolgee', { @@ -85,6 +90,42 @@ function parseConfig(input: Schema, configDir: string) { rc.pull.path = resolve(configDir, rc.pull.path).replace(/\\/g, '/'); } + if ( + rc.headers !== undefined && + typeof rc.headers === 'object' && + rc.headers !== null && + !Array.isArray(rc.headers) + ) { + const asConfigError = (fn: () => T): T => { + try { + return fn(); + } catch (e: any) { + throw new Error(`Invalid config: ${e.message}`); + } + }; + + const seen = new Set(); + const normalized: Record = {}; + for (const [name, value] of Object.entries(rc.headers)) { + asConfigError(() => validateHeaderName(name)); + const key = normalizeHeaderName(name); + if (seen.has(key)) { + throw new Error( + `Invalid config: duplicate header '${key}' (header names are case-insensitive)` + ); + } + seen.add(key); + // Non-string values are left for ajv to reject with a 'Tolgee config:' error. + if (typeof value === 'string') { + asConfigError(() => validateHeaderValue(value)); + normalized[key] = value.trim(); + } else { + normalized[key] = value; + } + } + rc.headers = normalized; + } + return rc; } diff --git a/src/options.ts b/src/options.ts index 7708c33..5914229 100644 --- a/src/options.ts +++ b/src/options.ts @@ -2,6 +2,7 @@ import { existsSync } from 'fs'; import { resolve } from 'path'; import { Option, InvalidArgumentError } from 'commander'; import { createTolgeeClient } from './client/TolgeeClient.js'; +import { parseHeaderList } from './utils/headers.js'; import { VerboseOption } from './extractor/index.js'; function parseProjectId(v: string) { @@ -29,6 +30,15 @@ function parsePath(v: string) { return path; } +function accumulateHeader(v: string, previous: string[] = []) { + try { + parseHeaderList([v]); + } catch (e: any) { + throw new InvalidArgumentError(e.message); + } + return [...previous, v]; +} + export type BaseOptions = { apiUrl: URL; apiKey: string; @@ -39,6 +49,7 @@ export type BaseOptions = { patterns: string[] | undefined; strictNamespace: boolean | undefined; verbose: VerboseOption[] | boolean | undefined; + extraHeader?: string[]; }; export const API_KEY_OPT = new Option( @@ -141,3 +152,8 @@ export const VERBOSE = new Option( '-v, --verbose [rules...]', 'Enable verbose logging. If you want more info to be logged pass an option.' ).choices(['extractor']); + +export const EXTRA_HEADER = new Option( + '-H, --extra-header
', + 'Extra HTTP header added to every request to the Tolgee API, e.g. "Authorization: Bearer ". Repeatable. The user-agent, x-api-key and content-type headers are reserved and ignored.' +).argParser(accumulateHeader); diff --git a/src/schema.d.ts b/src/schema.d.ts index 638af8f..c8cb753 100644 --- a/src/schema.d.ts +++ b/src/schema.d.ts @@ -118,6 +118,14 @@ export interface Schema { * Override parser detection. */ parser?: "react" | "vue" | "svelte" | "ngx"; + /** + * Custom HTTP headers added to every request to the Tolgee API, e.g. when the platform is behind a custom auth/proxy layer. + * + * The `user-agent`, `x-api-key` and `content-type` headers are reserved and ignored/overridden by the CLI. Custom headers are additive to the Tolgee API key (which is still required). + */ + headers?: { + [k: string]: string; + }; push?: { /** * A template that describes the structure of the local files and their location with file [structure template format](https://docs.tolgee.io/tolgee-cli/push-pull-strings#file-structure-template-format). diff --git a/src/utils/headers.ts b/src/utils/headers.ts new file mode 100644 index 0000000..bde23d5 --- /dev/null +++ b/src/utils/headers.ts @@ -0,0 +1,85 @@ +// RFC 7230 token grammar for header field names. +const HEADER_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +export function normalizeHeaderName(name: string): string { + return name.trim().toLowerCase(); +} + +// True if the string contains an ASCII control character forbidden in a header +// field value. Per RFC 7230 a field value may contain HTAB (0x09), so only the +// other C0 controls (which include CR and LF, the injection vectors) and DEL +// are rejected. +function hasControlChar(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if ((code < 0x20 && code !== 0x09) || code === 0x7f) { + return true; + } + } + return false; +} + +export function validateHeaderName(name: string): void { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error('header name must not be empty'); + } + if (!HEADER_NAME_TOKEN.test(trimmed)) { + throw new Error(`invalid header name "${name}"`); + } +} + +export function validateHeaderValue(value: string): void { + if (hasControlChar(value)) { + throw new Error('header value must not contain control characters'); + } +} + +export function normalizeHeaderKeys( + headers: Record | undefined +): Record { + const out: Record = {}; + for (const [name, value] of Object.entries(headers ?? {})) { + out[normalizeHeaderName(name)] = value; + } + return out; +} + +/** + * Parses a list of raw `Name: Value` header strings (e.g. from repeated + * `--extra-header` flags) into a name->value map. Names are lowercased; later + * entries override earlier ones. Throws on a missing colon, an empty/invalid + * name, or control characters. Empty values are allowed. + */ +export function parseHeaderList( + list: string[] | undefined +): Record { + const out: Record = {}; + for (const entry of list ?? []) { + const separator = entry.indexOf(':'); + if (separator === -1) { + throw new Error(`invalid header "${entry}", expected "Name: Value"`); + } + const name = entry.slice(0, separator); + const value = entry.slice(separator + 1).trim(); + validateHeaderName(name); + validateHeaderValue(value); + out[normalizeHeaderName(name)] = value; + } + return out; +} + +/** + * Merges config-file headers with CLI `--extra-header` values into a single + * name->value map. CLI values override config values for the same (lowercased) + * name. + */ +export function mergeHeaders( + configHeaders: Record | undefined, + extraHeaderList: string[] | undefined +): Record { + return { + ...normalizeHeaderKeys(configHeaders), + ...parseHeaderList(extraHeaderList), + }; +} diff --git a/test/__fixtures__/invalidTolgeeRcHeaderDuplicate/.tolgeerc b/test/__fixtures__/invalidTolgeeRcHeaderDuplicate/.tolgeerc new file mode 100644 index 0000000..af145cc --- /dev/null +++ b/test/__fixtures__/invalidTolgeeRcHeaderDuplicate/.tolgeerc @@ -0,0 +1,6 @@ +{ + "headers": { + "Authorization": "a", + "authorization": "b" + } +} diff --git a/test/__fixtures__/invalidTolgeeRcHeaderName/.tolgeerc b/test/__fixtures__/invalidTolgeeRcHeaderName/.tolgeerc new file mode 100644 index 0000000..4c360bd --- /dev/null +++ b/test/__fixtures__/invalidTolgeeRcHeaderName/.tolgeerc @@ -0,0 +1,5 @@ +{ + "headers": { + "X Foo": "v" + } +} diff --git a/test/__fixtures__/invalidTolgeeRcHeaderType/.tolgeerc b/test/__fixtures__/invalidTolgeeRcHeaderType/.tolgeerc new file mode 100644 index 0000000..a09e3db --- /dev/null +++ b/test/__fixtures__/invalidTolgeeRcHeaderType/.tolgeerc @@ -0,0 +1,5 @@ +{ + "headers": { + "X-Foo": 123 + } +} diff --git a/test/__fixtures__/invalidTolgeeRcHeaderValue/.tolgeerc b/test/__fixtures__/invalidTolgeeRcHeaderValue/.tolgeerc new file mode 100644 index 0000000..ca92284 --- /dev/null +++ b/test/__fixtures__/invalidTolgeeRcHeaderValue/.tolgeerc @@ -0,0 +1,5 @@ +{ + "headers": { + "X-Foo": "a\r\nInjected: b" + } +} diff --git a/test/__fixtures__/validTolgeeRcHeaders/.tolgeerc b/test/__fixtures__/validTolgeeRcHeaders/.tolgeerc new file mode 100644 index 0000000..f1e1859 --- /dev/null +++ b/test/__fixtures__/validTolgeeRcHeaders/.tolgeerc @@ -0,0 +1,8 @@ +{ + "apiUrl": "https://app.tolgee.io", + "projectId": 1337, + "headers": { + "X-Custom": "bar", + " X-Padded ": "v" + } +} diff --git a/test/__fixtures__/validTolgeeRcHeadersEmpty/.tolgeerc b/test/__fixtures__/validTolgeeRcHeadersEmpty/.tolgeerc new file mode 100644 index 0000000..ffdc460 --- /dev/null +++ b/test/__fixtures__/validTolgeeRcHeadersEmpty/.tolgeerc @@ -0,0 +1,5 @@ +{ + "apiUrl": "https://app.tolgee.io", + "projectId": 1337, + "headers": {} +} diff --git a/test/e2e/headers.test.ts b/test/e2e/headers.test.ts new file mode 100644 index 0000000..371cf89 --- /dev/null +++ b/test/e2e/headers.test.ts @@ -0,0 +1,44 @@ +import { tmpdir } from 'os'; +import { join } from 'path'; +import { rm } from 'fs/promises'; +import { run } from './utils/run.js'; + +const AUTH_FILE_PATH = join(tmpdir(), '.tolgee-e2e', 'authentication.json'); + +// These cases fail at argument parsing / option validation, before any request, +// so they do not need a running backend. +beforeEach(async () => { + try { + await rm(AUTH_FILE_PATH); + } catch (e: any) { + if (e.code !== 'ENOENT') throw e; + } +}); + +describe('--extra-header', () => { + it('rejects a malformed header with a clean validation error', async () => { + const out = await run([ + 'push', + '--project-id', + '1', + '--api-key', + 'tgpak_dummy', + '-H', + 'badheader', + ]); + + expect(out.code).not.toBe(0); + expect(`${out.stdout}${out.stderr}`).toMatch(/invalid header/i); + // Must not fall through to the generic unexpected-error catch-all. + expect(`${out.stdout}${out.stderr}`).not.toMatch( + /report this to our issue/i + ); + }); + + it('still requires a Tolgee API key (headers are additive)', async () => { + const out = await run(['push', '--project-id', '1', '-H', 'X-Foo: bar']); + + expect(out.code).toBe(1); + expect(`${out.stdout}${out.stderr}`).toMatch(/not authenticated/i); + }); +}); diff --git a/test/unit/apiClient.test.ts b/test/unit/apiClient.test.ts new file mode 100644 index 0000000..db48881 --- /dev/null +++ b/test/unit/apiClient.test.ts @@ -0,0 +1,124 @@ +import { createApiClient } from '#cli/client/ApiClient.js'; +import { createTolgeeClient } from '#cli/client/TolgeeClient.js'; +import { USER_AGENT } from '#cli/constants.js'; + +let captured: Request | undefined; + +beforeEach(() => { + captured = undefined; + vi.stubGlobal( + 'fetch', + vi.fn(async (request: Request) => { + captured = request; + return new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +type Props = Parameters[0]; + +// Fires a GET and returns the Request openapi-fetch actually handed to fetch. +async function get(props: Omit): Promise { + const client = createApiClient({ baseUrl: 'http://localhost', ...props }); + await (client as any).GET('/v2/projects'); + if (!captured) throw new Error('no request was captured'); + return captured; +} + +describe('createApiClient headers', () => { + it('forwards an arbitrary custom header to the request', async () => { + const req = await get({ headers: { 'x-foo': 'bar' } }); + expect(req.headers.get('x-foo')).toBe('bar'); + }); + + it('forwards multiple custom headers', async () => { + const req = await get({ + headers: { 'x-a': '1', authorization: 'Bearer token' }, + }); + expect(req.headers.get('x-a')).toBe('1'); + expect(req.headers.get('authorization')).toBe('Bearer token'); + }); + + it('keeps its own user-agent even if a custom one is supplied', async () => { + const req = await get({ + apiKey: 'tgpak_test', + headers: { 'user-agent': 'evil-bot' }, + }); + expect(req.headers.get('user-agent')).toBe(USER_AGENT); + }); + + it('uses the resolved api key, ignoring a custom x-api-key', async () => { + const req = await get({ + apiKey: 'tgpak_test', + headers: { 'x-api-key': 'bogus' }, + }); + expect(req.headers.get('x-api-key')).toBe('tgpak_test'); + }); + + it('drops a custom x-api-key when no api key is resolved', async () => { + const req = await get({ headers: { 'x-api-key': 'bogus' } }); + expect(req.headers.get('x-api-key')).toBeNull(); + }); + + it('sends no x-api-key when none is supplied', async () => { + const req = await get({}); + expect(req.headers.get('x-api-key')).toBeNull(); + }); + + it('strips a custom content-type regardless of its casing', async () => { + const req = await get({ headers: { 'Content-Type': 'text/xml' } }); + expect(req.headers.get('content-type')).not.toBe('text/xml'); + }); + + it('lets openapi-fetch set application/json on a JSON body', async () => { + const client = createApiClient({ + baseUrl: 'http://localhost', + headers: { 'content-type': 'text/xml' }, + }); + await (client as any).POST('/v2/projects', { body: { name: 'x' } }); + expect(captured!.headers.get('content-type')).toBe('application/json'); + }); + + it('lets the runtime set the multipart boundary on a FormData upload', async () => { + const client = createTolgeeClient({ + baseUrl: 'http://localhost', + projectId: 1, + headers: { 'content-type': 'text/xml' }, + }); + await client.import.import({ + files: [{ name: 'en.json', data: '{}' }], + params: { fileMappings: [] }, + } as any); + expect(captured!.headers.get('content-type')).toMatch( + /^multipart\/form-data; boundary=/ + ); + }); + + it('forwards custom headers to import/export sub-client requests', async () => { + const client = createTolgeeClient({ + baseUrl: 'http://localhost', + projectId: 1, + headers: { 'x-foo': 'bar' }, + }); + await client.import.import({ + files: [{ name: 'en.json', data: '{}' }], + params: { fileMappings: [] }, + } as any); + expect(captured!.headers.get('x-foo')).toBe('bar'); + }); + + it('round-trips custom headers through getSettings()', () => { + const settings = createApiClient({ + baseUrl: 'http://localhost', + headers: { 'x-foo': 'bar' }, + }).getSettings(); + expect(settings.headers).toEqual({ 'x-foo': 'bar' }); + }); +}); diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 9d382cf..352b953 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -103,4 +103,71 @@ describe('.tolgeerc', () => { expect(cfg?.branch).toBe('config-branch'); }); + + it('loads and normalizes custom headers', async () => { + const testWd = fileURLToPath( + new URL('./validTolgeeRcHeaders', FIXTURES_PATH) + ); + cwd.mockReturnValue(testWd); + + const cfg = await loadTolgeeRc(); + expect(cfg?.headers).toEqual({ 'x-custom': 'bar', 'x-padded': 'v' }); + }); + + it('accepts an empty headers object', async () => { + const testWd = fileURLToPath( + new URL('./validTolgeeRcHeadersEmpty', FIXTURES_PATH) + ); + cwd.mockReturnValue(testWd); + + const cfg = await loadTolgeeRc(); + expect(cfg?.headers).toEqual({}); + }); + + it('rejects an invalid header name', async () => { + const testWd = fileURLToPath( + new URL('./invalidTolgeeRcHeaderName', FIXTURES_PATH) + ); + cwd.mockReturnValue(testWd); + + return expect(loadTolgeeRc()).rejects.toThrow(/invalid header name/); + }); + + it('rejects a header value with control characters', async () => { + const testWd = fileURLToPath( + new URL('./invalidTolgeeRcHeaderValue', FIXTURES_PATH) + ); + cwd.mockReturnValue(testWd); + + return expect(loadTolgeeRc()).rejects.toThrow(/control characters/); + }); + + it('rejects headers that collide only by case', async () => { + const testWd = fileURLToPath( + new URL('./invalidTolgeeRcHeaderDuplicate', FIXTURES_PATH) + ); + cwd.mockReturnValue(testWd); + + return expect(loadTolgeeRc()).rejects.toThrow(/duplicate header/); + }); + + it('defers a non-string header value to schema validation', async () => { + const testWd = fileURLToPath( + new URL('./invalidTolgeeRcHeaderType', FIXTURES_PATH) + ); + cwd.mockReturnValue(testWd); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit'); + }) as any); + + await expect(loadTolgeeRc()).rejects.toThrow('process.exit'); + expect(logSpy).toHaveBeenCalledWith( + expect.stringMatching(/Tolgee config:/) + ); + + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); }); diff --git a/test/unit/headers.test.ts b/test/unit/headers.test.ts new file mode 100644 index 0000000..6128d7e --- /dev/null +++ b/test/unit/headers.test.ts @@ -0,0 +1,88 @@ +import { parseHeaderList, mergeHeaders } from '#cli/utils/headers.js'; + +describe('parseHeaderList', () => { + it('parses a simple header', () => { + expect(parseHeaderList(['X-Foo: bar'])).toEqual({ 'x-foo': 'bar' }); + }); + + it('trims the value', () => { + expect(parseHeaderList(['X-Foo: bar '])).toEqual({ 'x-foo': 'bar' }); + }); + + it('splits on the first colon so the value may contain colons', () => { + expect(parseHeaderList(['X-Url: https://example.com:8080'])).toEqual({ + 'x-url': 'https://example.com:8080', + }); + }); + + it('lowercases and trims the name', () => { + expect(parseHeaderList([' X-Foo : v'])).toEqual({ 'x-foo': 'v' }); + }); + + it('allows empty values', () => { + expect(parseHeaderList(['X-Foo:'])).toEqual({ 'x-foo': '' }); + expect(parseHeaderList(['X-Foo: '])).toEqual({ 'x-foo': '' }); + }); + + it('keeps the last value for a duplicated name', () => { + expect(parseHeaderList(['X: 1', 'X: 2'])).toEqual({ x: '2' }); + }); + + it('returns an empty object for empty or missing input', () => { + expect(parseHeaderList(undefined)).toEqual({}); + expect(parseHeaderList([])).toEqual({}); + }); + + it('throws when the colon is missing', () => { + expect(() => parseHeaderList(['badheader'])).toThrow(); + }); + + it('throws on an empty name (even after trimming)', () => { + expect(() => parseHeaderList([': value'])).toThrow(); + expect(() => parseHeaderList([' : value'])).toThrow(); + }); + + it('throws on a name with whitespace or non-token characters', () => { + expect(() => parseHeaderList(['X Foo: v'])).toThrow(); + expect(() => parseHeaderList(['X\tFoo: v'])).toThrow(); + }); + + it('throws on control characters in the value (header injection)', () => { + expect(() => parseHeaderList(['X-Foo: a\r\nInjected: b'])).toThrow(); + }); + + it('throws on a non-CRLF control character in the value', () => { + const nul = String.fromCharCode(0); + expect(() => parseHeaderList([`X-Foo: a${nul}b`])).toThrow(); + }); + + it('allows a horizontal tab in the value (RFC 7230 field-content)', () => { + expect(parseHeaderList(['X-Foo: a\tb'])).toEqual({ 'x-foo': 'a\tb' }); + }); +}); + +describe('mergeHeaders', () => { + it('lets a CLI header override a config header of the same name', () => { + expect(mergeHeaders({ 'x-foo': 'config' }, ['X-Foo: cli'])).toEqual({ + 'x-foo': 'cli', + }); + }); + + it('overrides case-insensitively', () => { + expect( + mergeHeaders({ Authorization: 'config' }, ['authorization: cli']) + ).toEqual({ authorization: 'cli' }); + }); + + it('keeps config-only and CLI-only headers', () => { + expect(mergeHeaders({ 'x-a': '1' }, ['X-B: 2'])).toEqual({ + 'x-a': '1', + 'x-b': '2', + }); + }); + + it('returns an empty object for empty inputs', () => { + expect(mergeHeaders(undefined, undefined)).toEqual({}); + expect(mergeHeaders({}, [])).toEqual({}); + }); +}); diff --git a/test/unit/login.test.ts b/test/unit/login.test.ts new file mode 100644 index 0000000..5595ca9 --- /dev/null +++ b/test/unit/login.test.ts @@ -0,0 +1,68 @@ +import { Command } from 'commander'; +import { Login } from '#cli/commands/login.js'; +import { API_URL_OPT, EXTRA_HEADER } from '#cli/options.js'; +import { createTolgeeClient } from '#cli/client/TolgeeClient.js'; + +vi.mock('#cli/client/TolgeeClient.js', () => ({ + createTolgeeClient: vi.fn(() => ({ + getApiKeyInfo: vi.fn().mockResolvedValue({ + type: 'PAT', + key: 'tgpat_test', + username: 'tester', + expires: 0, + }), + })), +})); + +vi.mock('#cli/config/credentials.js', () => ({ + saveApiKey: vi.fn(), + clearAuthStore: vi.fn(), + removeApiKeys: vi.fn(), +})); + +const mockedCreateClient = vi.mocked(createTolgeeClient); + +async function runLogin(config: any, args: string[]) { + const program = new Command(); + program.addOption(API_URL_OPT); + program.addOption(EXTRA_HEADER); + program.addCommand(Login(config)); + await program.parseAsync(args, { from: 'user' }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('login custom headers', () => { + it('forwards merged config + CLI headers and the api key to the client', async () => { + await runLogin({ headers: { 'x-config': 'c' } }, [ + '--api-url', + 'http://localhost', + '-H', + 'X-Cli: v', + 'login', + 'tgpat_test', + ]); + + expect(mockedCreateClient).toHaveBeenCalledTimes(1); + const props = mockedCreateClient.mock.calls[0][0]; + expect(props.apiKey).toBe('tgpat_test'); + expect(props.headers).toEqual({ 'x-config': 'c', 'x-cli': 'v' }); + }); + + it('does not pre-strip a custom x-api-key (the client arbitrates it)', async () => { + await runLogin({}, [ + '--api-url', + 'http://localhost', + '-H', + 'X-API-Key: bogus', + 'login', + 'tgpat_test', + ]); + + const props = mockedCreateClient.mock.calls[0][0]; + expect(props.apiKey).toBe('tgpat_test'); + expect(props.headers).toEqual({ 'x-api-key': 'bogus' }); + }); +}); From a2718158db2d16bfddfd24ea0eb3054673956251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 9 Jun 2026 15:39:46 +0200 Subject: [PATCH 2/3] test: assert order-agnostic key set in push.p3 e2e The translations endpoint orders by key id, and import no longer creates keys in file order, so the returned key order is not stable across server versions. Compare the stored key set instead of an exact-order array. --- test/e2e/push.p3.test.ts | 72 ++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/test/e2e/push.p3.test.ts b/test/e2e/push.p3.test.ts index d6d2fc6..ec9c968 100644 --- a/test/e2e/push.p3.test.ts +++ b/test/e2e/push.p3.test.ts @@ -169,17 +169,21 @@ describe('project 3', () => { const stored = tolgeeDataToDict(keys.data); - // Keys in the "food" namespace should not be removed - expect(Object.keys(stored)).toEqual([ - 'table', - 'chair', - 'plate', - 'fork', - 'water', - 'salad', - 'tomato', - 'onions', - ]); + // Keys in the "food" namespace should not be removed. + // Key creation order during import is not guaranteed and the translations + // endpoint orders by key id, so compare the key set, not its order. + expect(Object.keys(stored).sort()).toEqual( + [ + 'table', + 'chair', + 'plate', + 'fork', + 'water', + 'salad', + 'tomato', + 'onions', + ].sort() + ); }); it('removes other keys (config)', async () => { @@ -217,16 +221,18 @@ describe('project 3', () => { const stored = tolgeeDataToDict(keys.data); // Keys in the "food" namespace should not be removed - expect(Object.keys(stored)).toEqual([ - 'table', - 'chair', - 'plate', - 'fork', - 'water', - 'salad', - 'tomato', - 'onions', - ]); + expect(Object.keys(stored).sort()).toEqual( + [ + 'table', + 'chair', + 'plate', + 'fork', + 'water', + 'salad', + 'tomato', + 'onions', + ].sort() + ); }); it("doesn't remove other keys when filtered by namespace", async () => { @@ -270,16 +276,18 @@ describe('project 3', () => { const stored = tolgeeDataToDict(keys.data); - expect(Object.keys(stored)).toEqual([ - 'table', - 'chair', - 'plate', - 'fork', - 'knife', - 'water', - 'salad', - 'tomato', - 'onions', - ]); + expect(Object.keys(stored).sort()).toEqual( + [ + 'table', + 'chair', + 'plate', + 'fork', + 'knife', + 'water', + 'salad', + 'tomato', + 'onions', + ].sort() + ); }); }); From cc53bd589f84ca2456d59175a6eef9f6505b0f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 9 Jun 2026 15:48:23 +0200 Subject: [PATCH 3/3] fix: reject control chars in header values before trimming parseHeaderList trimmed the value before validating it, so leading or trailing CR/LF (and other control whitespace) were silently stripped instead of rejected, contradicting the documented reject-control-chars contract. Validate the raw value first, then store it trimmed. --- src/utils/headers.ts | 6 +++--- test/unit/headers.test.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/utils/headers.ts b/src/utils/headers.ts index bde23d5..c705096 100644 --- a/src/utils/headers.ts +++ b/src/utils/headers.ts @@ -61,10 +61,10 @@ export function parseHeaderList( throw new Error(`invalid header "${entry}", expected "Name: Value"`); } const name = entry.slice(0, separator); - const value = entry.slice(separator + 1).trim(); + const rawValue = entry.slice(separator + 1); validateHeaderName(name); - validateHeaderValue(value); - out[normalizeHeaderName(name)] = value; + validateHeaderValue(rawValue); + out[normalizeHeaderName(name)] = rawValue.trim(); } return out; } diff --git a/test/unit/headers.test.ts b/test/unit/headers.test.ts index 6128d7e..8dba8ed 100644 --- a/test/unit/headers.test.ts +++ b/test/unit/headers.test.ts @@ -56,6 +56,10 @@ describe('parseHeaderList', () => { expect(() => parseHeaderList([`X-Foo: a${nul}b`])).toThrow(); }); + it('throws on trailing CRLF rather than silently trimming it', () => { + expect(() => parseHeaderList(['X-Foo: bar\r\n'])).toThrow(); + }); + it('allows a horizontal tab in the value (RFC 7230 field-content)', () => { expect(parseHeaderList(['X-Foo: a\tb'])).toEqual({ 'x-foo': 'a\tb' }); });