Skip to content

Commit 3bc89c7

Browse files
Kanishkaclaude
andcommitted
fix: address PR review — scheme-restricted URLs, CWV default, test coverage
- Replace Joi.string() with relaxedUrl custom validator for BROKEN_INTERNAL_LINKS to reject dangerous URI schemes (javascript:, data:, blob:) while allowing malformed http/https URLs and relative paths - Add .default([]) to CWV issues to prevent undefined access by consumers - Document two CWV data shapes (page-level vs group-type) in schema comment - Add comment explaining BROKEN_INTERNAL_LINKS divergence from BROKEN_BACKLINKS - Add tests for: COLOR_CONTRAST patchContent/isCodeChangeAvailable, A11Y_ASSISTIVE patchContent/isCodeChangeAvailable, ALT_TEXT hasAltAttribute, BROKEN_INTERNAL_LINKS malformed URLs, relative paths, dangerous schemes, empty strings, and urlsSuggested - Rename CWV test descriptions to clarify optionality Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e0565f7 commit 3bc89c7

File tree

2 files changed

+117
-10
lines changed

2 files changed

+117
-10
lines changed

packages/spacecat-shared-data-access/src/models/suggestion/suggestion.data-schemas.js

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@
2626
import Joi from 'joi';
2727
import { OPPORTUNITY_TYPES } from '@adobe/spacecat-shared-utils';
2828

29+
/**
30+
* Custom Joi validator that accepts malformed HTTP/HTTPS URLs and relative paths
31+
* while rejecting dangerous URI schemes (javascript:, data:, blob:, etc.).
32+
* Used for BROKEN_INTERNAL_LINKS where crawled content may contain malformed URLs.
33+
*/
34+
const relaxedUrl = Joi.string().min(1).custom((value, helpers) => (
35+
/^(https?:\/\/|\/)/i.test(value) ? value : helpers.error('string.uriScheme')
36+
), 'relaxed URL').messages({
37+
'string.uriScheme': '{{#label}} must start with http://, https://, or /',
38+
});
39+
2940
/**
3041
* Data schemas configuration per opportunity type.
3142
*
@@ -127,6 +138,10 @@ export const DATA_SCHEMAS = {
127138
},
128139
},
129140
},
141+
// CWV has two implicit data shapes:
142+
// 1. Page-level (type='url'): url and issues are present
143+
// 2. Group-type (type='group'): url is absent, issues may be populated later via update
144+
// Both shapes share the same schema; url and issues are optional to support both.
130145
[OPPORTUNITY_TYPES.CWV]: {
131146
schema: Joi.object({
132147
type: Joi.string().required(),
@@ -148,7 +163,7 @@ export const DATA_SCHEMAS = {
148163
organic: Joi.number().optional(),
149164
}).unknown(true),
150165
).required(),
151-
issues: Joi.array().items(Joi.object()).optional(),
166+
issues: Joi.array().items(Joi.object()).optional().default([]),
152167
jiraLink: Joi.string().uri().allow(null).optional(),
153168
aggregationKey: Joi.string().allow(null).optional(),
154169
}).unknown(true),
@@ -379,14 +394,16 @@ export const DATA_SCHEMAS = {
379394
[OPPORTUNITY_TYPES.BROKEN_INTERNAL_LINKS]: {
380395
schema: Joi.object({
381396
// Support both naming conventions (snake_case and camelCase)
382-
// URL fields use Joi.string() instead of Joi.string().uri() because
383-
// internal-links sometimes keeps URL values as-is, including malformed URLs
384-
url_from: Joi.string().optional(),
385-
urlFrom: Joi.string().optional(),
386-
url_to: Joi.string().optional(),
387-
urlTo: Joi.string().optional(),
397+
// URL fields use relaxedUrl instead of Joi.string().uri() because
398+
// internal-links sometimes keeps URL values as-is, including malformed URLs.
399+
// Unlike BROKEN_BACKLINKS, these accept malformed http/https/relative URLs
400+
// but reject dangerous schemes (javascript:, data:, blob:, etc.).
401+
url_from: relaxedUrl.optional(),
402+
urlFrom: relaxedUrl.optional(),
403+
url_to: relaxedUrl.optional(),
404+
urlTo: relaxedUrl.optional(),
388405
title: Joi.string().optional(),
389-
urlsSuggested: Joi.array().items(Joi.string()).optional(),
406+
urlsSuggested: Joi.array().items(relaxedUrl).optional(),
390407
aiRationale: Joi.string().optional(),
391408
trafficDomain: Joi.number().optional(),
392409
priority: Joi.string().optional(),

packages/spacecat-shared-data-access/test/unit/models/suggestion/suggestion.model.test.js

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ describe('SuggestionModel', () => {
259259
expect(() => Suggestion.validateData(suggestionData, 'cwv')).to.not.throw();
260260
});
261261

262-
it('passes when issues is missing (issues is optional for update flow)', () => {
262+
it('passes when issues is missing (issues is optional)', () => {
263263
const suggestionData = {
264264
type: 'url',
265265
url: 'https://www.example.com/page',
@@ -269,7 +269,7 @@ describe('SuggestionModel', () => {
269269
expect(() => Suggestion.validateData(suggestionData, 'cwv')).to.not.throw();
270270
});
271271

272-
it('passes when group-type has pattern but no url (url is optional for groups)', () => {
272+
it('passes when url is missing (url is optional)', () => {
273273
const suggestionData = {
274274
type: 'group',
275275
name: 'Some pages',
@@ -285,6 +285,96 @@ describe('SuggestionModel', () => {
285285
expect(() => Suggestion.validateData(suggestionData, 'cwv')).to.not.throw();
286286
});
287287
});
288+
289+
describe('COLOR_CONTRAST opportunity type', () => {
290+
it('passes with patchContent and isCodeChangeAvailable fields', () => {
291+
const data = {
292+
url: 'https://example.com/page',
293+
issues: [{ wcagLevel: 'AA', severity: 'serious' }],
294+
patchContent: 'diff --git a/file.js',
295+
isCodeChangeAvailable: true,
296+
};
297+
expect(() => Suggestion.validateData(data, 'color-contrast')).to.not.throw();
298+
});
299+
});
300+
301+
describe('A11Y_ASSISTIVE opportunity type', () => {
302+
it('passes with patchContent and isCodeChangeAvailable fields', () => {
303+
const data = {
304+
url: 'https://example.com/page',
305+
issues: [{ wcagLevel: 'A', severity: 'critical' }],
306+
patchContent: 'diff --git a/style.css',
307+
isCodeChangeAvailable: false,
308+
};
309+
expect(() => Suggestion.validateData(data, 'a11y-assistive')).to.not.throw();
310+
});
311+
});
312+
313+
describe('ALT_TEXT opportunity type', () => {
314+
it('passes with hasAltAttribute field in recommendations', () => {
315+
const data = {
316+
recommendations: [{
317+
pageUrl: 'https://example.com/page',
318+
imageUrl: 'https://example.com/img.png',
319+
isDecorative: false,
320+
hasAltAttribute: true,
321+
}],
322+
};
323+
expect(() => Suggestion.validateData(data, 'image-alt-text')).to.not.throw();
324+
});
325+
});
326+
327+
describe('BROKEN_INTERNAL_LINKS opportunity type', () => {
328+
it('passes with malformed http/https URLs', () => {
329+
const data = {
330+
url_from: 'https://example.com/page with spaces',
331+
url_to: 'https://example.com/broken[link]',
332+
};
333+
expect(() => Suggestion.validateData(data, 'broken-internal-links')).to.not.throw();
334+
});
335+
336+
it('passes with relative URLs', () => {
337+
const data = {
338+
urlFrom: '/relative/path',
339+
urlTo: '/another/path',
340+
};
341+
expect(() => Suggestion.validateData(data, 'broken-internal-links')).to.not.throw();
342+
});
343+
344+
it('rejects dangerous URI schemes (javascript:)', () => {
345+
const data = {
346+
// eslint-disable-next-line no-script-url
347+
url_from: 'javascript:alert(1)',
348+
url_to: 'https://example.com',
349+
};
350+
expect(() => Suggestion.validateData(data, 'broken-internal-links')).to.throw();
351+
});
352+
353+
it('rejects dangerous URI schemes (data:)', () => {
354+
const data = {
355+
url_from: 'https://example.com',
356+
url_to: 'data:text/html,<script>alert(1)</script>',
357+
};
358+
expect(() => Suggestion.validateData(data, 'broken-internal-links')).to.throw();
359+
});
360+
361+
it('rejects empty string URLs', () => {
362+
const data = {
363+
url_from: '',
364+
url_to: 'https://example.com',
365+
};
366+
expect(() => Suggestion.validateData(data, 'broken-internal-links')).to.throw();
367+
});
368+
369+
it('passes with urlsSuggested containing relaxed URLs', () => {
370+
const data = {
371+
url_from: 'https://example.com/from',
372+
url_to: 'https://example.com/to',
373+
urlsSuggested: ['https://example.com/suggested page', '/relative/suggestion'],
374+
};
375+
expect(() => Suggestion.validateData(data, 'broken-internal-links')).to.not.throw();
376+
});
377+
});
288378
});
289379
});
290380
});

0 commit comments

Comments
 (0)