Skip to content

Commit

Permalink
feat(scan): trigger polling breakpoint on the same severity or higher
Browse files Browse the repository at this point in the history
closes #467
  • Loading branch information
denis-maiorov-brightsec committed Oct 6, 2023
1 parent fa9ce01 commit fe0ebba
Show file tree
Hide file tree
Showing 11 changed files with 373 additions and 72 deletions.
221 changes: 221 additions & 0 deletions src/Scan/BasePolling.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import 'reflect-metadata';
import { Breakpoint } from './Breakpoint';
import { Logger, logger } from '../Utils';
import { BasePolling } from './BasePolling';
import { Scans, ScanState, ScanStatus } from './Scans';
import {
instance,
mock,
objectContaining,
reset,
spy,
verify,
when
} from 'ts-mockito';

describe('BasePolling', () => {
const breakpointMock = mock<Breakpoint>();
const scanManagerMock = mock<Scans>();

let loggerSpy!: Logger;

beforeEach(() => {
loggerSpy = spy(logger);
});

afterEach(() => {
reset<Breakpoint | Scans | Logger>(
breakpointMock,
scanManagerMock,
loggerSpy
);
});

describe('constructor', () => {
it('should warn if timeout is not specified', () => {
// arrange
const options = {
scanId: 'scanId'
};

// act
new BasePolling(
options,
instance(scanManagerMock),
instance(breakpointMock)
);

// assert
verify(
loggerSpy.warn(
`Warning: It looks like you've been running polling without "timeout" option.`
)
).once();
verify(
loggerSpy.warn(
`The recommended way to install polling with a minimal timeout: 10-20min.`
)
).once();
});

it('should warn if interval is less than 10s', () => {
// arrange
const options = {
scanId: 'scanId',
interval: 5000
};

// act
new BasePolling(
options,
instance(scanManagerMock),
instance(breakpointMock)
);

// assert
verify(loggerSpy.warn(`Warning: polling interval is too small.`)).once();
verify(
loggerSpy.warn(`The recommended way to set polling interval to 10s.`)
).once();
});
});

describe('start', () => {
it('should start polling and stop on breakpoint exception', async () => {
// arrange
const scanId = 'scanId';
const sut = new BasePolling(
{
scanId,
interval: 1
},
instance(scanManagerMock),
instance(breakpointMock)
);
const response: ScanState = {
numberOfHighSeverityIssues: 0,
numberOfCriticalSeverityIssues: 0,
numberOfLowSeverityIssues: 0,
numberOfMediumSeverityIssues: 0,
status: ScanStatus.RUNNING
};
when(scanManagerMock.status(scanId))
.thenResolve(response)
.thenResolve({ ...response, numberOfLowSeverityIssues: 1 });

when(
breakpointMock.execute(
objectContaining({ numberOfLowSeverityIssues: 1 })
)
).thenReject(new Error('breakpoint error'));
// act
const act = sut.start();
// assert
await expect(act).rejects.toThrow('breakpoint error');
verify(loggerSpy.log('Starting polling...')).once();
verify(scanManagerMock.status(scanId)).twice();
});

it.each([
ScanStatus.DONE,
ScanStatus.DISRUPTED,
ScanStatus.FAILED,
ScanStatus.STOPPED
])(
'should start polling and stop on scan status changed to "%s"',
async (status) => {
// arrange
const scanId = 'scanId';
const sut = new BasePolling(
{
scanId,
interval: 1
},
instance(scanManagerMock),
instance(breakpointMock)
);
const response: ScanState = {
numberOfHighSeverityIssues: 0,
numberOfCriticalSeverityIssues: 0,
numberOfLowSeverityIssues: 0,
numberOfMediumSeverityIssues: 0,
status: ScanStatus.RUNNING
};
when(scanManagerMock.status(scanId))
.thenResolve(response)
.thenResolve({ ...response, status });

// act
await sut.start();
// assert
verify(scanManagerMock.status(scanId)).twice();
}
);

it('should start polling and stop on timeout', async () => {
// arrange
const scanId = 'scanId';
const timeout = 10000;
const sut = new BasePolling(
{
scanId,
timeout,
interval: 1
},
instance(scanManagerMock),
instance(breakpointMock)
);
const response: ScanState = {
numberOfHighSeverityIssues: 0,
numberOfCriticalSeverityIssues: 0,
numberOfLowSeverityIssues: 0,
numberOfMediumSeverityIssues: 0,
status: ScanStatus.RUNNING
};
when(scanManagerMock.status(scanId)).thenResolve(response);

// act
jest.useFakeTimers();
const promise = sut.start();
jest.runAllTimers();
await promise;
jest.useRealTimers();

// assert
verify(scanManagerMock.status(scanId)).once();
verify(loggerSpy.log('Polling has been terminated by timeout.')).once();
});
});

describe('stop', () => {
it('should stop polling', async () => {
// arrange
const scanId = 'scanId';
const sut = new BasePolling(
{
scanId,
interval: 1
},
instance(scanManagerMock),
instance(breakpointMock)
);

const response: ScanState = {
numberOfHighSeverityIssues: 0,
numberOfCriticalSeverityIssues: 0,
numberOfLowSeverityIssues: 0,
numberOfMediumSeverityIssues: 0,
status: ScanStatus.RUNNING
};
when(scanManagerMock.status(scanId)).thenResolve(response);

// act
const start = sut.start();
await sut.stop();
await start;

// assert
verify(scanManagerMock.status(scanId)).once();
});
});
});
14 changes: 7 additions & 7 deletions src/Scan/BasePolling.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CountIssuesBySeverity, Scans, ScanState, ScanStatus } from './Scans';
import { Scans, ScanState, ScanStatus } from './Scans';
import { Polling } from './Polling';
import { Breakpoint } from './Breakpoint';
import { Backoff, logger } from '../Utils';
Expand Down Expand Up @@ -34,7 +34,6 @@ export class BasePolling implements Polling {

if (this.options.interval) {
const interval = this.toMilliseconds(this.options.interval);

if (interval < this.defaultInterval) {
logger.warn(`Warning: polling interval is too small.`);
logger.warn(`The recommended way to set polling interval to 10s.`);
Expand Down Expand Up @@ -78,21 +77,21 @@ export class BasePolling implements Polling {
logger.debug(`The polling timeout has been set to %d ms.`, timeoutInMs);
}

private async *poll(): AsyncIterableIterator<CountIssuesBySeverity[]> {
private async *poll(): AsyncIterableIterator<ScanState> {
while (this.active) {
await this.delay();

const backoff = this.createBackoff();

const { status, issuesBySeverity }: ScanState = await backoff.execute(
() => this.scanManager.status(this.options.scanId)
const state: ScanState = await backoff.execute(() =>
this.scanManager.status(this.options.scanId)
);

if (this.isRedundant(status)) {
if (this.isRedundant(state.status)) {
break;
}

yield issuesBySeverity;
yield state;
}
}

Expand All @@ -113,6 +112,7 @@ export class BasePolling implements Polling {
return (
status === ScanStatus.DONE ||
status === ScanStatus.STOPPED ||
status === ScanStatus.DISRUPTED ||
status === ScanStatus.FAILED
);
}
Expand Down
25 changes: 6 additions & 19 deletions src/Scan/Breakpoint.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
import { CountIssuesBySeverity } from './Scans';
import { ScanState } from './Scans';

export abstract class Breakpoint {
protected abstract breakOn(stat?: CountIssuesBySeverity): never | void;

protected abstract selectCriterion(
stats: CountIssuesBySeverity[]
): CountIssuesBySeverity | undefined;
protected abstract breakOn(stat: ScanState): never | void;
protected abstract isExcepted(stats: ScanState): boolean;

// eslint-disable-next-line @typescript-eslint/require-await
public async execute(
statsIssuesCategories: CountIssuesBySeverity[]
): Promise<void> {
const stat: CountIssuesBySeverity | undefined = this.selectCriterion(
statsIssuesCategories
);

if (this.isExcepted(stat)) {
this.breakOn(stat);
public async execute(scanIssues: ScanState): Promise<void> {
if (this.isExcepted(scanIssues)) {
this.breakOn(scanIssues);
}
}

protected isExcepted(stats?: CountIssuesBySeverity): boolean {
return !!stats?.number;
}
}
19 changes: 0 additions & 19 deletions src/Scan/Breakpoints/OnAny.ts

This file was deleted.

Loading

0 comments on commit fe0ebba

Please sign in to comment.