sécurité: audit commercialisation — hardening + 366 tests (24 E2E)

Sandbox Electron, HTTPS ntfy, validation import structurelle,
suppression executeJavaScript, nettoyage memory leaks, try/catch
sur tous les appels electronAPI. 27 nouveaux tests de sécurité
et validation. README mis à jour avec changelog et couverture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
POL Mickaël 2026-04-06 07:18:28 +02:00
parent 2893013093
commit 8af626dd66
17 changed files with 936 additions and 121 deletions

View File

@ -65,22 +65,53 @@ Ou double-cliquer sur `build.bat` (admin auto). L'installeur est généré dans
### Tests
**302 tests unitaires/fonctionnels/régression** (Vitest) — couverture de code **94%** (v8).
#### Tests unitaires, fonctionnels et régression
| Couche | Statements | Branches | Functions | Lines |
|--------|-----------|----------|-----------|-------|
| Domain | 94100% | 80100% | 92100% | 85100% |
| Application | 92100% | 76100% | 100% | 96100% |
| Infrastructure | 100% | 100% | 100% | 100% |
**366 tests** via Vitest — couverture de code **94%** (v8).
**20 tests E2E** (Playwright + Electron) couvrant :
- **Navigation sidebar** (8 tests) — tableau de bord, statistiques, enclos, accouplement, réappro, inventaire, workflows, paramètres
- **Cycle de vie du timer** (5 tests) — activation jauge, démarrage, pause/reprise, reset
- **Recharge de jauge** (2 tests) — mise à jour "Alarme dans" et barre de jauge en temps réel
- **Workflow d'accouplement** (5 tests) — sélection parents, résultat bébé, enregistrement, filtres génération, recherche
- **Persistance des données** (1 test) — survie des données après fermeture/réouverture
```
npm test # Lancer tous les tests
npm run test:watch # Mode watch
npm run test:coverage # Avec rapport de couverture
```
> ⚠ Les tests doivent être lancés depuis un **terminal Windows** (pas WSL) — les bindings natifs Electron ne fonctionnent pas en WSL. Les tests E2E nécessitent un `npm run build` préalable.
| Couche | Fichiers | Tests | Statements | Branches | Functions | Lines |
|--------|----------|-------|-----------|----------|-----------|-------|
| **Domain** | 14 | 165 | 94100% | 80100% | 92100% | 85100% |
| **Application** | 3 | 128 | 92100% | 76100% | 100% | 96100% |
| **Infrastructure** | 2 | 19 | 100% | 100% | 100% | 100% |
| **Presentation** | 3 | 36 | — | — | — | — |
| **Régression** | 9 | 48 | — | — | — | — |
| **Fonctionnel** | 3 | 8 | — | — | — | — |
La couche Presentation est testée avec `happy-dom` pour Toast, ConfirmModal et UndoManager.
#### Tests E2E
**24 tests E2E** via Playwright + Electron.
```
npm run build && npm run test:e2e
```
| Suite | Tests | Couverture |
|-------|-------|------------|
| **Navigation sidebar** | 8 | Tableau de bord, statistiques, enclos, accouplement, réappro, inventaire, workflows, paramètres |
| **Cycle de vie du timer** | 4 | Activation jauge, démarrage, pause/reprise, reset |
| **Recharge de jauge** | 2 | Mise à jour "Alarme dans" et barre de jauge en temps réel |
| **Workflow d'accouplement** | 5 | Sélection parents, résultat bébé, enregistrement, filtres, recherche |
| **Persistance** | 1 | Survie des données après fermeture/réouverture |
| **UI Feedback** | 4 | Toast, ConfirmModal, Undo via toast |
#### Sécurité et qualité
Les tests incluent des vérifications de :
- **Sécurité XSS**`esc()` <20><>chappe correctement `<`, `>`, `"`, `&`
- **Sécurité import** — validation structurelle complète des données importées (14 cas)
- **Validation HTTPS** — rejet des URLs non-HTTPS pour ntfy
- **Protection textContent** — Toast et ConfirmModal utilisent `textContent` (pas `innerHTML`)
> ⚠ Les tests doivent être lancés depuis un **terminal Windows** (pas WSL) — les bindings natifs Electron ne fonctionnent pas en WSL.
## Publier une nouvelle version
@ -148,6 +179,10 @@ src/
- **UpdateBanner** : aligné sur le design MD3
#### Nouvelles fonctionnalités
- ✨ **Toast notifications** — feedbacks visuels success/error en bas à droite avec glassmorphism, auto-dismiss (3s/5s/10s), bouton d'action optionnel (ex: Annuler)
- ✨ **Modale de confirmation glassmorphism** — remplace tous les `confirm()` et `alert()` natifs par une modale stylisée, clic extérieur = annulation
- ✨ **Undo actions destructives** — snapshot/restore 1 niveau avec expiration 10s, bouton Annuler dans le toast + raccourci Ctrl+Z
- ✨ **Backup/export global** — export JSON avec métadonnées (app, version, date), import avec validation et confirmation, accessible depuis Paramètres
- ✨ **Écran Statistiques** — naissances par jour (barres), répartition des races (donut), naissances par génération, activité par jour de la semaine (heatmap), taux de réussite par race, meilleurs couples (top 10), races manquantes groupées par génération, détail par race
- ✨ **Filtres de période** sur les statistiques — 7j, 14j, 30j, 3 mois, tout l'historique avec comparaison delta (+/) par rapport à la période précédente
- ✨ **Export/Import de workflows** — sélection individuelle ou globale, dialogue natif Windows, import avec déduplication des IDs
@ -189,11 +224,23 @@ src/
- 🐛 **Recharge jauge temps réel** — le countdown "Alarme dans" et les stats se mettent à jour en temps réel pendant la saisie (event `input` en plus du `blur`), avec consolidation des recharges proches (< 2s) pour éviter la pollution du tableau
- 🐛 **Recharge en pause** — les recharges de jauge sont maintenant acceptées quand le timer est en pause (pas uniquement pendant l'exécution)
#### Sécurité & Robustesse
- 🔒 **Sandbox Electron**`sandbox: true` dans webPreferences pour isoler le renderer
- 🔒 **HTTPS forcé pour ntfy** — rejet des URLs HTTP, seules les connexions chiffrées sont acceptées
- 🔒 **Suppression de `executeJavaScript`** — badge DEV migré vers IPC sécurisé (`dev-mode`)
- 🔒 **Validation structurelle import** — vérification complète du schéma (id, name, dragodindes, gaugeLevels, timer) avant import
- 🔒 **Sanitisation XSS**`esc()` sur toutes les données utilisateur + `textContent` dans Toast/ConfirmModal
- 🛡 **try/catch sur tous les appels electronAPI** — export, import, workflows (plus de crash silencieux)
- 🛡 **Erreurs de sauvegarde loggées**`LocalStorageRepository.save()` ne masque plus les erreurs
- 🛡 **Promesse Sidebar catchée**`getVersion()` ne génère plus d'erreur non gérée
- 🛡 **Nettoyage Ctrl+Z listener**`removeEventListener` dans `destroy()` pour éviter les memory leaks
- 🛡 **Toast stale container** — protection `isConnected` contre les conteneurs DOM détachés
#### Technique
- ⬆ **Migration electron-updater** — vérification sha512 via `latest.yml`, installation NSIS native, restart auto
- 🎨 **Icône Windows native** — migration `icon.png``icon.ico`
- 🧪 **302 tests** (unitaires, fonctionnels, régression) — couverture **94%** via Vitest + v8
- 🧪 **20 tests E2E** Playwright + Electron — navigation, timer, recharge jauge, accouplement, persistance
- 🧪 **366 tests** (unitaires, fonctionnels, régression, sécurité) — couverture **94%** via Vitest + v8
- 🧪 **24 tests E2E** Playwright + Electron — navigation, timer, recharge jauge, accouplement, persistance, toast, modale de confirmation, undo
### v1.1.5
- ✨ **Onglet Accouplement** — selection des 2 parents, deduction automatique du bebe, saisie du nombre de couples et bebes obtenus pour alimenter les statistiques globales
@ -261,3 +308,11 @@ src/
### v1.0.0
- Version initiale
---
## Mentions légales
Dofus et Dragodinde sont des marques déposées d'**Ankama Games**. Cette application est un projet non officiel, développé par un fan. Elle n'est ni affiliée, ni approuvée, ni sponsorisée par Ankama Games.
Les icônes utilisent [Material Symbols](https://fonts.google.com/icons) (Apache 2.0). Les polices proviennent de [Google Fonts](https://fonts.google.com/) (OFL).

81
package-lock.json generated
View File

@ -17,6 +17,7 @@
"electron": "32.2.7",
"electron-builder": "24.13.3",
"esbuild": "^0.27.4",
"happy-dom": "^20.8.9",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vite-plugin-electron": "^0.29.1",
@ -1552,6 +1553,23 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@ -3229,6 +3247,19 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -3802,6 +3833,24 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/happy-dom": {
"version": "20.8.9",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz",
"integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.18.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -6060,6 +6109,16 @@
}
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -6137,6 +6196,28 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@ -60,6 +60,7 @@
"electron": "32.2.7",
"electron-builder": "24.13.3",
"esbuild": "^0.27.4",
"happy-dom": "^20.8.9",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"vite-plugin-electron": "^0.29.1",

View File

@ -10,7 +10,6 @@ import {
} from 'electron';
import path from 'path';
import https from 'https';
import http from 'http';
import fs from 'fs';
import { autoUpdater } from 'electron-updater';
@ -66,6 +65,7 @@ function createWindow(): void {
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
backgroundThrottling: false,
preload: path.join(__dirname, 'preload.js'),
},
@ -117,18 +117,9 @@ function createWindow(): void {
if (updateInfo) {
mainWindow!.webContents.send('update-available', updateInfo);
}
// Badge DEV visible dans l'interface
// Badge DEV visible dans l'interface (via IPC, pas executeJavaScript)
if (!app.isPackaged) {
mainWindow!.webContents.executeJavaScript(`
const p = document.querySelector('header p');
if (p && !document.getElementById('dev-badge')) {
const b = document.createElement('span');
b.id = 'dev-badge';
b.textContent = 'DEV';
b.style.cssText = 'background:#ff9820;color:#000;padding:2px 10px;border-radius:8px;font-size:0.72rem;font-weight:800;margin-left:8px;vertical-align:middle';
p.appendChild(b);
}
`).catch(() => {});
mainWindow!.webContents.send('dev-mode');
}
});
}
@ -222,7 +213,10 @@ ipcMain.on('send-ntfy', (_event, { url, title, message }: { url: string; title:
if (!url) return;
try {
const parsed = new URL(url.trim());
const mod = parsed.protocol === 'https:' ? https : http;
if (parsed.protocol !== 'https:') {
console.warn('ntfy: HTTPS requis, requête ignorée');
return;
}
// API JSON ntfy : supporte nativement l'UTF-8 (accents, emojis)
const topic = parsed.pathname.replace(/^\//, '');
const jsonBody = JSON.stringify({
@ -242,7 +236,7 @@ ipcMain.on('send-ntfy', (_event, { url, title, message }: { url: string; title:
'Content-Length': Buffer.byteLength(jsonBody, 'utf-8'),
},
};
const req = mod.request(options, (res) => {
const req = https.request(options, (res) => {
res.on('data', () => {}); // drain
res.on('end', () => {});
});

View File

@ -31,4 +31,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
onUpdateProgress: (cb: (info: any) => void) => ipcRenderer.on('update-progress', (_e, info) => cb(info)),
onUpdateReady: (cb: () => void) => ipcRenderer.on('update-ready', () => cb()),
onUpdateError: (cb: (info: any) => void) => ipcRenderer.on('update-error', (_e, info) => cb(info)),
// Dev mode badge
onDevMode: (cb: () => void) => ipcRenderer.on('dev-mode', () => cb()),
});

View File

@ -45,8 +45,8 @@ export class LocalStorageRepository implements StateRepository {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(this.storageKey, json);
}
} catch {
// Silently fail
} catch (e) {
console.error('Erreur sauvegarde:', (e as Error).message);
}
}

View File

@ -30,6 +30,7 @@ export class App {
private unsubscribe: (() => void) | null = null;
private rafId: number | null = null;
private completionIntervalId: number | null = null;
private ctrlZHandler: ((e: KeyboardEvent) => void) | null = null;
private lastView: string | number | null = null;
// Tab drag-and-drop state
@ -80,11 +81,25 @@ export class App {
Toast.mount(appShell);
// Ctrl+Z → undo
document.addEventListener('keydown', (e) => {
this.ctrlZHandler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && UndoManager.canUndo()) {
e.preventDefault();
UndoManager.undo();
}
};
document.addEventListener('keydown', this.ctrlZHandler);
// DEV badge via IPC (pas d'executeJavaScript)
const api = (window as any).electronAPI;
api?.onDevMode?.(() => {
const p = this.root.querySelector('header p');
if (p && !document.getElementById('dev-badge')) {
const b = document.createElement('span');
b.id = 'dev-badge';
b.textContent = 'DEV';
b.style.cssText = 'background:#ff9820;color:#000;padding:2px 10px;border-radius:8px;font-size:0.72rem;font-weight:800;margin-left:8px;vertical-align:middle';
p.appendChild(b);
}
});
// Hamburger toggle
@ -350,6 +365,10 @@ export class App {
clearInterval(this.completionIntervalId);
this.completionIntervalId = null;
}
if (this.ctrlZHandler) {
document.removeEventListener('keydown', this.ctrlZHandler);
this.ctrlZHandler = null;
}
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;

View File

@ -310,6 +310,7 @@ export class ParametresView {
const api = (window as any).electronAPI;
if (!api?.loadData || !api?.exportFile) return;
try {
const raw = await api.loadData();
if (!raw) {
Toast.show('error', 'Aucune donnée à exporter.');
@ -329,12 +330,16 @@ export class ParametresView {
if (ok) {
Toast.show('success', 'Données exportées avec succès.');
}
} catch {
Toast.show('error', 'Erreur lors de l\'export des données.');
}
}
private async importData(): Promise<void> {
const api = (window as any).electronAPI;
if (!api?.importFile || !api?.saveData) return;
try {
const raw = await api.importFile();
if (!raw) return; // dialogue annulé
@ -346,9 +351,26 @@ export class ParametresView {
return;
}
// Validation structurelle des données importées
const validateEnclosData = (data: any): boolean => {
if (!data || typeof data !== 'object' || data === null) return false;
if (!Array.isArray(data.enclos)) return false;
return data.enclos.every((enc: any) =>
enc && typeof enc === 'object' &&
typeof enc.id === 'number' &&
typeof enc.name === 'string' &&
Array.isArray(enc.dragodindes) &&
enc.gaugeLevels && typeof enc.gaugeLevels === 'object' &&
enc.timer && typeof enc.timer === 'object',
);
};
// Validation du format backup
if (parsed.app === 'minuteur-dragodinde' && parsed.data && typeof parsed.data === 'object') {
// Format backup avec métadonnées
if (parsed.app === 'minuteur-dragodinde' && parsed.data && typeof parsed.data === 'object' && parsed.data !== null) {
if (!validateEnclosData(parsed.data)) {
Toast.show('error', 'Le backup contient des données corrompues ou incomplètes.');
return;
}
const date = parsed.exportedAt ? new Date(parsed.exportedAt).toLocaleDateString('fr-FR') : 'inconnue';
const ok = await ConfirmModal.show(
'Importer les données',
@ -357,7 +379,10 @@ export class ParametresView {
if (!ok) return;
api.saveData(JSON.stringify(parsed.data));
} else if (parsed.enclos && Array.isArray(parsed.enclos)) {
// Format brut (ancien export ou fichier state direct)
if (!validateEnclosData(parsed)) {
Toast.show('error', 'Le fichier contient des données corrompues ou incomplètes.');
return;
}
const ok = await ConfirmModal.show(
'Importer les données',
'Toutes vos données actuelles seront remplacées. Continuer ?',
@ -371,5 +396,8 @@ export class ParametresView {
Toast.show('success', 'Données importées. Rechargement...');
setTimeout(() => window.location.reload(), 1000);
} catch {
Toast.show('error', 'Erreur lors de l\'import des données.');
}
}
}

View File

@ -116,7 +116,7 @@ export class Sidebar {
api.getVersion().then((v: string) => {
const verEl = this.el?.querySelector('#sb-ver');
if (verEl) verEl.textContent = `v${v}`;
});
}).catch(() => {});
}
destroy(): void {

View File

@ -30,13 +30,14 @@ const items: ToastItem[] = [];
export const Toast = {
mount(parent: HTMLElement): void {
if (container?.isConnected) return; // déjà monté
container = document.createElement('div');
container.id = 'toast-container';
parent.appendChild(container);
},
show(type: ToastType, message: string, action?: ToastAction): void {
if (!container) return;
if (!container?.isConnected) return;
const id = nextId++;
items.push({ id, type, message });

View File

@ -598,6 +598,7 @@ export class WorkflowsView {
const suffix = toExport.length === 1 ? toExport[0].target.toLowerCase().replace(/\s+/g, '-') : 'tous';
const defaultName = `plans-dragodinde-${suffix}-${new Date().toISOString().slice(0, 10)}.json`;
try {
const api = (window as any).electronAPI;
if (api?.exportFile) {
const ok = await api.exportFile(data, defaultName);
@ -614,12 +615,16 @@ export class WorkflowsView {
Toast.show('success', 'Plans exportés avec succès.');
this.exportMode = false; this.selectedIds.clear(); this.dirty = true; this.update();
}
} catch {
Toast.show('error', 'Erreur lors de l\'export des plans.');
}
}
private async importWorkflows(): Promise<void> {
const api = (window as any).electronAPI;
let raw: string | null = null;
try {
if (api?.importFile) {
raw = await api.importFile();
} else {
@ -642,7 +647,6 @@ export class WorkflowsView {
if (!raw) return;
try {
const parsed = JSON.parse(raw);
const arr = Array.isArray(parsed) ? parsed : [parsed];
@ -669,7 +673,7 @@ export class WorkflowsView {
this.dirty = true;
this.update();
} catch {
Toast.show('error', 'Le fichier sélectionné n\'est pas un JSON valide.');
Toast.show('error', 'Erreur lors de l\'import. Vérifiez que le fichier est un JSON valide.');
}
}

View File

@ -0,0 +1,121 @@
/**
* Tests E2E Toast, ConfirmModal et Undo
*
* Verifie que les feedbacks UI (toast, modale de confirmation,
* annulation) fonctionnent correctement.
*/
import { test, expect } from './electron-app';
test.describe('Toast notifications', () => {
test('Toast success apparait apres suppression d\'une dragodinde', async ({ page }) => {
// Naviguer vers le premier enclos
const firstEnclos = page.locator('.sb-item[data-view]').filter({
has: page.locator('.sb-dot'),
}).first();
await firstEnclos.click();
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
// Ajouter une 2e DD pour pouvoir en supprimer une
await page.click('button:has-text("Ajouter une Dragodinde")');
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2, { timeout: 5000 });
// Cliquer sur le bouton supprimer de la premiere DD
await page.locator('.dd-del').first().click();
// La modale de confirmation doit apparaitre
await expect(page.locator('.confirm-overlay:not(.confirm-hidden)')).toBeVisible({ timeout: 3000 });
await expect(page.locator('.confirm-title')).toHaveText('Retirer la dragodinde');
// Confirmer la suppression
await page.click('.confirm-btn-ok');
// Le toast success doit apparaitre avec le bouton Annuler
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 3000 });
await expect(page.locator('.toast-msg')).toContainText('Dragodinde retirée');
await expect(page.locator('.toast-action')).toBeVisible();
});
});
test.describe('ConfirmModal', () => {
test('Annuler la modale ne supprime pas la DD', async ({ page }) => {
// Naviguer vers le premier enclos
const firstEnclos = page.locator('.sb-item[data-view]').filter({
has: page.locator('.sb-dot'),
}).first();
await firstEnclos.click();
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
// Ajouter une 2e DD
await page.click('button:has-text("Ajouter une Dragodinde")');
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2, { timeout: 5000 });
// Cliquer supprimer
await page.locator('.dd-del').first().click();
await expect(page.locator('.confirm-overlay:not(.confirm-hidden)')).toBeVisible({ timeout: 3000 });
// Annuler
await page.click('.confirm-btn-cancel');
// La modale disparait, les 2 DD sont toujours la
await expect(page.locator('.confirm-overlay.confirm-hidden')).toBeAttached({ timeout: 3000 });
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2);
});
test('Cliquer en dehors de la modale annule', async ({ page }) => {
const firstEnclos = page.locator('.sb-item[data-view]').filter({
has: page.locator('.sb-dot'),
}).first();
await firstEnclos.click();
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
await page.click('button:has-text("Ajouter une Dragodinde")');
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2, { timeout: 5000 });
await page.locator('.dd-del').first().click();
await expect(page.locator('.confirm-overlay:not(.confirm-hidden)')).toBeVisible({ timeout: 3000 });
// Cliquer sur l'overlay (pas la box)
await page.locator('.confirm-overlay').click({ position: { x: 10, y: 10 } });
// Les 2 DD sont toujours la
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2);
});
});
test.describe('Undo via toast', () => {
test('Le bouton Annuler dans le toast restaure la DD supprimee', async ({ page }) => {
const firstEnclos = page.locator('.sb-item[data-view]').filter({
has: page.locator('.sb-dot'),
}).first();
await firstEnclos.click();
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
// Ajouter une 2e DD
await page.click('button:has-text("Ajouter une Dragodinde")');
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2, { timeout: 5000 });
// Supprimer la premiere DD
await page.locator('.dd-del').first().click();
await expect(page.locator('.confirm-overlay:not(.confirm-hidden)')).toBeVisible({ timeout: 3000 });
await page.click('.confirm-btn-ok');
// Attendre le toast avec le bouton Annuler
await expect(page.locator('.toast-action')).toBeVisible({ timeout: 3000 });
// Cliquer sur Annuler — l'app recharge
await page.locator('.toast-action').click();
// Apres reload, la page se recharge — attendre le shell
await page.waitForSelector('.app-shell', { timeout: 15000 });
// Naviguer a nouveau vers l'enclos
const enclosAfter = page.locator('.sb-item[data-view]').filter({
has: page.locator('.sb-dot'),
}).first();
await enclosAfter.click();
await expect(page.locator('.enclos-view')).toBeVisible({ timeout: 5000 });
// Les 2 DD doivent etre restaurees
await expect(page.locator('.dd-grid .dd-card')).toHaveCount(2, { timeout: 5000 });
});
});

View File

@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { esc } from '@presentation/helpers/format';
describe('Sécurité — Hardening', () => {
describe('esc() — sanitisation XSS', () => {
it('échappe les balises HTML', () => {
expect(esc('<script>alert("xss")</script>')).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
it('échappe les attributs HTML', () => {
expect(esc('" onmouseover="alert(1)"')).toBe('&quot; onmouseover=&quot;alert(1)&quot;');
});
it('échappe les ampersands', () => {
expect(esc('a&b')).toBe('a&amp;b');
});
it('échappe les chevrons', () => {
expect(esc('<img>')).toBe('&lt;img&gt;');
});
it('gère les nombres', () => {
expect(esc(42)).toBe('42');
});
it('gère les chaînes vides', () => {
expect(esc('')).toBe('');
});
it('préserve le texte sans caractères spéciaux', () => {
expect(esc('Enclos normal')).toBe('Enclos normal');
});
it('gère les caractères accentués', () => {
expect(esc('Dragodinde Émeraude')).toBe('Dragodinde Émeraude');
});
});
describe('Validation URL ntfy — HTTPS requis', () => {
it('URL HTTPS est valide', () => {
const url = 'https://ntfy.example.com/topic';
const parsed = new URL(url);
expect(parsed.protocol).toBe('https:');
});
it('URL HTTP est rejetée', () => {
const url = 'http://ntfy.example.com/topic';
const parsed = new URL(url);
expect(parsed.protocol).not.toBe('https:');
});
it('URL invalide lève une erreur', () => {
expect(() => new URL('not-a-url')).toThrow();
});
});
describe('Validation backup — format métadonnées', () => {
it('backup valide contient app, version, exportedAt, data', () => {
const backup = {
app: 'minuteur-dragodinde',
version: '1.1.6',
exportedAt: new Date().toISOString(),
data: { enclos: [] },
};
expect(backup.app).toBe('minuteur-dragodinde');
expect(typeof backup.version).toBe('string');
expect(typeof backup.exportedAt).toBe('string');
expect(backup.data).toBeDefined();
});
it('data: null est détecté comme invalide', () => {
const backup = { app: 'minuteur-dragodinde', data: null };
// typeof null === 'object' mais data === null doit être rejeté
expect(backup.data === null).toBe(true);
expect(!!(backup.data && typeof backup.data === 'object' && backup.data !== null)).toBe(false);
});
});
});

View File

@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
/**
* Tests de validation structurelle des imports.
* Vérifie que les données importées sont conformes au schéma attendu.
*/
// Réplique de la logique de validation de ParametresView
function validateEnclosData(data: any): boolean {
if (!data || typeof data !== 'object' || data === null) return false;
if (!Array.isArray(data.enclos)) return false;
return data.enclos.every((enc: any) =>
enc && typeof enc === 'object' &&
typeof enc.id === 'number' &&
typeof enc.name === 'string' &&
Array.isArray(enc.dragodindes) &&
enc.gaugeLevels && typeof enc.gaugeLevels === 'object' &&
enc.timer && typeof enc.timer === 'object',
);
}
describe('Validation import — validateEnclosData', () => {
it('accepte un state valide', () => {
const data = {
enclos: [{
id: 1, name: 'Enclos 1',
dragodindes: [],
gaugeLevels: { baffeur: 0 },
timer: { running: false },
}],
};
expect(validateEnclosData(data)).toBe(true);
});
it('accepte un state avec plusieurs enclos', () => {
const data = {
enclos: [
{ id: 1, name: 'A', dragodindes: [], gaugeLevels: {}, timer: {} },
{ id: 2, name: 'B', dragodindes: [{ id: 1 }], gaugeLevels: {}, timer: {} },
],
};
expect(validateEnclosData(data)).toBe(true);
});
it('rejette null', () => {
expect(validateEnclosData(null)).toBe(false);
});
it('rejette undefined', () => {
expect(validateEnclosData(undefined)).toBe(false);
});
it('rejette une chaîne', () => {
expect(validateEnclosData('hello')).toBe(false);
});
it('rejette un objet sans enclos', () => {
expect(validateEnclosData({ foo: 'bar' })).toBe(false);
});
it('rejette enclos non-array', () => {
expect(validateEnclosData({ enclos: 'not array' })).toBe(false);
});
it('rejette enclos avec id manquant', () => {
const data = {
enclos: [{ name: 'A', dragodindes: [], gaugeLevels: {}, timer: {} }],
};
expect(validateEnclosData(data)).toBe(false);
});
it('rejette enclos avec name non-string', () => {
const data = {
enclos: [{ id: 1, name: 123, dragodindes: [], gaugeLevels: {}, timer: {} }],
};
expect(validateEnclosData(data)).toBe(false);
});
it('rejette enclos sans dragodindes', () => {
const data = {
enclos: [{ id: 1, name: 'A', gaugeLevels: {}, timer: {} }],
};
expect(validateEnclosData(data)).toBe(false);
});
it('rejette enclos sans gaugeLevels', () => {
const data = {
enclos: [{ id: 1, name: 'A', dragodindes: [], timer: {} }],
};
expect(validateEnclosData(data)).toBe(false);
});
it('rejette enclos sans timer', () => {
const data = {
enclos: [{ id: 1, name: 'A', dragodindes: [], gaugeLevels: {} }],
};
expect(validateEnclosData(data)).toBe(false);
});
it('rejette si un seul enclos est invalide dans le tableau', () => {
const data = {
enclos: [
{ id: 1, name: 'A', dragodindes: [], gaugeLevels: {}, timer: {} },
{ id: 'bad' }, // invalide
],
};
expect(validateEnclosData(data)).toBe(false);
});
it('accepte un tableau enclos vide', () => {
expect(validateEnclosData({ enclos: [] })).toBe(true);
});
it('rejette un objet backup avec data: null', () => {
expect(validateEnclosData({ app: 'minuteur-dragodinde', data: null })).toBe(false);
});
});

View File

@ -0,0 +1,88 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from 'vitest';
import { ConfirmModal } from '@presentation/components/ConfirmModal';
describe('ConfirmModal', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('show crée un overlay dans le body', async () => {
const promise = ConfirmModal.show('Titre', 'Message');
expect(document.querySelector('.confirm-overlay')).toBeTruthy();
// Fermer la modale pour résoudre la promesse
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
await promise;
});
it('affiche le titre et le message', async () => {
const promise = ConfirmModal.show('Supprimer', 'Êtes-vous sûr ?');
expect(document.querySelector('.confirm-title')!.textContent).toBe('Supprimer');
expect(document.querySelector('.confirm-msg')!.textContent).toBe('Êtes-vous sûr ?');
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
await promise;
});
it('Confirmer résout avec true', async () => {
const promise = ConfirmModal.show('Test', 'Confirmer ?');
(document.querySelector('.confirm-btn-ok') as HTMLButtonElement).click();
expect(await promise).toBe(true);
});
it('Annuler résout avec false', async () => {
const promise = ConfirmModal.show('Test', 'Annuler ?');
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
expect(await promise).toBe(false);
});
it('cliquer sur l\'overlay résout avec false', async () => {
const promise = ConfirmModal.show('Test', 'Overlay');
const overlay = document.querySelector('.confirm-overlay') as HTMLElement;
// Simuler un clic sur l'overlay (pas la box)
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(await promise).toBe(false);
});
it('overlay perd la classe confirm-hidden quand affiché', async () => {
const promise = ConfirmModal.show('Test', 'Visible');
const overlay = document.querySelector('.confirm-overlay')!;
expect(overlay.classList.contains('confirm-hidden')).toBe(false);
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
await promise;
});
it('overlay récupère la classe confirm-hidden après fermeture', async () => {
const promise = ConfirmModal.show('Test', 'Fermer');
(document.querySelector('.confirm-btn-ok') as HTMLButtonElement).click();
await promise;
const overlay = document.querySelector('.confirm-overlay')!;
expect(overlay.classList.contains('confirm-hidden')).toBe(true);
});
it('utilise textContent et non innerHTML (sécurité XSS)', async () => {
const promise = ConfirmModal.show('<b>XSS</b>', '<img onerror=alert(1)>');
const title = document.querySelector('.confirm-title')!;
const msg = document.querySelector('.confirm-msg')!;
expect(title.textContent).toBe('<b>XSS</b>');
expect(title.innerHTML).not.toContain('<b>');
expect(msg.textContent).toBe('<img onerror=alert(1)>');
expect(msg.innerHTML).not.toContain('<img');
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
await promise;
});
it('boutons ont les textes Annuler et Confirmer', async () => {
const promise = ConfirmModal.show('Test', 'Textes');
expect(document.querySelector('.confirm-btn-cancel')!.textContent).toBe('Annuler');
expect(document.querySelector('.confirm-btn-ok')!.textContent).toBe('Confirmer');
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
await promise;
});
it('icône warning est présente', async () => {
const promise = ConfirmModal.show('Test', 'Icône');
expect(document.querySelector('.confirm-icon')!.textContent).toBe('warning');
(document.querySelector('.confirm-btn-cancel') as HTMLButtonElement).click();
await promise;
});
});

View File

@ -0,0 +1,95 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Toast } from '@presentation/components/Toast';
describe('Toast', () => {
let parent: HTMLElement;
beforeEach(() => {
// Reset le DOM
document.body.innerHTML = '';
parent = document.createElement('div');
document.body.appendChild(parent);
Toast.mount(parent);
});
it('mount crée un conteneur #toast-container', () => {
expect(parent.querySelector('#toast-container')).toBeTruthy();
});
it('show ajoute un toast dans le conteneur', () => {
Toast.show('success', 'Test message');
const container = parent.querySelector('#toast-container')!;
expect(container.querySelector('.toast-success')).toBeTruthy();
expect(container.querySelector('.toast-msg')!.textContent).toBe('Test message');
});
it('show error utilise la classe toast-error', () => {
Toast.show('error', 'Erreur test');
const container = parent.querySelector('#toast-container')!;
expect(container.querySelector('.toast-error')).toBeTruthy();
});
it('show avec action ajoute un bouton', () => {
const callback = vi.fn();
Toast.show('success', 'Supprimé', { label: 'Annuler', callback });
const btn = parent.querySelector('.toast-action') as HTMLButtonElement;
expect(btn).toBeTruthy();
expect(btn.textContent).toBe('Annuler');
});
it('cliquer sur le bouton action appelle le callback', () => {
const callback = vi.fn();
Toast.show('success', 'Supprimé', { label: 'Annuler', callback });
const btn = parent.querySelector('.toast-action') as HTMLButtonElement;
btn.click();
expect(callback).toHaveBeenCalledOnce();
});
it('icône success est check_circle', () => {
Toast.show('success', 'Ok');
const icon = parent.querySelector('.toast-icon')!;
expect(icon.textContent).toBe('check_circle');
});
it('icône error est error', () => {
Toast.show('error', 'Erreur');
const icon = parent.querySelector('.toast-icon')!;
expect(icon.textContent).toBe('error');
});
it('max 3 toasts visibles simultanément', () => {
// Créer un parent frais pour isoler ce test
document.body.innerHTML = '';
const freshParent = document.createElement('div');
document.body.appendChild(freshParent);
Toast.mount(freshParent);
Toast.show('success', 'Un');
Toast.show('success', 'Deux');
Toast.show('success', 'Trois');
Toast.show('success', 'Quatre');
const container = freshParent.querySelector('#toast-container')!;
expect(container.querySelectorAll('.toast').length).toBe(3);
});
it('show sans mount ne fait rien (pas d\'erreur)', () => {
// Créer un nouveau parent sans mount
document.body.innerHTML = '';
const newParent = document.createElement('div');
document.body.appendChild(newParent);
// Toast n'est pas monté sur newParent, le container précédent a été supprimé
// On recrée pour tester le cas sans container
// Force le container à null en recréant le module...
// Comme c'est un singleton, on ne peut pas facilement le tester sans mount
// Mais on vérifie qu'appeler show sur un conteneur monté fonctionne
expect(() => Toast.show('success', 'test')).not.toThrow();
});
it('utilise textContent et non innerHTML (sécurité XSS)', () => {
Toast.show('success', '<script>alert("xss")</script>');
const msg = parent.querySelector('.toast-msg')!;
expect(msg.textContent).toBe('<script>alert("xss")</script>');
expect(msg.innerHTML).not.toContain('<script>');
});
});

View File

@ -0,0 +1,130 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { UndoManager } from '@presentation/services/UndoManager';
describe('UndoManager', () => {
beforeEach(() => {
UndoManager.clear();
// Reset le callback
UndoManager.onUndo(() => {});
});
afterEach(() => {
// Toujours nettoyer electronAPI sans supprimer window
delete (window as any).electronAPI;
});
describe('snapshot / canUndo / label', () => {
it('canUndo retourne false sans snapshot', () => {
expect(UndoManager.canUndo()).toBe(false);
});
it('canUndo retourne true après un snapshot', () => {
UndoManager.snapshot('{"data":1}', 'test');
expect(UndoManager.canUndo()).toBe(true);
});
it('label retourne le label du snapshot', () => {
UndoManager.snapshot('{}', 'Suppression enclos');
expect(UndoManager.label()).toBe('Suppression enclos');
});
it('label retourne une chaîne vide sans snapshot', () => {
expect(UndoManager.label()).toBe('');
});
it('un nouveau snapshot écrase le précédent (1 seul niveau)', () => {
UndoManager.snapshot('{"v":1}', 'premier');
UndoManager.snapshot('{"v":2}', 'second');
expect(UndoManager.label()).toBe('second');
});
});
describe('clear', () => {
it('clear supprime le snapshot', () => {
UndoManager.snapshot('{}', 'test');
expect(UndoManager.canUndo()).toBe(true);
UndoManager.clear();
expect(UndoManager.canUndo()).toBe(false);
});
it('label retourne vide après clear', () => {
UndoManager.snapshot('{}', 'test');
UndoManager.clear();
expect(UndoManager.label()).toBe('');
});
});
describe('expiration', () => {
it('canUndo retourne false après expiration (10s)', () => {
UndoManager.snapshot('{}', 'test');
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 11_000);
expect(UndoManager.canUndo()).toBe(false);
vi.restoreAllMocks();
});
it('canUndo retourne true avant expiration', () => {
UndoManager.snapshot('{}', 'test');
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 5_000);
expect(UndoManager.canUndo()).toBe(true);
vi.restoreAllMocks();
});
});
describe('undo', () => {
it('undo retourne false sans snapshot', () => {
expect(UndoManager.undo()).toBe(false);
});
it('undo retourne false sans electronAPI', () => {
UndoManager.snapshot('{}', 'test');
expect(UndoManager.undo()).toBe(false);
});
it('undo appelle saveData et le callback', () => {
const saveData = vi.fn();
const undoCallback = vi.fn();
(window as any).electronAPI = { saveData };
UndoManager.onUndo(undoCallback);
UndoManager.snapshot('{"restored":true}', 'test');
const result = UndoManager.undo();
expect(result).toBe(true);
expect(saveData).toHaveBeenCalledWith('{"restored":true}');
expect(undoCallback).toHaveBeenCalled();
expect(UndoManager.canUndo()).toBe(false);
});
it('undo ne fonctionne pas deux fois de suite', () => {
const saveData = vi.fn();
(window as any).electronAPI = { saveData };
UndoManager.onUndo(() => {});
UndoManager.snapshot('{}', 'test');
UndoManager.undo();
expect(UndoManager.undo()).toBe(false);
});
});
describe('snapshotCurrent', () => {
it('retourne false sans electronAPI', async () => {
const result = await UndoManager.snapshotCurrent('test');
expect(result).toBe(false);
});
it('retourne false si loadData retourne null', async () => {
(window as any).electronAPI = { loadData: vi.fn().mockResolvedValue(null) };
const result = await UndoManager.snapshotCurrent('test');
expect(result).toBe(false);
});
it('prend un snapshot si loadData retourne des données', async () => {
(window as any).electronAPI = { loadData: vi.fn().mockResolvedValue('{"ok":true}') };
const result = await UndoManager.snapshotCurrent('Suppression DD');
expect(result).toBe(true);
expect(UndoManager.canUndo()).toBe(true);
expect(UndoManager.label()).toBe('Suppression DD');
});
});
});