diff --git a/src/server/routes/opportunity/opportunity-volunteer.routes.ts b/src/server/routes/opportunity/opportunity-volunteer.routes.ts index acb81318..17e932ea 100644 --- a/src/server/routes/opportunity/opportunity-volunteer.routes.ts +++ b/src/server/routes/opportunity/opportunity-volunteer.routes.ts @@ -1,9 +1,11 @@ import { FastifyInstance, FastifyPluginOptions } from "fastify"; -import { BadRequestError } from "../../../config/error/fastify"; +import { UserRole } from "need4deed-sdk"; +import { BadRequestError, NotFoundError } from "../../../config/error/fastify"; import OpportunityVolunteer from "../../../data/entity/m2m/opportunity-volunteer"; import { opportunityOpportunityVolunteerDTO } from "../../../services"; import { idParamSchema, responseSchema } from "../../schema"; import { + getCallerAgentIds, maskVolunteerIdentities, shouldMaskInactiveAgentData, } from "../../utils"; @@ -36,6 +38,22 @@ export default function opportunityOpportunityVolunteerRoutes( throw new BadRequestError(msg400); } + if (request.authUser?.role === UserRole.AGENT) { + const opportunity = await fastify.db.opportunityRepository.findOne({ + where: { id: opportunityId }, + select: { id: true, agentId: true }, + }); + const agentIds = await getCallerAgentIds( + fastify, + request.authUser.personId, + ); + if (!opportunity?.agentId || !agentIds.includes(opportunity.agentId)) { + throw new NotFoundError( + `Opportunity (id:${opportunityId}) not found.`, + ); + } + } + const opportunityVolunteerRepository = fastify.db.opportunityVolunteerRepository; @@ -54,12 +72,12 @@ export default function opportunityOpportunityVolunteerRoutes( ], }); + const agent = volunteers[0]?.opportunity?.agent; // An INACTIVE agent's linked volunteers shouldn't read as live, // actionable data (be#885) here either — this route surfaces the same // underlying rows as GET /agent/:id/volunteer-linked, just scoped by // opportunityId instead of agentId, so it needs the same rule. All // rows share one opportunity/agent, so checking the first is enough. - const agent = volunteers[0]?.opportunity?.agent; if (agent && shouldMaskInactiveAgentData(agent, request.authUser?.role)) { maskVolunteerIdentities(volunteers); } diff --git a/src/server/routes/opportunity/opportunity.routes.ts b/src/server/routes/opportunity/opportunity.routes.ts index 225b231d..099599b7 100644 --- a/src/server/routes/opportunity/opportunity.routes.ts +++ b/src/server/routes/opportunity/opportunity.routes.ts @@ -1,5 +1,6 @@ import { FastifyInstance, FastifyPluginOptions } from "fastify"; import { + AgentMembershipStatus, ApiOpportunityGet, ApiOpportunityPatch, CommunicationType, @@ -13,7 +14,7 @@ import { SortOrder, UserRole, } from "need4deed-sdk"; -import { EntityManager } from "typeorm"; +import { EntityManager, In } from "typeorm"; import { BadRequestError, NotFoundError, @@ -62,6 +63,7 @@ import { import { addAgentTypeServiceTranslations, addComments2Entity, + getCallerAgentIds, getCategoryToDealHandler, getDistrictToAgentHandler, getDistrictToOpportunityHandler, @@ -207,6 +209,15 @@ export default async function opportunityRoutes( if (!opportunity) { throw new NotFoundError(`Opportunity (id:${id}) not found.`); } + if (request.authUser?.role === UserRole.AGENT) { + const agentIds = await getCallerAgentIds( + fastify, + request.authUser.personId, + ); + if (!opportunity.agentId || !agentIds.includes(opportunity.agentId)) { + throw new NotFoundError(`Opportunity (id:${id}) not found.`); + } + } const opportunityComments: Opportunity & { comments: Comment[] } = await addComments2Entity(opportunity); @@ -286,6 +297,27 @@ export default async function opportunityRoutes( : undefined; const where = getOpportunityWhere(request.query.filter, request.query); + const message = `Opportunities page:${request.query.page}.`; + + // NGOs see only their own agent's opportunities + if (request.authUser?.role === UserRole.AGENT) { + const agentIds = await getCallerAgentIds( + fastify, + request.authUser.personId, + ); + + // An agent with no shelter must see nothing, so return here rather than + // skipping the filter, which would show everything. + if (agentIds.length === 0) { + return reply.status(200).send({ + message, + data: [], + count: 0, + }); + } + // NGOs are scoped to their own agents, so this overwrites any agent condition. + where.agent = { id: In(agentIds) }; + } logger.debug( `GET /opportunities called. options: ${JSON.stringify({ where })}`, @@ -373,7 +405,7 @@ export default async function opportunityRoutes( // DTO (dtoOpportunityGetList) runs in the preSerialization hook after PII masking. return reply.status(200).send({ - message: `Opportunities page:${request.query.page}.`, + message, data: opportunitiesCategoryDistrict, count, }); @@ -439,6 +471,7 @@ export default async function opportunityRoutes( ? await fastify.db.agentPersonRepository.findOneBy({ agentId, personId, + status: AgentMembershipStatus.ACTIVE, }) : null; if (!membership) { @@ -614,6 +647,7 @@ export default async function opportunityRoutes( ? await fastify.db.agentPersonRepository.findOneBy({ agentId: opportunity.agentId, personId, + status: AgentMembershipStatus.ACTIVE, }) : null; if (!membership) { @@ -725,6 +759,7 @@ export default async function opportunityRoutes( await fastify.db.agentPersonRepository.findOneBy({ agentId: effectiveAgentId, personId: contactLinkId, + status: AgentMembershipStatus.ACTIVE, }); if (!agentContactMembership) { throw new NotFoundError( diff --git a/src/server/utils/data/get-caller-agent-ids.ts b/src/server/utils/data/get-caller-agent-ids.ts new file mode 100644 index 00000000..f2f5a985 --- /dev/null +++ b/src/server/utils/data/get-caller-agent-ids.ts @@ -0,0 +1,19 @@ +import { FastifyInstance } from "fastify"; +import { AgentMembershipStatus } from "need4deed-sdk"; + +// The agents this caller belongs to. Only ACTIVE memberships count. A PENDING +// one is still waiting on a coordinator to approve it, so it grants nothing. +export async function getCallerAgentIds( + fastify: FastifyInstance, + personId: number | null | undefined, +): Promise { + if (personId === null || personId === undefined) { + return []; + } + + const memberships = await fastify.db.agentPersonRepository.find({ + where: { personId, status: AgentMembershipStatus.ACTIVE }, + }); + + return [...new Set(memberships.map((m) => m.agentId))]; +} diff --git a/src/server/utils/data/index.ts b/src/server/utils/data/index.ts index 04bee3a7..fb8c3ab9 100644 --- a/src/server/utils/data/index.ts +++ b/src/server/utils/data/index.ts @@ -8,6 +8,7 @@ export * from "./create-agent-contact"; export * from "./for-routes"; export * from "./get-agent-by-postcode"; export * from "./get-agent-where"; +export * from "./get-caller-agent-ids"; export * from "./get-language-title"; export * from "./get-opportunity-orphanage-agent"; export * from "./get-opportunity-where"; diff --git a/src/server/utils/pii/visible-persons.ts b/src/server/utils/pii/visible-persons.ts index c6282c1b..8eef17e4 100644 --- a/src/server/utils/pii/visible-persons.ts +++ b/src/server/utils/pii/visible-persons.ts @@ -2,6 +2,7 @@ import { FastifyInstance } from "fastify"; import { UserRole } from "need4deed-sdk"; import { In } from "typeorm"; import User from "../../../data/entity/user.entity"; +import { getCallerAgentIds } from "../data/get-caller-agent-ids"; /** * What a non COORDINATOR/ADMIN caller may see UNMASKED, resolved per request @@ -65,8 +66,9 @@ export async function resolveCallerVisibility( } const agentPersonRepository = fastify.db.agentPersonRepository; - const memberships = await agentPersonRepository.find({ where: { personId } }); - memberships.forEach((m) => agentIds.add(m.agentId)); + const callerAgentIds = await getCallerAgentIds(fastify, personId); + callerAgentIds.forEach((id) => agentIds.add(id)); + if (agentIds.size === 0) { return visibility; } diff --git a/src/test/server/routes/opportunity-agent-scope.routes.test.ts b/src/test/server/routes/opportunity-agent-scope.routes.test.ts new file mode 100644 index 00000000..feed5fce --- /dev/null +++ b/src/test/server/routes/opportunity-agent-scope.routes.test.ts @@ -0,0 +1,252 @@ +import { FastifyInstance } from "fastify"; +import { AgentMembershipStatus, AgentRoleType, UserRole } from "need4deed-sdk"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { accessCookieName } from "../../../config/constants"; +import AgentPerson from "../../../data/entity/m2m/agent-person"; +import Person from "../../../data/entity/person.entity"; +import User from "../../../data/entity/user.entity"; +import { hashPassword } from "../../../data/utils"; +import { createServer } from "../../../server"; + +// GET /opportunity returns every agent's opportunities to everyone. +// AGENT caller must only receive their own agent's, resolved from the +// authenticated caller rather than anything the request supplies. +const PASSWORD = "test_password"; + +describe("GET /opportunity is scoped to an AGENT caller's own agent(s)", () => { + let fastify: FastifyInstance; + const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const coordinatorEmail = `coordinator-${suffix}@test.need4deed.org`; + const agentEmail = `agentrole-${suffix}@test.need4deed.org`; + + let coordinatorCookie: string; + let agentCookie: string; + let personId: number; + + // Chosen from seeded data + let ownAgentId: number; + let otherAgentId: number; + let ownAgentOpportunityCount: number; + let totalOpportunityCount: number; + + async function login(email: string): Promise { + const res = await fastify.inject({ + method: "POST", + url: "/auth/login", + payload: { email, password: PASSWORD }, + }); + return res.cookies.find((cookie) => cookie.name === accessCookieName)! + .value; + } + + async function listOpportunities(cookie: string, query = "") { + return fastify.inject({ + method: "GET", + url: `/opportunity/?limit=120${query}`, + cookies: { [accessCookieName]: cookie }, + }); + } + + beforeAll(async () => { + fastify = await createServer(); + await fastify.ready(); + + await fastify.db.userRepository.save( + new User({ + email: coordinatorEmail, + password: await hashPassword(PASSWORD), + role: UserRole.COORDINATOR, + isActive: true, + }), + ); + coordinatorCookie = await login(coordinatorEmail); + + const person = await fastify.db.personRepository.save( + new Person({ firstName: "Scope", lastName: `Test-${suffix}` }), + ); + personId = person.id; + + await fastify.db.userRepository.save( + new User({ + email: agentEmail, + password: await hashPassword(PASSWORD), + role: UserRole.AGENT, + isActive: true, + personId, + }), + ); + agentCookie = await login(agentEmail); + + // Pick an agent that actually owns opportunities, and a different one that + // also does, otherwise test 3 can't tell "scoped" from "empty". + const owned = await fastify.db.opportunityRepository + .createQueryBuilder("opportunity") + .select("opportunity.agent_id", "agentId") + .addSelect("COUNT(*)", "count") + .where("opportunity.agent_id IS NOT NULL") + .groupBy("opportunity.agent_id") + .orderBy("COUNT(*)", "DESC") + .getRawMany<{ agentId: number; count: string }>(); + + if (owned.length < 2) { + throw new Error( + `This suite needs at least 2 agents owning opportunities, found ${owned.length}.`, + ); + } + + ownAgentId = Number(owned[0].agentId); + otherAgentId = Number(owned[1].agentId); + ownAgentOpportunityCount = Number(owned[0].count); + totalOpportunityCount = await fastify.db.opportunityRepository.count(); + + await fastify.db.agentPersonRepository.save( + new AgentPerson({ + agentId: ownAgentId, + personId, + role: AgentRoleType.SOCIAL_WORKER, + status: AgentMembershipStatus.ACTIVE, + }), + ); + }); + + afterAll(async () => { + await fastify.db.agentPersonRepository.delete({ personId }); + await fastify.db.userRepository.delete({ personId }); + await fastify.db.personRepository.delete({ id: personId }); + await fastify.db.userRepository.delete({ email: coordinatorEmail }); + await fastify.close(); + }); + + it("returns only the caller's own agent's opportunities", async () => { + const res = await listOpportunities(agentCookie); + + expect(res.statusCode).toBe(200); + const { data, count } = res.json(); + expect(count).toBe(ownAgentOpportunityCount); + expect(data.length).toBeGreaterThan(0); + expect( + data.every((o: { agentId: number }) => o.agentId === ownAgentId), + ).toBe(true); + }); + + it("leaves a COORDINATOR seeing every agent's opportunities", async () => { + const res = await listOpportunities(coordinatorCookie); + + expect(res.statusCode).toBe(200); + expect(res.json().count).toBe(totalOpportunityCount); + expect(totalOpportunityCount).toBeGreaterThan(ownAgentOpportunityCount); + }); + + it("keeps an AGENT scoped to their own agent even when filter[agentId] names another", async () => { + const res = await listOpportunities( + agentCookie, + `&filter[agentId]=${otherAgentId}`, + ); + + expect(res.statusCode).toBe(200); + const { data, count } = res.json(); + expect(count).toBe(ownAgentOpportunityCount); + expect( + data.every((o: { agentId: number }) => o.agentId !== otherAgentId), + ).toBe(true); + }); + + it("404s when an AGENT fetches another agent's linked volunteers", async () => { + const link = await fastify.db.opportunityVolunteerRepository.findOne({ + where: { opportunity: { agentId: otherAgentId } }, + relations: ["opportunity"], + }); + expect(link).toBeTruthy(); + + const res = await fastify.inject({ + method: "GET", + url: `/opportunity/${link!.opportunityId}/volunteer-linked`, + cookies: { [accessCookieName]: agentCookie }, + }); + expect(res.statusCode).toBe(404); + }); + + it("lets an AGENT fetch their own agent's linked volunteers", async () => { + const link = await fastify.db.opportunityVolunteerRepository.findOne({ + where: { opportunity: { agentId: ownAgentId } }, + relations: ["opportunity"], + }); + expect(link).toBeTruthy(); + + const res = await fastify.inject({ + method: "GET", + url: `/opportunity/${link!.opportunityId}/volunteer-linked`, + cookies: { [accessCookieName]: agentCookie }, + }); + expect(res.statusCode).toBe(200); + }); + + it("404s when an AGENT fetches another agent's opportunity by id", async () => { + const other = await fastify.db.opportunityRepository.findOneBy({ + agentId: otherAgentId, + }); + const res = await fastify.inject({ + method: "GET", + url: `/opportunity/${other!.id}`, + cookies: { [accessCookieName]: agentCookie }, + }); + expect(res.statusCode).toBe(404); + }); + it("lets an AGENT fetch their own agent's opportunity by id", async () => { + const own = await fastify.db.opportunityRepository.findOneBy({ + agentId: ownAgentId, + }); + const res = await fastify.inject({ + method: "GET", + url: `/opportunity/${own!.id}`, + cookies: { [accessCookieName]: agentCookie }, + }); + expect(res.statusCode).toBe(200); + }); + + it("covers every agent a caller is a member of, not just the first", async () => { + await fastify.db.agentPersonRepository.save( + new AgentPerson({ + agentId: otherAgentId, + personId, + role: AgentRoleType.SOCIAL_WORKER, + status: AgentMembershipStatus.ACTIVE, + }), + ); + + const res = await listOpportunities(agentCookie); + + expect(res.statusCode).toBe(200); + const agentIds = new Set( + res.json().data.map((o: { agentId: number }) => o.agentId), + ); + expect(agentIds.has(ownAgentId)).toBe(true); + expect(agentIds.has(otherAgentId)).toBe(true); + }); + + it("returns an empty list for an AGENT with no membership at all", async () => { + await fastify.db.agentPersonRepository.delete({ personId }); + + const res = await listOpportunities(agentCookie); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ data: [], count: 0 }); + }); + + it("ignores a PENDING membership", async () => { + await fastify.db.agentPersonRepository.delete({ personId }); + await fastify.db.agentPersonRepository.save( + new AgentPerson({ + agentId: ownAgentId, + personId, + role: AgentRoleType.SOCIAL_WORKER, + status: AgentMembershipStatus.PENDING, + }), + ); + + const res = await listOpportunities(agentCookie); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ data: [], count: 0 }); + }); +});