diff --git a/src/api/routes/linkry.ts b/src/api/routes/linkry.ts index 9643dd15..9b898d90 100644 --- a/src/api/routes/linkry.ts +++ b/src/api/routes/linkry.ts @@ -488,7 +488,9 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => { setUserGroups, ); if (mutualGroups.size == 0) { - throw new NotFoundError({ endpointName: request.url }); + throw new UnauthorizedError({ + message: "You have not been delegated access.", + }); } } return reply.status(200).send(item); diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index d61d4a75..5654dd56 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -18,6 +18,7 @@ import { ScanTicketsPage } from './pages/tickets/ScanTickets.page'; import { SelectTicketsPage } from './pages/tickets/SelectEventId.page'; import { ViewTicketsPage } from './pages/tickets/ViewTickets.page'; import { ManageIamPage } from './pages/iam/ManageIam.page'; +import { LinkShortenerAdmin } from './pages/linkry/LinkShortenerAdmin.page'; import { ManageProfilePage } from './pages/profile/ManageProfile.page'; import { ManageStripeLinksPage } from './pages/stripe/ViewLinks.page'; import { ManageRoomRequestsPage } from './pages/roomRequest/RoomRequestLanding.page'; diff --git a/src/ui/config.ts b/src/ui/config.ts index 74276625..9c1272f3 100644 --- a/src/ui/config.ts +++ b/src/ui/config.ts @@ -23,6 +23,8 @@ export type KnownGroups = { export type ConfigType = { AadValidClientId: string; ServiceConfiguration: Record; + LinkryGroupNameToGroupUUIDMap: Map; + LinkryGroupUUIDToGroupNameMap: Map; KnownGroupMappings: KnownGroups; }; @@ -43,6 +45,22 @@ type EnvironmentConfigType = { const environmentConfig: EnvironmentConfigType = { 'local-dev': { AadValidClientId: 'd1978c23-6455-426a-be4d-528b2d2e4026', + LinkryGroupNameToGroupUUIDMap: new Map([ + ['ACM Exec Linkry Test', '6d0bf289-71e3-4b8f-929b-63d93c2e0533'], + ['ACM Link Shortener Managers Linkry Test', 'a93bc2ad-b2b4-47bf-aa32-603dda8f6fdd'], + ['ACM Officers Linkry Test', '99b6b87c-9550-4529-87c1-f40862ab7add'], + ['ACM Infra Leadership Linkry Test', '83c275f8-e533-4987-b537-a94b86c9d28e'], + ['ACM Infra Team', '940e4f9e-6891-4e28-9e29-148798495cdb'], + ['ACM Infra Leads', 'f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6'], + ]), + LinkryGroupUUIDToGroupNameMap: new Map([ + ['6d0bf289-71e3-4b8f-929b-63d93c2e0533', 'ACM Exec Linkry Test'], + ['a93bc2ad-b2b4-47bf-aa32-603dda8f6fdd', 'ACM Link Shortener Managers Linkry Test'], + ['99b6b87c-9550-4529-87c1-f40862ab7add', 'ACM Officers Linkry Test'], + ['83c275f8-e533-4987-b537-a94b86c9d28e', 'ACM Infra Leadership Linkry Test'], + ['940e4f9e-6891-4e28-9e29-148798495cdb', 'ACM Infra Team'], + ['f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6', 'ACM Infra Leads'], + ]), ServiceConfiguration: { core: { friendlyName: 'Core Management Service (NonProd)', @@ -74,6 +92,22 @@ const environmentConfig: EnvironmentConfigType = { }, dev: { AadValidClientId: 'd1978c23-6455-426a-be4d-528b2d2e4026', + LinkryGroupNameToGroupUUIDMap: new Map([ + ['ACM Exec Linkry Test', '6d0bf289-71e3-4b8f-929b-63d93c2e0533'], + ['ACM Link Shortener Managers Linkry Test', 'a93bc2ad-b2b4-47bf-aa32-603dda8f6fdd'], + ['ACM Officers Linkry Test', '99b6b87c-9550-4529-87c1-f40862ab7add'], + ['ACM Infra Leadership Linkry Test', '83c275f8-e533-4987-b537-a94b86c9d28e'], + ['ACM Infra Team', '940e4f9e-6891-4e28-9e29-148798495cdb'], + ['ACM Infra Leads', 'f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6'], + ]), + LinkryGroupUUIDToGroupNameMap: new Map([ + ['6d0bf289-71e3-4b8f-929b-63d93c2e0533', 'ACM Exec Linkry Test'], + ['a93bc2ad-b2b4-47bf-aa32-603dda8f6fdd', 'ACM Link Shortener Managers Linkry Test'], + ['99b6b87c-9550-4529-87c1-f40862ab7add', 'ACM Officers Linkry Test'], + ['83c275f8-e533-4987-b537-a94b86c9d28e', 'ACM Infra Leadership Linkry Test'], + ['940e4f9e-6891-4e28-9e29-148798495cdb', 'ACM Infra Team'], + ['f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6', 'ACM Infra Leads'], + ]), ServiceConfiguration: { core: { friendlyName: 'Core Management Service (NonProd)', @@ -105,6 +139,18 @@ const environmentConfig: EnvironmentConfigType = { }, prod: { AadValidClientId: '43fee67e-e383-4071-9233-ef33110e9386', + LinkryGroupNameToGroupUUIDMap: new Map([ + ['ACM Exec', 'ad81254b-4eeb-4c96-8191-3acdce9194b1'], + ['ACM Link Shortener Managers', '270c2d58-11f6-4c45-a217-d46a035fe853'], + ['ACM Officers', 'ff49e948-4587-416b-8224-65147540d5fc'], + ['ACM Infra Leadership', 'f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6'], //TODO: is this correct? + ]), + LinkryGroupUUIDToGroupNameMap: new Map([ + ['ad81254b-4eeb-4c96-8191-3acdce9194b1', 'ACM Exec'], + ['270c2d58-11f6-4c45-a217-d46a035fe853', 'ACM Link Shortener Managers'], + ['ff49e948-4587-416b-8224-65147540d5fc', 'ACM Officers'], + ['f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6', 'ACM Infra Leadership'], //TODO: is this correct? + ]), ServiceConfiguration: { core: { friendlyName: 'Core Management Service', diff --git a/src/ui/pages/linkry/LinkShortenerAdmin.page.tsx b/src/ui/pages/linkry/LinkShortenerAdmin.page.tsx new file mode 100644 index 00000000..3e101d45 --- /dev/null +++ b/src/ui/pages/linkry/LinkShortenerAdmin.page.tsx @@ -0,0 +1,446 @@ +import { + Text, + Box, + Title, + Button, + Table, + Modal, + Group, + Transition, + ButtonGroup, + Anchor, + Badge, + Loader, + Tabs, + useMantineColorScheme, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { notifications } from '@mantine/notifications'; +import { IconCancel, IconCross, IconEdit, IconPlus, IconTrash } from '@tabler/icons-react'; +import dayjs from 'dayjs'; +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { z } from 'zod'; + +import { capitalizeFirstLetter } from './ManageLink.page.js'; +import FullScreenLoader from '@ui/components/AuthContext/LoadingScreen'; +import { AuthGuard } from '@ui/components/AuthGuard'; +import { useApi } from '@ui/util/api'; +import { AppRoles } from '@common/roles.js'; +import { wrap } from 'module'; + +const repeatOptions = ['weekly', 'biweekly'] as const; + +const baseSchema = z.object({ + slug: z.string().min(1).optional(), + access: z.string().min(1).optional(), + redirect: z.string().min(1).optional(), + createdAtUtc: z.number().optional(), + updatedAtUtc: z.number().optional(), + counter: z.number().optional(), +}); + +// const requestSchema = baseSchema.extend({ +// repeats: z.optional(z.enum(repeatOptions)), +// repeatEnds: z.string().optional(), +// }); + +const getLinkrySchema = baseSchema.extend({ + id: z.string(), + owner: z.string().min(1), +}); + +const getLinkryAdminSchema = baseSchema.extend({ + id: z.string(), +}); + +const wrapTextStyle: React.CSSProperties = { + wordWrap: 'break-word', + overflowWrap: 'break-word' as const, + whiteSpace: 'normal', +}; + +export type LinkryGetResponse = z.infer; +export type LinkryAdminGetResponse = z.infer; +//const getLinksSchema = z.array(getLinkrySchema); + +export const LinkShortenerAdmin: React.FC = () => { + const [isLoading, setIsLoading] = useState(false); + const [adminLinks, setAdminLinks] = useState([]); + const api = useApi('core'); + const [opened, { open, close }] = useDisclosure(false); + const [showPrevious, { toggle: togglePrevious }] = useDisclosure(false); // Changed default to false + const [deleteLinkCandidate, setDeleteLinkCandidate] = useState(null); + const navigate = useNavigate(); + const { colorScheme } = useMantineColorScheme(); + + const renderTableRow = (link: LinkryGetResponse, index: number) => { + const shouldShow = true; + + return ( + + {(styles) => ( + + + + {/* Currently set to localhost for local testing purposes */} + https://go.acm.illinois.edu/{link.slug} + + + + + {link.redirect} + + + + + {link.owner && link.owner.length > 0 ? ( + link.owner + .split(';') // Split the access string by ";" + .map((group, index) => ( + + {group.trim()} {/* Trim any extra whitespace */} + + )) + ) : ( + <> + )} + + + + {link.access && link.access.length > 0 ? ( + link.access + .split(';') // Split the access string by ";" + .map((group, index) => ( + + {group.trim()} {/* Trim any extra whitespace */} + + )) + ) : ( + <> + )} + + {link.counter || 0} + {/* {dayjs(link.createdAtUtc).format('MMM D YYYY hh:mm')} + {dayjs(link.updatedAtUtc).format('MMM D YYYY hh:mm')} */} + + + {/* */} + + + + + + )} + + ); + }; + + const renderAdminLinks = (link: LinkryGetResponse, index: number) => { + const shouldShow = true; + + return ( + + {(styles) => ( + + + + {' '} + {/* Currently set to localhost for local testing purposes */} + https://go.acm.illinois.edu/{link.slug} + + + + + {link.redirect} + + + + + {link.access + ?.split(';') // Split the access string by ";" + .map((group, index) => ( + + {group.trim()} {/* Trim any extra whitespace */} + + ))} + + {/* {dayjs(link.createdAtUtc).format('MMM D YYYY hh:mm')} + {dayjs(link.updatedAtUtc).format('MMM D YYYY hh:mm')} */} + + + {/* */} + + + + + + )} + + ); + }; + + useEffect(() => { + const getEvents = async () => { + try { + setIsLoading(true); + const response = await api.get('/api/v1/linkry/admin/redir'); + const adminLinks = response.data.adminLinks; + setIsLoading(false); + setAdminLinks(adminLinks); + } catch (e: unknown) { + notifications.show({ + title: 'Error accesing admin', + message: 'Error retrieving admin informations', + color: 'red', + }); + navigate(new URLSearchParams(window.location.search).get('previousPage') || '/linkry'); + } + }; + getEvents(); + }, []); + + const deleteLink = async (slug: string) => { + try { + const encodedSlug = encodeURIComponent(slug); + setIsLoading(true); + await api.delete(`/api/v1/linkry/redir/${encodedSlug}`); + setAdminLinks((prevEvents) => prevEvents.filter((link) => link.slug !== slug)); + setIsLoading(false); + notifications.show({ + title: 'Link deleted', + message: 'The link was deleted successfully.', + }); + close(); + } catch (error) { + console.error(error); + notifications.show({ + title: 'Error deleting event', + message: `${error}`, + color: 'red', + }); + } + }; + + return ( + + + + + {deleteLinkCandidate && ( + { + setDeleteLinkCandidate(null); + close(); + }} + title="Confirm action" + > + + Are you sure you want to delete the link with slug {deleteLinkCandidate?.slug}? + +
+ + + + +
+ )} + + Admin Links + + +
+ + + +
+ + + + All Links + + + +
+ + + + Shortened Link + Redirect URL + Owner + Access Groups + Visit Count + {/* Created On + Updated On */} + + + {adminLinks.map(renderTableRow)} +
+
+
+
+
+ ); +}; diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index 1fa46cdc..60b4d5d2 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -19,12 +19,19 @@ const ddbMock = mockClient(SecretsManagerClient); const app = await init(); const jwt_secret = secretObject["jwt_key"]; -export function createJwt(date?: Date, group?: string, email?: string) { +export function createJwt( + date?: Date, + groups?: string[], + email?: string, + roles?: string[], // Add roles parameter +) { let modifiedPayload = { ...jwtPayload, email: email || jwtPayload.email, groups: [...jwtPayload.groups], + roles: roles || jwtPayload.roles, // Use provided roles or default roles }; + if (date) { const nowMs = Math.floor(date.valueOf() / 1000); const laterMs = nowMs + 3600 * 24; @@ -36,9 +43,10 @@ export function createJwt(date?: Date, group?: string, email?: string) { }; } - if (group) { - modifiedPayload.groups = [group]; + if (groups) { + modifiedPayload.groups = groups; } + return jwt.sign(modifiedPayload, jwt_secret, { algorithm: "HS256" }); } diff --git a/tests/unit/eventPost.test.ts b/tests/unit/eventPost.test.ts index af1d9785..96c23123 100644 --- a/tests/unit/eventPost.test.ts +++ b/tests/unit/eventPost.test.ts @@ -48,7 +48,7 @@ test("Sad path: Not authenticated", async () => { test("Sad path: Authenticated but not authorized", async () => { await app.ready(); - const testJwt = createJwt(undefined, "1"); + const testJwt = createJwt(undefined, ["1"]); const response = await supertest(app.server) .post("/api/v1/events") .set("Authorization", `Bearer ${testJwt}`) @@ -66,7 +66,7 @@ test("Sad path: Authenticated but not authorized", async () => { }); test("Sad path: Prevent empty body request", async () => { await app.ready(); - const testJwt = createJwt(undefined, "0"); + const testJwt = createJwt(undefined, ["0"]); const response = await supertest(app.server) .post("/api/v1/events") .set("Authorization", `Bearer ${testJwt}`) @@ -226,7 +226,7 @@ describe("ETag Lifecycle Tests", () => { Items: [], }); - const testJwt = createJwt(undefined, "0"); + const testJwt = createJwt(undefined, ["0"]); // 1. Check initial etag for all events is 0 const initialAllResponse = await app.inject({ @@ -312,7 +312,7 @@ describe("ETag Lifecycle Tests", () => { Items: [], }); - const testJwt = createJwt(undefined, "0"); + const testJwt = createJwt(undefined, ["0"]); // 1. Create an event const eventResponse = await supertest(app.server) @@ -412,7 +412,7 @@ describe("ETag Lifecycle Tests", () => { Items: [], }); - const testJwt = createJwt(undefined, "0"); + const testJwt = createJwt(undefined, ["0"]); // 1. Check initial etag for all events is 0 const initialAllResponse = await app.inject({ diff --git a/tests/unit/events.test.ts b/tests/unit/events.test.ts index 75f5256f..b6478a8b 100644 --- a/tests/unit/events.test.ts +++ b/tests/unit/events.test.ts @@ -50,7 +50,7 @@ test("ETag should increment after event creation", async () => { Items: [], }); - const testJwt = createJwt(undefined, "0"); + const testJwt = createJwt(undefined, ["0"]); // 1. Check initial etag for all events is 0 const initialAllResponse = await app.inject({ @@ -138,7 +138,7 @@ test("Should return 304 Not Modified when If-None-Match header matches ETag", as Items: [], }); - const testJwt = createJwt(undefined, "0"); + const testJwt = createJwt(undefined, ["0"]); // 1. First GET request to establish ETag const initialResponse = await app.inject({ @@ -188,7 +188,7 @@ test("Should return 304 Not Modified when If-None-Match header matches quoted ET Items: [], }); - const testJwt = createJwt(undefined, "0"); + const testJwt = createJwt(undefined, ["0"]); // 1. First GET request to establish ETag const initialResponse = await app.inject({ @@ -238,7 +238,7 @@ test("Should NOT return 304 when ETag has changed", async () => { Items: [], }); - const testJwt = createJwt(undefined, "0"); + const testJwt = createJwt(undefined, ["0"]); // 1. Initial GET to establish ETag const initialResponse = await app.inject({ @@ -313,7 +313,7 @@ test("Should handle 304 responses for individual event endpoints", async () => { ddbMock.on(PutItemCommand).resolves({}); // Create an event - const testJwt = createJwt(undefined, "0"); + const testJwt = createJwt(undefined, ["0"]); const eventResponse = await supertest(app.server) .post("/api/v1/events") .set("Authorization", `Bearer ${testJwt}`) diff --git a/tests/unit/linkry.test.ts b/tests/unit/linkry.test.ts index f1aa9333..43435040 100644 --- a/tests/unit/linkry.test.ts +++ b/tests/unit/linkry.test.ts @@ -1,4 +1,4 @@ -import { expect, test, vi } from "vitest"; +import { beforeEach, expect, test, vi } from "vitest"; import { DynamoDBClient, ScanCommand, @@ -15,6 +15,7 @@ import { import { secretJson, secretObject } from "./secret.testdata.js"; import supertest from "supertest"; +import { dynamoTableData } from "./mockLinkryData.testdata.js"; const ddbMock = mockClient(DynamoDBClient); const smMock = mockClient(SecretsManagerClient); @@ -48,9 +49,13 @@ smMock.on(GetSecretValueCommand).resolves({ SecretString: secretJson, }); -const testJwt = createJwt(undefined, "0", "test@gmail.com"); +const adminJwt = createJwt(undefined, ["LINKS_ADMIN"], "test@gmail.com"); -test("Happy path: Fetch all linkry redirects with proper roles", async () => { +beforeEach(() => { + ddbMock.reset(); +}); +// Get Link +test("Happy path: Fetch all linkry redirects with admin roles", async () => { ddbMock.on(QueryCommand).resolves({ Items: [], }); @@ -58,7 +63,7 @@ test("Happy path: Fetch all linkry redirects with proper roles", async () => { ddbMock .on(ScanCommand) .resolvesOnce({ - Items: [], + Items: dynamoTableData, }) .rejects(); @@ -66,15 +71,51 @@ test("Happy path: Fetch all linkry redirects with proper roles", async () => { method: "GET", url: "/api/v1/linkry/redir", headers: { - Authorization: `Bearer ${testJwt}`, + Authorization: `Bearer ${adminJwt}`, }, }); expect(response.statusCode).toBe(200); + let body = JSON.parse(response.body); + expect(body.ownedLinks).toEqual([]); +}); + +test("Happy path: Fetch all linkry redirects with admin roles owned", async () => { + const adminOwnedJwt = createJwt( + undefined, + ["LINKS_ADMIN"], + "bob@illinois.edu", + ); + + ddbMock.on(QueryCommand).resolves({ + Items: dynamoTableData, + }); + + ddbMock + .on(ScanCommand) + .resolvesOnce({ + Items: dynamoTableData, + }) + .rejects(); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/linkry/redir", + headers: { + Authorization: `Bearer ${adminOwnedJwt}`, + }, + }); + expect(response.statusCode).toBe(200); + let body = JSON.parse(response.body); + expect(body.delegatedLinks).toEqual([]); }); test("Make sure that a DB scan is only called for admins", async () => { - const testManagerJwt = createJwt(undefined, "999", "test@gmail.com"); + const testManagerJwt = createJwt( + undefined, + ["LINKS_MANAGER"], + "test@gmail.com", + ); ddbMock.on(QueryCommand).resolves({ Items: [], @@ -93,9 +134,15 @@ test("Make sure that a DB scan is only called for admins", async () => { expect(response.statusCode).toBe(200); }); -test("Happy path: Create a new linkry redirect", async () => { +//Create/Edit Link +test("Happy path: Create/Edit linkry redirect success", async () => { + const userJwt = createJwt( + undefined, + ["LINKS_MANAGER", "940e4f9e-6891-4e28-9e29-148798495cdb"], + "alice@illinois.edu", + ); ddbMock.on(QueryCommand).resolves({ - Items: [], + Items: dynamoTableData, }); ddbMock.on(TransactWriteItemsCommand).resolves({}); @@ -103,13 +150,239 @@ test("Happy path: Create a new linkry redirect", async () => { const payload = { access: [], redirect: "https://www.acm.illinois.edu/", - slug: "acm-test-slug", + slug: "WlQDmu", }; const response = await supertest(app.server) .post("/api/v1/linkry/redir") - .set("Authorization", `Bearer ${testJwt}`) + .set("Authorization", `Bearer ${userJwt}`) .send(payload); expect(response.statusCode).toBe(201); }); + +test("Unhappy path: Edit linkry redirect not authorized", async () => { + const userJwt = createJwt( + undefined, + ["LINKS_MANAGER", "IncorrectGroupID233"], + "alice@illinois.edu", + ); + ddbMock.on(QueryCommand).resolves({ + Items: dynamoTableData, + }); + + ddbMock.on(TransactWriteItemsCommand).resolves({}); + + const payload = { + access: [], + redirect: "https://www.acm.illinois.edu/", + slug: "WlQDmu", + }; + + const response = await supertest(app.server) + .post("/api/v1/linkry/redir") + .set("Authorization", `Bearer ${userJwt}`) + .send(payload); + + expect(response.statusCode).toBe(401); + expect(response.body.name).toEqual("UnauthorizedError"); +}); + +test("Unhappy path: Edit linkry time stamp mismatch", async () => { + const userJwt = createJwt(undefined, ["LINKS_ADMIN"], "alice@illinois.edu"); + ddbMock.on(QueryCommand).resolves({ + Items: [ + ...dynamoTableData, + { + slug: { + S: "WlQDmu", + }, + access: { + S: "GROUP#940e4f9e-6891-4e28-9e29-148798495cdb", + }, + createdAt: { + S: "2030-04-18T18:36:50.706Z", + }, + updatedAt: { + S: "2030-04-18T18:37:40.681Z", + }, + }, + ], + }); + + ddbMock.on(TransactWriteItemsCommand).resolves({}); + + const payload = { + access: [], + redirect: "https://www.acm.illinois.edu/", + slug: "WlQDmu", + }; + + const response = await supertest(app.server) + .post("/api/v1/linkry/redir") + .set("Authorization", `Bearer ${userJwt}`) + .send(payload); + + console.log(response); + expect(response.statusCode).toBe(400); + expect(response.body.name).toEqual("ValidationError"); +}); + +//Delete Link +test("Happy path: Delete linkry success", async () => { + const userJwt = createJwt( + undefined, + ["LINKS_MANAGER", "940e4f9e-6891-4e28-9e29-148798495cdb"], + "alice@illinois.edu", + ); + ddbMock.on(QueryCommand).resolves({ + Items: dynamoTableData, + }); + + ddbMock.on(TransactWriteItemsCommand).resolves({}); + + const response = await supertest(app.server) + .delete("/api/v1/linkry/redir/WLQDmu") + .set("Authorization", `Bearer ${userJwt}`); + + expect(response.statusCode).toBe(200); +}); + +test("Unhappy path: Delete linkry slug not found/invalid", async () => { + const userJwt = createJwt(undefined, ["LINKS_MANAGER"], "alice@illinois.edu"); + ddbMock.on(QueryCommand).resolves({ + Items: [], + }); + + ddbMock.on(TransactWriteItemsCommand).resolves({}); + + const response = await supertest(app.server) + .delete("/api/v1/linkry/redir/invalid") + .set("Authorization", `Bearer ${userJwt}`); + + expect(response.statusCode).toBe(404); + expect(response.body.name).toEqual("NotFoundError"); +}); + +test("Unhappy path: Delete linkry Invalid Access", async () => { + const userJwt = createJwt( + undefined, + ["LINKS_MANAGER", "InvalidGroupId22"], + "alice@illinois.edu", + ); + ddbMock.on(QueryCommand).resolves({ + Items: dynamoTableData, + }); + + ddbMock.on(TransactWriteItemsCommand).resolves({}); + + const response = await supertest(app.server) + .delete("/api/v1/linkry/redir/WLQDmu") + .set("Authorization", `Bearer ${userJwt}`); + + expect(response.statusCode).toBe(401); + expect(response.body.name).toEqual("UnauthorizedError"); +}); + +//Get Link by Slug +test("Happy path: Get Delegated Link by Slug Correct Access", async () => { + const userJwt = createJwt( + undefined, + ["LINKS_MANAGER", "940e4f9e-6891-4e28-9e29-148798495cdb"], + "cloud@illinois.edu", + ); + ddbMock.on(QueryCommand).resolves({ + Items: dynamoTableData, + }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/linkry/redir/WlQDmu", + headers: { + Authorization: `Bearer ${userJwt}`, + }, + }); + expect(response.statusCode).toBe(200); + let body = JSON.parse(response.body); + expect(body).toEqual({ + slug: "WlQDmu", + access: [ + "f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6", + "940e4f9e-6891-4e28-9e29-148798495cdb", + ], + createdAt: "2025-04-18T18:36:50.706Z", + redirect: "https://www.gmaill.com/", + updatedAt: "2025-04-18T18:37:40.681Z", + owner: "bob@illinois.edu", + }); +}); + +test("Happy path: Get Delegated Link by Slug Admin Access", async () => { + const userJwt = createJwt(undefined, ["LINKS_ADMIN"], "test@illinois.edu"); + ddbMock.on(QueryCommand).resolves({ + Items: dynamoTableData, + }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/linkry/redir/WlQDmu", + headers: { + Authorization: `Bearer ${userJwt}`, + }, + }); + expect(response.statusCode).toBe(200); + let body = JSON.parse(response.body); + expect(body).toEqual({ + slug: "WlQDmu", + access: [ + "f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6", + "940e4f9e-6891-4e28-9e29-148798495cdb", + ], + createdAt: "2025-04-18T18:36:50.706Z", + redirect: "https://www.gmaill.com/", + updatedAt: "2025-04-18T18:37:40.681Z", + owner: "bob@illinois.edu", + }); +}); + +test("Unhappy path: Get Delegated Link by Slug Incorrect Access", async () => { + const userJwt = createJwt( + undefined, + ["LINKS_MANAGER", "NotValidGroupId222"], + "cloud@illinois.edu", + ); + + ddbMock.on(QueryCommand).resolves({ + Items: dynamoTableData, + }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/linkry/redir/WlQDmu", + headers: { + Authorization: `Bearer ${userJwt}`, + }, + }); + expect(response.statusCode).toBe(401); + let body = JSON.parse(response.body); + expect(body.name).toEqual("UnauthorizedError"); +}); + +test("Unhappy path: Get Delegated Link by Slug Not Found", async () => { + const userJwt = createJwt(undefined, ["LINKS_ADMIN"], "cloud@illinois.edu"); + + ddbMock.on(QueryCommand).resolves({ + Items: [], + }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/linkry/redir/WlQDmu", + headers: { + Authorization: `Bearer ${userJwt}`, + }, + }); + expect(response.statusCode).toBe(404); + let body = JSON.parse(response.body); + expect(body.name).toEqual("NotFoundError"); +}); diff --git a/tests/unit/mockLinkryData.testdata.ts b/tests/unit/mockLinkryData.testdata.ts new file mode 100644 index 00000000..049779e4 --- /dev/null +++ b/tests/unit/mockLinkryData.testdata.ts @@ -0,0 +1,49 @@ +const linkry1 = { + slug: { + S: "WlQDmu", + }, + access: { + S: "OWNER#bob@illinois.edu", + }, + createdAt: { + S: "2025-04-18T18:36:50.706Z", + }, + redirect: { + S: "https://www.gmaill.com/", + }, + updatedAt: { + S: "2025-04-18T18:37:40.681Z", + }, +}; +const linkry2 = { + slug: { + S: "WlQDmu", + }, + access: { + S: "GROUP#f8dfc4cf-456b-4da3-9053-f7fdeda5d5d6", + }, + createdAt: { + S: "2025-04-18T18:36:50.706Z", + }, + updatedAt: { + S: "2025-04-18T18:37:40.681Z", + }, +}; +const linkry3 = { + slug: { + S: "WlQDmu", + }, + access: { + S: "GROUP#940e4f9e-6891-4e28-9e29-148798495cdb", + }, + createdAt: { + S: "2025-04-18T18:36:50.706Z", + }, + updatedAt: { + S: "2025-04-18T18:37:40.681Z", + }, +}; + +const dynamoTableData = [linkry1, linkry2, linkry3]; + +export { dynamoTableData }; diff --git a/tests/unit/mockPaidEventData.testdata.ts b/tests/unit/mockPaidEventData.testdata.ts new file mode 100644 index 00000000..b00b84d7 --- /dev/null +++ b/tests/unit/mockPaidEventData.testdata.ts @@ -0,0 +1,77 @@ +import { unmarshall } from "@aws-sdk/util-dynamodb"; + +const dynamoTableData = [ + { + event_id: { + S: "test_barcrawl", + }, + eventCost: { + M: { + others: { + N: "100", + }, + paid: { + N: "0", + }, + }, + }, + eventDetails: { + S: "Join ACM", + }, + eventImage: { + S: "img/test.png", + }, + eventProps: { + M: { + end: { + N: "", + }, + host: { + S: "", + }, + location: { + S: "", + }, + }, + }, + event_capacity: { + N: "130", + }, + event_name: { + S: "ACM Fall 2023 Bar Crawl", + }, + event_sales_active_utc: { + N: "0", + }, + event_time: { + N: "1699578000", + }, + member_price: { + S: "price_1O6zHhDiGOXU9RuSvlrcIfOv", + }, + nonmember_price: { + S: "price_1O6zHhDiGOXU9RuSvlrcIfOv", + }, + tickets_sold: { + N: "0", + }, + }, +]; + +const dynamoTableDataUnmarshalled = dynamoTableData.map((x: any) => { + const temp = unmarshall(x); + return temp; +}); + +const dynamoTableDataUnmarshalledUpcomingOnly = dynamoTableData + .map((x: any) => { + const temp = unmarshall(x); + return temp; + }) + .filter((x: any) => x.title != "Event in the past."); + +export { + dynamoTableData, + dynamoTableDataUnmarshalled, + dynamoTableDataUnmarshalledUpcomingOnly, +}; diff --git a/tests/unit/secret.testdata.ts b/tests/unit/secret.testdata.ts index 978bc25f..7a41efb4 100644 --- a/tests/unit/secret.testdata.ts +++ b/tests/unit/secret.testdata.ts @@ -26,6 +26,7 @@ const jwtPayload = { appidacr: "1", email: "infra-unit-test@acm.illinois.edu", groups: ["0"], + roles: ["manage:links", "admin:links"], // Add roles here idp: "https://login.microsoftonline.com/", ipaddr: "192.168.1.1", name: "John Doe", diff --git a/tests/unit/stripe.test.ts b/tests/unit/stripe.test.ts index 05c61f40..4dca8056 100644 --- a/tests/unit/stripe.test.ts +++ b/tests/unit/stripe.test.ts @@ -234,7 +234,7 @@ describe("Test Stripe link creation", async () => { }); const testJwt = createJwt( undefined, - "1", + ["1"], "infra-unit-test-stripeonly@acm.illinois.edu", ); const response = await supertest(app.server) diff --git a/tests/unit/tickets.test.ts b/tests/unit/tickets.test.ts index cff62706..dfce8677 100644 --- a/tests/unit/tickets.test.ts +++ b/tests/unit/tickets.test.ts @@ -96,7 +96,7 @@ describe("Test getting ticketing + merch metadata", async () => { Items: ticketsMetadata as Record[], }) .rejects(); - const testJwt = createJwt(undefined, "scanner-only"); + const testJwt = createJwt(undefined, ["scanner-only"]); await app.ready(); const response = await supertest(app.server) .get("/api/v1/tickets") diff --git a/tests/unit/vitest.setup.ts b/tests/unit/vitest.setup.ts index da95f650..80baf19a 100644 --- a/tests/unit/vitest.setup.ts +++ b/tests/unit/vitest.setup.ts @@ -42,9 +42,9 @@ vi.mock( const mockGroupRoles = { "0": allAppRoles, "1": [], - LINKS_ADMIN: [AppRoles.LINKS_ADMIN], "scanner-only": [AppRoles.TICKETS_SCANNER], - "999": [AppRoles.LINKS_MANAGER], + LINKS_ADMIN: [AppRoles.LINKS_ADMIN], + LINKS_MANAGER: [AppRoles.LINKS_MANAGER], }; return mockGroupRoles[groupId] || [];