Compare commits

...

16 Commits

Author SHA1 Message Date
gitea-actions 3a2b268337 chore: bump version to v0.4.46
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 44s
2026-06-26 14:52:54 +00:00
matthieu f676b217bc Merge pull request 'fix(project) : sélection du workflow à la création + filet par défaut' (#29) from fix/project-creation-workflow into develop
Auto Tag Develop / tag (push) Successful in 9s
Reviewed-on: #29
2026-06-26 14:52:44 +00:00
matthieu 8bebfe1595 Merge branch 'develop' into fix/project-creation-workflow
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m28s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m40s
2026-06-26 14:50:08 +00:00
Matthieu 49267ad2fb fix(project) : erreur explicite si aucun workflow à la création au lieu d'une violation NOT NULL
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (build) (pull_request) Has been cancelled
2026-06-26 16:49:33 +02:00
Matthieu d3abb584a9 fix(project) : permet de choisir un workflow à la création + filet par défaut
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 38s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m39s
La création de projet échouait : `Project.workflow` est obligatoire mais
n'était jamais fourni (formulaire frontend, MCP create-project), tout POST
/api/projects partait en erreur de validation/contrainte NOT NULL.

- ProjectDefaultWorkflowListener (prePersist) : assigne le workflow par
  défaut quand aucun n'est fourni, couvrant API Platform, API brute et MCP.
- retrait de l'Assert\NotNull sur Project::workflow (la validation tournait
  avant le flush et empêchait le filet) ; la contrainte DB reste le garde-fou.
- CreateProjectTool (MCP) : paramètre optionnel workflowId.
- ProjectDrawer : sélecteur Workflow en création, pré-rempli sur le défaut,
  IRI envoyée dans le payload.
- tests fonctionnels : création avec et sans workflow.
2026-06-26 16:42:02 +02:00
gitea-actions 98e3990fa5 chore: bump version to v0.4.45
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 3m27s
2026-06-26 14:21:43 +00:00
matthieu 172f79d348 Merge pull request 'fix(user) : archivage au lieu de suppression + réparation des références orphelines' (#28) from fix/user-soft-delete-orphan-references into develop
Auto Tag Develop / tag (push) Successful in 11s
Reviewed-on: #28
2026-06-26 14:21:30 +00:00
matthieu f221976573 Merge branch 'develop' into fix/user-soft-delete-orphan-references
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 41s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m0s
2026-06-26 14:17:09 +00:00
Matthieu 133f205393 test(user) : couvre le soft-delete + désarchivage admin et corrige les retours de review
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m39s
- ajoute des tests fonctionnels (archive au DELETE, exclusion de la
  collection, listing/désarchivage admin, anti-auto-archivage) et un test
  unitaire du ArchivedUserChecker
- expose un filtre BooleanFilter `archived` + bypass admin dans
  ExcludeArchivedUserExtension pour lister les archivés (?archived=true)
- rend `archived` modifiable par un admin (groupe user:write + ApiProperty
  ROLE_ADMIN) → désarchivage possible via PATCH /api/users/{id}
- RestoreMissingUsersCommand : ne compte que les insertions réelles
  (ON CONFLICT DO NOTHING n'est plus comptabilisé à tort)
- relève memory_limit des tests à 512M (boot sérialiseur API Platform)
2026-06-26 16:14:11 +02:00
Matthieu d8d755d4c5 fix(user) : archivage au lieu de suppression + réparation des références orphelines
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m1s
Un user supprimé physiquement laissait des références orphelines (task.assignee,
time entries, notifications) car les FK vers "user" ont été créées NOT VALID lors
du refactor modular-monolith : elles n'ont jamais nettoyé les orphelins legacy. La
sérialisation API Platform d'une tâche embarquant un assignee inexistant levait une
EntityNotFoundException non rattrapable (HTTP 500 sur tout PATCH/GET de ces tickets).

- User::$archived (bool) + migration (soft delete)
- Delete de User -> UserArchiveProcessor : archive (archived=true, apiToken vidé)
  au lieu de supprimer, préservant l'intégrité référentielle
- ArchivedUserChecker : login bloqué pour un user archivé (firewalls login + api)
- ExcludeArchivedUserExtension : archivés exclus de GET /api/users (assignation),
  les références existantes restent sérialisées normalement
- Commande app:restore-missing-users : recrée (en archivés) les users encore
  référencés mais supprimés, restaurant l'intégrité sans perte de données.
  Idempotente, option --dry-run. À lancer une fois en prod après déploiement.
2026-06-26 15:51:27 +02:00
gitea-actions 3ea1a31784 chore: bump version to v0.4.44
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 1m42s
2026-06-25 20:53:41 +00:00
matthieu a2dcab6ec1 docs : config MCP HTTP par poste (token local, Fedora/Windows) + rotation token
Auto Tag Develop / tag (push) Successful in 8s
Le serveur MCP HTTP (token Bearer) se configure dans la config Claude Code
locale (jamais dans le .mcp.json versionné). Ajout de la méthode cross-OS
(claude mcp add), des emplacements de fichier par OS (Fedora/Linux, Windows,
macOS) et de la procédure de régénération du token (invalidé au reseed BDD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:53:31 +02:00
gitea-actions d55a088e41 chore: bump version to v0.4.43
Auto Tag Develop / tag (push) Successful in 10s
Build & Push Docker Image / build (push) Successful in 23s
2026-06-25 20:22:20 +00:00
matthieu 95b192858b docs : recap HTTPS/CA interne pour l'error tracking GlitchTip (INFRA #153)
Auto Tag Develop / tag (push) Successful in 11s
Sous-section "Certificat HTTPS interne (CA auto-signée)" : contexte (CA interne,
domaine non public, Let's Encrypt impossible), fix backend (CA bakée dans
l'image), fix postes via GPO (+ caveat Firefox), procédure de renouvellement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:05:03 +02:00
matthieu 6fc6eee5b9 chore : bump version to v0.4.42
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 2m30s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:42:23 +02:00
matthieu 7fe427d676 fix(observability) : trust la CA interne MALIO pour l'ingest GlitchTip (TLS)
logs.malio-dev.fr utilise un certificat signé par la CA auto-signée
"MALIO-DEV Local Root CA", inconnue du container -> le SDK Sentry échouait en
TLS ("Message not sent"). On installe la CA racine (publique) dans le trust
store de l'image (ca-certificates + update-ca-certificates), ce qui débloque
aussi tout futur appel HTTPS interne.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:42:02 +02:00
20 changed files with 848 additions and 14 deletions
+107 -9
View File
@@ -219,28 +219,60 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
} }
``` ```
### Configuration réseau (HTTP) ### Configuration réseau (HTTP) — par poste, hors git
Le transport HTTP nécessite un **token API** (Bearer), qui est un **secret** : il ne va **jamais**
dans le `.mcp.json` versionné (celui-ci ne contient que le serveur STDIO local, sans secret).
Chaque développeur configure le serveur HTTP dans sa **config Claude Code locale**.
**Méthode recommandée (identique sur Fedora, Windows et macOS) :**
```bash
claude mcp add --transport http --scope user lesstime \
http://project.malio-dev.fr/_mcp \
--header "Authorization: Bearer <api-token>"
```
- En prod : `http://project.malio-dev.fr/_mcp`
- En réseau local : `http://<ip-serveur>:8082/_mcp`
**Où c'est stocké** (si tu édites le fichier à la main, sous la clé `mcpServers`) :
| OS | Fichier de config Claude Code |
|----|-------------------------------|
| **Fedora / Linux** | `~/.claude.json` |
| **Windows** (collègue) | `%USERPROFILE%\.claude.json` (ex. `C:\Users\<user>\.claude.json`) |
| **macOS** | `~/.claude.json` |
```json ```json
{ {
"mcpServers": { "mcpServers": {
"lesstime": { "lesstime": {
"type": "url", "type": "http",
"url": "http://<ip-serveur>:8082/_mcp", "url": "http://project.malio-dev.fr/_mcp",
"headers": { "headers": { "Authorization": "Bearer <api-token>" }
"Authorization": "Bearer <api-token>"
}
} }
} }
} }
``` ```
Après modification, relancer la connexion avec `/mcp` dans Claude Code.
### Gestion des tokens API ### Gestion des tokens API
Générer / régénérer un token pour un utilisateur :
```bash ```bash
# En dev (container local)
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username> docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
# En prod (sur le serveur, dans infra/prod)
sudo docker compose exec -T -u www-data app php bin/console app:generate-api-token <username>
``` ```
⚠️ Le token est **invalidé à chaque reset/reseed de la base**. Symptôme : `/mcp` renvoie
`HTTP 401 "Invalid API token"`. Il faut alors le **régénérer** (commande ci-dessus) puis remplacer
la valeur `Bearer ...` dans ta config locale (par poste).
## Déploiement ## Déploiement
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*` La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
@@ -270,7 +302,7 @@ INFRA #146.
|---|---|---| |---|---|---|
| Nature | process PHP qui tourne en continu | fichiers JS/HTML **statiques** (`nuxt generate`) | | Nature | process PHP qui tourne en continu | fichiers JS/HTML **statiques** (`nuxt generate`) |
| Quand le DSN est lu | au **runtime** | **figé au build** (baké dans le JS) | | Quand le DSN est lu | au **runtime** | **figé au build** (baké dans le JS) |
| Où mettre le DSN | `infra/prod/.env` (runtime) | **secrets Gitea** → build-args de la CI | | Où mettre le DSN | `.env` du serveur (`/var/www/lesstime/.env`) — runtime | **secrets Gitea** → build-args de la CI |
> Les erreurs partent **toujours vers GlitchTip**, jamais vers la CI. La CI ne sert qu'à *écrire* > Les erreurs partent **toujours vers GlitchTip**, jamais vers la CI. La CI ne sert qu'à *écrire*
> le DSN front dans le bundle au moment du build (il n'y a aucun process front en prod qui > le DSN front dans le bundle au moment du build (il n'y a aucun process front en prod qui
@@ -278,7 +310,7 @@ INFRA #146.
### Variables ### Variables
**Backend — fichier `infra/prod/.env` du serveur** (chargé via `env_file`) : **Backend — fichier `.env` du serveur** (`/var/www/lesstime/.env`, chargé via `env_file` ; le repo ne fournit que le template `infra/prod/.env.example`) :
```env ```env
SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api> SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
``` ```
@@ -312,10 +344,76 @@ SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
1. Dans GlitchTip : créer les projets `lesstime-api` et `lesstime-front`, récupérer les 2 DSN 1. Dans GlitchTip : créer les projets `lesstime-api` et `lesstime-front`, récupérer les 2 DSN
(+ un auth token pour les source maps). (+ un auth token pour les source maps).
2. Backend : ajouter `SENTRY_DSN` dans `infra/prod/.env` du serveur. 2. Backend : ajouter `SENTRY_DSN` dans le `.env` du serveur (`/var/www/lesstime/.env`).
3. Frontend : ajouter les secrets Gitea ci-dessus. 3. Frontend : ajouter les secrets Gitea ci-dessus.
4. Tagger une version (`v*`) → la CI build l'image avec le DSN front baké → `deploy.sh`. 4. Tagger une version (`v*`) → la CI build l'image avec le DSN front baké → `deploy.sh`.
### Certificat HTTPS interne (CA auto-signée)
GlitchTip est servi en **HTTPS** sur `https://logs.malio-dev.fr` (nginx devant), avec un certificat
**auto-signé** par une **CA interne** (« MALIO-DEV Local Root CA », cert serveur `*.malio-dev.fr`).
`malio-dev.fr` est un **domaine interne uniquement** (DNS local, pas de résolution publique).
> **Pourquoi pas Let's Encrypt ?** Une CA publique doit valider le domaine via Internet (challenge
> HTTP ou DNS public). Comme `malio-dev.fr` n'existe qu'en interne, aucune validation n'est
> possible → on reste sur la CA interne, qu'il faut faire **approuver partout** où la connexion TLS
> est établie. Tant que la CA n'est pas approuvée, **rien ne remonte** : le backend logue
> « Message not sent » (SDK Sentry) et le navigateur affiche « connexion non sécurisée » (le front
> n'envoie rien).
**Qui doit faire confiance à la CA ?** La connexion à `logs.malio-dev.fr` part de deux endroits
différents, donc deux fixes distincts :
| Émetteur des erreurs | Qui établit le TLS | Où approuver la CA |
|---|---|---|
| Backend (Symfony) | le **container PHP** | CA bakée dans l'**image Docker** (ci-dessous) |
| Frontend (SPA) | le **navigateur du poste** | CA poussée sur les **postes via GPO** (ci-dessous) |
#### Fix backend — CA bakée dans l'image
Le certificat **public** de la root CA est committé dans le repo (`infra/prod/malio-dev-root-ca.crt`,
aucune clé privée) et installé dans le trust store du container au build (`infra/prod/Dockerfile`,
stage production — `ca-certificates` est déjà installé) :
```dockerfile
COPY infra/prod/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
RUN update-ca-certificates
```
Le container fait alors confiance à tout `*.malio-dev.fr` interne et le SDK Sentry backend peut
envoyer. Vérification :
```bash
curl --cacert infra/prod/malio-dev-root-ca.crt https://logs.malio-dev.fr/api/1/store/ # → HTTP 200
```
#### Fix postes — CA poussée par GPO (Active Directory)
Le front est une SPA : c'est le **navigateur de l'utilisateur** qui contacte `logs.malio-dev.fr`,
donc c'est le **poste** qui doit faire confiance à la CA (la CA de l'image ne sert qu'au backend).
Sur le domaine Active Directory, on pousse la CA **une seule fois via GPO** plutôt que poste par poste :
1. Contrôleur de domaine → **Group Policy Management** → éditer une GPO.
2. `Configuration ordinateur → Stratégies → Paramètres Windows → Paramètres de sécurité → Stratégies
de clé publique → Autorités de certification racines de confiance`.
3. Clic droit → **Importer** → sélectionner `rootCA.crt` (« MALIO-DEV Local Root CA »).
4. Sur les postes : `gpupdate /force` (ou attendre le rafraîchissement), puis **redémarrer le navigateur**.
- Chrome / Edge utilisent le magasin Windows → confiance automatique.
- ⚠️ **Firefox** a son propre magasin : activer `security.enterprise_roots.enabled = true`
(`about:config` ou via policy) pour qu'il lise le magasin Windows.
> **Validation poste** : ouvrir `https://logs.malio-dev.fr` → cadenas vert sans avertissement = CA
> approuvée = le front peut envoyer.
#### Renouvellement / changement de CA
Si la CA interne change (rotation, expiration) :
1. Remplacer `infra/prod/malio-dev-root-ca.crt` par le nouveau certificat public, commit + **rebuild
de l'image** (re-tag `v*`) pour le backend.
2. **Re-pousser** la nouvelle CA via GPO (étapes ci-dessus) pour les postes.
## Licence ## Licence
Propriétaire — Tous droits réservés. Propriétaire — Tous droits réservés.
+2
View File
@@ -22,6 +22,7 @@ security:
pattern: ^/login_check pattern: ^/login_check
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
login_throttling: login_throttling:
max_attempts: 5 max_attempts: 5
interval: '1 minute' interval: '1 minute'
@@ -41,6 +42,7 @@ security:
pattern: ^/api pattern: ^/api
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
jwt: ~ jwt: ~
logout: logout:
path: /api/logout path: /api/logout
+4
View File
@@ -125,6 +125,10 @@ services:
tags: tags:
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist } - { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
App\Module\ProjectManagement\Infrastructure\EventListener\ProjectDefaultWorkflowListener:
tags:
- { name: doctrine.orm.entity_listener, entity: 'App\Module\ProjectManagement\Domain\Entity\Project', event: prePersist }
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor: App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.41' app.version: '0.4.46'
@@ -32,6 +32,13 @@
empty-option-label="Aucun client" empty-option-label="Aucun client"
group-class="w-full" group-class="w-full"
/> />
<MalioSelect
v-if="!isEditing"
v-model="form.workflowId"
:options="workflowOptions"
label="Workflow"
group-class="w-full"
/>
<div class="mt-4"> <div class="mt-4">
<ColorPicker v-model="form.color" /> <ColorPicker v-model="form.color" />
</div> </div>
@@ -124,10 +131,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project' import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import type { Client } from '~/modules/directory/services/dto/client' import type { Client } from '~/modules/directory/services/dto/client'
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea' import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack' import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
import { useProjectService } from '~/modules/project-management/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/modules/integration/services/gitea'
import { useBookStackService } from '~/modules/integration/services/bookstack' import { useBookStackService } from '~/modules/integration/services/bookstack'
@@ -174,12 +183,24 @@ const bookstackShelfOptions = computed(() =>
bookstackShelves.value.map(s => ({ label: s.name, value: s.id })) bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
) )
const { getAll: getAllWorkflows } = useWorkflowService()
const workflows = ref<Workflow[]>([])
const workflowOptions = computed(() =>
workflows.value.map(w => ({ label: w.name, value: w.id }))
)
function defaultWorkflowId(): number | null {
return (workflows.value.find(w => w.isDefault) ?? workflows.value[0])?.id ?? null
}
const form = reactive({ const form = reactive({
code: '', code: '',
name: '', name: '',
description: '', description: '',
color: '#222783', color: '#222783',
clientId: null as number | null, clientId: null as number | null,
workflowId: null as number | null,
giteaRepoFullName: null as string | null, giteaRepoFullName: null as string | null,
bookstackShelfId: null as number | null, bookstackShelfId: null as number | null,
}) })
@@ -222,6 +243,7 @@ watch(() => props.modelValue, (open) => {
form.description = '' form.description = ''
form.color = '#222783' form.color = '#222783'
form.clientId = null form.clientId = null
form.workflowId = defaultWorkflowId()
form.giteaRepoFullName = null form.giteaRepoFullName = null
form.bookstackShelfId = null form.bookstackShelfId = null
} }
@@ -269,6 +291,9 @@ async function handleSubmit() {
await update(props.project.id, payload) await update(props.project.id, payload)
} else { } else {
payload.code = form.code payload.code = form.code
if (form.workflowId) {
payload.workflow = `/api/workflows/${form.workflowId}`
}
await create(payload) await create(payload)
} }
@@ -308,6 +333,15 @@ async function handleArchiveToggle() {
} }
onMounted(async () => { onMounted(async () => {
try {
workflows.value = await getAllWorkflows()
// Si le drawer est déjà ouvert en création, pré-remplir une fois les workflows chargés.
if (props.modelValue && !props.project && !form.workflowId) {
form.workflowId = defaultWorkflowId()
}
} catch {
// Workflows indisponibles, ignore (le serveur assignera le défaut)
}
try { try {
giteaRepos.value = await listRepositories() giteaRepos.value = await listRepositories()
} catch { } catch {
+6 -1
View File
@@ -56,10 +56,15 @@ FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \ libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor smbclient \ nginx supervisor smbclient ca-certificates \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \ && docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# CA racine interne MALIO (auto-signée) — permet au SDK Sentry/HttpClient de
# joindre les services HTTPS internes (ex. GlitchTip sur logs.malio-dev.fr).
COPY infra/prod/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
RUN update-ca-certificates
# PHP production config # PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
+31
View File
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFZzCCA0+gAwIBAgIUOiZigxwgIgtLipnLnu4eSgItc5MwDQYJKoZIhvcNAQEL
BQAwQzELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCU1BTElPLURFVjEgMB4GA1UEAwwX
TUFMSU8tREVWIExvY2FsIFJvb3QgQ0EwHhcNMjYwNjI1MTYxMjIwWhcNMzYwNjIy
MTYxMjIwWjBDMQswCQYDVQQGEwJGUjESMBAGA1UECgwJTUFMSU8tREVWMSAwHgYD
VQQDDBdNQUxJTy1ERVYgTG9jYWwgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQAD
ggIPADCCAgoCggIBALqHXVWEae9aKtveLfSpxYy9RS0Aslw2Ls9+LWI33lpMRs02
QssE9wquf3WGjz8NnHUWl5RM0QHC0DOCCddcbnRBciDRJeTaU43IGdNg+TSY+7aM
3t/jysZrpc/eu/udlIs7npCPaOGnRiuGN68Fkf9Q70FtmaASpusUe7J3jKDinznr
R2hARplO4OF01tFauu039A4yudLrZTUFTldicuZ6a5U3NhajgfNZA+pyJqvL3tLT
lXG3KupPD9BsbWe4zSM96CmyHM22QNlcL+M5XG5+EtDtM07tkDcyxFOsREjQHvSQ
NH+7h6G/QBHHKkYJhdyiuvpj6b5tEJBM2PVgy1T2JX5TuOBOLx6HvHLbNjUY/JI5
0sIjnHbeybQCOfnKNAwidtnqjAfVg+XJ9UZCiGJOeRJOdN5isvvqEKydsX4ouCTj
89kwBbfCJeCS6BiadvNFUwnM0PksV0ovnOiUEEAPHRiP74jZ3IvH95BEwiZzyLpy
tXiJMW7cJMaqlT3jNwq3P00irfrpJNy4S1Mg2cBQh5ucv+PcMBfQT8YiarzlTQJo
saksh/2C43WH+qIFAL2aeD+rKReVBZcGa1XOBI8FUJTu3rLd37+iS4N2BUKq4fWo
FttuX5NOfeU3BRDLlCJ2AXau7o0czVy896R9iZTfBJC95QWD07PdHgoctuexAgMB
AAGjUzBRMB0GA1UdDgQWBBRNU0WsMg/pqo5XF/WXx78GrAzD5TAfBgNVHSMEGDAW
gBRNU0WsMg/pqo5XF/WXx78GrAzD5TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4ICAQBFXsuT7Rm2oJBlWT/RsJtmWr95NoFLHovVDycgM8Vjm+E8hv/m
AcSjPjZDmXQLOrN31T/XUAs0nURHxSFgVzdIKpq2gOlGgHkZRMAW/iTON9Cqjn81
Arjp5fjAJyFkoCiT3eTOElpteF4NhL8xMFaOg1Y2CEfOYO9OZR7Z38HdB6IArVwr
W3Dxq3DPtarCeo1k8SHJmJzUduYCltV8urB43gIiI2Hqd7aAlpkTfDhruKxxr7sJ
3/TpemJDCN9m8XMv2QvxqpMwH6EXg/7oqit5k0MvD445f3xt9vZydmV/x6F7u/A/
gJitN+ixA4AKv7Lw210vaupiChqdY+78TXgLoPJ2/l2QPWG/R7Fb4yNZ2rEd6lyt
KLPxHDcdZetFnyqyaoB2SNtLx9hNUE5G3udU6DkNhDfQlDhqEG4f7GAInOu/cMWE
2uiIUEjcGSLM+XrrTFRc1tdXy6hnu+sw5ckvhwJ+kjah/pVGz21/y5a0p42AUznI
iN7HBV8YaSkeJLvBPnfakUAat1R98e0l72DucHe8RF44NmZCywpaUBsTpNy+bO2f
atqp4/ZEGJJlJ38rLv9bAuwr6d8x6T+m0oHknqtJHcWfO0kr4l3Lxsd8mRpGgmBe
zOjqjrat4vSc04Rqic4UV2IEoWCiSS/TSiBx8JAB6Ck0+YR9dUgXVQsFFg==
-----END CERTIFICATE-----
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds a soft-delete flag on user. Deleting a user now archives it instead of
* removing the row, preserving referential integrity (tasks, time entries,
* notifications…). Existing users are kept active (archived = false).
*/
final class Version20260626153721 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add archived flag on user (soft delete)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" ADD archived BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP archived');
}
}
+4
View File
@@ -13,6 +13,10 @@
<php> <php>
<ini name="display_errors" value="1" /> <ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" /> <ini name="error_reporting" value="-1" />
<!-- API Platform's serializer/metadata boot is memory-hungry on the first
call in a process (cold phpdoc + serializer metadata). 128M is too
tight for non-paginated collections such as GET /api/users. -->
<ini name="memory_limit" value="512M" />
<server name="APP_ENV" value="test" force="true" /> <server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" /> <server name="SHELL_VERBOSITY" value="-1" />
<server name="KERNEL_CLASS" value="App\Kernel" /> <server name="KERNEL_CLASS" value="App\Kernel" />
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Module\Core\Domain\Entity\User;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use function sprintf;
/**
* Recreates user rows that are still referenced by other tables but no longer
* exist (legacy hard-deletes performed before the foreign keys enforced
* ON DELETE SET NULL / CASCADE). Recreated accounts are archived: their data
* (tasks, time entries, notifications…) is preserved and references become
* valid again, fixing the serialization crash (EntityNotFoundException), but
* the accounts cannot log in and are hidden from selectable user lists.
*
* Idempotent and non-destructive — nothing is deleted.
*/
#[AsCommand(
name: 'app:restore-missing-users',
description: 'Recreate (as archived) users that are still referenced but were hard-deleted, to restore referential integrity',
)]
final class RestoreMissingUsersCommand extends Command
{
public function __construct(
private readonly Connection $connection,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'List missing user ids without recreating them');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
// 1. Discover every (table, column) that references "user" via a foreign key.
$references = $this->connection->fetchAllAssociative(<<<'SQL'
SELECT t.relname AS child_table, a.attname AS child_col
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_class rt ON rt.oid = c.confrelid
JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.contype = 'f' AND rt.relname = 'user'
ORDER BY t.relname, a.attname
SQL);
// 2. Collect distinct orphan ids across all those columns.
$missingIds = [];
foreach ($references as $ref) {
$table = $ref['child_table'];
$col = $ref['child_col'];
$ids = $this->connection->fetchFirstColumn(sprintf(
'SELECT DISTINCT %1$s FROM %2$s WHERE %1$s IS NOT NULL AND %1$s NOT IN (SELECT id FROM "user")',
$this->connection->quoteIdentifier($col),
$this->connection->quoteIdentifier($table),
));
foreach ($ids as $id) {
$missingIds[(int) $id] = true;
}
}
$missingIds = array_keys($missingIds);
sort($missingIds);
$io->section(sprintf('%d foreign-key column(s) scanned', count($references)));
if ([] === $missingIds) {
$io->success('No missing users referenced. Nothing to restore.');
return Command::SUCCESS;
}
$io->writeln(sprintf('Missing user id(s): %s', implode(', ', $missingIds)));
if ($dryRun) {
$io->note('Dry run — no user recreated.');
return Command::SUCCESS;
}
// 3. Recreate each missing user as an archived placeholder, preserving its id.
$hash = $this->passwordHasher->hashPassword(new User(), bin2hex(random_bytes(16)));
$created = 0;
foreach ($missingIds as $id) {
$inserted = $this->connection->executeStatement(
<<<'SQL'
INSERT INTO "user"
(id, username, first_name, last_name, roles, password, created_at,
is_employee, work_time_ratio, annual_leave_days, reference_period_start,
initial_leave_balance, archived)
VALUES
(:id, :username, :firstName, :lastName, :roles, :password, NOW(),
false, 1.0, 25.0, '06-01', 0.0, true)
ON CONFLICT (id) DO NOTHING
SQL,
[
'id' => $id,
'username' => sprintf('deleted-user-%d', $id),
'firstName' => 'Compte',
'lastName' => sprintf('supprimé #%d', $id),
'roles' => json_encode(['ROLE_USER'], JSON_THROW_ON_ERROR),
'password' => $hash,
],
);
// ON CONFLICT may have skipped an already-present row — only count real inserts.
if ($inserted > 0) {
++$created;
$io->writeln(sprintf(' ✓ user #%d recreated (archived)', $id));
} else {
$io->writeln(sprintf(' • user #%d already present — skipped', $id));
}
}
$io->success(sprintf('%d user(s) restored as archived. References are valid again — no data lost.', $created));
return Command::SUCCESS;
}
}
+29 -1
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Module\Core\Domain\Entity; namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
@@ -13,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Enum\ContractType; use App\Module\Core\Domain\Enum\ContractType;
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider; use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserArchiveProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
@@ -47,7 +50,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
), ),
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')", processor: UserArchiveProcessor::class),
new Get( new Get(
uriTemplate: '/users/{id}/rbac', uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')", security: "is_granted('core.users.manage')",
@@ -63,6 +66,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
], ],
denormalizationContext: ['groups' => ['user:write']], denormalizationContext: ['groups' => ['user:write']],
)] )]
// Archived users are hidden from the default /users collection by
// ExcludeArchivedUserExtension; an admin can still list them with ?archived=true.
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
#[Auditable] #[Auditable]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')] #[ORM\Table(name: '`user`')]
@@ -111,6 +117,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
private ?string $avatarFileName = null; private ?string $avatarFileName = null;
/**
* Soft-delete flag. Archived users are kept for referential integrity
* (tasks, time entries, notifications…) but cannot log in and are hidden
* from selectable user lists.
*/
#[ORM\Column(options: ['default' => false])]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private bool $archived = false;
// --- HR / absence management fields (readable only by an admin or the user themselves) --- // --- HR / absence management fields (readable only by an admin or the user themselves) ---
/** Whether this user is an employee subject to absence management. */ /** Whether this user is an employee subject to absence management. */
@@ -228,6 +244,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
return (string) $this->username; return (string) $this->username;
} }
public function isArchived(): bool
{
return $this->archived;
}
public function setArchived(bool $archived): static
{
$this->archived = $archived;
return $this;
}
/** @return list<string> */ /** @return list<string> */
public function getRoles(): array public function getRoles(): array
{ {
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
use function array_key_exists;
/**
* Hides archived (soft-deleted) users from the `/users` collection so they are
* no longer offered as assignees/collaborators, while existing references to
* them (already stored on tasks, time entries…) keep resolving normally.
*
* An admin can opt back in to see archived users — e.g. to restore one — by
* passing the `archived` query filter explicitly (`?archived=true`), in which
* case the BooleanFilter declared on User handles the predicate instead.
*/
final readonly class ExcludeArchivedUserExtension implements QueryCollectionExtensionInterface
{
public function __construct(private Security $security) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (User::class !== $resourceClass) {
return;
}
// Let an admin explicitly query archived users via ?archived=...
$filters = $context['filters'] ?? [];
if (array_key_exists('archived', $filters) && $this->security->isGranted('ROLE_ADMIN')) {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.archived = false', $alias));
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use function assert;
/**
* Soft-delete processor wired on the User `Delete` operation: instead of
* removing the row (which would orphan every task / time entry / notification
* referencing it and break their serialization), the user is archived. The
* account is kept for referential integrity but can no longer log in
* (ArchivedUserChecker) and is hidden from selectable user lists
* (ExcludeArchivedUserExtension).
*
* @implements ProcessorInterface<User, null|User>
*/
final readonly class UserArchiveProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?User
{
assert($data instanceof User);
// Prevent an admin from archiving (locking out) their own account.
$current = $this->security->getUser();
if ($current instanceof User && $current->getId() === $data->getId()) {
throw new AccessDeniedHttpException('You cannot archive your own account.');
}
if ($data->isArchived()) {
return null;
}
$data->setArchived(true);
$data->setApiToken(null);
$this->em->flush();
return null;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Rejects authentication for archived (soft-deleted) users, both at password
* login and on every JWT-authenticated request, so an archived account is
* effectively locked out while its data is preserved.
*/
final class ArchivedUserChecker implements UserCheckerInterface
{
public function checkPreAuth(UserInterface $user, ?TokenInterface $token = null): void
{
if ($user instanceof User && $user->isArchived()) {
throw new CustomUserMessageAccountStatusException('This account has been archived.');
}
}
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
}
@@ -92,10 +92,11 @@ class Project implements ProjectInterface, TimestampableInterface, BlamableInter
#[Groups(['project:read', 'project:write'])] #[Groups(['project:read', 'project:write'])]
private ?ClientInterface $client = null; private ?ClientInterface $client = null;
// workflow_id reste NOT NULL en base ; quand l'appelant n'en fournit pas,
// ProjectDefaultWorkflowListener assigne le workflow par défaut au prePersist.
#[ORM\ManyToOne(targetEntity: Workflow::class)] #[ORM\ManyToOne(targetEntity: Workflow::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')] #[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
#[Groups(['project:read', 'project:write', 'task:read'])] #[Groups(['project:read', 'project:write', 'task:read'])]
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
private ?Workflow $workflow = null; private ?Workflow $workflow = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\EventListener;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use Doctrine\ORM\Event\PrePersistEventArgs;
use RuntimeException;
/**
* Assigns the default workflow to a project when none was provided.
* Guarantees the NOT NULL workflow_id constraint across every persistence
* path (API Platform, raw API, MCP) without forcing the caller to supply one.
*/
final readonly class ProjectDefaultWorkflowListener
{
public function __construct(private WorkflowRepositoryInterface $workflowRepository) {}
public function prePersist(Project $project, PrePersistEventArgs $args): void
{
if (null !== $project->getWorkflow()) {
return;
}
$default = $this->workflowRepository->findDefault()
?? ($this->workflowRepository->findBy([], ['position' => 'ASC'], 1)[0] ?? null);
if (null === $default) {
throw new RuntimeException('Cannot create a project: no workflow exists. Seed at least one workflow first.');
}
$project->setWorkflow($default);
}
}
@@ -6,6 +6,7 @@ namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface; use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\ProjectManagement\Domain\Entity\Project; use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer; use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
@@ -15,12 +16,13 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')] #[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters. Optional workflowId selects the kanban workflow; the default workflow is used when omitted.')]
class CreateProjectTool class CreateProjectTool
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly ClientRepositoryInterface $clientRepository, private readonly ClientRepositoryInterface $clientRepository,
private readonly WorkflowRepositoryInterface $workflowRepository,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -30,6 +32,7 @@ class CreateProjectTool
?string $description = null, ?string $description = null,
?string $color = null, ?string $color = null,
?int $clientId = null, ?int $clientId = null,
?int $workflowId = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) { if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.'); throw new AccessDeniedException('Access denied: ROLE_USER required.');
@@ -52,6 +55,14 @@ class CreateProjectTool
} }
$project->setClient($client); $project->setClient($client);
} }
if (null !== $workflowId) {
$workflow = $this->workflowRepository->findById($workflowId);
if (null === $workflow) {
throw new InvalidArgumentException(sprintf('Workflow with ID %d not found.', $workflowId));
}
$project->setWorkflow($workflow);
}
// When no workflow is supplied, ProjectDefaultWorkflowListener assigns the default at prePersist.
$this->entityManager->persist($project); $this->entityManager->persist($project);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Covers the soft-delete behaviour: deleting a user archives it (the row is
* kept so referencing tasks/time entries still serialize), archived users are
* hidden from the default collection but an admin can list and restore them.
*
* @internal
*/
final class UserArchiveApiTest extends WebTestCase
{
public function testDeleteArchivesUserInsteadOfRemovingIt(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$target = $this->createUser($em, 'archive-target-'.uniqid());
$em->flush();
$targetId = $target->getId();
$this->loginAdmin($client);
$client->request('DELETE', '/api/users/'.$targetId);
self::assertResponseStatusCodeSame(204);
$em->clear();
$reloaded = $em->getRepository(User::class)->find($targetId);
self::assertInstanceOf(User::class, $reloaded, 'Row must still exist (soft delete)');
self::assertTrue($reloaded->isArchived(), 'User must be flagged archived');
self::assertNull($reloaded->getApiToken(), 'API token must be cleared on archive');
}
public function testAdminCannotArchiveOwnAccount(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAdmin($client);
$adminId = $this->userId('admin');
$client->request('DELETE', '/api/users/'.$adminId);
self::assertResponseStatusCodeSame(403);
$em->clear();
$admin = $em->getRepository(User::class)->find($adminId);
self::assertFalse($admin->isArchived(), 'Admin must not have archived itself');
}
public function testArchivedUserIsHiddenFromDefaultCollection(): void
{
$client = self::createClient();
$username = $this->createArchivedUser();
$this->loginAdmin($client);
$client->request('GET', '/api/users', server: ['HTTP_ACCEPT' => 'application/json']);
self::assertResponseIsSuccessful();
$usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username');
self::assertNotContains($username, $usernames, 'Archived user must not appear in the default list');
}
public function testAdminCanListArchivedUsersViaFilter(): void
{
$client = self::createClient();
$username = $this->createArchivedUser();
$this->loginAdmin($client);
$client->request('GET', '/api/users?archived=true', server: ['HTTP_ACCEPT' => 'application/json']);
self::assertResponseIsSuccessful();
$usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username');
self::assertContains($username, $usernames, 'Admin must be able to list archived users via ?archived=true');
}
public function testAdminCanRestoreUserViaPatch(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser($em, 'restore-target-'.uniqid());
$user->setArchived(true);
$em->flush();
$userId = $user->getId();
$em->clear();
$this->loginAdmin($client);
$client->request('PATCH', '/api/users/'.$userId, server: [
'CONTENT_TYPE' => 'application/merge-patch+json',
], content: json_encode(['archived' => false]));
self::assertResponseIsSuccessful();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($userId);
self::assertFalse($reloaded->isArchived(), 'Admin PATCH must be able to un-archive a user');
}
private function createArchivedUser(): string
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$username = 'archived-'.uniqid();
$user = $this->createUser($em, $username);
$user->setArchived(true);
$em->flush();
$em->clear();
return $username;
}
private function createUser(EntityManagerInterface $em, string $username): User
{
$user = new User();
$user->setUsername($username);
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$em->persist($user);
return $user;
}
private function loginAdmin(KernelBrowser $client): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $user);
$client->loginUser($user);
}
private function userId(string $username): int
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user);
return $user->getId();
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\ProjectManagement;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Workflow;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Vérifie que la création d'un projet fonctionne avec ou sans workflow fourni :
* - sans workflow → le workflow par défaut est assigné par le listener prePersist
* - avec workflow → le workflow choisi est conservé.
*
* @internal
*/
final class ProjectCreationWorkflowTest extends WebTestCase
{
public function testCreateProjectWithoutWorkflowAssignsDefault(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$client->loginUser($this->createManager($em));
$client->request('POST', '/api/projects', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'code' => $this->randomCode(),
'name' => 'Projet sans workflow',
]));
self::assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('workflow', $data);
self::assertNotNull($data['workflow'], 'Un workflow par défaut doit avoir été assigné.');
}
public function testCreateProjectWithExplicitWorkflow(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$workflow = self::getContainer()->get(WorkflowRepositoryInterface::class)->findDefault()
?? $em->getRepository(Workflow::class)->findOneBy([]);
self::assertInstanceOf(Workflow::class, $workflow, 'Les fixtures doivent fournir au moins un workflow.');
$client->loginUser($this->createManager($em));
$client->request('POST', '/api/projects', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'code' => $this->randomCode(),
'name' => 'Projet avec workflow',
'workflow' => '/api/workflows/'.$workflow->getId(),
]));
self::assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame($workflow->getId(), $data['workflow']['id'] ?? null);
}
private function createManager(EntityManagerInterface $em): User
{
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.manage']);
self::assertInstanceOf(Permission::class, $permission, 'Lancer app:sync-permissions pour project-management.projects.manage.');
$user = new User();
$user->setUsername('proj-create-'.uniqid());
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$user->addDirectPermission($permission);
$em->persist($user);
$em->flush();
return $user;
}
private function randomCode(): string
{
$letters = '';
for ($i = 0; $i < 6; ++$i) {
$letters .= chr(random_int(65, 90));
}
return $letters;
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Infrastructure\Security\ArchivedUserChecker;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\InMemoryUser;
/**
* @internal
*/
final class ArchivedUserCheckerTest extends TestCase
{
public function testArchivedUserIsRejectedPreAuth(): void
{
$user = new User()->setArchived(true);
$this->expectException(CustomUserMessageAccountStatusException::class);
new ArchivedUserChecker()->checkPreAuth($user);
}
public function testActiveUserPassesPreAuth(): void
{
$user = new User()->setArchived(false);
new ArchivedUserChecker()->checkPreAuth($user);
$this->addToAssertionCount(1);
}
public function testNonAppUserIsIgnored(): void
{
// A user that is not our entity must not be rejected by this checker.
new ArchivedUserChecker()->checkPreAuth(new InMemoryUser('someone', null));
$this->addToAssertionCount(1);
}
}