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..6a655c0f 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 { + 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..56a2d5b2 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 { + 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}

)}