2
0
Files
cal/calcom/packages/app-store/_utils/oauth/OAuthManager.test.ts
2024-08-09 00:39:27 +02:00

1474 lines
50 KiB
TypeScript

// import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { afterEach, expect, test, vi, describe } from "vitest";
import "vitest-fetch-mock";
import {
generateJsonResponse,
successResponse,
internalServerErrorResponse,
generateTextResponse,
} from "../testUtils";
import { OAuthManager, TokenStatus } from "./OAuthManager";
afterEach(() => {
vi.resetAllMocks();
});
const credentialSyncVariables = {
APP_CREDENTIAL_SHARING_ENABLED: false,
CREDENTIAL_SYNC_SECRET: "SECRET",
CREDENTIAL_SYNC_SECRET_HEADER_NAME: "calcom-credential-sync-secret",
CREDENTIAL_SYNC_ENDPOINT: "https://example.com/getToken",
};
function getDummyTokenObject(
token: { refresh_token?: string; expiry_date?: number; expires_in?: number } | null = null
) {
return {
access_token: "ACCESS_TOKEN",
...token,
};
}
function getExpiredTokenObject() {
return getDummyTokenObject({
// To make sure that existing token is used and thus refresh token doesn't happen
expiry_date: Date.now() - 10 * 1000,
});
}
describe("Credential Sync Disabled", () => {
const useCredentialSyncVariables = credentialSyncVariables;
describe("API: `getTokenObjectOrFetch`", () => {
describe("`fetchNewTokenObject` gets called with refresh_token arg", async () => {
test('refresh_token argument would be null if "refresh_token" is not present in the currentTokenObject', async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(successResponse({ json: getDummyTokenObject() }));
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await auth.getTokenObjectOrFetch();
expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: null });
});
test('refresh_token would be the value if "refresh_token" is present in the currentTokenObject', async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await auth1.getTokenObjectOrFetch();
expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" });
});
});
describe("expiry_date based token refresh", () => {
describe("checking using expiry_date", () => {
test("fetchNewTokenObject is not called if token has not expired", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
expiry_date: Date.now() + 10 * 1000,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await auth1.getTokenObjectOrFetch();
expect(fetchNewTokenObject).not.toHaveBeenCalled();
expect(updateTokenObject).not.toHaveBeenCalled();
});
test("`fetchNewTokenObject` is called if token has expired. Also, `updateTokenObject` is called with currentTokenObject and newTokenObject merged", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const currentTokenObject = getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
expiry_date: Date.now() - 10 * 1000,
});
const newTokenObjectInResponse = getDummyTokenObject();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: newTokenObjectInResponse }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: currentTokenObject,
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await auth1.getTokenObjectOrFetch();
expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" });
expect(updateTokenObject).toHaveBeenCalledWith({
...currentTokenObject,
...newTokenObjectInResponse,
// Consider the token as expired as newTokenObjectInResponse didn't have expiry
expiry_date: 0,
});
});
});
describe("checking using expires_in", () => {
// eslint-disable-next-line playwright/max-nested-describe
describe("expires_in(relative to current time)", () => {
test("fetchNewTokenObject is called if expires_in is 0", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
expires_in: 0,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await auth1.getTokenObjectOrFetch();
expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" });
});
test("`fetchNewTokenObject` is not called even if expires_in is any non zero positive value(that is not 'time since epoch')", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
expires_in: 5,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await auth1.getTokenObjectOrFetch();
expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" });
});
});
// eslint-disable-next-line playwright/max-nested-describe
describe("expires_in(relative to epoch time)", () => {
test("fetchNewTokenObject is not called if token has not expired", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
expires_in: Date.now() / 1000 + 5,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await auth1.getTokenObjectOrFetch();
expect(fetchNewTokenObject).not.toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" });
});
test("fetchNewTokenObject is called if token has expired", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
expires_in: Date.now() / 1000 + 0,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await auth1.getTokenObjectOrFetch();
expect(fetchNewTokenObject).toHaveBeenCalledWith({ refreshToken: "REFRESH_TOKEN" });
});
});
});
});
test("If fetchNewTokenObject returns null then auth.getTokenObjectOrFetch would throw error", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject: async () => {
return null;
},
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
expect(async () => {
return auth.getTokenObjectOrFetch();
}).rejects.toThrowError("could not refresh the token");
});
test("if fetchNewTokenObject throws error that's not handled by isTokenObjectUnusable and isAccessTokenUnusable then auth.getTokenObjectOrFetch would still not throw error", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject: async () => {
throw new Error("testError");
},
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
expect(async () => {
return auth.getTokenObjectOrFetch();
}).rejects.toThrowError("Invalid token response");
});
test("if fetchNewTokenObject throws error that's handled by isTokenObjectUnusable then auth.getTokenObjectOrFetch would still throw error but a different one as access_token won't be available", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject: async () => {
throw new Error("testError");
},
isTokenObjectUnusable: async () => {
return {
reason: "some reason",
};
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
expect(async () => {
return auth.getTokenObjectOrFetch();
}).rejects.toThrowError("Invalid token response");
});
});
describe("API: `request`", () => {
test("It would call fetch by adding Authorization and content header automatically", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
fetchMock.mockReturnValueOnce(Promise.resolve(generateJsonResponse({ json: { key: "value" } })));
const response = await auth.request({
url: "https://example.com",
options: {
method: "POST",
body: JSON.stringify({
key: "value",
}),
},
});
expect(response).toEqual({ tokenStatus: TokenStatus.VALID, json: { key: "value" } });
const fetchCallArguments = fetchMock.mock.calls[0];
expect(fetchCallArguments[0]).toBe("https://example.com");
// Verify that Authorization header is added automatically
// Along with other passed headers and other options
expect(fetchCallArguments[1]).toEqual(
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer ACCESS_TOKEN",
},
body: JSON.stringify({
key: "value",
}),
})
);
});
test("If `isTokenObjectUnusable` marks the response invalid, then `invalidateTokenObject` function is called", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const fakedFetchJsonResult = { key: "value" };
const fakedFetchResponse = generateJsonResponse({ json: fakedFetchJsonResult });
const auth = new OAuthManager({
autoCheckTokenExpiryOnRequest: false,
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async (response) => {
const jsonRes = await response.json();
expect(jsonRes).toEqual(fakedFetchJsonResult);
return {
reason: "some reason",
};
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const response = await auth.request({
url: "https://example.com",
options: {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: "value",
}),
},
});
expect(response).toEqual({
tokenStatus: TokenStatus.UNUSABLE_TOKEN_OBJECT,
json: fakedFetchJsonResult,
});
expect(invalidateTokenObject).toHaveBeenCalled();
expect(expireAccessToken).not.toHaveBeenCalled();
});
test("If `isAccessTokenUnusable` marks the response invalid, then `expireAccessToken` function is called", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const fakedFetchJsonResult = { key: "value" };
const fakedFetchResponse = generateJsonResponse({ json: fakedFetchJsonResult });
const auth = new OAuthManager({
autoCheckTokenExpiryOnRequest: false,
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async (response) => {
const jsonRes = await response.json();
expect(jsonRes).toEqual(fakedFetchJsonResult);
return {
reason: "some reason",
};
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const response = await auth.request({
url: "https://example.com",
options: {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: "value",
}),
},
});
expect(response).toEqual({
tokenStatus: TokenStatus.UNUSABLE_ACCESS_TOKEN,
json: fakedFetchJsonResult,
});
expect(invalidateTokenObject).not.toHaveBeenCalled();
expect(expireAccessToken).toHaveBeenCalled();
});
test("If the response is empty string make the json null(because empty string which is usually the case with 204 status is not a valid json). There shouldn't be any error even if `isTokenObjectUnusable` and `isAccessTokenUnusable` do json()", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const fakedFetchResponse = generateTextResponse({ text: "", status: 204 });
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async (response) => {
return await response.json();
},
isAccessTokenUnusable: async (response) => {
return await response.json();
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const response = await auth.request({
url: "https://example.com",
options: {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: "value",
}),
},
});
expect(response).toEqual({ tokenStatus: TokenStatus.VALID, json: null });
expect(expireAccessToken).not.toHaveBeenCalled();
});
test("If status is not okay it throws error with statusText", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const fakedFetchJsonResult = { key: "value" };
const fakedFetchResponse = generateJsonResponse({
json: fakedFetchJsonResult,
status: 500,
statusText: "Internal Server Error",
});
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const { json, tokenStatus } = await auth.request({
url: "https://example.com",
options: {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: "value",
}),
},
});
expect(json).toEqual(fakedFetchJsonResult);
expect(tokenStatus).toEqual(TokenStatus.INCONCLUSIVE);
});
test("if `customFetch` throws error that is handled by `isTokenObjectUnusable` then `request` would still throw error but also invalidate", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return {
reason: "some reason",
};
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await expect(
auth.request(() => {
throw new Error("Internal Server Error");
})
).rejects.toThrowError("Internal Server Error");
expect(invalidateTokenObject).toHaveBeenCalled();
});
});
describe("API: `requestRaw`", () => {
test("It would call fetch by adding Authorization and content header automatically", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
fetchMock.mockReturnValueOnce(Promise.resolve(generateJsonResponse({ json: { key: "value" } })));
const response = await auth.requestRaw({
url: "https://example.com",
options: {
method: "POST",
body: JSON.stringify({
key: "value",
}),
},
});
expect(await response.json()).toEqual({ key: "value" });
const fetchCallArguments = fetchMock.mock.calls[0];
expect(fetchCallArguments[0]).toBe("https://example.com");
// Verify that Authorization header is added automatically
// Along with other passed headers and other options
expect(fetchCallArguments[1]).toEqual(
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer ACCESS_TOKEN",
},
body: JSON.stringify({
key: "value",
}),
})
);
});
});
// Credentials might have no userId attached in production. They could instead have teamId
test("OAuthManager without resourceOwner.id is okay", async () => {
const userId = null;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi.fn().mockResolvedValue(successResponse({ json: getDummyTokenObject() }));
expect(
() =>
new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
})
).not.toThrowError();
});
});
describe("Credential Sync Enabled", () => {
const useCredentialSyncVariables = {
...credentialSyncVariables,
APP_CREDENTIAL_SHARING_ENABLED: true,
};
describe("API: `getTokenObjectOrFetch`", () => {
test("CREDENTIAL_SYNC_ENDPOINT is hit if no expiry_date is set in the `currentTokenObject`", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
const fakedFetchResponse = generateJsonResponse({ json: getDummyTokenObject() });
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
await auth1.getTokenObjectOrFetch();
expectToBeTokenGetCall({
fetchCall: fetchMock.mock.calls[0],
useCredentialSyncVariables,
userId,
appSlug: "demo-app",
});
expect(fetchNewTokenObject).not.toHaveBeenCalled();
});
describe("expiry_date based token refresh", () => {
test("CREDENTIAL_SYNC_ENDPOINT is not hit if token has not expired", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
expiry_date: Date.now() + 10 * 1000,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
await auth1.getTokenObjectOrFetch();
expect(fetchNewTokenObject).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test("CREDENTIAL_SYNC_ENDPOINT is hit if token has expired", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi
.fn()
.mockResolvedValue(generateJsonResponse({ json: getDummyTokenObject() }));
const auth1 = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
refresh_token: "REFRESH_TOKEN",
expiry_date: Date.now() - 10 * 1000,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
const fakedFetchResponse = generateJsonResponse({ json: getDummyTokenObject() });
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
await auth1.getTokenObjectOrFetch();
expectToBeTokenGetCall({
fetchCall: fetchMock.mock.calls[0],
useCredentialSyncVariables,
userId,
appSlug: "demo-app",
});
expect(fetchNewTokenObject).not.toHaveBeenCalled();
});
});
test("OAuthManager without resourceOwner.id should throw error as it is a requirement", async () => {
const userId = null;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi.fn().mockResolvedValue(successResponse({ json: getDummyTokenObject() }));
expect(
() =>
new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
})
).toThrowError("resourceOwner should have id set");
});
});
describe("API: `request`", () => {
test("If `isTokenObjectUnusable` marks the response invalid, then `invalidateTokenObject` function is called", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi.fn();
const fakedFetchJsonResult = { key: "value" };
const fakedFetchResponse = generateJsonResponse({ json: fakedFetchJsonResult });
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
// To make sure that existing token is used and thus refresh token doesn't happen
expiry_date: Date.now() + 10 * 1000,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async (response) => {
const jsonRes = await response.json();
expect(jsonRes).toEqual(fakedFetchJsonResult);
return {
reason: "some reason",
};
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
// For fetch triggered by the actual request
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const response = await auth.request({
url: "https://example.com",
options: {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: "value",
}),
},
});
expect(response).toEqual({
tokenStatus: TokenStatus.UNUSABLE_TOKEN_OBJECT,
json: fakedFetchJsonResult,
});
expect(invalidateTokenObject).not.toHaveBeenCalled();
expect(expireAccessToken).toHaveBeenCalled();
});
test("If neither of `isTokenObjectUnusable` and `isAccessTokenInvalid` mark the response invalid, but the response is still not OK then `markTokenExpired` is still called.", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi.fn();
const fakedFetchJsonResult = { key: "value" };
const fakedFetchResponse = internalServerErrorResponse({ json: fakedFetchJsonResult });
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
// To make sure that existing token is used and thus refresh token doesn't happen
expiry_date: Date.now() + 10 * 1000,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
// For fetch triggered by the actual request
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const response = await auth.request({
url: "https://example.com",
options: {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: "value",
}),
},
});
expect(response).toEqual({
tokenStatus: TokenStatus.INCONCLUSIVE,
json: fakedFetchJsonResult,
});
expect(invalidateTokenObject).not.toHaveBeenCalled();
expect(expireAccessToken).toHaveBeenCalled();
});
test("If neither of `isTokenObjectUnusable` and `isAccessTokenInvalid` mark the response invalid, and the response is also OK then `markTokenExpired` is not called.", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi.fn();
const fakedFetchJsonResult = { key: "value" };
const fakedFetchResponse = successResponse({ json: fakedFetchJsonResult });
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
// To make sure that existing token is used and thus refresh token doesn't happen
expiry_date: Date.now() + 10 * 1000,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
// For fetch triggered by the actual request
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const response = await auth.request({
url: "https://example.com",
options: {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: "value",
}),
},
});
expect(response).toEqual({
tokenStatus: TokenStatus.VALID,
json: fakedFetchJsonResult,
});
expect(invalidateTokenObject).not.toHaveBeenCalled();
expect(expireAccessToken).not.toHaveBeenCalled();
});
test("If `autoCheckTokenExpiryOnRequest` is true and token is expired, then token sync endpoint is hit", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi.fn();
const currentTokenObject = getExpiredTokenObject();
const newTokenObjectInResponse = getDummyTokenObject();
const fakedTokenGetResponse = generateJsonResponse({ json: newTokenObjectInResponse });
const fakedFetchJsonResult = { key: "value" };
const fakedFetchResponse = successResponse({ json: fakedFetchJsonResult });
const auth = new OAuthManager({
autoCheckTokenExpiryOnRequest: true,
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject,
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
// For fetch triggered by the token sync request
fetchMock.mockReturnValueOnce(Promise.resolve(fakedTokenGetResponse));
// For fetch triggered by the request call fetch
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const response = await auth.request({
url: "https://example.com",
options: {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: "value",
}),
},
});
expect(response).toEqual({
tokenStatus: TokenStatus.VALID,
json: fakedFetchJsonResult,
});
expect(updateTokenObject).toHaveBeenCalledWith(expect.objectContaining(newTokenObjectInResponse));
// In credential sync mode, the expiry date is set to next year as it is not explicitly set in newTokenObject
expectExpiryToBeNextYear(updateTokenObject.mock.calls[0][0].expiry_date);
expect(invalidateTokenObject).not.toHaveBeenCalled();
expect(expireAccessToken).not.toHaveBeenCalled();
});
test("If `autoCheckTokenExpiryOnRequest` is not set(default true is used) and token is expired, then token sync endpoint is hit", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi.fn();
const fakedTokenGetJson = getDummyTokenObject();
const fakedTokenGetResponse = generateJsonResponse({ json: fakedTokenGetJson });
const fakedFetchJsonResult = { key: "value" };
const fakedFetchResponse = successResponse({ json: fakedFetchJsonResult });
const auth = new OAuthManager({
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getExpiredTokenObject(),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
// For fetch triggered by the token sync request
fetchMock.mockReturnValueOnce(Promise.resolve(fakedTokenGetResponse));
// For fetch triggered by the request call fetch
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const response = await auth.request({
url: "https://example.com",
options: {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
key: "value",
}),
},
});
expect(response).toEqual({
tokenStatus: TokenStatus.VALID,
json: fakedFetchJsonResult,
});
expect(updateTokenObject).toHaveBeenCalled();
expect(invalidateTokenObject).not.toHaveBeenCalled();
expect(expireAccessToken).not.toHaveBeenCalled();
});
});
describe("API: `requestRaw`", () => {
test("Though `isTokenObjectUnusable` and `isAccessTokenInvalid` aren't applicable here, but if the response is not OK then `markTokenExpired` is still called.", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi.fn();
const fakedFetchJsonResult = { key: "value" };
const fakedFetchResponse = internalServerErrorResponse({ json: fakedFetchJsonResult });
const auth = new OAuthManager({
autoCheckTokenExpiryOnRequest: false,
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
// To make sure that existing token is used and thus refresh token doesn't happen
expiry_date: Date.now() + 10 * 1000,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
// For fetch triggered by the actual request
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const response = await auth.requestRaw({
url: "https://example.com",
options: {
method: "POST",
body: JSON.stringify({
key: "value",
}),
},
});
expect(await response.json()).toEqual(fakedFetchJsonResult);
expect(invalidateTokenObject).not.toHaveBeenCalled();
expect(expireAccessToken).toHaveBeenCalled();
});
test("Though `isTokenObjectUnusable` and `isAccessTokenInvalid` aren't applicable here, and the response is also OK then `markTokenExpired` is not called.", async () => {
const userId = 1;
const invalidateTokenObject = vi.fn();
const expireAccessToken = vi.fn();
const updateTokenObject = vi.fn();
const fetchNewTokenObject = vi.fn();
const fakedFetchJsonResult = { key: "value" };
const fakedFetchResponse = successResponse({ json: fakedFetchJsonResult });
const auth = new OAuthManager({
autoCheckTokenExpiryOnRequest: false,
credentialSyncVariables: useCredentialSyncVariables,
resourceOwner: {
type: "user",
id: userId,
},
appSlug: "demo-app",
currentTokenObject: getDummyTokenObject({
// To make sure that existing token is used and thus refresh token doesn't happen
expiry_date: Date.now() + 10 * 1000,
}),
fetchNewTokenObject,
isTokenObjectUnusable: async () => {
return null;
},
isAccessTokenUnusable: async () => {
return null;
},
invalidateTokenObject: invalidateTokenObject,
updateTokenObject: updateTokenObject,
expireAccessToken: expireAccessToken,
});
// For fetch triggered by the actual request
fetchMock.mockReturnValueOnce(Promise.resolve(fakedFetchResponse));
const response = await auth.requestRaw({
url: "https://example.com",
options: {
method: "POST",
body: JSON.stringify({
key: "value",
}),
},
});
expect(await response.json()).toEqual(fakedFetchJsonResult);
expect(invalidateTokenObject).not.toHaveBeenCalled();
expect(expireAccessToken).not.toHaveBeenCalled();
});
});
});
function expectExpiryToBeNextYear(expiry_date: number) {
expect(new Date(expiry_date).getFullYear() - new Date().getFullYear()).toBe(1);
}
function expectToBeTokenGetCall({
fetchCall,
useCredentialSyncVariables,
userId,
appSlug,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetchCall: any[];
useCredentialSyncVariables: {
APP_CREDENTIAL_SHARING_ENABLED: boolean;
CREDENTIAL_SYNC_SECRET: string;
CREDENTIAL_SYNC_SECRET_HEADER_NAME: string;
CREDENTIAL_SYNC_ENDPOINT: string;
};
userId: number;
appSlug: string;
}) {
expect(fetchCall[0]).toBe("https://example.com/getToken");
expect(fetchCall[1]).toEqual(
expect.objectContaining({
method: "POST",
headers: {
[useCredentialSyncVariables.CREDENTIAL_SYNC_SECRET_HEADER_NAME]:
useCredentialSyncVariables.CREDENTIAL_SYNC_SECRET,
},
})
);
const fetchBody = fetchCall[1]?.body as unknown as URLSearchParams;
expect(fetchBody.get("calcomUserId")).toBe(userId.toString());
expect(fetchBody.get("appSlug")).toBe(appSlug);
}