From 38d7bf41b85d56768e1a4a30221457421cc76a0e Mon Sep 17 00:00:00 2001 From: ghe Date: Sat, 15 Oct 2022 13:24:01 +0100 Subject: [PATCH] feat: sync all projects in a given Org for Github Sync all projects in a given org for Github.com Co-authored-by: mathild3r --- package.json | 3 +- src/lib/api/feature-flags/index.ts | 9 +- src/lib/api/org/index.ts | 12 +- src/lib/project/compare-branches.ts | 11 +- src/lib/types.ts | 4 + src/scripts/sync/sync-org-projects.ts | 174 ++++++ src/scripts/sync/sync-projects-per-target.ts | 56 ++ test/lib/org.test.ts | 1 - test/lib/project/compare-branches.test.ts | 30 +- .../project/update-project-per-target.test.ts | 101 ++++ test/scripts/import-projects.test.ts | 3 +- test/scripts/sync/sync-org-projects.test.ts | 539 ++++++++++++++++++ 12 files changed, 926 insertions(+), 17 deletions(-) create mode 100644 src/scripts/sync/sync-org-projects.ts create mode 100644 src/scripts/sync/sync-projects-per-target.ts create mode 100644 test/lib/project/update-project-per-target.test.ts create mode 100644 test/scripts/sync/sync-org-projects.test.ts diff --git a/package.json b/package.json index 1c55635b..f687e8f8 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,8 @@ "semantic-release": "17.3.0", "ts-jest": "27.0.3", "tsc-watch": "^4.1.0", - "typescript": "4.3.5" + "typescript": "4.3.5", + "uuid": "9.0.0" }, "pkg": { "scripts": [ diff --git a/src/lib/api/feature-flags/index.ts b/src/lib/api/feature-flags/index.ts index ac7dc0ee..45a7f252 100644 --- a/src/lib/api/feature-flags/index.ts +++ b/src/lib/api/feature-flags/index.ts @@ -18,12 +18,17 @@ export async function getFeatureFlag( url: url, useRESTApi: false, }); + debug(`Feature flag ${featureFlagName} is enabled for Org ${orgId}`); - return res.data['ok']; + + const enabled: boolean = res.data['ok']; + + return enabled; } catch (err) { if (err instanceof Error) { //Currently this is the only way to distinguish between an actual 403 and a 403 that is returned when an org hasn't got that FF enabled - if (JSON.stringify(err).search('"ok":false') > 0) { + const errorMessage = JSON.stringify(err); + if (errorMessage.includes('"ok":false')) { debug( `Feature flag ${featureFlagName} is not enabled for Org ${orgId}, please advise with your Snyk representative`, ); diff --git a/src/lib/api/org/index.ts b/src/lib/api/org/index.ts index 92b07914..16997217 100644 --- a/src/lib/api/org/index.ts +++ b/src/lib/api/org/index.ts @@ -139,7 +139,7 @@ export async function deleteOrg( return res.data; } -interface ProjectsResponse { +export interface ProjectsResponse { org: { id: string; }; @@ -166,6 +166,7 @@ interface ProjectsFilters { origin?: string; //If supplied, only projects that exactly match this origin will be returned type?: string; //If supplied, only projects that exactly match this type will be returned isMonitored?: boolean; // If set to true, only include projects which are monitored, if set to false, only include projects which are not monitored + targetId?: string; // The target ID to be used in sunc functions } export async function listProjects( @@ -175,8 +176,7 @@ export async function listProjects( ): Promise { getApiToken(); getSnykHost(); - debug(`Listing all projects for org: ${orgId}`); - + debug(`Listing all projects for org: ${orgId} with filter ${JSON.stringify(filters)}`); if (!orgId) { throw new Error( `Missing required parameters. Please ensure you have set: orgId, settings. @@ -280,7 +280,7 @@ function convertToSnykProject(projectData: RESTProjectData[]): SnykProject[] { return projects; } -interface TargetFilters { +export interface TargetFilters { remoteUrl?: string; limit?: number; isPrivate?: boolean; @@ -304,12 +304,12 @@ export async function listTargets( ); } - const targets = await listAllSnykTarget(requestManager, orgId, config); + const targets = await listAllSnykTargets(requestManager, orgId, config); return { targets }; } -export async function listAllSnykTarget( +export async function listAllSnykTargets( requestManager: requestsManager, orgId: string, config?: TargetFilters, diff --git a/src/lib/project/compare-branches.ts b/src/lib/project/compare-branches.ts index 9a071086..01caddb4 100644 --- a/src/lib/project/compare-branches.ts +++ b/src/lib/project/compare-branches.ts @@ -10,15 +10,20 @@ export async function compareAndUpdateBranches( }, defaultBranch: string, orgId: string, + dryRun = false, ): Promise<{ updated: boolean }> { - const {branch, projectPublicId} = project; + const { branch, projectPublicId } = project; let updated = false try { if (branch != defaultBranch) { debug(`Default branch has changed for Snyk project ${projectPublicId}`); - await updateProject(requestManager, orgId, projectPublicId, { branch: defaultBranch }); - updated = true + if (!dryRun) { + await updateProject(requestManager, orgId, project.projectPublicId, { + branch: defaultBranch, + }); + } + updated = true; } return { updated } diff --git a/src/lib/types.ts b/src/lib/types.ts index 31d15b0b..014b8bfb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -91,6 +91,10 @@ export enum SupportedIntegrationTypesImportOrgData { BITBUCKET_CLOUD = 'bitbucket-cloud', } +export enum SupportedIntegrationTypesUpdateProject { + GITHUB = 'github', +} + // used to generate imported targets that exist in Snyk // when we need to grab the integrationId from Snyk export enum SupportedIntegrationTypesToListSnykTargets { diff --git a/src/scripts/sync/sync-org-projects.ts b/src/scripts/sync/sync-org-projects.ts new file mode 100644 index 00000000..8e34f3d8 --- /dev/null +++ b/src/scripts/sync/sync-org-projects.ts @@ -0,0 +1,174 @@ +import pMap = require('p-map'); +import * as debugLib from 'debug'; +import * as path from 'path'; +import { requestsManager } from 'snyk-request-manager'; +import { UPDATED_PROJECTS_LOG_NAME } from '../../common'; +import type { + TargetFilters, +} from '../../lib'; +import { + getLoggingPath, + listProjects, + listTargets, +} from '../../lib'; +import { getFeatureFlag } from '../../lib/api/feature-flags'; +import type { + SnykProject, + SnykTarget, +} from '../../lib/types'; +import { + SupportedIntegrationTypesUpdateProject, +} from '../../lib/types'; +import { logUpdatedProjects } from '../../loggers/log-updated-project'; +import { updateProjectForTarget as updateProjectForTarget } from './sync-projects-per-target'; + +const debug = debugLib('snyk:sync-org-projects'); + +export async function updateOrgTargets( + publicOrgId: string, + sources: SupportedIntegrationTypesUpdateProject[], + dryRun = false, +): Promise<{ + fileName: string; + processedTargets: number; + meta: { + projects: { + branchUpdated: string[]; + }; + }; +}> { + const branchUpdated: string[] = []; + const logFile = path.resolve(getLoggingPath(), UPDATED_PROJECTS_LOG_NAME); + const res = { + fileName: logFile, + processedTargets: 0, + meta: { + projects: { + branchUpdated, + }, + }, + }; + + // ensure source is enabled for sync + const allowedSources = sources.filter((source) => + Object.values(SupportedIntegrationTypesUpdateProject).includes(source), + ); + if (!allowedSources.length) { + console.warn( + `The organization (${publicOrgId}) does not have any projects that are supported for sync. Currently supported projects are ${Object.values( + SupportedIntegrationTypesUpdateProject, + ).join(',')}`, + ); + return res; + } + + const requestManager = new requestsManager({ + userAgentPrefix: 'snyk-api-import', + period: 1000, + maxRetryCount: 3, + }); + + if (await getFeatureFlag(requestManager, 'customBranch', publicOrgId)) { + console.warn( + 'Detected custom branches are used in this organization. Skipping syncing organization ${publicOrgId}', + ); + return res; + } + + await pMap( + allowedSources, + async (source: SupportedIntegrationTypesUpdateProject) => { + const filters: TargetFilters = { + limit: 100, + origin: source, + excludeEmpty: true, + }; + debug(`Listing all targets for source ${source}`); + const { targets } = await listTargets( + requestManager, + publicOrgId, + filters, + ); + debug(`Syncing targets for source ${source}`); + const updated = await updateTargets( + requestManager, + publicOrgId, + targets, + dryRun, + ); + res.processedTargets += updated.processedTargets; + res.meta.projects.branchUpdated.push(...updated.meta.projects.branchUpdated); + debug(`Logging updated targets for source ${source}`); + // TODO: add a test to ensure a log was created & is the expected format + await logUpdatedProjects(publicOrgId, branchUpdated, logFile); + }, + { concurrency: 3 }, + ); + return res; +} + +export async function updateTargets( + requestManager: requestsManager, + orgId: string, + targets: SnykTarget[], + dryRun = false, +): Promise<{ + processedTargets: number; + meta: { + projects: { + branchUpdated: string[]; + }; + }; +}> { + let processedTargets = 0; + const updated: string[] = []; + + await pMap( + targets, + async (target: SnykTarget) => { + try { + const filters = { targetId: target.id }; + debug(`Listing projects for target ${target.attributes.displayName}`); + const { projects } = await listProjects(requestManager, orgId, filters); + debug(`Syncing projects for target ${target.attributes.displayName}`); + const { updatedProjects } = await syncAllProjects(requestManager, orgId, projects, dryRun); + updated.push(...updatedProjects); + processedTargets += 1; + } catch (e) { + debug(e); + console.warn(`Failed to sync target ${target.attributes.displayName}. ERROR: ${e.message}`) + } + }, + { concurrency: 10 }, + ); + return { + processedTargets, + // TODO: collect failed targets & log them with reason? + meta: { + projects: { + branchUpdated: updated, + }, + }, + }; +} + +async function syncAllProjects( + requestManager: requestsManager, + orgId: string, + projects: SnykProject[], + dryRun = false): Promise<{ updatedProjects: string[] }> { + const updatedProjects: string[] = []; + await pMap(projects, async (project) => { + const { updated } = await updateProjectForTarget( + requestManager, + orgId, + project, + dryRun, + ); + if (updated) { + updatedProjects.push(project.id); + } + }); + + return { updatedProjects }; +} diff --git a/src/scripts/sync/sync-projects-per-target.ts b/src/scripts/sync/sync-projects-per-target.ts new file mode 100644 index 00000000..98cb2deb --- /dev/null +++ b/src/scripts/sync/sync-projects-per-target.ts @@ -0,0 +1,56 @@ +import type { requestsManager } from 'snyk-request-manager'; +import * as debugLib from 'debug'; + +import { getGithubReposDefaultBranch } from '../../lib/source-handlers/github'; +import { compareAndUpdateBranches } from '../../lib/project/compare-branches'; +import type { + SnykProject, Target, +} from '../../lib/types'; +import { + SupportedIntegrationTypesUpdateProject, +} from '../../lib/types'; +import { targetGenerators } from '../generate-imported-targets-from-snyk'; +const debug = debugLib('snyk:sync-projects-per-target'); + +export function getBranchGenerator( + origin: SupportedIntegrationTypesUpdateProject, +): (target: Target, host?: string | undefined) => Promise { + const getDefaultBranchGenerators = { + [SupportedIntegrationTypesUpdateProject.GITHUB]: getGithubReposDefaultBranch, + }; + return getDefaultBranchGenerators[origin]; +} + +export async function updateProjectForTarget( + requestManager: requestsManager, + orgId: string, + project: SnykProject, + dryRun = false, // TODO: add a test for this function + this param +): Promise<{ updated: boolean }> { + let defaultBranch; + const origin = project.origin as SupportedIntegrationTypesUpdateProject; + + try { + const target = targetGenerators[origin](project); + defaultBranch = await getBranchGenerator(origin)(target); + } catch (e) { + debug(`Getting default branch failed with error: ${e}`); + } + + if (!defaultBranch) { + return { updated: false }; + } + + const { updated } = await compareAndUpdateBranches( + requestManager, + { + branch: project.branch!, + projectPublicId: project.id, + }, + defaultBranch, + orgId, + dryRun, + ); + + return { updated }; +} diff --git a/test/lib/org.test.ts b/test/lib/org.test.ts index f0808cf1..aebb81a0 100644 --- a/test/lib/org.test.ts +++ b/test/lib/org.test.ts @@ -97,7 +97,6 @@ describe('listTargets', () => { jest.restoreAllMocks(); }); - it('list the targets in a given Org without pagination - mock', async () => { jest.spyOn(requestManager, 'request').mockResolvedValue({ statusCode: 200, diff --git a/test/lib/project/compare-branches.test.ts b/test/lib/project/compare-branches.test.ts index 0fc34742..935b2bc4 100644 --- a/test/lib/project/compare-branches.test.ts +++ b/test/lib/project/compare-branches.test.ts @@ -11,10 +11,14 @@ describe('compareAndUpdateBranches', () => { process.env.SNYK_TOKEN = process.env.SNYK_TOKEN_TEST; }); afterAll(() => { + jest.clearAllMocks(); + process.env = { ...OLD_ENV }; + }, 1000); + afterEach(() => { jest.resetAllMocks(); process.env = { ...OLD_ENV }; }, 1000); - it('Update project branch', async () => { + it('updates project branch if the default branch changed', async () => { jest.spyOn(requestManager, 'request').mockResolvedValue({ data: { name: 'test', @@ -59,7 +63,27 @@ describe('compareAndUpdateBranches', () => { expect(res.updated).toBeTruthy(); }, 5000); - it('Return ProjectNeededUpdate false if branches are the same', async () => { + it('does not call the Projects API in dryRun mode', async () => { + const requestSpy = jest.spyOn(requestManager, 'request').mockResolvedValue({ + data: {}, + status: 200, + }); + + const res = await compareProject.compareAndUpdateBranches( + requestManager, + { + branch: 'main', + projectPublicId: 'af137b96-6966-46c1-826b-2e79ac49bbd9', + }, + 'newDefaultBranch', + 'af137b96-6966-46c1-826b-2e79ac49bbxx', + true, + ); + expect(requestSpy).not.toHaveBeenCalled(); + expect(res.updated).toBeTruthy(); + }, 5000); + + it('does not update the project if the branches are the same', async () => { const res = await compareProject.compareAndUpdateBranches( requestManager, { @@ -72,7 +96,7 @@ describe('compareAndUpdateBranches', () => { expect(res.updated).toBeFalsy(); }, 5000); - it('throw if the api requests fails', async () => { + it('throws if the api requests fails', async () => { jest .spyOn(requestManager, 'request') .mockResolvedValue({ statusCode: 500, data: {} }); diff --git a/test/lib/project/update-project-per-target.test.ts b/test/lib/project/update-project-per-target.test.ts new file mode 100644 index 00000000..2623ecae --- /dev/null +++ b/test/lib/project/update-project-per-target.test.ts @@ -0,0 +1,101 @@ +import { requestsManager } from 'snyk-request-manager'; +import { updateProjectForTarget } from '../../../src/scripts/sync/sync-projects-per-target'; +import * as github from '../../../src/lib/source-handlers/github'; +import * as projects from '../../../src/lib/api/project'; + +describe('UpdateProject (Github)', () => { + const requestManager = new requestsManager({ + userAgentPrefix: 'snyk-api-import:tests', + }); + let githubSpy: jest.SpyInstance; + let projectsSpy: jest.SpyInstance; + + beforeAll(() => { + githubSpy = jest.spyOn(github, 'getGithubReposDefaultBranch'); + projectsSpy = jest.spyOn(projects, 'updateProject'); + }); + + afterAll(async () => { + jest.restoreAllMocks(); + }, 1000); + + beforeEach(async () => { + jest.clearAllMocks(); + }, 1000); + + it('updates project when default branch changed', async () => { + const defaultBranch = 'newBranch'; + githubSpy.mockImplementation(() => Promise.resolve(defaultBranch)); + projectsSpy.mockImplementation(() => + Promise.resolve({ ...testProject, branch: defaultBranch }), + ); + const testProject = { + name: 'testProject', + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + created: '2018-10-29T09:50:54.014Z', + origin: 'github', + type: 'npm', + branch: 'master', + }; + + const res = await updateProjectForTarget( + requestManager, + 'af137b96-6966-46c1-826b-2e79ac49bbxx', + testProject, + ); + expect(githubSpy).toHaveBeenCalledTimes(1); + expect(projectsSpy).toHaveBeenCalledTimes(1); + + expect(res.updated).toBeTruthy(); + }, 5000); + + it('fails to update project, if API call to get default branch fails', async () => { + const defaultBranch = 'newBranch'; + githubSpy.mockImplementation(() => + Promise.reject({ statusCode: 500, message: 'Error' }), + ); + projectsSpy.mockImplementation(() => + Promise.resolve({ ...testProject, branch: defaultBranch }), + ); + const testProject = { + name: 'testProject', + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + created: '2018-10-29T09:50:54.014Z', + origin: 'github', + type: 'npm', + branch: 'master', + }; + expect( + updateProjectForTarget( + requestManager, + 'af137b96-6966-46c1-826b-2e79ac49bbxx', + testProject, + ), + ).rejects.toThrow(); + expect(githubSpy).toHaveBeenCalledTimes(1); + expect(projectsSpy).not.toHaveBeenCalled(); + }, 5000); + + it('does nothing if the default branch did no change', async () => { + jest + .spyOn(github, 'getGithubReposDefaultBranch') + .mockResolvedValue('master'); + + const testProject = { + name: 'testProject', + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + created: '2018-10-29T09:50:54.014Z', + origin: 'github', + type: 'npm', + branch: 'master', + }; + + const res = await updateProjectForTarget( + requestManager, + 'af137b96-6966-46c1-826b-2e79ac49bbxx', + testProject, + ); + expect(res.updated).toBeFalsy(); + expect(projectsSpy).not.toHaveBeenCalled(); + }, 5000); +}); diff --git a/test/scripts/import-projects.test.ts b/test/scripts/import-projects.test.ts index 1ec37752..3e162a17 100644 --- a/test/scripts/import-projects.test.ts +++ b/test/scripts/import-projects.test.ts @@ -56,7 +56,8 @@ describe('Import skips previously imported', () => { process.env.INTEGRATION_ID = 'INTEGRATION_ID'; process.env.ORG_ID = 'ORG_ID'; - afterAll(async () => { + afterAll(() => { + jest.restoreAllMocks(); process.env = { ...OLD_ENV }; }, 1000); it('succeeds to import targets from file with import log', async () => { diff --git a/test/scripts/sync/sync-org-projects.test.ts b/test/scripts/sync/sync-org-projects.test.ts new file mode 100644 index 00000000..993ed192 --- /dev/null +++ b/test/scripts/sync/sync-org-projects.test.ts @@ -0,0 +1,539 @@ +import { requestsManager } from 'snyk-request-manager'; +import * as uuid from 'uuid'; +import { updateOrgTargets, updateTargets } from '../../../src/scripts/sync/sync-org-projects'; +import type { ProjectsResponse } from '../../../src/lib/api/org'; +import * as updateProjectForTarget from '../../../src/scripts/sync/sync-projects-per-target'; +import type { SnykProject, SnykTarget, SnykTargetRelationships } from '../../../src/lib/types'; +import { SupportedIntegrationTypesUpdateProject } from '../../../src/lib/types'; +import * as lib from '../../../src/lib'; +import * as projectApi from '../../../src/lib/api/project'; +import * as github from '../../../src/lib/source-handlers/github'; +import * as featureFlags from '../../../src/lib/api/feature-flags'; +import * as updateProjectsLog from '../../../src/loggers/log-updated-project'; + +describe('updateTargets', () => { + const requestManager = new requestsManager({ + userAgentPrefix: 'snyk-api-import:tests', + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Github', () => { + + it('updates a projects branch if default branch changed', async () => { + const testTarget = [ + { + attributes: { + displayName: 'test', + isPrivate: false, + origin: 'github', + remoteUrl: null, + }, + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + relationships: { + org: { + data: { + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + type: 'org', + }, + links: {}, + meta: {}, + }, + }, + type: 'target', + }, + ]; + + const testProjects: ProjectsResponse = { + org: { + id: "af137b96-6966-46c1-826b-2e79ac49bbxx", + }, + projects: [{ + name: 'testProject', + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + created: '2018-10-29T09:50:54.014Z', + origin: 'github', + type: 'npm', + branch: 'master', + }] + } + + const orgId = 'af137b96-6966-46c1-826b-2e79ac49bbxx'; + + jest.spyOn(lib, 'listProjects').mockImplementation(() => Promise.resolve(testProjects)); + jest.spyOn(updateProjectForTarget, 'updateProjectForTarget').mockImplementation(() => Promise.resolve({ updated: true })); + + const res = await updateTargets(requestManager, orgId, testTarget); + + expect(res.processedTargets).toEqual(1); + expect(res.meta.projects.branchUpdated).toEqual( + ['af137b96-6966-46c1-826b-2e79ac49bbxx',] + ); + }, 5000); + + it('did not need to update a projects branch', async () => { + const testTarget = [ + { + attributes: { + displayName: 'test', + isPrivate: false, + origin: 'github', + remoteUrl: null, + }, + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + relationships: { + org: { + data: { + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + type: 'org', + }, + links: {}, + meta: {}, + }, + }, + type: 'target', + }, + ]; + + const testProjects: ProjectsResponse = { + org: { + id: "af137b96-6966-46c1-826b-2e79ac49bbxx", + }, + projects: [{ + name: 'testProject', + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + created: '2018-10-29T09:50:54.014Z', + origin: 'github', + type: 'npm', + branch: 'master', + }] + } + + const orgId = 'af137b96-6966-46c1-826b-2e79ac49bbxx'; + + jest.spyOn(lib, 'listProjects').mockImplementation(() => Promise.resolve(testProjects)); + jest.spyOn(updateProjectForTarget, 'updateProjectForTarget').mockImplementation(() => Promise.resolve({ updated: false })); + + const res = await updateTargets(requestManager, orgId, testTarget); + + expect(res.processedTargets).toEqual(1); + expect(res.meta.projects.branchUpdated).toEqual( + [] + ); + }, 5000); + + it('updates several projects from the same target 1 failed 1 success', async () => { + const testTargets = [ + { + attributes: { + displayName: 'test', + isPrivate: false, + origin: 'github', + remoteUrl: null, + }, + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + relationships: { + org: { + data: { + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + type: 'org', + }, + links: {}, + meta: {}, + }, + }, + type: 'target', + }, + ]; + + const testProjects: ProjectsResponse = { + org: { + id: "af137b96-6966-46c1-826b-2e79ac49bbxx", + }, + projects: [{ + name: 'testProject', + id: 'af137b96-6966-46c1-826b-2e79ac49bbxx', + created: '2018-10-29T09:50:54.014Z', + origin: 'github', + type: 'npm', + branch: 'master', + }, { + name: 'testProject2', + id: 'af137b96-6966-46c1-826b-2e79ac49aaxx', + created: '2018-10-29T09:50:54.014Z', + origin: 'github', + type: 'maven', + branch: 'master', + }] + } + + const orgId = 'af137b96-6966-46c1-826b-2e79ac49bbxx'; + + jest.spyOn(lib, 'listProjects').mockImplementationOnce(() => Promise.resolve(testProjects)) + jest.spyOn(updateProjectForTarget, 'updateProjectForTarget').mockImplementationOnce(() => Promise.resolve({ updated: true })).mockImplementationOnce(() => Promise.resolve({ updated: false })); + + const res = await updateTargets(requestManager, orgId, testTargets); + + expect(res.processedTargets).toEqual(1); + expect(res.meta.projects.branchUpdated).toEqual( + ['af137b96-6966-46c1-826b-2e79ac49bbxx',] + ); + }, 5000); + }); +}); +describe('updateOrgTargets', () => { + const OLD_ENV = process.env; + process.env.SNYK_LOG_PATH = './'; + process.env.SNYK_TOKEN = 'dummy' + + let featureFlagsSpy: jest.SpyInstance; + let listTargetsSpy: jest.SpyInstance; + let listProjectsSpy: jest.SpyInstance; + let logUpdatedProjectsSpy: jest.SpyInstance; + let githubSpy: jest.SpyInstance; + let updateProjectSpy: jest.SpyInstance; + + beforeAll(() => { + featureFlagsSpy = jest.spyOn(featureFlags, 'getFeatureFlag'); + listTargetsSpy = jest.spyOn(lib, 'listTargets'); + listProjectsSpy = jest.spyOn(lib, 'listProjects'); + logUpdatedProjectsSpy = jest.spyOn(updateProjectsLog, 'logUpdatedProjects'); + githubSpy = jest.spyOn(github, 'getGithubReposDefaultBranch'); + updateProjectSpy = jest.spyOn(projectApi, 'updateProject'); + }); + afterAll(() => { + jest.restoreAllMocks(); + }, 1000); + + afterEach(() => { + process.env = { ...OLD_ENV }; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Github', () => { + it('skip unsupported origins', async () => { + const res = await updateOrgTargets('xxx', ['unsupported' as any]); + expect(res).toEqual({ + "fileName": expect.stringMatching("/updated-projects.log"), + "meta": { + "projects": { + "branchUpdated": [], + }, + }, + "processedTargets": 0 + }) + }); + it('skip an org that uses the customBranch FF', async () => { + featureFlagsSpy.mockResolvedValue(true); + logUpdatedProjectsSpy.mockResolvedValue(null); + const res = await updateOrgTargets('xxx', ['unsupported' as any]); + expect(res).toEqual({ + "fileName": expect.stringMatching("/updated-projects.log"), + "meta": { + "projects": { + "branchUpdated": [], + }, + }, + "processedTargets": 0 + }) + }); + + it('throws error if listTargets has API error', async () => { + featureFlagsSpy.mockResolvedValue(false) + listTargetsSpy.mockRejectedValue('Expected a 200 response, instead received:' + JSON.stringify({ statusCode: 500, message: 'Something went wrong' })) + expect(updateOrgTargets('xxx', [SupportedIntegrationTypesUpdateProject.GITHUB])).rejects.toThrowError('Expected a 200 response, instead received'); + }); + it('skips target if listingProjects has API error', async () => { + const targets: SnykTarget[] = [{ + attributes: { + displayName: 'foo/bar', + isPrivate: true, + origin: 'github', + remoteUrl: null, + }, + id: 'xxx', + relationships: {} as unknown as SnykTargetRelationships, + type: 'target', + }] + featureFlagsSpy.mockResolvedValue(false); + listTargetsSpy.mockResolvedValue({ targets }); + listProjectsSpy.mockRejectedValue('Expected a 200 response, instead received:' + JSON.stringify({ statusCode: 500, message: 'Something went wrong' })); + logUpdatedProjectsSpy.mockResolvedValue(null); + + const res = await updateOrgTargets('xxx', [SupportedIntegrationTypesUpdateProject.GITHUB]); + expect(res).toEqual({ + "fileName": expect.stringMatching("/updated-projects.log"), + "meta": { + "projects": { + "branchUpdated": [], + }, + }, + "processedTargets": 0, + }); + }); + it('skips target & projects error if getting default branch fails', async () => { + const targets: SnykTarget[] = [{ + attributes: { + displayName: 'foo/bar', + isPrivate: true, + origin: 'github', + remoteUrl: null, + }, + id: 'xxx', + relationships: {} as unknown as SnykTargetRelationships, + type: 'target', + }]; + const projects: SnykProject[] = [{ + name: 'example', + id: '123', + created: 'date', + origin: 'github', + type: 'npm', + branch: 'main' + }]; + featureFlagsSpy.mockResolvedValue(false); + listTargetsSpy.mockResolvedValue({ targets }); + listProjectsSpy.mockRejectedValue(projects); + logUpdatedProjectsSpy.mockResolvedValue(null); + + const res = await updateOrgTargets('xxx', [SupportedIntegrationTypesUpdateProject.GITHUB]); + expect(res).toEqual({ + "fileName": expect.stringMatching("/updated-projects.log"), + "meta": { + "projects": { + "branchUpdated": [], + }, + }, + "processedTargets": 0, + }); + }); + + // TODO: needs more work, updateProject spy is calling real function still. + it('Successfully updated several targets (1 supported, 1 unsupported)', async () => { + const targets: SnykTarget[] = [{ + attributes: { + displayName: 'snyk/bar', + isPrivate: true, + origin: 'github', + remoteUrl: null, + }, + id: uuid.v4(), + relationships: {} as unknown as SnykTargetRelationships, + type: 'target', + }, + { + attributes: { + displayName: 'snyk/cli', + isPrivate: true, + origin: 'github-enterprise', + remoteUrl: null, + }, + id: uuid.v4(), + relationships: {} as unknown as SnykTargetRelationships, + type: 'target', + }]; + const updatedProjectId = uuid.v4(); + const projects: SnykProject[] = [{ + name: 'example', + id: updatedProjectId, + created: 'date', + origin: 'github', + type: 'npm', + branch: 'main' + }]; + featureFlagsSpy.mockResolvedValue(false); + listTargetsSpy.mockResolvedValue({ targets }); + listProjectsSpy.mockResolvedValueOnce({ projects }); + listProjectsSpy.mockResolvedValue({ projects: [] }); + logUpdatedProjectsSpy.mockResolvedValue(null); + githubSpy.mockResolvedValue('develop'); + updateProjectSpy.mockResolvedValue(''); + + const res = await updateOrgTargets('xxx', [SupportedIntegrationTypesUpdateProject.GITHUB]); + expect(res).toEqual({ + "fileName": expect.stringMatching("/updated-projects.log"), + "meta": { + "projects": { + "branchUpdated": [updatedProjectId], + }, + }, + "processedTargets": 2, + }); + }); + it('Some projects fail to update in a target', async () => { + const targets: SnykTarget[] = [{ + attributes: { + displayName: 'snyk/bar', + isPrivate: true, + origin: 'github', + remoteUrl: null, + }, + id: uuid.v4(), + relationships: {} as unknown as SnykTargetRelationships, + type: 'target', + }]; + const updatedProjectId = uuid.v4(); + const projects: SnykProject[] = [{ + name: 'snyk/bar', + id: updatedProjectId, + created: 'date', + origin: 'github', + type: 'npm', + branch: 'main' + }, + { + name: 'snyk/foo', + id: uuid.v4(), + created: 'date', + origin: 'github', + type: 'yarn', + branch: 'develop' + }]; + featureFlagsSpy.mockResolvedValueOnce(false); + listTargetsSpy.mockResolvedValueOnce({ targets }); + listProjectsSpy.mockResolvedValueOnce({ projects }); + logUpdatedProjectsSpy.mockResolvedValueOnce(null); + githubSpy.mockResolvedValueOnce('develop'); + githubSpy.mockRejectedValueOnce('Failed to get default branch from Github'); + updateProjectSpy.mockResolvedValue(''); + + const res = await updateOrgTargets('xxx', [SupportedIntegrationTypesUpdateProject.GITHUB]); + expect(res).toEqual({ + "fileName": expect.stringMatching("/updated-projects.log"), + "meta": { + "projects": { + "branchUpdated": [updatedProjectId], + }, + }, + "processedTargets": 1, + }); + }); + it('Successfully updated several targets', async () => { + const targets: SnykTarget[] = [{ + attributes: { + displayName: 'snyk/bar', + isPrivate: true, + origin: 'github', + remoteUrl: null, + }, + id: uuid.v4(), + relationships: {} as unknown as SnykTargetRelationships, + type: 'target', + }, { + attributes: { + displayName: 'snyk/foo', + isPrivate: false, + origin: 'github', + remoteUrl: null, + }, + id: uuid.v4(), + relationships: {} as unknown as SnykTargetRelationships, + type: 'target', + }]; + const updatedProjectId1 = uuid.v4(); + const updatedProjectId2 = uuid.v4(); + const projectsTarget1: SnykProject[] = [{ + name: 'snyk/bar', + id: updatedProjectId1, + created: 'date', + origin: 'github', + type: 'npm', + branch: 'main' + }]; + const projectsTarget2: SnykProject[] = [ + { + name: 'snyk/foo', + id: updatedProjectId2, + created: 'date', + origin: 'github', + type: 'yarn', + branch: 'develop' + }]; + featureFlagsSpy.mockResolvedValueOnce(false); + listTargetsSpy.mockResolvedValueOnce({ targets }); + listProjectsSpy.mockResolvedValueOnce({ projects: projectsTarget1 }); + listProjectsSpy.mockResolvedValueOnce({ projects: projectsTarget2 }); + + logUpdatedProjectsSpy.mockResolvedValueOnce(null); + githubSpy.mockResolvedValue('new-branch'); + updateProjectSpy.mockResolvedValue(''); + + const res = await updateOrgTargets('xxx', [SupportedIntegrationTypesUpdateProject.GITHUB]); + expect(res).toEqual({ + "fileName": expect.stringMatching("/updated-projects.log"), + "meta": { + "projects": { + "branchUpdated": [updatedProjectId1, updatedProjectId2], + }, + }, + "processedTargets": 2, + }); + }); + it('Successfully updated several targets (dryRun mode)', async () => { + const targets: SnykTarget[] = [{ + attributes: { + displayName: 'snyk/bar', + isPrivate: true, + origin: 'github', + remoteUrl: null, + }, + id: uuid.v4(), + relationships: {} as unknown as SnykTargetRelationships, + type: 'target', + }, { + attributes: { + displayName: 'snyk/foo', + isPrivate: false, + origin: 'github', + remoteUrl: null, + }, + id: uuid.v4(), + relationships: {} as unknown as SnykTargetRelationships, + type: 'target', + }]; + const updatedProjectId1 = uuid.v4(); + const updatedProjectId2 = uuid.v4(); + const projectsTarget1: SnykProject[] = [{ + name: 'snyk/bar', + id: updatedProjectId1, + created: 'date', + origin: 'github', + type: 'npm', + branch: 'main' + }]; + const projectsTarget2: SnykProject[] = [ + { + name: 'snyk/foo', + id: updatedProjectId2, + created: 'date', + origin: 'github', + type: 'yarn', + branch: 'develop' + }]; + featureFlagsSpy.mockResolvedValueOnce(false); + listTargetsSpy.mockResolvedValueOnce({ targets }); + listProjectsSpy.mockResolvedValueOnce({ projects: projectsTarget1 }); + listProjectsSpy.mockResolvedValueOnce({ projects: projectsTarget2 }); + + logUpdatedProjectsSpy.mockResolvedValueOnce(null); + githubSpy.mockResolvedValue('new-branch'); + + const res = await updateOrgTargets('xxx', [SupportedIntegrationTypesUpdateProject.GITHUB], true); + expect(res).toEqual({ + "fileName": expect.stringMatching("/updated-projects.log"), + "meta": { + "projects": { + "branchUpdated": [updatedProjectId1, updatedProjectId2], + }, + }, + "processedTargets": 2, + }); + expect(updateProjectSpy).not.toHaveBeenCalled(); + }); + }); +});