diff --git a/api/src/plugins/__fixtures__/user.ts b/api/src/plugins/__fixtures__/user.ts new file mode 100644 index 00000000000..bbe7e8efcf1 --- /dev/null +++ b/api/src/plugins/__fixtures__/user.ts @@ -0,0 +1,92 @@ +import { nanoidCharSet } from '../../utils/create-user'; + +const uuidRe = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/; +const fccUuidRe = /^fcc-[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/; +const unsubscribeIdRe = new RegExp(`^[${nanoidCharSet}]{21}$`); +const mongodbIdRe = /^[a-f0-9]{24}$/; + + +// eslint-disable-next-line jsdoc/require-jsdoc +export const newUser = (email: string) => ({ + about: '', + acceptedPrivacyTerms: false, + completedChallenges: [], + completedExams: [], + currentChallengeId: '', + donationEmails: [], + email, + emailAuthLinkTTL: null, + emailVerified: true, + emailVerifyTTL: null, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + externalId: expect.stringMatching(uuidRe), + githubProfile: null, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + id: expect.stringMatching(mongodbIdRe), + is2018DataVisCert: false, + is2018FullStackCert: false, + isApisMicroservicesCert: false, + isBackEndCert: false, + isBanned: false, + isCheater: false, + isClassroomAccount: null, + isDataAnalysisPyCertV7: false, + isDataVisCert: false, + isDonating: false, + isFoundationalCSharpCertV8: false, + isFrontEndCert: false, + isFrontEndLibsCert: false, + isFullStackCert: false, + isHonest: false, + isInfosecCertV7: false, + isInfosecQaCert: false, + isJsAlgoDataStructCert: false, + isJsAlgoDataStructCertV8: false, + isMachineLearningPyCertV7: false, + isQaCertV7: false, + isRelationalDatabaseCertV8: false, + isCollegeAlgebraPyCertV8: false, + isRespWebDesignCert: false, + isSciCompPyCertV7: false, + isUpcomingPythonCertV8: null, + keyboardShortcuts: false, + linkedin: null, + location: '', + name: '', + needsModeration: false, + newEmail: null, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + unsubscribeId: expect.stringMatching(unsubscribeIdRe), + partiallyCompletedChallenges: [], + password: null, + picture: '', + portfolio: [], + profileUI: { + isLocked: false, + showAbout: false, + showCerts: false, + showDonation: false, + showHeatMap: false, + showLocation: false, + showName: false, + showPoints: false, + showPortfolio: false, + showTimeLine: false + }, + progressTimestamps: [expect.any(Number)], + rand: null, // TODO(Post-MVP): delete from schema (it's not used or required). + savedChallenges: [], + sendQuincyEmail: false, + theme: 'default', + timezone: null, + twitter: null, + updateCount: 0, // see extendClient in prisma.ts + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + username: expect.stringMatching(fccUuidRe), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + usernameDisplay: expect.stringMatching(fccUuidRe), + verificationToken: null, + website: null, + yearsTopContributor: [] +} +) diff --git a/api/src/plugins/auth-dev.test.ts b/api/src/plugins/auth-dev.test.ts new file mode 100644 index 00000000000..4c772b2a470 --- /dev/null +++ b/api/src/plugins/auth-dev.test.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Fastify, { FastifyInstance } from 'fastify'; + +import { defaultUserEmail } from '../../jest.utils'; +import { HOME_LOCATION } from '../utils/env'; +import { devAuth } from '../plugins/auth-dev'; +import prismaPlugin from '../db/prisma'; +import auth from './auth'; +import cookies from './cookies'; + +import { newUser } from './__fixtures__/user'; + +describe('dev login', () => { + let fastify: FastifyInstance; + + beforeAll(async () => { + fastify = Fastify(); + + await fastify.register(cookies); + await fastify.register(auth); + await fastify.register(devAuth); + await fastify.register(prismaPlugin); + }); + + beforeEach(async () => { + await fastify.prisma.user.deleteMany({ + where: { email: defaultUserEmail } + }); + }); + + afterAll(async () => { + await fastify.prisma.user.deleteMany({ + where: { email: defaultUserEmail } + }); + await fastify.close(); + }); + + describe('GET /signin', () => { + it('should create an account if one does not exist', async () => { + const before = await fastify.prisma.user.count({}); + await fastify.inject({ + method: 'GET', + url: '/signin' + }); + + const after = await fastify.prisma.user.count({}); + + expect(before).toBe(0); + expect(after).toBe(before + 1); + }); + + it('should populate the user with the correct data', async () => { + await fastify.inject({ + method: 'GET', + url: '/signin' + }); + + const user = await fastify.prisma.user.findFirstOrThrow({ + where: { email: defaultUserEmail } + }); + + expect(user).toEqual(newUser(defaultUserEmail)); + expect(user.username).toBe(user.usernameDisplay); + }); + + it('should set the jwt_access_token cookie', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/signin' + }); + + expect(res.statusCode).toBe(302); + + expect(res.cookies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'jwt_access_token' }) + ]) + ); + }); + + it.todo('should create a session'); + + it('should redirect to the Referer (if it is a valid origin)', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/signin', + headers: { + referer: 'https://www.freecodecamp.org/some-path/or/other' + } + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe( + 'https://www.freecodecamp.org/some-path/or/other' + ); + }); + + it('should redirect to /valid-language/learn when signing in from /valid-language', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/signin', + headers: { + referer: 'https://www.freecodecamp.org/espanol' + } + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe( + 'https://www.freecodecamp.org/espanol/learn' + ); + }); + + it('should handle referers with trailing slahes', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/signin', + headers: { + referer: 'https://www.freecodecamp.org/espanol/' + } + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe( + 'https://www.freecodecamp.org/espanol/learn' + ); + }); + + it('should redirect to /learn by default', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/signin' + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe(`${HOME_LOCATION}/learn`); + }); + }); +}); diff --git a/api/src/plugins/auth-dev.ts b/api/src/plugins/auth-dev.ts new file mode 100644 index 00000000000..5db34cc837d --- /dev/null +++ b/api/src/plugins/auth-dev.ts @@ -0,0 +1,48 @@ +import type { FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { + getRedirectParams, + getPrefixedLandingPath, + haveSamePath +} from '../utils/redirection'; +import { findOrCreateUser } from '../routes/helpers/auth-helpers'; +import { createAccessToken } from '../utils/tokens'; + +const trimTrailingSlash = (str: string) => + str.endsWith('/') ? str.slice(0, -1) : str; + +async function handleRedirects(req: FastifyRequest, reply: FastifyReply) { + const params = getRedirectParams(req); + const { origin, pathPrefix } = params; + const returnTo = trimTrailingSlash(params.returnTo); + const landingUrl = getPrefixedLandingPath(origin, pathPrefix); + + return await reply.redirect( + haveSamePath(landingUrl, returnTo) ? `${returnTo}/learn` : returnTo + ); +} + +/** + * Fastify plugin for dev authentication. + * + * @param fastify - The Fastify instance. + * @param _options - The plugin options. + * @param done - The callback function. + */ +export const devAuth: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + fastify.get('/signin', async (req, reply) => { + const email = 'foo@bar.com'; + + const { id } = await findOrCreateUser(fastify, email); + + reply.setAccessTokenCookie(createAccessToken(id)); + + await handleRedirects(req, reply); + }); + + done(); +}; diff --git a/api/src/plugins/auth0.test.ts b/api/src/plugins/auth0.test.ts index a58bc4c0534..198b594ae7c 100644 --- a/api/src/plugins/auth0.test.ts +++ b/api/src/plugins/auth0.test.ts @@ -1,7 +1,7 @@ const COOKIE_DOMAIN = 'test.com'; import Fastify, { FastifyInstance } from 'fastify'; -import { createUserInput, nanoidCharSet } from '../utils/create-user'; +import { createUserInput } from '../utils/create-user'; import { AUTH0_DOMAIN, HOME_LOCATION } from '../utils/env'; import prismaPlugin from '../db/prisma'; import cookies, { sign, unsign } from './cookies'; @@ -9,6 +9,7 @@ import { auth0Client } from './auth0'; import redirectWithMessage, { formatMessage } from './redirect-with-message'; import auth from './auth'; import bouncer from './bouncer'; +import { newUser } from './__fixtures__/user'; // eslint-disable-next-line @typescript-eslint/no-unsafe-return jest.mock('../utils/env', () => ({ @@ -305,10 +306,6 @@ describe('auth0 plugin', () => { it('should populate the user with the correct data', async () => { mockAuthSuccess(); - const uuidRe = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/; - const fccUuidRe = /^fcc-[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/; - const unsubscribeIdRe = new RegExp(`^[${nanoidCharSet}]{21}$`); - const mongodbIdRe = /^[a-f0-9]{24}$/; await fastify.inject({ method: 'GET', @@ -319,88 +316,7 @@ describe('auth0 plugin', () => { where: { email } }); - expect(user).toEqual({ - about: '', - acceptedPrivacyTerms: false, - completedChallenges: [], - completedExams: [], - currentChallengeId: '', - donationEmails: [], - email, - emailAuthLinkTTL: null, - emailVerified: true, - emailVerifyTTL: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - externalId: expect.stringMatching(uuidRe), - githubProfile: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - id: expect.stringMatching(mongodbIdRe), - is2018DataVisCert: false, - is2018FullStackCert: false, - isApisMicroservicesCert: false, - isBackEndCert: false, - isBanned: false, - isCheater: false, - isClassroomAccount: null, - isDataAnalysisPyCertV7: false, - isDataVisCert: false, - isDonating: false, - isFoundationalCSharpCertV8: false, - isFrontEndCert: false, - isFrontEndLibsCert: false, - isFullStackCert: false, - isHonest: false, - isInfosecCertV7: false, - isInfosecQaCert: false, - isJsAlgoDataStructCert: false, - isJsAlgoDataStructCertV8: false, - isMachineLearningPyCertV7: false, - isQaCertV7: false, - isRelationalDatabaseCertV8: false, - isCollegeAlgebraPyCertV8: false, - isRespWebDesignCert: false, - isSciCompPyCertV7: false, - isUpcomingPythonCertV8: null, - keyboardShortcuts: false, - linkedin: null, - location: '', - name: '', - needsModeration: false, - newEmail: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - unsubscribeId: expect.stringMatching(unsubscribeIdRe), - partiallyCompletedChallenges: [], - password: null, - picture: '', - portfolio: [], - profileUI: { - isLocked: false, - showAbout: false, - showCerts: false, - showDonation: false, - showHeatMap: false, - showLocation: false, - showName: false, - showPoints: false, - showPortfolio: false, - showTimeLine: false - }, - progressTimestamps: [expect.any(Number)], - rand: null, - savedChallenges: [], - sendQuincyEmail: false, - theme: 'default', - timezone: null, - twitter: null, - updateCount: 0, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - username: expect.stringMatching(fccUuidRe), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - usernameDisplay: expect.stringMatching(fccUuidRe), - verificationToken: null, - website: null, - yearsTopContributor: [] - }); + expect(user).toEqual(newUser(email)); expect(user.username).toBe(user.usernameDisplay); }); }); diff --git a/api/src/routes/public/auth-dev.test.ts b/api/src/routes/public/auth-dev.test.ts deleted file mode 100644 index 47d66a236aa..00000000000 --- a/api/src/routes/public/auth-dev.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { - defaultUserEmail, - setupServer, - superRequest -} from '../../../jest.utils'; -import { HOME_LOCATION } from '../../utils/env'; -import { nanoidCharSet } from '../../utils/create-user'; - -describe('dev login', () => { - setupServer(); - - beforeEach(async () => { - await fastifyTestInstance.prisma.user.deleteMany({ - where: { email: defaultUserEmail } - }); - }); - - afterAll(async () => { - await fastifyTestInstance.prisma.user.deleteMany({ - where: { email: defaultUserEmail } - }); - }); - - describe('GET /signin', () => { - it('should create an account if one does not exist', async () => { - const res = await superRequest('/signin', { method: 'GET' }); - - const count = await fastifyTestInstance.prisma.user.count({ - where: { email: defaultUserEmail } - }); - - expect(count).toBe(1); - expect(res.body).toStrictEqual({}); - expect(res.status).toBe(302); - }); - - it('should populate the user with the correct data', async () => { - const uuidRe = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/; - const fccUuidRe = /^fcc-[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/; - const unsubscribeIdRe = new RegExp(`^[${nanoidCharSet}]{21}$`); - const mongodbIdRe = /^[a-f0-9]{24}$/; - - await superRequest('/signin', { method: 'GET' }); - const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ - where: { email: defaultUserEmail } - }); - - expect(user).toMatchObject({ - about: '', - acceptedPrivacyTerms: false, - completedChallenges: [], - completedExams: [], - currentChallengeId: '', - donationEmails: [], - email: defaultUserEmail, - emailAuthLinkTTL: null, - emailVerified: true, - emailVerifyTTL: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - externalId: expect.stringMatching(uuidRe), - githubProfile: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - id: expect.stringMatching(mongodbIdRe), - is2018DataVisCert: false, - is2018FullStackCert: false, - isApisMicroservicesCert: false, - isBackEndCert: false, - isBanned: false, - isCheater: false, - isClassroomAccount: null, - isDataAnalysisPyCertV7: false, - isDataVisCert: false, - isDonating: false, - isFoundationalCSharpCertV8: false, - isFrontEndCert: false, - isFrontEndLibsCert: false, - isFullStackCert: false, - isHonest: false, - isInfosecCertV7: false, - isInfosecQaCert: false, - isJsAlgoDataStructCert: false, - isJsAlgoDataStructCertV8: false, - isMachineLearningPyCertV7: false, - isQaCertV7: false, - isRelationalDatabaseCertV8: false, - isCollegeAlgebraPyCertV8: false, - isRespWebDesignCert: false, - isSciCompPyCertV7: false, - isUpcomingPythonCertV8: null, - keyboardShortcuts: false, - linkedin: null, - location: '', - name: '', - needsModeration: false, - newEmail: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - unsubscribeId: expect.stringMatching(unsubscribeIdRe), - partiallyCompletedChallenges: [], - password: null, - picture: '', - portfolio: [], - profileUI: { - isLocked: false, - showAbout: false, - showCerts: false, - showDonation: false, - showHeatMap: false, - showLocation: false, - showName: false, - showPoints: false, - showPortfolio: false, - showTimeLine: false - }, - progressTimestamps: [expect.any(Number)], - savedChallenges: [], - sendQuincyEmail: false, - theme: 'default', - timezone: null, - twitter: null, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - username: expect.stringMatching(fccUuidRe), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - usernameDisplay: expect.stringMatching(fccUuidRe), - verificationToken: null, - website: null, - yearsTopContributor: [] - }); - expect(user.username).toBe(user.usernameDisplay); - }); - - it('should set the jwt_access_token cookie', async () => { - const res = await superRequest('/signin', { method: 'GET' }); - - expect(res.status).toBe(302); - expect(res.headers['set-cookie']).toEqual( - expect.arrayContaining([expect.stringMatching(/jwt_access_token=/)]) - ); - // TODO: check the cookie value - }); - - it.todo('should create a session'); - - // duplicate of the server.test test to make sure I've not done something - // silly - it('should have OWASP recommended headers', async () => { - const res = await superRequest('/signin', { method: 'GET' }); - expect(res.headers).toMatchObject({ - 'cache-control': 'no-store', - 'content-security-policy': "frame-ancestors 'none'", - 'x-content-type-options': 'nosniff', - 'x-frame-options': 'DENY' - }); - }); - - it('should redirect to the Referer (if it is a valid origin)', async () => { - const res = await superRequest('/signin', { method: 'GET' }).set( - 'referer', - 'https://www.freecodecamp.org/some-path/or/other' - ); - - expect(res.status).toBe(302); - expect(res.headers.location).toBe( - 'https://www.freecodecamp.org/some-path/or/other' - ); - }); - - it('should redirect to /valid-language/learn when signing in from /valid-language', async () => { - const res = await superRequest('/signin', { method: 'GET' }).set( - 'referer', - 'https://www.freecodecamp.org/espanol' - ); - - expect(res.status).toBe(302); - expect(res.headers.location).toBe( - 'https://www.freecodecamp.org/espanol/learn' - ); - }); - - it('should handle referers with trailing slahes', async () => { - const res = await superRequest('/signin', { - method: 'GET' - }).set('referer', 'https://www.freecodecamp.org/espanol/'); - - expect(res.status).toBe(302); - expect(res.headers.location).toBe( - 'https://www.freecodecamp.org/espanol/learn' - ); - }); - - it('should redirect to /learn by default', async () => { - const res = await superRequest('/signin', { method: 'GET' }); - - expect(res.status).toBe(302); - expect(res.headers.location).toBe(`${HOME_LOCATION}/learn`); - }); - }); -}); diff --git a/api/src/routes/public/auth-dev.ts b/api/src/routes/public/auth-dev.ts index 8e4e76c3109..04ec983ff19 100644 --- a/api/src/routes/public/auth-dev.ts +++ b/api/src/routes/public/auth-dev.ts @@ -1,15 +1,6 @@ -import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyPluginCallback } from 'fastify'; -import { createAccessToken } from '../../utils/tokens'; -import { - getPrefixedLandingPath, - getRedirectParams, - haveSamePath -} from '../../utils/redirection'; -import { findOrCreateUser } from '../helpers/auth-helpers'; - -const trimTrailingSlash = (str: string) => - str.endsWith('/') ? str.slice(0, -1) : str; +import { devAuth } from '../../plugins/auth-dev'; /** * Route handler for development login. @@ -25,26 +16,6 @@ export const devAuthRoutes: FastifyPluginCallback = ( _options, done ) => { - async function handleRedirects(req: FastifyRequest, reply: FastifyReply) { - const params = getRedirectParams(req); - const { origin, pathPrefix } = params; - const returnTo = trimTrailingSlash(params.returnTo); - const landingUrl = getPrefixedLandingPath(origin, pathPrefix); - - return await reply.redirect( - haveSamePath(landingUrl, returnTo) ? `${returnTo}/learn` : returnTo - ); - } - - fastify.get('/signin', async (req, reply) => { - const email = 'foo@bar.com'; - - const { id } = await findOrCreateUser(fastify, email); - - reply.setAccessTokenCookie(createAccessToken(id)); - - await handleRedirects(req, reply); - }); - + void fastify.register(devAuth); done(); };