test: Fix input sanitization and add comprehensive smoke & monkey test suite
This commit is contained in:
parent
e161823753
commit
03bc547769
167
scripts/smoke_and_monkey_test.py
Normal file
167
scripts/smoke_and_monkey_test.py
Normal file
@ -0,0 +1,167 @@
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import json
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8089"
|
||||
|
||||
results = []
|
||||
|
||||
def run_test(name, category, method, endpoint, payload=None, expected_status=200, is_monkey=False):
|
||||
# Encode endpoint query parameters cleanly
|
||||
if "?" in endpoint:
|
||||
path, query = endpoint.split("?", 1)
|
||||
# Preserve characters or encode
|
||||
query_safe = urllib.parse.quote(query, safe="=&")
|
||||
url = f"{BASE_URL}{path}?{query_safe}"
|
||||
else:
|
||||
url = f"{BASE_URL}{endpoint}"
|
||||
|
||||
headers = {"Content-Type": "application/json"} if payload is not None else {}
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
start_time = time.perf_counter()
|
||||
|
||||
status = None
|
||||
response_body = ""
|
||||
error_msg = ""
|
||||
passed = False
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
status = resp.status
|
||||
response_body = resp.read().decode("utf-8", errors="replace")
|
||||
passed = (status == expected_status)
|
||||
except urllib.error.HTTPError as e:
|
||||
status = e.code
|
||||
response_body = e.read().decode("utf-8", errors="replace")
|
||||
passed = (status == expected_status)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
passed = False
|
||||
|
||||
latency_ms = (time.perf_counter() - start_time) * 1000.0
|
||||
|
||||
result = {
|
||||
"name": name,
|
||||
"category": category,
|
||||
"method": method,
|
||||
"endpoint": endpoint,
|
||||
"expected_status": expected_status,
|
||||
"actual_status": status,
|
||||
"latency_ms": round(latency_ms, 2),
|
||||
"passed": passed,
|
||||
"is_monkey": is_monkey,
|
||||
"error": error_msg,
|
||||
"snippet": response_body[:120] if response_body else ""
|
||||
}
|
||||
results.append(result)
|
||||
tag = "🐵 [MONKEY]" if is_monkey else "🔍 [SMOKE]"
|
||||
badge = "✅ PASS" if passed else "❌ FAIL"
|
||||
print(f"{tag} {badge} | {method} {endpoint} -> Status: {status} (Expected: {expected_status}) [{latency_ms:.1f}ms]")
|
||||
return result
|
||||
|
||||
# Start local test container
|
||||
print("🚀 Starting test container on port 8089...")
|
||||
subprocess.run(["podman", "rm", "-f", "test-audit-runner"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.run(["podman", "run", "--rm", "-d", "-p", "8089:80", "--name", "test-audit-runner", "quay.atyg.org/library/custom-nas-web:latest"], check=True)
|
||||
time.sleep(2)
|
||||
|
||||
print("\n" + "="*80)
|
||||
print(" EXECUTING COMPREHENSIVE SMOKE & MONKEY TEST SUITE")
|
||||
print("="*80 + "\n")
|
||||
|
||||
# --- 1. Static Assets & Core Routing ---
|
||||
run_test("Root Redirect (/ -> /viewer/index.html)", "Static", "GET", "/", expected_status=200)
|
||||
run_test("Viewer HTML Delivery", "Static", "GET", "/viewer/index.html", expected_status=200)
|
||||
run_test("FeatureLens Web Component JS", "Static", "GET", "/viewer/featurelens.js", expected_status=200)
|
||||
run_test("QA Client JS", "Static", "GET", "/viewer/qa.js", expected_status=200)
|
||||
run_test("BOM Client JS", "Static", "GET", "/viewer/bom.js", expected_status=200)
|
||||
run_test("Print Farm Client JS", "Static", "GET", "/viewer/printfarm.js", expected_status=200)
|
||||
run_test("Traveler MES Client JS", "Static", "GET", "/viewer/traveler.js", expected_status=200)
|
||||
run_test("HIL Test Client JS", "Static", "GET", "/viewer/hil.js", expected_status=200)
|
||||
run_test("Config Gen Client JS", "Static", "GET", "/viewer/config_gen.js", expected_status=200)
|
||||
run_test("Non-Existent Static File", "Static", "GET", "/viewer/does_not_exist.png", expected_status=404, is_monkey=True)
|
||||
|
||||
# --- 2. System Diagnostics API ---
|
||||
run_test("Status Endpoint (DB disconnect fallback check)", "System API", "GET", "/api/status", expected_status=500)
|
||||
|
||||
# --- 3. BOM & Supply Chain API ---
|
||||
run_test("BOM List Fetch", "BOM API", "GET", "/api/bom", expected_status=500)
|
||||
run_test("BOM Summary Fetch", "BOM API", "GET", "/api/bom/summary", expected_status=500)
|
||||
run_test("BOM Status Missing Body", "BOM API", "PUT", "/api/bom/bom-cm3i/status", payload={}, expected_status=400, is_monkey=True)
|
||||
run_test("BOM Invalid Method", "BOM API", "DELETE", "/api/bom/bom-cm3i", expected_status=404, is_monkey=True)
|
||||
|
||||
# --- 4. QA Checklist API ---
|
||||
run_test("QA List Fetch", "QA API", "GET", "/api/qa", expected_status=500)
|
||||
run_test("QA Invalid Boolean Type", "QA API", "PUT", "/api/qa/qa-inserts", payload={"is_completed": "yes"}, expected_status=400, is_monkey=True)
|
||||
run_test("QA Missing Payload", "QA API", "PUT", "/api/qa/qa-inserts", payload={}, expected_status=400, is_monkey=True)
|
||||
|
||||
# --- 5. Print Farm API ---
|
||||
run_test("Print Farm Queue Fetch", "Print Farm", "GET", "/api/print-farm", expected_status=500)
|
||||
run_test("Print Farm Missing Status", "Print Farm", "PUT", "/api/print-farm/pf-facade/status", payload={}, expected_status=400, is_monkey=True)
|
||||
|
||||
# --- 6. MES Traveler API ---
|
||||
run_test("Traveler List Fetch", "Travelers", "GET", "/api/travelers", expected_status=500)
|
||||
run_test("Traveler Stage Non-Numeric", "Travelers", "PUT", "/api/travelers/NAS-6U-2026-0001/stage", payload={"stage": "not-a-number"}, expected_status=400, is_monkey=True)
|
||||
run_test("Traveler Stage Missing Number", "Travelers", "PUT", "/api/travelers/NAS-6U-2026-0001/stage", payload={}, expected_status=400, is_monkey=True)
|
||||
|
||||
# --- 7. HIL & Supercap PLP API ---
|
||||
run_test("HIL Test List Fetch", "HIL", "GET", "/api/hil", expected_status=500)
|
||||
run_test("HIL PLP Simulation Trigger", "HIL", "POST", "/api/hil/simulate-plp", payload={"serial_number": "NAS-6U-2026-TEST"}, expected_status=500)
|
||||
|
||||
# --- 8. FeatureLens Note Staging API ---
|
||||
run_test("Notes List Fetch", "Notes API", "GET", "/api/notes", expected_status=500)
|
||||
run_test("Note Creation Missing Title", "Notes API", "POST", "/api/notes", payload={"description": "No title provided"}, expected_status=400, is_monkey=True)
|
||||
run_test("Note Status Update Missing Field", "Notes API", "PUT", "/api/notes/fn-123/status", payload={}, expected_status=400, is_monkey=True)
|
||||
|
||||
# --- 9. Automated OS & Provisioning Script Generator ---
|
||||
run_test("Provisioning Script (8 Bays RAID-Z2)", "Config Gen", "POST", "/api/config/zfs-script",
|
||||
payload={"hostname": "test-nas", "domain": "atyg.org", "raid_level": "raidz2", "drive_bays": 8, "serial_number": "NAS-6U-2026-0099"},
|
||||
expected_status=200)
|
||||
|
||||
run_test("Provisioning Script (4 Bays Mirror)", "Config Gen", "POST", "/api/config/zfs-script",
|
||||
payload={"hostname": "edge-storage", "domain": "custom.internal", "raid_level": "mirror", "drive_bays": 4, "serial_number": "NAS-6U-2026-0044"},
|
||||
expected_status=200)
|
||||
|
||||
# --- 10. Monkey Testing & Fuzzing (Boundary & Injection Attacks) ---
|
||||
print("\n" + "="*80)
|
||||
print(" EXECUTING MONKEY & FUZZING INJECTION VECTORS")
|
||||
print("="*80 + "\n")
|
||||
|
||||
# Giant Payload (100KB)
|
||||
huge_text = "A" * 100000
|
||||
run_test("Oversized 100KB Payload", "Fuzzing", "POST", "/api/config/zfs-script",
|
||||
payload={"hostname": huge_text[:50], "domain": "atyg.org", "notes": huge_text},
|
||||
expected_status=200, is_monkey=True)
|
||||
|
||||
# SQL Injection query parameter check (Parameterized queries protect the DB)
|
||||
run_test("SQL Injection in Category Query Param", "Fuzzing", "GET", "/api/notes?category=' OR '1'='1", expected_status=500, is_monkey=True)
|
||||
|
||||
# XSS String in JSON payload
|
||||
xss_string = "<script>alert('XSS_AUDIT_EXPLOIT');</script>"
|
||||
run_test("XSS Injection in Provisioner Hostname", "Fuzzing", "POST", "/api/config/zfs-script",
|
||||
payload={"hostname": xss_string, "domain": "atyg.org"},
|
||||
expected_status=200, is_monkey=True)
|
||||
|
||||
# Unicode / Emojis / Special Characters
|
||||
emoji_payload = {"hostname": "🚀-nas-⚡-storage-💾", "domain": "🔥.atyg.org", "drive_bays": 8}
|
||||
run_test("Unicode & Emoji Field Parsing", "Fuzzing", "POST", "/api/config/zfs-script",
|
||||
payload=emoji_payload,
|
||||
expected_status=200, is_monkey=True)
|
||||
|
||||
# Cleanup
|
||||
subprocess.run(["podman", "stop", "test-audit-runner"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
with open("/tmp/audit_results.json", "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
|
||||
print("\n" + "="*80)
|
||||
total_tests = len(results)
|
||||
passed_tests = sum(1 for r in results if r["passed"])
|
||||
failed_tests = total_tests - passed_tests
|
||||
print(f" AUDIT EXECUTION SUMMARY: {passed_tests}/{total_tests} Tests Passed (Success Rate: {(passed_tests/total_tests)*100:.1f}%)")
|
||||
print("="*80 + "\n")
|
||||
@ -11,21 +11,27 @@ export interface NasConfigOptions {
|
||||
}
|
||||
|
||||
export function generateZfsProvisioningScript(opts: NasConfigOptions): string {
|
||||
const drives = Array.from({ length: opts.drive_bays }, (_, i) => `/dev/disk/by-path/pci-0000:01:00.0-sas-phy${i}-lun-0`).join(" ");
|
||||
const baysCount = Math.max(1, Math.min(8, isNaN(opts.drive_bays) ? 8 : opts.drive_bays));
|
||||
const drives = Array.from({ length: baysCount }, (_, i) => `/dev/disk/by-path/pci-0000:01:00.0-sas-phy${i}-lun-0`).join(" ");
|
||||
|
||||
const cleanSerial = (opts.serial_number || "NAS-6U-2026-0001").replace(/["$`\\]/g, "");
|
||||
const cleanHostname = (opts.hostname || "nas-core").replace(/["$`\\]/g, "");
|
||||
const cleanDomain = (opts.domain || "atyg.org").replace(/["$`\\]/g, "");
|
||||
const cleanPool = (opts.pool_name || "tank0").replace(/["$`\\]/g, "");
|
||||
|
||||
return `#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Open-Source 6U Tri-Mode Storage Array — Automated Provisioning Script
|
||||
# Chassis Serial: ${opts.serial_number}
|
||||
# Hostname: ${opts.hostname}.${opts.domain}
|
||||
# Chassis Serial: ${cleanSerial}
|
||||
# Hostname: ${cleanHostname}.${cleanDomain}
|
||||
# Target Environment: TrueNAS SCALE / Debian 12 (Kernel 6.6+ LTS)
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
echo "🚀 Provisioning 6U Tri-Mode Storage Array [${opts.serial_number}]..."
|
||||
echo "🚀 Provisioning 6U Tri-Mode Storage Array [${cleanSerial}]..."
|
||||
|
||||
# 1. System Hostname & Identity
|
||||
hostnamectl set-hostname "${opts.hostname}.${opts.domain}"
|
||||
hostnamectl set-hostname "${cleanHostname}.${cleanDomain}"
|
||||
|
||||
# 2. RP2040 OpenBMC UART Initialization
|
||||
echo "🔧 Configuring RP2040 UART Telemetry on /dev/ttyS2 (115200 baud)..."
|
||||
@ -37,7 +43,7 @@ After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/rp2040-bmc-daemon --port /dev/ttyS2 --profile ${opts.fan_profile} --supercap-plp=${opts.enable_supercap_daemon}
|
||||
ExecStart=/usr/local/bin/rp2040-bmc-daemon --port /dev/ttyS2 --profile ${opts.fan_profile || "balanced"} --supercap-plp=${opts.enable_supercap_daemon ?? true}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
@ -48,16 +54,16 @@ systemctl daemon-reload
|
||||
systemctl enable --now rp2040-bmc.service || true
|
||||
|
||||
# 3. ZFS Pool Creation & Universal U.3 Tri-Mode Allocation
|
||||
echo "💾 Initializing ZFS Pool [${opts.pool_name}] (${opts.raid_level} across ${opts.drive_bays} U.3 bays)..."
|
||||
echo "💾 Initializing ZFS Pool [${cleanPool}] (${opts.raid_level || "raidz2"} across ${baysCount} U.3 bays)..."
|
||||
zpool create -f -o ashift=12 \\
|
||||
-O compression=zstd \\
|
||||
-O atime=off \\
|
||||
-O xattr=sa \\
|
||||
-O acltype=posixacl \\
|
||||
"${opts.pool_name}" ${opts.raid_level} ${drives}
|
||||
"${cleanPool}" ${opts.raid_level || "raidz2"} ${drives}
|
||||
|
||||
# 4. Supercapacitor PLP Emergency Flush Watchdog
|
||||
if [ "${opts.enable_supercap_daemon}" = "true" ]; then
|
||||
if [ "${opts.enable_supercap_daemon ?? true}" = "true" ]; then
|
||||
echo "⚡ Enabling LTC3350 Emergency Supercapacitor Flush Watchdog..."
|
||||
cat << 'PLP_CONF' > /etc/udev/rules.d/99-ltc3350-plp.rules
|
||||
# LTC3350 PFI Power-Fail Interrupt on GPIO4_C2
|
||||
@ -66,6 +72,6 @@ PLP_CONF
|
||||
udevadm control --reload-rules || true
|
||||
fi
|
||||
|
||||
echo "✅ Provisioning Complete for [${opts.serial_number}]! Array is ONLINE."
|
||||
echo "✅ Provisioning Complete for [${cleanSerial}]! Array is ONLINE."
|
||||
`;
|
||||
}
|
||||
|
||||
@ -24,11 +24,18 @@ bomRoutes.get("/summary", async (c) => {
|
||||
|
||||
bomRoutes.put("/:id/status", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
let body: any;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON payload" }, 400);
|
||||
}
|
||||
|
||||
if (!body || !body.status) {
|
||||
return c.json({ error: "Missing required 'status' field" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
if (!body.status) {
|
||||
return c.json({ error: "Missing status field" }, 400);
|
||||
}
|
||||
const updated = await updateBomItemStatus(id, body.status);
|
||||
if (updated.length === 0) return c.json({ error: "BOM item not found" }, 404);
|
||||
return c.json(updated[0]);
|
||||
|
||||
@ -4,11 +4,20 @@ import { generateZfsProvisioningScript, NasConfigOptions } from "../db/config_ge
|
||||
export const configExportRoutes = new Hono();
|
||||
|
||||
configExportRoutes.post("/zfs-script", async (c) => {
|
||||
let body: any;
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON payload" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const rawHostname = body.hostname || "nas-core";
|
||||
const cleanFilenameHostname = rawHostname.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
|
||||
const opts: NasConfigOptions = {
|
||||
serial_number: body.serial_number || "NAS-6U-2026-0001",
|
||||
hostname: body.hostname || "nas-core",
|
||||
hostname: rawHostname,
|
||||
domain: body.domain || "atyg.org",
|
||||
pool_name: body.pool_name || "tank0",
|
||||
raid_level: body.raid_level || "raidz2",
|
||||
@ -20,8 +29,8 @@ configExportRoutes.post("/zfs-script", async (c) => {
|
||||
|
||||
const script = generateZfsProvisioningScript(opts);
|
||||
|
||||
c.header("Content-Type", "text/x-shellscript");
|
||||
c.header("Content-Disposition", `attachment; filename="${opts.hostname}-provision.sh"`);
|
||||
c.header("Content-Type", "text/x-shellscript; charset=utf-8");
|
||||
c.header("Content-Disposition", `attachment; filename="${cleanFilenameHostname}-provision.sh"`);
|
||||
return c.text(script);
|
||||
} catch (error: any) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
|
||||
@ -32,11 +32,18 @@ noteRoutes.get("/:id", async (c) => {
|
||||
});
|
||||
|
||||
noteRoutes.post("/", async (c) => {
|
||||
let body: any;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON payload" }, 400);
|
||||
}
|
||||
|
||||
if (!body || !body.title || typeof body.title !== "string" || !body.title.trim()) {
|
||||
return c.json({ error: "Missing required 'title' field" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
if (!body.title) {
|
||||
return c.json({ error: "Title is required" }, 400);
|
||||
}
|
||||
const created = await createFeatureNote(body);
|
||||
return c.json(created, 201);
|
||||
} catch (error: any) {
|
||||
@ -46,11 +53,18 @@ noteRoutes.post("/", async (c) => {
|
||||
|
||||
noteRoutes.put("/:id/status", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
let body: any;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON payload" }, 400);
|
||||
}
|
||||
|
||||
if (!body || !body.status) {
|
||||
return c.json({ error: "Missing required 'status' field" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
if (!body.status) {
|
||||
return c.json({ error: "Status is required" }, 400);
|
||||
}
|
||||
const updated = await updateFeatureNoteStatus(id, body.status);
|
||||
if (updated.length === 0) return c.json({ error: "Note not found" }, 404);
|
||||
return c.json(updated[0]);
|
||||
|
||||
@ -14,11 +14,18 @@ printFarmRoutes.get("/", async (c) => {
|
||||
|
||||
printFarmRoutes.put("/:id/status", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
let body: any;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON payload" }, 400);
|
||||
}
|
||||
|
||||
if (!body || !body.status) {
|
||||
return c.json({ error: "Missing required 'status' field" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
if (!body.status) {
|
||||
return c.json({ error: "Missing status field" }, 400);
|
||||
}
|
||||
const updated = await updatePrintJobStatus(id, body.status);
|
||||
if (updated.length === 0) return c.json({ error: "Job not found" }, 404);
|
||||
return c.json(updated[0]);
|
||||
|
||||
@ -14,11 +14,18 @@ qaRoutes.get("/", async (c) => {
|
||||
|
||||
qaRoutes.put("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
let body: any;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON payload" }, 400);
|
||||
}
|
||||
|
||||
if (!body || typeof body.is_completed !== "boolean") {
|
||||
return c.json({ error: "Invalid payload: 'is_completed' must be a boolean" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
if (typeof body.is_completed !== "boolean") {
|
||||
return c.json({ error: "Invalid payload: is_completed must be a boolean" }, 400);
|
||||
}
|
||||
const updated = await toggleQaTask(id, body.is_completed);
|
||||
if (updated.length === 0) return c.json({ error: "Task not found" }, 404);
|
||||
return c.json(updated[0]);
|
||||
|
||||
@ -24,9 +24,15 @@ travelerRoutes.get("/:serial", async (c) => {
|
||||
});
|
||||
|
||||
travelerRoutes.post("/", async (c) => {
|
||||
let body: any;
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const created = await createTraveler(body);
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON payload" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createTraveler(body || {});
|
||||
return c.json(created, 201);
|
||||
} catch (error: any) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
@ -35,16 +41,26 @@ travelerRoutes.post("/", async (c) => {
|
||||
|
||||
travelerRoutes.put("/:serial/stage", async (c) => {
|
||||
const serial = c.req.param("serial");
|
||||
let body: any;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON payload" }, 400);
|
||||
}
|
||||
|
||||
if (!body || body.stage === undefined) {
|
||||
return c.json({ error: "Missing required 'stage' field" }, 400);
|
||||
}
|
||||
|
||||
const stage = parseInt(body.stage);
|
||||
if (isNaN(stage)) {
|
||||
return c.json({ error: "'stage' must be a valid integer" }, 400);
|
||||
}
|
||||
|
||||
const status = body.status || "in_progress";
|
||||
const notes = body.notes;
|
||||
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const stage = parseInt(body.stage);
|
||||
const status = body.status || "in_progress";
|
||||
const notes = body.notes;
|
||||
|
||||
if (isNaN(stage)) {
|
||||
return c.json({ error: "Invalid stage number" }, 400);
|
||||
}
|
||||
|
||||
const updated = await updateTravelerStage(serial, stage, status, notes);
|
||||
if (updated.length === 0) return c.json({ error: "Traveler not found" }, 404);
|
||||
return c.json(updated[0]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user