mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-11-09 13:44:29 +08:00
parent
9f9663682d
commit
460f803bca
13 changed files with 775 additions and 248 deletions
429
backend/__tests__/api/controllers/admin.spec.ts
Normal file
429
backend/__tests__/api/controllers/admin.spec.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
await enableAdminEndpoints(false);
|
||||
const { body } = await call.expect(503);
|
||||
expect(body.message).toEqual("Admin endpoints are currently disabled.");
|
||||
}
|
||||
});
|
||||
async function enableAdminEndpoints(enabled: boolean): Promise<void> {
|
||||
const mockConfig = _.merge(await configuration, {
|
||||
admin: { endpointsEnabled: enabled },
|
||||
});
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig
|
||||
);
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
await enableApeKeysEndpoints(false);
|
||||
const { body } = await call.expect(503);
|
||||
expect(body.message).toEqual("ApeKeys are currently disabled.");
|
||||
}
|
||||
});
|
||||
|
||||
function apeKeyDb(
|
||||
|
|
|
|||
29
backend/__tests__/dal/admin-uids.spec.ts
Normal file
29
backend/__tests__/dal/admin-uids.spec.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MonkeyResponse> {
|
||||
return new MonkeyResponse("OK");
|
||||
export async function test(
|
||||
_req: MonkeyTypes.Request2
|
||||
): Promise<MonkeyResponse2> {
|
||||
return new MonkeyResponse2("OK", null);
|
||||
}
|
||||
|
||||
export async function toggleBan(
|
||||
req: MonkeyTypes.Request2<undefined, ToggleBanRequest>
|
||||
): Promise<ToggleBanResponse> {
|
||||
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<MonkeyResponse> {
|
||||
return handleReports(req, true);
|
||||
req: MonkeyTypes.Request2<undefined, AcceptReportsRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
return handleReports(req, false);
|
||||
req: MonkeyTypes.Request2<undefined, RejectReportsRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
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<void> {
|
||||
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<undefined, SendForgotPasswordEmailRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { email } = req.body;
|
||||
await authSendForgotPasswordEmail(email);
|
||||
return new MonkeyResponse2("Password reset request email sent.", null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
if (!(await verify(captcha))) {
|
||||
|
|
@ -159,29 +160,7 @@ export async function sendForgotPasswordEmail(
|
|||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
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."
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { Collection, WithId } from "mongodb";
|
||||
import * as db from "../init/db";
|
||||
|
||||
export const getCollection = (): Collection<WithId<{ uid: string }>> =>
|
||||
db.collection("admin-uids");
|
||||
|
||||
export async function isAdmin(uid: string): Promise<boolean> {
|
||||
const doc = await db.collection("admin-uids").findOne({ uid });
|
||||
const doc = await getCollection().findOne({ uid });
|
||||
if (doc) {
|
||||
return true;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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<string, DecodedIdToken>({
|
||||
max: 20000,
|
||||
|
|
@ -82,3 +85,28 @@ export async function revokeTokensByUid(uid: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendForgotPasswordEmail(email: string): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
packages/contracts/src/admin.ts
Normal file
117
packages/contracts/src/admin.ts
Normal file
|
|
@ -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<typeof ToggleBanRequestSchema>;
|
||||
|
||||
export const ToggleBanResponseSchema = responseWithData(
|
||||
z.object({
|
||||
banned: z.boolean(),
|
||||
})
|
||||
).strict();
|
||||
export type ToggleBanResponse = z.infer<typeof ToggleBanResponseSchema>;
|
||||
|
||||
export const AcceptReportsRequestSchema = z
|
||||
.object({
|
||||
reports: z.array(z.object({ reportId: z.string() }).strict()).nonempty(),
|
||||
})
|
||||
.strict();
|
||||
export type AcceptReportsRequest = z.infer<typeof AcceptReportsRequestSchema>;
|
||||
|
||||
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<typeof RejectReportsRequestSchema>;
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue