This commit is contained in:
580
backend/__tests__/middlewares/auth.spec.ts
Normal file
580
backend/__tests__/middlewares/auth.spec.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as AuthUtils from "../../src/utils/auth";
|
||||
import * as Auth from "../../src/middlewares/auth";
|
||||
import { DecodedIdToken } from "firebase-admin/auth";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { getCachedConfiguration } from "../../src/init/configuration";
|
||||
import * as ApeKeys from "../../src/dal/ape-keys";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { hashSync } from "bcrypt";
|
||||
import MonkeyError from "../../src/utils/error";
|
||||
import * as Misc from "../../src/utils/misc";
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
EndpointMetadata,
|
||||
RequestAuthenticationOptions,
|
||||
} from "@monkeytype/contracts/util/api";
|
||||
import * as Prometheus from "../../src/utils/prometheus";
|
||||
import { TsRestRequestWithContext } from "../../src/api/types";
|
||||
import { enableMonkeyErrorExpects } from "../__testData__/monkey-error";
|
||||
|
||||
enableMonkeyErrorExpects();
|
||||
const mockDecodedToken: DecodedIdToken = {
|
||||
uid: "123456789",
|
||||
email: "newuser@mail.com",
|
||||
iat: 0,
|
||||
} as DecodedIdToken;
|
||||
|
||||
vi.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken);
|
||||
|
||||
const mockApeKey = {
|
||||
_id: new ObjectId(),
|
||||
uid: "123",
|
||||
name: "test",
|
||||
hash: hashSync("key", 5),
|
||||
createdOn: Date.now(),
|
||||
modifiedOn: Date.now(),
|
||||
lastUsedOn: Date.now(),
|
||||
useCount: 0,
|
||||
enabled: true,
|
||||
};
|
||||
vi.spyOn(ApeKeys, "getApeKey").mockResolvedValue(mockApeKey);
|
||||
vi.spyOn(ApeKeys, "updateLastUsedOn").mockResolvedValue();
|
||||
const isDevModeMock = vi.spyOn(Misc, "isDevEnvironment");
|
||||
let mockRequest: Partial<TsRestRequestWithContext>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let nextFunction: NextFunction;
|
||||
|
||||
describe("middlewares/auth", () => {
|
||||
beforeEach(async () => {
|
||||
isDevModeMock.mockReturnValue(true);
|
||||
let config = await getCachedConfiguration(true);
|
||||
config.apeKeys.acceptKeys = true;
|
||||
|
||||
mockRequest = {
|
||||
baseUrl: "/api/v1",
|
||||
route: {
|
||||
path: "/",
|
||||
},
|
||||
headers: {
|
||||
authorization: "Bearer 123456789",
|
||||
},
|
||||
ctx: {
|
||||
configuration: config,
|
||||
decodedToken: {
|
||||
type: "None",
|
||||
uid: "",
|
||||
email: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockResponse = {
|
||||
json: vi.fn(),
|
||||
};
|
||||
nextFunction = vi.fn((error) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return "Next function called";
|
||||
}) as unknown as NextFunction;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
isDevModeMock.mockClear();
|
||||
});
|
||||
|
||||
describe("authenticateTsRestRequest", () => {
|
||||
const prometheusRecordAuthTimeMock = vi.spyOn(Prometheus, "recordAuthTime");
|
||||
const prometheusIncrementAuthMock = vi.spyOn(Prometheus, "incrementAuth");
|
||||
const timingSafeEqualMock = vi.spyOn(crypto, "timingSafeEqual");
|
||||
|
||||
beforeEach(() => {
|
||||
timingSafeEqualMock.mockClear().mockReturnValue(true);
|
||||
[prometheusIncrementAuthMock, prometheusRecordAuthTimeMock].forEach(
|
||||
(it) => it.mockClear(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail if token is not fresh", async () => {
|
||||
//GIVEN
|
||||
Date.now = vi.fn(() => 60001);
|
||||
const expectedError = new MonkeyError(
|
||||
401,
|
||||
"Unauthorized\nStack: This endpoint requires a fresh token",
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await expect(() =>
|
||||
authenticate({}, { requireFreshToken: true }),
|
||||
).rejects.toMatchMonkeyError(expectedError);
|
||||
|
||||
//THEN
|
||||
|
||||
expect(nextFunction).toHaveBeenLastCalledWith(
|
||||
expect.toMatchMonkeyError(expectedError),
|
||||
);
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow the request if token is fresh", async () => {
|
||||
//GIVEN
|
||||
Date.now = vi.fn(() => 10000);
|
||||
|
||||
//WHEN
|
||||
const result = await authenticate({}, { requireFreshToken: true });
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe(mockDecodedToken.email);
|
||||
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
|
||||
expect(nextFunction).toHaveBeenCalledOnce();
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("Bearer");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow the request if apeKey is supported", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("ApeKey");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should fail with apeKey if apeKey is not supported", async () => {
|
||||
//WHEN
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: false },
|
||||
),
|
||||
).rejects.toThrow("This endpoint does not accept ApeKeys");
|
||||
|
||||
//THEN
|
||||
});
|
||||
it("should fail with apeKey if apeKeys are disabled", async () => {
|
||||
//GIVEN
|
||||
|
||||
//@ts-expect-error
|
||||
mockRequest.ctx.configuration.apeKeys.acceptKeys = false;
|
||||
|
||||
//WHEN
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: false },
|
||||
),
|
||||
).rejects.toThrow("ApeKeys are not being accepted at this time");
|
||||
|
||||
//THEN
|
||||
});
|
||||
it("should allow the request with authentation on public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate({}, { isPublic: true });
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe(mockDecodedToken.email);
|
||||
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should allow the request without authentication on public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate({ headers: {} }, { isPublic: true });
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("None");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("None");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow the request with apeKey on public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ isPublic: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("ApeKey");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow request with Uid on dev", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate({
|
||||
headers: { authorization: "Uid 123" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should allow request with Uid and email on dev", async () => {
|
||||
const result = await authenticate({
|
||||
headers: { authorization: "Uid 123|test@example.com" },
|
||||
});
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe("test@example.com");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should fail request with Uid on non-dev", async () => {
|
||||
//GIVEN
|
||||
isDevModeMock.mockReturnValue(false);
|
||||
|
||||
//WHEN / THEN
|
||||
await expect(() =>
|
||||
authenticate({ headers: { authorization: "Uid 123" } }),
|
||||
).rejects.toMatchMonkeyError(
|
||||
new MonkeyError(401, "Bearer type uid is not supported"),
|
||||
);
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await expect(() => authenticate({ headers: {} })).rejects.toThrow(
|
||||
"Unauthorized\nStack: endpoint: /api/v1 no authorization header found",
|
||||
);
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"None",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should fail with empty authentication", async () => {
|
||||
await expect(() =>
|
||||
authenticate({ headers: { authorization: "" } }),
|
||||
).rejects.toThrow(
|
||||
"Unauthorized\nStack: endpoint: /api/v1 no authorization header found",
|
||||
);
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should fail with missing authentication token", async () => {
|
||||
await expect(() =>
|
||||
authenticate({ headers: { authorization: "Bearer" } }),
|
||||
).rejects.toThrow(
|
||||
"Missing authentication token\nStack: authenticateWithAuthHeader",
|
||||
);
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"Bearer",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should fail with unknown authentication scheme", async () => {
|
||||
await expect(() =>
|
||||
authenticate({ headers: { authorization: "unknown format" } }),
|
||||
).rejects.toThrow(
|
||||
'Unknown authentication scheme\nStack: The authentication scheme "unknown" is not implemented',
|
||||
);
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"unknown",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should record country if provided", async () => {
|
||||
const prometheusRecordRequestCountryMock = vi.spyOn(
|
||||
Prometheus,
|
||||
"recordRequestCountry",
|
||||
);
|
||||
|
||||
await authenticate(
|
||||
{ headers: { "cf-ipcountry": "gb" } },
|
||||
{ isPublic: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
expect(prometheusRecordRequestCountryMock).toHaveBeenCalledWith(
|
||||
"gb",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should allow the request with authentation on dev public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate({}, { isPublicOnDev: true });
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe(mockDecodedToken.email);
|
||||
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should allow the request without authentication on dev public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{ headers: {} },
|
||||
{ isPublicOnDev: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("None");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("None");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow the request with apeKey on dev public endpoint", async () => {
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: true, isPublicOnDev: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("ApeKey");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow with apeKey if apeKeys are disabled on dev public endpoint", async () => {
|
||||
//GIVEN
|
||||
|
||||
//@ts-expect-error
|
||||
mockRequest.ctx.configuration.apeKeys.acceptKeys = false;
|
||||
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: true, isPublicOnDev: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("ApeKey");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow the request with authentation on dev public endpoint in production", async () => {
|
||||
//WHEN
|
||||
isDevModeMock.mockReturnValue(false);
|
||||
const result = await authenticate({}, { isPublicOnDev: true });
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("Bearer");
|
||||
expect(decodedToken?.email).toBe(mockDecodedToken.email);
|
||||
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("should fail without authentication on dev public endpoint in production", async () => {
|
||||
//WHEN
|
||||
isDevModeMock.mockReturnValue(false);
|
||||
|
||||
//THEN
|
||||
await expect(() =>
|
||||
authenticate({ headers: {} }, { isPublicOnDev: true }),
|
||||
).rejects.toThrow("Unauthorized");
|
||||
});
|
||||
it("should allow with apeKey on dev public endpoint in production", async () => {
|
||||
//WHEN
|
||||
isDevModeMock.mockReturnValue(false);
|
||||
const result = await authenticate(
|
||||
{ headers: { authorization: "ApeKey aWQua2V5" } },
|
||||
{ acceptApeKeys: true, isPublicOnDev: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("ApeKey");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("123");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("ApeKey");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("should allow githubwebhook with header", async () => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
|
||||
//WHEN
|
||||
const result = await authenticate(
|
||||
{
|
||||
headers: { "x-hub-signature-256": "the-signature" },
|
||||
body: { action: "published", release: { id: 1 } },
|
||||
},
|
||||
{ isGithubWebhook: true },
|
||||
);
|
||||
|
||||
//THEN
|
||||
const decodedToken = result.decodedToken;
|
||||
expect(decodedToken?.type).toBe("GithubWebhook");
|
||||
expect(decodedToken?.email).toBe("");
|
||||
expect(decodedToken?.uid).toBe("");
|
||||
expect(nextFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("GithubWebhook");
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledOnce();
|
||||
expect(timingSafeEqualMock).toHaveBeenCalledWith(
|
||||
Buffer.from(
|
||||
"sha256=ff0f3080539e9df19153f6b5b5780f66e558d61038e6cf5ecf4efdc7266a7751",
|
||||
),
|
||||
Buffer.from("the-signature"),
|
||||
);
|
||||
});
|
||||
it("should fail githubwebhook with mismatched signature", async () => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
|
||||
timingSafeEqualMock.mockReturnValue(false);
|
||||
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{
|
||||
headers: { "x-hub-signature-256": "the-signature" },
|
||||
body: { action: "published", release: { id: 1 } },
|
||||
},
|
||||
{ isGithubWebhook: true },
|
||||
),
|
||||
).rejects.toThrow("Github webhook signature invalid");
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"None",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should fail without header when endpoint is using githubwebhook", async () => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{
|
||||
headers: {},
|
||||
body: { action: "published", release: { id: 1 } },
|
||||
},
|
||||
{ isGithubWebhook: true },
|
||||
),
|
||||
).rejects.toThrow("Missing Github signature header");
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"None",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should fail with missing GITHUB_WEBHOOK_SECRET when endpoint is using githubwebhook", async () => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "");
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{
|
||||
headers: { "x-hub-signature-256": "the-signature" },
|
||||
body: { action: "published", release: { id: 1 } },
|
||||
},
|
||||
{ isGithubWebhook: true },
|
||||
),
|
||||
).rejects.toThrow("Missing Github Webhook Secret");
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"None",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
it("should throw 500 if something went wrong when validating the signature when endpoint is using githubwebhook", async () => {
|
||||
vi.stubEnv("GITHUB_WEBHOOK_SECRET", "GITHUB_WEBHOOK_SECRET");
|
||||
timingSafeEqualMock.mockImplementation(() => {
|
||||
throw new Error("could not validate");
|
||||
});
|
||||
await expect(() =>
|
||||
authenticate(
|
||||
{
|
||||
headers: { "x-hub-signature-256": "the-signature" },
|
||||
body: { action: "published", release: { id: 1 } },
|
||||
},
|
||||
{ isGithubWebhook: true },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Failed to authenticate Github webhook: could not validate",
|
||||
);
|
||||
|
||||
//THEH
|
||||
expect(prometheusIncrementAuthMock).not.toHaveBeenCalled();
|
||||
expect(prometheusRecordAuthTimeMock).toHaveBeenCalledWith(
|
||||
"None",
|
||||
"failure",
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function authenticate(
|
||||
request: Partial<Request>,
|
||||
authenticationOptions?: RequestAuthenticationOptions,
|
||||
): Promise<{ decodedToken: Auth.DecodedToken }> {
|
||||
const mergedRequest = {
|
||||
...mockRequest,
|
||||
...request,
|
||||
tsRestRoute: {
|
||||
metadata: { authenticationOptions } as EndpointMetadata,
|
||||
},
|
||||
} as any;
|
||||
|
||||
await Auth.authenticateTsRestRequest()(
|
||||
mergedRequest,
|
||||
mockResponse as Response,
|
||||
nextFunction,
|
||||
);
|
||||
|
||||
return { decodedToken: mergedRequest.ctx.decodedToken };
|
||||
}
|
||||
201
backend/__tests__/middlewares/configuration.spec.ts
Normal file
201
backend/__tests__/middlewares/configuration.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { RequireConfiguration } from "@monkeytype/contracts/require-configuration/index";
|
||||
import { verifyRequiredConfiguration } from "../../src/middlewares/configuration";
|
||||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { Response } from "express";
|
||||
import MonkeyError from "../../src/utils/error";
|
||||
import { TsRestRequest } from "../../src/api/types";
|
||||
import { enableMonkeyErrorExpects } from "../__testData__/monkey-error";
|
||||
|
||||
enableMonkeyErrorExpects();
|
||||
describe("configuration middleware", () => {
|
||||
const handler = verifyRequiredConfiguration();
|
||||
const res: Response = {} as any;
|
||||
const next = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
next.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
//next function must only be called once
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should pass without requireConfiguration", async () => {
|
||||
//GIVEN
|
||||
const req = { tsRestRoute: { metadata: {} } } as any;
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should pass for enabled configuration", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ path: "maintenance" }, { maintenance: true });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should pass for enabled configuration with complex path", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "users.xp.streak.enabled" },
|
||||
{ users: { xp: { streak: { enabled: true } as any } as any } as any },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should fail for disabled configuration", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ path: "maintenance" }, { maintenance: false });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(503, "This endpoint is currently unavailable."),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail for disabled configuration and custom message", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "maintenance", invalidMessage: "Feature not enabled." },
|
||||
{ maintenance: false },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(new MonkeyError(503, "Feature not enabled.")),
|
||||
);
|
||||
});
|
||||
it("should fail for invalid path", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ path: "invalid.path" as any }, {});
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(500, 'Invalid configuration path: "invalid.path"'),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail for undefined value", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "admin.endpointsEnabled" },
|
||||
{ admin: {} as any },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(
|
||||
500,
|
||||
'Required configuration doesnt exist: "admin.endpointsEnabled"',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail for null value", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "admin.endpointsEnabled" },
|
||||
{ admin: { endpointsEnabled: null as any } },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(
|
||||
500,
|
||||
'Required configuration doesnt exist: "admin.endpointsEnabled"',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail for non booean value", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{ path: "admin.endpointsEnabled" },
|
||||
{ admin: { endpointsEnabled: "disabled" as any } },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(
|
||||
500,
|
||||
'Required configuration is not a boolean: "admin.endpointsEnabled"',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should pass for multiple configurations", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
[{ path: "maintenance" }, { path: "admin.endpointsEnabled" }],
|
||||
{ maintenance: true, admin: { endpointsEnabled: true } },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should fail for multiple configurations", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
[
|
||||
{ path: "maintenance", invalidMessage: "maintenance mode" },
|
||||
{ path: "admin.endpointsEnabled", invalidMessage: "admin disabled" },
|
||||
],
|
||||
{ maintenance: true, admin: { endpointsEnabled: false } },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(new MonkeyError(503, "admin disabled")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function givenRequest(
|
||||
requireConfiguration: RequireConfiguration | RequireConfiguration[],
|
||||
configuration: Partial<Configuration>,
|
||||
): TsRestRequest {
|
||||
return {
|
||||
tsRestRoute: { metadata: { requireConfiguration } },
|
||||
ctx: { configuration },
|
||||
} as any;
|
||||
}
|
||||
338
backend/__tests__/middlewares/permission.spec.ts
Normal file
338
backend/__tests__/middlewares/permission.spec.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Response } from "express";
|
||||
import { verifyPermissions } from "../../src/middlewares/permission";
|
||||
import { EndpointMetadata } from "@monkeytype/contracts/util/api";
|
||||
import * as Misc from "../../src/utils/misc";
|
||||
import * as AdminUids from "../../src/dal/admin-uids";
|
||||
import * as UserDal from "../../src/dal/user";
|
||||
import MonkeyError from "../../src/utils/error";
|
||||
import { DecodedToken } from "../../src/middlewares/auth";
|
||||
import { TsRestRequest } from "../../src/api/types";
|
||||
import { enableMonkeyErrorExpects } from "../__testData__/monkey-error";
|
||||
|
||||
enableMonkeyErrorExpects();
|
||||
const uid = "123456789";
|
||||
|
||||
describe("permission middleware", () => {
|
||||
const handler = verifyPermissions();
|
||||
const res: Response = {} as any;
|
||||
const next = vi.fn();
|
||||
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
const isAdminMock = vi.spyOn(AdminUids, "isAdmin");
|
||||
const isDevMock = vi.spyOn(Misc, "isDevEnvironment");
|
||||
|
||||
beforeEach(() => {
|
||||
next.mockClear();
|
||||
getPartialUserMock.mockClear().mockResolvedValue({} as any);
|
||||
isDevMock.mockClear().mockReturnValue(false);
|
||||
isAdminMock.mockClear().mockResolvedValue(false);
|
||||
});
|
||||
afterEach(() => {
|
||||
//next function must only be called once
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should bypass without requiredPermission", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({});
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should bypass with empty requiredPermission", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({ requirePermission: [] });
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THE
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
describe("admin check", () => {
|
||||
const requireAdminPermission: EndpointMetadata = {
|
||||
requirePermission: "admin",
|
||||
};
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(requireAdminPermission);
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should pass without authentication if publicOnDev on dev", async () => {
|
||||
//GIVEN
|
||||
isDevMock.mockReturnValue(true);
|
||||
const req = givenRequest(
|
||||
{
|
||||
...requireAdminPermission,
|
||||
authenticationOptions: { isPublicOnDev: true },
|
||||
},
|
||||
{ uid },
|
||||
);
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should fail without authentication if publicOnDev on prod ", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{
|
||||
...requireAdminPermission,
|
||||
authenticationOptions: { isPublicOnDev: true },
|
||||
},
|
||||
{ uid },
|
||||
);
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail without admin permissions", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(requireAdminPermission, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
expect(isAdminMock).toHaveBeenCalledWith(uid);
|
||||
});
|
||||
});
|
||||
describe("user checks", () => {
|
||||
it("should fetch user only once", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest(
|
||||
{
|
||||
requirePermission: ["canReport", "canManageApeKeys"],
|
||||
},
|
||||
{ uid },
|
||||
);
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(getPartialUserMock).toHaveBeenCalledOnce();
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"check user permissions",
|
||||
["canReport", "canManageApeKeys"],
|
||||
);
|
||||
});
|
||||
it("should fail if authentication is missing", async () => {
|
||||
//GIVEN
|
||||
const req = givenRequest({
|
||||
requirePermission: ["canReport", "canManageApeKeys"],
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(
|
||||
403,
|
||||
"Failed to check permissions, authentication required.",
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("quoteMod check", () => {
|
||||
const requireQuoteMod: EndpointMetadata = {
|
||||
requirePermission: "quoteMod",
|
||||
};
|
||||
|
||||
it("should pass for quoteAdmin", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ quoteMod: true } as any);
|
||||
const req = givenRequest(requireQuoteMod, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"check user permissions",
|
||||
["quoteMod"],
|
||||
);
|
||||
});
|
||||
it("should pass for specific language", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ quoteMod: "english" } as any);
|
||||
const req = givenRequest(requireQuoteMod, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"check user permissions",
|
||||
["quoteMod"],
|
||||
);
|
||||
});
|
||||
it("should fail for empty string", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ quoteMod: "" } as any);
|
||||
const req = givenRequest(requireQuoteMod, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("should fail for missing quoteMod", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({} as any);
|
||||
const req = givenRequest(requireQuoteMod, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("canReport check", () => {
|
||||
const requireCanReport: EndpointMetadata = {
|
||||
requirePermission: "canReport",
|
||||
};
|
||||
|
||||
it("should fail if user cannot report", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ canReport: false } as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(403, "You don't have permission to do this."),
|
||||
),
|
||||
);
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"check user permissions",
|
||||
["canReport"],
|
||||
);
|
||||
});
|
||||
it("should pass if user can report", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ canReport: true } as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should pass if canReport is not set", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({} as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
describe("canManageApeKeys check", () => {
|
||||
const requireCanReport: EndpointMetadata = {
|
||||
requirePermission: "canManageApeKeys",
|
||||
};
|
||||
|
||||
it("should fail if user cannot report", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ canManageApeKeys: false } as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith(
|
||||
expect.toMatchMonkeyError(
|
||||
new MonkeyError(
|
||||
403,
|
||||
"You have lost access to ape keys, please contact support",
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"check user permissions",
|
||||
["canManageApeKeys"],
|
||||
);
|
||||
});
|
||||
it("should pass if user can report", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({ canManageApeKeys: true } as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
it("should pass if canManageApeKeys is not set", async () => {
|
||||
//GIVEN
|
||||
getPartialUserMock.mockResolvedValue({} as any);
|
||||
const req = givenRequest(requireCanReport, { uid });
|
||||
|
||||
//WHEN
|
||||
await handler(req, res, next);
|
||||
|
||||
//THEN
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function givenRequest(
|
||||
metadata: EndpointMetadata,
|
||||
decodedToken?: Partial<DecodedToken>,
|
||||
): TsRestRequest {
|
||||
return { tsRestRoute: { metadata }, ctx: { decodedToken } } as any;
|
||||
}
|
||||
Reference in New Issue
Block a user