diff --git a/backend/__tests__/api/controllers/admin.spec.ts b/backend/__tests__/api/controllers/admin.spec.ts new file mode 100644 index 000000000..9cf789c96 --- /dev/null +++ b/backend/__tests__/api/controllers/admin.spec.ts @@ -0,0 +1,429 @@ +import request, { Test as SuperTest } from "supertest"; +import app from "../../../src/app"; +import { ObjectId } from "mongodb"; +import * as Configuration from "../../../src/init/configuration"; +import * as AdminUuidDal from "../../../src/dal/admin-uids"; +import * as UserDal from "../../../src/dal/user"; +import * as ReportDal from "../../../src/dal/report"; +import GeorgeQueue from "../../../src/queues/george-queue"; +import * as AuthUtil from "../../../src/utils/auth"; +import _ from "lodash"; + +const mockApp = request(app); +const configuration = Configuration.getCachedConfiguration(); +const uid = new ObjectId().toHexString(); + +describe("ApeKeyController", () => { + const isAdminMock = vi.spyOn(AdminUuidDal, "isAdmin"); + + beforeEach(async () => { + isAdminMock.mockReset(); + await enableAdminEndpoints(true); + isAdminMock.mockResolvedValue(true); + }); + + describe("check for admin", () => { + it("should succeed if user is admin", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .get("/admin") + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "OK", + data: null, + }); + + expect(isAdminMock).toHaveBeenCalledWith(uid); + }); + it("should fail if user is no admin", async () => { + await expectFailForNonAdmin( + mockApp.get("/admin").set("authorization", `Uid ${uid}`) + ); + }); + it("should fail if admin endpoints are disabled", async () => { + await expectFailForDisabledEndpoint( + mockApp.get("/admin").set("authorization", `Uid ${uid}`) + ); + }); + }); + + describe("toggle ban", () => { + const userBannedMock = vi.spyOn(UserDal, "setBanned"); + const georgeBannedMock = vi.spyOn(GeorgeQueue, "userBanned"); + const getUserMock = vi.spyOn(UserDal, "getPartialUser"); + + beforeEach(() => { + [userBannedMock, georgeBannedMock, getUserMock].forEach((it) => + it.mockReset() + ); + }); + + it("should ban user with discordId", async () => { + //GIVEN + const victimUid = new ObjectId().toHexString(); + getUserMock.mockResolvedValue({ + banned: false, + discordId: "discordId", + } as any); + + //WHEN + const { body } = await mockApp + .post("/admin/toggleBan") + .send({ uid: victimUid }) + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Ban toggled", + data: { banned: true }, + }); + + expect(getUserMock).toHaveBeenCalledWith(victimUid, "toggle ban", [ + "banned", + "discordId", + ]); + expect(userBannedMock).toHaveBeenCalledWith(victimUid, true); + expect(georgeBannedMock).toHaveBeenCalledWith("discordId", true); + }); + it("should unban user without discordId", async () => { + //GIVEN + const victimUid = new ObjectId().toHexString(); + getUserMock.mockResolvedValue({ + banned: true, + } as any); + + //WHEN + const { body } = await mockApp + .post("/admin/toggleBan") + .send({ uid: victimUid }) + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Ban toggled", + data: { banned: false }, + }); + + expect(getUserMock).toHaveBeenCalledWith(victimUid, "toggle ban", [ + "banned", + "discordId", + ]); + expect(userBannedMock).toHaveBeenCalledWith(victimUid, false); + expect(georgeBannedMock).not.toHaveBeenCalled(); + }); + it("should fail without mandatory properties", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/admin/toggleBan") + .send({}) + .set("authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"uid" Required'], + }); + }); + it("should fail with unknown properties", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/admin/toggleBan") + .send({ uid: new ObjectId().toHexString(), extra: "value" }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("should fail if user is no admin", async () => { + await expectFailForNonAdmin( + mockApp + .post("/admin/toggleBan") + .send({ uid: new ObjectId().toHexString() }) + .set("authorization", `Uid ${uid}`) + ); + }); + it("should fail if admin endpoints are disabled", async () => { + //GIVEN + await expectFailForDisabledEndpoint( + mockApp + .post("/admin/toggleBan") + .send({ uid: new ObjectId().toHexString() }) + .set("authorization", `Uid ${uid}`) + ); + }); + }); + describe("accept reports", () => { + const getReportsMock = vi.spyOn(ReportDal, "getReports"); + const deleteReportsMock = vi.spyOn(ReportDal, "deleteReports"); + const addToInboxMock = vi.spyOn(UserDal, "addToInbox"); + + beforeEach(() => { + [getReportsMock, deleteReportsMock, addToInboxMock].forEach((it) => + it.mockReset() + ); + }); + + it("should accept reports", async () => { + //GIVEN + const reportOne = { + id: "1", + reason: "one", + } as any as MonkeyTypes.Report; + const reportTwo = { + id: "2", + reason: "two", + } as any as MonkeyTypes.Report; + getReportsMock.mockResolvedValue([reportOne, reportTwo]); + + //WHEN + const { body } = await mockApp + .post("/admin/report/accept") + .send({ + reports: [{ reportId: reportOne.id }, { reportId: reportTwo.id }], + }) + .set("authorization", `Uid ${uid}`) + .expect(200); + + expect(body).toEqual({ + message: "Reports removed and users notified.", + data: null, + }); + + expect(addToInboxMock).toBeCalledTimes(2); + expect(deleteReportsMock).toHaveBeenCalledWith(["1", "2"]); + }); + it("should fail wihtout mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/admin/report/accept") + .send({}) + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"reports" Required'], + }); + }); + it("should fail with empty reports", async () => { + //WHEN + const { body } = await mockApp + .post("/admin/report/accept") + .send({ reports: [] }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"reports" Array must contain at least 1 element(s)', + ], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .post("/admin/report/accept") + .send({ reports: [{ reportId: "1", extra2: "value" }], extra: "value" }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + `"reports.0" Unrecognized key(s) in object: 'extra2'`, + "Unrecognized key(s) in object: 'extra'", + ], + }); + }); + it("should fail if user is no admin", async () => { + await expectFailForNonAdmin( + mockApp + .post("/admin/report/accept") + .send({ reports: [] }) + .set("authorization", `Uid ${uid}`) + ); + }); + it("should fail if admin endpoints are disabled", async () => { + //GIVEN + await expectFailForDisabledEndpoint( + mockApp + .post("/admin/report/accept") + .send({ reports: [] }) + .set("authorization", `Uid ${uid}`) + ); + }); + }); + describe("reject reports", () => { + const getReportsMock = vi.spyOn(ReportDal, "getReports"); + const deleteReportsMock = vi.spyOn(ReportDal, "deleteReports"); + const addToInboxMock = vi.spyOn(UserDal, "addToInbox"); + + beforeEach(() => { + [getReportsMock, deleteReportsMock, addToInboxMock].forEach((it) => + it.mockReset() + ); + }); + + it("should reject reports", async () => { + //GIVEN + const reportOne = { + id: "1", + reason: "one", + } as any as MonkeyTypes.Report; + const reportTwo = { + id: "2", + reason: "two", + } as any as MonkeyTypes.Report; + getReportsMock.mockResolvedValue([reportOne, reportTwo]); + + //WHEN + const { body } = await mockApp + .post("/admin/report/reject") + .send({ + reports: [ + { reportId: reportOne.id, reason: "test" }, + { reportId: reportTwo.id }, + ], + }) + .set("authorization", `Uid ${uid}`) + .expect(200); + + expect(body).toEqual({ + message: "Reports removed and users notified.", + data: null, + }); + + expect(addToInboxMock).toHaveBeenCalledTimes(2); + expect(deleteReportsMock).toHaveBeenCalledWith(["1", "2"]); + }); + it("should fail wihtout mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/admin/report/reject") + .send({}) + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"reports" Required'], + }); + }); + it("should fail with empty reports", async () => { + //WHEN + const { body } = await mockApp + .post("/admin/report/reject") + .send({ reports: [] }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"reports" Array must contain at least 1 element(s)', + ], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .post("/admin/report/reject") + .send({ reports: [{ reportId: "1", extra2: "value" }], extra: "value" }) + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + `"reports.0" Unrecognized key(s) in object: 'extra2'`, + "Unrecognized key(s) in object: 'extra'", + ], + }); + }); + it("should fail if user is no admin", async () => { + await expectFailForNonAdmin( + mockApp + .post("/admin/report/reject") + .send({ reports: [] }) + .set("authorization", `Uid ${uid}`) + ); + }); + it("should fail if admin endpoints are disabled", async () => { + //GIVEN + await expectFailForDisabledEndpoint( + mockApp + .post("/admin/report/reject") + .send({ reports: [] }) + .set("authorization", `Uid ${uid}`) + ); + }); + }); + describe("send forgot password email", () => { + const sendForgotPasswordEmailMock = vi.spyOn( + AuthUtil, + "sendForgotPasswordEmail" + ); + + beforeEach(() => { + sendForgotPasswordEmailMock.mockReset(); + }); + + it("should send forgot password link", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/admin/sendForgotPasswordEmail") + .send({ email: "meowdec@example.com" }) + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Password reset request email sent.", + data: null, + }); + + expect(sendForgotPasswordEmailMock).toHaveBeenCalledWith( + "meowdec@example.com" + ); + }); + }); + + async function expectFailForNonAdmin(call: SuperTest): Promise { + isAdminMock.mockResolvedValue(false); + const { body } = await call.expect(403); + expect(body.message).toEqual("You don't have permission to do this."); + } + async function expectFailForDisabledEndpoint(call: SuperTest): Promise { + await enableAdminEndpoints(false); + const { body } = await call.expect(503); + expect(body.message).toEqual("Admin endpoints are currently disabled."); + } +}); +async function enableAdminEndpoints(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + admin: { endpointsEnabled: enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/__tests__/api/controllers/ape-key.spec.ts b/backend/__tests__/api/controllers/ape-key.spec.ts index 0330907d8..4855ee5f4 100644 --- a/backend/__tests__/api/controllers/ape-key.spec.ts +++ b/backend/__tests__/api/controllers/ape-key.spec.ts @@ -1,4 +1,4 @@ -import request from "supertest"; +import request, { Test as SuperTest } from "supertest"; import app from "../../../src/app"; import * as ApeKeyDal from "../../../src/dal/ape-keys"; import { ObjectId } from "mongodb"; @@ -65,31 +65,13 @@ describe("ApeKeyController", () => { expect(getApeKeysMock).toHaveBeenCalledWith(uid); }); it("should fail if apeKeys endpoints are disabled", async () => { - //GIVEN - await enableApeKeysEndpoints(false); - - //WHEN - const { body } = await mockApp - .get("/ape-keys") - .set("authorization", `Uid ${uid}`) - .expect(503); - - //THEN - expect(body.message).toEqual("ApeKeys are currently disabled."); + await expectFailForDisabledEndpoint( + mockApp.get("/ape-keys").set("authorization", `Uid ${uid}`) + ); }); it("should fail if user has no apeKey permissions", async () => { - //GIVEN - - getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false })); - //WHEN - const { body } = await mockApp - .get("/ape-keys") - .set("authorization", `Uid ${uid}`) - .expect(403); - - //THEN - expect(body.message).toEqual( - "You have lost access to ape keys, please contact support" + await expectFailForNoPermissions( + mockApp.get("/ape-keys").set("authorization", `Uid ${uid}`) ); }); }); @@ -190,33 +172,19 @@ describe("ApeKeyController", () => { ); }); it("should fail if apeKeys endpoints are disabled", async () => { - //GIVEN - await enableApeKeysEndpoints(false); - - //WHEN - const { body } = await mockApp - .post("/ape-keys") - .send({ name: "test", enabled: false }) - .set("authorization", `Uid ${uid}`) - .expect(503); - - //THEN - expect(body.message).toEqual("ApeKeys are currently disabled."); + await expectFailForDisabledEndpoint( + mockApp + .post("/ape-keys") + .send({ name: "test", enabled: false }) + .set("authorization", `Uid ${uid}`) + ); }); it("should fail if user has no apeKey permissions", async () => { - //GIVEN - getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false })); - - //WHEN - const { body } = await mockApp - .post("/ape-keys") - .send({ name: "test", enabled: false }) - .set("authorization", `Uid ${uid}`) - .expect(403); - - //THEN - expect(body.message).toEqual( - "You have lost access to ape keys, please contact support" + await expectFailForNoPermissions( + mockApp + .post("/ape-keys") + .send({ name: "test", enabled: false }) + .set("authorization", `Uid ${uid}`) ); }); }); @@ -290,33 +258,19 @@ describe("ApeKeyController", () => { }); }); it("should fail if apeKeys endpoints are disabled", async () => { - //GIVEN - await enableApeKeysEndpoints(false); - - //WHEN - const { body } = await mockApp - .patch(`/ape-keys/${apeKeyId}`) - .send({ name: "test", enabled: false }) - .set("authorization", `Uid ${uid}`) - .expect(503); - - //THEN - expect(body.message).toEqual("ApeKeys are currently disabled."); + await expectFailForDisabledEndpoint( + mockApp + .patch(`/ape-keys/${apeKeyId}`) + .send({ name: "test", enabled: false }) + .set("authorization", `Uid ${uid}`) + ); }); it("should fail if user has no apeKey permissions", async () => { - //GIVEN - getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false })); - - //WHEN - const { body } = await mockApp - .patch(`/ape-keys/${apeKeyId}`) - .send({ name: "test", enabled: false }) - .set("authorization", `Uid ${uid}`) - .expect(403); - - //THEN - expect(body.message).toEqual( - "You have lost access to ape keys, please contact support" + await expectFailForNoPermissions( + mockApp + .patch(`/ape-keys/${apeKeyId}`) + .send({ name: "test", enabled: false }) + .set("authorization", `Uid ${uid}`) ); }); }); @@ -352,35 +306,33 @@ describe("ApeKeyController", () => { .expect(404); }); it("should fail if apeKeys endpoints are disabled", async () => { - //GIVEN - await enableApeKeysEndpoints(false); - - //WHEN - const { body } = await mockApp - .delete(`/ape-keys/${apeKeyId}`) - .set("authorization", `Uid ${uid}`) - .expect(503); - - //THEN - expect(body.message).toEqual("ApeKeys are currently disabled."); + await expectFailForDisabledEndpoint( + mockApp + .delete(`/ape-keys/${apeKeyId}`) + .set("authorization", `Uid ${uid}`) + ); }); it("should fail if user has no apeKey permissions", async () => { - //GIVEN - getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false })); - - //WHEN - const { body } = await mockApp - .delete(`/ape-keys/${apeKeyId}`) - .set("authorization", `Uid ${uid}`) - .expect(403); - - //THEN - expect(body.message).toEqual( - "You have lost access to ape keys, please contact support" + await expectFailForNoPermissions( + mockApp + .delete(`/ape-keys/${apeKeyId}`) + .set("authorization", `Uid ${uid}`) ); }); }); + async function expectFailForNoPermissions(call: SuperTest): Promise { + getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false })); + const { body } = await call.expect(403); + expect(body.message).toEqual( + "You have lost access to ape keys, please contact support" + ); + } + async function expectFailForDisabledEndpoint(call: SuperTest): Promise { + await enableApeKeysEndpoints(false); + const { body } = await call.expect(503); + expect(body.message).toEqual("ApeKeys are currently disabled."); + } }); function apeKeyDb( diff --git a/backend/__tests__/dal/admin-uids.spec.ts b/backend/__tests__/dal/admin-uids.spec.ts new file mode 100644 index 000000000..aecf35355 --- /dev/null +++ b/backend/__tests__/dal/admin-uids.spec.ts @@ -0,0 +1,29 @@ +import { ObjectId } from "mongodb"; +import * as AdminUidsDal from "../../src/dal/admin-uids"; + +describe("AdminUidsDal", () => { + describe("isAdmin", () => { + it("should return true for existing admin user", async () => { + //GIVEN + const uid = new ObjectId().toHexString(); + AdminUidsDal.getCollection().insertOne({ + _id: new ObjectId(), + uid: uid, + }); + + //WHEN / THEN + expect(await AdminUidsDal.isAdmin(uid)).toBe(true); + }); + + it("should return false for non-existing admin user", async () => { + //GIVEN + AdminUidsDal.getCollection().insertOne({ + _id: new ObjectId(), + uid: "admin", + }); + + //WHEN / THEN + expect(await AdminUidsDal.isAdmin("regularUser")).toBe(false); + }); + }); +}); diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index f85ddb50e..75317f4c8 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -66,6 +66,12 @@ export function getOpenApi(): OpenAPIObject { description: "Ape keys provide access to certain API endpoints.", "x-displayName": "Ape Keys", }, + { + name: "admin", + description: + "Various administrative endpoints. Require user to have admin permissions.", + "x-displayName": "Admin", + }, ], }, diff --git a/backend/src/api/controllers/admin.ts b/backend/src/api/controllers/admin.ts index aed23660f..39c37cb14 100644 --- a/backend/src/api/controllers/admin.ts +++ b/backend/src/api/controllers/admin.ts @@ -1,31 +1,75 @@ -import { MonkeyResponse } from "../../utils/monkey-response"; +import { MonkeyResponse2 } from "../../utils/monkey-response"; import { buildMonkeyMail } from "../../utils/monkey-mail"; import * as UserDAL from "../../dal/user"; import * as ReportDAL from "../../dal/report"; +import GeorgeQueue from "../../queues/george-queue"; +import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth"; +import { + AcceptReportsRequest, + RejectReportsRequest, + SendForgotPasswordEmailRequest, + ToggleBanRequest, + ToggleBanResponse, +} from "@monkeytype/contracts/admin"; +import MonkeyError from "../../utils/error"; +import { Configuration } from "@monkeytype/shared-types"; +import { addImportantLog } from "../../dal/logs"; -export async function test(): Promise { - return new MonkeyResponse("OK"); +export async function test( + _req: MonkeyTypes.Request2 +): Promise { + return new MonkeyResponse2("OK", null); +} + +export async function toggleBan( + req: MonkeyTypes.Request2 +): Promise { + const { uid } = req.body; + + const user = await UserDAL.getPartialUser(uid, "toggle ban", [ + "banned", + "discordId", + ]); + const discordId = user.discordId; + const discordIdIsValid = discordId !== undefined && discordId !== ""; + + await UserDAL.setBanned(uid, !user.banned); + if (discordIdIsValid) await GeorgeQueue.userBanned(discordId, !user.banned); + + void addImportantLog("user_ban_toggled", { banned: !user.banned }, uid); + + return new MonkeyResponse2(`Ban toggled`, { + banned: !user.banned, + }); } export async function acceptReports( - req: MonkeyTypes.Request -): Promise { - return handleReports(req, true); + req: MonkeyTypes.Request2 +): Promise { + await handleReports( + req.body.reports.map((it) => ({ ...it })), + true, + req.ctx.configuration.users.inbox + ); + return new MonkeyResponse2("Reports removed and users notified.", null); } export async function rejectReports( - req: MonkeyTypes.Request -): Promise { - return handleReports(req, false); + req: MonkeyTypes.Request2 +): Promise { + await handleReports( + req.body.reports.map((it) => ({ ...it })), + false, + req.ctx.configuration.users.inbox + ); + return new MonkeyResponse2("Reports removed and users notified.", null); } export async function handleReports( - req: MonkeyTypes.Request, - accept: boolean -): Promise { - const { reports } = req.body; - // TODO: remove once this gets converted to ts-rest - // eslint-disable-next-line @typescript-eslint/no-unsafe-return + reports: { reportId: string; reason?: string }[], + accept: boolean, + inboxConfig: Configuration["users"]["inbox"] +): Promise { const reportIds = reports.map(({ reportId }) => reportId); const reportsFromDb = await ReportDAL.getReports(reportIds); @@ -37,10 +81,9 @@ export async function handleReports( ); if (missingReportIds.length > 0) { - return new MonkeyResponse( - `Reports not found for some IDs`, - missingReportIds, - 404 + throw new MonkeyError( + 404, + `Reports not found for some IDs ${missingReportIds.join(",")}` ); } @@ -50,11 +93,7 @@ export async function handleReports( try { const report = reportById.get(reportId); if (!report) { - return new MonkeyResponse( - `Report not found for ID: ${reportId}`, - null, - 404 - ); + throw new MonkeyError(404, `Report not found for ID: ${reportId}`); } let mailBody = ""; @@ -75,14 +114,17 @@ export async function handleReports( subject: mailSubject, body: mailBody, }); - await UserDAL.addToInbox( - report.uid, - [mail], - req.ctx.configuration.users.inbox - ); + await UserDAL.addToInbox(report.uid, [mail], inboxConfig); } catch (e) { - return new MonkeyResponse(e.message, null, e.status); + throw new MonkeyError(e.status, e.message); } } - return new MonkeyResponse("Reports removed and users notified."); +} + +export async function sendForgotPasswordEmail( + req: MonkeyTypes.Request2 +): Promise { + const { email } = req.body; + await authSendForgotPasswordEmail(email); + return new MonkeyResponse2("Password reset request email sent.", null); } diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 7f2b0ab99..641a8aa2d 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -37,6 +37,7 @@ import { UserProfileDetails, } from "@monkeytype/shared-types"; import { addImportantLog, addLog, deleteUserLogs } from "../../dal/logs"; +import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth"; async function verifyCaptcha(captcha: string): Promise { if (!(await verify(captcha))) { @@ -159,29 +160,7 @@ export async function sendForgotPasswordEmail( req: MonkeyTypes.Request ): Promise { const { email } = req.body; - - try { - const uid = (await FirebaseAdmin().auth().getUserByEmail(email)).uid; - const userInfo = await UserDAL.getPartialUser( - uid, - "request forgot password email", - ["name"] - ); - - const link = await FirebaseAdmin() - .auth() - .generatePasswordResetLink(email, { - url: isDevEnvironment() - ? "http://localhost:3000" - : "https://monkeytype.com", - }); - - await emailQueue.sendForgotPasswordEmail(email, userInfo.name, link); - } catch { - return new MonkeyResponse( - "Password reset request received. If the email is valid, you will receive an email shortly." - ); - } + await authSendForgotPasswordEmail(email); return new MonkeyResponse( "Password reset request received. If the email is valid, you will receive an email shortly." ); diff --git a/backend/src/api/routes/admin.ts b/backend/src/api/routes/admin.ts index 4c1bdb93f..882249567 100644 --- a/backend/src/api/routes/admin.ts +++ b/backend/src/api/routes/admin.ts @@ -1,107 +1,46 @@ // import joi from "joi"; -import { Router } from "express"; -import { authenticateRequest } from "../../middlewares/auth"; -import * as AdminController from "../controllers/admin"; import { adminLimit } from "../../middlewares/rate-limit"; -import { sendForgotPasswordEmail, toggleBan } from "../controllers/user"; -import joi from "joi"; +import * as AdminController from "../controllers/admin"; + +import { adminContract } from "@monkeytype/contracts/admin"; +import { initServer } from "@ts-rest/express"; import { validate } from "../../middlewares/configuration"; import { checkIfUserIsAdmin } from "../../middlewares/permission"; -import { asyncHandler } from "../../middlewares/utility"; -import { validateRequest } from "../../middlewares/validation"; +import { callController } from "../ts-rest-adapter"; -const router = Router(); +const commonMiddleware = [ + adminLimit, -router.use( validate({ criteria: (configuration) => { return configuration.admin.endpointsEnabled; }, invalidMessage: "Admin endpoints are currently disabled.", - }) -); - -router.get( - "/", - adminLimit, - authenticateRequest({ - noCache: true, }), checkIfUserIsAdmin(), - asyncHandler(AdminController.test) -); +]; -router.post( - "/toggleBan", - adminLimit, - authenticateRequest({ - noCache: true, - }), - checkIfUserIsAdmin(), - validateRequest({ - body: { - uid: joi.string().required().token(), - }, - }), - asyncHandler(toggleBan) -); - -router.post( - "/report/accept", - authenticateRequest({ - noCache: true, - }), - checkIfUserIsAdmin(), - validateRequest({ - body: { - reports: joi - .array() - .items( - joi.object({ - reportId: joi.string().required(), - }) - ) - .required(), - }, - }), - asyncHandler(AdminController.acceptReports) -); - -router.post( - "/report/reject", - authenticateRequest({ - noCache: true, - }), - checkIfUserIsAdmin(), - validateRequest({ - body: { - reports: joi - .array() - .items( - joi.object({ - reportId: joi.string().required(), - reason: joi.string().optional(), - }) - ) - .required(), - }, - }), - asyncHandler(AdminController.rejectReports) -); - -router.post( - "/sendForgotPasswordEmail", - adminLimit, - authenticateRequest({ - noCache: true, - }), - checkIfUserIsAdmin(), - validateRequest({ - body: { - email: joi.string().email().required(), - }, - }), - asyncHandler(sendForgotPasswordEmail) -); - -export default router; +const s = initServer(); +export default s.router(adminContract, { + test: { + middleware: commonMiddleware, + handler: async (r) => callController(AdminController.test)(r), + }, + toggleBan: { + middleware: commonMiddleware, + handler: async (r) => callController(AdminController.toggleBan)(r), + }, + acceptReports: { + middleware: commonMiddleware, + handler: async (r) => callController(AdminController.acceptReports)(r), + }, + rejectReports: { + middleware: commonMiddleware, + handler: async (r) => callController(AdminController.rejectReports)(r), + }, + sendForgotPasswordEmail: { + middleware: commonMiddleware, + handler: async (r) => + callController(AdminController.sendForgotPasswordEmail)(r), + }, +}); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 7e5bd4cf1..6f3b22b6c 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -47,13 +47,13 @@ const API_ROUTE_MAP = { "/public": publicStats, "/leaderboards": leaderboards, "/quotes": quotes, - "/admin": admin, "/webhooks": webhooks, "/docs": docs, }; const s = initServer(); const router = s.router(contract, { + admin, apeKeys, configs, presets, diff --git a/backend/src/dal/admin-uids.ts b/backend/src/dal/admin-uids.ts index cd2bbfaf5..8e5029f0d 100644 --- a/backend/src/dal/admin-uids.ts +++ b/backend/src/dal/admin-uids.ts @@ -1,7 +1,11 @@ +import { Collection, WithId } from "mongodb"; import * as db from "../init/db"; +export const getCollection = (): Collection> => + db.collection("admin-uids"); + export async function isAdmin(uid: string): Promise { - const doc = await db.collection("admin-uids").findOne({ uid }); + const doc = await getCollection().findOne({ uid }); if (doc) { return true; } else { diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index befc876d4..e76b9440e 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -6,6 +6,9 @@ import { setTokenCacheSize, } from "./prometheus"; import { type DecodedIdToken, UserRecord } from "firebase-admin/auth"; +import { isDevEnvironment } from "./misc"; +import emailQueue from "../queues/email-queue"; +import * as UserDAL from "../dal/user"; const tokenCache = new LRUCache({ max: 20000, @@ -82,3 +85,28 @@ export async function revokeTokensByUid(uid: string): Promise { } } } + +export async function sendForgotPasswordEmail(email: string): Promise { + try { + const uid = (await FirebaseAdmin().auth().getUserByEmail(email)).uid; + const { name } = await UserDAL.getPartialUser( + uid, + "request forgot password email", + ["name"] + ); + + const link = await FirebaseAdmin() + .auth() + .generatePasswordResetLink(email, { + url: isDevEnvironment() + ? "http://localhost:3000" + : "https://monkeytype.com", + }); + + await emailQueue.sendForgotPasswordEmail(email, name, link); + } catch (err) { + if (err.errorInfo?.code !== "auth/user-not-found") { + throw err; + } + } +} diff --git a/packages/contracts/src/admin.ts b/packages/contracts/src/admin.ts new file mode 100644 index 000000000..0d68e7e41 --- /dev/null +++ b/packages/contracts/src/admin.ts @@ -0,0 +1,117 @@ +import { initContract } from "@ts-rest/core"; +import { z } from "zod"; +import { + CommonResponses, + EndpointMetadata, + MonkeyResponseSchema, + responseWithData, +} from "./schemas/api"; +import { IdSchema } from "./schemas/util"; + +export const ToggleBanRequestSchema = z + .object({ + uid: IdSchema, + }) + .strict(); +export type ToggleBanRequest = z.infer; + +export const ToggleBanResponseSchema = responseWithData( + z.object({ + banned: z.boolean(), + }) +).strict(); +export type ToggleBanResponse = z.infer; + +export const AcceptReportsRequestSchema = z + .object({ + reports: z.array(z.object({ reportId: z.string() }).strict()).nonempty(), + }) + .strict(); +export type AcceptReportsRequest = z.infer; + +export const RejectReportsRequestSchema = z + .object({ + reports: z + .array( + z + .object({ reportId: z.string(), reason: z.string().optional() }) + .strict() + ) + .nonempty(), + }) + .strict(); +export type RejectReportsRequest = z.infer; + +export const SendForgotPasswordEmailRequestSchema = z + .object({ + email: z.string().email(), + }) + .strict(); +export type SendForgotPasswordEmailRequest = z.infer< + typeof SendForgotPasswordEmailRequestSchema +>; + +const c = initContract(); +export const adminContract = c.router( + { + test: { + summary: "test permission", + description: "Check for admin permission for the current user", + method: "GET", + path: "/", + responses: { + 200: MonkeyResponseSchema, + }, + }, + toggleBan: { + summary: "toggle user ban", + description: "Ban an unbanned user or unban a banned user.", + method: "POST", + path: "/toggleBan", + body: ToggleBanRequestSchema, + responses: { + 200: ToggleBanResponseSchema, + }, + }, + acceptReports: { + summary: "accept reports", + description: "Accept one or many reports", + method: "POST", + path: "/report/accept", + body: AcceptReportsRequestSchema, + responses: { + 200: MonkeyResponseSchema, + }, + }, + rejectReports: { + summary: "reject reports", + description: "Reject one or many reports", + method: "POST", + path: "/report/reject", + body: RejectReportsRequestSchema, + responses: { + 200: MonkeyResponseSchema, + }, + }, + sendForgotPasswordEmail: { + summary: "send forgot password email", + description: "Send a forgot password email to the given user email", + method: "POST", + path: "/sendForgotPasswordEmail", + body: SendForgotPasswordEmailRequestSchema, + responses: { + 200: MonkeyResponseSchema, + }, + }, + }, + { + pathPrefix: "/admin", + strictStatusCodes: true, + metadata: { + openApiTags: "admin", + authenticationOptions: { noCache: true }, + } as EndpointMetadata, + + commonResponses: CommonResponses, + } +); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 94ab54b91..533e4a3d2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,11 +1,13 @@ import { initContract } from "@ts-rest/core"; +import { adminContract } from "./admin"; +import { apeKeysContract } from "./ape-keys"; import { configsContract } from "./configs"; import { presetsContract } from "./presets"; -import { apeKeysContract } from "./ape-keys"; const c = initContract(); export const contract = c.router({ + admin: adminContract, apeKeys: apeKeysContract, configs: configsContract, presets: presetsContract, diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index 69a1ae299..37d3b6d2e 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -1,6 +1,6 @@ import { z, ZodSchema } from "zod"; -export type OpenApiTag = "configs" | "presets" | "ape-keys"; +export type OpenApiTag = "configs" | "presets" | "ape-keys" | "admin"; export type EndpointMetadata = { /** Authentication options, by default a bearer token is required. */