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
18 changes: 16 additions & 2 deletions app/api/community/connections/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { devLog } from '@/lib/devLogger';
import { adminDb } from '@/lib/firebase-admin';
import { verifyUserRequest, resolveOwnedUserId } from '@/lib/userApiAuth';

export const dynamic = 'force-dynamic';

Expand All @@ -17,11 +18,24 @@
return NextResponse.json({ error: 'Not available in static export' }, { status: 404 })
}
try {
const auth = await verifyUserRequest(request, 'community-connections');
if (!auth.ok) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { id: connectionId } = await params;
const body = await request.json();
const { action, userId } = body; // action: 'accept' | 'decline'
const { action } = body; // action: 'accept' | 'decline'
const userId = resolveOwnedUserId(body.userId, auth.uid);

if (!userId) {
return NextResponse.json(
{ error: 'userId is required and must match the authenticated user' },
{ status: 403 }
);
}

if (!action || !userId) {
if (!action) {
return NextResponse.json(
{ error: 'Missing required fields: action, userId' },
{ status: 400 }
Expand Down Expand Up @@ -81,7 +95,7 @@
respondedAt: now.toISOString(),
},
});
} catch (error: any) {

Check warning on line 98 in app/api/community/connections/[id]/route.ts

View workflow job for this annotation

GitHub Actions / Lint + Jest

Unexpected any. Specify a different type
devLog.error('Error updating connection request:', error, 'route');
return NextResponse.json({ error: error.message || 'Failed to update connection request' }, { status: 500 });
}
Expand Down
30 changes: 26 additions & 4 deletions app/api/community/connections/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { Query, QueryDocumentSnapshot } from 'firebase-admin/firestore';
import { devLog } from '@/lib/devLogger';
import { adminDb } from '@/lib/firebase-admin';
import { verifyUserRequest, resolveOwnedUserId } from '@/lib/userApiAuth';

// Must be dynamic: GET uses searchParams + Firestore; force-static breaks per-request handling.
export const dynamic = 'force-dynamic';
Expand Down Expand Up @@ -84,10 +85,23 @@
return NextResponse.json({ error: 'Not available in static export' }, { status: 404 })
}
try {
const auth = await verifyUserRequest(request, 'community-connections');
if (!auth.ok) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const body: ConnectionRequestData = await request.json();
const { fromUserId, fromUserName, toUserId, toUserName, topic, message } = body;
const { fromUserName, toUserId, toUserName, topic, message } = body;
const fromUserId = resolveOwnedUserId(body.fromUserId, auth.uid);

if (!fromUserId) {
return NextResponse.json(
{ error: 'fromUserId is required and must match the authenticated user' },
{ status: 403 }
);
}

if (!fromUserId || !fromUserName || !toUserId || !toUserName || !topic || !message) {
if (!fromUserName || !toUserId || !toUserName || !topic || !message) {
return NextResponse.json(
{ error: 'Missing required fields: fromUserId, fromUserName, toUserId, toUserName, topic, message' },
{ status: 400 }
Expand Down Expand Up @@ -157,7 +171,7 @@
createdAt: connectionData.createdAt.toISOString(),
},
});
} catch (error: any) {

Check warning on line 174 in app/api/community/connections/route.ts

View workflow job for this annotation

GitHub Actions / Lint + Jest

Unexpected any. Specify a different type
devLog.error('Error creating connection request:', error, 'route');
return NextResponse.json({ error: error.message || 'Failed to create connection request' }, { status: 500 });
}
Expand All @@ -169,12 +183,20 @@
return NextResponse.json({ error: 'Not available in static export' }, { status: 404 })
}
try {
const auth = await verifyUserRequest(request, 'community-connections');
if (!auth.ok) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
const userId = resolveOwnedUserId(searchParams.get('userId'), auth.uid);
const type = searchParams.get('type') || 'all'; // all, incoming, outgoing

if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 400 });
return NextResponse.json(
{ error: 'User ID is required and must match the authenticated user' },
{ status: 403 }
);
}

const db = adminDb;
Expand Down Expand Up @@ -208,7 +230,7 @@
success: true,
requests,
});
} catch (error: any) {

Check warning on line 233 in app/api/community/connections/route.ts

View workflow job for this annotation

GitHub Actions / Lint + Jest

Unexpected any. Specify a different type
devLog.error('Error fetching connection requests:', error, 'route');
return NextResponse.json({ error: error.message || 'Failed to fetch connection requests' }, { status: 500 });
}
Expand Down
10 changes: 6 additions & 4 deletions app/community/attribution/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
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';
Expand Down Expand Up @@ -230,7 +232,7 @@
return null;
});

const requestsPromise = fetch(`/api/community/connections?userId=${uid}&type=incoming`)
const requestsPromise = fetchWithFirebaseAuthRequired(`/api/community/connections?userId=${uid}&type=incoming`)
.then(async (r) => {
if (!r.ok) return null;
const data = await r.json();
Expand Down Expand Up @@ -496,7 +498,7 @@
if (!user?.uid) return;
setRequestsLoading(true);
try {
const response = await fetch(`/api/community/connections?userId=${user.uid}&type=all`);
const response = await fetchWithFirebaseAuthRequired(`/api/community/connections?userId=${user.uid}&type=all`);
if (!response.ok) throw new Error('Failed to load requests');
const data = await response.json();
const requests = (data.requests ?? []).map((request: Record<string, unknown>) => ({
Expand All @@ -522,7 +524,7 @@
const respondToConnectionRequest = async (requestId: string, action: 'accept' | 'decline') => {
if (!user?.uid) return;
try {
const response = await fetch(`/api/community/connections/${requestId}`, {
const response = await fetchWithFirebaseAuthRequired(`/api/community/connections/${requestId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, userId: user.uid }),
Expand Down Expand Up @@ -557,7 +559,7 @@
}

try {
const response = await fetch('/api/community/connections', {
const response = await fetchWithFirebaseAuthRequired('/api/community/connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down Expand Up @@ -1270,7 +1272,7 @@
)}
</div>
) : (
<p className="text-sm text-slate-700">Pick any discussion and tap "Open thread" to read and reply in one flow.</p>

Check warning on line 1275 in app/community/attribution/page.tsx

View workflow job for this annotation

GitHub Actions / Lint + Jest

`"` can be escaped with `&quot;`, `&ldquo;`, `&#34;`, `&rdquo;`

Check warning on line 1275 in app/community/attribution/page.tsx

View workflow job for this annotation

GitHub Actions / Lint + Jest

`"` can be escaped with `&quot;`, `&ldquo;`, `&#34;`, `&rdquo;`
)}
</CardContent>
</Card>
Expand Down
256 changes: 256 additions & 0 deletions tests/integration/community-connections-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/**
* Integration tests: /api/community/connections
* Unauthenticated callers must not read private messages or impersonate senders/recipients.
* @jest-environment node
*/

import { NextRequest } from 'next/server';

const mockVerifyUserRequest = jest.fn();
const mockQueryGet = jest.fn();
const mockDocGet = jest.fn();
const mockDocSet = jest.fn();
const mockDocUpdate = jest.fn();

function chainableQuery() {
const query = {
where: jest.fn(() => query),
orderBy: jest.fn(() => query),
get: mockQueryGet,
};
return query;
}

jest.mock('@/lib/userApiAuth', () => ({
verifyUserRequest: (...args: unknown[]) => mockVerifyUserRequest(...args),
resolveOwnedUserId: (requested: unknown, authUid: string) =>
typeof requested === 'string' && requested.trim() === authUid ? requested.trim() : null,
}));

jest.mock('@/lib/firebase-admin', () => ({
adminDb: {
collection: () => {
const query = chainableQuery();
return {
where: query.where,
orderBy: query.orderBy,
get: mockQueryGet,
doc: (id?: string) => ({
id: id || 'conn-new',
get: mockDocGet,
set: mockDocSet,
update: mockDocUpdate,
}),
};
},
},
}));

import { GET, POST } from '@/app/api/community/connections/route';
import { PATCH } from '@/app/api/community/connections/[id]/route';

describe('community connections auth', () => {
const victimUid = 'victim-uid';
const attackerUid = 'attacker-uid';
const privateMessage = 'Let us talk about my birth chart privately';

beforeEach(() => {
jest.clearAllMocks();
mockQueryGet.mockResolvedValue({ empty: true, docs: [] });
mockDocSet.mockResolvedValue(undefined);
mockDocUpdate.mockResolvedValue(undefined);
mockDocGet.mockResolvedValue({
exists: true,
id: 'conn-1',
data: () => ({
fromUserId: attackerUid,
fromUserName: 'Attacker',
toUserId: victimUid,
toUserName: 'Victim',
topic: 'Chart reading',
message: privateMessage,
status: 'pending',
createdAt: { toDate: () => new Date('2026-08-01T00:00:00.000Z') },
}),
});
});

function jsonHeaders(authHeader: boolean): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (authHeader) headers.Authorization = 'Bearer test-token';
return headers;
}

async function postConnection(
body: Record<string, unknown>,
authHeader = true,
): Promise<Response> {
const req = new NextRequest('http://localhost:3000/api/community/connections', {
method: 'POST',
headers: jsonHeaders(authHeader),
body: JSON.stringify(body),
});
return POST(req) as Promise<Response>;
}

async function getConnections(
userId: string,
authHeader = true,
type = 'all',
): Promise<Response> {
const req = new NextRequest(
`http://localhost:3000/api/community/connections?userId=${userId}&type=${type}`,
{
method: 'GET',
headers: jsonHeaders(authHeader),
},
);
return GET(req) as Promise<Response>;
}

async function patchConnection(
body: Record<string, unknown>,
authHeader = true,
): Promise<Response> {
const req = new NextRequest('http://localhost:3000/api/community/connections/conn-1', {
method: 'PATCH',
headers: jsonHeaders(authHeader),
body: JSON.stringify(body),
});
return PATCH(req, { params: Promise.resolve({ id: 'conn-1' }) }) as Promise<Response>;
}

const validPostBody = {
fromUserId: victimUid,
fromUserName: 'Impostor',
toUserId: attackerUid,
toUserName: 'Target',
topic: 'Hello',
message: 'Forged request',
};

describe('GET', () => {
it('returns 401 and does not query when Authorization is missing', async () => {
mockVerifyUserRequest.mockResolvedValue({ ok: false, reason: 'missing_token' });

const res = await getConnections(victimUid, false);

expect(res.status).toBe(401);
expect(mockQueryGet).not.toHaveBeenCalled();
});

it('returns 403 and does not leak another user private messages', async () => {
mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: attackerUid });

const res = await getConnections(victimUid);

expect(res.status).toBe(403);
expect(mockQueryGet).not.toHaveBeenCalled();
});

it('returns the authenticated user connections when userId matches the token', async () => {
mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: victimUid });
mockQueryGet.mockResolvedValue({
empty: false,
docs: [
{
id: 'conn-1',
data: () => ({
fromUserId: attackerUid,
fromUserName: 'Attacker',
toUserId: victimUid,
toUserName: 'Victim',
topic: 'Chart reading',
message: privateMessage,
status: 'pending',
createdAt: { toDate: () => new Date('2026-08-01T00:00:00.000Z') },
}),
},
],
});

const res = await getConnections(victimUid, true, 'incoming');
const body = await res.json();

expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.requests).toHaveLength(1);
expect(body.requests[0].message).toBe(privateMessage);
});
});

describe('POST', () => {
it('returns 401 and does not write when Authorization is missing', async () => {
mockVerifyUserRequest.mockResolvedValue({ ok: false, reason: 'missing_token' });

const res = await postConnection(validPostBody, false);

expect(res.status).toBe(401);
expect(mockDocSet).not.toHaveBeenCalled();
});

it('returns 403 and does not send as another user when fromUserId mismatches the token', async () => {
mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: attackerUid });

const res = await postConnection(validPostBody);

expect(res.status).toBe(403);
expect(mockDocSet).not.toHaveBeenCalled();
});

it('creates a request when fromUserId matches the token', async () => {
mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: attackerUid });

const res = await postConnection({
fromUserId: attackerUid,
fromUserName: 'Attacker',
toUserId: victimUid,
toUserName: 'Victim',
topic: 'Hello',
message: 'Want to connect',
});
const body = await res.json();

expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(mockDocSet).toHaveBeenCalledTimes(1);
const stored = mockDocSet.mock.calls[0][0] as Record<string, unknown>;
expect(stored.fromUserId).toBe(attackerUid);
expect(stored.toUserId).toBe(victimUid);
});
});

describe('PATCH', () => {
it('returns 401 and does not update when Authorization is missing', async () => {
mockVerifyUserRequest.mockResolvedValue({ ok: false, reason: 'missing_token' });

const res = await patchConnection({ action: 'accept', userId: victimUid }, false);

expect(res.status).toBe(401);
expect(mockDocUpdate).not.toHaveBeenCalled();
});

it('returns 403 and does not accept as another user when userId mismatches the token', async () => {
mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: attackerUid });

const res = await patchConnection({ action: 'accept', userId: victimUid });

expect(res.status).toBe(403);
expect(mockDocUpdate).not.toHaveBeenCalled();
});

it('accepts when the authenticated user is the recipient', async () => {
mockVerifyUserRequest.mockResolvedValue({ ok: true, uid: victimUid });

const res = await patchConnection({ action: 'accept', userId: victimUid });
const body = await res.json();

expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(mockDocUpdate).toHaveBeenCalledTimes(1);
expect(mockDocUpdate.mock.calls[0][0]).toEqual(
expect.objectContaining({ status: 'accepted' }),
);
});
});
});
Loading