Skip to content
Draft
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
17 changes: 11 additions & 6 deletions app/api/vedic/career/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,26 @@ import {
type VedicCareerAnalysis,
} from '@/lib/vedic/vedicCareerReport';
import { generateVedicFocusedReport } from '@/lib/vedic/generateVedicFocusedReport';
import { authorizeVedicFocusedReportRequest } from '@/lib/vedic/vedicFocusedReportRouteGuard';
import type { ChartDataInput } from '@/lib/vedic/vedicChartContext';
import type { VedicBirthProfile } from '@/lib/vedic/vedicReportFirestore';
import { withRateLimit, rateLimiters } from '@/lib/rateLimit';

interface CareerRequest {
userId: string;
vedicChartData?: ChartDataInput;
userProfile?: VedicBirthProfile & { currentRole?: string; skills?: string };
}

export async function POST(request: NextRequest): Promise<NextResponse> {
async function handlePost(request: NextRequest): Promise<NextResponse> {
const authorized = await authorizeVedicFocusedReportRequest(request, 'vedic-career');
if (!authorized.ok) return authorized.response;

try {
const body = (await request.json()) as CareerRequest;
const { userId, vedicChartData, userProfile } = body;
const body = authorized.body as unknown as CareerRequest;
const userId = authorized.userId;
const { vedicChartData, userProfile } = body;

if (!userId) {
return NextResponse.json({ success: false, error: 'User ID is required' }, { status: 400 });
}
if (!userProfile?.birthDate || !userProfile?.birthTime || !userProfile?.birthPlace) {
return NextResponse.json(
{ success: false, error: 'Complete birth data (date, time, place) is required' },
Expand Down Expand Up @@ -93,3 +96,5 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ success: false, error: message }, { status: 500 });
}
}

export const POST = withRateLimit(handlePost, rateLimiters.ai, 'vedic_career_post');
17 changes: 11 additions & 6 deletions app/api/vedic/relationships/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getVedicReportDoc } from '@/lib/vedic/vedicReportFirestore';
import type { ChartDataInput } from '@/lib/vedic/vedicChartContext';
import type { VedicBirthProfile } from '@/lib/vedic/vedicReportFirestore';
import { generateVedicFocusedReport } from '@/lib/vedic/generateVedicFocusedReport';
import { authorizeVedicFocusedReportRequest } from '@/lib/vedic/vedicFocusedReportRouteGuard';
import {
buildVedicRelationshipDeterministicFallback,
buildVedicRelationshipPrompt,
Expand All @@ -15,6 +16,7 @@ import {
type PartnerContext,
type VedicRelationshipAnalysis,
} from '@/lib/vedic/vedicRelationshipReport';
import { withRateLimit, rateLimiters } from '@/lib/rateLimit';

interface RelationshipsRequest {
userId: string;
Expand All @@ -23,14 +25,15 @@ interface RelationshipsRequest {
partner?: PartnerContext;
}

export async function POST(request: NextRequest): Promise<NextResponse> {
async function handlePost(request: NextRequest): Promise<NextResponse> {
const authorized = await authorizeVedicFocusedReportRequest(request, 'vedic-relationships');
if (!authorized.ok) return authorized.response;

try {
const body = (await request.json()) as RelationshipsRequest;
const { userId, vedicChartData, userProfile, partner } = body;
const body = authorized.body as unknown as RelationshipsRequest;
const userId = authorized.userId;
const { vedicChartData, userProfile, partner } = body;

if (!userId) {
return NextResponse.json({ success: false, error: 'User ID is required' }, { status: 400 });
}
if (!userProfile?.birthDate || !userProfile?.birthTime || !userProfile?.birthPlace) {
return NextResponse.json(
{ success: false, error: 'Complete birth data (date, time, place) is required' },
Expand Down Expand Up @@ -96,3 +99,5 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ success: false, error: message }, { status: 500 });
}
}

export const POST = withRateLimit(handlePost, rateLimiters.ai, 'vedic_relationships_post');
3 changes: 2 additions & 1 deletion components/vedic/VedicCareerReportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
toVedicFocusedReportApiProfile,
type VedicFocusedReportUserInput,
} from '@/lib/vedic/vedicFocusedReportProfile';
import { fetchWithFirebaseAuthRequired } from '@/lib/clientFirebaseFetch';
import { AlertCircle, Briefcase, Calendar, Loader2, MessageCircle, RefreshCw, Target, TrendingUp } from 'lucide-react';

interface VedicCareerReportPanelProps {
Expand All @@ -35,7 +36,7 @@ export function VedicCareerReportPanel({
setLoading(true);
setError(null);
try {
const res = await fetch('/api/vedic/career', {
const res = await fetchWithFirebaseAuthRequired('/api/vedic/career', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down
3 changes: 2 additions & 1 deletion components/vedic/VedicRelationshipReportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
toVedicFocusedReportApiProfile,
type VedicFocusedReportUserInput,
} from '@/lib/vedic/vedicFocusedReportProfile';
import { fetchWithFirebaseAuthRequired } from '@/lib/clientFirebaseFetch';
import { AlertCircle, Calendar, Heart, Loader2, MessageCircle, RefreshCw, Users } from 'lucide-react';

interface VedicRelationshipReportPanelProps {
Expand Down Expand Up @@ -50,7 +51,7 @@ export function VedicRelationshipReportPanel({
}
: undefined;

const res = await fetch('/api/vedic/relationships', {
const res = await fetchWithFirebaseAuthRequired('/api/vedic/relationships', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down
51 changes: 51 additions & 0 deletions lib/vedic/vedicFocusedReportRouteGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { verifyUserRequest, resolveOwnedUserId } from '@/lib/userApiAuth';

export type VedicFocusedReportAuthOk = {
ok: true;
userId: string;
body: Record<string, unknown>;
};

export type VedicFocusedReportAuthFail = {
ok: false;
response: NextResponse;
};

/**
* Require Firebase Bearer auth and body.userId ownership before Groq
* generation or Admin cache R/W under users/{userId}/mysticalProfile.
*/
export async function authorizeVedicFocusedReportRequest(
request: NextRequest,
logTag: string,
): Promise<VedicFocusedReportAuthOk | VedicFocusedReportAuthFail> {
const auth = await verifyUserRequest(request, logTag);
if (!auth.ok) {
return {
ok: false,
response: NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }),
};
}

let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return {
ok: false,
response: NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 }),
};
}

const ownedUserId = resolveOwnedUserId(body.userId, auth.uid);
if (!ownedUserId) {
return {
ok: false,
response: NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }),
};
}

return { ok: true, userId: ownedUserId, body };
}
222 changes: 222 additions & 0 deletions tests/integration/vedic-focused-report-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* Vedic career/relationships routes must not be unauthenticated paid proxies
* (Groq via generateVedicFocusedReport) or allow Admin cache / profile
* IDOR via body userId.
* @jest-environment node
*/

import { NextRequest } from 'next/server';

const mockVerifyIdToken = jest.fn();
const mockGetVedicReportDoc = jest.fn();
const mockGenerateVedicFocusedReport = jest.fn();

jest.mock('@/lib/firebase-admin', () => ({
getAuth: () => ({ verifyIdToken: mockVerifyIdToken }),
}));

jest.mock('@/lib/vedic/vedicReportFirestore', () => {
const actual = jest.requireActual('@/lib/vedic/vedicReportFirestore') as Record<string, unknown>;
return {
...actual,
getVedicReportDoc: (...args: unknown[]) => mockGetVedicReportDoc(...args),
};
});

jest.mock('@/lib/vedic/generateVedicFocusedReport', () => ({
generateVedicFocusedReport: (...args: unknown[]) => mockGenerateVedicFocusedReport(...args),
}));

jest.mock('@/lib/rateLimitFirestore', () => ({
checkRateLimitWithOptionalFirestore: async (
limiter: { check: (identifier: string) => { allowed: boolean; remaining: number; resetTime: number } },
_logicalKey: string,
identifier: string,
) => limiter.check(identifier),
}));

import { POST as postCareer } from '@/app/api/vedic/career/route';
import { POST as postRelationships } from '@/app/api/vedic/relationships/route';

const birthProfile = {
birthDate: '1990-01-15',
birthTime: '14:30:00',
birthPlace: 'Mumbai',
};

function makeCareerBody(overrides: Record<string, unknown> = {}) {
return {
userId: 'user-1',
userProfile: birthProfile,
...overrides,
};
}

function makeRelationshipBody(overrides: Record<string, unknown> = {}) {
return {
userId: 'user-1',
userProfile: birthProfile,
...overrides,
};
}

describe('POST /api/vedic/career and /api/vedic/relationships auth', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetVedicReportDoc.mockResolvedValue({ exists: () => false, data: () => null });
mockGenerateVedicFocusedReport.mockResolvedValue({
analysis: { careerProfile: 'generated' },
source: 'llm',
cached: false,
});
});

it('rejects missing Authorization on career without Admin read or Groq', async () => {
const req = new NextRequest('http://localhost/api/vedic/career', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(makeCareerBody({ userId: 'victim' })),
});

const res = await postCareer(req);
expect(res.status).toBe(401);
expect(mockVerifyIdToken).not.toHaveBeenCalled();
expect(mockGetVedicReportDoc).not.toHaveBeenCalled();
expect(mockGenerateVedicFocusedReport).not.toHaveBeenCalled();
});

it('rejects invalid token on career without Admin read or Groq', async () => {
mockVerifyIdToken.mockRejectedValueOnce(new Error('bad token'));
const req = new NextRequest('http://localhost/api/vedic/career', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer bad',
},
body: JSON.stringify(makeCareerBody({ userId: 'victim' })),
});

const res = await postCareer(req);
expect(res.status).toBe(401);
expect(mockGetVedicReportDoc).not.toHaveBeenCalled();
expect(mockGenerateVedicFocusedReport).not.toHaveBeenCalled();
});

it('rejects career userId mismatch (cache IDOR) without Admin read or Groq', async () => {
mockVerifyIdToken.mockResolvedValueOnce({ uid: 'attacker', email: 'a@b.c' });
const req = new NextRequest('http://localhost/api/vedic/career', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer good',
},
body: JSON.stringify(makeCareerBody({ userId: 'victim' })),
});

const res = await postCareer(req);
expect(res.status).toBe(403);
expect(mockGetVedicReportDoc).not.toHaveBeenCalled();
expect(mockGenerateVedicFocusedReport).not.toHaveBeenCalled();
});

it('allows owned career userId and returns inline analysis without Groq', async () => {
mockVerifyIdToken.mockResolvedValueOnce({ uid: 'user-1', email: 'u@b.c' });
const req = new NextRequest('http://localhost/api/vedic/career', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer good',
},
body: JSON.stringify(
makeCareerBody({
vedicChartData: { careerAnalysis: { careerProfile: 'owned career' } },
}),
),
});

const res = await postCareer(req);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.success).toBe(true);
expect(json.data.careerAnalysis.careerProfile).toBe('owned career');
expect(mockGetVedicReportDoc).not.toHaveBeenCalled();
expect(mockGenerateVedicFocusedReport).not.toHaveBeenCalled();
});

it('loads only the owned user profile document on career cache miss', async () => {
mockVerifyIdToken.mockResolvedValueOnce({ uid: 'user-1', email: 'u@b.c' });
mockGetVedicReportDoc.mockResolvedValue({
exists: () => true,
data: () => ({ careerAnalysis: { careerProfile: 'persisted career' } }),
});

const req = new NextRequest('http://localhost/api/vedic/career', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer good',
},
body: JSON.stringify(makeCareerBody()),
});

const res = await postCareer(req);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.careerAnalysis.careerProfile).toBe('persisted career');
expect(mockGetVedicReportDoc).toHaveBeenCalledWith(['users'], 'user-1');
expect(mockGenerateVedicFocusedReport).not.toHaveBeenCalled();
});

it('rejects missing Authorization on relationships without Admin read or Groq', async () => {
const req = new NextRequest('http://localhost/api/vedic/relationships', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(makeRelationshipBody({ userId: 'victim' })),
});

const res = await postRelationships(req);
expect(res.status).toBe(401);
expect(mockGetVedicReportDoc).not.toHaveBeenCalled();
expect(mockGenerateVedicFocusedReport).not.toHaveBeenCalled();
});

it('rejects relationships userId mismatch without Admin read or Groq', async () => {
mockVerifyIdToken.mockResolvedValueOnce({ uid: 'attacker', email: 'a@b.c' });
const req = new NextRequest('http://localhost/api/vedic/relationships', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer good',
},
body: JSON.stringify(makeRelationshipBody({ userId: 'victim' })),
});

const res = await postRelationships(req);
expect(res.status).toBe(403);
expect(mockGetVedicReportDoc).not.toHaveBeenCalled();
expect(mockGenerateVedicFocusedReport).not.toHaveBeenCalled();
});

it('allows owned relationships userId and returns inline analysis without Groq', async () => {
mockVerifyIdToken.mockResolvedValueOnce({ uid: 'user-1', email: 'u@b.c' });
const req = new NextRequest('http://localhost/api/vedic/relationships', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer good',
},
body: JSON.stringify(
makeRelationshipBody({
vedicChartData: { relationshipAnalysis: { relationshipProfile: 'owned relationship' } },
}),
),
});

const res = await postRelationships(req);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.success).toBe(true);
expect(json.data.relationshipAnalysis.relationshipProfile).toBe('owned relationship');
expect(mockGenerateVedicFocusedReport).not.toHaveBeenCalled();
});
});
Loading