diff --git a/setup/env-audit.test.ts b/setup/env-audit.test.ts new file mode 100644 index 0000000..289300f --- /dev/null +++ b/setup/env-audit.test.ts @@ -0,0 +1,271 @@ +/** + * setup/env-audit.test.ts — auditEnvFile unit tests. + * + * Tests defaults, missing keys, warnings, and feature-flag interactions. + * Uses real temp files (simpler than mocking fs for a function that + * reads env file content line-by-line). + * + * Run with: npx vitest run setup/env-audit.test.ts + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// Mock non-auditEnvFile deps (only used by run(), not by auditEnvFile) +// --------------------------------------------------------------------------- + +vi.mock('./status.js', () => ({ emitStatus: vi.fn() })); +vi.mock('./platform.js', () => ({ getPlatform: vi.fn().mockReturnValue('freebsd') })); + +import { auditEnvFile } from './env-audit.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let tmpDir: string; +let envFile: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-audit-test-')); + envFile = path.join(tmpDir, '.env'); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeEnv(content: string): void { + fs.writeFileSync(envFile, content, 'utf-8'); +} + +// --------------------------------------------------------------------------- +// Missing env file +// --------------------------------------------------------------------------- + +describe('auditEnvFile — no env file', () => { + it('uses defaults when file does not exist', () => { + const result = auditEnvFile(path.join(tmpDir, 'nonexistent.env')); + expect(result.values.AGENT_NAME).toBe('clawdie'); + expect(result.values.AGENT_SUBNET_BASE).toBe('10.0.0'); + }); + + it('ASSISTANT_NAME is in missing list when file is absent', () => { + const result = auditEnvFile(path.join(tmpDir, 'nonexistent.env')); + expect(result.missing).toContain('ASSISTANT_NAME'); + }); +}); + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +describe('auditEnvFile — defaults', () => { + it('uses "clawdie" as default AGENT_NAME', () => { + writeEnv(''); + const result = auditEnvFile(envFile); + expect(result.values.AGENT_NAME).toBe('clawdie'); + }); + + it('defaults AGENT_SUBNET_BASE to 10.0.0', () => { + writeEnv(''); + const result = auditEnvFile(envFile); + expect(result.values.AGENT_SUBNET_BASE).toBe('10.0.0'); + }); + + it('derives WARDEN_GATEWAY from subnet base', () => { + writeEnv('AGENT_SUBNET_BASE=192.168.1\n'); + const result = auditEnvFile(envFile); + expect(result.values.WARDEN_GATEWAY).toBe('192.168.1.1'); + }); + + it('defaults WARDEN_DB_IP to subnet.3', () => { + writeEnv('AGENT_SUBNET_BASE=10.1.2\n'); + const result = auditEnvFile(envFile); + expect(result.values.WARDEN_DB_IP).toBe('10.1.2.3'); + }); + + it('defaults CODE_HOSTING_MODE to "git"', () => { + writeEnv(''); + const result = auditEnvFile(envFile); + expect(result.values.CODE_HOSTING_MODE).toBe('git'); + }); + + it('defaults DB_RUNTIME to "jail"', () => { + writeEnv(''); + const result = auditEnvFile(envFile); + expect(result.values.DB_RUNTIME).toBe('jail'); + }); + + it('lowercases AGENT_NAME value', () => { + writeEnv('AGENT_NAME=MyBot\n'); + const result = auditEnvFile(envFile); + expect(result.values.AGENT_NAME).toBe('mybot'); + }); +}); + +// --------------------------------------------------------------------------- +// Missing key detection +// --------------------------------------------------------------------------- + +describe('auditEnvFile — missing keys', () => { + it('adds ASSISTANT_NAME to missing when absent', () => { + writeEnv('AGENT_NAME=clawdie\n'); + const result = auditEnvFile(envFile); + expect(result.missing).toContain('ASSISTANT_NAME'); + }); + + it('ASSISTANT_NAME is not missing when set', () => { + writeEnv('ASSISTANT_NAME=Clawdie\n'); + const result = auditEnvFile(envFile); + expect(result.missing).not.toContain('ASSISTANT_NAME'); + }); + + it('returns empty missing array for a complete minimal env', () => { + writeEnv('ASSISTANT_NAME=Clawdie\n'); + const result = auditEnvFile(envFile); + expect(result.missing).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Warnings +// --------------------------------------------------------------------------- + +describe('auditEnvFile — warnings', () => { + it('warns when DISPLAY_LOCALE is not set', () => { + writeEnv('ASSISTANT_NAME=Clawdie\n'); + const result = auditEnvFile(envFile); + expect(result.warnings.some((w) => w.includes('DISPLAY_LOCALE'))).toBe(true); + }); + + it('no DISPLAY_LOCALE warning when set', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nDISPLAY_LOCALE=en-GB\n'); + const result = auditEnvFile(envFile); + expect(result.warnings.some((w) => w.startsWith('DISPLAY_LOCALE') || w.includes('DISPLAY_LOCALE not set'))).toBe(false); + }); + + it('warns when FEATURE_TAILSCALE=YES but TAILSCALE_AUTHKEY not set', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nFEATURE_TAILSCALE=YES\n'); + const result = auditEnvFile(envFile); + expect(result.warnings.some((w) => w.includes('TAILSCALE_AUTHKEY'))).toBe(true); + }); + + it('no tailscale authkey warning when authkey is set', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nFEATURE_TAILSCALE=YES\nTAILSCALE_AUTHKEY=tskey-abc\n'); + const result = auditEnvFile(envFile); + expect(result.warnings.some((w) => w.includes('TAILSCALE_AUTHKEY'))).toBe(false); + }); + + it('warns when FEATURE_GITEA=YES but FEATURE_GIT is not enabled', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nFEATURE_GITEA=YES\n'); + const result = auditEnvFile(envFile); + expect(result.warnings.some((w) => w.includes('FEATURE_GITEA'))).toBe(true); + }); + + it('warns when LOCAL_LLM_PROVIDER=ollama but FEATURE_OLLAMA is not enabled', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nLOCAL_LLM_PROVIDER=ollama\n'); + const result = auditEnvFile(envFile); + expect(result.warnings.some((w) => w.includes('FEATURE_OLLAMA'))).toBe(true); + }); + + it('warns when LOCAL_LLM_PROVIDER=llama_cpp but FEATURE_LLAMA_CPP is not enabled', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nLOCAL_LLM_PROVIDER=llama_cpp\n'); + const result = auditEnvFile(envFile); + expect(result.warnings.some((w) => w.includes('FEATURE_LLAMA_CPP'))).toBe(true); + }); + + it('no LLM provider warning when provider=none', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nLOCAL_LLM_PROVIDER=none\n'); + const result = auditEnvFile(envFile); + expect(result.warnings.some((w) => w.includes('FEATURE_OLLAMA') || w.includes('FEATURE_LLAMA_CPP'))).toBe(false); + }); + + it('warns when SSH_PUBLIC_KEY not set', () => { + writeEnv('ASSISTANT_NAME=Clawdie\n'); + const result = auditEnvFile(envFile); + expect(result.warnings.some((w) => w.includes('SSH_PUBLIC_KEY'))).toBe(true); + }); + + it('no SSH warning when SSH_PUBLIC_KEY is set', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nSSH_PUBLIC_KEY=ssh-ed25519 AAAA...\n'); + const result = auditEnvFile(envFile); + expect(result.warnings.some((w) => w.includes('SSH_PUBLIC_KEY'))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Feature flags (isTruthyFlag) +// --------------------------------------------------------------------------- + +describe('auditEnvFile — feature flags', () => { + it('recognises YES as truthy', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nFEATURE_GIT=YES\n'); + const result = auditEnvFile(envFile); + expect(result.values.FEATURE_GIT).toBe('YES'); + }); + + it('recognises true as truthy', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nFEATURE_GIT=true\n'); + const result = auditEnvFile(envFile); + expect(result.values.FEATURE_GIT).toBe('YES'); + }); + + it('recognises 1 as truthy', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nFEATURE_GIT=1\n'); + const result = auditEnvFile(envFile); + expect(result.values.FEATURE_GIT).toBe('YES'); + }); + + it('treats NO as falsy for feature flags', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nFEATURE_GIT=NO\n'); + const result = auditEnvFile(envFile); + expect(result.values.FEATURE_GIT).toBe('NO'); + }); +}); + +// --------------------------------------------------------------------------- +// GIT_MIRROR_URLS parsing +// --------------------------------------------------------------------------- + +describe('auditEnvFile — GIT_MIRROR_URLS', () => { + it('parses comma-separated mirror URLs', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nGIT_MIRROR_URLS=https://a.com,https://b.com\n'); + const result = auditEnvFile(envFile); + expect(result.values.GIT_MIRROR_URLS).toContain('https://a.com'); + expect(result.values.GIT_MIRROR_URLS).toContain('https://b.com'); + }); + + it('shows (unset) when GIT_MIRROR_URLS is empty', () => { + writeEnv('ASSISTANT_NAME=Clawdie\n'); + const result = auditEnvFile(envFile); + expect(result.values.GIT_MIRROR_URLS).toBe('(unset)'); + }); +}); + +// --------------------------------------------------------------------------- +// TAILSCALE_AUTHKEY masking +// --------------------------------------------------------------------------- + +describe('auditEnvFile — value masking', () => { + it('shows "(set)" for TAILSCALE_AUTHKEY when present', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nTAILSCALE_AUTHKEY=tskey-secret\n'); + const result = auditEnvFile(envFile); + expect(result.values.TAILSCALE_AUTHKEY).toBe('(set)'); + }); + + it('shows "(unset)" for TAILSCALE_AUTHKEY when absent', () => { + writeEnv('ASSISTANT_NAME=Clawdie\n'); + const result = auditEnvFile(envFile); + expect(result.values.TAILSCALE_AUTHKEY).toBe('(unset)'); + }); + + it('shows "(set)" for SSH_PUBLIC_KEY when present', () => { + writeEnv('ASSISTANT_NAME=Clawdie\nSSH_PUBLIC_KEY=ssh-ed25519 AAAA...\n'); + const result = auditEnvFile(envFile); + expect(result.values.SSH_PUBLIC_KEY).toBe('(set)'); + }); +}); diff --git a/setup/freebsd-timezones.test.ts b/setup/freebsd-timezones.test.ts new file mode 100644 index 0000000..8e1d006 --- /dev/null +++ b/setup/freebsd-timezones.test.ts @@ -0,0 +1,162 @@ +/** + * setup/freebsd-timezones.test.ts — pure timezone helper tests. + * + * No mocks needed — all functions are pure. + * + * Run with: npx vitest run setup/freebsd-timezones.test.ts + */ +import { describe, it, expect } from 'vitest'; + +import { + getTimezoneOptions, + formatTimezoneLabel, + prioritizeTimezones, + findTimezoneOption, + isValidTimezone, +} from './freebsd-timezones.js'; + +// --------------------------------------------------------------------------- +// getTimezoneOptions +// --------------------------------------------------------------------------- + +describe('getTimezoneOptions', () => { + it('returns a non-empty array', () => { + expect(getTimezoneOptions().length).toBeGreaterThan(0); + }); + + it('includes Europe/Ljubljana', () => { + const opts = getTimezoneOptions(); + expect(opts.some((o) => o.timezone === 'Europe/Ljubljana')).toBe(true); + }); + + it('includes UTC', () => { + const opts = getTimezoneOptions(); + expect(opts.some((o) => o.timezone === 'UTC')).toBe(true); + }); + + it('every entry has timezone, label, and region fields', () => { + for (const opt of getTimezoneOptions()) { + expect(typeof opt.timezone).toBe('string'); + expect(typeof opt.label).toBe('string'); + expect(typeof opt.region).toBe('string'); + } + }); + + it('returns the same reference each call (stable array)', () => { + // Both calls should return equal content (not necessarily same reference) + expect(getTimezoneOptions()).toEqual(getTimezoneOptions()); + }); +}); + +// --------------------------------------------------------------------------- +// formatTimezoneLabel +// --------------------------------------------------------------------------- + +describe('formatTimezoneLabel', () => { + it('returns the label field of the option', () => { + const opt = { timezone: 'UTC', label: 'UTC (Coordinated Universal Time)', region: 'Etc' }; + expect(formatTimezoneLabel(opt)).toBe('UTC (Coordinated Universal Time)'); + }); +}); + +// --------------------------------------------------------------------------- +// prioritizeTimezones +// --------------------------------------------------------------------------- + +describe('prioritizeTimezones', () => { + const opts = getTimezoneOptions(); + + it('returns list unchanged when preferred is null', () => { + const result = prioritizeTimezones(opts, null); + expect(result).toEqual(opts); + }); + + it('returns list unchanged when preferred is undefined', () => { + const result = prioritizeTimezones(opts); + expect(result).toEqual(opts); + }); + + it('moves preferred timezone to first position', () => { + const result = prioritizeTimezones(opts, 'UTC'); + expect(result[0].timezone).toBe('UTC'); + }); + + it('keeps preferred timezone in list (no duplication)', () => { + const result = prioritizeTimezones(opts, 'UTC'); + const count = result.filter((o) => o.timezone === 'UTC').length; + expect(count).toBe(1); + }); + + it('preserves total length when preferred is found', () => { + const result = prioritizeTimezones(opts, 'America/New_York'); + expect(result).toHaveLength(opts.length); + }); + + it('preserves total length when preferred is not found', () => { + const result = prioritizeTimezones(opts, 'Unknown/Timezone'); + expect(result).toHaveLength(opts.length); + }); + + it('returns list unchanged when preferred is not in list', () => { + const result = prioritizeTimezones(opts, 'Unknown/Timezone'); + expect(result).toEqual(opts); + }); + + it('does not mutate the original array', () => { + const original = [...opts]; + prioritizeTimezones(opts, 'UTC'); + expect(opts).toEqual(original); + }); + + it('places Ljubljana first when preferred', () => { + const result = prioritizeTimezones(opts, 'Europe/Ljubljana'); + expect(result[0].timezone).toBe('Europe/Ljubljana'); + }); +}); + +// --------------------------------------------------------------------------- +// findTimezoneOption +// --------------------------------------------------------------------------- + +describe('findTimezoneOption', () => { + it('returns the option for a known timezone', () => { + const result = findTimezoneOption('UTC'); + expect(result).not.toBeUndefined(); + expect(result?.timezone).toBe('UTC'); + }); + + it('returns undefined for an unknown timezone', () => { + expect(findTimezoneOption('Mars/Olympus')).toBeUndefined(); + }); + + it('returns the correct label for America/New_York', () => { + const result = findTimezoneOption('America/New_York'); + expect(result?.label).toContain('New York'); + }); +}); + +// --------------------------------------------------------------------------- +// isValidTimezone +// --------------------------------------------------------------------------- + +describe('isValidTimezone', () => { + it('returns true for UTC', () => { + expect(isValidTimezone('UTC')).toBe(true); + }); + + it('returns true for Europe/Ljubljana', () => { + expect(isValidTimezone('Europe/Ljubljana')).toBe(true); + }); + + it('returns false for an unknown timezone string', () => { + expect(isValidTimezone('Fake/Zone')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isValidTimezone('')).toBe(false); + }); + + it('is case-sensitive (lowercase fails)', () => { + expect(isValidTimezone('utc')).toBe(false); + }); +});