Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions __tests__/csp-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
134 changes: 134 additions & 0 deletions __tests__/profile.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
93 changes: 93 additions & 0 deletions app/api/user/profile/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 }
);
}
}
Loading
Loading