From a0124f096db9359280f68c5bd8450df29425e688 Mon Sep 17 00:00:00 2001 From: Liam Lloyd Date: Thu, 4 Apr 2024 17:07:31 -0700 Subject: [PATCH] Add admin endpoint to recalculate record thumbnails We have some records in our database with thumbnail urls in an outdated format. We'd like to force the regeneration of those urls. This commit adds an admin endpoint that does that for a given record ID. --- API.md | 19 ++++ packages/api/package.json | 2 +- packages/api/src/admin/controller.test.ts | 105 ++++++++++++++++++ packages/api/src/admin/controller.ts | 18 +++ packages/api/src/admin/models.ts | 6 + packages/api/src/admin/queries/get_record.sql | 11 ++ packages/api/src/admin/service.ts | 42 ++++++- packages/api/src/admin/validators.ts | 13 +++ .../src/fixtures/create_test_folder_links.sql | 20 ++++ 9 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/admin/queries/get_record.sql create mode 100644 packages/api/src/fixtures/create_test_folder_links.sql diff --git a/API.md b/API.md index 095d47b7..c9f73f27 100644 --- a/API.md +++ b/API.md @@ -72,6 +72,24 @@ account's status is `status.auth.ok` and its current `subject` is `null` } ``` +### POST `/admin/record/:recordId/recalculate_thumbnail + +Queues a thumbnail regeneration task for the record given by `recordId` + +- Headers: Authorization: Bearer \ + +- Request + +``` +{} +``` + +- Response + +``` +{} +``` + ## Directives ### POST `/directive` @@ -402,6 +420,7 @@ account's status is `status.auth.ok` and its current `subject` is `null` alreadyInvited: [string] } ``` + ## Events ### POST `/event` diff --git a/packages/api/package.json b/packages/api/package.json index 98130bbf..31bd3051 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -32,7 +32,7 @@ "clear-database-ci": "psql postgresql://postgres:permanent@database:5432 -c 'DROP DATABASE IF EXISTS test_permanent;'", "create-database-ci": "psql postgresql://postgres:permanent@database:5432 -c 'CREATE DATABASE test_permanent;'", "set-up-database-ci": "pg_dump postgresql://postgres:permanent@database:5432/permanent --schema-only | psql postgresql://postgres:permanent@database:5432/test_permanent", - "test": "npm run start-containers && npm run clear-database && npm run create-database && npm run set-up-database && (cd ../..; docker compose run stela node --experimental-vm-modules ../../node_modules/jest/bin/jest.js -i)", + "test": "npm run start-containers && npm run clear-database && npm run create-database && npm run set-up-database && (cd ../..; docker compose run stela node --experimental-vm-modules ../../node_modules/jest/bin/jest.js -i --silent=false)", "test-ci": "npm run clear-database-ci && npm run create-database-ci && npm run set-up-database-ci && node --experimental-vm-modules ../../node_modules/jest/bin/jest.js -i --coverage" }, "dependencies": { diff --git a/packages/api/src/admin/controller.test.ts b/packages/api/src/admin/controller.test.ts index ea630ac6..31ffcdd0 100644 --- a/packages/api/src/admin/controller.test.ts +++ b/packages/api/src/admin/controller.test.ts @@ -378,3 +378,108 @@ describe("set_null_subjects", () => { }); }); }); + +describe("/record/:recordId/recalculate_thumbnail", () => { + const agent = request(app); + + const loadFixtures = async (): Promise => { + await db.sql("fixtures.create_test_accounts"); + await db.sql("fixtures.create_test_archive"); + await db.sql("fixtures.create_test_records"); + await db.sql("fixtures.create_test_folders"); + await db.sql("fixtures.create_test_folder_links"); + }; + + const clearDatabase = async (): Promise => { + await db.query( + "TRUNCATE account, archive, record, folder, folder_link CASCADE" + ); + }; + + beforeEach(async () => { + (verifyAdminAuthentication as jest.Mock).mockImplementation( + (req: Request, __, next: NextFunction) => { + ( + req.body as { + beginTimestamp: Date; + endTimestamp: Date; + emailFromAuthToken: string; + } + ).emailFromAuthToken = "test@permanent.org"; + next(); + } + ); + jest.clearAllMocks(); + await clearDatabase(); + await loadFixtures(); + }); + + afterEach(async () => { + await clearDatabase(); + }); + + test("should response with 401 if not authenticated as an admin", async () => { + (verifyAdminAuthentication as jest.Mock).mockImplementation( + (_, __, next: NextFunction) => { + next(new createError.Unauthorized("Invalid token")); + } + ); + await agent + .post("/api/v2/admin/record/1/recalculate_thumbnail") + .expect(401); + }); + + test("should publish a message with correct parameters", async () => { + (publisherClient.batchPublishMessages as jest.Mock).mockResolvedValueOnce({ + failedMessages: [], + messagesSent: 1, + }); + await agent + .post("/api/v2/admin/record/1/recalculate_thumbnail") + .expect(200); + + expect(publisherClient.batchPublishMessages).toHaveBeenCalled(); + const publishMessages = ( + (publisherClient.batchPublishMessages as jest.Mock).mock.calls[0] as [ + string, + Message[] + ] + )[1] as unknown as Message[]; + if (publishMessages[0]) { + const publishBody = JSON.parse(publishMessages[0].body) as { + parameters: string[]; + }; + expect(publishBody.parameters.length).toBe(6); + expect(publishBody.parameters[1]).toBe("1"); + expect(publishBody.parameters[2]).toBe("2"); + expect(publishBody.parameters[3]).toBe("1"); + } else { + expect(true).toBe(false); + } + }); + + test("should respond with 404 if the record doesn't exist", async () => { + await agent + .post("/api/v2/admin/record/1000/recalculate_thumbnail") + .expect(404); + }); + + test("should respond with 500 if the message fails to send", async () => { + jest + .spyOn(publisherClient, "batchPublishMessages") + .mockResolvedValueOnce({ failedMessages: ["1"], messagesSent: 0 }); + await agent + .post("/api/v2/admin/record/1/recalculate_thumbnail") + .expect(500); + }); + + test("should respond with 500 and log the error if the database call fails", async () => { + const testError = new Error("out of cheese - redo from start"); + jest.spyOn(db, "sql").mockRejectedValueOnce(testError); + + await agent + .post("/api/v2/admin/record/1/recalculate_thumbnail") + .expect(500); + expect(logger.error).toHaveBeenCalledWith(testError); + }); +}); diff --git a/packages/api/src/admin/controller.ts b/packages/api/src/admin/controller.ts index 5f4614d0..0c7456d7 100644 --- a/packages/api/src/admin/controller.ts +++ b/packages/api/src/admin/controller.ts @@ -4,6 +4,7 @@ import { adminService } from "./service"; import { verifyAdminAuthentication } from "../middleware"; import { validateRecalculateFolderThumbnailsRequest, + validateRecalculateRecordThumbnailRequest, validateAccountSetNullSubjectsRequest, } from "./validators"; import { isValidationError } from "../validators/validator_util"; @@ -53,3 +54,20 @@ adminController.post( } } ); + +adminController.post( + "/record/:recordId/recalculate_thumbnail", + verifyAdminAuthentication, + async (req: Request, res: Response, next: NextFunction): Promise => { + try { + // You couldn't actually call this endpoint with invalid params, + // because the route would not match. We validate solely to make the type checker happy. + // This is why there is no error handling for validation errors. + validateRecalculateRecordThumbnailRequest(req.params); + await adminService.recalculateRecordThumbnail(req.params.recordId); + res.status(200).json({}); + } catch (err) { + next(err); + } + } +); diff --git a/packages/api/src/admin/models.ts b/packages/api/src/admin/models.ts index 791475d8..11b3dc9b 100644 --- a/packages/api/src/admin/models.ts +++ b/packages/api/src/admin/models.ts @@ -2,3 +2,9 @@ export interface Folder { folderId: string; archiveId: string; } + +export interface ArchiveRecord { + recordId: string; + parentFolderId: string; + archiveId: string; +} diff --git a/packages/api/src/admin/queries/get_record.sql b/packages/api/src/admin/queries/get_record.sql new file mode 100644 index 00000000..a648774e --- /dev/null +++ b/packages/api/src/admin/queries/get_record.sql @@ -0,0 +1,11 @@ +SELECT + record.recordid AS "recordId", + record.archiveid AS "archiveId", + folder_link.parentfolderid AS "parentFolderId" +FROM + record +INNER JOIN + folder_link + ON record.recordid = folder_link.recordid +WHERE + record.recordid = :recordId; diff --git a/packages/api/src/admin/service.ts b/packages/api/src/admin/service.ts index e6710a9a..76219b6e 100644 --- a/packages/api/src/admin/service.ts +++ b/packages/api/src/admin/service.ts @@ -1,7 +1,7 @@ import createError from "http-errors"; import { logger } from "@stela/logger"; import { db } from "../database"; -import type { Folder } from "./models"; +import type { Folder, ArchiveRecord } from "./models"; import { publisherClient, lowPriorityTopicArn } from "../publisher_client"; const recalculateFolderThumbnails = async ( @@ -48,6 +48,45 @@ const recalculateFolderThumbnails = async ( }; }; +const recalculateRecordThumbnail = async (recordId: string): Promise => { + const recordResult = await db + .sql("admin.queries.get_record", { + recordId, + }) + .catch((err) => { + logger.error(err); + throw new createError.InternalServerError("Failed to retrieve record"); + }); + + if (!recordResult.rows[0]) { + throw new createError.NotFound("Record not found"); + } + + const publishResult = await publisherClient.batchPublishMessages( + lowPriorityTopicArn, + [ + { + id: recordId, + body: JSON.stringify({ + task: "task.thumbnail.record", + parameters: [ + "Thumbnail_Redo", + recordId, + recordResult.rows[0].parentFolderId, + recordResult.rows[0].archiveId, + 0, + new Date().toISOString(), + ], + }), + }, + ] + ); + + if (publishResult.failedMessages.length > 0) { + throw new createError.InternalServerError("Failed to publish message"); + } +}; + const setNullAccountSubjects = async ( accounts: { email: string; @@ -82,5 +121,6 @@ const setNullAccountSubjects = async ( export const adminService = { recalculateFolderThumbnails, + recalculateRecordThumbnail, setNullAccountSubjects, }; diff --git a/packages/api/src/admin/validators.ts b/packages/api/src/admin/validators.ts index b67ed31a..4aa07ba6 100644 --- a/packages/api/src/admin/validators.ts +++ b/packages/api/src/admin/validators.ts @@ -35,3 +35,16 @@ export function validateAccountSetNullSubjectsRequest( throw validation.error; } } + +export function validateRecalculateRecordThumbnailRequest( + data: unknown +): asserts data is { recordId: string } { + const validation = Joi.object() + .keys({ + recordId: Joi.string().required(), + }) + .validate(data); + if (validation.error) { + throw validation.error; + } +} diff --git a/packages/api/src/fixtures/create_test_folder_links.sql b/packages/api/src/fixtures/create_test_folder_links.sql new file mode 100644 index 00000000..68c00b09 --- /dev/null +++ b/packages/api/src/fixtures/create_test_folder_links.sql @@ -0,0 +1,20 @@ +INSERT INTO +folder_link ( + recordid, + parentfolderid, + archiveid, + position, + accessrole, + status, + type +) +VALUES +( + 1, + 2, + 1, + 1, + 'access.role.owner', + 'status.generic.ok', + 'type.folder_link.private' +);