Checkpoint: Correctif UX Salaires : pendant le chargement de la sonde AES-256-GCM, l'interface affiche désormais une vérification neutre et désactive l'import au lieu d'afficher à tort une alerte de blocage. Test de régression ajouté. Validation complète réussie : 58 tests, TypeScript et build.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m52s
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m52s
This commit is contained in:
18
client/src/pages/Salaires.test.tsx
Normal file
18
client/src/pages/Salaires.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PayrollSecurityNotice } from "./Salaires";
|
||||||
|
|
||||||
|
describe("PayrollSecurityNotice", () => {
|
||||||
|
it("affiche un état neutre de vérification, et non une alerte, tant que la sonde de chiffrement charge", () => {
|
||||||
|
const html = renderToStaticMarkup(<PayrollSecurityNotice isLoading ready={undefined} />);
|
||||||
|
|
||||||
|
expect(html).toContain("Vérification du chiffrement sécurisé…");
|
||||||
|
expect(html).not.toContain("Import bloqué");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'affiche l'alerte de blocage que lorsque la sonde a répondu indisponible", () => {
|
||||||
|
const html = renderToStaticMarkup(<PayrollSecurityNotice isLoading={false} ready={false} />);
|
||||||
|
|
||||||
|
expect(html).toContain("Import bloqué");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,7 +61,7 @@ export default function Salaires() {
|
|||||||
const filters = useMemo(() => ({ annee, mois }), [annee, mois]);
|
const filters = useMemo(() => ({ annee, mois }), [annee, mois]);
|
||||||
const { data, isLoading, isError } = trpc.salaires.list.useQuery(filters);
|
const { data, isLoading, isError } = trpc.salaires.list.useQuery(filters);
|
||||||
const { data: periods = [] } = trpc.salaires.periods.useQuery();
|
const { data: periods = [] } = trpc.salaires.periods.useQuery();
|
||||||
const { data: security } = trpc.salaires.getSecurityStatus.useQuery();
|
const { data: security, isLoading: isSecurityLoading } = trpc.salaires.getSecurityStatus.useQuery();
|
||||||
|
|
||||||
const years = useMemo(() => {
|
const years = useMemo(() => {
|
||||||
const options = new Set([now.getFullYear(), ...periods.map((period) => period.annee)]);
|
const options = new Set([now.getFullYear(), ...periods.map((period) => period.annee)]);
|
||||||
@@ -77,7 +77,7 @@ export default function Salaires() {
|
|||||||
toast.error("Le fichier sélectionné doit être un PDF.");
|
toast.error("Le fichier sélectionné doit être un PDF.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!security?.ready) {
|
if (security?.ready !== true) {
|
||||||
toast.error("Le chiffrement sécurisé est indisponible : l'import est bloqué.");
|
toast.error("Le chiffrement sécurisé est indisponible : l'import est bloqué.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,12 +143,7 @@ export default function Salaires() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto px-6 py-5 space-y-5">
|
<div className="flex-1 overflow-auto px-6 py-5 space-y-5">
|
||||||
{!security?.ready && (
|
<PayrollSecurityNotice isLoading={isSecurityLoading} ready={security?.ready} />
|
||||||
<section className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 flex gap-3 text-sm text-rose-900">
|
|
||||||
<CircleAlert className="w-4 h-4 mt-0.5 shrink-0 text-rose-600" />
|
|
||||||
<p><strong>Import bloqué.</strong> Le chiffrement AES-256-GCM n'est pas disponible dans cet environnement. Aucun PDF n'est transmis sans chiffrement.</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="bg-card border border-border rounded-xl shadow-sm p-4">
|
<section className="bg-card border border-border rounded-xl shadow-sm p-4">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
@@ -185,7 +180,7 @@ export default function Salaires() {
|
|||||||
<FileText className="w-6 h-6 text-emerald-600 shrink-0" />
|
<FileText className="w-6 h-6 text-emerald-600 shrink-0" />
|
||||||
<span className="min-w-0 flex-1"><strong className="block text-sm text-foreground truncate">{selectedFile?.name ?? "Choisir la liasse PDF"}</strong><span className="text-xs text-muted-foreground">PDF uniquement, 25 Mo maximum</span><input ref={inputRef} type="file" accept="application/pdf,.pdf" aria-label="Choisir la liasse PDF" onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)} className="mt-1.5 block max-w-full text-xs text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-emerald-600 file:px-2.5 file:py-1 file:text-xs file:font-semibold file:text-white hover:file:bg-emerald-700" /></span>
|
<span className="min-w-0 flex-1"><strong className="block text-sm text-foreground truncate">{selectedFile?.name ?? "Choisir la liasse PDF"}</strong><span className="text-xs text-muted-foreground">PDF uniquement, 25 Mo maximum</span><input ref={inputRef} type="file" accept="application/pdf,.pdf" aria-label="Choisir la liasse PDF" onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)} className="mt-1.5 block max-w-full text-xs text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-emerald-600 file:px-2.5 file:py-1 file:text-xs file:font-semibold file:text-white hover:file:bg-emerald-700" /></span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={uploadLiasse} disabled={isUploading || hasExistingPeriod || !selectedFile || !security?.ready} className="h-10 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 transition-colors active:scale-[0.97]">
|
<button onClick={uploadLiasse} disabled={isUploading || isSecurityLoading || hasExistingPeriod || !selectedFile || security?.ready !== true} className="h-10 inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 transition-colors active:scale-[0.97]">
|
||||||
{isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <LockKeyhole className="w-4 h-4" />} {isUploading ? "Archivage…" : "Importer et chiffrer"}
|
{isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <LockKeyhole className="w-4 h-4" />} {isUploading ? "Archivage…" : "Importer et chiffrer"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,3 +219,11 @@ function StatCard({ icon: Icon, label, value, accent }: { icon: React.ElementTyp
|
|||||||
function DataNotice({ message }: { message: string }) {
|
function DataNotice({ message }: { message: string }) {
|
||||||
return <div className="py-12 text-center text-sm text-muted-foreground"><CircleAlert className="w-5 h-5 mx-auto mb-2 text-muted-foreground/60" />{message}</div>;
|
return <div className="py-12 text-center text-sm text-muted-foreground"><CircleAlert className="w-5 h-5 mx-auto mb-2 text-muted-foreground/60" />{message}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PayrollSecurityNotice({ isLoading, ready }: { isLoading: boolean; ready: boolean | undefined }) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <section className="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 flex gap-3 text-sm text-sky-900"><Loader2 className="w-4 h-4 mt-0.5 shrink-0 animate-spin text-sky-700" /><p><strong>Vérification du chiffrement sécurisé…</strong> L'import reste temporairement désactivé.</p></section>;
|
||||||
|
}
|
||||||
|
if (ready === true) return null;
|
||||||
|
return <section className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 flex gap-3 text-sm text-rose-900"><CircleAlert className="w-4 h-4 mt-0.5 shrink-0 text-rose-600" /><p><strong>Import bloqué.</strong> Le chiffrement AES-256-GCM n'est pas disponible dans cet environnement. Aucun PDF n'est transmis sans chiffrement.</p></section>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,3 +25,13 @@ La consultation réelle de la liasse de juin a retourné un document dont la sig
|
|||||||
Après un redémarrage complet du serveur de développement, les deux archives mensuelles et leurs statuts d'indexation étaient toujours présents dans l'interface. La relecture de la liasse de juin a de nouveau retourné un PDF valide, avec l'entête `no-store`. Cette vérification confirme que le fichier chiffré est lu depuis le stockage persistant extérieur au processus applicatif, et non depuis le système de fichiers d'un conteneur.
|
Après un redémarrage complet du serveur de développement, les deux archives mensuelles et leurs statuts d'indexation étaient toujours présents dans l'interface. La relecture de la liasse de juin a de nouveau retourné un PDF valide, avec l'entête `no-store`. Cette vérification confirme que le fichier chiffré est lu depuis le stockage persistant extérieur au processus applicatif, et non depuis le système de fichiers d'un conteneur.
|
||||||
|
|
||||||
La CI Gitea de recette et le déploiement Blue-Green ont été validés pour le commit qui transmet explicitement la clé de paie au conteneur applicatif. Le déploiement sauvegarde la base avant reconstruction, met à jour le code depuis Gitea, reconstruit l'image sans cache, puis recrée l'application une fois la base saine.
|
La CI Gitea de recette et le déploiement Blue-Green ont été validés pour le commit qui transmet explicitement la clé de paie au conteneur applicatif. Le déploiement sauvegarde la base avant reconstruction, met à jour le code depuis Gitea, reconstruit l'image sans cache, puis recrée l'application une fois la base saine.
|
||||||
|
|
||||||
|
Après ce déploiement, une vérification de recette a créé un marqueur de contrôle dans le volume de paie, recréé uniquement le conteneur applicatif, puis relu le marqueur avec la même empreinte SHA-256. Le marqueur a été supprimé immédiatement après le contrôle. Cette vérification confirme la persistance effective du volume à travers une recréation Docker, sans y déposer de donnée salariale de test.
|
||||||
|
|
||||||
|
Le contrôle final de recette a ensuite importé une liasse réelle de mai 2026. L'import a créé une archive chiffrée et un index de 18 bulletins au statut `partiel`, sans fabrication des quatre fiches non reconnues. La liste tRPC filtrée retourne les 18 entrées et n'expose ni clé de stockage, ni IV, ni tag d'authentification, ni empreinte. La route PDF a retourné un document de 79 752 octets commençant par `%PDF-`, avec les en-têtes `Content-Type: application/pdf`, `Cache-Control: no-store, private` et `X-Content-Type-Options: nosniff`. Après une recréation Docker de l'application, la même archive était encore lisible, avec la même taille et la même signature PDF.
|
||||||
|
|
||||||
|
La vérification visuelle administrateur de recette a confirmé la présence de l'entrée **Salaires**, des filtres, de l'archive de mai et de son statut partiel. Une alerte frontale incohérente indiquant que le chiffrement était indisponible a toutefois été observée alors que la sonde serveur retournait bien `AES-256-GCM` opérationnel ; cette divergence doit être corrigée avant clôture.
|
||||||
|
|
||||||
|
Après le chargement des requêtes, le filtre de mai 2026 a affiché 18 bulletins indexés, un total brut mensuel et le statut partiel de la liasse, sans alerte de chiffrement. La divergence initiale correspond au rendu transitoire avant réception de la sonde de sécurité ; ce rendu doit être remplacé par un état de chargement neutre, jamais par une alerte de blocage.
|
||||||
|
|
||||||
|
Depuis la page authentifiée de recette, le bouton **Voir la liasse** a ouvert le visualiseur PDF du navigateur sur la route protégée de consultation. Le parcours interface utilise uniquement cette route applicative ; il ne présente aucune URL de stockage au navigateur.
|
||||||
|
|||||||
21
todo.md
21
todo.md
@@ -151,8 +151,8 @@
|
|||||||
## Mise à jour des astreintes selon le planning
|
## Mise à jour des astreintes selon le planning
|
||||||
- [x] Identifier les semaines d'astreinte 2025 et 2026 du planning pour les six salariés ciblés
|
- [x] Identifier les semaines d'astreinte 2025 et 2026 du planning pour les six salariés ciblés
|
||||||
- [x] Tracer explicitement les segments partiels à cheval entre exercices, exclus du décompte de semaine complète
|
- [x] Tracer explicitement les segments partiels à cheval entre exercices, exclus du décompte de semaine complète
|
||||||
- [ ] Intégrer toutes les semaines à cheval explicitement confirmées par les bulletins et vérifier les montants annuels
|
- [x] Intégrer toutes les semaines à cheval explicitement confirmées par les bulletins et vérifier les montants annuels — écarté par la règle finale lundi-lundi complète, qui exclut les segments à cheval entre exercices
|
||||||
- [ ] Intégrer la semaine du 29/12/2025 de Jérôme Pascuzzi confirmée par le bulletin de janvier 2026
|
- [x] Intégrer la semaine du 29/12/2025 de Jérôme Pascuzzi confirmée par le bulletin de janvier 2026 — écartée par la même règle lundi-lundi complète appliquée au classeur livré
|
||||||
- [x] Relier le tableau principal aux totaux annuels d'astreinte des onglets dédiés
|
- [x] Relier le tableau principal aux totaux annuels d'astreinte des onglets dédiés
|
||||||
- [x] Vérifier les décomptes chiffrés et le fichier Excel final actualisé
|
- [x] Vérifier les décomptes chiffrés et le fichier Excel final actualisé
|
||||||
|
|
||||||
@@ -179,17 +179,19 @@
|
|||||||
## Sécurité des bulletins de salaire
|
## Sécurité des bulletins de salaire
|
||||||
- [x] Chiffrer les fichiers PDF avant leur stockage dédié et ne conserver en base que les métadonnées et références
|
- [x] Chiffrer les fichiers PDF avant leur stockage dédié et ne conserver en base que les métadonnées et références
|
||||||
- [x] Contrôler que la consultation des PDFs chiffrés est réservée aux administrateurs
|
- [x] Contrôler que la consultation des PDFs chiffrés est réservée aux administrateurs
|
||||||
- [ ] Vérifier que les fichiers stockés restent disponibles après rebuild, mise à jour et réinitialisation des conteneurs
|
- [x] Vérifier que les fichiers stockés restent disponibles après rebuild, mise à jour et réinitialisation des conteneurs
|
||||||
- [x] Générer et valider une clé AES-256 dédiée aux bulletins de salaire
|
- [x] Générer et valider une clé AES-256 dédiée aux bulletins de salaire
|
||||||
|
|
||||||
## Déploiement de recette — Fenêtre Salaires
|
## Déploiement de recette — Fenêtre Salaires
|
||||||
- [x] Vérifier les contrôles complets avant la publication
|
- [x] Vérifier les contrôles complets avant la publication
|
||||||
- [ ] Pousser la fenêtre Salaires vers Gitea et déclencher la CI/CD de recette
|
- [x] Pousser la fenêtre Salaires vers Gitea et déclencher la CI/CD de recette
|
||||||
- [ ] Vérifier l'application Salaires déployée en HTTPS sur la recette
|
- [ ] Vérifier l'application Salaires déployée en HTTPS sur la recette
|
||||||
- [ ] Configurer la clé AES des bulletins dans l'environnement sécurisé de recette et recréer le conteneur
|
- [x] Configurer la clé AES des bulletins dans l'environnement sécurisé de recette et recréer le conteneur
|
||||||
- [ ] Transmettre explicitement PAYROLL_ENCRYPTION_KEY au conteneur applicatif de recette
|
- [x] Transmettre explicitement PAYROLL_ENCRYPTION_KEY au conteneur applicatif de recette
|
||||||
- [ ] Vérifier la disponibilité du stockage persistant chiffré dans l'environnement de recette
|
- [x] Vérifier la disponibilité du stockage persistant chiffré dans l'environnement de recette
|
||||||
- [ ] Stocker les PDF déjà chiffrés sur le volume persistant de recette si Forge n'est pas configuré
|
- [x] Stocker les PDF déjà chiffrés sur le volume persistant de recette si Forge n'est pas configuré
|
||||||
|
- [x] Valider en recette l'import, l'index et la consultation d'une liasse réelle sans exposer de données de paie
|
||||||
|
- [x] Appliquer et enregistrer les migrations Salaires dans la base de recette avant l'import mensuel
|
||||||
|
|
||||||
## Exécution en arrière-plan
|
## Exécution en arrière-plan
|
||||||
- [ ] Finaliser la fenêtre Salaires et le déploiement de recette sans intervention utilisateur
|
- [ ] Finaliser la fenêtre Salaires et le déploiement de recette sans intervention utilisateur
|
||||||
@@ -198,3 +200,6 @@
|
|||||||
- [x] Importer une liasse PDF de contrôle avec un compte administrateur, contrôler son index, le filtre et la consultation sécurisée
|
- [x] Importer une liasse PDF de contrôle avec un compte administrateur, contrôler son index, le filtre et la consultation sécurisée
|
||||||
- [x] Redémarrer le serveur puis relire une liasse archivée pour confirmer la persistance hors conteneur
|
- [x] Redémarrer le serveur puis relire une liasse archivée pour confirmer la persistance hors conteneur
|
||||||
- [x] Permettre à un administrateur de réindexer sans écraser une liasse déjà archivée
|
- [x] Permettre à un administrateur de réindexer sans écraser une liasse déjà archivée
|
||||||
|
- [ ] Vérifier visuellement la page Salaires de recette dans une session administrateur : navigation, filtres, liste et statuts
|
||||||
|
- [ ] Ouvrir une liasse depuis le bouton de l'interface de recette afin de valider le parcours utilisateur de consultation
|
||||||
|
- [ ] Corriger l'alerte frontale indiquant à tort un chiffrement indisponible en recette
|
||||||
|
|||||||
Reference in New Issue
Block a user