diff --git a/apps/backend/src/__tests__/connect.test.ts b/apps/backend/src/__tests__/connect.test.ts index 2b39535b..8e8604c3 100644 --- a/apps/backend/src/__tests__/connect.test.ts +++ b/apps/backend/src/__tests__/connect.test.ts @@ -1,7 +1,10 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; import jwt from '@fastify/jwt'; +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { connectRoutes } from '../routes/connect.js'; +import { encrypt } from '../utils/encryption.js'; + import type { PrismaClient } from '@prisma/client'; process.env.PUBLIC_APP_URL = 'http://localhost:3000'; @@ -20,6 +23,7 @@ const mockRedis = { const mockPrisma = { oAuthToken: { findMany: vi.fn(), + findUnique: vi.fn(), upsert: vi.fn(), delete: vi.fn(), }, @@ -27,16 +31,16 @@ const mockPrisma = { global.fetch = vi.fn(); -async function buildApp() { +async function buildApp(): Promise> { const app = Fastify(); await app.register(jwt, { secret: 'test-secret' }); app.decorate('prisma', mockPrisma as unknown as PrismaClient); app.decorate('redis', mockRedis as any); - + app.decorate('authenticate', async (request: any, reply: any) => { try { await request.jwtVerify(); - } catch (err) { + } catch { reply.status(401).send({ error: 'Unauthorized' }); } }); @@ -46,6 +50,10 @@ async function buildApp() { return app; } +function authHeader(app: any): { authorization: string } { + return { authorization: `Bearer ${app.jwt.sign({ id: 'user-1' })}` }; +} + describe('GET /api/connect/github/callback', () => { beforeEach(() => { vi.clearAllMocks(); @@ -184,4 +192,119 @@ describe('GET /api/connect/github/callback', () => { expect(res.statusCode).toBe(302); expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed'); }); + + it('returns cached discovery suggestions when Redis stores the response', async () => { + const cachedResponse = [{ platform: 'twitter', username: 'octocat', confidence: 'high' }]; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedResponse)); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual(cachedResponse); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns discovery suggestions and caches the result', async () => { + mockRedis.get.mockResolvedValue(null); + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') }); + (global.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + twitter_username: 'octocat', + blog: 'https://dev.to/octocat', + company: 'GitHub', + bio: 'Developer', + html_url: 'https://github.com/octocat', + }), + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + const expected = [ + { platform: 'twitter', username: 'octocat', confidence: 'high' }, + { platform: 'devto', username: 'octocat', confidence: 'low' }, + ]; + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual(expected); + expect(mockRedis.set).toHaveBeenCalledWith('github:autodiscover:user-1', JSON.stringify(expected), 'EX', 3600); + }); + + it('returns unauthorized when GitHub API returns 401', async () => { + mockRedis.get.mockResolvedValue(null); + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') }); + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 401, + text: vi.fn().mockResolvedValue('Bad credentials'), + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: 'GitHub token expired or revoked', requiresAuth: true }); + expect(mockRedis.del).toHaveBeenCalledWith('github:autodiscover:user-1'); + }); + + it('returns an error when the GitHub follow token is missing', async () => { + mockRedis.get.mockResolvedValue(null); + mockPrisma.oAuthToken.findUnique.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: 'Not connected to GitHub. Please connect GitHub first.', requiresAuth: true }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('falls back to live GitHub discovery when Redis read fails', async () => { + mockRedis.get.mockRejectedValue(new Error('Redis unavailable')); + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') }); + (global.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + twitter_username: 'octocat', + blog: 'https://npmjs.com/~octocat', + company: 'GitHub', + bio: 'Developer', + html_url: 'https://github.com/octocat', + }), + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([ + { platform: 'twitter', username: 'octocat', confidence: 'high' }, + { platform: 'npm', username: 'octocat', confidence: 'low' }, + ]); + expect(global.fetch).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index bb04194d..2dda0636 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,6 +1,9 @@ +import { randomBytes } from 'node:crypto'; + +import { decrypt, encrypt } from '../utils/encryption.js'; +import { getErrorMessage } from '../utils/error.util.js'; + import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { randomBytes } from 'crypto'; -import { encrypt } from '../utils/encryption.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -11,6 +14,7 @@ const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; // the same OAuthToken record. Whichever flow runs last can no longer // silently overwrite the other's access token. const GITHUB_FOLLOW_PLATFORM = 'github_follow'; +const GITHUB_AUTODISCOVER_CACHE_TTL = 3600; interface OAuthCallbackQuery { code: string; @@ -22,7 +26,7 @@ interface ParsedOAuthState { nonce: string; } -export async function connectRoutes(app: FastifyInstance) { +export async function connectRoutes(app: FastifyInstance): Promise { // ─── Status ─── app.get('/status', { @@ -30,7 +34,7 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -50,7 +54,7 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -102,7 +106,9 @@ export async function connectRoutes(app: FastifyInstance) { } // Consume the nonce -- one-time use only (if redis configured) - if (app.redis) await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + if (app.redis) { + await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + } const userId = decodedState.userId; @@ -167,6 +173,92 @@ export async function connectRoutes(app: FastifyInstance) { } }); + app.get('/github/autodiscover', { + preHandler: [async (request, reply) => { + const server = request.server as any; + if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } + if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } + }], + }, async (request: FastifyRequest, reply: FastifyReply) => { + const userId = (request.user as any).id; + const cacheKey = `github:autodiscover:${userId}`; + + if (app.redis) { + try { + const cached = await app.redis.get(cacheKey); + if (cached) { + try { + return reply.send(JSON.parse(cached)); + } catch (err: unknown) { + app.log.warn(`Redis cache parse failed for ${cacheKey}: ${getErrorMessage(err)}`); + } + } + } catch (err: unknown) { + app.log.warn(`Redis cache read failed for ${cacheKey}: ${getErrorMessage(err)}`); + } + } + + const oauthToken = await app.prisma.oAuthToken.findUnique({ + where: { + userId_platform: { + userId, + platform: GITHUB_FOLLOW_PLATFORM, + }, + }, + select: { accessToken: true }, + }); + + if (!oauthToken) { + return reply.status(400).send({ error: 'Not connected to GitHub. Please connect GitHub first.', requiresAuth: true }); + } + + let accessToken: string; + try { + accessToken = decrypt(oauthToken.accessToken); + } catch (err: unknown) { + app.log.error({ err, userId }, 'GitHub follow token decrypt failed'); + return reply.status(500).send({ error: 'Failed to access GitHub connection' }); + } + + let response: Response; + try { + response = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + } catch (error: unknown) { + app.log.error({ userId, error: getErrorMessage(error) }, 'GitHub autodiscovery failed'); + return reply.status(502).send({ error: 'Failed to fetch GitHub profile' }); + } + + if (response.status === 401) { + if (app.redis) { + void Promise.resolve(app.redis.del(cacheKey)) + .catch((err: unknown) => app.log.warn(`Redis cache delete failed for ${cacheKey}: ${getErrorMessage(err)}`)); + } + return reply.status(401).send({ error: 'GitHub token expired or revoked', requiresAuth: true }); + } + + if (!response.ok) { + const body = await response.text(); + app.log.error({ status: response.status, body, userId }, 'GitHub user API request failed'); + return reply.status(502).send({ error: 'Failed to fetch GitHub profile' }); + } + + const githubUser = await response.json() as { twitter_username?: string | null; blog?: string | null; company?: string | null; bio?: string | null; html_url?: string | null }; + const suggestions = buildGitHubDiscoverySuggestions(githubUser); + + if (app.redis) { + void Promise.resolve(app.redis.set(cacheKey, JSON.stringify(suggestions), 'EX', GITHUB_AUTODISCOVER_CACHE_TTL)) + .catch((err: unknown) => app.log.warn(`Redis cache write failed for ${cacheKey}: ${getErrorMessage(err)}`)); + } + + return reply.send(suggestions); + }); + // ─── Disconnect ─── @@ -175,7 +267,7 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest<{ Params: { platform: string } }>, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -196,7 +288,7 @@ export async function connectRoutes(app: FastifyInstance) { }, }); return { success: true }; - } catch (error) { + } catch { return reply.status(404).send({ error: 'Connection not found' }); } }); @@ -216,6 +308,76 @@ function parseOAuthState(state: string): ParsedOAuthState | null { } } +function buildGitHubDiscoverySuggestions(user: { + twitter_username?: string | null; + blog?: string | null; + company?: string | null; + bio?: string | null; + html_url?: string | null; +}): Array<{ platform: string; username: string; confidence: 'high' | 'low' }> { + const { twitter_username, blog } = user; + + const suggestions: Array<{ platform: string; username: string; confidence: 'high' | 'low' }> = []; + + if (twitter_username?.trim()) { + suggestions.push({ + platform: 'twitter', + username: twitter_username.trim(), + confidence: 'high', + }); + } + + if (blog) { + const blogSuggestion = parseBlogSuggestion(blog); + if (blogSuggestion) { + suggestions.push(blogSuggestion); + } + } + + return suggestions; +} + +function parseBlogSuggestion(blog: string): { platform: string; username: string; confidence: 'high' | 'low' } | null { + const trimmed = blog.trim(); + if (!trimmed) { + return null; + } + + const url = parseBlogUrl(trimmed); + if (!url) { + return { platform: 'portfolio', username: trimmed, confidence: 'high' }; + } + + const host = url.hostname.replace(/^www\./i, '').toLowerCase(); + const pathname = url.pathname.replace(/\/+$/, ''); + + if (host === 'dev.to' && pathname.length > 1) { + return { platform: 'devto', username: pathname.slice(1), confidence: 'low' }; + } + + if (host === 'hashnode.com' && pathname.startsWith('/@') && pathname.length > 2) { + return { platform: 'hashnode', username: pathname.slice(2), confidence: 'low' }; + } + + if (host === 'npmjs.com' && pathname.startsWith('/~') && pathname.length > 2) { + return { platform: 'npm', username: pathname.slice(2), confidence: 'low' }; + } + + return { platform: 'portfolio', username: url.href, confidence: 'high' }; +} + +function parseBlogUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + try { + return new URL(`https://${value}`); + } catch { + return null; + } + } +} + function generateState(): string { return randomBytes(32).toString('hex'); }