diff --git a/.env b/.env index 7e671be9b6..52801f67b3 100644 --- a/.env +++ b/.env @@ -92,3 +92,4 @@ CLICKHOUSE_USER=default CLICKHOUSE_PASSWORD=changeme RESUME_BUCKET_NAME=bucket-name EMPLOYMENT_AGREEMENT_BUCKET_NAME=other-bucket-name +HMAC_SECRET=supersecretkey diff --git a/__tests__/redirector.ts b/__tests__/redirector.ts index e0d3701785..6f667643b5 100644 --- a/__tests__/redirector.ts +++ b/__tests__/redirector.ts @@ -1,14 +1,19 @@ import appFunc from '../src'; import { FastifyInstance } from 'fastify'; -import { saveFixtures, TEST_UA } from './helpers'; +import { authorizeRequest, saveFixtures, TEST_UA } from './helpers'; import { ArticlePost, Source, User, YouTubePost } from '../src/entity'; import { sourcesFixture } from './fixture/source'; import request from 'supertest'; import { postsFixture, videoPostsFixture } from './fixture/post'; -import { notifyView } from '../src/common'; +import { hmacHashIP, notifyView } from '../src/common'; import { DataSource } from 'typeorm'; import createOrGetConnection from '../src/db'; import { fallbackImages } from '../src/config'; +import { usersFixture } from './fixture/user'; +import { UserReferralLinkedin } from '../src/entity/user/referral/UserReferralLinkedin'; +import { logger } from '../src/logger'; +import { UserReferralStatus } from '../src/entity/user/referral/UserReferral'; +import { BASE_RECRUITER_URL } from '../src/routes/redirector'; jest.mock('../src/common', () => ({ ...(jest.requireActual('../src/common') as Record), @@ -134,3 +139,216 @@ describe('GET /:id/profile-image', () => { .expect('Location', fallbackImages.avatar); }); }); + +describe('GET /r/recruiter/:id', () => { + const spyLogger = jest.fn(); + + const saveReferral = async (override?: Partial) => { + return con.getRepository(UserReferralLinkedin).save({ + userId: usersFixture[0].id, + externalUserId: 'ext-0', + flags: { hashedRequestIP: hmacHashIP('198.51.100.1') }, + ...override, + }); + }; + + const getReferral = async (id: string) => { + return con.getRepository(UserReferralLinkedin).findOne({ where: { id } }); + }; + + beforeEach(async () => { + await saveFixtures(con, User, usersFixture); + }); + + it('should redirect to recruiter landing', async () => { + const r = await saveReferral(); + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe(BASE_RECRUITER_URL); + }); + + it('should redirect to recruiter landing even with invalid UUID', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const res = await request(app.server) + .get(`/r/recruiter/invalid-uuid`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe(BASE_RECRUITER_URL); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: 'invalid-uuid' }, + 'Invalid referral id provided, skipping recruiter redirector', + ); + }); + + it('should redirect to recruiter landing without marking visited if user is logged in', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral(); + + const res = await authorizeRequest( + request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'), + ); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(BASE_RECRUITER_URL); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id }, + 'User is logged in, skipping recruiter redirector', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(false); + }); + + it('should redirect to recruiter landing without marking visited if no referrer', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral(); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('X-Forwarded-For', '203.0.113.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(BASE_RECRUITER_URL); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id }, + 'No referrer provided, skipping recruiter redirector', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(false); + }); + + it('should redirect to recruiter landing without marking visited if referrer is not linkedin', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral(); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://daily.dev/') + .set('X-Forwarded-For', '203.0.113.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(BASE_RECRUITER_URL); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { + referralId: r.id, + referrer: 'https://daily.dev/', + }, + 'Referrer is not linkedin, skipping recruiter redirector', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(false); + }); + + it('should mark referral as visited when all conditions met', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral(); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(BASE_RECRUITER_URL); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id }, + 'Marked referral as visited', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(true); + }); + + it('should not mark referral as visited if visitor is the requester', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral(); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '198.51.100.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(BASE_RECRUITER_URL); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id }, + 'No referral found or referral already marked as visited', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(false); + }); + + it('should not do anything if already visited', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral({ visited: true }); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(BASE_RECRUITER_URL); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id }, + 'No referral found or referral already marked as visited', + ); + }); + + it('should not do anything if referral status is not pending', async () => { + jest.spyOn(logger, 'debug').mockImplementation(spyLogger); + const r = await saveReferral({ status: UserReferralStatus.Rejected }); + + const res = await request(app.server) + .get(`/r/recruiter/${r.id}`) + .set('Referer', 'https://www.linkedin.com/') + .set('X-Forwarded-For', '203.0.113.1'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe(BASE_RECRUITER_URL); + + await new Promise((r) => setTimeout(r, 100)); // wait for onResponse async tasks + + expect(spyLogger).toHaveBeenCalledTimes(1); + expect(spyLogger).toHaveBeenCalledWith( + { referralId: r.id }, + 'No referral found or referral already marked as visited', + ); + + const referral = await getReferral(r.id); + expect(referral?.visited).toBe(false); + }); +}); diff --git a/src/routes/redirector.ts b/src/routes/redirector.ts index c470815c3e..e10e1e4b06 100644 --- a/src/routes/redirector.ts +++ b/src/routes/redirector.ts @@ -7,6 +7,8 @@ import createOrGetConnection from '../db'; import { isNullOrUndefined } from '../common/object'; import { UserReferralLinkedin } from '../entity/user/referral/UserReferralLinkedin'; import { JsonContains, Not } from 'typeorm'; +import { logger } from '../logger'; +import { UserReferralStatus } from '../entity/user/referral/UserReferral'; export default async function (fastify: FastifyInstance): Promise { fastify.get<{ Params: { postId: string }; Querystring: { a?: string } }>( @@ -46,7 +48,7 @@ export default async function (fastify: FastifyInstance): Promise { const userId = req.userId || req.trackingId; if (userId) { notifyView( - req.log, + logger, post.id, userId, req.headers['referer'], @@ -71,29 +73,42 @@ export default async function (fastify: FastifyInstance): Promise { fastify.register(recruiterRedirector, { prefix: '/recruiter' }); } +export const BASE_RECRUITER_URL = + 'https://recruiter.daily.dev/?utm_source=dailydev&utm_medium=linkedin_referral'; + const recruiterRedirector = async (fastify: FastifyInstance): Promise => { fastify.addHook<{ Params: { id: string } }>('onResponse', async (req) => { const { error, data: id } = z.uuidv4().safeParse(req.params.id); if (error) { - req.log.debug( + logger.debug( + { referralId: req.params.id }, 'Invalid referral id provided, skipping recruiter redirector', ); return; } if (req.userId) { - req.log.debug('User is logged in, skipping recruiter redirector'); + logger.debug( + { referralId: id }, + 'User is logged in, skipping recruiter redirector', + ); return; } const referrer = req.headers['referer']; if (isNullOrUndefined(referrer)) { - req.log.debug('No referrer provided, skipping recruiter redirector'); + logger.debug( + { referralId: id }, + 'No referrer provided, skipping recruiter redirector', + ); return; } if (referrer.startsWith('https://www.linkedin.com/') === false) { - req.log.debug('Referrer is not linkedin, skipping recruiter redirector'); + logger.debug( + { referralId: id, referrer }, + 'Referrer is not linkedin, skipping recruiter redirector', + ); return; } @@ -103,6 +118,7 @@ const recruiterRedirector = async (fastify: FastifyInstance): Promise => { const result = await con.getRepository(UserReferralLinkedin).update( { id: id, + status: UserReferralStatus.Pending, visited: false, flags: Not(JsonContains({ hashedRequestIP: hmacHashIP(req.ip) })), }, @@ -110,17 +126,17 @@ const recruiterRedirector = async (fastify: FastifyInstance): Promise => { ); if (result.affected === 0) { - req.log.debug( - { id }, + logger.debug( + { referralId: id }, `No referral found or referral already marked as visited`, ); return; } - req.log.debug({ id }, `Marked referral as visited`); + logger.debug({ referralId: id }, 'Marked referral as visited'); } catch (_err) { const err = _err as Error; - req.log.error( + logger.error( { err, referralId: id }, 'Failed to mark referral as visited', ); @@ -128,8 +144,6 @@ const recruiterRedirector = async (fastify: FastifyInstance): Promise => { }); fastify.get<{ Params: { id: string } }>('/:id', (_, res) => - res.redirect( - 'https://recruiter.daily.dev/?utm_source=dailydev&utm_medium=linkedin_referral', - ), + res.redirect(BASE_RECRUITER_URL), ); };