• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22667312941

04 Mar 2026 11:25AM UTC coverage: 87.864% (+0.07%) from 87.794%
22667312941

push

github

web-flow
refactor: modularize events.js and add missing tests (#240)

5819 of 7025 branches covered (82.83%)

Branch coverage included in aggregate %.

258 of 322 new or added lines in 7 files covered. (80.12%)

42 existing lines in 5 files now uncovered.

9979 of 10955 relevant lines covered (91.09%)

236.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.45
/src/modules/backup.js
1
/**
2
 * Backup Module
3
 * Handles server configuration export, import, scheduled backups, and backup history.
4
 *
5
 * @see https://github.com/VolvoxLLC/volvox-bot/issues/129
6
 */
7

8
import {
9
  access,
10
  constants,
11
  mkdir,
12
  readdir,
13
  readFile,
14
  stat,
15
  unlink,
16
  writeFile,
17
} from 'node:fs/promises';
18
import path from 'node:path';
19
import { fileURLToPath } from 'node:url';
20
import { SAFE_CONFIG_KEYS, SENSITIVE_FIELDS } from '../api/utils/configAllowlist.js';
21
import { info, error as logError, warn } from '../logger.js';
22
import { flattenToLeafPaths } from '../utils/flattenToLeafPaths.js';
23
import { getConfig, setConfigValue } from './config.js';
24

25
const __dirname = path.dirname(fileURLToPath(import.meta.url));
55✔
26

27
/** Default backup directory (data/backups relative to project root) */
28
const DEFAULT_BACKUP_DIR = path.join(__dirname, '..', '..', 'data', 'backups');
55✔
29

30
/** Backup file naming pattern */
31
const BACKUP_FILENAME_PATTERN = /^backup-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3})-\d{4}\.json$/;
55✔
32

33
/** Default retention: keep last 7 daily + 4 weekly backups */
34
const DEFAULT_RETENTION = { daily: 7, weekly: 4 };
55✔
35

36
/** Monotonic counter used to disambiguate same-millisecond backups */
37
let backupSeq = 0;
55✔
38

39
/** Interval handle for scheduled backups */
40
let scheduledBackupInterval = null;
55✔
41

42
/**
43
 * Get or create the backup directory.
44
 *
45
 * @param {string} [dir] - Override backup directory path
46
 * @returns {Promise<string>} The backup directory path
47
 */
48
export async function getBackupDir(dir) {
49
  const backupDir = dir ?? DEFAULT_BACKUP_DIR;
27!
50
  // Use recursive mkdir to avoid race condition between check and create
51
  await mkdir(backupDir, { recursive: true });
27✔
52
  return backupDir;
27✔
53
}
54

55
/**
56
 * Sanitize config by replacing sensitive field values with a redaction placeholder.
57
 * Exported configs can then be shared without leaking secrets.
58
 *
59
 * @param {Object} config - Config object to sanitize
60
 * @returns {Object} Sanitized deep-clone of config
61
 */
62
export function sanitizeConfig(config) {
63
  const sanitized = structuredClone(config);
18✔
64

65
  for (const dotPath of SENSITIVE_FIELDS) {
18✔
66
    const parts = dotPath.split('.');
36✔
67
    let obj = sanitized;
36✔
68
    for (let i = 0; i < parts.length - 1; i++) {
36✔
69
      if (obj == null || typeof obj !== 'object') break;
36!
70
      obj = obj[parts[i]];
36✔
71
    }
72
    const lastKey = parts[parts.length - 1];
36✔
73
    if (obj != null && typeof obj === 'object' && lastKey in obj) {
36✔
74
      obj[lastKey] = '[REDACTED]';
34✔
75
    }
76
  }
77

78
  return sanitized;
18✔
79
}
80

81
/**
82
 * Export current configuration as a JSON-serialisable payload.
83
 * Only exports sections listed in SAFE_CONFIG_KEYS.
84
 * Sensitive fields are redacted.
85
 *
86
 * @returns {{config: Object, exportedAt: string, version: number}} Export payload
87
 */
88
export function exportConfig() {
89
  const config = getConfig();
15✔
90
  const exported = {};
15✔
91

92
  for (const key of SAFE_CONFIG_KEYS) {
15✔
93
    if (key in config) {
75!
94
      exported[key] = config[key];
75✔
95
    }
96
  }
97

98
  return {
15✔
99
    config: sanitizeConfig(exported),
100
    exportedAt: new Date().toISOString(),
101
    version: 1,
102
  };
103
}
104

105
/**
106
 * Validate an import payload structure.
107
 *
108
 * @param {unknown} payload - Parsed JSON payload from an import file
109
 * @returns {string[]} Array of validation error messages (empty if valid)
110
 */
111
export function validateImportPayload(payload) {
112
  if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
8✔
113
    return ['Import payload must be a JSON object'];
2✔
114
  }
115

116
  if (!('config' in payload)) {
6✔
117
    return ['Import payload must have a "config" key'];
2✔
118
  }
119

120
  const { config } = payload;
4✔
121
  if (typeof config !== 'object' || config === null || Array.isArray(config)) {
4✔
122
    return ['"config" must be a JSON object'];
1✔
123
  }
124

125
  const errors = [];
3✔
126
  for (const [key, value] of Object.entries(config)) {
3✔
127
    if (!SAFE_CONFIG_KEYS.has(key)) {
7✔
128
      errors.push(`"${key}" is not a writable config section`);
1✔
129
    } else if (typeof value !== 'object' || value === null) {
6!
UNCOV
130
      errors.push(`"${key}" must be an object or array`);
×
131
    }
132
  }
133

134
  return errors;
3✔
135
}
136

137
/**
138
 * Import configuration from an export payload.
139
 * Applies all non-redacted values to the live config.
140
 *
141
 * @param {Object} payload - Export payload (must pass validateImportPayload)
142
 * @returns {Promise<{applied: string[], skipped: string[], failed: Array<{path: string, error: string}>}>}
143
 */
144
export async function importConfig(payload) {
145
  const { config } = payload;
4✔
146

147
  const applied = [];
4✔
148
  const skipped = [];
4✔
149
  const failed = [];
4✔
150

151
  for (const [section, sectionValue] of Object.entries(config)) {
4✔
152
    if (!SAFE_CONFIG_KEYS.has(section)) continue;
8!
153

154
    const paths = flattenToLeafPaths(sectionValue, section);
8✔
155
    for (const [dotPath, value] of paths) {
8✔
156
      // Skip redacted placeholders — don't overwrite real secrets with placeholder text
157
      if (value === '[REDACTED]') {
12✔
158
        skipped.push(dotPath);
3✔
159
        continue;
3✔
160
      }
161

162
      try {
9✔
163
        await setConfigValue(dotPath, value);
9✔
164
        applied.push(dotPath);
8✔
165
      } catch (err) {
166
        failed.push({ path: dotPath, error: err.message });
1✔
167
      }
168
    }
169
  }
170

171
  return { applied, skipped, failed };
4✔
172
}
173

174
/**
175
 * Generate a backup filename for the given date.
176
 *
177
 * @param {Date} [date] - Date to use (defaults to now)
178
 * @returns {string} Filename like "backup-2026-03-01T12-00-00-000-0000.json" (includes milliseconds and sequence suffix)
179
 */
180
function makeBackupFilename(date = new Date()) {
12✔
181
  // Include milliseconds for precision; append an incrementing sequence to guarantee uniqueness
182
  // within the same millisecond (e.g. rapid test runs or burst backup triggers).
183
  const iso = date.toISOString().replace(/:/g, '-').replace(/Z$/, '').replace(/\./, '-');
12✔
184
  const seq = String(backupSeq++).padStart(4, '0');
12✔
185
  return `backup-${iso}-${seq}.json`;
12✔
186
}
187

188
/**
189
 * Create a timestamped backup of the current config in the backup directory.
190
 *
191
 * @param {string} [backupDir] - Override backup directory
192
 * @returns {Promise<{id: string, path: string, size: number, createdAt: string}>} Backup metadata
193
 */
194
export async function createBackup(backupDir) {
195
  const dir = await getBackupDir(backupDir);
12✔
196
  const now = new Date();
12✔
197
  const filename = makeBackupFilename(now);
12✔
198
  const filePath = path.join(dir, filename);
12✔
199

200
  const payload = exportConfig();
12✔
201
  const json = JSON.stringify(payload, null, 2);
12✔
202

203
  await writeFile(filePath, json, 'utf8');
12✔
204

205
  const stats = await stat(filePath);
12✔
206
  const id = filename.replace('.json', '');
12✔
207

208
  info('Config backup created', { id, path: filePath, size: stats.size });
12✔
209

210
  return {
12✔
211
    id,
212
    path: filePath,
213
    size: stats.size,
214
    createdAt: now.toISOString(),
215
  };
216
}
217

218
/**
219
 * Parse backup metadata from a filename and stat the file.
220
 *
221
 * @param {string} filename - Backup filename
222
 * @param {string} dir - Directory containing the backup file
223
 * @returns {Promise<{id: string, filename: string, createdAt: string, size: number} | null>}
224
 */
225
async function parseBackupMeta(filename, dir) {
226
  const match = BACKUP_FILENAME_PATTERN.exec(filename);
12✔
227
  if (!match) return null;
12!
228

229
  const filePath = path.join(dir, filename);
12✔
230
  let size = 0;
12✔
231
  try {
12✔
232
    const stats = await stat(filePath);
12✔
233
    size = stats.size;
12✔
234
  } catch {
UNCOV
235
    return null;
×
236
  }
237

238
  // Convert "2026-03-01T12-00-00-000" → "2026-03-01T12:00:00.000Z"
239
  const isoStr = match[1].replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})$/, 'T$1:$2:$3.$4Z');
12✔
240

241
  return {
12✔
242
    id: filename.replace('.json', ''),
243
    filename,
244
    createdAt: isoStr,
245
    size,
246
  };
247
}
248

249
/**
250
 * List all available backups, sorted newest first.
251
 *
252
 * @param {string} [backupDir] - Override backup directory
253
 * @returns {Promise<Array<{id: string, filename: string, createdAt: string, size: number}>>}
254
 */
255
export async function listBackups(backupDir) {
256
  const dir = await getBackupDir(backupDir);
7✔
257

258
  let files;
259
  try {
7✔
260
    files = await readdir(dir);
7✔
261
  } catch {
UNCOV
262
    return [];
×
263
  }
264

265
  const backupMetaPromises = files.map((filename) => parseBackupMeta(filename, dir));
12✔
266
  const results = await Promise.all(backupMetaPromises);
7✔
267

268
  const backups = results
7✔
269
    .filter(Boolean)
270
    .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
15✔
271

272
  return backups;
7✔
273
}
274

275
/**
276
 * Read and parse a backup file by ID.
277
 *
278
 * @param {string} id - Backup ID (filename without .json)
279
 * @param {string} [backupDir] - Override backup directory
280
 * @returns {Promise<Object>} Parsed backup payload
281
 * @throws {Error} If backup file not found or invalid
282
 */
283
export async function readBackup(id, backupDir) {
284
  // Validate ID against strict pattern: backup-YYYY-MM-DDTHH-mm-ss-SSS-NNNN
285
  const BACKUP_ID_PATTERN =
286
    /^backup-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{3}-[0-9]{4}$/;
7✔
287
  if (!BACKUP_ID_PATTERN.test(id)) {
7✔
288
    throw new Error('Invalid backup ID');
2✔
289
  }
290

291
  const dir = await getBackupDir(backupDir);
5✔
292
  const filename = `${id}.json`;
5✔
293
  const filePath = path.join(dir, filename);
5✔
294

295
  try {
5✔
296
    await access(filePath, constants.F_OK);
5✔
297
  } catch {
298
    throw new Error(`Backup not found: ${id}`);
1✔
299
  }
300

301
  const raw = await readFile(filePath, 'utf8');
4✔
302
  try {
4✔
303
    return JSON.parse(raw);
4✔
304
  } catch {
305
    throw new Error(`Backup file is corrupted: ${id}`);
1✔
306
  }
307
}
308

309
/**
310
 * Restore configuration from a backup.
311
 *
312
 * @param {string} id - Backup ID to restore from
313
 * @param {string} [backupDir] - Override backup directory
314
 * @returns {Promise<{applied: string[], skipped: string[], failed: Array<{path: string, error: string}>}>}
315
 * @throws {Error} If backup not found or invalid
316
 */
317
export async function restoreBackup(id, backupDir) {
318
  const payload = await readBackup(id, backupDir);
2✔
319

320
  const validationErrors = validateImportPayload(payload);
2✔
321
  if (validationErrors.length > 0) {
2✔
322
    throw new Error(`Invalid backup format: ${validationErrors.join(', ')}`);
1✔
323
  }
324

325
  info('Restoring config from backup', { id });
1✔
326
  const result = await importConfig(payload);
1✔
327
  info('Config restored from backup', {
1✔
328
    id,
329
    applied: result.applied.length,
330
    failed: result.failed.length,
331
  });
332

333
  return result;
1✔
334
}
335

336
/**
337
 * Prune old backups according to retention policy.
338
 * Always keeps the `daily` most recent backups.
339
 * Additionally keeps one backup per week for the last `weekly` weeks.
340
 *
341
 * @param {{daily?: number, weekly?: number}} [retention] - Retention counts
342
 * @param {string} [backupDir] - Override backup directory
343
 * @returns {Promise<string[]>} IDs of deleted backups
344
 */
345
export async function pruneBackups(retention, backupDir) {
346
  const { daily = DEFAULT_RETENTION.daily, weekly = DEFAULT_RETENTION.weekly } = retention ?? {};
3!
347
  const dir = await getBackupDir(backupDir);
3✔
348
  const all = await listBackups(dir);
3✔
349

350
  if (all.length === 0) return [];
3✔
351

352
  // Always keep the `daily` most recent backups
353
  const toKeep = new Set(all.slice(0, daily).map((b) => b.id));
3✔
354

355
  // Keep one representative backup per week for `weekly` weeks
356
  const now = new Date();
2✔
357
  for (let week = 0; week < weekly; week++) {
2✔
UNCOV
358
    const weekStart = new Date(now);
×
UNCOV
359
    weekStart.setDate(weekStart.getDate() - (week + 1) * 7);
×
UNCOV
360
    const weekEnd = new Date(now);
×
UNCOV
361
    weekEnd.setDate(weekEnd.getDate() - week * 7);
×
362

UNCOV
363
    const weekBackup = all.find((b) => {
×
UNCOV
364
      const ts = new Date(b.createdAt);
×
365
      return ts >= weekStart && ts < weekEnd;
×
366
    });
367

368
    if (weekBackup) {
×
UNCOV
369
      toKeep.add(weekBackup.id);
×
370
    }
371
  }
372

UNCOV
373
  const deleted = [];
×
UNCOV
374
  for (const backup of all) {
×
375
    if (!toKeep.has(backup.id)) {
7✔
376
      try {
4✔
377
        await unlink(path.join(dir, backup.filename));
4✔
378
        deleted.push(backup.id);
4✔
379
        info('Pruned old backup', { id: backup.id });
4✔
380
      } catch (err) {
381
        logError('Failed to prune backup', { id: backup.id, error: err.message });
×
382
      }
383
    }
384
  }
385

386
  return deleted;
2✔
387
}
388

389
/**
390
 * Start the scheduled backup job.
391
 * Creates a backup at the given interval (default: every 24 hours)
392
 * and prunes old backups after each run.
393
 *
394
 * @param {{intervalMs?: number, retention?: {daily?: number, weekly?: number}, backupDir?: string}} [opts]
395
 * @returns {void}
396
 */
397
export function startScheduledBackups(opts = {}) {
4✔
398
  const {
399
    intervalMs = 24 * 60 * 60 * 1000, // 24 hours
4✔
400
    retention,
401
    backupDir,
402
  } = opts;
4✔
403

404
  if (scheduledBackupInterval) {
4✔
405
    warn('Scheduled backups already running — skipping duplicate start');
1✔
406
    return;
1✔
407
  }
408

409
  info('Starting scheduled config backups', { intervalMs });
3✔
410

411
  scheduledBackupInterval = setInterval(async () => {
3✔
UNCOV
412
    try {
×
UNCOV
413
      await createBackup(backupDir);
×
UNCOV
414
      await pruneBackups(retention, backupDir);
×
415
    } catch (err) {
UNCOV
416
      logError('Scheduled backup failed', { error: err.message });
×
417
    }
418
  }, intervalMs);
419

420
  // Prevent the interval from keeping the process alive unnecessarily (e.g. in tests)
421
  if (typeof scheduledBackupInterval.unref === 'function') {
3!
422
    scheduledBackupInterval.unref();
3✔
423
  }
424
}
425

426
/**
427
 * Stop the scheduled backup job.
428
 *
429
 * @returns {void}
430
 */
431
export function stopScheduledBackups() {
432
  if (scheduledBackupInterval) {
31✔
433
    clearInterval(scheduledBackupInterval);
3✔
434
    scheduledBackupInterval = null;
3✔
435
    info('Stopped scheduled config backups');
3✔
436
  }
437
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc