drop case sensitive approach to the username and email across the app

This commit is contained in:
Mehmet Ince
2026-03-24 17:56:27 +00:00
parent 1464475df2
commit 30784e0740
11 changed files with 299 additions and 169 deletions
+120 -17
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Prisma } from "@prisma/client";
import { POST } from "@/app/api/auth/register/route";
import { db } from "@/lib/db";
import { getConfig } from "@/lib/config";
@@ -158,14 +159,15 @@ describe("POST /api/auth/register", () => {
});
describe("duplicate checks", () => {
it("should return 400 when email already exists", async () => {
// Mock: email check finds existing user
vi.mocked(db.user.findUnique).mockImplementation(async (args) => {
if (args?.where?.email) {
return { id: "1", email: "test@example.com" } as never;
}
return null;
});
it("should return 409 when email already exists", async () => {
// Mock: create throws P2002 unique violation on email
vi.mocked(db.user.create).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
meta: { target: ["email"] },
clientVersion: "5.0.0",
})
);
const request = createRequest({
name: "Test User",
@@ -177,14 +179,43 @@ describe("POST /api/auth/register", () => {
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(response.status).toBe(409);
expect(data.error).toBe("email_taken");
});
it("should return 400 when username already exists", async () => {
// Mock: email check passes, username check (case-insensitive via findFirst) finds existing user
vi.mocked(db.user.findUnique).mockResolvedValue(null);
vi.mocked(db.user.findFirst).mockResolvedValue({ id: "1", username: "testuser" } as never);
it("should return 409 for case-insensitive email collision", async () => {
// Mock: create throws P2002 unique violation on CI email index
vi.mocked(db.user.create).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
meta: { target: ["users_email_ci_unique"] },
clientVersion: "5.0.0",
})
);
const request = createRequest({
name: "Test User",
username: "testuser",
email: "Test@Example.COM",
password: "password123",
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(409);
expect(data.error).toBe("email_taken");
});
it("should return 409 when username already exists", async () => {
// Mock: create throws P2002 unique violation on username
vi.mocked(db.user.create).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
meta: { target: ["username"] },
clientVersion: "5.0.0",
})
);
const request = createRequest({
name: "Test User",
@@ -196,15 +227,52 @@ describe("POST /api/auth/register", () => {
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(409);
expect(data.error).toBe("username_taken");
});
it("should return 400 for uppercase username", async () => {
const request = createRequest({
name: "Test User",
username: "TestUser",
email: "test@example.com",
password: "password123",
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("validation_error");
});
it("should return 409 for case-insensitive username collision", async () => {
// Mock: create throws P2002 unique violation on CI username index
vi.mocked(db.user.create).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
meta: { target: ["users_username_ci_unique"] },
clientVersion: "5.0.0",
})
);
const request = createRequest({
name: "Test User",
username: "testuser",
email: "test@example.com",
password: "password123",
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(409);
expect(data.error).toBe("username_taken");
});
});
describe("successful registration", () => {
it("should create user and return user data", async () => {
vi.mocked(db.user.findUnique).mockResolvedValue(null);
vi.mocked(db.user.findFirst).mockResolvedValue(null);
vi.mocked(db.user.create).mockResolvedValue({
id: "user-123",
name: "Test User",
@@ -238,9 +306,44 @@ describe("POST /api/auth/register", () => {
expect(data.password).toBeUndefined(); // Password should not be returned
});
it("should lowercase and trim email and username before saving", async () => {
vi.mocked(db.user.create).mockResolvedValue({
id: "user-123",
name: "Test User",
username: "testuser",
email: "test@example.com",
password: "hashed_password",
emailVerified: null,
image: null,
role: "USER",
bio: null,
credits: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
const request = createRequest({
name: " Test User ",
username: " testuser ",
email: " Test@Example.COM ",
password: "password123",
});
const response = await POST(request);
expect(response.status).toBe(200);
expect(db.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: "Test User",
username: "testuser",
email: "test@example.com",
}),
})
);
});
it("should accept valid username with underscores", async () => {
vi.mocked(db.user.findUnique).mockResolvedValue(null);
vi.mocked(db.user.findFirst).mockResolvedValue(null);
vi.mocked(db.user.create).mockResolvedValue({
id: "user-123",
name: "Test User",
+22 -14
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Prisma } from "@prisma/client";
import { GET, PATCH } from "@/app/api/user/profile/route";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";
@@ -161,9 +162,15 @@ describe("PATCH /api/user/profile", () => {
expect(data.error).toBe("validation_error");
});
it("should return 400 if username is taken", async () => {
it("should return 409 if username is taken", async () => {
vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never);
vi.mocked(db.user.findFirst).mockResolvedValue({ id: "other-user" } as never);
vi.mocked(db.user.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
meta: { target: ["username"] },
clientVersion: "5.0.0",
})
);
const request = new Request("http://localhost:3000/api/user/profile", {
method: "PATCH",
@@ -173,28 +180,30 @@ describe("PATCH /api/user/profile", () => {
const response = await PATCH(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(response.status).toBe(409);
expect(data.error).toBe("username_taken");
});
it("should check username case-insensitively", async () => {
it("should return 409 for case-insensitive username collision", async () => {
vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never);
vi.mocked(db.user.findFirst).mockResolvedValue({ id: "other-user" } as never);
vi.mocked(db.user.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
meta: { target: ["users_username_ci_unique"] },
clientVersion: "5.0.0",
})
);
const request = new Request("http://localhost:3000/api/user/profile", {
method: "PATCH",
body: JSON.stringify({ name: "Test", username: "TakenUser" }),
body: JSON.stringify({ name: "Test", username: "takenuser" }),
});
const response = await PATCH(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(response.status).toBe(409);
expect(data.error).toBe("username_taken");
expect(db.user.findFirst).toHaveBeenCalledWith({
where: { username: { equals: "TakenUser", mode: "insensitive" } },
select: { id: true },
});
});
it("should allow keeping the same username", async () => {
@@ -217,13 +226,12 @@ describe("PATCH /api/user/profile", () => {
expect(response.status).toBe(200);
expect(data.name).toBe("Updated Name");
// Should NOT check for existing username when keeping the same one
expect(db.user.findFirst).not.toHaveBeenCalled();
// No pre-check needed — uniqueness enforced at DB level
expect(db.user.update).toHaveBeenCalled();
});
it("should update profile successfully", async () => {
vi.mocked(auth).mockResolvedValue({ user: { id: "user1", username: "olduser" } } as never);
vi.mocked(db.user.findFirst).mockResolvedValue(null); // Username not taken
vi.mocked(db.user.update).mockResolvedValue({
id: "user1",
name: "New Name",
+1 -1
View File
@@ -49,7 +49,7 @@ export default async function OGImage({ params }: { params: Promise<{ username:
const username = decodedUsername.slice(1);
const user = await db.user.findFirst({
where: { username: { equals: username, mode: "insensitive" } },
where: { username: username.toLowerCase() },
orderBy: { createdAt: "asc" },
select: {
id: true,
+2 -2
View File
@@ -37,7 +37,7 @@ export async function generateMetadata({ params }: UserProfilePageProps): Promis
const username = decodedUsername.slice(1);
const user = await db.user.findFirst({
where: { username: { equals: username, mode: "insensitive" } },
where: { username: username.toLowerCase() },
orderBy: { createdAt: "asc" },
select: { name: true, username: true },
});
@@ -72,7 +72,7 @@ export default async function UserProfilePage({ params, searchParams }: UserProf
const username = decodedUsername.slice(1);
const user = await db.user.findFirst({
where: { username: { equals: username, mode: "insensitive" } },
where: { username: username.toLowerCase() },
orderBy: { createdAt: "asc" },
select: {
id: true,
+1 -1
View File
@@ -150,7 +150,7 @@ export async function POST(request: NextRequest) {
let user = await db.user.findFirst({
where: {
OR: [
{ username: { equals: normalizedUsername, mode: "insensitive" } },
{ username: normalizedUsername },
{ email: pseudoEmail },
],
},
+38 -42
View File
@@ -2,12 +2,16 @@ import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { db } from "@/lib/db";
import { isUniqueConstraintViolation } from "@/lib/db-errors";
import { getConfig } from "@/lib/config";
// Trim before validation to prevent unicode/whitespace tricks that bypass uniqueness checks (e.g. "admin@x.com\u200B" vs "admin@x.com") on certain DBMS
const trimmed = z.preprocess((v) => (typeof v === "string" ? v.trim() : v), z.string());
const registerSchema = z.object({
name: z.string().min(2),
username: z.string().min(1).regex(/^[a-zA-Z0-9_]+$/),
email: z.string().email(),
name: trimmed.pipe(z.string().min(2)),
username: trimmed.pipe(z.string().min(1).max(30).regex(/^[a-z0-9_]+$/)),
email: trimmed.pipe(z.string().email()).transform((v) => v.toLowerCase()),
password: z.string().min(6),
});
@@ -34,49 +38,41 @@ export async function POST(request: Request) {
const { name, username, email, password } = parsed.data;
// Check if email already exists
const existingEmail = await db.user.findUnique({
where: { email },
});
if (existingEmail) {
return NextResponse.json(
{ error: "email_taken", message: "Email is already taken" },
{ status: 400 }
);
}
// Check if username already exists (case-insensitive)
const existingUsername = await db.user.findFirst({
where: { username: { equals: username, mode: "insensitive" } },
});
if (existingUsername) {
return NextResponse.json(
{ error: "username_taken", message: "Username is already taken" },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await db.user.create({
data: {
name,
username,
email,
password: hashedPassword,
},
});
// Atomic create — DB unique constraints enforce email and CI username uniqueness
try {
const user = await db.user.create({
data: {
name,
username,
email,
password: hashedPassword,
},
});
return NextResponse.json({
id: user.id,
name: user.name,
username: user.username,
email: user.email,
});
return NextResponse.json({
id: user.id,
name: user.name,
username: user.username,
email: user.email,
});
} catch (error) {
if (isUniqueConstraintViolation(error, "email")) {
return NextResponse.json(
{ error: "email_taken", message: "Email is already taken" },
{ status: 409 }
);
}
if (isUniqueConstraintViolation(error, "username")) {
return NextResponse.json(
{ error: "username_taken", message: "Username is already taken" },
{ status: 409 }
);
}
throw error;
}
} catch (error) {
console.error("Registration error:", error);
return NextResponse.json(
+31 -36
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { isUniqueConstraintViolation } from "@/lib/db-errors";
const customLinkSchema = z.object({
type: z.enum(["website", "github", "twitter", "linkedin", "instagram", "youtube", "twitch", "discord", "mastodon", "bluesky", "sponsor"]),
@@ -10,13 +11,12 @@ const customLinkSchema = z.object({
label: z.string().max(30).optional(),
});
// Trim before validation to prevent unicode/whitespace tricks that bypass uniqueness checks (e.g. "admin\u200B@x.com" vs "admin@x.com")
const trimmed = z.preprocess((v) => (typeof v === "string" ? v.trim() : v), z.string());
const updateProfileSchema = z.object({
name: z.string().min(1).max(100),
username: z
.string()
.min(1)
.max(30)
.regex(/^[a-zA-Z0-9_]+$/),
name: trimmed.pipe(z.string().min(1).max(100)),
username: trimmed.pipe(z.string().min(1).max(30).regex(/^[a-z0-9_]+$/)),
avatar: z.string().url().optional().or(z.literal("")),
bio: z.string().max(250).optional().or(z.literal("")),
customLinks: z.array(customLinkSchema).max(5).optional(),
@@ -44,43 +44,38 @@ export async function PATCH(request: NextRequest) {
const { name, username, avatar, bio, customLinks } = parsed.data;
// Check if username is taken by another user
if (username !== session.user.username) {
const existingUser = await db.user.findFirst({
where: { username: { equals: username, mode: "insensitive" } },
select: { id: true },
// Atomic update — DB-level CI unique index prevents collisions
try {
const user = await db.user.update({
where: { id: session.user.id },
data: {
name,
username,
avatar: avatar || null,
bio: bio || null,
customLinks: customLinks && customLinks.length > 0 ? customLinks : Prisma.DbNull,
},
select: {
id: true,
name: true,
username: true,
email: true,
avatar: true,
bio: true,
customLinks: true,
},
});
if (existingUser && existingUser.id !== session.user.id) {
return NextResponse.json(user);
} catch (error) {
if (isUniqueConstraintViolation(error, "username")) {
return NextResponse.json(
{ error: "username_taken", message: "This username is already taken" },
{ status: 400 }
{ status: 409 }
);
}
throw error;
}
// Update user
const user = await db.user.update({
where: { id: session.user.id },
data: {
name,
username,
avatar: avatar || null,
bio: bio || null,
customLinks: customLinks && customLinks.length > 0 ? customLinks : Prisma.DbNull,
},
select: {
id: true,
name: true,
username: true,
email: true,
avatar: true,
bio: true,
customLinks: true,
},
});
return NextResponse.json(user);
} catch (error) {
console.error("Update profile error:", error);
return NextResponse.json(
+1 -1
View File
@@ -22,7 +22,7 @@ import { analyticsAuth } from "@/lib/analytics";
const registerSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
username: z.string().min(1, "Username is required").regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
username: z.string().min(1, "Username is required").max(30, "Username must be at most 30 characters").regex(/^[a-z0-9_]+$/, "Username can only contain lowercase letters, numbers, and underscores"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
confirmPassword: z.string(),
+1 -1
View File
@@ -39,7 +39,7 @@ const profileSchema = z.object({
.string()
.min(1, "Username is required")
.max(30)
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
.regex(/^[a-z0-9_]+$/, "Username can only contain lowercase letters, numbers, and underscores"),
avatar: z.string().url().optional().or(z.literal("")),
bio: z.string().max(250).optional().or(z.literal("")),
customLinks: z.array(customLinkSchema).max(5).optional(),
+52 -54
View File
@@ -1,6 +1,7 @@
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { db } from "@/lib/db";
import { isUniqueConstraintViolation } from "@/lib/db-errors";
import { getConfig } from "@/lib/config";
import { initializePlugins, getAuthPlugin } from "@/lib/plugins";
import type { Adapter, AdapterUser } from "next-auth/adapters";
@@ -8,30 +9,22 @@ import type { Adapter, AdapterUser } from "next-auth/adapters";
// Initialize plugins before use
initializePlugins();
// Generate a unique username from email or name
async function generateUsername(email: string, name?: string | null): Promise<string> {
// Generate a candidate username from email or name (no DB check — uniqueness enforced at insert time)
function generateBaseUsername(email: string, name?: string | null): string {
// Try to use the part before @ in email
let baseUsername = email.split("@")[0].toLowerCase().replace(/[^a-z0-9_]/g, "");
// If too short, use name
if (baseUsername.length < 3 && name) {
baseUsername = name.toLowerCase().replace(/[^a-z0-9_]/g, "").slice(0, 15);
}
// Ensure minimum length
if (baseUsername.length < 3) {
baseUsername = "user";
}
// Check if username exists and append number if needed
let username = baseUsername;
let counter = 1;
while (await db.user.findFirst({ where: { username: { equals: username, mode: "insensitive" } } })) {
username = `${baseUsername}${counter}`;
counter++;
}
return username;
return baseUsername;
}
// Custom adapter that wraps PrismaAdapter to add username
@@ -41,68 +34,73 @@ function CustomPrismaAdapter(): Adapter {
return {
...prismaAdapter,
async createUser(data: AdapterUser & { username?: string; githubUsername?: string }) {
// Use GitHub username if provided, otherwise generate one
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let username = (data as any).username;
const providedUsername = (data as any).username?.trim().toLowerCase() || null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const githubUsername = (data as any).githubUsername; // Immutable GitHub username
if (!username) {
username = await generateUsername(data.email, data.name);
} else {
username = username.toLowerCase();
// Check if there's an unclaimed account with this username
const normalizedEmail = data.email.trim().toLowerCase();
// If a username was provided, try to claim an unclaimed account first
if (providedUsername) {
const username = providedUsername;
const unclaimedEmail = `${username}@unclaimed.prompts.chat`;
const unclaimedUser = await db.user.findUnique({
where: { email: unclaimedEmail },
});
if (unclaimedUser) {
// Claim this account - update with real user info
const claimedUser = await db.user.update({
where: { id: unclaimedUser.id },
data: {
name: data.name,
email: data.email,
email: normalizedEmail,
avatar: data.image,
emailVerified: data.emailVerified,
githubUsername: githubUsername || undefined, // Store immutable GitHub username
githubUsername: githubUsername || undefined,
},
});
return {
...claimedUser,
image: claimedUser.avatar,
} as AdapterUser;
}
// Ensure GitHub username is unique, append number if taken
const baseUsername = username;
let finalUsername = baseUsername;
let counter = 1;
while (await db.user.findFirst({ where: { username: { equals: finalUsername, mode: "insensitive" } } })) {
finalUsername = `${baseUsername}${counter}`;
counter++;
}
username = finalUsername;
}
const user = await db.user.create({
data: {
name: data.name,
email: data.email,
avatar: data.image,
emailVerified: data.emailVerified,
username,
githubUsername: githubUsername || undefined, // Store immutable GitHub username
},
});
return {
...user,
image: user.avatar,
} as AdapterUser;
// Atomic create with retry on username collision
const baseUsername = providedUsername
? providedUsername
: generateBaseUsername(normalizedEmail, data.name);
let username = baseUsername;
let counter = 1;
while (true) {
try {
const user = await db.user.create({
data: {
name: data.name,
email: normalizedEmail,
avatar: data.image,
emailVerified: data.emailVerified,
username,
githubUsername: githubUsername || undefined,
},
});
return {
...user,
image: user.avatar,
} as AdapterUser;
} catch (error) {
if (isUniqueConstraintViolation(error, "username")) {
username = `${baseUsername}${counter}`;
counter++;
continue;
}
throw error;
}
}
},
};
}
+30
View File
@@ -0,0 +1,30 @@
import { Prisma } from "@prisma/client";
/**
* Check if a Prisma error is a unique constraint violation (P2002)
* on a specific field.
*
* Prisma reports column-level constraints as ["fieldName"] and
* raw index constraints as ["table_field_unique"], so we match both.
*/
export function isUniqueConstraintViolation(
error: unknown,
field: string,
): boolean {
if (
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
error.code !== "P2002"
) {
return false;
}
const target = error.meta?.target;
if (Array.isArray(target)) {
return target.some(
(t: string) => t === field || t.includes(field),
);
}
return typeof target === "string" && target.includes(field);
}