1
0
Fork 0

feat: manage script mappings from admin

This commit is contained in:
thibaud-leclere 2026-05-05 10:00:56 +02:00
parent c39a24a4f2
commit 4e2f181dd9
7 changed files with 325 additions and 2 deletions

View file

@ -2,6 +2,7 @@
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;
@ -9,8 +10,10 @@ use Symfony\Component\Routing\Attribute\Route;
final class DashboardController extends AbstractController
{
#[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(),
]);
}
}

View 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');
}
}

View 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,
]);
}
}

View file

@ -20,4 +20,10 @@ final class ScriptMappingRepository extends ServiceEntityRepository
'active' => true,
]);
}
/** @return list<ScriptMapping> */
public function findAllOrderedByPublicPath(): array
{
return $this->findBy([], ['publicPath' => 'ASC']);
}
}

View file

@ -5,5 +5,51 @@
{% block body %}
<main>
<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>
{% endblock %}

View 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 %}

View 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);
}
}