Skip to content

Commit 43b294e

Browse files
authored
Add a batch membership lookup route, and use it to check memberships (#281)
1 parent 9e4552b commit 43b294e

File tree

6 files changed

+515
-141
lines changed

6 files changed

+515
-141
lines changed

src/api/routes/v2/membership.ts

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ import { getEntraIdToken } from "api/functions/entraId.js";
2828
import { genericConfig, roleArns } from "common/config.js";
2929
import { getRoleCredentials } from "api/functions/sts.js";
3030
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
31-
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
31+
import { BatchGetItemCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";
3232
import { AppRoles } from "common/roles.js";
33+
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
3334

3435
const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
3536
const getAuthorizedClients = async () => {
@@ -160,6 +161,213 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
160161
);
161162
},
162163
);
164+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
165+
"/verifyBatchOfMembers",
166+
{
167+
schema: withRoles(
168+
[
169+
AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST,
170+
AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST,
171+
],
172+
withTags(["Membership"], {
173+
body: z.array(illinoisNetId).nonempty().max(500),
174+
querystring: z.object({
175+
list: z.string().min(1).optional().meta({
176+
example: "built",
177+
description:
178+
"Membership list to check from (defaults to ACM Paid Member list).",
179+
}),
180+
}),
181+
summary:
182+
"Check a batch of NetIDs for ACM @ UIUC paid membership (or partner organization membership) status.",
183+
response: {
184+
200: {
185+
description: "List membership status.",
186+
content: {
187+
"application/json": {
188+
schema: z
189+
.object({
190+
members: z.array(illinoisNetId),
191+
notMembers: z.array(illinoisNetId),
192+
list: z.optional(z.string().min(1)),
193+
})
194+
.meta({
195+
example: {
196+
members: ["rjjones"],
197+
notMembers: ["isbell"],
198+
list: "built",
199+
},
200+
}),
201+
},
202+
},
203+
},
204+
},
205+
}),
206+
),
207+
onRequest: async (request, reply) => {
208+
await fastify.authorizeFromSchema(request, reply);
209+
if (!request.userRoles) {
210+
throw new InternalServerError({});
211+
}
212+
const list = request.query.list || "acmpaid";
213+
if (
214+
list === "acmpaid" &&
215+
!request.userRoles.has(AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST)
216+
) {
217+
throw new UnauthorizedError({});
218+
}
219+
if (
220+
list !== "acmpaid" &&
221+
!request.userRoles.has(AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST)
222+
) {
223+
throw new UnauthorizedError({});
224+
}
225+
},
226+
},
227+
async (request, reply) => {
228+
const list = request.query.list || "acmpaid";
229+
let netIdsToCheck = [
230+
...new Set(request.body.map((id) => id.toLowerCase())),
231+
];
232+
233+
const members = new Set<string>();
234+
const notMembers = new Set<string>();
235+
236+
const cacheKeys = netIdsToCheck.map((id) => `membership:${id}:${list}`);
237+
if (cacheKeys.length > 0) {
238+
const cachedResults = await fastify.redisClient.mget(cacheKeys);
239+
const remainingNetIds: string[] = [];
240+
cachedResults.forEach((result, index) => {
241+
const netId = netIdsToCheck[index];
242+
if (result) {
243+
const { isMember } = JSON.parse(result) as { isMember: boolean };
244+
if (isMember) {
245+
members.add(netId);
246+
} else {
247+
notMembers.add(netId);
248+
}
249+
} else {
250+
remainingNetIds.push(netId);
251+
}
252+
});
253+
netIdsToCheck = remainingNetIds;
254+
}
255+
256+
if (netIdsToCheck.length === 0) {
257+
return reply.send({
258+
members: [...members].sort(),
259+
notMembers: [...notMembers].sort(),
260+
list: list === "acmpaid" ? undefined : list,
261+
});
262+
}
263+
264+
const cachePipeline = fastify.redisClient.pipeline();
265+
266+
if (list !== "acmpaid") {
267+
// can't do batch get on an index.
268+
const checkPromises = netIdsToCheck.map(async (netId) => {
269+
const isMember = await checkExternalMembership(
270+
netId,
271+
list,
272+
fastify.dynamoClient,
273+
);
274+
if (isMember) {
275+
members.add(netId);
276+
} else {
277+
notMembers.add(netId);
278+
}
279+
cachePipeline.set(
280+
`membership:${netId}:${list}`,
281+
JSON.stringify({ isMember }),
282+
"EX",
283+
MEMBER_CACHE_SECONDS,
284+
);
285+
});
286+
await Promise.all(checkPromises);
287+
} else {
288+
const BATCH_SIZE = 100;
289+
const foundInDynamo = new Set<string>();
290+
for (let i = 0; i < netIdsToCheck.length; i += BATCH_SIZE) {
291+
const batch = netIdsToCheck.slice(i, i + BATCH_SIZE);
292+
const command = new BatchGetItemCommand({
293+
RequestItems: {
294+
[genericConfig.MembershipTableName]: {
295+
Keys: batch.map((netId) =>
296+
marshall({ email: `${netId}@illinois.edu` }),
297+
),
298+
},
299+
},
300+
});
301+
const { Responses } = await fastify.dynamoClient.send(command);
302+
const items = Responses?.[genericConfig.MembershipTableName] ?? [];
303+
for (const item of items) {
304+
const { email } = unmarshall(item);
305+
const netId = email.split("@")[0];
306+
members.add(netId);
307+
foundInDynamo.add(netId);
308+
cachePipeline.set(
309+
`membership:${netId}:${list}`,
310+
JSON.stringify({ isMember: true }),
311+
"EX",
312+
MEMBER_CACHE_SECONDS,
313+
);
314+
}
315+
}
316+
317+
// 3. Fallback to Entra ID for remaining paid members
318+
const netIdsForEntra = netIdsToCheck.filter(
319+
(id) => !foundInDynamo.has(id),
320+
);
321+
if (netIdsForEntra.length > 0) {
322+
const entraIdToken = await getEntraIdToken({
323+
clients: await getAuthorizedClients(),
324+
clientId: fastify.environmentConfig.AadValidClientId,
325+
secretName: genericConfig.EntraSecretName,
326+
logger: request.log,
327+
});
328+
const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId;
329+
const entraCheckPromises = netIdsForEntra.map(async (netId) => {
330+
const isMember = await checkPaidMembershipFromEntra(
331+
netId,
332+
entraIdToken,
333+
paidMemberGroup,
334+
);
335+
if (isMember) {
336+
members.add(netId);
337+
// Fire-and-forget writeback to DynamoDB to warm it up
338+
setPaidMembershipInTable(netId, fastify.dynamoClient).catch(
339+
(err) =>
340+
request.log.error(
341+
err,
342+
`Failed to write back Entra membership for ${netId}`,
343+
),
344+
);
345+
} else {
346+
notMembers.add(netId);
347+
}
348+
cachePipeline.set(
349+
`membership:${netId}:${list}`,
350+
JSON.stringify({ isMember }),
351+
"EX",
352+
MEMBER_CACHE_SECONDS,
353+
);
354+
});
355+
await Promise.all(entraCheckPromises);
356+
}
357+
}
358+
359+
if (cachePipeline.length > 0) {
360+
await cachePipeline.exec();
361+
}
362+
363+
return reply.send({
364+
members: [...members].sort(),
365+
notMembers: [...notMembers].sort(),
366+
list: list === "acmpaid" ? undefined : list,
367+
});
368+
},
369+
);
370+
163371
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
164372
"/:netId",
165373
{
Lines changed: 75 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,101 @@
11
import React from "react";
2-
import { render, screen, act } from "@testing-library/react";
2+
import { render, screen } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import { vi } from "vitest";
55
import { MantineProvider } from "@mantine/core";
6-
import { notifications } from "@mantine/notifications";
7-
import InternalMembershipQuery from "./InternalMembershipQuery";
8-
import { Modules, ModulesToHumanName } from "@common/modules";
9-
import { MemoryRouter } from "react-router-dom";
6+
import { MembershipListQuery } from "./InternalMembershipQuery";
107

11-
describe("InternalMembershipQuery Tests", () => {
12-
const validNetIds = ["rjjones", "test2"];
13-
const queryInternalMembershipMock = vi
8+
// Mock the useClipboard hook from @mantine/hooks
9+
vi.mock("@mantine/hooks", async (importOriginal) => {
10+
const originalModule =
11+
await importOriginal<typeof import("@mantine/hooks")>();
12+
return {
13+
...originalModule,
14+
useClipboard: vi.fn(() => ({
15+
copy: vi.fn(),
16+
copied: false,
17+
})),
18+
};
19+
});
20+
21+
// Mock implementation for the clipboard API
22+
Object.assign(navigator, {
23+
clipboard: {
24+
writeText: vi.fn().mockResolvedValue(undefined),
25+
},
26+
});
27+
28+
describe("MembershipListQuery Tests", () => {
29+
const queryFunctionMock = vi
1430
.fn()
15-
.mockImplementation((netId) => validNetIds.includes(netId));
16-
const renderComponent = async () => {
17-
await act(async () => {
18-
render(
19-
<MemoryRouter>
20-
<MantineProvider
21-
withGlobalClasses
22-
withCssVariables
23-
forceColorScheme="light"
24-
>
25-
<InternalMembershipQuery
26-
queryInternalMembership={queryInternalMembershipMock}
27-
/>
28-
</MantineProvider>
29-
</MemoryRouter>,
30-
);
31+
.mockImplementation(async (netIds: string[]) => {
32+
const validNetIds = ["rjjones", "test2"];
33+
const members = netIds.filter((id) => validNetIds.includes(id));
34+
const nonMembers = netIds.filter((id) => !validNetIds.includes(id));
35+
return { members, nonMembers };
3136
});
37+
38+
const renderComponent = () => {
39+
render(
40+
<MantineProvider>
41+
<MembershipListQuery queryFunction={queryFunctionMock} />
42+
</MantineProvider>,
43+
);
3244
};
3345

3446
beforeEach(() => {
3547
vi.clearAllMocks();
36-
// Reset notification spy
37-
vi.spyOn(notifications, "show");
3848
});
3949

40-
it("renders the component correctly", async () => {
41-
await renderComponent();
42-
43-
expect(screen.getByText("NetID")).toBeInTheDocument();
50+
it("renders the component correctly", () => {
51+
renderComponent();
4452
expect(
45-
screen.getByRole("button", { name: /Query Membership/i }),
53+
screen.getByLabelText(/Enter NetIDs or Illinois Emails/i),
54+
).toBeInTheDocument();
55+
expect(
56+
screen.getByRole("button", { name: /Query Memberships/i }),
4657
).toBeInTheDocument();
4758
});
4859

49-
it("disables query button when no NetID is provided", async () => {
50-
await renderComponent();
60+
it("disables the query button when the input is empty", () => {
61+
renderComponent();
5162
expect(
52-
screen.getByRole("button", { name: /Query Membership/i }),
63+
screen.getByRole("button", { name: /Query Memberships/i }),
5364
).toBeDisabled();
54-
expect(queryInternalMembershipMock).not.toHaveBeenCalled();
5565
});
56-
it("correctly renders members", async () => {
57-
await renderComponent();
66+
67+
it("enables the query button when input is provided", async () => {
68+
renderComponent();
5869
const user = userEvent.setup();
59-
const textbox = screen.getByRole("textbox", { name: /NetID/i });
60-
await user.type(textbox, "rjjones");
61-
await user.click(screen.getByRole("button", { name: /Query Membership/i }));
62-
expect(queryInternalMembershipMock).toHaveBeenCalledExactlyOnceWith(
63-
"rjjones",
64-
);
65-
expect(screen.getByText("is a paid member.")).toBeVisible();
70+
const textarea = screen.getByLabelText(/Enter NetIDs or Illinois Emails/i);
71+
await user.type(textarea, "test");
72+
expect(
73+
screen.getByRole("button", { name: /Query Memberships/i }),
74+
).toBeEnabled();
6675
});
67-
it("correctly renders non-members", async () => {
68-
await renderComponent();
76+
77+
it("correctly processes input and displays members and non-members", async () => {
78+
renderComponent();
6979
const user = userEvent.setup();
70-
const textbox = screen.getByRole("textbox", { name: /NetID/i });
71-
await user.type(textbox, "invalid");
72-
await user.click(screen.getByRole("button", { name: /Query Membership/i }));
73-
expect(queryInternalMembershipMock).toHaveBeenCalledExactlyOnceWith(
80+
const textarea = screen.getByLabelText(/Enter NetIDs or Illinois Emails/i);
81+
const queryButton = screen.getByRole("button", {
82+
name: /Query Memberships/i,
83+
});
84+
const inputText = "rjjones, invalid, [email protected], rjjones";
85+
86+
await user.type(textarea, inputText);
87+
await user.click(queryButton);
88+
89+
expect(queryFunctionMock).toHaveBeenCalledTimes(1);
90+
expect(queryFunctionMock).toHaveBeenCalledWith([
91+
"rjjones",
7492
"invalid",
75-
);
76-
expect(screen.getByText("is not a paid member.")).toBeVisible();
93+
"test2",
94+
]);
95+
96+
expect(await screen.findByText(/Paid Members \(2\)/i)).toBeVisible();
97+
98+
expect(screen.getByText("rjjones")).toBeVisible();
99+
expect(screen.getByText("test2")).toBeVisible();
77100
});
78101
});

0 commit comments

Comments
 (0)