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:
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user