diff --git a/src/api/components/index.ts b/src/api/components/index.ts index 87635e26..73f8bc8c 100644 --- a/src/api/components/index.ts +++ b/src/api/components/index.ts @@ -32,8 +32,12 @@ export function withRoles( schema: T, ): T & RoleSchema { return { + security: [{ bearerAuth: [] }], "x-required-roles": roles, - description: `Requires one of the following roles: ${roles.join(", ")}.${schema.description ? "\n\n" + schema.description : ""}`, + description: + roles.length > 0 + ? `Requires one of the following roles: ${roles.join(", ")}.${schema.description ? "\n\n" + schema.description : ""}` + : "Requires valid authentication but no specific role.", ...schema, }; } diff --git a/src/api/index.ts b/src/api/index.ts index f7dda5bd..774898fc 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -165,6 +165,17 @@ async function init(prettyPrint: boolean = false) { }, ], openapi: "3.0.3" satisfies ZodOpenApiVersion, // If this is not specified, it will default to 3.1.0 + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: + "Authorization: Bearer {token}\n\nThis API uses JWT tokens issued by Entra ID (Azure AD) with the Core API audience. Tokens must be included in the Authorization header as a Bearer token for all protected endpoints.", + }, + }, + }, }, transform: fastifyZodOpenApiTransform, transformObject: fastifyZodOpenApiTransformObject, diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index 5f1e4a15..3c58d9e4 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -40,7 +40,7 @@ import { serializerCompiler, validatorCompiler, } from "fastify-zod-openapi"; -import { ts, withTags } from "api/components/index.js"; +import { ts, withRoles, withTags } from "api/components/index.js"; const repeatOptions = ["weekly", "biweekly"] as const; export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${EVENT_CACHED_DURATION}, stale-while-revalidate=420, stale-if-error=3600`; @@ -221,26 +221,27 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( fastify.withTypeProvider().post( "/:id?", { - schema: withTags(["Events"], { - // response: { - // 201: z.object({ - // id: z.string(), - // resource: z.string(), - // }), - // }, - body: postRequestSchema, - params: z.object({ - id: z.string().min(1).optional().openapi({ - description: - "Event ID to modify (leave empty to create a new event).", - example: "6667e095-8b04-4877-b361-f636f459ba42", + schema: withRoles( + [AppRoles.EVENTS_MANAGER], + withTags(["Events"], { + // response: { + // 201: z.object({ + // id: z.string(), + // resource: z.string(), + // }), + // }, + body: postRequestSchema, + params: z.object({ + id: z.string().min(1).optional().openapi({ + description: + "Event ID to modify (leave empty to create a new event).", + example: "6667e095-8b04-4877-b361-f636f459ba42", + }), }), + summary: "Modify a calendar event.", }), - summary: "Modify a calendar event.", - }) satisfies FastifyZodOpenApiSchema, - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]); - }, + ) satisfies FastifyZodOpenApiSchema, + onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { if (!request.username) { @@ -361,24 +362,25 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( fastify.withTypeProvider().delete( "/:id", { - schema: withTags(["Events"], { - params: z.object({ - id: z.string().min(1).openapi({ - description: "Event ID to delete.", - example: "6667e095-8b04-4877-b361-f636f459ba42", + schema: withRoles( + [AppRoles.EVENTS_MANAGER], + withTags(["Events"], { + params: z.object({ + id: z.string().min(1).openapi({ + description: "Event ID to delete.", + example: "6667e095-8b04-4877-b361-f636f459ba42", + }), }), + // response: { + // 201: z.object({ + // id: z.string(), + // resource: z.string(), + // }), + // }, + summary: "Delete a calendar event.", }), - // response: { - // 201: z.object({ - // id: z.string(), - // resource: z.string(), - // }), - // }, - summary: "Delete a calendar event.", - }) satisfies FastifyZodOpenApiSchema, - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.EVENTS_MANAGER]); - }, + ) satisfies FastifyZodOpenApiSchema, + onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { const id = request.params.id; diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 6a149664..0f6d9725 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -79,13 +79,14 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.withTypeProvider().patch( "/profile", { - schema: withTags(["IAM"], { - body: entraProfilePatchRequest, - summary: "Update user's profile.", - }), - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, []); - }, + schema: withRoles( + [], + withTags(["IAM"], { + body: entraProfilePatchRequest, + summary: "Update user's profile.", + }), + ), + onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { if (!request.tokenPayload || !request.username) { diff --git a/src/api/routes/protected.ts b/src/api/routes/protected.ts index e6a9269f..d75bbe23 100644 --- a/src/api/routes/protected.ts +++ b/src/api/routes/protected.ts @@ -1,6 +1,6 @@ import { FastifyPluginAsync } from "fastify"; import rateLimiter from "api/plugins/rateLimiter.js"; -import { withTags } from "api/components/index.js"; +import { withRoles, withTags } from "api/components/index.js"; const protectedRoute: FastifyPluginAsync = async (fastify, _options) => { await fastify.register(rateLimiter, { @@ -11,9 +11,12 @@ const protectedRoute: FastifyPluginAsync = async (fastify, _options) => { fastify.get( "", { - schema: withTags(["Generic"], { - summary: "Get a user's username and roles.", - }), + schema: withRoles( + [], + withTags(["Generic"], { + summary: "Get a user's username and roles.", + }), + ), }, async (request, reply) => { const roles = await fastify.authorize(request, reply, []); diff --git a/src/api/routes/roomRequests.ts b/src/api/routes/roomRequests.ts index 2eedce27..8902d2e1 100644 --- a/src/api/routes/roomRequests.ts +++ b/src/api/routes/roomRequests.ts @@ -27,7 +27,7 @@ import { genericConfig, notificationRecipients } from "common/config.js"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; -import { withTags } from "api/components/index.js"; +import { withRoles, withTags } from "api/components/index.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { z } from "zod"; @@ -37,29 +37,27 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { duration: 30, rateLimitIdentifier: "roomRequests", }); - fastify.post<{ - Body: RoomRequestStatusUpdatePostBody; - Params: { requestId: string; semesterId: string }; - }>( + fastify.withTypeProvider().post( "/:semesterId/:requestId/status", { - schema: withTags(["Room Requests"], { - summary: "Create status update for a room request.", - params: z.object({ - requestId: z.string().min(1).openapi({ - description: "Room request ID.", - example: "6667e095-8b04-4877-b361-f636f459ba42", - }), - semesterId: z.string().min(1).openapi({ - description: "Short semester slug for a given semester.", - example: "sp25", + schema: withRoles( + [AppRoles.ROOM_REQUEST_UPDATE], + withTags(["Room Requests"], { + summary: "Create status update for a room request.", + params: z.object({ + requestId: z.string().min(1).openapi({ + description: "Room request ID.", + example: "6667e095-8b04-4877-b361-f636f459ba42", + }), + semesterId: z.string().min(1).openapi({ + description: "Short semester slug for a given semester.", + example: "sp25", + }), }), + body: roomRequestStatusUpdateRequest, }), - body: roomRequestStatusUpdateRequest, - }), - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_UPDATE]); - }, + ), + onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { if (!request.username) { @@ -154,18 +152,19 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.withTypeProvider().get( "/:semesterId", { - schema: withTags(["Room Requests"], { - summary: "Get room requests for a specific semester.", - params: z.object({ - semesterId: z.string().min(1).openapi({ - description: "Short semester slug for a given semester.", - example: "sp25", + schema: withRoles( + [AppRoles.ROOM_REQUEST_CREATE], + withTags(["Room Requests"], { + summary: "Get room requests for a specific semester.", + params: z.object({ + semesterId: z.string().min(1).openapi({ + description: "Short semester slug for a given semester.", + example: "sp25", + }), }), }), - }), - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]); - }, + ), + onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { const semesterId = request.params.semesterId; @@ -252,13 +251,14 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.withTypeProvider().post( "", { - schema: withTags(["Room Requests"], { - summary: "Create a room request.", - body: roomRequestSchema, - }), - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]); - }, + schema: withRoles( + [AppRoles.ROOM_REQUEST_CREATE], + withTags(["Room Requests"], { + summary: "Create a room request.", + body: roomRequestSchema, + }), + ), + onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { const requestId = request.id; @@ -350,22 +350,23 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.withTypeProvider().get( "/:semesterId/:requestId", { - schema: withTags(["Room Requests"], { - summary: "Get specific room request data.", - params: z.object({ - requestId: z.string().min(1).openapi({ - description: "Room request ID.", - example: "6667e095-8b04-4877-b361-f636f459ba42", - }), - semesterId: z.string().min(1).openapi({ - description: "Short semester slug for a given semester.", - example: "sp25", + schema: withRoles( + [AppRoles.ROOM_REQUEST_CREATE], + withTags(["Room Requests"], { + summary: "Get specific room request data.", + params: z.object({ + requestId: z.string().min(1).openapi({ + description: "Room request ID.", + example: "6667e095-8b04-4877-b361-f636f459ba42", + }), + semesterId: z.string().min(1).openapi({ + description: "Short semester slug for a given semester.", + example: "sp25", + }), }), }), - }), - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.ROOM_REQUEST_CREATE]); - }, + ), + onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { const requestId = request.params.requestId; diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index 8b591739..781ab406 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -4,7 +4,7 @@ import { ScanCommand, } from "@aws-sdk/client-dynamodb"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; -import { withTags } from "api/components/index.js"; +import { withRoles, withTags } from "api/components/index.js"; import { createAuditLogEntry } from "api/functions/auditLog.js"; import { createStripeLink, @@ -36,12 +36,13 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.withTypeProvider().get( "/paymentLinks", { - schema: withTags(["Stripe"], { - summary: "Get available Stripe payment links.", - }), - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.STRIPE_LINK_CREATOR]); - }, + schema: withRoles( + [AppRoles.STRIPE_LINK_CREATOR], + withTags(["Stripe"], { + summary: "Get available Stripe payment links.", + }), + ), + onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { let dynamoCommand; @@ -91,13 +92,14 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.withTypeProvider().post( "/paymentLinks", { - schema: withTags(["Stripe"], { - summary: "Create a Stripe payment link.", - body: invoiceLinkPostRequestSchema, - }), - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.STRIPE_LINK_CREATOR]); - }, + schema: withRoles( + [AppRoles.STRIPE_LINK_CREATOR], + withTags(["Stripe"], { + summary: "Create a Stripe payment link.", + body: invoiceLinkPostRequestSchema, + }), + ), + onRequest: fastify.authorizeFromSchema, }, async (request, reply) => { if (!request.username) { diff --git a/tests/live/documentation.test.ts b/tests/live/documentation.test.ts new file mode 100644 index 00000000..3a427316 --- /dev/null +++ b/tests/live/documentation.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; + +const baseEndpoint = `https://core.aws.qa.acmuiuc.org`; + +test("Get OpenAPI JSON", async () => { + const response = await fetch(`${baseEndpoint}/api/documentation/json`); + expect(response.status).toBe(200); + + const responseDataJson = await response.json(); + expect(responseDataJson).toHaveProperty("openapi"); + expect(responseDataJson["openapi"]).toEqual("3.0.3"); +}); + +test("Get OpenAPI UI", async () => { + const response = await fetch(`${baseEndpoint}/api/documentation`); + expect(response.status).toBe(200); + const contentType = response.headers.get("content-type"); + expect(contentType).toContain("text/html"); +}); diff --git a/tests/unit/documentation.test.ts b/tests/unit/documentation.test.ts new file mode 100644 index 00000000..f1ab51ee --- /dev/null +++ b/tests/unit/documentation.test.ts @@ -0,0 +1,30 @@ +import { afterAll, expect, test } from "vitest"; +import init from "../../src/api/index.js"; + +const app = await init(); +test("Test getting OpenAPI JSON", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/documentation/json", + }); + expect(response.statusCode).toBe(200); + const responseDataJson = await response.json(); + expect(responseDataJson).toHaveProperty("openapi"); + expect(responseDataJson["openapi"]).toEqual("3.0.3"); +}); +afterAll(async () => { + await app.close(); +}); + +test("Test getting OpenAPI UI", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/documentation", + }); + expect(response.statusCode).toBe(200); + const contentType = response.headers["content-type"]; + expect(contentType).toContain("text/html"); +}); +afterAll(async () => { + await app.close(); +});