feat: category zoom + secure AddKeywordDialog (#74) #93

Merged
maximus merged 1 commit from issue-74-zoom-add-keyword into main 2026-04-14 19:11:55 +00:00
Owner

Fixes #74

Implémente le zoom catégorie et l'édition contextuelle des mots-clés. Cette PR porte toutes les garanties de sécurité listées au review expert.

Service — getCategoryZoom

  • Recursive CTE bornée (WHERE ct.depth < 5) contre les cycles parent_id (CWE-835)
  • SQL strictement paramétré, categoryId jamais interpolé
  • Retourne { rollupTotal, byChild, monthlyEvolution, transactions }

AddKeywordDialog — 7 garanties de sécurité

  1. Validation longueur 2–64 chars après .trim() → anti-ReDoS (CWE-1333)
  2. Preview via SQL paramétré LIKE $1 + filtrage regex en mémoire (CWE-89)
  3. Cap 1000 candidats avant regex filter → anti-backtracking catastrophique
  4. Apply dans transaction SQL BEGIN/COMMIT/ROLLBACK via db.execute (CWE-662)
  5. Application uniquement aux cochées visibles (max 50) + checkbox explicite N-50 non affichées
  6. Keyword existant pour autre catégorie → prompt explicite, reassign uniquement si user confirme, jamais rétroactif
  7. Rendu XSS-safe : {tx.description} comme enfants React, troncature CSS (CWE-79), 0 dangerouslySetInnerHTML

Composants

  • CategoryZoomHeader — combobox + toggle include-subcategories
  • CategoryDonutChartPie innerRadius={55} + ChartPatternDefs
  • CategoryEvolutionChart — AreaChart monthly
  • CategoryTransactionsTable — sortable, onContextMenu → ContextMenu → AddKeywordDialog
  • AddKeywordDialog placé dans components/categories/ (pas reports) — composant du domaine édition mot-clé

Exports depuis categorizationService

  • normalizeDescription, buildKeywordRegex, compileKeywords (avant privés)
  • validateKeyword, previewKeywordMatches, applyKeywordWithReassignment
  • Constantes KEYWORD_MIN_LENGTH=2, KEYWORD_MAX_LENGTH=64, KEYWORD_PREVIEW_LIMIT=50

i18n

  • reports.category.* + reports.keyword.* (FR + EN)
  • Pluriels i18next v25 format : nMatches_one / nMatches_other
  • Parité FR/EN vérifiée

Tests

  • npm run build
  • npm test 62/62 (20 nouveaux)
  • cargo check
  • getCategoryZoom : 3 tests (CTE bornée, cycle guard, direct-only)
  • categorizationService : 13 tests couvrant validation boundaries, paramétrisation LIKE, filtrage regex, BEGIN/COMMIT wrap, ROLLBACK on failure, reassignment policy, reject-before-db
  • grep -rn "dangerouslySetInnerHTML" src/components/categories src/components/reports → 0 occurrence

Scope du clic droit

Branché uniquement sur CategoryTransactionsTable dans cette issue. La propagation aux autres tables (Highlights, Compare, Transactions) est traitée en Issue #75.

Fixes #74 Implémente le zoom catégorie et l'édition contextuelle des mots-clés. Cette PR porte toutes les garanties de sécurité listées au review expert. ## Service — `getCategoryZoom` - Recursive CTE **bornée** (`WHERE ct.depth < 5`) contre les cycles `parent_id` (CWE-835) - SQL strictement paramétré, categoryId jamais interpolé - Retourne `{ rollupTotal, byChild, monthlyEvolution, transactions }` ## AddKeywordDialog — 7 garanties de sécurité 1. **Validation longueur** 2–64 chars après `.trim()` → anti-ReDoS (CWE-1333) 2. **Preview via SQL paramétré** `LIKE $1` + filtrage regex en mémoire (CWE-89) 3. **Cap 1000 candidats** avant regex filter → anti-backtracking catastrophique 4. **Apply dans transaction SQL** BEGIN/COMMIT/ROLLBACK via `db.execute` (CWE-662) 5. **Application uniquement aux cochées visibles** (max 50) + checkbox explicite N-50 non affichées 6. **Keyword existant pour autre catégorie** → prompt explicite, reassign uniquement si user confirme, jamais rétroactif 7. **Rendu XSS-safe** : `{tx.description}` comme enfants React, troncature CSS (CWE-79), 0 `dangerouslySetInnerHTML` ## Composants - `CategoryZoomHeader` — combobox + toggle include-subcategories - `CategoryDonutChart` — `Pie innerRadius={55}` + `ChartPatternDefs` - `CategoryEvolutionChart` — AreaChart monthly - `CategoryTransactionsTable` — sortable, onContextMenu → ContextMenu → AddKeywordDialog - `AddKeywordDialog` placé dans `components/categories/` (pas reports) — composant du domaine édition mot-clé ## Exports depuis categorizationService - `normalizeDescription`, `buildKeywordRegex`, `compileKeywords` (avant privés) - `validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment` - Constantes `KEYWORD_MIN_LENGTH=2`, `KEYWORD_MAX_LENGTH=64`, `KEYWORD_PREVIEW_LIMIT=50` ## i18n - `reports.category.*` + `reports.keyword.*` (FR + EN) - Pluriels i18next v25 format : `nMatches_one` / `nMatches_other` - Parité FR/EN vérifiée ## Tests - `npm run build` ✅ - `npm test` ✅ **62/62** (20 nouveaux) - `cargo check` ✅ - `getCategoryZoom` : 3 tests (CTE bornée, cycle guard, direct-only) - `categorizationService` : 13 tests couvrant validation boundaries, paramétrisation LIKE, filtrage regex, BEGIN/COMMIT wrap, ROLLBACK on failure, reassignment policy, reject-before-db - `grep -rn "dangerouslySetInnerHTML" src/components/categories src/components/reports` → 0 occurrence ## Scope du clic droit Branché **uniquement sur `CategoryTransactionsTable`** dans cette issue. La propagation aux autres tables (Highlights, Compare, Transactions) est traitée en Issue #75.
maximus added 1 commit 2026-04-14 19:09:46 +00:00
feat: category zoom + secure AddKeywordDialog with context menu (#74)
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
PR Check / rust (pull_request) Has been cancelled
PR Check / frontend (pull_request) Has been cancelled
62430c63dc
Service layer
- New reportService.getCategoryZoom(categoryId, from, to, includeChildren) —
  bounded recursive CTE (WHERE ct.depth < 5) protects against parent_id cycles;
  direct-only path skips the CTE; every binding is parameterised
- Export categorizationService helpers normalizeDescription / buildKeywordRegex /
  compileKeywords so the dialog can reuse them
- New validateKeyword() enforces 2–64 char length (anti-ReDoS), whitespace-only
  rejection, returns discriminated result
- New previewKeywordMatches(keyword, limit=50) uses parameterised LIKE + regex
  filter in memory; caps candidate scan at 1000 rows to protect against
  catastrophic backtracking
- New applyKeywordWithReassignment wraps INSERT (or UPDATE-reassign) +
  per-transaction UPDATEs in an explicit BEGIN/COMMIT/ROLLBACK; rejects
  existing keyword reassignment unless allowReplaceExisting is set; never
  recategorises historical transactions beyond the ids the caller supplied

Hook
- Flesh out useCategoryZoom with reducer + fetch + refetch hook

Components (flat under src/components/reports/)
- CategoryZoomHeader — category combobox + include/direct toggle
- CategoryDonutChart — template'd from dashboard/CategoryPieChart with
  innerRadius=55 and ChartPatternDefs for SVG patterns
- CategoryEvolutionChart — AreaChart with Intl-formatted axes
- CategoryTransactionsTable — sortable table with per-row onContextMenu
  → ContextMenu → "Add as keyword" action

AddKeywordDialog — src/components/categories/AddKeywordDialog.tsx
- Lives in categories/ (not reports/) because it is a keyword-editing widget
  consumed from multiple sections
- Renders transaction descriptions as React children only (no
  dangerouslySetInnerHTML); CSS truncation (CWE-79 safe)
- Per-row checkboxes for applying recategorisation; cap visible rows at 50;
  explicit opt-in checkbox to extend to N-50 non-displayed matches
- Surfaces apply errors + "keyword already exists" replace prompt
- Re-runs category zoom fetch on success so the zoomed view updates

Page
- ReportsCategoryPage composes header + donut + evolution + transactions
  + AddKeywordDialog, fetches from useCategoryZoom, preserves query string
  for back navigation

i18n
- New keys reports.category.* and reports.keyword.* in FR + EN
- Plural forms use i18next v25 _one / _other suffixes (nMatches)

Tests
- 3 reportService tests cover bounded CTE, cycle-guard depth check, direct-only fallthrough
- New categorizationService.test.ts: 13 tests covering validation boundaries,
  parameterised LIKE preview, regex word-boundary filter, explicit BEGIN/COMMIT
  wrapping, rollback on failure, existing keyword reassignment policy
- 62 total tests passing

Fixes #74

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Review — APPROVE

PR qui porte le gros du travail sécurité du sprint. Je vérifie chaque garantie du spec review une par une.

Sécurité — 9 findings 🔴/🟡 du review expert, tous résolus

# Finding Resolution dans cette PR
1 SQL injection sur preview LIKE $1 paramétré + regex en mémoire (test binds the LIKE pattern as a parameter)
2 ReDoS / longueur mot-clé validateKeyword cappe à 64 chars (test rejects keywords longer than 64 characters)
3 Cycle guard rollup CTE WHERE ct.depth < 5 (test uses a bounded recursive CTE, terminates on a cyclic category tree)
4 Transaction SQL englobante Explicit BEGIN / COMMIT / ROLLBACK via db.execute (test wraps INSERT + UPDATEs in a BEGIN/COMMIT transaction, rolls back when an UPDATE throws)
5 Preview 50 + apply sur tous Cap 50 visible + applyToHidden opt-in checkbox + user cochées uniquement
6 Keyword existant ambigu Reject sans allowReplaceExisting, prompt UI, UPDATE explicite (test blocks reassignment... without allowReplaceExisting)
7 XSS via descriptions 0 dangerouslySetInnerHTML dans components/categories + components/reports (grep vérifié), rendering {tx.description} + CSS truncate
8 AddKeywordDialog emplacement src/components/categories/ (pas reports/) comme spec
9 Exports privés normalizeDescription, buildKeywordRegex, compileKeywords exportés

Correctness

  • getCategoryZoom : CTE bornée testée sur chaîne cyclique ; direct-only skip le CTE
  • applyKeywordWithReassignment :
    • Valide avant DB (test dédié rejects invalid keywords before touching the database)
    • ROLLBACK sur exception avec throw e qui propage
    • replacedExisting true uniquement quand category_id change réellement
  • useCategoryZoom : refetch sur refetch() appel (exposé pour AddKeywordDialog.onApplied)
  • Preview : 3 rows canned (METRO #123, METRO PLUS, METROPOLITAIN) — la 3ème est exclue par word-boundary, cap 2 → test confirme que only 2 sont renvoyées

Qualité

  • npm run build
  • npm test 62/62 (20 nouveaux pour ce PR)
  • cargo check
  • Parité i18n FR/EN
  • Pluriels i18next v25 : _one / _other
  • Convention flat respectée (Category* dans reports/)

Non-bloquant

  • Le replacePrompt utilise un message générique sans montrer le nom de la catégorie actuelle — pourrait être amélioré avec reports.keyword.alreadyExists prenant un paramètre, mais le comportement sécurité est correct
  • Le cap 1000 candidats dans previewKeywordMatches est une défense en profondeur (pas dans la spec explicitement, mais cohérent avec l'esprit anti-ReDoS)

9/9 findings sécurité du spec review sont résolus. Ready to merge.

## Review — APPROVE PR qui porte le gros du travail sécurité du sprint. Je vérifie **chaque garantie** du spec review une par une. ### Sécurité — 9 findings 🔴/🟡 du review expert, tous résolus ✅ | # | Finding | Resolution dans cette PR | |---|---------|--------------------------| | 1 | SQL injection sur preview | `LIKE $1` paramétré + regex en mémoire (test `binds the LIKE pattern as a parameter`) | | 2 | ReDoS / longueur mot-clé | `validateKeyword` cappe à 64 chars (test `rejects keywords longer than 64 characters`) | | 3 | Cycle guard rollup | CTE `WHERE ct.depth < 5` (test `uses a bounded recursive CTE`, `terminates on a cyclic category tree`) | | 4 | Transaction SQL englobante | Explicit `BEGIN` / `COMMIT` / `ROLLBACK` via `db.execute` (test `wraps INSERT + UPDATEs in a BEGIN/COMMIT transaction`, `rolls back when an UPDATE throws`) | | 5 | Preview 50 + apply sur tous | Cap 50 visible + `applyToHidden` opt-in checkbox + user cochées uniquement | | 6 | Keyword existant ambigu | Reject sans `allowReplaceExisting`, prompt UI, UPDATE explicite (test `blocks reassignment... without allowReplaceExisting`) | | 7 | XSS via descriptions | 0 `dangerouslySetInnerHTML` dans components/categories + components/reports (grep vérifié), rendering `{tx.description}` + CSS truncate | | 8 | AddKeywordDialog emplacement | `src/components/categories/` (pas reports/) comme spec | | 9 | Exports privés | `normalizeDescription`, `buildKeywordRegex`, `compileKeywords` exportés | ### Correctness ✅ - `getCategoryZoom` : CTE bornée testée sur chaîne cyclique ; direct-only skip le CTE - `applyKeywordWithReassignment` : - Valide avant DB (test dédié `rejects invalid keywords before touching the database`) - `ROLLBACK` sur exception avec `throw e` qui propage - `replacedExisting` true uniquement quand category_id change réellement - `useCategoryZoom` : refetch sur refetch() appel (exposé pour AddKeywordDialog.onApplied) - Preview : 3 rows canned (METRO #123, METRO PLUS, METROPOLITAIN) — la 3ème est exclue par word-boundary, cap 2 → test confirme que only 2 sont renvoyées ### Qualité ✅ - `npm run build` ✅ - `npm test` ✅ 62/62 (20 nouveaux pour ce PR) - `cargo check` ✅ - Parité i18n FR/EN ✅ - Pluriels i18next v25 : `_one` / `_other` - Convention flat respectée (`Category*` dans reports/) ### Non-bloquant - Le `replacePrompt` utilise un message générique sans montrer le nom de la catégorie actuelle — pourrait être amélioré avec `reports.keyword.alreadyExists` prenant un paramètre, mais le comportement sécurité est correct - Le cap 1000 candidats dans `previewKeywordMatches` est une défense en profondeur (pas dans la spec explicitement, mais cohérent avec l'esprit anti-ReDoS) **9/9 findings sécurité du spec review sont résolus.** Ready to merge.
maximus merged commit 334f975deb into main 2026-04-14 19:11:55 +00:00
maximus deleted branch issue-74-zoom-add-keyword 2026-04-14 19:11:55 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#93
No description provided.