diff --git a/src/guards/pipeline-execution.guard.spec.ts b/src/guards/pipeline-execution.guard.spec.ts new file mode 100644 index 0000000..0e2dbf9 --- /dev/null +++ b/src/guards/pipeline-execution.guard.spec.ts @@ -0,0 +1,71 @@ +import { BadRequestException } from '@nestjs/common'; +import { PipelineExecutionGuard } from './pipeline-execution.guard'; + +const logger = { info: jest.fn(), error: jest.fn() }; + +function buildGuard(proxyImpl: jest.Mock) { + const platformApiService: any = { proxy: proxyImpl }; + return new PipelineExecutionGuard( + { logger } as any, + platformApiService, + ); +} + +function contextWith(pipelineId = 'abc-123') { + return { + switchToHttp: () => ({ + getRequest: () => ({ params: { pipelineId }, user: {} }), + }), + } as any; +} + +describe('PipelineExecutionGuard', () => { + afterEach(() => jest.clearAllMocks()); + + it('allows the edit when the pipeline has no run history (empty array)', async () => { + const guard = buildGuard(jest.fn().mockResolvedValue([])); + await expect(guard.canActivate(contextWith())).resolves.toBe(true); + }); + + it('allows the edit when the last run has no last_status', async () => { + const guard = buildGuard(jest.fn().mockResolvedValue([{}])); + await expect(guard.canActivate(contextWith())).resolves.toBe(true); + }); + + it('allows the edit when the pipeline is not running', async () => { + const guard = buildGuard( + jest.fn().mockResolvedValue([{ last_status: 'SUCCEEDED' }]), + ); + await expect(guard.canActivate(contextWith())).resolves.toBe(true); + }); + + it('blocks with the is-running message when the pipeline is running', async () => { + const guard = buildGuard( + jest.fn().mockResolvedValue([{ last_status: 'RUNNING' }]), + ); + await expect(guard.canActivate(contextWith())).rejects.toThrow( + 'Pipeline is running, cannot update input now', + ); + }); + + it('wraps a genuine status-check failure (fail closed)', async () => { + const guard = buildGuard( + jest.fn().mockRejectedValue(new Error('platform down')), + ); + await expect(guard.canActivate(contextWith())).rejects.toThrow( + 'Error checking pipeline status: platform down', + ); + }); + + it('does not double-wrap the is-running BadRequestException', async () => { + const guard = buildGuard( + jest.fn().mockResolvedValue([{ last_status: 'running' }]), + ); + await expect(guard.canActivate(contextWith())).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(guard.canActivate(contextWith())).rejects.not.toThrow( + /Error checking pipeline status/, + ); + }); +}); diff --git a/src/guards/pipeline-execution.guard.ts b/src/guards/pipeline-execution.guard.ts index ffd651b..1b6ebcd 100644 --- a/src/guards/pipeline-execution.guard.ts +++ b/src/guards/pipeline-execution.guard.ts @@ -46,10 +46,16 @@ export class PipelineExecutionGuard implements CanActivate { user, ); - const currentStatus = status[status.length - 1] + const currentStatus = status?.[status.length - 1]; this.logger.info('Pipeline current status response:' + JSON.stringify(currentStatus)); - + + // No run history (e.g. CDC pipelines never record batch runs) means + // nothing is executing — allow the edit rather than crash on .last_status. + if (!currentStatus?.last_status) { + return true; + } + if (currentStatus.last_status.toLowerCase() === 'running') { this.logger.error('Pipeline is running, cannot update input now'); throw new BadRequestException('Pipeline is running, cannot update input now'); @@ -57,6 +63,11 @@ export class PipelineExecutionGuard implements CanActivate { return true; } } catch (error) { + // Preserve the deliberate is-running rejection; only wrap genuine + // status-check failures (fail closed on those for a destructive gate). + if (error instanceof BadRequestException) { + throw error; + } this.logger.error('Error in PipelineExecutionGuard: ' + error.message); throw new BadRequestException('Error checking pipeline status: ' + error.message); }