Skip to content

Commit b5ae18c

Browse files
authored
Merge pull request #6807 from snyk/chore/CLI-1494_fault_injection
chore: unify resilience tests and add 401/mid-execution-maintenance scenarios
2 parents 383497a + 8e496d9 commit b5ae18c

3 files changed

Lines changed: 276 additions & 206 deletions

File tree

test/jest/acceptance/maintenance.spec.ts

Lines changed: 0 additions & 103 deletions
This file was deleted.
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { fakeServer, getFirstIPv4Address } from '../../acceptance/fake-server';
2+
import { runSnykCLI } from '../util/runSnykCLI';
3+
import { getAvailableServerPort } from '../util/getServerPort';
4+
import { Snyk } from '@snyk/error-catalog-nodejs-public';
5+
import { EXIT_CODES } from '../../../src/cli/exit-codes';
6+
import { getCliConfig, restoreCliConfig } from '../../acceptance/config-helper';
7+
8+
jest.setTimeout(1000 * 60);
9+
10+
const TIMEOUT_SECS = 5;
11+
const GRACE_PERIOD_SECS = 5;
12+
const SERVER_DELAY_MS = 10000;
13+
const FAKE_ORG = '11111111-1111-1111-1111-111111111111';
14+
15+
// Commands that should behave consistently across all fault scenarios
16+
const COMMANDS_UNDER_TEST = [
17+
'test',
18+
'code test',
19+
'container test scratch',
20+
'container monitor scratch',
21+
'iac test',
22+
'secrets test',
23+
'monitor',
24+
'whoami',
25+
'auth 11111111-2222-3333-4444-555555555555',
26+
'sbom --org=11111111-1111-1111-1111-111111111111 --format=cyclonedx1.4+json',
27+
'container sbom scratch --format=cyclonedx1.4+json',
28+
'sbom test --experimental --file=package.json',
29+
'aibom test --experimental',
30+
];
31+
32+
interface ScenarioContext {
33+
server: ReturnType<typeof fakeServer>;
34+
savedConfig?: Record<string, string>;
35+
}
36+
37+
interface TestResult {
38+
code: number;
39+
stdout: string;
40+
duration: number;
41+
}
42+
43+
interface AssertionContext {
44+
server: ReturnType<typeof fakeServer>;
45+
result: TestResult;
46+
}
47+
48+
interface ResilienceScenario {
49+
name: string;
50+
description: string;
51+
setup: (ctx: ScenarioContext) => void | Promise<void>;
52+
teardown?: (ctx: ScenarioContext) => void | Promise<void>;
53+
expectedExitCode: number;
54+
expectedErrorCode: string;
55+
assert?: (ctx: AssertionContext) => void; // Additional scenario-specific assertions
56+
envOverrides?: Record<string, string>;
57+
skip?: string[]; // Commands to skip for this scenario (not yet consistent)
58+
}
59+
60+
const RESILIENCE_SCENARIOS: ResilienceScenario[] = [
61+
// Scenario 1
62+
{
63+
name: 'maintenance-window',
64+
description: 'Backend in maintenance mode (503 with error catalog)',
65+
setup: ({ server }) => {
66+
const maintenanceErrorRes = {
67+
jsonapi: { version: '1.0' },
68+
errors: [new Snyk.MaintenanceWindowError('').toJsonApiErrorObject()],
69+
description: 'Maintenance window',
70+
};
71+
server.setGlobalResponse(
72+
maintenanceErrorRes,
73+
parseInt(maintenanceErrorRes.errors[0].status),
74+
);
75+
},
76+
expectedExitCode: EXIT_CODES.EX_TEMPFAIL,
77+
expectedErrorCode: 'SNYK-0099',
78+
assert: ({ server }) => {
79+
// Verify no retries (fail fast for maintenance)
80+
// Each snyk-request-id should appear only once - duplicates indicate retries
81+
const requests = server.getRequests();
82+
const requestIdCounts = new Map<string, number>();
83+
for (const req of requests) {
84+
const header = req.headers?.['snyk-request-id'];
85+
const requestId = Array.isArray(header) ? header[0] : header;
86+
if (requestId) {
87+
requestIdCounts.set(
88+
requestId,
89+
(requestIdCounts.get(requestId) ?? 0) + 1,
90+
);
91+
}
92+
}
93+
for (const count of requestIdCounts.values()) {
94+
expect(count).toBe(1);
95+
}
96+
},
97+
envOverrides: {
98+
// Enable retries to verify they are NOT used
99+
SNYK_MAX_ATTEMPTS: '10',
100+
},
101+
},
102+
103+
// Scenario 2
104+
{
105+
name: 'timeout',
106+
description: 'CLI times out before command finishes',
107+
setup: ({ server }) => {
108+
server.setResponseDelay(SERVER_DELAY_MS);
109+
},
110+
expectedExitCode: EXIT_CODES.EX_UNAVAILABLE,
111+
expectedErrorCode: 'SNYK-CLI-0026',
112+
assert: ({ result, server }) => {
113+
// Verify timeout occurred within expected bounds
114+
expect(result.duration).toBeGreaterThanOrEqual(TIMEOUT_SECS * 1000);
115+
expect(result.duration).toBeLessThan(
116+
(TIMEOUT_SECS + GRACE_PERIOD_SECS) * 1000,
117+
);
118+
119+
const requests = server.getRequests();
120+
const instrumentationRequest = requests.find((r) =>
121+
r.url?.includes(`/api/hidden/orgs/${FAKE_ORG}/analytics`),
122+
);
123+
//eslint-disable-next-line jest/no-standalone-expect
124+
expect(instrumentationRequest).toBeDefined();
125+
},
126+
envOverrides: {
127+
SNYK_TIMEOUT_SECS: String(TIMEOUT_SECS),
128+
},
129+
skip: ['container sbom scratch'],
130+
},
131+
132+
// Scenario 3
133+
{
134+
name: 'unauthorized-401',
135+
description: 'Backend returns 401 Unauthorized',
136+
setup: ({ server }) => {
137+
server.setGlobalResponse(
138+
{
139+
jsonapi: { version: '1.0' },
140+
errors: [new Snyk.UnauthorisedError('').toJsonApiErrorObject()],
141+
},
142+
401,
143+
);
144+
},
145+
expectedExitCode: EXIT_CODES.ERROR,
146+
expectedErrorCode: 'SNYK-0005',
147+
skip: [
148+
'container sbom scratch',
149+
'container test scratch',
150+
'container monitor scratch',
151+
'iac test',
152+
'secrets test',
153+
'auth', // auth doesn't need to
154+
],
155+
},
156+
157+
// Scenario 4
158+
{
159+
name: 'mid-execution-maintenance',
160+
description: 'Backend enters maintenance after initial successful requests',
161+
setup: ({ server }) => {
162+
const maintenanceErrorRes = {
163+
jsonapi: { version: '1.0' },
164+
errors: [new Snyk.MaintenanceWindowError('').toJsonApiErrorObject()],
165+
description: 'Maintenance window',
166+
};
167+
168+
// First request succeeds, subsequent requests hit maintenance
169+
server.setNextStatusCode(200);
170+
server.setNextStatusCode(200);
171+
server.setNextStatusCode(200);
172+
server.setNextStatusCode(200);
173+
server.setGlobalResponse(
174+
maintenanceErrorRes,
175+
parseInt(maintenanceErrorRes.errors[0].status),
176+
);
177+
},
178+
expectedExitCode: EXIT_CODES.EX_TEMPFAIL,
179+
expectedErrorCode: 'SNYK-0099',
180+
skip: [
181+
'whoami', // Single-request commands won't hit the failure
182+
'auth', // Single-request commands won't hit the failure
183+
'container monitor scratch',
184+
],
185+
},
186+
];
187+
188+
function shouldSkip(scenario: ResilienceScenario, command: string): boolean {
189+
if (!scenario.skip) return false;
190+
return scenario.skip.some((skip) => command.startsWith(skip));
191+
}
192+
193+
describe('Resilience - Consistent CLI Behavior', () => {
194+
let server: ReturnType<typeof fakeServer>;
195+
let baseEnv: Record<string, string>;
196+
197+
beforeAll(async () => {
198+
const ipAddr = getFirstIPv4Address();
199+
const port = await getAvailableServerPort(process);
200+
const baseApi = '/api/v1';
201+
202+
baseEnv = {
203+
...process.env,
204+
SNYK_API: 'http://' + ipAddr + ':' + port + baseApi,
205+
SNYK_TOKEN: '123456789',
206+
SNYK_HTTP_PROTOCOL_UPGRADE: '0',
207+
SNYK_CFG_ORG: FAKE_ORG,
208+
};
209+
210+
server = fakeServer(baseApi, baseEnv.SNYK_TOKEN);
211+
server.setFeatureFlag('isSecretsEnabled', true);
212+
await server.listenPromise(port);
213+
});
214+
215+
afterEach(() => {
216+
server.restore();
217+
});
218+
219+
afterAll(async () => {
220+
await server.closePromise();
221+
});
222+
223+
describe.each(RESILIENCE_SCENARIOS)(
224+
'$name: $description',
225+
(scenario: ResilienceScenario) => {
226+
const commandsToRun = COMMANDS_UNDER_TEST.filter(
227+
(cmd) => !shouldSkip(scenario, cmd),
228+
);
229+
const commandsToSkip = COMMANDS_UNDER_TEST.filter((cmd) =>
230+
shouldSkip(scenario, cmd),
231+
);
232+
233+
if (commandsToSkip.length > 0) {
234+
it.skip.each(commandsToSkip)('"%s" (not yet consistent)', () => {});
235+
}
236+
237+
it.each(commandsToRun)('"%s"', async (command) => {
238+
const ctx: ScenarioContext = { server };
239+
const requiresConfigRestore = command.startsWith('auth');
240+
241+
try {
242+
if (requiresConfigRestore) {
243+
ctx.savedConfig = await getCliConfig();
244+
}
245+
246+
await scenario.setup(ctx);
247+
const env = { ...baseEnv, ...scenario.envOverrides };
248+
249+
const startTime = Date.now();
250+
const { code, stdout } = await runSnykCLI(command, { env });
251+
const duration = Date.now() - startTime;
252+
253+
// Common assertions
254+
expect(code).toEqual(scenario.expectedExitCode);
255+
expect(stdout).toContain(scenario.expectedErrorCode);
256+
257+
// Scenario-specific assertions
258+
if (scenario.assert) {
259+
scenario.assert({
260+
server,
261+
result: { code, stdout, duration },
262+
});
263+
}
264+
} finally {
265+
server.restore();
266+
if (scenario.teardown) {
267+
await scenario.teardown(ctx);
268+
}
269+
if (requiresConfigRestore && ctx.savedConfig) {
270+
await restoreCliConfig(ctx.savedConfig);
271+
}
272+
}
273+
});
274+
},
275+
);
276+
});

0 commit comments

Comments
 (0)