@@ -0,0 +1,168 @@
|
||||
import {
|
||||
detectAttestationType,
|
||||
validateAttestationInputs,
|
||||
DetectionInputs
|
||||
} from '../../src/detect'
|
||||
|
||||
describe('detectAttestationType', () => {
|
||||
const blankInputs: DetectionInputs = {
|
||||
sbomPath: '',
|
||||
predicateType: '',
|
||||
predicate: '',
|
||||
predicatePath: ''
|
||||
}
|
||||
|
||||
it('should return provenance when no inputs are provided', () => {
|
||||
expect(detectAttestationType(blankInputs)).toBe('provenance')
|
||||
})
|
||||
|
||||
describe('SBOM detection', () => {
|
||||
it('should return sbom when sbom-path is provided', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
sbomPath: '/path/to/sbom.json'
|
||||
}
|
||||
expect(detectAttestationType(inputs)).toBe('sbom')
|
||||
})
|
||||
|
||||
it('should prioritize sbom over custom predicate inputs', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
sbomPath: '/path/to/sbom.json',
|
||||
predicateType: 'https://example.com/predicate'
|
||||
}
|
||||
expect(detectAttestationType(inputs)).toBe('sbom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom detection', () => {
|
||||
it('should return custom when predicate-type is provided', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
predicateType: 'https://example.com/predicate'
|
||||
}
|
||||
expect(detectAttestationType(inputs)).toBe('custom')
|
||||
})
|
||||
|
||||
it('should return custom when predicate is provided', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
predicate: '{}'
|
||||
}
|
||||
expect(detectAttestationType(inputs)).toBe('custom')
|
||||
})
|
||||
|
||||
it('should return custom when predicate-path is provided', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
predicatePath: '/path/to/predicate.json'
|
||||
}
|
||||
expect(detectAttestationType(inputs)).toBe('custom')
|
||||
})
|
||||
|
||||
it('should return custom when predicate-type and predicate are both provided', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
predicateType: 'https://example.com/predicate',
|
||||
predicate: '{}'
|
||||
}
|
||||
expect(detectAttestationType(inputs)).toBe('custom')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateAttestationInputs', () => {
|
||||
const blankInputs: DetectionInputs = {
|
||||
sbomPath: '',
|
||||
predicateType: '',
|
||||
predicate: '',
|
||||
predicatePath: ''
|
||||
}
|
||||
|
||||
it('should not throw when no inputs are provided', () => {
|
||||
expect(() => validateAttestationInputs(blankInputs)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when sbom-path is provided alone', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
sbomPath: '/path/to/sbom.json'
|
||||
}
|
||||
expect(() => validateAttestationInputs(inputs)).not.toThrow()
|
||||
})
|
||||
|
||||
describe('sbom-path conflicts', () => {
|
||||
it('should throw when sbom-path is combined with predicate-type', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
sbomPath: '/path/to/sbom.json',
|
||||
predicateType: 'https://example.com/predicate'
|
||||
}
|
||||
expect(() => validateAttestationInputs(inputs)).toThrow(
|
||||
/Cannot specify sbom-path together with/
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when sbom-path is combined with predicate', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
sbomPath: '/path/to/sbom.json',
|
||||
predicate: '{}'
|
||||
}
|
||||
expect(() => validateAttestationInputs(inputs)).toThrow(
|
||||
/Cannot specify sbom-path together with/
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when sbom-path is combined with predicate-path', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
sbomPath: '/path/to/sbom.json',
|
||||
predicatePath: '/path/to/predicate.json'
|
||||
}
|
||||
expect(() => validateAttestationInputs(inputs)).toThrow(
|
||||
/Cannot specify sbom-path together with/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('predicate-type requirements', () => {
|
||||
it('should throw when predicate is provided without predicate-type', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
predicate: '{}'
|
||||
}
|
||||
expect(() => validateAttestationInputs(inputs)).toThrow(
|
||||
/predicate-type is required/
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when predicate-path is provided without predicate-type', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
predicatePath: '/path/to/predicate.json'
|
||||
}
|
||||
expect(() => validateAttestationInputs(inputs)).toThrow(
|
||||
/predicate-type is required/
|
||||
)
|
||||
})
|
||||
|
||||
it('should not throw when predicate-type and predicate are provided', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
predicateType: 'https://example.com/predicate',
|
||||
predicate: '{}'
|
||||
}
|
||||
expect(() => validateAttestationInputs(inputs)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when predicate-type and predicate-path are provided', () => {
|
||||
const inputs: DetectionInputs = {
|
||||
...blankInputs,
|
||||
predicateType: 'https://example.com/predicate',
|
||||
predicatePath: '/path/to/predicate.json'
|
||||
}
|
||||
expect(() => validateAttestationInputs(inputs)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import fs from 'fs/promises'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { predicateFromInputs, PredicateInputs } from '../../src/predicate'
|
||||
|
||||
describe('predicateFromInputs', () => {
|
||||
const blankInputs: PredicateInputs = {
|
||||
predicateType: '',
|
||||
predicate: '',
|
||||
predicatePath: ''
|
||||
}
|
||||
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'predicate-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('input validation', () => {
|
||||
it('should throw when predicate-type is not provided', async () => {
|
||||
await expect(predicateFromInputs(blankInputs)).rejects.toThrow(
|
||||
/predicate-type must be provided/
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when neither predicate nor predicate-path is provided', async () => {
|
||||
const inputs: PredicateInputs = {
|
||||
...blankInputs,
|
||||
predicateType: 'https://example.com/predicate'
|
||||
}
|
||||
await expect(predicateFromInputs(inputs)).rejects.toThrow(
|
||||
/one of predicate-path or predicate must be provided/i
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when both predicate and predicate-path are provided', async () => {
|
||||
const inputs: PredicateInputs = {
|
||||
predicateType: 'https://example.com/predicate',
|
||||
predicate: '{}',
|
||||
predicatePath: '/path/to/predicate.json'
|
||||
}
|
||||
await expect(predicateFromInputs(inputs)).rejects.toThrow(
|
||||
/only one of predicate-path or predicate may be provided/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with predicate string', () => {
|
||||
it('should parse and return the predicate', async () => {
|
||||
const predicateType = 'https://example.com/predicate'
|
||||
const predicateContent = { foo: 'bar', nested: { value: 123 } }
|
||||
|
||||
const inputs: PredicateInputs = {
|
||||
...blankInputs,
|
||||
predicateType,
|
||||
predicate: JSON.stringify(predicateContent)
|
||||
}
|
||||
|
||||
const result = await predicateFromInputs(inputs)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: predicateType,
|
||||
params: predicateContent
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw when predicate string exceeds max size', async () => {
|
||||
const predicateType = 'https://example.com/predicate'
|
||||
const largeContent = JSON.stringify({ data: 'x'.repeat(16 * 1024 * 1024) })
|
||||
|
||||
const inputs: PredicateInputs = {
|
||||
...blankInputs,
|
||||
predicateType,
|
||||
predicate: largeContent
|
||||
}
|
||||
|
||||
await expect(predicateFromInputs(inputs)).rejects.toThrow(
|
||||
/predicate string exceeds maximum/
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when predicate is invalid JSON', async () => {
|
||||
const inputs: PredicateInputs = {
|
||||
...blankInputs,
|
||||
predicateType: 'https://example.com/predicate',
|
||||
predicate: 'not valid json'
|
||||
}
|
||||
|
||||
await expect(predicateFromInputs(inputs)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with predicate path', () => {
|
||||
it('should read and parse predicate from file', async () => {
|
||||
const predicateType = 'https://example.com/predicate'
|
||||
const predicateContent = { buildType: 'test', metadata: { version: '1.0' } }
|
||||
const filePath = path.join(tempDir, 'predicate.json')
|
||||
|
||||
await fs.writeFile(filePath, JSON.stringify(predicateContent))
|
||||
|
||||
const inputs: PredicateInputs = {
|
||||
...blankInputs,
|
||||
predicateType,
|
||||
predicatePath: filePath
|
||||
}
|
||||
|
||||
const result = await predicateFromInputs(inputs)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: predicateType,
|
||||
params: predicateContent
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw when predicate file does not exist', async () => {
|
||||
const inputs: PredicateInputs = {
|
||||
...blankInputs,
|
||||
predicateType: 'https://example.com/predicate',
|
||||
predicatePath: '/nonexistent/file.json'
|
||||
}
|
||||
|
||||
await expect(predicateFromInputs(inputs)).rejects.toThrow(/file not found/)
|
||||
})
|
||||
|
||||
it('should throw when predicate file contains invalid JSON', async () => {
|
||||
const filePath = path.join(tempDir, 'invalid.json')
|
||||
await fs.writeFile(filePath, 'not valid json')
|
||||
|
||||
const inputs: PredicateInputs = {
|
||||
...blankInputs,
|
||||
predicateType: 'https://example.com/predicate',
|
||||
predicatePath: filePath
|
||||
}
|
||||
|
||||
await expect(predicateFromInputs(inputs)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import fs from 'fs/promises'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { parseSBOMFromPath, generateSBOMPredicate, SBOM } from '../../src/sbom'
|
||||
|
||||
describe('parseSBOMFromPath', () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sbom-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('file handling', () => {
|
||||
it('should throw when file does not exist', async () => {
|
||||
await expect(parseSBOMFromPath('/nonexistent/file.json')).rejects.toThrow(
|
||||
/ENOENT/
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when file contains invalid JSON', async () => {
|
||||
const filePath = path.join(tempDir, 'invalid.json')
|
||||
await fs.writeFile(filePath, 'not valid json')
|
||||
|
||||
await expect(parseSBOMFromPath(filePath)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw when file exceeds maximum size', async () => {
|
||||
const filePath = path.join(tempDir, 'large.json')
|
||||
const largeContent = 'x'.repeat(17 * 1024 * 1024)
|
||||
await fs.writeFile(filePath, largeContent)
|
||||
|
||||
await expect(parseSBOMFromPath(filePath)).rejects.toThrow(
|
||||
/SBOM file exceeds maximum allowed size/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SPDX format', () => {
|
||||
const spdxSBOM = {
|
||||
spdxVersion: 'SPDX-2.3',
|
||||
SPDXID: 'SPDXRef-DOCUMENT',
|
||||
name: 'test-package',
|
||||
packages: []
|
||||
}
|
||||
|
||||
it('should parse valid SPDX SBOM', async () => {
|
||||
const filePath = path.join(tempDir, 'sbom.spdx.json')
|
||||
await fs.writeFile(filePath, JSON.stringify(spdxSBOM))
|
||||
|
||||
const result = await parseSBOMFromPath(filePath)
|
||||
|
||||
expect(result.type).toBe('spdx')
|
||||
expect(result.object).toEqual(spdxSBOM)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CycloneDX format', () => {
|
||||
const cyclonedxSBOM = {
|
||||
bomFormat: 'CycloneDX',
|
||||
specVersion: '1.4',
|
||||
serialNumber: 'urn:uuid:12345',
|
||||
components: []
|
||||
}
|
||||
|
||||
it('should parse valid CycloneDX SBOM', async () => {
|
||||
const filePath = path.join(tempDir, 'sbom.cdx.json')
|
||||
await fs.writeFile(filePath, JSON.stringify(cyclonedxSBOM))
|
||||
|
||||
const result = await parseSBOMFromPath(filePath)
|
||||
|
||||
expect(result.type).toBe('cyclonedx')
|
||||
expect(result.object).toEqual(cyclonedxSBOM)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsupported formats', () => {
|
||||
it('should throw for unrecognized SBOM format', async () => {
|
||||
const filePath = path.join(tempDir, 'invalid-sbom.json')
|
||||
await fs.writeFile(filePath, JSON.stringify({ random: 'data' }))
|
||||
|
||||
await expect(parseSBOMFromPath(filePath)).rejects.toThrow(
|
||||
/Unsupported SBOM format/
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw for SPDX missing SPDXID', async () => {
|
||||
const filePath = path.join(tempDir, 'partial-spdx.json')
|
||||
await fs.writeFile(filePath, JSON.stringify({ spdxVersion: 'SPDX-2.3' }))
|
||||
|
||||
await expect(parseSBOMFromPath(filePath)).rejects.toThrow(
|
||||
/Unsupported SBOM format/
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw for CycloneDX missing required fields', async () => {
|
||||
const filePath = path.join(tempDir, 'partial-cdx.json')
|
||||
await fs.writeFile(filePath, JSON.stringify({ bomFormat: 'CycloneDX' }))
|
||||
|
||||
await expect(parseSBOMFromPath(filePath)).rejects.toThrow(
|
||||
/Unsupported SBOM format/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateSBOMPredicate', () => {
|
||||
describe('SPDX predicates', () => {
|
||||
it('should generate predicate with correct SPDX type URL', () => {
|
||||
const sbom: SBOM = {
|
||||
type: 'spdx',
|
||||
object: {
|
||||
spdxVersion: 'SPDX-2.3',
|
||||
SPDXID: 'SPDXRef-DOCUMENT',
|
||||
name: 'test-package'
|
||||
}
|
||||
}
|
||||
|
||||
const predicate = generateSBOMPredicate(sbom)
|
||||
|
||||
expect(predicate.type).toBe('https://spdx.dev/Document/v2.3')
|
||||
expect(predicate.params).toEqual(sbom.object)
|
||||
})
|
||||
|
||||
it('should throw when spdxVersion is missing', () => {
|
||||
const sbom: SBOM = {
|
||||
type: 'spdx',
|
||||
object: { SPDXID: 'SPDXRef-DOCUMENT' }
|
||||
}
|
||||
|
||||
expect(() => generateSBOMPredicate(sbom)).toThrow(
|
||||
/Cannot find spdxVersion/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CycloneDX predicates', () => {
|
||||
it('should generate predicate with correct CycloneDX type URL', () => {
|
||||
const sbom: SBOM = {
|
||||
type: 'cyclonedx',
|
||||
object: {
|
||||
bomFormat: 'CycloneDX',
|
||||
specVersion: '1.4',
|
||||
serialNumber: 'urn:uuid:12345'
|
||||
}
|
||||
}
|
||||
|
||||
const predicate = generateSBOMPredicate(sbom)
|
||||
|
||||
expect(predicate.type).toBe('https://cyclonedx.org/bom')
|
||||
expect(predicate.params).toEqual(sbom.object)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsupported types', () => {
|
||||
it('should throw for unsupported SBOM type', () => {
|
||||
const sbom = {
|
||||
type: 'unknown' as SBOM['type'],
|
||||
object: { foo: 'bar' }
|
||||
}
|
||||
|
||||
expect(() => generateSBOMPredicate(sbom)).toThrow(/Unsupported SBOM format/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { highlight, mute } from '../../src/style'
|
||||
|
||||
describe('style', () => {
|
||||
describe('highlight', () => {
|
||||
it('should wrap text with cyan ANSI color codes', () => {
|
||||
const result = highlight('test message')
|
||||
expect(result).toBe('\x1B[36mtest message\x1B[39m')
|
||||
})
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const result = highlight('')
|
||||
expect(result).toBe('\x1B[36m\x1B[39m')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mute', () => {
|
||||
it('should wrap text with gray ANSI color codes', () => {
|
||||
const result = mute('test message')
|
||||
expect(result).toBe('\x1B[38;5;244mtest message\x1B[39m')
|
||||
})
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const result = mute('')
|
||||
expect(result).toBe('\x1B[38;5;244m\x1B[39m')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,462 @@
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs/promises'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import {
|
||||
subjectFromInputs,
|
||||
formatSubjectDigest,
|
||||
SubjectInputs
|
||||
} from '../../src/subject'
|
||||
|
||||
describe('subjectFromInputs', () => {
|
||||
const blankInputs: SubjectInputs = {
|
||||
subjectPath: '',
|
||||
subjectName: '',
|
||||
subjectDigest: '',
|
||||
subjectChecksums: ''
|
||||
}
|
||||
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'subject-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('input validation', () => {
|
||||
it('should throw when no inputs are provided', async () => {
|
||||
await expect(subjectFromInputs(blankInputs)).rejects.toThrow(
|
||||
/one of subject-path, subject-digest, or subject-checksums must be provided/i
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when multiple subject inputs are provided', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: '/some/path',
|
||||
subjectDigest: 'sha256:abc123'
|
||||
}
|
||||
await expect(subjectFromInputs(inputs)).rejects.toThrow(
|
||||
/only one of subject-path, subject-digest, or subject-checksums may be provided/i
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when subject-digest is provided without subject-name', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectDigest: 'sha256:7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||
}
|
||||
await expect(subjectFromInputs(inputs)).rejects.toThrow(
|
||||
/subject-name must be provided when using subject-digest/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with subject-digest', () => {
|
||||
const validDigest = 'sha256:7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||
|
||||
it('should return subject with provided name and digest', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectName: 'my-artifact',
|
||||
subjectDigest: validDigest
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(1)
|
||||
expect(subjects[0].name).toBe('my-artifact')
|
||||
expect(subjects[0].digest).toEqual({
|
||||
sha256: '7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||
})
|
||||
})
|
||||
|
||||
it('should lowercase name when downcaseName is true', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectName: 'ghcr.io/FOO/Bar',
|
||||
subjectDigest: validDigest,
|
||||
downcaseName: true
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects[0].name).toBe('ghcr.io/foo/bar')
|
||||
})
|
||||
|
||||
it('should throw for malformed digest format', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectName: 'artifact',
|
||||
subjectDigest: 'invalid-digest'
|
||||
}
|
||||
|
||||
await expect(subjectFromInputs(inputs)).rejects.toThrow(
|
||||
/subject-digest must be in the format/
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw for unsupported hash algorithm', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectName: 'artifact',
|
||||
subjectDigest: 'md5:d41d8cd98f00b204e9800998ecf8427e'
|
||||
}
|
||||
|
||||
await expect(subjectFromInputs(inputs)).rejects.toThrow(
|
||||
/subject-digest must be in the format/
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw for incorrect sha256 digest length', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectName: 'artifact',
|
||||
subjectDigest: 'sha256:deadbeef'
|
||||
}
|
||||
|
||||
await expect(subjectFromInputs(inputs)).rejects.toThrow(
|
||||
/subject-digest must be in the format/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with subject-path', () => {
|
||||
const fileContent = 'test file content'
|
||||
const expectedDigest = crypto.createHash('sha256').update(fileContent).digest('hex')
|
||||
|
||||
it('should calculate digest from file', async () => {
|
||||
const filePath = path.join(tempDir, 'artifact.bin')
|
||||
await fs.writeFile(filePath, fileContent)
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: filePath
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(1)
|
||||
expect(subjects[0].name).toBe('artifact.bin')
|
||||
expect(subjects[0].digest).toEqual({ sha256: expectedDigest })
|
||||
})
|
||||
|
||||
it('should use provided name instead of filename', async () => {
|
||||
const filePath = path.join(tempDir, 'artifact.bin')
|
||||
await fs.writeFile(filePath, fileContent)
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: filePath,
|
||||
subjectName: 'custom-name'
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects[0].name).toBe('custom-name')
|
||||
})
|
||||
|
||||
it('should throw when file does not exist', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: '/nonexistent/file'
|
||||
}
|
||||
|
||||
await expect(subjectFromInputs(inputs)).rejects.toThrow(
|
||||
/could not find subject at path/i
|
||||
)
|
||||
})
|
||||
|
||||
describe('glob patterns', () => {
|
||||
beforeEach(async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await fs.writeFile(path.join(tempDir, `file-${i}.txt`), fileContent)
|
||||
}
|
||||
})
|
||||
|
||||
it('should expand glob pattern to multiple subjects', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: path.join(tempDir, 'file-*.txt')
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(3)
|
||||
expect(subjects.map(s => s.name).sort()).toEqual([
|
||||
'file-0.txt',
|
||||
'file-1.txt',
|
||||
'file-2.txt'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle comma-separated paths', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: `${path.join(tempDir, 'file-0.txt')},${path.join(tempDir, 'file-1.txt')}`
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle newline-separated paths', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: `${path.join(tempDir, 'file-0.txt')}\n${path.join(tempDir, 'file-2.txt')}`
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should support exclusion patterns', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: `${path.join(tempDir, 'file-*.txt')},!${path.join(tempDir, 'file-1.txt')}`
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(2)
|
||||
expect(subjects.map(s => s.name)).not.toContain('file-1.txt')
|
||||
})
|
||||
|
||||
it('should deduplicate subjects with same name and digest', async () => {
|
||||
// Create another directory with same file
|
||||
const otherDir = await fs.mkdtemp(path.join(os.tmpdir(), 'subject-dup-'))
|
||||
await fs.writeFile(path.join(otherDir, 'file-0.txt'), fileContent)
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: `${path.join(tempDir, 'file-0.txt')},${path.join(otherDir, 'file-0.txt')}`
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(1)
|
||||
|
||||
await fs.rm(otherDir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('should exclude directories from glob results', async () => {
|
||||
await fs.mkdir(path.join(tempDir, 'subdir'))
|
||||
await fs.writeFile(path.join(tempDir, 'file.txt'), fileContent)
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: path.join(tempDir, '*')
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(1)
|
||||
expect(subjects[0].name).toBe('file.txt')
|
||||
})
|
||||
|
||||
it('should throw when too many subjects are specified', async () => {
|
||||
// Create 1025 files (exceeds MAX_SUBJECT_COUNT of 1024)
|
||||
for (let i = 0; i < 1025; i++) {
|
||||
await fs.writeFile(path.join(tempDir, `file-${i}.txt`), `content-${i}`)
|
||||
}
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectPath: path.join(tempDir, 'file-*.txt')
|
||||
}
|
||||
|
||||
await expect(subjectFromInputs(inputs)).rejects.toThrow(/too many subjects/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with subject-checksums', () => {
|
||||
describe('from string', () => {
|
||||
it('should parse sha256 checksums', async () => {
|
||||
const checksums = `187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d artifact-linux
|
||||
9ecbf449e286a8a8748c161c52aa28b6b2fc64ab86f94161c5d1b3abc18156c5 artifact-darwin`
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: checksums
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(2)
|
||||
expect(subjects).toContainEqual({
|
||||
name: 'artifact-linux',
|
||||
digest: { sha256: '187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d' }
|
||||
})
|
||||
expect(subjects).toContainEqual({
|
||||
name: 'artifact-darwin',
|
||||
digest: { sha256: '9ecbf449e286a8a8748c161c52aa28b6b2fc64ab86f94161c5d1b3abc18156c5' }
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse sha512 checksums', async () => {
|
||||
const sha512 = '5d8b4751ef31f9440d843fcfa4e53ca2e25b1cb1f13fd355fdc7c24b41fe645293291ea9297ba3989078abb77ebbaac66be073618a9e4974dbd0361881d4c718'
|
||||
const checksums = `${sha512} artifact-amd64`
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: checksums
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(1)
|
||||
expect(subjects[0].digest).toEqual({ sha512 })
|
||||
})
|
||||
|
||||
it('should handle binary mode flag (*)', async () => {
|
||||
const checksums = `187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d *artifact.bin`
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: checksums
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects[0].name).toBe('artifact.bin')
|
||||
})
|
||||
|
||||
it('should handle text mode flag (space)', async () => {
|
||||
const checksums = `187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d artifact.txt`
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: checksums
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects[0].name).toBe('artifact.txt')
|
||||
})
|
||||
|
||||
it('should handle checksums without mode flag', async () => {
|
||||
// Single space between digest and name (no flag character)
|
||||
const checksums = `187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d artifact-no-flag`
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: checksums
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects[0].name).toBe('artifact-no-flag')
|
||||
})
|
||||
|
||||
it('should skip malformed lines', async () => {
|
||||
const checksums = `187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d valid-artifact
|
||||
badline
|
||||
9ecbf449e286a8a8748c161c52aa28b6b2fc64ab86f94161c5d1b3abc18156c5 another-artifact`
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: checksums
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should deduplicate identical entries', async () => {
|
||||
const checksums = `187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d artifact
|
||||
187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d artifact`
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: checksums
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should throw for invalid digest characters', async () => {
|
||||
const checksums = `!!!!e68a080799ca83104630b56abb90d8dbcc5f8b5a8639cb691e269838f29e artifact`
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: checksums
|
||||
}
|
||||
|
||||
await expect(subjectFromInputs(inputs)).rejects.toThrow(/invalid digest/i)
|
||||
})
|
||||
|
||||
it('should throw for unknown digest algorithm', async () => {
|
||||
const checksums = `f861e artifact`
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: checksums
|
||||
}
|
||||
|
||||
await expect(subjectFromInputs(inputs)).rejects.toThrow(/unknown digest algorithm/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('from file', () => {
|
||||
it('should read checksums from file', async () => {
|
||||
const checksumFile = path.join(tempDir, 'SHA256SUMS')
|
||||
const checksums = `187dcd1506a170337415589ff00c8743f19d41cc31fca246c2739dfd450d0b9d artifact-linux
|
||||
9ecbf449e286a8a8748c161c52aa28b6b2fc64ab86f94161c5d1b3abc18156c5 artifact-darwin`
|
||||
|
||||
await fs.writeFile(checksumFile, checksums)
|
||||
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: checksumFile
|
||||
}
|
||||
|
||||
const subjects = await subjectFromInputs(inputs)
|
||||
|
||||
expect(subjects).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should throw when checksums path is a directory', async () => {
|
||||
const inputs: SubjectInputs = {
|
||||
...blankInputs,
|
||||
subjectChecksums: tempDir
|
||||
}
|
||||
|
||||
await expect(subjectFromInputs(inputs)).rejects.toThrow(
|
||||
/subject checksums file not found/i
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSubjectDigest', () => {
|
||||
it('should format digest as algorithm:hash', () => {
|
||||
const subject = {
|
||||
name: 'artifact',
|
||||
digest: { sha256: 'abc123def456' }
|
||||
}
|
||||
|
||||
expect(formatSubjectDigest(subject)).toBe('sha256:abc123def456')
|
||||
})
|
||||
|
||||
it('should use first algorithm alphabetically when multiple exist', () => {
|
||||
const subject = {
|
||||
name: 'artifact',
|
||||
digest: {
|
||||
sha512: 'longer-hash',
|
||||
sha256: 'shorter-hash'
|
||||
}
|
||||
}
|
||||
|
||||
expect(formatSubjectDigest(subject)).toBe('sha256:shorter-hash')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user