diff --git a/infracost-usage.yml b/infracost-usage.yml index 52e70f83..9ac255c8 100644 --- a/infracost-usage.yml +++ b/infracost-usage.yml @@ -286,9 +286,9 @@ resource_type_default_usage: spectrum_data_scanned_tb: 1 backup_storage_gb: 217 aws_route53_record: - monthly_standard_queries: 12500000 # Monthly number of Standard queries. - monthly_latency_based_queries: 8333333 # Monthly number of Latency Based Routing queries. - monthly_geo_queries: 7142857 # Monthly number of Geo DNS and Geoproximity queries. + monthly_standard_queries: 1250000 # Monthly number of Standard queries. + monthly_latency_based_queries: 0 # Monthly number of Latency Based Routing queries. + monthly_geo_queries: 0 # Monthly number of Geo DNS and Geoproximity queries. aws_route53_resolver_endpoint: monthly_queries: 12500000 # Monthly number of DNS queries processed through the endpoints. aws_s3_bucket_analytics_configuration: diff --git a/src/api/functions/organizations.ts b/src/api/functions/organizations.ts new file mode 100644 index 00000000..1c3e8938 --- /dev/null +++ b/src/api/functions/organizations.ts @@ -0,0 +1,155 @@ +import { AllOrganizationList } from "@acm-uiuc/js-shared"; +import { QueryCommand, type DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { genericConfig } from "common/config.js"; +import { + BaseError, + DatabaseFetchError, + ValidationError, +} from "common/errors/index.js"; +import { OrgRole, orgRoles } from "common/roles.js"; +import { getOrganizationInfoResponse } from "common/types/organizations.js"; +import { type FastifyBaseLogger } from "fastify"; +import pino from "pino"; +import z from "zod"; + +export interface GetOrgInfoInputs { + id: string; + dynamoClient: DynamoDBClient; + logger: FastifyBaseLogger | pino.Logger; +} + +export interface GetUserOrgRolesInputs { + username: string; + dynamoClient: DynamoDBClient; + logger: FastifyBaseLogger | pino.Logger; +} + +export async function getOrgInfo({ + id, + dynamoClient, + logger, +}: GetOrgInfoInputs) { + const query = new QueryCommand({ + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: `primaryKey = :definitionId`, + ExpressionAttributeValues: { + ":definitionId": { S: `DEFINE#${id}` }, + }, + }); + let response = { leads: [] } as { + leads: { name: string; username: string; title: string | undefined }[]; + }; + try { + const responseMarshall = await dynamoClient.send(query); + if (!responseMarshall.Items || responseMarshall.Items.length === 0) { + logger.debug( + `Could not find SIG information for ${id}, returning default.`, + ); + return { id }; + } + const temp = unmarshall(responseMarshall.Items[0]); + temp.id = temp.primaryKey.replace("DEFINE#", ""); + delete temp.primaryKey; + response = { ...temp, ...response }; + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + logger.error(e); + throw new DatabaseFetchError({ + message: "Failed to get org metadata.", + }); + } + // Get leads + const leadsQuery = new QueryCommand({ + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :leadName", + ExpressionAttributeValues: { + ":leadName": { S: `LEAD#${id}` }, + }, + }); + try { + const responseMarshall = await dynamoClient.send(leadsQuery); + if (responseMarshall.Items) { + const unmarshalledLeads = responseMarshall.Items.map((x) => unmarshall(x)) + .filter((x) => x.username) + .map( + (x) => + ({ + name: x.name, + username: x.username, + title: x.title, + }) as { name: string; username: string; title: string | undefined }, + ); + response = { ...response, leads: unmarshalledLeads }; + } + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + logger.error(e); + throw new DatabaseFetchError({ + message: "Failed to get org leads.", + }); + } + return response as z.infer; +} + +export async function getUserOrgRoles({ + username, + dynamoClient, + logger, +}: GetUserOrgRolesInputs) { + const query = new QueryCommand({ + TableName: genericConfig.SigInfoTableName, + IndexName: "UsernameIndex", + KeyConditionExpression: `username = :username`, + ExpressionAttributeValues: { + ":username": { S: username }, + }, + }); + try { + const response = await dynamoClient.send(query); + if (!response.Items) { + return []; + } + const unmarshalled = response.Items.map((x) => unmarshall(x)).map( + (x) => + ({ username: x.username, rawRole: x.primaryKey }) as { + username: string; + rawRole: string; + }, + ); + const cleanedRoles = []; + for (const item of unmarshalled) { + const splits = item.rawRole.split("#"); + if (splits.length !== 2) { + logger.warn(`Invalid PK in role definition: ${JSON.stringify(item)}`); + continue; + } + const [role, org] = splits; + if (!orgRoles.includes(role as OrgRole)) { + logger.warn(`Invalid role in role definition: ${JSON.stringify(item)}`); + continue; + } + if (!AllOrganizationList.includes(org)) { + logger.warn(`Invalid org in role definition: ${JSON.stringify(item)}`); + continue; + } + cleanedRoles.push({ + org, + role, + } as { org: (typeof AllOrganizationList)[number]; role: OrgRole }); + } + return cleanedRoles; + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + logger.error(e); + throw new DatabaseFetchError({ + message: "Could not get roles for user.", + }); + } +} diff --git a/src/api/package.json b/src/api/package.json index f8cd6941..a9a91075 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -27,7 +27,6 @@ "@azure/msal-node": "^3.5.1", "@fastify/auth": "^5.0.1", "@fastify/aws-lambda": "^6.0.0", - "@fastify/caching": "^9.0.1", "@fastify/cors": "^11.0.1", "@fastify/static": "^8.1.1", "@fastify/swagger": "^9.5.0", diff --git a/src/api/plugins/verifyUserOrgRole.ts b/src/api/plugins/verifyUserOrgRole.ts new file mode 100644 index 00000000..1309fab6 --- /dev/null +++ b/src/api/plugins/verifyUserOrgRole.ts @@ -0,0 +1,43 @@ +import { getUserOrgRoles } from "api/functions/organizations.js"; +import { UnauthorizedError } from "common/errors/index.js"; +import { OrgRoleDefinition } from "common/roles.js"; +import { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; +import fp from "fastify-plugin"; + +const orgRolePlugin: FastifyPluginAsync = async (fastify, _options) => { + fastify.decorate( + "verifyOrgRole", + async ( + request: FastifyRequest, + _reply: FastifyReply, + validOrgRoles: OrgRoleDefinition[], + ) => { + const username = request.username; + if (!username) { + throw new UnauthorizedError({ + message: "Could not determine user identity.", + }); + } + const userRoles = await getUserOrgRoles({ + username, + dynamoClient: fastify.dynamoClient, + logger: request.log, + }); + let isAuthorized = false; + for (const role of userRoles) { + if (validOrgRoles.includes(role)) { + isAuthorized = true; + break; + } + } + if (!isAuthorized) { + throw new UnauthorizedError({ + message: "User does not have the required role in this organization.", + }); + } + }, + ); +}; + +const fastifyOrgRolePlugin = fp(orgRolePlugin); +export default fastifyOrgRolePlugin; diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts index 4c9a25da..c80e8d30 100644 --- a/src/api/routes/membership.ts +++ b/src/api/routes/membership.ts @@ -44,37 +44,6 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { global: false, runFirst: true, }); - const getAuthorizedClients = async () => { - if (roleArns.Entra) { - fastify.log.info( - `Attempting to assume Entra role ${roleArns.Entra} to get the Entra token...`, - ); - const credentials = await getRoleCredentials(roleArns.Entra); - const clients = { - smClient: new SecretsManagerClient({ - region: genericConfig.AwsRegion, - credentials, - }), - dynamoClient: new DynamoDBClient({ - region: genericConfig.AwsRegion, - credentials, - }), - redisClient: fastify.redisClient, - }; - fastify.log.info( - `Assumed Entra role ${roleArns.Entra} to get the Entra token.`, - ); - return clients; - } - fastify.log.debug( - "Did not assume Entra role as no env variable was present", - ); - return { - smClient: fastify.secretsManagerClient, - dynamoClient: fastify.dynamoClient, - redisClient: fastify.redisClient, - }; - }; const limitedRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(rateLimiter, { limit: 20, diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index 09a419c5..5cfe694f 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -1,42 +1,105 @@ import { FastifyPluginAsync } from "fastify"; import { AllOrganizationList } from "@acm-uiuc/js-shared"; -import fastifyCaching from "@fastify/caching"; import rateLimiter from "api/plugins/rateLimiter.js"; import { withTags } from "api/components/index.js"; import { z } from "zod/v4"; +import { getOrganizationInfoResponse } from "common/types/organizations.js"; +import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; +import { BaseError, DatabaseFetchError } from "common/errors/index.js"; +import { getOrgInfo } from "api/functions/organizations.js"; + +export const ORG_DATA_CACHED_DURATION = 300; +export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${ORG_DATA_CACHED_DURATION}, stale-while-revalidate=${Math.floor(ORG_DATA_CACHED_DURATION * 1.1)}, stale-if-error=3600`; const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { - fastify.register(fastifyCaching, { - privacy: fastifyCaching.privacy.PUBLIC, - serverExpiresIn: 60 * 60 * 4, - expiresIn: 60 * 60 * 4, - }); fastify.register(rateLimiter, { limit: 60, duration: 60, rateLimitIdentifier: "organizations", }); + fastify.addHook("onSend", async (request, reply, payload) => { + if (request.method === "GET") { + reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY); + } + return payload; + }); fastify.get( "", { - schema: withTags(["Generic"], { - summary: "Get a list of ACM @ UIUC sub-organizations.", + schema: withTags(["Organizations"], { + summary: "Get info for all of ACM @ UIUC's sub-organizations.", + response: { + 200: { + description: "List of ACM @ UIUC sub-organizations and info.", + content: { + "application/json": { + schema: z.array(getOrganizationInfoResponse), + }, + }, + }, + }, + }), + }, + async (request, reply) => { + const promises = AllOrganizationList.map((x) => + getOrgInfo({ + id: x, + dynamoClient: fastify.dynamoClient, + logger: request.log, + }), + ); + try { + const data = await Promise.allSettled(promises); + const successOnly = data + .filter((x) => x.status === "fulfilled") + .map((x) => x.value); + // return just the ID for anything not in the DB. + const successIds = successOnly.map((x) => x.id); + const unknownIds = AllOrganizationList.filter( + (x) => !successIds.includes(x), + ).map((x) => ({ id: x })); + return reply.send([...successOnly, ...unknownIds]); + } catch (e) { + if (e instanceof BaseError) { + throw e; + } + request.log.error(e); + throw new DatabaseFetchError({ + message: "Failed to get org information.", + }); + } + }, + ); + fastify.withTypeProvider().get( + "/:id", + { + schema: withTags(["Organizations"], { + summary: + "Get information about a specific ACM @ UIUC sub-organization.", + params: z.object({ + id: z + .enum(AllOrganizationList) + .meta({ description: "ACM @ UIUC organization to query." }), + }), response: { 200: { - description: "List of ACM @ UIUC sub-organizations.", + description: "ACM @ UIUC sub-organization info.", content: { "application/json": { - schema: z - .array(z.enum(AllOrganizationList)) - .default(AllOrganizationList), + schema: getOrganizationInfoResponse, }, }, }, }, }), }, - async (_request, reply) => { - reply.send(AllOrganizationList); + async (request, reply) => { + const response = await getOrgInfo({ + id: request.params.id, + dynamoClient: fastify.dynamoClient, + logger: request.log, + }); + return reply.send(response); }, ); }; diff --git a/src/common/config.ts b/src/common/config.ts index 411f5aa8..57a3d788 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -55,6 +55,7 @@ export type GenericConfigType = { UinHashingSecret: string; UinExtendedAttributeName: string; UserInfoTable: string; + SigInfoTableName: string; }; type EnvironmentConfigType = { @@ -94,7 +95,8 @@ const genericConfig: GenericConfigType = { TestingCredentialsSecret: "infra-core-api-testing-credentials", UinHashingSecret: "infra-core-api-uin-pepper", UinExtendedAttributeName: "extension_a70c2e1556954056a6a8edfb1f42f556_uiucEduUIN", - UserInfoTable: "infra-core-api-user-info" + UserInfoTable: "infra-core-api-user-info", + SigInfoTableName: "infra-core-api-sigs" } as const; const environmentConfig: EnvironmentConfigType = { diff --git a/src/common/roles.ts b/src/common/roles.ts index cf3ec8f5..a7e93d7d 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -1,3 +1,5 @@ +import { AllOrganizationList } from "@acm-uiuc/js-shared"; + /* eslint-disable import/prefer-default-export */ export const runEnvironments = ["dev", "prod"] as const; export type RunEnvironment = (typeof runEnvironments)[number]; @@ -17,8 +19,16 @@ export enum AppRoles { MANAGE_ORG_API_KEYS = "manage:orgApiKey", VIEW_INTERNAL_MEMBERSHIP_LIST = "view:internalMembershipList", VIEW_EXTERNAL_MEMBERSHIP_LIST = "view:externalMembershipList", - MANAGE_EXTERNAL_MEMBERSHIP_LIST = "manage:externalMembershipList" + MANAGE_EXTERNAL_MEMBERSHIP_LIST = "manage:externalMembershipList", + ALL_ORG_MANAGER = "manage:orgDefinitions" +} +export const orgRoles = ["LEAD", "MEMBER"] as const; +export type OrgRole = typeof orgRoles[number]; +export type OrgRoleDefinition = { + org: typeof AllOrganizationList[number], + role: OrgRole } + export const allAppRoles = Object.values(AppRoles).filter( (value) => typeof value === "string", ); @@ -40,4 +50,5 @@ export const AppRoleHumanMapper: Record = { [AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST]: "Internal Membership List Viewer", [AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Viewer", [AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Manager", + [AppRoles.ALL_ORG_MANAGER]: "Organization Definition Manager", } diff --git a/src/common/types/organizations.ts b/src/common/types/organizations.ts new file mode 100644 index 00000000..2b4a04a1 --- /dev/null +++ b/src/common/types/organizations.ts @@ -0,0 +1,24 @@ +import { AllOrganizationList } from "@acm-uiuc/js-shared"; +import { z } from "zod/v4"; + + +export const orgLeadEntry = z.object({ + name: z.optional(z.string()), + username: z.email(), + title: z.optional(z.string()) +}) + +export const validOrgLinkTypes = ["DISCORD", "CAMPUSWIRE", "SLACK", "NOTION", "MATRIX", "OTHER"] as const as [string, ...string[]]; + +export const orgLinkEntry = z.object({ + type: z.enum(validOrgLinkTypes), + url: z.url() +}) + +export const getOrganizationInfoResponse = z.object({ + id: z.enum(AllOrganizationList), + description: z.optional(z.string()), + website: z.optional(z.url()), + leads: z.optional(z.array(orgLeadEntry)), + links: z.optional(z.array(orgLinkEntry)) +}) diff --git a/terraform/envs/prod/main.tf b/terraform/envs/prod/main.tf index 4989d5b1..698cc31d 100644 --- a/terraform/envs/prod/main.tf +++ b/terraform/envs/prod/main.tf @@ -116,6 +116,14 @@ module "frontend" { LinkryKvArn = aws_cloudfront_key_value_store.linkry_kv.arn } +module "assets" { + source = "../../modules/assets" + BucketPrefix = local.bucket_prefix + AssetsPublicDomain = var.AssetsPublicDomain + ProjectId = var.ProjectId + CoreCertificateArn = var.CoreCertificateArn +} + resource "aws_lambda_event_source_mapping" "queue_consumer" { depends_on = [module.lambdas, module.sqs_queues] for_each = local.queue_arns diff --git a/terraform/envs/prod/variables.tf b/terraform/envs/prod/variables.tf index 7edf08cd..f64e25da 100644 --- a/terraform/envs/prod/variables.tf +++ b/terraform/envs/prod/variables.tf @@ -19,6 +19,12 @@ variable "PrioritySNSAlertArn" { } +variable "AssetsPublicDomain" { + type = string + default = "assets.acm.illinois.edu" +} + + variable "CoreCertificateArn" { type = string default = "arn:aws:acm:us-east-1:298118738376:certificate/aeb93d9e-b0b7-4272-9c12-24ca5058c77e" diff --git a/terraform/envs/qa/main.tf b/terraform/envs/qa/main.tf index 44136506..84505781 100644 --- a/terraform/envs/qa/main.tf +++ b/terraform/envs/qa/main.tf @@ -118,7 +118,26 @@ module "frontend" { LinkryPublicDomain = var.LinkryPublicDomain LinkryKvArn = aws_cloudfront_key_value_store.linkry_kv.arn } + +module "assets" { + source = "../../modules/assets" + BucketPrefix = local.bucket_prefix + AssetsPublicDomain = var.AssetsPublicDomain + ProjectId = var.ProjectId + CoreCertificateArn = var.CoreCertificateArn +} // QA only - setup Route 53 records +resource "aws_route53_record" "assets" { + for_each = toset(["A", "AAAA"]) + zone_id = "Z04502822NVIA85WM2SML" + type = each.key + name = var.AssetsPublicDomain + alias { + name = module.assets.main_cloudfront_domain_name + zone_id = "Z2FDTNDATAQYW2" + evaluate_target_health = false + } +} resource "aws_route53_record" "frontend" { for_each = toset(["A", "AAAA"]) zone_id = "Z04502822NVIA85WM2SML" diff --git a/terraform/envs/qa/variables.tf b/terraform/envs/qa/variables.tf index a1848b67..83f2e278 100644 --- a/terraform/envs/qa/variables.tf +++ b/terraform/envs/qa/variables.tf @@ -13,6 +13,13 @@ variable "CoreCertificateArn" { default = "arn:aws:acm:us-east-1:427040638965:certificate/63ccdf0b-d2b5-44f0-b589-eceffb935c23" } + +variable "AssetsPublicDomain" { + type = string + default = "assets.aws.qa.acmuiuc.org" +} + + variable "CorePublicDomain" { type = string default = "core.aws.qa.acmuiuc.org" diff --git a/terraform/modules/assets/main.tf b/terraform/modules/assets/main.tf new file mode 100644 index 00000000..3ef89904 --- /dev/null +++ b/terraform/modules/assets/main.tf @@ -0,0 +1,104 @@ +resource "aws_s3_bucket" "this" { + bucket = "${var.BucketPrefix}-${var.ProjectId}-assets" +} + +resource "aws_s3_bucket_lifecycle_configuration" "this" { + bucket = aws_s3_bucket.this.id + + rule { + id = "AbortIncompleteMultipartUploads" + status = "Enabled" + + abort_incomplete_multipart_upload { + days_after_initiation = 1 + } + } + + rule { + id = "ObjectLifecycle" + status = "Enabled" + + filter {} + + transition { + days = 30 + storage_class = "INTELLIGENT_TIERING" + } + + noncurrent_version_transition { + noncurrent_days = 30 + storage_class = "STANDARD_IA" + } + + noncurrent_version_expiration { + noncurrent_days = 60 + } + } +} + + +resource "aws_cloudfront_distribution" "this" { + http_version = "http2and3" + origin { + origin_id = "S3Bucket" + origin_access_control_id = aws_cloudfront_origin_access_control.this.id + domain_name = aws_s3_bucket.this.bucket_regional_domain_name + } + aliases = [var.AssetsPublicDomain] + enabled = true + is_ipv6_enabled = true + default_cache_behavior { + compress = true + target_origin_id = "S3Bucket" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # caching-optimized + } + viewer_certificate { + acm_certificate_arn = var.CoreCertificateArn + minimum_protocol_version = "TLSv1.2_2021" + ssl_support_method = "sni-only" + } + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + price_class = "PriceClass_100" +} + +resource "aws_cloudfront_origin_access_control" "this" { + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" + name = "${var.ProjectId}-assets-oac" +} + +resource "aws_s3_bucket_policy" "frontend_bucket_policy" { + bucket = aws_s3_bucket.this.id + policy = jsonencode(({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow", + Principal = { + Service = "cloudfront.amazonaws.com" + }, + Action = "s3:GetObject", + Resource = "${aws_s3_bucket.this.arn}/*" + Condition = { + StringEquals = { + "AWS:SourceArn" = aws_cloudfront_distribution.this.arn + } + } + }, + ] + + })) +} + +output "main_cloudfront_domain_name" { + value = aws_cloudfront_distribution.this.domain_name +} diff --git a/terraform/modules/assets/variables.tf b/terraform/modules/assets/variables.tf new file mode 100644 index 00000000..5f11a309 --- /dev/null +++ b/terraform/modules/assets/variables.tf @@ -0,0 +1,18 @@ +variable "ProjectId" { + type = string + description = "Prefix before each resource" +} + +variable "BucketPrefix" { + type = string +} + +variable "CoreCertificateArn" { + type = string + description = "Core ACM ARN" +} + +variable "AssetsPublicDomain" { + type = string + description = "Core Assets Public Host" +} diff --git a/terraform/modules/dynamo/main.tf b/terraform/modules/dynamo/main.tf index cdd5f491..6bb42abc 100644 --- a/terraform/modules/dynamo/main.tf +++ b/terraform/modules/dynamo/main.tf @@ -300,3 +300,32 @@ resource "aws_dynamodb_table" "cache" { enabled = true } } + +resource "aws_dynamodb_table" "sig_info" { + billing_mode = "PAY_PER_REQUEST" + name = "${var.ProjectId}-sigs" + deletion_protection_enabled = true + hash_key = "primaryKey" + range_key = "entryId" + point_in_time_recovery { + enabled = false + } + attribute { + name = "primaryKey" + type = "S" + } + attribute { + name = "entryId" + type = "S" + } + attribute { + name = "username" + type = "S" + } + global_secondary_index { + name = "UsernameIndex" + hash_key = "username" + range_key = "primaryKey" + projection_type = "KEYS_ONLY" + } +} diff --git a/terraform/modules/frontend/main.tf b/terraform/modules/frontend/main.tf index 92db83c2..483c1c9c 100644 --- a/terraform/modules/frontend/main.tf +++ b/terraform/modules/frontend/main.tf @@ -200,7 +200,7 @@ resource "aws_cloudfront_distribution" "app_cloudfront_distribution" { } } ordered_cache_behavior { - path_pattern = "/api/v1/organizations" + path_pattern = "/api/v1/organizations*" target_origin_id = "LambdaFunction" viewer_protocol_policy = "redirect-to-https" allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] diff --git a/terraform/modules/lambdas/main.tf b/terraform/modules/lambdas/main.tf index b8e78339..cca9c86f 100644 --- a/terraform/modules/lambdas/main.tf +++ b/terraform/modules/lambdas/main.tf @@ -233,6 +233,8 @@ resource "aws_iam_policy" "shared_iam_policy" { "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-linkry", "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-linkry/index/*", "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-keys", + "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-sigs", + "arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-sigs/index/*", ] }, { diff --git a/tests/live/organizations.test.ts b/tests/live/organizations.test.ts index 7aade598..06979a34 100644 --- a/tests/live/organizations.test.ts +++ b/tests/live/organizations.test.ts @@ -8,6 +8,9 @@ test("getting organizations", async () => { expect(response.status).toBe(200); const responseJson = (await response.json()) as string[]; expect(responseJson.length).greaterThan(0); - expect(responseJson).toContain("ACM"); - expect(responseJson).toContain("Infrastructure Committee"); +}); + +test("getting ACM organization info", async () => { + const response = await fetch(`${baseEndpoint}/api/v1/organizations/ACM`); + expect(response.status).toBe(200); }); diff --git a/tests/unit/organizations.test.ts b/tests/unit/organizations.test.ts index 7a605f77..eddb3aa7 100644 --- a/tests/unit/organizations.test.ts +++ b/tests/unit/organizations.test.ts @@ -1,18 +1,166 @@ import { afterAll, expect, test, beforeEach } from "vitest"; +import { + DynamoDBClient, + GetItemCommand, + QueryCommand, +} from "@aws-sdk/client-dynamodb"; +import { marshall } from "@aws-sdk/util-dynamodb"; import init from "../../src/api/index.js"; +import { describe } from "node:test"; +import { mockClient } from "aws-sdk-client-mock"; +import { genericConfig } from "../../src/common/config.js"; const app = await init(); -test("Test getting the list of organizations succeeds", async () => { - const response = await app.inject({ - method: "GET", - url: "/api/v1/organizations", +const ddbMock = mockClient(DynamoDBClient); + +const acmMeta = { + primaryKey: "DEFINE#ACM", + leadsEntraGroup: "a3c37a24-1e21-4338-813f-15478eb40137", + links: [ + { + type: "DISCORD", + url: "https://go.acm.illinois.edu/discord", + }, + ], + website: "https://www.acm.illinois.edu", +}; +describe("Organization info tests", async () => { + test("Test getting the list of organizations succeeds", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations", + }); + expect(response.statusCode).toBe(200); + await response.json(); + }); + test("Test getting info about an org succeeds", async () => { + ddbMock + .on(QueryCommand, { + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :definitionId", + ExpressionAttributeValues: { + ":definitionId": { S: "DEFINE#ACM" }, + }, + }) + .resolves({ + Items: [marshall(acmMeta)], + }); + ddbMock + .on(QueryCommand, { + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :leadName", + ExpressionAttributeValues: { + ":leadName": { S: "LEAD#ACM" }, + }, + }) + .resolves({ + Items: [ + { + primaryKey: "LEAD#ACM", + name: "John Doe", + title: "Chair", + username: "jdoe@illinois.edu", + }, + ].map((x) => marshall(x)), + }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations/ACM", + }); + expect(response.statusCode).toBe(200); + const responseJson = await response.json(); + expect(responseJson).toStrictEqual({ + id: "ACM", + website: "https://www.acm.illinois.edu", + leads: [ + { + username: "jdoe@illinois.edu", + name: "John Doe", + title: "Chair", + }, + ], + links: [ + { + type: "DISCORD", + url: "https://go.acm.illinois.edu/discord", + }, + ], + }); + }); + test("Test getting info about an unknown valid org returns just the ID", async () => { + ddbMock + .on(QueryCommand, { + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :definitionId", + ExpressionAttributeValues: { + ":definitionId": { S: "DEFINE#ACM" }, + }, + }) + .resolves({ + Items: undefined, + }); + ddbMock + .on(QueryCommand, { + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :leadName", + ExpressionAttributeValues: { + ":leadName": { S: "LEAD#ACM" }, + }, + }) + .resolves({ Items: [] }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations/ACM", + }); + expect(response.statusCode).toBe(200); + const responseJson = await response.json(); + expect(responseJson).toStrictEqual({ + id: "ACM", + }); + }); + test("Test that getting org with no leads succeeds", async () => { + ddbMock + .on(QueryCommand, { + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :definitionId", + ExpressionAttributeValues: { + ":definitionId": { S: "DEFINE#ACM" }, + }, + }) + .resolves({ + Items: [marshall(acmMeta)], + }); + ddbMock + .on(QueryCommand, { + TableName: genericConfig.SigInfoTableName, + KeyConditionExpression: "primaryKey = :leadName", + ExpressionAttributeValues: { + ":leadName": { S: "LEAD#ACM" }, + }, + }) + .resolves({ Items: [] }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/organizations/ACM", + }); + expect(response.statusCode).toBe(200); + const responseJson = await response.json(); + expect(responseJson).toStrictEqual({ + id: "ACM", + website: "https://www.acm.illinois.edu", + leads: [], + links: [ + { + type: "DISCORD", + url: "https://go.acm.illinois.edu/discord", + }, + ], + }); + }); + afterAll(async () => { + await app.close(); + }); + beforeEach(() => { + (app as any).nodeCache.flushAll(); }); - expect(response.statusCode).toBe(200); - await response.json(); -}); -afterAll(async () => { - await app.close(); -}); -beforeEach(() => { - (app as any).nodeCache.flushAll(); });