373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|