Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
210 changes: 210 additions & 0 deletions app/tests/api/auth-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => Promise<unknown>;

beforeEach(() => {
vi.clearAllMocks();
mockFromPublicKey.mockReturnValue({ verify: mockVerify });
authorize = (authConfig.providers[0] as any).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();

Check failure on line 78 in app/tests/api/auth-credentials.test.ts

View workflow job for this annotation

GitHub Actions / test

tests/api/auth-credentials.test.ts > Auth Credentials Provider (authorize) > should register a new user when valid signature and username are provided

AssertionError: expected null not to be null ❯ tests/api/auth-credentials.test.ts:78:24
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();

Check failure on line 116 in app/tests/api/auth-credentials.test.ts

View workflow job for this annotation

GitHub Actions / test

tests/api/auth-credentials.test.ts > Auth Credentials Provider (authorize) > should register with an email when email is provided during registration

AssertionError: expected null not to be null ❯ tests/api/auth-credentials.test.ts:116:24
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();

Check failure on line 150 in app/tests/api/auth-credentials.test.ts

View workflow job for this annotation

GitHub Actions / test

tests/api/auth-credentials.test.ts > Auth Credentials Provider (authorize) > should return user on successful login with an existing account

AssertionError: expected null not to be null ❯ tests/api/auth-credentials.test.ts:150:24
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();
});
});
180 changes: 180 additions & 0 deletions app/tests/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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/api/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();
});
});
Loading
Loading