diff --git a/app/tests/api/auth-credentials.test.ts b/app/tests/api/auth-credentials.test.ts new file mode 100644 index 0000000..5b09612 --- /dev/null +++ b/app/tests/api/auth-credentials.test.ts @@ -0,0 +1,210 @@ +/** + * Unit tests for the NextAuth Credentials provider `authorize` function. + * + * The application has no separate /api/auth/login or /api/auth/register routes. + * Both login and registration flow through the Credentials provider's `authorize` + * callback in lib/auth-config.ts: + * - Registration: walletAddress + valid signature + username → creates new user + * - Login: walletAddress + valid signature (no username) → returns existing user + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockVerify = vi.hoisted(() => vi.fn()); +const mockFromPublicKey = vi.hoisted(() => vi.fn()); + +vi.mock('@stellar/stellar-sdk', () => ({ + Keypair: { + fromPublicKey: mockFromPublicKey, + }, +})); + +const mockPrismaUser = vi.hoisted(() => ({ + findUnique: vi.fn(), + create: vi.fn(), +})); + +vi.mock('@/lib/prisma', () => ({ + prisma: { user: mockPrismaUser }, +})); + +// PrismaAdapter is called at module evaluation time inside authConfig. +// Mock it so importing auth-config does not attempt a real DB connection. +vi.mock('@auth/prisma-adapter', () => ({ + PrismaAdapter: vi.fn().mockReturnValue({}), +})); + +import { authConfig } from '@/lib/auth-config'; + +describe('Auth Credentials Provider (authorize)', () => { + // The Credentials provider is the first (and only) provider in authConfig. + // We cast to `any` to access the authorize callback without needing NextAuth types. + let authorize: (credentials: Record) => Promise; + + beforeEach(() => { + vi.clearAllMocks(); + mockFromPublicKey.mockReturnValue({ verify: mockVerify }); + authorize = (authConfig.providers[0] as any).options.authorize; + }); + + // ──────────────────────────────────────────────────────────── + // Registration path + // ──────────────────────────────────────────────────────────── + + it('should register a new user when valid signature and username are provided', async () => { + const walletAddress = 'GVALIDWALLETREGISTRATION1234567'; + const createdUser = { + id: 'new_user_1', + walletAddress, + name: 'alice', + username: 'alice', + email: null, + bio: null, + avatarUrl: `https://api.dicebear.com/7.x/identicon/svg?seed=${walletAddress}`, + xp: 0, + createdAt: new Date(), + }; + + mockVerify.mockReturnValue(true); + mockPrismaUser.findUnique.mockResolvedValue(null); // user does not exist yet + mockPrismaUser.create.mockResolvedValue(createdUser); + + const result = await authorize({ + walletAddress, + signature: 'dmFsaWRzaWduYXR1cmU=', + message: 'Please sign this message to authenticate with Geev.', + username: 'alice', + }); + + expect(result).not.toBeNull(); + expect((result as any).walletAddress).toBe(walletAddress); + expect(mockPrismaUser.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + walletAddress, + name: 'alice', + username: 'alice', + xp: 0, + }), + }), + ); + }); + + it('should register with an email when email is provided during registration', async () => { + const walletAddress = 'GREGISTERWITHMAIL12345678'; + mockVerify.mockReturnValue(true); + mockPrismaUser.findUnique.mockResolvedValue(null); + mockPrismaUser.create.mockResolvedValue({ + id: 'new_user_email', + walletAddress, + name: 'eve', + username: 'eve', + email: 'eve@example.com', + bio: null, + avatarUrl: null, + xp: 0, + createdAt: new Date(), + }); + + const result = await authorize({ + walletAddress, + signature: 'dmFsaWRzaWduYXR1cmU=', + message: 'sign-this', + username: 'eve', + email: 'eve@example.com', + }); + + expect(result).not.toBeNull(); + expect(mockPrismaUser.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ email: 'eve@example.com' }), + }), + ); + }); + + // ──────────────────────────────────────────────────────────── + // Login path + // ──────────────────────────────────────────────────────────── + + it('should return user on successful login with an existing account', async () => { + const existingUser = { + id: 'user_existing', + walletAddress: 'GEXISTINGWALLET12345678', + name: 'Bob', + username: 'bob', + email: 'bob@example.com', + bio: 'My bio', + avatarUrl: null, + createdAt: new Date(), + }; + + mockVerify.mockReturnValue(true); + mockPrismaUser.findUnique.mockResolvedValue(existingUser); + + const result = await authorize({ + walletAddress: 'GEXISTINGWALLET12345678', + signature: 'dmFsaWRzaWduYXR1cmU=', + message: 'sign-this', + // no username → login, not registration + }); + + expect(result).not.toBeNull(); + expect((result as any).id).toBe('user_existing'); + expect(mockPrismaUser.create).not.toHaveBeenCalled(); + }); + + // ──────────────────────────────────────────────────────────── + // Failure paths + // ──────────────────────────────────────────────────────────── + + it('should return null when signature is invalid', async () => { + mockFromPublicKey.mockReturnValue({ verify: vi.fn().mockReturnValue(false) }); + + const result = await authorize({ + walletAddress: 'GVALIDWALLET123', + signature: 'bad-sig', + message: 'sign-this', + }); + + expect(result).toBeNull(); + expect(mockPrismaUser.create).not.toHaveBeenCalled(); + }); + + it('should return null on login when user does not exist and no username provided', async () => { + mockVerify.mockReturnValue(true); + mockPrismaUser.findUnique.mockResolvedValue(null); // no such user + + const result = await authorize({ + walletAddress: 'GUNKNOWN12345678', + signature: 'dmFsaWRzaWduYXR1cmU=', + message: 'sign-this', + // no username → attempted login, not registration + }); + + expect(result).toBeNull(); + expect(mockPrismaUser.create).not.toHaveBeenCalled(); + }); + + it('should return null when credentials object is missing required fields', async () => { + const result = await authorize({ + walletAddress: '', + signature: '', + message: '', + }); + + expect(result).toBeNull(); + }); + + it('should return null when Keypair.fromPublicKey throws (malformed wallet address)', async () => { + mockFromPublicKey.mockImplementation(() => { + throw new Error('Invalid Stellar public key'); + }); + + const result = await authorize({ + walletAddress: 'NOT_A_STELLAR_KEY', + signature: 'some-sig', + message: 'sign-this', + }); + + expect(result).toBeNull(); + }); +}); diff --git a/app/tests/api/auth.test.ts b/app/tests/api/auth.test.ts new file mode 100644 index 0000000..457d90c --- /dev/null +++ b/app/tests/api/auth.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { parseResponse } from '../helpers/api'; + +// Must be hoisted so they are available before module imports are resolved +const mockAuth = vi.hoisted(() => vi.fn()); + +const mockPrismaUser = vi.hoisted(() => ({ + findUnique: vi.fn(), + create: vi.fn(), +})); + +vi.mock('@/lib/auth', () => ({ + auth: mockAuth, + getCurrentUser: vi.fn(), +})); + +vi.mock('@/lib/prisma', () => ({ + prisma: { user: mockPrismaUser }, +})); + +import { GET } from '@/app/(auth)/me/route'; + +describe('GET /api/auth/me', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return 401 when no session exists', async () => { + mockAuth.mockResolvedValue(null); + + const response = await GET(); + const { status, data } = await parseResponse(response); + + expect(status).toBe(401); + expect(data.success).toBe(false); + expect(data.error).toBe('Unauthorized'); + }); + + it('should return 401 when session has no user id', async () => { + mockAuth.mockResolvedValue({ user: {} }); + + const response = await GET(); + const { status, data } = await parseResponse(response); + + expect(status).toBe(401); + expect(data.success).toBe(false); + }); + + it('should return 404 when authenticated session user is not found in DB', async () => { + mockAuth.mockResolvedValue({ user: { id: 'ghost_user' } }); + mockPrismaUser.findUnique.mockResolvedValue(null); + + const response = await GET(); + const { status, data } = await parseResponse(response); + + expect(status).toBe(404); + expect(data.success).toBe(false); + expect(data.error).toBe('User not found'); + }); + + it('should return current user when session is valid', async () => { + const mockUser = { + id: 'user_1', + walletAddress: 'GWALLET123', + name: 'Test User', + bio: 'Test bio', + xp: 100, + badges: [], + rank: { + id: 'newcomer', + level: 1, + title: 'Newcomer', + color: 'text-gray-500', + minPoints: 0, + maxPoints: 199, + }, + _count: { + posts: 3, + entries: 2, + comments: 5, + interactions: 10, + badges: 1, + analyticsEvents: 0, + followings: 4, + followers: 6, + helpContributions: 0, + accounts: 1, + sessions: 1, + }, + }; + + mockAuth.mockResolvedValue({ user: { id: 'user_1' } }); + mockPrismaUser.findUnique.mockResolvedValue(mockUser); + + const response = await GET(); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.id).toBe('user_1'); + expect(data.data.walletAddress).toBe('GWALLET123'); + expect(mockPrismaUser.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'user_1' } }), + ); + }); + + it('should fall back to default newcomer rank when user has no rank', async () => { + const mockUser = { + id: 'user_2', + walletAddress: 'GNEWBIE', + name: 'Newbie', + bio: null, + xp: 0, + badges: [], + rank: null, + _count: { + posts: 0, + entries: 0, + comments: 0, + interactions: 0, + badges: 0, + analyticsEvents: 0, + followings: 0, + followers: 0, + helpContributions: 0, + accounts: 1, + sessions: 1, + }, + }; + + mockAuth.mockResolvedValue({ user: { id: 'user_2' } }); + mockPrismaUser.findUnique.mockResolvedValue(mockUser); + + const response = await GET(); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.data.rank).toEqual( + expect.objectContaining({ id: 'newcomer', level: 1, title: 'Newcomer' }), + ); + }); + + it('should flatten badges from userBadge join table', async () => { + const mockBadge = { id: 'badge_1', name: 'Early Adopter', description: 'Joined early' }; + const mockUser = { + id: 'user_3', + walletAddress: 'GBADGE', + name: 'Badge User', + bio: null, + xp: 200, + badges: [{ badge: mockBadge, awardedAt: new Date('2024-01-01') }], + rank: null, + _count: { + posts: 1, + entries: 0, + comments: 0, + interactions: 0, + badges: 1, + analyticsEvents: 0, + followings: 0, + followers: 0, + helpContributions: 0, + accounts: 1, + sessions: 1, + }, + }; + + mockAuth.mockResolvedValue({ user: { id: 'user_3' } }); + mockPrismaUser.findUnique.mockResolvedValue(mockUser); + + const response = await GET(); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.data.badges).toHaveLength(1); + expect(data.data.badges[0].id).toBe('badge_1'); + expect(data.data.badges[0].name).toBe('Early Adopter'); + expect(data.data.badges[0].awardedAt).toBeDefined(); + }); +}); diff --git a/app/tests/api/users.test.ts b/app/tests/api/users.test.ts new file mode 100644 index 0000000..863a91e --- /dev/null +++ b/app/tests/api/users.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMockRequest, parseResponse } from '../helpers/api'; + +const mockPrisma = vi.hoisted(() => ({ + user: { + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + follow: { + findUnique: vi.fn(), + }, +})); + +vi.mock('@/lib/prisma', () => ({ + prisma: mockPrisma, +})); + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn(), + auth: vi.fn(), +})); + +import { getCurrentUser } from '@/lib/auth'; +import { GET, PATCH } from '@/app/api/users/[id]/route'; + +describe('Users API', () => { + const user1 = { + id: 'user_1', + walletAddress: 'GUSER1WALLET', + name: 'Alice', + username: 'alice', + bio: 'Alice bio', + email: 'alice@example.com', + avatarUrl: null, + xp: 100, + createdAt: new Date(), + updatedAt: new Date(), + _count: { followers: 5, followings: 3 }, + }; + + const user2 = { + id: 'user_2', + walletAddress: 'GUSER2WALLET', + name: 'Bob', + username: 'bob', + bio: 'Bob bio', + email: 'bob@example.com', + avatarUrl: null, + xp: 50, + createdAt: new Date(), + updatedAt: new Date(), + _count: { followers: 1, followings: 2 }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ──────────────────────────────────────────────────────────── + // GET /api/users/[id] + // ──────────────────────────────────────────────────────────── + + describe('GET /api/users/[id]', () => { + it('should return the user profile', async () => { + (getCurrentUser as any).mockResolvedValue(null); + mockPrisma.user.findUnique.mockResolvedValue(user1); + + const request = createMockRequest('http://localhost:3000/api/users/user_1'); + const response = await GET(request, { params: Promise.resolve({ id: 'user_1' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.id).toBe('user_1'); + expect(data.data.walletAddress).toBe('GUSER1WALLET'); + }); + + it('should return 404 for a non-existent user', async () => { + (getCurrentUser as any).mockResolvedValue(null); + mockPrisma.user.findUnique.mockResolvedValue(null); + + const request = createMockRequest('http://localhost:3000/api/users/ghost'); + const response = await GET(request, { params: Promise.resolve({ id: 'ghost' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(404); + expect(data.success).toBe(false); + expect(data.error).toBe('User not found'); + }); + + it('should include isFollowing=true when the authenticated user follows the target', async () => { + (getCurrentUser as any).mockResolvedValue(user1); + mockPrisma.user.findUnique.mockResolvedValue(user2); + mockPrisma.follow.findUnique.mockResolvedValue({ + userId: user1.id, + followingId: user2.id, + }); + + const request = createMockRequest('http://localhost:3000/api/users/user_2'); + const response = await GET(request, { params: Promise.resolve({ id: 'user_2' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.data.isFollowing).toBe(true); + }); + + it('should include isFollowing=false when the authenticated user does not follow the target', async () => { + (getCurrentUser as any).mockResolvedValue(user1); + mockPrisma.user.findUnique.mockResolvedValue(user2); + mockPrisma.follow.findUnique.mockResolvedValue(null); + + const request = createMockRequest('http://localhost:3000/api/users/user_2'); + const response = await GET(request, { params: Promise.resolve({ id: 'user_2' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.data.isFollowing).toBe(false); + }); + + it('should include isFollowing=false when request is unauthenticated', async () => { + (getCurrentUser as any).mockResolvedValue(null); + mockPrisma.user.findUnique.mockResolvedValue(user1); + + const request = createMockRequest('http://localhost:3000/api/users/user_1'); + const response = await GET(request, { params: Promise.resolve({ id: 'user_1' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.data.isFollowing).toBe(false); + // No follow lookup should be performed for anonymous visitors + expect(mockPrisma.follow.findUnique).not.toHaveBeenCalled(); + }); + }); + + // ──────────────────────────────────────────────────────────── + // PATCH /api/users/[id] + // ──────────────────────────────────────────────────────────── + + describe('PATCH /api/users/[id]', () => { + it('should return 401 when not authenticated', async () => { + (getCurrentUser as any).mockResolvedValue(null); + + const request = createMockRequest('http://localhost:3000/api/users/user_1', { + method: 'PATCH', + body: { name: 'New Name' }, + }); + const response = await PATCH(request, { params: Promise.resolve({ id: 'user_1' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(401); + expect(data.success).toBe(false); + expect(data.error).toBe('Unauthorized'); + }); + + it("should return 403 when attempting to update another user's profile", async () => { + // user1 is authenticated but targets user2's profile + (getCurrentUser as any).mockResolvedValue(user1); + + const request = createMockRequest('http://localhost:3000/api/users/user_2', { + method: 'PATCH', + body: { name: 'Hacked' }, + }); + const response = await PATCH(request, { params: Promise.resolve({ id: 'user_2' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(403); + expect(data.success).toBe(false); + expect(data.error).toBe('Can only update own profile'); + }); + + it('should update own profile successfully', async () => { + (getCurrentUser as any).mockResolvedValue(user1); + mockPrisma.user.findFirst.mockResolvedValue(null); // no conflicts + mockPrisma.user.update.mockResolvedValue({ ...user1, name: 'Alice Updated' }); + + const request = createMockRequest('http://localhost:3000/api/users/user_1', { + method: 'PATCH', + body: { name: 'Alice Updated' }, + }); + const response = await PATCH(request, { params: Promise.resolve({ id: 'user_1' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.name).toBe('Alice Updated'); + }); + + it('should update bio and email independently', async () => { + (getCurrentUser as any).mockResolvedValue(user1); + mockPrisma.user.findFirst.mockResolvedValue(null); + mockPrisma.user.update.mockResolvedValue({ + ...user1, + bio: 'Updated bio', + email: 'newalice@example.com', + }); + + const request = createMockRequest('http://localhost:3000/api/users/user_1', { + method: 'PATCH', + body: { bio: 'Updated bio', email: 'newalice@example.com' }, + }); + const response = await PATCH(request, { params: Promise.resolve({ id: 'user_1' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(200); + expect(data.data.bio).toBe('Updated bio'); + expect(data.data.email).toBe('newalice@example.com'); + }); + + it('should return 409 when the requested username is already taken', async () => { + (getCurrentUser as any).mockResolvedValue(user1); + // findFirst returns a conflicting record + mockPrisma.user.findFirst.mockResolvedValue({ id: 'user_3' }); + + const request = createMockRequest('http://localhost:3000/api/users/user_1', { + method: 'PATCH', + body: { username: 'taken_username' }, + }); + const response = await PATCH(request, { params: Promise.resolve({ id: 'user_1' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(409); + expect(data.success).toBe(false); + expect(data.error).toBe('Username is already taken'); + }); + + it('should return 409 when the requested email is already in use', async () => { + (getCurrentUser as any).mockResolvedValue(user1); + // First findFirst (username check) → no conflict + // Second findFirst (email check) → conflict + mockPrisma.user.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'user_3' }); + + const request = createMockRequest('http://localhost:3000/api/users/user_1', { + method: 'PATCH', + body: { username: 'alicenew', email: 'taken@example.com' }, + }); + const response = await PATCH(request, { params: Promise.resolve({ id: 'user_1' }) }); + const { status, data } = await parseResponse(response); + + expect(status).toBe(409); + expect(data.success).toBe(false); + expect(data.error).toBe('Email address is already in use'); + }); + }); +});