Add manual backup creation and delete buttons to Update Manager (#1255)

* Add manual backup creation and delete buttons to Update Manager

- Add "Create Backup" button in the backups tab for on-demand backups
- Add delete buttons (trash icons) for update logs and backups
- New controller routes with CSRF protection and permission checks
- Use data-turbo-confirm for CSP-safe confirmation dialogs
- Add deleteLog() method to UpdateExecutor with filename validation

* Add Docker backup support: download button, SQLite restore fix, decouple from auto-update

- Decouple backup creation/restore UI from can_auto_update so Docker
  and other non-git installations can use backup features
- Add backup download endpoint for saving backups externally
- Fix SQLite restore to use configured DATABASE_URL path instead of
  hardcoded var/app.db (affects Docker and custom SQLite paths)
- Show Docker-specific warning about var/backups/ not being persisted
- Pass is_docker flag to template via InstallationTypeDetector

* Add tests for backup/update manager improvements

- Controller tests: auth, CSRF validation, 404 for missing backups, restore disabled check
- UpdateExecutor: deleteLog validation, non-existent file, successful deletion
- BackupManager: deleteBackup validation for missing/non-zip files

* Fix test failures: add locale prefix to URLs, correct log directory path

* Fix auth test: expect 401 instead of redirect for HTTP Basic auth

* Improve test coverage for update manager controller

Add happy-path tests for backup creation, deletion, download,
and log deletion with valid CSRF tokens. Also test the locked
state blocking backup creation.

* Fix CSRF tests: initialize session before getting tokens

* Fix CSRF tests: extract tokens from rendered page HTML

* Harden backup security: password confirmation, CSRF, env toggle

Address security review feedback from jbtronics:

- Add IS_AUTHENTICATED_FULLY to all sensitive endpoints (create/delete
  backup, delete log, download backup, start update, restore)
- Change backup download from GET to POST with CSRF token
- Require password confirmation before downloading backups (backups
  contain sensitive data like password hashes and secrets)
- Add DISABLE_BACKUP_DOWNLOAD env var (default: disabled) to control
  whether backup downloads are allowed
- Add password confirmation modal with security warning in template
- Add comprehensive tests: auth checks, env var blocking, POST-only
  enforcement, status/progress endpoint auth

* Fix download modal: use per-backup modals for CSP/Turbo compatibility

- Replace shared modal + inline JS with per-backup modals that have
  filename pre-set in hidden fields (no JavaScript needed)
- Add data-turbo="false" to download forms for native browser handling
- Add data-bs-dismiss="modal" to submit button to auto-close modal
- Add hidden username field for Chrome accessibility best practice
- Fix test: GET on POST-only route returns 404 not 405

* Fixed translation keys

* Fixed text justification in download modal

* Hardenened security of deleteLogEndpoint

* Show whether backup, restores and updates are allowed or disabled by sysadmin on update manager

* Added documentation for update manager related env variables

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
Sebastian Almberg 2026-03-07 19:31:00 +01:00 committed by GitHub
parent db8881621c
commit 0d58262e19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1077 additions and 246 deletions

5
.env
View file

@ -71,6 +71,11 @@ DISABLE_WEB_UPDATES=1
# Restoring backups is a destructive operation that could overwrite your database.
DISABLE_BACKUP_RESTORE=1
# Disable backup download from the Update Manager UI (0=enabled, 1=disabled).
# Backups contain sensitive data including password hashes and secrets.
# When enabled, users must confirm their password before downloading.
DISABLE_BACKUP_DOWNLOAD=1
###################################################################################
# SAML Single sign on-settings
###################################################################################

View file

@ -144,6 +144,18 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `ALLOW_EMAIL_PW_RESET`: Set this value to true, if you want to allow users to reset their password via an email
notification. You have to configure the mail provider first before via the MAILER_DSN setting.
### Update manager settings
* `DISABLE_WEB_UPDATES` (default `1`): Set this to 0 to enable web-based updates. When enabled, you can perform updates
via the web interface in the update manager. This is disabled by default for security reasons, as it can be a risk if
not used carefully. You can still use the CLI commands to perform updates, even when web updates are disabled.
* `DISABLE_BACKUP_RESTORE` (default `1`): Set this to 0 to enable backup restore via the web interface. When enabled, you can
restore backups via the web interface in the update manager. This is disabled by default for security reasons, as it can
be a risk if not used carefully. You can still use the CLI commands to perform backup restores, even when web-based
backup restore is disabled.
* `DISABLE_BACKUP_DOWNLOAD` (default `1`): Set this to 0 to enable backup download via the web interface. When enabled, you can download backups via the web interface
in the update manager. This is disabled by default for security reasons, as it can be a risk if not used carefully, as
the downloads contain sensitive data like password hashes or secrets.
### Table related settings
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set

View file

@ -23,16 +23,21 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\UserSystem\User;
use App\Services\System\BackupManager;
use App\Services\System\InstallationTypeDetector;
use App\Services\System\UpdateChecker;
use App\Services\System\UpdateExecutor;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
@ -49,10 +54,14 @@ class UpdateManagerController extends AbstractController
private readonly UpdateExecutor $updateExecutor,
private readonly VersionManagerInterface $versionManager,
private readonly BackupManager $backupManager,
private readonly InstallationTypeDetector $installationTypeDetector,
private readonly UserPasswordHasherInterface $passwordHasher,
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
private readonly bool $webUpdatesDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
private readonly bool $backupRestoreDisabled = false,
#[Autowire(env: 'bool:DISABLE_BACKUP_DOWNLOAD')]
private readonly bool $backupDownloadDisabled = false,
) {
}
@ -76,6 +85,16 @@ class UpdateManagerController extends AbstractController
}
}
/**
* Check if backup download is disabled and throw exception if so.
*/
private function denyIfBackupDownloadDisabled(): void
{
if ($this->backupDownloadDisabled) {
throw new AccessDeniedHttpException('Backup download is disabled by server configuration.');
}
}
/**
* Main update manager page.
*/
@ -101,6 +120,8 @@ class UpdateManagerController extends AbstractController
'backups' => $this->backupManager->getBackups(),
'web_updates_disabled' => $this->webUpdatesDisabled,
'backup_restore_disabled' => $this->backupRestoreDisabled,
'backup_download_disabled' => $this->backupDownloadDisabled,
'is_docker' => $this->installationTypeDetector->isDocker(),
]);
}
@ -206,6 +227,7 @@ class UpdateManagerController extends AbstractController
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
public function startUpdate(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfWebUpdatesDisabled();
@ -314,12 +336,126 @@ class UpdateManagerController extends AbstractController
return $this->json($details);
}
/**
* Create a manual backup.
*/
#[Route('/backup', name: 'admin_update_manager_backup', methods: ['POST'])]
public function createBackup(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
if (!$this->isCsrfTokenValid('update_manager_backup', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
if ($this->updateExecutor->isLocked()) {
$this->addFlash('error', 'Cannot create backup while an update is in progress.');
return $this->redirectToRoute('admin_update_manager');
}
try {
$this->backupManager->createBackup(null, 'manual');
$this->addFlash('success', 'update_manager.backup.created');
} catch (\Exception $e) {
$this->addFlash('error', 'Backup failed: ' . $e->getMessage());
}
return $this->redirectToRoute('admin_update_manager');
}
/**
* Delete a backup file.
*/
#[Route('/backup/delete', name: 'admin_update_manager_backup_delete', methods: ['POST'])]
public function deleteBackup(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename');
if ($filename && $this->backupManager->deleteBackup($filename)) {
$this->addFlash('success', 'update_manager.backup.deleted');
} else {
$this->addFlash('error', 'update_manager.backup.delete_error');
}
return $this->redirectToRoute('admin_update_manager');
}
/**
* Delete an update log file.
*/
#[Route('/log/delete', name: 'admin_update_manager_log_delete', methods: ['POST'])]
public function deleteLog(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename');
if ($filename && $this->updateExecutor->deleteLog($filename)) {
$this->addFlash('success', 'update_manager.log.deleted');
} else {
$this->addFlash('error', 'update_manager.log.delete_error');
}
return $this->redirectToRoute('admin_update_manager');
}
/**
* Download a backup file.
* Requires password confirmation as backups contain sensitive data (password hashes, secrets, etc.).
*/
#[Route('/backup/download', name: 'admin_update_manager_backup_download', methods: ['POST'])]
public function downloadBackup(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfBackupDownloadDisabled();
if (!$this->isCsrfTokenValid('update_manager_download', $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('admin_update_manager');
}
// Verify password
$password = $request->request->get('password', '');
$user = $this->getUser();
if (!$user instanceof User || !$this->passwordHasher->isPasswordValid($user, $password)) {
$this->addFlash('error', 'update_manager.backup.download.invalid_password');
return $this->redirectToRoute('admin_update_manager');
}
$filename = $request->request->get('filename', '');
$details = $this->backupManager->getBackupDetails($filename);
if (!$details) {
throw $this->createNotFoundException('Backup not found');
}
$response = new BinaryFileResponse($details['path']);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $details['file']);
return $response;
}
/**
* Restore from a backup.
*/
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
public function restore(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@system.manage_updates');
$this->denyIfBackupRestoreDisabled();

View file

@ -327,14 +327,14 @@ readonly class BackupManager
*/
private function restoreDatabaseFromBackup(string $tempDir): void
{
// Get database connection params from Doctrine
$connection = $this->entityManager->getConnection();
$params = $connection->getParams();
$platform = $connection->getDatabasePlatform();
// Check for SQL dump (MySQL/PostgreSQL)
$sqlFile = $tempDir . '/database.sql';
if (file_exists($sqlFile)) {
// Import SQL using mysql/psql command directly
// First, get database connection params from Doctrine
$connection = $this->entityManager->getConnection();
$params = $connection->getParams();
$platform = $connection->getDatabasePlatform();
if ($platform instanceof AbstractMySQLPlatform) {
// Use mysql command to import - need to use shell to handle input redirection
@ -403,7 +403,8 @@ readonly class BackupManager
// Check for SQLite database file
$sqliteFile = $tempDir . '/var/app.db';
if (file_exists($sqliteFile)) {
$targetDb = $this->projectDir . '/var/app.db';
// Use the actual configured SQLite path from Doctrine, not a hardcoded path
$targetDb = $params['path'] ?? $this->projectDir . '/var/app.db';
$this->filesystem->copy($sqliteFile, $targetDb, true);
return;
}

View file

@ -686,6 +686,33 @@ class UpdateExecutor
}
/**
* Delete a specific update log file.
*/
public function deleteLog(string $filename): bool
{
// Validate filename pattern for security
if (!preg_match('/^update-[\w.\-]+\.log$/', $filename)) {
$this->logger->warning('Attempted to delete invalid log filename: ' . $filename);
return false;
}
$logPath = $this->project_dir . '/' . self::UPDATE_LOG_DIR . '/' . basename($filename);
if (!file_exists($logPath)) {
return false;
}
try {
$this->filesystem->remove($logPath);
$this->logger->info('Deleted update log: ' . $filename);
return true;
} catch (\Exception $e) {
$this->logger->error('Failed to delete update log: ' . $e->getMessage());
return false;
}
}
/**
* Restore from a backup file with maintenance mode and cache clearing.
*

View file

@ -1,5 +1,7 @@
{% extends "main_card.html.twig" %}
{% import "helper.twig" as helper %}
{% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %}
{% block card_title %}
@ -7,60 +9,60 @@
{% endblock %}
{% block card_content %}
<div>
<div>
{# Maintenance Mode Warning #}
{% if is_maintenance %}
<div class="alert alert-warning" role="alert">
<i class="fas fa-tools me-2"></i>
<strong>{% trans %}update_manager.maintenance_mode_active{% endtrans %}</strong>
{% if maintenance_info.reason is defined %}
- {{ maintenance_info.reason }}
{% endif %}
</div>
{% endif %}
{# Maintenance Mode Warning #}
{% if is_maintenance %}
<div class="alert alert-warning" role="alert">
<i class="fas fa-tools me-2"></i>
<strong>{% trans %}update_manager.maintenance_mode_active{% endtrans %}</strong>
{% if maintenance_info.reason is defined %}
- {{ maintenance_info.reason }}
{% endif %}
</div>
{% endif %}
{# Lock Warning #}
{% if is_locked %}
<div class="alert alert-info" role="alert">
<i class="fas fa-lock me-2"></i>
<strong>{% trans %}update_manager.update_in_progress{% endtrans %}</strong>
{% if lock_info.started_at is defined %}
({% trans %}update_manager.started_at{% endtrans %}: {{ lock_info.started_at }})
{% endif %}
<a href="{{ path('admin_update_manager_progress') }}" class="alert-link ms-2">
{% trans %}update_manager.view_progress{% endtrans %}
</a>
</div>
{% endif %}
{# Lock Warning #}
{% if is_locked %}
<div class="alert alert-info" role="alert">
<i class="fas fa-lock me-2"></i>
<strong>{% trans %}update_manager.update_in_progress{% endtrans %}</strong>
{% if lock_info.started_at is defined %}
({% trans %}update_manager.started_at{% endtrans %}: {{ lock_info.started_at }})
{% endif %}
<a href="{{ path('admin_update_manager_progress') }}" class="alert-link ms-2">
{% trans %}update_manager.view_progress{% endtrans %}
</a>
</div>
{% endif %}
{# Web Updates Disabled Warning #}
{% if web_updates_disabled %}
<div class="alert alert-secondary" role="alert">
<i class="fas fa-ban me-2"></i>
<strong>{% trans %}update_manager.web_updates_disabled{% endtrans %}</strong>
<p class="mb-0 mt-2">{% trans %}update_manager.web_updates_disabled_hint{% endtrans %}</p>
</div>
{% endif %}
{# Web Updates Disabled Warning #}
{% if web_updates_disabled %}
<div class="alert alert-secondary" role="alert">
<i class="fas fa-ban me-2"></i>
<strong>{% trans %}update_manager.web_updates_disabled{% endtrans %}</strong>
<p class="mb-0 mt-2">{% trans %}update_manager.web_updates_disabled_hint{% endtrans %}</p>
</div>
{% endif %}
{# Backup Restore Disabled Warning #}
{% if backup_restore_disabled %}
<div class="alert alert-secondary" role="alert">
<i class="fas fa-ban me-2"></i>
<strong>{% trans %}update_manager.backup_restore_disabled{% endtrans %}</strong>
</div>
{% endif %}
{# Backup Restore Disabled Warning #}
{% if backup_restore_disabled %}
<div class="alert alert-secondary" role="alert">
<i class="fas fa-ban me-2"></i>
<strong>{% trans %}update_manager.backup_restore_disabled{% endtrans %}</strong>
</div>
{% endif %}
<div class="row">
{# Current Version Card #}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-info-circle me-2"></i>{% trans %}update_manager.current_installation{% endtrans %}
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tbody>
<div class="row">
{# Current Version Card #}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-info-circle me-2"></i>{% trans %}update_manager.current_installation{% endtrans %}
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tbody>
<tr>
<th scope="row" style="width: 40%">{% trans %}update_manager.version{% endtrans %}</th>
<td>
@ -100,153 +102,159 @@
<tr>
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
<td>
{% if status.can_auto_update %}
<span class="badge bg-success">
<i class="fas fa-check me-1"></i>{% trans %}Yes{% endtrans %}
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-times me-1"></i>{% trans %}No{% endtrans %}
</span>
{% endif %}
{{ helper.boolean_badge(status.can_auto_update) }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="card-footer">
<form action="{{ path('admin_update_manager_refresh') }}" method="post" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_refresh') }}">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-sync-alt me-1"></i> {% trans %}update_manager.refresh{% endtrans %}
</button>
</form>
<tr>
<th scope="row">{% trans %}update_manager.web_updates_allowed{% endtrans %}</th>
<td>{{ helper.boolean_badge(not web_updates_disabled) }}</td>
</tr>
<tr>
<th scope="row">{% trans %}update_manager.backup_restore_allowed{% endtrans %}</th>
<td>{{ helper.boolean_badge(not backup_restore_disabled) }}</td>
</tr>
<tr>
<th scope="row">{% trans %}update_manager.backup_download_allowed{% endtrans %}</th>
<td>{{ helper.boolean_badge(not backup_download_disabled) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="card-footer">
<form action="{{ path('admin_update_manager_refresh') }}" method="post" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_refresh') }}">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-sync-alt me-1"></i> {% trans %}update_manager.refresh{% endtrans %}
</button>
</form>
</div>
</div>
</div>
</div>
{# Latest Version / Update Card #}
<div class="col-lg-6 mb-4">
<div class="card h-100 {{ status.update_available ? 'border-success' : '' }}">
<div class="card-header {{ status.update_available ? 'bg-success text-white' : '' }}">
{% if status.update_available %}
<i class="fas fa-gift me-2"></i>{% trans %}update_manager.new_version_available.title{% endtrans %}
{% else %}
<i class="fas fa-check-circle me-2"></i>{% trans %}update_manager.latest_release{% endtrans %}
{% endif %}
</div>
<div class="card-body">
{% if status.latest_version %}
<div class="text-center mb-3">
{# Latest Version / Update Card #}
<div class="col-lg-6 mb-4">
<div class="card h-100 {{ status.update_available ? 'border-success' : '' }}">
<div class="card-header {{ status.update_available ? 'bg-success text-white' : '' }}">
{% if status.update_available %}
<i class="fas fa-gift me-2"></i>{% trans %}update_manager.new_version_available.title{% endtrans %}
{% else %}
<i class="fas fa-check-circle me-2"></i>{% trans %}update_manager.latest_release{% endtrans %}
{% endif %}
</div>
<div class="card-body">
{% if status.latest_version %}
<div class="text-center mb-3">
<span class="badge bg-{{ status.update_available ? 'success' : 'primary' }} fs-4 px-4 py-2">
{{ status.latest_tag }}
</span>
{% if not status.update_available %}
<p class="text-success mt-2 mb-0">
<i class="fas fa-check-circle me-1"></i>
{% trans %}update_manager.already_up_to_date{% endtrans %}
{% if not status.update_available %}
<p class="text-success mt-2 mb-0">
<i class="fas fa-check-circle me-1"></i>
{% trans %}update_manager.already_up_to_date{% endtrans %}
</p>
{% endif %}
</div>
{% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
<form action="{{ path('admin_update_manager_start') }}" method="post"
data-controller="update-confirm"
data-update-confirm-is-downgrade-value="false"
data-update-confirm-target-version-value="{{ status.latest_tag }}"
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
<input type="hidden" name="version" value="{{ status.latest_tag }}">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-download me-2"></i>
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
</button>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
<label class="form-check-label" for="create-backup">
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
</label>
</div>
</form>
{% endif %}
{% if status.published_at %}
<p class="text-muted small mt-3 mb-0">
<i class="fas fa-calendar me-1"></i>
{% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }}
</p>
{% endif %}
</div>
{% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %}
<form action="{{ path('admin_update_manager_start') }}" method="post"
data-controller="update-confirm"
data-update-confirm-is-downgrade-value="false"
data-update-confirm-target-version-value="{{ status.latest_tag }}"
data-update-confirm-confirm-update-value="{{ 'update_manager.confirm_update'|trans }}"
data-update-confirm-confirm-downgrade-value="{{ 'update_manager.confirm_downgrade'|trans }}"
data-update-confirm-downgrade-warning-value="{{ 'update_manager.downgrade_removes_update_manager'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_start') }}">
<input type="hidden" name="version" value="{{ status.latest_tag }}">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-success btn-lg">
<i class="fas fa-download me-2"></i>
{% trans %}update_manager.update_to{% endtrans %} {{ status.latest_tag }}
</button>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="backup" value="1" id="create-backup" checked>
<label class="form-check-label" for="create-backup">
<i class="fas fa-database me-1"></i> {% trans %}update_manager.create_backup{% endtrans %}
</label>
</div>
</form>
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-question-circle fa-3x mb-3"></i>
<p>{% trans %}update_manager.could_not_fetch_releases{% endtrans %}</p>
</div>
{% endif %}
{% if status.published_at %}
<p class="text-muted small mt-3 mb-0">
<i class="fas fa-calendar me-1"></i>
{% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }}
</p>
{% endif %}
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-question-circle fa-3x mb-3"></i>
<p>{% trans %}update_manager.could_not_fetch_releases{% endtrans %}</p>
</div>
{% if status.latest_tag %}
<div class="card-footer">
<a href="{{ path('admin_update_manager_release', {tag: status.latest_tag}) }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-file-alt me-1"></i> {% trans %}update_manager.view_release_notes{% endtrans %}
</a>
{% if status.release_url %}
<a href="{{ status.release_url }}" class="btn btn-outline-secondary btn-sm" target="_blank">
<i class="fab fa-github me-1"></i> GitHub
</a>
{% endif %}
</div>
{% endif %}
</div>
{% if status.latest_tag %}
<div class="card-footer">
<a href="{{ path('admin_update_manager_release', {tag: status.latest_tag}) }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-file-alt me-1"></i> {% trans %}update_manager.view_release_notes{% endtrans %}
</a>
{% if status.release_url %}
<a href="{{ status.release_url }}" class="btn btn-outline-secondary btn-sm" target="_blank">
<i class="fab fa-github me-1"></i> GitHub
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
{# Validation Issues #}
{% if not validation.valid %}
<div class="alert alert-warning" role="alert">
<h6 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>{% trans %}update_manager.validation_issues{% endtrans %}
</h6>
<ul class="mb-0">
{% for error in validation.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Validation Issues #}
{% if not validation.valid %}
<div class="alert alert-warning" role="alert">
<h6 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>{% trans %}update_manager.validation_issues{% endtrans %}
</h6>
<ul class="mb-0">
{% for error in validation.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Non-auto-update installations info #}
{% if not status.can_auto_update %}
<div class="alert alert-secondary">
<h6 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
</h6>
<p class="mb-0">{{ status.installation.update_instructions }}</p>
</div>
{% endif %}
{# Non-auto-update installations info #}
{% if not status.can_auto_update %}
<div class="alert alert-secondary">
<h6 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>{% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }}
</h6>
<p class="mb-0">{{ status.installation.update_instructions }}</p>
</div>
{% endif %}
<div class="row">
{# Available Versions #}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-tags me-2"></i>{% trans %}update_manager.available_versions{% endtrans %}
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="sticky-top" style="background-color: #f8f9fa;">
<div class="row">
{# Available Versions #}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-tags me-2"></i>{% trans %}update_manager.available_versions{% endtrans %}
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="sticky-top" style="background-color: #f8f9fa;">
<tr>
<th>{% trans %}update_manager.version{% endtrans %}</th>
<th>{% trans %}update_manager.released{% endtrans %}</th>
<th></th>
</tr>
</thead>
<tbody>
</thead>
<tbody>
{% for release in all_releases %}
<tr{% if release.version == status.current_version %} class="table-active"{% endif %}>
<td>
@ -280,8 +288,8 @@
<input type="hidden" name="version" value="{{ release.tag }}">
<input type="hidden" name="backup" value="1">
<button type="submit"
class="btn btn-{{ release.version > status.current_version ? 'outline-success' : 'outline-warning' }}"
title="{% trans %}update_manager.switch_to{% endtrans %} {{ release.tag }}">
class="btn btn-{{ release.version > status.current_version ? 'outline-success' : 'outline-warning' }}"
title="{% trans %}update_manager.switch_to{% endtrans %} {{ release.tag }}">
{% if release.version > status.current_version %}
<i class="fas fa-arrow-up"></i>
{% else %}
@ -300,54 +308,69 @@
</td>
</tr>
{% endfor %}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{# Update History & Backups #}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#logs-tab" type="button">
<i class="fas fa-history me-1"></i>{% trans %}update_manager.update_logs{% endtrans %}
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#backups-tab" type="button">
<i class="fas fa-archive me-1"></i>{% trans %}update_manager.backups{% endtrans %}
</button>
</li>
</ul>
</div>
<div class="card-body p-0">
<div class="tab-content">
<div class="tab-pane fade show active" id="logs-tab">
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="sticky-top" style="background-color: #f8f9fa;">
{# Update History & Backups #}
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#logs-tab" type="button">
<i class="fas fa-history me-1"></i>{% trans %}update_manager.update_logs{% endtrans %}
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#backups-tab" type="button">
<i class="fas fa-archive me-1"></i>{% trans %}update_manager.backups{% endtrans %}
</button>
</li>
</ul>
</div>
<div class="card-body p-0">
<div class="tab-content">
<div class="tab-pane fade show active" id="logs-tab">
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="sticky-top" style="background-color: #f8f9fa;">
<tr>
<th>{% trans %}update_manager.date{% endtrans %}</th>
<th>{% trans %}update_manager.log_file{% endtrans %}</th>
<th></th>
</tr>
</thead>
<tbody>
</thead>
<tbody>
{% for log in update_logs %}
<tr>
<td class="text-muted small">
{{ log.date|date('Y-m-d H:i') }}
</td>
<td><code class="small">{{ log.file }}</code></td>
<td>
<a href="{{ path('admin_update_manager_log', {filename: log.file}) }}"
class="btn btn-sm btn-outline-secondary">
<i class="fas fa-eye"></i>
</a>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{{ path('admin_update_manager_log', {filename: log.file}) }}"
class="btn btn-outline-secondary"
title="{% trans %}update_manager.view_log{% endtrans %}">
<i class="fas fa-eye"></i>
</a>
{% if is_granted('@system.manage_updates') %}
<form action="{{ path('admin_update_manager_log_delete') }}" method="post" class="d-inline"
data-turbo-confirm="{% trans %}update_manager.log.delete.confirm{% endtrans %}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_delete') }}">
<input type="hidden" name="filename" value="{{ log.file }}">
<button type="submit"
class="btn btn-outline-danger"
title="{% trans %}update_manager.delete{% endtrans %}">
<i class="fas fa-trash-alt"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
@ -357,22 +380,39 @@
</td>
</tr>
{% endfor %}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</div>
<div class="tab-pane fade" id="backups-tab">
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="sticky-top" style="background-color: #f8f9fa;">
<div class="tab-pane fade" id="backups-tab">
{% if is_granted('@system.manage_updates') and not is_locked %}
<div class="p-2 border-bottom">
<form action="{{ path('admin_update_manager_backup') }}" method="post" class="d-inline"
data-turbo-confirm="{% trans %}update_manager.backup.create.confirm{% endtrans %}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_backup') }}">
<button type="submit" class="btn btn-sm btn-success">
<i class="fas fa-plus me-1"></i>{% trans %}update_manager.backup.create{% endtrans %}
</button>
</form>
</div>
{% endif %}
{% if is_docker %}
<div class="alert alert-info alert-sm m-2 mb-0 py-2 small">
<i class="fas fa-info-circle me-1"></i>
{% trans %}update_manager.backup.docker_warning{% endtrans %}
</div>
{% endif %}
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="sticky-top" style="background-color: #f8f9fa;">
<tr>
<th>{% trans %}update_manager.date{% endtrans %}</th>
<th>{% trans %}update_manager.file{% endtrans %}</th>
<th>{% trans %}update_manager.size{% endtrans %}</th>
<th></th>
</tr>
</thead>
<tbody>
</thead>
<tbody>
{% for backup in backups %}
<tr>
<td class="text-muted small">
@ -383,23 +423,89 @@
{{ (backup.size / 1024 / 1024)|number_format(1) }} MB
</td>
<td class="text-end">
{% if status.can_auto_update and validation.valid and not backup_restore_disabled %}
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
data-controller="backup-restore"
data-backup-restore-filename-value="{{ backup.file }}"
data-backup-restore-date-value="{{ backup.date|date('Y-m-d H:i') }}"
data-backup-restore-confirm-title-value="{{ 'update_manager.restore_confirm_title'|trans }}"
data-backup-restore-confirm-message-value="{{ 'update_manager.restore_confirm_message'|trans }}"
data-backup-restore-confirm-warning-value="{{ 'update_manager.restore_confirm_warning'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_restore') }}">
<input type="hidden" name="filename" value="{{ backup.file }}">
<input type="hidden" name="restore_database" value="1">
<button type="submit"
class="btn btn-sm btn-outline-warning"
title="{% trans %}update_manager.restore_backup{% endtrans %}">
<i class="fas fa-undo"></i>
<div class="btn-group btn-group-sm">
{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
<button type="button"
class="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#downloadBackupModal-{{ loop.index }}"
title="{% trans %}update_manager.backup.download{% endtrans %}">
<i class="fas fa-download"></i>
</button>
</form>
{% endif %}
{% if not backup_restore_disabled and is_granted('@system.manage_updates') %}
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
{{ stimulus_controller('backup-restore') }}
data-backup-restore-filename-value="{{ backup.file }}"
data-backup-restore-date-value="{{ backup.date|date('Y-m-d H:i') }}"
data-backup-restore-confirm-title-value="{{ 'update_manager.restore_confirm_title'|trans }}"
data-backup-restore-confirm-message-value="{{ 'update_manager.restore_confirm_message'|trans }}"
data-backup-restore-confirm-warning-value="{{ 'update_manager.restore_confirm_warning'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_restore') }}">
<input type="hidden" name="filename" value="{{ backup.file }}">
<input type="hidden" name="restore_database" value="1">
<button type="submit"
class="btn btn-outline-warning"
title="{% trans %}update_manager.restore_backup{% endtrans %}">
<i class="fas fa-undo"></i>
</button>
</form>
{% endif %}
{% if is_granted('@system.manage_updates') %}
<form action="{{ path('admin_update_manager_backup_delete') }}" method="post" class="d-inline"
data-turbo-confirm="{% trans %}update_manager.backup.delete.confirm{% endtrans %}">
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_delete') }}">
<input type="hidden" name="filename" value="{{ backup.file }}">
<button type="submit"
class="btn btn-outline-danger"
title="{% trans %}update_manager.delete{% endtrans %}">
<i class="fas fa-trash-alt"></i>
</button>
</form>
{% endif %}
</div>
{% if not backup_download_disabled and is_granted('@system.manage_updates') %}
{# Per-backup download modal - no inline JS needed, CSP compatible with Turbo #}
<div class="modal fade text-start" id="downloadBackupModal-{{ loop.index }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('admin_update_manager_backup_download') }}" method="post" data-turbo="false">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-download me-2"></i>{% trans %}update_manager.backup.download{% endtrans %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-1"></i>
{% trans %}update_manager.backup.download.security_warning{% endtrans %}
</div>
<p class="text-muted small mb-3">{{ backup.file }}</p>
<input type="hidden" name="_token" value="{{ csrf_token('update_manager_download') }}">
<input type="hidden" name="filename" value="{{ backup.file }}">
<input type="hidden" name="username" autocomplete="username">
<div class="mb-3">
<label for="downloadPassword-{{ loop.index }}" class="form-label">
{% trans %}update_manager.backup.download.password_label{% endtrans %}
</label>
<input type="password" class="form-control" id="downloadPassword-{{ loop.index }}"
name="password" required autocomplete="current-password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans %}modal.cancel{% endtrans %}
</button>
<button type="submit" class="btn btn-primary" data-bs-dismiss="modal">
<i class="fas fa-download me-1"></i>{% trans %}update_manager.backup.download{% endtrans %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
</td>
</tr>
@ -410,8 +516,9 @@
</td>
</tr>
{% endfor %}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</div>
</div>
@ -419,5 +526,5 @@
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,8 +1,8 @@
{% macro boolean(value) %}
{% if value %}
{% trans %}bool.true{% endtrans %}
{% trans %}Yes{% endtrans %}
{% else %}
{% trans %}bool.false{% endtrans %}
{% trans %}No{% endtrans %}
{% endif %}
{% endmacro %}
@ -14,9 +14,9 @@
{% macro bool_icon(bool) %}
{% if bool %}
<i class="fas fa-check-circle fa-fw" title="{% trans %}Yes{% endtrans %}"></i>
<i class="fas fa-check fa-fw" title="{% trans %}Yes{% endtrans %}"></i>
{% else %}
<i class="fas fa-times-circle fa-fw" title="{% trans %}No{% endtrans %}"></i>
<i class="fas fa-times fa-fw" title="{% trans %}No{% endtrans %}"></i>
{% endif %}
{% endmacro %}
@ -24,7 +24,7 @@
{% if value %}
{% set class = class ~ ' bg-success' %}
{% else %}
{% set class = class ~ ' bg-danger' %}
{% set class = class ~ ' bg-secondary' %}
{% endif %}
<span class="{{ class }}">{{ _self.bool_icon(value) }} {{ _self.boolean(value) }}</span>

View file

@ -0,0 +1,381 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Entity\UserSystem\User;
use App\Services\System\BackupManager;
use App\Services\System\UpdateExecutor;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
#[Group("slow")]
#[Group("DB")]
final class UpdateManagerControllerTest extends WebTestCase
{
private function loginAsAdmin($client): void
{
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->findOneBy(['name' => 'admin']);
if (!$user) {
$this->markTestSkipped('Admin user not found');
}
$client->loginUser($user);
}
/**
* Extract a CSRF token from the rendered update manager page.
*/
private function getCsrfTokenFromPage($crawler, string $formAction): string
{
$form = $crawler->filter('form[action*="' . $formAction . '"]');
if ($form->count() === 0) {
$this->fail('Form with action containing "' . $formAction . '" not found on page');
}
return $form->filter('input[name="_token"]')->attr('value');
}
// ---- Authentication tests ----
public function testIndexPageRequiresAuth(): void
{
$client = static::createClient();
$client->request('GET', '/en/system/update-manager');
// Should deny access (401 with HTTP Basic auth in test env)
$this->assertResponseStatusCodeSame(401);
}
public function testIndexPageAccessibleByAdmin(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('GET', '/en/system/update-manager');
$this->assertResponseIsSuccessful();
}
// ---- Backup creation tests ----
public function testCreateBackupRequiresCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('POST', '/en/system/update-manager/backup', [
'_token' => 'invalid',
]);
// Should redirect with error flash
$this->assertResponseRedirects();
}
public function testCreateBackupWithValidCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// Load the page and extract CSRF token from the backup form
$crawler = $client->request('GET', '/en/system/update-manager');
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup');
$client->request('POST', '/en/system/update-manager/backup', [
'_token' => $csrfToken,
]);
$this->assertResponseRedirects();
// Clean up: delete the backup that was just created
$backupManager = $client->getContainer()->get(BackupManager::class);
$backups = $backupManager->getBackups();
foreach ($backups as $backup) {
if (str_contains($backup['file'], 'manual')) {
$backupManager->deleteBackup($backup['file']);
}
}
}
public function testCreateBackupBlockedWhenLocked(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// Load the page first to get CSRF token before locking
$crawler = $client->request('GET', '/en/system/update-manager');
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup');
// Acquire lock to simulate update in progress
$updateExecutor = $client->getContainer()->get(UpdateExecutor::class);
$updateExecutor->acquireLock();
try {
$client->request('POST', '/en/system/update-manager/backup', [
'_token' => $csrfToken,
]);
$this->assertResponseRedirects();
} finally {
// Always release lock
$updateExecutor->releaseLock();
}
}
// ---- Backup deletion tests ----
public function testDeleteBackupRequiresCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('POST', '/en/system/update-manager/backup/delete', [
'_token' => 'invalid',
'filename' => 'test.zip',
]);
$this->assertResponseRedirects();
}
public function testDeleteBackupWithValidCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// Create a temporary backup file so the page shows the delete form
$backupManager = $client->getContainer()->get(BackupManager::class);
$backupDir = $backupManager->getBackupDir();
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
$testFile = 'test-delete-' . uniqid() . '.zip';
file_put_contents($backupDir . '/' . $testFile, 'test');
// Load the page and extract CSRF token from the delete form
$crawler = $client->request('GET', '/en/system/update-manager');
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup/delete');
$client->request('POST', '/en/system/update-manager/backup/delete', [
'_token' => $csrfToken,
'filename' => $testFile,
]);
$this->assertResponseRedirects();
$this->assertFileDoesNotExist($backupDir . '/' . $testFile);
}
// ---- Log deletion tests ----
public function testDeleteLogRequiresCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('POST', '/en/system/update-manager/log/delete', [
'_token' => 'invalid',
'filename' => 'test.log',
]);
$this->assertResponseRedirects();
}
public function testDeleteLogWithValidCsrf(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// Create a temporary log file so the page shows the delete form
$projectDir = $client->getContainer()->getParameter('kernel.project_dir');
$logDir = $projectDir . '/var/log/updates';
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$testFile = 'update-test-delete-' . uniqid() . '.log';
file_put_contents($logDir . '/' . $testFile, 'test log content');
// Load the page and extract CSRF token from the log delete form
$crawler = $client->request('GET', '/en/system/update-manager');
$csrfToken = $this->getCsrfTokenFromPage($crawler, 'log/delete');
$client->request('POST', '/en/system/update-manager/log/delete', [
'_token' => $csrfToken,
'filename' => $testFile,
]);
$this->assertResponseRedirects();
$this->assertFileDoesNotExist($logDir . '/' . $testFile);
}
// ---- Backup download tests ----
public function testDownloadBackupBlockedByDefault(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// DISABLE_BACKUP_DOWNLOAD=1 is the default in .env, so this should return 403
$client->request('POST', '/en/system/update-manager/backup/download', [
'_token' => 'any',
'filename' => 'test.zip',
'password' => 'test',
]);
$this->assertResponseStatusCodeSame(403);
}
public function testDownloadBackupRequiresPost(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// GET returns 404 since no GET route exists for this path
$client->request('GET', '/en/system/update-manager/backup/download');
$this->assertResponseStatusCodeSame(404);
}
public function testDownloadBackupRequiresAuth(): void
{
$client = static::createClient();
$client->request('POST', '/en/system/update-manager/backup/download', [
'_token' => 'any',
'filename' => 'test.zip',
'password' => 'test',
]);
// Should deny access (401 with HTTP Basic auth in test env)
$this->assertResponseStatusCodeSame(401);
}
// ---- Backup details tests ----
public function testBackupDetailsReturns404ForNonExistent(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('GET', '/en/system/update-manager/backup/nonexistent.zip');
$this->assertResponseStatusCodeSame(404);
}
// ---- Restore tests ----
public function testRestoreBlockedWhenDisabled(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// DISABLE_BACKUP_RESTORE=1 is the default in .env, so this should return 403
$client->request('POST', '/en/system/update-manager/restore', [
'_token' => 'invalid',
'filename' => 'test.zip',
]);
$this->assertResponseStatusCodeSame(403);
}
public function testRestoreRequiresAuth(): void
{
$client = static::createClient();
$client->request('POST', '/en/system/update-manager/restore', [
'_token' => 'invalid',
'filename' => 'test.zip',
]);
$this->assertResponseStatusCodeSame(401);
}
// ---- Start update tests ----
public function testStartUpdateRequiresAuth(): void
{
$client = static::createClient();
$client->request('POST', '/en/system/update-manager/start', [
'_token' => 'invalid',
'version' => 'v1.0.0',
]);
$this->assertResponseStatusCodeSame(401);
}
public function testStartUpdateBlockedWhenWebUpdatesDisabled(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
// DISABLE_WEB_UPDATES=1 is the default in .env
$client->request('POST', '/en/system/update-manager/start', [
'_token' => 'invalid',
'version' => 'v1.0.0',
]);
$this->assertResponseStatusCodeSame(403);
}
// ---- Status and progress tests ----
public function testStatusEndpointRequiresAuth(): void
{
$client = static::createClient();
$client->request('GET', '/en/system/update-manager/status');
$this->assertResponseStatusCodeSame(401);
}
public function testStatusEndpointAccessibleByAdmin(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('GET', '/en/system/update-manager/status');
$this->assertResponseIsSuccessful();
}
public function testProgressStatusEndpointRequiresAuth(): void
{
$client = static::createClient();
$client->request('GET', '/en/system/update-manager/progress/status');
$this->assertResponseStatusCodeSame(401);
}
public function testProgressStatusEndpointAccessibleByAdmin(): void
{
$client = static::createClient();
$this->loginAsAdmin($client);
$client->request('GET', '/en/system/update-manager/progress/status');
$this->assertResponseIsSuccessful();
}
}

View file

@ -82,6 +82,16 @@ final class BackupManagerTest extends KernelTestCase
$this->assertSame('2.6.0', $matches[2]);
}
public function testDeleteBackupReturnsFalseForNonExistentFile(): void
{
$this->assertFalse($this->backupManager->deleteBackup('non-existent.zip'));
}
public function testDeleteBackupReturnsFalseForNonZipFile(): void
{
$this->assertFalse($this->backupManager->deleteBackup('not-a-zip.txt'));
}
/**
* Test version parsing with different filename formats.
*/

View file

@ -139,6 +139,38 @@ final class UpdateExecutorTest extends KernelTestCase
$this->assertFalse($this->updateExecutor->isLocked());
}
public function testDeleteLogRejectsInvalidFilename(): void
{
// Path traversal attempts should be rejected
$this->assertFalse($this->updateExecutor->deleteLog('../../../etc/passwd'));
$this->assertFalse($this->updateExecutor->deleteLog('malicious.txt'));
$this->assertFalse($this->updateExecutor->deleteLog(''));
// Must start with "update-"
$this->assertFalse($this->updateExecutor->deleteLog('backup-v1.0.0.log'));
}
public function testDeleteLogReturnsFalseForNonExistentFile(): void
{
$this->assertFalse($this->updateExecutor->deleteLog('update-nonexistent-file.log'));
}
public function testDeleteLogDeletesExistingFile(): void
{
// Create a temporary log file in the update logs directory
$projectDir = self::getContainer()->getParameter('kernel.project_dir');
$logDir = $projectDir . '/var/log/updates';
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$testFile = 'update-test-delete-' . uniqid() . '.log';
file_put_contents($logDir . '/' . $testFile, 'test log content');
$this->assertTrue($this->updateExecutor->deleteLog($testFile));
$this->assertFileDoesNotExist($logDir . '/' . $testFile);
}
public function testEnableAndDisableMaintenanceMode(): void
{
// First, ensure maintenance mode is off

View file

@ -12491,6 +12491,102 @@ Buerklin-API Authentication server:
<target>Backup restore is disabled by server configuration.</target>
</segment>
</unit>
<unit id="oAb35wU" name="update_manager.backup.create">
<segment state="translated">
<source>update_manager.backup.create</source>
<target>Create Backup</target>
</segment>
</unit>
<unit id="ms26oI0" name="update_manager.backup.create.confirm">
<segment state="translated">
<source>update_manager.backup.create.confirm</source>
<target>Create a full backup now? This may take a moment.</target>
</segment>
</unit>
<unit id="H9y0eLa" name="update_manager.backup.created">
<segment state="translated">
<source>update_manager.backup.created</source>
<target>Backup created successfully.</target>
</segment>
</unit>
<unit id="bMhXPVB" name="update_manager.backup.delete.confirm">
<segment state="translated">
<source>update_manager.backup.delete.confirm</source>
<target>Are you sure you want to delete this backup?</target>
</segment>
</unit>
<unit id="8tw67c_" name="update_manager.backup.deleted">
<segment state="translated">
<source>update_manager.backup.deleted</source>
<target>Backup deleted successfully.</target>
</segment>
</unit>
<unit id="BzBBuqk" name="update_manager.backup.delete_error">
<segment state="translated">
<source>update_manager.backup.delete_error</source>
<target>Failed to delete backup.</target>
</segment>
</unit>
<unit id="2olmcSs" name="update_manager.log.delete.confirm">
<segment state="translated">
<source>update_manager.log.delete.confirm</source>
<target>Are you sure you want to delete this log?</target>
</segment>
</unit>
<unit id=".ZrVHpp" name="update_manager.log.deleted">
<segment state="translated">
<source>update_manager.log.deleted</source>
<target>Log deleted successfully.</target>
</segment>
</unit>
<unit id="P2JI5Yw" name="update_manager.log.delete_error">
<segment state="translated">
<source>update_manager.log.delete_error</source>
<target>Failed to delete log.</target>
</segment>
</unit>
<unit id="Yos9FWk" name="update_manager.view_log">
<segment state="translated">
<source>update_manager.view_log</source>
<target>View log</target>
</segment>
</unit>
<unit id="B9uA2va" name="update_manager.delete">
<segment state="translated">
<source>update_manager.delete</source>
<target>Delete</target>
</segment>
</unit>
<unit id="ZtgvnXB" name="update_manager.backup.download">
<segment state="translated">
<source>update_manager.backup.download</source>
<target>Download backup</target>
</segment>
</unit>
<unit id="wxtmrnP" name="update_manager.backup.download.password_label">
<segment state="translated">
<source>update_manager.backup.download.password_label</source>
<target>Confirm your password to download</target>
</segment>
</unit>
<unit id="MIlTTgL" name="update_manager.backup.download.security_warning">
<segment state="translated">
<source>update_manager.backup.download.security_warning</source>
<target>Backups contain sensitive data including password hashes and secrets. Please confirm your password to proceed with the download.</target>
</segment>
</unit>
<unit id="kZPHBRt" name="update_manager.backup.download.invalid_password">
<segment state="translated">
<source>update_manager.backup.download.invalid_password</source>
<target>Invalid password. Backup download denied.</target>
</segment>
</unit>
<unit id="AZOjnE0" name="update_manager.backup.docker_warning">
<segment state="translated">
<source>update_manager.backup.docker_warning</source>
<target>Docker installation detected. Backups are stored in var/backups/ which is not a persistent volume. Use the download button to save backups externally, or mount var/backups/ as a volume in your docker-compose.yml.</target>
</segment>
</unit>
<unit id="kHKChQB" name="settings.ips.conrad">
<segment state="translated">
<source>settings.ips.conrad</source>
@ -12821,5 +12917,29 @@ Buerklin-API Authentication server:
<target>View as plain text</target>
</segment>
</unit>
<unit id="Ehsj93c" name="modal.cancel">
<segment>
<source>modal.cancel</source>
<target>Cancel</target>
</segment>
</unit>
<unit id="jdpoFf2" name="update_manager.web_updates_allowed">
<segment>
<source>update_manager.web_updates_allowed</source>
<target>Web updates allowed</target>
</segment>
</unit>
<unit id="bdWa7is" name="update_manager.backup_restore_allowed">
<segment>
<source>update_manager.backup_restore_allowed</source>
<target>Backup restore allowed</target>
</segment>
</unit>
<unit id="kllGQEN" name="update_manager.backup_download_allowed">
<segment>
<source>update_manager.backup_download_allowed</source>
<target>Backup download allowed</target>
</segment>
</unit>
</file>
</xliff>