Browse Source

refactor(news): validate feed JSON shape with zod

Replace the unsafe `as FeedJson` cast at the news feed adapter with
a zod-based schema check. The top-level shape is validated as a
gate; each item is parsed individually so a single malformed item
is skipped (logged) without aborting the batch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ryotaro Nagahara 3 weeks ago
parent
commit
4ab07fd074

+ 65 - 0
apps/app/src/features/news/server/services/feed-parser.ts

@@ -0,0 +1,65 @@
+import { z } from 'zod';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:feature:news:feed-parser');
+
+const FeedItemSchema = z.object({
+  id: z.string().min(1),
+  type: z.string().optional(),
+  emoji: z.string().optional(),
+  title: z.record(z.string()),
+  body: z.record(z.string()).optional(),
+  url: z.string().optional(),
+  publishedAt: z.string().min(1),
+  conditions: z
+    .object({
+      targetRoles: z.array(z.string()).optional(),
+      growiVersionRegExps: z.array(z.string()).optional(),
+    })
+    .optional(),
+});
+
+const FeedJsonSchema = z.object({
+  version: z.string(),
+  // Items are parsed individually so a single bad item does not abort the batch
+  items: z.array(z.unknown()),
+});
+
+export type FeedItem = z.infer<typeof FeedItemSchema>;
+
+export interface FeedJson {
+  version: string;
+  items: FeedItem[];
+}
+
+/**
+ * Validate parsed JSON against the feed schema.
+ * Items failing per-item validation are skipped (logged), allowing the rest to be processed.
+ * Returns null when the top-level shape is invalid.
+ */
+export const parseFeedJson = (raw: unknown): FeedJson | null => {
+  const topResult = FeedJsonSchema.safeParse(raw);
+  if (!topResult.success) {
+    logger.error(
+      { issues: topResult.error.issues },
+      'News feed JSON top-level shape invalid',
+    );
+    return null;
+  }
+
+  const validItems: FeedItem[] = [];
+  for (const rawItem of topResult.data.items) {
+    const itemResult = FeedItemSchema.safeParse(rawItem);
+    if (itemResult.success) {
+      validItems.push(itemResult.data);
+    } else {
+      logger.warn(
+        { issues: itemResult.error.issues },
+        'News feed item failed validation, skipping',
+      );
+    }
+  }
+
+  return { version: topResult.data.version, items: validItems };
+};

+ 48 - 0
apps/app/src/features/news/server/services/news-cron-service.spec.ts

@@ -242,5 +242,53 @@ describe('NewsCronService', () => {
       expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
       expect(mocks.deleteNewsItemsByExternalIds).not.toHaveBeenCalled();
     });
+
+    test('should abort when top-level shape is invalid', async () => {
+      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+      // Missing `items` field — top-level schema check fails
+      mocks.mockFetch.mockResolvedValue(mockResponse({ version: '1.0' }));
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+      expect(mocks.deleteNewsItemsByExternalIds).not.toHaveBeenCalled();
+    });
+
+    test('should skip individual invalid items but keep valid ones', async () => {
+      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+      const feedWithMixedItems = {
+        version: '1.0',
+        items: [
+          // Missing required fields (title, publishedAt) → skipped
+          { id: 'broken-item' },
+          // Valid item
+          {
+            id: 'good-item',
+            title: { ja_JP: '正常' },
+            publishedAt: '2026-01-01T00:00:00Z',
+          },
+        ],
+      };
+      mocks.mockFetch.mockResolvedValue(mockResponse(feedWithMixedItems));
+
+      await service.executeJob();
+
+      const upsertCall = mocks.upsertNewsItems.mock.calls[0][0];
+      expect(upsertCall.map((i: { id: string }) => i.id)).toEqual([
+        'good-item',
+      ]);
+    });
+
+    test('should skip when response body is not valid JSON', async () => {
+      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+      mocks.mockFetch.mockResolvedValue({
+        ok: true,
+        text: () => Promise.resolve('not-a-json{'),
+      });
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+    });
   });
 });

+ 8 - 21
apps/app/src/features/news/server/services/news-cron-service.ts

@@ -3,6 +3,7 @@ import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
 
 import type { INewsItemInput } from '../../interfaces/news-item';
+import { type FeedItem, parseFeedJson } from './feed-parser';
 import { NewsService } from './news-service';
 
 const logger = loggerFactory('growi:feature:news:cron');
@@ -20,25 +21,6 @@ const FETCH_TIMEOUT_MS = 10_000;
  */
 const MAX_RESPONSE_SIZE_BYTES = 5 * 1024 * 1024;
 
-interface FeedItem {
-  id: string;
-  type?: string;
-  emoji?: string;
-  title: Record<string, string>;
-  body?: Record<string, string>;
-  url?: string;
-  publishedAt: string;
-  conditions?: {
-    targetRoles?: string[];
-    growiVersionRegExps?: string[];
-  };
-}
-
-interface FeedJson {
-  version: string;
-  items: FeedItem[];
-}
-
 /**
  * Check if the given URL is allowed for fetching
  */
@@ -102,7 +84,7 @@ export class NewsCronService extends CronService {
     // Random sleep to distribute requests across multiple GROWI instances
     await randomSleep(MAX_RANDOM_SLEEP_MS);
 
-    let feedJson: FeedJson;
+    let rawJson: unknown;
     try {
       const response = await fetch(feedUrl, {
         signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -121,12 +103,17 @@ export class NewsCronService extends CronService {
         return;
       }
 
-      feedJson = JSON.parse(text) as FeedJson;
+      rawJson = JSON.parse(text);
     } catch (err) {
       logger.error('Error fetching news feed, keeping existing data', err);
       return;
     }
 
+    const feedJson = parseFeedJson(rawJson);
+    if (feedJson == null) {
+      return;
+    }
+
     const currentVersion = getGrowiVersion();
     const filteredItems = feedJson.items.filter((item) =>
       matchesGrowiVersion(item, currentVersion),