diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 73c4acd3b5d..19d86727bc3 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -682,6 +682,7 @@ "http-url": "An unsecure (http) URL cannot be used.", "own-work-url": "Remember to submit your own work.", "publicly-visible-url": "Remember to submit a publicly visible app URL.", + "ms-learn-link": "Please use a valid Microsoft Learn trophy link.", "path-url": "You probably want to submit the root path i.e. https://example.com, not https://example.com/path" }, "certification": { diff --git a/client/src/components/formHelpers/form-fields.tsx b/client/src/components/formHelpers/form-fields.tsx index 0ab9eeb9931..dade529c657 100644 --- a/client/src/components/formHelpers/form-fields.tsx +++ b/client/src/components/formHelpers/form-fields.tsx @@ -16,13 +16,15 @@ import { composeValidators, fCCValidator, httpValidator, - pathValidator + pathValidator, + microsoftValidator } from './form-validators'; export type FormOptions = { ignored?: string[]; isEditorLinkAllowed?: boolean; isLocalLinkAllowed?: boolean; + isMicrosoftLearnLink?: boolean; required?: string[]; types?: { [key: string]: string }; placeholders?: { [key: string]: string }; @@ -42,7 +44,8 @@ function FormFields(props: FormFieldsProps): JSX.Element { required = [], types = {}, isEditorLinkAllowed = false, - isLocalLinkAllowed = false + isLocalLinkAllowed = false, + isMicrosoftLearnLink = false } = options; const nullOrWarning = ( @@ -71,6 +74,7 @@ function FormFields(props: FormFieldsProps): JSX.Element { if (!isLocalLinkAllowed) { validators.push(localhostValidator); } + if (isMicrosoftLearnLink) validators.push(microsoftValidator); const validationWarning = composeValidators(...validators)(value); const message: string = (error || validationError || diff --git a/client/src/components/formHelpers/form-validators.test.tsx b/client/src/components/formHelpers/form-validators.test.tsx new file mode 100644 index 00000000000..3e2807b4f42 --- /dev/null +++ b/client/src/components/formHelpers/form-validators.test.tsx @@ -0,0 +1,63 @@ +import { isMicrosoftLearnLink } from './form-validators'; + +const baseUrl = + 'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy'; +describe('form-validators', () => { + describe('isMicrosoftLearnLink', () => { + it('should reject links to domains other than learn.microsoft.com', () => { + { + expect(isMicrosoftLearnLink('https://lean.microsoft.com')).toBe(false); + expect(isMicrosoftLearnLink('https://learn.microsft.com')).toBe(false); + } + }); + + it('should reject links without a sharingId', () => { + expect(isMicrosoftLearnLink(`${baseUrl}?username=moT01`)).toBe(false); + + expect(isMicrosoftLearnLink(`${baseUrl}?username=moT01&sharingId=`)).toBe( + false + ); + }); + + it('should reject links without a username', () => { + expect(isMicrosoftLearnLink(`${baseUrl}?sharingId=Whatever`)).toBe(false); + expect(isMicrosoftLearnLink(`${baseUrl}?sharingId=123&username=`)).toBe( + false + ); + }); + + it('should reject links without the /training/achievements/ subpath', () => { + expect( + isMicrosoftLearnLink( + 'https://learn.microsoft.com/en-us/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8' + ) + ).toBe(false); + }); + + it('should reject links with the wrong trophy subpath', () => { + // missing .trophy + expect( + isMicrosoftLearnLink( + 'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1?username=moT01&sharingId=E2EF453C1F9208B8' + ) + ).toBe(false); + // no number + expect( + isMicrosoftLearnLink( + 'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-a.trophy?username=moT01&sharingId=E2EF453C1F9208B8' + ) + ).toBe(false); + }); + + it.each(['en-us', 'fr-fr', 'lang-country'])( + 'should accept links with the %s locale', + locale => { + expect( + isMicrosoftLearnLink( + `https://learn.microsoft.com/${locale}/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8` + ) + ).toBe(true); + } + ); + }); +}); diff --git a/client/src/components/formHelpers/form-validators.tsx b/client/src/components/formHelpers/form-validators.tsx index cab0ac9dc63..66e299f9506 100644 --- a/client/src/components/formHelpers/form-validators.tsx +++ b/client/src/components/formHelpers/form-validators.tsx @@ -19,6 +19,27 @@ function isPathRoot(urlString: string): boolean { type Validator = (value: string) => React.ReactElement | null; +// example link: https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8 +export const isMicrosoftLearnLink = (value: string): boolean => { + let url; + try { + url = new URL(value); + } catch { + return false; + } + + const correctDomain = url.hostname === 'learn.microsoft.com'; + const correctPath = !!url.pathname.match( + /^\/[^/]+\/training\/achievements\/learn\.wwl\.get-started-c-sharp-part-\d\.trophy$/ + ); + const hasSharingId = !!url.searchParams.get('sharingId'); + const hasUsername = !!url.searchParams.get('username'); + return correctDomain && correctPath && hasSharingId && hasUsername; +}; + +export const microsoftValidator: Validator = value => + !isMicrosoftLearnLink(value) ? validation.ms-learn-link : null; + export const editorValidator: Validator = value => editorRegex.test(value) ? validation.editor-url : null; @@ -36,11 +57,10 @@ export const httpValidator: Validator = value => export const pathValidator: Validator = value => isPathRoot(value) ? validation.path-url : null; -export function composeValidators(...validators: (Validator | null)[]) { +export function composeValidators(...validators: Validator[]) { return (value: string): ReturnType | null => validators.reduce( - (error: ReturnType, validator) => - error ?? (validator ? validator(value) : null), + (error: ReturnType, validator) => error ?? validator(value), null ); } diff --git a/client/src/components/formHelpers/form.tsx b/client/src/components/formHelpers/form.tsx index dc5ecd0c894..ad98380acde 100644 --- a/client/src/components/formHelpers/form.tsx +++ b/client/src/components/formHelpers/form.tsx @@ -8,11 +8,12 @@ import { editorValidator, composeValidators, fCCValidator, - httpValidator + httpValidator, + microsoftValidator } from './form-validators'; import FormFields, { FormOptions } from './form-fields'; -type URLValues = { +type FormValues = { [key: string]: string; }; @@ -22,7 +23,7 @@ type ValidationError = { }; export type ValidatedValues = { - values: URLValues; + values: FormValues; errors: ValidationError[]; invalidValues: (JSX.Element | null)[]; }; @@ -31,45 +32,55 @@ const normalizeOptions = { stripWWW: false }; -function formatUrlValues( - values: URLValues, +function validateFormValues( + formValues: FormValues, options: FormOptions ): ValidatedValues { - const { isEditorLinkAllowed, isLocalLinkAllowed, types } = options; + const { + isEditorLinkAllowed, + isLocalLinkAllowed, + isMicrosoftLearnLink, + types + } = options; const validatedValues: ValidatedValues = { values: {}, errors: [], invalidValues: [] }; - const urlValues = Object.keys(values).reduce((result, key: string) => { - // NOTE: pathValidator is not used here, because it is only used as a - // suggestion - should not prevent form submission - const validators = [fCCValidator, httpValidator]; - const isSolutionLink = key !== 'githubLink'; - if (isSolutionLink && !isEditorLinkAllowed) { - validators.push(editorValidator); - } - if (!isLocalLinkAllowed) { - validators.push(localhostValidator); - } - - let value: string = values[key]; - const nullOrWarning = composeValidators(...validators)(value); - if (nullOrWarning) { - validatedValues.invalidValues.push(nullOrWarning); - } - if (value && types && types[key] === 'url') { - try { - value = normalizeUrl(value, normalizeOptions); - } catch (err: unknown) { - validatedValues.errors.push({ - error: err as { message?: string }, - value - }); + const urlValues = Object.entries(formValues).reduce( + (result, [key, value]) => { + // NOTE: pathValidator is not used here, because it is only used as a + // suggestion - should not prevent form submission + const validators = [fCCValidator, httpValidator]; + const isSolutionLink = key !== 'githubLink'; + if (isSolutionLink && !isEditorLinkAllowed) { + validators.push(editorValidator); } - } - return { ...result, [key]: value }; - }, {}); + if (!isLocalLinkAllowed) { + validators.push(localhostValidator); + } + if (isMicrosoftLearnLink) { + validators.push(microsoftValidator); + } + + const nullOrWarning = composeValidators(...validators)(value); + if (nullOrWarning) { + validatedValues.invalidValues.push(nullOrWarning); + } + if (value && types && types[key] === 'url') { + try { + value = normalizeUrl(value, normalizeOptions); + } catch (err: unknown) { + validatedValues.errors.push({ + error: err as { message?: string }, + value + }); + } + } + return { ...result, [key]: value }; + }, + {} + ); validatedValues.values = urlValues; return validatedValues; } @@ -81,7 +92,7 @@ export type StrictSolutionFormProps = { id: string; initialValues?: Record; options: FormOptions; - submit: (values: ValidatedValues, ...args: unknown[]) => void; + submit: (values: ValidatedValues) => void; }; export const StrictSolutionForm = ({ @@ -96,8 +107,8 @@ export const StrictSolutionForm = ({ return (
{ - submit(formatUrlValues(values, options), ...args); + onSubmit={(values: FormValues) => { + submit(validateFormValues(values, options)); }} > {({ handleSubmit, pristine, error }) => ( diff --git a/client/src/templates/Challenges/projects/solution-form.tsx b/client/src/templates/Challenges/projects/solution-form.tsx index 33c7932d1b9..12ae2ae23cd 100644 --- a/client/src/templates/Challenges/projects/solution-form.tsx +++ b/client/src/templates/Challenges/projects/solution-form.tsx @@ -64,7 +64,8 @@ export class SolutionForm extends Component { }, required: ['solution'], isEditorLinkAllowed: false, - isLocalLinkAllowed: false + isLocalLinkAllowed: false, + isMicrosoftLearnLink: false }; let formFields = solutionField; @@ -109,6 +110,7 @@ export class SolutionForm extends Component { case challengeTypes.msTrophyUrl: formFields = msTrophyField; + options.isMicrosoftLearnLink = true; solutionLink = solutionLink + 'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=you';