Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
222 changes: 220 additions & 2 deletions __tests__/redirector.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>),
Expand Down Expand Up @@ -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<UserReferralLinkedin>) => {
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);
});
});
38 changes: 26 additions & 12 deletions src/routes/redirector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
fastify.get<{ Params: { postId: string }; Querystring: { a?: string } }>(
Expand Down Expand Up @@ -46,7 +48,7 @@ export default async function (fastify: FastifyInstance): Promise<void> {
const userId = req.userId || req.trackingId;
if (userId) {
notifyView(
req.log,
logger,
post.id,
userId,
req.headers['referer'],
Expand All @@ -71,29 +73,42 @@ export default async function (fastify: FastifyInstance): Promise<void> {
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<void> => {
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;
}

Expand All @@ -103,33 +118,32 @@ const recruiterRedirector = async (fastify: FastifyInstance): Promise<void> => {
const result = await con.getRepository(UserReferralLinkedin).update(
{
id: id,
status: UserReferralStatus.Pending,
visited: false,
flags: Not(JsonContains({ hashedRequestIP: hmacHashIP(req.ip) })),
},
{ visited: true },
);

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',
);
}
});

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),
);
};