mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-06-04 01:31:41 +00:00
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:
parent
db8881621c
commit
0d58262e19
11 changed files with 1077 additions and 246 deletions
5
.env
5
.env
|
|
@ -71,6 +71,11 @@ DISABLE_WEB_UPDATES=1
|
||||||
# Restoring backups is a destructive operation that could overwrite your database.
|
# Restoring backups is a destructive operation that could overwrite your database.
|
||||||
DISABLE_BACKUP_RESTORE=1
|
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
|
# SAML Single sign on-settings
|
||||||
###################################################################################
|
###################################################################################
|
||||||
|
|
|
||||||
|
|
@ -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
|
* `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.
|
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 related settings
|
||||||
|
|
||||||
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set
|
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,21 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
use App\Services\System\BackupManager;
|
use App\Services\System\BackupManager;
|
||||||
|
use App\Services\System\InstallationTypeDetector;
|
||||||
use App\Services\System\UpdateChecker;
|
use App\Services\System\UpdateChecker;
|
||||||
use App\Services\System\UpdateExecutor;
|
use App\Services\System\UpdateExecutor;
|
||||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -49,10 +54,14 @@ class UpdateManagerController extends AbstractController
|
||||||
private readonly UpdateExecutor $updateExecutor,
|
private readonly UpdateExecutor $updateExecutor,
|
||||||
private readonly VersionManagerInterface $versionManager,
|
private readonly VersionManagerInterface $versionManager,
|
||||||
private readonly BackupManager $backupManager,
|
private readonly BackupManager $backupManager,
|
||||||
|
private readonly InstallationTypeDetector $installationTypeDetector,
|
||||||
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
#[Autowire(env: 'bool:DISABLE_WEB_UPDATES')]
|
||||||
private readonly bool $webUpdatesDisabled = false,
|
private readonly bool $webUpdatesDisabled = false,
|
||||||
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
#[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')]
|
||||||
private readonly bool $backupRestoreDisabled = false,
|
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.
|
* Main update manager page.
|
||||||
*/
|
*/
|
||||||
|
|
@ -101,6 +120,8 @@ class UpdateManagerController extends AbstractController
|
||||||
'backups' => $this->backupManager->getBackups(),
|
'backups' => $this->backupManager->getBackups(),
|
||||||
'web_updates_disabled' => $this->webUpdatesDisabled,
|
'web_updates_disabled' => $this->webUpdatesDisabled,
|
||||||
'backup_restore_disabled' => $this->backupRestoreDisabled,
|
'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'])]
|
#[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])]
|
||||||
public function startUpdate(Request $request): Response
|
public function startUpdate(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||||
$this->denyIfWebUpdatesDisabled();
|
$this->denyIfWebUpdatesDisabled();
|
||||||
|
|
||||||
|
|
@ -314,12 +336,126 @@ class UpdateManagerController extends AbstractController
|
||||||
return $this->json($details);
|
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.
|
* Restore from a backup.
|
||||||
*/
|
*/
|
||||||
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
|
#[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])]
|
||||||
public function restore(Request $request): Response
|
public function restore(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
$this->denyAccessUnlessGranted('@system.manage_updates');
|
$this->denyAccessUnlessGranted('@system.manage_updates');
|
||||||
$this->denyIfBackupRestoreDisabled();
|
$this->denyIfBackupRestoreDisabled();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -327,15 +327,15 @@ readonly class BackupManager
|
||||||
*/
|
*/
|
||||||
private function restoreDatabaseFromBackup(string $tempDir): void
|
private function restoreDatabaseFromBackup(string $tempDir): void
|
||||||
{
|
{
|
||||||
// Check for SQL dump (MySQL/PostgreSQL)
|
// Get database connection params from Doctrine
|
||||||
$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();
|
$connection = $this->entityManager->getConnection();
|
||||||
$params = $connection->getParams();
|
$params = $connection->getParams();
|
||||||
$platform = $connection->getDatabasePlatform();
|
$platform = $connection->getDatabasePlatform();
|
||||||
|
|
||||||
|
// Check for SQL dump (MySQL/PostgreSQL)
|
||||||
|
$sqlFile = $tempDir . '/database.sql';
|
||||||
|
if (file_exists($sqlFile)) {
|
||||||
|
|
||||||
if ($platform instanceof AbstractMySQLPlatform) {
|
if ($platform instanceof AbstractMySQLPlatform) {
|
||||||
// Use mysql command to import - need to use shell to handle input redirection
|
// Use mysql command to import - need to use shell to handle input redirection
|
||||||
$mysqlCmd = 'mysql';
|
$mysqlCmd = 'mysql';
|
||||||
|
|
@ -403,7 +403,8 @@ readonly class BackupManager
|
||||||
// Check for SQLite database file
|
// Check for SQLite database file
|
||||||
$sqliteFile = $tempDir . '/var/app.db';
|
$sqliteFile = $tempDir . '/var/app.db';
|
||||||
if (file_exists($sqliteFile)) {
|
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);
|
$this->filesystem->copy($sqliteFile, $targetDb, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* Restore from a backup file with maintenance mode and cache clearing.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
{% extends "main_card.html.twig" %}
|
{% extends "main_card.html.twig" %}
|
||||||
|
|
||||||
|
{% import "helper.twig" as helper %}
|
||||||
|
|
||||||
{% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %}
|
{% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %}
|
||||||
|
|
||||||
{% block card_title %}
|
{% block card_title %}
|
||||||
|
|
@ -100,17 +102,23 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
|
<th scope="row">{% trans %}update_manager.auto_update_supported{% endtrans %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% if status.can_auto_update %}
|
{{ helper.boolean_badge(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 %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -343,11 +351,26 @@
|
||||||
{{ log.date|date('Y-m-d H:i') }}
|
{{ log.date|date('Y-m-d H:i') }}
|
||||||
</td>
|
</td>
|
||||||
<td><code class="small">{{ log.file }}</code></td>
|
<td><code class="small">{{ log.file }}</code></td>
|
||||||
<td>
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
<a href="{{ path('admin_update_manager_log', {filename: log.file}) }}"
|
<a href="{{ path('admin_update_manager_log', {filename: log.file}) }}"
|
||||||
class="btn btn-sm btn-outline-secondary">
|
class="btn btn-outline-secondary"
|
||||||
|
title="{% trans %}update_manager.view_log{% endtrans %}">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -362,6 +385,23 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane fade" id="backups-tab">
|
<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;">
|
<div class="table-responsive" style="max-height: 350px; overflow-y: auto;">
|
||||||
<table class="table table-hover table-sm mb-0">
|
<table class="table table-hover table-sm mb-0">
|
||||||
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
<thead class="sticky-top" style="background-color: #f8f9fa;">
|
||||||
|
|
@ -383,9 +423,19 @@
|
||||||
{{ (backup.size / 1024 / 1024)|number_format(1) }} MB
|
{{ (backup.size / 1024 / 1024)|number_format(1) }} MB
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
{% if status.can_auto_update and validation.valid and not backup_restore_disabled %}
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
{% if not backup_restore_disabled and is_granted('@system.manage_updates') %}
|
||||||
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
|
<form action="{{ path('admin_update_manager_restore') }}" method="post" class="d-inline"
|
||||||
data-controller="backup-restore"
|
{{ stimulus_controller('backup-restore') }}
|
||||||
data-backup-restore-filename-value="{{ backup.file }}"
|
data-backup-restore-filename-value="{{ backup.file }}"
|
||||||
data-backup-restore-date-value="{{ backup.date|date('Y-m-d H:i') }}"
|
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-title-value="{{ 'update_manager.restore_confirm_title'|trans }}"
|
||||||
|
|
@ -395,12 +445,68 @@
|
||||||
<input type="hidden" name="filename" value="{{ backup.file }}">
|
<input type="hidden" name="filename" value="{{ backup.file }}">
|
||||||
<input type="hidden" name="restore_database" value="1">
|
<input type="hidden" name="restore_database" value="1">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="btn btn-sm btn-outline-warning"
|
class="btn btn-outline-warning"
|
||||||
title="{% trans %}update_manager.restore_backup{% endtrans %}">
|
title="{% trans %}update_manager.restore_backup{% endtrans %}">
|
||||||
<i class="fas fa-undo"></i>
|
<i class="fas fa-undo"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -420,4 +526,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{% macro boolean(value) %}
|
{% macro boolean(value) %}
|
||||||
{% if value %}
|
{% if value %}
|
||||||
{% trans %}bool.true{% endtrans %}
|
{% trans %}Yes{% endtrans %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans %}bool.false{% endtrans %}
|
{% trans %}No{% endtrans %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
|
|
||||||
{% macro bool_icon(bool) %}
|
{% macro bool_icon(bool) %}
|
||||||
{% if 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
{% if value %}
|
{% if value %}
|
||||||
{% set class = class ~ ' bg-success' %}
|
{% set class = class ~ ' bg-success' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set class = class ~ ' bg-danger' %}
|
{% set class = class ~ ' bg-secondary' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<span class="{{ class }}">{{ _self.bool_icon(value) }} {{ _self.boolean(value) }}</span>
|
<span class="{{ class }}">{{ _self.bool_icon(value) }} {{ _self.boolean(value) }}</span>
|
||||||
|
|
|
||||||
381
tests/Controller/UpdateManagerControllerTest.php
Normal file
381
tests/Controller/UpdateManagerControllerTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -82,6 +82,16 @@ final class BackupManagerTest extends KernelTestCase
|
||||||
$this->assertSame('2.6.0', $matches[2]);
|
$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.
|
* Test version parsing with different filename formats.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,38 @@ final class UpdateExecutorTest extends KernelTestCase
|
||||||
$this->assertFalse($this->updateExecutor->isLocked());
|
$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
|
public function testEnableAndDisableMaintenanceMode(): void
|
||||||
{
|
{
|
||||||
// First, ensure maintenance mode is off
|
// First, ensure maintenance mode is off
|
||||||
|
|
|
||||||
|
|
@ -12491,6 +12491,102 @@ Buerklin-API Authentication server:
|
||||||
<target>Backup restore is disabled by server configuration.</target>
|
<target>Backup restore is disabled by server configuration.</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="kHKChQB" name="settings.ips.conrad">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.ips.conrad</source>
|
<source>settings.ips.conrad</source>
|
||||||
|
|
@ -12821,5 +12917,29 @@ Buerklin-API Authentication server:
|
||||||
<target>View as plain text</target>
|
<target>View as plain text</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue