Skip to content

Commit

Permalink
split partnerIntegrations file into separate modules
Browse files Browse the repository at this point in the history
  • Loading branch information
Ben Loe committed May 17, 2024
1 parent fbbcfbe commit 59552fe
Show file tree
Hide file tree
Showing 9 changed files with 336 additions and 219 deletions.
6 changes: 2 additions & 4 deletions src/background/messenger/strict/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,6 @@ import { locator, refreshServices } from "@/background/locator";
import { closeTab, focusTab, openTab } from "@/background/tabs";
import launchInteractiveOAuth2Flow from "@/background/auth/launchInteractiveOAuth2Flow";
import { performConfiguredRequest } from "@/background/requests";
import {
getPartnerPrincipals,
launchAuthIntegration,
} from "@/background/partnerIntegrations";
import { getAvailableVersion } from "@/background/installer";
import {
collectPerformanceDiagnostics,
Expand All @@ -74,6 +70,8 @@ import {
requestRunInTarget,
requestRunInTop,
} from "@/background/executor";
import getPartnerPrincipals from "@/background/partnerIntegrations/getPartnerPrincipals";
import launchAuthIntegration from "@/background/partnerIntegrations/launchAuthIntegration";

expectContext("background");

Expand Down
111 changes: 111 additions & 0 deletions src/background/partnerIntegrations/getPartnerPrincipals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*

Check failure on line 1 in src/background/partnerIntegrations/getPartnerPrincipals.test.ts

View workflow job for this annotation

GitHub Actions / strictNullChecks

strictNullChecks

src/background/partnerIntegrations/getPartnerPrincipals.test.ts was not found in tsconfig.strictNullChecks.json
* Copyright (C) 2024 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { appApiMock } from "@/testUtils/appApiMock";
import tokenIntegrationDefinition from "@contrib/integrations/automation-anywhere.yaml";
import oauthIntegrationDefinition from "@contrib/integrations/automation-anywhere-oauth2.yaml";
import { syncRemotePackages } from "@/registry/memoryRegistry";
import { locator as serviceLocator } from "@/background/locator";
import getPartnerPrincipals from "@/background/partnerIntegrations/getPartnerPrincipals";
import {
integrationConfigFactory,
secretsConfigFactory,
} from "@/testUtils/factories/integrationFactories";
import {
CONTROL_ROOM_OAUTH_INTEGRATION_ID,
CONTROL_ROOM_TOKEN_INTEGRATION_ID,
} from "@/integrations/constants";
import { readRawConfigurations } from "@/integrations/registry";
import { registry } from "@/background/messenger/strict/api";
import { type RegistryId } from "@/types/registryTypes";

const integrationDefinitionMap = new Map([
[CONTROL_ROOM_TOKEN_INTEGRATION_ID, tokenIntegrationDefinition],
[CONTROL_ROOM_OAUTH_INTEGRATION_ID, oauthIntegrationDefinition],
]);

// Module mocked via __mocks__/@/background/messenger/api
jest.mocked(registry.find).mockImplementation(async (id: RegistryId) => {
const config = integrationDefinitionMap.get(id);
return {
id: (config!.metadata as any).id,
config,
} as any;
});

jest.mock("@/integrations/registry", () => {
const actual = jest.requireActual("@/integrations/registry");
return {
// Include __esModule so default export works
__esModule: true,
...actual,
readRawConfigurations: jest.fn().mockResolvedValue([]),
};
});

const readRawConfigurationsMock = jest.mocked(readRawConfigurations);

describe("getPartnerPrincipals", () => {
beforeEach(() => {
appApiMock.reset();

appApiMock
.onGet("/api/registry/bricks/")
.reply(200, [tokenIntegrationDefinition, oauthIntegrationDefinition]);

appApiMock.onGet("/api/services/shared/").reply(200, []);

readRawConfigurationsMock.mockReset();
});

test("get empty principals", async () => {
// No local integration configurations
readRawConfigurationsMock.mockResolvedValue([]);

await syncRemotePackages();
await serviceLocator.refresh();

const principals = await getPartnerPrincipals();

expect(principals).toStrictEqual([]);
});

test("get configured principal", async () => {
// Local configuration
readRawConfigurationsMock.mockResolvedValue([
integrationConfigFactory({
integrationId: CONTROL_ROOM_TOKEN_INTEGRATION_ID,
config: secretsConfigFactory({
controlRoomUrl: "https://control-room.example.com",
username: "bot_creator",
}),
}),
]);

await serviceLocator.refresh();
await syncRemotePackages();

const principals = await getPartnerPrincipals();

expect(principals).toStrictEqual([
{
hostname: "control-room.example.com",
principalId: "bot_creator",
},
]);
});
});
67 changes: 67 additions & 0 deletions src/background/partnerIntegrations/getPartnerPrincipals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*

Check failure on line 1 in src/background/partnerIntegrations/getPartnerPrincipals.ts

View workflow job for this annotation

GitHub Actions / strictNullChecks

strictNullChecks

src/background/partnerIntegrations/getPartnerPrincipals.ts was not found in tsconfig.strictNullChecks.json
* Copyright (C) 2024 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { PartnerPrincipal } from "@/background/partnerIntegrations/types";

Check failure on line 18 in src/background/partnerIntegrations/getPartnerPrincipals.ts

View workflow job for this annotation

GitHub Actions / lint

All imports in the declaration are only used as types. Use `import type`
import { expectContext } from "@/utils/expectContext";
import {
CONTROL_ROOM_OAUTH_INTEGRATION_ID,
CONTROL_ROOM_TOKEN_INTEGRATION_ID,
} from "@/integrations/constants";
import { compact, flatten } from "lodash";
import { locator as serviceLocator } from "@/background/locator";
import { canParseUrl } from "@/utils/urlUtils";

/**
* Return principals for configured remote partner integrations.
*/
export default async function getPartnerPrincipals(): Promise<
PartnerPrincipal[]
> {
expectContext("background");

const partnerIds = [
CONTROL_ROOM_OAUTH_INTEGRATION_ID,
CONTROL_ROOM_TOKEN_INTEGRATION_ID,
];

const auths = flatten(
await Promise.all(
partnerIds.map(async (id) => {
try {
return await serviceLocator.locateAllForService(id);
} catch {
// `serviceLocator` throws if the user doesn't have the service definition. Handle case where the brick
// definition for CONTROL_ROOM_OAUTH_SERVICE_ID hasn't been made available on the server yet
return [];
}
}),
),
);

return compact(
auths.map((auth) => {
if (canParseUrl(auth.config.controlRoomUrl)) {
return {
hostname: new URL(auth.config.controlRoomUrl).hostname,
principalId: auth.config.username ?? null,
} as PartnerPrincipal;
}

return null;
}),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,88 +15,28 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { locator as serviceLocator } from "@/background/locator";
import { compact, flatten } from "lodash";
import type { RegistryId } from "@/types/registryTypes";
import { expectContext } from "@/utils/expectContext";
import { type RegistryId } from "@/types/registryTypes";
import launchOAuth2Flow from "@/background/auth/launchOAuth2Flow";
import { readPartnerAuthData, setPartnerAuth } from "@/auth/authStorage";
import serviceRegistry from "@/integrations/registry";
import axios from "axios";
import { locator as serviceLocator } from "@/background/locator";
import { assertNotNullish } from "@/utils/nullishUtils";
import launchOAuth2Flow from "@/background/auth/launchOAuth2Flow";
import { CONTROL_ROOM_OAUTH_INTEGRATION_ID } from "@/integrations/constants";
import { canParseUrl } from "@/utils/urlUtils";
import { getBaseURL } from "@/data/service/baseService";
import axios from "axios";
import { isAxiosError } from "@/errors/networkErrorHelpers";
import chromeP from "webext-polyfill-kinda";
import { setCachedAuthData } from "@/background/auth/authStorage";
import { getErrorMessage } from "@/errors/errorHelpers";
import {
CONTROL_ROOM_OAUTH_INTEGRATION_ID,
CONTROL_ROOM_TOKEN_INTEGRATION_ID,
} from "@/integrations/constants";
import { stringToBase64 } from "uint8array-extras";
import { canParseUrl } from "@/utils/urlUtils";
import { assertNotNullish } from "@/utils/nullishUtils";

/**
* A principal on a remote service, e.g., an Automation Anywhere Control Room.
*/
type PartnerPrincipal = {
/**
* The hostname of the remote service, e.g., the Automation Anywhere Control Room.
*/
hostname: string;

/**
* The principal unique id, or null for OAuth-based integrations.
*/
principalId: string | null;
};

/**
* Return principals for configured remote partner integrations.
*/
export async function getPartnerPrincipals(): Promise<PartnerPrincipal[]> {
expectContext("background");

const partnerIds = [
CONTROL_ROOM_OAUTH_INTEGRATION_ID,
CONTROL_ROOM_TOKEN_INTEGRATION_ID,
];

const auths = flatten(
await Promise.all(
partnerIds.map(async (id) => {
try {
return await serviceLocator.locateAllForService(id);
} catch {
// `serviceLocator` throws if the user doesn't have the service definition. Handle case where the brick
// definition for CONTROL_ROOM_OAUTH_SERVICE_ID hasn't been made available on the server yet
return [];
}
}),
),
);

return compact(
auths.map((auth) => {
if (canParseUrl(auth.config.controlRoomUrl)) {
return {
hostname: new URL(auth.config.controlRoomUrl).hostname,
principalId: auth.config.username ?? null,
} as PartnerPrincipal;
}

return null;
}),
);
}
import { setPartnerAuth } from "@/auth/authStorage";

/**
* Launch the browser's web auth flow get a partner token for communicating with the PixieBrix server.
*
* WARNING: PixieBrix should already have the required permissions (e.g., to authorize and token endpoints) before
* calling this method.
*/
export async function launchAuthIntegration({
export default async function launchAuthIntegration({
integrationId,
}: {
integrationId: RegistryId;
Expand Down Expand Up @@ -195,73 +135,3 @@ export async function launchAuthIntegration({
);
}
}

/**
* Refresh an Automation Anywhere JWT. NOOP if a JWT refresh token is not available.
*/
export async function _refreshPartnerToken(): Promise<void> {
expectContext("background");

const authData = await readPartnerAuthData();

if (authData.authId && authData.refreshToken) {
console.debug("Refreshing partner JWT");

const service = await serviceRegistry.lookup(
CONTROL_ROOM_OAUTH_INTEGRATION_ID,
);
const integrationConfig = await serviceLocator.findIntegrationConfig(
authData.authId,
);
assertNotNullish(
integrationConfig,
`Integration config not found for authId: ${authData.authId}`,
);

const { controlRoomUrl } = integrationConfig.config;
if (!canParseUrl(controlRoomUrl)) {
// Fine to dump to console for debugging because CONTROL_ROOM_OAUTH_SERVICE_ID doesn't have any secret props.
console.warn(
"controlRoomUrl is missing on configuration",
integrationConfig,
);
throw new Error("controlRoomUrl is missing on configuration");
}

const context = service.getOAuth2Context(integrationConfig.config);
assertNotNullish(context, "Service did not return an OAuth2 context");
assertNotNullish(
context.tokenUrl,
`OAuth2 context for service ${integrationConfig.integrationId} does not include a token URL`,
);

// https://axios-http.com/docs/urlencoded
const params = new URLSearchParams();
params.append("grant_type", "refresh_token");
params.append("client_id", context.client_id);
params.append("refresh_token", authData.refreshToken);
params.append("hosturl", controlRoomUrl);

// On 401, throw the error. In the future, we might consider clearing the partnerAuth. However, currently that
// would trigger a re-login, which may not be desirable at arbitrary times.
const { data } = await axios.post(context.tokenUrl, params, {
headers: { Authorization: `Basic ${stringToBase64(context.client_id)} ` },
});

// Store for use direct calls to the partner API
await setCachedAuthData(integrationConfig.id, data);

// Store for use with the PixieBrix API
await setPartnerAuth({
authId: integrationConfig.id,
token: data.access_token,
// `refresh_token` only returned if offline_access scope is requested
refreshToken: data.refresh_token,
extraHeaders: {
"X-Control-Room": controlRoomUrl,
},
});

console.debug("Successfully refreshed partner token");
}
}

0 comments on commit 59552fe

Please sign in to comment.