import {vi, it, expect, beforeEach, describe} from 'vitest' import * as core from '../__fixtures__/core.js' const mockExistsSync = vi.fn() const mockReadFileSync = vi.fn() vi.mock('fs', () => ({ existsSync: mockExistsSync, readFileSync: mockReadFileSync, })) vi.mock('@actions/core', () => core) const {loadContentFromFileOrInput, parseCustomHeaders} = await import('../src/helpers.js') describe('helpers.ts', () => { beforeEach(() => { vi.clearAllMocks() }) describe('loadContentFromFileOrInput', () => { it('loads content from file when file path is provided', () => { const filePath = '/path/to/file.txt' const fileContent = 'File content here' core.getInput.mockImplementation((name: string) => { if (name === 'file-input') return filePath if (name === 'content-input') return '' return '' }) mockExistsSync.mockReturnValue(true) mockReadFileSync.mockReturnValue(fileContent) const result = loadContentFromFileOrInput('file-input', 'content-input') expect(core.getInput).toHaveBeenCalledWith('file-input') expect(mockExistsSync).toHaveBeenCalledWith(filePath) expect(mockReadFileSync).toHaveBeenCalledWith(filePath, 'utf-8') expect(result).toBe(fileContent) }) it('throws error when file path is provided but file does not exist', () => { const filePath = '/path/to/nonexistent.txt' core.getInput.mockImplementation((name: string) => { if (name === 'file-input') return filePath if (name === 'content-input') return '' return '' }) mockExistsSync.mockReturnValue(false) expect(() => { loadContentFromFileOrInput('file-input', 'content-input') }).toThrow('File for file-input was not found: /path/to/nonexistent.txt') expect(mockExistsSync).toHaveBeenCalledWith(filePath) expect(mockReadFileSync).not.toHaveBeenCalled() }) it('uses content input when file path is empty', () => { const contentInput = 'Direct content input' core.getInput.mockImplementation((name: string) => { if (name === 'file-input') return '' if (name === 'content-input') return contentInput return '' }) const result = loadContentFromFileOrInput('file-input', 'content-input') expect(core.getInput).toHaveBeenCalledWith('file-input') expect(core.getInput).toHaveBeenCalledWith('content-input') expect(mockExistsSync).not.toHaveBeenCalled() expect(mockReadFileSync).not.toHaveBeenCalled() expect(result).toBe(contentInput) }) it('prefers file path over content input when both are provided', () => { const filePath = '/path/to/file.txt' const fileContent = 'File content' const contentInput = 'Direct content input' core.getInput.mockImplementation((name: string) => { if (name === 'file-input') return filePath if (name === 'content-input') return contentInput return '' }) mockExistsSync.mockReturnValue(true) mockReadFileSync.mockReturnValue(fileContent) const result = loadContentFromFileOrInput('file-input', 'content-input') expect(result).toBe(fileContent) expect(mockExistsSync).toHaveBeenCalledWith(filePath) expect(mockReadFileSync).toHaveBeenCalledWith(filePath, 'utf-8') }) it('uses default value when neither file nor content is provided', () => { const defaultValue = 'Default content' core.getInput.mockImplementation(() => '') const result = loadContentFromFileOrInput('file-input', 'content-input', defaultValue) expect(result).toBe(defaultValue) expect(mockExistsSync).not.toHaveBeenCalled() expect(mockReadFileSync).not.toHaveBeenCalled() }) it('throws error when neither file nor content is provided and no default', () => { core.getInput.mockImplementation(() => '') expect(() => { loadContentFromFileOrInput('file-input', 'content-input') }).toThrow('Neither file-input nor content-input was set') expect(mockExistsSync).not.toHaveBeenCalled() expect(mockReadFileSync).not.toHaveBeenCalled() }) it('handles undefined inputs correctly', () => { const defaultValue = 'Default content' // eslint-disable-next-line @typescript-eslint/no-explicit-any core.getInput.mockImplementation(() => undefined as any) const result = loadContentFromFileOrInput('file-input', 'content-input', defaultValue) expect(result).toBe(defaultValue) }) }) describe('parseCustomHeaders', () => { beforeEach(() => { vi.clearAllMocks() }) it('parses YAML format headers correctly', () => { const yamlInput = `header1: value1 header2: value2 X-Custom-Header: custom-value` const result = parseCustomHeaders(yamlInput) expect(result).toEqual({ header1: 'value1', header2: 'value2', 'X-Custom-Header': 'custom-value', }) expect(core.debug).toHaveBeenCalledWith('Custom header added: header1: value1') expect(core.debug).toHaveBeenCalledWith('Custom header added: header2: value2') expect(core.debug).toHaveBeenCalledWith('Custom header added: X-Custom-Header: custom-value') }) it('parses JSON format headers correctly', () => { const jsonInput = '{"header1": "value1", "header2": "value2", "X-Team": "engineering"}' const result = parseCustomHeaders(jsonInput) expect(result).toEqual({ header1: 'value1', header2: 'value2', 'X-Team': 'engineering', }) expect(core.debug).toHaveBeenCalledWith('Custom header added: header1: value1') expect(core.debug).toHaveBeenCalledWith('Custom header added: header2: value2') expect(core.debug).toHaveBeenCalledWith('Custom header added: X-Team: engineering') }) it('returns empty object for empty input', () => { expect(parseCustomHeaders('')).toEqual({}) expect(parseCustomHeaders(' ')).toEqual({}) expect(core.warning).not.toHaveBeenCalled() }) it('masks sensitive header values in logs', () => { const yamlInput = `Ocp-Apim-Subscription-Key: secret123 X-Api-Token: token456 Authorization: Bearer abc123 serviceName: my-service password: pass123` const result = parseCustomHeaders(yamlInput) expect(result).toEqual({ 'Ocp-Apim-Subscription-Key': 'secret123', 'X-Api-Token': 'token456', Authorization: 'Bearer abc123', serviceName: 'my-service', password: 'pass123', }) // Sensitive headers should be masked expect(core.debug).toHaveBeenCalledWith('Custom header added: Ocp-Apim-Subscription-Key: ***MASKED***') expect(core.debug).toHaveBeenCalledWith('Custom header added: X-Api-Token: ***MASKED***') expect(core.debug).toHaveBeenCalledWith('Custom header added: Authorization: ***MASKED***') expect(core.debug).toHaveBeenCalledWith('Custom header added: password: ***MASKED***') // Non-sensitive headers should not be masked expect(core.debug).toHaveBeenCalledWith('Custom header added: serviceName: my-service') }) it('validates header names and skips invalid ones', () => { const yamlInput = `valid-header: value1 invalid header: value2 invalid_underscore: value3 invalid@header: value4 valid123: value5` const result = parseCustomHeaders(yamlInput) expect(result).toEqual({ 'valid-header': 'value1', invalid_underscore: 'value3', valid123: 'value5', }) expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('Skipping invalid header name: invalid header')) expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('Skipping invalid header name: invalid@header')) }) it('warns and returns empty object for invalid JSON', () => { const invalidJson = '{invalid json}' const result = parseCustomHeaders(invalidJson) expect(result).toEqual({}) expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('Failed to parse custom headers')) }) it('warns and returns empty object for invalid YAML', () => { const invalidYaml = 'invalid: yaml: structure: bad' const result = parseCustomHeaders(invalidYaml) expect(result).toEqual({}) expect(core.warning).toHaveBeenCalledWith(expect.stringContaining('Failed to parse custom headers')) }) it('warns and returns empty object for JSON array', () => { const jsonArray = '["header1", "header2"]' const result = parseCustomHeaders(jsonArray) expect(result).toEqual({}) expect(core.warning).toHaveBeenCalledWith('Custom headers JSON must be an object, not null or an array') }) it('warns and returns empty object for null value', () => { // The string 'null' is valid YAML and gets parsed as null const nullValue = 'null' const result = parseCustomHeaders(nullValue) expect(result).toEqual({}) expect(core.warning).toHaveBeenCalledWith('Custom headers YAML must be an object') }) it('warns and returns empty object for YAML array', () => { const yamlArray = `- header1 - header2` const result = parseCustomHeaders(yamlArray) expect(result).toEqual({}) expect(core.warning).toHaveBeenCalledWith('Custom headers YAML must be an object') }) it('converts non-string values to strings', () => { const jsonInput = '{"numericHeader": 123, "boolHeader": true, "nullHeader": null}' const result = parseCustomHeaders(jsonInput) expect(result).toEqual({ numericHeader: '123', boolHeader: 'true', nullHeader: 'null', }) }) it('rejects header values with newline characters (LF)', () => { const jsonInput = '{"X-Custom-Header": "value\\nwith\\nnewline", "header1": "safe-value"}' const result = parseCustomHeaders(jsonInput) // Only the safe header should be accepted expect(result).toEqual({ header1: 'safe-value', }) expect(core.warning).toHaveBeenCalledWith( 'Skipping header "X-Custom-Header" because its value contains newline characters, which are not allowed in HTTP header values.', ) }) it('rejects header values with carriage return characters (CR)', () => { const jsonInput = '{"X-Injected": "value\\rwith\\rcarriage", "X-Safe": "safe-value"}' const result = parseCustomHeaders(jsonInput) // Only the safe header should be accepted expect(result).toEqual({ 'X-Safe': 'safe-value', }) expect(core.warning).toHaveBeenCalledWith( 'Skipping header "X-Injected" because its value contains newline characters, which are not allowed in HTTP header values.', ) }) it('rejects header values with CRLF sequences', () => { const jsonInput = '{"X-Attack": "value\\r\\nInjected-Header: malicious", "X-Valid": "normal"}' const result = parseCustomHeaders(jsonInput) // Only the valid header should be accepted expect(result).toEqual({ 'X-Valid': 'normal', }) expect(core.warning).toHaveBeenCalledWith( 'Skipping header "X-Attack" because its value contains newline characters, which are not allowed in HTTP header values.', ) }) it('rejects multiline YAML values for security', () => { const yamlInput = `header1: value1 header2: | multiline value here` const result = parseCustomHeaders(yamlInput) // header2 should be rejected because it contains newlines expect(result).toEqual({ header1: 'value1', }) expect(core.warning).toHaveBeenCalledWith( 'Skipping header "header2" because its value contains newline characters, which are not allowed in HTTP header values.', ) }) it('handles complex real-world Azure APIM example', () => { const apimHeaders = `Ocp-Apim-Subscription-Key: my-subscription-key-123 serviceName: terraform-plan-workflow env: prod team: infrastructure computer: github-actions systemID: terraform-ci` const result = parseCustomHeaders(apimHeaders) expect(result).toEqual({ 'Ocp-Apim-Subscription-Key': 'my-subscription-key-123', serviceName: 'terraform-plan-workflow', env: 'prod', team: 'infrastructure', computer: 'github-actions', systemID: 'terraform-ci', }) // Only the subscription key should be masked expect(core.debug).toHaveBeenCalledWith('Custom header added: Ocp-Apim-Subscription-Key: ***MASKED***') expect(core.debug).toHaveBeenCalledWith('Custom header added: serviceName: terraform-plan-workflow') }) }) })