diff --git a/app/(dashboard)/DashboardLayoutClient.tsx b/app/(dashboard)/DashboardLayoutClient.tsx index 5b8c3347..d3494252 100644 --- a/app/(dashboard)/DashboardLayoutClient.tsx +++ b/app/(dashboard)/DashboardLayoutClient.tsx @@ -7,6 +7,7 @@ import { useTheme } from "next-themes"; import { LayoutDashboard, ArrowLeftRight, Cable, KeyRound, ShieldCheck, ShieldOff, BarChart2, History, Settings, LogOut, Menu, Sun, Moon, + FileJson2, } from "lucide-react"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; @@ -31,6 +32,7 @@ const NAV_ITEMS = [ { href: "/waf", label: "WAF", icon: ShieldOff }, { href: "/analytics", label: "Analytics", icon: BarChart2 }, { href: "/audit-log", label: "Audit Log", icon: History }, + { href: "/api-docs", label: "API Docs", icon: FileJson2 }, { href: "/settings", label: "Settings", icon: Settings }, ] as const; diff --git a/app/(dashboard)/api-docs/ApiDocsClient.tsx b/app/(dashboard)/api-docs/ApiDocsClient.tsx new file mode 100644 index 00000000..6228cb64 --- /dev/null +++ b/app/(dashboard)/api-docs/ApiDocsClient.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +export default function ApiDocsClient() { + const containerRef = useRef(null); + const initializedRef = useRef(false); + + useEffect(() => { + if (initializedRef.current) return; + initializedRef.current = true; + + // Load Swagger UI CSS + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"; + document.head.appendChild(link); + + // Inject overrides for dark-mode compatibility + const style = document.createElement("style"); + style.textContent = ` + .dark .swagger-ui { + filter: invert(88%) hue-rotate(180deg); + } + .dark .swagger-ui .highlight-code, + .dark .swagger-ui pre { + filter: invert(100%) hue-rotate(180deg); + } + .swagger-ui .topbar { display: none; } + .swagger-ui .information-container { padding: 1rem 0; } + `; + document.head.appendChild(style); + + // Load Swagger UI bundle script + const script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"; + script.onload = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const SwaggerUIBundle = (window as any).SwaggerUIBundle; + if (SwaggerUIBundle && containerRef.current) { + SwaggerUIBundle({ + url: "/api/v1/openapi.json", + domNode: containerRef.current, + presets: [SwaggerUIBundle.presets.apis], + deepLinking: true, + defaultModelsExpandDepth: 1, + }); + } + }; + document.body.appendChild(script); + + return () => { + // Cleanup on unmount + link.remove(); + style.remove(); + script.remove(); + }; + }, []); + + return ( +
+ ); +} diff --git a/app/(dashboard)/api-docs/page.tsx b/app/(dashboard)/api-docs/page.tsx new file mode 100644 index 00000000..4b536217 --- /dev/null +++ b/app/(dashboard)/api-docs/page.tsx @@ -0,0 +1,12 @@ +import { requireUser } from "@/src/lib/auth"; +import ApiDocsClient from "./ApiDocsClient"; + +export const metadata = { + title: "API Docs", +}; + +export default async function ApiDocsPage() { + await requireUser(); + + return ; +} diff --git a/app/(dashboard)/api-tokens/actions.ts b/app/(dashboard)/api-tokens/actions.ts new file mode 100644 index 00000000..1db34605 --- /dev/null +++ b/app/(dashboard)/api-tokens/actions.ts @@ -0,0 +1,28 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireUser } from "@/src/lib/auth"; +import { createApiToken, deleteApiToken } from "@/src/lib/models/api-tokens"; + +export async function createApiTokenAction(formData: FormData): Promise<{ rawToken: string } | { error: string }> { + const session = await requireUser(); + const userId = Number(session.user.id); + const name = String(formData.get("name") ?? "").trim(); + + if (!name) { + return { error: "Name is required" }; + } + + const expiresAt = formData.get("expires_at") ? String(formData.get("expires_at")) : undefined; + + const { rawToken } = await createApiToken(name, userId, expiresAt || undefined); + revalidatePath("/profile"); + return { rawToken }; +} + +export async function deleteApiTokenAction(id: number) { + const session = await requireUser(); + const userId = Number(session.user.id); + await deleteApiToken(id, userId); + revalidatePath("/profile"); +} diff --git a/app/(dashboard)/profile/ProfileClient.tsx b/app/(dashboard)/profile/ProfileClient.tsx index f7a12043..b6dac1b6 100644 --- a/app/(dashboard)/profile/ProfileClient.tsx +++ b/app/(dashboard)/profile/ProfileClient.tsx @@ -18,7 +18,9 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { signIn } from "next-auth/react"; -import { Camera, Link, LogIn, Lock, Trash2, Unlink, User } from "lucide-react"; +import { Camera, Check, Clock, Copy, Key, Link, LogIn, Lock, Plus, Trash2, Unlink, User, AlertTriangle } from "lucide-react"; +import type { ApiToken } from "@/lib/models/api-tokens"; +import { createApiTokenAction, deleteApiTokenAction } from "../api-tokens/actions"; interface UserData { id: number; @@ -34,9 +36,10 @@ interface UserData { interface ProfileClientProps { user: UserData; enabledProviders: Array<{ id: string; name: string }>; + apiTokens: ApiToken[]; } -export default function ProfileClient({ user, enabledProviders }: ProfileClientProps) { +export default function ProfileClient({ user, enabledProviders, apiTokens }: ProfileClientProps) { const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const [unlinkDialogOpen, setUnlinkDialogOpen] = useState(false); const [currentPassword, setCurrentPassword] = useState(""); @@ -46,6 +49,8 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP const [success, setSuccess] = useState(null); const [loading, setLoading] = useState(false); const [avatarUrl, setAvatarUrl] = useState(user.avatar_url); + const [newToken, setNewToken] = useState(null); + const [copied, setCopied] = useState(false); const hasPassword = !!user.password_hash; const hasOAuth = user.provider !== "credentials"; @@ -245,6 +250,39 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP } }; + const handleCreateToken = async (formData: FormData) => { + setError(null); + setNewToken(null); + const result = await createApiTokenAction(formData); + if ("error" in result) { + setError(result.error); + } else { + setNewToken(result.rawToken); + setSuccess("API token created successfully"); + } + }; + + const handleCopyToken = () => { + if (newToken) { + navigator.clipboard.writeText(newToken); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const formatDate = (iso: string | null): string => { + if (!iso) return "Never"; + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", + }); + }; + + const isExpired = (expiresAt: string | null): boolean => { + if (!expiresAt) return false; + return new Date(expiresAt) <= new Date(); + }; + const getProviderName = (provider: string) => { if (provider === "credentials") return "Username/Password"; if (provider === "oauth2") return "OAuth2"; @@ -448,6 +486,118 @@ export default function ProfileClient({ user, enabledProviders }: ProfileClientP )} + + {/* API Tokens */} + + +
+ +

API Tokens

+
+ + + +

+ Create tokens for programmatic access to the API using Authorization: Bearer {''} +

+ + {/* Newly created token */} + {newToken && ( +
+

+ Copy this token now — it will not be shown again. +

+
+ + {newToken} + + +
+
+ )} + + {/* Existing tokens */} + {apiTokens.length > 0 && ( +
+ {apiTokens.map((token) => { + const expired = isExpired(token.expires_at); + return ( +
+
+ +
+
+

{token.name}

+ {expired && ( + + + Expired + + )} +
+
+

+ Created {formatDate(token.created_at)} +

+

+ + Used {formatDate(token.last_used_at)} +

+ {token.expires_at && ( +

+ {expired ? "Expired" : "Expires"} {formatDate(token.expires_at)} +

+ )} +
+
+
+
+ +
+
+ ); + })} +
+ )} + + {apiTokens.length === 0 && !newToken && ( +
+ + No API tokens yet — create one below. +
+ )} + + {/* Create new token */} +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
{/* Change Password Dialog */} diff --git a/app/(dashboard)/profile/page.tsx b/app/(dashboard)/profile/page.tsx index 765db370..4fe394f3 100644 --- a/app/(dashboard)/profile/page.tsx +++ b/app/(dashboard)/profile/page.tsx @@ -1,23 +1,29 @@ import { requireUser } from "@/src/lib/auth"; import { getUserById } from "@/src/lib/models/user"; import { getEnabledOAuthProviders } from "@/src/lib/config"; +import { listApiTokens } from "@/src/lib/models/api-tokens"; import ProfileClient from "./ProfileClient"; import { redirect } from "next/navigation"; export default async function ProfilePage() { const session = await requireUser(); + const userId = Number(session.user.id); - const user = await getUserById(Number(session.user.id)); + const user = await getUserById(userId); if (!user) { redirect("/login"); } - const enabledProviders = getEnabledOAuthProviders(); + const [enabledProviders, apiTokens] = await Promise.all([ + Promise.resolve(getEnabledOAuthProviders()), + listApiTokens(userId), + ]); return ( ); } diff --git a/app/api/v1/access-lists/[id]/entries/[entryId]/route.ts b/app/api/v1/access-lists/[id]/entries/[entryId]/route.ts new file mode 100644 index 00000000..97185e72 --- /dev/null +++ b/app/api/v1/access-lists/[id]/entries/[entryId]/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { removeAccessListEntry } from "@/src/lib/models/access-lists"; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; entryId: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id, entryId } = await params; + const list = await removeAccessListEntry(Number(id), Number(entryId), userId); + return NextResponse.json(list); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/access-lists/[id]/entries/route.ts b/app/api/v1/access-lists/[id]/entries/route.ts new file mode 100644 index 00000000..22f59a78 --- /dev/null +++ b/app/api/v1/access-lists/[id]/entries/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { addAccessListEntry } from "@/src/lib/models/access-lists"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + const body = await request.json(); + const list = await addAccessListEntry(Number(id), body, userId); + return NextResponse.json(list, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/access-lists/[id]/route.ts b/app/api/v1/access-lists/[id]/route.ts new file mode 100644 index 00000000..7474bdc7 --- /dev/null +++ b/app/api/v1/access-lists/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { getAccessList, updateAccessList, deleteAccessList } from "@/src/lib/models/access-lists"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireApiAdmin(request); + const { id } = await params; + const list = await getAccessList(Number(id)); + if (!list) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(list); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + const body = await request.json(); + const list = await updateAccessList(Number(id), body, userId); + return NextResponse.json(list); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + await deleteAccessList(Number(id), userId); + return NextResponse.json({ ok: true }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/access-lists/route.ts b/app/api/v1/access-lists/route.ts new file mode 100644 index 00000000..94332cd5 --- /dev/null +++ b/app/api/v1/access-lists/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { listAccessLists, createAccessList } from "@/src/lib/models/access-lists"; + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const lists = await listAccessLists(); + return NextResponse.json(lists); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function POST(request: NextRequest) { + try { + const { userId } = await requireApiAdmin(request); + const body = await request.json(); + const list = await createAccessList(body, userId); + return NextResponse.json(list, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/audit-log/route.ts b/app/api/v1/audit-log/route.ts new file mode 100644 index 00000000..3a5634ff --- /dev/null +++ b/app/api/v1/audit-log/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { listAuditEvents, countAuditEvents } from "@/src/lib/models/audit"; + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const { searchParams } = request.nextUrl; + const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10) || 1); + const perPage = Math.min(200, Math.max(1, parseInt(searchParams.get("per_page") ?? "50", 10) || 50)); + const search = searchParams.get("search")?.trim() || undefined; + const offset = (page - 1) * perPage; + + const [events, total] = await Promise.all([ + listAuditEvents(perPage, offset, search), + countAuditEvents(search), + ]); + + return NextResponse.json({ events, total, page, perPage }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/ca-certificates/[id]/route.ts b/app/api/v1/ca-certificates/[id]/route.ts new file mode 100644 index 00000000..d8269867 --- /dev/null +++ b/app/api/v1/ca-certificates/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { getCaCertificate, updateCaCertificate, deleteCaCertificate } from "@/src/lib/models/ca-certificates"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireApiAdmin(request); + const { id } = await params; + const cert = await getCaCertificate(Number(id)); + if (!cert) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(cert); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + const body = await request.json(); + const cert = await updateCaCertificate(Number(id), body, userId); + return NextResponse.json(cert); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + await deleteCaCertificate(Number(id), userId); + return NextResponse.json({ ok: true }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/ca-certificates/route.ts b/app/api/v1/ca-certificates/route.ts new file mode 100644 index 00000000..3469296e --- /dev/null +++ b/app/api/v1/ca-certificates/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { listCaCertificates, createCaCertificate } from "@/src/lib/models/ca-certificates"; + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const certs = await listCaCertificates(); + return NextResponse.json(certs); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function POST(request: NextRequest) { + try { + const { userId } = await requireApiAdmin(request); + const body = await request.json(); + const cert = await createCaCertificate(body, userId); + return NextResponse.json(cert, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/caddy/apply/route.ts b/app/api/v1/caddy/apply/route.ts new file mode 100644 index 00000000..11ffb8b6 --- /dev/null +++ b/app/api/v1/caddy/apply/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { applyCaddyConfig } from "@/src/lib/caddy"; + +export async function POST(request: NextRequest) { + try { + await requireApiAdmin(request); + await applyCaddyConfig(); + return NextResponse.json({ ok: true }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/certificates/[id]/route.ts b/app/api/v1/certificates/[id]/route.ts new file mode 100644 index 00000000..e9cb875d --- /dev/null +++ b/app/api/v1/certificates/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { getCertificate, updateCertificate, deleteCertificate } from "@/src/lib/models/certificates"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireApiAdmin(request); + const { id } = await params; + const cert = await getCertificate(Number(id)); + if (!cert) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(cert); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + const body = await request.json(); + const cert = await updateCertificate(Number(id), body, userId); + return NextResponse.json(cert); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + await deleteCertificate(Number(id), userId); + return NextResponse.json({ ok: true }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/certificates/route.ts b/app/api/v1/certificates/route.ts new file mode 100644 index 00000000..30100ece --- /dev/null +++ b/app/api/v1/certificates/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { listCertificates, createCertificate } from "@/src/lib/models/certificates"; + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const certs = await listCertificates(); + return NextResponse.json(certs); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function POST(request: NextRequest) { + try { + const { userId } = await requireApiAdmin(request); + const body = await request.json(); + const cert = await createCertificate(body, userId); + return NextResponse.json(cert, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/client-certificates/[id]/route.ts b/app/api/v1/client-certificates/[id]/route.ts new file mode 100644 index 00000000..bfafe500 --- /dev/null +++ b/app/api/v1/client-certificates/[id]/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { getIssuedClientCertificate, revokeIssuedClientCertificate } from "@/src/lib/models/issued-client-certificates"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireApiAdmin(request); + const { id } = await params; + const cert = await getIssuedClientCertificate(Number(id)); + if (!cert) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(cert); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + const cert = await revokeIssuedClientCertificate(Number(id), userId); + return NextResponse.json(cert); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/client-certificates/route.ts b/app/api/v1/client-certificates/route.ts new file mode 100644 index 00000000..1e925b9f --- /dev/null +++ b/app/api/v1/client-certificates/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { listIssuedClientCertificates, createIssuedClientCertificate } from "@/src/lib/models/issued-client-certificates"; + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const certs = await listIssuedClientCertificates(); + return NextResponse.json(certs); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function POST(request: NextRequest) { + try { + const { userId } = await requireApiAdmin(request); + const body = await request.json(); + const cert = await createIssuedClientCertificate(body, userId); + return NextResponse.json(cert, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/instances/[id]/route.ts b/app/api/v1/instances/[id]/route.ts new file mode 100644 index 00000000..993e9921 --- /dev/null +++ b/app/api/v1/instances/[id]/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { deleteInstance } from "@/src/lib/models/instances"; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireApiAdmin(request); + const { id } = await params; + await deleteInstance(Number(id)); + return NextResponse.json({ ok: true }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/instances/route.ts b/app/api/v1/instances/route.ts new file mode 100644 index 00000000..4575f91c --- /dev/null +++ b/app/api/v1/instances/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { listInstances, createInstance } from "@/src/lib/models/instances"; + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const instances = await listInstances(); + return NextResponse.json(instances); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function POST(request: NextRequest) { + try { + await requireApiAdmin(request); + const body = await request.json(); + const instance = await createInstance(body); + return NextResponse.json(instance, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/instances/sync/route.ts b/app/api/v1/instances/sync/route.ts new file mode 100644 index 00000000..14753c39 --- /dev/null +++ b/app/api/v1/instances/sync/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { syncInstances } from "@/src/lib/instance-sync"; + +export async function POST(request: NextRequest) { + try { + await requireApiAdmin(request); + const result = await syncInstances(); + return NextResponse.json(result); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/l4-proxy-hosts/[id]/route.ts b/app/api/v1/l4-proxy-hosts/[id]/route.ts new file mode 100644 index 00000000..f7e66d4f --- /dev/null +++ b/app/api/v1/l4-proxy-hosts/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { getL4ProxyHost, updateL4ProxyHost, deleteL4ProxyHost } from "@/src/lib/models/l4-proxy-hosts"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireApiAdmin(request); + const { id } = await params; + const host = await getL4ProxyHost(Number(id)); + if (!host) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(host); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + const body = await request.json(); + const host = await updateL4ProxyHost(Number(id), body, userId); + return NextResponse.json(host); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + await deleteL4ProxyHost(Number(id), userId); + return NextResponse.json({ ok: true }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/l4-proxy-hosts/route.ts b/app/api/v1/l4-proxy-hosts/route.ts new file mode 100644 index 00000000..6d4f44b2 --- /dev/null +++ b/app/api/v1/l4-proxy-hosts/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { listL4ProxyHosts, createL4ProxyHost } from "@/src/lib/models/l4-proxy-hosts"; + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const hosts = await listL4ProxyHosts(); + return NextResponse.json(hosts); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function POST(request: NextRequest) { + try { + const { userId } = await requireApiAdmin(request); + const body = await request.json(); + const host = await createL4ProxyHost(body, userId); + return NextResponse.json(host, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/openapi.json/route.ts b/app/api/v1/openapi.json/route.ts new file mode 100644 index 00000000..f172c09f --- /dev/null +++ b/app/api/v1/openapi.json/route.ts @@ -0,0 +1,1766 @@ +import { NextResponse } from "next/server"; + +const spec = { + openapi: "3.1.0", + info: { + title: "Caddy Proxy Manager API", + version: "1.0.0", + description: + "REST API for managing Caddy reverse proxy configurations, certificates, access lists, and more.", + }, + servers: [{ url: "/" }], + security: [{ bearerAuth: [] }, { sessionAuth: [] }], + tags: [ + { name: "Tokens", description: "API token management" }, + { name: "Proxy Hosts", description: "HTTP/HTTPS reverse proxy hosts" }, + { name: "L4 Proxy Hosts", description: "Layer 4 (TCP/UDP) proxy hosts" }, + { name: "Certificates", description: "TLS certificate management" }, + { name: "CA Certificates", description: "Certificate Authority certificates" }, + { name: "Client Certificates", description: "Client certificate management" }, + { name: "Access Lists", description: "HTTP basic-auth access lists" }, + { name: "Settings", description: "Application settings" }, + { name: "Instances", description: "Multi-instance management" }, + { name: "Users", description: "User management" }, + { name: "Audit Log", description: "Audit log" }, + { name: "Caddy", description: "Caddy server operations" }, + ], + paths: { + // ── Tokens ────────────────────────────────────────────────────── + "/api/v1/tokens": { + get: { + tags: ["Tokens"], + summary: "List tokens", + operationId: "listTokens", + responses: { + "200": { + description: "List of tokens", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Token" }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + post: { + tags: ["Tokens"], + summary: "Create a token", + operationId: "createToken", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/TokenInput" }, + }, + }, + }, + responses: { + "201": { + description: "Token created", + content: { + "application/json": { + schema: { + type: "object", + properties: { + token: { $ref: "#/components/schemas/Token" }, + raw_token: { + type: "string", + description: + "Plain-text token value. Only returned at creation time.", + }, + }, + required: ["token", "raw_token"], + }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/v1/tokens/{id}": { + delete: { + tags: ["Tokens"], + summary: "Delete a token", + operationId: "deleteToken", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + + // ── Proxy Hosts ───────────────────────────────────────────────── + "/api/v1/proxy-hosts": { + get: { + tags: ["Proxy Hosts"], + summary: "List proxy hosts", + operationId: "listProxyHosts", + responses: { + "200": { + description: "List of proxy hosts", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/ProxyHost" }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + post: { + tags: ["Proxy Hosts"], + summary: "Create a proxy host", + operationId: "createProxyHost", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ProxyHostInput" }, + }, + }, + }, + responses: { + "201": { + description: "Proxy host created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ProxyHost" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/v1/proxy-hosts/{id}": { + get: { + tags: ["Proxy Hosts"], + summary: "Get a proxy host", + operationId: "getProxyHost", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { + description: "Proxy host", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ProxyHost" }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + put: { + tags: ["Proxy Hosts"], + summary: "Update a proxy host", + operationId: "updateProxyHost", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ProxyHostInput" }, + }, + }, + }, + responses: { + "200": { + description: "Proxy host updated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ProxyHost" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + delete: { + tags: ["Proxy Hosts"], + summary: "Delete a proxy host", + operationId: "deleteProxyHost", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + + // ── L4 Proxy Hosts ────────────────────────────────────────────── + "/api/v1/l4-proxy-hosts": { + get: { + tags: ["L4 Proxy Hosts"], + summary: "List L4 proxy hosts", + operationId: "listL4ProxyHosts", + responses: { + "200": { + description: "List of L4 proxy hosts", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/L4ProxyHost" }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + post: { + tags: ["L4 Proxy Hosts"], + summary: "Create an L4 proxy host", + operationId: "createL4ProxyHost", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/L4ProxyHostInput" }, + }, + }, + }, + responses: { + "201": { + description: "L4 proxy host created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/L4ProxyHost" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/v1/l4-proxy-hosts/{id}": { + get: { + tags: ["L4 Proxy Hosts"], + summary: "Get an L4 proxy host", + operationId: "getL4ProxyHost", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { + description: "L4 proxy host", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/L4ProxyHost" }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + put: { + tags: ["L4 Proxy Hosts"], + summary: "Update an L4 proxy host", + operationId: "updateL4ProxyHost", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/L4ProxyHostInput" }, + }, + }, + }, + responses: { + "200": { + description: "L4 proxy host updated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/L4ProxyHost" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + delete: { + tags: ["L4 Proxy Hosts"], + summary: "Delete an L4 proxy host", + operationId: "deleteL4ProxyHost", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + + // ── Certificates ──────────────────────────────────────────────── + "/api/v1/certificates": { + get: { + tags: ["Certificates"], + summary: "List certificates", + operationId: "listCertificates", + responses: { + "200": { + description: "List of certificates", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Certificate" }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + post: { + tags: ["Certificates"], + summary: "Create a certificate", + operationId: "createCertificate", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CertificateInput" }, + }, + }, + }, + responses: { + "201": { + description: "Certificate created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Certificate" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/v1/certificates/{id}": { + get: { + tags: ["Certificates"], + summary: "Get a certificate", + operationId: "getCertificate", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { + description: "Certificate", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Certificate" }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + put: { + tags: ["Certificates"], + summary: "Update a certificate", + operationId: "updateCertificate", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CertificateInput" }, + }, + }, + }, + responses: { + "200": { + description: "Certificate updated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Certificate" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + delete: { + tags: ["Certificates"], + summary: "Delete a certificate", + operationId: "deleteCertificate", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + + // ── CA Certificates ───────────────────────────────────────────── + "/api/v1/ca-certificates": { + get: { + tags: ["CA Certificates"], + summary: "List CA certificates", + operationId: "listCaCertificates", + responses: { + "200": { + description: "List of CA certificates", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/CaCertificate" }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + post: { + tags: ["CA Certificates"], + summary: "Create a CA certificate", + operationId: "createCaCertificate", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CaCertificateInput" }, + }, + }, + }, + responses: { + "201": { + description: "CA certificate created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CaCertificate" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/v1/ca-certificates/{id}": { + get: { + tags: ["CA Certificates"], + summary: "Get a CA certificate", + operationId: "getCaCertificate", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { + description: "CA certificate", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CaCertificate" }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + put: { + tags: ["CA Certificates"], + summary: "Update a CA certificate", + operationId: "updateCaCertificate", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CaCertificateInput" }, + }, + }, + }, + responses: { + "200": { + description: "CA certificate updated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CaCertificate" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + delete: { + tags: ["CA Certificates"], + summary: "Delete a CA certificate", + operationId: "deleteCaCertificate", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + + // ── Client Certificates ───────────────────────────────────────── + "/api/v1/client-certificates": { + get: { + tags: ["Client Certificates"], + summary: "List client certificates", + operationId: "listClientCertificates", + responses: { + "200": { + description: "List of client certificates", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/ClientCertificate" }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + post: { + tags: ["Client Certificates"], + summary: "Create a client certificate", + operationId: "createClientCertificate", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ClientCertificateInput" }, + }, + }, + }, + responses: { + "201": { + description: "Client certificate created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ClientCertificate" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/v1/client-certificates/{id}": { + get: { + tags: ["Client Certificates"], + summary: "Get a client certificate", + operationId: "getClientCertificate", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { + description: "Client certificate", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ClientCertificate" }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + delete: { + tags: ["Client Certificates"], + summary: "Revoke a client certificate", + operationId: "revokeClientCertificate", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + + // ── Access Lists ──────────────────────────────────────────────── + "/api/v1/access-lists": { + get: { + tags: ["Access Lists"], + summary: "List access lists", + operationId: "listAccessLists", + responses: { + "200": { + description: "List of access lists", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/AccessList" }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + post: { + tags: ["Access Lists"], + summary: "Create an access list", + operationId: "createAccessList", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AccessListInput" }, + }, + }, + }, + responses: { + "201": { + description: "Access list created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AccessList" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/v1/access-lists/{id}": { + get: { + tags: ["Access Lists"], + summary: "Get an access list", + operationId: "getAccessList", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { + description: "Access list", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AccessList" }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + put: { + tags: ["Access Lists"], + summary: "Update an access list", + operationId: "updateAccessList", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AccessListInput" }, + }, + }, + }, + responses: { + "200": { + description: "Access list updated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AccessList" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + delete: { + tags: ["Access Lists"], + summary: "Delete an access list", + operationId: "deleteAccessList", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + "/api/v1/access-lists/{id}/entries": { + post: { + tags: ["Access Lists"], + summary: "Add an entry to an access list", + operationId: "addAccessListEntry", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + username: { type: "string" }, + password: { type: "string" }, + }, + required: ["username", "password"], + }, + }, + }, + }, + responses: { + "201": { + description: "Entry added", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AccessListEntry" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + "/api/v1/access-lists/{id}/entries/{entryId}": { + delete: { + tags: ["Access Lists"], + summary: "Remove an entry from an access list", + operationId: "removeAccessListEntry", + parameters: [ + { $ref: "#/components/parameters/IdPath" }, + { + name: "entryId", + in: "path", + required: true, + schema: { type: "integer" }, + description: "Entry ID", + }, + ], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + + // ── Settings ──────────────────────────────────────────────────── + "/api/v1/settings/{group}": { + get: { + tags: ["Settings"], + summary: "Get settings for a group", + operationId: "getSettings", + parameters: [ + { + name: "group", + in: "path", + required: true, + schema: { + type: "string", + enum: [ + "general", + "cloudflare", + "authentik", + "metrics", + "logging", + "dns", + "upstream-dns", + "geoblock", + "waf", + "instance-mode", + "sync-token", + ], + }, + description: "Settings group name", + }, + ], + responses: { + "200": { + description: "Settings object (shape varies by group). For instance-mode: `{mode}`. For sync-token: `{has_token}`.", + content: { + "application/json": { + schema: { + oneOf: [ + { $ref: "#/components/schemas/GeneralSettings" }, + { $ref: "#/components/schemas/CloudflareSettings" }, + { $ref: "#/components/schemas/AuthentikSettings" }, + { $ref: "#/components/schemas/MetricsSettings" }, + { $ref: "#/components/schemas/LoggingSettings" }, + { $ref: "#/components/schemas/DnsSettings" }, + { $ref: "#/components/schemas/UpstreamDnsSettings" }, + { $ref: "#/components/schemas/GeoBlockConfig" }, + { $ref: "#/components/schemas/WafSettings" }, + ], + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + put: { + tags: ["Settings"], + summary: "Update settings for a group", + operationId: "updateSettings", + parameters: [ + { + name: "group", + in: "path", + required: true, + schema: { + type: "string", + enum: [ + "general", + "cloudflare", + "authentik", + "metrics", + "logging", + "dns", + "upstream-dns", + "geoblock", + "waf", + "instance-mode", + "sync-token", + ], + }, + description: "Settings group name", + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + oneOf: [ + { $ref: "#/components/schemas/GeneralSettings" }, + { $ref: "#/components/schemas/CloudflareSettings" }, + { $ref: "#/components/schemas/AuthentikSettings" }, + { $ref: "#/components/schemas/MetricsSettings" }, + { $ref: "#/components/schemas/LoggingSettings" }, + { $ref: "#/components/schemas/DnsSettings" }, + { $ref: "#/components/schemas/UpstreamDnsSettings" }, + { $ref: "#/components/schemas/GeoBlockConfig" }, + { $ref: "#/components/schemas/WafSettings" }, + ], + }, + }, + }, + }, + responses: { + "200": { + description: "Settings updated", + content: { + "application/json": { schema: { $ref: "#/components/responses/Ok" } }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + + // ── Instances ─────────────────────────────────────────────────── + "/api/v1/instances": { + get: { + tags: ["Instances"], + summary: "List instances", + operationId: "listInstances", + responses: { + "200": { + description: "List of instances", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Instance" }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + post: { + tags: ["Instances"], + summary: "Create an instance", + operationId: "createInstance", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/InstanceInput" }, + }, + }, + }, + responses: { + "201": { + description: "Instance created", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Instance" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/v1/instances/{id}": { + delete: { + tags: ["Instances"], + summary: "Delete an instance", + operationId: "deleteInstance", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + "/api/v1/instances/sync": { + post: { + tags: ["Instances"], + summary: "Trigger instance sync", + operationId: "syncInstances", + responses: { + "200": { + description: "Sync result", + content: { + "application/json": { + schema: { + type: "object", + properties: { + total: { type: "integer" }, + success: { type: "integer" }, + failed: { type: "integer" }, + skippedHttp: { type: "integer" }, + }, + required: ["total", "success", "failed", "skippedHttp"], + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + + // ── Users ─────────────────────────────────────────────────────── + "/api/v1/users": { + get: { + tags: ["Users"], + summary: "List users", + operationId: "listUsers", + responses: { + "200": { + description: "List of users", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/User" }, + }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + "/api/v1/users/{id}": { + get: { + tags: ["Users"], + summary: "Get a user", + operationId: "getUser", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + responses: { + "200": { + description: "User", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/User" }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + put: { + tags: ["Users"], + summary: "Update a user", + operationId: "updateUser", + parameters: [{ $ref: "#/components/parameters/IdPath" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: ["string", "null"] }, + email: { type: "string" }, + role: { type: "string", enum: ["admin", "user"] }, + status: { type: "string", enum: ["active", "disabled"] }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "User updated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/User" }, + }, + }, + }, + "400": { $ref: "#/components/responses/BadRequest" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "403": { $ref: "#/components/responses/Forbidden" }, + "404": { $ref: "#/components/responses/NotFound" }, + }, + }, + }, + + // ── Audit Log ─────────────────────────────────────────────────── + "/api/v1/audit-log": { + get: { + tags: ["Audit Log"], + summary: "List audit log events", + operationId: "listAuditLog", + parameters: [ + { + name: "page", + in: "query", + schema: { type: "integer", default: 1 }, + description: "Page number", + }, + { + name: "per_page", + in: "query", + schema: { type: "integer", default: 50 }, + description: "Items per page", + }, + { + name: "search", + in: "query", + schema: { type: "string" }, + description: "Search term", + }, + ], + responses: { + "200": { + description: "Paginated audit log", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AuditLogResponse" }, + }, + }, + }, + "401": { $ref: "#/components/responses/Unauthorized" }, + }, + }, + }, + + // ── Caddy ─────────────────────────────────────────────────────── + "/api/v1/caddy/apply": { + post: { + tags: ["Caddy"], + summary: "Apply Caddy configuration", + operationId: "applyCaddyConfig", + responses: { + "200": { $ref: "#/components/responses/Ok" }, + "401": { $ref: "#/components/responses/Unauthorized" }, + "500": { $ref: "#/components/responses/InternalError" }, + }, + }, + }, + }, + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + description: "API token created from the Profile page", + }, + sessionAuth: { + type: "apiKey", + in: "cookie", + name: "authjs.session-token", + description: "Cookie-based session from browser login", + }, + }, + parameters: { + IdPath: { + name: "id", + in: "path", + required: true, + schema: { type: "integer" }, + description: "Resource ID", + }, + }, + responses: { + Ok: { + description: "Success", + content: { + "application/json": { + schema: { + type: "object", + properties: { ok: { type: "boolean", enum: [true] } }, + required: ["ok"], + }, + }, + }, + }, + BadRequest: { + description: "Bad request", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + Unauthorized: { + description: "Unauthorized", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + Forbidden: { + description: "Forbidden", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + NotFound: { + description: "Not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + InternalError: { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, + schemas: { + Error: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + Token: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + created_by: { type: "integer" }, + created_at: { type: "string", format: "date-time" }, + last_used_at: { type: ["string", "null"], format: "date-time" }, + expires_at: { type: ["string", "null"], format: "date-time" }, + }, + required: ["id", "name", "created_by", "created_at"], + }, + TokenInput: { + type: "object", + properties: { + name: { type: "string", example: "CI/CD Pipeline" }, + expires_at: { type: "string", format: "date-time", description: "Optional expiration date" }, + }, + required: ["name"], + }, + + // ── Shared sub-schemas ────────────────────────────────────── + AuthentikConfig: { + type: "object", + description: "Authentik SSO forward-auth configuration", + properties: { + enabled: { type: "boolean" }, + outpostDomain: { type: ["string", "null"], example: "auth.example.com" }, + outpostUpstream: { type: ["string", "null"], example: "http://authentik:9000" }, + authEndpoint: { type: ["string", "null"] }, + copyHeaders: { type: "array", items: { type: "string" }, description: "Headers to copy from Authentik response" }, + trustedProxies: { type: "array", items: { type: "string" }, example: ["private_ranges"] }, + setOutpostHostHeader: { type: "boolean" }, + protectedPaths: { type: ["array", "null"], items: { type: "string" }, description: "Paths to protect (null = all)" }, + }, + }, + LoadBalancerConfig: { + type: "object", + description: "Load balancing configuration for multiple upstreams", + properties: { + enabled: { type: "boolean" }, + policy: { type: "string", enum: ["random", "round_robin", "least_conn", "ip_hash", "first", "header", "cookie", "uri_hash"] }, + policyHeaderField: { type: ["string", "null"], description: "Header name for 'header' policy" }, + policyCookieName: { type: ["string", "null"], description: "Cookie name for 'cookie' policy" }, + policyCookieSecret: { type: ["string", "null"] }, + tryDuration: { type: ["string", "null"], example: "5s" }, + tryInterval: { type: ["string", "null"], example: "250ms" }, + retries: { type: ["integer", "null"] }, + activeHealthCheck: { + type: ["object", "null"], + properties: { + enabled: { type: "boolean" }, + uri: { type: ["string", "null"], example: "/health" }, + port: { type: ["integer", "null"] }, + interval: { type: ["string", "null"], example: "30s" }, + timeout: { type: ["string", "null"], example: "5s" }, + status: { type: ["integer", "null"], example: 200 }, + body: { type: ["string", "null"] }, + }, + }, + passiveHealthCheck: { + type: ["object", "null"], + properties: { + enabled: { type: "boolean" }, + failDuration: { type: ["string", "null"], example: "30s" }, + maxFails: { type: ["integer", "null"], example: 3 }, + unhealthyStatus: { type: ["array", "null"], items: { type: "integer" } }, + unhealthyLatency: { type: ["string", "null"], example: "5s" }, + }, + }, + }, + }, + L4LoadBalancerConfig: { + type: "object", + description: "L4 load balancing configuration", + properties: { + enabled: { type: "boolean" }, + policy: { type: "string", enum: ["random", "round_robin", "least_conn", "ip_hash", "first"] }, + tryDuration: { type: ["string", "null"] }, + tryInterval: { type: ["string", "null"] }, + retries: { type: ["integer", "null"] }, + activeHealthCheck: { + type: ["object", "null"], + properties: { + enabled: { type: "boolean" }, + port: { type: ["integer", "null"] }, + interval: { type: ["string", "null"] }, + timeout: { type: ["string", "null"] }, + }, + }, + passiveHealthCheck: { + type: ["object", "null"], + properties: { + enabled: { type: "boolean" }, + failDuration: { type: ["string", "null"] }, + maxFails: { type: ["integer", "null"] }, + unhealthyLatency: { type: ["string", "null"] }, + }, + }, + }, + }, + DnsResolverConfig: { + type: "object", + description: "Custom DNS resolver for upstream resolution", + properties: { + enabled: { type: "boolean" }, + resolvers: { type: "array", items: { type: "string" }, example: ["1.1.1.1", "8.8.8.8"] }, + fallbacks: { type: ["array", "null"], items: { type: "string" } }, + timeout: { type: ["string", "null"], example: "5s" }, + }, + }, + UpstreamDnsResolutionConfig: { + type: "object", + description: "Upstream DNS address family preference", + properties: { + enabled: { type: ["boolean", "null"] }, + family: { type: ["string", "null"], enum: ["ipv4", "ipv6", "both", null] }, + }, + }, + GeoBlockConfig: { + type: "object", + description: "Geographic/network-based access control", + properties: { + enabled: { type: "boolean" }, + block_countries: { type: "array", items: { type: "string" }, example: ["CN", "RU"], description: "ISO 3166-1 alpha-2 codes" }, + block_continents: { type: "array", items: { type: "string" }, example: ["AS"], description: "AF, AN, AS, EU, NA, OC, SA" }, + block_asns: { type: "array", items: { type: "integer" } }, + block_cidrs: { type: "array", items: { type: "string" }, example: ["10.0.0.0/8"] }, + block_ips: { type: "array", items: { type: "string" } }, + allow_countries: { type: "array", items: { type: "string" } }, + allow_continents: { type: "array", items: { type: "string" } }, + allow_asns: { type: "array", items: { type: "integer" } }, + allow_cidrs: { type: "array", items: { type: "string" } }, + allow_ips: { type: "array", items: { type: "string" } }, + trusted_proxies: { type: "array", items: { type: "string" }, description: "Trusted proxy CIDRs for X-Forwarded-For" }, + fail_closed: { type: "boolean", description: "Block when client IP cannot be determined" }, + response_status: { type: "integer", example: 403 }, + response_body: { type: "string", example: "Forbidden" }, + response_headers: { type: "object", additionalProperties: { type: "string" } }, + redirect_url: { type: "string", description: "If set, 302 redirect instead of status/body" }, + }, + }, + WafConfig: { + type: "object", + description: "Web Application Firewall configuration", + properties: { + enabled: { type: "boolean" }, + mode: { type: "string", enum: ["Off", "On"] }, + load_owasp_crs: { type: "boolean", description: "Load OWASP Core Rule Set" }, + custom_directives: { type: "string", description: "Custom WAF directives" }, + excluded_rule_ids: { type: "array", items: { type: "integer" }, description: "Rule IDs to exclude" }, + waf_mode: { type: "string", enum: ["merge", "override"], description: "How per-host WAF merges with global" }, + }, + }, + MtlsConfig: { + type: "object", + description: "Mutual TLS (client certificate) configuration", + properties: { + enabled: { type: "boolean" }, + ca_certificate_ids: { type: "array", items: { type: "integer" }, description: "CA certificate IDs to trust" }, + }, + }, + RedirectRule: { + type: "object", + description: "HTTP redirect rule", + properties: { + from: { type: "string", example: "/.well-known/carddav", description: "Path pattern to match" }, + to: { type: "string", example: "/remote.php/dav/", description: "Redirect destination" }, + status: { type: "integer", enum: [301, 302, 307, 308], example: 301 }, + }, + required: ["from", "to", "status"], + }, + RewriteConfig: { + type: "object", + description: "Path rewrite (strip prefix)", + properties: { + path_prefix: { type: "string", example: "/app", description: "Prefix to strip from request path" }, + }, + required: ["path_prefix"], + }, + + // ── Main resource schemas ─────────────────────────────────── + ProxyHost: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + domains: { type: "array", items: { type: "string" }, example: ["example.com", "www.example.com"] }, + upstreams: { type: "array", items: { type: "string" }, example: ["localhost:8080"] }, + certificate_id: { type: ["integer", "null"] }, + access_list_id: { type: ["integer", "null"] }, + ssl_forced: { type: "boolean" }, + hsts_enabled: { type: "boolean" }, + hsts_subdomains: { type: "boolean" }, + allow_websocket: { type: "boolean" }, + preserve_host_header: { type: "boolean" }, + skip_https_hostname_validation: { type: "boolean" }, + enabled: { type: "boolean" }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + custom_reverse_proxy_json: { type: ["string", "null"], description: "Raw Caddy JSON for reverse_proxy handler" }, + custom_pre_handlers_json: { type: ["string", "null"], description: "Raw Caddy JSON for handlers before reverse_proxy" }, + authentik: { oneOf: [{ $ref: "#/components/schemas/AuthentikConfig" }, { type: "null" }] }, + load_balancer: { oneOf: [{ $ref: "#/components/schemas/LoadBalancerConfig" }, { type: "null" }] }, + dns_resolver: { oneOf: [{ $ref: "#/components/schemas/DnsResolverConfig" }, { type: "null" }] }, + upstream_dns_resolution: { oneOf: [{ $ref: "#/components/schemas/UpstreamDnsResolutionConfig" }, { type: "null" }] }, + geoblock: { oneOf: [{ $ref: "#/components/schemas/GeoBlockConfig" }, { type: "null" }] }, + geoblock_mode: { type: "string", enum: ["merge", "override"], description: "How per-host geoblock merges with global" }, + waf: { oneOf: [{ $ref: "#/components/schemas/WafConfig" }, { type: "null" }] }, + mtls: { oneOf: [{ $ref: "#/components/schemas/MtlsConfig" }, { type: "null" }] }, + redirects: { type: "array", items: { $ref: "#/components/schemas/RedirectRule" } }, + rewrite: { oneOf: [{ $ref: "#/components/schemas/RewriteConfig" }, { type: "null" }] }, + }, + required: ["id", "name", "domains", "upstreams", "enabled", "created_at", "updated_at"], + }, + ProxyHostInput: { + type: "object", + properties: { + name: { type: "string", example: "My App" }, + domains: { type: "array", items: { type: "string" }, example: ["app.example.com"] }, + upstreams: { type: "array", items: { type: "string" }, example: ["localhost:3000"] }, + certificate_id: { type: ["integer", "null"] }, + access_list_id: { type: ["integer", "null"] }, + ssl_forced: { type: "boolean" }, + hsts_enabled: { type: "boolean" }, + hsts_subdomains: { type: "boolean" }, + allow_websocket: { type: "boolean" }, + preserve_host_header: { type: "boolean" }, + skip_https_hostname_validation: { type: "boolean" }, + enabled: { type: "boolean" }, + custom_reverse_proxy_json: { type: ["string", "null"] }, + custom_pre_handlers_json: { type: ["string", "null"] }, + authentik: { oneOf: [{ $ref: "#/components/schemas/AuthentikConfig" }, { type: "null" }] }, + load_balancer: { oneOf: [{ $ref: "#/components/schemas/LoadBalancerConfig" }, { type: "null" }] }, + dns_resolver: { oneOf: [{ $ref: "#/components/schemas/DnsResolverConfig" }, { type: "null" }] }, + upstream_dns_resolution: { oneOf: [{ $ref: "#/components/schemas/UpstreamDnsResolutionConfig" }, { type: "null" }] }, + geoblock: { oneOf: [{ $ref: "#/components/schemas/GeoBlockConfig" }, { type: "null" }] }, + geoblock_mode: { type: "string", enum: ["merge", "override"] }, + waf: { oneOf: [{ $ref: "#/components/schemas/WafConfig" }, { type: "null" }] }, + mtls: { oneOf: [{ $ref: "#/components/schemas/MtlsConfig" }, { type: "null" }] }, + redirects: { type: "array", items: { $ref: "#/components/schemas/RedirectRule" } }, + rewrite: { oneOf: [{ $ref: "#/components/schemas/RewriteConfig" }, { type: "null" }] }, + }, + required: ["name", "domains", "upstreams"], + }, + L4ProxyHost: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + listen_addresses: { type: "array", items: { type: "string" }, example: [":5432"] }, + matchers: { type: "array", items: { type: "string" } }, + upstreams: { type: "array", items: { type: "string" }, example: ["db-server:5432"] }, + protocol: { type: "string", enum: ["tcp", "udp"] }, + matcher_type: { type: "string", enum: ["none", "tls_sni", "http_host", "proxy_protocol"] }, + tls_termination: { type: "boolean" }, + proxy_protocol_version: { type: ["string", "null"], enum: ["v1", "v2", null] }, + enabled: { type: "boolean" }, + load_balancer: { oneOf: [{ $ref: "#/components/schemas/L4LoadBalancerConfig" }, { type: "null" }] }, + dns_resolver: { oneOf: [{ $ref: "#/components/schemas/DnsResolverConfig" }, { type: "null" }] }, + upstream_dns_resolution: { oneOf: [{ $ref: "#/components/schemas/UpstreamDnsResolutionConfig" }, { type: "null" }] }, + geoblock: { oneOf: [{ $ref: "#/components/schemas/GeoBlockConfig" }, { type: "null" }] }, + geoblock_mode: { type: "string", enum: ["merge", "override"] }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + }, + required: ["id", "name", "listen_addresses", "upstreams", "protocol", "enabled", "created_at", "updated_at"], + }, + L4ProxyHostInput: { + type: "object", + properties: { + name: { type: "string", example: "PostgreSQL Proxy" }, + listen_addresses: { type: "array", items: { type: "string" }, example: [":5432"] }, + matchers: { type: "array", items: { type: "string" } }, + upstreams: { type: "array", items: { type: "string" }, example: ["db:5432"] }, + protocol: { type: "string", enum: ["tcp", "udp"] }, + matcher_type: { type: "string", enum: ["none", "tls_sni", "http_host", "proxy_protocol"] }, + tls_termination: { type: "boolean" }, + proxy_protocol_version: { type: ["string", "null"], enum: ["v1", "v2", null] }, + enabled: { type: "boolean" }, + load_balancer: { oneOf: [{ $ref: "#/components/schemas/L4LoadBalancerConfig" }, { type: "null" }] }, + dns_resolver: { oneOf: [{ $ref: "#/components/schemas/DnsResolverConfig" }, { type: "null" }] }, + upstream_dns_resolution: { oneOf: [{ $ref: "#/components/schemas/UpstreamDnsResolutionConfig" }, { type: "null" }] }, + geoblock: { oneOf: [{ $ref: "#/components/schemas/GeoBlockConfig" }, { type: "null" }] }, + geoblock_mode: { type: "string", enum: ["merge", "override"] }, + }, + required: ["name", "listen_addresses", "upstreams", "protocol"], + }, + Certificate: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + type: { type: "string", enum: ["managed", "imported"] }, + domain_names: { type: "array", items: { type: "string" }, example: ["example.com", "*.example.com"] }, + auto_renew: { type: "boolean" }, + provider_options: { + type: ["object", "null"], + description: "Provider-specific options (e.g. Cloudflare API token)", + properties: { + api_token: { type: "string", description: "DNS provider API token" }, + }, + }, + certificate_pem: { type: ["string", "null"], description: "PEM-encoded certificate (imported type only)" }, + private_key_pem: { type: ["string", "null"], description: "PEM-encoded private key (imported type only)" }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + }, + required: ["id", "name", "type", "domain_names", "created_at", "updated_at"], + }, + CertificateInput: { + type: "object", + properties: { + name: { type: "string", example: "Wildcard Cert" }, + type: { type: "string", enum: ["managed", "imported"] }, + domain_names: { type: "array", items: { type: "string" } }, + auto_renew: { type: "boolean" }, + provider_options: { type: ["object", "null"], properties: { api_token: { type: "string" } } }, + certificate_pem: { type: ["string", "null"] }, + private_key_pem: { type: ["string", "null"] }, + }, + required: ["name", "type", "domain_names"], + }, + CaCertificate: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + certificate_pem: { type: "string", description: "PEM-encoded CA certificate" }, + has_private_key: { type: "boolean", description: "Whether a private key is stored (for issuing client certs)" }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + }, + required: ["id", "name", "certificate_pem", "has_private_key", "created_at", "updated_at"], + }, + CaCertificateInput: { + type: "object", + properties: { + name: { type: "string", example: "Internal CA" }, + certificate_pem: { type: "string", description: "PEM-encoded CA certificate" }, + private_key_pem: { type: "string", description: "PEM-encoded private key (optional, needed for issuing client certs)" }, + }, + required: ["name", "certificate_pem"], + }, + ClientCertificate: { + type: "object", + properties: { + id: { type: "integer" }, + ca_certificate_id: { type: "integer" }, + common_name: { type: "string", example: "client-device-01" }, + serial_number: { type: "string" }, + fingerprint_sha256: { type: "string" }, + certificate_pem: { type: "string" }, + valid_from: { type: "string", format: "date-time" }, + valid_to: { type: "string", format: "date-time" }, + revoked_at: { type: ["string", "null"], format: "date-time" }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + }, + required: ["id", "ca_certificate_id", "common_name", "serial_number", "fingerprint_sha256", "certificate_pem", "valid_from", "valid_to", "created_at", "updated_at"], + }, + ClientCertificateInput: { + type: "object", + properties: { + ca_certificate_id: { type: "integer", description: "ID of the CA certificate to issue from" }, + common_name: { type: "string", example: "client-device-01" }, + }, + required: ["ca_certificate_id", "common_name"], + }, + AccessList: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + description: { type: ["string", "null"] }, + entries: { type: "array", items: { $ref: "#/components/schemas/AccessListEntry" } }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + }, + required: ["id", "name", "entries", "created_at", "updated_at"], + }, + AccessListInput: { + type: "object", + properties: { + name: { type: "string", example: "Internal Users" }, + description: { type: ["string", "null"] }, + users: { + type: "array", + description: "Seed members (only used during creation)", + items: { + type: "object", + properties: { + username: { type: "string" }, + password: { type: "string" }, + }, + required: ["username", "password"], + }, + }, + }, + required: ["name"], + }, + AccessListEntry: { + type: "object", + properties: { + id: { type: "integer" }, + username: { type: "string" }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + }, + required: ["id", "username", "created_at", "updated_at"], + }, + AccessListEntryInput: { + type: "object", + properties: { + username: { type: "string", example: "admin" }, + password: { type: "string", example: "secret123" }, + }, + required: ["username", "password"], + }, + + // ── Settings schemas ──────────────────────────────────────── + GeneralSettings: { + type: "object", + properties: { + primaryDomain: { type: "string", example: "example.com" }, + acmeEmail: { type: "string", format: "email", example: "admin@example.com" }, + }, + required: ["primaryDomain"], + }, + CloudflareSettings: { + type: "object", + properties: { + apiToken: { type: "string", description: "Cloudflare API token" }, + zoneId: { type: "string" }, + accountId: { type: "string" }, + }, + required: ["apiToken"], + }, + AuthentikSettings: { + type: "object", + properties: { + outpostDomain: { type: "string", example: "auth.example.com" }, + outpostUpstream: { type: "string", example: "http://authentik:9000" }, + authEndpoint: { type: "string" }, + }, + required: ["outpostDomain", "outpostUpstream"], + }, + MetricsSettings: { + type: "object", + properties: { + enabled: { type: "boolean" }, + port: { type: "integer", example: 9090, description: "Prometheus metrics port" }, + }, + required: ["enabled"], + }, + LoggingSettings: { + type: "object", + properties: { + enabled: { type: "boolean" }, + format: { type: "string", enum: ["json", "console"] }, + }, + required: ["enabled"], + }, + DnsSettings: { + type: "object", + properties: { + enabled: { type: "boolean" }, + resolvers: { type: "array", items: { type: "string" }, example: ["1.1.1.1", "8.8.8.8"] }, + fallbacks: { type: "array", items: { type: "string" } }, + timeout: { type: "string", example: "5s" }, + }, + required: ["enabled", "resolvers"], + }, + UpstreamDnsSettings: { + type: "object", + properties: { + enabled: { type: "boolean" }, + family: { type: "string", enum: ["ipv4", "ipv6", "both"] }, + }, + required: ["enabled", "family"], + }, + WafSettings: { + type: "object", + description: "Global WAF settings", + properties: { + enabled: { type: "boolean" }, + mode: { type: "string", enum: ["Off", "On"] }, + load_owasp_crs: { type: "boolean" }, + custom_directives: { type: "string" }, + excluded_rule_ids: { type: "array", items: { type: "integer" } }, + }, + required: ["enabled", "mode", "load_owasp_crs", "custom_directives"], + }, + + // ── Other resources ───────────────────────────────────────── + Instance: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + base_url: { type: "string", example: "https://slave.example.com:3000" }, + enabled: { type: "boolean" }, + has_token: { type: "boolean" }, + last_sync_at: { type: ["string", "null"], format: "date-time" }, + last_sync_error: { type: ["string", "null"] }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + }, + required: ["id", "name", "base_url", "enabled", "has_token", "created_at", "updated_at"], + }, + InstanceInput: { + type: "object", + properties: { + name: { type: "string", example: "Slave 1" }, + baseUrl: { type: "string", example: "https://slave.example.com:3000" }, + apiToken: { type: "string", description: "Sync token for the slave instance" }, + enabled: { type: "boolean" }, + }, + required: ["name", "baseUrl", "apiToken"], + }, + SyncResult: { + type: "object", + properties: { + total: { type: "integer" }, + success: { type: "integer" }, + failed: { type: "integer" }, + skippedHttp: { type: "integer" }, + }, + required: ["total", "success", "failed", "skippedHttp"], + }, + User: { + type: "object", + description: "User account (password_hash is never exposed)", + properties: { + id: { type: "integer" }, + email: { type: "string" }, + name: { type: ["string", "null"] }, + role: { type: "string", enum: ["admin", "user", "viewer"] }, + provider: { type: "string", example: "credentials" }, + subject: { type: "string" }, + avatar_url: { type: ["string", "null"] }, + status: { type: "string", enum: ["active", "inactive"] }, + created_at: { type: "string", format: "date-time" }, + updated_at: { type: "string", format: "date-time" }, + }, + required: ["id", "email", "role", "provider", "subject", "status", "created_at", "updated_at"], + }, + AuditLogEvent: { + type: "object", + properties: { + id: { type: "integer" }, + user_id: { type: ["integer", "null"] }, + action: { type: "string", example: "proxy_host_created" }, + entity_type: { type: "string", example: "proxy_host" }, + entity_id: { type: ["integer", "null"] }, + summary: { type: ["string", "null"] }, + created_at: { type: "string", format: "date-time" }, + }, + required: ["id", "action", "entity_type", "created_at"], + }, + AuditLogResponse: { + type: "object", + properties: { + events: { type: "array", items: { $ref: "#/components/schemas/AuditLogEvent" } }, + total: { type: "integer" }, + page: { type: "integer" }, + perPage: { type: "integer" }, + }, + required: ["events", "total", "page", "perPage"], + }, + }, + }, +}; + +export async function GET() { + return NextResponse.json(spec, { + headers: { + "Cache-Control": "public, max-age=3600", + }, + }); +} diff --git a/app/api/v1/proxy-hosts/[id]/route.ts b/app/api/v1/proxy-hosts/[id]/route.ts new file mode 100644 index 00000000..69657254 --- /dev/null +++ b/app/api/v1/proxy-hosts/[id]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { getProxyHost, updateProxyHost, deleteProxyHost } from "@/src/lib/models/proxy-hosts"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireApiAdmin(request); + const { id } = await params; + const host = await getProxyHost(Number(id)); + if (!host) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(host); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + const body = await request.json(); + const host = await updateProxyHost(Number(id), body, userId); + return NextResponse.json(host); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiAdmin(request); + const { id } = await params; + await deleteProxyHost(Number(id), userId); + return NextResponse.json({ ok: true }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/proxy-hosts/route.ts b/app/api/v1/proxy-hosts/route.ts new file mode 100644 index 00000000..5c846f1d --- /dev/null +++ b/app/api/v1/proxy-hosts/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { listProxyHosts, createProxyHost } from "@/src/lib/models/proxy-hosts"; + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const hosts = await listProxyHosts(); + return NextResponse.json(hosts); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function POST(request: NextRequest) { + try { + const { userId } = await requireApiAdmin(request); + const body = await request.json(); + const host = await createProxyHost(body, userId); + return NextResponse.json(host, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/settings/[group]/route.ts b/app/api/v1/settings/[group]/route.ts new file mode 100644 index 00000000..61527742 --- /dev/null +++ b/app/api/v1/settings/[group]/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { + getGeneralSettings, saveGeneralSettings, + getCloudflareSettings, saveCloudflareSettings, + getAuthentikSettings, saveAuthentikSettings, + getMetricsSettings, saveMetricsSettings, + getLoggingSettings, saveLoggingSettings, + getDnsSettings, saveDnsSettings, + getUpstreamDnsResolutionSettings, saveUpstreamDnsResolutionSettings, + getGeoBlockSettings, saveGeoBlockSettings, + getWafSettings, saveWafSettings, +} from "@/src/lib/settings"; +import { getInstanceMode, setInstanceMode, getSlaveMasterToken, setSlaveMasterToken } from "@/src/lib/instance-sync"; +import { applyCaddyConfig } from "@/src/lib/caddy"; + +type SettingsHandler = { + get: () => Promise; + save: (data: never) => Promise; + applyCaddy?: boolean; +}; + +const SETTINGS_HANDLERS: Record = { + general: { get: getGeneralSettings, save: saveGeneralSettings as (data: never) => Promise, applyCaddy: true }, + cloudflare: { get: getCloudflareSettings, save: saveCloudflareSettings as (data: never) => Promise, applyCaddy: true }, + authentik: { get: getAuthentikSettings, save: saveAuthentikSettings as (data: never) => Promise, applyCaddy: true }, + metrics: { get: getMetricsSettings, save: saveMetricsSettings as (data: never) => Promise, applyCaddy: true }, + logging: { get: getLoggingSettings, save: saveLoggingSettings as (data: never) => Promise, applyCaddy: true }, + dns: { get: getDnsSettings, save: saveDnsSettings as (data: never) => Promise, applyCaddy: true }, + "upstream-dns": { get: getUpstreamDnsResolutionSettings, save: saveUpstreamDnsResolutionSettings as (data: never) => Promise, applyCaddy: true }, + geoblock: { get: getGeoBlockSettings, save: saveGeoBlockSettings as (data: never) => Promise, applyCaddy: true }, + waf: { get: getWafSettings, save: saveWafSettings as (data: never) => Promise, applyCaddy: true }, +}; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ group: string }> } +) { + try { + await requireApiAdmin(request); + const { group } = await params; + + if (group === "instance-mode") { + const mode = await getInstanceMode(); + return NextResponse.json({ mode }); + } + + if (group === "sync-token") { + const token = await getSlaveMasterToken(); + return NextResponse.json({ has_token: token !== null }); + } + + const handler = SETTINGS_HANDLERS[group]; + if (!handler) { + return NextResponse.json({ error: "Unknown settings group" }, { status: 404 }); + } + + const settings = await handler.get(); + return NextResponse.json(settings ?? {}); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ group: string }> } +) { + try { + await requireApiAdmin(request); + const { group } = await params; + const body = await request.json(); + + if (group === "instance-mode") { + await setInstanceMode(body.mode); + return NextResponse.json({ ok: true }); + } + + if (group === "sync-token") { + await setSlaveMasterToken(body.token ?? null); + return NextResponse.json({ ok: true }); + } + + const handler = SETTINGS_HANDLERS[group]; + if (!handler) { + return NextResponse.json({ error: "Unknown settings group" }, { status: 404 }); + } + + await handler.save(body as never); + + if (handler.applyCaddy) { + try { + await applyCaddyConfig(); + } catch (e) { + console.error("Failed to apply Caddy config after settings update:", e); + } + } + + return NextResponse.json({ ok: true }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/tokens/[id]/route.ts b/app/api/v1/tokens/[id]/route.ts new file mode 100644 index 00000000..72854c80 --- /dev/null +++ b/app/api/v1/tokens/[id]/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiUser, apiErrorResponse } from "@/src/lib/api-auth"; +import { deleteApiToken } from "@/src/lib/models/api-tokens"; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { userId } = await requireApiUser(request); + const { id } = await params; + await deleteApiToken(Number(id), userId); + return NextResponse.json({ ok: true }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/tokens/route.ts b/app/api/v1/tokens/route.ts new file mode 100644 index 00000000..71c46e3e --- /dev/null +++ b/app/api/v1/tokens/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiUser, apiErrorResponse } from "@/src/lib/api-auth"; +import { createApiToken, listApiTokens, listAllApiTokens } from "@/src/lib/models/api-tokens"; + +export async function GET(request: NextRequest) { + try { + const { userId, role } = await requireApiUser(request); + const tokens = role === "admin" ? await listAllApiTokens() : await listApiTokens(userId); + return NextResponse.json(tokens); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function POST(request: NextRequest) { + try { + const { userId } = await requireApiUser(request); + const body = await request.json(); + + if (!body.name || typeof body.name !== "string") { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + + const { token, rawToken } = await createApiToken(body.name, userId, body.expires_at); + return NextResponse.json({ token, raw_token: rawToken }, { status: 201 }); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/users/[id]/route.ts b/app/api/v1/users/[id]/route.ts new file mode 100644 index 00000000..31f96002 --- /dev/null +++ b/app/api/v1/users/[id]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiUser, requireApiAdmin, apiErrorResponse, ApiAuthError } from "@/src/lib/api-auth"; +import { getUserById, updateUserProfile } from "@/src/lib/models/user"; + +function stripPasswordHash(user: Record) { + const { password_hash, ...rest } = user; + return rest; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const auth = await requireApiUser(request); + const { id } = await params; + const targetId = Number(id); + + // Non-admins can only view themselves + if (auth.role !== "admin" && auth.userId !== targetId) { + throw new ApiAuthError("Forbidden", 403); + } + + const user = await getUserById(targetId); + if (!user) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(stripPasswordHash(user as unknown as Record)); + } catch (error) { + return apiErrorResponse(error); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireApiAdmin(request); + const { id } = await params; + const body = await request.json(); + const user = await updateUserProfile(Number(id), body); + if (!user) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(stripPasswordHash(user as unknown as Record)); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/users/route.ts b/app/api/v1/users/route.ts new file mode 100644 index 00000000..9c6b3a6f --- /dev/null +++ b/app/api/v1/users/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth"; +import { listUsers } from "@/src/lib/models/user"; + +function stripPasswordHash(user: Record) { + const { password_hash, ...rest } = user; + return rest; +} + +export async function GET(request: NextRequest) { + try { + await requireApiAdmin(request); + const users = await listUsers(); + return NextResponse.json(users.map(u => stripPasswordHash(u as unknown as Record))); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/next.config.mjs b/next.config.mjs index 233f582e..c0ca76ba 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,8 +1,21 @@ /* global process */ +// When building under Node.js (not Bun), redirect bun:sqlite to a better-sqlite3 shim +// so `next build` works locally without Bun installed. +const isBun = typeof globalThis.Bun !== 'undefined'; + /** @type {import('next').NextConfig} */ const nextConfig = { - serverExternalPackages: ['bun:sqlite'], + serverExternalPackages: isBun ? ['bun:sqlite'] : ['better-sqlite3'], + ...(!isBun && { + turbopack: { + resolveAlias: { + 'bun:sqlite': './tests/helpers/bun-sqlite-compat.ts', + 'drizzle-orm/bun-sqlite/migrator': 'drizzle-orm/better-sqlite3/migrator', + 'drizzle-orm/bun-sqlite': 'drizzle-orm/better-sqlite3', + }, + }, + }), experimental: { serverActions: { bodySizeLimit: '2mb' diff --git a/proxy.ts b/proxy.ts index 8ef6ced9..3807d706 100644 --- a/proxy.ts +++ b/proxy.ts @@ -18,7 +18,8 @@ export default auth((req) => { pathname === "/login" || pathname.startsWith("/api/auth") || pathname === "/api/health" || - pathname === "/api/instances/sync" + pathname === "/api/instances/sync" || + pathname.startsWith("/api/v1/") ) { return NextResponse.next(); } diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts new file mode 100644 index 00000000..bf6cc4fc --- /dev/null +++ b/src/lib/api-auth.ts @@ -0,0 +1,92 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { auth, checkSameOrigin } from "./auth"; +import { validateToken } from "./models/api-tokens"; + +export class ApiAuthError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = "ApiAuthError"; + this.status = status; + } +} + +export type ApiAuthResult = { + userId: number; + role: string; + authMethod: "bearer" | "session"; +}; + +export async function authenticateApiRequest( + request: NextRequest +): Promise { + // Try Bearer token first + const authHeader = request.headers.get("authorization") ?? ""; + if (authHeader.startsWith("Bearer ")) { + const rawToken = authHeader.slice(7); + if (!rawToken) { + throw new ApiAuthError("Invalid Bearer token", 401); + } + + const result = await validateToken(rawToken); + if (!result) { + throw new ApiAuthError("Invalid or expired API token", 401); + } + + return { + userId: result.user.id, + role: result.user.role, + authMethod: "bearer", + }; + } + + // Fall back to session auth + const session = await auth(); + if (!session?.user?.id) { + throw new ApiAuthError("Unauthorized", 401); + } + + return { + userId: Number(session.user.id), + role: session.user.role ?? "user", + authMethod: "session", + }; +} + +export async function requireApiUser(request: NextRequest): Promise { + const result = await authenticateApiRequest(request); + + // CSRF check for session-authenticated mutating requests + if (result.authMethod === "session") { + const method = request.method.toUpperCase(); + if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") { + const csrfResponse = checkSameOrigin(request); + if (csrfResponse) { + throw new ApiAuthError("Forbidden", 403); + } + } + } + + return result; +} + +export async function requireApiAdmin(request: NextRequest): Promise { + const result = await requireApiUser(request); + if (result.role !== "admin") { + throw new ApiAuthError("Administrator privileges required", 403); + } + return result; +} + +/** + * Helper to build an error response from an ApiAuthError or generic error. + */ +export function apiErrorResponse(error: unknown): NextResponse { + if (error instanceof ApiAuthError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : "Internal server error" }, + { status: 500 } + ); +} diff --git a/src/lib/models/api-tokens.ts b/src/lib/models/api-tokens.ts new file mode 100644 index 00000000..37ff62bd --- /dev/null +++ b/src/lib/models/api-tokens.ts @@ -0,0 +1,143 @@ +import { createHash, randomBytes } from "node:crypto"; +import db, { nowIso, toIso } from "../db"; +import { apiTokens, users } from "../db/schema"; +import { eq, and } from "drizzle-orm"; + +export type ApiToken = { + id: number; + name: string; + created_by: number; + created_at: string; + last_used_at: string | null; + expires_at: string | null; +}; + +type ApiTokenRow = typeof apiTokens.$inferSelect; + +function toApiToken(row: ApiTokenRow): ApiToken { + return { + id: row.id, + name: row.name, + created_by: row.createdBy, + created_at: toIso(row.createdAt)!, + last_used_at: row.lastUsedAt ? toIso(row.lastUsedAt) : null, + expires_at: row.expiresAt ? toIso(row.expiresAt) : null, + }; +} + +function hashToken(rawToken: string): string { + return createHash("sha256").update(rawToken).digest("hex"); +} + +export async function createApiToken( + name: string, + createdBy: number, + expiresAt?: string +): Promise<{ token: ApiToken; rawToken: string }> { + const rawToken = randomBytes(32).toString("hex"); + const tokenHash = hashToken(rawToken); + const now = nowIso(); + + const [row] = await db + .insert(apiTokens) + .values({ + name: name.trim(), + tokenHash, + createdBy, + createdAt: now, + expiresAt: expiresAt ?? null, + }) + .returning(); + + if (!row) { + throw new Error("Failed to create API token"); + } + + return { token: toApiToken(row), rawToken }; +} + +export async function listApiTokens(userId: number): Promise { + const rows = await db.query.apiTokens.findMany({ + where: (table, { eq }) => eq(table.createdBy, userId), + orderBy: (table, { desc }) => desc(table.createdAt), + }); + return rows.map(toApiToken); +} + +export async function listAllApiTokens(): Promise { + const rows = await db.query.apiTokens.findMany({ + orderBy: (table, { desc }) => desc(table.createdAt), + }); + return rows.map(toApiToken); +} + +export async function deleteApiToken(id: number, userId: number): Promise { + // Check ownership — fetch the token first + const token = await db.query.apiTokens.findFirst({ + where: (table, { eq }) => eq(table.id, id), + }); + + if (!token) { + throw new Error("Token not found"); + } + + // Check if the user owns the token or is an admin + if (token.createdBy !== userId) { + const user = await db.query.users.findFirst({ + where: (table, { eq }) => eq(table.id, userId), + }); + if (!user || user.role !== "admin") { + throw new Error("Forbidden"); + } + } + + await db.delete(apiTokens).where(eq(apiTokens.id, id)); +} + +const LAST_USED_DEBOUNCE_MS = 60_000; // 60 seconds + +export async function validateToken( + rawToken: string +): Promise<{ token: ApiToken; user: { id: number; role: string } } | null> { + const tokenHash = hashToken(rawToken); + + const row = await db.query.apiTokens.findFirst({ + where: (table, { eq }) => eq(table.tokenHash, tokenHash), + }); + + if (!row) { + return null; + } + + // Check expiry + if (row.expiresAt) { + const expiresAt = new Date(row.expiresAt); + if (expiresAt <= new Date()) { + return null; + } + } + + // Load the creator user + const user = await db.query.users.findFirst({ + where: (table, { eq }) => eq(table.id, row.createdBy), + }); + + if (!user || user.status !== "active") { + return null; + } + + // Debounced lastUsedAt update + const now = new Date(); + const lastUsed = row.lastUsedAt ? new Date(row.lastUsedAt) : null; + if (!lastUsed || now.getTime() - lastUsed.getTime() > LAST_USED_DEBOUNCE_MS) { + await db + .update(apiTokens) + .set({ lastUsedAt: nowIso() }) + .where(eq(apiTokens.id, row.id)); + } + + return { + token: toApiToken(row), + user: { id: user.id, role: user.role }, + }; +} diff --git a/tests/integration/api-tokens.test.ts b/tests/integration/api-tokens.test.ts new file mode 100644 index 00000000..68c83b21 --- /dev/null +++ b/tests/integration/api-tokens.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb, type TestDb } from '../helpers/db'; +import { apiTokens, users } from '@/src/lib/db/schema'; +import { createHash } from 'node:crypto'; +import { eq } from 'drizzle-orm'; + +let db: TestDb; + +beforeEach(() => { + db = createTestDb(); +}); + +function nowIso() { + return new Date().toISOString(); +} + +function hashToken(rawToken: string): string { + return createHash('sha256').update(rawToken).digest('hex'); +} + +async function insertUser(overrides: Partial = {}) { + const now = nowIso(); + const [user] = await db.insert(users).values({ + email: 'admin@localhost', + name: 'Admin', + passwordHash: 'hash123', + role: 'admin', + provider: 'credentials', + subject: 'admin@localhost', + status: 'active', + createdAt: now, + updatedAt: now, + ...overrides, + }).returning(); + return user; +} + +async function insertApiToken(createdBy: number, overrides: Partial = {}) { + const now = nowIso(); + const rawToken = 'test-token-' + Math.random().toString(36).slice(2); + const tokenHash = hashToken(rawToken); + const [token] = await db.insert(apiTokens).values({ + name: 'Test Token', + tokenHash, + createdBy, + createdAt: now, + ...overrides, + }).returning(); + return { token, rawToken }; +} + +describe('api-tokens integration', () => { + it('inserts an api token and retrieves it by hash', async () => { + const user = await insertUser(); + const { token, rawToken } = await insertApiToken(user.id); + + const hash = hashToken(rawToken); + const row = await db.query.apiTokens.findFirst({ + where: (t, { eq }) => eq(t.tokenHash, hash), + }); + + expect(row).toBeDefined(); + expect(row!.id).toBe(token.id); + expect(row!.name).toBe('Test Token'); + expect(row!.createdBy).toBe(user.id); + }); + + it('stored hash matches SHA-256 of raw token', async () => { + const user = await insertUser(); + const { token, rawToken } = await insertApiToken(user.id); + + const expectedHash = hashToken(rawToken); + expect(token.tokenHash).toBe(expectedHash); + }); + + it('different raw tokens produce different hashes', async () => { + const user = await insertUser(); + const t1 = await insertApiToken(user.id, { name: 'Token 1' }); + const t2 = await insertApiToken(user.id, { name: 'Token 2' }); + + expect(t1.token.tokenHash).not.toBe(t2.token.tokenHash); + }); + + it('token lookup fails for wrong hash', async () => { + const user = await insertUser(); + await insertApiToken(user.id); + + const wrongHash = hashToken('wrong-token'); + const row = await db.query.apiTokens.findFirst({ + where: (t, { eq }) => eq(t.tokenHash, wrongHash), + }); + + expect(row).toBeUndefined(); + }); + + it('expired token is detectable', async () => { + const user = await insertUser(); + const pastDate = new Date(Date.now() - 86400000).toISOString(); // 1 day ago + const { token } = await insertApiToken(user.id, { expiresAt: pastDate }); + + const row = await db.query.apiTokens.findFirst({ + where: (t, { eq }) => eq(t.id, token.id), + }); + + expect(row).toBeDefined(); + expect(new Date(row!.expiresAt!).getTime()).toBeLessThan(Date.now()); + }); + + it('non-expired token has future expiry', async () => { + const user = await insertUser(); + const futureDate = new Date(Date.now() + 86400000).toISOString(); // 1 day from now + const { token } = await insertApiToken(user.id, { expiresAt: futureDate }); + + const row = await db.query.apiTokens.findFirst({ + where: (t, { eq }) => eq(t.id, token.id), + }); + + expect(row).toBeDefined(); + expect(new Date(row!.expiresAt!).getTime()).toBeGreaterThan(Date.now()); + }); + + it('deleting a token removes it from the database', async () => { + const user = await insertUser(); + const { token } = await insertApiToken(user.id); + + await db.delete(apiTokens).where(eq(apiTokens.id, token.id)); + + const row = await db.query.apiTokens.findFirst({ + where: (t, { eq }) => eq(t.id, token.id), + }); + expect(row).toBeUndefined(); + }); + + it('cascade deletes tokens when user is deleted', async () => { + const user = await insertUser(); + const { token } = await insertApiToken(user.id); + + await db.delete(users).where(eq(users.id, user.id)); + + const row = await db.query.apiTokens.findFirst({ + where: (t, { eq }) => eq(t.id, token.id), + }); + expect(row).toBeUndefined(); + }); + + it('lastUsedAt is initially null', async () => { + const user = await insertUser(); + const { token } = await insertApiToken(user.id); + + expect(token.lastUsedAt).toBeNull(); + }); + + it('lastUsedAt can be updated', async () => { + const user = await insertUser(); + const { token } = await insertApiToken(user.id); + + const now = nowIso(); + await db.update(apiTokens).set({ lastUsedAt: now }).where(eq(apiTokens.id, token.id)); + + const row = await db.query.apiTokens.findFirst({ + where: (t, { eq }) => eq(t.id, token.id), + }); + expect(row!.lastUsedAt).toBe(now); + }); + + it('unique index prevents duplicate token hashes', async () => { + const user = await insertUser(); + const { token } = await insertApiToken(user.id); + + await expect( + db.insert(apiTokens).values({ + name: 'Duplicate', + tokenHash: token.tokenHash, + createdBy: user.id, + createdAt: nowIso(), + }) + ).rejects.toThrow(); + }); + + it('lists tokens for a specific user', async () => { + const user1 = await insertUser({ email: 'u1@localhost', subject: 'u1@localhost' }); + const user2 = await insertUser({ email: 'u2@localhost', subject: 'u2@localhost' }); + + await insertApiToken(user1.id, { name: 'User1 Token' }); + await insertApiToken(user2.id, { name: 'User2 Token' }); + + const user1Tokens = await db.query.apiTokens.findMany({ + where: (t, { eq }) => eq(t.createdBy, user1.id), + }); + expect(user1Tokens).toHaveLength(1); + expect(user1Tokens[0].name).toBe('User1 Token'); + }); +}); diff --git a/tests/unit/api-auth.test.ts b/tests/unit/api-auth.test.ts new file mode 100644 index 00000000..106a7a7a --- /dev/null +++ b/tests/unit/api-auth.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the api-tokens model +vi.mock('@/src/lib/models/api-tokens', () => ({ + validateToken: vi.fn(), +})); + +// Mock next-auth +vi.mock('@/src/lib/auth', () => ({ + auth: vi.fn(), + checkSameOrigin: vi.fn(() => null), +})); + +import { authenticateApiRequest, requireApiUser, requireApiAdmin, ApiAuthError } from '@/src/lib/api-auth'; +import { validateToken } from '@/src/lib/models/api-tokens'; +import { auth } from '@/src/lib/auth'; + +const mockValidateToken = vi.mocked(validateToken); +const mockAuth = vi.mocked(auth); + +function createMockRequest(options: { authorization?: string; method?: string; origin?: string } = {}): any { + return { + headers: { + get(name: string) { + if (name === 'authorization') return options.authorization ?? null; + if (name === 'origin') return options.origin ?? null; + return null; + }, + }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/test' }, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('authenticateApiRequest', () => { + it('authenticates via Bearer token', async () => { + mockValidateToken.mockResolvedValue({ + token: { id: 1, name: 'test', created_by: 42, created_at: '', last_used_at: null, expires_at: null }, + user: { id: 42, role: 'admin' }, + }); + + const result = await authenticateApiRequest(createMockRequest({ authorization: 'Bearer test-token' })); + + expect(result.userId).toBe(42); + expect(result.role).toBe('admin'); + expect(result.authMethod).toBe('bearer'); + expect(mockValidateToken).toHaveBeenCalledWith('test-token'); + }); + + it('rejects invalid Bearer token', async () => { + mockValidateToken.mockResolvedValue(null); + + await expect( + authenticateApiRequest(createMockRequest({ authorization: 'Bearer bad-token' })) + ).rejects.toThrow(ApiAuthError); + }); + + it('falls back to session auth when no Bearer header', async () => { + mockAuth.mockResolvedValue({ + user: { id: '10', role: 'user', name: 'Test', email: 'test@test.com' }, + expires: '', + } as any); + + const result = await authenticateApiRequest(createMockRequest()); + + expect(result.userId).toBe(10); + expect(result.role).toBe('user'); + expect(result.authMethod).toBe('session'); + }); + + it('throws 401 when neither auth method succeeds', async () => { + mockAuth.mockResolvedValue(null as any); + + await expect( + authenticateApiRequest(createMockRequest()) + ).rejects.toThrow(ApiAuthError); + + try { + await authenticateApiRequest(createMockRequest()); + } catch (e) { + expect((e as ApiAuthError).status).toBe(401); + } + }); +}); + +describe('requireApiAdmin', () => { + it('allows admin users', async () => { + mockValidateToken.mockResolvedValue({ + token: { id: 1, name: 'test', created_by: 1, created_at: '', last_used_at: null, expires_at: null }, + user: { id: 1, role: 'admin' }, + }); + + const result = await requireApiAdmin(createMockRequest({ authorization: 'Bearer token' })); + expect(result.role).toBe('admin'); + }); + + it('rejects non-admin users with 403', async () => { + mockValidateToken.mockResolvedValue({ + token: { id: 1, name: 'test', created_by: 2, created_at: '', last_used_at: null, expires_at: null }, + user: { id: 2, role: 'user' }, + }); + + try { + await requireApiAdmin(createMockRequest({ authorization: 'Bearer token' })); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ApiAuthError); + expect((e as ApiAuthError).status).toBe(403); + } + }); +}); + +describe('requireApiUser', () => { + it('returns auth result for valid user', async () => { + mockAuth.mockResolvedValue({ + user: { id: '5', role: 'viewer', name: 'V', email: 'v@test.com' }, + expires: '', + } as any); + + const result = await requireApiUser(createMockRequest()); + expect(result.userId).toBe(5); + expect(result.role).toBe('viewer'); + }); +}); diff --git a/tests/unit/api-routes/access-lists.test.ts b/tests/unit/api-routes/access-lists.test.ts new file mode 100644 index 00000000..74986aec --- /dev/null +++ b/tests/unit/api-routes/access-lists.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/models/access-lists', () => ({ + listAccessLists: vi.fn(), + createAccessList: vi.fn(), + getAccessList: vi.fn(), + updateAccessList: vi.fn(), + deleteAccessList: vi.fn(), + addAccessListEntry: vi.fn(), + removeAccessListEntry: vi.fn(), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET as listGET, POST as listPOST } from '@/app/api/v1/access-lists/route'; +import { GET as getGET, PUT, DELETE } from '@/app/api/v1/access-lists/[id]/route'; +import { POST as entriesPOST } from '@/app/api/v1/access-lists/[id]/entries/route'; +import { DELETE as entryDELETE } from '@/app/api/v1/access-lists/[id]/entries/[entryId]/route'; +import { listAccessLists, createAccessList, getAccessList, updateAccessList, deleteAccessList, addAccessListEntry, removeAccessListEntry } from '@/src/lib/models/access-lists'; +import { requireApiAdmin } from '@/src/lib/api-auth'; + +const mockList = vi.mocked(listAccessLists); +const mockCreate = vi.mocked(createAccessList); +const mockGet = vi.mocked(getAccessList); +const mockUpdate = vi.mocked(updateAccessList); +const mockDelete = vi.mocked(deleteAccessList); +const mockAddEntry = vi.mocked(addAccessListEntry); +const mockRemoveEntry = vi.mocked(removeAccessListEntry); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); + +function createMockRequest(options: { method?: string; body?: unknown } = {}): any { + return { + headers: { get: () => null }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/access-lists', searchParams: new URLSearchParams() }, + json: async () => options.body ?? {}, + }; +} + +const sampleList = { + id: 1, + name: 'Whitelist', + type: 'allow', + entries: [{ id: 1, value: '10.0.0.0/8', type: 'ip' }], + created_at: '2026-01-01', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/access-lists', () => { + it('returns list of access lists', async () => { + mockList.mockResolvedValue([sampleList] as any); + + const response = await listGET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual([sampleList]); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await listGET(createMockRequest()); + expect(response.status).toBe(401); + }); +}); + +describe('POST /api/v1/access-lists', () => { + it('creates an access list and returns 201', async () => { + const body = { name: 'New List', type: 'deny' }; + mockCreate.mockResolvedValue({ id: 2, ...body, entries: [] } as any); + + const response = await listPOST(createMockRequest({ method: 'POST', body })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(2); + expect(mockCreate).toHaveBeenCalledWith(body, 1); + }); +}); + +describe('GET /api/v1/access-lists/[id]', () => { + it('returns an access list by id', async () => { + mockGet.mockResolvedValue(sampleList as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(sampleList); + }); + + it('returns 404 for non-existent access list', async () => { + mockGet.mockResolvedValue(null as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Not found'); + }); +}); + +describe('PUT /api/v1/access-lists/[id]', () => { + it('updates an access list', async () => { + const body = { name: 'Updated List' }; + mockUpdate.mockResolvedValue({ ...sampleList, name: 'Updated List' } as any); + + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.name).toBe('Updated List'); + expect(mockUpdate).toHaveBeenCalledWith(1, body, 1); + }); +}); + +describe('DELETE /api/v1/access-lists/[id]', () => { + it('deletes an access list', async () => { + mockDelete.mockResolvedValue(undefined as any); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockDelete).toHaveBeenCalledWith(1, 1); + }); +}); + +describe('POST /api/v1/access-lists/[id]/entries', () => { + it('adds an entry to an access list and returns 201', async () => { + const body = { value: '192.168.0.0/16', type: 'ip' }; + const updatedList = { ...sampleList, entries: [...sampleList.entries, { id: 2, ...body }] }; + mockAddEntry.mockResolvedValue(updatedList as any); + + const response = await entriesPOST(createMockRequest({ method: 'POST', body }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.entries).toHaveLength(2); + expect(mockAddEntry).toHaveBeenCalledWith(1, body, 1); + }); +}); + +describe('DELETE /api/v1/access-lists/[id]/entries/[entryId]', () => { + it('removes an entry from an access list', async () => { + const updatedList = { ...sampleList, entries: [] }; + mockRemoveEntry.mockResolvedValue(updatedList as any); + + const response = await entryDELETE( + createMockRequest({ method: 'DELETE' }), + { params: Promise.resolve({ id: '1', entryId: '1' }) } + ); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.entries).toHaveLength(0); + expect(mockRemoveEntry).toHaveBeenCalledWith(1, 1, 1); + }); +}); diff --git a/tests/unit/api-routes/audit-log.test.ts b/tests/unit/api-routes/audit-log.test.ts new file mode 100644 index 00000000..bbb4e981 --- /dev/null +++ b/tests/unit/api-routes/audit-log.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/models/audit', () => ({ + listAuditEvents: vi.fn(), + countAuditEvents: vi.fn(), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET } from '@/app/api/v1/audit-log/route'; +import { listAuditEvents, countAuditEvents } from '@/src/lib/models/audit'; +import { requireApiAdmin } from '@/src/lib/api-auth'; + +const mockListAuditEvents = vi.mocked(listAuditEvents); +const mockCountAuditEvents = vi.mocked(countAuditEvents); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); + +function createMockRequest(options: { searchParams?: string } = {}): any { + return { + headers: { get: () => null }, + method: 'GET', + nextUrl: { pathname: '/api/v1/audit-log', searchParams: new URLSearchParams(options.searchParams ?? '') }, + json: async () => ({}), + }; +} + +const sampleEvents = [ + { id: 1, action: 'proxy_host.create', user_id: 1, details: '{}', created_at: '2026-01-01T00:00:00Z' }, + { id: 2, action: 'certificate.create', user_id: 1, details: '{}', created_at: '2026-01-01T01:00:00Z' }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/audit-log', () => { + it('returns paginated events with total', async () => { + mockListAuditEvents.mockResolvedValue(sampleEvents as any); + mockCountAuditEvents.mockResolvedValue(2); + + const response = await GET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.events).toEqual(sampleEvents); + expect(data.total).toBe(2); + expect(data.page).toBe(1); + expect(data.perPage).toBe(50); + expect(mockListAuditEvents).toHaveBeenCalledWith(50, 0, undefined); + expect(mockCountAuditEvents).toHaveBeenCalledWith(undefined); + }); + + it('parses page and per_page params', async () => { + mockListAuditEvents.mockResolvedValue([]); + mockCountAuditEvents.mockResolvedValue(100); + + const response = await GET(createMockRequest({ searchParams: 'page=3&per_page=25' })); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.page).toBe(3); + expect(data.perPage).toBe(25); + expect(mockListAuditEvents).toHaveBeenCalledWith(25, 50, undefined); + }); + + it('passes search param through', async () => { + mockListAuditEvents.mockResolvedValue([]); + mockCountAuditEvents.mockResolvedValue(0); + + await GET(createMockRequest({ searchParams: 'search=proxy' })); + + expect(mockListAuditEvents).toHaveBeenCalledWith(50, 0, 'proxy'); + expect(mockCountAuditEvents).toHaveBeenCalledWith('proxy'); + }); + + it('clamps per_page to max 200', async () => { + mockListAuditEvents.mockResolvedValue([]); + mockCountAuditEvents.mockResolvedValue(0); + + await GET(createMockRequest({ searchParams: 'per_page=500' })); + + expect(mockListAuditEvents).toHaveBeenCalledWith(200, 0, undefined); + }); + + it('clamps per_page to min 1', async () => { + mockListAuditEvents.mockResolvedValue([]); + mockCountAuditEvents.mockResolvedValue(0); + + await GET(createMockRequest({ searchParams: 'per_page=0' })); + + expect(mockListAuditEvents).toHaveBeenCalledWith(50, 0, undefined); + }); + + it('clamps page to min 1', async () => { + mockListAuditEvents.mockResolvedValue([]); + mockCountAuditEvents.mockResolvedValue(0); + + await GET(createMockRequest({ searchParams: 'page=-1' })); + + expect(mockListAuditEvents).toHaveBeenCalledWith(50, 0, undefined); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await GET(createMockRequest()); + expect(response.status).toBe(401); + }); +}); diff --git a/tests/unit/api-routes/ca-certificates.test.ts b/tests/unit/api-routes/ca-certificates.test.ts new file mode 100644 index 00000000..86aeb0e6 --- /dev/null +++ b/tests/unit/api-routes/ca-certificates.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/models/ca-certificates', () => ({ + listCaCertificates: vi.fn(), + createCaCertificate: vi.fn(), + getCaCertificate: vi.fn(), + updateCaCertificate: vi.fn(), + deleteCaCertificate: vi.fn(), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET as listGET, POST } from '@/app/api/v1/ca-certificates/route'; +import { GET as getGET, PUT, DELETE } from '@/app/api/v1/ca-certificates/[id]/route'; +import { listCaCertificates, createCaCertificate, getCaCertificate, updateCaCertificate, deleteCaCertificate } from '@/src/lib/models/ca-certificates'; +import { requireApiAdmin } from '@/src/lib/api-auth'; + +const mockList = vi.mocked(listCaCertificates); +const mockCreate = vi.mocked(createCaCertificate); +const mockGet = vi.mocked(getCaCertificate); +const mockUpdate = vi.mocked(updateCaCertificate); +const mockDelete = vi.mocked(deleteCaCertificate); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); + +function createMockRequest(options: { method?: string; body?: unknown } = {}): any { + return { + headers: { get: () => null }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/ca-certificates', searchParams: new URLSearchParams() }, + json: async () => options.body ?? {}, + }; +} + +const sampleCaCert = { + id: 1, + name: 'Internal CA', + certificate: '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----', + private_key: '-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----', + created_at: '2026-01-01', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/ca-certificates', () => { + it('returns list of CA certificates', async () => { + mockList.mockResolvedValue([sampleCaCert] as any); + + const response = await listGET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual([sampleCaCert]); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await listGET(createMockRequest()); + expect(response.status).toBe(401); + }); +}); + +describe('POST /api/v1/ca-certificates', () => { + it('creates a CA certificate and returns 201', async () => { + const body = { name: 'New CA', certificate: '---CERT---', private_key: '---KEY---' }; + mockCreate.mockResolvedValue({ id: 2, ...body } as any); + + const response = await POST(createMockRequest({ method: 'POST', body })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(2); + expect(mockCreate).toHaveBeenCalledWith(body, 1); + }); +}); + +describe('GET /api/v1/ca-certificates/[id]', () => { + it('returns a CA certificate by id', async () => { + mockGet.mockResolvedValue(sampleCaCert as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(sampleCaCert); + }); + + it('returns 404 for non-existent CA certificate', async () => { + mockGet.mockResolvedValue(null as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Not found'); + }); +}); + +describe('PUT /api/v1/ca-certificates/[id]', () => { + it('updates a CA certificate', async () => { + const body = { name: 'Updated CA' }; + mockUpdate.mockResolvedValue({ ...sampleCaCert, name: 'Updated CA' } as any); + + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.name).toBe('Updated CA'); + expect(mockUpdate).toHaveBeenCalledWith(1, body, 1); + }); +}); + +describe('DELETE /api/v1/ca-certificates/[id]', () => { + it('deletes a CA certificate', async () => { + mockDelete.mockResolvedValue(undefined as any); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockDelete).toHaveBeenCalledWith(1, 1); + }); +}); diff --git a/tests/unit/api-routes/caddy.test.ts b/tests/unit/api-routes/caddy.test.ts new file mode 100644 index 00000000..15766dc2 --- /dev/null +++ b/tests/unit/api-routes/caddy.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/caddy', () => ({ + applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { POST } from '@/app/api/v1/caddy/apply/route'; +import { applyCaddyConfig } from '@/src/lib/caddy'; +import { requireApiAdmin } from '@/src/lib/api-auth'; + +const mockApplyCaddyConfig = vi.mocked(applyCaddyConfig); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); + +function createMockRequest(): any { + return { + headers: { get: () => null }, + method: 'POST', + nextUrl: { pathname: '/api/v1/caddy/apply', searchParams: new URLSearchParams() }, + json: async () => ({}), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('POST /api/v1/caddy/apply', () => { + it('applies caddy config and returns ok', async () => { + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockApplyCaddyConfig).toHaveBeenCalled(); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe('Unauthorized'); + }); + + it('returns 500 when applyCaddyConfig fails', async () => { + mockApplyCaddyConfig.mockRejectedValue(new Error('Connection refused')); + + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe('Connection refused'); + }); +}); diff --git a/tests/unit/api-routes/certificates.test.ts b/tests/unit/api-routes/certificates.test.ts new file mode 100644 index 00000000..0cb79507 --- /dev/null +++ b/tests/unit/api-routes/certificates.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/models/certificates', () => ({ + listCertificates: vi.fn(), + createCertificate: vi.fn(), + getCertificate: vi.fn(), + updateCertificate: vi.fn(), + deleteCertificate: vi.fn(), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET as listGET, POST } from '@/app/api/v1/certificates/route'; +import { GET as getGET, PUT, DELETE } from '@/app/api/v1/certificates/[id]/route'; +import { listCertificates, createCertificate, getCertificate, updateCertificate, deleteCertificate } from '@/src/lib/models/certificates'; +import { requireApiAdmin } from '@/src/lib/api-auth'; + +const mockList = vi.mocked(listCertificates); +const mockCreate = vi.mocked(createCertificate); +const mockGet = vi.mocked(getCertificate); +const mockUpdate = vi.mocked(updateCertificate); +const mockDelete = vi.mocked(deleteCertificate); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); + +function createMockRequest(options: { method?: string; body?: unknown } = {}): any { + return { + headers: { get: () => null }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/certificates', searchParams: new URLSearchParams() }, + json: async () => options.body ?? {}, + }; +} + +const sampleCert = { + id: 1, + domains: ['secure.example.com'], + type: 'acme', + status: 'active', + expires_at: '2027-01-01', + created_at: '2026-01-01', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/certificates', () => { + it('returns list of certificates', async () => { + mockList.mockResolvedValue([sampleCert] as any); + + const response = await listGET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual([sampleCert]); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await listGET(createMockRequest()); + expect(response.status).toBe(401); + }); +}); + +describe('POST /api/v1/certificates', () => { + it('creates a certificate and returns 201', async () => { + const body = { domains: ['new.example.com'], type: 'acme' }; + mockCreate.mockResolvedValue({ id: 2, ...body } as any); + + const response = await POST(createMockRequest({ method: 'POST', body })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(2); + expect(mockCreate).toHaveBeenCalledWith(body, 1); + }); +}); + +describe('GET /api/v1/certificates/[id]', () => { + it('returns a certificate by id', async () => { + mockGet.mockResolvedValue(sampleCert as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(sampleCert); + }); + + it('returns 404 for non-existent certificate', async () => { + mockGet.mockResolvedValue(null as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Not found'); + }); +}); + +describe('PUT /api/v1/certificates/[id]', () => { + it('updates a certificate', async () => { + const body = { domains: ['updated.example.com'] }; + mockUpdate.mockResolvedValue({ ...sampleCert, domains: ['updated.example.com'] } as any); + + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.domains).toEqual(['updated.example.com']); + expect(mockUpdate).toHaveBeenCalledWith(1, body, 1); + }); +}); + +describe('DELETE /api/v1/certificates/[id]', () => { + it('deletes a certificate', async () => { + mockDelete.mockResolvedValue(undefined as any); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockDelete).toHaveBeenCalledWith(1, 1); + }); +}); diff --git a/tests/unit/api-routes/client-certificates.test.ts b/tests/unit/api-routes/client-certificates.test.ts new file mode 100644 index 00000000..eb0ae3b3 --- /dev/null +++ b/tests/unit/api-routes/client-certificates.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/models/issued-client-certificates', () => ({ + listIssuedClientCertificates: vi.fn(), + createIssuedClientCertificate: vi.fn(), + getIssuedClientCertificate: vi.fn(), + revokeIssuedClientCertificate: vi.fn(), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET as listGET, POST } from '@/app/api/v1/client-certificates/route'; +import { GET as getGET, DELETE } from '@/app/api/v1/client-certificates/[id]/route'; +import { listIssuedClientCertificates, createIssuedClientCertificate, getIssuedClientCertificate, revokeIssuedClientCertificate } from '@/src/lib/models/issued-client-certificates'; +import { requireApiAdmin } from '@/src/lib/api-auth'; + +const mockList = vi.mocked(listIssuedClientCertificates); +const mockCreate = vi.mocked(createIssuedClientCertificate); +const mockGet = vi.mocked(getIssuedClientCertificate); +const mockRevoke = vi.mocked(revokeIssuedClientCertificate); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); + +function createMockRequest(options: { method?: string; body?: unknown } = {}): any { + return { + headers: { get: () => null }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/client-certificates', searchParams: new URLSearchParams() }, + json: async () => options.body ?? {}, + }; +} + +const sampleClientCert = { + id: 1, + common_name: 'client1.example.com', + ca_certificate_id: 1, + status: 'active', + expires_at: '2027-06-01', + created_at: '2026-01-01', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/client-certificates', () => { + it('returns list of client certificates', async () => { + mockList.mockResolvedValue([sampleClientCert] as any); + + const response = await listGET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual([sampleClientCert]); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await listGET(createMockRequest()); + expect(response.status).toBe(401); + }); +}); + +describe('POST /api/v1/client-certificates', () => { + it('creates a client certificate and returns 201', async () => { + const body = { common_name: 'client2.example.com', ca_certificate_id: 1 }; + mockCreate.mockResolvedValue({ id: 2, ...body, status: 'active' } as any); + + const response = await POST(createMockRequest({ method: 'POST', body })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(2); + expect(mockCreate).toHaveBeenCalledWith(body, 1); + }); +}); + +describe('GET /api/v1/client-certificates/[id]', () => { + it('returns a client certificate by id', async () => { + mockGet.mockResolvedValue(sampleClientCert as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(sampleClientCert); + }); + + it('returns 404 for non-existent client certificate', async () => { + mockGet.mockResolvedValue(null as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Not found'); + }); +}); + +describe('DELETE /api/v1/client-certificates/[id]', () => { + it('revokes a client certificate and returns it', async () => { + const revoked = { ...sampleClientCert, status: 'revoked' }; + mockRevoke.mockResolvedValue(revoked as any); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.status).toBe('revoked'); + expect(mockRevoke).toHaveBeenCalledWith(1, 1); + }); +}); diff --git a/tests/unit/api-routes/instances.test.ts b/tests/unit/api-routes/instances.test.ts new file mode 100644 index 00000000..03fde65a --- /dev/null +++ b/tests/unit/api-routes/instances.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/models/instances', () => ({ + listInstances: vi.fn(), + createInstance: vi.fn(), + deleteInstance: vi.fn(), +})); + +vi.mock('@/src/lib/instance-sync', () => ({ + syncInstances: vi.fn(), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET, POST } from '@/app/api/v1/instances/route'; +import { DELETE } from '@/app/api/v1/instances/[id]/route'; +import { POST as syncPOST } from '@/app/api/v1/instances/sync/route'; +import { listInstances, createInstance, deleteInstance } from '@/src/lib/models/instances'; +import { syncInstances } from '@/src/lib/instance-sync'; +import { requireApiAdmin } from '@/src/lib/api-auth'; + +const mockList = vi.mocked(listInstances); +const mockCreate = vi.mocked(createInstance); +const mockDelete = vi.mocked(deleteInstance); +const mockSync = vi.mocked(syncInstances); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); + +function createMockRequest(options: { method?: string; body?: unknown } = {}): any { + return { + headers: { get: () => null }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/instances', searchParams: new URLSearchParams() }, + json: async () => options.body ?? {}, + }; +} + +const sampleInstance = { + id: 1, + name: 'Slave 1', + url: 'https://slave1.example.com:3000', + token: 'sync-token-abc', + created_at: '2026-01-01', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/instances', () => { + it('returns list of instances', async () => { + mockList.mockResolvedValue([sampleInstance] as any); + + const response = await GET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual([sampleInstance]); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await GET(createMockRequest()); + expect(response.status).toBe(401); + }); +}); + +describe('POST /api/v1/instances', () => { + it('creates an instance and returns 201', async () => { + const body = { name: 'Slave 2', url: 'https://slave2.example.com:3000', token: 'token-xyz' }; + mockCreate.mockResolvedValue({ id: 2, ...body } as any); + + const response = await POST(createMockRequest({ method: 'POST', body })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(2); + expect(mockCreate).toHaveBeenCalledWith(body); + }); +}); + +describe('DELETE /api/v1/instances/[id]', () => { + it('deletes an instance', async () => { + mockDelete.mockResolvedValue(undefined as any); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockDelete).toHaveBeenCalledWith(1); + }); +}); + +describe('POST /api/v1/instances/sync', () => { + it('syncs instances and returns result', async () => { + const syncResult = { synced: 2, errors: [] }; + mockSync.mockResolvedValue(syncResult as any); + + const response = await syncPOST(createMockRequest({ method: 'POST' })); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(syncResult); + expect(mockSync).toHaveBeenCalled(); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await syncPOST(createMockRequest({ method: 'POST' })); + expect(response.status).toBe(401); + }); +}); diff --git a/tests/unit/api-routes/l4-proxy-hosts.test.ts b/tests/unit/api-routes/l4-proxy-hosts.test.ts new file mode 100644 index 00000000..1e279f02 --- /dev/null +++ b/tests/unit/api-routes/l4-proxy-hosts.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/models/l4-proxy-hosts', () => ({ + listL4ProxyHosts: vi.fn(), + createL4ProxyHost: vi.fn(), + getL4ProxyHost: vi.fn(), + updateL4ProxyHost: vi.fn(), + deleteL4ProxyHost: vi.fn(), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET as listGET, POST } from '@/app/api/v1/l4-proxy-hosts/route'; +import { GET as getGET, PUT, DELETE } from '@/app/api/v1/l4-proxy-hosts/[id]/route'; +import { listL4ProxyHosts, createL4ProxyHost, getL4ProxyHost, updateL4ProxyHost, deleteL4ProxyHost } from '@/src/lib/models/l4-proxy-hosts'; +import { requireApiAdmin } from '@/src/lib/api-auth'; + +const mockList = vi.mocked(listL4ProxyHosts); +const mockCreate = vi.mocked(createL4ProxyHost); +const mockGet = vi.mocked(getL4ProxyHost); +const mockUpdate = vi.mocked(updateL4ProxyHost); +const mockDelete = vi.mocked(deleteL4ProxyHost); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); + +function createMockRequest(options: { method?: string; body?: unknown } = {}): any { + return { + headers: { get: () => null }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/l4-proxy-hosts', searchParams: new URLSearchParams() }, + json: async () => options.body ?? {}, + }; +} + +const sampleHost = { + id: 1, + name: 'SSH Forward', + listen_port: 2222, + forward_host: '10.0.0.5', + forward_port: 22, + protocol: 'tcp', + enabled: true, + created_at: '2026-01-01', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/l4-proxy-hosts', () => { + it('returns list of L4 proxy hosts', async () => { + mockList.mockResolvedValue([sampleHost] as any); + + const response = await listGET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual([sampleHost]); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await listGET(createMockRequest()); + expect(response.status).toBe(401); + }); +}); + +describe('POST /api/v1/l4-proxy-hosts', () => { + it('creates an L4 proxy host and returns 201', async () => { + const body = { name: 'New L4', listen_port: 3333, forward_host: '10.0.0.6', forward_port: 33 }; + mockCreate.mockResolvedValue({ id: 2, ...body } as any); + + const response = await POST(createMockRequest({ method: 'POST', body })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(2); + expect(mockCreate).toHaveBeenCalledWith(body, 1); + }); +}); + +describe('GET /api/v1/l4-proxy-hosts/[id]', () => { + it('returns an L4 proxy host by id', async () => { + mockGet.mockResolvedValue(sampleHost as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(sampleHost); + }); + + it('returns 404 for non-existent host', async () => { + mockGet.mockResolvedValue(null as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Not found'); + }); +}); + +describe('PUT /api/v1/l4-proxy-hosts/[id]', () => { + it('updates an L4 proxy host', async () => { + const body = { listen_port: 4444 }; + mockUpdate.mockResolvedValue({ ...sampleHost, listen_port: 4444 } as any); + + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.listen_port).toBe(4444); + expect(mockUpdate).toHaveBeenCalledWith(1, body, 1); + }); +}); + +describe('DELETE /api/v1/l4-proxy-hosts/[id]', () => { + it('deletes an L4 proxy host', async () => { + mockDelete.mockResolvedValue(undefined as any); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockDelete).toHaveBeenCalledWith(1, 1); + }); +}); diff --git a/tests/unit/api-routes/proxy-hosts.test.ts b/tests/unit/api-routes/proxy-hosts.test.ts new file mode 100644 index 00000000..619050e1 --- /dev/null +++ b/tests/unit/api-routes/proxy-hosts.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/models/proxy-hosts', () => ({ + listProxyHosts: vi.fn(), + createProxyHost: vi.fn(), + getProxyHost: vi.fn(), + updateProxyHost: vi.fn(), + deleteProxyHost: vi.fn(), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET as listGET, POST } from '@/app/api/v1/proxy-hosts/route'; +import { GET as getGET, PUT, DELETE } from '@/app/api/v1/proxy-hosts/[id]/route'; +import { listProxyHosts, createProxyHost, getProxyHost, updateProxyHost, deleteProxyHost } from '@/src/lib/models/proxy-hosts'; +import { requireApiAdmin } from '@/src/lib/api-auth'; + +const mockListProxyHosts = vi.mocked(listProxyHosts); +const mockCreateProxyHost = vi.mocked(createProxyHost); +const mockGetProxyHost = vi.mocked(getProxyHost); +const mockUpdateProxyHost = vi.mocked(updateProxyHost); +const mockDeleteProxyHost = vi.mocked(deleteProxyHost); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); + +function createMockRequest(options: { method?: string; body?: unknown } = {}): any { + return { + headers: { get: () => null }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/proxy-hosts', searchParams: new URLSearchParams() }, + json: async () => options.body ?? {}, + }; +} + +const sampleHost = { + id: 1, + domains: ['example.com'], + forward_host: '10.0.0.1', + forward_port: 8080, + forward_scheme: 'http', + enabled: true, + created_at: '2026-01-01', + updated_at: '2026-01-01', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/proxy-hosts', () => { + it('returns list of proxy hosts', async () => { + mockListProxyHosts.mockResolvedValue([sampleHost] as any); + + const response = await listGET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual([sampleHost]); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await listGET(createMockRequest()); + expect(response.status).toBe(401); + }); +}); + +describe('POST /api/v1/proxy-hosts', () => { + it('creates a proxy host and returns 201', async () => { + const body = { domains: ['new.example.com'], forward_host: '10.0.0.2', forward_port: 3000 }; + mockCreateProxyHost.mockResolvedValue({ id: 2, ...body } as any); + + const response = await POST(createMockRequest({ method: 'POST', body })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.id).toBe(2); + expect(mockCreateProxyHost).toHaveBeenCalledWith(body, 1); + }); +}); + +describe('GET /api/v1/proxy-hosts/[id]', () => { + it('returns a proxy host by id', async () => { + mockGetProxyHost.mockResolvedValue(sampleHost as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(sampleHost); + expect(mockGetProxyHost).toHaveBeenCalledWith(1); + }); + + it('returns 404 for non-existent host', async () => { + mockGetProxyHost.mockResolvedValue(null as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Not found'); + }); +}); + +describe('PUT /api/v1/proxy-hosts/[id]', () => { + it('updates a proxy host', async () => { + const body = { forward_port: 9090 }; + const updated = { ...sampleHost, forward_port: 9090 }; + mockUpdateProxyHost.mockResolvedValue(updated as any); + + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.forward_port).toBe(9090); + expect(mockUpdateProxyHost).toHaveBeenCalledWith(1, body, 1); + }); +}); + +describe('DELETE /api/v1/proxy-hosts/[id]', () => { + it('deletes a proxy host', async () => { + mockDeleteProxyHost.mockResolvedValue(undefined as any); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockDeleteProxyHost).toHaveBeenCalledWith(1, 1); + }); +}); diff --git a/tests/unit/api-routes/settings.test.ts b/tests/unit/api-routes/settings.test.ts new file mode 100644 index 00000000..b3e5d09a --- /dev/null +++ b/tests/unit/api-routes/settings.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/settings', () => ({ + getGeneralSettings: vi.fn(), + saveGeneralSettings: vi.fn(), + getCloudflareSettings: vi.fn(), + saveCloudflareSettings: vi.fn(), + getAuthentikSettings: vi.fn(), + saveAuthentikSettings: vi.fn(), + getMetricsSettings: vi.fn(), + saveMetricsSettings: vi.fn(), + getLoggingSettings: vi.fn(), + saveLoggingSettings: vi.fn(), + getDnsSettings: vi.fn(), + saveDnsSettings: vi.fn(), + getUpstreamDnsResolutionSettings: vi.fn(), + saveUpstreamDnsResolutionSettings: vi.fn(), + getGeoBlockSettings: vi.fn(), + saveGeoBlockSettings: vi.fn(), + getWafSettings: vi.fn(), + saveWafSettings: vi.fn(), +})); + +vi.mock('@/src/lib/instance-sync', () => ({ + getInstanceMode: vi.fn(), + setInstanceMode: vi.fn(), + getSlaveMasterToken: vi.fn(), + setSlaveMasterToken: vi.fn(), +})); + +vi.mock('@/src/lib/caddy', () => ({ + applyCaddyConfig: vi.fn().mockResolvedValue({ ok: true }), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET, PUT } from '@/app/api/v1/settings/[group]/route'; +import { getGeneralSettings, saveGeneralSettings } from '@/src/lib/settings'; +import { getInstanceMode, setInstanceMode, getSlaveMasterToken, setSlaveMasterToken } from '@/src/lib/instance-sync'; +import { applyCaddyConfig } from '@/src/lib/caddy'; +import { requireApiAdmin } from '@/src/lib/api-auth'; + +const mockGetGeneral = vi.mocked(getGeneralSettings); +const mockSaveGeneral = vi.mocked(saveGeneralSettings); +const mockGetInstanceMode = vi.mocked(getInstanceMode); +const mockSetInstanceMode = vi.mocked(setInstanceMode); +const mockGetSlaveMasterToken = vi.mocked(getSlaveMasterToken); +const mockSetSlaveMasterToken = vi.mocked(setSlaveMasterToken); +const mockApplyCaddyConfig = vi.mocked(applyCaddyConfig); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); + +function createMockRequest(options: { method?: string; body?: unknown } = {}): any { + return { + headers: { get: () => null }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/settings/general', searchParams: new URLSearchParams() }, + json: async () => options.body ?? {}, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/settings/[group]', () => { + it('returns general settings', async () => { + const settings = { site_name: 'My Proxy', admin_email: 'admin@example.com' }; + mockGetGeneral.mockResolvedValue(settings as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'general' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(settings); + }); + + it('returns empty object when settings are null', async () => { + mockGetGeneral.mockResolvedValue(null as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'general' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({}); + }); + + it('returns instance mode', async () => { + mockGetInstanceMode.mockResolvedValue('standalone' as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'instance-mode' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ mode: 'standalone' }); + }); + + it('returns sync-token status', async () => { + mockGetSlaveMasterToken.mockResolvedValue('some-token' as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'sync-token' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ has_token: true }); + }); + + it('returns has_token false when no token', async () => { + mockGetSlaveMasterToken.mockResolvedValue(null as any); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'sync-token' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ has_token: false }); + }); + + it('returns 404 for unknown settings group', async () => { + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'unknown' }) }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Unknown settings group'); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await GET(createMockRequest(), { params: Promise.resolve({ group: 'general' }) }); + expect(response.status).toBe(401); + }); +}); + +describe('PUT /api/v1/settings/[group]', () => { + it('saves general settings and applies caddy config', async () => { + mockSaveGeneral.mockResolvedValue(undefined); + + const body = { site_name: 'Updated Proxy' }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'general' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSaveGeneral).toHaveBeenCalledWith(body); + expect(mockApplyCaddyConfig).toHaveBeenCalled(); + }); + + it('sets instance mode', async () => { + mockSetInstanceMode.mockResolvedValue(undefined as any); + + const body = { mode: 'master' }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'instance-mode' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSetInstanceMode).toHaveBeenCalledWith('master'); + }); + + it('sets sync token', async () => { + mockSetSlaveMasterToken.mockResolvedValue(undefined as any); + + const body = { token: 'new-sync-token' }; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'sync-token' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockSetSlaveMasterToken).toHaveBeenCalledWith('new-sync-token'); + }); + + it('clears sync token when null', async () => { + mockSetSlaveMasterToken.mockResolvedValue(undefined as any); + + const body = {}; + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ group: 'sync-token' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(mockSetSlaveMasterToken).toHaveBeenCalledWith(null); + }); + + it('returns 404 for unknown settings group', async () => { + const response = await PUT(createMockRequest({ method: 'PUT', body: {} }), { params: Promise.resolve({ group: 'unknown' }) }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Unknown settings group'); + }); + + it('still returns ok even if applyCaddyConfig fails', async () => { + mockSaveGeneral.mockResolvedValue(undefined); + mockApplyCaddyConfig.mockRejectedValue(new Error('caddy down')); + + const response = await PUT(createMockRequest({ method: 'PUT', body: { site_name: 'Test' } }), { params: Promise.resolve({ group: 'general' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + }); +}); diff --git a/tests/unit/api-routes/tokens.test.ts b/tests/unit/api-routes/tokens.test.ts new file mode 100644 index 00000000..d6afb6b5 --- /dev/null +++ b/tests/unit/api-routes/tokens.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/models/api-tokens', () => ({ + createApiToken: vi.fn(), + listApiTokens: vi.fn(), + listAllApiTokens: vi.fn(), + deleteApiToken: vi.fn(), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET, POST } from '@/app/api/v1/tokens/route'; +import { DELETE } from '@/app/api/v1/tokens/[id]/route'; +import { createApiToken, listApiTokens, listAllApiTokens, deleteApiToken } from '@/src/lib/models/api-tokens'; +import { requireApiUser } from '@/src/lib/api-auth'; + +const mockCreateApiToken = vi.mocked(createApiToken); +const mockListApiTokens = vi.mocked(listApiTokens); +const mockListAllApiTokens = vi.mocked(listAllApiTokens); +const mockDeleteApiToken = vi.mocked(deleteApiToken); +const mockRequireApiUser = vi.mocked(requireApiUser); + +function createMockRequest(options: { method?: string; body?: unknown; authorization?: string; searchParams?: string } = {}): any { + return { + headers: { + get(name: string) { + if (name === 'authorization') return options.authorization ?? 'Bearer test-token'; + return null; + }, + }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/tokens', searchParams: new URLSearchParams(options.searchParams ?? '') }, + json: async () => options.body ?? {}, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiUser.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/tokens', () => { + it('returns all tokens for admin', async () => { + const tokens = [ + { id: 1, name: 'Token 1', created_by: 1, created_at: '2026-01-01', last_used_at: null, expires_at: null }, + { id: 2, name: 'Token 2', created_by: 2, created_at: '2026-01-02', last_used_at: null, expires_at: null }, + ]; + mockListAllApiTokens.mockResolvedValue(tokens as any); + + const response = await GET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(tokens); + expect(mockListAllApiTokens).toHaveBeenCalled(); + expect(mockListApiTokens).not.toHaveBeenCalled(); + }); + + it('returns own tokens for non-admin user', async () => { + mockRequireApiUser.mockResolvedValue({ userId: 5, role: 'user', authMethod: 'bearer' }); + const tokens = [{ id: 3, name: 'My Token', created_by: 5, created_at: '2026-01-01', last_used_at: null, expires_at: null }]; + mockListApiTokens.mockResolvedValue(tokens as any); + + const response = await GET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(tokens); + expect(mockListApiTokens).toHaveBeenCalledWith(5); + expect(mockListAllApiTokens).not.toHaveBeenCalled(); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiUser.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await GET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe('Unauthorized'); + }); +}); + +describe('POST /api/v1/tokens', () => { + it('creates a token and returns 201', async () => { + const tokenResult = { + token: { id: 10, name: 'New Token', created_by: 1, created_at: '2026-01-01', last_used_at: null, expires_at: null }, + rawToken: 'cpm_raw_token_abc123', + }; + mockCreateApiToken.mockResolvedValue(tokenResult as any); + + const response = await POST(createMockRequest({ method: 'POST', body: { name: 'New Token' } })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.raw_token).toBe('cpm_raw_token_abc123'); + expect(data.token).toEqual(tokenResult.token); + expect(mockCreateApiToken).toHaveBeenCalledWith('New Token', 1, undefined); + }); + + it('creates a token with expires_at', async () => { + const tokenResult = { + token: { id: 11, name: 'Expiring Token', created_by: 1, created_at: '2026-01-01', last_used_at: null, expires_at: '2027-01-01' }, + rawToken: 'cpm_raw_token_xyz', + }; + mockCreateApiToken.mockResolvedValue(tokenResult as any); + + const response = await POST(createMockRequest({ method: 'POST', body: { name: 'Expiring Token', expires_at: '2027-01-01' } })); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(mockCreateApiToken).toHaveBeenCalledWith('Expiring Token', 1, '2027-01-01'); + }); + + it('returns 400 when name is missing', async () => { + const response = await POST(createMockRequest({ method: 'POST', body: {} })); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('name is required'); + }); + + it('returns 400 when name is not a string', async () => { + const response = await POST(createMockRequest({ method: 'POST', body: { name: 123 } })); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe('name is required'); + }); +}); + +describe('DELETE /api/v1/tokens/[id]', () => { + it('deletes a token and returns ok', async () => { + mockDeleteApiToken.mockResolvedValue(undefined as any); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '5' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ ok: true }); + expect(mockDeleteApiToken).toHaveBeenCalledWith(5, 1); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiUser.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await DELETE(createMockRequest({ method: 'DELETE' }), { params: Promise.resolve({ id: '5' }) }); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe('Unauthorized'); + }); +}); diff --git a/tests/unit/api-routes/users.test.ts b/tests/unit/api-routes/users.test.ts new file mode 100644 index 00000000..56a0f1ea --- /dev/null +++ b/tests/unit/api-routes/users.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/src/lib/models/user', () => ({ + listUsers: vi.fn(), + getUserById: vi.fn(), + updateUserProfile: vi.fn(), +})); + +vi.mock('@/src/lib/api-auth', () => { + const ApiAuthError = class extends Error { + status: number; + constructor(msg: string, status: number) { super(msg); this.status = status; this.name = 'ApiAuthError'; } + }; + return { + requireApiAdmin: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + requireApiUser: vi.fn().mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }), + apiErrorResponse: vi.fn((error: unknown) => { + const { NextResponse } = require('next/server'); + if (error && typeof error === 'object' && 'status' in error) { + return NextResponse.json({ error: (error as Error).message }, { status: (error as any).status }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 }); + }), + ApiAuthError, + }; +}); + +import { GET as listGET } from '@/app/api/v1/users/route'; +import { GET as getGET, PUT } from '@/app/api/v1/users/[id]/route'; +import { listUsers, getUserById, updateUserProfile } from '@/src/lib/models/user'; +import { requireApiAdmin, requireApiUser } from '@/src/lib/api-auth'; + +const mockListUsers = vi.mocked(listUsers); +const mockGetUserById = vi.mocked(getUserById); +const mockUpdateUserProfile = vi.mocked(updateUserProfile); +const mockRequireApiAdmin = vi.mocked(requireApiAdmin); +const mockRequireApiUser = vi.mocked(requireApiUser); + +function createMockRequest(options: { method?: string; body?: unknown } = {}): any { + return { + headers: { get: () => null }, + method: options.method ?? 'GET', + nextUrl: { pathname: '/api/v1/users', searchParams: new URLSearchParams() }, + json: async () => options.body ?? {}, + }; +} + +const sampleUser = { + id: 1, + name: 'Admin User', + email: 'admin@example.com', + role: 'admin', + password_hash: '$2b$10$hashedpassword', + created_at: '2026-01-01', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequireApiAdmin.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); + mockRequireApiUser.mockResolvedValue({ userId: 1, role: 'admin', authMethod: 'bearer' }); +}); + +describe('GET /api/v1/users', () => { + it('returns list of users with password_hash stripped', async () => { + mockListUsers.mockResolvedValue([sampleUser] as any); + + const response = await listGET(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveLength(1); + expect(data[0]).not.toHaveProperty('password_hash'); + expect(data[0].name).toBe('Admin User'); + expect(data[0].email).toBe('admin@example.com'); + }); + + it('returns 401 on auth failure', async () => { + const { ApiAuthError } = await import('@/src/lib/api-auth'); + mockRequireApiAdmin.mockRejectedValue(new ApiAuthError('Unauthorized', 401)); + + const response = await listGET(createMockRequest()); + expect(response.status).toBe(401); + }); +}); + +describe('GET /api/v1/users/[id]', () => { + it('returns a user by id with password_hash stripped', async () => { + mockGetUserById.mockResolvedValue(sampleUser as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).not.toHaveProperty('password_hash'); + expect(data.name).toBe('Admin User'); + }); + + it('returns 404 for non-existent user', async () => { + mockGetUserById.mockResolvedValue(null as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Not found'); + }); + + it('returns 403 when non-admin tries to view another user', async () => { + mockRequireApiUser.mockResolvedValue({ userId: 5, role: 'user', authMethod: 'bearer' }); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe('Forbidden'); + }); + + it('allows non-admin to view themselves', async () => { + mockRequireApiUser.mockResolvedValue({ userId: 5, role: 'user', authMethod: 'bearer' }); + const user = { ...sampleUser, id: 5, role: 'user' }; + mockGetUserById.mockResolvedValue(user as any); + + const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '5' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.id).toBe(5); + expect(data).not.toHaveProperty('password_hash'); + }); +}); + +describe('PUT /api/v1/users/[id]', () => { + it('updates a user', async () => { + const body = { name: 'Updated Name' }; + const updated = { ...sampleUser, name: 'Updated Name' }; + mockUpdateUserProfile.mockResolvedValue(updated as any); + + const response = await PUT(createMockRequest({ method: 'PUT', body }), { params: Promise.resolve({ id: '1' }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.name).toBe('Updated Name'); + expect(data).not.toHaveProperty('password_hash'); + expect(mockUpdateUserProfile).toHaveBeenCalledWith(1, body); + }); + + it('returns 404 when updating non-existent user', async () => { + mockUpdateUserProfile.mockResolvedValue(null as any); + + const response = await PUT(createMockRequest({ method: 'PUT', body: { name: 'X' } }), { params: Promise.resolve({ id: '999' }) }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Not found'); + }); +});