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..c705096 --- /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 rawValue = entry.slice(separator + 1); + validateHeaderName(name); + validateHeaderValue(rawValue); + out[normalizeHeaderName(name)] = rawValue.trim(); + } + 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/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() + ); }); }); 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..8dba8ed --- /dev/null +++ b/test/unit/headers.test.ts @@ -0,0 +1,92 @@ +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('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' }); + }); +}); + +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' }); + }); +});