diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index c9b06908..5101bd29 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -76,8 +76,6 @@ Resources: - Sid: DynamoDBCacheAccess Effect: Allow Action: - - dynamodb:BatchGetItem - - dynamodb:BatchWriteItem - dynamodb:ConditionCheckItem - dynamodb:PutItem - dynamodb:DescribeTable @@ -87,15 +85,6 @@ Resources: - dynamodb:UpdateItem Resource: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache - Condition: - ForAllValues:StringEquals: - dynamodb:LeadingKeys: - - testing # add any keys that must be accessible - ForAllValues:StringLike: - dynamodb:Attributes: - - primaryKey - - expireAt - - "*" - Sid: DynamoDBRateLimitTableAccess Effect: Allow @@ -188,28 +177,6 @@ Resources: Effect: Allow Resource: - Fn::Sub: arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:infra-core-api-entra* - - Action: - - dynamodb:BatchGetItem - - dynamodb:GetItem - - dynamodb:Query - - dynamodb:DescribeTable - - dynamodb:BatchWriteItem - - dynamodb:ConditionCheckItem - - dynamodb:PutItem - - dynamodb:DeleteItem - - dynamodb:UpdateItem - Effect: Allow - Resource: - - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache - Condition: - ForAllValues:StringEquals: - dynamodb:LeadingKeys: - - entra_id_access_token # add any keys that must be accessible - ForAllValues:StringLike: - dynamodb:Attributes: - - primaryKey - - expireAt - - "*" # SQS Lambda IAM Role SqsLambdaIAMRole: diff --git a/src/api/functions/cache.ts b/src/api/functions/cache.ts index 62759889..d0c0849a 100644 --- a/src/api/functions/cache.ts +++ b/src/api/functions/cache.ts @@ -1,7 +1,10 @@ import { + DeleteItemCommand, DynamoDBClient, + GetItemCommand, PutItemCommand, QueryCommand, + UpdateItemCommand, } from "@aws-sdk/client-dynamodb"; import { genericConfig } from "../../common/config.js"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; @@ -52,3 +55,79 @@ export async function insertItemIntoCache( }), ); } + +export async function atomicIncrementCacheCounter( + dynamoClient: DynamoDBClient, + key: string, + amount: number, + returnOld: boolean = false, +): Promise { + const response = await dynamoClient.send( + new UpdateItemCommand({ + TableName: genericConfig.CacheDynamoTableName, + Key: marshall({ + primaryKey: key, + }), + UpdateExpression: "ADD #counterValue :increment", + ExpressionAttributeNames: { + "#counterValue": "counterValue", + }, + ExpressionAttributeValues: marshall({ + ":increment": amount, + }), + ReturnValues: returnOld ? "UPDATED_OLD" : "UPDATED_NEW", + }), + ); + + if (!response.Attributes) { + return returnOld ? 0 : amount; + } + + const value = unmarshall(response.Attributes).counter; + return typeof value === "number" ? value : 0; +} + +export async function getCacheCounter( + dynamoClient: DynamoDBClient, + key: string, + defaultValue: number = 0, +): Promise { + const response = await dynamoClient.send( + new GetItemCommand({ + TableName: genericConfig.CacheDynamoTableName, + Key: marshall({ + primaryKey: key, + }), + ProjectionExpression: "counterValue", // Only retrieve the value attribute + }), + ); + + if (!response.Item) { + return defaultValue; + } + + const value = unmarshall(response.Item).counterValue; + return typeof value === "number" ? value : defaultValue; +} + +export async function deleteCacheCounter( + dynamoClient: DynamoDBClient, + key: string, +): Promise { + const params = { + TableName: genericConfig.CacheDynamoTableName, + Key: marshall({ + primaryKey: key, + }), + ReturnValue: "ALL_OLD", + }; + + const response = await dynamoClient.send(new DeleteItemCommand(params)); + + if (response.Attributes) { + const item = unmarshall(response.Attributes); + const value = item.counterValue; + return typeof value === "number" ? value : 0; + } + return null; +} diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index 180ef24b..27367260 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -24,6 +24,11 @@ import { randomUUID } from "crypto"; import moment from "moment-timezone"; import { IUpdateDiscord, updateDiscord } from "../functions/discord.js"; import rateLimiter from "api/plugins/rateLimiter.js"; +import { + atomicIncrementCacheCounter, + deleteCacheCounter, + getCacheCounter, +} from "api/functions/cache.js"; const repeatOptions = ["weekly", "biweekly"] as const; const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${EVENT_CACHED_DURATION}, stale-while-revalidate=420, stale-if-error=3600`; @@ -119,7 +124,27 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { const upcomingOnly = request.query?.upcomingOnly || false; const host = request.query?.host; const ts = request.query?.ts; // we only use this to disable cache control + try { + const ifNoneMatch = request.headers["if-none-match"]; + if (ifNoneMatch) { + const etag = await getCacheCounter( + fastify.dynamoClient, + "events-etag-all", + ); + + if ( + ifNoneMatch === `"${etag.toString()}"` || + ifNoneMatch === etag.toString() + ) { + return reply + .code(304) + .header("ETag", etag) + .header("Cache-Control", CLIENT_HTTP_CACHE_POLICY) + .send(); + } + reply.header("etag", etag); + } let command; if (host) { command = new QueryCommand({ @@ -137,6 +162,14 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { TableName: genericConfig.EventsDynamoTableName, }); } + if (!ifNoneMatch) { + const etag = await getCacheCounter( + fastify.dynamoClient, + "events-etag-all", + ); + reply.header("etag", etag); + } + const response = await fastify.dynamoClient.send(command); const items = response.Items?.map((item) => unmarshall(item)); const currentTimeChicago = moment().tz("America/Chicago"); @@ -277,6 +310,18 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { } throw new DiscordEventError({}); } + await atomicIncrementCacheCounter( + fastify.dynamoClient, + `events-etag-${entryUUID}`, + 1, + false, + ); + await atomicIncrementCacheCounter( + fastify.dynamoClient, + "events-etag-all", + 1, + false, + ); reply.status(201).send({ id: entryUUID, resource: `/api/v1/events/${entryUUID}`, @@ -335,6 +380,13 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { message: "Failed to delete event from Dynamo table.", }); } + await deleteCacheCounter(fastify.dynamoClient, `events-etag-${id}`); + await atomicIncrementCacheCounter( + fastify.dynamoClient, + "events-etag-all", + 1, + false, + ); request.log.info( { type: "audit", actor: request.username, target: id }, `deleted event "${id}"`, @@ -357,7 +409,30 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { async (request: FastifyRequest, reply) => { const id = request.params.id; const ts = request.query?.ts; + try { + // Check If-None-Match header + const ifNoneMatch = request.headers["if-none-match"]; + if (ifNoneMatch) { + const etag = await getCacheCounter( + fastify.dynamoClient, + `events-etag-${id}`, + ); + + if ( + ifNoneMatch === `"${etag.toString()}"` || + ifNoneMatch === etag.toString() + ) { + return reply + .code(304) + .header("ETag", etag) + .header("Cache-Control", CLIENT_HTTP_CACHE_POLICY) + .send(); + } + + reply.header("etag", etag); + } + const response = await fastify.dynamoClient.send( new GetItemCommand({ TableName: genericConfig.EventsDynamoTableName, @@ -372,6 +447,16 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { if (!ts) { reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY); } + + // Only get the etag now if we didn't already get it above + if (!ifNoneMatch) { + const etag = await getCacheCounter( + fastify.dynamoClient, + `events-etag-${id}`, + ); + reply.header("etag", etag); + } + return reply.send(item); } catch (e) { if (e instanceof BaseError) { diff --git a/tests/unit/eventPost.test.ts b/tests/unit/eventPost.test.ts index 755d118b..af1d9785 100644 --- a/tests/unit/eventPost.test.ts +++ b/tests/unit/eventPost.test.ts @@ -1,5 +1,10 @@ -import { afterAll, expect, test, beforeEach, vi } from "vitest"; -import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb"; +import { afterAll, expect, test, beforeEach, vi, describe } from "vitest"; +import { + DynamoDBClient, + GetItemCommand, + PutItemCommand, + ScanCommand, +} from "@aws-sdk/client-dynamodb"; import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; import { createJwt } from "./auth.test.js"; @@ -9,6 +14,7 @@ import { } from "@aws-sdk/client-secrets-manager"; import { secretJson, secretObject } from "./secret.testdata.js"; import supertest from "supertest"; +import { marshall } from "@aws-sdk/util-dynamodb"; const ddbMock = mockClient(DynamoDBClient); const smMock = mockClient(SecretsManagerClient); @@ -17,6 +23,13 @@ vi.stubEnv("JwtSigningKey", jwt_secret); const app = await init(); +vi.mock("../../src/api/functions/discord.js", () => { + const updateDiscordMock = vi.fn().mockResolvedValue({}); + return { + updateDiscord: updateDiscordMock, + }; +}); + test("Sad path: Not authenticated", async () => { await app.ready(); const response = await supertest(app.server).post("/api/v1/events").send({ @@ -192,6 +205,332 @@ test("Happy path: Adding a weekly repeating, non-featured, paid event", async () }); }); +describe("ETag Lifecycle Tests", () => { + test("ETag should increment after event creation", async () => { + // Setup + (app as any).nodeCache.flushAll(); + ddbMock.reset(); + smMock.reset(); + vi.useFakeTimers(); + + // Mock secrets manager + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + + // Mock successful DynamoDB operations + ddbMock.on(PutItemCommand).resolves({}); + + // Mock ScanCommand to return empty Items array + ddbMock.on(ScanCommand).resolves({ + Items: [], + }); + + const testJwt = createJwt(undefined, "0"); + + // 1. Check initial etag for all events is 0 + const initialAllResponse = await app.inject({ + method: "GET", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(initialAllResponse.statusCode).toBe(200); + expect(initialAllResponse.headers.etag).toBe("0"); + + // 2. Create a new event using supertest + const eventResponse = await supertest(app.server) + .post("/api/v1/events") + .set("Authorization", `Bearer ${testJwt}`) + .send({ + description: "Test event for ETag verification", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + title: "ETag Test Event", + featured: false, + }); + + expect(eventResponse.statusCode).toBe(201); + const eventId = eventResponse.body.id; + + // Mock GetItemCommand to return the event we just created + ddbMock.on(GetItemCommand).resolves({ + Item: marshall({ + id: eventId, + title: "ETag Test Event", + description: "Test event for ETag verification", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + featured: false, + }), + }); + + // 3. Check that the all events etag is now 1 + const allEventsResponse = await app.inject({ + method: "GET", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(allEventsResponse.statusCode).toBe(200); + expect(allEventsResponse.headers.etag).toBe("1"); + + // 4. Check that the specific event etag is also 1 + const specificEventResponse = await app.inject({ + method: "GET", + url: `/api/v1/events/${eventId}`, + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(specificEventResponse.statusCode).toBe(200); + expect(specificEventResponse.headers.etag).toBe("1"); + }); + + test("ETags should be deleted when events are deleted", async () => { + // Setup + (app as any).nodeCache.flushAll(); + ddbMock.reset(); + smMock.reset(); + vi.useFakeTimers(); + + // Mock secrets manager + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + + // Mock successful DynamoDB operations + ddbMock.on(PutItemCommand).resolves({}); + ddbMock.on(ScanCommand).resolves({ + Items: [], + }); + + const testJwt = createJwt(undefined, "0"); + + // 1. Create an event + const eventResponse = await supertest(app.server) + .post("/api/v1/events") + .set("Authorization", `Bearer ${testJwt}`) + .send({ + description: "Test event for deletion", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + title: "Event to delete", + featured: false, + }); + + expect(eventResponse.statusCode).toBe(201); + const eventId = eventResponse.body.id; + + // Mock GetItemCommand to return the event + ddbMock.on(GetItemCommand).resolves({ + Item: marshall({ + id: eventId, + title: "Event to delete", + description: "Test event for deletion", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + featured: false, + }), + }); + + // 2. Verify the event's etag exists (should be 1) + const eventBeforeDelete = await app.inject({ + method: "GET", + url: `/api/v1/events/${eventId}`, + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(eventBeforeDelete.statusCode).toBe(200); + expect(eventBeforeDelete.headers.etag).toBe("1"); + + // 3. Delete the event + const deleteResponse = await supertest(app.server) + .delete(`/api/v1/events/${eventId}`) + .set("Authorization", `Bearer ${testJwt}`); + + expect(deleteResponse.statusCode).toBe(201); + + // 4. Verify the event no longer exists (should return 404) + // Change the mock to return empty response (simulating deleted event) + ddbMock.on(GetItemCommand).resolves({ + Item: undefined, + }); + + const eventAfterDelete = await app.inject({ + method: "GET", + url: `/api/v1/events/${eventId}`, + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(eventAfterDelete.statusCode).toBe(404); + + // 5. Check that all-events etag is incremented to 2 + // (1 for creation, 2 for deletion) + const allEventsResponse = await app.inject({ + method: "GET", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(allEventsResponse.statusCode).toBe(200); + expect(allEventsResponse.headers.etag).toBe("2"); + }); + + test("ETags for different events should be independent", async () => { + // Setup + (app as any).nodeCache.flushAll(); + ddbMock.reset(); + smMock.reset(); + vi.useFakeTimers(); + + // Mock secrets manager + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + + // Mock successful DynamoDB operations + ddbMock.on(PutItemCommand).resolves({}); + + // Mock ScanCommand to return empty Items array initially + ddbMock.on(ScanCommand).resolves({ + Items: [], + }); + + const testJwt = createJwt(undefined, "0"); + + // 1. Check initial etag for all events is 0 + const initialAllResponse = await app.inject({ + method: "GET", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(initialAllResponse.statusCode).toBe(200); + expect(initialAllResponse.headers.etag).toBe("0"); + + // 2. Create first event + const event1Response = await supertest(app.server) + .post("/api/v1/events") + .set("Authorization", `Bearer ${testJwt}`) + .send({ + description: "First test event", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + title: "Event 1", + featured: false, + }); + + expect(event1Response.statusCode).toBe(201); + const event1Id = event1Response.body.id; + + // 3. Create second event + const event2Response = await supertest(app.server) + .post("/api/v1/events") + .set("Authorization", `Bearer ${testJwt}`) + .send({ + description: "Second test event", + host: "Infrastructure Committee", + location: "ECEB", + start: "2024-09-26T18:00:00", + title: "Event 2", + featured: false, + }); + + expect(event2Response.statusCode).toBe(201); + const event2Id = event2Response.body.id; + + // Update GetItemCommand mock to handle different events + ddbMock.on(GetItemCommand).callsFake((params) => { + if (params.Key && params.Key.id) { + const eventId = params.Key.id.S; + + if (eventId === event1Id) { + return Promise.resolve({ + Item: marshall({ + id: event1Id, + title: "Event 1", + description: "First test event", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + featured: false, + }), + }); + } else if (eventId === event2Id) { + return Promise.resolve({ + Item: marshall({ + id: event2Id, + title: "Event 2", + description: "Second test event", + host: "Infrastructure Committee", + location: "ECEB", + start: "2024-09-26T18:00:00", + featured: false, + }), + }); + } + } + + return Promise.resolve({}); + }); + + // 4. Check that all events etag is now 2 (incremented twice) + const allEventsResponse = await app.inject({ + method: "GET", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(allEventsResponse.statusCode).toBe(200); + expect(allEventsResponse.headers.etag).toBe("2"); + + // 5. Check first event etag is 1 + const event1Response2 = await app.inject({ + method: "GET", + url: `/api/v1/events/${event1Id}`, + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(event1Response2.statusCode).toBe(200); + expect(event1Response2.headers.etag).toBe("1"); + + // 6. Check second event etag is also 1 + const event2Response2 = await app.inject({ + method: "GET", + url: `/api/v1/events/${event2Id}`, + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(event2Response2.statusCode).toBe(200); + expect(event2Response2.headers.etag).toBe("1"); + }); +}); + afterAll(async () => { await app.close(); vi.useRealTimers(); @@ -200,5 +539,6 @@ beforeEach(() => { (app as any).nodeCache.flushAll(); ddbMock.reset(); smMock.reset(); + vi.clearAllMocks(); vi.useFakeTimers(); }); diff --git a/tests/unit/events.test.ts b/tests/unit/events.test.ts index 0919adcf..75f5256f 100644 --- a/tests/unit/events.test.ts +++ b/tests/unit/events.test.ts @@ -1,99 +1,383 @@ import { afterAll, expect, test, beforeEach, vi } from "vitest"; import { - ScanCommand, DynamoDBClient, - QueryCommand, + PutItemCommand, + GetItemCommand, + ScanCommand, } from "@aws-sdk/client-dynamodb"; import { mockClient } from "aws-sdk-client-mock"; import init from "../../src/api/index.js"; -import { EventGetResponse } from "../../src/api/routes/events.js"; +import { createJwt } from "./auth.test.js"; import { - dynamoTableData, - dynamoTableDataUnmarshalled, - dynamoTableDataUnmarshalledUpcomingOnly, - infraEventsOnly, - infraEventsOnlyUnmarshalled, -} from "./mockEventData.testdata.js"; -import { secretObject } from "./secret.testdata.js"; + GetSecretValueCommand, + SecretsManagerClient, +} from "@aws-sdk/client-secrets-manager"; +import { secretJson, secretObject } from "./secret.testdata.js"; +import supertest from "supertest"; +import { marshall } from "@aws-sdk/util-dynamodb"; const ddbMock = mockClient(DynamoDBClient); +const smMock = mockClient(SecretsManagerClient); const jwt_secret = secretObject["jwt_key"]; vi.stubEnv("JwtSigningKey", jwt_secret); +// Mock the Discord client to prevent the actual Discord API call +vi.mock("../../src/api/functions/discord.js", async () => { + return { + updateDiscord: vi.fn().mockResolvedValue({}), + }; +}); + const app = await init(); -test("Test getting events", async () => { + +test("ETag should increment after event creation", async () => { + // Setup + (app as any).nodeCache.flushAll(); + ddbMock.reset(); + smMock.reset(); + vi.useFakeTimers(); + + // Mock secrets manager + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + + // Mock successful DynamoDB operations + ddbMock.on(PutItemCommand).resolves({}); + + // Mock ScanCommand to return empty Items array ddbMock.on(ScanCommand).resolves({ - Items: dynamoTableData as any, + Items: [], }); - const response = await app.inject({ + + const testJwt = createJwt(undefined, "0"); + + // 1. Check initial etag for all events is 0 + const initialAllResponse = await app.inject({ method: "GET", url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + }, }); - expect(response.statusCode).toBe(200); - const responseDataJson = (await response.json()) as EventGetResponse; - expect(responseDataJson).toEqual(dynamoTableDataUnmarshalled); + + expect(initialAllResponse.statusCode).toBe(200); + expect(initialAllResponse.headers.etag).toBe("0"); + + // 2. Create a new event using supertest + const eventResponse = await supertest(app.server) + .post("/api/v1/events") + .set("Authorization", `Bearer ${testJwt}`) + .send({ + description: "Test event for ETag verification", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + title: "ETag Test Event", + featured: false, + }); + + expect(eventResponse.statusCode).toBe(201); + const eventId = eventResponse.body.id; + + // Mock GetItemCommand to return the event we just created + ddbMock.on(GetItemCommand).resolves({ + Item: marshall({ + id: eventId, + title: "ETag Test Event", + description: "Test event for ETag verification", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + featured: false, + }), + }); + + // 3. Check that the all events etag is now 1 + const allEventsResponse = await app.inject({ + method: "GET", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(allEventsResponse.statusCode).toBe(200); + expect(allEventsResponse.headers.etag).toBe("1"); + + // 4. Check that the specific event etag is also 1 + const specificEventResponse = await app.inject({ + method: "GET", + url: `/api/v1/events/${eventId}`, + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(specificEventResponse.statusCode).toBe(200); + expect(specificEventResponse.headers.etag).toBe("1"); }); -test("Test dynamodb error handling", async () => { - const response = await app.inject({ +test("Should return 304 Not Modified when If-None-Match header matches ETag", async () => { + // Setup + (app as any).nodeCache.flushAll(); + ddbMock.reset(); + smMock.reset(); + vi.useFakeTimers(); + + // Mock secrets manager + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + + // Mock successful DynamoDB operations + ddbMock.on(PutItemCommand).resolves({}); + + // Mock ScanCommand to return empty Items array + ddbMock.on(ScanCommand).resolves({ + Items: [], + }); + + const testJwt = createJwt(undefined, "0"); + + // 1. First GET request to establish ETag + const initialResponse = await app.inject({ method: "GET", url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + }, }); - expect(response.statusCode).toBe(500); - const responseDataJson = await response.json(); - expect(responseDataJson).toEqual({ - error: true, - name: "DatabaseFetchError", - id: 106, - message: "Failed to get events from Dynamo table.", + + expect(initialResponse.statusCode).toBe(200); + expect(initialResponse.headers.etag).toBe("0"); + + // 2. Second GET request with If-None-Match header matching the ETag + const conditionalResponse = await app.inject({ + method: "GET", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + "If-None-Match": "0", + }, }); + + // Expect 304 Not Modified + expect(conditionalResponse.statusCode).toBe(304); + expect(conditionalResponse.headers.etag).toBe("0"); + expect(conditionalResponse.body).toBe(""); // Empty body on 304 }); -test("Test upcoming only", async () => { - const date = new Date(2024, 7, 10, 13, 0, 0); // 2024-08-10T17:00:00.000Z, don't ask me why its off a month - vi.setSystemTime(date); +test("Should return 304 Not Modified when If-None-Match header matches quoted ETag", async () => { + // Setup + (app as any).nodeCache.flushAll(); + ddbMock.reset(); + smMock.reset(); + vi.useFakeTimers(); + + // Mock secrets manager + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + + // Mock successful DynamoDB operations + ddbMock.on(PutItemCommand).resolves({}); + + // Mock ScanCommand to return empty Items array ddbMock.on(ScanCommand).resolves({ - Items: dynamoTableData as any, + Items: [], }); - const response = await app.inject({ + + const testJwt = createJwt(undefined, "0"); + + // 1. First GET request to establish ETag + const initialResponse = await app.inject({ + method: "GET", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(initialResponse.statusCode).toBe(200); + expect(initialResponse.headers.etag).toBe("0"); + + // 2. Second GET request with quoted If-None-Match header + const conditionalResponse = await app.inject({ + method: "GET", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + "If-None-Match": '"0"', + }, + }); + + // Expect 304 Not Modified + expect(conditionalResponse.statusCode).toBe(304); + expect(conditionalResponse.headers.etag).toBe("0"); + expect(conditionalResponse.body).toBe(""); // Empty body on 304 +}); + +test("Should NOT return 304 when ETag has changed", async () => { + // Setup + (app as any).nodeCache.flushAll(); + ddbMock.reset(); + smMock.reset(); + vi.useFakeTimers(); + + // Mock secrets manager + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + + // Mock successful DynamoDB operations + ddbMock.on(PutItemCommand).resolves({}); + + // Mock ScanCommand to return empty Items array + ddbMock.on(ScanCommand).resolves({ + Items: [], + }); + + const testJwt = createJwt(undefined, "0"); + + // 1. Initial GET to establish ETag + const initialResponse = await app.inject({ + method: "GET", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + }, + }); + + expect(initialResponse.statusCode).toBe(200); + expect(initialResponse.headers.etag).toBe("0"); + + // 2. Create a new event to change the ETag + const eventResponse = await supertest(app.server) + .post("/api/v1/events") + .set("Authorization", `Bearer ${testJwt}`) + .send({ + description: "Test event to change ETag", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + title: "ETag Change Test", + featured: false, + }); + + expect(eventResponse.statusCode).toBe(201); + const eventId = eventResponse.body.id; + + // Mock GetItemCommand to return the event we just created + ddbMock.on(GetItemCommand).resolves({ + Item: marshall({ + id: eventId, + title: "ETag Change Test", + description: "Test event to change ETag", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + featured: false, + }), + }); + + // 3. Make conditional request with old ETag + const conditionalResponse = await app.inject({ method: "GET", - url: "/api/v1/events?upcomingOnly=true", + url: "/api/v1/events", + headers: { + Authorization: `Bearer ${testJwt}`, + "If-None-Match": "0", + }, }); - expect(response.statusCode).toBe(200); - const responseDataJson = (await response.json()) as EventGetResponse; - expect(responseDataJson).toEqual(dynamoTableDataUnmarshalledUpcomingOnly); + + // Expect 200 OK (not 304) since ETag has changed + expect(conditionalResponse.statusCode).toBe(200); + expect(conditionalResponse.headers.etag).toBe("1"); + expect(conditionalResponse.body).not.toBe(""); // Should have body content }); -test("Test host filter", async () => { - ddbMock.on(ScanCommand).rejects(); - ddbMock.on(QueryCommand).resolves({ Items: infraEventsOnly as any }); - const response = await app.inject({ +test("Should handle 304 responses for individual event endpoints", async () => { + // Setup + (app as any).nodeCache.flushAll(); + ddbMock.reset(); + smMock.reset(); + vi.useFakeTimers(); + + // Mock secrets manager + smMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + + // Mock successful DynamoDB operations + ddbMock.on(PutItemCommand).resolves({}); + + // Create an event + const testJwt = createJwt(undefined, "0"); + const eventResponse = await supertest(app.server) + .post("/api/v1/events") + .set("Authorization", `Bearer ${testJwt}`) + .send({ + description: "Individual event test", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + title: "ETag Individual Test", + featured: false, + }); + + expect(eventResponse.statusCode).toBe(201); + const eventId = eventResponse.body.id; + + // Mock GetItemCommand to return the event + ddbMock.on(GetItemCommand).resolves({ + Item: marshall({ + id: eventId, + title: "ETag Individual Test", + description: "Individual event test", + host: "Social Committee", + location: "Siebel Center", + start: "2024-09-25T18:00:00", + featured: false, + }), + }); + + // 1. First GET to establish ETag + const initialEventResponse = await app.inject({ method: "GET", - url: "/api/v1/events?host=Infrastructure Committee", - }); - expect(response.statusCode).toBe(200); - const responseDataJson = (await response.json()) as EventGetResponse; - expect(responseDataJson).toEqual(infraEventsOnlyUnmarshalled); - expect(ddbMock.commandCalls(QueryCommand)).toHaveLength(1); - const queryCommandCall = ddbMock.commandCalls(QueryCommand)[0].args[0].input; - expect(queryCommandCall).toEqual({ - TableName: "infra-core-api-events", - ExpressionAttributeValues: { - ":host": { - S: "Infrastructure Committee", - }, + url: `/api/v1/events/${eventId}`, + headers: { + Authorization: `Bearer ${testJwt}`, }, - KeyConditionExpression: "host = :host", - IndexName: "HostIndex", }); + + expect(initialEventResponse.statusCode).toBe(200); + expect(initialEventResponse.headers.etag).toBe("1"); + + // 2. Second GET with matching If-None-Match + const conditionalEventResponse = await app.inject({ + method: "GET", + url: `/api/v1/events/${eventId}`, + headers: { + Authorization: `Bearer ${testJwt}`, + "If-None-Match": "1", + }, + }); + + // Expect 304 Not Modified + expect(conditionalEventResponse.statusCode).toBe(304); + expect(conditionalEventResponse.headers.etag).toBe("1"); + expect(conditionalEventResponse.body).toBe(""); }); afterAll(async () => { await app.close(); vi.useRealTimers(); }); + beforeEach(() => { (app as any).nodeCache.flushAll(); ddbMock.reset(); + smMock.reset(); vi.useFakeTimers(); }); diff --git a/tests/unit/vitest.setup.ts b/tests/unit/vitest.setup.ts index d8894379..dab8afdf 100644 --- a/tests/unit/vitest.setup.ts +++ b/tests/unit/vitest.setup.ts @@ -1,4 +1,4 @@ -import { vi } from "vitest"; +import { vi, afterEach } from "vitest"; import { allAppRoles, AppRoles } from "../../src/common/roles.js"; import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb"; import { mockClient } from "aws-sdk-client-mock"; @@ -100,3 +100,79 @@ ddbMock.on(QueryCommand).callsFake((command) => { } return Promise.reject(new Error("Table not mocked")); }); + +let mockCacheStore = new Map(); + +vi.mock(import("../../src/api/functions/cache.js"), async (importOriginal) => { + const mod = await importOriginal(); + + // Create mock functions + const getItemFromCacheMock = vi.fn(async (_, key) => { + const item = mockCacheStore.get(key); + if (!item) return null; + + const currentTime = Math.floor(Date.now() / 1000); + if (item.expireAt < currentTime) { + mockCacheStore.delete(key); + return null; + } + + return item; + }); + + const insertItemIntoCacheMock = vi.fn(async (_, key, value, expireAt) => { + const item = { + primaryKey: key, + expireAt: Math.floor(expireAt.getTime() / 1000), + ...value, + }; + mockCacheStore.set(key, item); + }); + + const atomicIncrementCacheCounterMock = vi.fn( + async (_, key, amount, returnOld = false) => { + let item = mockCacheStore.get(key); + const oldValue = item?.counterValue || 0; + const newValue = oldValue + amount; + + // Create or update the item + if (!item) { + item = { primaryKey: key, counterValue: newValue }; + } else { + item.counterValue = newValue; + } + + mockCacheStore.set(key, item); + return returnOld ? oldValue : newValue; + }, + ); + + const getCacheCounterMock = vi.fn(async (_, key, defaultValue = 0) => { + const item = mockCacheStore.get(key); + return item?.counterValue !== undefined ? item.counterValue : defaultValue; + }); + + const deleteCacheCounterMock = vi.fn(async (_, key) => { + const item = mockCacheStore.get(key); + if (!item) return null; + + const counterValue = + item.counterValue !== undefined ? item.counterValue : 0; + mockCacheStore.delete(key); + return counterValue; + }); + + // Clear cache store when mocks are reset + afterEach(() => { + mockCacheStore.clear(); + }); + + return { + ...mod, + getItemFromCache: getItemFromCacheMock, + insertItemIntoCache: insertItemIntoCacheMock, + atomicIncrementCacheCounter: atomicIncrementCacheCounterMock, + getCacheCounter: getCacheCounterMock, + deleteCacheCounter: deleteCacheCounterMock, + }; +});