diff --git a/.babelrc b/.babelrc index 68ba92054d..26ac1c7b41 100644 --- a/.babelrc +++ b/.babelrc @@ -1,6 +1,6 @@ { "presets": [ - ["topcoder-react-utils/config/babel/node-ssr", { + ["./config/babel/node", { "baseAssetsOutputPath": "/community-app-assets" }] ] diff --git a/.circleci/config.yml b/.circleci/config.yml index 40e916d303..df5535e2e4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -111,11 +111,11 @@ jobs: # Jest unit-tests). test: docker: - - image: circleci/node:10.24.1 + - image: cimg/node:24.18.0@sha256:4a638ad75f8601fec1f193e71df584639e6accb1771cea23f3d9d3857bca480c steps: - checkout - restore_cache: - key: test-node-modules-{{ checksum "package-lock.json" }} + key: test-node24-modules-{{ checksum "package-lock.json" }} - run: name: Config Git command: git config --global url."https://git@".insteadOf git:// @@ -124,15 +124,14 @@ jobs: command: npm ci no_output_timeout: 20m - save_cache: - key: test-node-modules-{{ checksum "package-lock.json" }} + key: test-node24-modules-{{ checksum "package-lock.json" }} paths: - node_modules - - run: npm test + - run: npm run lint && npm run jest:ci Performance-Testing: docker: - # specify the version you desire here - - image: circleci/openjdk:8-jdk + - image: cimg/openjdk:17.0.19@sha256:09490c4b3e6e85f8b382c7ce0ef70aa8e940ee3a3567a11d2aede0ed094dc525 # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images @@ -151,16 +150,16 @@ jobs: # Download and cache dependencies - restore_cache: keys: - - v1-dependencies-{{ checksum "pom.xml" }} + - performance-jdk17-maven3-{{ checksum "pom.xml" }} # fallback to using the latest cache if no exact match is found - - v1-dependencies- + - performance-jdk17-maven3- - run: mvn dependency:go-offline - save_cache: paths: - ~/.m2 - key: v1-dependencies-{{ checksum "pom.xml" }} + key: performance-jdk17-maven3-{{ checksum "pom.xml" }} - run: mvn verify diff --git a/.dockerignore b/.dockerignore index 1dba739ceb..ac650ed4c7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,40 @@ -__coverage__/ -.git/ -node_modules/ \ No newline at end of file +# Version-control and local editor state +.git +.github +.circleci +.idea +.vscode +*.swp +*.swo + +# Local dependencies, generated output, and test artifacts +node_modules +automated-smoke-test +build +.build-info +target +coverage +__coverage__ +.nyc_output +*.log +npm-debug.log* + +# Local environment and deployment material must never enter the build context +.env +.env.* +!.env.example +*.key +*.pem +awsenvconf +buildvar_env +deployvar_env + +# Files that are not needed to install, test, or build the application +docs +README.md +CHANGELOG.md +CONTRIBUTING.md +LICENSE +CODEOWNERS +pom.xml +*.patch diff --git a/.eslintignore b/.eslintignore index 42dd1b1464..5408f9f519 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ __coverage__ +automated-smoke-test/temp build -node_modules \ No newline at end of file +node_modules diff --git a/.eslintrc b/.eslintrc index c323322f56..c9fde00e2a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,12 +1,69 @@ { - "extends": "./node_modules/topcoder-react-utils/config/eslint/default.json", + "extends": "airbnb", + "parser": "@babel/eslint-parser", + "parserOptions": { + "requireConfigFile": false, + "sourceType": "module", + "babelOptions": { + "babelrc": false, + "configFile": false, + "presets": ["@babel/preset-react"], + "plugins": [ + "@babel/plugin-proposal-export-default-from", + "@babel/plugin-transform-class-properties" + ] + } + }, "rules": { - "jsx-a11y/anchor-is-valid": false, + "arrow-parens": ["error", "as-needed", { "requireForBlockBody": true }], + "class-methods-use-this": "off", + "default-param-last": "off", + "function-call-argument-newline": "off", + "function-paren-newline": ["error", "consistent"], + "import/no-import-module-exports": "off", + "jsx-a11y/anchor-is-valid": "off", + "jsx-a11y/control-has-associated-label": "off", + "jsx-a11y/href-no-hash": "off", + "max-classes-per-file": "off", "import/no-cycle": [2, { "maxDepth": 1 }], - "react/forbid-prop-types": false, - "react/no-unknown-property": ["error", { "ignore": ["styleName"] }] + "no-multiple-empty-lines": ["error", { "max": 2, "maxBOF": 2, "maxEOF": 0 }], + "no-promise-executor-return": "off", + "no-redeclare": ["error", { "builtinGlobals": false }], + "prefer-regex-literals": "off", + "react/forbid-prop-types": "off", + "react/function-component-definition": "off", + "react/jsx-curly-brace-presence": "off", + "react/jsx-curly-newline": "off", + "react/jsx-fragments": "off", + "react/jsx-no-useless-fragment": "off", + "react/jsx-one-expression-per-line": "off", + "react/jsx-props-no-spreading": "off", + "react/no-deprecated": "off", + "react/no-invalid-html-attribute": "off", + "react/no-unstable-nested-components": "off", + "react/no-unused-class-component-methods": "off", + "react/no-unknown-property": ["error", { "ignore": ["styleName"] }], + "react/sort-comp": "off" }, "env": { - "browser": true - } + "browser": true, + "es6": true, + "node": true + }, + "settings": { + "import/resolver": { + "node": { + "extensions": [".js", ".jsx"], + "moduleDirectory": ["node_modules", "src/shared", "src"] + } + } + }, + "overrides": [ + { + "files": ["config/**/*.js", "webpack.config.js"], + "rules": { + "import/no-extraneous-dependencies": ["error", { "devDependencies": true }] + } + } + ] } diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 795006101e..f2eeb3db8d 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -1,6 +1,10 @@ name: Commitlint on: [pull_request] +permissions: + contents: read + pull-requests: read + jobs: commit-lint: runs-on: ubuntu-latest @@ -12,4 +16,4 @@ jobs: fetch-depth: 0 - uses: wagoid/commitlint-github-action@v1.4.0 with: - configFile: './.commitlintrc.yml' \ No newline at end of file + configFile: './.commitlintrc.yml' diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 9cbcf52099..97d227d3e2 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -24,8 +24,9 @@ jobs: ignore-unfixed: true format: "sarif" output: "trivy-results.sarif" - severity: "CRITICAL,HIGH,UNKNOWN" - scanners: vuln,secret,misconfig,license + severity: "CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN" + limit-severities-for-sarif: true + scanners: vuln,secret,misconfig github-pat: ${{ secrets.GITHUB_TOKEN }} - name: Upload Trivy scan results to GitHub Security tab diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..a48ecb008f --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +legacy-peer-deps=true +strict-allow-scripts=true diff --git a/.nvmrc b/.nvmrc index c8b7cbff70..5bcf9c6e6a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v10.24.1 +v24.18.0 diff --git a/.stylelintrc b/.stylelintrc index d2541eb3f4..ad21f237e9 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,11 +1,42 @@ { "extends": "stylelint-config-standard", "rules": { + "alpha-value-notation": null, + "annotation-no-unknown": null, + "at-rule-descriptor-value-no-unknown": null, + "at-rule-no-vendor-prefix": null, "at-rule-no-unknown": [true, { "ignoreAtRules": ["content", "extend", "for", "include", "mixin"] }], + "color-function-alias-notation": null, + "color-function-notation": null, + "declaration-block-no-duplicate-properties": [true, { + "ignore": ["consecutive-duplicates-with-different-values"] + }], + "declaration-block-no-redundant-longhand-properties": null, + "declaration-property-value-keyword-no-deprecated": null, + "declaration-property-value-no-unknown": null, + "font-family-name-quotes": null, + "function-url-quotes": null, + "import-notation": null, + "keyframes-name-pattern": null, + "media-feature-range-notation": null, + "media-query-no-invalid": null, + "nesting-selector-no-missing-scoping-root": null, + "no-descending-specificity": null, + "no-invalid-position-at-import-rule": null, + "number-max-precision": null, + "property-no-deprecated": null, + "property-no-vendor-prefix": null, + "selector-attribute-quotes": null, + "selector-class-pattern": null, + "selector-no-vendor-prefix": null, + "selector-not-notation": null, "selector-pseudo-class-no-unknown": [true, { "ignorePseudoClasses": ["global"] - }] + }], + "shorthand-property-no-redundant-values": null, + "value-keyword-case": null, + "value-no-vendor-prefix": null } } diff --git a/Dockerfile b/Dockerfile index de3ab09f79..c2e2659ea7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,173 +1,122 @@ -# Builds production version of Community App inside Docker container, -# and runs it against the specified Topcoder backend (development or -# production) when container is executed. +# syntax=docker/dockerfile:1.7 -FROM node:10.24.1 -LABEL app="Community App" version="1.0" -RUN useradd -m -s /bin/bash appuser +# Pin the complete multi-platform image digest so builds cannot silently pick up +# a different base image. Renovate/Dependabot can update the tag and digest +# together when a patched Node image is published. +ARG NODE_IMAGE=node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436 +ARG NODE_BUILD_IMAGE=node:24.18.0-alpine3.23@sha256:595398b0081eacda8e1c4c5b97b76cd1020e4d58a8ebcb4843b9bca1e79e7436 + +FROM ${NODE_BUILD_IMAGE} AS development-dependencies WORKDIR /opt/app -COPY . . -RUN chown -R appuser:appuser /opt/app -USER appuser +# Native build tools stay isolated in the disposable builder stages. The +# Alpine runtime stage below never receives them. +RUN apk add --no-cache git python3 make g++ \ + && git config --global url."https://github.com/".insteadOf "git://github.com/" -################################################################################ -# Receiving of build arguments. +COPY package.json package-lock.json .npmrc ./ +COPY vendor ./vendor -ARG AUTH0_CLIENT_ID -ARG CDN_URL -ARG COGNITIVE_NEWSLETTER_SIGNUP_APIKEY -ARG COGNITIVE_NEWSLETTER_SIGNUP_URL -ARG CONTENTFUL_CDN_API_KEY -ARG CONTENTFUL_PREVIEW_API_KEY -ARG CONTENTFUL_SPACE_ID - -# Credentials for access to Zurich space in Contentful CMS -ARG CONTENTFUL_ZURICH_SPACE_ID -ARG CONTENTFUL_ZURICH_CDN_API_KEY -ARG CONTENTFUL_ZURICH_PREVIEW_API_KEY - -# Credentials for access to TopGear space in Contentful CMS -ARG CONTENTFUL_TOPGEAR_SPACE_ID -ARG CONTENTFUL_TOPGEAR_CDN_API_KEY -ARG CONTENTFUL_TOPGEAR_PREVIEW_API_KEY - -# Credentials for access to Comcast space in Contentful CMS -ARG CONTENTFUL_COMCAST_SPACE_ID -ARG CONTENTFUL_COMCAST_CDN_API_KEY -ARG CONTENTFUL_COMCAST_PREVIEW_API_KEY - -#Credentials for Contentfu EDU space - -ARG CONTENTFUL_MANAGEMENT_TOKEN -ARG CONTENTFUL_EDU_SPACE_ID -ARG CONTENTFUL_EDU_CDN_API_KEY -ARG CONTENTFUL_EDU_PREVIEW_API_KEY - -ARG FILESTACK_API_KEY -ARG FILESTACK_SUBMISSION_CONTAINER -ARG RECRUITCRM_API_KEY - -# Credentials for Mailchimp service -ARG MAILCHIMP_API_KEY -ARG MAILCHIMP_BASE_URL - -ARG NODE_CONFIG_ENV -ARG OPEN_EXCHANGE_RATES_KEY -ARG SEGMENT_IO_API_KEY -ARG CHAMELEON_VERIFICATION_SECRET -ARG SERVER_API_KEY - -# TC M2M credentials for Community App server -ARG TC_M2M_CLIENT_ID -ARG TC_M2M_CLIENT_SECRET -ARG TC_M2M_AUDIENCE -ARG TC_M2M_GRANT_TYPE - -ARG TC_M2M_AUTH0_PROXY_SERVER_URL -ARG TC_M2M_AUTH0_URL -ARG AUTH_SECRET -ARG VALID_ISSUERS - -ARG COMMUNITY_APP_URL -ARG GSHEETS_API_KEY - -# Gig work referrals -ARG SENDGRID_API_KEY -ARG GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY - -# Optimizely -ARG OPTIMIZELY_SDK_KEY - -# Gamification -ARG GAMIFICATION_ORG_ID - -# Universal Nav -ARG UNIVERSAL_NAV_URL - -# Topgear submissions allowed domains -ARG TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS - -################################################################################ -# Setting of environment variables in the Docker image. - -ENV AUTH0_CLIENT_ID=$AUTH0_CLIENT_ID -ENV CDN_URL=$CDN_URL -ENV COGNITIVE_NEWSLETTER_SIGNUP_APIKEY=$COGNITIVE_NEWSLETTER_SIGNUP_APIKEY -ENV COGNITIVE_NEWSLETTER_SIGNUP_URL=$COGNITIVE_NEWSLETTER_SIGNUP_URL -ENV CONTENTFUL_CDN_API_KEY=$CONTENTFUL_CDN_API_KEY -ENV CONTENTFUL_PREVIEW_API_KEY=$CONTENTFUL_PREVIEW_API_KEY -ENV CONTENTFUL_SPACE_ID=$CONTENTFUL_SPACE_ID - -# Credentials for access to Zurich space in Contentful CMS -ENV CONTENTFUL_ZURICH_SPACE_ID=$CONTENTFUL_ZURICH_SPACE_ID -ENV CONTENTFUL_ZURICH_CDN_API_KEY=$CONTENTFUL_ZURICH_CDN_API_KEY -ENV CONTENTFUL_ZURICH_PREVIEW_API_KEY=$CONTENTFUL_ZURICH_PREVIEW_API_KEY - -# Credentials for access to TopGear space in Contentful CMS -ENV CONTENTFUL_TOPGEAR_SPACE_ID=$CONTENTFUL_TOPGEAR_SPACE_ID -ENV CONTENTFUL_TOPGEAR_CDN_API_KEY=$CONTENTFUL_TOPGEAR_CDN_API_KEY -ENV CONTENTFUL_TOPGEAR_PREVIEW_API_KEY=$CONTENTFUL_TOPGEAR_PREVIEW_API_KEY - -# Credentials for access to Comcast space in Contentful CMS -ENV CONTENTFUL_COMCAST_SPACE_ID=$CONTENTFUL_COMCAST_SPACE_ID -ENV CONTENTFUL_COMCAST_CDN_API_KEY=$CONTENTFUL_COMCAST_CDN_API_KEY -ENV CONTENTFUL_COMCAST_PREVIEW_API_KEY=$CONTENTFUL_COMCAST_PREVIEW_API_KEY - -ENV FILESTACK_API_KEY=$FILESTACK_API_KEY -ENV FILESTACK_SUBMISSION_CONTAINER=$FILESTACK_SUBMISSION_CONTAINER - -# Credentials for Mailchimp service -ENV MAILCHIMP_API_KEY=$MAILCHIMP_API_KEY -ENV MAILCHIMP_BASE_URL=$MAILCHIMP_BASE_URL - -ENV NODE_CONFIG_ENV=$NODE_CONFIG_ENV -ENV OPEN_EXCHANGE_RATES_KEY=$OPEN_EXCHANGE_RATES_KEY -ENV SEGMENT_IO_API_KEY=$SEGMENT_IO_API_KEY -ENV CHAMELEON_VERIFICATION_SECRET=$CHAMELEON_VERIFICATION_SECRET -ENV SERVER_API_KEY=$SERVER_API_KEY - -# TC M2M credentials for Community App server -ENV TC_M2M_CLIENT_ID=$TC_M2M_CLIENT_ID -ENV TC_M2M_CLIENT_SECRET=$TC_M2M_CLIENT_SECRET -ENV TC_M2M_AUDIENCE=$TC_M2M_AUDIENCE -ENV TC_M2M_GRANT_TYPE=$TC_M2M_GRANT_TYPE - -ENV TC_M2M_AUTH0_PROXY_SERVER_URL=$TC_M2M_AUTH0_PROXY_SERVER_URL -ENV TC_M2M_AUTH0_URL=$TC_M2M_AUTH0_URL -ENV AUTH_SECRET=$AUTH_SECRET -ENV VALID_ISSUERS=$VALID_ISSUERS - -ENV CONTENTFUL_MANAGEMENT_TOKEN=$CONTENTFUL_MANAGEMENT_TOKEN -ENV CONTENTFUL_EDU_SPACE_ID=$CONTENTFUL_EDU_SPACE_ID -ENV CONTENTFUL_EDU_CDN_API_KEY=$CONTENTFUL_EDU_CDN_API_KEY -ENV CONTENTFUL_EDU_PREVIEW_API_KEY=$CONTENTFUL_EDU_PREVIEW_API_KEY -ENV RECRUITCRM_API_KEY=$RECRUITCRM_API_KEY -ENV COMMUNITY_APP_URL=$COMMUNITY_APP_URL -ENV SENDGRID_API_KEY=$SENDGRID_API_KEY -ENV GSHEETS_API_KEY=$GSHEETS_API_KEY -ENV GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY=$GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY - -# Optimizely -ENV OPTIMIZELY_SDK_KEY=$OPTIMIZELY_SDK_KEY - -ENV GAMIFICATION_ORG_ID=$GAMIFICATION_ORG_ID - -# Universal nav -ENV UNIVERSAL_NAV_URL=$UNIVERSAL_NAV_URL - -# Topgear submissions allowed domains -ENV TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS=$TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS - -################################################################################ -# Testing and build of the application inside the container. - -RUN npm config set unsafe-perm true -RUN git config --global url."https://git@".insteadOf git:// RUN npm ci + +FROM development-dependencies AS test + +ENV CI=true + +COPY . . + RUN npm test -RUN npm run build + +FROM test AS build + +ARG CDN_URL +ARG NODE_CONFIG_ENV=production + +ENV BABEL_ENV=production \ + CDN_URL=${CDN_URL} \ + NODE_CONFIG_ENV=${NODE_CONFIG_ENV} \ + NODE_ENV=production + +# The browser bundle is built as before. Server/shared sources are then +# precompiled so the runtime does not need Babel or the Webpack toolchain. +RUN npm run build \ + && ./node_modules/.bin/babel src \ + --out-dir /opt/runtime-src \ + --copy-files \ + --extensions ".js,.jsx" \ + && rm -rf \ + /opt/runtime-src/client \ + /opt/runtime-src/styles \ + /opt/runtime-src/test \ + && find /opt/runtime-src -type f \ + ! -name "*.js" \ + ! -name "*.json" \ + -delete \ + && install --directory /opt/runtime-src/assets/images \ + && install --mode=0644 \ + src/assets/images/favicon.ico \ + /opt/runtime-src/assets/images/favicon.ico + +FROM development-dependencies AS production-dependencies + +ENV NODE_ENV=production + +RUN npm prune --omit=dev --ignore-scripts \ + && npm cache clean --force + +FROM ${NODE_IMAGE} AS runtime + +LABEL org.opencontainers.image.title="Topcoder Community App" \ + org.opencontainers.image.description="Topcoder Community App web server" + +ARG CDN_URL +ARG NODE_CONFIG_ENV=production + +ENV BABEL_ENV=production \ + CDN_URL=${CDN_URL} \ + NODE_CONFIG_ENV=${NODE_CONFIG_ENV} \ + NODE_ENV=production \ + PORT=3000 + +WORKDIR /opt/app + +# The application starts Node directly, so package-manager executables and +# their dependency trees are unnecessary attack surface in production. +RUN rm -rf \ + /opt/yarn-* \ + /usr/local/lib/node_modules/corepack \ + /usr/local/lib/node_modules/npm \ + && rm -f \ + /usr/local/bin/corepack \ + /usr/local/bin/npm \ + /usr/local/bin/npx \ + /usr/local/bin/yarn \ + /usr/local/bin/yarnpkg + +COPY --from=production-dependencies --chown=node:node /opt/app/vendor ./vendor +COPY --from=production-dependencies --chown=node:node /opt/app/node_modules ./node_modules +COPY --from=build --chown=node:node /opt/app/build ./build +COPY --from=build --chown=node:node /opt/app/.build-info ./.build-info +COPY --from=build --chown=node:node /opt/runtime-src ./src +COPY --from=build --chown=node:node \ + /opt/app/config/custom-environment-variables.js \ + /opt/app/config/default.js \ + /opt/app/config/development.js \ + /opt/app/config/production.js \ + /opt/app/config/qa.js \ + ./config/ +COPY --from=build --chown=node:node /opt/app/config/contentful ./config/contentful +COPY --chown=node:node package.json ./package.json +COPY --chown=node:node bin/runtime.js ./bin/runtime.js + +USER node EXPOSE 3000 -CMD ["npm", "start"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["node", "-e", "const http=require('http');const req=http.get({host:'127.0.0.1',port:process.env.PORT||3000,path:'/api/cdn/public/ping',timeout:3000},res=>{res.resume();process.exit(res.statusCode===200?0:1);});req.on('timeout',()=>{req.destroy();process.exit(1);});req.on('error',()=>process.exit(1));"] + +STOPSIGNAL SIGTERM + +CMD ["node", "--max-old-space-size=8192", "bin/runtime.js"] diff --git a/__tests__/.eslintrc b/__tests__/.eslintrc index c4c8a8486f..f2d3a19d77 100644 --- a/__tests__/.eslintrc +++ b/__tests__/.eslintrc @@ -1,3 +1,12 @@ { - "extends": "../node_modules/topcoder-react-utils/config/eslint/jest.json" -} \ No newline at end of file + "env": { + "jest": true + }, + "plugins": [ + "jest" + ], + "rules": { + "global-require": 0, + "import/no-dynamic-require": 0 + } +} diff --git a/__tests__/config/security.js b/__tests__/config/security.js new file mode 100644 index 0000000000..1636b629e0 --- /dev/null +++ b/__tests__/config/security.js @@ -0,0 +1,138 @@ +/* eslint-env jest */ + +const fs = require('fs'); +const nodePath = require('path'); + +const backupDefaults = require('../../config/backup-default'); +const customEnvironmentVariables = require('../../config/custom-environment-variables'); +const defaults = require('../../config/default'); +const development = require('../../config/development'); +const production = require('../../config/production'); +const qa = require('../../config/qa'); +const testConfig = require('../../config/test'); + +/** + * Gets a nested configuration value from a dot-separated path. + * + * @param {Object} object configuration object + * @param {String} path dot-separated configuration path + * @returns {*} resolved value + */ +function get(object, path) { + return path.split('.').reduce((value, key) => value && value[key], object); +} + +const DEFAULT_CREDENTIAL_PATHS = [ + 'LOG_ENTRIES_TOKEN', + 'NEWSLETTER_SIGNUP.COGNITIVE.APIKEY', + 'SEGMENT_IO_API_KEY', + 'SERVER_API_KEY', + 'FILESTACK.API_KEY', + 'SECRET.CONTENTFUL.MANAGEMENT_TOKEN', + 'SECRET.CONTENTFUL.default.master.CDN_API_KEY', + 'SECRET.CONTENTFUL.default.master.PREVIEW_API_KEY', + 'SECRET.CONTENTFUL.EDU.master.CDN_API_KEY', + 'SECRET.CONTENTFUL.EDU.master.PREVIEW_API_KEY', + 'SECRET.CONTENTFUL.zurich.master.CDN_API_KEY', + 'SECRET.CONTENTFUL.zurich.master.PREVIEW_API_KEY', + 'SECRET.CONTENTFUL.topgear.master.CDN_API_KEY', + 'SECRET.CONTENTFUL.topgear.master.PREVIEW_API_KEY', + 'SECRET.CONTENTFUL.comcast.master.CDN_API_KEY', + 'SECRET.CONTENTFUL.comcast.master.PREVIEW_API_KEY', + 'SECRET.MAILCHIMP.default.API_KEY', + 'SECRET.OPEN_EXCHANGE_RATES_KEY', + 'SECRET.TC_M2M.CLIENT_ID', + 'SECRET.TC_M2M.CLIENT_SECRET', + 'SECRET.RECRUITCRM_API_KEY', + 'SECRET.SENDGRID_API_KEY', + 'SECRET.JWT_AUTH.SECRET', + 'SECRET.JWT_AUTH.AUTH_SECRET', + 'SECRET.CHAMELEON_VERIFICATION_SECRET', + 'GSHEETS_API_KEY', + 'GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY', +]; + +const ENVIRONMENT_CREDENTIAL_PATHS = [ + ['development', development, 'SEGMENT_IO_API_KEY'], + ['development', development, 'SERVER_API_KEY'], + ['production', production, 'LOG_ENTRIES_TOKEN'], + ['production', production, 'SERVER_API_KEY'], + ['qa', qa, 'SEGMENT_IO_API_KEY'], + ['qa', qa, 'SERVER_API_KEY'], + ['test', testConfig, 'SERVER_API_KEY'], +]; + +describe('credential configuration', () => { + [ + ['default', defaults], + ['backup-default', backupDefaults], + ].forEach(([name, config]) => { + test(`${name} configuration has no credential fallback values`, () => { + DEFAULT_CREDENTIAL_PATHS.forEach((path) => { + expect(get(config, path)).toBe(''); + }); + }); + }); + + ENVIRONMENT_CREDENTIAL_PATHS.forEach(([name, config, path]) => { + test(`${name} configuration leaves ${path} empty`, () => { + expect(get(config, path)).toBe(''); + }); + }); + + test.each(DEFAULT_CREDENTIAL_PATHS)( + '%s is mapped to an environment variable', + (path) => { + expect(get(customEnvironmentVariables, path)).toMatch(/^[A-Z0-9_]+$/); + }, + ); + + test('Docker builds receive only non-secret build arguments', () => { + const source = fs.readFileSync( + nodePath.resolve(__dirname, '../../build.sh'), + 'utf8', + ); + const buildArguments = [...source.matchAll(/--build-arg\s+["']?([A-Z0-9_]+)/g)] + .map(match => match[1]); + + expect(buildArguments).toEqual(['CDN_URL', 'NODE_CONFIG_ENV']); + }); + + test('JMeter loads M2M credentials from runtime properties', () => { + const source = fs.readFileSync( + nodePath.resolve(__dirname, '../../src/test/jmeter/Community-25UV.jmx'), + 'utf8', + ); + + // eslint-disable-next-line no-template-curly-in-string + expect(source).toContain('${__P(TC_M2M_CLIENT_ID,)}'); + // eslint-disable-next-line no-template-curly-in-string + expect(source).toContain('${__P(TC_M2M_CLIENT_SECRET,)}'); + expect(source).not.toMatch( + /name="client_(?:id|secret)"[\s\S]{0,250}Argument\.value">(?!(?:\$\{__P\(|<\/))/, + ); + }); + + test('Segment analytics uses the configured key without a static literal', () => { + const source = fs.readFileSync( + nodePath.resolve(__dirname, '../../src/server/index.js'), + 'utf8', + ); + + expect(source).toMatch( + /analytics\.load\(\$\{serializeJs\(config\.SEGMENT_IO_API_KEY\)\}\);/, + ); + expect(source).not.toMatch(/analytics\.load\(['"][^'"]+['"]\)/); + }); + + test('API-key authorization fails closed when configuration is empty', () => { + const source = fs.readFileSync( + nodePath.resolve(__dirname, '../../src/server/index.js'), + 'utf8', + ); + + expect(source).toMatch( + /if \(!config\.SERVER_API_KEY\s*\|\|\s*req\.headers\.authorization !==/, + ); + }); +}); diff --git a/__tests__/server/avatar.js b/__tests__/server/avatar.js new file mode 100644 index 0000000000..fd77a73541 --- /dev/null +++ b/__tests__/server/avatar.js @@ -0,0 +1,101 @@ +import fetch from 'isomorphic-fetch'; +import sharp from 'sharp'; + +import getAvatar, { normalizeAvatarUrl } from 'server/services/avatar'; + +jest.mock('isomorphic-fetch', () => jest.fn()); +jest.mock('sharp', () => jest.fn()); + +function mockResponse({ + body = Buffer.from('image'), + contentType = 'image/png', + location, + status = 200, +} = {}) { + const headers = { + 'content-length': String(body.length), + 'content-type': contentType, + location, + }; + return { + buffer: jest.fn(() => Promise.resolve(body)), + headers: { + get: jest.fn(name => headers[name.toLowerCase()] || null), + }, + ok: status >= 200 && status < 300, + status, + }; +} + +describe('avatar service security boundaries', () => { + let resize; + let toBuffer; + + beforeEach(() => { + jest.clearAllMocks(); + toBuffer = jest.fn(() => Promise.resolve(Buffer.from('resized'))); + resize = jest.fn(() => ({ toBuffer })); + sharp.mockReturnValue({ resize }); + }); + + test('rejects loopback and attacker-controlled destinations before fetching', async () => { + await expect(getAvatar('http://127.0.0.1/latest/meta-data', 32)) + .rejects.toThrow('Avatar URL is not trusted'); + await expect(getAvatar('https://member-media.topcoder.com.attacker.test/a.png', 32)) + .rejects.toThrow('Avatar URL is not trusted'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + test('normalizes only legacy relative paths against the configured site', () => { + const normalized = normalizeAvatarUrl('/i/m/avatar.png'); + + expect(normalized.pathname).toBe('/i/m/avatar.png'); + expect(normalized.protocol).toBe('https:'); + }); + + test('fetches and resizes a bounded raster image from a trusted media host', async () => { + fetch.mockResolvedValue(mockResponse()); + + await expect(getAvatar( + 'https://topcoder-prod-media.s3.amazonaws.com/member/profile/avatar.png', + 64, + )).resolves.toEqual(Buffer.from('resized')); + + expect(fetch).toHaveBeenCalledWith( + 'https://topcoder-prod-media.s3.amazonaws.com/member/profile/avatar.png', + expect.objectContaining({ redirect: 'manual' }), + ); + expect(sharp).toHaveBeenCalledWith( + Buffer.from('image'), + { limitInputPixels: 40000000 }, + ); + expect(resize).toHaveBeenCalledWith(64, 64, { fit: 'inside' }); + }); + + test('rejects a redirect that leaves the trusted media origins', async () => { + fetch.mockResolvedValue(mockResponse({ + location: 'http://169.254.169.254/latest/meta-data', + status: 302, + })); + + await expect(getAvatar( + 'https://member-media.topcoder.com/avatar.png', + 32, + )).rejects.toThrow('Avatar URL is not trusted'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + test('rejects unsupported content types and excessive resize requests', async () => { + fetch.mockResolvedValue(mockResponse({ contentType: 'text/html' })); + + await expect(getAvatar( + 'https://member-media.topcoder.com/avatar.png', + 32, + )).rejects.toThrow('Avatar response is not a supported image'); + await expect(getAvatar( + 'https://member-media.topcoder.com/avatar.png', + 2048, + )).rejects.toThrow('Invalid avatar size'); + }); +}); diff --git a/__tests__/server/recruitCRM.js b/__tests__/server/recruitCRM.js new file mode 100644 index 0000000000..5b2b01aaa1 --- /dev/null +++ b/__tests__/server/recruitCRM.js @@ -0,0 +1,96 @@ +import fetch from 'isomorphic-fetch'; + +import RecruitCRMService, { + normalizeRecruitCrmIdentifier, + parseApplicationForm, +} from 'server/services/recruitCRM'; + +jest.mock('isomorphic-fetch', () => jest.fn()); +jest.mock('topcoder-react-lib', () => ({ + logger: { + error: jest.fn(), + }, + services: { + api: {}, + }, +})); +jest.mock('server/services/sendGrid', () => ({ + sendEmailDirect: jest.fn(), +})); + +function validApplication(overrides = {}) { + return { + city: 'Hobart', + contact_number: '+61 400 000 000', + custom_fields: [ + { field_id: 1, value: 'https://topcoder.com/members/member' }, + { field_id: 2, value: 'member' }, + { field_id: 14, value: 'Job information' }, + ], + email: 'member@example.com', + first_name: 'Test', + last_name: 'Member', + locality: 'Australia', + salary_expectation: '', + skill: 'JavaScript', + ...overrides, + }; +} + +describe('RecruitCRM input security boundaries', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('accepts bounded opaque identifiers and rejects URL path injection', () => { + expect(normalizeRecruitCrmIdentifier('job_slug-123')).toBe('job_slug-123'); + expect(normalizeRecruitCrmIdentifier('../../admin')).toBeNull(); + expect(normalizeRecruitCrmIdentifier('job/assign?admin=true')).toBeNull(); + expect(normalizeRecruitCrmIdentifier('short')).toBeNull(); + }); + + test('parses a valid bounded application form', () => { + const parsed = parseApplicationForm(JSON.stringify(validApplication())); + + expect(parsed.email).toBe('member@example.com'); + expect(parsed.custom_fields).toHaveLength(3); + }); + + test('rejects malformed and unbounded custom field arrays', () => { + expect(() => parseApplicationForm('{invalid-json')) + .toThrow('Invalid application form'); + expect(() => parseApplicationForm(JSON.stringify(validApplication({ + custom_fields: Array.from( + { length: 33 }, + (value, fieldId) => ({ field_id: fieldId + 1, value: '' }), + ), + })))).toThrow('Invalid application form'); + }); + + test('rejects an invalid job identifier before any upstream request', async () => { + const service = new RecruitCRMService(); + const req = { + body: { form: JSON.stringify(validApplication()) }, + params: { id: '../../../metadata' }, + }; + const res = { + json: jest.fn(), + status: jest.fn(), + }; + res.status.mockReturnValue(res); + + await service.applyForJob(req, res, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid job ID format.' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('does not return an upstream exception or stack trace to the cache', async () => { + fetch.mockRejectedValue(new Error('private upstream stack details')); + const service = new RecruitCRMService(); + + await expect(service.getAll({ job_status: 1 })) + .resolves.toEqual({ error: true }); + }); +}); diff --git a/__tests__/server/routes/authentication.js b/__tests__/server/routes/authentication.js new file mode 100644 index 0000000000..d45b688508 --- /dev/null +++ b/__tests__/server/routes/authentication.js @@ -0,0 +1,63 @@ +import { createJwtAuthenticator } from 'server/routes/authentication'; + +function mockResponse() { + const res = { + json: jest.fn(), + status: jest.fn(), + }; + res.status.mockReturnValue(res); + return res; +} + +describe('JWT route configuration', () => { + test('fails closed when the canonical secret is absent', () => { + const factory = jest.fn(); + const handler = createJwtAuthenticator({ + AUTH_SECRET: 'legacy-fallback-must-not-be-used', + SECRET: '', + VALID_ISSUERS: '["https://api.topcoder.com"]', + }, factory); + const next = jest.fn(); + const res = mockResponse(); + + handler({}, res, next); + + expect(factory).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith({ + error: 'Authentication is unavailable.', + }); + }); + + test('fails closed when issuer configuration is malformed', () => { + const factory = jest.fn(); + const handler = createJwtAuthenticator({ + SECRET: 'configured-secret', + VALID_ISSUERS: 'not-json', + }, factory); + const next = jest.fn(); + const res = mockResponse(); + + handler({}, res, next); + + expect(factory).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(503); + }); + + test('maps the canonical secret to the tc-core authenticator contract', () => { + const expectedMiddleware = jest.fn(); + const factory = jest.fn(() => expectedMiddleware); + const handler = createJwtAuthenticator({ + SECRET: 'configured-secret', + VALID_ISSUERS: ['https://api.topcoder.com'], + }, factory); + + expect(handler).toBe(expectedMiddleware); + expect(factory).toHaveBeenCalledWith({ + AUTH_SECRET: 'configured-secret', + VALID_ISSUERS: '["https://api.topcoder.com"]', + }); + }); +}); diff --git a/__tests__/server/routes/security.js b/__tests__/server/routes/security.js new file mode 100644 index 0000000000..9a8dfa1df6 --- /dev/null +++ b/__tests__/server/routes/security.js @@ -0,0 +1,98 @@ +import config from 'config'; +import express from 'express'; +import request from 'supertest'; + +import { + configuredJwtAuthenticator, + protectedCorsOptions, +} from 'server/routes/authentication'; +import contentfulRoutes, { + articleVoteLimiter, +} from 'server/routes/contentful'; +import recruitRoutes, { + sensitiveRouteLimiter, +} from 'server/routes/recruitCRM'; + +jest.mock('tc-core-library-js', () => ({ + middleware: { + jwtAuthenticator: jest.fn(() => (req, res, next) => next()), + }, +})); + +jest.mock('server/services/contentful', () => ({ + ALLOWED_DOMAINS: [], + ASSETS_DOMAIN: 'assets.example.test', + IMAGES_DOMAIN: 'images.example.test', + articleVote: jest.fn(), + getService: jest.fn(), + getSpaceId: jest.fn(), +})); + +jest.mock('server/services/recruitCRM', () => jest.fn()); + +/** + * Gets middleware attached to a specific Express router method. + * @param {Function} router Express router. + * @param {String} path Route path. + * @param {String} method Lowercase HTTP method. + * @return {Function[]} Attached middleware functions. + */ +function getRouteMiddleware(router, path, method) { + const layer = router.stack.find(item => item.route + && item.route.path === path + && item.route.methods[method]); + return layer ? layer.route.stack.map(item => item.handle) : []; +} + +describe('authenticated route protections', () => { + test.each([ + ['/jobs/cache/flush', 'get'], + ['/jobs/:id/apply', 'post'], + ['/profile', 'get'], + ['/profile', 'post'], + ])('rate limits RecruitCRM %s %s before handling it', (path, method) => { + const routeMiddleware = getRouteMiddleware(recruitRoutes, path, method); + expect(routeMiddleware).toContain(sensitiveRouteLimiter); + expect(routeMiddleware).toContain(configuredJwtAuthenticator); + expect(routeMiddleware.indexOf(sensitiveRouteLimiter)) + .toBeLessThan(routeMiddleware.indexOf(configuredJwtAuthenticator)); + }); + + test('rate limits Contentful article voting', () => { + const routeMiddleware = getRouteMiddleware( + contentfulRoutes, + '/:spaceName/:environment/votes', + 'post', + ); + expect(routeMiddleware).toContain(articleVoteLimiter); + expect(routeMiddleware).toContain(configuredJwtAuthenticator); + expect(routeMiddleware.indexOf(articleVoteLimiter)) + .toBeLessThan(routeMiddleware.indexOf(configuredJwtAuthenticator)); + }); + + test('uses exact configured origins instead of reflecting any request origin', () => { + expect(protectedCorsOptions.origin).toContain(new URL(config.URL.BASE).origin); + expect(protectedCorsOptions.origin).not.toContain('*'); + expect(protectedCorsOptions.origin).not.toContain(true); + }); + + test('does not reflect an untrusted origin on the job application preflight', async () => { + const app = express(); + app.use(recruitRoutes); + const trustedOrigin = new URL(config.URL.BASE).origin; + + const trustedResponse = await request(app) + .options('/jobs/job_slug-123/apply') + .set('Origin', trustedOrigin) + .set('Access-Control-Request-Method', 'POST'); + const untrustedResponse = await request(app) + .options('/jobs/job_slug-123/apply') + .set('Origin', 'https://attacker.example') + .set('Access-Control-Request-Method', 'POST'); + + expect(trustedResponse.headers['access-control-allow-origin']) + .toBe(trustedOrigin); + expect(untrustedResponse.headers['access-control-allow-origin']) + .toBeUndefined(); + }); +}); diff --git a/__tests__/shared/actions/challenge-listing/index.js b/__tests__/shared/actions/challenge-listing/index.js new file mode 100644 index 0000000000..c2a918424b --- /dev/null +++ b/__tests__/shared/actions/challenge-listing/index.js @@ -0,0 +1,102 @@ +const mockGetChallenges = jest.fn(() => Promise.resolve({ + challenges: [], + meta: { allChallengesCount: 0 }, +})); +const mockGetService = jest.fn(() => ({ + getChallenges: mockGetChallenges, +})); + +jest.mock('@topcoder-platform/tc-auth-lib', () => ({ + decodeToken: jest.fn(() => ({ userId: 123 })), +})); + +jest.mock('utils/tc', () => ({ + processSRM: jest.fn(), +})); + +jest.mock('topcoder-react-lib', () => ({ + errors: { + fireErrorMessage: jest.fn(), + }, + services: { + challenge: { + getService: mockGetService, + }, + }, +})); + +const actions = require('actions/challenge-listing').default.challengeListing; + +const backendFilter = { legacyId: 12345 }; +const frontFilter = { + sorts: { + all: 'startDate', + my: 'startDate', + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('live challenge listing filters', () => { + test('only requests All challenges with an open submission phase', async () => { + const action = actions.getAllChallengesDone( + 'all-uuid', + 0, + backendFilter, + 'token', + frontFilter, + ); + + await action.payload; + + expect(mockGetChallenges).toHaveBeenCalledWith(expect.objectContaining({ + backendFilter, + frontFilter: expect.objectContaining({ + currentPhaseName: 'Submission', + status: 'ACTIVE', + }), + })); + }); + + test('requests all active challenges registered to the member', async () => { + const action = actions.getMyChallengesDone( + 'my-uuid', + 0, + backendFilter, + 'token', + frontFilter, + ); + + await action.payload; + + expect(mockGetChallenges).toHaveBeenCalledWith(expect.objectContaining({ + backendFilter, + frontFilter: expect.objectContaining({ + memberId: '123', + status: 'ACTIVE', + }), + })); + expect(mockGetChallenges.mock.calls[0][0].frontFilter) + .not.toHaveProperty('currentPhaseName'); + }); + + test('counts only live challenges with an open submission phase', async () => { + const action = actions.getTotalChallengesCountDone( + 'count-uuid', + 'token', + frontFilter, + ); + + await action.payload; + + expect(mockGetChallenges).toHaveBeenCalledWith(expect.objectContaining({ + frontFilter: expect.objectContaining({ + currentPhaseName: 'Submission', + isLightweight: true, + status: 'ACTIVE', + }), + })); + }); +}); diff --git a/__tests__/shared/components/ChallengeTile/__snapshots__/index.jsx.snap b/__tests__/shared/components/ChallengeTile/__snapshots__/index.jsx.snap index 04faa6f2d0..4beb7d5141 100644 --- a/__tests__/shared/components/ChallengeTile/__snapshots__/index.jsx.snap +++ b/__tests__/shared/components/ChallengeTile/__snapshots__/index.jsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`renders design 1`] = `
({ + getService: jest.fn(), +})); + +describe('Contentful SearchBar URL construction', () => { + const input = '&role=admin'; + + test.each([ + ['Author', { author: input }], + ['Title', { title: input }], + ['All', { phrase: input }], + ['Tags', { tags: [input] }], + ])('encodes %s input as query data rather than DOM markup', (filter, expected) => { + const searchUrl = buildSearchUrl(filter, input); + const query = searchUrl.slice(searchUrl.indexOf('?') + 1); + + expect(qs.parse(query)).toEqual(expected); + expect(searchUrl).not.toContain(' { + const searchUrl = buildSearchUrl('Tags', 'JavaScript & Node.js'); + const query = searchUrl.slice(searchUrl.indexOf('?') + 1); + + expect(qs.parse(query)).toEqual({ tags: ['JavaScript & Node.js'] }); + }); +}); diff --git a/__tests__/shared/components/Contentful/Shape/__snapshots__/Shape.jsx.snap b/__tests__/shared/components/Contentful/Shape/__snapshots__/Shape.jsx.snap index 32a134509d..f476d6b902 100644 --- a/__tests__/shared/components/Contentful/Shape/__snapshots__/Shape.jsx.snap +++ b/__tests__/shared/components/Contentful/Shape/__snapshots__/Shape.jsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Matches shallow shapshot 1`] = `
`; diff --git a/__tests__/shared/components/Contentful/TracksTree/ChildListRow/__snapshots__/ChildListRow.jsx.snap b/__tests__/shared/components/Contentful/TracksTree/ChildListRow/__snapshots__/ChildListRow.jsx.snap index be163816b1..a0c3db1942 100644 --- a/__tests__/shared/components/Contentful/TracksTree/ChildListRow/__snapshots__/ChildListRow.jsx.snap +++ b/__tests__/shared/components/Contentful/TracksTree/ChildListRow/__snapshots__/ChildListRow.jsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Matches shallow shapshot 1`] = `
diff --git a/__tests__/shared/components/GUIKit/TextInput/__snapshots__/index.jsx.snap b/__tests__/shared/components/GUIKit/TextInput/__snapshots__/index.jsx.snap index 739f1a551e..a4fda570a9 100644 --- a/__tests__/shared/components/GUIKit/TextInput/__snapshots__/index.jsx.snap +++ b/__tests__/shared/components/GUIKit/TextInput/__snapshots__/index.jsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Default render 1`] = `
@@ -102,7 +102,7 @@ exports[`Matches shallow shapshot 1`] = ` > @@ -156,7 +156,7 @@ exports[`Matches shallow shapshot 1`] = ` > @@ -210,7 +210,7 @@ exports[`Matches shallow shapshot 1`] = ` > diff --git a/__tests__/shared/components/Leaderboard/__snapshots__/Podium.jsx.snap b/__tests__/shared/components/Leaderboard/__snapshots__/Podium.jsx.snap index c5a8a3f5e5..22be825a52 100644 --- a/__tests__/shared/components/Leaderboard/__snapshots__/Podium.jsx.snap +++ b/__tests__/shared/components/Leaderboard/__snapshots__/Podium.jsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Matches shallow shapshot 1`] = `
@@ -68,7 +68,7 @@ exports[`Matches shallow shapshot 2`] = ` > diff --git a/__tests__/shared/components/Loader/__snapshots__/Loader.jsx.snap b/__tests__/shared/components/Loader/__snapshots__/Loader.jsx.snap index 38508a917b..df549582c8 100644 --- a/__tests__/shared/components/Loader/__snapshots__/Loader.jsx.snap +++ b/__tests__/shared/components/Loader/__snapshots__/Loader.jsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Matches shallow shapshot 1`] = `

Your submission has been received and may undergo AI-assisted review during Submission phase. Results will be available for inspection in the review app and final evaluation occurs during Review phase. diff --git a/__tests__/shared/components/SubmissionManagement/__snapshots__/ScreeningStatus.jsx.snap b/__tests__/shared/components/SubmissionManagement/__snapshots__/ScreeningStatus.jsx.snap index e0c4ffcd17..0954185768 100644 --- a/__tests__/shared/components/SubmissionManagement/__snapshots__/ScreeningStatus.jsx.snap +++ b/__tests__/shared/components/SubmissionManagement/__snapshots__/ScreeningStatus.jsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Snapshot match 1`] = `