Checkpoint: Les connecteurs web acceptent désormais les noms de domaine saisis sans protocole : HTTPS est ajouté automatiquement dans l’interface et à l’API. Les URL HTTP(S) valides restent acceptées et les adresses incorrectes affichent un message métier clair. Sept tests dédiés, la suite complète (71 tests), TypeScript et build ont été validés.

This commit is contained in:
Manus
2026-09-30 12:52:53 +00:00
parent 082e6d8326
commit 26eec3831a
6 changed files with 106 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -158,12 +159,20 @@ export default function WebImportSources() {
toast.error("Champs requis", { description: "Nom, identifiant et mot de passe sont obligatoires." }); toast.error("Champs requis", { description: "Nom, identifiant et mot de passe sont obligatoires." });
return; 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) { 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 if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide
updateMutation.mutate(updateData); updateMutation.mutate(updateData);
} else { } 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 }))} onChange={e => setForm(f => ({ ...f, portalUrl: e.target.value }))}
placeholder="https://..." placeholder="https://..."
/> />
<p className="mt-1 text-xs text-muted-foreground">Le préfixe https:// est ajouté automatiquement si nécessaire.</p>
</div> </div>
<div> <div>
<Label>Identifiant (email ou login)</Label> <Label>Identifiant (email ou login)</Label>

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { COOKIE_NAME } from "@shared/const"; import { COOKIE_NAME } from "@shared/const";
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility"; import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
import { calculateInvoiceQualityScore } from "@shared/invoiceQuality"; import { calculateInvoiceQualityScore } from "@shared/invoiceQuality";
import { webPortalUrlSchema } from "./webPortalUrl";
interface Condition { interface Condition {
field: string; field: string;
@@ -2743,7 +2744,7 @@ export const appRouter = router({
.input(z.object({ .input(z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
connectorType: z.string().min(1).max(50), connectorType: z.string().min(1).max(50),
portalUrl: z.string().url(), portalUrl: webPortalUrlSchema,
loginEmail: z.string().min(1), loginEmail: z.string().min(1),
loginPassword: z.string().min(1), loginPassword: z.string().min(1),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'), frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
@@ -2758,7 +2759,7 @@ export const appRouter = router({
id: z.number(), id: z.number(),
name: z.string().min(1).max(100).optional(), name: z.string().min(1).max(100).optional(),
connectorType: z.string().min(1).max(50).optional(), connectorType: z.string().min(1).max(50).optional(),
portalUrl: z.string().url().optional(), portalUrl: webPortalUrlSchema.optional(),
loginEmail: z.string().min(1).optional(), loginEmail: z.string().min(1).optional(),
loginPassword: z.string().optional(), loginPassword: z.string().optional(),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(), frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),

View File

@@ -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");
}
});
});

13
server/webPortalUrl.ts Normal file
View File

@@ -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.",
});

24
shared/webPortalUrl.ts Normal file
View File

@@ -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;
}
}

View File

@@ -1,5 +1,10 @@
# Invoice Analyzer - TODO # 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 ## 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] 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 - [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