|
| 1 | +// The fake server answers the batch flag evaluation endpoint, so the remote flag path is |
| 2 | +// testable without real backend access. |
| 3 | +import { runSnykCLI } from '../../util/runSnykCLI'; |
| 4 | +import { runCommand } from '../../util/runCommand'; |
| 5 | +import { fakeServer } from '../../../acceptance/fake-server'; |
| 6 | +import { fakeDeepCodeServer } from '../../../acceptance/deepcode-fake-server'; |
| 7 | +import { getServerPort } from '../../util/getServerPort'; |
| 8 | +import * as fs from 'fs'; |
| 9 | +import * as os from 'os'; |
| 10 | +import { join } from 'path'; |
| 11 | + |
| 12 | +jest.setTimeout(1000 * 120); |
| 13 | + |
| 14 | +const REMOTE_FLAG_NAME = 'clientFileFilterGitignore_TrackedFilesRollout'; |
| 15 | +const FLAG_ENV = 'INTERNAL_SNYK_GITIGNORE_RESPECT_TRACKED_FILES_ENABLED'; |
| 16 | +const PREVIEW_ENV = 'INTERNAL_PREVIEW_FEATURES_ENABLED'; |
| 17 | +const EVALUATION_PATH = '/feature_flags/evaluation'; |
| 18 | + |
| 19 | +describe('snyk code test — tracked-file feature flag wiring', () => { |
| 20 | + let server: ReturnType<typeof fakeServer>; |
| 21 | + let deepCodeServer: ReturnType<typeof fakeDeepCodeServer>; |
| 22 | + let baseEnv: Record<string, string>; |
| 23 | + const port = getServerPort(process); |
| 24 | + const baseApi = '/api/v1'; |
| 25 | + |
| 26 | + beforeAll(async () => { |
| 27 | + deepCodeServer = fakeDeepCodeServer(); |
| 28 | + await new Promise<void>((resolve) => |
| 29 | + deepCodeServer.listen(() => resolve()), |
| 30 | + ); |
| 31 | + server = fakeServer(baseApi, 'snykToken'); |
| 32 | + await new Promise<void>((resolve) => server.listen(port, () => resolve())); |
| 33 | + |
| 34 | + baseEnv = { |
| 35 | + ...process.env, |
| 36 | + SNYK_API: `http://localhost:${port}${baseApi}`, |
| 37 | + SNYK_HOST: `http://localhost:${port}`, |
| 38 | + SNYK_TOKEN: '123456789', |
| 39 | + SNYK_CFG_ORG: '11111111-2222-3333-4444-555555555555', |
| 40 | + INTERNAL_SNYK_CODE_NATIVE_IMPLEMENTATION: 'true', |
| 41 | + // Preview/dev builds force the flag on (cliv2/pkg/core/workflows.go), bypassing |
| 42 | + // the remote flag. Without this, every assertion below is vacuous. |
| 43 | + [PREVIEW_ENV]: 'false', |
| 44 | + } as Record<string, string>; |
| 45 | + // The local override must be absent, otherwise the remote flag is never consulted. |
| 46 | + delete baseEnv[FLAG_ENV]; |
| 47 | + }); |
| 48 | + |
| 49 | + afterAll(async () => { |
| 50 | + await new Promise<void>((resolve) => deepCodeServer.close(() => resolve())); |
| 51 | + await new Promise<void>((resolve) => server.close(() => resolve())); |
| 52 | + }); |
| 53 | + |
| 54 | + function configureServers(remoteFlagValue: boolean | undefined) { |
| 55 | + server.restore(); |
| 56 | + deepCodeServer.restore(); |
| 57 | + server.setOrgSetting('sast', true); |
| 58 | + server.setLocalCodeEngineConfiguration({ |
| 59 | + enabled: true, |
| 60 | + allowCloudUpload: true, |
| 61 | + url: `http://localhost:${deepCodeServer.getPort()}`, |
| 62 | + }); |
| 63 | + deepCodeServer.setFiltersResponse({ configFiles: [], extensions: ['.js'] }); |
| 64 | + deepCodeServer.setSarifResponse({ |
| 65 | + $schema: 'https://json.schemastore.org/sarif-2.1.0.json', |
| 66 | + version: '2.1.0', |
| 67 | + runs: [], |
| 68 | + }); |
| 69 | + // Preview builds force this on too; enable it here so rule parsing matches |
| 70 | + // production rather than falling back to the legacy parser. |
| 71 | + server.setFeatureFlag('clientFileFilterGitignore_MetaCharFix', true); |
| 72 | + if (remoteFlagValue !== undefined) { |
| 73 | + server.setFeatureFlag(REMOTE_FLAG_NAME, remoteFlagValue); |
| 74 | + } |
| 75 | + } |
| 76 | + |
| 77 | + /** A repo where tracked.js is both git-tracked and matched by .gitignore. */ |
| 78 | + async function buildFixture(): Promise<string> { |
| 79 | + const root = fs.mkdtempSync(join(os.tmpdir(), 'snyk-ff-')); |
| 80 | + fs.writeFileSync(join(root, 'control.js'), 'const c = 0;\n'); |
| 81 | + fs.writeFileSync(join(root, 'tracked.js'), 'const a = 1;\n'); |
| 82 | + fs.writeFileSync(join(root, '.gitignore'), 'tracked.js\n'); |
| 83 | + await runCommand('git', ['init'], { cwd: root }); |
| 84 | + await runCommand('git', ['add', '-f', 'tracked.js'], { cwd: root }); |
| 85 | + return root; |
| 86 | + } |
| 87 | + |
| 88 | + function uploadedFiles(): string[] { |
| 89 | + const bundleRequest = deepCodeServer |
| 90 | + .getRequests() |
| 91 | + .find( |
| 92 | + (r) => r.method === 'POST' && (r.url as string).includes('/bundle'), |
| 93 | + ); |
| 94 | + if (!bundleRequest) return []; |
| 95 | + const raw = Buffer.isBuffer(bundleRequest.body) |
| 96 | + ? Buffer.from(bundleRequest.body.toString('utf8'), 'base64').toString( |
| 97 | + 'utf8', |
| 98 | + ) |
| 99 | + : JSON.stringify(bundleRequest.body); |
| 100 | + return Object.keys(JSON.parse(raw)).sort(); |
| 101 | + } |
| 102 | + |
| 103 | + /** The flags the CLI asked the backend to evaluate. */ |
| 104 | + function evaluatedFlags(): string[] { |
| 105 | + return server |
| 106 | + .getRequests() |
| 107 | + .filter((r) => (r.url as string).includes(EVALUATION_PATH)) |
| 108 | + .flatMap((r) => r.body?.data?.attributes?.flags ?? []); |
| 109 | + } |
| 110 | + |
| 111 | + async function scan(opts: { |
| 112 | + remoteFlag?: boolean; |
| 113 | + envOverride?: boolean; |
| 114 | + previewFeatures?: boolean; |
| 115 | + }): Promise<{ files: string[]; flags: string[]; code: number }> { |
| 116 | + configureServers(opts.remoteFlag); |
| 117 | + const root = await buildFixture(); |
| 118 | + const env = { ...baseEnv }; |
| 119 | + if (opts.envOverride !== undefined) { |
| 120 | + env[FLAG_ENV] = String(opts.envOverride); |
| 121 | + } |
| 122 | + if (opts.previewFeatures) { |
| 123 | + env[PREVIEW_ENV] = 'true'; |
| 124 | + } |
| 125 | + const { code } = await runSnykCLI(`code test ${root}`, { env }); |
| 126 | + return { files: uploadedFiles(), flags: evaluatedFlags(), code }; |
| 127 | + } |
| 128 | + |
| 129 | + it('asks the backend to evaluate the tracked-files flag by its remote name', async () => { |
| 130 | + const { flags } = await scan({ remoteFlag: true }); |
| 131 | + |
| 132 | + // A wrong mapping here would leave a dead rollout switch that no behaviour test |
| 133 | + // would catch. |
| 134 | + expect(flags).toContain(REMOTE_FLAG_NAME); |
| 135 | + }); |
| 136 | + |
| 137 | + it('scans a tracked, gitignored file when the backend enables the flag', async () => { |
| 138 | + const { files } = await scan({ remoteFlag: true }); |
| 139 | + |
| 140 | + expect(files).toEqual(['control.js', 'tracked.js']); |
| 141 | + }); |
| 142 | + |
| 143 | + it('excludes a tracked, gitignored file when the backend disables the flag', async () => { |
| 144 | + const { files } = await scan({ remoteFlag: false }); |
| 145 | + |
| 146 | + expect(files).toEqual(['control.js']); |
| 147 | + }); |
| 148 | + |
| 149 | + it('defaults to the legacy behaviour when the backend does not know the flag', async () => { |
| 150 | + const { files } = await scan({}); |
| 151 | + |
| 152 | + expect(files).toEqual(['control.js']); |
| 153 | + }); |
| 154 | + |
| 155 | + it('a local override wins over the backend value', async () => { |
| 156 | + // Backend says on, local config says off. |
| 157 | + const off = await scan({ remoteFlag: true, envOverride: false }); |
| 158 | + expect(off.files).toEqual(['control.js']); |
| 159 | + |
| 160 | + // Backend says off, local config says on. |
| 161 | + const on = await scan({ remoteFlag: false, envOverride: true }); |
| 162 | + expect(on.files).toEqual(['control.js', 'tracked.js']); |
| 163 | + }); |
| 164 | + |
| 165 | + describe('preview builds deliberately force the flag on', () => { |
| 166 | + // Intended behaviour. The consequence worth pinning: a preview binary cannot |
| 167 | + // validate the backend rollout switch. |
| 168 | + it('activates the feature even when the backend disables it', async () => { |
| 169 | + const { files } = await scan({ |
| 170 | + remoteFlag: false, |
| 171 | + previewFeatures: true, |
| 172 | + }); |
| 173 | + |
| 174 | + expect(files).toEqual(['control.js', 'tracked.js']); |
| 175 | + }); |
| 176 | + |
| 177 | + it('does not even ask the backend to evaluate the flag', async () => { |
| 178 | + const { flags } = await scan({ |
| 179 | + remoteFlag: false, |
| 180 | + previewFeatures: true, |
| 181 | + }); |
| 182 | + |
| 183 | + expect(flags).not.toContain(REMOTE_FLAG_NAME); |
| 184 | + }); |
| 185 | + |
| 186 | + it('still lets a local override turn the feature off', async () => { |
| 187 | + const { files, flags } = await scan({ |
| 188 | + remoteFlag: true, |
| 189 | + previewFeatures: true, |
| 190 | + envOverride: false, |
| 191 | + }); |
| 192 | + |
| 193 | + expect(files).toEqual(['control.js']); |
| 194 | + // An override short-circuits the default-value function, so setting the key by |
| 195 | + // hand cannot be used to test the remote flag either. |
| 196 | + expect(flags).not.toContain(REMOTE_FLAG_NAME); |
| 197 | + }); |
| 198 | + }); |
| 199 | +}); |
0 commit comments