From 13c9784235a2ca3f459dc73e35b9c8be37ad4200 Mon Sep 17 00:00:00 2001 From: TimElschner Date: Thu, 5 Feb 2026 09:44:06 +0100 Subject: [PATCH 1/2] Add unit test coverage for core modules Adds 110+ tests across 7 new test files covering utilities (version, diff, ip), encryption, git utils, notification utils, auth logic (rate limiting, password hashing), and authorization helpers. Exports maskSecrets and escapeTelegramMarkdown for direct testability. --- src/lib/server/git.ts | 2 +- src/lib/server/notifications.ts | 2 +- tests/api-smoke.test.ts | 83 ++++++++ tests/auth.test.ts | 126 ++++++++++++ tests/authorize-helpers.test.ts | 47 +++++ tests/encryption.test.ts | 157 +++++++++++++++ tests/git-utils.test.ts | 164 ++++++++++++++++ tests/notifications-utils.test.ts | 49 +++++ tests/utils.test.ts | 314 ++++++++++++++++++++++++++++++ 9 files changed, 942 insertions(+), 2 deletions(-) create mode 100644 tests/api-smoke.test.ts create mode 100644 tests/auth.test.ts create mode 100644 tests/authorize-helpers.test.ts create mode 100644 tests/encryption.test.ts create mode 100644 tests/git-utils.test.ts create mode 100644 tests/notifications-utils.test.ts create mode 100644 tests/utils.test.ts diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 7768d5e..fc38f9d 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -26,7 +26,7 @@ if (!existsSync(GIT_REPOS_DIR)) { /** * Mask sensitive values in environment variables for safe logging. */ -function maskSecrets(vars: Record): Record { +export function maskSecrets(vars: Record): Record { const masked: Record = {}; const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i; for (const [key, value] of Object.entries(vars)) { diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts index 79b43ee..ada28fb 100644 --- a/src/lib/server/notifications.ts +++ b/src/lib/server/notifications.ts @@ -10,7 +10,7 @@ import { } from './db'; // Escape special characters for Telegram Markdown -function escapeTelegramMarkdown(text: string): string { +export function escapeTelegramMarkdown(text: string): string { // Escape characters that have special meaning in Telegram Markdown return text .replace(/\\/g, '\\\\') // Escape backslashes first diff --git a/tests/api-smoke.test.ts b/tests/api-smoke.test.ts new file mode 100644 index 0000000..8639bea --- /dev/null +++ b/tests/api-smoke.test.ts @@ -0,0 +1,83 @@ +/** + * API Smoke Tests + * + * Basic smoke tests that verify API endpoints are reachable and return + * expected status codes. Requires a running Dockhand instance. + * + * Set DOCKHAND_URL environment variable to override (default: http://localhost:3000). + */ +import { describe, test, expect } from 'bun:test'; + +const BASE_URL = process.env.DOCKHAND_URL || 'http://localhost:3000'; + +async function api(path: string, options: RequestInit = {}) { + const url = `${BASE_URL}${path}`; + const res = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}) + } + }); + return { status: res.status, data: await res.json().catch(() => null) }; +} + +describe('API Smoke Tests', () => { + test('GET /api/health returns 200', async () => { + const { status } = await api('/api/health'); + expect(status).toBe(200); + }); + + test('GET /api/system/version returns 200 with version info', async () => { + const { status, data } = await api('/api/system/version'); + expect(status).toBe(200); + expect(data).toBeDefined(); + }); + + test('GET /api/environments returns 200 with array', async () => { + const { status, data } = await api('/api/environments'); + expect(status).toBe(200); + expect(Array.isArray(data)).toBe(true); + }); + + test('GET /api/stacks returns 200', async () => { + const { status, data } = await api('/api/stacks'); + expect(status).toBe(200); + expect(data).toBeDefined(); + }); + + test('GET /api/registries returns 200 with array', async () => { + const { status, data } = await api('/api/registries'); + expect(status).toBe(200); + expect(Array.isArray(data)).toBe(true); + }); + + test('GET /api/git/repositories returns 200 with array', async () => { + const { status, data } = await api('/api/git/repositories'); + expect(status).toBe(200); + expect(Array.isArray(data)).toBe(true); + }); + + test('GET /api/git/stacks returns 200 with array', async () => { + const { status, data } = await api('/api/git/stacks'); + expect(status).toBe(200); + expect(Array.isArray(data)).toBe(true); + }); + + test('GET /api/notifications returns 200 with array', async () => { + const { status, data } = await api('/api/notifications'); + expect(status).toBe(200); + expect(Array.isArray(data)).toBe(true); + }); + + test('GET /api/auth/session returns session status', async () => { + const { status } = await api('/api/auth/session'); + // 200 if auth disabled or valid session, 401 if auth enabled without session + expect([200, 401]).toContain(status); + }); + + test('GET non-existent API endpoint returns 404', async () => { + const { status } = await api('/api/this-endpoint-does-not-exist'); + expect(status).toBe(404); + }); +}); diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..addac38 --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for Authentication Logic + * + * Tests rate limiting (in-memory state) and password hashing/verification. + */ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { + isRateLimited, + recordFailedAttempt, + clearRateLimit, + hashPassword, + verifyPassword +} from '../src/lib/server/auth'; + +// ============================================================================= +// Rate Limiting +// ============================================================================= + +describe('Rate Limiting', () => { + const testId = () => `test-${Date.now()}-${Math.random()}`; + + test('unknown identifier is not rate limited', () => { + const result = isRateLimited(testId()); + expect(result.limited).toBe(false); + expect(result.retryAfter).toBeUndefined(); + }); + + test('1-4 failed attempts are not rate limited', () => { + const id = testId(); + for (let i = 0; i < 4; i++) { + recordFailedAttempt(id); + } + const result = isRateLimited(id); + expect(result.limited).toBe(false); + }); + + test('5 failed attempts triggers rate limiting', () => { + const id = testId(); + for (let i = 0; i < 5; i++) { + recordFailedAttempt(id); + } + const result = isRateLimited(id); + expect(result.limited).toBe(true); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + test('clearRateLimit removes the limit', () => { + const id = testId(); + for (let i = 0; i < 5; i++) { + recordFailedAttempt(id); + } + expect(isRateLimited(id).limited).toBe(true); + + clearRateLimit(id); + expect(isRateLimited(id).limited).toBe(false); + }); + + test('clearing non-existent identifier does not throw', () => { + expect(() => clearRateLimit(testId())).not.toThrow(); + }); + + test('different identifiers are tracked independently', () => { + const id1 = testId(); + const id2 = testId(); + + for (let i = 0; i < 5; i++) { + recordFailedAttempt(id1); + } + + expect(isRateLimited(id1).limited).toBe(true); + expect(isRateLimited(id2).limited).toBe(false); + }); +}); + +// ============================================================================= +// Password Hashing +// ============================================================================= + +describe('Password Hashing', () => { + test('hashPassword returns a hash string', async () => { + const hash = await hashPassword('test-password'); + expect(typeof hash).toBe('string'); + expect(hash.length).toBeGreaterThan(0); + expect(hash).not.toBe('test-password'); + }); + + test('verifyPassword returns true for correct password', async () => { + const password = 'correct-password-123!'; + const hash = await hashPassword(password); + const result = await verifyPassword(password, hash); + expect(result).toBe(true); + }); + + test('verifyPassword returns false for wrong password', async () => { + const hash = await hashPassword('correct-password'); + const result = await verifyPassword('wrong-password', hash); + expect(result).toBe(false); + }); + + test('different passwords produce different hashes', async () => { + const hash1 = await hashPassword('password-one'); + const hash2 = await hashPassword('password-two'); + expect(hash1).not.toBe(hash2); + }); + + test('same password produces different hashes (salt)', async () => { + const hash1 = await hashPassword('same-password'); + const hash2 = await hashPassword('same-password'); + // Due to random salt, hashes should differ + expect(hash1).not.toBe(hash2); + }); + + test('handles special characters in passwords', async () => { + const password = 'P@$$w0rd!#%^&*()_+{}|:<>?äöü€'; + const hash = await hashPassword(password); + const result = await verifyPassword(password, hash); + expect(result).toBe(true); + }); + + test('handles long passwords', async () => { + const password = 'x'.repeat(1000); + const hash = await hashPassword(password); + const result = await verifyPassword(password, hash); + expect(result).toBe(true); + }); +}); diff --git a/tests/authorize-helpers.test.ts b/tests/authorize-helpers.test.ts new file mode 100644 index 0000000..34fdce8 --- /dev/null +++ b/tests/authorize-helpers.test.ts @@ -0,0 +1,47 @@ +/** + * Unit Tests for Authorization Helper Functions + * + * Tests the response helper functions from the authorize module. + */ +import { describe, test, expect } from 'bun:test'; +import { unauthorized, forbidden, enterpriseRequired } from '../src/lib/server/authorize'; + +describe('Authorization Helpers', () => { + describe('unauthorized', () => { + test('returns correct error object', () => { + const result = unauthorized(); + expect(result).toEqual({ + error: 'Authentication required', + status: 401 + }); + }); + }); + + describe('forbidden', () => { + test('returns default message', () => { + const result = forbidden(); + expect(result).toEqual({ + error: 'Permission denied', + status: 403 + }); + }); + + test('returns custom message', () => { + const result = forbidden('Custom reason'); + expect(result).toEqual({ + error: 'Custom reason', + status: 403 + }); + }); + }); + + describe('enterpriseRequired', () => { + test('returns enterprise required error', () => { + const result = enterpriseRequired(); + expect(result).toEqual({ + error: 'Enterprise license required', + status: 403 + }); + }); + }); +}); diff --git a/tests/encryption.test.ts b/tests/encryption.test.ts new file mode 100644 index 0000000..34c06a5 --- /dev/null +++ b/tests/encryption.test.ts @@ -0,0 +1,157 @@ +/** + * Unit Tests for Encryption Module + * + * Tests AES-256-GCM encryption/decryption, key generation, + * and backwards compatibility handling. + */ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { encrypt, decrypt, isEncrypted, generateKey, clearKeyCache } from '../src/lib/server/encryption'; + +describe('Encryption Module', () => { + beforeEach(() => { + // Reset key cache between tests to ensure isolation + clearKeyCache(); + }); + + describe('encrypt', () => { + test('returns null for null input', () => { + expect(encrypt(null)).toBeNull(); + }); + + test('passes through undefined input', () => { + expect(encrypt(undefined)).toBeUndefined(); + }); + + test('returns empty string for empty string input', () => { + expect(encrypt('')).toBe(''); + }); + + test('encrypts plaintext with enc:v1: prefix', () => { + const result = encrypt('my-secret-value'); + expect(result).not.toBeNull(); + expect(result!.startsWith('enc:v1:')).toBe(true); + }); + + test('produces different ciphertexts for same input (random IV)', () => { + const result1 = encrypt('same-text'); + const result2 = encrypt('same-text'); + expect(result1).not.toBeNull(); + expect(result2).not.toBeNull(); + // Different IVs should produce different ciphertexts + expect(result1).not.toBe(result2); + }); + + test('does not double-encrypt already encrypted values', () => { + const encrypted = encrypt('secret'); + expect(encrypted).not.toBeNull(); + const doubleEncrypted = encrypt(encrypted!); + // Should be the same - not re-encrypted + expect(doubleEncrypted).toBe(encrypted); + }); + }); + + describe('decrypt', () => { + test('returns null for null input', () => { + expect(decrypt(null)).toBeNull(); + }); + + test('passes through undefined input', () => { + expect(decrypt(undefined)).toBeUndefined(); + }); + + test('returns empty string for empty string input', () => { + expect(decrypt('')).toBe(''); + }); + + test('returns plaintext as-is (backwards compatibility)', () => { + expect(decrypt('plain-text-value')).toBe('plain-text-value'); + }); + + test('roundtrip: decrypt(encrypt(text)) returns original text', () => { + const original = 'my-secret-password-123!@#'; + const encrypted = encrypt(original); + expect(encrypted).not.toBeNull(); + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(original); + }); + + test('roundtrip works for unicode text', () => { + const original = 'Passwort: ä-ö-ü-ß-€-中文-🔑'; + const encrypted = encrypt(original); + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(original); + }); + + test('roundtrip works for long text', () => { + const original = 'x'.repeat(10000); + const encrypted = encrypt(original); + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(original); + }); + + test('returns original value for invalid encrypted payload', () => { + const badValue = 'enc:v1:not-valid-base64!!!'; + const result = decrypt(badValue); + // Should not crash, returns something (might be original or decrypted attempt) + expect(result).toBeDefined(); + }); + + test('returns original value for too-short payload', () => { + const shortPayload = 'enc:v1:' + Buffer.from('short').toString('base64'); + const result = decrypt(shortPayload); + expect(result).toBeDefined(); + }); + }); + + describe('isEncrypted', () => { + test('returns true for encrypted values', () => { + const encrypted = encrypt('test'); + expect(isEncrypted(encrypted)).toBe(true); + }); + + test('returns false for plain text', () => { + expect(isEncrypted('just-plain-text')).toBe(false); + }); + + test('returns false for null', () => { + expect(isEncrypted(null)).toBe(false); + }); + + test('returns false for undefined', () => { + expect(isEncrypted(undefined)).toBe(false); + }); + + test('returns false for empty string', () => { + expect(isEncrypted('')).toBe(false); + }); + + test('returns true for the exact prefix pattern', () => { + expect(isEncrypted('enc:v1:some-data')).toBe(true); + }); + }); + + describe('generateKey', () => { + test('returns a base64-encoded string', () => { + const key = generateKey(); + expect(typeof key).toBe('string'); + // Should be valid base64 + const decoded = Buffer.from(key, 'base64'); + expect(decoded.length).toBe(32); // 256 bits = 32 bytes + }); + + test('generates unique keys', () => { + const key1 = generateKey(); + const key2 = generateKey(); + expect(key1).not.toBe(key2); + }); + }); + + describe('clearKeyCache', () => { + test('clears cached key without error', () => { + // Ensure a key is cached by encrypting something + encrypt('trigger-key-creation'); + // Clearing should not throw + expect(() => clearKeyCache()).not.toThrow(); + }); + }); +}); diff --git a/tests/git-utils.test.ts b/tests/git-utils.test.ts new file mode 100644 index 0000000..a24c8ae --- /dev/null +++ b/tests/git-utils.test.ts @@ -0,0 +1,164 @@ +/** + * Unit Tests for Git Utility Functions + * + * Tests maskSecrets and parseEnvFileContent from the git module. + */ +import { describe, test, expect } from 'bun:test'; +import { maskSecrets, parseEnvFileContent } from '../src/lib/server/git'; + +// ============================================================================= +// maskSecrets +// ============================================================================= + +describe('maskSecrets', () => { + test('masks password keys', () => { + const result = maskSecrets({ PASSWORD: 'secret123', DB_PASSWORD: 'dbpass' }); + expect(result.PASSWORD).toBe('***'); + expect(result.DB_PASSWORD).toBe('***'); + }); + + test('masks token keys', () => { + const result = maskSecrets({ API_TOKEN: 'tok_abc', AUTH_TOKEN: 'xyz' }); + expect(result.API_TOKEN).toBe('***'); + expect(result.AUTH_TOKEN).toBe('***'); + }); + + test('masks secret keys', () => { + const result = maskSecrets({ CLIENT_SECRET: 'sec123', MY_SECRET: 'shh' }); + expect(result.CLIENT_SECRET).toBe('***'); + expect(result.MY_SECRET).toBe('***'); + }); + + test('masks api_key and apikey keys', () => { + const result = maskSecrets({ API_KEY: 'key123', APIKEY: 'key456' }); + expect(result.API_KEY).toBe('***'); + expect(result.APIKEY).toBe('***'); + }); + + test('masks auth keys', () => { + const result = maskSecrets({ AUTH_HEADER: 'Bearer xxx' }); + expect(result.AUTH_HEADER).toBe('***'); + }); + + test('masks credential keys', () => { + const result = maskSecrets({ CREDENTIAL: 'cred123' }); + expect(result.CREDENTIAL).toBe('***'); + }); + + test('masks private key references', () => { + const result = maskSecrets({ PRIVATE_KEY: 'key-data' }); + expect(result.PRIVATE_KEY).toBe('***'); + }); + + test('leaves normal keys unmasked', () => { + const result = maskSecrets({ + HOST: 'localhost', + PORT: '3000', + NODE_ENV: 'production' + }); + expect(result.HOST).toBe('localhost'); + expect(result.PORT).toBe('3000'); + expect(result.NODE_ENV).toBe('production'); + }); + + test('truncates long values (>50 chars)', () => { + const longValue = 'a'.repeat(60); + const result = maskSecrets({ DESCRIPTION: longValue }); + expect(result.DESCRIPTION).toContain('...(truncated)'); + expect(result.DESCRIPTION.length).toBeLessThan(longValue.length); + }); + + test('does not truncate values <= 50 chars', () => { + const shortValue = 'a'.repeat(50); + const result = maskSecrets({ DESCRIPTION: shortValue }); + expect(result.DESCRIPTION).toBe(shortValue); + }); + + test('handles empty object', () => { + const result = maskSecrets({}); + expect(Object.keys(result)).toHaveLength(0); + }); + + test('case insensitive matching', () => { + const result = maskSecrets({ password: 'lower', Password: 'mixed' }); + expect(result.password).toBe('***'); + expect(result.Password).toBe('***'); + }); +}); + +// ============================================================================= +// parseEnvFileContent +// ============================================================================= + +describe('parseEnvFileContent', () => { + test('parses simple KEY=value pairs', () => { + const content = 'HOST=localhost\nPORT=3000'; + const result = parseEnvFileContent(content); + expect(result.HOST).toBe('localhost'); + expect(result.PORT).toBe('3000'); + }); + + test('skips empty lines', () => { + const content = 'A=1\n\nB=2\n\n'; + const result = parseEnvFileContent(content); + expect(result.A).toBe('1'); + expect(result.B).toBe('2'); + expect(Object.keys(result)).toHaveLength(2); + }); + + test('skips comment lines', () => { + const content = '# This is a comment\nHOST=localhost\n# Another comment'; + const result = parseEnvFileContent(content); + expect(result.HOST).toBe('localhost'); + expect(Object.keys(result)).toHaveLength(1); + }); + + test('handles double-quoted values', () => { + const content = 'MSG="hello world"'; + const result = parseEnvFileContent(content); + expect(result.MSG).toBe('hello world'); + }); + + test('handles single-quoted values', () => { + const content = "MSG='hello world'"; + const result = parseEnvFileContent(content); + expect(result.MSG).toBe('hello world'); + }); + + test('handles values with equals signs', () => { + const content = 'CONNECTION=host=db;port=5432'; + const result = parseEnvFileContent(content); + expect(result.CONNECTION).toBe('host=db;port=5432'); + }); + + test('handles empty values', () => { + const content = 'EMPTY='; + const result = parseEnvFileContent(content); + expect(result.EMPTY).toBe(''); + }); + + test('trims whitespace around keys and values', () => { + const content = ' HOST = localhost '; + const result = parseEnvFileContent(content); + expect(result.HOST).toBe('localhost'); + }); + + test('skips lines without equals sign', () => { + const content = 'VALID=yes\ninvalid-line\nALSO_VALID=yes'; + const result = parseEnvFileContent(content); + expect(result.VALID).toBe('yes'); + expect(result.ALSO_VALID).toBe('yes'); + expect(Object.keys(result)).toHaveLength(2); + }); + + test('handles empty content', () => { + const result = parseEnvFileContent(''); + expect(Object.keys(result)).toHaveLength(0); + }); + + test('accepts optional stackName parameter', () => { + // Should not throw + const result = parseEnvFileContent('A=1', 'my-stack'); + expect(result.A).toBe('1'); + }); +}); diff --git a/tests/notifications-utils.test.ts b/tests/notifications-utils.test.ts new file mode 100644 index 0000000..b8950ba --- /dev/null +++ b/tests/notifications-utils.test.ts @@ -0,0 +1,49 @@ +/** + * Unit Tests for Notification Utility Functions + * + * Tests the escapeTelegramMarkdown function for correct character escaping. + */ +import { describe, test, expect } from 'bun:test'; +import { escapeTelegramMarkdown } from '../src/lib/server/notifications'; + +describe('escapeTelegramMarkdown', () => { + test('escapes backslashes', () => { + expect(escapeTelegramMarkdown('path\\to\\file')).toBe('path\\\\to\\\\file'); + }); + + test('escapes underscores', () => { + expect(escapeTelegramMarkdown('some_text_here')).toBe('some\\_text\\_here'); + }); + + test('escapes asterisks', () => { + expect(escapeTelegramMarkdown('**bold**')).toBe('\\*\\*bold\\*\\*'); + }); + + test('escapes square brackets', () => { + expect(escapeTelegramMarkdown('[link](url)')).toBe('\\[link\\](url)'); + }); + + test('escapes backticks', () => { + expect(escapeTelegramMarkdown('`code`')).toBe('\\`code\\`'); + }); + + test('leaves normal text unchanged', () => { + expect(escapeTelegramMarkdown('Hello World 123')).toBe('Hello World 123'); + }); + + test('handles empty string', () => { + expect(escapeTelegramMarkdown('')).toBe(''); + }); + + test('handles multiple special characters together', () => { + const input = 'Container *nginx_proxy* updated [v1.0]'; + const expected = 'Container \\*nginx\\_proxy\\* updated \\[v1.0\\]'; + expect(escapeTelegramMarkdown(input)).toBe(expected); + }); + + test('escapes all special characters in one pass', () => { + const input = '\\_*[]`'; + const expected = '\\\\\\_\\*\\[\\]\\`'; + expect(escapeTelegramMarkdown(input)).toBe(expected); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..cb5ccae --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,314 @@ +/** + * Unit Tests for Utility Functions + * + * Tests pure utility functions that require no mocking or external dependencies. + * Covers: version.ts, diff.ts, ip.ts + */ +import { describe, test, expect } from 'bun:test'; +import { compareVersions, shouldShowWhatsNew } from '../src/lib/utils/version'; +import { computeAuditDiff, formatFieldName } from '../src/lib/utils/diff'; +import { ipToNumber } from '../src/lib/utils/ip'; + +// ============================================================================= +// version.ts +// ============================================================================= + +describe('compareVersions', () => { + test('equal versions return 0', () => { + expect(compareVersions('1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('0.0.0', '0.0.0')).toBe(0); + expect(compareVersions('10.20.30', '10.20.30')).toBe(0); + }); + + test('greater version returns 1', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBe(1); + expect(compareVersions('1.1.0', '1.0.0')).toBe(1); + expect(compareVersions('1.0.1', '1.0.0')).toBe(1); + }); + + test('lesser version returns -1', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.1.0')).toBe(-1); + expect(compareVersions('1.0.0', '1.0.1')).toBe(-1); + }); + + test('handles v-prefix', () => { + expect(compareVersions('v1.0.0', '1.0.0')).toBe(0); + expect(compareVersions('v2.0.0', 'v1.0.0')).toBe(1); + expect(compareVersions('v1.0.0', 'v2.0.0')).toBe(-1); + }); + + test('handles different segment lengths', () => { + expect(compareVersions('1.0', '1.0.0')).toBe(0); + expect(compareVersions('1.0.0', '1.0')).toBe(0); + expect(compareVersions('1.0.1', '1.0')).toBe(1); + expect(compareVersions('1.0', '1.0.1')).toBe(-1); + }); + + test('handles multi-digit segments', () => { + expect(compareVersions('1.10.0', '1.9.0')).toBe(1); + expect(compareVersions('1.0.10', '1.0.9')).toBe(1); + }); +}); + +describe('shouldShowWhatsNew', () => { + test('returns false when currentVersion is null', () => { + expect(shouldShowWhatsNew(null, null)).toBe(false); + expect(shouldShowWhatsNew(null, '1.0.0')).toBe(false); + }); + + test('returns false when currentVersion is "unknown"', () => { + expect(shouldShowWhatsNew('unknown', null)).toBe(false); + expect(shouldShowWhatsNew('unknown', '1.0.0')).toBe(false); + }); + + test('returns true when lastSeenVersion is null (first visit)', () => { + expect(shouldShowWhatsNew('1.0.0', null)).toBe(true); + }); + + test('returns false when same version', () => { + expect(shouldShowWhatsNew('1.0.0', '1.0.0')).toBe(false); + }); + + test('returns true when current version is newer', () => { + expect(shouldShowWhatsNew('1.1.0', '1.0.0')).toBe(true); + expect(shouldShowWhatsNew('2.0.0', '1.9.9')).toBe(true); + }); + + test('returns false when current version is older', () => { + expect(shouldShowWhatsNew('1.0.0', '1.1.0')).toBe(false); + }); +}); + +// ============================================================================= +// diff.ts +// ============================================================================= + +describe('computeAuditDiff', () => { + test('returns null for null/undefined inputs', () => { + expect(computeAuditDiff(null, { a: 1 })).toBeNull(); + expect(computeAuditDiff({ a: 1 }, null)).toBeNull(); + expect(computeAuditDiff(undefined, { a: 1 })).toBeNull(); + expect(computeAuditDiff(null, null)).toBeNull(); + }); + + test('returns null for identical objects', () => { + expect(computeAuditDiff({ name: 'foo' }, { name: 'foo' })).toBeNull(); + expect(computeAuditDiff({ a: 1, b: 2 }, { a: 1, b: 2 })).toBeNull(); + }); + + test('detects changed fields', () => { + const result = computeAuditDiff({ name: 'old' }, { name: 'new' }); + expect(result).not.toBeNull(); + expect(result!.changes).toHaveLength(1); + expect(result!.changes[0]).toEqual({ field: 'name', oldValue: 'old', newValue: 'new' }); + }); + + test('detects added fields', () => { + const result = computeAuditDiff({}, { name: 'new' }); + expect(result).not.toBeNull(); + expect(result!.changes[0].field).toBe('name'); + expect(result!.changes[0].oldValue).toBeNull(); + expect(result!.changes[0].newValue).toBe('new'); + }); + + test('skips internal fields (id, createdAt, updatedAt)', () => { + const result = computeAuditDiff( + { id: 1, createdAt: 'old', updatedAt: 'old', name: 'same' }, + { id: 2, createdAt: 'new', updatedAt: 'new', name: 'same' } + ); + expect(result).toBeNull(); + }); + + test('masks sensitive fields (password)', () => { + const result = computeAuditDiff( + { password: 'old-secret' }, + { password: 'new-secret' } + ); + expect(result).not.toBeNull(); + expect(result!.changes[0]).toEqual({ + field: 'password', + oldValue: '••••••••', + newValue: '••••••••' + }); + }); + + test('masks sensitive field set to null', () => { + const result = computeAuditDiff( + { password: 'secret' }, + { password: null } + ); + expect(result).not.toBeNull(); + expect(result!.changes[0].oldValue).toBe('••••••••'); + expect(result!.changes[0].newValue).toBeNull(); + }); + + test('masks sensitive field set from null', () => { + const result = computeAuditDiff( + { password: null }, + { password: 'secret' } + ); + expect(result).not.toBeNull(); + expect(result!.changes[0].oldValue).toBeNull(); + expect(result!.changes[0].newValue).toBe('••••••••'); + }); + + test('skips fields in SENSITIVE_FIELDS that are not MASKED (like tlsCert, tlsCa)', () => { + const result = computeAuditDiff( + { tlsCert: 'old-cert' }, + { tlsCert: 'new-cert' } + ); + // tlsCert is in SENSITIVE_FIELDS but not in MASKED_FIELDS → skipped entirely + expect(result).toBeNull(); + }); + + test('respects includeFields option', () => { + const result = computeAuditDiff( + { name: 'old', host: 'old-host' }, + { name: 'new', host: 'new-host' }, + { includeFields: ['name'] } + ); + expect(result).not.toBeNull(); + expect(result!.changes).toHaveLength(1); + expect(result!.changes[0].field).toBe('name'); + }); + + test('respects excludeFields option', () => { + const result = computeAuditDiff( + { name: 'old', host: 'old-host' }, + { name: 'new', host: 'new-host' }, + { excludeFields: ['host'] } + ); + expect(result).not.toBeNull(); + expect(result!.changes).toHaveLength(1); + expect(result!.changes[0].field).toBe('name'); + }); + + test('skips undefined new values', () => { + const result = computeAuditDiff( + { name: 'old', host: 'old-host' }, + { name: 'new' } // host is undefined in new + ); + expect(result).not.toBeNull(); + expect(result!.changes).toHaveLength(1); + expect(result!.changes[0].field).toBe('name'); + }); + + test('handles deep equality for arrays', () => { + expect(computeAuditDiff( + { tags: ['a', 'b'] }, + { tags: ['a', 'b'] } + )).toBeNull(); + + const result = computeAuditDiff( + { tags: ['a', 'b'] }, + { tags: ['a', 'c'] } + ); + expect(result).not.toBeNull(); + }); + + test('handles deep equality for nested objects', () => { + expect(computeAuditDiff( + { config: { port: 80, host: 'localhost' } }, + { config: { port: 80, host: 'localhost' } } + )).toBeNull(); + + const result = computeAuditDiff( + { config: { port: 80 } }, + { config: { port: 443 } } + ); + expect(result).not.toBeNull(); + }); + + test('truncates long string values in diff output', () => { + const longString = 'x'.repeat(300); + const result = computeAuditDiff( + { data: 'short' }, + { data: longString } + ); + expect(result).not.toBeNull(); + expect(result!.changes[0].newValue.length).toBeLessThan(longString.length); + expect(result!.changes[0].newValue).toContain('...'); + }); + + test('summarizes large arrays', () => { + const largeArray = Array.from({ length: 15 }, (_, i) => `item-${i}`); + const result = computeAuditDiff( + { items: [] }, + { items: largeArray } + ); + expect(result).not.toBeNull(); + expect(result!.changes[0].newValue).toBe('[15 items]'); + }); + + test('summarizes objects with many properties', () => { + const largeObj: Record = {}; + for (let i = 0; i < 15; i++) largeObj[`key${i}`] = i; + const result = computeAuditDiff( + { config: {} }, + { config: largeObj } + ); + expect(result).not.toBeNull(); + expect(result!.changes[0].newValue).toBe('{15 properties}'); + }); +}); + +describe('formatFieldName', () => { + test('converts camelCase to Title Case', () => { + expect(formatFieldName('userName')).toBe('User Name'); + expect(formatFieldName('firstName')).toBe('First Name'); + }); + + test('handles special cases', () => { + expect(formatFieldName('tlsCa')).toBe('TLS CA'); + expect(formatFieldName('tlsCert')).toBe('TLS certificate'); + expect(formatFieldName('tlsKey')).toBe('TLS key'); + expect(formatFieldName('sshPrivateKey')).toBe('SSH private key'); + expect(formatFieldName('envVars')).toBe('Environment variables'); + expect(formatFieldName('ipAddress')).toBe('IP address'); + expect(formatFieldName('connectionType')).toBe('Connection type'); + expect(formatFieldName('socketPath')).toBe('Socket path'); + }); + + test('handles single-word fields', () => { + expect(formatFieldName('name')).toBe('Name'); + expect(formatFieldName('host')).toBe('Host'); + }); +}); + +// ============================================================================= +// ip.ts +// ============================================================================= + +describe('ipToNumber', () => { + test('converts standard IPv4 addresses', () => { + expect(ipToNumber('0.0.0.0')).toBe(0); + expect(ipToNumber('0.0.0.1')).toBe(1); + expect(ipToNumber('10.0.0.1')).toBe(167772161); + expect(ipToNumber('192.168.1.1')).toBe(3232235777); + expect(ipToNumber('255.255.255.255')).toBe(4294967295); + }); + + test('strips CIDR notation', () => { + expect(ipToNumber('192.168.1.0/24')).toBe(ipToNumber('192.168.1.0')); + expect(ipToNumber('10.0.0.0/8')).toBe(ipToNumber('10.0.0.0')); + }); + + test('returns Infinity for null/undefined/empty', () => { + expect(ipToNumber(null)).toBe(Infinity); + expect(ipToNumber(undefined)).toBe(Infinity); + expect(ipToNumber('-')).toBe(Infinity); + }); + + test('returns Infinity for invalid IPs', () => { + expect(ipToNumber('not-an-ip')).toBe(Infinity); + expect(ipToNumber('1.2.3')).toBe(Infinity); + expect(ipToNumber('1.2.3.4.5')).toBe(Infinity); + }); + + test('maintains sort order', () => { + expect(ipToNumber('10.0.0.1')).toBeLessThan(ipToNumber('10.0.0.2')); + expect(ipToNumber('10.0.0.255')).toBeLessThan(ipToNumber('10.0.1.0')); + expect(ipToNumber('192.168.0.1')).toBeGreaterThan(ipToNumber('10.0.0.1')); + }); +}); From 4164a218fc52882c9f8875710c910a41fc244b31 Mon Sep 17 00:00:00 2001 From: TimElschner Date: Thu, 5 Feb 2026 09:51:45 +0100 Subject: [PATCH 2/2] Fix api-smoke test: correct system endpoint path --- tests/api-smoke.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api-smoke.test.ts b/tests/api-smoke.test.ts index 8639bea..0c69c9d 100644 --- a/tests/api-smoke.test.ts +++ b/tests/api-smoke.test.ts @@ -28,8 +28,8 @@ describe('API Smoke Tests', () => { expect(status).toBe(200); }); - test('GET /api/system/version returns 200 with version info', async () => { - const { status, data } = await api('/api/system/version'); + test('GET /api/system returns 200 with system info', async () => { + const { status, data } = await api('/api/system'); expect(status).toBe(200); expect(data).toBeDefined(); });