From 43b06c519f266e15e109ee9ba36491ed26d4d013 Mon Sep 17 00:00:00 2001 From: Sami Date: Fri, 20 Mar 2026 22:45:11 -0700 Subject: [PATCH] test: add comprehensive docker-security test suite (39 tests, Phase 3) --- .../__tests__/docker-security.test.js | 432 ++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 dashcaddy-api/__tests__/docker-security.test.js diff --git a/dashcaddy-api/__tests__/docker-security.test.js b/dashcaddy-api/__tests__/docker-security.test.js new file mode 100644 index 0000000..0072c1e --- /dev/null +++ b/dashcaddy-api/__tests__/docker-security.test.js @@ -0,0 +1,432 @@ +/** + * Docker Security Module Tests + * Tests for image digest verification, security modes, and trusted digest management + * + * Note: These tests focus on the security logic and configuration management. + * Docker API integration tests are handled separately. + */ + +const fs = require('fs'); +const path = require('path'); + +// We'll test the module with a temp config file to avoid affecting production +const TEST_CONFIG_PATH = path.join(__dirname, 'test-docker-security-config.json'); + +describe('DockerSecurity Module', () => { + let dockerSecurity; + const originalEnv = process.env.DOCKER_SECURITY_CONFIG; + + beforeAll(() => { + // Point to test config file + process.env.DOCKER_SECURITY_CONFIG = TEST_CONFIG_PATH; + }); + + beforeEach(() => { + // Clean up test config before each test + if (fs.existsSync(TEST_CONFIG_PATH)) { + fs.unlinkSync(TEST_CONFIG_PATH); + } + + // Clear module cache and reload + delete require.cache[require.resolve('../docker-security')]; + dockerSecurity = require('../docker-security'); + }); + + afterEach(() => { + // Clean up test config + if (fs.existsSync(TEST_CONFIG_PATH)) { + fs.unlinkSync(TEST_CONFIG_PATH); + } + }); + + afterAll(() => { + // Restore original env + if (originalEnv) { + process.env.DOCKER_SECURITY_CONFIG = originalEnv; + } else { + delete process.env.DOCKER_SECURITY_CONFIG; + } + }); + + describe('Configuration Management', () => { + test('should create default config when file does not exist', () => { + expect(dockerSecurity.config).toBeDefined(); + expect(dockerSecurity.config).toHaveProperty('trustedDigests'); + expect(dockerSecurity.config).toHaveProperty('verificationMode'); + expect(dockerSecurity.config).toHaveProperty('allowUnverified'); + expect(dockerSecurity.config).toHaveProperty('updateTrustedOnPull'); + }); + + test('should save and load config correctly', () => { + // Add a trusted digest + dockerSecurity.setTrustedDigest('test:v1', 'sha256:test123'); + + // Reload module + delete require.cache[require.resolve('../docker-security')]; + const reloaded = require('../docker-security'); + + // Should have loaded the saved config + expect(reloaded.config.trustedDigests['test:v1']).toBe('sha256:test123'); + }); + + test('should handle corrupted config file gracefully', () => { + // Write corrupted JSON + fs.writeFileSync(TEST_CONFIG_PATH, '{ invalid json'); + + // Reload - should not crash + delete require.cache[require.resolve('../docker-security')]; + const fresh = require('../docker-security'); + + expect(fresh.config).toBeDefined(); + expect(fresh.config.trustedDigests).toBeDefined(); + }); + + test('should persist changes to disk', () => { + dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:abc123'); + + // Config file should exist + expect(fs.existsSync(TEST_CONFIG_PATH)).toBe(true); + + // Should be valid JSON + const savedData = fs.readFileSync(TEST_CONFIG_PATH, 'utf8'); + const parsed = JSON.parse(savedData); + expect(parsed.trustedDigests['nginx:latest']).toBe('sha256:abc123'); + }); + }); + + describe('Trusted Digest Management', () => { + test('should add new trusted digest', () => { + dockerSecurity.setTrustedDigest('postgres:14', 'sha256:newdigest'); + + expect(dockerSecurity.config.trustedDigests['postgres:14']).toBe('sha256:newdigest'); + }); + + test('should update existing trusted digest', () => { + dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:original'); + dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:updated'); + + expect(dockerSecurity.config.trustedDigests['nginx:latest']).toBe('sha256:updated'); + }); + + test('should remove trusted digest', () => { + dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:test'); + expect(dockerSecurity.config.trustedDigests['nginx:latest']).toBeDefined(); + + dockerSecurity.removeTrustedDigest('nginx:latest'); + expect(dockerSecurity.config.trustedDigests['nginx:latest']).toBeUndefined(); + }); + + test('should get all trusted digests', () => { + dockerSecurity.setTrustedDigest('image1', 'sha256:digest1'); + dockerSecurity.setTrustedDigest('image2', 'sha256:digest2'); + + const digests = dockerSecurity.getTrustedDigests(); + + expect(digests).toHaveProperty('image1'); + expect(digests).toHaveProperty('image2'); + expect(Object.keys(digests).length).toBeGreaterThanOrEqual(2); + }); + + test('should return copy of trusted digests (not reference)', () => { + dockerSecurity.setTrustedDigest('original', 'sha256:original'); + + const digests = dockerSecurity.getTrustedDigests(); + digests['modified'] = 'sha256:modified'; + + expect(dockerSecurity.config.trustedDigests['modified']).toBeUndefined(); + expect(dockerSecurity.config.trustedDigests['original']).toBe('sha256:original'); + }); + + test('should handle image names with special characters', () => { + const specialName = 'my-app_v2.0:latest'; + + dockerSecurity.setTrustedDigest(specialName, 'sha256:special'); + expect(dockerSecurity.config.trustedDigests[specialName]).toBe('sha256:special'); + }); + + test('should handle very long image names', () => { + const longName = 'registry.example.com/team/project/' + 'a'.repeat(100) + ':v1.2.3'; + + dockerSecurity.setTrustedDigest(longName, 'sha256:long'); + expect(dockerSecurity.config.trustedDigests[longName]).toBe('sha256:long'); + }); + }); + + describe('Security Modes', () => { + test('should set mode to strict', () => { + dockerSecurity.setMode('strict'); + + expect(dockerSecurity.mode).toBe('strict'); + expect(dockerSecurity.config.verificationMode).toBe('strict'); + }); + + test('should set mode to verify', () => { + dockerSecurity.setMode('verify'); + + expect(dockerSecurity.mode).toBe('verify'); + expect(dockerSecurity.config.verificationMode).toBe('verify'); + }); + + test('should set mode to permissive', () => { + dockerSecurity.setMode('permissive'); + + expect(dockerSecurity.mode).toBe('permissive'); + expect(dockerSecurity.config.verificationMode).toBe('permissive'); + }); + + test('should reject invalid mode', () => { + expect(() => dockerSecurity.setMode('invalid')).toThrow('Invalid mode'); + expect(() => dockerSecurity.setMode('STRICT')).toThrow('Invalid mode'); + expect(() => dockerSecurity.setMode('')).toThrow('Invalid mode'); + }); + + test('should persist mode change to config file', () => { + dockerSecurity.setMode('strict'); + + const savedData = fs.readFileSync(TEST_CONFIG_PATH, 'utf8'); + const parsed = JSON.parse(savedData); + expect(parsed.verificationMode).toBe('strict'); + }); + + test('should load mode from config on startup', () => { + dockerSecurity.setMode('strict'); + + // Reload module + delete require.cache[require.resolve('../docker-security')]; + const reloaded = require('../docker-security'); + + expect(reloaded.mode).toBe('strict'); + }); + }); + + describe('Digest Verification Logic', () => { + test('should accept matching digest', async () => { + dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:trusted123'); + + const result = await dockerSecurity.verifyImageDigest('nginx:latest', 'sha256:trusted123'); + + expect(result.verified).toBe(true); + expect(result.action).toBe('accept'); + expect(result.reason).toContain('matches trusted value'); + }); + + test('should reject mismatched digest in strict mode', async () => { + dockerSecurity.setMode('strict'); + dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:trusted123'); + + const result = await dockerSecurity.verifyImageDigest('nginx:latest', 'sha256:different'); + + expect(result.verified).toBe(false); + expect(result.action).toBe('reject'); + expect(result.reason).toContain('mismatch'); + }); + + test('should warn on mismatched digest in verify mode', async () => { + dockerSecurity.setMode('verify'); + dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:trusted123'); + + const result = await dockerSecurity.verifyImageDigest('nginx:latest', 'sha256:different'); + + expect(result.verified).toBe(false); + expect(result.action).toBe('warn'); + expect(result.reason).toContain('mismatch'); + }); + + test('should accept mismatched digest in permissive mode', async () => { + dockerSecurity.setMode('permissive'); + dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:trusted123'); + + const result = await dockerSecurity.verifyImageDigest('nginx:latest', 'sha256:different'); + + expect(result.verified).toBe(true); + expect(result.action).toBe('accept'); + expect(result.reason).toContain('permissive mode'); + }); + + test('should reject unknown image in strict mode', async () => { + dockerSecurity.setMode('strict'); + + const result = await dockerSecurity.verifyImageDigest('unknown:latest', 'sha256:anything'); + + expect(result.verified).toBe(false); + expect(result.action).toBe('reject'); + expect(result.reason).toContain('No trusted digest'); + }); + + test('should accept unknown image in verify mode', async () => { + dockerSecurity.setMode('verify'); + + const result = await dockerSecurity.verifyImageDigest('unknown:latest', 'sha256:anything'); + + expect(result.verified).toBe(true); + expect(result.action).toBe('accept'); + }); + + test('should accept and auto-trust unknown image when updateTrustedOnPull enabled', async () => { + dockerSecurity.setMode('permissive'); + dockerSecurity.config.updateTrustedOnPull = true; + + const result = await dockerSecurity.verifyImageDigest('newimage:v1', 'sha256:new123'); + + expect(result.verified).toBe(true); + expect(result.action).toBe('accept'); + expect(dockerSecurity.config.trustedDigests['newimage:v1']).toBe('sha256:new123'); + }); + + test('should not auto-trust when updateTrustedOnPull disabled', async () => { + dockerSecurity.config.updateTrustedOnPull = false; + + const result = await dockerSecurity.verifyImageDigest('newimage:v2', 'sha256:new456'); + + expect(result.verified).toBe(true); + expect(dockerSecurity.config.trustedDigests['newimage:v2']).toBeUndefined(); + }); + + test('should match base image name when tag not in config', async () => { + // Config has 'redis' (no tag), test 'redis:alpine' + dockerSecurity.setTrustedDigest('redis', 'sha256:base'); + + const result = await dockerSecurity.verifyImageDigest('redis:alpine', 'sha256:base'); + + expect(result.verified).toBe(true); + expect(result.action).toBe('accept'); + }); + + test('should prefer specific tag over base name', async () => { + dockerSecurity.setTrustedDigest('redis', 'sha256:base'); + dockerSecurity.setTrustedDigest('redis:alpine', 'sha256:alpine'); + + const result = await dockerSecurity.verifyImageDigest('redis:alpine', 'sha256:alpine'); + + expect(result.verified).toBe(true); + expect(result.trustedDigest).toBe('sha256:alpine'); + }); + }); + + describe('Status Reporting', () => { + test('should return security status', () => { + const status = dockerSecurity.getStatus(); + + expect(status).toHaveProperty('mode'); + expect(status).toHaveProperty('trustedImagesCount'); + expect(status).toHaveProperty('configFile'); + expect(status).toHaveProperty('updateTrustedOnPull'); + }); + + test('should count trusted images correctly', () => { + const initialCount = dockerSecurity.getStatus().trustedImagesCount; + + dockerSecurity.setTrustedDigest('count-test-1', 'sha256:1'); + dockerSecurity.setTrustedDigest('count-test-2', 'sha256:2'); + dockerSecurity.setTrustedDigest('count-test-3', 'sha256:3'); + + const status = dockerSecurity.getStatus(); + + expect(status.trustedImagesCount).toBe(initialCount + 3); + }); + + test('should reflect current mode', () => { + dockerSecurity.setMode('strict'); + expect(dockerSecurity.getStatus().mode).toBe('strict'); + + dockerSecurity.setMode('verify'); + expect(dockerSecurity.getStatus().mode).toBe('verify'); + }); + }); + + describe('Edge Cases & Security Boundaries', () => { + test('should handle empty digest string in strict mode', async () => { + dockerSecurity.setMode('strict'); + dockerSecurity.setTrustedDigest('test:latest', 'sha256:trusted'); + + const result = await dockerSecurity.verifyImageDigest('test:latest', ''); + + expect(result.verified).toBe(false); + expect(result.action).toBe('reject'); + }); + + test('should handle null/undefined digest in strict mode', async () => { + dockerSecurity.setMode('strict'); + dockerSecurity.setTrustedDigest('test:latest', 'sha256:trusted'); + + const result1 = await dockerSecurity.verifyImageDigest('test:latest', null); + const result2 = await dockerSecurity.verifyImageDigest('test:latest', undefined); + + expect(result1.verified).toBe(false); + expect(result2.verified).toBe(false); + }); + + test('should not expose sensitive data in verification result', async () => { + dockerSecurity.setTrustedDigest('nginx:latest', 'sha256:trusted123'); + + const result = await dockerSecurity.verifyImageDigest('nginx:latest', 'sha256:trusted123'); + + // Should not contain file paths, internal state, etc + const resultJson = JSON.stringify(result); + expect(resultJson).not.toContain(TEST_CONFIG_PATH); + expect(result).not.toHaveProperty('config'); + expect(result).not.toHaveProperty('_internal'); + }); + + test('should handle concurrent digest updates safely', () => { + // Add multiple digests rapidly + for (let i = 0; i < 10; i++) { + dockerSecurity.setTrustedDigest(`image${i}`, `sha256:digest${i}`); + } + + // All should be present + for (let i = 0; i < 10; i++) { + expect(dockerSecurity.config.trustedDigests[`image${i}`]).toBe(`sha256:digest${i}`); + } + }); + + test('should handle removal of non-existent digest', () => { + expect(() => { + dockerSecurity.removeTrustedDigest('nonexistent:latest'); + }).not.toThrow(); + }); + + test('should validate digest format (basic)', async () => { + dockerSecurity.setMode('verify'); + + // These should all work (verification logic doesn't enforce sha256: prefix) + const result1 = await dockerSecurity.verifyImageDigest('test:1', 'sha256:abc123'); + const result2 = await dockerSecurity.verifyImageDigest('test:2', 'localid123'); + + expect(result1.actualDigest).toBe('sha256:abc123'); + expect(result2.actualDigest).toBe('localid123'); + }); + }); + + describe('Verification Result Structure', () => { + test('should include all expected fields in result', async () => { + dockerSecurity.setTrustedDigest('test:v1', 'sha256:trusted'); + + const result = await dockerSecurity.verifyImageDigest('test:v1', 'sha256:trusted'); + + expect(result).toHaveProperty('verified'); + expect(result).toHaveProperty('mode'); + expect(result).toHaveProperty('imageName'); + expect(result).toHaveProperty('actualDigest'); + expect(result).toHaveProperty('trustedDigest'); + expect(result).toHaveProperty('action'); + expect(result).toHaveProperty('reason'); + }); + + test('should set trustedDigest to null when not configured', async () => { + const result = await dockerSecurity.verifyImageDigest('unknown:v1', 'sha256:test'); + + expect(result.trustedDigest).toBeNull(); + }); + + test('should preserve imageName and actualDigest in result', async () => { + const imageName = 'myapp:1.2.3'; + const digest = 'sha256:abcdef123456'; + + const result = await dockerSecurity.verifyImageDigest(imageName, digest); + + expect(result.imageName).toBe(imageName); + expect(result.actualDigest).toBe(digest); + }); + }); +});