diff --git a/__tests__/routes/private/companyVerification.ts b/__tests__/routes/private/companyVerification.ts new file mode 100644 index 0000000000..8bc313c368 --- /dev/null +++ b/__tests__/routes/private/companyVerification.ts @@ -0,0 +1,248 @@ +import type { FastifyInstance } from 'fastify'; +import request from 'supertest'; +import type { DataSource } from 'typeorm'; +import appFunc from '../../../src'; +import createOrGetConnection from '../../../src/db'; +import { saveFixtures } from '../../helpers'; +import { User } from '../../../src/entity/user/User'; +import { Company } from '../../../src/entity/Company'; +import { UserCompany } from '../../../src/entity/UserCompany'; +import * as cloudinary from '../../../src/common/cloudinary'; + +let app: FastifyInstance; +let con: DataSource; + +const serviceHeaders = { + authorization: `Service ${process.env.ACCESS_SECRET}`, + 'content-type': 'application/json', +}; + +const hostedImage = 'https://media.daily.dev/company-logo'; + +const userId = '99999999-9999-4999-8999-999999999991'; +const secondUserId = '99999999-9999-4999-8999-999999999992'; +const thirdUserId = '99999999-9999-4999-8999-999999999993'; +const otherUserId = '99999999-9999-4999-8999-999999999994'; +const existingCompanyId = 'existingco'; + +beforeAll(async () => { + app = await appFunc(); + con = await createOrGetConnection(); + return app.ready(); +}); + +beforeEach(async () => { + jest.restoreAllMocks(); + jest.spyOn(cloudinary, 'uploadLogoFromUrl').mockResolvedValue(hostedImage); + await saveFixtures(con, User, [ + { id: userId, reputation: 10 }, + { id: secondUserId, reputation: 10 }, + { id: thirdUserId, reputation: 10 }, + { id: otherUserId, reputation: 10 }, + ]); +}); + +afterAll(() => app.close()); + +const seedUserCompanies = async () => { + await saveFixtures(con, Company, [ + { + id: existingCompanyId, + name: 'Existing Co', + image: hostedImage, + domains: ['example.com'], + }, + ]); + await con.getRepository(UserCompany).save([ + { + userId, + email: 'alice@example.com', + code: '111111', + verified: true, + }, + { + userId: secondUserId, + email: 'Bob@Example.com', + code: '222222', + verified: true, + }, + { + userId: thirdUserId, + email: 'carol@other.com', + code: '333333', + verified: true, + }, + ]); +}; + +describe('private company verification routes', () => { + describe('service guard', () => { + it('returns 404 for POST /companies without service auth', () => + request(app.server) + .post('/p/company-verification/companies') + .send({ + name: 'Acme', + domains: ['acme.com'], + image: 'https://x.com/a.png', + }) + .expect(404)); + + it('returns 404 for POST /link-domain without service auth', () => + request(app.server) + .post('/p/company-verification/link-domain') + .send({ companyId: existingCompanyId, domain: 'example.com' }) + .expect(404)); + + it('returns 404 for POST /reject-domain without service auth', () => + request(app.server) + .post('/p/company-verification/reject-domain') + .send({ domain: 'example.com' }) + .expect(404)); + }); + + describe('POST /companies', () => { + it('creates a company with a generated id and hosted image', async () => { + const uploadLogoFromUrl = jest.spyOn(cloudinary, 'uploadLogoFromUrl'); + const { body } = await request(app.server) + .post('/p/company-verification/companies') + .set(serviceHeaders) + .send({ + name: 'Acme', + domains: [' Acme.com ', 'ACME.IO'], + image: 'https://cdn.example.com/logo.png', + }) + .expect(201); + + expect(body.id).toEqual(expect.any(String)); + expect(uploadLogoFromUrl).toHaveBeenCalledWith( + body.id, + 'https://cdn.example.com/logo.png', + ); + + const company = await con + .getRepository(Company) + .findOneByOrFail({ id: body.id }); + expect(company).toMatchObject({ + name: 'Acme', + image: hostedImage, + domains: ['acme.com', 'acme.io'], + }); + }); + + it('respects a provided id', async () => { + const { body } = await request(app.server) + .post('/p/company-verification/companies') + .set(serviceHeaders) + .send({ + id: 'mycompany', + name: 'Acme', + domains: ['acme.com'], + image: 'https://cdn.example.com/logo.png', + }) + .expect(201); + + expect(body.id).toEqual('mycompany'); + }); + + it('returns 409 when a provided id already exists', async () => { + await seedUserCompanies(); + + await request(app.server) + .post('/p/company-verification/companies') + .set(serviceHeaders) + .send({ + id: existingCompanyId, + name: 'Acme', + domains: ['acme.com'], + image: 'https://cdn.example.com/logo.png', + }) + .expect(409); + + const company = await con + .getRepository(Company) + .findOneByOrFail({ id: existingCompanyId }); + expect(company.name).toEqual('Existing Co'); + }); + + it('rejects missing required fields', async () => { + await request(app.server) + .post('/p/company-verification/companies') + .set(serviceHeaders) + .send({ domains: ['acme.com'], image: 'https://cdn.example.com/a.png' }) + .expect(400); + await request(app.server) + .post('/p/company-verification/companies') + .set(serviceHeaders) + .send({ name: 'Acme', image: 'https://cdn.example.com/a.png' }) + .expect(400); + await request(app.server) + .post('/p/company-verification/companies') + .set(serviceHeaders) + .send({ name: 'Acme', domains: ['acme.com'] }) + .expect(400); + }); + }); + + describe('POST /link-domain', () => { + it('links all matching rows regardless of email casing', async () => { + await seedUserCompanies(); + + const { body } = await request(app.server) + .post('/p/company-verification/link-domain') + .set(serviceHeaders) + .send({ companyId: existingCompanyId, domain: 'Example.com' }) + .expect(200); + + expect(body).toEqual({ affected: 2 }); + + const linked = await con + .getRepository(UserCompany) + .findBy({ companyId: existingCompanyId }); + expect(linked.map((uc) => uc.email).sort()).toEqual([ + 'Bob@Example.com', + 'alice@example.com', + ]); + + const other = await con + .getRepository(UserCompany) + .findOneByOrFail({ email: 'carol@other.com' }); + expect(other.companyId).toBeNull(); + }); + + it('returns 404 when the company does not exist', async () => { + await seedUserCompanies(); + + await request(app.server) + .post('/p/company-verification/link-domain') + .set(serviceHeaders) + .send({ companyId: 'missing', domain: 'example.com' }) + .expect(404); + }); + }); + + describe('POST /reject-domain', () => { + it('rejects matching rows and is idempotent', async () => { + await seedUserCompanies(); + + const first = await request(app.server) + .post('/p/company-verification/reject-domain') + .set(serviceHeaders) + .send({ domain: 'example.com' }) + .expect(200); + expect(first.body).toEqual({ affected: 2 }); + + const rejected = await con.getRepository(UserCompany).find(); + const byEmail = new Map(rejected.map((uc) => [uc.email, uc.flags])); + expect(byEmail.get('alice@example.com')).toEqual({ rejected: true }); + expect(byEmail.get('Bob@Example.com')).toEqual({ rejected: true }); + expect(byEmail.get('carol@other.com')).toEqual({}); + + const second = await request(app.server) + .post('/p/company-verification/reject-domain') + .set(serviceHeaders) + .send({ domain: 'example.com' }) + .expect(200); + expect(second.body).toEqual({ affected: 2 }); + }); + }); +}); diff --git a/src/common/schema/companyVerification.ts b/src/common/schema/companyVerification.ts new file mode 100644 index 0000000000..98037a5734 --- /dev/null +++ b/src/common/schema/companyVerification.ts @@ -0,0 +1,23 @@ +import z from 'zod'; +import { CompanyType } from '../../entity/Company'; +import { enumValues } from './utils'; + +const normalizedDomainSchema = z.string().trim().toLowerCase().min(1); + +export const companyVerificationCreateCompanySchema = z.object({ + id: z.string().trim().min(1).optional(), + name: z.string().trim().min(1), + altName: z.string().trim().min(1).nullish(), + domains: z.array(normalizedDomainSchema).min(1), + image: z.url(), + type: z.enum(enumValues(CompanyType)).optional(), +}); + +export const companyVerificationLinkDomainSchema = z.object({ + companyId: z.string().trim().min(1), + domain: normalizedDomainSchema, +}); + +export const companyVerificationRejectDomainSchema = z.object({ + domain: normalizedDomainSchema, +}); diff --git a/src/routes/private.ts b/src/routes/private.ts index f85edee23f..76f3e71153 100644 --- a/src/routes/private.ts +++ b/src/routes/private.ts @@ -17,6 +17,7 @@ import { import { queryReadReplica } from '../common/queryReadReplica'; import { kvasir } from './private/kvasir'; import contributions from './private/contributions'; +import companyVerification from './private/companyVerification'; import rpc from './private/rpc'; import { createWorkerJobRpc } from './private/workerJobRpc'; import { connectRpcPlugin, baseRpcContext } from '../common/connectRpc'; @@ -59,6 +60,7 @@ const vordrUsersSchema = z.object({ export default async function (fastify: FastifyInstance): Promise { fastify.register(contributions, { prefix: '/contributions' }); + fastify.register(companyVerification, { prefix: '/company-verification' }); fastify.post<{ Body: AddUserDataPost }>('/newUser', async (req, res) => { if (!req.service) { diff --git a/src/routes/private/companyVerification.ts b/src/routes/private/companyVerification.ts new file mode 100644 index 0000000000..6b9b13e35a --- /dev/null +++ b/src/routes/private/companyVerification.ts @@ -0,0 +1,113 @@ +import type { FastifyInstance } from 'fastify'; +import type z from 'zod'; +import createOrGetConnection from '../../db'; +import { + companyVerificationCreateCompanySchema, + companyVerificationLinkDomainSchema, + companyVerificationRejectDomainSchema, +} from '../../common/schema/companyVerification'; +import { Company } from '../../entity/Company'; +import { UserCompany } from '../../entity/UserCompany'; +import { generateShortId } from '../../ids'; +import { uploadLogoFromUrl } from '../../common/cloudinary'; +import { updateFlagsStatement } from '../../common/utils'; +import { parseSchema } from './utils'; + +const domainWhere = `split_part(lower(email), '@', 2) = :domain`; + +export default async (fastify: FastifyInstance): Promise => { + fastify.addHook('preHandler', async (req, res) => { + if (!req.service) { + return res.status(404).send(); + } + }); + + fastify.post<{ + Body: z.infer; + }>('/companies', async (req, res) => { + const body = parseSchema({ + schema: companyVerificationCreateCompanySchema, + value: req.body, + res, + }); + if (!body) { + return; + } + + const con = await createOrGetConnection(); + const repo = con.getRepository(Company); + const id = body.id ?? (await generateShortId()); + + if (body.id && (await repo.findOneBy({ id: body.id }))) { + return res.status(409).send({ error: 'Company already exists' }); + } + + const image = await uploadLogoFromUrl(id, body.image); + const company = await repo.save({ + id, + name: body.name, + altName: body.altName ?? null, + domains: body.domains, + image, + ...(body.type ? { type: body.type } : {}), + }); + + return res.status(201).send(company); + }); + + fastify.post<{ + Body: z.infer; + }>('/link-domain', async (req, res) => { + const body = parseSchema({ + schema: companyVerificationLinkDomainSchema, + value: req.body, + res, + }); + if (!body) { + return; + } + + const con = await createOrGetConnection(); + const company = await con + .getRepository(Company) + .findOneBy({ id: body.companyId }); + + if (!company) { + return res.status(404).send({ error: 'Company not found' }); + } + + const result = await con + .getRepository(UserCompany) + .createQueryBuilder() + .update() + .set({ companyId: body.companyId }) + .where(domainWhere, { domain: body.domain }) + .execute(); + + return res.status(200).send({ affected: result.affected ?? 0 }); + }); + + fastify.post<{ + Body: z.infer; + }>('/reject-domain', async (req, res) => { + const body = parseSchema({ + schema: companyVerificationRejectDomainSchema, + value: req.body, + res, + }); + if (!body) { + return; + } + + const con = await createOrGetConnection(); + const result = await con + .getRepository(UserCompany) + .createQueryBuilder() + .update() + .set({ flags: updateFlagsStatement({ rejected: true }) }) + .where(domainWhere, { domain: body.domain }) + .execute(); + + return res.status(200).send({ affected: result.affected ?? 0 }); + }); +}; diff --git a/src/routes/private/contributions.ts b/src/routes/private/contributions.ts index c479acde51..21890d156b 100644 --- a/src/routes/private/contributions.ts +++ b/src/routes/private/contributions.ts @@ -1,7 +1,8 @@ -import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { FastifyInstance } from 'fastify'; import type z from 'zod'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import createOrGetConnection from '../../db'; +import { parseSchema } from './utils'; import { contributionPrivateBlockUserSchema, contributionPrivateBlockedUserParamsSchema, @@ -34,47 +35,6 @@ import { UserContributionRewardStatus, } from '../../entity/contribution/UserContributionReward'; -const parseSchema = ({ - schema, - value, - res, - requireNonEmpty = false, -}: { - schema: TSchema; - value: unknown; - res: FastifyReply; - requireNonEmpty?: boolean; -}): z.infer | undefined => { - const parsed = schema.safeParse(value); - if (!parsed.success) { - res.status(400).send({ - error: { - name: parsed.error.name, - issues: parsed.error.issues, - }, - }); - return undefined; - } - - if ( - requireNonEmpty && - parsed.data && - typeof parsed.data === 'object' && - !Array.isArray(parsed.data) && - Object.keys(parsed.data).length === 0 - ) { - res.status(400).send({ - error: { - name: 'ZodError', - issues: [{ message: 'At least one field is required' }], - }, - }); - return undefined; - } - - return parsed.data; -}; - export default async (fastify: FastifyInstance): Promise => { fastify.addHook('preHandler', async (req, res) => { if (!req.service) { diff --git a/src/routes/private/utils.ts b/src/routes/private/utils.ts new file mode 100644 index 0000000000..4453668b1f --- /dev/null +++ b/src/routes/private/utils.ts @@ -0,0 +1,43 @@ +import type { FastifyReply } from 'fastify'; +import type z from 'zod'; + +export const parseSchema = ({ + schema, + value, + res, + requireNonEmpty = false, +}: { + schema: TSchema; + value: unknown; + res: FastifyReply; + requireNonEmpty?: boolean; +}): z.infer | undefined => { + const parsed = schema.safeParse(value); + if (!parsed.success) { + res.status(400).send({ + error: { + name: parsed.error.name, + issues: parsed.error.issues, + }, + }); + return undefined; + } + + if ( + requireNonEmpty && + parsed.data && + typeof parsed.data === 'object' && + !Array.isArray(parsed.data) && + Object.keys(parsed.data).length === 0 + ) { + res.status(400).send({ + error: { + name: 'ZodError', + issues: [{ message: 'At least one field is required' }], + }, + }); + return undefined; + } + + return parsed.data; +};