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

@@ -37,6 +37,12 @@ data class SpielplanResponse(
val seasons: List<SeasonDto> = emptyList(), val seasons: List<SeasonDto> = emptyList(),
) )
data class SeasonDto(val slug: String = "", val label: String = "") data class SeasonDto(val slug: String = "", val label: String = "")
data class MannschaftenSeasonsResponse(
val success: Boolean = false,
val seasons: List<SeasonDto> = emptyList(),
val currentSeason: String = "",
val defaultSeason: String = "",
)
data class SpielDto( data class SpielDto(
@param:Json(name = "Termin") val termin: String = "", @param:Json(name = "Termin") val termin: String = "",
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "", @param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
@@ -584,6 +590,9 @@ interface ApiService {
@GET("/api/mannschaften") @GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody> suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@GET("/api/mannschaften/seasons")
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
@GET("/api/config") @GET("/api/config")
suspend fun config(): Response<ConfigResponse> suspend fun config(): Response<ConfigResponse>

View File

@@ -1,6 +1,8 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import de.harheimertc.data.ApiService import de.harheimertc.data.ApiService
import de.harheimertc.data.MannschaftenSeasonsResponse
import de.harheimertc.data.SeasonDto
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -29,6 +31,12 @@ class MannschaftenRepository @Inject constructor(private val api: ApiService) {
parseCsv(response.body()?.string().orEmpty()) parseCsv(response.body()?.string().orEmpty())
} }
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
val response = api.mannschaftenSeasons()
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
response.body() ?: error("Saisons konnten nicht geladen werden.")
}
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence() private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
.filter(String::isNotBlank) .filter(String::isNotBlank)
.drop(1) .drop(1)

View File

@@ -18,7 +18,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -27,6 +30,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -34,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -43,6 +48,7 @@ import de.harheimertc.repositories.Mannschaft
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900 import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100 import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600 import de.harheimertc.ui.theme.Primary600
@@ -64,8 +70,17 @@ fun MannschaftenScreen(
BackLink(navController, showBackNavigation) BackLink(navController, showBackNavigation)
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp)) Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp)) Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
if (state.seasons.isNotEmpty()) {
SeasonSelector(
seasons = state.seasons,
selectedSeason = state.selectedSeason,
onSeasonSelected = viewModel::selectSeason,
modifier = Modifier.padding(top = 14.dp),
)
}
} }
when { when {
state.seasonsLoading -> item { Loading() }
state.loading -> item { Loading() } state.loading -> item { Loading() }
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) } state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) } state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
@@ -88,6 +103,38 @@ fun MannschaftenScreen(
} }
} }
@Composable
private fun SeasonSelector(
seasons: List<de.harheimertc.data.SeasonDto>,
selectedSeason: String,
onSeasonSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var open by remember { mutableStateOf(false) }
val selectedLabel = seasons.firstOrNull { it.slug == selectedSeason }?.label ?: selectedSeason
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Saison", color = Accent700, style = MaterialTheme.typography.labelSmall)
BoxWithConstraints {
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
Text(selectedLabel.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
Text("v")
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
seasons.forEach { season ->
DropdownMenuItem(
text = { Text(season.label.ifBlank { season.slug }) },
onClick = {
open = false
onSeasonSelected(season.slug)
},
)
}
}
}
}
}
@Composable @Composable
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) { private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
Surface( Surface(

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.SpielDto import de.harheimertc.data.SpielDto
import de.harheimertc.data.LeagueTableRowDto import de.harheimertc.data.LeagueTableRowDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.SpielplanRepository import de.harheimertc.repositories.SpielplanRepository
@@ -17,6 +18,9 @@ data class MannschaftenUiState(
val loading: Boolean = true, val loading: Boolean = true,
val error: String? = null, val error: String? = null,
val teams: List<Mannschaft> = emptyList(), val teams: List<Mannschaft> = emptyList(),
val seasons: List<SeasonDto> = emptyList(),
val selectedSeason: String = "",
val seasonsLoading: Boolean = false,
) )
@HiltViewModel @HiltViewModel
@@ -27,17 +31,69 @@ class MannschaftenViewModel @Inject constructor(
val state: StateFlow<MannschaftenUiState> = _state val state: StateFlow<MannschaftenUiState> = _state
init { init {
load() loadSeasonsAndMannschaften()
} }
fun load() { fun load() {
viewModelScope.launch { viewModelScope.launch {
_state.value = MannschaftenUiState(loading = true) val season = _state.value.selectedSeason.ifBlank { null }
repository.fetchMannschaften() _state.value = _state.value.copy(loading = true, error = null)
.onSuccess { _state.value = MannschaftenUiState(loading = false, teams = it) } repository.fetchMannschaften(season)
.onFailure { _state.value = MannschaftenUiState(loading = false, error = "Mannschaften konnten nicht geladen werden.") } .onSuccess { teams -> _state.value = _state.value.copy(loading = false, teams = teams) }
.onFailure { _state.value = _state.value.copy(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
} }
} }
fun selectSeason(season: String) {
if (season == _state.value.selectedSeason) return
_state.value = _state.value.copy(selectedSeason = season)
load()
}
private fun loadSeasonsAndMannschaften() {
viewModelScope.launch {
_state.value = _state.value.copy(seasonsLoading = true, error = null)
repository.fetchSeasons()
.onSuccess { response ->
val currentSeason = getCurrentSeasonSlug()
val seasons = response.seasons.ifEmpty { listOf(SeasonDto(slug = response.currentSeason.ifBlank { currentSeason }, label = formatSeasonLabel(response.currentSeason.ifBlank { currentSeason }))) }
val selectedSeason = when {
response.defaultSeason.isNotBlank() -> response.defaultSeason
seasons.any { it.slug == currentSeason } -> currentSeason
seasons.isNotEmpty() -> seasons.first().slug
else -> currentSeason
}
_state.value = _state.value.copy(
seasonsLoading = false,
seasons = seasons,
selectedSeason = selectedSeason,
)
load()
}
.onFailure {
val currentSeason = getCurrentSeasonSlug()
_state.value = _state.value.copy(
seasonsLoading = false,
seasons = listOf(SeasonDto(slug = currentSeason, label = formatSeasonLabel(currentSeason))),
selectedSeason = currentSeason,
)
load()
}
}
}
private fun getCurrentSeasonSlug(): String {
val now = java.util.Calendar.getInstance()
val year = now.get(java.util.Calendar.YEAR)
val startYear = if (now.get(java.util.Calendar.MONTH) >= 6) year else year - 1
val endYear = startYear + 1
return "%02d--%02d".format(startYear % 100, endYear % 100)
}
private fun formatSeasonLabel(seasonSlug: String): String {
val match = Regex("^(\\d{2})--(\\d{2})$").matchEntire(seasonSlug.trim()) ?: return seasonSlug
return "20${match.groupValues[1]}/${match.groupValues[2]}"
}
} }
data class MannschaftDetailUiState( data class MannschaftDetailUiState(

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads # Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=19 ANDROID_VERSION_CODE=20
ANDROID_VERSION_NAME=0.9.14 ANDROID_VERSION_NAME=0.9.15
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false RELEASE_MINIFY_ENABLED=false

View File

@@ -16,9 +16,12 @@
"start": "nuxt start --port 3100", "start": "nuxt start --port 3100",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"test": "vitest run", "test": "vitest run",
"test:data-rotation": "vitest run tests/data-file-rotation.spec.ts",
"check-security": "node scripts/verify-no-public-writes.js", "check-security": "node scripts/verify-no-public-writes.js",
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js", "smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
"sync-public-data": "node scripts/sync-public-data.js", "sync-public-data": "node scripts/sync-public-data.js",
"data-backups:list": "node scripts/data-backup-restore.js list",
"data-backups:restore": "node scripts/data-backup-restore.js restore",
"hero:prepare": "node scripts/prepare-hero-variants.mjs", "hero:prepare": "node scripts/prepare-hero-variants.mjs",
"import-spielplan": "node scripts/import-spielplan.js", "import-spielplan": "node scripts/import-spielplan.js",
"publish-spielplan": "node scripts/publish-imported-spielplan.js", "publish-spielplan": "node scripts/publish-imported-spielplan.js",

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env node
import path from 'path'
import {
getBackupDirectoryForDataFile,
listDataFileBackups,
restoreDataFileBackup
} from '../server/utils/data-file-rotation.js'
const FILES = {
'users.json': getDataPath('users.json'),
'sessions.json': getDataPath('sessions.json'),
'members.json': getDataPath('members.json'),
'newsletter-subscribers.json': getDataPath('newsletter-subscribers.json'),
'news.json': getDataPath('news.json'),
'termine.csv': getDataPath('termine.csv'),
'contact-requests.json': getDataPath('contact-requests.json')
}
function getDataPath(filename) {
const cwd = process.cwd()
if (cwd.endsWith('.output')) {
return path.join(cwd, '../server/data', filename)
}
return path.join(cwd, 'server/data', filename)
}
function parseArg(name) {
const index = process.argv.findIndex((arg) => arg === name)
if (index === -1) return null
const next = process.argv[index + 1]
if (!next || next.startsWith('--')) return null
return next
}
function hasFlag(name) {
return process.argv.includes(name)
}
function printUsage() {
console.log('Verwendung:')
console.log(' node scripts/data-backup-restore.js list [--file users.json]')
console.log(' node scripts/data-backup-restore.js restore --file users.json --latest')
console.log(' node scripts/data-backup-restore.js restore --file users.json --backup <backup-datei.bak>')
console.log('')
console.log('Optionen:')
console.log(' --file Eine der bekannten Daten-Dateien')
console.log(' --latest Stellt das neueste Backup wieder her')
console.log(' --backup Konkreter Backup-Dateiname (*.bak)')
console.log('')
console.log('Bekannte Dateien:')
Object.keys(FILES).forEach((name) => console.log(` - ${name}`))
}
async function listCommand() {
const requestedFile = parseArg('--file')
const names = requestedFile ? [requestedFile] : Object.keys(FILES)
for (const name of names) {
const dataPath = FILES[name]
if (!dataPath) {
console.error(`Unbekannte Datei: ${name}`)
process.exitCode = 1
continue
}
const backups = await listDataFileBackups(dataPath)
const backupDir = getBackupDirectoryForDataFile(dataPath)
console.log(`\n${name}`)
console.log(` Datenpfad: ${dataPath}`)
console.log(` Backup-Ordner: ${backupDir}`)
if (backups.length === 0) {
console.log(' Backups: keine')
continue
}
console.log(` Backups (${backups.length}, neuestes zuerst):`)
backups.slice(0, 15).forEach((backup) => {
console.log(` - ${backup}`)
})
if (backups.length > 15) {
console.log(` ... (${backups.length - 15} weitere)`)
}
}
}
async function restoreCommand() {
const fileName = parseArg('--file')
if (!fileName) {
console.error('Fehlend: --file <datei>')
printUsage()
process.exit(1)
}
const dataPath = FILES[fileName]
if (!dataPath) {
console.error(`Unbekannte Datei: ${fileName}`)
process.exit(1)
}
const backups = await listDataFileBackups(dataPath)
if (backups.length === 0) {
console.error(`Keine Backups gefunden für ${fileName}`)
process.exit(1)
}
const backupName = parseArg('--backup')
const latest = hasFlag('--latest')
let targetBackup = backupName
if (!targetBackup && latest) {
targetBackup = backups[0]
}
if (!targetBackup) {
console.error('Bitte --latest oder --backup <name> angeben')
process.exit(1)
}
if (!backups.includes(targetBackup)) {
console.error(`Backup nicht gefunden: ${targetBackup}`)
console.error('Nutzen Sie zuerst: node scripts/data-backup-restore.js list --file <datei>')
process.exit(1)
}
const result = await restoreDataFileBackup(dataPath, targetBackup)
console.log(`Wiederherstellung abgeschlossen: ${fileName}`)
console.log(` Eingespieltes Backup: ${targetBackup}`)
if (result.backupPath) {
console.log(` Backup des vorherigen Zustands: ${result.backupPath}`)
}
}
async function main() {
const command = process.argv[2]
if (!command || command === '--help' || command === '-h') {
printUsage()
return
}
if (command === 'list') {
await listCommand()
return
}
if (command === 'restore') {
await restoreCommand()
return
}
console.error(`Unbekannter Befehl: ${command}`)
printUsage()
process.exit(1)
}
main().catch((error) => {
console.error('Fehler im Backup/Restore-Skript:', error)
process.exit(1)
})

View File

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

View File

@@ -1,6 +1,7 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' 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 // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant, never user input // filename is always a hardcoded constant, never user input
@@ -29,7 +30,7 @@ export async function readContactRequests() {
} }
export async function writeContactRequests(items) { 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) { 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 path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js' import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// Handle both dev and production paths // Handle both dev and production paths
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // 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 { try {
const encryptionKey = getEncryptionKey() const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(members, encryptionKey) const encryptedData = encryptObject(members, encryptionKey)
await fs.writeFile(MEMBERS_FILE, encryptedData, 'utf-8') await writeDataFileWithRotation(MEMBERS_FILE, encryptedData, { encoding: 'utf-8' })
return true return true
} catch (error) { } catch (error) {
console.error('Fehler beim Schreiben der Mitgliederdaten:', error) console.error('Fehler beim Schreiben der Mitgliederdaten:', error)

View File

@@ -1,6 +1,7 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { writeDataFileWithRotation } from './data-file-rotation.js'
// Handle both dev and production paths // Handle both dev and production paths
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // 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 // Write news to file
export async function writeNews(news) { export async function writeNews(news) {
try { 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 return true
} catch (error) { } catch (error) {
console.error('Fehler beim Schreiben der News:', 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 { readUsers } from './auth.js'
import { encryptObject, decryptObject } from './encryption.js' import { encryptObject, decryptObject } from './encryption.js'
import crypto from 'crypto' 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 // 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 // filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input
@@ -136,7 +137,7 @@ export async function writeSubscribers(subscribers) {
try { try {
const encryptionKey = getEncryptionKey() const encryptionKey = getEncryptionKey()
const encryptedData = encryptObject(subscribers, encryptionKey) 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 return true
} catch (error) { } catch (error) {
console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error) console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import os from 'os'
import path from 'path'
import { promises as fs } from 'fs'
import {
getBackupDirectoryForDataFile,
listDataFileBackups,
restoreDataFileBackup,
writeDataFileWithRotation
} from '../server/utils/data-file-rotation.js'
describe('Data file rotation utility', () => {
let tempRoot = ''
let backupRoot = ''
let previousBackupDir = ''
let dataFile = ''
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'harheimertc-data-rotation-'))
backupRoot = path.join(tempRoot, 'backup-store')
dataFile = path.join(tempRoot, 'server', 'data', 'users.json')
previousBackupDir = process.env.DATA_FILE_BACKUP_DIR || ''
process.env.DATA_FILE_BACKUP_DIR = backupRoot
})
afterEach(async () => {
if (previousBackupDir) {
process.env.DATA_FILE_BACKUP_DIR = previousBackupDir
} else {
delete process.env.DATA_FILE_BACKUP_DIR
}
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true })
}
})
it('writes initial file without creating backup', async () => {
const result = await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 5 })
expect(result.changed).toBe(true)
expect(result.backupPath).toBe(null)
const content = await fs.readFile(dataFile, 'utf-8')
expect(content).toBe('v1')
const backups = await listDataFileBackups(dataFile)
expect(backups).toEqual([])
})
it('creates backup from previous content on change', async () => {
await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 5 })
const result = await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 5 })
expect(result.changed).toBe(true)
expect(result.backupPath).toBeTruthy()
const backups = await listDataFileBackups(dataFile)
expect(backups.length).toBe(1)
const backupDir = getBackupDirectoryForDataFile(dataFile)
const backupContent = await fs.readFile(path.join(backupDir, backups[0]), 'utf-8')
expect(backupContent).toBe('v1')
const currentContent = await fs.readFile(dataFile, 'utf-8')
expect(currentContent).toBe('v2')
})
it('does not create backup when content is unchanged', async () => {
await writeDataFileWithRotation(dataFile, 'stable', { maxBackups: 5 })
await writeDataFileWithRotation(dataFile, 'next', { maxBackups: 5 })
const before = await listDataFileBackups(dataFile)
const result = await writeDataFileWithRotation(dataFile, 'next', { maxBackups: 5 })
const after = await listDataFileBackups(dataFile)
expect(result.changed).toBe(false)
expect(result.backupPath).toBe(null)
expect(after).toEqual(before)
})
it('rotates old backups and keeps configured maximum', async () => {
await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 2 })
await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 2 })
await writeDataFileWithRotation(dataFile, 'v3', { maxBackups: 2 })
await writeDataFileWithRotation(dataFile, 'v4', { maxBackups: 2 })
const backups = await listDataFileBackups(dataFile)
expect(backups.length).toBe(2)
const backupDir = getBackupDirectoryForDataFile(dataFile)
const backupContents = await Promise.all(
backups.map((name) => fs.readFile(path.join(backupDir, name), 'utf-8'))
)
expect(backupContents).toEqual(expect.arrayContaining(['v2', 'v3']))
expect(backupContents).not.toContain('v1')
})
it('restores selected backup and keeps pre-restore state as backup', async () => {
await writeDataFileWithRotation(dataFile, 'v1', { maxBackups: 10 })
await writeDataFileWithRotation(dataFile, 'v2', { maxBackups: 10 })
await writeDataFileWithRotation(dataFile, 'v3', { maxBackups: 10 })
const beforeRestoreBackups = await listDataFileBackups(dataFile)
const backupDir = getBackupDirectoryForDataFile(dataFile)
const resolved = await Promise.all(
beforeRestoreBackups.map(async (name) => ({
name,
content: await fs.readFile(path.join(backupDir, name), 'utf-8')
}))
)
const v1Entry = resolved.find((entry) => entry.content === 'v1')
expect(v1Entry).toBeTruthy()
const restoreResult = await restoreDataFileBackup(dataFile, v1Entry!.name, { maxBackups: 10 })
expect(restoreResult.changed).toBe(true)
expect(restoreResult.backupPath).toBeTruthy()
const restoredContent = await fs.readFile(dataFile, 'utf-8')
expect(restoredContent).toBe('v1')
const afterRestoreBackups = await listDataFileBackups(dataFile)
const afterContents = await Promise.all(
afterRestoreBackups.map((name) => fs.readFile(path.join(backupDir, name), 'utf-8'))
)
expect(afterContents).toContain('v3')
})
})