Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: key rotation, encryption panel, automations (#136, #137, #134)' (#141) from feat/key-rotation-encryption-automations into main

scott e5f9c32f 4da707e6

+1105
+197
src/lib/encryption-panel.ts
··· 1 + /** 2 + * Verifiable Encryption Panel — transparency for E2EE operations. 3 + * 4 + * Pure logic module: encryption status, verification records, audit display. 5 + * Actual crypto operations handled by the crypto layer. 6 + */ 7 + 8 + export type EncryptionAlgorithm = 'AES-256-GCM' | 'AES-128-GCM' | 'ChaCha20-Poly1305'; 9 + 10 + export interface EncryptionStatus { 11 + /** Whether the document is currently encrypted */ 12 + encrypted: boolean; 13 + /** Algorithm in use */ 14 + algorithm: EncryptionAlgorithm; 15 + /** Key fingerprint (first 8 chars of SHA-256 of key) */ 16 + keyFingerprint: string; 17 + /** When encryption was last verified */ 18 + lastVerified: number | null; 19 + /** Whether key is in URL fragment (never sent to server) */ 20 + keyInFragment: boolean; 21 + } 22 + 23 + export interface VerificationRecord { 24 + id: string; 25 + timestamp: number; 26 + /** What was verified */ 27 + check: string; 28 + /** Passed or failed */ 29 + passed: boolean; 30 + /** Details */ 31 + details: string; 32 + } 33 + 34 + export interface EncryptionPanelState { 35 + status: EncryptionStatus; 36 + verifications: VerificationRecord[]; 37 + /** Show the panel */ 38 + visible: boolean; 39 + } 40 + 41 + let _verifyCounter = 0; 42 + 43 + /** 44 + * Create initial panel state. 45 + */ 46 + export function createPanelState( 47 + algorithm: EncryptionAlgorithm = 'AES-256-GCM', 48 + keyFingerprint = '', 49 + ): EncryptionPanelState { 50 + return { 51 + status: { 52 + encrypted: keyFingerprint.length > 0, 53 + algorithm, 54 + keyFingerprint, 55 + lastVerified: null, 56 + keyInFragment: true, 57 + }, 58 + verifications: [], 59 + visible: false, 60 + }; 61 + } 62 + 63 + /** 64 + * Toggle panel visibility. 65 + */ 66 + export function togglePanel(state: EncryptionPanelState): EncryptionPanelState { 67 + return { ...state, visible: !state.visible }; 68 + } 69 + 70 + /** 71 + * Record a verification check. 72 + */ 73 + export function addVerification( 74 + state: EncryptionPanelState, 75 + check: string, 76 + passed: boolean, 77 + details = '', 78 + ): EncryptionPanelState { 79 + const record: VerificationRecord = { 80 + id: `verify-${Date.now()}-${++_verifyCounter}`, 81 + timestamp: Date.now(), 82 + check, 83 + passed, 84 + details, 85 + }; 86 + return { 87 + ...state, 88 + status: { ...state.status, lastVerified: record.timestamp }, 89 + verifications: [record, ...state.verifications], 90 + }; 91 + } 92 + 93 + /** 94 + * Run all standard verification checks (returns check results). 95 + */ 96 + export function runVerificationChecks( 97 + keyFingerprint: string, 98 + urlContainsKey: boolean, 99 + serverCanSeeKey: boolean, 100 + dataIsEncrypted: boolean, 101 + ): Array<{ check: string; passed: boolean; details: string }> { 102 + return [ 103 + { 104 + check: 'Key present', 105 + passed: keyFingerprint.length > 0, 106 + details: keyFingerprint ? `Fingerprint: ${keyFingerprint}` : 'No key found', 107 + }, 108 + { 109 + check: 'Key in URL fragment', 110 + passed: urlContainsKey, 111 + details: urlContainsKey ? 'Key stays client-side (after #)' : 'Key not found in URL fragment', 112 + }, 113 + { 114 + check: 'Server cannot see key', 115 + passed: !serverCanSeeKey, 116 + details: serverCanSeeKey ? 'WARNING: Key may be visible to server' : 'Key never sent to server', 117 + }, 118 + { 119 + check: 'Data encrypted at rest', 120 + passed: dataIsEncrypted, 121 + details: dataIsEncrypted ? 'All data encrypted before transmission' : 'Unencrypted data detected', 122 + }, 123 + ]; 124 + } 125 + 126 + /** 127 + * Apply verification results to panel state. 128 + */ 129 + export function applyVerifications( 130 + state: EncryptionPanelState, 131 + results: Array<{ check: string; passed: boolean; details: string }>, 132 + ): EncryptionPanelState { 133 + let updated = state; 134 + for (const r of results) { 135 + updated = addVerification(updated, r.check, r.passed, r.details); 136 + } 137 + return updated; 138 + } 139 + 140 + /** 141 + * Get overall verification status. 142 + */ 143 + export function overallStatus(state: EncryptionPanelState): 'verified' | 'warning' | 'unverified' { 144 + if (state.verifications.length === 0) return 'unverified'; 145 + const recent = state.verifications.slice(0, 4); // Last round of checks 146 + if (recent.every(v => v.passed)) return 'verified'; 147 + return 'warning'; 148 + } 149 + 150 + /** 151 + * Count passed and failed checks. 152 + */ 153 + export function checkCounts( 154 + state: EncryptionPanelState, 155 + ): { passed: number; failed: number } { 156 + // Count from most recent verification round (last 4) 157 + const recent = state.verifications.slice(0, 4); 158 + return { 159 + passed: recent.filter(v => v.passed).length, 160 + failed: recent.filter(v => !v.passed).length, 161 + }; 162 + } 163 + 164 + /** 165 + * Format key fingerprint for display. 166 + */ 167 + export function formatFingerprint(fingerprint: string): string { 168 + if (!fingerprint) return 'None'; 169 + // Group into pairs separated by colons 170 + return fingerprint.match(/.{1,2}/g)?.join(':') ?? fingerprint; 171 + } 172 + 173 + /** 174 + * Check if verification is stale (older than threshold). 175 + */ 176 + export function isVerificationStale( 177 + state: EncryptionPanelState, 178 + maxAge: number, 179 + now = Date.now(), 180 + ): boolean { 181 + if (!state.status.lastVerified) return true; 182 + return now - state.status.lastVerified > maxAge; 183 + } 184 + 185 + /** 186 + * Get human-readable algorithm description. 187 + */ 188 + export function algorithmDescription(algorithm: EncryptionAlgorithm): string { 189 + switch (algorithm) { 190 + case 'AES-256-GCM': 191 + return '256-bit AES with Galois/Counter Mode (authenticated encryption)'; 192 + case 'AES-128-GCM': 193 + return '128-bit AES with Galois/Counter Mode (authenticated encryption)'; 194 + case 'ChaCha20-Poly1305': 195 + return 'ChaCha20 stream cipher with Poly1305 MAC (authenticated encryption)'; 196 + } 197 + }
+186
src/lib/key-rotation.ts
··· 1 + /** 2 + * Key Rotation — E2EE key lifecycle management. 3 + * 4 + * Pure logic module: key generation scheduling, rotation state, re-encryption tracking. 5 + * Actual Web Crypto API calls handled by the crypto layer. 6 + */ 7 + 8 + export interface KeyVersion { 9 + id: string; 10 + /** Key creation timestamp */ 11 + createdAt: number; 12 + /** When this key was retired (null if active) */ 13 + retiredAt: number | null; 14 + /** Number of documents encrypted with this key */ 15 + documentCount: number; 16 + /** Number of documents re-encrypted to newer key */ 17 + migratedCount: number; 18 + } 19 + 20 + export interface KeyRotationState { 21 + /** All key versions, newest first */ 22 + versions: KeyVersion[]; 23 + /** ID of the currently active key */ 24 + activeKeyId: string; 25 + /** Rotation interval in milliseconds (0 = manual only) */ 26 + rotationInterval: number; 27 + /** Whether auto-rotation is enabled */ 28 + autoRotate: boolean; 29 + } 30 + 31 + let _keyCounter = 0; 32 + 33 + /** 34 + * Create initial key rotation state with one active key. 35 + */ 36 + export function createKeyRotationState( 37 + rotationInterval = 0, 38 + autoRotate = false, 39 + ): KeyRotationState { 40 + const key: KeyVersion = { 41 + id: `key-${Date.now()}-${++_keyCounter}`, 42 + createdAt: Date.now(), 43 + retiredAt: null, 44 + documentCount: 0, 45 + migratedCount: 0, 46 + }; 47 + return { 48 + versions: [key], 49 + activeKeyId: key.id, 50 + rotationInterval, 51 + autoRotate, 52 + }; 53 + } 54 + 55 + /** 56 + * Rotate to a new key version. 57 + */ 58 + export function rotateKey(state: KeyRotationState): KeyRotationState { 59 + const now = Date.now(); 60 + const newKey: KeyVersion = { 61 + id: `key-${now}-${++_keyCounter}`, 62 + createdAt: now, 63 + retiredAt: null, 64 + documentCount: 0, 65 + migratedCount: 0, 66 + }; 67 + 68 + // Retire the current active key 69 + const versions = state.versions.map(v => 70 + v.id === state.activeKeyId ? { ...v, retiredAt: now } : v, 71 + ); 72 + 73 + return { 74 + ...state, 75 + versions: [newKey, ...versions], 76 + activeKeyId: newKey.id, 77 + }; 78 + } 79 + 80 + /** 81 + * Get the active key version. 82 + */ 83 + export function activeKey(state: KeyRotationState): KeyVersion { 84 + return state.versions.find(v => v.id === state.activeKeyId)!; 85 + } 86 + 87 + /** 88 + * Get all retired (non-active) key versions. 89 + */ 90 + export function retiredKeys(state: KeyRotationState): KeyVersion[] { 91 + return state.versions.filter(v => v.retiredAt !== null); 92 + } 93 + 94 + /** 95 + * Check if rotation is due. 96 + */ 97 + export function isRotationDue( 98 + state: KeyRotationState, 99 + now = Date.now(), 100 + ): boolean { 101 + if (!state.autoRotate || state.rotationInterval <= 0) return false; 102 + const active = activeKey(state); 103 + return now - active.createdAt >= state.rotationInterval; 104 + } 105 + 106 + /** 107 + * Record that a document was encrypted with the active key. 108 + */ 109 + export function recordEncryption( 110 + state: KeyRotationState, 111 + count = 1, 112 + ): KeyRotationState { 113 + const versions = state.versions.map(v => 114 + v.id === state.activeKeyId 115 + ? { ...v, documentCount: v.documentCount + count } 116 + : v, 117 + ); 118 + return { ...state, versions }; 119 + } 120 + 121 + /** 122 + * Record that documents were migrated from an old key to the new key. 123 + */ 124 + export function recordMigration( 125 + state: KeyRotationState, 126 + oldKeyId: string, 127 + count: number, 128 + ): KeyRotationState { 129 + const versions = state.versions.map(v => { 130 + if (v.id === oldKeyId) return { ...v, migratedCount: v.migratedCount + count }; 131 + if (v.id === state.activeKeyId) return { ...v, documentCount: v.documentCount + count }; 132 + return v; 133 + }); 134 + return { ...state, versions }; 135 + } 136 + 137 + /** 138 + * Get migration progress for a retired key (0-1). 139 + */ 140 + export function migrationProgress(key: KeyVersion): number { 141 + if (key.documentCount === 0) return 1; 142 + return Math.min(1, key.migratedCount / key.documentCount); 143 + } 144 + 145 + /** 146 + * Check if all documents have been migrated from old keys. 147 + */ 148 + export function isFullyMigrated(state: KeyRotationState): boolean { 149 + return retiredKeys(state).every(k => migrationProgress(k) >= 1); 150 + } 151 + 152 + /** 153 + * Get keys that still need migration. 154 + */ 155 + export function pendingMigrationKeys(state: KeyRotationState): KeyVersion[] { 156 + return retiredKeys(state).filter(k => migrationProgress(k) < 1); 157 + } 158 + 159 + /** 160 + * Total document count across all key versions. 161 + */ 162 + export function totalDocumentCount(state: KeyRotationState): number { 163 + return state.versions.reduce((sum, v) => sum + v.documentCount, 0); 164 + } 165 + 166 + /** 167 + * Set auto-rotation configuration. 168 + */ 169 + export function setAutoRotation( 170 + state: KeyRotationState, 171 + autoRotate: boolean, 172 + interval?: number, 173 + ): KeyRotationState { 174 + return { 175 + ...state, 176 + autoRotate, 177 + rotationInterval: interval ?? state.rotationInterval, 178 + }; 179 + } 180 + 181 + /** 182 + * Get key version count. 183 + */ 184 + export function keyVersionCount(state: KeyRotationState): number { 185 + return state.versions.length; 186 + }
+237
src/sheets/automations.ts
··· 1 + /** 2 + * Client-side Automations — when/then rules for sheet events. 3 + * 4 + * Pure logic module: rule definition, trigger matching, action specification. 5 + * Actual execution and DOM side-effects handled by the sheets UI layer. 6 + */ 7 + 8 + export type TriggerType = 9 + | 'cell_changed' 10 + | 'row_added' 11 + | 'row_deleted' 12 + | 'value_equals' 13 + | 'value_greater_than' 14 + | 'value_less_than' 15 + | 'value_contains' 16 + | 'value_is_empty' 17 + | 'column_changed'; 18 + 19 + export type ActionType = 20 + | 'set_cell' 21 + | 'set_color' 22 + | 'send_notification' 23 + | 'copy_value' 24 + | 'clear_cell' 25 + | 'add_row' 26 + | 'set_timestamp'; 27 + 28 + export interface AutomationTrigger { 29 + type: TriggerType; 30 + /** Column index (for column-specific triggers) */ 31 + column?: number; 32 + /** Row index (for row-specific triggers) */ 33 + row?: number; 34 + /** Comparison value */ 35 + value?: string; 36 + } 37 + 38 + export interface AutomationAction { 39 + type: ActionType; 40 + /** Target cell column */ 41 + targetColumn?: number; 42 + /** Target cell row (null = same row as trigger) */ 43 + targetRow?: number | null; 44 + /** Value to set */ 45 + value?: string; 46 + /** Color to apply */ 47 + color?: string; 48 + /** Notification message */ 49 + message?: string; 50 + /** Source column for copy */ 51 + sourceColumn?: number; 52 + } 53 + 54 + export interface AutomationRule { 55 + id: string; 56 + name: string; 57 + enabled: boolean; 58 + trigger: AutomationTrigger; 59 + actions: AutomationAction[]; 60 + /** Max times this rule can fire (0 = unlimited) */ 61 + maxExecutions: number; 62 + executionCount: number; 63 + createdAt: number; 64 + } 65 + 66 + export interface AutomationState { 67 + rules: AutomationRule[]; 68 + } 69 + 70 + let _ruleCounter = 0; 71 + 72 + /** 73 + * Create initial automation state. 74 + */ 75 + export function createAutomationState(): AutomationState { 76 + return { rules: [] }; 77 + } 78 + 79 + /** 80 + * Add an automation rule. 81 + */ 82 + export function addRule( 83 + state: AutomationState, 84 + name: string, 85 + trigger: AutomationTrigger, 86 + actions: AutomationAction[], 87 + maxExecutions = 0, 88 + ): AutomationState { 89 + const rule: AutomationRule = { 90 + id: `auto-${Date.now()}-${++_ruleCounter}`, 91 + name, 92 + enabled: true, 93 + trigger, 94 + actions, 95 + maxExecutions, 96 + executionCount: 0, 97 + createdAt: Date.now(), 98 + }; 99 + return { rules: [...state.rules, rule] }; 100 + } 101 + 102 + /** 103 + * Remove a rule. 104 + */ 105 + export function removeRule(state: AutomationState, ruleId: string): AutomationState { 106 + return { rules: state.rules.filter(r => r.id !== ruleId) }; 107 + } 108 + 109 + /** 110 + * Enable or disable a rule. 111 + */ 112 + export function toggleRule(state: AutomationState, ruleId: string): AutomationState { 113 + return { 114 + rules: state.rules.map(r => 115 + r.id === ruleId ? { ...r, enabled: !r.enabled } : r, 116 + ), 117 + }; 118 + } 119 + 120 + /** 121 + * Check if a trigger matches a cell change event. 122 + */ 123 + export function matchesTrigger( 124 + trigger: AutomationTrigger, 125 + event: { column: number; row: number; oldValue: unknown; newValue: unknown }, 126 + ): boolean { 127 + switch (trigger.type) { 128 + case 'cell_changed': 129 + if (trigger.column !== undefined && trigger.column !== event.column) return false; 130 + if (trigger.row !== undefined && trigger.row !== event.row) return false; 131 + return true; 132 + 133 + case 'column_changed': 134 + return trigger.column === event.column; 135 + 136 + case 'row_added': 137 + case 'row_deleted': 138 + return true; // These are event-type triggers, matched externally 139 + 140 + case 'value_equals': 141 + return String(event.newValue) === trigger.value; 142 + 143 + case 'value_greater_than': { 144 + const num = Number(event.newValue); 145 + return !isNaN(num) && num > Number(trigger.value); 146 + } 147 + 148 + case 'value_less_than': { 149 + const num = Number(event.newValue); 150 + return !isNaN(num) && num < Number(trigger.value); 151 + } 152 + 153 + case 'value_contains': 154 + return String(event.newValue).toLowerCase().includes((trigger.value ?? '').toLowerCase()); 155 + 156 + case 'value_is_empty': 157 + return event.newValue === '' || event.newValue === null || event.newValue === undefined; 158 + 159 + default: 160 + return false; 161 + } 162 + } 163 + 164 + /** 165 + * Find all rules that match a cell change event. 166 + */ 167 + export function findMatchingRules( 168 + state: AutomationState, 169 + event: { column: number; row: number; oldValue: unknown; newValue: unknown }, 170 + ): AutomationRule[] { 171 + return state.rules.filter(r => { 172 + if (!r.enabled) return false; 173 + if (r.maxExecutions > 0 && r.executionCount >= r.maxExecutions) return false; 174 + return matchesTrigger(r.trigger, event); 175 + }); 176 + } 177 + 178 + /** 179 + * Record that a rule was executed. 180 + */ 181 + export function recordExecution( 182 + state: AutomationState, 183 + ruleId: string, 184 + ): AutomationState { 185 + return { 186 + rules: state.rules.map(r => 187 + r.id === ruleId ? { ...r, executionCount: r.executionCount + 1 } : r, 188 + ), 189 + }; 190 + } 191 + 192 + /** 193 + * Reset execution count for a rule. 194 + */ 195 + export function resetExecutionCount( 196 + state: AutomationState, 197 + ruleId: string, 198 + ): AutomationState { 199 + return { 200 + rules: state.rules.map(r => 201 + r.id === ruleId ? { ...r, executionCount: 0 } : r, 202 + ), 203 + }; 204 + } 205 + 206 + /** 207 + * Get enabled rule count. 208 + */ 209 + export function enabledRuleCount(state: AutomationState): number { 210 + return state.rules.filter(r => r.enabled).length; 211 + } 212 + 213 + /** 214 + * Get total rule count. 215 + */ 216 + export function totalRuleCount(state: AutomationState): number { 217 + return state.rules.length; 218 + } 219 + 220 + /** 221 + * Duplicate a rule. 222 + */ 223 + export function duplicateRule( 224 + state: AutomationState, 225 + ruleId: string, 226 + ): AutomationState { 227 + const rule = state.rules.find(r => r.id === ruleId); 228 + if (!rule) return state; 229 + const copy: AutomationRule = { 230 + ...rule, 231 + id: `auto-${Date.now()}-${++_ruleCounter}`, 232 + name: `${rule.name} (Copy)`, 233 + executionCount: 0, 234 + createdAt: Date.now(), 235 + }; 236 + return { rules: [...state.rules, copy] }; 237 + }
+159
tests/automations.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createAutomationState, 4 + addRule, 5 + removeRule, 6 + toggleRule, 7 + matchesTrigger, 8 + findMatchingRules, 9 + recordExecution, 10 + resetExecutionCount, 11 + enabledRuleCount, 12 + totalRuleCount, 13 + duplicateRule, 14 + } from '../src/sheets/automations.js'; 15 + 16 + describe('createAutomationState', () => { 17 + it('creates empty state', () => { 18 + const state = createAutomationState(); 19 + expect(state.rules).toEqual([]); 20 + }); 21 + }); 22 + 23 + describe('addRule / removeRule', () => { 24 + it('adds and removes rules', () => { 25 + let state = createAutomationState(); 26 + state = addRule(state, 'Highlight', { type: 'value_greater_than', column: 1, value: '100' }, [{ type: 'set_color', color: '#ff0000' }]); 27 + expect(state.rules).toHaveLength(1); 28 + expect(state.rules[0].name).toBe('Highlight'); 29 + state = removeRule(state, state.rules[0].id); 30 + expect(state.rules).toHaveLength(0); 31 + }); 32 + }); 33 + 34 + describe('toggleRule', () => { 35 + it('toggles enabled state', () => { 36 + let state = createAutomationState(); 37 + state = addRule(state, 'R1', { type: 'cell_changed' }, [{ type: 'set_timestamp' }]); 38 + const id = state.rules[0].id; 39 + expect(state.rules[0].enabled).toBe(true); 40 + state = toggleRule(state, id); 41 + expect(state.rules[0].enabled).toBe(false); 42 + state = toggleRule(state, id); 43 + expect(state.rules[0].enabled).toBe(true); 44 + }); 45 + }); 46 + 47 + describe('matchesTrigger', () => { 48 + const event = { column: 1, row: 3, oldValue: '', newValue: '150' }; 49 + 50 + it('matches cell_changed', () => { 51 + expect(matchesTrigger({ type: 'cell_changed' }, event)).toBe(true); 52 + }); 53 + 54 + it('matches cell_changed with column filter', () => { 55 + expect(matchesTrigger({ type: 'cell_changed', column: 1 }, event)).toBe(true); 56 + expect(matchesTrigger({ type: 'cell_changed', column: 2 }, event)).toBe(false); 57 + }); 58 + 59 + it('matches column_changed', () => { 60 + expect(matchesTrigger({ type: 'column_changed', column: 1 }, event)).toBe(true); 61 + expect(matchesTrigger({ type: 'column_changed', column: 0 }, event)).toBe(false); 62 + }); 63 + 64 + it('matches value_equals', () => { 65 + expect(matchesTrigger({ type: 'value_equals', value: '150' }, event)).toBe(true); 66 + expect(matchesTrigger({ type: 'value_equals', value: '200' }, event)).toBe(false); 67 + }); 68 + 69 + it('matches value_greater_than', () => { 70 + expect(matchesTrigger({ type: 'value_greater_than', value: '100' }, event)).toBe(true); 71 + expect(matchesTrigger({ type: 'value_greater_than', value: '200' }, event)).toBe(false); 72 + }); 73 + 74 + it('matches value_less_than', () => { 75 + expect(matchesTrigger({ type: 'value_less_than', value: '200' }, event)).toBe(true); 76 + }); 77 + 78 + it('matches value_contains', () => { 79 + expect(matchesTrigger({ type: 'value_contains', value: '15' }, event)).toBe(true); 80 + expect(matchesTrigger({ type: 'value_contains', value: 'abc' }, event)).toBe(false); 81 + }); 82 + 83 + it('matches value_is_empty', () => { 84 + expect(matchesTrigger({ type: 'value_is_empty' }, { ...event, newValue: '' })).toBe(true); 85 + expect(matchesTrigger({ type: 'value_is_empty' }, event)).toBe(false); 86 + }); 87 + }); 88 + 89 + describe('findMatchingRules', () => { 90 + it('finds enabled matching rules', () => { 91 + let state = createAutomationState(); 92 + state = addRule(state, 'R1', { type: 'value_greater_than', value: '100' }, [{ type: 'set_color' }]); 93 + state = addRule(state, 'R2', { type: 'value_equals', value: 'xyz' }, [{ type: 'set_cell' }]); 94 + const matches = findMatchingRules(state, { column: 0, row: 1, oldValue: '', newValue: '150' }); 95 + expect(matches).toHaveLength(1); 96 + expect(matches[0].name).toBe('R1'); 97 + }); 98 + 99 + it('excludes disabled rules', () => { 100 + let state = createAutomationState(); 101 + state = addRule(state, 'R1', { type: 'cell_changed' }, [{ type: 'set_timestamp' }]); 102 + state = toggleRule(state, state.rules[0].id); 103 + expect(findMatchingRules(state, { column: 0, row: 1, oldValue: '', newValue: 'x' })).toHaveLength(0); 104 + }); 105 + 106 + it('excludes rules at max executions', () => { 107 + let state = createAutomationState(); 108 + state = addRule(state, 'R1', { type: 'cell_changed' }, [{ type: 'set_timestamp' }], 1); 109 + state = recordExecution(state, state.rules[0].id); 110 + expect(findMatchingRules(state, { column: 0, row: 1, oldValue: '', newValue: 'x' })).toHaveLength(0); 111 + }); 112 + }); 113 + 114 + describe('recordExecution / resetExecutionCount', () => { 115 + it('increments execution count', () => { 116 + let state = createAutomationState(); 117 + state = addRule(state, 'R1', { type: 'cell_changed' }, []); 118 + state = recordExecution(state, state.rules[0].id); 119 + state = recordExecution(state, state.rules[0].id); 120 + expect(state.rules[0].executionCount).toBe(2); 121 + }); 122 + 123 + it('resets count', () => { 124 + let state = createAutomationState(); 125 + state = addRule(state, 'R1', { type: 'cell_changed' }, []); 126 + state = recordExecution(state, state.rules[0].id); 127 + state = resetExecutionCount(state, state.rules[0].id); 128 + expect(state.rules[0].executionCount).toBe(0); 129 + }); 130 + }); 131 + 132 + describe('enabledRuleCount / totalRuleCount', () => { 133 + it('counts rules', () => { 134 + let state = createAutomationState(); 135 + state = addRule(state, 'R1', { type: 'cell_changed' }, []); 136 + state = addRule(state, 'R2', { type: 'cell_changed' }, []); 137 + state = toggleRule(state, state.rules[0].id); 138 + expect(totalRuleCount(state)).toBe(2); 139 + expect(enabledRuleCount(state)).toBe(1); 140 + }); 141 + }); 142 + 143 + describe('duplicateRule', () => { 144 + it('duplicates with new ID and reset count', () => { 145 + let state = createAutomationState(); 146 + state = addRule(state, 'R1', { type: 'cell_changed' }, [{ type: 'set_timestamp' }]); 147 + state = recordExecution(state, state.rules[0].id); 148 + state = duplicateRule(state, state.rules[0].id); 149 + expect(state.rules).toHaveLength(2); 150 + expect(state.rules[1].name).toBe('R1 (Copy)'); 151 + expect(state.rules[1].executionCount).toBe(0); 152 + expect(state.rules[1].id).not.toBe(state.rules[0].id); 153 + }); 154 + 155 + it('returns same state for unknown ID', () => { 156 + const state = createAutomationState(); 157 + expect(duplicateRule(state, 'unknown')).toBe(state); 158 + }); 159 + });
+152
tests/encryption-panel.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createPanelState, 4 + togglePanel, 5 + addVerification, 6 + runVerificationChecks, 7 + applyVerifications, 8 + overallStatus, 9 + checkCounts, 10 + formatFingerprint, 11 + isVerificationStale, 12 + algorithmDescription, 13 + } from '../src/lib/encryption-panel.js'; 14 + 15 + describe('createPanelState', () => { 16 + it('creates state with fingerprint', () => { 17 + const state = createPanelState('AES-256-GCM', 'abcd1234'); 18 + expect(state.status.encrypted).toBe(true); 19 + expect(state.status.algorithm).toBe('AES-256-GCM'); 20 + expect(state.status.keyFingerprint).toBe('abcd1234'); 21 + expect(state.visible).toBe(false); 22 + }); 23 + 24 + it('marks as unencrypted with no fingerprint', () => { 25 + const state = createPanelState(); 26 + expect(state.status.encrypted).toBe(false); 27 + }); 28 + }); 29 + 30 + describe('togglePanel', () => { 31 + it('toggles visibility', () => { 32 + let state = createPanelState(); 33 + state = togglePanel(state); 34 + expect(state.visible).toBe(true); 35 + state = togglePanel(state); 36 + expect(state.visible).toBe(false); 37 + }); 38 + }); 39 + 40 + describe('addVerification', () => { 41 + it('adds verification record', () => { 42 + let state = createPanelState('AES-256-GCM', 'abc'); 43 + state = addVerification(state, 'Key present', true, 'Found'); 44 + expect(state.verifications).toHaveLength(1); 45 + expect(state.verifications[0].passed).toBe(true); 46 + expect(state.status.lastVerified).not.toBeNull(); 47 + }); 48 + 49 + it('prepends newest first', () => { 50 + let state = createPanelState('AES-256-GCM', 'abc'); 51 + state = addVerification(state, 'Check A', true); 52 + state = addVerification(state, 'Check B', false); 53 + expect(state.verifications[0].check).toBe('Check B'); 54 + }); 55 + }); 56 + 57 + describe('runVerificationChecks', () => { 58 + it('returns all-pass for valid setup', () => { 59 + const results = runVerificationChecks('abcd1234', true, false, true); 60 + expect(results).toHaveLength(4); 61 + expect(results.every(r => r.passed)).toBe(true); 62 + }); 63 + 64 + it('fails when server can see key', () => { 65 + const results = runVerificationChecks('abcd', true, true, true); 66 + const serverCheck = results.find(r => r.check === 'Server cannot see key')!; 67 + expect(serverCheck.passed).toBe(false); 68 + }); 69 + 70 + it('fails when no key', () => { 71 + const results = runVerificationChecks('', false, false, true); 72 + expect(results[0].passed).toBe(false); 73 + }); 74 + }); 75 + 76 + describe('applyVerifications', () => { 77 + it('applies all results to state', () => { 78 + let state = createPanelState('AES-256-GCM', 'abc'); 79 + const results = runVerificationChecks('abc', true, false, true); 80 + state = applyVerifications(state, results); 81 + expect(state.verifications).toHaveLength(4); 82 + }); 83 + }); 84 + 85 + describe('overallStatus', () => { 86 + it('returns unverified with no checks', () => { 87 + expect(overallStatus(createPanelState())).toBe('unverified'); 88 + }); 89 + 90 + it('returns verified when all pass', () => { 91 + let state = createPanelState('AES-256-GCM', 'abc'); 92 + const results = runVerificationChecks('abc', true, false, true); 93 + state = applyVerifications(state, results); 94 + expect(overallStatus(state)).toBe('verified'); 95 + }); 96 + 97 + it('returns warning when any fails', () => { 98 + let state = createPanelState('AES-256-GCM', 'abc'); 99 + state = addVerification(state, 'Check', true); 100 + state = addVerification(state, 'Failed', false); 101 + expect(overallStatus(state)).toBe('warning'); 102 + }); 103 + }); 104 + 105 + describe('checkCounts', () => { 106 + it('counts passed and failed', () => { 107 + let state = createPanelState('AES-256-GCM', 'abc'); 108 + const results = runVerificationChecks('abc', true, false, true); 109 + state = applyVerifications(state, results); 110 + const counts = checkCounts(state); 111 + expect(counts.passed).toBe(4); 112 + expect(counts.failed).toBe(0); 113 + }); 114 + }); 115 + 116 + describe('formatFingerprint', () => { 117 + it('formats with colons', () => { 118 + expect(formatFingerprint('abcd1234')).toBe('ab:cd:12:34'); 119 + }); 120 + 121 + it('returns None for empty', () => { 122 + expect(formatFingerprint('')).toBe('None'); 123 + }); 124 + }); 125 + 126 + describe('isVerificationStale', () => { 127 + it('returns true when never verified', () => { 128 + expect(isVerificationStale(createPanelState(), 3600000)).toBe(true); 129 + }); 130 + 131 + it('returns false for recent verification', () => { 132 + let state = createPanelState('AES-256-GCM', 'abc'); 133 + state = addVerification(state, 'Check', true); 134 + expect(isVerificationStale(state, 3600000)).toBe(false); 135 + }); 136 + 137 + it('returns true for old verification', () => { 138 + let state = createPanelState('AES-256-GCM', 'abc'); 139 + state = addVerification(state, 'Check', true); 140 + expect(isVerificationStale(state, 1000, Date.now() + 5000)).toBe(true); 141 + }); 142 + }); 143 + 144 + describe('algorithmDescription', () => { 145 + it('describes AES-256-GCM', () => { 146 + expect(algorithmDescription('AES-256-GCM')).toContain('256-bit'); 147 + }); 148 + 149 + it('describes ChaCha20', () => { 150 + expect(algorithmDescription('ChaCha20-Poly1305')).toContain('ChaCha20'); 151 + }); 152 + });
+174
tests/key-rotation.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createKeyRotationState, 4 + rotateKey, 5 + activeKey, 6 + retiredKeys, 7 + isRotationDue, 8 + recordEncryption, 9 + recordMigration, 10 + migrationProgress, 11 + isFullyMigrated, 12 + pendingMigrationKeys, 13 + totalDocumentCount, 14 + setAutoRotation, 15 + keyVersionCount, 16 + } from '../src/lib/key-rotation.js'; 17 + 18 + describe('createKeyRotationState', () => { 19 + it('creates state with one active key', () => { 20 + const state = createKeyRotationState(); 21 + expect(state.versions).toHaveLength(1); 22 + expect(state.activeKeyId).toBe(state.versions[0].id); 23 + expect(state.autoRotate).toBe(false); 24 + }); 25 + 26 + it('accepts config', () => { 27 + const state = createKeyRotationState(86400000, true); 28 + expect(state.rotationInterval).toBe(86400000); 29 + expect(state.autoRotate).toBe(true); 30 + }); 31 + }); 32 + 33 + describe('rotateKey', () => { 34 + it('creates new active key and retires old', () => { 35 + let state = createKeyRotationState(); 36 + const oldId = state.activeKeyId; 37 + state = rotateKey(state); 38 + expect(state.versions).toHaveLength(2); 39 + expect(state.activeKeyId).not.toBe(oldId); 40 + expect(activeKey(state).retiredAt).toBeNull(); 41 + expect(state.versions.find(v => v.id === oldId)!.retiredAt).not.toBeNull(); 42 + }); 43 + 44 + it('supports multiple rotations', () => { 45 + let state = createKeyRotationState(); 46 + state = rotateKey(state); 47 + state = rotateKey(state); 48 + expect(keyVersionCount(state)).toBe(3); 49 + expect(retiredKeys(state)).toHaveLength(2); 50 + }); 51 + }); 52 + 53 + describe('activeKey', () => { 54 + it('returns the current active key', () => { 55 + const state = createKeyRotationState(); 56 + const key = activeKey(state); 57 + expect(key.id).toBe(state.activeKeyId); 58 + expect(key.retiredAt).toBeNull(); 59 + }); 60 + }); 61 + 62 + describe('retiredKeys', () => { 63 + it('returns retired keys', () => { 64 + let state = createKeyRotationState(); 65 + expect(retiredKeys(state)).toHaveLength(0); 66 + state = rotateKey(state); 67 + expect(retiredKeys(state)).toHaveLength(1); 68 + }); 69 + }); 70 + 71 + describe('isRotationDue', () => { 72 + it('returns false when auto-rotate disabled', () => { 73 + const state = createKeyRotationState(1000, false); 74 + expect(isRotationDue(state, Date.now() + 5000)).toBe(false); 75 + }); 76 + 77 + it('returns true when interval exceeded', () => { 78 + const state = createKeyRotationState(1000, true); 79 + expect(isRotationDue(state, Date.now() + 5000)).toBe(true); 80 + }); 81 + 82 + it('returns false when interval not exceeded', () => { 83 + const state = createKeyRotationState(60000, true); 84 + expect(isRotationDue(state, Date.now() + 100)).toBe(false); 85 + }); 86 + }); 87 + 88 + describe('recordEncryption', () => { 89 + it('increments document count on active key', () => { 90 + let state = createKeyRotationState(); 91 + state = recordEncryption(state, 5); 92 + expect(activeKey(state).documentCount).toBe(5); 93 + }); 94 + }); 95 + 96 + describe('recordMigration', () => { 97 + it('tracks migration from old to new key', () => { 98 + let state = createKeyRotationState(); 99 + state = recordEncryption(state, 10); 100 + const oldId = state.activeKeyId; 101 + state = rotateKey(state); 102 + state = recordMigration(state, oldId, 3); 103 + const oldKey = state.versions.find(v => v.id === oldId)!; 104 + expect(oldKey.migratedCount).toBe(3); 105 + expect(activeKey(state).documentCount).toBe(3); 106 + }); 107 + }); 108 + 109 + describe('migrationProgress', () => { 110 + it('returns 0 for no migration', () => { 111 + let state = createKeyRotationState(); 112 + state = recordEncryption(state, 10); 113 + const oldId = state.activeKeyId; 114 + state = rotateKey(state); 115 + const oldKey = state.versions.find(v => v.id === oldId)!; 116 + expect(migrationProgress(oldKey)).toBe(0); 117 + }); 118 + 119 + it('returns 1 for fully migrated', () => { 120 + let state = createKeyRotationState(); 121 + state = recordEncryption(state, 10); 122 + const oldId = state.activeKeyId; 123 + state = rotateKey(state); 124 + state = recordMigration(state, oldId, 10); 125 + const oldKey = state.versions.find(v => v.id === oldId)!; 126 + expect(migrationProgress(oldKey)).toBe(1); 127 + }); 128 + 129 + it('returns 1 for key with no documents', () => { 130 + const state = createKeyRotationState(); 131 + expect(migrationProgress(activeKey(state))).toBe(1); 132 + }); 133 + }); 134 + 135 + describe('isFullyMigrated', () => { 136 + it('returns true with no retired keys', () => { 137 + expect(isFullyMigrated(createKeyRotationState())).toBe(true); 138 + }); 139 + 140 + it('returns false with pending migration', () => { 141 + let state = createKeyRotationState(); 142 + state = recordEncryption(state, 5); 143 + state = rotateKey(state); 144 + expect(isFullyMigrated(state)).toBe(false); 145 + }); 146 + }); 147 + 148 + describe('pendingMigrationKeys', () => { 149 + it('returns keys needing migration', () => { 150 + let state = createKeyRotationState(); 151 + state = recordEncryption(state, 5); 152 + state = rotateKey(state); 153 + expect(pendingMigrationKeys(state)).toHaveLength(1); 154 + }); 155 + }); 156 + 157 + describe('totalDocumentCount', () => { 158 + it('sums across all keys', () => { 159 + let state = createKeyRotationState(); 160 + state = recordEncryption(state, 10); 161 + state = rotateKey(state); 162 + state = recordEncryption(state, 5); 163 + expect(totalDocumentCount(state)).toBe(15); 164 + }); 165 + }); 166 + 167 + describe('setAutoRotation', () => { 168 + it('enables auto-rotation', () => { 169 + let state = createKeyRotationState(); 170 + state = setAutoRotation(state, true, 86400000); 171 + expect(state.autoRotate).toBe(true); 172 + expect(state.rotationInterval).toBe(86400000); 173 + }); 174 + });