diff --git a/app/api/vedic/career/route.ts b/app/api/vedic/career/route.ts index 4263bfc6..70c13228 100644 --- a/app/api/vedic/career/route.ts +++ b/app/api/vedic/career/route.ts @@ -12,8 +12,10 @@ 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; @@ -21,14 +23,15 @@ interface CareerRequest { userProfile?: VedicBirthProfile & { currentRole?: string; skills?: string }; } -export async function POST(request: NextRequest): Promise { +async function handlePost(request: NextRequest): Promise { + 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' }, @@ -93,3 +96,5 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ success: false, error: message }, { status: 500 }); } } + +export const POST = withRateLimit(handlePost, rateLimiters.ai, 'vedic_career_post'); diff --git a/app/api/vedic/relationships/route.ts b/app/api/vedic/relationships/route.ts index 2e273bec..ece2ad90 100644 --- a/app/api/vedic/relationships/route.ts +++ b/app/api/vedic/relationships/route.ts @@ -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, @@ -15,6 +16,7 @@ import { type PartnerContext, type VedicRelationshipAnalysis, } from '@/lib/vedic/vedicRelationshipReport'; +import { withRateLimit, rateLimiters } from '@/lib/rateLimit'; interface RelationshipsRequest { userId: string; @@ -23,14 +25,15 @@ interface RelationshipsRequest { partner?: PartnerContext; } -export async function POST(request: NextRequest): Promise { +async function handlePost(request: NextRequest): Promise { + 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' }, @@ -96,3 +99,5 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ success: false, error: message }, { status: 500 }); } } + +export const POST = withRateLimit(handlePost, rateLimiters.ai, 'vedic_relationships_post'); diff --git a/components/vedic/VedicCareerReportPanel.tsx b/components/vedic/VedicCareerReportPanel.tsx index fadad8a2..e6cd48cf 100644 --- a/components/vedic/VedicCareerReportPanel.tsx +++ b/components/vedic/VedicCareerReportPanel.tsx @@ -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 { @@ -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({ diff --git a/components/vedic/VedicRelationshipReportPanel.tsx b/components/vedic/VedicRelationshipReportPanel.tsx index 081fba71..197fe60f 100644 --- a/components/vedic/VedicRelationshipReportPanel.tsx +++ b/components/vedic/VedicRelationshipReportPanel.tsx @@ -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 { @@ -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({ diff --git a/lib/vedic/vedicFocusedReportRouteGuard.ts b/lib/vedic/vedicFocusedReportRouteGuard.ts new file mode 100644 index 00000000..cf1f07e3 --- /dev/null +++ b/lib/vedic/vedicFocusedReportRouteGuard.ts @@ -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; +}; + +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 { + const auth = await verifyUserRequest(request, logTag); + if (!auth.ok) { + return { + ok: false, + response: NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }), + }; + } + + let body: Record; + try { + body = (await request.json()) as Record; + } 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 }; +} diff --git a/tests/integration/vedic-focused-report-auth.test.ts b/tests/integration/vedic-focused-report-auth.test.ts new file mode 100644 index 00000000..7fc8a88b --- /dev/null +++ b/tests/integration/vedic-focused-report-auth.test.ts @@ -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; + 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 = {}) { + return { + userId: 'user-1', + userProfile: birthProfile, + ...overrides, + }; +} + +function makeRelationshipBody(overrides: Record = {}) { + 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(); + }); +});