diff --git a/packages/api/docs/src/api.yaml b/packages/api/docs/src/api.yaml index 0a5c8f25..ab7c172b 100644 --- a/packages/api/docs/src/api.yaml +++ b/packages/api/docs/src/api.yaml @@ -35,6 +35,8 @@ paths: $ref: "./paths/mfa_management.yaml#/mfa-method-with-id" /accounts: $ref: "./paths/account.yaml#/account" + /accounts/{id}/storage-adjustment: + $ref: "./paths/account.yaml#/storage-adjustment" /share-links: $ref: "./paths/share_link.yaml#/share-link" /share-links/{shareLinkId}: diff --git a/packages/api/docs/src/paths/account.yaml b/packages/api/docs/src/paths/account.yaml index 54986b3c..84dc2cf3 100644 --- a/packages/api/docs/src/paths/account.yaml +++ b/packages/api/docs/src/paths/account.yaml @@ -41,3 +41,60 @@ account: $ref: "../errors.yaml#/400" "500": $ref: "../errors.yaml#/500" +storage-adjustment: + post: + parameters: + - name: id + in: path + required: true + schema: + type: string + summary: Adjust the storage available to an account. Requires admin authentication + operationId: storageAdjustment + security: + - bearerHttpAuthentication: [] + tags: + - storage + - admin + requestBody: + content: + application/json: + schema: + type: object + required: [storageAmount] + properties: + storageAmount: + description: The amount of storage to add to the account, expressed as an integer number of GBs (this could be negative) + type: integer + responses: + "200": + description: > + The account's updated storage values + content: + application/json: + schema: + type: object + properties: + data: + type: object + required: [storageTotal, storageUsed] + properties: + newStorageTotal: + description: The total amount of storage now available to the account expressed in GBs + type: number + adjustmentSize: + description: The amount by which the account's storage total was changed + type: number + createdAt: + description: To time at which the storage adjustment occurred + type: string + format: date-time + + "400": + $ref: "../errors.yaml#/400" + "401": + $ref: "../errors.yaml#/401" + "404": + $ref: "../errors.yaml#/404" + "500": + $ref: "../errors.yaml#/500" diff --git a/packages/api/docs/src/paths/storage.yaml b/packages/api/docs/src/paths/storage.yaml new file mode 100644 index 00000000..e69de29b diff --git a/packages/api/src/account/controller/controller.ts b/packages/api/src/account/controller/controller.ts index 8c9725d9..b4871f90 100644 --- a/packages/api/src/account/controller/controller.ts +++ b/packages/api/src/account/controller/controller.ts @@ -2,16 +2,21 @@ import { Router } from "express"; import type { Request, Response, NextFunction } from "express"; import { HTTP_STATUS } from "@pdc/http-status-codes"; -import { extractIp, verifyUserAuthentication } from "../../middleware"; +import { + extractIp, + verifyUserAuthentication, + verifyAdminAuthentication, +} from "../../middleware"; import { isValidationError } from "../../validators/validator_util"; - import { validateUpdateTagsRequest, validateBodyFromAuthentication, validateLeaveArchiveParams, validateLeaveArchiveRequest, + validateCreateStorageAdjustmentRequest, + validateCreateStorageAdjustmentParams, } from "../validators"; -import { accountService } from "../service"; +import { accountService, createStorageAdjustment } from "../service"; import type { LeaveArchiveRequest } from "../models"; export const accountController = Router(); @@ -77,3 +82,27 @@ accountController.delete( } }, ); +accountController.post( + "/:accountId/storage-adjustments", + verifyAdminAuthentication, + async (req: Request, res: Response, next: NextFunction) => { + try { + validateCreateStorageAdjustmentRequest(req.body); + validateCreateStorageAdjustmentParams(req.params); + const result = await createStorageAdjustment( + req.params.accountId, + req.body, + ); + + res.status(HTTP_STATUS.SUCCESSFUL.OK).json(result); + } catch (err) { + if (isValidationError(err)) { + res + .status(HTTP_STATUS.CLIENT_ERROR.BAD_REQUEST) + .json({ error: err.message }); + return; + } + next(err); + } + }, +); diff --git a/packages/api/src/account/controller/create_storage_adjustments.test.ts b/packages/api/src/account/controller/create_storage_adjustments.test.ts new file mode 100644 index 00000000..118f1c44 --- /dev/null +++ b/packages/api/src/account/controller/create_storage_adjustments.test.ts @@ -0,0 +1,300 @@ +import request from "supertest"; +import { when } from "jest-when"; +import { logger } from "@stela/logger"; +import { app } from "../../app"; +import { db } from "../../database"; +import { GB } from "../../constants"; +import { verifyAdminAuthentication } from "../../middleware"; +import type { StorageAdjustment } from "../models"; +import { mockVerifyAdminAuthentication } from "../../../test/middleware_mocks"; + +jest.mock("../../database"); +jest.mock("../../middleware"); +jest.mock("@stela/logger"); + +interface AccountSpace { + spaceLeft: string; + spaceTotal: string; +} + +const testAccountId = "3"; + +const getAccountSpace = async ( + accountId: string, +): Promise => { + const result = await db.query( + 'SELECT spaceLeft "spaceLeft", spaceTotal "spaceTotal" FROM account_space WHERE accountId = :accountId', + { accountId }, + ); + return result.rows[0]; +}; + +describe("/account/storage-adjustments", () => { + const agent = request(app); + + const setupDatabase = async (): Promise => { + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); + }; + + const clearDatabase = async (): Promise => { + await db.query( + "TRUNCATE account, account_space, ledger_financial, email, invite CASCADE", + ); + }; + + beforeEach(async () => { + mockVerifyAdminAuthentication( + "admin@permanent.org", + "13bb917e-7c75-4971-a8ee-b22e82432888", + ); + await clearDatabase(); + await setupDatabase(); + }); + + afterEach(async () => { + await clearDatabase(); + jest.clearAllMocks(); + }); + + test("should call verifyAdminAuthentication", async () => { + await agent.post(`/api/v2/account/${testAccountId}/storage-adjustments`); + expect(verifyAdminAuthentication).toHaveBeenCalled(); + }); + + test("should return invalid request status if email from auth token is missing", async () => { + mockVerifyAdminAuthentication( + undefined, + "13bb917e-7c75-4971-a8ee-b22e82432888", + ); + await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .expect(400); + }); + + test("should return invalid request status if email from auth token is not an email", async () => { + mockVerifyAdminAuthentication( + "not_an_email", + "13bb917e-7c75-4971-a8ee-b22e82432888", + ); + await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .expect(400); + }); + + test("should return invalid request status if subject from auth token is missing", async () => { + mockVerifyAdminAuthentication("admin@permanent.org"); + await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .expect(400); + }); + + test("should return invalid request status if subject from auth token is not a uuid", async () => { + mockVerifyAdminAuthentication("admin@permanent.org", "not_a_uuid"); + await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .expect(400); + }); + + test("should return invalid request status if storageAmount is missing", async () => { + await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .send({}) + .expect(400); + }); + + test("should return invalid request status if storageAmount is not a number", async () => { + await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .send({ + storageAmount: "not_a_number", + }) + .expect(400); + }); + + test("should return invalid request status if storageAmount is not an integer", async () => { + await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .send({ storageAmount: 5.5 }) + .expect(400); + }); + + test("should return 404 if account does not exist", async () => { + await agent + .post(`/api/v2/account/10000000/storage-adjustments`) + .send({ storageAmount: 5 }) + .expect(404); + }); + + test("should successfully adjust storage with positive amount", async () => { + const initialAccountSpace = await getAccountSpace(testAccountId); + + const response = await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .send({ storageAmount: 5 }) + .expect(200); + + expect(response.body).toHaveProperty("newStorageTotal"); + expect(response.body).toHaveProperty("adjustmentAmount"); + expect(response.body).toHaveProperty("createdAt"); + + const updatedAccountSpace = await getAccountSpace(testAccountId); + if ( + initialAccountSpace?.spaceTotal === undefined || + updatedAccountSpace?.spaceTotal === undefined + ) { + expect(false).toBe(true); + } else { + expect(+updatedAccountSpace.spaceTotal).toBe( + +initialAccountSpace.spaceTotal + 5 * GB, + ); + } + }); + + test("should successfully adjust storage with negative amount", async () => { + const initialAccountSpace = await getAccountSpace(testAccountId); + + const response = await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .send({ storageAmount: -1 }) + .expect(200); + + expect(response.body).toHaveProperty("newStorageTotal"); + expect(response.body).toHaveProperty("adjustmentAmount"); + expect(response.body).toHaveProperty("createdAt"); + + const updatedAccountSpace = await getAccountSpace(testAccountId); + if ( + initialAccountSpace?.spaceTotal === undefined || + updatedAccountSpace?.spaceTotal === undefined + ) { + expect(false).toBe(true); + } else { + expect(+updatedAccountSpace.spaceTotal).toBe( + +initialAccountSpace.spaceTotal - 1 * GB, + ); + } + }); + + test("should return correct storageTotal and storageUsed in GB", async () => { + const initialAccountSpace = await getAccountSpace(testAccountId); + + const response = await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .send({ storageAmount: 3 }) + .expect(200); + + if (initialAccountSpace?.spaceTotal === undefined) { + expect(false).toBe(true); + } else { + const { + body: { newStorageTotal, adjustmentAmount, createdAt }, + } = response as { body: StorageAdjustment }; + expect(newStorageTotal).toBe( + (+initialAccountSpace.spaceTotal + 3 * GB) / GB, + ); + expect(adjustmentAmount).toBe(3); + expect(createdAt).toBeDefined(); + } + }); + + test("should create ledger entry with correct type", async () => { + const initialAccountSpace = await getAccountSpace(testAccountId); + + await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .send({ storageAmount: 5 }) + .expect(200); + + const ledgerEntryResponse = await db.query<{ + type: string; + spaceDelta: string; + toAccountId: string; + fileDelta: string; + fromAccountId: string; + fromSpaceBefore: string; + fromSpaceLeft: string; + fromSpaceTotal: string; + fromFileBefore: string; + fromFileLeft: string; + fromFileTotal: string; + toSpaceBefore: string; + toSpaceLeft: string; + toSpaceTotal: string; + toFileBefore: string; + toFileLeft: string; + toFileTotal: string; + }>( + `SELECT + type, + spacedelta "spaceDelta", + toaccountid "toAccountId", + filedelta "fileDelta", + fromaccountid "fromAccountId", + fromspacebefore "fromSpaceBefore", + fromspaceleft "fromSpaceLeft", + fromspacetotal "fromSpaceTotal", + toaccountid "toAccountId", + tospacebefore "toSpaceBefore", + tospaceleft "toSpaceLeft", + tospacetotal "toSpaceTotal", + status + FROM + ledger_financial + WHERE + type = 'type.billing.transfer.admin_adjustment' + AND toAccountId = :accountId`, + { accountId: testAccountId }, + ); + + if (ledgerEntryResponse.rows[0] === undefined) { + expect(true).toBe(false); + } else { + expect(ledgerEntryResponse.rows[0].type).toBe( + "type.billing.transfer.admin_adjustment", + ); + expect(ledgerEntryResponse.rows[0].spaceDelta).toBe((5 * GB).toString()); + expect(ledgerEntryResponse.rows[0].fileDelta).toBe("0"); + expect(ledgerEntryResponse.rows[0].fromAccountId).toBe("0"); + expect(ledgerEntryResponse.rows[0].fromSpaceBefore).toBe("0"); + expect(ledgerEntryResponse.rows[0].fromSpaceLeft).toBe("0"); + expect(ledgerEntryResponse.rows[0].fromSpaceTotal).toBe("0"); + expect(ledgerEntryResponse.rows[0].toAccountId).toBe("3"); + expect(ledgerEntryResponse.rows[0].toSpaceBefore).toBe( + initialAccountSpace?.spaceTotal, + ); + expect(parseInt(ledgerEntryResponse.rows[0].toSpaceLeft, 10)).toBe( + parseInt(initialAccountSpace?.spaceTotal ?? "0", 10) + 5 * GB, + ); + expect(parseInt(ledgerEntryResponse.rows[0].toSpaceTotal, 10)).toBe( + parseInt(initialAccountSpace?.spaceTotal ?? "0", 10) + 5 * GB, + ); + } + }); + + test("should log error and return 500 if storage adjustment fails", async () => { + const testError = new Error("test error"); + const spy = jest.spyOn(db, "sql"); + when(spy) + .calledWith("account.queries.adjust_account_storage", { + accountId: testAccountId, + storageAmountInBytes: 5 * GB, + }) + .mockRejectedValueOnce(testError); + + await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .send({ storageAmount: 5 }) + .expect(500); + + expect(logger.error).toHaveBeenCalledWith(testError); + }); + + test("should handle zero storage adjustment", async () => { + await agent + .post(`/api/v2/account/${testAccountId}/storage-adjustments`) + .send({ storageAmount: 0 }) + .expect(400); + }); +}); diff --git a/packages/api/src/account/models.ts b/packages/api/src/account/models.ts index 4616c181..ff471b56 100644 --- a/packages/api/src/account/models.ts +++ b/packages/api/src/account/models.ts @@ -29,3 +29,15 @@ export interface LeaveArchiveRequest { archiveId: string; ip: string; } + +export interface CreateStorageAdjustmentRequest { + emailFromAuthToken: string; + accountEmail: string; + storageAmount: number; +} + +export interface StorageAdjustment { + newStorageTotal: number; + adjustmentAmount: number; + createdAt: Date; +} diff --git a/packages/api/src/account/queries/adjust_account_storage.sql b/packages/api/src/account/queries/adjust_account_storage.sql new file mode 100644 index 00000000..84d05776 --- /dev/null +++ b/packages/api/src/account/queries/adjust_account_storage.sql @@ -0,0 +1,73 @@ +WITH to_account_space AS ( + UPDATE + account_space + SET + spaceleft = spaceleft + :storageAmountInBytes, + spacetotal = spacetotal + :storageAmountInBytes, + updateddt = CURRENT_TIMESTAMP + WHERE + account_space.accountid = :accountId + RETURNING + accountid, + spacetotal, + spaceleft, + filetotal +) + +INSERT INTO +ledger_financial ( + type, + spacedelta, + filedelta, + fromaccountid, + fromspacebefore, + fromspaceleft, + fromspacetotal, + fromfilebefore, + fromfileleft, + fromfiletotal, + toaccountid, + tospacebefore, + tospaceleft, + tospacetotal, + tofilebefore, + tofileleft, + tofiletotal, + status, + createddt, + updateddt +) +SELECT + 'type.billing.transfer.admin_adjustment' AS type, + :storageAmountInBytes, + 0 AS filedelta, + 0 AS fromaccountid, + 0 AS fromspacebefore, + 0 AS fromspaceleft, + 0 AS fromspacetotal, + 0 AS fromfilebefore, + 0 AS fromfileleft, + 0 AS fromfiletotal, + accountid, + spacetotal - :storageAmountInBytes, + -- toSpaceLeft and toSpaceTotal are always equal; + -- one should eventually be dropped + -- or toSpaceLeft should be updated to actually track space remaining + -- but for the time being they must both + -- be maintained for backward compatibility + spacetotal AS tospaceleft, + spacetotal AS tospacetotal, + -- fileTotal wasn't changed, see above commment on backward + -- compatibility for why toFileTotal = toFileLeft + filetotal AS tofilebefore, + filetotal AS tofileleft, + filetotal AS tofiletotal, + 'status.generic.ok' AS status, + CURRENT_TIMESTAMP AS createddt, + CURRENT_TIMESTAMP AS updateddt +FROM + to_account_space +RETURNING + tospacetotal AS "storageTotalInBytes", + spacedelta AS "adjustmentSizeInBytes", + createddt AS "createdAt"; diff --git a/packages/api/src/account/service.ts b/packages/api/src/account/service.ts index 534cdd8c..8e0ff7be 100644 --- a/packages/api/src/account/service.ts +++ b/packages/api/src/account/service.ts @@ -4,7 +4,7 @@ import { logger } from "@stela/logger"; import { db } from "../database"; import { MailchimpMarketing } from "../mailchimp"; -import { ACCESS_ROLE, EVENT_ACTION, EVENT_ENTITY } from "../constants"; +import { ACCESS_ROLE, EVENT_ACTION, EVENT_ENTITY, GB } from "../constants"; import { createEvent } from "../event/service"; import type { CreateEventRequest } from "../event/models"; @@ -14,6 +14,8 @@ import type { GetAccountArchiveResult, LeaveArchiveRequest, GetCurrentAccountArchiveResult, + CreateStorageAdjustmentRequest, + StorageAdjustment, } from "./models"; const updateTags = async (requestBody: UpdateTagsRequest): Promise => { @@ -170,3 +172,34 @@ export const accountService = { getAccountArchive, getCurrentAccountArchiveMemberships, }; + +export const createStorageAdjustment = async ( + accountId: string, + requestBody: CreateStorageAdjustmentRequest, +): Promise => { + const updatedStorage = await db + .sql<{ + storageTotalInBytes: bigint; + adjustmentSizeInBytes: bigint; + createdAt: Date; + }>("account.queries.adjust_account_storage", { + accountId, + storageAmountInBytes: requestBody.storageAmount * GB, + }) + .catch((err: unknown) => { + logger.error(err); + throw new createError.InternalServerError( + "Failed to update account storage", + ); + }); + + if (updatedStorage.rows[0] === undefined) { + throw new createError.NotFound("Account not found"); + } + + return { + newStorageTotal: Number(updatedStorage.rows[0].storageTotalInBytes) / GB, + adjustmentAmount: Number(updatedStorage.rows[0].adjustmentSizeInBytes) / GB, + createdAt: updatedStorage.rows[0].createdAt, + }; +}; diff --git a/packages/api/src/account/validators.test.ts b/packages/api/src/account/validators.test.ts index 24f3c4e7..6499e76e 100644 --- a/packages/api/src/account/validators.test.ts +++ b/packages/api/src/account/validators.test.ts @@ -1,4 +1,5 @@ import { + validateCreateStorageAdjustmentParams, validateLeaveArchiveParams, validateLeaveArchiveRequest, validateUpdateTagsRequest, @@ -322,3 +323,27 @@ describe("validateLeaveArchiveRequest", () => { } }); }); + +describe("validateCreateStorageAdjustmentParams", () => { + test("should error if accountId is missing", () => { + let error = null; + try { + validateCreateStorageAdjustmentParams({}); + } catch (err) { + error = err; + } finally { + expect(error).not.toBeNull(); + } + }); + + test("should error if accountId is not a string", () => { + let error = null; + try { + validateCreateStorageAdjustmentParams({ accountId: 1 }); + } catch (err) { + error = err; + } finally { + expect(error).not.toBeNull(); + } + }); +}); diff --git a/packages/api/src/account/validators.ts b/packages/api/src/account/validators.ts index dbdd4f18..aceb2ee2 100644 --- a/packages/api/src/account/validators.ts +++ b/packages/api/src/account/validators.ts @@ -1,7 +1,11 @@ import Joi from "joi"; -import type { UpdateTagsRequest } from "./models"; +import type { + CreateStorageAdjustmentRequest, + UpdateTagsRequest, +} from "./models"; import { fieldsFromUserAuthentication, + fieldsFromAdminAuthentication, validateBodyFromAuthentication, } from "../validators"; @@ -68,3 +72,34 @@ export const validateLeaveArchiveRequest: (data: unknown) => asserts data is { throw validation.error; } }; + +export const validateCreateStorageAdjustmentRequest: ( + data: unknown, +) => asserts data is CreateStorageAdjustmentRequest = ( + data: unknown, +): asserts data is CreateStorageAdjustmentRequest => { + const validation = Joi.object() + .keys({ + ...fieldsFromAdminAuthentication, + storageAmount: Joi.number().integer().not(0).required(), + }) + .validate(data); + if (validation.error !== undefined) { + throw validation.error; + } +}; + +export const validateCreateStorageAdjustmentParams: ( + data: unknown, +) => asserts data is { accountId: string } = ( + data: unknown, +): asserts data is { accountId: string } => { + const validation = Joi.object() + .keys({ + accountId: Joi.string().required(), + }) + .validate(data); + if (validation.error !== undefined) { + throw validation.error; + } +}; diff --git a/packages/api/src/billing/index.ts b/packages/api/src/billing/index.ts deleted file mode 100644 index f857a9ff..00000000 --- a/packages/api/src/billing/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { billingController } from "./controller"; diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index eae7bbe5..262acd5c 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -5,7 +5,7 @@ import { legacyContactController } from "../legacy_contact"; import { accountController } from "../account"; import { archiveController } from "../archive"; import { adminController } from "../admin"; -import { billingController } from "../billing"; +import { storageController } from "../storage"; import { eventController } from "../event"; import { promoController } from "../promo"; import { idpUserController } from "../idpuser"; @@ -21,7 +21,8 @@ apiRoutes.use("/legacy-contact", legacyContactController); apiRoutes.use("/account", accountController); apiRoutes.use("/archive", archiveController); apiRoutes.use("/admin", adminController); -apiRoutes.use("/billing", billingController); +apiRoutes.use("/billing", storageController); // Deprecated path maintained to avoid breaking changes +apiRoutes.use("/storage", storageController); apiRoutes.use("/event", eventController); apiRoutes.use("/promo", promoController); apiRoutes.use("/idpuser", idpUserController); diff --git a/packages/api/src/billing/controller.test.ts b/packages/api/src/storage/controller.test.ts similarity index 81% rename from packages/api/src/billing/controller.test.ts rename to packages/api/src/storage/controller.test.ts index 63dbbfbd..195b073a 100644 --- a/packages/api/src/billing/controller.test.ts +++ b/packages/api/src/storage/controller.test.ts @@ -119,7 +119,7 @@ const checkLedgerEntries = async (giftData: { }); }; -describe("/billing/gift", () => { +describe("/storage/gift", () => { const agent = request(app); beforeEach(async () => { @@ -136,7 +136,7 @@ describe("/billing/gift", () => { }); test("should call verifyUserAuthentication", async () => { - await agent.post("/api/v2/billing/gift"); + await agent.post("/api/v2/storage/gift"); expect(verifyUserAuthentication).toHaveBeenCalled(); }); @@ -145,7 +145,7 @@ describe("/billing/gift", () => { undefined, "13bb917e-7c75-4971-a8ee-b22e82432888", ); - await agent.post("/api/v2/billing/gift").expect(400); + await agent.post("/api/v2/storage/gift").expect(400); }); test("should return invalid request status if email from auth token is not an email", async () => { @@ -153,29 +153,29 @@ describe("/billing/gift", () => { "test", "13bb917e-7c75-4971-a8ee-b22e82432888", ); - await agent.post("/api/v2/billing/gift").expect(400); + await agent.post("/api/v2/storage/gift").expect(400); }); test("should return invalid request status if subject from auth token is missing", async () => { mockVerifyUserAuthentication("test@permanent.org"); - await agent.post("/api/v2/billing/gift").expect(400); + await agent.post("/api/v2/storage/gift").expect(400); }); test("should return invalid request status if subject from auth token is not a uuid", async () => { mockVerifyUserAuthentication("test@permanent.org", "not_a_uuid"); - await agent.post("/api/v2/billing/gift").expect(400); + await agent.post("/api/v2/storage/gift").expect(400); }); test("should return invalid request status if storage amount is missing", async () => { await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ recipientEmails: ["test+recipient@permanent.org"] }) .expect(400); }); test("should return invalid request status if storage amount is wrong type", async () => { await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: "test", recipientEmails: ["test+recipient@permanent.org"], @@ -185,7 +185,7 @@ describe("/billing/gift", () => { test("should return invalid request status if storage amount is non-integer", async () => { await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1.5, recipientEmails: ["test+recipient@permanent.org"], @@ -195,7 +195,7 @@ describe("/billing/gift", () => { test("should return invalid request status if storage amount is less than 1", async () => { await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 0, recipientEmails: ["test+recipient@permanent.org"], @@ -205,28 +205,28 @@ describe("/billing/gift", () => { test("should return invalid request status if recipient emails is missing", async () => { await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1 }) .expect(400); }); test("should return invalid request status if recipient emails is not an array", async () => { await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: "test" }) .expect(400); }); test("should return invalid request status if recipient emails is empty", async () => { await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: [] }) .expect(400); }); test("should return invalid request status if recipient emails contains non-email items", async () => { await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+recipient@permanent.org", "test"], @@ -236,7 +236,7 @@ describe("/billing/gift", () => { test("should return invalid request status if note is wrong type", async () => { await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+recipient@permanent.org"], @@ -246,16 +246,16 @@ describe("/billing/gift", () => { }); test("should return a 500 error if email from auth token has no permanent account", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); mockVerifyUserAuthentication( "not_a_user@permanent.org", "13bb917e-7c75-4971-a8ee-b22e82432888", ); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org"], @@ -264,14 +264,14 @@ describe("/billing/gift", () => { }); test("should create a ledger entry with correct values if recipient already has an account", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); const initialSenderSpace = await getAccountSpace(senderAccountId); const initialRecipientSpace = await getAccountSpace(recipientOneAccountId); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org"] }) .expect(200); await checkLedgerEntries({ @@ -284,13 +284,13 @@ describe("/billing/gift", () => { }); test("successful gift should update account_space for sender", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); const initialAccountSpace = await getAccountSpace(senderAccountId); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org"] }) .expect(200); const updatedAccountSpace = await getAccountSpace(senderAccountId); @@ -317,12 +317,12 @@ describe("/billing/gift", () => { }); test("successful gift should update account_space for recipient", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_emails"); - await db.sql("billing.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_account_space"); const initialAccountSpace = await getAccountSpace(recipientOneAccountId); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org"] }) .expect(200); const updatedAccountSpace = await getAccountSpace(recipientOneAccountId); @@ -349,9 +349,9 @@ describe("/billing/gift", () => { }); test("should create a multiple correct ledger entries if there are multiple recipients", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); const initialSenderSpace = await getAccountSpace(senderAccountId); const initialRecipientOneSpace = await getAccountSpace( @@ -361,7 +361,7 @@ describe("/billing/gift", () => { recipientTwoAccountId, ); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org", "test+2@permanent.org"], @@ -384,12 +384,12 @@ describe("/billing/gift", () => { }); test("should return an invalid request status if sender doesn't have enough storage", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 5, recipientEmails: ["test+1@permanent.org"], @@ -398,9 +398,9 @@ describe("/billing/gift", () => { }); test("should create an invite if recipient doesn't have an account", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); const newUserEmails = [ "test+not_a_user_yet@permanent.org", @@ -408,7 +408,7 @@ describe("/billing/gift", () => { ]; await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: newUserEmails, @@ -426,15 +426,15 @@ describe("/billing/gift", () => { }); test("should not create an invite if recipient already has an invite", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); - await db.sql("billing.fixtures.create_test_invites"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_invites"); const newUserEmails = ["test+already_invited@permanent.org"]; await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: newUserEmails, @@ -449,9 +449,9 @@ describe("/billing/gift", () => { }); test("should create a gift purchase ledger entry if recipient doesn't have an account", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); const initialSenderSpace = await getAccountSpace(senderAccountId); @@ -461,7 +461,7 @@ describe("/billing/gift", () => { ]; await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: newUserEmails, @@ -481,9 +481,9 @@ describe("/billing/gift", () => { }); test("should send invitation email if recipient doesn't have an account", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); const newUserEmails = [ "test+not_a_user_yet@permanent.org", @@ -491,7 +491,7 @@ describe("/billing/gift", () => { ]; await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: newUserEmails, @@ -502,14 +502,14 @@ describe("/billing/gift", () => { }); test("should send gift notification email if recipient does have an account", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); const newUserEmails = ["test+1@permanent.org"]; await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: newUserEmails, @@ -520,10 +520,10 @@ describe("/billing/gift", () => { }); test("should report what happened to each email passed in", async () => { - await db.sql("billing.fixtures.create_test_accounts"); - await db.sql("billing.fixtures.create_test_account_space"); - await db.sql("billing.fixtures.create_test_emails"); - await db.sql("billing.fixtures.create_test_invites"); + await db.sql("storage.fixtures.create_test_accounts"); + await db.sql("storage.fixtures.create_test_account_space"); + await db.sql("storage.fixtures.create_test_emails"); + await db.sql("storage.fixtures.create_test_invites"); const recipientEmails = [ "test+already_invited@permanent.org", @@ -532,7 +532,7 @@ describe("/billing/gift", () => { ]; const results = await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails, @@ -554,7 +554,7 @@ describe("/billing/gift", () => { }); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org"] }) .expect(500); @@ -565,13 +565,13 @@ describe("/billing/gift", () => { const testError = new Error("test error"); const spy = jest.spyOn(db, "sql"); when(spy) - .calledWith("billing.queries.get_invited_emails", { + .calledWith("storage.queries.get_invited_emails", { emails: ["test+1@permanent.org"], }) .mockRejectedValueOnce(testError); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org"] }) .expect(500); @@ -582,13 +582,13 @@ describe("/billing/gift", () => { const testError = new Error("test error"); const spy = jest.spyOn(db, "sql"); when(spy) - .calledWith("billing.queries.get_account_space_for_update", { + .calledWith("storage.queries.get_account_space_for_update", { email: "test@permanent.org", }) .mockRejectedValueOnce(testError); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org"] }) .expect(500); @@ -598,13 +598,13 @@ describe("/billing/gift", () => { test("should log error and return 500 if check for available space finds nothing", async () => { const spy = jest.spyOn(db, "sql"); when(spy) - .calledWith("billing.queries.get_account_space_for_update", { + .calledWith("storage.queries.get_account_space_for_update", { email: "test@permanent.org", }) .mockImplementationOnce(jest.fn().mockResolvedValueOnce({ rows: [] })); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org"] }) .expect(500); @@ -617,7 +617,7 @@ describe("/billing/gift", () => { const testError = new Error("test error"); const spy = jest.spyOn(db, "sql"); when(spy) - .calledWith("billing.queries.get_existing_account_emails", { + .calledWith("storage.queries.get_existing_account_emails", { emails: ["test+1@permanent.org"], }) .mockImplementationOnce( @@ -626,14 +626,14 @@ describe("/billing/gift", () => { .mockResolvedValueOnce({ rows: [{ email: "test+1@permanent.org" }] }), ); when(spy) - .calledWith("billing.queries.get_account_space_for_update", { + .calledWith("storage.queries.get_account_space_for_update", { email: "test@permanent.org", }) .mockImplementationOnce( jest.fn().mockResolvedValueOnce({ rows: [{ spaceLeft: 2 * GB }] }), ); when(spy) - .calledWith("billing.queries.record_gift", { + .calledWith("storage.queries.record_gift", { fromEmail: "test@permanent.org", toEmails: ["test+1@permanent.org"], storageAmountInBytes: 1 * GB, @@ -642,7 +642,7 @@ describe("/billing/gift", () => { .mockRejectedValueOnce(testError); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org"] }) .expect(500); @@ -653,14 +653,14 @@ describe("/billing/gift", () => { const testError = new Error("test error"); const spy = jest.spyOn(db, "sql"); when(spy) - .calledWith("billing.queries.get_account_space_for_update", { + .calledWith("storage.queries.get_account_space_for_update", { email: "test@permanent.org", }) .mockImplementationOnce( jest.fn().mockResolvedValueOnce({ rows: [{ spaceLeft: 2 * GB }] }), ); when(spy) - .calledWith("billing.queries.create_invites", { + .calledWith("storage.queries.create_invites", { emails: ["test+1@permanent.org"], storageAmountInBytes: 1 * GB, tokens: [expect.any(String)], @@ -670,7 +670,7 @@ describe("/billing/gift", () => { .mockRejectedValueOnce(testError); await agent - .post("/api/v2/billing/gift") + .post("/api/v2/storage/gift") .send({ storageAmount: 1, recipientEmails: ["test+1@permanent.org"] }) .expect(500); diff --git a/packages/api/src/billing/controller.ts b/packages/api/src/storage/controller.ts similarity index 92% rename from packages/api/src/billing/controller.ts rename to packages/api/src/storage/controller.ts index d9622522..18389c28 100644 --- a/packages/api/src/billing/controller.ts +++ b/packages/api/src/storage/controller.ts @@ -6,9 +6,9 @@ import { isValidationError } from "../validators/validator_util"; import { issueGift } from "./service"; import { HTTP_STATUS } from "@pdc/http-status-codes"; -export const billingController = Router(); +export const storageController = Router(); -billingController.post( +storageController.post( "/gift", verifyUserAuthentication, async (req: Request, res: Response, next: NextFunction) => { diff --git a/packages/api/src/billing/fixtures/create_test_account_space.sql b/packages/api/src/storage/fixtures/create_test_account_space.sql similarity index 100% rename from packages/api/src/billing/fixtures/create_test_account_space.sql rename to packages/api/src/storage/fixtures/create_test_account_space.sql diff --git a/packages/api/src/billing/fixtures/create_test_accounts.sql b/packages/api/src/storage/fixtures/create_test_accounts.sql similarity index 100% rename from packages/api/src/billing/fixtures/create_test_accounts.sql rename to packages/api/src/storage/fixtures/create_test_accounts.sql diff --git a/packages/api/src/billing/fixtures/create_test_emails.sql b/packages/api/src/storage/fixtures/create_test_emails.sql similarity index 100% rename from packages/api/src/billing/fixtures/create_test_emails.sql rename to packages/api/src/storage/fixtures/create_test_emails.sql diff --git a/packages/api/src/billing/fixtures/create_test_invites.sql b/packages/api/src/storage/fixtures/create_test_invites.sql similarity index 100% rename from packages/api/src/billing/fixtures/create_test_invites.sql rename to packages/api/src/storage/fixtures/create_test_invites.sql diff --git a/packages/api/src/storage/index.ts b/packages/api/src/storage/index.ts new file mode 100644 index 00000000..ce48ec65 --- /dev/null +++ b/packages/api/src/storage/index.ts @@ -0,0 +1 @@ +export { storageController } from "./controller"; diff --git a/packages/api/src/billing/models.ts b/packages/api/src/storage/models.ts similarity index 100% rename from packages/api/src/billing/models.ts rename to packages/api/src/storage/models.ts diff --git a/packages/api/src/billing/queries/create_invites.sql b/packages/api/src/storage/queries/create_invites.sql similarity index 100% rename from packages/api/src/billing/queries/create_invites.sql rename to packages/api/src/storage/queries/create_invites.sql diff --git a/packages/api/src/billing/queries/get_account_space_for_update.sql b/packages/api/src/storage/queries/get_account_space_for_update.sql similarity index 100% rename from packages/api/src/billing/queries/get_account_space_for_update.sql rename to packages/api/src/storage/queries/get_account_space_for_update.sql diff --git a/packages/api/src/billing/queries/get_existing_account_emails.sql b/packages/api/src/storage/queries/get_existing_account_emails.sql similarity index 100% rename from packages/api/src/billing/queries/get_existing_account_emails.sql rename to packages/api/src/storage/queries/get_existing_account_emails.sql diff --git a/packages/api/src/billing/queries/get_invited_emails.sql b/packages/api/src/storage/queries/get_invited_emails.sql similarity index 100% rename from packages/api/src/billing/queries/get_invited_emails.sql rename to packages/api/src/storage/queries/get_invited_emails.sql diff --git a/packages/api/src/billing/queries/record_gift.sql b/packages/api/src/storage/queries/record_gift.sql similarity index 100% rename from packages/api/src/billing/queries/record_gift.sql rename to packages/api/src/storage/queries/record_gift.sql diff --git a/packages/api/src/billing/service.ts b/packages/api/src/storage/service.ts similarity index 93% rename from packages/api/src/billing/service.ts rename to packages/api/src/storage/service.ts index 9c7e5ddd..9d80bce1 100644 --- a/packages/api/src/billing/service.ts +++ b/packages/api/src/storage/service.ts @@ -20,7 +20,7 @@ export const issueGift = async ( requestBody: GiftStorageRequest, ): Promise => { const existingAccountEmailResult = await db - .sql<{ email: string }>("billing.queries.get_existing_account_emails", { + .sql<{ email: string }>("storage.queries.get_existing_account_emails", { emails: requestBody.recipientEmails, }) .catch((err: unknown) => { @@ -37,7 +37,7 @@ export const issueGift = async ( ); const alreadyInvitedEmailResult = await db - .sql<{ email: string }>("billing.queries.get_invited_emails", { + .sql<{ email: string }>("storage.queries.get_invited_emails", { emails: newEmails, }) .catch((err: unknown) => { @@ -61,7 +61,7 @@ export const issueGift = async ( await db.transaction(async (transactionDb) => { const senderStorage = await transactionDb .sql<{ spaceLeft: string }>( - "billing.queries.get_account_space_for_update", + "storage.queries.get_account_space_for_update", { email: requestBody.emailFromAuthToken, }, @@ -88,7 +88,7 @@ export const issueGift = async ( } await transactionDb - .sql("billing.queries.record_gift", { + .sql("storage.queries.record_gift", { fromEmail: requestBody.emailFromAuthToken, toEmails: existingAccountEmails, storageAmountInBytes: requestBody.storageAmount * GB, @@ -100,7 +100,7 @@ export const issueGift = async ( }); await transactionDb - .sql("billing.queries.create_invites", { + .sql("storage.queries.create_invites", { emails: emailsToInvite, storageAmountInBytes: requestBody.storageAmount * GB, tokens: inviteTokens, diff --git a/packages/api/src/billing/validators.ts b/packages/api/src/storage/validators.ts similarity index 100% rename from packages/api/src/billing/validators.ts rename to packages/api/src/storage/validators.ts