From 2a1204864666c5610f5a7b340a974ee22e72bdf2 Mon Sep 17 00:00:00 2001 From: Francesco Mari Date: Tue, 12 Jul 2022 12:08:00 +0200 Subject: [PATCH] feat: add --remote-repo-url to "iac test" --- src/cli/commands/test/iac/index.ts | 17 ++ .../assert-iac-options-flag.ts | 1 + .../process-results/cli-share-results.ts | 49 +--- .../local-execution/process-results/index.ts | 8 +- .../process-results/process-results.ts | 8 +- .../share-results-formatter.ts | 7 +- .../process-results/share-results.ts | 12 +- .../test/iac/local-execution/types.ts | 1 + src/cli/commands/test/iac/meta.ts | 86 +++++++ src/cli/commands/test/iac/scan.ts | 41 +++- .../formatters/iac-output/v2/share-results.ts | 12 +- src/lib/iac/cli-share-results.ts | 85 ------- src/lib/iac/envelope-formatters.ts | 17 +- .../unit/cli/commands/test/iac/meta.spec.ts | 220 ++++++++++++++++++ test/jest/unit/iac/cli-share-results.spec.ts | 32 ++- test/jest/unit/iac/index.spec.ts | 4 + .../share-results-formatters.spec.ts | 11 +- .../iac-output/v2/share-results.spec.ts | 31 --- 18 files changed, 451 insertions(+), 191 deletions(-) create mode 100644 src/cli/commands/test/iac/meta.ts delete mode 100644 src/lib/iac/cli-share-results.ts create mode 100644 test/jest/unit/cli/commands/test/iac/meta.spec.ts diff --git a/src/cli/commands/test/iac/index.ts b/src/cli/commands/test/iac/index.ts index abcde621ff..9e57c7437f 100644 --- a/src/cli/commands/test/iac/index.ts +++ b/src/cli/commands/test/iac/index.ts @@ -13,6 +13,7 @@ import config from '../../../../lib/config'; import { UnsupportedEntitlementError } from '../../../../lib/errors/unsupported-entitlement-error'; import { scan } from './scan'; import { buildOutput, buildSpinner, printHeader } from './output'; +import { InvalidRemoteUrlError } from '../../../../lib/errors/invalid-remote-url-error'; export default async function(...args: MethodArgs): Promise { const { options: originalOptions, paths } = processCommandArgs(...args); @@ -20,6 +21,7 @@ export default async function(...args: MethodArgs): Promise { const options = setDefaultTestOptions(originalOptions); validateTestOptions(options); validateCredentials(options); + const remoteRepoUrl = getRemoteRepoUrl(options); const orgPublicId = (options.org as string) ?? config.org; const iacOrgSettings = await getIacOrgSettings(orgPublicId); @@ -69,6 +71,7 @@ export default async function(...args: MethodArgs): Promise { orgPublicId, buildOciRegistry, projectRoot, + remoteRepoUrl, ); return buildOutput({ @@ -84,3 +87,17 @@ export default async function(...args: MethodArgs): Promise { testSpinner, }); } + +function getRemoteRepoUrl(options: any) { + const remoteRepoUrl = options['remote-repo-url']; + + if (!remoteRepoUrl) { + return; + } + + if (typeof remoteRepoUrl !== 'string') { + throw new InvalidRemoteUrlError(); + } + + return remoteRepoUrl; +} diff --git a/src/cli/commands/test/iac/local-execution/assert-iac-options-flag.ts b/src/cli/commands/test/iac/local-execution/assert-iac-options-flag.ts index 4864c4beaa..2b2dc8c971 100644 --- a/src/cli/commands/test/iac/local-execution/assert-iac-options-flag.ts +++ b/src/cli/commands/test/iac/local-execution/assert-iac-options-flag.ts @@ -33,6 +33,7 @@ const keys: (keyof IaCTestFlags)[] = [ // PolicyOptions 'ignore-policy', 'policy-path', + 'remote-repo-url', ]; const allowed = new Set(keys); diff --git a/src/cli/commands/test/iac/local-execution/process-results/cli-share-results.ts b/src/cli/commands/test/iac/local-execution/process-results/cli-share-results.ts index 742a977241..4a0ce5db8c 100644 --- a/src/cli/commands/test/iac/local-execution/process-results/cli-share-results.ts +++ b/src/cli/commands/test/iac/local-execution/process-results/cli-share-results.ts @@ -8,19 +8,19 @@ import { } from '../types'; import { convertIacResultToScanResult } from '../../../../../../lib/iac/envelope-formatters'; import { Policy } from '../../../../../../lib/policy/find-and-load-policy'; -import { getInfo } from '../../../../../../lib/project-metadata/target-builders/git'; -import { GitTarget } from '../../../../../../lib/ecosystems/types'; -import { Contributor } from '../../../../../../lib/types'; +import { + Contributor, + IacOutputMeta, + ProjectAttributes, + Tag, +} from '../../../../../../lib/types'; import * as analytics from '../../../../../../lib/analytics'; import { getContributors } from '../../../../../../lib/monitor/dev-count-analysis'; import * as Debug from 'debug'; import { AuthFailedError, ValidationError } from '../../../../../../lib/errors'; -import * as pathLib from 'path'; +import { TestLimitReachedError } from '../usage-tracking'; const debug = Debug('iac-cli-share-results'); -import { ProjectAttributes, Tag } from '../../../../../../lib/types'; -import { TestLimitReachedError } from '../usage-tracking'; -import { getRepositoryRootForPath } from '../../../../../../lib/iac/git'; export async function shareResults({ results, @@ -28,22 +28,21 @@ export async function shareResults({ tags, attributes, options, - projectRoot, + meta, }: { results: IacShareResultsFormat[]; policy: Policy | undefined; tags?: Tag[]; attributes?: ProjectAttributes; options?: IaCTestFlags; - projectRoot: string; + meta: IacOutputMeta; }): Promise { - const gitTarget = await readGitInfoForProjectRoot(projectRoot); const scanResults = results.map((result) => - convertIacResultToScanResult(result, policy, gitTarget, options), + convertIacResultToScanResult(result, policy, meta, options), ); let contributors: Contributor[] = []; - if (gitTarget.remoteUrl) { + if (meta.gitRemoteUrl) { if (analytics.allowAnalytics()) { try { contributors = await getContributors(); @@ -79,29 +78,5 @@ export async function shareResults({ ); } - return { projectPublicIds: body, gitRemoteUrl: gitTarget?.remoteUrl }; -} - -async function readGitInfoForProjectRoot( - projectRoot: string, -): Promise { - const repositoryRoot = getRepositoryRootForPath(projectRoot); - - const resolvedRepositoryRoot = pathLib.resolve(repositoryRoot); - const resolvedProjectRoot = pathLib.resolve(projectRoot); - - if (resolvedRepositoryRoot != resolvedProjectRoot) { - return {}; - } - - const gitInfo = await getInfo({ - isFromContainer: false, - cwd: projectRoot, - }); - - if (gitInfo) { - return gitInfo; - } - - return {}; + return { projectPublicIds: body, gitRemoteUrl: meta.gitRemoteUrl }; } diff --git a/src/cli/commands/test/iac/local-execution/process-results/index.ts b/src/cli/commands/test/iac/local-execution/process-results/index.ts index a4ac4177a2..e2d026f05a 100644 --- a/src/cli/commands/test/iac/local-execution/process-results/index.ts +++ b/src/cli/commands/test/iac/local-execution/process-results/index.ts @@ -1,5 +1,9 @@ import { Policy } from '../../../../../../lib/policy/find-and-load-policy'; -import { ProjectAttributes, Tag } from '../../../../../../lib/types'; +import { + IacOutputMeta, + ProjectAttributes, + Tag, +} from '../../../../../../lib/types'; import { FormattedResult, IacFileScanResult, @@ -26,6 +30,7 @@ export class SingleGroupResultsProcessor implements ResultsProcessor { private orgPublicId: string, private iacOrgSettings: IacOrgSettings, private options: IaCTestFlags, + private meta: IacOutputMeta, ) {} processResults( @@ -43,6 +48,7 @@ export class SingleGroupResultsProcessor implements ResultsProcessor { attributes, this.options, this.projectRoot, + this.meta, ); } } diff --git a/src/cli/commands/test/iac/local-execution/process-results/process-results.ts b/src/cli/commands/test/iac/local-execution/process-results/process-results.ts index cb7654a7fc..3d077ac4b9 100644 --- a/src/cli/commands/test/iac/local-execution/process-results/process-results.ts +++ b/src/cli/commands/test/iac/local-execution/process-results/process-results.ts @@ -2,7 +2,11 @@ import { filterIgnoredIssues } from './policy'; import { formatAndShareResults } from './share-results'; import { formatScanResultsV2 } from '../measurable-methods'; import { Policy } from '../../../../../../lib/policy/find-and-load-policy'; -import { ProjectAttributes, Tag } from '../../../../../../lib/types'; +import { + IacOutputMeta, + ProjectAttributes, + Tag, +} from '../../../../../../lib/types'; import { FormattedResult, IacFileScanResult, @@ -19,6 +23,7 @@ export async function processResults( attributes: ProjectAttributes | undefined, options: IaCTestFlags, projectRoot: string, + meta: IacOutputMeta, ): Promise<{ filteredIssues: FormattedResult[]; ignoreCount: number; @@ -35,6 +40,7 @@ export async function processResults( tags, attributes, projectRoot, + meta, })); } diff --git a/src/cli/commands/test/iac/local-execution/process-results/share-results-formatter.ts b/src/cli/commands/test/iac/local-execution/process-results/share-results-formatter.ts index 9ca79b4521..8b376638f2 100644 --- a/src/cli/commands/test/iac/local-execution/process-results/share-results-formatter.ts +++ b/src/cli/commands/test/iac/local-execution/process-results/share-results-formatter.ts @@ -1,9 +1,11 @@ import { IacFileScanResult, IacShareResultsFormat } from '../types'; import * as path from 'path'; +import { IacOutputMeta } from '../../../../../../lib/types'; export function formatShareResults( projectRoot: string, scanResults: IacFileScanResult[], + meta: IacOutputMeta, ): IacShareResultsFormat[] { const resultsGroupedByFilePath = groupByFilePath(scanResults); @@ -11,6 +13,7 @@ export function formatShareResults( const { projectName, targetFile } = computePaths( projectRoot, result.filePath, + meta, ); return { @@ -45,16 +48,16 @@ function groupByFilePath(scanResults: IacFileScanResult[]) { function computePaths( projectRoot: string, filePath: string, + meta: IacOutputMeta, ): { targetFilePath: string; projectName: string; targetFile: string } { const projectDirectory = path.resolve(projectRoot); - const currentDirectoryName = path.basename(projectDirectory); const absoluteFilePath = path.resolve(filePath); const relativeFilePath = path.relative(projectDirectory, absoluteFilePath); const unixRelativeFilePath = relativeFilePath.split(path.sep).join('/'); return { targetFilePath: absoluteFilePath, - projectName: currentDirectoryName, + projectName: meta.projectName, targetFile: unixRelativeFilePath, }; } diff --git a/src/cli/commands/test/iac/local-execution/process-results/share-results.ts b/src/cli/commands/test/iac/local-execution/process-results/share-results.ts index 92ff12fbd1..6b12f754c7 100644 --- a/src/cli/commands/test/iac/local-execution/process-results/share-results.ts +++ b/src/cli/commands/test/iac/local-execution/process-results/share-results.ts @@ -1,7 +1,11 @@ import { isFeatureFlagSupportedForOrg } from '../../../../../../lib/feature-flags'; import { shareResults } from './cli-share-results'; import { Policy } from '../../../../../../lib/policy/find-and-load-policy'; -import { ProjectAttributes, Tag } from '../../../../../../lib/types'; +import { + IacOutputMeta, + ProjectAttributes, + Tag, +} from '../../../../../../lib/types'; import { FeatureFlagError } from '../assert-iac-options-flag'; import { formatShareResults } from './share-results-formatter'; import { IacFileScanResult, IaCTestFlags, ShareResultsOutput } from '../types'; @@ -14,6 +18,7 @@ export async function formatAndShareResults({ tags, attributes, projectRoot, + meta, }: { results: IacFileScanResult[]; options: IaCTestFlags; @@ -22,6 +27,7 @@ export async function formatAndShareResults({ tags?: Tag[]; attributes?: ProjectAttributes; projectRoot: string; + meta: IacOutputMeta; }): Promise { const isCliReportEnabled = await isFeatureFlagSupportedForOrg( 'iacCliShareResults', @@ -31,7 +37,7 @@ export async function formatAndShareResults({ throw new FeatureFlagError('report', 'iacCliShareResults'); } - const formattedResults = formatShareResults(projectRoot, results); + const formattedResults = formatShareResults(projectRoot, results, meta); return await shareResults({ results: formattedResults, @@ -39,6 +45,6 @@ export async function formatAndShareResults({ tags, attributes, options, - projectRoot, + meta, }); } diff --git a/src/cli/commands/test/iac/local-execution/types.ts b/src/cli/commands/test/iac/local-execution/types.ts index 5d83eb85ef..ef98f125bf 100644 --- a/src/cli/commands/test/iac/local-execution/types.ts +++ b/src/cli/commands/test/iac/local-execution/types.ts @@ -186,6 +186,7 @@ export type IaCTestFlags = Pick< | 'ignore-policy' | 'policy-path' | 'tags' + | 'remote-repo-url' > & { // Supported flags not yet covered by Options or TestOptions 'json-file-output'?: string; diff --git a/src/cli/commands/test/iac/meta.ts b/src/cli/commands/test/iac/meta.ts new file mode 100644 index 0000000000..1f8f2ca3dc --- /dev/null +++ b/src/cli/commands/test/iac/meta.ts @@ -0,0 +1,86 @@ +import { IacOutputMeta } from '../../../../lib/types'; +import { IacOrgSettings } from './local-execution/types'; +import * as pathLib from 'path'; + +export interface GitRepository { + readonly path: string; + + readRemoteUrl(): Promise; +} + +export interface GitRepositoryFinder { + findRepositoryForPath(path: string): Promise; +} + +export async function buildMeta( + repositoryFinder: GitRepositoryFinder, + orgSettings: IacOrgSettings, + projectRoot: string, + remoteRepoUrl?: string, +): Promise { + const gitRemoteUrl = await getGitRemoteUrl( + repositoryFinder, + projectRoot, + remoteRepoUrl, + ); + const projectName = getProjectName(projectRoot, gitRemoteUrl); + const orgName = getOrgName(orgSettings); + return { projectName, orgName, gitRemoteUrl }; +} + +function getProjectName(projectRoot: string, gitRemoteUrl?: string): string { + if (gitRemoteUrl) { + return getProjectNameFromGitUrl(gitRemoteUrl); + } + + return pathLib.basename(pathLib.resolve(projectRoot)); +} + +function getOrgName(orgSettings: IacOrgSettings): string { + return orgSettings.meta.org; +} + +async function getGitRemoteUrl( + repositoryFinder: GitRepositoryFinder, + projectRoot: string, + remoteRepoUrl?: string, +): Promise { + if (remoteRepoUrl) { + return remoteRepoUrl; + } + + const repository = await repositoryFinder.findRepositoryForPath(projectRoot); + + if (!repository) { + return; + } + + const resolvedRepositoryRoot = pathLib.resolve(repository.path); + const resolvedProjectRoot = pathLib.resolve(projectRoot); + + if (resolvedRepositoryRoot != resolvedProjectRoot) { + return; + } + + return await repository.readRemoteUrl(); +} + +export function getProjectNameFromGitUrl(url: string) { + const regexps = [ + /^ssh:\/\/([^@]+@)?[^:/]+(:[^/]+)?\/(?.*).git\/?$/, + /^(git|https?|ftp):\/\/[^:/]+(:[^/]+)?\/(?.*).git\/?$/, + /^[^@]+@[^:]+:(?.*).git$/, + ]; + + const trimmed = url.trim(); + + for (const regexp of regexps) { + const match = trimmed.match(regexp); + + if (match && match.groups) { + return match.groups.name; + } + } + + return trimmed; +} diff --git a/src/cli/commands/test/iac/scan.ts b/src/cli/commands/test/iac/scan.ts index 3512cc0da7..f5dcc2ce8e 100644 --- a/src/cli/commands/test/iac/scan.ts +++ b/src/cli/commands/test/iac/scan.ts @@ -23,6 +23,9 @@ import { CustomError } from '../../../../lib/errors'; import { OciRegistry } from './local-execution/rules/oci-registry'; import { SingleGroupResultsProcessor } from './local-execution/process-results'; import { getErrorStringCode } from './local-execution/error-utils'; +import { getRepositoryRootForPath } from '../../../../lib/iac/git'; +import { getInfo } from '../../../../lib/project-metadata/target-builders/git'; +import { buildMeta, GitRepository, GitRepositoryFinder } from './meta'; export async function scan( iacOrgSettings: IacOrgSettings, @@ -32,6 +35,7 @@ export async function scan( orgPublicId: string, buildOciRules: () => OciRegistry, projectRoot: string, + remoteRepoUrl?: string, ): Promise<{ iacOutputMeta: IacOutputMeta | undefined; iacScanFailures: IacFileInDirectory[]; @@ -41,8 +45,15 @@ export async function scan( }> { const results = [] as any[]; const resultOptions: Array = []; + const repositoryFinder = new DefaultGitRepositoryFinder(); + + const iacOutputMeta = await buildMeta( + repositoryFinder, + iacOrgSettings, + projectRoot, + remoteRepoUrl, + ); - let iacOutputMeta: IacOutputMeta | undefined; let iacScanFailures: IacFileInDirectory[] = []; let iacIgnoredIssuesCount = 0; @@ -77,6 +88,7 @@ export async function scan( orgPublicId, iacOrgSettings, testOpts, + iacOutputMeta, ); const { results, failures, ignoreCount } = await iacTest( @@ -86,11 +98,6 @@ export async function scan( iacOrgSettings, rulesOrigin, ); - iacOutputMeta = { - orgName: results[0]?.org, - projectName: results[0]?.projectName, - gitRemoteUrl: results[0]?.meta?.gitRemoteUrl, - }; res = results; iacScanFailures = [...iacScanFailures, ...(failures || [])]; @@ -168,3 +175,25 @@ class CurrentWorkingDirectoryTraversalError extends CustomError { this.filename = path; } } + +class DefaultGitRepository implements GitRepository { + constructor(public readonly path: string) {} + + async readRemoteUrl() { + const gitInfo = await getInfo({ + isFromContainer: false, + cwd: this.path, + }); + return gitInfo?.remoteUrl; + } +} + +class DefaultGitRepositoryFinder implements GitRepositoryFinder { + async findRepositoryForPath(path: string) { + try { + return new DefaultGitRepository(getRepositoryRootForPath(path)); + } catch { + return; + } + } +} diff --git a/src/lib/formatters/iac-output/v2/share-results.ts b/src/lib/formatters/iac-output/v2/share-results.ts index 44ab392e99..c9f3518592 100644 --- a/src/lib/formatters/iac-output/v2/share-results.ts +++ b/src/lib/formatters/iac-output/v2/share-results.ts @@ -4,16 +4,6 @@ import { EOL } from 'os'; import { colors, contentPadding } from './utils'; export function formatShareResultsOutput(iacOutputMeta: IacOutputMeta) { - let projectName: string = iacOutputMeta.projectName; - - if (iacOutputMeta.gitRemoteUrl) { - // from "http://github.com/snyk/cli.git" to "snyk/cli" - projectName = iacOutputMeta.gitRemoteUrl.replace( - /^https?:\/\/github.com\/(.*)\.git$/, - '$1', - ); - } - return ( colors.title('Report Complete') + EOL + @@ -24,7 +14,7 @@ export function formatShareResultsOutput(iacOutputMeta: IacOutputMeta) { EOL + contentPadding + 'under the name: ' + - colors.title(projectName) + colors.title(iacOutputMeta.projectName) ); } diff --git a/src/lib/iac/cli-share-results.ts b/src/lib/iac/cli-share-results.ts deleted file mode 100644 index 1b79897b45..0000000000 --- a/src/lib/iac/cli-share-results.ts +++ /dev/null @@ -1,85 +0,0 @@ -import config from '../config'; -import { makeRequest } from '../request'; -import { getAuthHeader } from '../api-token'; -import { - IacShareResultsFormat, - IaCTestFlags, - ShareResultsOutput, -} from '../../cli/commands/test/iac/local-execution/types'; -import { convertIacResultToScanResult } from './envelope-formatters'; -import { Policy } from '../policy/find-and-load-policy'; -import { getInfo } from '../project-metadata/target-builders/git'; -import { GitTarget } from '../ecosystems/types'; -import { Contributor } from '../types'; -import * as analytics from '../analytics'; -import { getContributors } from '../monitor/dev-count-analysis'; -import * as Debug from 'debug'; -import { AuthFailedError, ValidationError } from '../errors'; - -const debug = Debug('iac-cli-share-results'); -import { ProjectAttributes, Tag } from '../types'; -import { TestLimitReachedError } from '../../cli/commands/test/iac/local-execution/usage-tracking'; -import { getWorkingDirectoryForPath } from './git'; - -export async function shareResults({ - results, - policy, - tags, - attributes, - options, - pathToScan, -}: { - results: IacShareResultsFormat[]; - policy: Policy | undefined; - tags?: Tag[]; - attributes?: ProjectAttributes; - options?: IaCTestFlags; - pathToScan: string; -}): Promise { - const gitTarget = (await getInfo({ - isFromContainer: false, - cwd: getWorkingDirectoryForPath(pathToScan), - })) as GitTarget; - const scanResults = results.map((result) => - convertIacResultToScanResult(result, policy, gitTarget, options), - ); - - let contributors: Contributor[] = []; - if (gitTarget.remoteUrl) { - if (analytics.allowAnalytics()) { - try { - contributors = await getContributors(); - } catch (err) { - debug('error getting repo contributors', err); - } - } - } - const { res, body } = await makeRequest({ - method: 'POST', - url: `${config.API}/iac-cli-share-results`, - json: true, - qs: { org: options?.org ?? config.org }, - headers: { - authorization: getAuthHeader(), - }, - body: { - scanResults, - contributors, - tags, - attributes, - }, - }); - - switch (res.statusCode) { - case 401: - throw AuthFailedError(); - case 422: - throw new ValidationError( - res.body.error ?? 'An error occurred, please contact Snyk support', - ); - case 429: - throw new TestLimitReachedError(); - } - - return { projectPublicIds: body, gitRemoteUrl: gitTarget?.remoteUrl }; -} diff --git a/src/lib/iac/envelope-formatters.ts b/src/lib/iac/envelope-formatters.ts index 6044cb6739..6f5fe7501e 100644 --- a/src/lib/iac/envelope-formatters.ts +++ b/src/lib/iac/envelope-formatters.ts @@ -3,13 +3,14 @@ import { IaCTestFlags, PolicyMetadata, } from '../../cli/commands/test/iac/local-execution/types'; -import { GitTarget, ScanResult } from '../ecosystems/types'; +import { ScanResult } from '../ecosystems/types'; import { Policy } from '../policy/find-and-load-policy'; +import { IacOutputMeta } from '../types'; export function convertIacResultToScanResult( iacResult: IacShareResultsFormat, policy: Policy | undefined, - gitTarget: GitTarget, + meta: IacOutputMeta, options?: IaCTestFlags, ): ScanResult { return { @@ -25,11 +26,15 @@ export function convertIacResultToScanResult( }; }), name: iacResult.projectName, - target: - Object.keys(gitTarget).length === 0 - ? { name: iacResult.projectName } - : { remoteUrl: gitTarget.remoteUrl }, + target: buildTarget(meta), policy: policy?.toString() ?? '', targetReference: options?.['target-reference'], }; } + +function buildTarget(meta: IacOutputMeta) { + if (meta.gitRemoteUrl) { + return { remoteUrl: meta.gitRemoteUrl }; + } + return { name: meta.projectName }; +} diff --git a/test/jest/unit/cli/commands/test/iac/meta.spec.ts b/test/jest/unit/cli/commands/test/iac/meta.spec.ts new file mode 100644 index 0000000000..085d0c3066 --- /dev/null +++ b/test/jest/unit/cli/commands/test/iac/meta.spec.ts @@ -0,0 +1,220 @@ +import { + buildMeta, + getProjectNameFromGitUrl, +} from '../../../../../../../src/cli/commands/test/iac/meta'; +import * as path from 'path'; + +describe('buildMeta', () => { + describe('current directory is a repository with a URL', () => { + const orgName = 'org'; + const orgSettings = orgSettingsFor(orgName); + const repoPath = path.resolve('project'); + const repoUrl = 'git@example.com:foo/bar.git'; + const repoFinder = repositoryFound(repoPath, repoUrl); + + it('should return a valid meta', async () => { + const meta = await buildMeta(repoFinder, orgSettings, repoPath); + + expect(meta).toMatchObject({ + projectName: 'foo/bar', + orgName: orgName, + gitRemoteUrl: repoUrl, + }); + }); + + it('should respect the repository URL override', async () => { + const repoUrl = 'git@example.com:baz/qux.git'; + + const meta = await buildMeta(repoFinder, orgSettings, repoPath, repoUrl); + + expect(meta).toMatchObject({ + projectName: 'baz/qux', + orgName: 'org', + gitRemoteUrl: repoUrl, + }); + }); + }); + + describe('current directory is a repository without a URL', () => { + const orgName = 'org'; + const orgSettings = orgSettingsFor(orgName); + const repoPath = path.resolve('project'); + const repoFinder = repositoryFound(repoPath); + + it('should return a valid meta', async () => { + const meta = await buildMeta(repoFinder, orgSettings, repoPath); + + expect(meta).toMatchObject({ + projectName: 'project', + orgName: orgName, + gitRemoteUrl: undefined, + }); + }); + + it('should respect the repository URL override', async () => { + const repoUrl = 'git@example.com:baz/qux.git'; + + const meta = await buildMeta(repoFinder, orgSettings, repoPath, repoUrl); + + expect(meta).toMatchObject({ + projectName: 'baz/qux', + orgName: 'org', + gitRemoteUrl: repoUrl, + }); + }); + }); + + describe('current directory is not a repository', () => { + const orgName = 'org'; + const orgSettings = orgSettingsFor(orgName); + const repoFinder = repositoryNotFound(); + const projectPath = path.resolve('project'); + + it('should return a valid meta', async () => { + const meta = await buildMeta(repoFinder, orgSettings, projectPath); + + expect(meta).toMatchObject({ + projectName: 'project', + orgName: orgName, + gitRemoteUrl: undefined, + }); + }); + + it('should respect the repository URL override', async () => { + const repoUrl = 'git@example.com:baz/qux.git'; + + const meta = await buildMeta( + repoFinder, + orgSettings, + projectPath, + repoUrl, + ); + + expect(meta).toMatchObject({ + projectName: 'baz/qux', + orgName: 'org', + gitRemoteUrl: repoUrl, + }); + }); + }); + + describe('parent directory is a repository with a URL', () => { + const orgName = 'org'; + const orgSettings = orgSettingsFor(orgName); + const projectPath = path.resolve('project'); + const repoPath = path.resolve('.'); + const repoUrl = 'git@example.com:foo/bar.git'; + const repoFinder = repositoryFound(repoPath, repoUrl); + + it('should return a valid meta', async () => { + const meta = await buildMeta(repoFinder, orgSettings, projectPath); + + expect(meta).toMatchObject({ + projectName: 'project', + orgName: orgName, + gitRemoteUrl: undefined, + }); + }); + + it('should respect the repository URL override', async () => { + const repoUrl = 'git@example.com:baz/qux.git'; + + const meta = await buildMeta(repoFinder, orgSettings, repoPath, repoUrl); + + expect(meta).toMatchObject({ + projectName: 'baz/qux', + orgName: 'org', + gitRemoteUrl: repoUrl, + }); + }); + }); +}); + +describe('getProjectNameFromGitUrl', () => { + const urls = [ + // SSH URLs without ~username expansion, as documented by "git clone". + + 'ssh://user@host.xz:1234/user/repo.git/', + 'ssh://host.xz:1234/user/repo.git/', + 'ssh://user@host.xz/user/repo.git/', + 'ssh://host.xz/user/repo.git/', + 'ssh://user@host.xz:1234/user/repo.git', + 'ssh://host.xz:1234/user/repo.git', + 'ssh://user@host.xz/user/repo.git', + 'ssh://host.xz/user/repo.git', + + // Git URLs without ~username expansion, as documented by "git clone". + + 'git://host.xz:1234/user/repo.git/', + 'git://host.xz/user/repo.git/', + 'git://host.xz:1234/user/repo.git', + 'git://host.xz/user/repo.git', + + // HTTP URLs, as documented by "git clone". + + 'http://host.xz:1234/user/repo.git/', + 'http://host.xz/user/repo.git/', + 'http://host.xz:1234/user/repo.git', + 'http://host.xz/user/repo.git', + + // HTTPS URLs, as documented by "git clone". + + 'https://host.xz:1234/user/repo.git/', + 'https://host.xz/user/repo.git/', + 'https://host.xz:1234/user/repo.git', + 'https://host.xz/user/repo.git', + + // SSH URLs without protocol, as used by GitHub. + + 'git@github.com:user/repo.git', + + // If everything else fails, the URL should be returned as-is, but trimmed. + + 'user/repo', + ' user/repo', + 'user/repo ', + ]; + + it.each(urls)('should parse %s', (url) => { + expect(getProjectNameFromGitUrl(url)).toBe('user/repo'); + }); +}); + +function orgSettingsFor(org) { + return { + customPolicies: {}, + meta: { + isPrivate: false, + isLicensesEnabled: false, + org, + }, + }; +} + +function repositoryNotFound() { + const repositoryFinder = { + async findRepositoryForPath() { + return undefined; + }, + }; + + return repositoryFinder; +} + +function repositoryFound(path: string, url?: string) { + const repository = { + path, + + async readRemoteUrl() { + return url; + }, + }; + + const repositoryFinder = { + async findRepositoryForPath() { + return repository; + }, + }; + + return repositoryFinder; +} diff --git a/test/jest/unit/iac/cli-share-results.spec.ts b/test/jest/unit/iac/cli-share-results.spec.ts index 7dcc41f19e..82356adb35 100644 --- a/test/jest/unit/iac/cli-share-results.spec.ts +++ b/test/jest/unit/iac/cli-share-results.spec.ts @@ -1,4 +1,4 @@ -import { shareResults } from '../../../../src/lib/iac/cli-share-results'; +import { shareResults } from '../../../../src/cli/commands/test/iac/local-execution/process-results/cli-share-results'; import { expectedEnvelopeFormatterResults, expectedEnvelopeFormatterResultsWithPolicy, @@ -38,7 +38,11 @@ describe('CLI Share Results', () => { await shareResults({ results: scanResults, policy: undefined, - pathToScan: 'test/fixtures/iac/arm', + meta: { + projectName: 'project-name', + orgName: 'org-name', + gitRemoteUrl: 'http://github.com/snyk/cli.git', + }, }); expect(envelopeFormattersSpy.mock.calls.length).toBe(2); @@ -60,7 +64,11 @@ describe('CLI Share Results', () => { await shareResults({ results: scanResults, policy: snykPolicy, - pathToScan: 'test/fixtures/iac/arm', + meta: { + projectName: 'project-name', + orgName: 'org-name', + gitRemoteUrl: 'http://github.com/snyk/cli.git', + }, }); expect(envelopeFormattersSpy.mock.calls.length).toBe(2); @@ -95,7 +103,11 @@ describe('CLI Share Results', () => { options: { 'target-reference': testTargetRef, }, - pathToScan: 'test/fixtures/iac/arm', + meta: { + projectName: 'project-name', + orgName: 'org-name', + gitRemoteUrl: 'http://github.com/snyk/cli.git', + }, }); expect(envelopeFormattersSpy.mock.calls.length).toBe(2); @@ -122,7 +134,11 @@ describe('CLI Share Results', () => { await shareResults({ results: scanResults, policy: undefined, - pathToScan: 'test/fixtures/iac/arm', + meta: { + projectName: 'project-name', + orgName: 'org-name', + gitRemoteUrl: 'http://github.com/snyk/cli.git', + }, }); expect(requestSpy.mock.calls.length).toBe(1); @@ -144,7 +160,11 @@ describe('CLI Share Results', () => { options: { org: 'my-custom-org', }, - pathToScan: 'test/fixtures/iac/arm', + meta: { + projectName: 'project-name', + orgName: 'org-name', + gitRemoteUrl: 'http://github.com/snyk/cli.git', + }, }); expect(requestSpy.mock.calls.length).toBe(1); diff --git a/test/jest/unit/iac/index.spec.ts b/test/jest/unit/iac/index.spec.ts index 7dcc2266ff..3d641e55ee 100644 --- a/test/jest/unit/iac/index.spec.ts +++ b/test/jest/unit/iac/index.spec.ts @@ -110,6 +110,10 @@ describe('test()', () => { 'org-name', iacOrgSettings, opts, + { + projectName: 'project-name', + orgName: 'org-name', + }, ); const { failures } = await test( diff --git a/test/jest/unit/iac/process-results/share-results-formatters.spec.ts b/test/jest/unit/iac/process-results/share-results-formatters.spec.ts index f436cf1948..aed4b555f6 100644 --- a/test/jest/unit/iac/process-results/share-results-formatters.spec.ts +++ b/test/jest/unit/iac/process-results/share-results-formatters.spec.ts @@ -2,18 +2,25 @@ import { formatShareResults } from '../../../../../src/cli/commands/test/iac/loc import { generateScanResults } from '../results-formatter.fixtures'; import { expectedFormattedResultsForShareResults } from './share-results-formatters.fixtures'; import * as git from '../../../../../src/lib/iac/git'; +import * as path from 'path'; + +const projectRoot = path.resolve(__dirname, '..', '..', '..', '..', '..'); describe('formatShareResults', () => { beforeAll(() => { jest .spyOn(git, 'getWorkingDirectoryForPath') - .mockImplementation(() => process.cwd()); + .mockImplementation(() => projectRoot); }); it('returns the formatted results', () => { const IacShareResultsFormatResults = formatShareResults( - process.cwd(), + projectRoot, generateScanResults(), + { + projectName: path.basename(projectRoot), + orgName: 'org-name', + }, ); expect(IacShareResultsFormatResults).toStrictEqual( expectedFormattedResultsForShareResults, diff --git a/test/jest/unit/lib/formatters/iac-output/v2/share-results.spec.ts b/test/jest/unit/lib/formatters/iac-output/v2/share-results.spec.ts index 6206c11d0a..0a1bbd481a 100644 --- a/test/jest/unit/lib/formatters/iac-output/v2/share-results.spec.ts +++ b/test/jest/unit/lib/formatters/iac-output/v2/share-results.spec.ts @@ -32,35 +32,4 @@ describe('formatShareResultsOutput', () => { colors.title(testProjectName), ); }); - - describe('when the gitRemoteUrl is specified', () => { - it('returns the correct output', () => { - // Arrange - const testProjectName = 'test-project'; - const testOrgName = 'test-org'; - const testRepoName = 'test/repo'; - const testGitRemoteUrl = `http://github.com/${testRepoName}.git`; - - // Act - const output = formatShareResultsOutput({ - projectName: testProjectName, - orgName: testOrgName, - gitRemoteUrl: testGitRemoteUrl, - }); - - // Assert - expect(output).toEqual( - colors.title('Report Complete') + - EOL + - EOL + - contentPadding + - 'Your test results are available at: ' + - colors.title(`${config.ROOT}/org/${testOrgName}/projects`) + - EOL + - contentPadding + - 'under the name: ' + - colors.title(testRepoName), - ); - }); - }); });