diff --git a/apps/backend/src/__tests__/follow.test.ts b/apps/backend/src/__tests__/follow.test.ts index 199a016..4183001 100644 --- a/apps/backend/src/__tests__/follow.test.ts +++ b/apps/backend/src/__tests__/follow.test.ts @@ -1,5 +1,5 @@ -import Fastify from 'fastify'; -import { describe, expect, it, vi } from 'vitest'; +import Fastify, { FastifyInstance } from 'fastify'; +import { describe, expect, it, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { followRoutes } from '../routes/follow.js'; @@ -7,32 +7,57 @@ vi.mock('../utils/encryption.js', () => ({ decrypt: vi.fn(() => 'fake-access-token'), })); -describe('POST /api/follow/:platform/:targetUsername', () => { - it('returns 400 when API follow is not supported for the platform', async () => { - const app = Fastify({ logger: false }); +// ── Shared mock data ────────────────────────────────────────────────────────── - const findUnique = vi.fn().mockResolvedValue({ - id: 'token-1', - userId: 'user-1', - platform: 'unknown', - accessToken: 'encrypted-token', - }); +const MOCK_USER_ID = 'user-uuid-001'; - app.decorate('prisma', { - oAuthToken: { - findUnique, - }, - followLog: { - create: vi.fn(), - }, - }as any); +const MOCK_OAUTH_TOKEN = { + id: 'token-1', + userId: MOCK_USER_ID, + platform: 'unknown', + accessToken: 'encrypted-token', +}; - app.decorate('authenticate', async (request: any) => { - request.user = { id: 'user-1' }; - }); +// ── App factory ─────────────────────────────────────────────────────────────── + +function buildApp(overrides: { + oAuthToken?: Record; + followLog?: Record; +} = {}): FastifyInstance { + const app = Fastify({ logger: false }); - await app.register(followRoutes, { prefix: '/api/follow' }); - await app.ready(); + app.decorate('prisma', { + oAuthToken: { + findUnique: vi.fn(), + ...overrides.oAuthToken, + }, + followLog: { + create: vi.fn(), + deleteMany: vi.fn(), + ...overrides.followLog, + }, + } as any); + + app.decorate('authenticate', async (request: any) => { + request.user = { id: MOCK_USER_ID }; + }); + + return app; +} + +async function makeApp(overrides?: Parameters[0]): Promise { + const app = buildApp(overrides); + await app.register(followRoutes, { prefix: '/api/follow' }); + await app.ready(); + return app; +} + +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/follow/:platform/:targetUsername — API follow', () => { + it('returns 400 when API follow is not supported for the platform', async () => { + const findUnique = vi.fn().mockResolvedValue(MOCK_OAUTH_TOKEN); + const app = await makeApp({ oAuthToken: { findUnique } }); const response = await app.inject({ method: 'POST', @@ -46,7 +71,7 @@ describe('POST /api/follow/:platform/:targetUsername', () => { expect(findUnique).toHaveBeenCalledWith({ where: { userId_platform: { - userId: 'user-1', + userId: MOCK_USER_ID, platform: 'unknown', }, }, @@ -56,20 +81,7 @@ describe('POST /api/follow/:platform/:targetUsername', () => { }); it('returns webview strategy and url for webview-strategy platforms (e.g. linkedin)', async () => { - const app = Fastify({ logger: false }); - - app.decorate('prisma', { - followLog: { - create: vi.fn(), - }, - } as any); - - app.decorate('authenticate', async (request: any) => { - request.user = { id: 'user-1' }; - }); - - await app.register(followRoutes, { prefix: '/api/follow' }); - await app.ready(); + const app = await makeApp(); const response = await app.inject({ method: 'POST', @@ -84,56 +96,233 @@ describe('POST /api/follow/:platform/:targetUsername', () => { await app.close(); }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/follow/:platform/:targetUsername/log — follow log validation', () => { + let app: FastifyInstance; + let createLog: ReturnType; + + // One app instance shared across all log tests; mock reset between each test. + beforeAll(async () => { + createLog = vi.fn(); + app = await makeApp({ followLog: { create: createLog } }); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + createLog.mockReset(); + createLog.mockResolvedValue({ id: 'log-uuid-001' }); + }); + + // ── Valid payloads ──────────────────────────────────────────────────────── + + it('200 — accepts status: success, layer: foreground', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'success', layer: 'foreground' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ status: 'success', logId: 'log-uuid-001' }); + expect(createLog).toHaveBeenCalledOnce(); + expect(createLog.mock.calls[0][0].data.status).toBe('success'); + }); - it('successfully logs a webview follow action', async () => { - const app = Fastify({ logger: false }); + it('200 — accepts status: failed', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'failed', layer: 'foreground' }, + }); - const createLog = vi.fn().mockResolvedValue({ - id: 'log-1', - followerId: 'user-1', - targetUsername: 'testuser', - platform: 'linkedin', - status: 'success', - layer: 'webview', + expect(res.statusCode).toBe(200); + expect(createLog).toHaveBeenCalledOnce(); + expect(createLog.mock.calls[0][0].data.status).toBe('failed'); + }); + + it('200 — accepts status: pending, layer: background', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'pending', layer: 'background' }, }); - app.decorate('prisma', { - followLog: { - create: createLog, - }, - } as any); + expect(res.statusCode).toBe(200); + expect(createLog).toHaveBeenCalledOnce(); + expect(createLog.mock.calls[0][0].data.layer).toBe('background'); + }); + + // ── Invalid status values — analytics integrity ─────────────────────────── - app.decorate('authenticate', async (request: any) => { - request.user = { id: 'user-1' }; + it('400 — rejects invalid status "error" (old unvalidated internal value)', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'error', layer: 'foreground' }, }); - await app.register(followRoutes, { prefix: '/api/follow' }); - await app.ready(); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: 'Invalid follow log payload' }); + // DB must NOT be written — this is the analytics integrity guarantee + expect(createLog).not.toHaveBeenCalled(); + }); - const response = await app.inject({ + it('400 — rejects arbitrary status string injection', async () => { + const res = await app.inject({ method: 'POST', url: '/api/follow/linkedin/testuser/log', - payload: { - status: 'success', - layer: 'webview', - }, + payload: { status: '"; DROP TABLE follow_logs; --', layer: 'foreground' }, }); - const body = response.json(); + expect(res.statusCode).toBe(400); + expect(createLog).not.toHaveBeenCalled(); + }); + + // ── Invalid layer values — analytics integrity ──────────────────────────── + + // 'webview' was the old unvalidated default — it is now explicitly rejected. + // Any existing caller sending layer: 'webview' must migrate to 'foreground' + // (in-app WebView session) or 'background' (passive deep-link strategy). + it('400 — rejects legacy layer "webview" (old unvalidated default)', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'success', layer: 'webview' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: 'Invalid follow log payload' }); + expect(createLog).not.toHaveBeenCalled(); + }); + + it('400 — rejects invalid layer "api"', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'success', layer: 'api' }, + }); + + expect(res.statusCode).toBe(400); + expect(createLog).not.toHaveBeenCalled(); + }); + + // ── Malformed / missing payloads ────────────────────────────────────────── + + it('400 — rejects missing status field', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { layer: 'foreground' }, + }); + + expect(res.statusCode).toBe(400); + expect(createLog).not.toHaveBeenCalled(); + }); + + it('400 — rejects missing layer field', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'success' }, + }); + + expect(res.statusCode).toBe(400); + expect(createLog).not.toHaveBeenCalled(); + }); + + it('400 — rejects empty body', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + expect(createLog).not.toHaveBeenCalled(); + }); + + // ── Correct data persisted to DB ────────────────────────────────────────── + + it('persists exactly the validated platform, targetUsername, status, and layer', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/twitter/janedoe/log', + payload: { status: 'pending', layer: 'background' }, + }); + + expect(res.statusCode).toBe(200); + expect(createLog).toHaveBeenCalledOnce(); + + const written = createLog.mock.calls[0][0].data; + expect(written).toMatchObject({ + followerId: MOCK_USER_ID, + targetUsername: 'janedoe', + platform: 'twitter', + status: 'pending', + layer: 'background', + }); + }); + + // ── Response does not leak validation internals ─────────────────────────── + + it('400 response only exposes { error } — no schema internals or stack traces', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'bad', layer: 'bad' }, + }); + + expect(res.statusCode).toBe(400); + const body = res.json(); + expect(body).not.toHaveProperty('issues'); + expect(body).not.toHaveProperty('stack'); + expect(Object.keys(body)).toEqual(['error']); + }); + + // ── DB failure after valid payload ──────────────────────────────────────── + + it('500 — returns 500 when DB write fails after successful validation', async () => { + createLog.mockRejectedValueOnce(new Error('DB connection lost')); + + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'success', layer: 'foreground' }, + }); + + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ error: 'Failed to log follow event' }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('DELETE /api/follow/:platform/:targetUsername/log — clear follow log', () => { + it('clears follow log entries for the authenticated user', async () => { + const deleteMany = vi.fn().mockResolvedValue({ count: 1 }); + const app = await makeApp({ followLog: { deleteMany } }); + + const response = await app.inject({ + method: 'DELETE', + url: '/api/follow/linkedin/testuser/log', + }); expect(response.statusCode).toBe(200); - expect(body.status).toBe('success'); - expect(body.logId).toBe('log-1'); - expect(createLog).toHaveBeenCalledWith({ - data: { - followerId: 'user-1', - targetUsername: 'testuser', + expect(response.json()).toMatchObject({ status: 'cleared' }); + expect(deleteMany).toHaveBeenCalledWith({ + where: { + followerId: MOCK_USER_ID, platform: 'linkedin', - status: 'success', - layer: 'webview', + targetUsername: 'testuser', }, }); await app.close(); }); -}); \ No newline at end of file +}); diff --git a/apps/backend/src/routes/follow.ts b/apps/backend/src/routes/follow.ts index bf62d92..d5c57fe 100644 --- a/apps/backend/src/routes/follow.ts +++ b/apps/backend/src/routes/follow.ts @@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { decrypt } from '../utils/encryption.js'; import { getErrorMessage } from '../utils/error.util.js'; import { getPlatform, getProfileUrl, getWebViewUrl } from '@devcard/shared'; +import { followLogSchema } from '../validations/follow.validation.js'; export async function followRoutes(app: FastifyInstance) { app.addHook('preHandler', app.authenticate); @@ -92,7 +93,11 @@ export async function followRoutes(app: FastifyInstance) { } }); - // Log follow/connect event for Layer 2/3/4 strategies + // Log follow/connect event for Layer 2/3/4 strategies (WebView, deep-link, etc.) + // + // status and layer are analytics-impacting fields: they drive totalFollows counters + // and the follower-state dashboard. Both are validated against a strict allowlist + // before any database write — arbitrary client values are rejected with 400. app.post('/:platform/:targetUsername/log', async ( request: FastifyRequest<{ Params: { platform: string; targetUsername: string }; @@ -102,7 +107,13 @@ export async function followRoutes(app: FastifyInstance) { ) => { const userId = (request.user as any).id; const { platform, targetUsername } = request.params; - const { status = 'success', layer = 'webview' } = request.body || {}; + + const parsed = followLogSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Invalid follow log payload' }); + } + + const { status, layer } = parsed.data; try { const log = await app.prisma.followLog.create({ diff --git a/apps/backend/src/validations/follow.validation.ts b/apps/backend/src/validations/follow.validation.ts new file mode 100644 index 0000000..319f1de --- /dev/null +++ b/apps/backend/src/validations/follow.validation.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +/** + * Strict allowlist schema for analytics-impacting follow log fields. + * + * Both `status` and `layer` feed directly into analytics counters and the + * follower-state dashboard. Only the values enumerated below may be + * persisted — all other values are rejected before any database write. + * + * status: + * 'success' — the follow action completed and was accepted by the platform + * 'failed' — the action completed but was rejected (e.g. rate-limit, block) + * 'pending' — the action was initiated; outcome not yet confirmed by client + * + * layer (hybrid follow engine interaction surface): + * 'foreground' — user interacted directly with an in-app WebView session + * 'background' — follow triggered through a passive deep-link / redirect strategy + */ +export const followLogSchema = z.object({ + status: z.enum(['success', 'failed', 'pending'], { + errorMap: () => ({ + message: "status must be one of: 'success', 'failed', 'pending'", + }), + }), + layer: z.enum(['foreground', 'background'], { + errorMap: () => ({ + message: "layer must be one of: 'foreground', 'background'", + }), + }), +}); + +export type FollowLogBody = z.infer;