diff --git a/app/(root)/dashboard/api-integrations/page.tsx b/app/(root)/dashboard/api-integrations/page.tsx
index 9ed0f8f..6e62c52 100644
--- a/app/(root)/dashboard/api-integrations/page.tsx
+++ b/app/(root)/dashboard/api-integrations/page.tsx
@@ -1,7 +1,6 @@
+import { WebhookManager } from '@/components/Layout/Dashboard/API-Integrations/WebhookManager';
+
export default function Page(){
- return(
- <>
- >
- )
+ return
}
\ No newline at end of file
diff --git a/app/api/execute-webhook/route.ts b/app/api/execute-webhook/route.ts
new file mode 100644
index 0000000..90fefa1
--- /dev/null
+++ b/app/api/execute-webhook/route.ts
@@ -0,0 +1,131 @@
+import { isPrivateIP } from '@/lib/utils';
+import { URL } from 'url';
+import { promises as dns } from 'dns';
+
+// Helper function to resolve the IP address of a hostname
+async function resolveIP(hostname) {
+ try {
+ const addresses = await dns.lookup(hostname, { all: true });
+
+ const ipv4Address = addresses.find(addr => addr.family === 4)?.address;
+
+ if (ipv4Address) {
+ return ipv4Address;
+ }
+
+ return addresses[0]?.address;
+
+ } catch (error) {
+ console.error(`DNS lookup failed for ${hostname}:`, error.message);
+ throw new Error("DNS_RESOLUTION_FAILED");
+ }
+}
+
+/**
+ * This function enforces SSRF protection policies using real DNS lookups.
+ */
+export async function POST(request) {
+ let destinationUrl, secretKey, payload;
+
+ try {
+ const body = await request.json();
+ ({ destinationUrl, secretKey, payload } = body);
+ } catch (e) {
+ return Response.json({ success: false, error: 'Invalid JSON body or missing required fields.' }, { status: 400 });
+ }
+
+ // 1. Validate Required Payload Fields
+ if (!destinationUrl || !secretKey || !payload) {
+ return Response.json({ success: false, error: 'Missing destinationUrl, secretKey, or payload in the request body.' }, { status: 400 });
+ }
+
+ let url;
+ try {
+ url = new URL(destinationUrl);
+ } catch (e) {
+ return Response.json({ success: false, error: 'Invalid destination URL format.' }, { status: 400 });
+ }
+
+ // 2. --- SSRF DEFENSE 1: PROTOCOL LOCK ---
+ if (url.protocol !== 'https:') {
+ return Response.json({ success: false, error: 'Protocol violation. Only HTTPS endpoints are permitted for security reasons.' }, { status: 403 });
+ }
+
+ // Check if the hostname is an IP address already (e.g., https://1.2.3.4/hook)
+ const isDirectIP = url.hostname.match(/^(\d{1,3}\.){3}\d{1,3}$/);
+
+ let resolvedIp;
+
+ //. --- SSRF DEFENSE 2: IP BLOCKLIST CHECK ---
+ try {
+ if (isDirectIP) {
+ // If the user provided an IP directly, we use it directly
+ resolvedIp = url.hostname;
+ } else {
+ // Otherwise, we perform a real DNS lookup
+ resolvedIp = await resolveIP(url.hostname);
+ }
+
+ if (!resolvedIp) {
+ return Response.json({ success: false, error: 'DNS resolution failed or returned no address.' }, { status: 403 });
+ }
+
+ // Check if the resolved IP is private
+ if (isPrivateIP(resolvedIp)) {
+ console.warn(`SSRF BLOCK: Attempted request to private IP ${resolvedIp} for URL ${destinationUrl}`);
+ return Response.json({
+ success: false,
+ error: 'Security Policy Violation: Target IP resolves to a private or reserved network. Request blocked to prevent SSRF.'
+ }, { status: 403 });
+ }
+ } catch (e) {
+ if (e.message === "DNS_RESOLUTION_FAILED") {
+ return Response.json({ success: false, error: 'Target hostname could not be resolved.' }, { status: 403 });
+ }
+ return Response.json({ success: false, error: 'Internal server error during IP verification.' }, { status: 500 });
+ }
+
+ // Execute the Webhook Request
+ try {
+ const webhookResponse = await fetch(destinationUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ // --- PAYLOAD INTEGRITY DEFENSE ---
+ 'X-Webhook-Secret': secretKey,
+ 'User-Agent': 'AICAP-Webhook-Dispatcher/1.0',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ const status = webhookResponse.status;
+
+ if (webhookResponse.ok) {
+ return Response.json({
+ success: true,
+ message: 'Webhook delivered successfully.',
+ targetStatus: status
+ }, { status: 200 });
+ } else {
+ const responseText = await webhookResponse.text();
+ console.error(`Webhook failed, remote status: ${status}. Response: ${responseText.substring(0, 100)}`);
+ return Response.json({
+ success: false,
+ error: `Webhook failed. Target responded with status: ${status}.`,
+ targetStatus: status
+ }, { status: 200 });
+ }
+
+ } catch (e) {
+ console.error(`Network error during webhook execution: ${e.message}`);
+ return Response.json({
+ success: false,
+ error: `Network connection failed or timed out: ${e.message}`
+ }, { status: 200 });
+ }
+}
+
+// Optional: Block other methods for stricter API design
+export async function GET() {
+ return Response.json({ error: 'Method Not Allowed' }, { status: 405 });
+}
\ No newline at end of file
diff --git a/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx b/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx
new file mode 100644
index 0000000..b854922
--- /dev/null
+++ b/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx
@@ -0,0 +1,439 @@
+'use client'
+import React, { useState, useEffect } from 'react';
+import {
+ Zap,
+ Link,
+ Key,
+ Database,
+ X,
+ Plus,
+ Trash2,
+ Edit,
+ Loader2,
+ CheckCircle,
+ AlertTriangle,
+ ToggleLeft,
+ ToggleRight,
+ Clipboard,
+ Calendar,
+} from 'lucide-react';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+// Mock Encryption/Decryption Utility (Simulating secure storage/retrieval)
+const mockEncrypt = (data) => `ENC:${btoa(data)}`;
+const mockDecrypt = (data) => data.startsWith('ENC:') ? atob(data.slice(4)) : data;
+
+// Default state for a new webhook
+const defaultWebhook = {
+ url: '',
+ trigger_event: 'content.complete',
+ secret_key: crypto.randomUUID(), // Auto-generate a strong initial secret
+ is_active: true,
+};
+// --- Mock Data Setup ---
+const initialMockWebhooks = [
+ {
+ id: 'hook_1',
+ url: 'https://staging.cms-backend.io/api/ingest',
+ secret_key: mockEncrypt('MyCmsSecretToken'),
+ trigger_event: 'content.complete',
+ is_active: true,
+ created_at: Date.now() - 86400000,
+ },
+ {
+ id: 'hook_2',
+ url: 'https://dev.marketing-tool.net/receive-updates',
+ secret_key: mockEncrypt('MarketingApi123'),
+ trigger_event: 'content.complete',
+ is_active: false,
+ created_at: Date.now() - 3600000,
+ },
+];
+
+// --- Dialog Component (Simulating shadcn/ui Dialog) ---
+const WebhookFormDialog = ({ isOpen, onClose, initialData, onSave, status, message }) => {
+ const [formData, setFormData] = useState(initialData || defaultWebhook);
+ const [messageBox, setMessageBox] = useState({ message: message, status: status });
+
+ // Update form data if initialData changes (e.g., when editing starts)
+ useEffect(() => {
+ // We decrypt the key for presentation in the form
+ const decryptedData = initialData ? {
+ ...initialData,
+ secret_key: mockDecrypt(initialData.secret_key)
+ } : { ...defaultWebhook, secret_key: crypto.randomUUID() };
+
+ setFormData(decryptedData);
+ }, [initialData]);
+
+ useEffect(() => {
+ setMessageBox({ message, status })
+ }, [status, message]);
+
+
+ if (!isOpen) return null;
+
+ const handleSave = () => {
+ try {
+ const url = new URL(formData.url.trim());
+ if (url.protocol !== 'https:') {
+ // Using console.error instead of alert as per instructions
+ console.error("Validation Error: Only HTTPS URLs are permitted for security.");
+ // setMessageBox('Validation Error: Only HTTPS URLs are permitted for security.', 'error');
+ return;
+ }
+ } catch(e) {
+ console.error("Validation Error: Please provide a valid URL.");
+ // setMessageBox('Validation Error: Please provide a valid URL.', 'error');
+ return;
+ }
+
+ if (!formData.secret_key) {
+ console.error("Validation Error: Secret key is required.");
+ // setMessageBox('Validation Error: Secret key is required.', 'error');
+ return;
+ }
+
+ onSave(formData);
+ };
+
+ const isEditing = !!initialData?.id;
+
+ return (
+
+
+ {/* Dialog Header */}
+
+
+ {isEditing ? 'Edit Webhook Integration' : 'Add New Webhook'}
+
+
+
+
+ {/* Status Indicator */}
+ {messageBox.status !== 'idle' && messageBox.message && (
+
+ {messageBox.status === 'loading' &&
}
+ {messageBox.status === 'success' &&
}
+ {messageBox.status === 'error' &&
}
+ {messageBox.message}
+
+ )}
+
+ {/* Dialog Content */}
+
+
+ {/* URL Input */}
+
+
+
setFormData(prev => ({ ...prev, url: e.target.value }))}
+ placeholder="https://yourcms.com/api/webhooks/ingest"
+ disabled={messageBox.status === 'loading'}
+ />
+
+ Must use HTTPS. The server checks the IP for SSRF prevention.
+
+
+
+ {/* Secret Key Input */}
+
+
+
+ setFormData(prev => ({ ...prev, secret_key: e.target.value }))}
+ placeholder="Automatically generated secure token"
+ disabled={messageBox.status === 'loading'}
+ />
+
+
+
+
+ {/* Trigger and Active Status (Read-Only) */}
+
+
+
+ {/* Dialog Footer */}
+
+
+
+
+
+
+
+
+ );
+};
+
+
+// --- Main Webhook Manager Component ---
+export const WebhookManager = () => {
+ // Initialize state with mock data. We decrypt for display purposes.
+ const [webhooks, setWebhooks] = useState(initialMockWebhooks.map(h => ({
+ ...h,
+ secret_key: mockDecrypt(h.secret_key),
+ })));
+
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [editingWebhook, setEditingWebhook] = useState(null);
+
+ // UI Status State (used for global actions like delete or toggle)
+ const [status, setStatus] = useState('idle'); // 'idle', 'loading', 'success', 'error'
+ const [message, setMessage] = useState('');
+
+ // --- CRUD Handlers (Local State Management) ---
+
+ const startCreate = () => {
+ setEditingWebhook({ ...defaultWebhook, secret_key: crypto.randomUUID() });
+ setIsDialogOpen(true);
+ };
+
+ const startEdit = (hook) => {
+ // Pass the hook object to the dialog for pre-filling
+ setEditingWebhook(hook);
+ setIsDialogOpen(true);
+ };
+
+ const handleSave = (data) => {
+ setStatus('loading');
+ setMessage(data.id ? 'Updating webhook...' : 'Creating new webhook...');
+
+ // Simulate async operation delay
+ setTimeout(() => {
+ const dataToSave = {
+ ...data,
+ // Re-encrypt the key before saving to mock persistent storage
+ secret_key: mockEncrypt(data.secret_key),
+ updated_at: Date.now(),
+ };
+
+ setWebhooks(prevHooks => {
+ if (data.id) {
+ // Update existing hook
+ return prevHooks.map(hook =>
+ hook.id === data.id ? { ...dataToSave, id: data.id } : hook
+ );
+ } else {
+ // Add new hook
+ const newHook = {
+ ...dataToSave,
+ id: crypto.randomUUID(),
+ created_at: Date.now()
+ };
+ return [...prevHooks, newHook];
+ }
+ });
+
+ setMessage(data.id ? 'Webhook updated successfully!' : 'New webhook created successfully!');
+ setStatus('success');
+
+ // Close dialog after success
+ setTimeout(() => {
+ setStatus('idle');
+ setIsDialogOpen(false);
+ }, 1000);
+
+ }, 500); // Mock network delay
+ };
+
+ const handleDelete = (id) => {
+ // Substitute for custom confirmation dialog
+ if (!window.confirm("Confirm deletion: This action cannot be undone.")) return;
+
+ setStatus('loading');
+ setMessage('Deleting webhook...');
+
+ setTimeout(() => {
+ setWebhooks(prevHooks => prevHooks.filter(hook => hook.id !== id));
+ setMessage('Webhook deleted.');
+ setStatus('success');
+ setTimeout(() => setStatus('idle'), 1500);
+ }, 500); // Mock network delay
+ };
+
+ const handleToggleActive = (hook) => {
+ setStatus('loading');
+ const newStatus = !hook.is_active;
+
+ setTimeout(() => {
+ setWebhooks(prevHooks => prevHooks.map(h =>
+ h.id === hook.id ? { ...h, is_active: newStatus } : h
+ ));
+ setMessage(`Webhook ${newStatus ? 'activated' : 'deactivated'}.`);
+ setStatus('success');
+ setTimeout(() => setStatus('idle'), 1500);
+ }, 500); // Mock network delay
+ };
+
+
+ return (
+
+
+ {/* Header and Add Button */}
+
+
+
+ Webhook Integrations
+
+
+
+
+ {/* Global Status Display */}
+ {message && status !== 'idle' && status !== 'loading' && (
+
+ {status === 'success' &&
}
+ {status === 'error' &&
}
+ {message}
+
+ )}
+
+ {/* Webhooks List */}
+ {status === 'loading' ? (
+
+ ) : (
+
+ {webhooks.length === 0 ? (
+
+
+
No webhooks configured.
+
+ Start automating by adding your first content ingestion endpoint.
+
+
+ ) : (
+ webhooks.map((hook) => (
+
+
+
+
+
+
+
+
+ {hook.is_active ? 'Active' : 'Disabled'} Integration
+
+
+
+ {new Date(hook.created_at).toLocaleDateString()}
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+
+ {/* Details */}
+
+
Destination URL
+
+ {hook.url}
+
+
+
+
+ Trigger: {hook.trigger_event}
+ Secured by Secret Key
+
+
+ ))
+ )}
+
+ )}
+
+
+ {/* Webhook Dialog */}
+
setIsDialogOpen(false)}
+ initialData={editingWebhook}
+ onSave={handleSave}
+ status={'idle'}
+ message={''}
+ />
+
+ );
+};
\ No newline at end of file
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
index d0fde0f..3e657c8 100644
--- a/components/ui/label.tsx
+++ b/components/ui/label.tsx
@@ -5,7 +5,8 @@ import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
-function Label({ className, ...props }: React.ComponentProps) {
+function Label({ icon, className = '', children, ...props }) {
+ const IconComponent = icon ? icon : null
return (
+ >
+ {icon && }
+ {children}
+
);
}
diff --git a/context/GenerationContext.tsx b/context/GenerationContext.tsx
index d86b239..6299514 100644
--- a/context/GenerationContext.tsx
+++ b/context/GenerationContext.tsx
@@ -116,6 +116,8 @@ interface GenerationContextType {
setIsDialogOpen: (prev?: boolean) => void;
onRefinePrompt: () => Promise;
+
+ triggerWebhookDispatch: () => Promise;
}
// --- 2. Create the Context with Default Values ---
@@ -411,6 +413,40 @@ export function ContextProvider({ children }: { children: ReactNode }) {
setIsRefineLoading(false);
}, [prompt, setPrompt]);
+ // --- Webhook Trigger Function ---
+ async function triggerWebhookDispatch(hookConfig, contentPayload) {
+ const decryptedSecret = ""
+ const apiCallBody = {
+ destinationUrl: hookConfig.url,
+ secretKey: decryptedSecret, // The key we use for target validation
+ payload: contentPayload, // The content we are sending
+ };
+
+ try {
+ const response = await fetch('/api/execute-webhook', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(apiCallBody),
+ });
+
+ const result = await response.json();
+
+ if (!result.success) {
+ console.error(`Webhook execution failed on server: ${result.error}`);
+ toast.error('Webhook execution failed on server');
+ return;
+ }
+
+ console.log(`Webhook successfully dispatched to ${hookConfig.url}`);
+ toast.error('Webhook successfully dispatched');
+ // Show success message!
+
+ } catch (error) {
+ console.error("Local network error calling webhook API:", error);
+ toast.error('Local network error calling webhook API');
+ }
+ }
+
const value = {
generatedContent,
isLoading,
@@ -458,6 +494,7 @@ export function ContextProvider({ children }: { children: ReactNode }) {
onRefinePrompt,
isDialogOpen,
setIsDialogOpen,
+ triggerWebhookDispatch
};
return {children};
diff --git a/lib/actions/content.actions.ts b/lib/actions/content.actions.ts
index 60cbf95..c673082 100644
--- a/lib/actions/content.actions.ts
+++ b/lib/actions/content.actions.ts
@@ -71,7 +71,7 @@ export async function updateContent(contentId: string, newContent: string) {
}
}
-export async function makeRequest(url: string, options: any) {
+export async function makeRequest(url: string, options: never) {
try {
return await axios.post(url, options);
@@ -80,3 +80,4 @@ export async function makeRequest(url: string, options: any) {
throw e;
}
}
+
diff --git a/lib/utils.ts b/lib/utils.ts
index e2c899d..a3743ae 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -76,3 +76,39 @@ export const generateCronString = (frequency: string, timeStr: string): string =
return `${minute} ${hour} * * *`;
}
};
+
+
+export function isPrivateIP(ip) {
+ if (!ip) return true; // Safety check
+
+ // Parse the IP segments
+ const parts = ip.split('.').map(Number);
+ if (parts.length !== 4) return true;
+
+ // A CIDR range checker:
+ const privateRanges = [
+ // 10.0.0.0/8 (Private Class A)
+ [10, 0, 0, 0, 10, 255, 255, 255],
+ // 172.16.0.0/12 (Private Class B)
+ [172, 16, 0, 0, 172, 31, 255, 255],
+ // 192.168.0.0/16 (Private Class C)
+ [192, 168, 0, 0, 192, 168, 255, 255],
+ // 127.0.0.0/8 (Loopback)
+ [127, 0, 0, 0, 127, 255, 255, 255],
+ // 169.254.0.0/16 (Link-local)
+ [169, 254, 0, 0, 169, 254, 255, 255],
+ ];
+
+ const ipNum = parts[0] * 256**3 + parts[1] * 256**2 + parts[2] * 256 + parts[3];
+
+ for (const [startA, startB, startC, startD, endA, endB, endC, endD] of privateRanges) {
+ const startNum = startA * 256**3 + startB * 256**2 + startC * 256 + startD;
+ const endNum = endA * 256**3 + endB * 256**2 + endC * 256 + endD;
+
+ if (ipNum >= startNum && ipNum <= endNum) {
+ return true;
+ }
+ }
+
+ return false;
+}
\ No newline at end of file
diff --git a/testRunner.js b/testRunner.js
new file mode 100644
index 0000000..e4a8f26
--- /dev/null
+++ b/testRunner.js
@@ -0,0 +1,125 @@
+import { promises as dns } from 'dns';
+
+// --- IMPORTANT CONFIGURATION ---
+// You must replace this with the actual URL of your running Next.js API route.
+// If you run Next.js locally, it will likely be:
+const API_ENDPOINT = 'http://localhost:3000/api/execute-webhook';
+
+// --- SSRF TEST TARGETS ---
+// For a real test, you'd run a simple mock server that listens on different IPs
+// For demonstration, we'll use public IPs (like Google's DNS or a known public test service)
+// and the blocked private IP (127.0.0.1)
+
+/**
+ * Executes a POST request against the Next.js Webhook API endpoint.
+ * @param {string} destinationUrl The URL the Next.js server will attempt to call.
+ * @param {string} secretKey The mock secret key.
+ * @param {object} payload The mock JSON payload.
+ * @param {string} testName A description for the test case.
+ */
+async function runTest(testName, destinationUrl, secretKey, payload) {
+ console.log(`\n--- Running Test Case: ${testName} ---`);
+ console.log(`Target Webhook: ${destinationUrl}`);
+
+ const body = { destinationUrl, secretKey, payload };
+
+ try {
+ const response = await fetch(API_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+
+ const status = response.status;
+ const result = await response.json();
+
+ console.log(`API Status Code: ${status}`);
+ console.log('Response Body:', result);
+
+ // Assertions for expected behavior
+ if (testName.includes("BLOCK")) {
+ if (status === 403 && !result.success) {
+ console.log('✅ TEST PASSED: Request was correctly blocked with 403.');
+ } else {
+ console.log(`❌ TEST FAILED: Expected 403 block, got status ${status} and success: ${result.success}.`);
+ }
+ } else if (testName.includes("SUCCESS")) {
+ if (status === 200 && result.success === true) {
+ console.log('✅ TEST PASSED: Webhook dispatch was processed successfully (200 OK).');
+ } else {
+ console.log(`❌ TEST FAILED: Expected 200 success, got status ${status} and success: ${result.success}.`);
+ }
+ }
+
+ } catch (error) {
+ console.error(`❌ TEST FAILED: Network error connecting to Next.js API at ${API_ENDPOINT}:`, error.message);
+ console.error("-> Ensure your Next.js server is running on port 3000.");
+ }
+}
+
+
+async function main() {
+ console.log("=================================================");
+ console.log(" SSRF Defense Test Runner Script ");
+ console.log("=================================================");
+
+ // Check DNS module availability (only relevant if running on a platform
+ // where Node.js features might be restricted, like Vercel Edge).
+ try {
+ await dns.lookup('google.com');
+ console.log("DNS module check: Available (Node.js runtime confirmed).");
+ } catch (e) {
+ console.error("DNS module check: FAILED. Ensure your Next.js route is running in a Node.js environment.");
+ return;
+ }
+
+ // --- TEST SUITE ---
+
+ // 1. SSRF BLOCK TEST: Private IP (127.0.0.1) via Hostname
+ // NOTE: This will fail if the Next.js server is running on Edge,
+ // but should succeed if running as a Node function as expected.
+ await runTest(
+ "1. SSRF BLOCK TEST (127.0.0.1)",
+ "https://127.0.0.1/internal-resource-that-should-be-safe",
+ "mock-secret",
+ { event: "test", data: 123 }
+ );
+
+ // 2. PROTOCOL BLOCK TEST: HTTP (Non-HTTPS)
+ await runTest(
+ "2. PROTOCOL BLOCK TEST (HTTP)",
+ "http://example.com/some/path",
+ "mock-secret",
+ { event: "test", data: 456 }
+ );
+
+ // 3. SSRF BLOCK TEST: Private IP (192.168.x.x) via Hostname
+ // We use a hostname that resolves to a public IP to simulate the DNS lookup,
+ // but imagine 'localhost' or a resolved '192.168.1.1'
+ // This test relies on the DNS lookup mechanism identifying a private IP.
+ await runTest(
+ "3. SSRF BLOCK TEST (localhost, needs mock)",
+ "https://localhost/api/sensitive-data",
+ "mock-secret",
+ { event: "test", data: 789 }
+ );
+
+ // 4. EXTERNAL SUCCESS TEST: Public Endpoint (Assuming it connects)
+ // NOTE: This test requires a real public webhook target (e.g., https://postman-echo.com/post)
+ // or a proxy to succeed. We use a placeholder that will likely fail
+ // due to lack of a real server but will pass the SSRF/Protocol checks.
+ await runTest(
+ "4. EXTERNAL SUCCESS TEST (Public HTTPS)",
+ "https://jsonplaceholder.typicode.com/posts", // A real public API for testing POST
+ "mock-secret-prod",
+ { title: "Test Post", body: "This should be sent successfully", userId: 1 }
+ );
+
+ console.log("\n=================================================");
+ console.log("Test Suite Finished.");
+ console.log("=================================================");
+}
+
+main();
\ No newline at end of file