feat: add comprehensive REST API with token auth, OpenAPI docs, and full test coverage

- API token model (SHA-256 hashed, debounced lastUsedAt) with Bearer auth
- Dual auth middleware (session + API token) in src/lib/api-auth.ts
- 23 REST endpoints under /api/v1/ covering all functionality:
  tokens, proxy-hosts, l4-proxy-hosts, certificates, ca-certificates,
  client-certificates, access-lists, settings, instances, users,
  audit-log, caddy/apply
- OpenAPI 3.1 spec at /api/v1/openapi.json with fully typed schemas
- Swagger UI docs page at /api-docs in the dashboard
- API token management integrated into the Profile page
- Fix: next build now works under Node.js (bun:sqlite aliased to better-sqlite3)
- 89 new API route unit tests + 11 integration tests (592 total)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-26 09:45:45 +01:00
parent 0acb430ebb
commit de28478a42
49 changed files with 5160 additions and 6 deletions
@@ -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;
@@ -0,0 +1,66 @@
"use client";
import { useEffect, useRef } from "react";
export default function ApiDocsClient() {
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className="w-full min-h-[600px] -mx-4 md:-mx-8 -my-6 px-4 md:px-8 py-6"
/>
);
}
+12
View File
@@ -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 <ApiDocsClient />;
}
+28
View File
@@ -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");
}
+152 -2
View File
@@ -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<string | null>(null);
const [loading, setLoading] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [newToken, setNewToken] = useState<string | null>(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
</CardContent>
</Card>
)}
{/* API Tokens */}
<Card>
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">API Tokens</h2>
</div>
<Separator />
<p className="text-sm text-muted-foreground">
Create tokens for programmatic access to the API using <code className="text-xs bg-muted px-1 py-0.5 rounded">Authorization: Bearer {'<token>'}</code>
</p>
{/* Newly created token */}
{newToken && (
<div className="rounded-lg border border-emerald-500/50 bg-emerald-500/5 p-4 flex flex-col gap-2">
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
Copy this token now it will not be shown again.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted/50 px-3 py-2 text-xs font-mono break-all select-all">
{newToken}
</code>
<Button variant="outline" size="sm" className="shrink-0 h-8 gap-1.5" onClick={handleCopyToken}>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
</div>
</div>
)}
{/* Existing tokens */}
{apiTokens.length > 0 && (
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
{apiTokens.map((token) => {
const expired = isExpired(token.expires_at);
return (
<div
key={token.id}
className={`flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors ${expired ? "opacity-60" : ""}`}
>
<div className="flex items-center gap-3 min-w-0">
<Key className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{token.name}</p>
{expired && (
<span className="inline-flex items-center gap-1 rounded-full border border-destructive/30 bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium text-destructive">
<AlertTriangle className="h-2.5 w-2.5" />
Expired
</span>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0">
<p className="text-xs text-muted-foreground">
Created {formatDate(token.created_at)}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
Used {formatDate(token.last_used_at)}
</p>
{token.expires_at && (
<p className="text-xs text-muted-foreground">
{expired ? "Expired" : "Expires"} {formatDate(token.expires_at)}
</p>
)}
</div>
</div>
</div>
<form action={deleteApiTokenAction.bind(null, token.id)}>
<Button type="submit" variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</form>
</div>
);
})}
</div>
)}
{apiTokens.length === 0 && !newToken && (
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-3 text-sm text-muted-foreground">
<Key className="h-4 w-4 shrink-0" />
No API tokens yet create one below.
</div>
)}
{/* Create new token */}
<form action={handleCreateToken} className="flex flex-col gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="token-name" className="text-xs">
Name <span className="text-destructive">*</span>
</Label>
<Input id="token-name" name="name" required placeholder="e.g. CI/CD Pipeline" className="h-8 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="token-expires" className="text-xs">Expires at</Label>
<Input id="token-expires" name="expires_at" type="datetime-local" className="h-8 text-sm" />
</div>
</div>
<div className="flex justify-end">
<Button type="submit" size="sm">
<Plus className="h-3.5 w-3.5 mr-1.5" />
Create Token
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
{/* Change Password Dialog */}
+8 -2
View File
@@ -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 (
<ProfileClient
user={user}
enabledProviders={enabledProviders}
apiTokens={apiTokens}
/>
);
}
@@ -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);
}
}
@@ -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);
}
}
+49
View File
@@ -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);
}
}
+24
View File
@@ -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);
}
}
+23
View File
@@ -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);
}
}
+49
View File
@@ -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);
}
}
+24
View File
@@ -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);
}
}
+13
View File
@@ -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);
}
}
+49
View File
@@ -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);
}
}
+24
View File
@@ -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);
}
}
@@ -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);
}
}
+24
View File
@@ -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);
}
}
+17
View File
@@ -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);
}
}
+24
View File
@@ -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);
}
}
+13
View File
@@ -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);
}
}
+49
View File
@@ -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);
}
}
+24
View File
@@ -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);
}
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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);
}
}
+24
View File
@@ -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);
}
}
+103
View File
@@ -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<unknown>;
save: (data: never) => Promise<void>;
applyCaddy?: boolean;
};
const SETTINGS_HANDLERS: Record<string, SettingsHandler> = {
general: { get: getGeneralSettings, save: saveGeneralSettings as (data: never) => Promise<void>, applyCaddy: true },
cloudflare: { get: getCloudflareSettings, save: saveCloudflareSettings as (data: never) => Promise<void>, applyCaddy: true },
authentik: { get: getAuthentikSettings, save: saveAuthentikSettings as (data: never) => Promise<void>, applyCaddy: true },
metrics: { get: getMetricsSettings, save: saveMetricsSettings as (data: never) => Promise<void>, applyCaddy: true },
logging: { get: getLoggingSettings, save: saveLoggingSettings as (data: never) => Promise<void>, applyCaddy: true },
dns: { get: getDnsSettings, save: saveDnsSettings as (data: never) => Promise<void>, applyCaddy: true },
"upstream-dns": { get: getUpstreamDnsResolutionSettings, save: saveUpstreamDnsResolutionSettings as (data: never) => Promise<void>, applyCaddy: true },
geoblock: { get: getGeoBlockSettings, save: saveGeoBlockSettings as (data: never) => Promise<void>, applyCaddy: true },
waf: { get: getWafSettings, save: saveWafSettings as (data: never) => Promise<void>, 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);
}
}
+17
View File
@@ -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);
}
}
+29
View File
@@ -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);
}
}
+50
View File
@@ -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<string, unknown>) {
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<string, unknown>));
} 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<string, unknown>));
} catch (error) {
return apiErrorResponse(error);
}
}
+18
View File
@@ -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<string, unknown>) {
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<string, unknown>)));
} catch (error) {
return apiErrorResponse(error);
}
}
+14 -1
View File
@@ -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'
+2 -1
View File
@@ -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();
}
+92
View File
@@ -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<ApiAuthResult> {
// 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<ApiAuthResult> {
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<ApiAuthResult> {
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 }
);
}
+143
View File
@@ -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<ApiToken[]> {
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<ApiToken[]> {
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<void> {
// 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 },
};
}
+193
View File
@@ -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<typeof users.$inferInsert> = {}) {
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<typeof apiTokens.$inferInsert> = {}) {
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');
});
});
+128
View File
@@ -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');
});
});
+183
View File
@@ -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);
});
});
+128
View File
@@ -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);
});
});
@@ -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);
});
});
+77
View File
@@ -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');
});
});
+146
View File
@@ -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);
});
});
@@ -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);
});
});
+134
View File
@@ -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);
});
});
@@ -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);
});
});
+150
View File
@@ -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);
});
});
+219
View File
@@ -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 });
});
});
+172
View File
@@ -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');
});
});
+156
View File
@@ -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');
});
});