Skip to content

Commit

Permalink
Merge branch 'main' into F/mv/no-in-memory
Browse files Browse the repository at this point in the history
  • Loading branch information
fregante committed Nov 22, 2022
2 parents 6c824f6 + 8583011 commit fa9ecc1
Show file tree
Hide file tree
Showing 24 changed files with 849 additions and 287 deletions.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions src/auth/authTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,23 @@ export type UserData = Partial<{
* @since 1.7.1
*/
enforceUpdateMillis: number | null;
/**
* The partner, controlling theme, documentation links, etc.
*
* Introduced to support partner Community Edition users because they are not part of an organization.
*
* @since 1.7.14
*/
readonly partner?: Me["partner"];
}>;

// Exclude tenant information in updates (these are only updated on linking)
export type UserDataUpdate = Required<Except<UserData, "hostname" | "user">>;

/**
* User data keys (in addition to the token) to store in chrome.storage.local when linking the extension.
* @see updateUserData
*/
export const USER_DATA_UPDATE_KEYS: Array<keyof UserDataUpdate> = [
"email",
"organizationId",
Expand All @@ -85,6 +97,7 @@ export const USER_DATA_UPDATE_KEYS: Array<keyof UserDataUpdate> = [
"groups",
"flags",
"enforceUpdateMillis",
"partner",
];

export interface TokenAuthData extends UserData {
Expand Down
2 changes: 2 additions & 0 deletions src/auth/authUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function selectUserDataUpdate({
organization_memberships: organizationMemberships = [],
group_memberships = [],
flags = [],
partner,
enforce_update_millis: enforceUpdateMillis,
}: Me): UserDataUpdate {
const organizations = selectOrganizations(organizationMemberships);
Expand All @@ -66,6 +67,7 @@ export function selectUserDataUpdate({
flags,
organizations,
groups,
partner,
enforceUpdateMillis,
};
}
Expand Down
6 changes: 3 additions & 3 deletions src/auth/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,16 +182,16 @@ export async function clearCachedAuthSecrets(): Promise<void> {
* @see linkExtension
*/
export async function updateUserData(update: UserDataUpdate): Promise<void> {
const updated = await readAuthData();
const result = await readAuthData();

for (const key of USER_DATA_UPDATE_KEYS) {
// Intentionally overwrite values with null/undefined from the update. For some reason TypeScript was complaining
// about assigning any to never. It's not clear why update[key] was being typed as never
// eslint-disable-next-line security/detect-object-injection,@typescript-eslint/no-explicit-any -- keys from compile-time constant
(updated[key] as any) = update[key] as any;
(result[key] as any) = update[key] as any;
}

await setStorage(STORAGE_EXTENSION_KEY, updated);
await setStorage(STORAGE_EXTENSION_KEY, result);
}

/**
Expand Down
68 changes: 68 additions & 0 deletions src/auth/useRequiredPartnerAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe("useRequiredPartnerAuth", () => {
data: {
id: uuidv4(),
partner: null,
milestones: [],
},
}));

Expand Down Expand Up @@ -95,6 +96,7 @@ describe("useRequiredPartnerAuth", () => {
data: {
id: uuidv4(),
partner: null,
milestones: [],
},
}));

Expand Down Expand Up @@ -126,6 +128,7 @@ describe("useRequiredPartnerAuth", () => {
id: uuidv4(),
theme: "automation-anywhere",
},
milestones: [],
organization: {
control_room: {
id: uuidv4(),
Expand All @@ -151,6 +154,70 @@ describe("useRequiredPartnerAuth", () => {
});
});

test("requires integration for CE user", async () => {
const store = testStore();

(useGetMeQuery as jest.Mock).mockImplementation(() => ({
isSuccess: true,
isLoading: false,
data: {
id: uuidv4(),
partner: {
id: uuidv4(),
theme: "automation-anywhere",
},
milestones: [{ key: "aa_community_edition_register" }],
organization: null,
},
}));

const { result } = renderHook(() => useRequiredPartnerAuth(), {
wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
});

await waitForEffect();

expect(result.current).toStrictEqual({
hasPartner: true,
partnerKey: "automation-anywhere",
requiresIntegration: true,
hasConfiguredIntegration: false,
isLoading: false,
error: undefined,
});
});

test("does not require integration for CE user once partner is removed", async () => {
const store = testStore();

(useGetMeQuery as jest.Mock).mockImplementation(() => ({
isSuccess: true,
isLoading: false,
data: {
id: uuidv4(),
// Partner becomes null once full PixieBrix is toggled on in the Admin Console
partner: null,
milestones: [{ key: "aa_community_edition_register" }],
organization: null,
},
}));

const { result } = renderHook(() => useRequiredPartnerAuth(), {
wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
});

await waitForEffect();

expect(result.current).toStrictEqual({
hasPartner: false,
partnerKey: undefined,
requiresIntegration: false,
hasConfiguredIntegration: false,
isLoading: false,
error: undefined,
});
});

test("has required integration", async () => {
const store = testStore({
auth: authSlice.getInitialState(),
Expand All @@ -170,6 +237,7 @@ describe("useRequiredPartnerAuth", () => {
isLoading: false,
data: {
id: uuidv4(),
milestones: [],
partner: {
id: uuidv4(),
theme: "automation-anywhere",
Expand Down
40 changes: 26 additions & 14 deletions src/auth/useRequiredPartnerAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,22 @@ export type RequiredPartnerState = {

function decidePartnerServiceIds({
authServiceIdOverride,
authMethod,
authMethodOverride,
partnerId,
}: {
authServiceIdOverride: RegistryId | null;
authMethod: SettingsState["authMethod"];
authMethodOverride: SettingsState["authMethod"];
partnerId: AuthState["partner"]["theme"] | null;
}): Set<RegistryId> {
if (authServiceIdOverride) {
return new Set<RegistryId>([authServiceIdOverride]);
}

if (authMethod === "partner-oauth2") {
if (authMethodOverride === "partner-oauth2") {
return new Set<RegistryId>([CONTROL_ROOM_OAUTH_SERVICE_ID]);
}

if (authMethod === "partner-token") {
if (authMethodOverride === "partner-token") {
return new Set<RegistryId>([CONTROL_ROOM_SERVICE_ID]);
}

Expand All @@ -124,66 +124,76 @@ function useRequiredPartnerAuth(): RequiredPartnerState {
const localAuth = useSelector(selectAuth);
const {
authServiceId: authServiceIdOverride,
authMethod,
authMethod: authMethodOverride,
partnerId: partnerIdOverride,
} = useSelector(selectSettings);
const configuredServices = useSelector(selectConfiguredServices);

// Control Room URL specified by IT department during force-install
const [managedControlRoomUrl] = useAsyncState(
async () => readStorage(CONTROL_ROOM_URL_MANAGED_KEY, undefined, "managed"),
[]
);

// Partner Id/Key specified by IT department during force-install
const [managedPartnerId] = useAsyncState(
async () => readStorage(PARTNER_MANAGED_KEY, undefined, "managed"),
[]
);

// Prefer the latest remote data, but use local data to avoid blocking page load
const { partner, organization } = me ?? localAuth;

// `organization?.control_room?.id` can only be set when authenticated or the auth is cached. For unauthorized users,
// the organization will be null on result of useGetMeQuery
const hasControlRoom =
Boolean(organization?.control_room?.id) || Boolean(managedControlRoomUrl);
const isCommunityEditionUser = (me?.milestones ?? []).some(
({ key }) => key === "aa_community_edition_register"
);
const hasPartner =
Boolean(partner) || Boolean(managedPartnerId) || hasControlRoom;
Boolean(partner) ||
Boolean(managedPartnerId) ||
hasControlRoom ||
(Boolean(me?.partner) && isCommunityEditionUser);
const partnerId =
partnerIdOverride ??
managedPartnerId ??
partner?.theme ??
(hasControlRoom ? "automation-anywhere" : null);
(hasControlRoom || isCommunityEditionUser ? "automation-anywhere" : null);

const partnerServiceIds = decidePartnerServiceIds({
authServiceIdOverride,
authMethod,
authMethodOverride,
partnerId,
});

const partnerConfiguration = configuredServices.find((service) =>
partnerServiceIds.has(service.serviceId)
);

// WARNING: the logic in this method must match the logic in usePartnerLoginMode
// `_` prefix so lint doesn't yell for unused variables in the destructuring
const [
isMissingPartnerJwt,
_partnerJwtLoading,
_partnerJwtError,
refreshPartnerJwtState,
] = useAsyncState(async () => {
if (authMethod === "pixiebrix-token") {
if (authMethodOverride === "pixiebrix-token") {
// User forced pixiebrix-token authentication via Advanced Settings > Authentication Method
return false;
}

if (hasControlRoom || authMethod === "partner-oauth2") {
if (hasControlRoom || authMethodOverride === "partner-oauth2") {
// Future improvement: check that the Control Room URL from readPartnerAuthData matches the expected
// Control Room URL
const { token: partnerToken } = await readPartnerAuthData();
return partnerToken == null;
}

return false;
}, [authMethod, localAuth, hasControlRoom]);
}, [authMethodOverride, localAuth, hasControlRoom]);

useEffect(() => {
// Listen for token invalidation
Expand All @@ -202,11 +212,13 @@ function useRequiredPartnerAuth(): RequiredPartnerState {
const requiresIntegration =
// Primary organization has a partner and linked control room
(hasPartner && Boolean(organization?.control_room)) ||
// Community edition users are required to be linked until they join an organization
(me?.partner && isCommunityEditionUser) ||
// User has overridden local settings
authMethod === "partner-oauth2" ||
authMethod === "partner-token";
authMethodOverride === "partner-oauth2" ||
authMethodOverride === "partner-token";

if (authMethod === "pixiebrix-token") {
if (authMethodOverride === "pixiebrix-token") {
// User forced pixiebrix-token authentication via Advanced Settings > Authentication Method. Keep the theme,
// if any, but don't require a partner integration configuration.
return {
Expand Down
57 changes: 57 additions & 0 deletions src/background/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright (C) 2022 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 MockAdapter from "axios-mock-adapter";
import axios from "axios";
import { getToken } from "./auth";
import { UUID } from "@/core";
import { uuidv4 } from "@/types/helpers";

const axiosMock = new MockAdapter(axios);

const getOneToken = async (id: UUID) =>
getToken(
{
// @ts-expect-error The result isn't necessary at this time
getTokenContext: () => ({}),
isToken: true,
},
{ id }
);

describe("getToken", () => {
test("multiple requests are temporarily memoized", async () => {
let userId = 0;
axiosMock.onPost().reply(() => [200, userId++]); // Increase ID at every request

const id1 = uuidv4();
// Consecutive calls should make new requests
expect(await getOneToken(id1)).toBe(0);
expect(await getOneToken(id1)).toBe(1);

// Parallel calls should make one request
expect(
await Promise.all([getOneToken(id1), getOneToken(id1)])
).toStrictEqual([2, 2]);

// Parallel calls but with different auth.id’s should make multiple requests
const id2 = uuidv4();
expect(
await Promise.all([getOneToken(id1), getOneToken(id2)])
).toStrictEqual([3, 4]);
});
});
Loading

0 comments on commit fa9ecc1

Please sign in to comment.