mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2025-12-29 11:26:13 +08:00
parent
a6912d20af
commit
8a09acd8d5
19 changed files with 642 additions and 263 deletions
422
backend/__tests__/api/controllers/ape-key.spec.ts
Normal file
422
backend/__tests__/api/controllers/ape-key.spec.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
import request from "supertest";
|
||||
import app from "../../../src/app";
|
||||
import * as ApeKeyDal from "../../../src/dal/ape-keys";
|
||||
import { ObjectId } from "mongodb";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
import _ from "lodash";
|
||||
|
||||
const mockApp = request(app);
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
const uid = new ObjectId().toHexString();
|
||||
|
||||
describe("ApeKeyController", () => {
|
||||
const getUserMock = vi.spyOn(UserDal, "getUser");
|
||||
|
||||
beforeEach(async () => {
|
||||
await enableApeKeysEndpoints(true);
|
||||
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: true }));
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getUserMock.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("get ape keys", () => {
|
||||
const getApeKeysMock = vi.spyOn(ApeKeyDal, "getApeKeys");
|
||||
|
||||
afterEach(() => {
|
||||
getApeKeysMock.mockReset();
|
||||
});
|
||||
|
||||
it("should get the users config", async () => {
|
||||
//GIVEN
|
||||
const keyOne = apeKeyDb(uid);
|
||||
const keyTwo = apeKeyDb(uid);
|
||||
getApeKeysMock.mockResolvedValue([keyOne, keyTwo]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/ape-keys")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body).toHaveProperty("message", "ApeKeys retrieved");
|
||||
expect(body.data).toHaveProperty(keyOne._id.toHexString(), {
|
||||
name: keyOne.name,
|
||||
enabled: keyOne.enabled,
|
||||
createdOn: keyOne.createdOn,
|
||||
modifiedOn: keyOne.modifiedOn,
|
||||
lastUsedOn: keyOne.lastUsedOn,
|
||||
});
|
||||
expect(body.data).toHaveProperty(keyTwo._id.toHexString(), {
|
||||
name: keyTwo.name,
|
||||
enabled: keyTwo.enabled,
|
||||
createdOn: keyTwo.createdOn,
|
||||
modifiedOn: keyTwo.modifiedOn,
|
||||
lastUsedOn: keyTwo.lastUsedOn,
|
||||
});
|
||||
expect(body.data).keys([keyOne._id, keyTwo._id]);
|
||||
|
||||
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.");
|
||||
});
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add ape key", () => {
|
||||
const addApeKeyMock = vi.spyOn(ApeKeyDal, "addApeKey");
|
||||
const countApeKeysMock = vi.spyOn(ApeKeyDal, "countApeKeysForUser");
|
||||
|
||||
beforeEach(() => {
|
||||
countApeKeysMock.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
addApeKeyMock.mockReset();
|
||||
countApeKeysMock.mockReset();
|
||||
});
|
||||
|
||||
it("should add ape key", async () => {
|
||||
//GIVEN
|
||||
addApeKeyMock.mockResolvedValue("1");
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/ape-keys")
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.send({ name: "test", enabled: true })
|
||||
.expect(200);
|
||||
|
||||
expect(body.message).toEqual("ApeKey generated");
|
||||
expect(body.data).keys("apeKey", "apeKeyDetails", "apeKeyId");
|
||||
expect(body.data.apeKey).not.toBeNull();
|
||||
|
||||
expect(body.data.apeKeyDetails).toStrictEqual({
|
||||
createdOn: 1000,
|
||||
enabled: true,
|
||||
lastUsedOn: -1,
|
||||
modifiedOn: 1000,
|
||||
name: "test",
|
||||
});
|
||||
|
||||
expect(body.data.apeKeyId).toEqual("1");
|
||||
|
||||
expect(addApeKeyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdOn: 1000,
|
||||
enabled: true,
|
||||
lastUsedOn: -1,
|
||||
modifiedOn: 1000,
|
||||
name: "test",
|
||||
uid: uid,
|
||||
useCount: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/ape-keys")
|
||||
.send({})
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`"name" Required`, `"enabled" Required`],
|
||||
});
|
||||
});
|
||||
it("should fail with extra properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/ape-keys")
|
||||
.send({ name: "test", enabled: true, extra: "value" })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if max apeKeys is reached", async () => {
|
||||
//GIVEN
|
||||
countApeKeysMock.mockResolvedValue(1);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/ape-keys")
|
||||
.send({ name: "test", enabled: false })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(409);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual(
|
||||
"Maximum number of ApeKeys have been generated"
|
||||
);
|
||||
});
|
||||
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.");
|
||||
});
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit ape key", () => {
|
||||
const editApeKeyMock = vi.spyOn(ApeKeyDal, "editApeKey");
|
||||
const apeKeyId = new ObjectId().toHexString();
|
||||
|
||||
afterEach(() => {
|
||||
editApeKeyMock.mockReset();
|
||||
});
|
||||
|
||||
it("should edit ape key", async () => {
|
||||
//GIVEN
|
||||
editApeKeyMock.mockResolvedValue();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch(`/ape-keys/${apeKeyId}`)
|
||||
.send({ name: "new", enabled: false })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("ApeKey updated");
|
||||
expect(editApeKeyMock).toHaveBeenCalledWith(uid, apeKeyId, "new", false);
|
||||
});
|
||||
it("should edit ape key with single property", async () => {
|
||||
//GIVEN
|
||||
editApeKeyMock.mockResolvedValue();
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch(`/ape-keys/${apeKeyId}`)
|
||||
.send({ name: "new" })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("ApeKey updated");
|
||||
expect(editApeKeyMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
apeKeyId,
|
||||
"new",
|
||||
undefined
|
||||
);
|
||||
});
|
||||
it("should fail with missing path", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.patch(`/ape-keys/`)
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(404);
|
||||
});
|
||||
it("should fail with extra properties", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.patch(`/ape-keys/${apeKeyId}`)
|
||||
.send({ name: "new", extra: "value" })
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
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.");
|
||||
});
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("delete ape key", () => {
|
||||
const deleteApeKeyMock = vi.spyOn(ApeKeyDal, "deleteApeKey");
|
||||
const apeKeyId = new ObjectId().toHexString();
|
||||
|
||||
afterEach(() => {
|
||||
deleteApeKeyMock.mockReset();
|
||||
});
|
||||
|
||||
it("should delete ape key", async () => {
|
||||
//GIVEN
|
||||
|
||||
deleteApeKeyMock.mockResolvedValue();
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.delete(`/ape-keys/${apeKeyId}`)
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("ApeKey deleted");
|
||||
expect(deleteApeKeyMock).toHaveBeenCalledWith(uid, apeKeyId);
|
||||
});
|
||||
it("should fail with missing path", async () => {
|
||||
//GIVEN
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.delete(`/ape-keys/`)
|
||||
.set("authorization", `Uid ${uid}`)
|
||||
.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.");
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function apeKeyDb(
|
||||
uid: string,
|
||||
data?: Partial<MonkeyTypes.ApeKeyDB>
|
||||
): MonkeyTypes.ApeKeyDB {
|
||||
return {
|
||||
_id: new ObjectId(),
|
||||
uid,
|
||||
hash: "hash",
|
||||
useCount: 1,
|
||||
name: "name",
|
||||
enabled: true,
|
||||
createdOn: Math.random() * Date.now(),
|
||||
lastUsedOn: Math.random() * Date.now(),
|
||||
modifiedOn: Math.random() * Date.now(),
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
async function enableApeKeysEndpoints(enabled: boolean): Promise<void> {
|
||||
const mockConfig = _.merge(await configuration, {
|
||||
apeKeys: { endpointsEnabled: enabled, maxKeysPerUser: 1 },
|
||||
});
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig
|
||||
);
|
||||
}
|
||||
|
||||
function user(
|
||||
uid: string,
|
||||
data: Partial<MonkeyTypes.DBUser>
|
||||
): MonkeyTypes.DBUser {
|
||||
return {
|
||||
uid,
|
||||
...data,
|
||||
} as MonkeyTypes.DBUser;
|
||||
}
|
||||
|
|
@ -61,6 +61,11 @@ export function getOpenApi(): OpenAPIObject {
|
|||
description: "User specific configuration presets.",
|
||||
"x-displayName": "User presets",
|
||||
},
|
||||
{
|
||||
name: "ape-keys",
|
||||
description: "Ape keys provide access to certain API endpoints.",
|
||||
"x-displayName": "Ape Keys",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -3,29 +3,37 @@ import { randomBytes } from "crypto";
|
|||
import { hash } from "bcrypt";
|
||||
import * as ApeKeysDAL from "../../dal/ape-keys";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { MonkeyResponse2 } from "../../utils/monkey-response";
|
||||
import { base64UrlEncode } from "../../utils/misc";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { ApeKey } from "@monkeytype/shared-types";
|
||||
|
||||
import {
|
||||
AddApeKeyRequest,
|
||||
AddApeKeyResponse,
|
||||
ApeKeyParams,
|
||||
EditApeKeyRequest,
|
||||
GetApeKeyResponse,
|
||||
} from "@monkeytype/contracts/ape-keys";
|
||||
import { ApeKey } from "@monkeytype/contracts/schemas/ape-keys";
|
||||
|
||||
function cleanApeKey(apeKey: MonkeyTypes.ApeKeyDB): ApeKey {
|
||||
return _.omit(apeKey, "hash", "_id", "uid", "useCount");
|
||||
}
|
||||
|
||||
export async function getApeKeys(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetApeKeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const apeKeys = await ApeKeysDAL.getApeKeys(uid);
|
||||
const cleanedKeys = _(apeKeys).keyBy("_id").mapValues(cleanApeKey).value();
|
||||
|
||||
return new MonkeyResponse("ApeKeys retrieved", cleanedKeys);
|
||||
return new MonkeyResponse2("ApeKeys retrieved", cleanedKeys);
|
||||
}
|
||||
|
||||
export async function generateApeKey(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, AddApeKeyRequest>
|
||||
): Promise<AddApeKeyResponse> {
|
||||
const { name, enabled } = req.body;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { maxKeysPerUser, apeKeyBytes, apeKeySaltRounds } =
|
||||
|
|
@ -54,7 +62,7 @@ export async function generateApeKey(
|
|||
|
||||
const apeKeyId = await ApeKeysDAL.addApeKey(apeKey);
|
||||
|
||||
return new MonkeyResponse("ApeKey generated", {
|
||||
return new MonkeyResponse2("ApeKey generated", {
|
||||
apeKey: base64UrlEncode(`${apeKeyId}.${apiKey}`),
|
||||
apeKeyId,
|
||||
apeKeyDetails: cleanApeKey(apeKey),
|
||||
|
|
@ -62,24 +70,24 @@ export async function generateApeKey(
|
|||
}
|
||||
|
||||
export async function editApeKey(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, EditApeKeyRequest, ApeKeyParams>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { apeKeyId } = req.params;
|
||||
const { name, enabled } = req.body;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ApeKeysDAL.editApeKey(uid, apeKeyId as string, name, enabled);
|
||||
await ApeKeysDAL.editApeKey(uid, apeKeyId, name, enabled);
|
||||
|
||||
return new MonkeyResponse("ApeKey updated");
|
||||
return new MonkeyResponse2("ApeKey updated", null);
|
||||
}
|
||||
|
||||
export async function deleteApeKey(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, undefined, ApeKeyParams>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { apeKeyId } = req.params;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
await ApeKeysDAL.deleteApeKey(uid, apeKeyId as string);
|
||||
await ApeKeysDAL.deleteApeKey(uid, apeKeyId);
|
||||
|
||||
return new MonkeyResponse("ApeKey deleted");
|
||||
return new MonkeyResponse2("ApeKey deleted", null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,91 +1,42 @@
|
|||
import joi from "joi";
|
||||
import { Router } from "express";
|
||||
import { authenticateRequest } from "../../middlewares/auth";
|
||||
import * as ApeKeyController from "../controllers/ape-key";
|
||||
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import * as ApeKeyController from "../controllers/ape-key";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
import { checkUserPermissions } from "../../middlewares/permission";
|
||||
import { validate } from "../../middlewares/configuration";
|
||||
import { asyncHandler } from "../../middlewares/utility";
|
||||
import { validateRequest } from "../../middlewares/validation";
|
||||
|
||||
const apeKeyNameSchema = joi
|
||||
.string()
|
||||
.regex(/^[0-9a-zA-Z_.-]+$/)
|
||||
.max(20)
|
||||
.messages({
|
||||
"string.pattern.base": "Invalid ApeKey name",
|
||||
"string.max": "ApeKey name exceeds maximum of 20 characters",
|
||||
});
|
||||
|
||||
const checkIfUserCanManageApeKeys = checkUserPermissions({
|
||||
criteria: (user) => {
|
||||
// Must be an exact check
|
||||
return user.canManageApeKeys !== false;
|
||||
},
|
||||
invalidMessage: "You have lost access to ape keys, please contact support",
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(
|
||||
const commonMiddleware = [
|
||||
validate({
|
||||
criteria: (configuration) => {
|
||||
return configuration.apeKeys.endpointsEnabled;
|
||||
},
|
||||
invalidMessage: "ApeKeys are currently disabled.",
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
authenticateRequest(),
|
||||
RateLimit.apeKeysGet,
|
||||
checkIfUserCanManageApeKeys,
|
||||
asyncHandler(ApeKeyController.getApeKeys)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/",
|
||||
authenticateRequest(),
|
||||
RateLimit.apeKeysGenerate,
|
||||
checkIfUserCanManageApeKeys,
|
||||
validateRequest({
|
||||
body: {
|
||||
name: apeKeyNameSchema.required(),
|
||||
enabled: joi.boolean().required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(ApeKeyController.generateApeKey)
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/:apeKeyId",
|
||||
authenticateRequest(),
|
||||
RateLimit.apeKeysUpdate,
|
||||
checkIfUserCanManageApeKeys,
|
||||
validateRequest({
|
||||
params: {
|
||||
apeKeyId: joi.string().token().required(),
|
||||
},
|
||||
body: {
|
||||
name: apeKeyNameSchema,
|
||||
enabled: joi.boolean(),
|
||||
checkUserPermissions({
|
||||
criteria: (user) => {
|
||||
return user.canManageApeKeys ?? false;
|
||||
},
|
||||
invalidMessage: "You have lost access to ape keys, please contact support",
|
||||
}),
|
||||
asyncHandler(ApeKeyController.editApeKey)
|
||||
);
|
||||
];
|
||||
|
||||
router.delete(
|
||||
"/:apeKeyId",
|
||||
authenticateRequest(),
|
||||
RateLimit.apeKeysDelete,
|
||||
checkIfUserCanManageApeKeys,
|
||||
validateRequest({
|
||||
params: {
|
||||
apeKeyId: joi.string().token().required(),
|
||||
},
|
||||
}),
|
||||
asyncHandler(ApeKeyController.deleteApeKey)
|
||||
);
|
||||
|
||||
export default router;
|
||||
const s = initServer();
|
||||
export default s.router(apeKeysContract, {
|
||||
get: {
|
||||
middleware: [...commonMiddleware, RateLimit.apeKeysGet],
|
||||
handler: async (r) => callController(ApeKeyController.getApeKeys)(r),
|
||||
},
|
||||
add: {
|
||||
middleware: [...commonMiddleware, RateLimit.apeKeysGenerate],
|
||||
handler: async (r) => callController(ApeKeyController.generateApeKey)(r),
|
||||
},
|
||||
save: {
|
||||
middleware: [...commonMiddleware, RateLimit.apeKeysUpdate],
|
||||
handler: async (r) => callController(ApeKeyController.editApeKey)(r),
|
||||
},
|
||||
delete: {
|
||||
middleware: [...commonMiddleware, RateLimit.apeKeysDelete],
|
||||
handler: async (r) => callController(ApeKeyController.deleteApeKey)(r),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ const API_ROUTE_MAP = {
|
|||
"/public": publicStats,
|
||||
"/leaderboards": leaderboards,
|
||||
"/quotes": quotes,
|
||||
"/ape-keys": apeKeys,
|
||||
"/admin": admin,
|
||||
"/webhooks": webhooks,
|
||||
"/docs": docs,
|
||||
|
|
@ -55,6 +54,7 @@ const API_ROUTE_MAP = {
|
|||
|
||||
const s = initServer();
|
||||
const router = s.router(contract, {
|
||||
apeKeys,
|
||||
configs,
|
||||
presets,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,8 +34,7 @@ export async function getApeKey(
|
|||
}
|
||||
|
||||
export async function countApeKeysForUser(uid: string): Promise<number> {
|
||||
const apeKeys = await getApeKeys(uid);
|
||||
return _.size(apeKeys);
|
||||
return getApeKeysCollection().countDocuments({ uid });
|
||||
}
|
||||
|
||||
export async function addApeKey(apeKey: MonkeyTypes.ApeKeyDB): Promise<string> {
|
||||
|
|
@ -64,9 +63,11 @@ async function updateApeKey(
|
|||
export async function editApeKey(
|
||||
uid: string,
|
||||
keyId: string,
|
||||
name: string,
|
||||
enabled: boolean
|
||||
name?: string,
|
||||
enabled?: boolean
|
||||
): Promise<void> {
|
||||
//check if there is a change
|
||||
if (name === undefined && enabled === undefined) return;
|
||||
const apeKeyUpdates = {
|
||||
name,
|
||||
enabled,
|
||||
|
|
|
|||
|
|
@ -27,10 +27,6 @@
|
|||
"name": "psas",
|
||||
"description": "Public service announcements"
|
||||
},
|
||||
{
|
||||
"name": "ape-keys",
|
||||
"description": "ApeKey data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "leaderboards",
|
||||
"description": "Leaderboard data"
|
||||
|
|
@ -434,92 +430,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/ape-keys": {
|
||||
"get": {
|
||||
"tags": ["ape-keys"],
|
||||
"summary": "Gets ApeKeys created by a user",
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["ape-keys"],
|
||||
"summary": "Creates an ApeKey",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ape-keys/{apeKeyId}": {
|
||||
"patch": {
|
||||
"tags": ["ape-keys"],
|
||||
"summary": "Updates an ApeKey",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "apeKeyId",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["ape-keys"],
|
||||
"summary": "Deletes an ApeKey",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "apeKeyId",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
|
|
|
|||
2
backend/src/types/types.d.ts
vendored
2
backend/src/types/types.d.ts
vendored
|
|
@ -76,7 +76,7 @@ declare namespace MonkeyTypes {
|
|||
_id: ObjectId;
|
||||
};
|
||||
|
||||
type ApeKeyDB = import("@monkeytype/shared-types").ApeKey & {
|
||||
type ApeKeyDB = import("@monkeytype/contracts/schemas/ape-keys").ApeKey & {
|
||||
_id: ObjectId;
|
||||
uid: string;
|
||||
hash: string;
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
const BASE_PATH = "/ape-keys";
|
||||
|
||||
export default class ApeKeys {
|
||||
constructor(private httpClient: Ape.HttpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
async get(): Ape.EndpointResponse<Ape.ApeKeys.GetApeKeys> {
|
||||
return await this.httpClient.get(BASE_PATH);
|
||||
}
|
||||
|
||||
async generate(
|
||||
name: string,
|
||||
enabled: boolean
|
||||
): Ape.EndpointResponse<Ape.ApeKeys.GenerateApeKey> {
|
||||
const payload = { name, enabled };
|
||||
return await this.httpClient.post(BASE_PATH, { payload });
|
||||
}
|
||||
|
||||
async update(
|
||||
apeKeyId: string,
|
||||
updates: { name?: string; enabled?: boolean }
|
||||
): Ape.EndpointResponse<null> {
|
||||
const payload = { ...updates };
|
||||
const encoded = encodeURIComponent(apeKeyId);
|
||||
return await this.httpClient.patch(`${BASE_PATH}/${encoded}`, { payload });
|
||||
}
|
||||
|
||||
async delete(apeKeyId: string): Ape.EndpointResponse<null> {
|
||||
const encoded = encodeURIComponent(apeKeyId);
|
||||
return await this.httpClient.delete(`${BASE_PATH}/${encoded}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ import Psas from "./psas";
|
|||
import Quotes from "./quotes";
|
||||
import Results from "./results";
|
||||
import Users from "./users";
|
||||
import ApeKeys from "./ape-keys";
|
||||
import Public from "./public";
|
||||
import Configuration from "./configuration";
|
||||
import Dev from "./dev";
|
||||
|
|
@ -15,7 +14,6 @@ export default {
|
|||
Quotes,
|
||||
Results,
|
||||
Users,
|
||||
ApeKeys,
|
||||
Configuration,
|
||||
Dev,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { envConfig } from "../constants/env-config";
|
|||
import { buildClient } from "./adapters/ts-rest-adapter";
|
||||
import { configsContract } from "@monkeytype/contracts/configs";
|
||||
import { presetsContract } from "@monkeytype/contracts/presets";
|
||||
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
|
||||
|
||||
const API_PATH = "";
|
||||
const BASE_URL = envConfig.backendUrl;
|
||||
|
|
@ -21,7 +22,7 @@ const Ape = {
|
|||
leaderboards: new endpoints.Leaderboards(httpClient),
|
||||
presets: buildClient(presetsContract, BASE_URL, 10_000),
|
||||
publicStats: new endpoints.Public(httpClient),
|
||||
apeKeys: new endpoints.ApeKeys(httpClient),
|
||||
apeKeys: buildClient(apeKeysContract, BASE_URL, 10_000),
|
||||
configuration: new endpoints.Configuration(httpClient),
|
||||
dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),
|
||||
};
|
||||
|
|
|
|||
11
frontend/src/ts/ape/types/ape-keys.d.ts
vendored
11
frontend/src/ts/ape/types/ape-keys.d.ts
vendored
|
|
@ -1,11 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// for some reason when using the dot notaion, the types are not being recognized as used
|
||||
declare namespace Ape.ApeKeys {
|
||||
type GetApeKeys = Record<string, import("@monkeytype/shared-types").ApeKey>;
|
||||
|
||||
type GenerateApeKey = {
|
||||
apeKey: string;
|
||||
apeKeyId: string;
|
||||
apeKeyDetails: import("@monkeytype/shared-types").ApeKey;
|
||||
};
|
||||
}
|
||||
|
|
@ -5,9 +5,9 @@ import { format } from "date-fns/format";
|
|||
import * as ConnectionState from "../states/connection";
|
||||
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
|
||||
import { showPopup } from "./simple-modals";
|
||||
import { ApeKey } from "@monkeytype/shared-types";
|
||||
import { ApeKey, ApeKeys } from "@monkeytype/contracts/schemas/ape-keys";
|
||||
|
||||
let apeKeys: Ape.ApeKeys.GetApeKeys | null = {};
|
||||
let apeKeys: ApeKeys | null = {};
|
||||
|
||||
async function getData(): Promise<void> {
|
||||
Loader.show();
|
||||
|
|
@ -15,11 +15,11 @@ async function getData(): Promise<void> {
|
|||
Loader.hide();
|
||||
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Error getting ape keys: " + response.message, -1);
|
||||
Notifications.add("Error getting ape keys: " + response.body.message, -1);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
apeKeys = response.data;
|
||||
apeKeys = response.body.data;
|
||||
}
|
||||
|
||||
function refreshList(): void {
|
||||
|
|
@ -113,10 +113,13 @@ async function toggleActiveKey(keyId: string): Promise<void> {
|
|||
const key = apeKeys?.[keyId];
|
||||
if (!key || apeKeys === undefined) return;
|
||||
Loader.show();
|
||||
const response = await Ape.apeKeys.update(keyId, { enabled: !key.enabled });
|
||||
const response = await Ape.apeKeys.save({
|
||||
params: { apeKeyId: keyId },
|
||||
body: { enabled: !key.enabled },
|
||||
});
|
||||
Loader.hide();
|
||||
if (response.status !== 200) {
|
||||
Notifications.add("Failed to update key: " + response.message, -1);
|
||||
Notifications.add("Failed to update key: " + response.body.message, -1);
|
||||
return;
|
||||
}
|
||||
key.enabled = !key.enabled;
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ type SimpleModalOptions = {
|
|||
title: string;
|
||||
inputs?: CommonInputType[];
|
||||
text?: string;
|
||||
textAllowHtml?: boolean;
|
||||
buttonText: string;
|
||||
execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise<ExecReturn>;
|
||||
beforeInitFn?: (thisPopup: SimpleModal) => void;
|
||||
|
|
@ -197,6 +198,7 @@ class SimpleModal {
|
|||
title: string;
|
||||
inputs: CommonInputType[];
|
||||
text?: string;
|
||||
textAllowHtml: boolean;
|
||||
buttonText: string;
|
||||
execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise<ExecReturn>;
|
||||
beforeInitFn: ((thisPopup: SimpleModal) => void) | undefined;
|
||||
|
|
@ -212,6 +214,7 @@ class SimpleModal {
|
|||
this.title = options.title;
|
||||
this.inputs = options.inputs ?? [];
|
||||
this.text = options.text;
|
||||
this.textAllowHtml = options.textAllowHtml ?? false;
|
||||
this.wrapper = modal.getWrapper();
|
||||
this.element = modal.getModal();
|
||||
this.buttonText = options.buttonText;
|
||||
|
|
@ -236,7 +239,11 @@ class SimpleModal {
|
|||
this.reset();
|
||||
el.attr("data-popup-id", this.id);
|
||||
el.find(".title").text(this.title);
|
||||
el.find(".text").text(this.text ?? "");
|
||||
if (this.textAllowHtml) {
|
||||
el.find(".text").html(this.text ?? "");
|
||||
} else {
|
||||
el.find(".text").text(this.text ?? "");
|
||||
}
|
||||
|
||||
this.initInputs();
|
||||
|
||||
|
|
@ -1468,16 +1475,15 @@ list.generateApeKey = new SimpleModal({
|
|||
buttonText: "generate",
|
||||
onlineOnly: true,
|
||||
execFn: async (_thisPopup, name): Promise<ExecReturn> => {
|
||||
const response = await Ape.apeKeys.generate(name, false);
|
||||
const response = await Ape.apeKeys.add({ body: { name, enabled: false } });
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to generate key: " + response.message,
|
||||
message: "Failed to generate key: " + response.body.message,
|
||||
};
|
||||
}
|
||||
|
||||
//if response is 200 data is guaranteed to not be null
|
||||
const data = response.data as Ape.ApeKeys.GenerateApeKey;
|
||||
const data = response.body.data;
|
||||
|
||||
const modalChain = modal.getPreviousModalInChain();
|
||||
return {
|
||||
|
|
@ -1508,7 +1514,10 @@ list.viewApeKey = new SimpleModal({
|
|||
initVal: "",
|
||||
},
|
||||
],
|
||||
text: "This is your new Ape Key. Please keep it safe. You will only see it once!",
|
||||
textAllowHtml: true,
|
||||
text: `
|
||||
This is your new Ape Key. Please keep it safe. You will only see it once!<br><br>
|
||||
<strong>Note:</strong> Ape Keys are disabled by default, you need to enable them before they can be used.`,
|
||||
buttonText: "close",
|
||||
hideCallsExec: true,
|
||||
execFn: async (_thisPopup): Promise<ExecReturn> => {
|
||||
|
|
@ -1543,11 +1552,13 @@ list.deleteApeKey = new SimpleModal({
|
|||
buttonText: "delete",
|
||||
onlineOnly: true,
|
||||
execFn: async (_thisPopup): Promise<ExecReturn> => {
|
||||
const response = await Ape.apeKeys.delete(_thisPopup.parameters[0] ?? "");
|
||||
const response = await Ape.apeKeys.delete({
|
||||
params: { apeKeyId: _thisPopup.parameters[0] ?? "" },
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to delete key: " + response.message,
|
||||
message: "Failed to delete key: " + response.body.message,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1574,13 +1585,16 @@ list.editApeKey = new SimpleModal({
|
|||
buttonText: "edit",
|
||||
onlineOnly: true,
|
||||
execFn: async (_thisPopup, input): Promise<ExecReturn> => {
|
||||
const response = await Ape.apeKeys.update(_thisPopup.parameters[0] ?? "", {
|
||||
name: input,
|
||||
const response = await Ape.apeKeys.save({
|
||||
params: { apeKeyId: _thisPopup.parameters[0] ?? "" },
|
||||
body: {
|
||||
name: input,
|
||||
},
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
status: -1,
|
||||
message: "Failed to update key: " + response.message,
|
||||
message: "Failed to update key: " + response.body.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
96
packages/contracts/src/ape-keys.ts
Normal file
96
packages/contracts/src/ape-keys.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { initContract } from "@ts-rest/core";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CommonResponses,
|
||||
EndpointMetadata,
|
||||
MonkeyResponseSchema,
|
||||
responseWithData,
|
||||
} from "./schemas/api";
|
||||
|
||||
import { IdSchema } from "./schemas/util";
|
||||
import {
|
||||
ApeKeySchema,
|
||||
ApeKeysSchema,
|
||||
ApeKeyUserDefinedSchema,
|
||||
} from "./schemas/ape-keys";
|
||||
|
||||
export const GetApeKeyResponseSchema = responseWithData(ApeKeysSchema);
|
||||
export type GetApeKeyResponse = z.infer<typeof GetApeKeyResponseSchema>;
|
||||
|
||||
export const AddApeKeyRequestSchema = ApeKeyUserDefinedSchema;
|
||||
export type AddApeKeyRequest = z.infer<typeof AddApeKeyRequestSchema>;
|
||||
|
||||
export const AddApeKeyResponseSchema = responseWithData(
|
||||
z.object({
|
||||
apeKeyId: IdSchema,
|
||||
apeKey: z.string().base64(),
|
||||
apeKeyDetails: ApeKeySchema,
|
||||
})
|
||||
);
|
||||
export type AddApeKeyResponse = z.infer<typeof AddApeKeyResponseSchema>;
|
||||
|
||||
export const EditApeKeyRequestSchema = AddApeKeyRequestSchema.partial();
|
||||
export type EditApeKeyRequest = z.infer<typeof EditApeKeyRequestSchema>;
|
||||
|
||||
export const ApeKeyParamsSchema = z.object({
|
||||
apeKeyId: IdSchema,
|
||||
});
|
||||
export type ApeKeyParams = z.infer<typeof ApeKeyParamsSchema>;
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const apeKeysContract = c.router(
|
||||
{
|
||||
get: {
|
||||
summary: "get ape keys",
|
||||
description: "Get ape keys of the current user.",
|
||||
method: "GET",
|
||||
path: "/",
|
||||
responses: {
|
||||
200: GetApeKeyResponseSchema,
|
||||
},
|
||||
},
|
||||
add: {
|
||||
summary: "add ape key",
|
||||
description: "Add an ape key for the current user.",
|
||||
method: "POST",
|
||||
path: "/",
|
||||
body: AddApeKeyRequestSchema.strict(),
|
||||
responses: {
|
||||
200: AddApeKeyResponseSchema,
|
||||
},
|
||||
},
|
||||
save: {
|
||||
summary: "update ape key",
|
||||
description: "Update an existing ape key for the current user.",
|
||||
method: "PATCH",
|
||||
path: "/:apeKeyId",
|
||||
pathParams: ApeKeyParamsSchema,
|
||||
body: EditApeKeyRequestSchema.strict(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
summary: "delete ape key",
|
||||
description: "Delete ape key by id.",
|
||||
method: "DELETE",
|
||||
path: "/:apeKeyId",
|
||||
pathParams: ApeKeyParamsSchema,
|
||||
body: c.noBody(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pathPrefix: "/ape-keys",
|
||||
strictStatusCodes: true,
|
||||
metadata: {
|
||||
openApiTags: "ape-keys",
|
||||
} as EndpointMetadata,
|
||||
|
||||
commonResponses: CommonResponses,
|
||||
}
|
||||
);
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { initContract } from "@ts-rest/core";
|
||||
import { configsContract } from "./configs";
|
||||
import { presetsContract } from "./presets";
|
||||
import { apeKeysContract } from "./ape-keys";
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
apeKeys: apeKeysContract,
|
||||
configs: configsContract,
|
||||
presets: presetsContract,
|
||||
});
|
||||
|
|
|
|||
20
packages/contracts/src/schemas/ape-keys.ts
Normal file
20
packages/contracts/src/schemas/ape-keys.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { z } from "zod";
|
||||
import { IdSchema } from "./util";
|
||||
|
||||
export const ApeKeyUserDefinedSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.regex(/^[0-9a-zA-Z_.-]+$/)
|
||||
.max(20),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const ApeKeySchema = ApeKeyUserDefinedSchema.extend({
|
||||
createdOn: z.number().min(0),
|
||||
modifiedOn: z.number().min(0),
|
||||
lastUsedOn: z.number().min(0).or(z.literal(-1)),
|
||||
});
|
||||
export type ApeKey = z.infer<typeof ApeKeySchema>;
|
||||
|
||||
export const ApeKeysSchema = z.record(IdSchema, ApeKeySchema);
|
||||
export type ApeKeys = z.infer<typeof ApeKeysSchema>;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { z, ZodSchema } from "zod";
|
||||
|
||||
export type OpenApiTag = "configs" | "presets";
|
||||
export type OpenApiTag = "configs" | "presets" | "ape-keys";
|
||||
|
||||
export type EndpointMetadata = {
|
||||
/** Authentication options, by default a bearer token is required. */
|
||||
|
|
|
|||
|
|
@ -320,14 +320,6 @@ export type PublicTypingStats = {
|
|||
testsStarted: number;
|
||||
};
|
||||
|
||||
export type ApeKey = {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
createdOn: number;
|
||||
modifiedOn: number;
|
||||
lastUsedOn: number;
|
||||
};
|
||||
|
||||
export type LeaderboardEntry = {
|
||||
_id: string;
|
||||
wpm: number;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue