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
84 changes: 84 additions & 0 deletions __tests__/delete-alert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';

// Mock auth BEFORE importing the action
vi.mock('@/lib/better-auth/auth', () => ({
auth: { api: { getSession: vi.fn() } },
}));

// Mock headers
vi.mock('next/headers', () => ({
headers: vi.fn(() => Promise.resolve(new Headers())),
}));

// Mock revalidatePath
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}));

// Mock mongoose connection
vi.mock('@/database/mongoose', () => ({
connectToDatabase: vi.fn().mockResolvedValue({}),
}));

// Mock Alert model
const findOneAndDelete = vi.fn();
vi.mock('@/database/models/alert.model', () => ({
Alert: { findOneAndDelete: (...args: unknown[]) => findOneAndDelete(...args) },
}));

import { deleteAlert } from '@/lib/actions/alert.actions';
import { auth } from '@/lib/better-auth/auth';

const getSession = vi.mocked(auth.api.getSession);

describe('deleteAlert (IDOR fix)', () => {
beforeEach(() => {
vi.clearAllMocks();
getSession.mockResolvedValue({
user: {
id: 'user-123',
createdAt: new Date(),
updatedAt: new Date(),
email: 'test@example.com',
emailVerified: true,
name: 'Test User',
},
session: {
id: 'sess-1',
createdAt: new Date(),
updatedAt: new Date(),
userId: 'user-123',
expiresAt: new Date(Date.now() + 86400000),
token: 'tok',
},
});
findOneAndDelete.mockResolvedValue({ _id: 'alert-1', userId: 'user-123' });
});

it('deletes own alert successfully', async () => {
const result = await deleteAlert('alert-1');
expect(result).toEqual({ success: true });
expect(findOneAndDelete).toHaveBeenCalledWith({
_id: 'alert-1',
userId: 'user-123',
});
});

it('throws when not authenticated', async () => {
getSession.mockResolvedValue(null);
await expect(deleteAlert('alert-1')).rejects.toThrow('Failed to delete alert');
// Should NOT call the DB when unauthenticated
expect(findOneAndDelete).not.toHaveBeenCalled();
});

it('throws and does NOT delete when the alert belongs to another user', async () => {
findOneAndDelete.mockResolvedValue(null); // other user's alert -> nothing deleted
await expect(deleteAlert('alert-other')).rejects.toThrow('Failed to delete alert');
// Verify the DB WOULD have been queried with the session user's id,
// not with a client-supplied id
expect(findOneAndDelete).toHaveBeenCalledWith({
_id: 'alert-other',
userId: 'user-123', // <-- uses session user, not attacker-supplied
});
});
});
2 changes: 1 addition & 1 deletion __tests__/reset-password-email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { sendPasswordResetEmail } from '@/lib/nodemailer/reset-password';

describe('sendPasswordResetEmail', () => {
const originalEnv = { ...process.env };
const sendMailMock = vi.mocked(transporter.sendMail);
const sendMailMock = vi.mocked(transporter!.sendMail);

beforeEach(() => {
process.env = {
Expand Down
168 changes: 0 additions & 168 deletions components/watchlist/WatchlistTable.tsx

This file was deleted.

11 changes: 10 additions & 1 deletion database/mongoose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export const connectToDatabase = async () => {
throw err;
}

console.log(`MongoDB Connected ${MONGODB_URI} in ${process.env.NODE_ENV}`);
// Redact credentials from the URI (e.g. mongodb+srv://user:pass@host/db -> host/db)
const safeUri = (() => {
try {
return MONGODB_URI.replace(/\/\/[^@/]+@/, '//***@');
} catch {
return 'MongoDB';
}
})();

console.log(`MongoDB Connected ${safeUri} in ${process.env.NODE_ENV}`);
return cached.conn;
}
36 changes: 20 additions & 16 deletions lib/actions/alert.actions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use server';

import { connectToDatabase } from '@/database/mongoose';
import { Alert, type IAlert } from '@/database/models/alert.model';
import { Alert } from '@/database/models/alert.model';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/better-auth/auth';
import { headers } from 'next/headers';

// Create a new alert
export async function createAlert(params: {
Expand Down Expand Up @@ -38,28 +40,30 @@ export async function getUserAlerts(userId: string) {
}
}

// Delete an alert
// Delete an alert belonging to the current user
export async function deleteAlert(alertId: string) {
try {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user?.id) {
throw new Error('Not authenticated');
}

await connectToDatabase();
await Alert.findByIdAndDelete(alertId);
const deleted = await Alert.findOneAndDelete({
_id: alertId,
userId: session.user.id
});

if (!deleted) {
throw new Error('Alert not found or does not belong to the current user');
}

revalidatePath('/watchlist');
return { success: true };
} catch (error) {
console.error('Error deleting alert:', error);
throw new Error('Failed to delete alert');
}
}

// Toggle alert active status (optional utility)
export async function toggleAlert(alertId: string, active: boolean) {
try {
await connectToDatabase();
await Alert.findByIdAndUpdate(alertId, { active });
revalidatePath('/watchlist');
return { success: true };
} catch (error) {
console.error('Error toggling alert:', error);
throw new Error('Failed to update alert');
}
}
27 changes: 0 additions & 27 deletions lib/actions/finnhub.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,33 +87,6 @@ export async function getCompanyProfile(symbol: string) {
}
}

export async function getWatchlistData(symbols: string[]) {
if (!symbols || symbols.length === 0) return [];

// Fetch quotes and profiles in parallel
const promises = symbols.map(async (sym) => {
const [quote, profile] = await Promise.all([
getQuote(sym),
getCompanyProfile(sym)
]);

return {
symbol: sym,
price: quote?.c || 0,
change: quote?.d || 0,
changePercent: quote?.dp || 0,
currency: profile?.currency || 'USD',
name: profile?.name || sym,
logo: profile?.logo,
marketCap: profile?.marketCapitalization,
peRatio: 0 // Finnhub 'quote' and 'profile2' don't easily give real-time PE. Might need 'metric' endpoint, but skipping for now to save rate limits.
};
});

return await Promise.all(promises);
}


export async function getNews(symbols?: string[]): Promise<MarketNewsArticle[]> {
try {
const range = getDateRange(5);
Expand Down
Loading