From 44c57f01e08eca2867ad5116291f6da118477b07 Mon Sep 17 00:00:00 2001 From: michaelvic123 Date: Mon, 1 Jun 2026 07:09:19 +0100 Subject: [PATCH 1/3] feat: add real-time webhook URL validation - Real-time URL format validation as user types - HTTPS enforcement - Add test connection button - Add error messages for invalid URLs - Update timeout validation to also be real-time --- django-backend/soroscan/ingest/schema.py | 26 ++++++ .../__tests__/webhook-components.test.tsx | 2 +- .../settings/components/WebhookManager.tsx | 93 ++++++++++++++++--- soroscan-frontend/app/webhooks/[id]/page.tsx | 4 +- .../components/CreateWebhookModal.tsx | 56 ++++++++++- 5 files changed, 160 insertions(+), 21 deletions(-) diff --git a/django-backend/soroscan/ingest/schema.py b/django-backend/soroscan/ingest/schema.py index b1b82a09..e157fbf0 100644 --- a/django-backend/soroscan/ingest/schema.py +++ b/django-backend/soroscan/ingest/schema.py @@ -841,6 +841,32 @@ def recent_errors(self, info: Info, limit: int = 10) -> list[ErrorLog]: @strawberry.type class Mutation: + @strawberry.mutation + def test_webhook_url(self, url: str, timeout_seconds: int = 10) -> dict: + """Test a webhook URL by sending a ping payload.""" + import requests + import json + from django.utils import timezone + + payload = {"type": "ping", "timestamp": timezone.now().isoformat()} + payload_bytes = json.dumps(payload, sort_keys=True).encode("utf-8") + headers = { + "Content-Type": "application/json", + "X-SoroScan-Event": "ping", + } + + try: + response = requests.post( + url, + data=payload_bytes, + headers=headers, + timeout=timeout_seconds, + ) + success = response.status_code == 200 + return {"success": success, "status_code": response.status_code} + except requests.RequestException as exc: + return {"success": False, "error": str(exc)} + @strawberry.mutation def register_contract( self, diff --git a/soroscan-frontend/__tests__/webhook-components.test.tsx b/soroscan-frontend/__tests__/webhook-components.test.tsx index 0944ede0..d7700e58 100644 --- a/soroscan-frontend/__tests__/webhook-components.test.tsx +++ b/soroscan-frontend/__tests__/webhook-components.test.tsx @@ -196,7 +196,7 @@ describe("CreateWebhookModal", () => { const input = screen.getByPlaceholderText(/https:\/\/yourapp.io/) fireEvent.change(input, { target: { value: "not-a-url" } }) fireEvent.blur(input) - expect(screen.getByText(/valid https:\/\//)).toBeInTheDocument() + expect(screen.getByText(/Must be a valid HTTPS URL/)).toBeInTheDocument() }) it("shows no error for a valid URL", () => { diff --git a/soroscan-frontend/app/settings/components/WebhookManager.tsx b/soroscan-frontend/app/settings/components/WebhookManager.tsx index ab95061a..f3ca5fbe 100644 --- a/soroscan-frontend/app/settings/components/WebhookManager.tsx +++ b/soroscan-frontend/app/settings/components/WebhookManager.tsx @@ -10,6 +10,15 @@ type Webhook = { const defaultWebhooks: Webhook[] = []; +function isValidUrl(str: string): boolean { + try { + const u = new URL(str); + return u.protocol === "https:"; + } catch { + return false; + } +} + export default function WebhookManager() { const [webhooks, setWebhooks] = useState(() => { if (typeof window !== "undefined") { @@ -27,15 +36,51 @@ export default function WebhookManager() { const [newUrl, setNewUrl] = useState(""); const [saved, setSaved] = useState(false); const [error, setError] = useState(""); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; status_code?: number; error?: string } | null>(null); + const [urlTouched, setUrlTouched] = useState(false); + + const urlValid = isValidUrl(newUrl); + const urlError = (urlTouched || newUrl.length > 0) && !urlValid ? (newUrl ? "Must be a valid HTTPS URL" : "URL is required") : null; const persist = (next: Webhook[]) => { setWebhooks(next); localStorage.setItem("webhooks", JSON.stringify(next)); }; + const handleTestConnection = async () => { + if (!isValidUrl(newUrl)) return; + setTesting(true); + setTestResult(null); + try { + const response = await fetch("/api/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: ` + mutation TestWebhookUrl($url: String!, $timeoutSeconds: Int!) { + testWebhookUrl(url: $url, timeoutSeconds: $timeoutSeconds) + } + `, + variables: { url: newUrl, timeoutSeconds: 10 }, + }), + }); + const result = await response.json(); + setTestResult(result.data?.testWebhookUrl || { success: false, error: "Unknown error" }); + } catch (e) { + setTestResult({ success: false, error: "Failed to test connection" }); + } finally { + setTesting(false); + } + }; + const handleAddWebhook = () => { if (!newUrl.trim()) { - setError("Enter a valid webhook URL."); + setError("Enter a valid HTTPS URL."); + return; + } + if (!isValidUrl(newUrl)) { + setError("URL must be HTTPS."); return; } const nextWebhook: Webhook = { @@ -71,19 +116,39 @@ export default function WebhookManager() {

[ WEBHOOKS ]

-
- setNewUrl(event.target.value)} - placeholder="https://hooks.example.com/events" - className="w-full rounded-lg border border-green-500/30 bg-transparent px-3 py-2 font-mono text-sm text-green-300 focus:outline-none focus:border-green-400" - /> - +
+
+ setNewUrl(event.target.value)} + onBlur={() => setUrlTouched(true)} + placeholder="https://hooks.example.com/events" + className="w-full rounded-lg border border-green-500/30 bg-transparent px-3 py-2 font-mono text-sm text-green-300 focus:outline-none focus:border-green-400" + /> + + +
+ {urlError &&

{urlError}

} + {testResult && ( +

+ {testResult.success + ? testResult.status_code + ? `Connection successful! (HTTP ${testResult.status_code})` + : "Connection successful!" + : `Connection failed: ${testResult.error || "Unknown error"}`} +

+ )}
{error &&

{error}

} diff --git a/soroscan-frontend/app/webhooks/[id]/page.tsx b/soroscan-frontend/app/webhooks/[id]/page.tsx index 8135c7a2..b4b2df64 100644 --- a/soroscan-frontend/app/webhooks/[id]/page.tsx +++ b/soroscan-frontend/app/webhooks/[id]/page.tsx @@ -87,13 +87,13 @@ export default function WebhookDetailPage() { const handleTimeoutChange = (e: React.ChangeEvent) => { setTimeoutInput(e.target.value) - setTimeoutError(null) + setTimeoutError(validateTimeout(parseTimeout(e.target.value))) setSaveMessage(null) } const handleTimeoutSuggestion = (value: number) => { setTimeoutInput(String(value)) - setTimeoutError(null) + setTimeoutError(validateTimeout(value)) setSaveMessage(null) } diff --git a/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx b/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx index 988c8bf3..f174f836 100644 --- a/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx +++ b/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx @@ -26,7 +26,7 @@ interface CreateWebhookModalProps { function isValidUrl(str: string): boolean { try { const u = new URL(str) - return u.protocol === "http:" || u.protocol === "https:" + return u.protocol === "https:" } catch { return false } @@ -41,12 +41,40 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM const [timeoutInput, setTimeoutInput] = React.useState("30") const [timeoutTouched, setTimeoutTouched] = React.useState(false) const [submitting, setSubmitting] = React.useState(false) + const [testing, setTesting] = React.useState(false) + const [testResult, setTestResult] = React.useState<{ success: boolean; status_code?: number; error?: string } | null>(null) const urlValid = isValidUrl(url) const timeoutValue = Number(timeoutInput) const timeoutValid = Number.isInteger(timeoutValue) && timeoutValue >= 5 && timeoutValue <= 60 - const urlError = urlTouched && !urlValid ? "Must be a valid https:// URL" : null - const timeoutError = timeoutTouched && !timeoutValid ? "Timeout must be a whole number between 5 and 60 seconds." : null + const urlError = (urlTouched || url.length > 0) && !urlValid ? (url ? "Must be a valid HTTPS URL" : "URL is required") : null + const timeoutError = (timeoutTouched || timeoutInput.length > 0) && !timeoutValid ? "Timeout must be a whole number between 5 and 60 seconds." : null + + const handleTestConnection = async () => { + if (!urlValid) return + setTesting(true) + setTestResult(null) + try { + const response = await fetch("/api/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: ` + mutation TestWebhookUrl($url: String!, $timeoutSeconds: Int!) { + testWebhookUrl(url: $url, timeoutSeconds: $timeoutSeconds) + } + `, + variables: { url, timeoutSeconds: timeoutValue }, + }), + }) + const result = await response.json() + setTestResult(result.data?.testWebhookUrl || { success: false, error: "Unknown error" }) + } catch (e) { + setTestResult({ success: false, error: "Failed to test connection" }) + } finally { + setTesting(false) + } + } const toggleType = (t: EventType) => { if (t === "ALL") { @@ -95,7 +123,7 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM
{/* URL */} -
+
setUrlTouched(true)} aria-invalid={!!urlError} /> +
+ + {testResult && ( + + {testResult.success + ? testResult.status_code + ? `Success! (HTTP ${testResult.status_code})` + : "Success!" + : `Failed: ${testResult.error || "Unknown error"}`} + + )} +
{urlError && (

{urlError}

)} From 4b2517e94f3717a3f9dc640652d3b8011e3f0245 Mon Sep 17 00:00:00 2001 From: michaelvic123 Date: Mon, 1 Jun 2026 07:11:00 +0100 Subject: [PATCH 2/3] fix: address linter warnings for unused vars --- soroscan-frontend/app/settings/components/WebhookManager.tsx | 2 +- .../app/webhooks/components/CreateWebhookModal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/soroscan-frontend/app/settings/components/WebhookManager.tsx b/soroscan-frontend/app/settings/components/WebhookManager.tsx index f3ca5fbe..3ccb9e82 100644 --- a/soroscan-frontend/app/settings/components/WebhookManager.tsx +++ b/soroscan-frontend/app/settings/components/WebhookManager.tsx @@ -67,7 +67,7 @@ export default function WebhookManager() { }); const result = await response.json(); setTestResult(result.data?.testWebhookUrl || { success: false, error: "Unknown error" }); - } catch (e) { + } catch (_e) { setTestResult({ success: false, error: "Failed to test connection" }); } finally { setTesting(false); diff --git a/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx b/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx index f174f836..795961a2 100644 --- a/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx +++ b/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx @@ -69,7 +69,7 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM }) const result = await response.json() setTestResult(result.data?.testWebhookUrl || { success: false, error: "Unknown error" }) - } catch (e) { + } catch (_e) { setTestResult({ success: false, error: "Failed to test connection" }) } finally { setTesting(false) From 717c08c009cc74edfac7deab917f481c0bebf460 Mon Sep 17 00:00:00 2001 From: michaelvic123 Date: Mon, 1 Jun 2026 07:11:30 +0100 Subject: [PATCH 3/3] fix: remove unused catch variables entirely --- soroscan-frontend/app/settings/components/WebhookManager.tsx | 2 +- .../app/webhooks/components/CreateWebhookModal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/soroscan-frontend/app/settings/components/WebhookManager.tsx b/soroscan-frontend/app/settings/components/WebhookManager.tsx index 3ccb9e82..6a655c0f 100644 --- a/soroscan-frontend/app/settings/components/WebhookManager.tsx +++ b/soroscan-frontend/app/settings/components/WebhookManager.tsx @@ -67,7 +67,7 @@ export default function WebhookManager() { }); const result = await response.json(); setTestResult(result.data?.testWebhookUrl || { success: false, error: "Unknown error" }); - } catch (_e) { + } catch { setTestResult({ success: false, error: "Failed to test connection" }); } finally { setTesting(false); diff --git a/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx b/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx index 795961a2..56a2d5b2 100644 --- a/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx +++ b/soroscan-frontend/app/webhooks/components/CreateWebhookModal.tsx @@ -69,7 +69,7 @@ export function CreateWebhookModal({ isOpen, onClose, onCreate }: CreateWebhookM }) const result = await response.json() setTestResult(result.data?.testWebhookUrl || { success: false, error: "Unknown error" }) - } catch (_e) { + } catch { setTestResult({ success: false, error: "Failed to test connection" }) } finally { setTesting(false)