fix: allow using notifications without users in catalog
additionally refactored and added tests for the notification receiver logic fixes #25768 Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-notifications-backend': patch
|
||||
---
|
||||
|
||||
Allow using notifications without users in the catalog
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { getUsersForEntityRef } from './getUsersForEntityRef';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import {
|
||||
RELATION_HAS_MEMBER,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PARENT_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
describe('getUsersForEntityRef', () => {
|
||||
const catalogApiMock = {
|
||||
getEntitiesByRefs: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
};
|
||||
const authMock = mockServices.auth();
|
||||
|
||||
it('should return empty array if entityRef is null', async () => {
|
||||
await expect(
|
||||
getUsersForEntityRef(null, [], {
|
||||
auth: authMock,
|
||||
catalogClient: catalogApiMock as unknown as CatalogApi,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should resolve users without calling catalog', async () => {
|
||||
await expect(
|
||||
getUsersForEntityRef(['user:foo', 'user:ignored'], ['user:ignored'], {
|
||||
auth: authMock,
|
||||
catalogClient: catalogApiMock as unknown as CatalogApi,
|
||||
}),
|
||||
).resolves.toEqual(['user:foo']);
|
||||
expect(catalogApiMock.getEntitiesByRefs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve group entities to users', async () => {
|
||||
catalogApiMock.getEntitiesByRefs.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'parent_group',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_HAS_MEMBER,
|
||||
targetRef: 'user:default/foo',
|
||||
},
|
||||
{
|
||||
type: RELATION_PARENT_OF,
|
||||
targetRef: 'group:default/child_group',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
catalogApiMock.getEntitiesByRefs.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'child_group',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_HAS_MEMBER,
|
||||
targetRef: 'user:default/bar',
|
||||
},
|
||||
{
|
||||
type: RELATION_HAS_MEMBER,
|
||||
targetRef: 'user:default/ignored',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getUsersForEntityRef(
|
||||
'group:default/parent_group',
|
||||
['user:default/ignored'],
|
||||
{
|
||||
auth: authMock,
|
||||
catalogClient: catalogApiMock as unknown as CatalogApi,
|
||||
},
|
||||
),
|
||||
).resolves.toEqual(['user:default/foo', 'user:default/bar']);
|
||||
});
|
||||
|
||||
it('should resolve user owner of entity from entity ref', async () => {
|
||||
catalogApiMock.getEntitiesByRefs.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test_component',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
targetRef: 'user:default/foo',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getUsersForEntityRef('component:default/test_component', [], {
|
||||
auth: authMock,
|
||||
catalogClient: catalogApiMock as unknown as CatalogApi,
|
||||
}),
|
||||
).resolves.toEqual(['user:default/foo']);
|
||||
});
|
||||
|
||||
it('should resolve group owner of entity from entity ref', async () => {
|
||||
catalogApiMock.getEntitiesByRefs.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test_component',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_OWNED_BY,
|
||||
targetRef: 'group:default/owner_group',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
catalogApiMock.getEntityByRef.mockResolvedValueOnce({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'owner_group',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: RELATION_HAS_MEMBER,
|
||||
targetRef: 'user:default/foo',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getUsersForEntityRef('component:default/test_component', [], {
|
||||
auth: authMock,
|
||||
catalogClient: catalogApiMock as unknown as CatalogApi,
|
||||
}),
|
||||
).resolves.toEqual(['user:default/foo']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
Entity,
|
||||
isGroupEntity,
|
||||
isUserEntity,
|
||||
parseEntityRef,
|
||||
RELATION_HAS_MEMBER,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PARENT_OF,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
|
||||
const isUserEntityRef = (ref: string) =>
|
||||
parseEntityRef(ref).kind.toLocaleLowerCase() === 'user';
|
||||
|
||||
// Partitions array of entity references to two arrays; user entity refs and other entity refs
|
||||
const partitionEntityRefs = (refs: string[]): string[][] =>
|
||||
refs.reduce(
|
||||
([userEntityRefs, otherEntityRefs]: string[][], ref: string) => {
|
||||
return isUserEntityRef(ref)
|
||||
? [[...userEntityRefs, ref], otherEntityRefs]
|
||||
: [userEntityRefs, [...otherEntityRefs, ref]];
|
||||
},
|
||||
[[], []],
|
||||
);
|
||||
|
||||
export const getUsersForEntityRef = async (
|
||||
entityRef: string | string[] | null,
|
||||
excludeEntityRefs: string | string[],
|
||||
options: {
|
||||
auth: AuthService;
|
||||
catalogClient: CatalogApi;
|
||||
},
|
||||
): Promise<string[]> => {
|
||||
const { auth, catalogClient } = options;
|
||||
|
||||
if (entityRef === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { token } = await auth.getPluginRequestToken({
|
||||
onBehalfOf: await auth.getOwnServiceCredentials(),
|
||||
targetPluginId: 'catalog',
|
||||
});
|
||||
|
||||
const excluded = Array.isArray(excludeEntityRefs)
|
||||
? excludeEntityRefs
|
||||
: [excludeEntityRefs];
|
||||
|
||||
const refsArr = Array.isArray(entityRef) ? entityRef : [entityRef];
|
||||
const [userEntityRefs, otherEntityRefs] = partitionEntityRefs(refsArr);
|
||||
const users: string[] = userEntityRefs.filter(ref => !excluded.includes(ref));
|
||||
const entityRefs = otherEntityRefs.filter(ref => !excluded.includes(ref));
|
||||
|
||||
const fields = ['kind', 'metadata.name', 'metadata.namespace', 'relations'];
|
||||
let entities: Array<Entity | undefined> = [];
|
||||
if (entityRefs.length > 0) {
|
||||
const fetchedEntities = await catalogClient.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs,
|
||||
fields,
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
entities = fetchedEntities.items;
|
||||
}
|
||||
|
||||
const mapEntity = async (entity: Entity | undefined): Promise<string[]> => {
|
||||
if (!entity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentEntityRef = stringifyEntityRef(entity);
|
||||
if (excluded.includes(currentEntityRef)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isUserEntity(entity)) {
|
||||
return [currentEntityRef];
|
||||
}
|
||||
|
||||
if (isGroupEntity(entity)) {
|
||||
if (!entity.relations?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupUsers = entity.relations
|
||||
.filter(
|
||||
relation =>
|
||||
relation.type === RELATION_HAS_MEMBER &&
|
||||
isUserEntityRef(relation.targetRef),
|
||||
)
|
||||
.map(r => r.targetRef);
|
||||
|
||||
const childGroupRefs = entity.relations
|
||||
.filter(relation => relation.type === RELATION_PARENT_OF)
|
||||
.map(r => r.targetRef);
|
||||
|
||||
let childGroupUsers: string[][] = [];
|
||||
if (childGroupRefs.length > 0) {
|
||||
const childGroups = await catalogClient.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: childGroupRefs,
|
||||
fields,
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
childGroupUsers = await Promise.all(childGroups.items.map(mapEntity));
|
||||
}
|
||||
|
||||
return [...groupUsers, ...childGroupUsers.flat(2)].filter(
|
||||
ref => !excluded.includes(ref),
|
||||
);
|
||||
}
|
||||
|
||||
if (entity.relations?.length) {
|
||||
const ownerRef = entity.relations.find(
|
||||
relation => relation.type === RELATION_OWNED_BY,
|
||||
)?.targetRef;
|
||||
|
||||
if (!ownerRef) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isUserEntityRef(ownerRef)) {
|
||||
if (excluded.includes(ownerRef)) {
|
||||
return [];
|
||||
}
|
||||
return [ownerRef];
|
||||
}
|
||||
|
||||
const owner = await catalogClient.getEntityByRef(ownerRef, { token });
|
||||
return mapEntity(owner);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
for (const entity of entities) {
|
||||
const u = await mapEntity(entity);
|
||||
users.push(...u);
|
||||
}
|
||||
|
||||
return [...new Set(users)];
|
||||
};
|
||||
@@ -23,15 +23,6 @@ import {
|
||||
} from '../database';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
|
||||
import {
|
||||
Entity,
|
||||
isGroupEntity,
|
||||
isUserEntity,
|
||||
RELATION_HAS_MEMBER,
|
||||
RELATION_OWNED_BY,
|
||||
RELATION_PARENT_OF,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
NotificationProcessor,
|
||||
NotificationSendOptions,
|
||||
@@ -54,6 +45,7 @@ import {
|
||||
NotificationStatus,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams';
|
||||
import { getUsersForEntityRef } from './getUsersForEntityRef';
|
||||
|
||||
/** @internal */
|
||||
export interface RouterOptions {
|
||||
@@ -94,91 +86,6 @@ export async function createRouter(
|
||||
return info.userEntityRef;
|
||||
};
|
||||
|
||||
const getUsersForEntityRef = async (
|
||||
entityRef: string | string[] | null,
|
||||
excludeEntityRefs: string | string[],
|
||||
): Promise<string[]> => {
|
||||
const { token } = await auth.getPluginRequestToken({
|
||||
onBehalfOf: await auth.getOwnServiceCredentials(),
|
||||
targetPluginId: 'catalog',
|
||||
});
|
||||
|
||||
if (entityRef === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fields = ['kind', 'metadata.name', 'metadata.namespace', 'relations'];
|
||||
|
||||
const refs = Array.isArray(entityRef) ? entityRef : [entityRef];
|
||||
const entities = await catalogClient.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: refs,
|
||||
fields,
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
const excluded = Array.isArray(excludeEntityRefs)
|
||||
? excludeEntityRefs
|
||||
: [excludeEntityRefs];
|
||||
|
||||
const mapEntity = async (entity: Entity | undefined): Promise<string[]> => {
|
||||
if (!entity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentEntityRef = stringifyEntityRef(entity);
|
||||
if (excluded.includes(currentEntityRef)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isUserEntity(entity)) {
|
||||
return [currentEntityRef];
|
||||
} else if (isGroupEntity(entity) && entity.relations) {
|
||||
const users = entity.relations
|
||||
.filter(relation => relation.type === RELATION_HAS_MEMBER)
|
||||
.map(r => r.targetRef);
|
||||
|
||||
const childGroupRefs = entity.relations
|
||||
.filter(relation => relation.type === RELATION_PARENT_OF)
|
||||
.map(r => r.targetRef);
|
||||
|
||||
const childGroups = await catalogClient.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: childGroupRefs,
|
||||
fields,
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
const childGroupUsers = await Promise.all(
|
||||
childGroups.items.map(mapEntity),
|
||||
);
|
||||
return [...users, ...childGroupUsers.flat(2)];
|
||||
} else if (entity.relations) {
|
||||
const ownerRef = entity.relations.find(
|
||||
relation => relation.type === RELATION_OWNED_BY,
|
||||
)?.targetRef;
|
||||
|
||||
if (ownerRef) {
|
||||
const owner = await catalogClient.getEntityByRef(ownerRef, { token });
|
||||
if (owner) {
|
||||
return mapEntity(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const users: string[] = [];
|
||||
for (const entity of entities.items) {
|
||||
const u = await mapEntity(entity);
|
||||
users.push(...u);
|
||||
}
|
||||
|
||||
return users;
|
||||
};
|
||||
|
||||
const filterProcessors = (payload: NotificationPayload) => {
|
||||
const result: NotificationProcessor[] = [];
|
||||
|
||||
@@ -543,6 +450,7 @@ export async function createRouter(
|
||||
users = await getUsersForEntityRef(
|
||||
entityRef,
|
||||
recipients.excludeEntityRef ?? [],
|
||||
{ auth, catalogClient },
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to resolve notification receivers: ${e}`);
|
||||
|
||||
Reference in New Issue
Block a user