diff --git a/package-lock.json b/package-lock.json index 2c67630b68f..9a897398077 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "@commitlint/prompt": "^11.0.0", "@esri/arcgis-rest-auth": "^3.1.1", "@esri/arcgis-rest-feature-layer": "^3.4.3", - "@esri/arcgis-rest-portal": "^3.5.0", + "@esri/arcgis-rest-portal": "^3.7.0", "@esri/arcgis-rest-request": "^3.1.1", "@esri/arcgis-rest-service-admin": "^3.6.0", "@esri/arcgis-rest-types": "^3.1.1", @@ -4974,11 +4974,11 @@ } }, "node_modules/@esri/arcgis-rest-portal": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-portal/-/arcgis-rest-portal-3.6.0.tgz", - "integrity": "sha512-TPLcbQn+PfKqGlkCvlDYs2AQs7KdTKnM17AhGytnQkqYamNlhkZMAlgC4qq5/H7URTIqlQx52fx/OOfTfO0ABw==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-portal/-/arcgis-rest-portal-3.7.0.tgz", + "integrity": "sha512-KEdBHNmoYqbN1tmfCTwRq6AJ/Dl2rMSYgDFR1ODOKwicJ4PZPzfjHwUkSTS77eDEL/+ziRiSp5hJceJfDWzAgw==", "dependencies": { - "@esri/arcgis-rest-types": "^3.6.0", + "@esri/arcgis-rest-types": "^3.7.0", "tslib": "^1.13.0" }, "peerDependencies": { @@ -64981,7 +64981,7 @@ }, "packages/common": { "name": "@esri/hub-common", - "version": "14.19.0", + "version": "14.20.0", "license": "Apache-2.0", "dependencies": { "abab": "^2.0.5", @@ -65038,7 +65038,7 @@ "peerDependencies": { "@esri/arcgis-rest-auth": "^3.1.0", "@esri/arcgis-rest-feature-layer": "^3.1.0", - "@esri/arcgis-rest-portal": "^3.5.0", + "@esri/arcgis-rest-portal": "^3.7.0", "@esri/arcgis-rest-request": "^3.1.0", "@esri/hub-common": "^14.0.0" } @@ -68688,11 +68688,11 @@ } }, "@esri/arcgis-rest-portal": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-portal/-/arcgis-rest-portal-3.6.0.tgz", - "integrity": "sha512-TPLcbQn+PfKqGlkCvlDYs2AQs7KdTKnM17AhGytnQkqYamNlhkZMAlgC4qq5/H7URTIqlQx52fx/OOfTfO0ABw==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-portal/-/arcgis-rest-portal-3.7.0.tgz", + "integrity": "sha512-KEdBHNmoYqbN1tmfCTwRq6AJ/Dl2rMSYgDFR1ODOKwicJ4PZPzfjHwUkSTS77eDEL/+ziRiSp5hJceJfDWzAgw==", "requires": { - "@esri/arcgis-rest-types": "^3.6.0", + "@esri/arcgis-rest-types": "^3.7.0", "tslib": "^1.13.0" } }, diff --git a/package.json b/package.json index 3e8f916a5e7..0ed1e4ec308 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@commitlint/prompt": "^11.0.0", "@esri/arcgis-rest-auth": "^3.1.1", "@esri/arcgis-rest-feature-layer": "^3.4.3", - "@esri/arcgis-rest-portal": "^3.5.0", + "@esri/arcgis-rest-portal": "^3.7.0", "@esri/arcgis-rest-request": "^3.1.1", "@esri/arcgis-rest-service-admin": "^3.6.0", "@esri/arcgis-rest-types": "^3.1.1", diff --git a/packages/common/src/discussions/utils.ts b/packages/common/src/discussions/utils.ts index faf9636cc61..91eade70a98 100644 --- a/packages/common/src/discussions/utils.ts +++ b/packages/common/src/discussions/utils.ts @@ -1,9 +1,22 @@ import { IGroup, IItem } from "@esri/arcgis-rest-types"; import { IHubContent, IHubItemEntity } from "../core"; import { CANNOT_DISCUSS } from "./constants"; -import { IRequestOptions } from "@esri/arcgis-rest-request"; -import { updateItem, updateGroup } from "@esri/arcgis-rest-portal"; -import { IUserRequestOptions } from "@esri/arcgis-rest-auth"; +import { + AclCategory, + AclSubCategory, + IChannel, + IChannelAclPermission, + SharingAccess, +} from "./api/types"; +import { + IFilter, + IHubSearchOptions, + IHubSearchResponse, + IHubSearchResult, + IPredicate, + IQuery, + hubSearch, +} from "../search"; /** * Utility to determine if a given IGroup, IItem, IHubContent, or IHubItemEntity @@ -35,3 +48,160 @@ export function setDiscussableKeyword( } return updatedTypeKeywords; } + +/** + * Determines if the given channel is considered to be a `public` channel, supporting both + * legacy permissions and V2 ACL model. + * @param channel An IChannel record + * @returns true if the channel is considered `public` + */ +export function isPublicChannel(channel: IChannel): boolean { + return channel.channelAcl + ? channel.channelAcl.some( + ({ category }) => category === AclCategory.AUTHENTICATED_USER + ) + : channel.access === SharingAccess.PUBLIC; +} + +/** + * Determines if the given channel is considered to be an `org` channel, supporting both + * legacy permissions and V2 ACL model. + * @param channel An IChannel record + * @returns true if the channel is considered `org` + */ +export function isOrgChannel(channel: IChannel): boolean { + return channel.channelAcl + ? !isPublicChannel(channel) && + channel.channelAcl.some( + ({ category, subCategory }) => + category === AclCategory.ORG && + subCategory === AclSubCategory.MEMBER + ) + : channel.access === SharingAccess.ORG; +} + +/** + * Determines if the given channel is considered to be a `private` channel, supporting both + * legacy permissions and V2 ACL model. + * @param channel An IChannel record + * @returns true if the channel is considered `private` + */ +export function isPrivateChannel(channel: IChannel): boolean { + return !isPublicChannel(channel) && !isOrgChannel(channel); +} + +/** + * Determines the given channel's access, supporting both legacy permissions and V2 ACL + * model. + * @param channel An IChannel record + * @returns `public`, `org` or `private` + */ +export function getChannelAccess(channel: IChannel): SharingAccess { + let access = SharingAccess.PRIVATE; + if (isPublicChannel(channel)) { + access = SharingAccess.PUBLIC; + } else if (isOrgChannel(channel)) { + access = SharingAccess.ORG; + } + return access; +} + +/** + * Returns an array of org ids configured for the channel, supporting both legacy permissions + * and V2 ACL model. + * @param channel An IChannel record + * @returns an array of org ids for the given channel + */ +export function getChannelOrgIds(channel: IChannel): string[] { + return channel.channelAcl + ? channel.channelAcl.reduce( + (acc, permission) => + permission.category === AclCategory.ORG && + permission.subCategory === AclSubCategory.MEMBER + ? [...acc, permission.key] + : acc, + [] + ) + : channel.orgs; +} + +/** + * Returns an array of group ids configured for the channel, supporting both legacy permissions + * and V2 ACL model. + * @param channel An IChannel record + * @returns an array of group ids for the given channel + */ +export function getChannelGroupIds(channel: IChannel): string[] { + return channel.channelAcl + ? channel.channelAcl.reduce( + (acc, permission) => + permission.category === AclCategory.GROUP && + permission.subCategory === AclSubCategory.MEMBER + ? [...acc, permission.key] + : acc, + [] + ) + : channel.groups; +} + +/** + * A utility method used to build an IQuery to search for users that are permitted to be at-mentioned for the given channel. + * @param input An array of strings to search for. Each string is mapped to `username` and `fullname`, filters as an OR condition + * @param channel An IChannel record + * @param currentUsername The currently authenticated user's username + * @param options An IHubSearchOptions object + * @returns a promise that resolves an IHubSearchResponse + */ +export function getChannelUsersQuery( + inputs: string[], + channel: IChannel, + currentUsername?: string +): IQuery { + const groupIds = getChannelGroupIds(channel); + const orgIds = getChannelOrgIds(channel); + const groupsPredicate = { group: groupIds }; + let filters: IFilter[]; + if (isPublicChannel(channel)) { + filters = [ + { + operation: "OR", + predicates: [{ orgid: { from: "0", to: "{" } }], + }, + ]; + } else if (isOrgChannel(channel)) { + const additional = groupIds.length ? [groupsPredicate] : []; + filters = [ + { + operation: "OR", + predicates: [{ orgid: orgIds }, ...additional], + }, + ]; + } else { + filters = [ + { + operation: "AND", + predicates: [groupsPredicate], + }, + ]; + } + if (currentUsername) { + filters.push({ + operation: "AND", + predicates: [{ username: { not: currentUsername } }], + }); + } + const query: IQuery = { + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: inputs.reduce( + (acc, input) => [...acc, { username: input }, { fullname: input }], + [] + ), + }, + ...filters, + ], + }; + return query; +} diff --git a/packages/common/src/search/_internal/portalSearchGroupMembers.ts b/packages/common/src/search/_internal/portalSearchGroupMembers.ts index d86bfd6abab..6425b591b28 100644 --- a/packages/common/src/search/_internal/portalSearchGroupMembers.ts +++ b/packages/common/src/search/_internal/portalSearchGroupMembers.ts @@ -218,5 +218,9 @@ async function memberToSearchResult( } }); - return enrichUserSearchResult(user, include, requestOptions); + return enrichUserSearchResult( + user, + ["org.name as OrgName", ...include], + requestOptions + ); } diff --git a/packages/common/src/search/_internal/portalSearchUsers.ts b/packages/common/src/search/_internal/portalSearchUsers.ts index 6d46eba9253..1d6254ee34b 100644 --- a/packages/common/src/search/_internal/portalSearchUsers.ts +++ b/packages/common/src/search/_internal/portalSearchUsers.ts @@ -1,7 +1,9 @@ import { ISearchOptions, + ISearchResult, IUserSearchOptions, searchUsers, + searchCommunityUsers as _searchCommunityUsers, } from "@esri/arcgis-rest-portal"; import { IUser } from "@esri/arcgis-rest-types"; import { enrichUserSearchResult } from "../../users"; @@ -16,41 +18,34 @@ import { } from "../types"; import { getNextFunction } from "../utils"; import { expandPredicate } from "./expandPredicate"; +import { cloneObject } from "../../util"; -/** - * @private - * Portal Search Implementation for Users - * @param filterGroups - * @param options - * @returns - */ -export async function portalSearchUsers( +function buildSearchOptions( query: IQuery, - options: IHubSearchOptions -): Promise> { + options: IHubSearchOptions, + operation: string +): IUserSearchOptions { // requestOptions is always required and user must be authd if (!options.requestOptions) { throw new HubError( - "portalSearchUsers", + operation, "requestOptions: IHubRequestOptions is required." ); } if (!options.requestOptions.authentication) { - throw new HubError( - "portalSearchUsers", - "requestOptions must pass authentication." - ); + throw new HubError(operation, "requestOptions must pass authentication."); } + const clonedQuery = cloneObject(query); // Expand the individual predicates in each filter - query.filters = query.filters.map((filter) => { + clonedQuery.filters = clonedQuery.filters.map((filter) => { filter.predicates = filter.predicates.map(expandPredicate); return filter; }); // Serialize the all the groups for portal - const so = serializeQueryForPortal(query); + const so = serializeQueryForPortal(clonedQuery); // Array of properties we want to copy from IHubSearchOptions to the ISearchOptions const props: Array = [ "num", @@ -71,36 +66,131 @@ export async function portalSearchUsers( // so we set it directly so.authentication = options.requestOptions.authentication; + return so as IUserSearchOptions; +} + +/** + * @private + * + * Portal Search Implementation for Users within the currently authenticated user's organization. + * Automatically adds "org.name as OrgName" enrichment + * + * DEPRECATED: This method will be deprecated in a future release, as it's not ideal to impose default enrichments in all + * cases. When this method is depreated, all places that currently call `hubSearch` with a `targetEntity` of `user` will + * need to be updated to use the `portalUser` `targetEntity` and explicitly pass `"org.name as OrgName"` in `inclues` to + * preserve that enrichment, if needed. E.g. + * + * ```js + * // before + * await hubSearch( + * { targetEntity: "user", ... }, + * { start: 1, ... }, + * ); + * + * // after + * await hubSearch( + * { targetEntity: "portalUser", ... }, + * { start: 1, include: ["org.name as OrgName", ...], ... }, + * ); + * ``` + * + * @param query An IQuery object representing the query to serialize + * @param options An IHubSearchOptions of search options + * @returns a promise that resolves an IHubSearchResponse of users results + */ +export function searchPortalUsersLegacy( + query: IQuery, + options: IHubSearchOptions +): Promise> { + const searchOptions = buildSearchOptions( + query, + options, + "searchPortalUsersLegacy" + ); // Execute search - return searchPortal(so as IUserSearchOptions); + return searchPortal({ + ...searchOptions, + include: ["org.name as OrgName", ...(searchOptions.include || [])], + }); } /** - * Internal portal search, which then converts `IGroup`s to `IHubSearchResult`s - * handling enrichments & includes along the way + * @private * - * @param searchOptions - * @returns + * Portal Search Implementation for Users within the currently authenticated user's organization. + * No enrichments added by default. + * + * @param query An IQuery object representing the query to serialize + * @param options An IHubSearchOptions of search options + * @returns a promise that resolves an IHubSearchResponse of users results */ -async function searchPortal( - searchOptions: IUserSearchOptions +export function searchPortalUsers( + query: IQuery, + options: IHubSearchOptions ): Promise> { - // Execute portal search - const resp = await searchUsers(searchOptions); + const searchOptions = buildSearchOptions(query, options, "searchPortalUsers"); + // Execute search + return searchPortal(searchOptions); +} + +/** + * @private + * + * Community Search Implementation for Users within in any organization. + * No enrichments added by default. + * + * @param query An IQuery object representing the query to serialize + * @param options An IHubSearchOptions of search options + * @returns a promise that resolves an IHubSearchResponse of users results + */ +export function searchCommunityUsers( + query: IQuery, + options: IHubSearchOptions +): Promise> { + const searchOptions = buildSearchOptions( + query, + options, + "searchCommunityUsers" + ); + // Execute search + return searchCommunity(searchOptions); +} +/** + * @private + * @param searchOptions An IUserSearchOptions object + * @param searchResponse A ISearchResult object + * @returns + */ +function mapUsersToSearchResults( + searchOptions: IUserSearchOptions, + searchResponse: ISearchResult +): Promise { // create mappable fn that will close // over the includes and requestOptions - const fn = (user: IUser) => { - return userToSearchResult( + const fn = (user: IUser) => + userToSearchResult( user, searchOptions.include, searchOptions.requestOptions ); - }; + return Promise.all(searchResponse.results.map(fn)); +} +/** + * Internal portal search, which then converts `IGroup`s to `IHubSearchResult`s + * handling enrichments & includes along the way + * + * @param searchOptions + * @returns a promise that resolves enriched internal portal user search results + */ +async function searchPortal( + searchOptions: IUserSearchOptions +): Promise> { + // Execute portal search + const resp = await searchUsers(searchOptions); // map over results - const results = await Promise.all(resp.results.map(fn)); - + const results = await mapUsersToSearchResults(searchOptions, resp); // Group Search does not support aggregations // Construct the return return { @@ -116,6 +206,35 @@ async function searchPortal( }; } +/** + * Community search, which then converts `IGroup`s to `IHubSearchResult`s + * handling enrichments & includes along the way + * + * @param searchOptions + * @returns a promise that resolves enriched community user search results + */ +async function searchCommunity( + searchOptions: IUserSearchOptions +): Promise> { + // Execute portal search + const resp = await _searchCommunityUsers(searchOptions); + // map over results + const results = await mapUsersToSearchResults(searchOptions, resp); + // Group Search does not support aggregations + // Construct the return + return { + total: resp.total, + results, + hasNext: resp.nextStart > -1, + next: getNextFunction( + searchOptions, + resp.nextStart, + resp.total, + searchCommunity + ), + }; +} + /** * Convert an Item to a IHubSearchResult * Fetches the includes and attaches them to the item @@ -124,7 +243,7 @@ async function searchPortal( * @param requestOptions * @returns */ -async function userToSearchResult( +function userToSearchResult( user: IUser, include: string[] = [], requestOptions?: IHubRequestOptions diff --git a/packages/common/src/search/hubSearch.ts b/packages/common/src/search/hubSearch.ts index 4a93be80b72..f82b495d81c 100644 --- a/packages/common/src/search/hubSearch.ts +++ b/packages/common/src/search/hubSearch.ts @@ -8,11 +8,10 @@ import { IQuery, } from "./types"; import { getApi } from "./_internal/commonHelpers/getApi"; - import { portalSearchGroupMembers } from "./_internal/portalSearchGroupMembers"; import { portalSearchItems } from "./_internal/portalSearchItems"; import { portalSearchGroups } from "./_internal/portalSearchGroups"; -import { portalSearchUsers } from "./_internal/portalSearchUsers"; +import { searchPortalUsersLegacy, searchPortalUsers, searchCommunityUsers } from "./_internal/portalSearchUsers"; import { hubSearchItems } from "./_internal/hubSearchItems"; import { hubSearchChannels } from "./_internal/hubSearchChannels"; @@ -74,7 +73,9 @@ export async function hubSearch( arcgis: { item: portalSearchItems, group: portalSearchGroups, - user: portalSearchUsers, + user: searchPortalUsersLegacy, + portalUser: searchPortalUsers, + communityUser: searchCommunityUsers, groupMember: portalSearchGroupMembers, }, "arcgis-hub": { diff --git a/packages/common/src/search/serializeQueryForPortal.ts b/packages/common/src/search/serializeQueryForPortal.ts index 52f2a44e6f7..49a231337fc 100644 --- a/packages/common/src/search/serializeQueryForPortal.ts +++ b/packages/common/src/search/serializeQueryForPortal.ts @@ -164,11 +164,8 @@ function serializePredicate(predicate: IPredicate): ISearchOptions { if (!specialProps.includes(key) && key !== "term") { so.q = serializeMatchOptions(key, value); } - if (dateProps.includes(key)) { - so.q = serializeDateRange( - key, - value as unknown as IDateRange - ); + if (dateProps.includes(key) || isRange(value)) { + so.q = serializeRange(key, value as unknown as IDateRange); } if (boolProps.includes(key)) { so.q = `${key}:${value}`; @@ -230,12 +227,12 @@ function serializeMatchOptions(key: string, value: IMatchOptions): string { } /** - * Serialize a date-range into Portal syntax + * Serialize a range into Portal syntax * @param key * @param range * @returns */ -function serializeDateRange(key: string, range: IDateRange): string { +function serializeRange(key: string, range: IDateRange): string { return `${key}:[${range.from} TO ${range.to}]`; } @@ -262,3 +259,16 @@ function serializeStringOrArray( } return q; } + +/** + * Determines if the given value is a range object + * @param value A search param value + * @returns true when the value is a range object, e.g. { from: '0', to: '{' } + */ +function isRange(value: any): boolean { + return ( + typeof value === "object" && + value !== null && + ["from", "to"].every((key) => typeof value[key] !== "undefined") + ); +} diff --git a/packages/common/src/search/types/IHubCatalog.ts b/packages/common/src/search/types/IHubCatalog.ts index 2608200a5be..e8292451acf 100644 --- a/packages/common/src/search/types/IHubCatalog.ts +++ b/packages/common/src/search/types/IHubCatalog.ts @@ -57,6 +57,8 @@ export type EntityType = | "item" | "group" | "user" + | "portalUser" + | "communityUser" | "groupMember" | "event" | "channel"; diff --git a/packages/common/src/users/HubUsers.ts b/packages/common/src/users/HubUsers.ts index f9d06669b67..78e8a98bc34 100644 --- a/packages/common/src/users/HubUsers.ts +++ b/packages/common/src/users/HubUsers.ts @@ -56,16 +56,8 @@ export async function enrichUserSearchResult( result.isGroupOwner = user.isGroupOwner; } - // Informal Enrichments - basically adding type-specific props - // derived directly from the entity - - // default includes - const DEFAULTS: string[] = ["org.name AS orgName"]; - - // merge includes - include = [...DEFAULTS, ...include].filter(unique); // Parse the includes into a valid set of enrichments - const specs = include.map(parseInclude); + const specs = include.filter(unique).map(parseInclude); // Extract out the low-level enrichments needed const enrichments = mapBy("enrichment", specs).filter(unique); // fetch the enrichments diff --git a/packages/common/test/discussions/utils.test.ts b/packages/common/test/discussions/utils.test.ts index 1e1036b2ebc..5f24a0b869b 100644 --- a/packages/common/test/discussions/utils.test.ts +++ b/packages/common/test/discussions/utils.test.ts @@ -2,6 +2,17 @@ import { CANNOT_DISCUSS, isDiscussable, setDiscussableKeyword, + getChannelUsersQuery, + SharingAccess, + isPublicChannel, + isOrgChannel, + isPrivateChannel, + getChannelAccess, + getChannelGroupIds, + getChannelOrgIds, + IChannel, + AclCategory, + AclSubCategory, } from "../../src"; describe("discussions utils", () => { @@ -36,4 +47,525 @@ describe("discussions utils", () => { expect(result).toEqual([CANNOT_DISCUSS]); }); }); + describe("isPublicChannel", () => { + it("should return true for legacy permissions", () => { + expect( + isPublicChannel({ access: SharingAccess.PUBLIC } as IChannel) + ).toBe(true); + }); + it("should return false for legacy permissions", () => { + expect(isPublicChannel({ access: SharingAccess.ORG } as IChannel)).toBe( + false + ); + }); + it("should return true for acl", () => { + expect( + isPublicChannel({ + channelAcl: [{ category: AclCategory.AUTHENTICATED_USER }], + } as IChannel) + ).toBe(true); + }); + it("should return false for acl", () => { + expect( + isPublicChannel({ + channelAcl: [{ category: AclCategory.ORG }], + } as IChannel) + ).toBe(false); + }); + }); + describe("isOrgChannel", () => { + it("should return true for legacy permissions", () => { + expect(isOrgChannel({ access: SharingAccess.ORG } as IChannel)).toBe( + true + ); + }); + it("should return false for legacy permissions", () => { + expect(isOrgChannel({ access: SharingAccess.PUBLIC } as IChannel)).toBe( + false + ); + }); + it("should return true for acl", () => { + expect( + isOrgChannel({ + channelAcl: [ + { category: AclCategory.ORG, subCategory: AclSubCategory.MEMBER }, + ], + } as IChannel) + ).toBe(true); + }); + it("should return false for acl", () => { + expect( + isOrgChannel({ + channelAcl: [ + { category: AclCategory.GROUP, subCategory: AclSubCategory.MEMBER }, + ], + } as IChannel) + ).toBe(false); + }); + }); + describe("isPrivateChannel", () => { + it("should return true for legacy permissions", () => { + expect( + isPrivateChannel({ access: SharingAccess.PRIVATE } as IChannel) + ).toBe(true); + }); + it("should return false for legacy permissions", () => { + expect( + isPrivateChannel({ access: SharingAccess.PUBLIC } as IChannel) + ).toBe(false); + }); + it("should return true for acl", () => { + expect( + isPrivateChannel({ + channelAcl: [{ category: AclCategory.GROUP }], + } as IChannel) + ).toBe(true); + }); + it("should return false for acl", () => { + expect( + isPrivateChannel({ + channelAcl: [ + { category: AclCategory.ORG, subCategory: AclSubCategory.MEMBER }, + ], + } as IChannel) + ).toBe(false); + }); + }); + describe("getChannelAccess", () => { + it("should return public for legacy permissions", () => { + expect( + getChannelAccess({ access: SharingAccess.PUBLIC } as IChannel) + ).toBe(SharingAccess.PUBLIC); + }); + it("should return public for acl", () => { + expect( + getChannelAccess({ + channelAcl: [{ category: AclCategory.AUTHENTICATED_USER }], + } as IChannel) + ).toBe(SharingAccess.PUBLIC); + }); + it("should return org for legacy permissions", () => { + expect(getChannelAccess({ access: SharingAccess.ORG } as IChannel)).toBe( + SharingAccess.ORG + ); + }); + it("should return org for acl", () => { + expect( + getChannelAccess({ + channelAcl: [ + { category: AclCategory.ORG, subCategory: AclSubCategory.MEMBER }, + ], + } as IChannel) + ).toBe(SharingAccess.ORG); + }); + it("should return private for legacy permissions", () => { + expect( + getChannelAccess({ access: SharingAccess.PRIVATE } as IChannel) + ).toBe(SharingAccess.PRIVATE); + }); + it("should return private for acl", () => { + expect( + getChannelAccess({ + channelAcl: [{ category: AclCategory.GROUP }], + } as IChannel) + ).toBe(SharingAccess.PRIVATE); + }); + }); + describe("getChannelOrgIds", () => { + it("should return channel.orgs for legacy permissions", () => { + expect(getChannelOrgIds({ orgs: ["31c"] } as IChannel)).toEqual(["31c"]); + }); + it("should return org ids from acl records", () => { + expect( + getChannelOrgIds({ + channelAcl: [ + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: "31c", + }, + ], + } as IChannel) + ).toEqual(["31c"]); + }); + }); + describe("getChannelGroupIds", () => { + it("should return channel.groups for legacy permissions", () => { + expect(getChannelGroupIds({ groups: ["31c"] } as IChannel)).toEqual([ + "31c", + ]); + }); + it("should return group ids from acl records", () => { + expect( + getChannelGroupIds({ + channelAcl: [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: "31c", + }, + ], + } as IChannel) + ).toEqual(["31c"]); + }); + }); + describe("getChannelUsersQuery", () => { + describe("legacy permissions", () => { + it("should return a query for users in a private channel", () => { + const res = getChannelUsersQuery( + ["user1", "user2"], + { + access: SharingAccess.PRIVATE, + orgs: ["org1", "org2"], + groups: ["group1", "group2"], + } as IChannel, + "currentUser" + ); + expect(res).toEqual({ + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: [ + { username: "user1" }, + { fullname: "user1" }, + { username: "user2" }, + { fullname: "user2" }, + ], + }, + { + operation: "AND", + predicates: [{ group: ["group1", "group2"] }], + }, + { + operation: "AND", + predicates: [{ username: { not: "currentUser" } }], + }, + ], + }); + }); + it("should return a query for users in an org-only channel", () => { + const res = getChannelUsersQuery( + ["user1", "user2"], + { + access: SharingAccess.ORG, + orgs: ["org1", "org2"], + groups: [] as string[], + } as IChannel, + "currentUser" + ); + expect(res).toEqual({ + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: [ + { username: "user1" }, + { fullname: "user1" }, + { username: "user2" }, + { fullname: "user2" }, + ], + }, + { + operation: "OR", + predicates: [{ orgid: ["org1", "org2"] }], + }, + { + operation: "AND", + predicates: [{ username: { not: "currentUser" } }], + }, + ], + }); + }); + it("should return a query for users in an org channel with groups", () => { + const res = getChannelUsersQuery( + ["user1", "user2"], + { + access: SharingAccess.ORG, + orgs: ["org1", "org2"], + groups: ["group1", "group2"], + } as IChannel, + "currentUser" + ); + expect(res).toEqual({ + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: [ + { username: "user1" }, + { fullname: "user1" }, + { username: "user2" }, + { fullname: "user2" }, + ], + }, + { + operation: "OR", + predicates: [ + { orgid: ["org1", "org2"] }, + { group: ["group1", "group2"] }, + ], + }, + { + operation: "AND", + predicates: [{ username: { not: "currentUser" } }], + }, + ], + }); + }); + it("should return a query for users in a public channel", () => { + const res = getChannelUsersQuery( + ["user1", "user2"], + { + access: SharingAccess.PUBLIC, + orgs: ["org1", "org2"], + groups: [] as string[], + } as IChannel, + "currentUser" + ); + expect(res).toEqual({ + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: [ + { username: "user1" }, + { fullname: "user1" }, + { username: "user2" }, + { fullname: "user2" }, + ], + }, + { + operation: "OR", + predicates: [{ orgid: { from: "0", to: "{" } }], + }, + { + operation: "AND", + predicates: [{ username: { not: "currentUser" } }], + }, + ], + }); + }); + it("should not filter out the authenticated user", () => { + const res = getChannelUsersQuery(["user1", "user2"], { + access: SharingAccess.PUBLIC, + orgs: ["org1", "org2"], + groups: ["group1", "group2"], + } as IChannel); + expect(res).toEqual({ + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: [ + { username: "user1" }, + { fullname: "user1" }, + { username: "user2" }, + { fullname: "user2" }, + ], + }, + { + operation: "OR", + predicates: [{ orgid: { from: "0", to: "{" } }], + }, + ], + }); + }); + }); + describe("acl", () => { + it("should return a query for users in a private channel", () => { + const res = getChannelUsersQuery( + ["user1", "user2"], + { + channelAcl: [ + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: "group1", + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: "group2", + }, + ], + } as IChannel, + "currentUser" + ); + expect(res).toEqual({ + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: [ + { username: "user1" }, + { fullname: "user1" }, + { username: "user2" }, + { fullname: "user2" }, + ], + }, + { + operation: "AND", + predicates: [{ group: ["group1", "group2"] }], + }, + { + operation: "AND", + predicates: [{ username: { not: "currentUser" } }], + }, + ], + }); + }); + it("should return a query for users in an org-only channel", () => { + const res = getChannelUsersQuery( + ["user1", "user2"], + { + channelAcl: [ + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: "org1", + }, + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: "org2", + }, + ], + } as IChannel, + "currentUser" + ); + expect(res).toEqual({ + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: [ + { username: "user1" }, + { fullname: "user1" }, + { username: "user2" }, + { fullname: "user2" }, + ], + }, + { + operation: "OR", + predicates: [{ orgid: ["org1", "org2"] }], + }, + { + operation: "AND", + predicates: [{ username: { not: "currentUser" } }], + }, + ], + }); + }); + it("should return a query for users in an org channel with groups", () => { + const res = getChannelUsersQuery( + ["user1", "user2"], + { + channelAcl: [ + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: "org1", + }, + { + category: AclCategory.ORG, + subCategory: AclSubCategory.MEMBER, + key: "org2", + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: "group1", + }, + { + category: AclCategory.GROUP, + subCategory: AclSubCategory.MEMBER, + key: "group2", + }, + ], + } as IChannel, + "currentUser" + ); + expect(res).toEqual({ + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: [ + { username: "user1" }, + { fullname: "user1" }, + { username: "user2" }, + { fullname: "user2" }, + ], + }, + { + operation: "OR", + predicates: [ + { orgid: ["org1", "org2"] }, + { group: ["group1", "group2"] }, + ], + }, + { + operation: "AND", + predicates: [{ username: { not: "currentUser" } }], + }, + ], + }); + }); + it("should return a query for users in a public channel", () => { + const res = getChannelUsersQuery( + ["user1", "user2"], + { + channelAcl: [{ category: AclCategory.AUTHENTICATED_USER }], + } as IChannel, + "currentUser" + ); + expect(res).toEqual({ + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: [ + { username: "user1" }, + { fullname: "user1" }, + { username: "user2" }, + { fullname: "user2" }, + ], + }, + { + operation: "OR", + predicates: [{ orgid: { from: "0", to: "{" } }], + }, + { + operation: "AND", + predicates: [{ username: { not: "currentUser" } }], + }, + ], + }); + }); + it("should not filter out the authenticated user", () => { + const res = getChannelUsersQuery(["user1", "user2"], { + channelAcl: [{ category: AclCategory.AUTHENTICATED_USER }], + } as IChannel); + expect(res).toEqual({ + targetEntity: "communityUser", + filters: [ + { + operation: "OR", + predicates: [ + { username: "user1" }, + { fullname: "user1" }, + { username: "user2" }, + { fullname: "user2" }, + ], + }, + { + operation: "OR", + predicates: [{ orgid: { from: "0", to: "{" } }], + }, + ], + }); + }); + }); + }); }); diff --git a/packages/common/test/search/_internal/portalSearchUsers.test.ts b/packages/common/test/search/_internal/portalSearchUsers.test.ts index c7ac6ff7eba..7ee3d4b3f4b 100644 --- a/packages/common/test/search/_internal/portalSearchUsers.test.ts +++ b/packages/common/test/search/_internal/portalSearchUsers.test.ts @@ -1,12 +1,16 @@ import { cloneObject, IHubSearchOptions, IQuery } from "../../../src"; -import { portalSearchUsers } from "../../../src/search/_internal/portalSearchUsers"; +import { + searchPortalUsers, + searchCommunityUsers, + searchPortalUsersLegacy, +} from "../../../src/search/_internal/portalSearchUsers"; import * as Portal from "@esri/arcgis-rest-portal"; import * as users from "../../../src/users"; import { MOCK_AUTH } from "../../mocks/mock-auth"; import * as SimpleResponse from "../../mocks/user-search/simple-response.json"; -describe("portalSearchUsers module:", () => { - describe("portalSearchUsers:", () => { +describe("searchPortalUsersLegacy module:", () => { + describe("searchPortalUsersLegacy:", () => { it("throws if requestOptions not passed in IHubSearchOptions", async () => { const qry: IQuery = { targetEntity: "user", @@ -23,7 +27,7 @@ describe("portalSearchUsers module:", () => { const opts: IHubSearchOptions = {}; try { - await portalSearchUsers(qry, opts); + await searchPortalUsersLegacy(qry, opts); } catch (err) { expect(err.name).toBe("HubError"); expect(err.message).toBe( @@ -51,7 +55,7 @@ describe("portalSearchUsers module:", () => { }; try { - await portalSearchUsers(qry, opts); + await searchPortalUsersLegacy(qry, opts); } catch (err) { expect(err.name).toBe("HubError"); expect(err.message).toBe("requestOptions must pass authentication."); @@ -85,7 +89,7 @@ describe("portalSearchUsers module:", () => { }, }; - await portalSearchUsers(qry, opts); + await searchPortalUsersLegacy(qry, opts); expect(searchUsersSpy.calls.count()).toBe(1, "should call searchItems"); const [expectedParams] = searchUsersSpy.calls.argsFor(0); @@ -101,4 +105,200 @@ describe("portalSearchUsers module:", () => { ); }); }); + describe("searchPortalUsers:", () => { + it("throws if requestOptions not passed in IHubSearchOptions", async () => { + const qry: IQuery = { + targetEntity: "user", + filters: [ + { + predicates: [ + { + term: "water", + }, + ], + }, + ], + }; + const opts: IHubSearchOptions = {}; + + try { + await searchPortalUsers(qry, opts); + } catch (err) { + expect(err.name).toBe("HubError"); + expect(err.message).toBe( + "requestOptions: IHubRequestOptions is required." + ); + } + }); + it("throws if requestOptions.auth not passed in IHubSearchOptions", async () => { + const qry: IQuery = { + targetEntity: "user", + filters: [ + { + predicates: [ + { + term: "water", + }, + ], + }, + ], + }; + const opts: IHubSearchOptions = { + requestOptions: { + portal: "https://myserver.com/sharing/rest", + }, + }; + + try { + await searchPortalUsers(qry, opts); + } catch (err) { + expect(err.name).toBe("HubError"); + expect(err.message).toBe("requestOptions must pass authentication."); + } + }); + it("simple search", async () => { + const searchUsersSpy = spyOn(Portal, "searchUsers").and.callFake(() => { + return Promise.resolve(cloneObject(SimpleResponse)); + }); + // NOTE: enrichUserSearchResult is tested elsewhere so we don't assert on the results here + const enrichUserSearchResultSpy = spyOn( + users, + "enrichUserSearchResult" + ).and.callFake(() => Promise.resolve({})); + const qry: IQuery = { + targetEntity: "user", + filters: [ + { + predicates: [ + { + firstname: "Jane", + }, + ], + }, + ], + }; + const opts: IHubSearchOptions = { + requestOptions: { + portal: "https://www.arcgis.com/sharing/rest", + authentication: MOCK_AUTH, + }, + }; + + await searchPortalUsers(qry, opts); + + expect(searchUsersSpy.calls.count()).toBe(1, "should call searchItems"); + const [expectedParams] = searchUsersSpy.calls.argsFor(0); + expect(expectedParams.portal).toBeUndefined(); + expect(expectedParams.q).toEqual(`(firstname:"Jane")`); + expect(expectedParams.authentication).toEqual( + opts.requestOptions?.authentication + ); + expect(expectedParams.countFields).not.toBeDefined(); + expect(enrichUserSearchResultSpy.calls.count()).toBe( + 10, + "should call enrichUserSearchResult for each result" + ); + }); + }); + describe("searchCommunityUsers:", () => { + it("throws if requestOptions not passed in IHubSearchOptions", async () => { + const qry: IQuery = { + targetEntity: "user", + filters: [ + { + predicates: [ + { + term: "water", + }, + ], + }, + ], + }; + const opts: IHubSearchOptions = {}; + + try { + await searchCommunityUsers(qry, opts); + } catch (err) { + expect(err.name).toBe("HubError"); + expect(err.message).toBe( + "requestOptions: IHubRequestOptions is required." + ); + } + }); + it("throws if requestOptions.auth not passed in IHubSearchOptions", async () => { + const qry: IQuery = { + targetEntity: "user", + filters: [ + { + predicates: [ + { + term: "water", + }, + ], + }, + ], + }; + const opts: IHubSearchOptions = { + requestOptions: { + portal: "https://myserver.com/sharing/rest", + }, + }; + + try { + await searchCommunityUsers(qry, opts); + } catch (err) { + expect(err.name).toBe("HubError"); + expect(err.message).toBe("requestOptions must pass authentication."); + } + }); + it("simple search", async () => { + const searchCommunityUsersSpy = spyOn( + Portal, + "searchCommunityUsers" + ).and.callFake(() => { + return Promise.resolve(cloneObject(SimpleResponse)); + }); + // NOTE: enrichUserSearchResult is tested elsewhere so we don't assert on the results here + const enrichUserSearchResultSpy = spyOn( + users, + "enrichUserSearchResult" + ).and.callFake(() => Promise.resolve({})); + const qry: IQuery = { + targetEntity: "user", + filters: [ + { + predicates: [ + { + firstname: "Jane", + }, + ], + }, + ], + }; + const opts: IHubSearchOptions = { + requestOptions: { + portal: "https://www.arcgis.com/sharing/rest", + authentication: MOCK_AUTH, + }, + }; + + await searchCommunityUsers(qry, opts); + + expect(searchCommunityUsersSpy.calls.count()).toBe( + 1, + "should call searchItems" + ); + const [expectedParams] = searchCommunityUsersSpy.calls.argsFor(0); + expect(expectedParams.portal).toBeUndefined(); + expect(expectedParams.q).toEqual(`(firstname:"Jane")`); + expect(expectedParams.authentication).toEqual( + opts.requestOptions?.authentication + ); + expect(expectedParams.countFields).not.toBeDefined(); + expect(enrichUserSearchResultSpy.calls.count()).toBe( + 10, + "should call enrichUserSearchResult for each result" + ); + }); + }); }); diff --git a/packages/common/test/search/serializeQueryForPortal.test.ts b/packages/common/test/search/serializeQueryForPortal.test.ts index c563ad64490..9fd4b67cd55 100644 --- a/packages/common/test/search/serializeQueryForPortal.test.ts +++ b/packages/common/test/search/serializeQueryForPortal.test.ts @@ -30,6 +30,27 @@ describe("ifilter-utils:", () => { '(water AND modified:[1689716790912 TO 1652808629198] AND (type:"Web Map" OR type:"Hub Project"))' ); }); + it("handles non-date ranges", () => { + const p: IPredicate = { + // orgid: "[0 TO {]", + orgid: { from: "0", to: "{" }, + type: "Web Map", + }; + + const query: IQuery = { + targetEntity: "item", + filters: [ + { + operation: "AND", + predicates: [p], + }, + ], + }; + + const chk = serializeQueryForPortal(query); + + expect(chk.q).toEqual('(orgid:[0 TO {] AND type:"Web Map")'); + }); it("handles categories", () => { const p: IPredicate = { term: "water", diff --git a/packages/common/test/users/HubUsers.test.ts b/packages/common/test/users/HubUsers.test.ts index b18d416bcf4..a40d245477e 100644 --- a/packages/common/test/users/HubUsers.test.ts +++ b/packages/common/test/users/HubUsers.test.ts @@ -68,8 +68,8 @@ describe("HubUsers Module:", () => { hubRo ); expect(enrichmentSpy.calls.count()).toBe( - 1, - "should fetch default enrichment" + 0, + "should not fetch enrichments" ); const USR = cloneObject(TEST_USER); @@ -93,6 +93,36 @@ describe("HubUsers Module:", () => { `${hubRo.portal}/community/users/${USR.username}/info/${USR.thumbnail}?token=fake-token` ); }); + + it("converts user to search result and fetches enrichments", async () => { + const chk = await enrichUserSearchResult( + cloneObject(TEST_USER), + ["org.name as OrgName"], + hubRo + ); + expect(enrichmentSpy.calls.count()).toBe(1, "should fetch enrichments"); + + const USR = cloneObject(TEST_USER); + expect(chk.access).toEqual(USR.access); + expect(chk.id).toEqual(USR.username); + expect(chk.type).toEqual("User"); + expect(chk.name).toEqual(USR.fullName); + expect(chk.owner).toEqual(USR.username); + expect(chk.summary).toEqual(USR.description); + expect(chk.createdDate).toEqual(new Date(USR.created)); + expect(chk.createdDateSource).toEqual("user.created"); + expect(chk.updatedDate).toEqual(new Date(USR.modified)); + expect(chk.updatedDateSource).toEqual("user.modified"); + expect(chk.family).toEqual("people"); + + expect(chk.links.self).toEqual( + `https://some-server.com/gis/home/user.html?user=${USR.username}` + ); + expect(chk.links.siteRelative).toEqual(`/people/${USR.username}`); + expect(chk.links.thumbnail).toEqual( + `${hubRo.portal}/community/users/${USR.username}/info/${USR.thumbnail}?token=fake-token` + ); + }); it("handles memberType", async () => { const USR = cloneObject(TEST_USER); USR.memberType = "admin"; diff --git a/packages/downloads/package.json b/packages/downloads/package.json index c332155e9f7..68c38b928fe 100644 --- a/packages/downloads/package.json +++ b/packages/downloads/package.json @@ -14,7 +14,7 @@ "peerDependencies": { "@esri/arcgis-rest-auth": "^3.1.0", "@esri/arcgis-rest-feature-layer": "^3.1.0", - "@esri/arcgis-rest-portal": "^3.5.0", + "@esri/arcgis-rest-portal": "^3.7.0", "@esri/arcgis-rest-request": "^3.1.0", "@esri/hub-common": "^14.0.0" },