feat: manage script mappings from admin
This commit is contained in:
parent
c39a24a4f2
commit
4e2f181dd9
7 changed files with 325 additions and 2 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Controller\Admin;
|
namespace App\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Repository\ScriptMappingRepository;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
@ -9,8 +10,10 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||||
final class DashboardController extends AbstractController
|
final class DashboardController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/admin', name: 'admin_dashboard', methods: ['GET'])]
|
#[Route('/admin', name: 'admin_dashboard', methods: ['GET'])]
|
||||||
public function __invoke(): Response
|
public function __invoke(ScriptMappingRepository $mappingRepository): Response
|
||||||
{
|
{
|
||||||
return $this->render('admin/dashboard.html.twig');
|
return $this->render('admin/dashboard.html.twig', [
|
||||||
|
'mappings' => $mappingRepository->findAllOrderedByPublicPath(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
src/Controller/Admin/ScriptMappingController.php
Normal file
86
src/Controller/Admin/ScriptMappingController.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Entity\ScriptMapping;
|
||||||
|
use App\Form\ScriptMappingType;
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,4 +20,10 @@ final class ScriptMappingRepository extends ServiceEntityRepository
|
||||||
'active' => true,
|
'active' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<ScriptMapping> */
|
||||||
|
public function findAllOrderedByPublicPath(): array
|
||||||
|
{
|
||||||
|
return $this->findBy([], ['publicPath' => 'ASC']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,51 @@
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<main>
|
<main>
|
||||||
<h1>Mappings</h1>
|
<h1>Mappings</h1>
|
||||||
|
<p><a href="{{ path('admin_mapping_new') }}">Nouveau mapping</a></p>
|
||||||
|
|
||||||
|
{% for message in app.flashes('success') %}
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<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>{{ mapping.publicPath }}</td>
|
||||||
|
<td>{{ mapping.repositoryUrl }}</td>
|
||||||
|
<td>{{ mapping.gitRef }}</td>
|
||||||
|
<td>{{ mapping.repositoryFilePath }}</td>
|
||||||
|
<td>{{ mapping.active ? 'oui' : 'non' }}</td>
|
||||||
|
<td>{{ mapping.lastSyncStatus }}</td>
|
||||||
|
<td>{{ mapping.lastSuccessfulSyncAt ? mapping.lastSuccessfulSyncAt|date('Y-m-d H:i:s') : '' }}</td>
|
||||||
|
<td>{{ mapping.lastSyncError }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ path('admin_mapping_edit', {id: mapping.id}) }}">Modifier</a>
|
||||||
|
<form method="post" action="{{ path('admin_mapping_delete', {id: mapping.id}) }}">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('delete_mapping_' ~ mapping.id) }}">
|
||||||
|
<button type="submit">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9">Aucun mapping.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
19
templates/admin/mapping_form.html.twig
Normal file
19
templates/admin/mapping_form.html.twig
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<main>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
|
||||||
|
{{ form_start(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 type="submit">Enregistrer</button>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
94
tests/Controller/Admin/ScriptMappingControllerTest.php
Normal file
94
tests/Controller/Admin/ScriptMappingControllerTest.php
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Entity\ScriptMapping;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\Tools\SchemaTool;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
final class ScriptMappingControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
private 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNewMappingRequiresAuthentication(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/admin/mappings/new');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/admin/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loginAsAdmin(): void
|
||||||
|
{
|
||||||
|
$user = (new User())->setUsername('admin')->setPasswordHash('unused');
|
||||||
|
$this->entityManager->persist($user);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->client->loginUser($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue