chore: copy redirect + tests to new api (#53999)

Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2024-03-15 14:21:05 +01:00
committed by GitHub
parent 1b43f6d91b
commit efb8cafb06
5 changed files with 377 additions and 5 deletions
@@ -9,7 +9,7 @@ describe('Deprecated unsubscribeEndpoints', () => {
setupServer();
unsubscribeEndpoints.forEach(([endpoint, method]) => {
test(`${method} ${endpoint} redirects to referer with "info" message`, async () => {
test(`${method} ${endpoint} redirects to origin with "info" message`, async () => {
const response = await superRequest(endpoint, { method }).set(
'Referer',
'https://www.freecodecamp.org/settings'
@@ -17,7 +17,7 @@ describe('Deprecated unsubscribeEndpoints', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect(response.headers.location).toStrictEqual(
'https://www.freecodecamp.org/settings' + urlEncodedMessage
'https://www.freecodecamp.org' + urlEncodedMessage
);
expect(response.status).toBe(302);
});
+2 -3
View File
@@ -1,6 +1,6 @@
import { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { HOME_LOCATION } from '../utils/env';
import { getRedirectParams } from '../utils/redirection';
type Endpoint = [string, 'GET' | 'POST'];
@@ -28,8 +28,7 @@ export const unsubscribeDeprecated: FastifyPluginCallbackTypebox = (
method,
url: endpoint,
handler: async (req, reply) => {
// TODO: port over getRedirectParams from api-server anduser that
const origin = req.headers.referer ?? HOME_LOCATION;
const { origin } = getRedirectParams(req);
void reply.redirectWithMessage(origin, {
type: 'info',
content:
+9
View File
@@ -0,0 +1,9 @@
export const allowedOrigins = [
'https://www.freecodecamp.dev',
'https://www.freecodecamp.org',
// pretty sure the rest of these can go?
'https://beta.freecodecamp.dev',
'https://beta.freecodecamp.org',
'https://chinese.freecodecamp.dev',
'https://chinese.freecodecamp.org'
];
+225
View File
@@ -0,0 +1,225 @@
import { FastifyRequest } from 'fastify';
import jwt from 'jsonwebtoken';
import {
getReturnTo,
normalizeParams,
getRedirectParams,
getPrefixedLandingPath
} from './redirection';
import { HOME_LOCATION } from './env';
const validJWTSecret = 'this is a super secret string';
const invalidJWTSecret = 'This is not correct secret';
const validReturnTo = 'https://www.freecodecamp.org/settings';
const invalidReturnTo = 'https://www.freecodecamp.org.fake/settings';
const defaultReturnTo = 'https://www.freecodecamp.org/learn';
const defaultOrigin = 'https://www.freecodecamp.org';
const defaultPrefix = '';
const defaultObject = {
returnTo: defaultReturnTo,
origin: defaultOrigin,
pathPrefix: defaultPrefix
};
// TODO: tidy this up (the mocking is a bit of a mess)
describe('redirection', () => {
describe('getReturnTo', () => {
it('should extract returnTo from a jwt', () => {
expect.assertions(1);
const encryptedReturnTo = jwt.sign(
{ returnTo: validReturnTo, origin: defaultOrigin },
validJWTSecret
);
expect(
getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin)
).toStrictEqual({
...defaultObject,
returnTo: validReturnTo
});
});
it('should return a default url if the secrets do not match', () => {
const oldLog = console.log;
expect.assertions(2);
console.log = jest.fn();
const encryptedReturnTo = jwt.sign(
{ returnTo: validReturnTo },
invalidJWTSecret
);
expect(
getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin)
).toStrictEqual(defaultObject);
expect(console.log).toHaveBeenCalled();
console.log = oldLog;
});
it('should return a default url for unknown origins', () => {
expect.assertions(1);
const encryptedReturnTo = jwt.sign(
{ returnTo: invalidReturnTo },
validJWTSecret
);
expect(
getReturnTo(encryptedReturnTo, validJWTSecret, defaultOrigin)
).toStrictEqual(defaultObject);
});
});
describe('normalizeParams', () => {
it('should return a {returnTo, origin, pathPrefix} object', () => {
expect.assertions(2);
const keys = Object.keys(normalizeParams({}));
const expectedKeys = ['returnTo', 'origin', 'pathPrefix'];
expect(keys.length).toBe(3);
expect(keys).toEqual(expect.arrayContaining(expectedKeys));
});
it('should default to process.env.HOME_LOCATION', () => {
expect.assertions(1);
expect(normalizeParams({}, defaultOrigin)).toEqual(defaultObject);
});
it('should convert an unknown pathPrefix to ""', () => {
expect.assertions(1);
const brokenPrefix = {
...defaultObject,
pathPrefix: 'not-really-a-name'
};
expect(normalizeParams(brokenPrefix, defaultOrigin)).toEqual(
defaultObject
);
});
it('should not change a known pathPrefix', () => {
expect.assertions(1);
const spanishPrefix = {
...defaultObject,
pathPrefix: 'espanol'
};
expect(normalizeParams(spanishPrefix, defaultOrigin)).toEqual(
spanishPrefix
);
});
// we *could*, in principle, grab the path and send them to
// process.env.HOME_LOCATION/path, but if the origin is wrong something unexpected is
// going on. In that case it's probably best to just send them to
// process.env.HOME_LOCATION/learn.
it('should return default parameters if the origin is unknown', () => {
expect.assertions(1);
const exampleOrigin = {
...defaultObject,
origin: 'http://example.com',
pathPrefix: 'espanol'
};
expect(normalizeParams(exampleOrigin, defaultOrigin)).toEqual(
defaultObject
);
});
it('should return default parameters if the returnTo is unknown', () => {
expect.assertions(1);
const exampleReturnTo = {
...defaultObject,
returnTo: 'http://example.com/path',
pathPrefix: 'espanol'
};
expect(normalizeParams(exampleReturnTo, defaultOrigin)).toEqual(
defaultObject
);
});
it('should reject returnTo without trailing slashes', () => {
const exampleReturnTo = {
...defaultObject,
returnTo: 'https://www.freecodecamp.dev'
};
expect(normalizeParams(exampleReturnTo, defaultOrigin)).toEqual(
defaultObject
);
});
it('should not modify the returnTo if it is valid', () => {
const exampleReturnTo = {
...defaultObject,
returnTo: 'https://www.freecodecamp.dev/'
};
expect(normalizeParams(exampleReturnTo, defaultOrigin)).toEqual(
exampleReturnTo
);
});
});
describe('getRedirectParams', () => {
it('should decorate the request object', () => {
const req: FastifyRequest = {
headers: {
referer: `https://www.freecodecamp.org/espanol/learn/rosetta-code/`
}
} as FastifyRequest;
const expectedReturn = {
origin: 'https://www.freecodecamp.org',
pathPrefix: 'espanol',
returnTo: 'https://www.freecodecamp.org/espanol/learn/rosetta-code/'
};
const result = getRedirectParams(req);
expect(result).toEqual(expectedReturn);
});
it('should use HOME_LOCATION with missing referer', () => {
const req: FastifyRequest = {
headers: {}
} as FastifyRequest;
const expectedReturn = {
returnTo: `${HOME_LOCATION}/learn`,
origin: HOME_LOCATION,
pathPrefix: ''
};
const result = getRedirectParams(req);
expect(result).toEqual(expectedReturn);
});
it('should use HOME_LOCATION with invalid referrer', () => {
const req: FastifyRequest = {
headers: {
referer: 'invalid-url'
}
} as FastifyRequest;
const expectedReturn = {
returnTo: `${HOME_LOCATION}/learn`,
origin: HOME_LOCATION,
pathPrefix: ''
};
const result = getRedirectParams(req);
expect(result).toEqual(expectedReturn);
});
});
describe('getPrefixedLandingPath', () => {
it('should return the origin when no pathPrefix is provided', () => {
const result = getPrefixedLandingPath(defaultOrigin);
expect(result).toEqual(defaultOrigin);
});
it('should append pathPrefix to origin when pathPrefix is provided', () => {
const expectedPath = `${defaultOrigin}/learn`;
const result = getPrefixedLandingPath(defaultOrigin, 'learn');
expect(result).toEqual(expectedPath);
});
it('should handle empty origin', () => {
const pathPrefix = 'learn';
const expectedPath = '/learn';
const result = getPrefixedLandingPath('', pathPrefix);
expect(result).toEqual(expectedPath);
});
it('should handle empty pathPrefix', () => {
const result = getPrefixedLandingPath(defaultOrigin, '');
expect(result).toEqual(defaultOrigin);
});
});
});
+139
View File
@@ -0,0 +1,139 @@
import { FastifyRequest } from 'fastify';
import jwt from 'jsonwebtoken';
// import { allowedOrigins } from '../../config/allowed-origins';
import { availableLangs } from '../../../shared/config/i18n';
import { allowedOrigins } from './allowed-origins';
// process.env.HOME_LOCATION is being used as a fallback here. If the one
// provided by the client is invalid we default to this.
import { HOME_LOCATION } from './env';
/**
* Get the returnTo value.
*
* @param encryptedParams - The encrypted parameters.
* @param secret - The secret key.
* @param _homeLocation - The home location.
* @returns The returnTo value.
*/
export function getReturnTo(
encryptedParams: string,
secret: jwt.Secret,
_homeLocation = process.env.HOME_LOCATION
) {
let params;
try {
params = jwt.verify(encryptedParams, secret);
} catch (e) {
// TODO: report to Sentry? Probably not. Remove entirely?
console.log(e);
// something went wrong, use default params
params = {
returnTo: `${_homeLocation}/learn`,
origin: _homeLocation,
pathPrefix: ''
};
}
// @ts-expect-error - I'm working on it...
return normalizeParams(params, _homeLocation);
}
/**
* Normalize the parameters, making they're valid.
*
* @param arg - The parameters to normalize.
* @param arg.returnTo - The returnTo value.
* @param arg.origin - The origin value.
* @param arg.pathPrefix - The pathPrefix value.
* @param _homeLocation - The home location.
* @returns The normalized parameters.
*/
export function normalizeParams(
{
returnTo,
origin,
pathPrefix
}: { returnTo?: string; origin?: string; pathPrefix?: string },
_homeLocation = HOME_LOCATION
) {
// coerce to strings, just in case something weird and nefarious is happening
// TODO: validate, don't coerce
returnTo = '' + returnTo;
origin = '' + origin;
pathPrefix = '' + pathPrefix;
// we add the '/' to prevent returns to
// www.freecodecamp.org.somewhere.else.com
if (
!returnTo ||
!allowedOrigins.some(allowed => returnTo?.startsWith(allowed + '/'))
) {
returnTo = `${_homeLocation}/learn`;
origin = _homeLocation;
pathPrefix = '';
}
if (!origin || !allowedOrigins.includes(origin)) {
returnTo = `${_homeLocation}/learn`;
origin = _homeLocation;
pathPrefix = '';
}
pathPrefix = availableLangs.client.includes(pathPrefix) ? pathPrefix : '';
return { returnTo, origin, pathPrefix };
}
/**
* Get the prefixed landing path.
*
* @param origin - The origin value.
* @param pathPrefix - The pathPrefix value.
* @returns The prefixed landing path.
*/
export function getPrefixedLandingPath(origin: string, pathPrefix?: string) {
const redirectPathSegment = pathPrefix ? `/${pathPrefix}` : '';
return `${origin}${redirectPathSegment}`;
}
/**
* Get the redirect parameters.
*
* @param req - A fastify Request.
* @param _normalizeParams - The function to normalize the parameters.
* @returns The redirect parameters.
*/
export function getRedirectParams(
req: FastifyRequest,
_normalizeParams = normalizeParams
) {
const url = req.headers['referer'];
// since we do not always redirect the user back to the page they were on
// we need client locale and origin to construct the redirect url.
let returnUrl;
try {
returnUrl = new URL(url ? url : HOME_LOCATION);
} catch (e) {
returnUrl = new URL(HOME_LOCATION);
}
const origin = returnUrl.origin;
// if this is not one of the client languages, validation will convert
// this to '' before it is used.
const pathPrefix = returnUrl.pathname.split('/')[1] ?? '';
return _normalizeParams({ returnTo: returnUrl.href, origin, pathPrefix });
}
/**
* Check if the redirect base and return URL have the same path.
*
* @param redirectBase - The redirect base URL.
* @param returnUrl - The return URL.
* @returns A boolean indicating whether the paths are the same.
*/
export function haveSamePath(
redirectBase: string | URL,
returnUrl: string | URL
) {
const base = new URL(redirectBase);
const url = new URL(returnUrl);
return base.pathname === url.pathname;
}