001/*-------------------------------------------------------------------------+ 002| | 003| Copyright 2005-2011 The ConQAT Project | 004| | 005| Licensed under the Apache License, Version 2.0 (the "License"); | 006| you may not use this file except in compliance with the License. | 007| You may obtain a copy of the License at | 008| | 009| http://www.apache.org/licenses/LICENSE-2.0 | 010| | 011| Unless required by applicable law or agreed to in writing, software | 012| distributed under the License is distributed on an "AS IS" BASIS, | 013| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 014| See the License for the specific language governing permissions and | 015| limitations under the License. | 016+-------------------------------------------------------------------------*/ 017package eu.cqse.check.framework.checktest; 018 019import static org.assertj.core.api.Assertions.assertThat; 020import static org.assertj.core.api.Assertions.fail; 021 022import java.io.File; 023import java.io.IOException; 024import java.lang.reflect.InvocationTargetException; 025import java.util.HashMap; 026import java.util.HashSet; 027import java.util.List; 028import java.util.Map; 029import java.util.Set; 030import java.util.stream.Collectors; 031 032import org.assertj.core.api.Assertions; 033import org.conqat.lib.commons.collections.CollectionUtils; 034import org.conqat.lib.commons.filesystem.ClassPathUtils; 035import org.conqat.lib.commons.filesystem.FileSystemUtils; 036import org.conqat.lib.commons.resources.Resource; 037import org.conqat.lib.commons.string.StringUtils; 038import org.conqat.lib.commons.test.ExpectedDataContainer; 039import org.conqat.lib.commons.test.ExpectedResourceSource; 040import org.junit.jupiter.api.BeforeEach; 041import org.junit.jupiter.api.TestInstance; 042import org.junit.jupiter.params.ParameterizedTest; 043 044import eu.cqse.check.framework.core.CheckException; 045import eu.cqse.check.framework.core.CheckInfo; 046import eu.cqse.check.framework.core.CheckInstance; 047import eu.cqse.check.framework.core.option.CheckOptionWrapper; 048import eu.cqse.check.framework.core.phase.IExtractedValue; 049import eu.cqse.check.framework.core.phase.IGlobalExtractionPhase; 050import eu.cqse.check.framework.core.registry.CheckRegistry; 051import eu.cqse.check.framework.scanner.ELanguage; 052 053/** 054 * Parameterized test for check implementations. This base class is based on a 055 * mockup of the teamscale infrastructure used in production. 056 */ 057@TestInstance(TestInstance.Lifecycle.PER_CLASS) 058public abstract class CheckTestBase { 059 060 /** 061 * We have to load Check classes into the static singleton 062 * {@link CheckRegistry#getInstance()}. This field is to remember which we 063 * already have loaded and to avoid "is already registered" warnings. 064 */ 065 private static final HashSet<String> LOADED_DIRECTORIES = new HashSet<>(); 066 067 /** Extension of the check parameter files. */ 068 private static final String PARAMETERS_EXTENSION = "parameters"; 069 070 /** Maps simple class names to the corresponding check info. */ 071 protected Map<String, CheckInfo> checkInfoBySimpleClassName; 072 073 protected final Class<?> markerClass; 074 075 protected CheckTestBase(Class<?> markerClass) { 076 this.markerClass = markerClass; 077 } 078 079 @BeforeEach 080 protected void setUpCheckInfoMap() throws IOException { 081 if (checkInfoBySimpleClassName == null) { 082 checkInfoBySimpleClassName = buildCheckInfoMap(markerClass); 083 } 084 } 085 086 /** 087 * Run test by parsing whole file and checking against reference. 088 * 089 * @param expectedDataContainer 090 * Container holding an expected resource and their associated code 091 * and parameter resources. 092 * test-data/eu.cqse.check/./abap/AbapHardCodedUsernameCheck/hard_coded_username.abap.expected 093 */ 094 @ParameterizedTest(name = "{0}") 095 @ExpectedResourceSource 096 void test(ExpectedDataContainer expectedDataContainer) throws CheckException { 097 098 // simple check name is the name of the parent directory 099 Resource expectedFile = expectedDataContainer.getExpectedResource(); 100 String checkSimpleName = determineCheckNameFromPath(expectedFile); 101 Resource parametersResource = expectedDataContainer.getTestResources().stream() 102 .filter(resource -> resource.hasExtension(PARAMETERS_EXTENSION)).findFirst().orElse(null); 103 Map<String, Object> optionValues = parseParameterFile(checkSimpleName, parametersResource, 104 checkInfoBySimpleClassName); 105 106 runCheckAndAssertFindings(checkSimpleName, expectedDataContainer, optionValues, checkInfoBySimpleClassName); 107 } 108 109 /** 110 * Determines which check should be executed with the given expected file. 111 * Searches backwards from the end of the path. The first folder name that 112 * equals the simple name of a custom check class determines which check is 113 * executed. 114 */ 115 private String determineCheckNameFromPath(Resource expectedFile) throws CheckException { 116 String[] segments = FileSystemUtils.getPathSegments(expectedFile.getPath()); 117 for (int i = segments.length - 2; i >= 0; i--) { 118 if (checkInfoBySimpleClassName.containsKey(segments[i])) { 119 return segments[i]; 120 } 121 } 122 throw new CheckException("Could not determine check name from path " + expectedFile.getPath() 123 + ". One folder name in the path must be equal to the check's simple class name."); 124 } 125 126 /** 127 * Determines the language to use for the test file based on the 128 * {@link ELanguage}s of the check and the ending of the uniform path. 129 */ 130 public static ELanguage determineLanguage(CheckInfo checkInfo, String uniformPath) { 131 Set<ELanguage> matchingLanguages = CollectionUtils 132 .intersectionSet(ELanguage.getAllLanguagesForPath(uniformPath), checkInfo.getSupportedLanguages()); 133 if (matchingLanguages.size() != 1) { 134 Assertions.assertThat(checkInfo.getSupportedLanguages().size()).isEqualTo(1).withFailMessage( 135 "Languages for check test filename extension couldn't be uniquely determined, but the check supports multiple languages."); 136 return checkInfo.getSupportedLanguages().iterator().next(); 137 } 138 return matchingLanguages.iterator().next(); 139 } 140 141 /** 142 * Builds a map from simple class names to check infos. The simple class name 143 * must be unique. The rationale here is avoid classes with same name in 144 * different packages (confusing) but also make the test-data more readable. The 145 * clazz will be used to resolve the classpath to search for checks. 146 */ 147 public static Map<String, CheckInfo> buildCheckInfoMap(Class<?> clazz) throws IOException { 148 // we expect the checks to reside in the same class folder as the tests 149 String classPath = ClassPathUtils.createClassPath(null, clazz); 150 if (!LOADED_DIRECTORIES.contains(classPath)) { 151 if (StringUtils.endsWithOneOf(classPath, ".jar")) { 152 CheckRegistry.getInstance().loadChecksFromJar(new File(classPath)); 153 } 154 CheckRegistry.getInstance().registerChecksFromClasspathDirectory(new File(classPath)); 155 LOADED_DIRECTORIES.add(classPath); 156 } 157 Map<String, CheckInfo> checkInfoBySimpleClassName = new HashMap<>(); 158 for (CheckInfo checkInfo : CheckRegistry.getInstance().getChecksInfos()) { 159 if (checkInfoBySimpleClassName.put(checkInfo.getSimpleClassName(), checkInfo) != null) { 160 fail("Duplicate simple class name: " + checkInfo.getSimpleClassName()); 161 } 162 } 163 return checkInfoBySimpleClassName; 164 } 165 166 /** 167 * Parses the check parameter files if it is available and return a map of 168 * parameter name to parameter values. 169 */ 170 private static Map<String, Object> parseParameterFile(String checkSimpleName, Resource parameterFile, 171 Map<String, CheckInfo> checkInfoBySimpleClassName) { 172 Map<String, Object> optionValues = new HashMap<>(); 173 174 if (parameterFile != null) { 175 CheckInfo check = checkInfoBySimpleClassName.get(checkSimpleName); 176 Map<String, CheckOptionWrapper<?>> options = check.getOptions(); 177 List<String> lines = parameterFile.getLines(); 178 for (String line : lines) { 179 String[] parts = line.split(":", 2); 180 String parameterName = parts[0].trim(); 181 String parameterValue = parts[1].trim(); 182 if (!options.containsKey(parameterName)) { 183 Assertions.fail("Unknown parameter name " + parameterName + " in parameters file: " 184 + parameterFile.getPath()); 185 } 186 CheckOptionWrapper<?> checkOption = options.get(parameterName); 187 optionValues.put(parameterName, parseCheckOptionValue(checkOption.getType(), parameterValue)); 188 189 } 190 } 191 return optionValues; 192 } 193 194 /** Parses a check option value of the given type from a String */ 195 private static Object parseCheckOptionValue(Class<?> type, String parameterValue) { 196 if (type == String.class) { 197 return parameterValue; 198 } else if (type == Integer.class) { 199 return Integer.parseInt(parameterValue); 200 } else if (type == Boolean.class) { 201 return Boolean.valueOf(parameterValue); 202 } else if (type == List.class) { 203 return StringUtils.splitWithEscapeCharacter(parameterValue, ","); 204 } else if (type == Set.class) { 205 List<String> parts = StringUtils.splitWithEscapeCharacter(parameterValue, ","); 206 return new HashSet<>(parts); 207 } else { 208 throw new IllegalStateException("Check option type not supported: " + type); 209 } 210 } 211 212 /** 213 * Asserts that the check executed on the given code file returns the findings 214 * as described by the findings string. 215 */ 216 protected void runCheckAndAssertFindings(String checkSimpleName, ExpectedDataContainer expectedDataContainer, 217 Map<String, Object> options, Map<String, CheckInfo> checkInfoBySimpleClassName) throws CheckException { 218 runCheckWithMockupContextAndAssertFindings(checkSimpleName, expectedDataContainer, options, 219 checkInfoBySimpleClassName); 220 } 221 222 /** 223 * Asserts that the check executed on the given code returns the findings as 224 * described by the findings string. 225 */ 226 private static void runCheckWithMockupContextAndAssertFindings(String checkSimpleName, 227 ExpectedDataContainer expectedDataContainer, Map<String, Object> options, 228 Map<String, CheckInfo> checkInfoBySimpleClassName) throws CheckException { 229 List<Resource> parsableFiles = CollectionUtils.filter(expectedDataContainer.getTestResources(), 230 file -> ELanguage.fromResource(file) != null); 231 232 runCheckWithMockupContextAndAssertFindings(checkSimpleName, expectedDataContainer, checkInfoBySimpleClassName, 233 options, parsableFiles); 234 } 235 236 /** 237 * Asserts that the check executed on the given code returns the findings as 238 * described by the findings string. 239 */ 240 private static void runCheckWithMockupContextAndAssertFindings(String checkSimpleName, 241 ExpectedDataContainer expectedDataContainer, Map<String, CheckInfo> checks, Map<String, Object> options, 242 List<Resource> allFiles) throws CheckException { 243 CheckInfo checkInfo = checks.get(checkSimpleName); 244 TestCheckContext context = generateCheckContext(expectedDataContainer.getPrimaryTestResource(), checkInfo); 245 for (Class<? extends IGlobalExtractionPhase<?, ?>> phase : checkInfo.getRequiredPhases()) { 246 preloadPhase(phase, allFiles, context); 247 } 248 runCheckWithMockupContextAndAssertFindings(checkSimpleName, 249 expectedDataContainer.getExpectedResource().getContent(), options, context); 250 } 251 252 /** 253 * Creates a {@link TestCheckContext} that is provided to the tested 254 * custom-check instance. 255 */ 256 private static TestCheckContext generateCheckContext(Resource code, CheckInfo checkInfo) { 257 return new TestCheckContext(code.getPath(), code.getContent(), checkInfo); 258 } 259 260 @SuppressWarnings("unchecked") 261 private static void preloadPhase(Class<? extends IGlobalExtractionPhase<?, ?>> phase, List<Resource> resources, 262 TestCheckContext context) throws CheckException { 263 IGlobalExtractionPhase<?, ?> phaseInstance; 264 try { 265 phaseInstance = phase.getConstructor().newInstance(); 266 } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException 267 | NoSuchMethodException | SecurityException e) { 268 throw new CheckException("Could not initialize phase " + phase, e); 269 } 270 271 Map<String, List<IExtractedValue<?>>> result = new HashMap<>(); 272 for (Resource resource : resources) { 273 TestTokenElementContext phaseContext = new TestTokenElementContext(resource, 274 determineLanguage(context.getCheckInfo(), resource.getPath())); 275 List<IExtractedValue<?>> values = (List<IExtractedValue<?>>) phaseInstance.extract(phaseContext); 276 result.put(phaseContext.getUniformPath(), values); 277 } 278 context.addPhaseResult(phase, result); 279 280 if (phaseInstance.needsAccessUniformPathByValue()) { 281 Map<String, List<IExtractedValue<?>>> byValue = result.values().stream().flatMap(List::stream) 282 .collect(Collectors.groupingBy(IExtractedValue::getValue)); 283 context.addInvertedPhaseResult(phase, byValue); 284 } 285 } 286 287 /** 288 * Asserts that the check executed on the given code returns the findings as 289 * described by the findings string. 290 */ 291 private static void runCheckWithMockupContextAndAssertFindings(String checkSimpleName, 292 String expectedFindingsString, Map<String, Object> options, TestCheckContext context) 293 throws CheckException { 294 assertThat(context.getCheckInfo()).as("No check with simple class name " + checkSimpleName + " found!") 295 .isNotNull(); 296 297 CheckInstance instance = new CheckInstance(context.getCheckInfo()); 298 for (String optionName : options.keySet()) { 299 instance.setOption(optionName, options.get(optionName)); 300 } 301 instance.initializeAndSetContext(context); 302 instance.execute(); 303 304 String actualFindings = sortFindings(context.getFindingsString()); 305 String expectedFindings = sortFindings(expectedFindingsString); 306 307 assertThat(actualFindings).as("Unexpected check result for " + context.getUniformPath()) 308 .isEqualTo(expectedFindings); 309 } 310 311 /** 312 * Sorts the lines within the findings to stay independent of finding creation 313 * order. 314 */ 315 private static String sortFindings(String findingsText) { 316 return StringUtils.concat( 317 CollectionUtils.sort(StringUtils 318 .splitLinesAsList(StringUtils.normalizeLineSeparatorsPlatformSpecific(findingsText))), 319 StringUtils.LINE_SEPARATOR); 320 } 321}