Skip to content

Commit 07a89e6

Browse files
committed
chore: add resilience-test suite and remove individual test
1 parent de24e31 commit 07a89e6

4 files changed

Lines changed: 269 additions & 206 deletions

File tree

test/acceptance/fake-server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const featureFlagDefaults = (): Map<string, boolean> => {
3333
['sbomTestReachability', false],
3434
['useTestShimForOSCliTest', false],
3535
['cliDotnetRuntimeResolution', false],
36+
['isSecretsEnabled', true],
3637
]);
3738
};
3839

test/jest/acceptance/maintenance.spec.ts

Lines changed: 0 additions & 103 deletions
This file was deleted.
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
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 }) => {
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+
envOverrides: {
120+
SNYK_TIMEOUT_SECS: String(TIMEOUT_SECS),
121+
},
122+
skip: ['container sbom scratch'],
123+
},
124+
125+
// Scenario 3
126+
{
127+
name: 'unauthorized-401',
128+
description: 'Backend returns 401 Unauthorized',
129+
setup: ({ server }) => {
130+
server.setGlobalResponse(
131+
{
132+
jsonapi: { version: '1.0' },
133+
errors: [new Snyk.UnauthorisedError('').toJsonApiErrorObject()],
134+
},
135+
401,
136+
);
137+
},
138+
expectedExitCode: EXIT_CODES.ERROR,
139+
expectedErrorCode: 'SNYK-0005',
140+
skip: [
141+
'container sbom scratch',
142+
'container test scratch',
143+
'container monitor scratch',
144+
'iac test',
145+
'secrets test',
146+
'auth', // auth doesn't need to
147+
],
148+
},
149+
150+
// Scenario 4
151+
{
152+
name: 'mid-execution-maintenance',
153+
description: 'Backend enters maintenance after initial successful requests',
154+
setup: ({ server }) => {
155+
const maintenanceErrorRes = {
156+
jsonapi: { version: '1.0' },
157+
errors: [new Snyk.MaintenanceWindowError('').toJsonApiErrorObject()],
158+
description: 'Maintenance window',
159+
};
160+
161+
// First request succeeds, subsequent requests hit maintenance
162+
server.setNextStatusCode(200);
163+
server.setNextStatusCode(200);
164+
server.setNextStatusCode(200);
165+
server.setNextStatusCode(200);
166+
server.setGlobalResponse(
167+
maintenanceErrorRes,
168+
parseInt(maintenanceErrorRes.errors[0].status),
169+
);
170+
},
171+
expectedExitCode: EXIT_CODES.EX_TEMPFAIL,
172+
expectedErrorCode: 'SNYK-0099',
173+
skip: [
174+
'whoami', // Single-request commands won't hit the failure
175+
'auth', // Single-request commands won't hit the failure
176+
'container monitor scratch',
177+
],
178+
},
179+
];
180+
181+
function shouldSkip(scenario: ResilienceScenario, command: string): boolean {
182+
if (!scenario.skip) return false;
183+
return scenario.skip.some((skip) => command.startsWith(skip));
184+
}
185+
186+
describe('Resilience - Consistent CLI Behavior', () => {
187+
let server: ReturnType<typeof fakeServer>;
188+
let baseEnv: Record<string, string>;
189+
190+
beforeAll(async () => {
191+
const ipAddr = getFirstIPv4Address();
192+
const port = await getAvailableServerPort(process);
193+
const baseApi = '/api/v1';
194+
195+
baseEnv = {
196+
...process.env,
197+
SNYK_API: 'http://' + ipAddr + ':' + port + baseApi,
198+
SNYK_TOKEN: '123456789',
199+
SNYK_HTTP_PROTOCOL_UPGRADE: '0',
200+
SNYK_CFG_ORG: FAKE_ORG,
201+
};
202+
203+
server = fakeServer(baseApi, baseEnv.SNYK_TOKEN);
204+
await server.listenPromise(port);
205+
});
206+
207+
afterEach(() => {
208+
server.restore();
209+
});
210+
211+
afterAll(async () => {
212+
await server.closePromise();
213+
});
214+
215+
describe.each(RESILIENCE_SCENARIOS)(
216+
'$name: $description',
217+
(scenario: ResilienceScenario) => {
218+
const commandsToRun = COMMANDS_UNDER_TEST.filter(
219+
(cmd) => !shouldSkip(scenario, cmd),
220+
);
221+
const commandsToSkip = COMMANDS_UNDER_TEST.filter((cmd) =>
222+
shouldSkip(scenario, cmd),
223+
);
224+
225+
if (commandsToSkip.length > 0) {
226+
it.skip.each(commandsToSkip)('"%s" (not yet consistent)', () => {});
227+
}
228+
229+
it.each(commandsToRun)('"%s"', async (command) => {
230+
const ctx: ScenarioContext = { server };
231+
const requiresConfigRestore = command.startsWith('auth');
232+
233+
try {
234+
if (requiresConfigRestore) {
235+
ctx.savedConfig = await getCliConfig();
236+
}
237+
238+
await scenario.setup(ctx);
239+
const env = { ...baseEnv, ...scenario.envOverrides };
240+
241+
const startTime = Date.now();
242+
const { code, stdout } = await runSnykCLI(command, { env });
243+
const duration = Date.now() - startTime;
244+
245+
// Common assertions
246+
expect(code).toEqual(scenario.expectedExitCode);
247+
expect(stdout).toContain(scenario.expectedErrorCode);
248+
249+
// Scenario-specific assertions
250+
if (scenario.assert) {
251+
scenario.assert({
252+
server,
253+
result: { code, stdout, duration },
254+
});
255+
}
256+
} finally {
257+
server.restore();
258+
if (scenario.teardown) {
259+
await scenario.teardown(ctx);
260+
}
261+
if (requiresConfigRestore && ctx.savedConfig) {
262+
await restoreCliConfig(ctx.savedConfig);
263+
}
264+
}
265+
});
266+
},
267+
);
268+
});

0 commit comments

Comments
 (0)