Add unit tests for data file rotation utility functions
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m24s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

- Implement tests for writing data files with rotation, ensuring backups are created only on changes.
- Verify that old backups are rotated correctly and the maximum number of backups is maintained.
- Test restoration of backups while preserving the current state as a backup.
- Utilize Vitest for testing framework and manage temporary file storage during tests.
This commit is contained in:
Torsten Schulz (local)
2026-06-01 11:21:21 +02:00
parent 80834d8652
commit 2014abe660
17 changed files with 563 additions and 14 deletions

View File

@@ -4,6 +4,7 @@ import crypto from 'crypto'
import { promises as fs } from 'fs'
import path from 'path'
import { encryptObject, decryptObject } from './encryption.js'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// Export migrateUserRoles für Verwendung in anderen Modulen
export function migrateUserRoles(user) {
@@ -196,7 +197,7 @@ export async function writeUsers(users) {
try {
const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(users, encryptionKey)
await fs.writeFile(USERS_FILE, encryptedData, 'utf-8')
await writeDataFileWithRotation(USERS_FILE, encryptedData, { encoding: 'utf-8' })
return true
} catch (error) {
console.error('Fehler beim Schreiben der Benutzerdaten:', error)
@@ -262,7 +263,7 @@ export async function writeSessions(sessions) {
try {
const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(sessions, encryptionKey)
await fs.writeFile(SESSIONS_FILE, encryptedData, 'utf-8')
await writeDataFileWithRotation(SESSIONS_FILE, encryptedData, { encoding: 'utf-8' })
return true
} catch (error) {
console.error('Fehler beim Schreiben der Sessions:', error)

View File

@@ -1,6 +1,7 @@
import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant, never user input
@@ -29,7 +30,7 @@ export async function readContactRequests() {
}
export async function writeContactRequests(items) {
await fs.writeFile(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), 'utf-8')
await writeDataFileWithRotation(CONTACT_REQUESTS_FILE, JSON.stringify(items, null, 2), { encoding: 'utf-8' })
}
export async function createContactRequest(data) {

View File

@@ -0,0 +1,125 @@
import { promises as fs } from 'fs'
import path from 'path'
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
function getProjectRoot() {
const cwd = process.cwd()
if (cwd.endsWith('.output')) {
return path.resolve(cwd, '..')
}
return cwd
}
function getBackupRoot() {
const configured = process.env.DATA_FILE_BACKUP_DIR
if (!configured) {
return path.join(getProjectRoot(), 'backups', 'data-rotation')
}
if (path.isAbsolute(configured)) {
return configured
}
return path.join(getProjectRoot(), configured)
}
function sanitizeFileKey(filePath) {
const projectRoot = getProjectRoot()
const relative = path.relative(projectRoot, filePath)
const normalized = relative.split(path.sep).join('__')
return normalized.replace(/[^a-zA-Z0-9._-]/g, '_')
}
function buildBackupName(date = new Date()) {
const randomSuffix = Math.random().toString(36).slice(2, 8)
return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak`
}
async function ensureDirectory(dirPath) {
await fs.mkdir(dirPath, { recursive: true })
}
async function rotateOldBackups(backupDir, maxBackups) {
if (!Number.isFinite(maxBackups) || maxBackups < 1) {
return
}
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
const backups = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
.map((entry) => entry.name)
.sort()
const overflowCount = Math.max(0, backups.length - maxBackups)
if (overflowCount === 0) {
return
}
const toDelete = backups.slice(0, overflowCount)
await Promise.all(toDelete.map((name) => fs.unlink(path.join(backupDir, name)).catch(() => {})))
}
export function getBackupDirectoryForDataFile(filePath) {
const resolvedPath = path.resolve(filePath)
return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath))
}
export async function listDataFileBackups(filePath) {
const backupDir = getBackupDirectoryForDataFile(filePath)
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
.map((entry) => entry.name)
.sort()
.reverse()
}
export async function writeDataFileWithRotation(filePath, content, {
encoding = 'utf-8',
maxBackups = DEFAULT_MAX_BACKUPS
} = {}) {
const resolvedPath = path.resolve(filePath)
await ensureDirectory(path.dirname(resolvedPath))
let existingContent = null
try {
existingContent = await fs.readFile(resolvedPath, encoding)
} catch (error) {
if (error.code !== 'ENOENT') {
throw error
}
}
if (existingContent !== null && existingContent === content) {
return {
changed: false,
backupPath: null
}
}
let backupPath = null
if (existingContent !== null) {
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
await ensureDirectory(backupDir)
backupPath = path.join(backupDir, buildBackupName())
await fs.copyFile(resolvedPath, backupPath)
await rotateOldBackups(backupDir, maxBackups)
}
const tmpPath = `${resolvedPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`
await fs.writeFile(tmpPath, content, encoding)
await fs.rename(tmpPath, resolvedPath)
return {
changed: true,
backupPath
}
}
export async function restoreDataFileBackup(filePath, backupName, options = {}) {
const resolvedPath = path.resolve(filePath)
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
const sourceBackupPath = path.join(backupDir, backupName)
const backupContent = await fs.readFile(sourceBackupPath, 'utf-8')
return writeDataFileWithRotation(resolvedPath, backupContent, options)
}

View File

@@ -2,6 +2,7 @@ import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// Handle both dev and production paths
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
@@ -192,7 +193,7 @@ export async function writeMembers(members) {
try {
const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(members, encryptionKey)
await fs.writeFile(MEMBERS_FILE, encryptedData, 'utf-8')
await writeDataFileWithRotation(MEMBERS_FILE, encryptedData, { encoding: 'utf-8' })
return true
} catch (error) {
console.error('Fehler beim Schreiben der Mitgliederdaten:', error)

View File

@@ -1,6 +1,7 @@
import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// Handle both dev and production paths
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
@@ -38,7 +39,7 @@ export async function readNews() {
// Write news to file
export async function writeNews(news) {
try {
await fs.writeFile(NEWS_FILE, JSON.stringify(news, null, 2), 'utf-8')
await writeDataFileWithRotation(NEWS_FILE, JSON.stringify(news, null, 2), { encoding: 'utf-8' })
return true
} catch (error) {
console.error('Fehler beim Schreiben der News:', error)

View File

@@ -4,6 +4,7 @@ import { readMembers } from './members.js'
import { readUsers } from './auth.js'
import { encryptObject, decryptObject } from './encryption.js'
import crypto from 'crypto'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input
@@ -136,7 +137,7 @@ export async function writeSubscribers(subscribers) {
try {
const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(subscribers, encryptionKey)
await fs.writeFile(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, 'utf-8')
await writeDataFileWithRotation(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, { encoding: 'utf-8' })
return true
} catch (error) {
console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error)

View File

@@ -1,6 +1,7 @@
import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// Use internal server/data directory for Termine CSV to avoid writing to public/
const getDataPath = (filename) => {
@@ -89,7 +90,7 @@ export async function writeTermine(termine) {
csv += `"${escapedDatum}","${escapedUhrzeit}","${escapedTitel}","${escapedBeschreibung}","${escapedKategorie}"\n`
}
await fs.writeFile(TERMINE_FILE, csv, 'utf-8')
await writeDataFileWithRotation(TERMINE_FILE, csv, { encoding: 'utf-8' })
return true
} catch (error) {
console.error('Fehler beim Schreiben der Termine:', error)