diff --git a/app/api/community/votes/route.ts b/app/api/community/votes/route.ts index a28e8f1f..704d66a3 100644 --- a/app/api/community/votes/route.ts +++ b/app/api/community/votes/route.ts @@ -4,7 +4,7 @@ import { adminDb } from '@/lib/firebase-admin'; import { calculateKarmaForAction } from '@/lib/firestore/communityHelpers'; import { verifyUserRequest, resolveOwnedUserId } from '@/lib/userApiAuth'; -export const dynamic = 'force-static' +export const dynamic = 'force-dynamic'; interface VoteData { userId: string; diff --git a/app/community/attribution/page.tsx b/app/community/attribution/page.tsx index 9b2da82d..637415d1 100644 --- a/app/community/attribution/page.tsx +++ b/app/community/attribution/page.tsx @@ -19,6 +19,7 @@ import { RecaptchaScript } from '@/components/RecaptchaScript'; import { useToast } from '@/components/ui/use-toast'; import { getReturningPaymentCommitDestination } from '@/lib/authRouting'; import { analytics } from '@/lib/analytics'; +import { fetchWithFirebaseAuthRequired } from '@/lib/clientFirebaseFetch'; interface UserContribution { id: string; type: 'feedback' | 'suggestion' | 'bug-report' | 'feature-request'; @@ -311,7 +312,7 @@ export default function CommunityAttributionPage() { // Load user votes in background so we don't block first paint const votePromises = discussionsData.map(async (d: Record) => { try { - const voteResponse = await fetch(`/api/community/votes?userId=${uid}&discussionId=${String(d.id ?? '')}`); + const voteResponse = await fetchWithFirebaseAuthRequired(`/api/community/votes?userId=${uid}&discussionId=${String(d.id ?? '')}`); if (!voteResponse.ok) return null; const voteData = await voteResponse.json(); if (voteData.success && voteData.hasVoted) { @@ -643,7 +644,7 @@ export default function CommunityAttributionPage() { }); // Send vote to API - const response = await fetch('/api/community/votes', { + const response = await fetchWithFirebaseAuthRequired('/api/community/votes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -700,7 +701,7 @@ export default function CommunityAttributionPage() { } try { - const response = await fetch('/api/community/discussions', { + const response = await fetchWithFirebaseAuthRequired('/api/community/discussions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/tests/unit/community-signed-in-writes-auth.test.ts b/tests/unit/community-signed-in-writes-auth.test.ts new file mode 100644 index 00000000..880f6c9b --- /dev/null +++ b/tests/unit/community-signed-in-writes-auth.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Signed-in community discussion create and votes require verifyUserRequest. + * The community page must send a Firebase Bearer token; guest discussion POST + * stays captcha-gated without auth. + */ +describe('community signed-in writes send Firebase auth', () => { + const pageSrc = readFileSync( + join(process.cwd(), 'app/community/attribution/page.tsx'), + 'utf8', + ); + const votesRouteSrc = readFileSync( + join(process.cwd(), 'app/api/community/votes/route.ts'), + 'utf8', + ); + + it('sends a Bearer token for signed-in discussion create', () => { + expect(pageSrc).toContain("fetchWithFirebaseAuthRequired('/api/community/discussions'"); + expect(pageSrc).toContain('guestPost: true'); + const guestBlock = pageSrc.slice(pageSrc.indexOf('handleCreateGuestDiscussion')); + expect(guestBlock).toMatch(/await fetch\('\/api\/community\/discussions'/); + }); + + it('sends a Bearer token for vote reads and writes', () => { + expect(pageSrc).toContain('fetchWithFirebaseAuthRequired(`/api/community/votes?userId='); + expect(pageSrc).toContain("fetchWithFirebaseAuthRequired('/api/community/votes'"); + }); + + it('renders the votes route dynamically so Authorization is available', () => { + expect(votesRouteSrc).toContain("export const dynamic = 'force-dynamic'"); + expect(votesRouteSrc).not.toMatch(/export const dynamic = 'force-static'/); + }); +});