diff --git a/src/app/api/profile/update/route.js b/src/app/api/profile/update/route.js index c057a163..dbc971bd 100644 --- a/src/app/api/profile/update/route.js +++ b/src/app/api/profile/update/route.js @@ -1,6 +1,18 @@ import { NextResponse } from 'next/server'; import { createSupabaseServerClient } from '@/lib/supabase.js'; +/** + * @param {string | null} authHeader + * @returns {string | null} + */ +function getBearerToken(authHeader) { + if (typeof authHeader !== 'string') return null; + + const match = authHeader.match(/^Bearer\s+(.+)$/i); + const token = match?.[1]?.trim(); + + return token || null; +} export async function POST(request, { params } = {}) { try { @@ -8,13 +20,13 @@ export async function POST(request, { params } = {}) { // Get authorization header const authHeader = request.headers.get('authorization'); - if (!authHeader?.startsWith('Bearer ')) { + const token = getBearerToken(authHeader); + if (!token) { return NextResponse.json({ error: 'Missing or invalid authorization header' }, { status: 401 }); } // Create Supabase client and set session const supabase = await createSupabaseServerClient(); - const token = authHeader.replace('Bearer ', ''); // Set the session to ensure auth.uid() is available for RLS const { data: { user }, error: authError } = await supabase.auth.getUser(token); @@ -103,4 +115,4 @@ export async function POST(request, { params } = {}) { console.error('Profile update error:', err); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/profile/update/route.test.js b/src/app/api/profile/update/route.test.js new file mode 100644 index 00000000..ee04e083 --- /dev/null +++ b/src/app/api/profile/update/route.test.js @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getUser: vi.fn(), + setSession: vi.fn(), + createSupabaseServerClient: vi.fn(() => ({ + auth: { + getUser: mocks.getUser, + setSession: mocks.setSession + }, + from: vi.fn(() => ({ + update: vi.fn(() => ({ + eq: vi.fn(() => ({ + select: vi.fn(() => ({ + data: [{ + id: 'row-1', + username: 'alice', + display_name: 'Alice', + avatar_url: null, + bio: 'hello', + website: null + }], + error: null + })) + })) + })) + })) + })) +})); + +vi.mock('@/lib/supabase.js', () => ({ + createSupabaseServerClient: mocks.createSupabaseServerClient +})); + +function profileRequest(authorization) { + return new Request('https://example.com/api/profile/update', { + method: 'POST', + headers: authorization ? { authorization } : {}, + body: JSON.stringify({ bio: 'hello' }) + }); +} + +describe('profile update bearer authentication', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.getUser.mockResolvedValue({ + data: { user: { id: 'user-1', email: 'alice@example.com', phone: null } }, + error: null + }); + }); + + it('normalizes bearer scheme casing and extra spaces before validating the token', async () => { + const { POST } = await import('./route.js'); + + const response = await POST(profileRequest('bearer access-token-123 ')); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(mocks.getUser).toHaveBeenCalledWith('access-token-123'); + expect(mocks.setSession).toHaveBeenCalledWith({ + access_token: 'access-token-123', + refresh_token: '' + }); + }); + + it('rejects an empty bearer header before creating a Supabase client', async () => { + const { POST } = await import('./route.js'); + + const response = await POST(profileRequest('Bearer ')); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body.error).toBe('Missing or invalid authorization header'); + expect(mocks.createSupabaseServerClient).not.toHaveBeenCalled(); + expect(mocks.getUser).not.toHaveBeenCalled(); + }); +});