diff --git a/__tests__/csp-middleware.test.ts b/__tests__/csp-middleware.test.ts new file mode 100644 index 00000000..56a05160 --- /dev/null +++ b/__tests__/csp-middleware.test.ts @@ -0,0 +1,85 @@ +import { describe, it, assert } from "vitest"; +import { NextRequest } from "next/server"; +import { middleware } from "@/middleware"; +import { CSRF_COOKIE_NAME } from "@/lib/auth/csrf"; + +describe("Content Security Policy Middleware Tests", () => { + it("should inject Content-Security-Policy header into the response", () => { + const req = new NextRequest("http://localhost/dashboard", { + method: "GET", + }); + + const res = middleware(req); + assert.ok(res, "Middleware must return a response"); + + const csp = res.headers.get("content-security-policy"); + assert.ok(csp, "Response must contain Content-Security-Policy header"); + assert.strictEqual(csp.includes("default-src 'self'"), true, "CSP should contain default-src 'self'"); + assert.strictEqual( + csp.includes("connect-src 'self' https://horizon-testnet.stellar.org https://soroban-testnet.stellar.org"), + true, + "CSP should contain authorized connect-src URLs" + ); + assert.strictEqual( + csp.includes("img-src 'self' data: https://images.unsplash.com https://i.pravatar.cc"), + true, + "CSP should contain authorized img-src URLs" + ); + }); + + it("should generate a random base64 nonce and replace the placeholder", () => { + const req = new NextRequest("http://localhost/dashboard", { + method: "GET", + }); + + const res = middleware(req); + const csp = res.headers.get("content-security-policy"); + assert.ok(csp); + + // It should not contain the literal placeholder {nonce} + assert.strictEqual(csp.includes("{nonce}"), false, "Should not contain the raw {nonce} placeholder"); + + // It should contain 'nonce-...' + const nonceMatch = csp.match(/'nonce-([^']+)'/); + assert.ok(nonceMatch, "CSP must contain a nonce matching 'nonce-...'"); + + const nonceValue = nonceMatch[1]; + assert.ok(nonceValue, "Nonce value must not be empty"); + assert.strictEqual(nonceValue.length > 10, true, "Nonce value should have sufficient length"); + }); + + it("should generate unique nonces for separate requests", () => { + const req1 = new NextRequest("http://localhost/dashboard", { method: "GET" }); + const req2 = new NextRequest("http://localhost/dashboard", { method: "GET" }); + + const res1 = middleware(req1); + const res2 = middleware(req2); + + const csp1 = res1.headers.get("content-security-policy"); + const csp2 = res2.headers.get("content-security-policy"); + + assert.ok(csp1); + assert.ok(csp2); + + const nonce1 = csp1.match(/'nonce-([^']+)'/)?.[1]; + const nonce2 = csp2.match(/'nonce-([^']+)'/)?.[1]; + + assert.ok(nonce1); + assert.ok(nonce2); + assert.notStrictEqual(nonce1, nonce2, "Nonces generated for separate requests must be unique"); + }); + + it("should set CSRF token cookie on GET requests to /login if missing", () => { + const req = new NextRequest("http://localhost/login", { + method: "GET", + }); + + const res = middleware(req); + assert.ok(res); + + // CSRF cookie should be set + const setCookie = res.headers.get("set-cookie"); + assert.ok(setCookie, "Response should set cookies"); + assert.strictEqual(setCookie.includes(CSRF_COOKIE_NAME), true, "Response should set CSRF cookie"); + }); +}); diff --git a/__tests__/profile.test.ts b/__tests__/profile.test.ts new file mode 100644 index 00000000..4c5cdffe --- /dev/null +++ b/__tests__/profile.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment node +import { PATCH } from "@/app/api/user/profile/route"; +import { describe, it, expect, beforeEach, vi, Mock } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/users", () => ({ + findUserById: vi.fn(), + findUserByEmail: vi.fn(), + updateUserProfile: vi.fn(), + toPublicUser: (u: any) => ({ id: u.id, email: u.email, name: u.name }), +})); + +vi.mock("@/lib/auth/jwt", () => ({ + verifyToken: vi.fn(), + signToken: vi.fn(), +})); + +import { findUserById, findUserByEmail, updateUserProfile } from "@/lib/auth/users"; +import { verifyToken, signToken } from "@/lib/auth/jwt"; + +describe("PATCH /api/user/profile", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return 401 if auth token is missing", async () => { + const req = new NextRequest("http://localhost:3000/api/user/profile", { + method: "PATCH", + body: JSON.stringify({ name: "New Name", email: "new@example.com" }), + }); + + const res = await PATCH(req); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 401 if auth token is invalid", async () => { + (verifyToken as Mock).mockResolvedValue(null); + + const req = new NextRequest("http://localhost:3000/api/user/profile", { + method: "PATCH", + headers: { + Cookie: "auth_token=invalid-token", + }, + body: JSON.stringify({ name: "New Name", email: "new@example.com" }), + }); + + const res = await PATCH(req); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toBe("Unauthorized"); + }); + + it("should return 400 if name is too short", async () => { + (verifyToken as Mock).mockResolvedValue({ userId: "user-123" }); + + const req = new NextRequest("http://localhost:3000/api/user/profile", { + method: "PATCH", + headers: { + Cookie: "auth_token=valid-token", + }, + body: JSON.stringify({ name: "a", email: "new@example.com" }), + }); + + const res = await PATCH(req); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe("Name must be at least 2 characters"); + }); + + it("should return 400 if email is invalid", async () => { + (verifyToken as Mock).mockResolvedValue({ userId: "user-123" }); + + const req = new NextRequest("http://localhost:3000/api/user/profile", { + method: "PATCH", + headers: { + Cookie: "auth_token=valid-token", + }, + body: JSON.stringify({ name: "New Name", email: "invalid-email" }), + }); + + const res = await PATCH(req); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe("Invalid email address"); + }); + + it("should return 409 if email is already in use by another user", async () => { + (verifyToken as Mock).mockResolvedValue({ userId: "user-123" }); + (findUserById as Mock).mockReturnValue({ id: "user-123", email: "old@example.com", name: "Old Name" }); + (findUserByEmail as Mock).mockReturnValue({ id: "user-456", email: "conflict@example.com", name: "Other User" }); + + const req = new NextRequest("http://localhost:3000/api/user/profile", { + method: "PATCH", + headers: { + Cookie: "auth_token=valid-token", + }, + body: JSON.stringify({ name: "New Name", email: "conflict@example.com" }), + }); + + const res = await PATCH(req); + expect(res.status).toBe(409); + const data = await res.json(); + expect(data.error).toBe("Email is already in use"); + }); + + it("should successfully update profile and return updated public user data with cookie", async () => { + (verifyToken as Mock).mockResolvedValue({ userId: "user-123" }); + (findUserById as Mock).mockReturnValue({ id: "user-123", email: "old@example.com", name: "Old Name" }); + (findUserByEmail as Mock).mockReturnValue(null); + (updateUserProfile as Mock).mockReturnValue({ id: "user-123", email: "new@example.com", name: "New Name" }); + (signToken as Mock).mockResolvedValue("new-jwt-token"); + + const req = new NextRequest("http://localhost:3000/api/user/profile", { + method: "PATCH", + headers: { + Cookie: "auth_token=valid-token", + }, + body: JSON.stringify({ name: "New Name", email: "new@example.com" }), + }); + + const res = await PATCH(req); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.user).toEqual({ id: "user-123", email: "new@example.com", name: "New Name" }); + expect(updateUserProfile).toHaveBeenCalledWith("user-123", "New Name", "new@example.com"); + + // Check that cookie was set in the response headers + const cookie = res.headers.get("set-cookie"); + expect(cookie).toContain("auth_token=new-jwt-token"); + }); +}); diff --git a/app/api/user/profile/route.ts b/app/api/user/profile/route.ts new file mode 100644 index 00000000..bed95d16 --- /dev/null +++ b/app/api/user/profile/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyToken, signToken } from "@/lib/auth/jwt"; +import { findUserById, findUserByEmail, updateUserProfile, toPublicUser } from "@/lib/auth/users"; +import { sanitizeEmail, sanitizeName } from "@/lib/auth/sanitize"; + +const COOKIE_OPTS = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + maxAge: 60 * 60 * 24 * 7, + path: "/", +}; + +export async function PATCH(req: NextRequest) { + // Extract and verify auth token + const token = req.cookies.get("auth_token")?.value; + if (!token) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const payload = await verifyToken(token); + if (!payload) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Parse request body + const body = await req.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const { name: rawName, email: rawEmail } = body as Record; + const name = sanitizeName(rawName); + const email = sanitizeEmail(rawEmail); + + // Field validation + if (!name || !email) { + return NextResponse.json( + { error: "Name and email are required" }, + { status: 400 } + ); + } + + if (name.length < 2) { + return NextResponse.json( + { error: "Name must be at least 2 characters" }, + { status: 400 } + ); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json({ error: "Invalid email address" }, { status: 400 }); + } + + // Retrieve user to make sure they exist + const user = findUserById(payload.userId); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Verify email uniqueness if it's changing + const existingUser = findUserByEmail(email); + if (existingUser && existingUser.id !== payload.userId) { + return NextResponse.json( + { error: "Email is already in use" }, + { status: 409 } + ); + } + + try { + // Persist profile updates + const updatedUser = updateUserProfile(payload.userId, name, email); + + // Sign and issue a fresh JWT cookie to sync local cookies + const newToken = await signToken({ + userId: updatedUser.id, + email: updatedUser.email, + name: updatedUser.name, + }); + + const res = NextResponse.json({ user: toPublicUser(updatedUser) }); + res.cookies.set("auth_token", newToken, COOKIE_OPTS); + + return res; + } catch (err) { + console.error("Profile update error:", err); + return NextResponse.json( + { error: "Failed to update profile" }, + { status: 500 } + ); + } +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index cfc304a9..cec33ce6 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -426,6 +426,110 @@ function PrivacySection({ ); } +// ─── Profile section ───────────────────────────────────────────────────────── + +function ProfileSection({ + initialName, + initialEmail, +}: { + initialName: string; + initialEmail: string; +}) { + const toast = useToast(); + const [name, setName] = useState(initialName); + const [email, setEmail] = useState(initialEmail); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + + const trimmedName = name.trim(); + const trimmedEmail = email.trim(); + + if (trimmedName.length < 2) { + setError("Name must be at least 2 characters."); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(trimmedEmail)) { + setError("Invalid email address."); + return; + } + + try { + setIsSubmitting(true); + const res = await fetch("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: trimmedName, email: trimmedEmail }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error(data.error ?? "Failed to update profile."); + } + + toast.success("Profile updated successfully."); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + setError(message); + toast.error(message); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ + setName(e.target.value)} + placeholder="e.g. John Doe" + className={inputClass} + required + /> + + + setEmail(e.target.value)} + placeholder="e.g. john@example.com" + className={inputClass} + required + /> + + + {error && ( +
+ + {error} +
+ )} + + +
+
+ ); +} + // ─── Password section ──────────────────────────────────────────────────────── function PasswordSection() { @@ -658,7 +762,8 @@ export default function SettingsPage() { onDismiss={dismissBanner} /> - + + diff --git a/middleware.ts b/middleware.ts index f34dc71f..3c9cf5cc 100644 --- a/middleware.ts +++ b/middleware.ts @@ -93,3 +93,4 @@ export async function middleware(request: NextRequest) { export const config = { matcher: "/api/:path*", }; + diff --git a/next.config.js b/next.config.js index 4edcaede..d67ebe61 100644 --- a/next.config.js +++ b/next.config.js @@ -44,6 +44,10 @@ const securityHeaders = [ key: "Permissions-Policy", value: "camera=(), microphone=()", }, + { + key: "Content-Security-Policy", + value: "default-src 'self'; script-src 'self' 'nonce-{nonce}'; connect-src 'self' https://horizon-testnet.stellar.org https://soroban-testnet.stellar.org; img-src 'self' data: https://images.unsplash.com https://i.pravatar.cc", + }, ]; /**