Sifa professional network API (Fastify, AT Protocol, Jetstream) sifa.id/

fix(import): silently drop invalid URLs instead of rejecting entire import (#162)

LinkedIn exports sometimes contain invalid credential URLs (e.g. partial
URLs or non-URL text). Previously, one bad URL in any certification,
project, or publication would cause the entire import to fail with a 400
"Validation failed" error.

The optionalUrl() Zod helper now validates URLs at parse time and
silently drops invalid ones to undefined, allowing the rest of the
import to proceed.

Closes #170

authored by

Guido X Jansen and committed by
GitHub
bddf8cb0 21c4ec9c

+38 -3
+18 -3
src/routes/schemas.ts
··· 1 1 import { z } from 'zod'; 2 2 3 + /** Accept a URL string or silently drop it if invalid (returns undefined). */ 4 + const optionalUrl = () => 5 + z 6 + .string() 7 + .optional() 8 + .transform((val) => { 9 + if (!val) return undefined; 10 + try { 11 + new URL(val); 12 + return val; 13 + } catch { 14 + return undefined; 15 + } 16 + }); 17 + 3 18 const locationSchema = z 4 19 .union([ 5 20 z.object({ ··· 60 75 name: z.string().min(1).max(100), 61 76 authority: z.string().max(100).optional(), 62 77 credentialId: z.string().max(100).optional(), 63 - credentialUrl: z.string().url().optional(), 78 + credentialUrl: optionalUrl(), 64 79 issuedAt: z.string().optional(), 65 80 expiresAt: z.string().optional(), 66 81 }); ··· 68 83 export const projectSchema = z.object({ 69 84 name: z.string().min(1).max(100), 70 85 description: z.string().max(50000).optional(), 71 - url: z.string().url().optional(), 86 + url: optionalUrl(), 72 87 startDate: z.string().optional(), 73 88 endDate: z.string().optional(), 74 89 }); ··· 85 100 export const publicationSchema = z.object({ 86 101 title: z.string().min(1).max(200), 87 102 publisher: z.string().max(100).optional(), 88 - url: z.string().url().optional(), 103 + url: optionalUrl(), 89 104 description: z.string().max(50000).optional(), 90 105 publishedAt: z.string().optional(), 91 106 });
+20
tests/routes/import.test.ts
··· 63 63 expect(res.statusCode).toBe(503); 64 64 }); 65 65 66 + it('POST /api/import/linkedin/confirm strips invalid credential URLs instead of rejecting', async () => { 67 + const res = await app.inject({ 68 + method: 'POST', 69 + url: '/api/import/linkedin/confirm', 70 + payload: { 71 + certifications: [ 72 + { name: 'Valid Cert', credentialUrl: 'https://example.com/cert' }, 73 + { name: 'Bad URL Cert', credentialUrl: 'not-a-url' }, 74 + { name: 'Empty URL Cert', credentialUrl: '' }, 75 + { name: 'No URL Cert' }, 76 + ], 77 + projects: [{ name: 'Proj', url: 'garbage' }], 78 + publications: [{ title: 'Pub', url: '????' }], 79 + }, 80 + cookies: { session: 'test-session-id' }, 81 + }); 82 + // 503 because no OAuth client -- but it passed validation (not 400) 83 + expect(res.statusCode).toBe(503); 84 + }); 85 + 66 86 it('POST /api/import/linkedin/confirm accepts valid payload with all sections', async () => { 67 87 const res = await app.inject({ 68 88 method: 'POST',