Compare commits
10 commits
4dc27cb86f
...
5b86d89be2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b86d89be2 | ||
|
|
e53b4ca85a | ||
|
|
e3517c3fcd | ||
|
|
c409b957c2 | ||
|
|
48def71a28 | ||
|
|
800f4c233f | ||
|
|
65e59e740c | ||
|
|
4e2f181dd9 | ||
|
|
c39a24a4f2 | ||
|
|
02ce67049e |
44 changed files with 2229 additions and 10 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -7,6 +7,8 @@
|
|||
/public/bundles/
|
||||
/var/
|
||||
/vendor/
|
||||
/.superpowers/
|
||||
/.playwright-mcp/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
|
|
|
|||
69
README.md
Normal file
69
README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Get Installer Bootstrap
|
||||
|
||||
Application web qui sert des scripts `.sh` depuis un cache local alimenté par des dépôts Git.
|
||||
|
||||
Exemple : un mapping `mcp/graylog/install.sh` peut servir le fichier `install.sh` du dépôt `https://forge.lclr.dev/AI/graylog-mcp.git`.
|
||||
|
||||
## Fonctionnement
|
||||
|
||||
- `/admin` affiche l’écran de connexion puis l’interface de gestion.
|
||||
- `/` redirige vers l’URL configurée dans `/admin/settings`, ou vers `/admin` si aucune URL n’est configurée.
|
||||
- Un mapping lie un chemin public `.sh` à une URL Git, une référence Git et un chemin de fichier dans le dépôt.
|
||||
- Le bouton `Synchroniser` clone ou met à jour le dépôt, extrait la référence demandée et copie le fichier dans le cache.
|
||||
- En production, un cron embarqué dans l’image relance `app:mappings:sync` toutes les 5 minutes.
|
||||
- Les chemins publics hors `/admin` servent uniquement les scripts déjà synchronisés.
|
||||
- Un token d’accès optionnel peut être renseigné pour les dépôts privés en HTTPS. Il est utilisé via `GIT_ASKPASS` pendant la synchronisation.
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f compose.dev.yaml up -d --build
|
||||
docker compose -f compose.yaml -f compose.dev.yaml exec app php bin/console app:admin:create admin 'change-me'
|
||||
```
|
||||
|
||||
L’application est disponible sur `http://localhost:8080`.
|
||||
|
||||
Le fichier `compose.dev.yaml` monte le répertoire local dans `/app`, ce qui permet de tester les changements sans rebuild de l’image.
|
||||
|
||||
## Production / Coolify
|
||||
|
||||
Déployer avec `compose.yaml`.
|
||||
|
||||
Variables utiles :
|
||||
|
||||
```dotenv
|
||||
APP_SECRET=replace-with-a-long-random-secret
|
||||
HTTP_PORT=8080
|
||||
```
|
||||
|
||||
Les migrations SQLite sont exécutées au démarrage du conteneur `app`. Les volumes Docker conservent :
|
||||
|
||||
- `app-data` : base SQLite `/app/var/data/app.db`
|
||||
- `app-cache` : dépôts clonés et scripts servis depuis `/app/var/bootstrap-cache`
|
||||
|
||||
Le fichier `crontab` à la racine du projet est copié dans l’image `app`. Le cron démarre uniquement quand `APP_ENV=prod` et que le conteneur lance `php-fpm`.
|
||||
|
||||
Créer ou mettre à jour un compte admin :
|
||||
|
||||
```bash
|
||||
docker compose exec app php bin/console app:admin:create admin 'strong-password'
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f compose.dev.yaml run --rm -e APP_ENV=test app php vendor/bin/phpunit
|
||||
```
|
||||
|
||||
## Servir un script
|
||||
|
||||
1. Se connecter sur `/admin`.
|
||||
2. Optionnel : configurer l’URL de redirection de `/` via `Configuration`.
|
||||
3. Créer un mapping :
|
||||
- Chemin public : `mcp/graylog/install.sh`
|
||||
- Dépôt : `https://forge.lclr.dev/AI/graylog-mcp.git`
|
||||
- Réf. : `main`
|
||||
- Fichier : `install.sh`
|
||||
- Token : optionnel, pour dépôt privé
|
||||
4. Cliquer sur `Synchroniser`.
|
||||
5. Appeler `http://localhost:8080/mcp/graylog/install.sh`.
|
||||
|
|
@ -5,7 +5,10 @@ security:
|
|||
|
||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
||||
providers:
|
||||
users_in_memory: { memory: null }
|
||||
app_user_provider:
|
||||
entity:
|
||||
class: App\Entity\User
|
||||
property: username
|
||||
|
||||
firewalls:
|
||||
dev:
|
||||
|
|
@ -14,17 +17,16 @@ security:
|
|||
security: false
|
||||
main:
|
||||
lazy: true
|
||||
provider: users_in_memory
|
||||
|
||||
# Activate different ways to authenticate:
|
||||
# https://symfony.com/doc/current/security.html#the-firewall
|
||||
|
||||
# https://symfony.com/doc/current/security/impersonating_user.html
|
||||
# switch_user: true
|
||||
provider: app_user_provider
|
||||
custom_authenticator: App\Security\LoginFormAuthenticator
|
||||
logout:
|
||||
path: admin_logout
|
||||
target: admin_login
|
||||
|
||||
# Note: Only the *first* matching rule is applied
|
||||
access_control:
|
||||
# - { path: ^/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/admin/login$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/admin, roles: ROLE_ADMIN }
|
||||
# - { path: ^/profile, roles: ROLE_USER }
|
||||
|
||||
when@test:
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@
|
|||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
app.cache_dir: '%env(APP_CACHE_DIR)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
autowire: true # Automatically injects dependencies in your services.
|
||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||
bind:
|
||||
string $cacheDir: '%app.cache_dir%'
|
||||
string $projectDir: '%kernel.project_dir%'
|
||||
|
||||
# makes classes in src/ available to be used as services
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
|
|
|
|||
4
crontab
Normal file
4
crontab
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
SHELL=/bin/sh
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
*/5 * * * * . /app/var/cron-env && su -m www-data -s /bin/sh -c 'cd /app && php bin/console app:mappings:sync' >> /proc/1/fd/1 2>> /proc/1/fd/2
|
||||
|
|
@ -14,8 +14,10 @@ RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-
|
|||
COPY . .
|
||||
COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini
|
||||
COPY docker/entrypoint.sh /usr/local/bin/app-entrypoint
|
||||
COPY crontab /etc/crontabs/root
|
||||
|
||||
RUN chmod +x /usr/local/bin/app-entrypoint \
|
||||
&& chmod 0600 /etc/crontabs/root \
|
||||
&& mkdir -p var/data var/bootstrap-cache \
|
||||
&& composer dump-autoload --classmap-authoritative --no-dev \
|
||||
&& composer run-script --no-dev post-install-cmd \
|
||||
|
|
|
|||
|
|
@ -5,7 +5,24 @@ mkdir -p /app/var/data /app/var/bootstrap-cache
|
|||
chown -R www-data:www-data /app/var
|
||||
|
||||
if [ "${APP_ENV:-prod}" != "test" ]; then
|
||||
php /app/bin/console cache:clear --no-warmup --no-interaction
|
||||
php /app/bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||
fi
|
||||
|
||||
if [ "${APP_ENV:-prod}" = "prod" ] && [ "${1:-}" = "php-fpm" ]; then
|
||||
php -r '
|
||||
$names = ["APP_ENV", "APP_DEBUG", "APP_SECRET", "DATABASE_URL", "APP_CACHE_DIR", "DEFAULT_URI"];
|
||||
foreach ($names as $name) {
|
||||
$value = getenv($name);
|
||||
if ($value !== false) {
|
||||
echo "export ".$name."=".escapeshellarg($value).PHP_EOL;
|
||||
}
|
||||
}
|
||||
' > /app/var/cron-env
|
||||
chown www-data:www-data /app/var/cron-env
|
||||
chmod 0600 /app/var/cron-env
|
||||
|
||||
crond -l 8 -L /proc/1/fd/1
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
|
|
|||
27
migrations/Version20260505081600.php
Normal file
27
migrations/Version20260505081600.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260505081600 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create application settings.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE app_setting (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(190) NOT NULL, value CLOB DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_app_setting_name ON app_setting (name)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE app_setting');
|
||||
}
|
||||
}
|
||||
322
public/admin.css
Normal file
322
public/admin.css
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--admin-bg: #f6f7f9;
|
||||
--admin-panel: #ffffff;
|
||||
--admin-panel-soft: #eef1f5;
|
||||
--admin-text: #172033;
|
||||
--admin-muted: #647083;
|
||||
--admin-border: #dfe4ec;
|
||||
--admin-border-soft: #edf0f4;
|
||||
--admin-primary: #172033;
|
||||
--admin-primary-hover: #26344d;
|
||||
--admin-danger: #b42318;
|
||||
--admin-danger-soft: #fff1f0;
|
||||
--admin-success: #0d7a4f;
|
||||
--admin-success-soft: #ecfdf3;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.admin-page,
|
||||
body.admin-auth {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--admin-bg);
|
||||
color: var(--admin-text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
width: min(1440px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.admin-toolbar h1,
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
color: var(--admin-text);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--admin-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbar-actions,
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--admin-primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background: var(--admin-primary-hover);
|
||||
}
|
||||
|
||||
.button-secondary,
|
||||
.button-ghost {
|
||||
border-color: var(--admin-border);
|
||||
background: #ffffff;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.button-secondary:hover,
|
||||
.button-ghost:hover {
|
||||
background: var(--admin-panel-soft);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
border-color: #f3b4ae;
|
||||
background: var(--admin-danger-soft);
|
||||
color: var(--admin-danger);
|
||||
}
|
||||
|
||||
.button-danger:hover {
|
||||
background: #ffe3e0;
|
||||
}
|
||||
|
||||
.table-panel,
|
||||
.admin-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
background: var(--admin-panel);
|
||||
box-shadow: 0 16px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: var(--admin-panel-soft);
|
||||
color: var(--admin-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 11px 12px;
|
||||
border-bottom: 1px solid var(--admin-border-soft);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-table code {
|
||||
color: #243149;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.error-cell {
|
||||
max-width: 260px;
|
||||
color: var(--admin-danger);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
min-width: 42px;
|
||||
justify-content: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: var(--admin-success-soft);
|
||||
color: var(--admin-success);
|
||||
}
|
||||
|
||||
.status-muted {
|
||||
background: var(--admin-panel-soft);
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--admin-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin: 0 0 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
border-color: #b7e4ce;
|
||||
background: var(--admin-success-soft);
|
||||
color: var(--admin-success);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
border-color: #f3b4ae;
|
||||
background: var(--admin-danger-soft);
|
||||
color: var(--admin-danger);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(100%, 380px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-form-shell {
|
||||
width: min(760px, calc(100% - 48px));
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-form > div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-form label {
|
||||
color: var(--admin-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-form input[type="text"],
|
||||
.admin-form input[type="password"],
|
||||
.admin-form input[type="url"] {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--admin-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.admin-form input[type="text"]:focus,
|
||||
.admin-form input[type="password"]:focus,
|
||||
.admin-form input[type="url"]:focus {
|
||||
border-color: var(--admin-primary);
|
||||
outline: 3px solid rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.admin-form input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--admin-primary);
|
||||
}
|
||||
|
||||
.admin-form .help-text,
|
||||
.admin-form .form-text,
|
||||
.admin-form small {
|
||||
color: var(--admin-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-form ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--admin-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-shell,
|
||||
.admin-form-shell {
|
||||
width: min(100% - 24px, 760px);
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar-actions .button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.table-panel {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
min-width: 980px;
|
||||
}
|
||||
}
|
||||
48
src/Command/CreateAdminUserCommand.php
Normal file
48
src/Command/CreateAdminUserCommand.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
#[AsCommand(name: 'app:admin:create', description: 'Create or update an admin user.')]
|
||||
final class CreateAdminUserCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('username', InputArgument::REQUIRED)
|
||||
->addArgument('password', InputArgument::REQUIRED);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$username = (string) $input->getArgument('username');
|
||||
$password = (string) $input->getArgument('password');
|
||||
|
||||
$user = $this->userRepository->findOneBy(['username' => $username]) ?? (new User())->setUsername($username);
|
||||
$user->setPasswordHash($this->passwordHasher->hashPassword($user, $password));
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$output->writeln(sprintf('Admin user "%s" is ready.', $username));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
55
src/Command/SyncScriptMappingsCommand.php
Normal file
55
src/Command/SyncScriptMappingsCommand.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Exception\GitSyncFailedException;
|
||||
use App\Repository\ScriptMappingRepository;
|
||||
use App\Service\GitSynchronizer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'app:mappings:sync', description: 'Synchronize all script mappings.')]
|
||||
final class SyncScriptMappingsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ScriptMappingRepository $scriptMappingRepository,
|
||||
private readonly GitSynchronizer $gitSynchronizer,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$synchronized = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($this->scriptMappingRepository->findAllOrderedByPublicPath() as $mapping) {
|
||||
try {
|
||||
$this->gitSynchronizer->sync($mapping);
|
||||
++$synchronized;
|
||||
} catch (GitSyncFailedException) {
|
||||
++$failed;
|
||||
$io->error(sprintf('Mapping "%s" synchronization failed.', $mapping->getPublicPath()));
|
||||
} finally {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
$summary = sprintf('%d mapping(s) synchronized, %d failed.', $synchronized, $failed);
|
||||
if ($failed === 0) {
|
||||
$io->success($summary);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->warning($summary);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
36
src/Controller/Admin/AuthController.php
Normal file
36
src/Controller/Admin/AuthController.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Form\LoginFormType;
|
||||
use LogicException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
|
||||
final class AuthController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/login', name: 'admin_login', methods: ['GET', 'POST'])]
|
||||
public function login(AuthenticationUtils $authenticationUtils): Response
|
||||
{
|
||||
if ($this->getUser() !== null) {
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
$form = $this->createForm(LoginFormType::class, [
|
||||
'username' => $authenticationUtils->getLastUsername(),
|
||||
]);
|
||||
|
||||
return $this->render('admin/login.html.twig', [
|
||||
'loginForm' => $form,
|
||||
'error' => $authenticationUtils->getLastAuthenticationError(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/logout', name: 'admin_logout', methods: ['GET'])]
|
||||
public function logout(): void
|
||||
{
|
||||
throw new LogicException('Logout is handled by the Symfony firewall.');
|
||||
}
|
||||
}
|
||||
19
src/Controller/Admin/DashboardController.php
Normal file
19
src/Controller/Admin/DashboardController.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Repository\ScriptMappingRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class DashboardController extends AbstractController
|
||||
{
|
||||
#[Route('/admin', name: 'admin_dashboard', methods: ['GET'])]
|
||||
public function __invoke(ScriptMappingRepository $mappingRepository): Response
|
||||
{
|
||||
return $this->render('admin/dashboard.html.twig', [
|
||||
'mappings' => $mappingRepository->findAllOrderedByPublicPath(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
107
src/Controller/Admin/ScriptMappingController.php
Normal file
107
src/Controller/Admin/ScriptMappingController.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\ScriptMapping;
|
||||
use App\Exception\GitSyncFailedException;
|
||||
use App\Form\ScriptMappingType;
|
||||
use App\Service\GitSynchronizer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/admin/mappings')]
|
||||
final class ScriptMappingController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/new', name: 'admin_mapping_new', methods: ['GET', 'POST'])]
|
||||
public function new(Request $request): Response
|
||||
{
|
||||
$mapping = new ScriptMapping();
|
||||
$form = $this->createForm(ScriptMappingType::class, $mapping);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$token = (string) $form->get('accessToken')->getData();
|
||||
$mapping->setAccessToken($token);
|
||||
|
||||
$this->entityManager->persist($mapping);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Mapping créé.');
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
return $this->render('admin/mapping_form.html.twig', [
|
||||
'form' => $form,
|
||||
'mapping' => $mapping,
|
||||
'title' => 'Nouveau mapping',
|
||||
], new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK));
|
||||
}
|
||||
|
||||
#[Route('/{id}/edit', name: 'admin_mapping_edit', methods: ['GET', 'POST'])]
|
||||
public function edit(Request $request, ScriptMapping $mapping): Response
|
||||
{
|
||||
$form = $this->createForm(ScriptMappingType::class, $mapping);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$token = (string) $form->get('accessToken')->getData();
|
||||
if ($token !== '') {
|
||||
$mapping->setAccessToken($token);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Mapping mis à jour.');
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
return $this->render('admin/mapping_form.html.twig', [
|
||||
'form' => $form,
|
||||
'mapping' => $mapping,
|
||||
'title' => 'Modifier le mapping',
|
||||
], new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK));
|
||||
}
|
||||
|
||||
#[Route('/{id}/delete', name: 'admin_mapping_delete', methods: ['POST'])]
|
||||
public function delete(Request $request, ScriptMapping $mapping): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('delete_mapping_'.$mapping->getId(), (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$this->entityManager->remove($mapping);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Mapping supprimé.');
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
#[Route('/{id}/sync', name: 'admin_mapping_sync', methods: ['POST'])]
|
||||
public function sync(Request $request, ScriptMapping $mapping, GitSynchronizer $synchronizer): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('sync_mapping_'.$mapping->getId(), (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
try {
|
||||
$synchronizer->sync($mapping);
|
||||
$this->addFlash('success', 'Mapping synchronisé.');
|
||||
} catch (GitSyncFailedException) {
|
||||
$this->addFlash('error', 'La synchronisation du mapping a échoué.');
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
}
|
||||
37
src/Controller/Admin/SettingsController.php
Normal file
37
src/Controller/Admin/SettingsController.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Form\SettingsType;
|
||||
use App\Service\AppSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
|
||||
public function __invoke(Request $request, AppSettings $settings, EntityManagerInterface $entityManager): Response
|
||||
{
|
||||
$form = $this->createForm(SettingsType::class, [
|
||||
'rootRedirectUrl' => $settings->rootRedirectUrl(),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$data = $form->getData();
|
||||
$settings->setRootRedirectUrl($data['rootRedirectUrl'] ?? null);
|
||||
$entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'Configuration mise à jour.');
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'form' => $form,
|
||||
], new Response(status: $form->isSubmitted() ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_OK));
|
||||
}
|
||||
}
|
||||
22
src/Controller/HomeController.php
Normal file
22
src/Controller/HomeController.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\AppSettings;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class HomeController extends AbstractController
|
||||
{
|
||||
#[Route('/', name: 'home', methods: ['GET'])]
|
||||
public function __invoke(AppSettings $settings): RedirectResponse
|
||||
{
|
||||
$redirectUrl = $settings->rootRedirectUrl();
|
||||
if ($redirectUrl !== null) {
|
||||
return new RedirectResponse($redirectUrl);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_dashboard');
|
||||
}
|
||||
}
|
||||
35
src/Controller/PublicScriptController.php
Normal file
35
src/Controller/PublicScriptController.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\CachedScriptResolver;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PublicScriptController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly CachedScriptResolver $resolver)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/{path}', name: 'public_script', requirements: ['path' => '(?!admin(?:/|$)).+'], priority: -255)]
|
||||
public function __invoke(string $path): Response
|
||||
{
|
||||
$file = $this->resolver->resolve($path);
|
||||
|
||||
if ($file === null) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getPathname());
|
||||
if ($content === false) {
|
||||
return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new Response($content, Response::HTTP_OK, [
|
||||
'Content-Type' => 'text/x-shellscript; charset=UTF-8',
|
||||
'Content-Disposition' => sprintf('inline; filename="%s"', basename($path)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
87
src/Entity/AppSetting.php
Normal file
87
src/Entity/AppSetting.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\AppSettingRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AppSettingRepository::class)]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_app_setting_name', columns: ['name'])]
|
||||
class AppSetting
|
||||
{
|
||||
public const ROOT_REDIRECT_URL = 'root_redirect_url';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 190)]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $value = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = trim($name);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getValue(): ?string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function setValue(?string $value): self
|
||||
{
|
||||
$value = $value === null ? null : trim($value);
|
||||
$this->value = $value === '' ? null : $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function initializeTimestamps(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt ??= $now;
|
||||
$this->updatedAt ??= $now;
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function refreshUpdatedAt(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||
return $this;
|
||||
}
|
||||
|
||||
#[\Deprecated]
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
}
|
||||
|
|
|
|||
9
src/Exception/GitSyncFailedException.php
Normal file
9
src/Exception/GitSyncFailedException.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class GitSyncFailedException extends RuntimeException
|
||||
{
|
||||
}
|
||||
26
src/Form/LoginFormType.php
Normal file
26
src/Form/LoginFormType.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class LoginFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('username', TextType::class)
|
||||
->add('password', PasswordType::class);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'csrf_token_id' => 'authenticate',
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
src/Form/ScriptMappingType.php
Normal file
69
src/Form/ScriptMappingType.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\ScriptMapping;
|
||||
use App\Service\PathNormalizer;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class ScriptMappingType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly PathNormalizer $pathNormalizer)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('publicPath', TextType::class)
|
||||
->add('repositoryUrl', UrlType::class, [
|
||||
'default_protocol' => null,
|
||||
])
|
||||
->add('gitRef', TextType::class)
|
||||
->add('repositoryFilePath', TextType::class)
|
||||
->add('accessToken', PasswordType::class, [
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
])
|
||||
->add('active', CheckboxType::class, [
|
||||
'required' => false,
|
||||
])
|
||||
->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void {
|
||||
$mapping = $event->getData();
|
||||
if (!$mapping instanceof ScriptMapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
$form = $event->getForm();
|
||||
|
||||
try {
|
||||
$mapping->setPublicPath($this->pathNormalizer->publicPath($mapping->getPublicPath()));
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$form->get('publicPath')->addError(new FormError($exception->getMessage()));
|
||||
}
|
||||
|
||||
try {
|
||||
$mapping->setRepositoryFilePath($this->pathNormalizer->repositoryPath($mapping->getRepositoryFilePath()));
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
$form->get('repositoryFilePath')->addError(new FormError($exception->getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ScriptMapping::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
34
src/Form/SettingsType.php
Normal file
34
src/Form/SettingsType.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Url;
|
||||
|
||||
final class SettingsType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('rootRedirectUrl', UrlType::class, [
|
||||
'default_protocol' => null,
|
||||
'required' => false,
|
||||
'constraints' => [
|
||||
new Url(
|
||||
protocols: ['http', 'https'],
|
||||
requireTld: false,
|
||||
message: 'L’URL de redirection doit être une URL http ou https.'
|
||||
),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
src/Repository/AppSettingRepository.php
Normal file
30
src/Repository/AppSettingRepository.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\AppSetting;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
final class AppSettingRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AppSetting::class);
|
||||
}
|
||||
|
||||
public function getValue(string $name): ?string
|
||||
{
|
||||
return $this->findOneBy(['name' => $name])?->getValue();
|
||||
}
|
||||
|
||||
public function setValue(string $name, ?string $value): AppSetting
|
||||
{
|
||||
$setting = $this->findOneBy(['name' => $name]) ?? (new AppSetting())->setName($name);
|
||||
$setting->setValue($value);
|
||||
|
||||
$this->getEntityManager()->persist($setting);
|
||||
|
||||
return $setting;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,4 +20,10 @@ final class ScriptMappingRepository extends ServiceEntityRepository
|
|||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<ScriptMapping> */
|
||||
public function findAllOrderedByPublicPath(): array
|
||||
{
|
||||
return $this->findBy([], ['publicPath' => 'ASC']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
src/Security/LoginFormAuthenticator.php
Normal file
55
src/Security/LoginFormAuthenticator.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||
|
||||
final class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
|
||||
{
|
||||
use TargetPathTrait;
|
||||
|
||||
public function __construct(private readonly UrlGeneratorInterface $urlGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
/** @var array{username?: string, password?: string, _token?: string} $data */
|
||||
$data = $request->request->all('login_form');
|
||||
$username = trim($data['username'] ?? '');
|
||||
|
||||
$request->getSession()->set('_security.last_username', $username);
|
||||
|
||||
return new Passport(
|
||||
new UserBadge($username),
|
||||
new PasswordCredentials($data['password'] ?? ''),
|
||||
[
|
||||
new CsrfTokenBadge('authenticate', $data['_token'] ?? ''),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
|
||||
return new RedirectResponse($targetPath);
|
||||
}
|
||||
|
||||
return new RedirectResponse($this->urlGenerator->generate('admin_dashboard'));
|
||||
}
|
||||
|
||||
protected function getLoginUrl(Request $request): string
|
||||
{
|
||||
return $this->urlGenerator->generate('admin_login');
|
||||
}
|
||||
}
|
||||
23
src/Service/AppSettings.php
Normal file
23
src/Service/AppSettings.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\AppSetting;
|
||||
use App\Repository\AppSettingRepository;
|
||||
|
||||
final class AppSettings
|
||||
{
|
||||
public function __construct(private readonly AppSettingRepository $settings)
|
||||
{
|
||||
}
|
||||
|
||||
public function rootRedirectUrl(): ?string
|
||||
{
|
||||
return $this->settings->getValue(AppSetting::ROOT_REDIRECT_URL);
|
||||
}
|
||||
|
||||
public function setRootRedirectUrl(?string $url): void
|
||||
{
|
||||
$this->settings->setValue(AppSetting::ROOT_REDIRECT_URL, $url);
|
||||
}
|
||||
}
|
||||
39
src/Service/CachePathResolver.php
Normal file
39
src/Service/CachePathResolver.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class CachePathResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $cacheDir,
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
public function servedScriptPath(string $cacheKey): string
|
||||
{
|
||||
$cacheKey = ltrim(str_replace('\\', '/', $cacheKey), '/');
|
||||
|
||||
if ($cacheKey === '' || str_contains($cacheKey, '../') || str_contains($cacheKey, '/..')) {
|
||||
throw new RuntimeException('Invalid script cache key.');
|
||||
}
|
||||
|
||||
return $this->baseDir().'/'.$cacheKey;
|
||||
}
|
||||
|
||||
public function scriptCacheKeyForMapping(int $mappingId): string
|
||||
{
|
||||
return sprintf('scripts/%d.sh', $mappingId);
|
||||
}
|
||||
|
||||
public function baseDir(): string
|
||||
{
|
||||
if (str_starts_with($this->cacheDir, '/')) {
|
||||
return rtrim($this->cacheDir, '/');
|
||||
}
|
||||
|
||||
return rtrim($this->projectDir, '/').'/'.trim($this->cacheDir, '/');
|
||||
}
|
||||
}
|
||||
46
src/Service/CachedScriptResolver.php
Normal file
46
src/Service/CachedScriptResolver.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\ScriptMappingRepository;
|
||||
use InvalidArgumentException;
|
||||
use SplFileInfo;
|
||||
|
||||
final class CachedScriptResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PathNormalizer $pathNormalizer,
|
||||
private readonly ScriptMappingRepository $mappingRepository,
|
||||
private readonly CachePathResolver $cachePathResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(string $requestPath): ?SplFileInfo
|
||||
{
|
||||
try {
|
||||
$publicPath = $this->pathNormalizer->publicPath($requestPath);
|
||||
} catch (InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mapping = $this->mappingRepository->findActiveByPublicPath($publicPath);
|
||||
if ($mapping === null || $mapping->getCacheKey() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $this->cachePathResolver->servedScriptPath($mapping->getCacheKey());
|
||||
$baseDir = $this->cachePathResolver->baseDir();
|
||||
$realPath = realpath($path);
|
||||
$realBase = realpath($baseDir);
|
||||
|
||||
if ($realPath === false || $realBase === false || !str_starts_with($realPath, rtrim($realBase, '/').'/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_file($realPath) || !is_readable($realPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SplFileInfo($realPath);
|
||||
}
|
||||
}
|
||||
175
src/Service/GitSynchronizer.php
Normal file
175
src/Service/GitSynchronizer.php
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\ScriptMapping;
|
||||
use App\Exception\GitSyncFailedException;
|
||||
use LogicException;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
final class GitSynchronizer
|
||||
{
|
||||
public function __construct(private readonly CachePathResolver $cachePathResolver)
|
||||
{
|
||||
}
|
||||
|
||||
public function sync(ScriptMapping $mapping): void
|
||||
{
|
||||
if ($mapping->getId() === null) {
|
||||
throw new LogicException('Cannot synchronize a mapping before it is persisted.');
|
||||
}
|
||||
|
||||
try {
|
||||
$repositoryDir = $this->checkoutRepository($mapping);
|
||||
$cacheKey = $this->cachePathResolver->scriptCacheKeyForMapping($mapping->getId());
|
||||
$this->copyRepositoryFileToCache($mapping, $repositoryDir, $cacheKey);
|
||||
|
||||
$mapping->markSyncSucceeded($cacheKey);
|
||||
} catch (Throwable $exception) {
|
||||
$message = $this->sanitizeError($exception->getMessage(), $mapping->getAccessToken());
|
||||
$mapping->markSyncFailed($message);
|
||||
|
||||
throw new GitSyncFailedException('Git synchronization failed.', 0, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkoutRepository(ScriptMapping $mapping): string
|
||||
{
|
||||
$repositoryDir = $this->repositoryDir($mapping);
|
||||
$this->ensureDirectory(dirname($repositoryDir));
|
||||
|
||||
$env = $this->gitEnvironment($mapping, $repositoryDir);
|
||||
|
||||
if (!is_dir($repositoryDir.'/.git')) {
|
||||
$this->removeDirectory($repositoryDir);
|
||||
$this->runGit(['git', 'clone', '--no-checkout', $mapping->getRepositoryUrl(), $repositoryDir], null, $env);
|
||||
} else {
|
||||
$this->runGit(['git', '-C', $repositoryDir, 'remote', 'set-url', 'origin', $mapping->getRepositoryUrl()], null, $env);
|
||||
}
|
||||
|
||||
$this->runGit(['git', '-C', $repositoryDir, 'fetch', '--prune', '--tags', 'origin', $mapping->getGitRef()], null, $env);
|
||||
$this->runGit(['git', '-C', $repositoryDir, 'checkout', '--force', 'FETCH_HEAD'], null, $env);
|
||||
|
||||
return $repositoryDir;
|
||||
}
|
||||
|
||||
private function copyRepositoryFileToCache(ScriptMapping $mapping, string $repositoryDir, string $cacheKey): void
|
||||
{
|
||||
$repositoryBase = realpath($repositoryDir);
|
||||
if ($repositoryBase === false) {
|
||||
throw new RuntimeException(sprintf('Repository directory "%s" does not exist.', $repositoryDir));
|
||||
}
|
||||
|
||||
$sourcePath = $repositoryDir.'/'.$mapping->getRepositoryFilePath();
|
||||
$sourceRealPath = realpath($sourcePath);
|
||||
if ($sourceRealPath === false || !str_starts_with($sourceRealPath, rtrim($repositoryBase, '/').'/')) {
|
||||
throw new RuntimeException(sprintf('Repository file "%s" was not found.', $mapping->getRepositoryFilePath()));
|
||||
}
|
||||
|
||||
if (!is_file($sourceRealPath) || !is_readable($sourceRealPath)) {
|
||||
throw new RuntimeException(sprintf('Repository file "%s" is not readable.', $mapping->getRepositoryFilePath()));
|
||||
}
|
||||
|
||||
$targetPath = $this->cachePathResolver->servedScriptPath($cacheKey);
|
||||
$this->ensureDirectory(dirname($targetPath));
|
||||
|
||||
$temporaryPath = $targetPath.'.tmp.'.bin2hex(random_bytes(6));
|
||||
if (!copy($sourceRealPath, $temporaryPath)) {
|
||||
throw new RuntimeException(sprintf('Could not write cache file "%s".', $targetPath));
|
||||
}
|
||||
|
||||
chmod($temporaryPath, 0644);
|
||||
if (!rename($temporaryPath, $targetPath)) {
|
||||
@unlink($temporaryPath);
|
||||
|
||||
throw new RuntimeException(sprintf('Could not publish cache file "%s".', $targetPath));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $command */
|
||||
private function runGit(array $command, ?string $cwd, array $env): void
|
||||
{
|
||||
try {
|
||||
(new Process($command, $cwd, $env))->mustRun();
|
||||
} catch (ProcessFailedException $exception) {
|
||||
throw new RuntimeException($exception->getMessage(), 0, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function repositoryDir(ScriptMapping $mapping): string
|
||||
{
|
||||
return $this->cachePathResolver->baseDir().'/repos/'.$mapping->getId();
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function gitEnvironment(ScriptMapping $mapping, string $repositoryDir): array
|
||||
{
|
||||
$env = [
|
||||
'GIT_CONFIG_COUNT' => '1',
|
||||
'GIT_CONFIG_KEY_0' => 'safe.directory',
|
||||
'GIT_CONFIG_VALUE_0' => $repositoryDir,
|
||||
'GIT_TERMINAL_PROMPT' => '0',
|
||||
];
|
||||
|
||||
if (!$mapping->hasAccessToken()) {
|
||||
return $env;
|
||||
}
|
||||
|
||||
$askPassPath = $this->cachePathResolver->baseDir().'/git-askpass.sh';
|
||||
$this->ensureDirectory(dirname($askPassPath));
|
||||
file_put_contents($askPassPath, <<<'SH'
|
||||
#!/usr/bin/env sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s\n' oauth2 ;;
|
||||
*) printf '%s\n' "$GIT_ACCESS_TOKEN" ;;
|
||||
esac
|
||||
SH);
|
||||
chmod($askPassPath, 0700);
|
||||
|
||||
$env['GIT_ASKPASS'] = $askPassPath;
|
||||
$env['GIT_ACCESS_TOKEN'] = (string) $mapping->getAccessToken();
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
private function sanitizeError(string $message, ?string $accessToken): string
|
||||
{
|
||||
if ($accessToken === null || $accessToken === '') {
|
||||
return $message;
|
||||
}
|
||||
|
||||
return str_replace($accessToken, '[redacted]', $message);
|
||||
}
|
||||
|
||||
private function ensureDirectory(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException(sprintf('Could not create directory "%s".', $directory));
|
||||
}
|
||||
}
|
||||
|
||||
private function removeDirectory(string $directory): void
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $item) {
|
||||
if ($item->isDir()) {
|
||||
rmdir($item->getPathname());
|
||||
} else {
|
||||
unlink($item->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($directory);
|
||||
}
|
||||
}
|
||||
8
templates/admin/base.html.twig
Normal file
8
templates/admin/base.html.twig
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
<link rel="stylesheet" href="{{ asset('admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}admin-page{% endblock %}
|
||||
78
templates/admin/dashboard.html.twig
Normal file
78
templates/admin/dashboard.html.twig
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{% extends 'admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Admin{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main class="admin-shell">
|
||||
<header class="admin-toolbar">
|
||||
<div>
|
||||
<p class="eyebrow">Administration</p>
|
||||
<h1>Mappings</h1>
|
||||
</div>
|
||||
<nav class="toolbar-actions" aria-label="Actions admin">
|
||||
<a class="button button-primary" href="{{ path('admin_mapping_new') }}">Nouveau mapping</a>
|
||||
<a class="button button-secondary" href="{{ path('admin_settings') }}">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% for message in app.flashes('success') %}
|
||||
<p class="alert alert-success">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% for message in app.flashes('error') %}
|
||||
<p class="alert alert-error">{{ message }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<section class="table-panel" aria-label="Mappings">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chemin public</th>
|
||||
<th>Dépôt</th>
|
||||
<th>Réf.</th>
|
||||
<th>Fichier</th>
|
||||
<th>Actif</th>
|
||||
<th>Dernière synchro</th>
|
||||
<th>Dernier succès</th>
|
||||
<th>Erreur</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mapping in mappings %}
|
||||
<tr>
|
||||
<td><code>{{ mapping.publicPath }}</code></td>
|
||||
<td class="text-muted">{{ mapping.repositoryUrl }}</td>
|
||||
<td><code>{{ mapping.gitRef }}</code></td>
|
||||
<td><code>{{ mapping.repositoryFilePath }}</code></td>
|
||||
<td>
|
||||
<span class="status-badge {{ mapping.active ? 'status-success' : 'status-muted' }}">
|
||||
{{ mapping.active ? 'oui' : 'non' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ mapping.lastSyncStatus ?: 'jamais' }}</td>
|
||||
<td>{{ mapping.lastSuccessfulSyncAt ? mapping.lastSuccessfulSyncAt|date('Y-m-d H:i:s') : '' }}</td>
|
||||
<td class="error-cell">{{ mapping.lastSyncError }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<a class="button button-ghost" href="{{ path('admin_mapping_edit', {id: mapping.id}) }}">Modifier</a>
|
||||
<form method="post" action="{{ path('admin_mapping_sync', {id: mapping.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('sync_mapping_' ~ mapping.id) }}">
|
||||
<button class="button button-secondary" type="submit">Synchroniser</button>
|
||||
</form>
|
||||
<form method="post" action="{{ path('admin_mapping_delete', {id: mapping.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_mapping_' ~ mapping.id) }}">
|
||||
<button class="button button-danger" type="submit">Supprimer</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td class="empty-state" colspan="9">Aucun mapping.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
23
templates/admin/login.html.twig
Normal file
23
templates/admin/login.html.twig
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{% extends 'admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Admin login{% endblock %}
|
||||
{% block body_class %}admin-auth{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main class="auth-shell">
|
||||
<section class="admin-card auth-card">
|
||||
<p class="eyebrow">Administration</p>
|
||||
<h1>Connexion</h1>
|
||||
|
||||
{% if error %}
|
||||
<p class="alert alert-error">{{ error.messageKey|trans(error.messageData, 'security') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{{ form_start(loginForm, { attr: { class: 'admin-form' } }) }}
|
||||
{{ form_row(loginForm.username) }}
|
||||
{{ form_row(loginForm.password) }}
|
||||
<button class="button button-primary" type="submit">Sign in</button>
|
||||
{{ form_end(loginForm) }}
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
27
templates/admin/mapping_form.html.twig
Normal file
27
templates/admin/mapping_form.html.twig
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{% extends 'admin/base.html.twig' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main class="admin-shell admin-form-shell">
|
||||
<header class="admin-toolbar">
|
||||
<div>
|
||||
<p class="eyebrow">Administration</p>
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
<a class="button button-secondary" href="{{ path('admin_dashboard') }}">Retour aux mappings</a>
|
||||
</header>
|
||||
|
||||
<section class="admin-card">
|
||||
{{ form_start(form, { attr: { class: 'admin-form' } }) }}
|
||||
{{ form_row(form.publicPath) }}
|
||||
{{ form_row(form.repositoryUrl) }}
|
||||
{{ form_row(form.gitRef) }}
|
||||
{{ form_row(form.repositoryFilePath) }}
|
||||
{{ form_row(form.accessToken) }}
|
||||
{{ form_row(form.active) }}
|
||||
<button class="button button-primary" type="submit">Enregistrer</button>
|
||||
{{ form_end(form) }}
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
25
templates/admin/settings.html.twig
Normal file
25
templates/admin/settings.html.twig
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{% extends 'admin/base.html.twig' %}
|
||||
|
||||
{% block title %}Configuration{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main class="admin-shell admin-form-shell">
|
||||
<header class="admin-toolbar">
|
||||
<div>
|
||||
<p class="eyebrow">Administration</p>
|
||||
<h1>Configuration</h1>
|
||||
</div>
|
||||
<a class="button button-secondary" href="{{ path('admin_dashboard') }}">Retour aux mappings</a>
|
||||
</header>
|
||||
|
||||
<section class="admin-card">
|
||||
{{ form_start(form, { attr: { class: 'admin-form' } }) }}
|
||||
{{ form_row(form.rootRedirectUrl, {
|
||||
label: 'URL de redirection de /',
|
||||
help: 'Laisser vide pour rediriger vers /admin.'
|
||||
}) }}
|
||||
<button class="button button-primary" type="submit">Enregistrer</button>
|
||||
{{ form_end(form) }}
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
<script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
139
tests/Command/SyncScriptMappingsCommandTest.php
Normal file
139
tests/Command/SyncScriptMappingsCommandTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Entity\ScriptMapping;
|
||||
use App\Tests\DatabaseWebTestCase;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
final class SyncScriptMappingsCommandTest extends DatabaseWebTestCase
|
||||
{
|
||||
private Filesystem $filesystem;
|
||||
private string $workDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->filesystem = new Filesystem();
|
||||
$this->workDir = sys_get_temp_dir().'/get-installer-bootstrap-command-test-'.bin2hex(random_bytes(6));
|
||||
$this->filesystem->mkdir($this->workDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->filesystem->remove($this->workDir);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testCommandSynchronizesEveryMapping(): void
|
||||
{
|
||||
$firstRepositoryDir = $this->createRepository('first', [
|
||||
'install.sh' => "#!/usr/bin/env bash\nprintf 'first'\n",
|
||||
]);
|
||||
$secondRepositoryDir = $this->createRepository('second', [
|
||||
'install.sh' => "#!/usr/bin/env bash\nprintf 'second'\n",
|
||||
]);
|
||||
|
||||
$firstMapping = $this->mapping('mcp/first/install.sh', $firstRepositoryDir);
|
||||
$secondMapping = $this->mapping('mcp/second/install.sh', $secondRepositoryDir);
|
||||
$this->entityManager->persist($firstMapping);
|
||||
$this->entityManager->persist($secondMapping);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$application = new Application(static::$kernel);
|
||||
self::assertTrue($application->has('app:mappings:sync'));
|
||||
|
||||
$commandTester = new CommandTester($application->find('app:mappings:sync'));
|
||||
$commandTester->execute([]);
|
||||
|
||||
self::assertSame(0, $commandTester->getStatusCode());
|
||||
self::assertStringContainsString('2 mapping(s) synchronized, 0 failed.', $commandTester->getDisplay());
|
||||
|
||||
$this->entityManager->clear();
|
||||
$syncedFirstMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($firstMapping->getId());
|
||||
$syncedSecondMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($secondMapping->getId());
|
||||
|
||||
self::assertInstanceOf(ScriptMapping::class, $syncedFirstMapping);
|
||||
self::assertInstanceOf(ScriptMapping::class, $syncedSecondMapping);
|
||||
self::assertSame(ScriptMapping::SYNC_STATUS_SYNCED, $syncedFirstMapping->getLastSyncStatus());
|
||||
self::assertSame(ScriptMapping::SYNC_STATUS_SYNCED, $syncedSecondMapping->getLastSyncStatus());
|
||||
}
|
||||
|
||||
public function testCommandContinuesAfterMappingFailureAndReturnsFailure(): void
|
||||
{
|
||||
$failingRepositoryDir = $this->createRepository('failing', [
|
||||
'install.sh' => "#!/usr/bin/env bash\nprintf 'missing target'\n",
|
||||
]);
|
||||
$workingRepositoryDir = $this->createRepository('working', [
|
||||
'install.sh' => "#!/usr/bin/env bash\nprintf 'working'\n",
|
||||
]);
|
||||
|
||||
$failingMapping = $this->mapping('mcp/a-failing/install.sh', $failingRepositoryDir)
|
||||
->setRepositoryFilePath('missing.sh');
|
||||
$workingMapping = $this->mapping('mcp/b-working/install.sh', $workingRepositoryDir);
|
||||
$this->entityManager->persist($failingMapping);
|
||||
$this->entityManager->persist($workingMapping);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$application = new Application(static::$kernel);
|
||||
self::assertTrue($application->has('app:mappings:sync'));
|
||||
|
||||
$commandTester = new CommandTester($application->find('app:mappings:sync'));
|
||||
$commandTester->execute([]);
|
||||
|
||||
self::assertSame(1, $commandTester->getStatusCode());
|
||||
self::assertStringContainsString('1 mapping(s) synchronized, 1 failed.', $commandTester->getDisplay());
|
||||
|
||||
$this->entityManager->clear();
|
||||
$syncedFailingMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($failingMapping->getId());
|
||||
$syncedWorkingMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($workingMapping->getId());
|
||||
|
||||
self::assertInstanceOf(ScriptMapping::class, $syncedFailingMapping);
|
||||
self::assertInstanceOf(ScriptMapping::class, $syncedWorkingMapping);
|
||||
self::assertSame(ScriptMapping::SYNC_STATUS_FAILED, $syncedFailingMapping->getLastSyncStatus());
|
||||
self::assertSame(ScriptMapping::SYNC_STATUS_SYNCED, $syncedWorkingMapping->getLastSyncStatus());
|
||||
}
|
||||
|
||||
private function mapping(string $publicPath, string $repositoryDir): ScriptMapping
|
||||
{
|
||||
return (new ScriptMapping())
|
||||
->setPublicPath($publicPath)
|
||||
->setRepositoryUrl($repositoryDir)
|
||||
->setGitRef('main')
|
||||
->setRepositoryFilePath('install.sh')
|
||||
->setActive(true);
|
||||
}
|
||||
|
||||
/** @param array<string, string> $files */
|
||||
private function createRepository(string $name, array $files): string
|
||||
{
|
||||
$repositoryDir = $this->workDir.'/'.$name;
|
||||
$this->filesystem->mkdir($repositoryDir);
|
||||
|
||||
foreach ($files as $path => $content) {
|
||||
$fullPath = $repositoryDir.'/'.$path;
|
||||
$this->filesystem->mkdir(dirname($fullPath));
|
||||
file_put_contents($fullPath, $content);
|
||||
}
|
||||
|
||||
$this->runGit(['init'], $repositoryDir);
|
||||
$this->runGit(['config', 'user.email', 'tests@example.com'], $repositoryDir);
|
||||
$this->runGit(['config', 'user.name', 'Tests'], $repositoryDir);
|
||||
$this->runGit(['add', '.'], $repositoryDir);
|
||||
$this->runGit(['commit', '-m', 'Initial commit'], $repositoryDir);
|
||||
$this->runGit(['branch', '-M', 'main'], $repositoryDir);
|
||||
|
||||
return $repositoryDir;
|
||||
}
|
||||
|
||||
/** @param list<string> $arguments */
|
||||
private function runGit(array $arguments, string $cwd): void
|
||||
{
|
||||
(new Process(['git', ...$arguments], $cwd))->mustRun();
|
||||
}
|
||||
}
|
||||
47
tests/Controller/Admin/AuthControllerTest.php
Normal file
47
tests/Controller/Admin/AuthControllerTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Tests\DatabaseWebTestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
final class AuthControllerTest extends DatabaseWebTestCase
|
||||
{
|
||||
public function testAdminRedirectsToLogin(): void
|
||||
{
|
||||
$this->client->request('GET', '/admin');
|
||||
|
||||
self::assertResponseRedirects('/admin/login');
|
||||
}
|
||||
|
||||
public function testAdminCanLogin(): void
|
||||
{
|
||||
$container = static::getContainer();
|
||||
|
||||
$user = (new User())->setUsername('admin');
|
||||
$hash = $container->get(UserPasswordHasherInterface::class)->hashPassword($user, 'secret-password');
|
||||
$user->setPasswordHash($hash);
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/login');
|
||||
$form = $crawler->selectButton('Sign in')->form([
|
||||
'login_form[username]' => 'admin',
|
||||
'login_form[password]' => 'secret-password',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseRedirects('/admin');
|
||||
}
|
||||
|
||||
public function testLoginUsesUtilityAdminLayout(): void
|
||||
{
|
||||
$this->client->request('GET', '/admin/login');
|
||||
|
||||
self::assertSelectorExists('body.admin-auth');
|
||||
self::assertSelectorExists('.admin-card');
|
||||
self::assertSelectorExists('.button-primary');
|
||||
}
|
||||
}
|
||||
172
tests/Controller/Admin/ScriptMappingControllerTest.php
Normal file
172
tests/Controller/Admin/ScriptMappingControllerTest.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Entity\ScriptMapping;
|
||||
use App\Entity\User;
|
||||
use App\Tests\DatabaseWebTestCase;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
final class ScriptMappingControllerTest extends DatabaseWebTestCase
|
||||
{
|
||||
private Filesystem $filesystem;
|
||||
private string $workDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->filesystem = new Filesystem();
|
||||
$this->workDir = sys_get_temp_dir().'/get-installer-bootstrap-controller-test-'.bin2hex(random_bytes(6));
|
||||
$this->filesystem->mkdir($this->workDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->filesystem->remove($this->workDir);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testNewMappingRequiresAuthentication(): void
|
||||
{
|
||||
$this->client->request('GET', '/admin/mappings/new');
|
||||
|
||||
self::assertResponseRedirects('/admin/login');
|
||||
}
|
||||
|
||||
public function testDashboardUsesUtilityAdminLayout(): void
|
||||
{
|
||||
$this->loginAsAdmin();
|
||||
|
||||
$mapping = (new ScriptMapping())
|
||||
->setPublicPath('mcp/graylog/install.sh')
|
||||
->setRepositoryUrl('https://forge.lclr.dev/AI/graylog-mcp.git')
|
||||
->setGitRef('main')
|
||||
->setRepositoryFilePath('install.sh')
|
||||
->setActive(true);
|
||||
$this->entityManager->persist($mapping);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->client->request('GET', '/admin');
|
||||
|
||||
self::assertSelectorExists('body.admin-page');
|
||||
self::assertSelectorExists('.admin-toolbar');
|
||||
self::assertSelectorExists('.admin-table');
|
||||
self::assertSelectorExists('.status-badge');
|
||||
}
|
||||
|
||||
public function testAdminCanCreateMappingWithNormalizedPaths(): void
|
||||
{
|
||||
$this->loginAsAdmin();
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/mappings/new');
|
||||
$form = $crawler->selectButton('Enregistrer')->form([
|
||||
'script_mapping[publicPath]' => '/mcp/graylog/install.sh',
|
||||
'script_mapping[repositoryUrl]' => 'https://forge.lclr.dev/AI/graylog-mcp.git',
|
||||
'script_mapping[gitRef]' => 'main',
|
||||
'script_mapping[repositoryFilePath]' => '/install.sh',
|
||||
'script_mapping[active]' => '1',
|
||||
]);
|
||||
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseRedirects('/admin');
|
||||
|
||||
$mapping = $this->entityManager->getRepository(ScriptMapping::class)->findOneBy([
|
||||
'publicPath' => 'mcp/graylog/install.sh',
|
||||
]);
|
||||
self::assertInstanceOf(ScriptMapping::class, $mapping);
|
||||
self::assertSame('install.sh', $mapping->getRepositoryFilePath());
|
||||
}
|
||||
|
||||
public function testInvalidPublicPathRendersValidationError(): void
|
||||
{
|
||||
$this->loginAsAdmin();
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/mappings/new');
|
||||
$form = $crawler->selectButton('Enregistrer')->form([
|
||||
'script_mapping[publicPath]' => 'mcp/graylog/install.txt',
|
||||
'script_mapping[repositoryUrl]' => 'https://forge.lclr.dev/AI/graylog-mcp.git',
|
||||
'script_mapping[gitRef]' => 'main',
|
||||
'script_mapping[repositoryFilePath]' => 'install.sh',
|
||||
'script_mapping[active]' => '1',
|
||||
]);
|
||||
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertSelectorTextContains('form', 'Public path must end with .sh.');
|
||||
}
|
||||
|
||||
public function testAdminCanSynchronizeMapping(): void
|
||||
{
|
||||
$this->loginAsAdmin();
|
||||
|
||||
$repositoryDir = $this->createRepository([
|
||||
'install.sh' => "#!/usr/bin/env bash\nprintf 'synced'\n",
|
||||
]);
|
||||
$mapping = (new ScriptMapping())
|
||||
->setPublicPath('mcp/graylog/install.sh')
|
||||
->setRepositoryUrl($repositoryDir)
|
||||
->setGitRef('main')
|
||||
->setRepositoryFilePath('install.sh')
|
||||
->setActive(true);
|
||||
$this->entityManager->persist($mapping);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin');
|
||||
$form = $crawler->selectButton('Synchroniser')->form();
|
||||
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseRedirects('/admin');
|
||||
|
||||
$syncedMapping = $this->entityManager->getRepository(ScriptMapping::class)->find($mapping->getId());
|
||||
self::assertInstanceOf(ScriptMapping::class, $syncedMapping);
|
||||
self::assertSame(
|
||||
ScriptMapping::SYNC_STATUS_SYNCED,
|
||||
$syncedMapping->getLastSyncStatus(),
|
||||
(string) $syncedMapping->getLastSyncError()
|
||||
);
|
||||
self::assertSame('scripts/'.$mapping->getId().'.sh', $syncedMapping->getCacheKey());
|
||||
}
|
||||
|
||||
private function loginAsAdmin(): void
|
||||
{
|
||||
$user = (new User())->setUsername('admin')->setPasswordHash('unused');
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
/** @param array<string, string> $files */
|
||||
private function createRepository(array $files): string
|
||||
{
|
||||
$repositoryDir = $this->workDir.'/repo';
|
||||
$this->filesystem->mkdir($repositoryDir);
|
||||
|
||||
foreach ($files as $path => $content) {
|
||||
$fullPath = $repositoryDir.'/'.$path;
|
||||
$this->filesystem->mkdir(dirname($fullPath));
|
||||
file_put_contents($fullPath, $content);
|
||||
}
|
||||
|
||||
$this->runGit(['init'], $repositoryDir);
|
||||
$this->runGit(['config', 'user.email', 'tests@example.com'], $repositoryDir);
|
||||
$this->runGit(['config', 'user.name', 'Tests'], $repositoryDir);
|
||||
$this->runGit(['add', '.'], $repositoryDir);
|
||||
$this->runGit(['commit', '-m', 'Initial commit'], $repositoryDir);
|
||||
$this->runGit(['branch', '-M', 'main'], $repositoryDir);
|
||||
|
||||
return $repositoryDir;
|
||||
}
|
||||
|
||||
/** @param list<string> $arguments */
|
||||
private function runGit(array $arguments, string $cwd): void
|
||||
{
|
||||
(new Process(['git', ...$arguments], $cwd))->mustRun();
|
||||
}
|
||||
}
|
||||
60
tests/Controller/Admin/SettingsControllerTest.php
Normal file
60
tests/Controller/Admin/SettingsControllerTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Entity\AppSetting;
|
||||
use App\Entity\User;
|
||||
use App\Tests\DatabaseWebTestCase;
|
||||
|
||||
final class SettingsControllerTest extends DatabaseWebTestCase
|
||||
{
|
||||
public function testSettingsRequireAuthentication(): void
|
||||
{
|
||||
$this->client->request('GET', '/admin/settings');
|
||||
|
||||
self::assertResponseRedirects('/admin/login');
|
||||
}
|
||||
|
||||
public function testAdminCanUpdateRootRedirectUrl(): void
|
||||
{
|
||||
$this->loginAsAdmin();
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/settings');
|
||||
$form = $crawler->selectButton('Enregistrer')->form([
|
||||
'settings[rootRedirectUrl]' => 'https://example.com/installers',
|
||||
]);
|
||||
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseRedirects('/admin');
|
||||
|
||||
self::assertSame(
|
||||
'https://example.com/installers',
|
||||
$this->entityManager->getRepository(AppSetting::class)->getValue(AppSetting::ROOT_REDIRECT_URL)
|
||||
);
|
||||
}
|
||||
|
||||
public function testRootRedirectUrlMustBeHttpOrHttps(): void
|
||||
{
|
||||
$this->loginAsAdmin();
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/settings');
|
||||
$form = $crawler->selectButton('Enregistrer')->form([
|
||||
'settings[rootRedirectUrl]' => 'javascript:alert(1)',
|
||||
]);
|
||||
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertNull($this->entityManager->getRepository(AppSetting::class)->getValue(AppSetting::ROOT_REDIRECT_URL));
|
||||
}
|
||||
|
||||
private function loginAsAdmin(): void
|
||||
{
|
||||
$user = (new User())->setUsername('admin')->setPasswordHash('unused');
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
}
|
||||
29
tests/Controller/HomeControllerTest.php
Normal file
29
tests/Controller/HomeControllerTest.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\AppSetting;
|
||||
use App\Tests\DatabaseWebTestCase;
|
||||
|
||||
final class HomeControllerTest extends DatabaseWebTestCase
|
||||
{
|
||||
public function testRootRedirectsToAdminWhenNoRedirectUrlIsConfigured(): void
|
||||
{
|
||||
$this->client->request('GET', '/');
|
||||
|
||||
self::assertResponseRedirects('/admin');
|
||||
}
|
||||
|
||||
public function testRootRedirectsToConfiguredUrl(): void
|
||||
{
|
||||
$setting = (new AppSetting())
|
||||
->setName(AppSetting::ROOT_REDIRECT_URL)
|
||||
->setValue('https://example.com/installers');
|
||||
$this->entityManager->persist($setting);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->client->request('GET', '/');
|
||||
|
||||
self::assertResponseRedirects('https://example.com/installers');
|
||||
}
|
||||
}
|
||||
44
tests/Controller/PublicScriptControllerTest.php
Normal file
44
tests/Controller/PublicScriptControllerTest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\ScriptMapping;
|
||||
use App\Tests\DatabaseWebTestCase;
|
||||
|
||||
final class PublicScriptControllerTest extends DatabaseWebTestCase
|
||||
{
|
||||
public function testUnknownPathReturnsNotFound(): void
|
||||
{
|
||||
$this->client->request('GET', '/missing/install.sh');
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testCachedScriptIsServed(): void
|
||||
{
|
||||
$container = static::getContainer();
|
||||
$cacheDir = $container->getParameter('app.cache_dir');
|
||||
if (!str_starts_with($cacheDir, '/')) {
|
||||
$cacheDir = $container->getParameter('kernel.project_dir').'/'.$cacheDir;
|
||||
}
|
||||
@mkdir($cacheDir.'/scripts', 0775, true);
|
||||
file_put_contents($cacheDir.'/scripts/test.sh', "#!/bin/sh\necho ok\n");
|
||||
|
||||
$mapping = (new ScriptMapping())
|
||||
->setPublicPath('mcp/graylog/install.sh')
|
||||
->setRepositoryUrl('https://forge.lclr.dev/AI/graylog-mcp.git')
|
||||
->setGitRef('main')
|
||||
->setRepositoryFilePath('install.sh')
|
||||
->setCacheKey('scripts/test.sh');
|
||||
$mapping->markSyncSucceeded('scripts/test.sh');
|
||||
|
||||
$this->entityManager->persist($mapping);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->client->request('GET', '/mcp/graylog/install.sh');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertResponseHeaderSame('content-type', 'text/x-shellscript; charset=UTF-8');
|
||||
self::assertStringContainsString('echo ok', (string) $this->client->getResponse()->getContent());
|
||||
}
|
||||
}
|
||||
33
tests/DatabaseWebTestCase.php
Normal file
33
tests/DatabaseWebTestCase.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Tools\SchemaTool;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
abstract class DatabaseWebTestCase extends WebTestCase
|
||||
{
|
||||
protected KernelBrowser $client;
|
||||
protected EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
$this->client = self::createClient();
|
||||
|
||||
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$metadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
|
||||
$schemaTool = new SchemaTool($this->entityManager);
|
||||
$schemaTool->dropSchema($metadata);
|
||||
$schemaTool->createSchema($metadata);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
|
||||
$this->entityManager->close();
|
||||
}
|
||||
}
|
||||
126
tests/Service/GitSynchronizerTest.php
Normal file
126
tests/Service/GitSynchronizerTest.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\ScriptMapping;
|
||||
use App\Service\CachePathResolver;
|
||||
use App\Service\GitSynchronizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionProperty;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
final class GitSynchronizerTest extends TestCase
|
||||
{
|
||||
private Filesystem $filesystem;
|
||||
private string $workDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->filesystem = new Filesystem();
|
||||
$this->workDir = sys_get_temp_dir().'/get-installer-bootstrap-test-'.bin2hex(random_bytes(6));
|
||||
$this->filesystem->mkdir($this->workDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->filesystem->remove($this->workDir);
|
||||
}
|
||||
|
||||
public function testSyncCopiesRepositoryScriptToCache(): void
|
||||
{
|
||||
$repositoryDir = $this->createRepository([
|
||||
'install.sh' => "#!/usr/bin/env bash\nprintf 'ok'\n",
|
||||
]);
|
||||
|
||||
$mapping = $this->mapping($repositoryDir, 'install.sh');
|
||||
|
||||
$synchronizer = $this->synchronizer();
|
||||
$synchronizer->sync($mapping);
|
||||
|
||||
self::assertSame(ScriptMapping::SYNC_STATUS_SYNCED, $mapping->getLastSyncStatus());
|
||||
self::assertNotNull($mapping->getLastSuccessfulSyncAt());
|
||||
self::assertNull($mapping->getLastSyncError());
|
||||
self::assertSame('scripts/42.sh', $mapping->getCacheKey());
|
||||
self::assertSame(
|
||||
"#!/usr/bin/env bash\nprintf 'ok'\n",
|
||||
file_get_contents($this->workDir.'/cache/scripts/42.sh')
|
||||
);
|
||||
}
|
||||
|
||||
public function testSyncFailureMarksMappingWithoutTokenLeak(): void
|
||||
{
|
||||
$repositoryDir = $this->createRepository([
|
||||
'install.sh' => "#!/usr/bin/env bash\n",
|
||||
]);
|
||||
|
||||
$mapping = $this->mapping($repositoryDir, 'missing.sh')->setAccessToken('secret-token');
|
||||
|
||||
$synchronizer = $this->synchronizer();
|
||||
|
||||
$this->expectExceptionMessage('Git synchronization failed.');
|
||||
|
||||
try {
|
||||
$synchronizer->sync($mapping);
|
||||
} finally {
|
||||
self::assertSame(ScriptMapping::SYNC_STATUS_FAILED, $mapping->getLastSyncStatus());
|
||||
self::assertNotNull($mapping->getLastSyncError());
|
||||
self::assertStringContainsString('missing.sh', $mapping->getLastSyncError());
|
||||
self::assertStringNotContainsString('secret-token', $mapping->getLastSyncError());
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, string> $files */
|
||||
private function createRepository(array $files): string
|
||||
{
|
||||
$repositoryDir = $this->workDir.'/repo';
|
||||
$this->filesystem->mkdir($repositoryDir);
|
||||
|
||||
foreach ($files as $path => $content) {
|
||||
$fullPath = $repositoryDir.'/'.$path;
|
||||
$this->filesystem->mkdir(dirname($fullPath));
|
||||
file_put_contents($fullPath, $content);
|
||||
}
|
||||
|
||||
$this->runGit(['init'], $repositoryDir);
|
||||
$this->runGit(['config', 'user.email', 'tests@example.com'], $repositoryDir);
|
||||
$this->runGit(['config', 'user.name', 'Tests'], $repositoryDir);
|
||||
$this->runGit(['add', '.'], $repositoryDir);
|
||||
$this->runGit(['commit', '-m', 'Initial commit'], $repositoryDir);
|
||||
$this->runGit(['branch', '-M', 'main'], $repositoryDir);
|
||||
|
||||
return $repositoryDir;
|
||||
}
|
||||
|
||||
/** @param list<string> $arguments */
|
||||
private function runGit(array $arguments, string $cwd): void
|
||||
{
|
||||
$process = new Process(['git', ...$arguments], $cwd);
|
||||
$process->mustRun();
|
||||
}
|
||||
|
||||
private function mapping(string $repositoryUrl, string $repositoryFilePath): ScriptMapping
|
||||
{
|
||||
$mapping = (new ScriptMapping())
|
||||
->setPublicPath('mcp/graylog/install.sh')
|
||||
->setRepositoryUrl($repositoryUrl)
|
||||
->setGitRef('main')
|
||||
->setRepositoryFilePath($repositoryFilePath)
|
||||
->setActive(true);
|
||||
|
||||
return $this->setMappingId($mapping, 42);
|
||||
}
|
||||
|
||||
private function synchronizer(): GitSynchronizer
|
||||
{
|
||||
return new GitSynchronizer(new CachePathResolver($this->workDir.'/cache', $this->workDir));
|
||||
}
|
||||
|
||||
private function setMappingId(ScriptMapping $mapping, int $id): ScriptMapping
|
||||
{
|
||||
$property = new ReflectionProperty(ScriptMapping::class, 'id');
|
||||
$property->setValue($mapping, $id);
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue