From 665cfed0969a94606fe19e552e0803cb0e6a4349 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Tue, 19 Oct 2021 18:04:49 +0200 Subject: [PATCH] Add tests Signed-off-by: Oliver Sand --- .../GithubOrgReaderProcessor.test.ts | 3 +- .../processors/GithubOrgReaderProcessor.ts | 13 +- .../src/ingestion/processors/util/org.test.ts | 49 ++++-- .../src/ingestion/processors/util/org.ts | 16 ++ .../providers/GitHubOrgEntityProvider.test.ts | 152 ++++++++++++++++++ .../providers/GitHubOrgEntityProvider.ts | 33 ++-- 6 files changed, 217 insertions(+), 49 deletions(-) diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts index c05d3c03ab..87a910ee06 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('@octokit/graphql'); import { getVoidLogger } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; @@ -24,6 +23,8 @@ import { import { graphql } from '@octokit/graphql'; import { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor'; +jest.mock('@octokit/graphql'); + describe('GithubOrgReaderProcessor', () => { describe('implementation', () => { const logger = getVoidLogger(); diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts index 9b102601ca..244dbd8104 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts @@ -30,7 +30,7 @@ import { } from './github'; import * as results from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import { buildOrgHierarchy } from './util/org'; +import { assignGroupsToUsers, buildOrgHierarchy } from './util/org'; type GraphQL = typeof graphql; @@ -82,16 +82,7 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { `Read ${users.length} GitHub users and ${groups.length} GitHub groups in ${duration} seconds`, ); - // Fill out the hierarchy - const usersByName = new Map(users.map(u => [u.metadata.name, u])); - for (const [groupName, userNames] of groupMemberUsers.entries()) { - for (const userName of userNames) { - const user = usersByName.get(userName); - if (user && !user.spec.memberOf.includes(groupName)) { - user.spec.memberOf.push(groupName); - } - } - } + assignGroupsToUsers(users, groupMemberUsers); buildOrgHierarchy(groups); // Done! diff --git a/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts index 254f511c4f..8a8f47642a 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts @@ -15,7 +15,16 @@ */ import { GroupEntity, UserEntity } from '@backstage/catalog-model'; -import { buildMemberOf, buildOrgHierarchy } from './org'; +import { assignGroupsToUsers, buildMemberOf, buildOrgHierarchy } from './org'; + +function u(name: string, memberOf: string[] = []): UserEntity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name }, + spec: { memberOf }, + }; +} function g( name: string, @@ -56,22 +65,33 @@ describe('buildOrgHierarchy', () => { }); }); +describe('assignGroupsToUsers', () => { + it('should assign groups to users', () => { + const users: UserEntity[] = [u('u1'), u('u2')]; + const groupMemberUsers = new Map([ + ['g1', ['u1', 'u2']], + ['g2', ['u2']], + ['g3', ['u3']], + ]); + + assignGroupsToUsers(users, groupMemberUsers); + + expect(users[0].spec.memberOf).toEqual(['g1']); + expect(users[1].spec.memberOf).toEqual(['g1', 'g2']); + }); +}); + describe('buildMemberOf', () => { it('fills indirect member of groups', () => { const a = g('a', undefined, []); const b = g('b', 'a', []); const c = g('c', 'b', []); - const u: UserEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { name: 'n' }, - spec: { profile: {}, memberOf: ['c'] }, - }; + const user = u('n', ['c']); const groups = [a, b, c]; buildOrgHierarchy(groups); - buildMemberOf(groups, [u]); - expect(u.spec.memberOf).toEqual(expect.arrayContaining(['a', 'b', 'c'])); + buildMemberOf(groups, [user]); + expect(user.spec.memberOf).toEqual(expect.arrayContaining(['a', 'b', 'c'])); }); it('takes group spec.members into account', () => { @@ -79,16 +99,11 @@ describe('buildMemberOf', () => { const b = g('b', 'a', []); const c = g('c', 'b', []); c.spec.members = ['n']; - const u: UserEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { name: 'n' }, - spec: { profile: {}, memberOf: [] }, - }; + const user = u('n'); const groups = [a, b, c]; buildOrgHierarchy(groups); - buildMemberOf(groups, [u]); - expect(u.spec.memberOf).toEqual(expect.arrayContaining(['a', 'b', 'c'])); + buildMemberOf(groups, [user]); + expect(user.spec.memberOf).toEqual(expect.arrayContaining(['a', 'b', 'c'])); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/util/org.ts b/plugins/catalog-backend/src/ingestion/processors/util/org.ts index bc90362233..3c71f350e6 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/org.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/org.ts @@ -49,6 +49,22 @@ export function buildOrgHierarchy(groups: GroupEntity[]) { } } +// Ensure that users have their direct group memberships. +export function assignGroupsToUsers( + users: UserEntity[], + groupMemberUsers: Map, +) { + const usersByName = new Map(users.map(u => [u.metadata.name, u])); + for (const [groupName, userNames] of groupMemberUsers.entries()) { + for (const userName of userNames) { + const user = usersByName.get(userName); + if (user && !user.spec.memberOf.includes(groupName)) { + user.spec.memberOf.push(groupName); + } + } + } +} + // Ensure that users have their transitive group memberships. Requires that // the groups were previously processed with buildOrgHierarchy() export function buildMemberOf(groups: GroupEntity[], users: UserEntity[]) { diff --git a/plugins/catalog-backend/src/ingestion/providers/GitHubOrgEntityProvider.test.ts b/plugins/catalog-backend/src/ingestion/providers/GitHubOrgEntityProvider.test.ts index 955116f457..75edea6909 100644 --- a/plugins/catalog-backend/src/ingestion/providers/GitHubOrgEntityProvider.test.ts +++ b/plugins/catalog-backend/src/ingestion/providers/GitHubOrgEntityProvider.test.ts @@ -13,10 +13,162 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { getVoidLogger } from '@backstage/backend-common'; import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import { + GithubCredentialsProvider, + GitHubIntegrationConfig, +} from '@backstage/integration'; +import { GitHubOrgEntityProvider } from '.'; +import { EntityProviderConnection } from '../../providers'; import { withLocations } from './GitHubOrgEntityProvider'; +import { graphql } from '@octokit/graphql'; + +jest.mock('@octokit/graphql'); describe('GitHubOrgEntityProvider', () => { + describe('read', () => { + afterEach(() => jest.resetAllMocks()); + + it('should read org data and apply mutation', async () => { + const mockClient = jest.fn(); + + mockClient + .mockResolvedValueOnce({ + organization: { + membersWithRole: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + login: 'a', + name: 'b', + bio: 'c', + email: 'd', + avatarUrl: 'e', + }, + ], + }, + }, + }) + .mockResolvedValueOnce({ + organization: { + teams: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + slug: 'team', + combinedSlug: 'blah/team', + name: 'Team', + description: 'The one and only team', + avatarUrl: 'http://example.com/team.jpeg', + parentTeam: { + slug: 'parent', + combinedSlug: '', + members: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'a' }], + }, + }, + ], + }, + }, + }); + + (graphql.defaults as jest.Mock).mockReturnValue(mockClient); + + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + }; + + const logger = getVoidLogger(); + const gitHubConfig: GitHubIntegrationConfig = { + host: 'https://github.com', + }; + + const mockGetCredentials = jest.fn().mockReturnValue({ + headers: { token: 'blah' }, + type: 'app', + }); + + jest.spyOn(GithubCredentialsProvider, 'create').mockReturnValue({ + getCredentials: mockGetCredentials, + } as any); + + const entityProvider = new GitHubOrgEntityProvider({ + id: 'my-id', + orgUrl: 'https://github.com/backstage', + gitHubConfig, + logger, + }); + + entityProvider.connect(entityProviderConnection); + + await entityProvider.read(); + + expect(entityProviderConnection.applyMutation).toBeCalledWith({ + entities: [ + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://https://github.com/a', + 'backstage.io/managed-by-origin-location': + 'url:https://https://github.com/a', + 'github.com/user-login': 'a', + }, + description: 'c', + name: 'a', + }, + spec: { + memberOf: ['team'], + profile: { + displayName: 'b', + email: 'd', + picture: 'e', + }, + }, + }, + locationKey: 'github-org-provider:my-id', + }, + { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://https://github.com/orgs/backstage/teams/team', + 'backstage.io/managed-by-origin-location': + 'url:https://https://github.com/orgs/backstage/teams/team', + 'github.com/team-slug': 'blah/team', + }, + name: 'team', + description: 'The one and only team', + }, + spec: { + children: [], + parent: 'parent', + profile: { + displayName: 'Team', + picture: 'http://example.com/team.jpeg', + }, + type: 'team', + }, + }, + locationKey: 'github-org-provider:my-id', + }, + ], + type: 'full', + }); + }); + }); + describe('withLocations', () => { it('should set location for user', () => { const entity: UserEntity = { diff --git a/plugins/catalog-backend/src/ingestion/providers/GitHubOrgEntityProvider.ts b/plugins/catalog-backend/src/ingestion/providers/GitHubOrgEntityProvider.ts index c2b38142ea..ecd0d153d1 100644 --- a/plugins/catalog-backend/src/ingestion/providers/GitHubOrgEntityProvider.ts +++ b/plugins/catalog-backend/src/ingestion/providers/GitHubOrgEntityProvider.ts @@ -27,21 +27,22 @@ import { import { graphql } from '@octokit/graphql'; import { merge } from 'lodash'; import { Logger } from 'winston'; +import { + EntityProvider, + EntityProviderConnection, +} from '../../providers/types'; import { getOrganizationTeams, getOrganizationUsers, parseGitHubOrgUrl, } from '../processors/github'; -import { buildOrgHierarchy } from '../processors/util/org'; -import { - EntityProvider, - EntityProviderConnection, -} from '../../providers/types'; +import { assignGroupsToUsers, buildOrgHierarchy } from '../processors/util/org'; // TODO: Consider supporting an (optional) webhook that reacts on org changes export class GitHubOrgEntityProvider implements EntityProvider { private connection?: EntityProviderConnection; + private readonly credentialsProvider: GithubCredentialsProvider; static fromConfig( config: Config, @@ -75,7 +76,11 @@ export class GitHubOrgEntityProvider implements EntityProvider { gitHubConfig: GitHubIntegrationConfig; logger: Logger; }, - ) {} + ) { + this.credentialsProvider = GithubCredentialsProvider.create( + options.gitHubConfig, + ); + } getProviderName() { return `GitHubOrgEntityProvider:${this.options.id}`; @@ -92,11 +97,8 @@ export class GitHubOrgEntityProvider implements EntityProvider { const { markReadComplete } = trackProgress(this.options.logger); - const credentialsProvider = GithubCredentialsProvider.create( - this.options.gitHubConfig, - ); const { headers, type: tokenType } = - await credentialsProvider.getCredentials({ + await this.credentialsProvider.getCredentials({ url: this.options.orgUrl, }); const client = graphql.defaults({ @@ -110,16 +112,7 @@ export class GitHubOrgEntityProvider implements EntityProvider { client, org, ); - // Fill out the hierarchy - const usersByName = new Map(users.map(u => [u.metadata.name, u])); - for (const [groupName, userNames] of groupMemberUsers.entries()) { - for (const userName of userNames) { - const user = usersByName.get(userName); - if (user && !user.spec.memberOf.includes(groupName)) { - user.spec.memberOf.push(groupName); - } - } - } + assignGroupsToUsers(users, groupMemberUsers); buildOrgHierarchy(groups); const { markCommitComplete } = markReadComplete({ users, groups });