From dc3186abab1d454f532bb831513ababf82424a29 Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Mon, 25 May 2026 19:49:12 +0530 Subject: [PATCH 1/2] fix(follow): validate status and layer enums before persisting follow logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /:platform/:targetUsername/log accepted free-form `status` and `layer` values and wrote them directly to followLog without validation. Both fields feed analytics counters (totalFollows) and the follower-state dashboard via `status: 'success'` queries, so an authenticated user could fabricate successful follow events, inflate engagement metrics, and manipulate the dashboard. Fix: - Add `followLogSchema` (Zod) in validations/follow.validation.ts with strict enum allowlists: status → 'success' | 'failed' | 'pending' layer → 'foreground' | 'background' - Validate request body with safeParse before any database write; invalid payloads return 400 without touching followLog.create() - Remove unsafe free-form defaults ('success' / 'webview') that silently accepted omitted fields - Response body on validation failure contains only { error } — no Zod internals, paths, or stack traces are exposed Layer 1 (API follow) writes status/layer internally and is unaffected. Tests: 22 cases covering all valid enum combinations, all rejection paths, DB-not-called guarantee on failure, correct payload written to DB, and opaque error responses. Closes #301 --- apps/backend/src/__tests__/follow.test.ts | 400 +++++++++++++++--- apps/backend/src/routes/follow.ts | 17 +- .../src/validations/follow.validation.ts | 32 ++ 3 files changed, 377 insertions(+), 72 deletions(-) create mode 100644 apps/backend/src/validations/follow.validation.ts diff --git a/apps/backend/src/__tests__/follow.test.ts b/apps/backend/src/__tests__/follow.test.ts index 199a016..d058c6f 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, beforeEach, afterEach } 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 }); + + 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; +} - await app.register(followRoutes, { prefix: '/api/follow' }); - await app.ready(); +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,306 @@ describe('POST /api/follow/:platform/:targetUsername', () => { await app.close(); }); +}); - it('successfully logs a webview follow action', async () => { - const app = Fastify({ logger: false }); +// ───────────────────────────────────────────────────────────────────────────── - const createLog = vi.fn().mockResolvedValue({ - id: 'log-1', - followerId: 'user-1', +describe('POST /api/follow/:platform/:targetUsername/log — follow log validation', () => { + let app: FastifyInstance; + let createLog: ReturnType; + + beforeEach(async () => { + createLog = vi.fn().mockResolvedValue({ + id: 'log-uuid-001', + followerId: MOCK_USER_ID, targetUsername: 'testuser', platform: 'linkedin', status: 'success', - layer: 'webview', + layer: 'foreground', }); + app = await makeApp({ followLog: { create: createLog } }); + }); - app.decorate('prisma', { - followLog: { - create: createLog, - }, - } as any); + afterEach(async () => { + await app.close(); + }); - app.decorate('authenticate', async (request: any) => { - request.user = { id: 'user-1' }; + // ── Valid status values ─────────────────────────────────────────────────── + + it('200 — accepts status: success', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'success', layer: 'foreground' }, }); - await app.register(followRoutes, { prefix: '/api/follow' }); - await app.ready(); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ status: 'logged', logId: 'log-uuid-001' }); + expect(createLog).toHaveBeenCalledOnce(); + expect(createLog.mock.calls[0][0].data.status).toBe('success'); + }); - const response = await app.inject({ + it('200 — accepts status: failed', async () => { + createLog.mockResolvedValueOnce({ id: 'log-uuid-002', status: 'failed', layer: 'foreground' }); + + const res = await app.inject({ method: 'POST', url: '/api/follow/linkedin/testuser/log', - payload: { - status: 'success', - layer: 'webview', - }, + payload: { status: 'failed', layer: 'foreground' }, }); - const body = response.json(); + expect(res.statusCode).toBe(200); + expect(createLog).toHaveBeenCalledOnce(); + expect(createLog.mock.calls[0][0].data.status).toBe('failed'); + }); + + it('200 — accepts status: pending', async () => { + createLog.mockResolvedValueOnce({ id: 'log-uuid-003', status: 'pending', layer: 'foreground' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'pending', layer: 'foreground' }, + }); + + expect(res.statusCode).toBe(200); + expect(createLog).toHaveBeenCalledOnce(); + expect(createLog.mock.calls[0][0].data.status).toBe('pending'); + }); + + // ── Valid layer values ──────────────────────────────────────────────────── + + it('200 — accepts 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(createLog.mock.calls[0][0].data.layer).toBe('foreground'); + }); + + it('200 — accepts layer: background', async () => { + createLog.mockResolvedValueOnce({ id: 'log-uuid-004', status: 'success', layer: 'background' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'success', layer: 'background' }, + }); + + expect(res.statusCode).toBe(200); + expect(createLog.mock.calls[0][0].data.layer).toBe('background'); + }); + + // ── Invalid status values — analytics integrity ─────────────────────────── + + 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' }, + }); + + 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(); + }); + + it('400 — rejects fabricated status "admin_override"', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'admin_override', layer: 'foreground' }, + }); + + expect(res.statusCode).toBe(400); + expect(createLog).not.toHaveBeenCalled(); + }); + + it('400 — rejects arbitrary status string injection', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: '"; DROP TABLE follow_logs; --', layer: 'foreground' }, + }); + + expect(res.statusCode).toBe(400); + expect(createLog).not.toHaveBeenCalled(); + }); + + // ── Invalid layer values — analytics integrity ──────────────────────────── + + it('400 — rejects invalid layer "webview" (old unvalidated value)', 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(); + }); + + it('400 — rejects arbitrary layer string injection', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 'success', layer: 'superuser' }, + }); + + 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(); + }); + + it('400 — rejects null values for both fields', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: null, layer: null }, + }); + + expect(res.statusCode).toBe(400); + expect(createLog).not.toHaveBeenCalled(); + }); + + it('400 — rejects numeric value for status', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/follow/linkedin/testuser/log', + payload: { status: 1, layer: 'foreground' }, + }); + + 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 does not expose internal schema details 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(); + // Must not expose Zod issue paths, internal type names, or stack traces + 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..422cd02 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({ @@ -114,7 +125,7 @@ export async function followRoutes(app: FastifyInstance) { layer, }, }); - return reply.send({ status: 'success', logId: log.id }); + return reply.send({ status: 'logged', logId: log.id }); } catch (err: any) { app.log.error('Failed to log follow:', err); return reply.status(500).send({ error: 'Failed to log follow event' }); 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; From 4f14f98b9f820a84e133dccced16e0389301e5f4 Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Tue, 26 May 2026 21:42:12 +0530 Subject: [PATCH 2/2] fix(follow): enforce enum validation on follow log status and layer fields --- apps/backend/src/__tests__/follow.test.ts | 113 ++++------------------ apps/backend/src/routes/follow.ts | 2 +- 2 files changed, 21 insertions(+), 94 deletions(-) diff --git a/apps/backend/src/__tests__/follow.test.ts b/apps/backend/src/__tests__/follow.test.ts index d058c6f..4183001 100644 --- a/apps/backend/src/__tests__/follow.test.ts +++ b/apps/backend/src/__tests__/follow.test.ts @@ -1,5 +1,5 @@ import Fastify, { FastifyInstance } from 'fastify'; -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { describe, expect, it, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { followRoutes } from '../routes/follow.js'; @@ -104,25 +104,24 @@ describe('POST /api/follow/:platform/:targetUsername/log — follow log validati let app: FastifyInstance; let createLog: ReturnType; - beforeEach(async () => { - createLog = vi.fn().mockResolvedValue({ - id: 'log-uuid-001', - followerId: MOCK_USER_ID, - targetUsername: 'testuser', - platform: 'linkedin', - status: 'success', - layer: 'foreground', - }); + // One app instance shared across all log tests; mock reset between each test. + beforeAll(async () => { + createLog = vi.fn(); app = await makeApp({ followLog: { create: createLog } }); }); - afterEach(async () => { + afterAll(async () => { await app.close(); }); - // ── Valid status values ─────────────────────────────────────────────────── + beforeEach(() => { + createLog.mockReset(); + createLog.mockResolvedValue({ id: 'log-uuid-001' }); + }); + + // ── Valid payloads ──────────────────────────────────────────────────────── - it('200 — accepts status: success', async () => { + it('200 — accepts status: success, layer: foreground', async () => { const res = await app.inject({ method: 'POST', url: '/api/follow/linkedin/testuser/log', @@ -130,14 +129,12 @@ describe('POST /api/follow/:platform/:targetUsername/log — follow log validati }); expect(res.statusCode).toBe(200); - expect(res.json()).toMatchObject({ status: 'logged', logId: 'log-uuid-001' }); + expect(res.json()).toMatchObject({ status: 'success', logId: 'log-uuid-001' }); expect(createLog).toHaveBeenCalledOnce(); expect(createLog.mock.calls[0][0].data.status).toBe('success'); }); it('200 — accepts status: failed', async () => { - createLog.mockResolvedValueOnce({ id: 'log-uuid-002', status: 'failed', layer: 'foreground' }); - const res = await app.inject({ method: 'POST', url: '/api/follow/linkedin/testuser/log', @@ -149,43 +146,15 @@ describe('POST /api/follow/:platform/:targetUsername/log — follow log validati expect(createLog.mock.calls[0][0].data.status).toBe('failed'); }); - it('200 — accepts status: pending', async () => { - createLog.mockResolvedValueOnce({ id: 'log-uuid-003', status: 'pending', layer: 'foreground' }); - + 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: 'foreground' }, + payload: { status: 'pending', layer: 'background' }, }); expect(res.statusCode).toBe(200); expect(createLog).toHaveBeenCalledOnce(); - expect(createLog.mock.calls[0][0].data.status).toBe('pending'); - }); - - // ── Valid layer values ──────────────────────────────────────────────────── - - it('200 — accepts 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(createLog.mock.calls[0][0].data.layer).toBe('foreground'); - }); - - it('200 — accepts layer: background', async () => { - createLog.mockResolvedValueOnce({ id: 'log-uuid-004', status: 'success', layer: 'background' }); - - const res = await app.inject({ - method: 'POST', - url: '/api/follow/linkedin/testuser/log', - payload: { status: 'success', layer: 'background' }, - }); - - expect(res.statusCode).toBe(200); expect(createLog.mock.calls[0][0].data.layer).toBe('background'); }); @@ -204,17 +173,6 @@ describe('POST /api/follow/:platform/:targetUsername/log — follow log validati expect(createLog).not.toHaveBeenCalled(); }); - it('400 — rejects fabricated status "admin_override"', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/follow/linkedin/testuser/log', - payload: { status: 'admin_override', layer: 'foreground' }, - }); - - expect(res.statusCode).toBe(400); - expect(createLog).not.toHaveBeenCalled(); - }); - it('400 — rejects arbitrary status string injection', async () => { const res = await app.inject({ method: 'POST', @@ -228,7 +186,10 @@ describe('POST /api/follow/:platform/:targetUsername/log — follow log validati // ── Invalid layer values — analytics integrity ──────────────────────────── - it('400 — rejects invalid layer "webview" (old unvalidated value)', async () => { + // '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', @@ -251,17 +212,6 @@ describe('POST /api/follow/:platform/:targetUsername/log — follow log validati expect(createLog).not.toHaveBeenCalled(); }); - it('400 — rejects arbitrary layer string injection', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/follow/linkedin/testuser/log', - payload: { status: 'success', layer: 'superuser' }, - }); - - expect(res.statusCode).toBe(400); - expect(createLog).not.toHaveBeenCalled(); - }); - // ── Malformed / missing payloads ────────────────────────────────────────── it('400 — rejects missing status field', async () => { @@ -297,28 +247,6 @@ describe('POST /api/follow/:platform/:targetUsername/log — follow log validati expect(createLog).not.toHaveBeenCalled(); }); - it('400 — rejects null values for both fields', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/follow/linkedin/testuser/log', - payload: { status: null, layer: null }, - }); - - expect(res.statusCode).toBe(400); - expect(createLog).not.toHaveBeenCalled(); - }); - - it('400 — rejects numeric value for status', async () => { - const res = await app.inject({ - method: 'POST', - url: '/api/follow/linkedin/testuser/log', - payload: { status: 1, layer: 'foreground' }, - }); - - 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 () => { @@ -343,7 +271,7 @@ describe('POST /api/follow/:platform/:targetUsername/log — follow log validati // ── Response does not leak validation internals ─────────────────────────── - it('400 response does not expose internal schema details or stack traces', async () => { + 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', @@ -352,7 +280,6 @@ describe('POST /api/follow/:platform/:targetUsername/log — follow log validati expect(res.statusCode).toBe(400); const body = res.json(); - // Must not expose Zod issue paths, internal type names, or stack traces expect(body).not.toHaveProperty('issues'); expect(body).not.toHaveProperty('stack'); expect(Object.keys(body)).toEqual(['error']); diff --git a/apps/backend/src/routes/follow.ts b/apps/backend/src/routes/follow.ts index 422cd02..d5c57fe 100644 --- a/apps/backend/src/routes/follow.ts +++ b/apps/backend/src/routes/follow.ts @@ -125,7 +125,7 @@ export async function followRoutes(app: FastifyInstance) { layer, }, }); - return reply.send({ status: 'logged', logId: log.id }); + return reply.send({ status: 'success', logId: log.id }); } catch (err: any) { app.log.error('Failed to log follow:', err); return reply.status(500).send({ error: 'Failed to log follow event' });