diff --git a/.claude/deploiement.md b/.claude/deploiement.md index 57a03df5..762100dc 100644 --- a/.claude/deploiement.md +++ b/.claude/deploiement.md @@ -8,7 +8,7 @@ Cible : hébergement mutualisé **o2switch**, un sous-domaine par instance (` Le déploiement automatique GitHub Actions (webhook) ne fonctionne **pas** sur o2switch — les IPs des runners GitHub Actions sont bloquées par le firewall SSH. Les scripts SSH sont la seule méthode fiable aujourd'hui (#288, en pause). Le webhook `public/deploy.php` existe dans le repo mais est cassé et inutilisé. +> Le déploiement automatique GitHub Actions (webhook) ne fonctionne **pas** sur o2switch — les IPs des runners GitHub Actions sont bloquées par le firewall SSH. Les scripts SSH sont la seule méthode fiable aujourd'hui (#288, en pause). Le webhook `public/deploy.php` existe dans le repo et est actif (vérifié HMAC-SHA256, secret `DEPLOY_WEBHOOK_SECRET`), déclenché par GitHub Actions après CI success — mais reste sans effet pratique tant que le blocage IP côté o2switch n'est pas levé. ### Piège IP dynamique / VPN d'entreprise @@ -354,6 +354,27 @@ MAILER_DSN=smtp://:@lenouvel.me:465 > l'instance est créée avec `MAILER_DSN=null://null` : aucun email d'invitation ni de > réinitialisation de mot de passe ne part (cf. `GuestAccountCreator`). +### `BROADCAST_SHARED_TOKEN` — diffusion admin multi-instances (#283) + +Secret partagé, **identique sur les 7 instances**, qui authentifie les appels +service-to-service entre `ronan.lenouvel.me` (qui orchestre) et chaque autre +instance (endpoint interne `POST /internal/broadcast`, hors JWT utilisateur). + +```bash +BROADCAST_SHARED_TOKEN= +BROADCAST_INSTANCE_NAME= # ronan, yannick, coralie... +BROADCAST_ADMIN_EMAIL= +``` + +Généré automatiquement par `bin/deploy-all.sh --init` depuis +`BROADCAST_SHARED_TOKEN_PRESET` (et `BROADCAST_ADMIN_EMAIL_PRESET`) dans +`.secrets` (variables globales, comme `MAILER_DSN_PRESET`). Pour les +instances **déjà déployées avant cette feature**, ajouter manuellement les +3 lignes ci-dessus dans le `.env.local` de chacune (SSH), avec la **même** +valeur de `BROADCAST_SHARED_TOKEN` partout — un token qui diverge sur une +seule instance la rend injoignable depuis `ronan.lenouvel.me` (401 silencieux +côté orchestrateur, loggé en warning, sans bloquer les autres instances). + ### `MAILER_DSN` — obligatoire pour les emails (dont la notif de fin de lot) Par défaut `MAILER_DSN=null://null` (`.env`) : **aucun email n'est envoyé**. Les diff --git a/.env b/.env index 61c0cefa..d04c84aa 100644 --- a/.env +++ b/.env @@ -60,3 +60,15 @@ JWT_PASSPHRASE= ###> symfony/mailer ### MAILER_DSN=null://null ###< symfony/mailer ### + +###> app/broadcast ### +# Diffusion admin multi-instances (#283) +# Secret partagé entre les 7 instances — générer avec : openssl rand -hex 32 +# Placer la valeur réelle dans .env.local (jamais commiter le secret) +BROADCAST_SHARED_TOKEN= +# Prénom de l'instance courante (ronan, yannick, coralie...) — utilisé pour +# s'auto-exclure de la boucle d'appels HTTP vers les autres instances +BROADCAST_INSTANCE_NAME= +# Email du compte admin autorisé à déclencher un broadcast depuis /admin/broadcast +BROADCAST_ADMIN_EMAIL= +###< app/broadcast ### diff --git a/.env.production.example b/.env.production.example index 115675af..924d2ef4 100644 --- a/.env.production.example +++ b/.env.production.example @@ -30,3 +30,12 @@ JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PASSPHRASE=CHANGE_ME JWT_TTL=3600 + +# Diffusion admin multi-instances (#283) +# Secret partagé — identique sur les 7 instances, généré une fois avec : +# openssl rand -hex 32 +BROADCAST_SHARED_TOKEN=CHANGE_ME +# Prénom de cette instance (ronan, yannick, coralie...) +BROADCAST_INSTANCE_NAME=PRENOM +# Email du compte admin autorisé à déclencher un broadcast (uniquement pertinent sur ronan.lenouvel.me) +BROADCAST_ADMIN_EMAIL=CHANGE_ME diff --git a/.env.test b/.env.test index 8a42dc22..b67c251f 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,6 @@ APP_ENV=test KERNEL_CLASS='App\Kernel' DATABASE_URL="mysql://root:root@127.0.0.1:3306/homecloud?serverVersion=mariadb-10.11.0&charset=utf8mb4" +BROADCAST_SHARED_TOKEN=test-broadcast-token-fixed +BROADCAST_INSTANCE_NAME=ronan +BROADCAST_ADMIN_EMAIL=admin@homecloud.test diff --git a/.github/avancement.md b/.github/avancement.md index 5c69fa37..bcc1daaf 100644 --- a/.github/avancement.md +++ b/.github/avancement.md @@ -6,6 +6,15 @@ --- +## ✅ Diffusion d'un message admin multi-instances (2026-07-23, #283, branche `feature/283-broadcast-message`) + +- Ronan peut diffuser un message (maintenance, indisponibilité, mise à jour majeure) par email à tous les utilisateurs (propriétaires + invités) de toutes les instances déployées, ou une seule instance ciblée, en une seule action depuis `/admin/broadcast`. +- Architecture : chaque instance a sa propre DB isolée (pas de requête SQL centrale possible). L'instance `ronan.lenouvel.me` orchestre via un appel HTTP interne (`BroadcastOrchestrator` → `POST /internal/broadcast`) sur chaque autre instance, qui envoie ensuite ses propres emails localement (`BroadcastMailer::sendToAllUsers`). Secret partagé identique sur les 7 instances (`BROADCAST_SHARED_TOKEN`), authentification par `BroadcastTokenAuthenticator` sur un firewall dédié `broadcast_internal`, hors du firewall JWT `api` (structurellement incompatible avec un appel service-to-service). +- Pas de nouveau `ROLE_ADMIN` Symfony (aucun rôle différencié n'existe dans ce projet) : garde applicative `BroadcastAdminChecker` (whitelist par email `BROADCAST_ADMIN_EMAIL`), couvrant le cas où un guest serait un jour ajouté sur l'instance admin elle-même. +- Dry-run à tous les niveaux (service, orchestrateur, endpoint, command, formulaire admin) sans duplication de la logique d'envoi — en dry-run, l'orchestrateur ne fait aucun appel HTTP sortant. +- Canal email uniquement pour cette version — notification in-app laissée en ticket de suivi (#361). +- Tests : `BroadcastTargetProviderTest`, `BroadcastMailerTest` (dont guest inclus, email invalide skippé), `BroadcastOrchestratorTest` (ciblage, dry-run, échec partiel n'interrompt pas les autres), `BroadcastInternalControllerTest` (401 sans/mauvais token), `BroadcastSendCommandTest`, `BroadcastAdminCheckerTest`, `BroadcastAdminWebControllerTest` (403 non-admin). + ## ✅ Stockage utilisé sur le dashboard (2026-07-23, #301, branche `feature/301-storage-used-dashboard`) - `HomeController::index()` affichait un placeholder statique (`'Calcul à implémenter'`) à la place du poids réel de stockage. diff --git a/bin/deploy-all.sh b/bin/deploy-all.sh index d22c0035..4d3d0eb8 100755 --- a/bin/deploy-all.sh +++ b/bin/deploy-all.sh @@ -144,6 +144,9 @@ for PRENOM in "${TARGETS[@]}"; do if [[ -z "${MAILER_DSN_PRESET:-}" ]]; then warn "MAILER_DSN_PRESET absent (.secrets ou .secrets.${PRENOM}) — l'instance n'enverra aucun email" fi + if [[ -z "${BROADCAST_SHARED_TOKEN_PRESET:-}" ]]; then + warn "BROADCAST_SHARED_TOKEN_PRESET absent (.secrets) — le broadcast admin (#283) sera désactivé sur cette instance" + fi DB_NAME="${SSH_USER}_${PRENOM}" DB_USER="${SSH_USER}_${PRENOM}" @@ -160,7 +163,10 @@ DATABASE_URL=${DATABASE_URL} JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PASSPHRASE=${JWT_PASSPHRASE} -MAILER_DSN=${MAILER_DSN_PRESET:-null://null}" +MAILER_DSN=${MAILER_DSN_PRESET:-null://null} +BROADCAST_SHARED_TOKEN=${BROADCAST_SHARED_TOKEN_PRESET:-} +BROADCAST_INSTANCE_NAME=${PRENOM} +BROADCAST_ADMIN_EMAIL=${BROADCAST_ADMIN_EMAIL_PRESET:-}" if ssh ${SSH_KEY_OPTS} -p "${SSH_PORT}" "${SSH_USER}@${SSH_HOST}" "set -e; mkdir -p ${DEPLOY_PATH}; cd ${DEPLOY_PATH}; git clone ${GIT_REPO} . && mkdir -p var/log && cat > .env.local <<'ENVEOF' ${ENV_LOCAL_CONTENT} diff --git a/config/broadcast_targets.php b/config/broadcast_targets.php new file mode 100644 index 00000000..bc30e107 --- /dev/null +++ b/config/broadcast_targets.php @@ -0,0 +1,17 @@ + 'https://ronan.lenouvel.me', + 'yannick' => 'https://yannick.lenouvel.me', + 'coralie' => 'https://coralie.lenouvel.me', + 'elea' => 'https://elea.lenouvel.me', + 'corentin' => 'https://corentin.lenouvel.me', + 'damien' => 'https://damien.lenouvel.me', + 'baptiste' => 'https://baptiste.lenouvel.me', +]; diff --git a/config/packages/security.yaml b/config/packages/security.yaml index c75b8310..77d664e8 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -26,6 +26,12 @@ security: max_attempts: 5 interval: '15 minutes' + broadcast_internal: + pattern: ^/internal/broadcast + stateless: true + custom_authenticators: + - App\Security\BroadcastTokenAuthenticator + api: pattern: ^/api stateless: true @@ -68,6 +74,9 @@ when@dev: &access_control - { path: ^/api/reset-password, roles: PUBLIC_ACCESS } - { path: ^/api/request-reset-password, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS } + # Auth par secret partagé gérée par BroadcastTokenAuthenticator, + # pas par ROLE_USER JWT — doit précéder la règle catch-all ^/api. + - { path: ^/internal/broadcast, roles: PUBLIC_ACCESS } - { path: ^/api, roles: ROLE_USER } # Partage par lien public : accessible sans compte, le secret est # dans (selector, token). Doit précéder toute règle catch-all ^/. diff --git a/config/packages/test/security.yaml b/config/packages/test/security.yaml index 92bfdb43..e5329c33 100644 --- a/config/packages/test/security.yaml +++ b/config/packages/test/security.yaml @@ -19,6 +19,12 @@ security: success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: lexik_jwt_authentication.handler.authentication_failure + broadcast_internal: + pattern: ^/internal/broadcast + stateless: true + custom_authenticators: + - App\Security\BroadcastTokenAuthenticator + api: pattern: ^/api stateless: true @@ -52,6 +58,7 @@ security: - { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/v1/auth/login$, roles: PUBLIC_ACCESS } - { path: ^/api/v1/auth/token/refresh$, roles: PUBLIC_ACCESS } + - { path: ^/internal/broadcast, roles: PUBLIC_ACCESS } - { path: ^/api, roles: ROLE_USER } - { path: ^/login$, roles: PUBLIC_ACCESS } - { path: ^/web/token$, roles: ROLE_USER } diff --git a/config/services.yaml b/config/services.yaml index 84868f9b..dfe6ba05 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -35,6 +35,9 @@ services: App\Interface\BatchCompletionNotifierInterface: '@App\Service\BatchCompletionNotifier' App\Interface\ShareNotificationMailerInterface: '@App\Service\ShareNotificationMailer' App\Interface\FolderZipArchiverInterface: '@App\Service\FolderZipArchiver' + App\Interface\BroadcastTargetProviderInterface: '@App\Service\BroadcastTargetProvider' + App\Interface\BroadcastMailerInterface: '@App\Service\BroadcastMailer' + App\Interface\BroadcastOrchestratorInterface: '@App\Service\BroadcastOrchestrator' App\State\AlbumProcessor: arguments: @@ -83,6 +86,11 @@ services: _defaults: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: + string $sharedToken: '%env(BROADCAST_SHARED_TOKEN)%' + string $adminEmail: '%env(BROADCAST_ADMIN_EMAIL)%' + string $currentInstance: '%env(BROADCAST_INSTANCE_NAME)%' + string $configPath: '%kernel.project_dir%/config/broadcast_targets.php' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name diff --git a/src/Command/BroadcastSendCommand.php b/src/Command/BroadcastSendCommand.php new file mode 100644 index 00000000..c73b77b3 --- /dev/null +++ b/src/Command/BroadcastSendCommand.php @@ -0,0 +1,59 @@ +addOption('subject', null, InputOption::VALUE_REQUIRED, 'Sujet du message') + ->addOption('body', null, InputOption::VALUE_REQUIRED, 'Corps du message (HTML autorisé)') + ->addOption('instance', null, InputOption::VALUE_REQUIRED, 'Cibler une seule instance (défaut : toutes)') + ->addOption('dry-run', null, InputOption::VALUE_NONE, "Simuler sans envoyer d'email réel"); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $results = $this->orchestrator->dispatch( + (string) $input->getOption('subject'), + (string) $input->getOption('body'), + $input->getOption('instance'), + (bool) $input->getOption('dry-run'), + ); + + foreach ($results as $instance => $success) { + if ($success) { + $io->writeln(sprintf('%s : OK', $instance)); + } else { + $io->writeln(sprintf('%s : échec', $instance)); + } + } + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Api/BroadcastInternalController.php b/src/Controller/Api/BroadcastInternalController.php new file mode 100644 index 00000000..5a820ea4 --- /dev/null +++ b/src/Controller/Api/BroadcastInternalController.php @@ -0,0 +1,38 @@ +getContent(), true) ?? []; + + $sent = $this->broadcastMailer->sendToAllUsers( + (string) ($data['subject'] ?? ''), + (string) ($data['body'] ?? ''), + (bool) ($data['dryRun'] ?? false), + ); + + return new JsonResponse(['sent' => $sent]); + } +} diff --git a/src/Controller/Web/BroadcastAdminWebController.php b/src/Controller/Web/BroadcastAdminWebController.php new file mode 100644 index 00000000..2152776c --- /dev/null +++ b/src/Controller/Web/BroadcastAdminWebController.php @@ -0,0 +1,62 @@ +getUser(); + + if (!$this->adminChecker->isAdmin($user)) { + throw $this->createAccessDeniedException("Réservé à l'administrateur."); + } + + $input = new BroadcastMessageInput(); + $form = $this->createForm(BroadcastMessageFormType::class, $input); + $form->handleRequest($request); + + $results = null; + + if ($form->isSubmitted() && $form->isValid()) { + $results = $this->orchestrator->dispatch( + $input->subject, + $input->body, + $input->targetInstance, + $input->dryRun, + ); + } + + return $this->render('web/broadcast_admin.html.twig', [ + 'form' => $form->createView(), + 'results' => $results, + ]); + } +} diff --git a/src/Dto/BroadcastMessageInput.php b/src/Dto/BroadcastMessageInput.php new file mode 100644 index 00000000..f5ff32cd --- /dev/null +++ b/src/Dto/BroadcastMessageInput.php @@ -0,0 +1,21 @@ + null]; + foreach (array_keys($this->targetProvider->getAllTargets()) as $instance) { + $instanceChoices[$instance] = $instance; + } + + $builder + ->add('subject', TextType::class, ['label' => 'Sujet']) + ->add('body', TextareaType::class, ['label' => 'Message']) + ->add('targetInstance', ChoiceType::class, [ + 'label' => 'Cible', + 'choices' => $instanceChoices, + 'required' => false, + ]) + ->add('dryRun', CheckboxType::class, [ + 'label' => "Essai à blanc (aucun email réel envoyé)", + 'required' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => BroadcastMessageInput::class]); + } +} diff --git a/src/Interface/BroadcastMailerInterface.php b/src/Interface/BroadcastMailerInterface.php new file mode 100644 index 00000000..ce0b19e1 --- /dev/null +++ b/src/Interface/BroadcastMailerInterface.php @@ -0,0 +1,15 @@ + prénom d'instance => succès + */ + public function dispatch(string $subject, string $body, ?string $targetInstance, bool $dryRun): array; +} diff --git a/src/Interface/BroadcastTargetProviderInterface.php b/src/Interface/BroadcastTargetProviderInterface.php new file mode 100644 index 00000000..9b0f48b9 --- /dev/null +++ b/src/Interface/BroadcastTargetProviderInterface.php @@ -0,0 +1,15 @@ + prénom d'instance => URL de base + */ + public function getAllTargets(): array; + + public function getTarget(string $instance): ?string; +} diff --git a/src/Interface/UserRepositoryInterface.php b/src/Interface/UserRepositoryInterface.php index 7c2a0770..98f71691 100644 --- a/src/Interface/UserRepositoryInterface.php +++ b/src/Interface/UserRepositoryInterface.php @@ -17,4 +17,7 @@ public function find(mixed $id, LockMode|int|null $lockMode = null, ?int $lockVe /** @param array $criteria */ public function findOneBy(array $criteria, ?array $orderBy = null): ?User; + + /** @return User[] */ + public function findAll(): array; } diff --git a/src/Security/BroadcastAdminChecker.php b/src/Security/BroadcastAdminChecker.php new file mode 100644 index 00000000..34a6861b --- /dev/null +++ b/src/Security/BroadcastAdminChecker.php @@ -0,0 +1,25 @@ +adminEmail !== '' && $user->getEmail() === $this->adminEmail; + } +} diff --git a/src/Security/BroadcastTokenAuthenticator.php b/src/Security/BroadcastTokenAuthenticator.php new file mode 100644 index 00000000..3dd5b17a --- /dev/null +++ b/src/Security/BroadcastTokenAuthenticator.php @@ -0,0 +1,59 @@ +headers->get('X-Broadcast-Token', ''); + + if ($this->sharedToken === '' || !hash_equals($this->sharedToken, $token)) { + throw new CustomUserMessageAuthenticationException('Token de broadcast invalide.'); + } + + return new SelfValidatingPassport(new UserBadge( + 'broadcast-service', + static fn (): InMemoryUser => new InMemoryUser('broadcast-service', null, ['ROLE_BROADCAST_SERVICE']), + )); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return new Response('Unauthorized', Response::HTTP_UNAUTHORIZED); + } +} diff --git a/src/Service/BroadcastMailer.php b/src/Service/BroadcastMailer.php new file mode 100644 index 00000000..e2a8acc8 --- /dev/null +++ b/src/Service/BroadcastMailer.php @@ -0,0 +1,64 @@ +userRepository->findAll() as $user) { + if (\count($this->validator->validate($user->getEmail(), new Email())) > 0) { + $this->logger->warning('Broadcast : email invalide ignoré', ['userId' => $user->getId()->toRfc4122()]); + continue; + } + + $sent++; + + if ($dryRun) { + continue; + } + + $email = (new TemplatedEmail()) + ->from(new Address('no-reply@homecloud.fr')) + ->to($user->getEmail()) + ->subject($subject) + ->htmlTemplate('emails/broadcast_message.html.twig') + ->context([ + 'subject' => $subject, + 'body' => $htmlBody, + 'accentColor' => EmailBranding::ACCENT_COLOR, + ]); + + $this->mailer->send($email); + } + + return $sent; + } +} diff --git a/src/Service/BroadcastOrchestrator.php b/src/Service/BroadcastOrchestrator.php new file mode 100644 index 00000000..f455998c --- /dev/null +++ b/src/Service/BroadcastOrchestrator.php @@ -0,0 +1,76 @@ + $this->targetProvider->getTarget($targetInstance)] + : $this->targetProvider->getAllTargets(); + + $results = []; + + foreach ($targets as $instance => $url) { + if ($instance === $this->currentInstance) { + $this->localMailer->sendToAllUsers($subject, $body, $dryRun); + $results[$instance] = true; + continue; + } + + if ($dryRun) { + $results[$instance] = true; + continue; + } + + $results[$instance] = $this->dispatchToRemote($url, $subject, $body); + } + + return $results; + } + + private function dispatchToRemote(string $url, string $subject, string $body): bool + { + try { + $this->httpClient->request('POST', $url . '/internal/broadcast', [ + 'headers' => ['X-Broadcast-Token' => $this->sharedToken], + 'json' => ['subject' => $subject, 'body' => $body, 'dryRun' => false], + ]); + + return true; + } catch (\Throwable $e) { + $this->logger->warning('Broadcast : échec vers une instance distante', [ + 'url' => $url, + 'error' => $e->getMessage(), + ]); + + return false; + } + } +} diff --git a/src/Service/BroadcastTargetProvider.php b/src/Service/BroadcastTargetProvider.php new file mode 100644 index 00000000..29bbcad7 --- /dev/null +++ b/src/Service/BroadcastTargetProvider.php @@ -0,0 +1,24 @@ +configPath; + } + + public function getTarget(string $instance): ?string + { + return $this->getAllTargets()[$instance] ?? null; + } +} diff --git a/src/Twig/BroadcastAdminExtension.php b/src/Twig/BroadcastAdminExtension.php new file mode 100644 index 00000000..ba60c332 --- /dev/null +++ b/src/Twig/BroadcastAdminExtension.php @@ -0,0 +1,33 @@ +isBroadcastAdmin(...)), + ]; + } + + public function isBroadcastAdmin(?User $user): bool + { + return $user !== null && $this->adminChecker->isAdmin($user); + } +} diff --git a/templates/emails/broadcast_message.html.twig b/templates/emails/broadcast_message.html.twig new file mode 100644 index 00000000..a8a71cee --- /dev/null +++ b/templates/emails/broadcast_message.html.twig @@ -0,0 +1,57 @@ + + + + + +{{ subject }} + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+HomeCloud +
+Bonjour, +
+{{ body|raw }} +
+ +Message envoyé par l'administrateur de HomeCloud. + +
+ + + + + +
+HomeCloud — votre cloud personnel +
+Solution entièrement développée par Ronan Lenouvel +
+ +
+ + diff --git a/templates/web/broadcast_admin.html.twig b/templates/web/broadcast_admin.html.twig new file mode 100644 index 00000000..ee75a775 --- /dev/null +++ b/templates/web/broadcast_admin.html.twig @@ -0,0 +1,37 @@ +{% extends 'web/layout.html.twig' %} + +{% block title %}Diffusion admin — HomeCloud{% endblock %} + +{% block content %} + +
+ +
+

Diffuser un message

+

Envoie un email à tous les utilisateurs (propriétaires et invités) de toutes les instances, ou d'une instance ciblée.

+
+ + {% if results is not null %} +
+

Résultat

+
    + {% for instance, success in results %} +
  • {{ instance }} : {{ success ? 'OK' : 'échec' }}
  • + {% endfor %} +
+
+ {% endif %} + + {{ form_start(form) }} +
+ {{ form_row(form.subject) }} + {{ form_row(form.body) }} + {{ form_row(form.targetInstance) }} + {{ form_row(form.dryRun) }} + +
+ {{ form_end(form) }} + +
+ +{% endblock %} diff --git a/templates/web/layout.html.twig b/templates/web/layout.html.twig index 93df8ed5..d526bec2 100644 --- a/templates/web/layout.html.twig +++ b/templates/web/layout.html.twig @@ -111,6 +111,16 @@ Changelog + + {% if is_broadcast_admin(app.user) %} + + + + + Diffuser un message + + {% endif %} diff --git a/tests/Api/BroadcastInternalControllerTest.php b/tests/Api/BroadcastInternalControllerTest.php new file mode 100644 index 00000000..7be264d0 --- /dev/null +++ b/tests/Api/BroadcastInternalControllerTest.php @@ -0,0 +1,58 @@ +request('POST', '/internal/broadcast', server: ['CONTENT_TYPE' => 'application/json'], content: '{"subject":"S","body":"B"}'); + + $this->assertSame(401, $client->getResponse()->getStatusCode()); + } + + public function testRejects401WithWrongToken(): void + { + $client = static::createClient(); + $client->request('POST', '/internal/broadcast', server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_BROADCAST_TOKEN' => 'mauvais-token', + ], content: '{"subject":"S","body":"B"}'); + + $this->assertSame(401, $client->getResponse()->getStatusCode()); + } + + public function testAcceptsValidTokenAndSendsLocally(): void + { + $client = static::createClient(); + $client->request('POST', '/internal/broadcast', server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_BROADCAST_TOKEN' => $_ENV['BROADCAST_SHARED_TOKEN'], + ], content: '{"subject":"Maintenance","body":"

Texte

","dryRun":false}'); + + $this->assertSame(200, $client->getResponse()->getStatusCode()); + } + + public function testDryRunReturns200WithoutSendingAnyEmail(): void + { + $client = static::createClient(); + $client->request('POST', '/internal/broadcast', server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_BROADCAST_TOKEN' => $_ENV['BROADCAST_SHARED_TOKEN'], + ], content: '{"subject":"Maintenance","body":"

Texte

","dryRun":true}'); + + $this->assertSame(200, $client->getResponse()->getStatusCode()); + self::assertEmailCount(0); + } +} diff --git a/tests/Command/BroadcastSendCommandTest.php b/tests/Command/BroadcastSendCommandTest.php new file mode 100644 index 00000000..2c03a9ee --- /dev/null +++ b/tests/Command/BroadcastSendCommandTest.php @@ -0,0 +1,81 @@ +createMock(BroadcastOrchestratorInterface::class); + $orchestrator->expects($this->once()) + ->method('dispatch') + ->with('Sujet', 'Corps', null, false) + ->willReturn(['ronan' => true, 'yannick' => true]); + + $tester = $this->commandTester($orchestrator); + $tester->execute(['--subject' => 'Sujet', '--body' => 'Corps']); + + $tester->assertCommandIsSuccessful(); + } + + public function testTargetsSingleInstanceWithOption(): void + { + $orchestrator = $this->createMock(BroadcastOrchestratorInterface::class); + $orchestrator->expects($this->once()) + ->method('dispatch') + ->with('Sujet', 'Corps', 'yannick', false) + ->willReturn(['yannick' => true]); + + $tester = $this->commandTester($orchestrator); + $tester->execute(['--subject' => 'Sujet', '--body' => 'Corps', '--instance' => 'yannick']); + + $tester->assertCommandIsSuccessful(); + } + + public function testDryRunOptionPreventsRealSend(): void + { + $orchestrator = $this->createMock(BroadcastOrchestratorInterface::class); + $orchestrator->expects($this->once()) + ->method('dispatch') + ->with('Sujet', 'Corps', null, true) + ->willReturn(['ronan' => true]); + + $tester = $this->commandTester($orchestrator); + $tester->execute(['--subject' => 'Sujet', '--body' => 'Corps', '--dry-run' => true]); + + $tester->assertCommandIsSuccessful(); + } + + public function testReportsFailedInstancesInOutput(): void + { + $orchestrator = $this->createMock(BroadcastOrchestratorInterface::class); + $orchestrator->method('dispatch')->willReturn(['ronan' => true, 'yannick' => false]); + + $tester = $this->commandTester($orchestrator); + $tester->execute(['--subject' => 'Sujet', '--body' => 'Corps']); + + $this->assertStringContainsString('yannick', $tester->getDisplay()); + } + + private function commandTester(BroadcastOrchestratorInterface $orchestrator): CommandTester + { + $command = new BroadcastSendCommand($orchestrator); + $application = new Application(); + $application->addCommand($command); + + return new CommandTester($application->find('app:broadcast:send')); + } +} diff --git a/tests/Unit/Security/BroadcastAdminCheckerTest.php b/tests/Unit/Security/BroadcastAdminCheckerTest.php new file mode 100644 index 00000000..52658454 --- /dev/null +++ b/tests/Unit/Security/BroadcastAdminCheckerTest.php @@ -0,0 +1,35 @@ +assertTrue($checker->isAdmin($user)); + } + + public function testIsAdminReturnsFalseForOtherUsers(): void + { + $checker = new BroadcastAdminChecker('ronan@example.com'); + $user = new User('guest@example.com', 'Guest'); + + $this->assertFalse($checker->isAdmin($user)); + } +} diff --git a/tests/Unit/Service/BroadcastMailerTest.php b/tests/Unit/Service/BroadcastMailerTest.php new file mode 100644 index 00000000..d3ef2b2c --- /dev/null +++ b/tests/Unit/Service/BroadcastMailerTest.php @@ -0,0 +1,110 @@ +createStub(LoggerInterface::class), + ); + } + + private function makeUserRepositoryStub(array $users): UserRepositoryInterface + { + $stub = $this->createStub(UserRepositoryInterface::class); + $stub->method('findAll')->willReturn($users); + + return $stub; + } + + public function testSendsToAllUsersWithValidEmail(): void + { + $owner = new User('owner@example.com', 'Owner'); + $guest = new User('guest@example.com', 'Guest'); + $guest->markAsGuest(); + + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects($this->exactly(2))->method('send'); + + $sender = $this->makeMailer($mailer, $this->makeUserRepositoryStub([$owner, $guest])); + + $count = $sender->sendToAllUsers('Maintenance', '

Texte

', dryRun: false); + + $this->assertSame(2, $count); + } + + public function testIncludesGuestAccounts(): void + { + $guest = new User('guest@example.com', 'Guest'); + $guest->markAsGuest(); + + $mailer = $this->createMock(MailerInterface::class); + $sentTo = null; + $mailer->expects($this->once())->method('send')->willReturnCallback( + function ($message) use (&$sentTo) { + $sentTo = $message->getTo()[0]->getAddress(); + } + ); + + $sender = $this->makeMailer($mailer, $this->makeUserRepositoryStub([$guest])); + $sender->sendToAllUsers('Maintenance', '

Texte

', dryRun: false); + + $this->assertSame('guest@example.com', $sentTo); + } + + public function testDryRunSendsNoEmailButReturnsCount(): void + { + $owner = new User('owner@example.com', 'Owner'); + $guest = new User('guest@example.com', 'Guest'); + + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects($this->never())->method('send'); + + $sender = $this->makeMailer($mailer, $this->makeUserRepositoryStub([$owner, $guest])); + + $count = $sender->sendToAllUsers('Maintenance', '

Texte

', dryRun: true); + + $this->assertSame(2, $count); + } + + public function testSkipsInvalidEmailAndLogsWarning(): void + { + $valid = new User('valid@example.com', 'Valid'); + $invalid = new User('pas-un-email', 'Invalid'); + + $mailer = $this->createMock(MailerInterface::class); + $mailer->expects($this->once())->method('send'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning'); + + $sender = $this->makeMailer($mailer, $this->makeUserRepositoryStub([$valid, $invalid]), $logger); + + $count = $sender->sendToAllUsers('Maintenance', '

Texte

', dryRun: false); + + $this->assertSame(1, $count); + } +} diff --git a/tests/Unit/Service/BroadcastOrchestratorTest.php b/tests/Unit/Service/BroadcastOrchestratorTest.php new file mode 100644 index 00000000..4b86e7e2 --- /dev/null +++ b/tests/Unit/Service/BroadcastOrchestratorTest.php @@ -0,0 +1,144 @@ + 'https://ronan.lenouvel.me', + 'yannick' => 'https://yannick.lenouvel.me', + 'elea' => 'https://elea.lenouvel.me', + ]; + + private function makeTargetProviderStub(): BroadcastTargetProviderInterface + { + $stub = $this->createStub(BroadcastTargetProviderInterface::class); + $stub->method('getAllTargets')->willReturn(self::TARGETS); + $stub->method('getTarget')->willReturnCallback( + fn (string $instance) => self::TARGETS[$instance] ?? null, + ); + + return $stub; + } + + private function makeOrchestrator( + MockHttpClient $httpClient, + ?BroadcastMailerInterface $localMailer = null, + ): BroadcastOrchestrator { + $localMailer ??= $this->createStub(BroadcastMailerInterface::class); + + return new BroadcastOrchestrator( + $httpClient, + $this->makeTargetProviderStub(), + $localMailer, + $this->createStub(LoggerInterface::class), + currentInstance: 'ronan', + sharedToken: 'the-secret-token', + ); + } + + public function testDispatchesToAllInstancesWhenNoTargetGiven(): void + { + $requests = []; + $httpClient = new MockHttpClient(function ($method, $url, $options) use (&$requests) { + $requests[] = $url; + + return new MockResponse('{"sent":1}'); + }); + + $localMailer = $this->createMock(BroadcastMailerInterface::class); + $localMailer->expects($this->once())->method('sendToAllUsers')->willReturn(1); + + $orchestrator = $this->makeOrchestrator($httpClient, $localMailer); + $orchestrator->dispatch('Sujet', 'Corps', targetInstance: null, dryRun: false); + + $this->assertCount(2, $requests); + $this->assertContains('https://yannick.lenouvel.me/internal/broadcast', $requests); + $this->assertContains('https://elea.lenouvel.me/internal/broadcast', $requests); + } + + public function testDispatchesToSingleTargetedInstanceOnly(): void + { + $requests = []; + $httpClient = new MockHttpClient(function ($method, $url, $options) use (&$requests) { + $requests[] = $url; + + return new MockResponse('{"sent":1}'); + }); + + $localMailer = $this->createMock(BroadcastMailerInterface::class); + $localMailer->expects($this->never())->method('sendToAllUsers'); + + $orchestrator = $this->makeOrchestrator($httpClient, $localMailer); + $orchestrator->dispatch('Sujet', 'Corps', targetInstance: 'yannick', dryRun: false); + + $this->assertSame(['https://yannick.lenouvel.me/internal/broadcast'], $requests); + } + + public function testDryRunMakesNoHttpCallAtAll(): void + { + $called = false; + $httpClient = new MockHttpClient(function () use (&$called) { + $called = true; + + return new MockResponse('{"sent":0}'); + }); + + $localMailer = $this->createMock(BroadcastMailerInterface::class); + $localMailer->expects($this->once())->method('sendToAllUsers')->with('Sujet', 'Corps', true)->willReturn(0); + + $orchestrator = $this->makeOrchestrator($httpClient, $localMailer); + $orchestrator->dispatch('Sujet', 'Corps', targetInstance: null, dryRun: true); + + $this->assertFalse($called, 'Aucun appel HTTP ne doit être fait en dry-run.'); + } + + public function testHttpFailureOnOneInstanceDoesNotAbortOthers(): void + { + $httpClient = new MockHttpClient(function ($method, $url) { + if (str_contains($url, 'yannick')) { + throw new TransportException('unreachable'); + } + + return new MockResponse('{"sent":1}'); + }); + + $orchestrator = $this->makeOrchestrator($httpClient); + $result = $orchestrator->dispatch('Sujet', 'Corps', targetInstance: null, dryRun: false); + + $this->assertFalse($result['yannick']); + $this->assertTrue($result['elea']); + } + + public function testSendsSharedTokenHeaderOnEachCall(): void + { + $capturedOptions = []; + $httpClient = new MockHttpClient(function ($method, $url, $options) use (&$capturedOptions) { + $capturedOptions[] = $options; + + return new MockResponse('{"sent":1}'); + }); + + $orchestrator = $this->makeOrchestrator($httpClient); + $orchestrator->dispatch('Sujet', 'Corps', targetInstance: 'yannick', dryRun: false); + + $this->assertContains('X-Broadcast-Token: the-secret-token', $capturedOptions[0]['headers']); + } +} diff --git a/tests/Unit/Service/BroadcastTargetProviderTest.php b/tests/Unit/Service/BroadcastTargetProviderTest.php new file mode 100644 index 00000000..4201a198 --- /dev/null +++ b/tests/Unit/Service/BroadcastTargetProviderTest.php @@ -0,0 +1,43 @@ +makeProvider()->getAllTargets(); + + $this->assertArrayHasKey('ronan', $targets); + $this->assertSame('https://ronan.lenouvel.me', $targets['ronan']); + $this->assertArrayHasKey('yannick', $targets); + } + + public function testGetTargetReturnsUrlForKnownInstance(): void + { + $this->assertSame( + 'https://damien.lenouvel.me', + $this->makeProvider()->getTarget('damien'), + ); + } + + public function testGetTargetReturnsNullForUnknownInstance(): void + { + $this->assertNull($this->makeProvider()->getTarget('inexistant')); + } +} diff --git a/tests/Web/BroadcastAdminWebControllerTest.php b/tests/Web/BroadcastAdminWebControllerTest.php new file mode 100644 index 00000000..2f3180fc --- /dev/null +++ b/tests/Web/BroadcastAdminWebControllerTest.php @@ -0,0 +1,68 @@ +client = static::createClient(); + $this->em = static::getContainer()->get(EntityManagerInterface::class); + $conn = $this->em->getConnection(); + $conn->executeStatement('SET FOREIGN_KEY_CHECKS=0'); + $conn->executeStatement('DELETE FROM users'); + $conn->executeStatement('SET FOREIGN_KEY_CHECKS=1'); + $this->em->clear(); + } + + private function createAdmin(): User + { + return $this->createWebUser($_ENV['BROADCAST_ADMIN_EMAIL'], 'secret123', 'Admin'); + } + + public function testRejectsAnonymousUser(): void + { + $this->client->request('GET', '/admin/broadcast'); + + $this->assertResponseRedirects('/login'); + } + + public function testRejectsNonAdminEmail(): void + { + $this->createWebUser('pas-admin@example.com', 'secret123', 'Pas Admin'); + $this->loginAs('pas-admin@example.com'); + + $this->client->request('GET', '/admin/broadcast'); + + $this->assertResponseStatusCodeSame(403); + } + + public function testRendersFormForAdminUser(): void + { + $this->createAdmin(); + $this->loginAs($_ENV['BROADCAST_ADMIN_EMAIL']); + + $this->client->request('GET', '/admin/broadcast'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists('form'); + } +}