diff --git a/client/src/pages/WebImportSources.tsx b/client/src/pages/WebImportSources.tsx index 9bf7bb4..6245ead 100644 --- a/client/src/pages/WebImportSources.tsx +++ b/client/src/pages/WebImportSources.tsx @@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; +import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl"; import { Dialog, DialogContent, @@ -158,12 +159,20 @@ export default function WebImportSources() { toast.error("Champs requis", { description: "Nom, identifiant et mot de passe sont obligatoires." }); return; } + const portalUrl = normalizeWebPortalUrl(form.portalUrl); + if (!isValidWebPortalUrl(portalUrl)) { + toast.error("URL de portail invalide", { + description: "Saisissez une adresse HTTP(S), par exemple https://espaceclient.exemple.fr.", + }); + return; + } + if (editSource) { - const updateData: any = { id: editSource.id, ...form }; + const updateData: any = { id: editSource.id, ...form, portalUrl }; if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide updateMutation.mutate(updateData); } else { - createMutation.mutate(form); + createMutation.mutate({ ...form, portalUrl }); } } @@ -316,6 +325,7 @@ export default function WebImportSources() { onChange={e => setForm(f => ({ ...f, portalUrl: e.target.value }))} placeholder="https://..." /> +

Le préfixe https:// est ajouté automatiquement si nécessaire.

diff --git a/server/routers.ts b/server/routers.ts index 497e551..5089433 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { COOKIE_NAME } from "@shared/const"; import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility"; import { calculateInvoiceQualityScore } from "@shared/invoiceQuality"; +import { webPortalUrlSchema } from "./webPortalUrl"; interface Condition { field: string; @@ -2743,7 +2744,7 @@ export const appRouter = router({ .input(z.object({ name: z.string().min(1).max(100), connectorType: z.string().min(1).max(50), - portalUrl: z.string().url(), + portalUrl: webPortalUrlSchema, loginEmail: z.string().min(1), loginPassword: z.string().min(1), frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'), @@ -2758,7 +2759,7 @@ export const appRouter = router({ id: z.number(), name: z.string().min(1).max(100).optional(), connectorType: z.string().min(1).max(50).optional(), - portalUrl: z.string().url().optional(), + portalUrl: webPortalUrlSchema.optional(), loginEmail: z.string().min(1).optional(), loginPassword: z.string().optional(), frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(), diff --git a/server/webPortalUrl.test.ts b/server/webPortalUrl.test.ts new file mode 100644 index 0000000..e002211 --- /dev/null +++ b/server/webPortalUrl.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl"; +import { webPortalUrlSchema } from "./webPortalUrl"; + +describe("normalizeWebPortalUrl", () => { + it("ajoute HTTPS aux adresses de portail saisies sans protocole", () => { + expect(normalizeWebPortalUrl("www.sfr.fr/mon-espace-client")).toBe( + "https://www.sfr.fr/mon-espace-client", + ); + }); + + it("retire les espaces sans modifier une URL HTTPS déjà valide", () => { + expect(normalizeWebPortalUrl(" https://pro.free.fr/ ")).toBe("https://pro.free.fr/"); + }); + + it("ne transforme pas un protocole non web en URL HTTPS", () => { + expect(normalizeWebPortalUrl("ftp://example.org/factures")).toBe("ftp://example.org/factures"); + }); +}); + +describe("isValidWebPortalUrl", () => { + it("accepte exclusivement les URLs HTTP ou HTTPS avec un hôte", () => { + expect(isValidWebPortalUrl("https://espaceclient.orange.fr/")).toBe(true); + expect(isValidWebPortalUrl("http://intranet.local/factures")).toBe(true); + }); + + it("refuse les valeurs vides, incomplètes ou avec un protocole non web", () => { + expect(isValidWebPortalUrl("")).toBe(false); + expect(isValidWebPortalUrl("https://")).toBe(false); + expect(isValidWebPortalUrl("ftp://example.org/factures")).toBe(false); + }); +}); + +describe("webPortalUrlSchema", () => { + it("normalise l’URL reçue par l’API avant de la persister", () => { + expect(webPortalUrlSchema.parse("espaceclient.orange.fr")).toBe( + "https://espaceclient.orange.fr", + ); + }); + + it("retourne un message métier pour une URL de portail non HTTP(S)", () => { + const result = webPortalUrlSchema.safeParse("ftp://example.org/factures"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("URL HTTP(S) valide"); + } + }); +}); diff --git a/server/webPortalUrl.ts b/server/webPortalUrl.ts new file mode 100644 index 0000000..c5a48af --- /dev/null +++ b/server/webPortalUrl.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl"; + +/** + * Normalise les domaines saisis sans protocole puis impose une URL HTTP(S). + * Le même schéma est utilisé à la création et à la mise à jour d’un connecteur. + */ +export const webPortalUrlSchema = z + .string() + .transform(normalizeWebPortalUrl) + .refine(isValidWebPortalUrl, { + message: "Saisissez une URL HTTP(S) valide, par exemple https://espaceclient.exemple.fr.", + }); diff --git a/shared/webPortalUrl.ts b/shared/webPortalUrl.ts new file mode 100644 index 0000000..fcf5a1a --- /dev/null +++ b/shared/webPortalUrl.ts @@ -0,0 +1,24 @@ +/** + * Ajoute HTTPS aux adresses de portail saisies sous forme de domaine simple. + * Les protocoles explicites sont conservés afin que la validation puisse refuser + * proprement ceux qui ne sont pas HTTP(S). + */ +export function normalizeWebPortalUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("//")) return `https:${trimmed}`; + if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || /^(?:mailto|tel|data):/i.test(trimmed)) { + return trimmed; + } + return `https://${trimmed}`; +} + +/** Vérifie qu’une URL de portail est une adresse HTTP(S) avec un hôte. */ +export function isValidWebPortalUrl(value: string): boolean { + try { + const url = new URL(value); + return (url.protocol === "https:" || url.protocol === "http:") && Boolean(url.hostname); + } catch { + return false; + } +} diff --git a/todo.md b/todo.md index 7c9ffc7..317af7a 100644 --- a/todo.md +++ b/todo.md @@ -1,5 +1,10 @@ # Invoice Analyzer - TODO +## Correction validation connecteur web — 30/09/2026 +- [x] Reproduire l’erreur `portalUrl` invalide et identifier l’écart formulaire/API +- [x] Corriger la normalisation et la validation de l’URL du portail +- [ ] Valider les tests, puis déployer en recette et production sans toucher à MySQL ni aux volumes + ## Diagnostic réception e-mail automatique — 24/09/2026 - [x] Reproduire le démarrage de la réception automatique et identifier le blocage interface → API → planificateur - [x] Corriger la cause sans lancer d’import ni créer de planification non sollicitée, avec sélection explicite du compte source pour les administrateurs