diff --git a/pom.xml b/pom.xml index 066ed62b60..602322ca45 100644 --- a/pom.xml +++ b/pom.xml @@ -161,6 +161,7 @@ 2.8.13 6.2.17 0.20.0 + 1.4.1 3.13.1 26.5.6 1.3.1 @@ -516,6 +517,12 @@ ${equalsverifier.version} test + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + test + org.wiremock wiremock-standalone diff --git a/rest/resource-server/pom.xml b/rest/resource-server/pom.xml index be79272bf5..085f5e5718 100644 --- a/rest/resource-server/pom.xml +++ b/rest/resource-server/pom.xml @@ -168,6 +168,11 @@ junit-vintage-engine test + + com.tngtech.archunit + archunit-junit5 + test + diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ARCHUNIT_TEST_SUMMARY.md b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ARCHUNIT_TEST_SUMMARY.md new file mode 100644 index 0000000000..8e4ea42e69 --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ARCHUNIT_TEST_SUMMARY.md @@ -0,0 +1,257 @@ + + +# SW360 ArchUnit Test Summary + +> **Last Updated:** April 7, 2026 +> **Module:** `rest/resource-server` + +This document provides a comprehensive overview of all ArchUnit architecture tests in the SW360 REST resource-server module. These tests enforce architectural patterns, coding standards, and best practices. + +--- + +## Test Suite Overview + +| Test Suite | Test Count | Purpose | +|------------|------------|---------| +| [Controller Annotation Rules](#1-controller-annotation-rules) | 6 | Validates REST controller annotations | +| [Controller-Service Relationship Rules](#2-controller-service-relationship-rules) | 3 | Ensures proper controller-service patterns | +| [Dependency Injection Rules](#3-dependency-injection-rules) | 2 | Enforces constructor injection | +| [Layered Architecture Rules](#4-layered-architecture-rules) | 3 | Maintains layer separation | +| [Logging Standard Rules](#5-logging-standard-rules) | 5 | Enforces Log4j2 logging standards | +| [Naming Convention Rules](#6-naming-convention-rules) | 5 | Validates class naming patterns | +| [OpenAPI Documentation Rules](#7-openapi-documentation-rules) | 2 | Ensures OpenAPI documentation | +| [Package Structure Rules](#8-package-structure-rules) | 5 | Enforces package organization | +| [Security Annotation Rules](#9-security-annotation-rules) | 4 | Validates security annotations | +| [Spring Framework Rules](#10-spring-framework-rules) | 7 | Enforces Spring best practices | +| [Thrift Service Boundary Rules](#11-thrift-service-boundary-rules) | 3 | Prevents bypassing Thrift layer | +| [Coding Standard Rules](#12-coding-standard-rules) | 6 | General coding standards | +| **Total** | **51** | **Complete architecture validation** | + +--- + +## Detailed Test Descriptions + +### 1. Controller Annotation Rules +**File:** `ControllerAnnotationRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `basePathAwareControllerShouldBeRestController` | Controllers with `@BasePathAwareController` must also have `@RestController` (except `LicenseInfoController`) | +| `controllersShouldHaveSecurityRequirement` | Controllers must declare `@SecurityRequirement` or `@SecurityRequirements` for OpenAPI docs (except `VersionController` and `AttachmentCleanUpController`) | +| `controllersShouldImplementRepresentationModelProcessor` | Controllers must implement `RepresentationModelProcessor` for HAL resource link registration (except `VersionController`) | +| `controllerAdviceShouldResideInCore` | `@ControllerAdvice` exception handlers must reside in the `core` package | +| `controllersShouldNotExtendOtherControllers` | REST controllers should not extend other controllers — prefer composition via service injection | +| `controllersShouldUseHateoasTypes` | REST controllers must depend on Spring HATEOAS types (`EntityModel`, `CollectionModel`, `HalResource`) for HAL+JSON response structure (except `VersionController`) | + +--- + +### 2. Controller-Service Relationship Rules +**File:** `ControllerServiceRelationshipRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `controllersShouldDeclareUrlConstant` | Controllers must declare a static `*_URL` constant for their base path (except `VersionController` and `SW360ConfigurationsController`) | +| `controllersShouldInjectRestControllerHelper` | Controllers must inject `RestControllerHelper` for user authentication and pagination (except `VersionController`) | +| `servicesShouldNotDependOnControllers` | `@Service` classes (except `RestControllerHelper` and `Sw360ProjectService`) must not depend on `@RestController` classes | + +--- + +### 3. Dependency Injection Rules +**File:** `DependencyInjectionRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `serviceClassesShouldNotUseFieldInjection` | `@Service` classes (outside security package) must not use field-level `@Autowired`; prefer constructor injection via `@RequiredArgsConstructor` | +| `springBeansShouldPreferConstructorInjection` | Controllers should not have more than one `@Autowired` field; use `@RequiredArgsConstructor` instead | + +--- + +### 4. Layered Architecture Rules +**File:** `LayerDependencyRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `securityShouldNotDependOnControllers` | Security package must not depend on any domain-specific controller or service package | +| `coreShouldNotDependOnDomainPackages` | Core package (except `JacksonCustomizations`, `RestControllerHelper`, `AwareOfRestServices`, `ThriftServiceProvider`, and custom `Serializer` classes) must not depend on domain-specific packages | +| `filterShouldNotDependOnDomainPackages` | Filter package must not depend on domain-specific packages | + +--- + +### 5. Logging Standard Rules +**File:** `LoggingStandardRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `noClassShouldUseSystemOut` | No class should use `System.out` — use Log4j2 logger instead | +| `noClassShouldUseSystemErr` | No class should use `System.err` — use Log4j2 logger instead | +| `noClassShouldCallPrintStackTrace` | No class should call `printStackTrace()` — use `log.error()` with exception parameter | +| `noClassShouldUseJavaUtilLogging` | No class should use `java.util.logging` — use Log4j2 (`LogManager.getLogger()`) or Lombok `@Slf4j` | +| `noClassShouldUseCommonsLogging` | No class should use Apache Commons Logging directly — use Log4j2 or Lombok `@Slf4j` | + +--- + +### 6. Naming Convention Rules +**File:** `NamingConventionRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `restControllersShouldBeNamedController` | Classes with `@RestController` must have names ending with `Controller` | +| `basePathAwareControllersShouldBeNamedController` | Classes with `@BasePathAwareController` must have names ending with `Controller` | +| `serviceClassesShouldBeNamedWithService` | `@Service` classes in domain packages must have names ending with `Service` or `Services` | +| `configurationClassesShouldBeNamedProperly` | `@Configuration` classes should be named `*Configuration` or `*Customizations` | +| `resourceProcessorsShouldBeNamedProperly` | `ResourceProcessor` classes must be Spring `@Component` beans | + +--- + +### 7. OpenAPI Documentation Rules +**File:** `OpenApiDocumentationRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `endpointMethodsShouldHaveOperationAnnotation` | All REST endpoint methods (`@GetMapping`, `@PostMapping`, etc.) must have `@Operation` annotation for OpenAPI documentation | +| `controllersShouldDeclareSecurityRequirementForSwagger` | REST controllers must declare `@SecurityRequirement` at class or method level (except `VersionController` and `AttachmentCleanUpController`) | + +--- + +### 8. Package Structure Rules +**File:** `PackageStructureRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `controllersShouldResideInDomainPackages` | Controller classes must reside in domain packages (e.g., `..project.ProjectController`), not in the `core` package | +| `serializersShouldResideInCoreSerializerPackage` | Custom JSON serializer classes must reside in the `core.serializer` package | +| `exceptionClassesShouldResideInCore` | Custom exception classes must reside in the `core` package | +| `securityClassesShouldResideInSecurityPackage` | Authentication-related classes must reside in the `security` or `core` package | +| `noClassesShouldDependOnInternalJdkPackages` | No class should depend on internal JDK (`sun..`) packages | + +--- + +### 9. Security Annotation Rules +**File:** `SecurityAnnotationRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `noClassShouldUseSecuredAnnotation` | No class should use deprecated `@Secured` — use `@PreAuthorize` instead | +| `noClassShouldUseRolesAllowedAnnotation` | No class should use `@RolesAllowed` — use `@PreAuthorize` instead | +| `noClassShouldUseDeprecatedMethodSecurityAnnotation` | No class should use deprecated `@EnableGlobalMethodSecurity` — use `@EnableMethodSecurity` (Spring Security 6.x) | +| `preAuthorizeValuesShouldUseKnownAuthorities` | `@PreAuthorize` annotations must only reference known SW360 authorities: `ADMIN`, `WRITE`, or `READ` | + +--- + +### 10. Spring Framework Rules +**File:** `SpringFrameworkRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `noClassShouldUseControllerAnnotation` | No class should use `@Controller` — use `@RestController` instead | +| `springBootApplicationShouldBeInRootPackage` | `@SpringBootApplication` class must reside in the root `resourceserver` package | +| `configurationClassesShouldNotBeServices` | `@Configuration` classes must not also be `@Service` — separate concerns | +| `servicesShouldNotBeControllers` | `@Service` classes must not also be `@RestController` — keep layers separate | +| `componentsShouldNotBeServicesOrControllers` | `@Component` should not also be `@Service` or `@RestController` — use the most specific stereotype | +| `restControllersShouldNotUseResponseBody` | `@RestController` already implies `@ResponseBody` — do not add it explicitly | +| `controllersShouldNotDefineBeans` | `@RestController` classes should not define `@Bean` methods — use `@Configuration` classes for bean definitions | + +--- + +### 11. Thrift Service Boundary Rules +**File:** `ThriftServiceBoundaryRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `restModuleShouldNotAccessDatabaseHandlers` | REST module must not bypass Thrift services to access database handlers directly | +| `restModuleShouldNotAccessRepositories` | REST module must not access backend repository classes in the `datahandler.db` package | +| `restModuleShouldNotAccessCouchDbDirectly` | REST module (except `SW360RestHealthIndicator`) must not access CouchDB/Cloudant client classes directly — use Thrift services via `ThriftClients` | + +--- + +### 12. Coding Standard Rules +**File:** `CodingStandardRulesTest.java` + +| Test Name | Description | +|-----------|-------------| +| `exceptionClassesShouldBeNamedProperly` | Custom exception classes must have names ending with `Exception` | +| `noClassShouldDependOnJavaLangError` | Classes outside `core`, `security` packages and `ResourceProcessor` classes should not depend on `java.lang.Error` subtypes — use specific exception types | +| `interfacesShouldNotHaveIPrefix` | Interfaces should not use `I` prefix — follow Java naming conventions | +| `constantsClassesShouldBeFinal` | Constants classes must be declared `final` (allows empty — validates future additions) | +| `noClassShouldDependOnJavaxServlet` | Use `jakarta.servlet` (not `javax.servlet`) — SW360 runs on Spring Boot 3.x / Jakarta EE | +| `noClassShouldDependOnJavaxAnnotationNullable` | Use `lombok.NonNull` instead of `javax.annotation.Nullable` | + +--- + +## Running the Tests + +### Run All Architecture Tests +```bash +mvn test -pl rest/resource-server -Dtest="*ArchitectureTest,*RulesTest" +``` + +### Run Specific Test Suite +```bash +# Example: Run only Controller Annotation Rules +mvn test -pl rest/resource-server -Dtest="ControllerAnnotationRulesTest" +``` + + +--- + +## Common Exclusions + +Some classes are intentionally excluded from certain rules due to legacy patterns or special requirements: + +| Class | Excluded From | Reason | +|-------|---------------|--------| +| `LicenseInfoController` | Controller annotations | Uses `@BasePathAwareController` without `@RestController` | +| `VersionController` | Security requirements, URL constants | Public endpoint, minimal controller | +| `AttachmentCleanUpController` | Security requirements | Internal admin utility | +| `JacksonCustomizations` | Core-to-domain dependency | Intentionally references domain mixins for JSON serialization | +| `Json*Serializer` | Core-to-domain dependency | Custom serializers in `core.serializer` reference domain controllers for link building | +| `RestControllerHelper` | Service-to-controller dependency | Helper class that bridges layers | +| `Sw360ProjectService` | Service-to-controller dependency | Builds embedded HAL resources referencing controller URLs | +| `SW360RestHealthIndicator` | CouchDB direct access | Needs direct DB connection for health checks | +| `SW360ConfigurationsController` | URL constant | Configuration endpoint | + +--- + +## Adding New ArchUnit Tests + +When adding new architecture rules: + +1. **Create a new test class** extending `SW360ArchitectureTest` +2. **Add `@DisplayName` annotations** for clear test descriptions +3. **Use meaningful test method names** following the pattern: `what + should + constraint` +4. **Update this summary document** with the new test details +5. **Document exclusions** in the test class JavaDoc if needed + +### Example Template +```java +@DisplayName("Your Rule Category") +class YourRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("Clear description of what is being validated") + void descriptiveTestMethodName() { + ArchRule rule = classes() + .that()... + .should()... + .as("Human-readable rule description"); + + rule.check(restClasses); + } +} +``` + +--- + +## Related Documentation + +- [SW360 Backend Instructions](../../../../../../../../.github/instructions/sw360_backend.instructions.md) +- [Git Commit Instructions](../../../../../../../../.github/instructions/git-commit.instructions.md) +- [ArchUnit User Guide](https://www.archunit.org/userguide/html/000_Index.html) + +--- + +**Note:** This summary is regenerated based on actual test files. Keep it up to date when adding or modifying architecture tests. diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/CodingStandardRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/CodingStandardRulesTest.java new file mode 100644 index 0000000000..d998ab5526 --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/CodingStandardRulesTest.java @@ -0,0 +1,112 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * Validates general coding standard rules for the SW360 REST module. + *

+ * These rules enforce structural integrity and code quality patterns: + *

+ */ +@DisplayName("Coding Standard Rules") +class CodingStandardRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("Exception classes should have name ending with 'Exception'") + void exceptionClassesShouldBeNamedProperly() { + ArchRule rule = classes() + .that().areAssignableTo(Exception.class) + .and().resideInAPackage("..rest.resourceserver..") + .and().doNotHaveFullyQualifiedName(Exception.class.getName()) + .should().haveSimpleNameEndingWith("Exception") + .as("Custom exception classes should have name ending with 'Exception'"); + + rule.check(restClasses); + } + + @Test + @DisplayName("No class outside core should depend on java.lang.Error") + void noClassShouldDependOnJavaLangError() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .and().resideOutsideOfPackage("..resourceserver.core..") + .and().resideOutsideOfPackage("..resourceserver.security..") + .and().haveSimpleNameNotEndingWith("ResourceProcessor") + .should().dependOnClassesThat() + .areAssignableTo(java.lang.Error.class) + .as("Classes should not depend on java.lang.Error subtypes — " + + "use specific exception types from the core package"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Interfaces should not have 'I' prefix") + void interfacesShouldNotHaveIPrefix() { + ArchRule rule = noClasses() + .that().areInterfaces() + .and().resideInAPackage("..rest.resourceserver..") + .should().haveSimpleNameStartingWith("I") + .as("Interfaces should not use 'I' prefix — follow Java naming conventions"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Constants classes should be final") + void constantsClassesShouldBeFinal() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Constants") + .and().resideInAPackage("..rest.resourceserver..") + .should().haveModifier(com.tngtech.archunit.core.domain.JavaModifier.FINAL) + .allowEmptyShould(true) + .as("Constants classes should be declared final"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Classes should not depend on removed javax.servlet — use jakarta.servlet instead") + void noClassShouldDependOnJavaxServlet() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .should().dependOnClassesThat() + .resideInAPackage("javax.servlet..") + .as("Use jakarta.servlet (not javax.servlet) — SW360 runs on Spring Boot 3.x / Jakarta EE"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Classes should not depend on javax.annotation (@Nonnull etc.) — use Lombok") + void noClassShouldDependOnJavaxAnnotationNullable() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .should().dependOnClassesThat() + .haveFullyQualifiedName("javax.annotation.Nullable") + .as("Use lombok.NonNull instead of javax.annotation"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ControllerAnnotationRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ControllerAnnotationRulesTest.java new file mode 100644 index 0000000000..45815c293f --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ControllerAnnotationRulesTest.java @@ -0,0 +1,156 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.rest.webmvc.BasePathAwareController; +import org.springframework.web.bind.annotation.RestController; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +/** + * Validates that REST controllers follow SW360's Spring annotation standards. + *

+ * Every REST controller in SW360 must: + *

+ */ +@DisplayName("Controller Annotation Rules") +class ControllerAnnotationRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("Controllers annotated with @BasePathAwareController should also be @RestController") + void basePathAwareControllerShouldBeRestController() { + ArchRule rule = classes() + .that().areAnnotatedWith(BasePathAwareController.class) + .and().doNotHaveSimpleName("LicenseInfoController") + .should().beAnnotatedWith(RestController.class) + .as("@BasePathAwareController classes must also be annotated with @RestController"); + + rule.check(restClasses); + } + + @Test + @DisplayName("REST controllers should be annotated with @SecurityRequirement") + void controllersShouldHaveSecurityRequirement() { + ArchRule rule = classes() + .that().areAnnotatedWith(RestController.class) + .and().areAnnotatedWith(BasePathAwareController.class) + .and().doNotHaveSimpleName("VersionController") + .and().doNotHaveSimpleName("AttachmentCleanUpController") + .should().beAnnotatedWith(SecurityRequirement.class) + .orShould().beAnnotatedWith(io.swagger.v3.oas.annotations.security.SecurityRequirements.class) + .as("All REST controllers with @BasePathAwareController should declare " + + "@SecurityRequirement for OpenAPI documentation"); + + rule.check(restClasses); + } + + @Test + @DisplayName("REST controllers should implement RepresentationModelProcessor") + void controllersShouldImplementRepresentationModelProcessor() { + ArchRule rule = classes() + .that().areAnnotatedWith(RestController.class) + .and().areAnnotatedWith(BasePathAwareController.class) + .and().doNotHaveSimpleName("VersionController") + .should().implement(org.springframework.hateoas.server.RepresentationModelProcessor.class) + .as("All REST controllers should implement RepresentationModelProcessor " + + "for HAL resource link registration"); + + rule.check(restClasses); + } + + @Test + @DisplayName("@ControllerAdvice classes should reside in core package") + void controllerAdviceShouldResideInCore() { + ArchRule rule = classes() + .that().areAnnotatedWith(org.springframework.web.bind.annotation.ControllerAdvice.class) + .should().resideInAPackage("..resourceserver.core..") + .as("@ControllerAdvice exception handlers should reside in the core package"); + + rule.check(restClasses); + } + + @Test + @DisplayName("REST controllers should not extend other controllers") + void controllersShouldNotExtendOtherControllers() { + ArchCondition notExtendAnotherController = + new ArchCondition<>("not extend another @RestController class") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + javaClass.getAllRawSuperclasses().stream() + .filter(superClass -> superClass.isAnnotatedWith(RestController.class)) + .forEach(superClass -> events.add(SimpleConditionEvent.violated( + javaClass, + String.format("%s extends %s which is also a @RestController — " + + "prefer composition via service injection", + javaClass.getSimpleName(), + superClass.getSimpleName())))); + } + }; + + ArchRule rule = classes() + .that().areAnnotatedWith(RestController.class) + .should(notExtendAnotherController) + .as("REST controllers should not extend other controllers — " + + "prefer composition via service injection"); + + rule.check(restClasses); + } + + @Test + @DisplayName("REST controllers should depend on Spring HATEOAS for HAL+JSON responses") + void controllersShouldUseHateoasTypes() { + ArchCondition dependOnHateoas = + new ArchCondition<>("depend on Spring HATEOAS types for HAL+JSON responses") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean usesHateoas = javaClass.getDirectDependenciesFromSelf().stream() + .anyMatch(dep -> + dep.getTargetClass().getPackageName() + .startsWith("org.springframework.hateoas") + || dep.getTargetClass().getSimpleName() + .equals("HalResource")); + + if (!usesHateoas) { + events.add(SimpleConditionEvent.violated(javaClass, + String.format("%s does not use Spring HATEOAS types " + + "(EntityModel, CollectionModel, HalResource) — " + + "SW360 APIs should return HAL+JSON responses", + javaClass.getSimpleName()))); + } + } + }; + + ArchRule rule = classes() + .that().areAnnotatedWith(RestController.class) + .and().areAnnotatedWith(BasePathAwareController.class) + .and().doNotHaveSimpleName("VersionController") + .should(dependOnHateoas) + .as("REST controllers should use Spring HATEOAS types (EntityModel, CollectionModel, " + + "HalResource) for HAL+JSON response structure"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ControllerServiceRelationshipRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ControllerServiceRelationshipRulesTest.java new file mode 100644 index 0000000000..9298392be1 --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ControllerServiceRelationshipRulesTest.java @@ -0,0 +1,116 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * Validates the SW360 REST module's controller–service–helper class relationships. + *

+ * SW360 REST follows a consistent pattern where: + *

    + *
  • Each domain package has a Controller and a Service class
  • + *
  • Controllers declare a static URL constant (e.g., {@code PROJECTS_URL})
  • + *
  • Controllers inject their corresponding service(s)
  • + *
  • Controllers share common helpers via {@code RestControllerHelper}
  • + *
+ */ +@DisplayName("Controller-Service Relationship Rules") +class ControllerServiceRelationshipRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("Controllers should declare a static URL constant") + void controllersShouldDeclareUrlConstant() { + ArchCondition declareUrlConstant = + new ArchCondition<>("declare a static *_URL constant") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean hasUrlConstant = javaClass.getFields().stream() + .anyMatch(field -> field.getName().endsWith("_URL") + && field.getModifiers().contains( + com.tngtech.archunit.core.domain.JavaModifier.STATIC)); + + if (!hasUrlConstant) { + events.add(SimpleConditionEvent.violated(javaClass, + String.format("%s does not declare a static *_URL constant — " + + "controllers should define their base URL path", + javaClass.getSimpleName()))); + } + } + }; + + ArchRule rule = classes() + .that().areAnnotatedWith(org.springframework.web.bind.annotation.RestController.class) + .and().areAnnotatedWith( + org.springframework.data.rest.webmvc.BasePathAwareController.class) + .and().doNotHaveSimpleName("VersionController") + .and().doNotHaveSimpleName("SW360ConfigurationsController") + .should(declareUrlConstant) + .as("REST controllers should declare a static *_URL constant for their base path"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Controllers should inject RestControllerHelper") + void controllersShouldInjectRestControllerHelper() { + ArchCondition dependOnRestControllerHelper = + new ArchCondition<>("depend on RestControllerHelper") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean hasHelper = javaClass.getFields().stream() + .anyMatch(field -> field.getRawType().getSimpleName() + .equals("RestControllerHelper")); + + if (!hasHelper) { + events.add(SimpleConditionEvent.violated(javaClass, + String.format("%s does not inject RestControllerHelper — " + + "use restControllerHelper.getSw360UserFromAuthentication() " + + "for user resolution", + javaClass.getSimpleName()))); + } + } + }; + + ArchRule rule = classes() + .that().areAnnotatedWith(org.springframework.web.bind.annotation.RestController.class) + .and().areAnnotatedWith( + org.springframework.data.rest.webmvc.BasePathAwareController.class) + .and().doNotHaveSimpleName("VersionController") + .should(dependOnRestControllerHelper) + .as("REST controllers should inject RestControllerHelper for user authentication and pagination"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Services (except RestControllerHelper and Sw360ProjectService) should not depend on controller classes") + void servicesShouldNotDependOnControllers() { + ArchRule rule = noClasses() + .that().areAnnotatedWith(org.springframework.stereotype.Service.class) + .and().doNotHaveSimpleName("RestControllerHelper") + .and().doNotHaveSimpleName("Sw360ProjectService") + .should().dependOnClassesThat() + .areAnnotatedWith(org.springframework.web.bind.annotation.RestController.class) + .as("@Service classes (except RestControllerHelper, Sw360ProjectService) should not have " + + "a direct dependency on @RestController classes — services are a lower layer"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/DependencyInjectionRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/DependencyInjectionRulesTest.java new file mode 100644 index 0000000000..2a97fa3d0c --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/DependencyInjectionRulesTest.java @@ -0,0 +1,80 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.RestController; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noFields; + +/** + * Validates dependency injection patterns in the SW360 REST module. + *

+ * SW360 prefers constructor injection via Lombok's + * {@code @RequiredArgsConstructor} over field-level {@code @Autowired}. + *

+ * Note: Lombok annotations ({@code @RequiredArgsConstructor}, {@code @NonNull}) + * have {@code @Retention(SOURCE)} and are erased after compilation, so ArchUnit + * cannot verify their presence. Instead, we validate the absence of + * field-level {@code @Autowired} as a proxy for constructor injection. + */ +@DisplayName("Dependency Injection Rules") +class DependencyInjectionRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("Service classes should not use field injection with @Autowired") + void serviceClassesShouldNotUseFieldInjection() { + ArchRule rule = noFields() + .that().areDeclaredInClassesThat().areAnnotatedWith(Service.class) + .and().areDeclaredInClassesThat().resideOutsideOfPackage("..resourceserver.security..") + .should().beAnnotatedWith(Autowired.class) + .as("@Service classes (outside security package) should not use field-level @Autowired; " + + "use constructor injection via @RequiredArgsConstructor instead"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Spring beans should prefer constructor injection over field injection") + void springBeansShouldPreferConstructorInjection() { + ArchCondition notHaveTooManyAutowiredFields = + new ArchCondition<>("not have more than one @Autowired field") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + long autowiredFieldCount = javaClass.getFields().stream() + .filter(field -> field.isAnnotatedWith(Autowired.class)) + .count(); + if (autowiredFieldCount > 1) { + events.add(SimpleConditionEvent.violated(javaClass, + String.format("%s has %d @Autowired fields; " + + "consider using @RequiredArgsConstructor", + javaClass.getName(), autowiredFieldCount))); + } + } + }; + + ArchRule rule = classes() + .that().areAnnotatedWith(RestController.class) + .should(notHaveTooManyAutowiredFields) + .as("Controllers should not have multiple @Autowired fields; " + + "use constructor injection via @RequiredArgsConstructor"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/LayerDependencyRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/LayerDependencyRulesTest.java new file mode 100644 index 0000000000..af31b7e416 --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/LayerDependencyRulesTest.java @@ -0,0 +1,118 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * Validates the layered architecture of the SW360 REST resource-server module. + *

+ * The canonical layer flow is: + *

+ *   Controller  →  Service  →  Core / Security / Filter
+ * 
+ * The {@code security} and {@code filter} packages should not depend on specific + * domain packages (project, component, release, etc.). + *

+ * Note: The {@code core} package contains {@code JacksonCustomizations} which + * intentionally references domain-specific mixin types for JSON serialization. + * Therefore core-to-domain dependency checks exclude {@code JacksonCustomizations}. + */ +@DisplayName("Layered Architecture Rules") +class LayerDependencyRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("Security package should not depend on any specific controller") + void securityShouldNotDependOnControllers() { + ArchRule rule = noClasses() + .that().resideInAPackage("..resourceserver.security..") + .should().dependOnClassesThat() + .resideInAnyPackage( + "..resourceserver.project..", + "..resourceserver.component..", + "..resourceserver.release..", + "..resourceserver.license..", + "..resourceserver.vulnerability..", + "..resourceserver.packages..", + "..resourceserver.obligation..", + "..resourceserver.vendor..", + "..resourceserver.attachment..", + "..resourceserver.changelog..", + "..resourceserver.clearingrequest..", + "..resourceserver.moderationrequest..", + "..resourceserver.ecc..", + "..resourceserver.report..", + "..resourceserver.schedule..", + "..resourceserver.search..", + "..resourceserver.department..", + "..resourceserver.databasesanitation..", + "..resourceserver.importexport..", + "..resourceserver.licenseinfo..", + "..resourceserver.admin.." + ) + .as("Security classes should not depend on any domain-specific controller or service package"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Core package (except JacksonCustomizations and RestControllerHelper) should not depend on domain packages") + void coreShouldNotDependOnDomainPackages() { + ArchRule rule = noClasses() + .that().resideInAPackage("..resourceserver.core..") + .and().doNotHaveSimpleName("RestControllerHelper") + .and().doNotHaveSimpleName("AwareOfRestServices") + .and().doNotHaveSimpleName("ThriftServiceProvider") + .and().haveNameNotMatching(".*JacksonCustomizations.*") + .and().haveNameNotMatching(".*Serializer") + .should().dependOnClassesThat() + .resideInAnyPackage( + "..resourceserver.project..", + "..resourceserver.component..", + "..resourceserver.release..", + "..resourceserver.license..", + "..resourceserver.vulnerability..", + "..resourceserver.packages..", + "..resourceserver.obligation..", + "..resourceserver.vendor..", + "..resourceserver.changelog..", + "..resourceserver.clearingrequest..", + "..resourceserver.moderationrequest.." + ) + .as("Core classes (except JacksonCustomizations, RestControllerHelper, AwareOfRestServices, " + + "and custom Serializers) should not depend on domain-specific packages"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Filter package should not depend on domain-specific packages") + void filterShouldNotDependOnDomainPackages() { + ArchRule rule = noClasses() + .that().resideInAPackage("..resourceserver.filter..") + .should().dependOnClassesThat() + .resideInAnyPackage( + "..resourceserver.project..", + "..resourceserver.component..", + "..resourceserver.release..", + "..resourceserver.license..", + "..resourceserver.vulnerability..", + "..resourceserver.packages..", + "..resourceserver.obligation.." + ) + .as("Filter classes should not depend on domain-specific packages"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/LoggingStandardRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/LoggingStandardRulesTest.java new file mode 100644 index 0000000000..c6f9c7a6bf --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/LoggingStandardRulesTest.java @@ -0,0 +1,119 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * Validates that SW360 REST module follows proper logging standards. + *

+ * SW360 uses Log4j2 ({@code LogManager.getLogger()}) or + * Lombok ({@code @Slf4j}) for logging. + * The following are strictly prohibited: + *

    + *
  • {@code System.out.println()} / {@code System.err.println()}
  • + *
  • {@code e.printStackTrace()}
  • + *
  • {@code java.util.logging} (JUL)
  • + *
  • Apache Commons Logging
  • + *
+ */ +@DisplayName("Logging Standard Rules") +class LoggingStandardRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("No class should use System.out") + void noClassShouldUseSystemOut() { + ArchRule rule = noClasses() + .should().accessFieldWhere( + com.tngtech.archunit.core.domain.JavaFieldAccess.Predicates + .target(com.tngtech.archunit.base.DescribedPredicate.describe( + "System.out", + target -> target.getOwner().isEquivalentTo(System.class) + && target.getName().equals("out"))) + ) + .as("No class should use System.out — use Log4j2 logger instead"); + + rule.check(restClasses); + } + + @Test + @DisplayName("No class should use System.err") + void noClassShouldUseSystemErr() { + ArchRule rule = noClasses() + .should().accessFieldWhere( + com.tngtech.archunit.core.domain.JavaFieldAccess.Predicates + .target(com.tngtech.archunit.base.DescribedPredicate.describe( + "System.err", + target -> target.getOwner().isEquivalentTo(System.class) + && target.getName().equals("err"))) + ) + .as("No class should use System.err — use Log4j2 logger instead"); + + rule.check(restClasses); + } + + @Test + @DisplayName("No class should call printStackTrace()") + void noClassShouldCallPrintStackTrace() { + ArchCondition notCallPrintStackTrace = + new ArchCondition<>("not call printStackTrace()") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + javaClass.getCodeUnits().stream() + .flatMap(codeUnit -> codeUnit.getCallsFromSelf().stream()) + .filter(call -> call.getTarget().getName().equals("printStackTrace")) + .forEach(call -> events.add(SimpleConditionEvent.violated( + javaClass, + String.format("%s calls printStackTrace() in %s — " + + "use log.error() with exception parameter instead", + javaClass.getName(), + call.getOrigin().getName())))); + } + }; + + ArchRule rule = noClasses() + .should(notCallPrintStackTrace) + .as("No class should call printStackTrace() — use Log4j2 logger with exception parameter"); + + rule.check(restClasses); + } + + @Test + @DisplayName("No class should use java.util.logging") + void noClassShouldUseJavaUtilLogging() { + ArchRule rule = noClasses() + .should().dependOnClassesThat() + .resideInAPackage("java.util.logging") + .as("No class should use java.util.logging — use Log4j2 (LogManager.getLogger()) " + + "or Lombok @Slf4j instead"); + + rule.check(restClasses); + } + + @Test + @DisplayName("No class should use Commons Logging directly") + void noClassShouldUseCommonsLogging() { + ArchRule rule = noClasses() + .should().dependOnClassesThat() + .resideInAPackage("org.apache.commons.logging") + .as("No class should use Apache Commons Logging directly — " + + "use Log4j2 (LogManager.getLogger()) or Lombok @Slf4j instead"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/NamingConventionRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/NamingConventionRulesTest.java new file mode 100644 index 0000000000..c9cb515ac0 --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/NamingConventionRulesTest.java @@ -0,0 +1,95 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.rest.webmvc.BasePathAwareController; +import org.springframework.web.bind.annotation.RestController; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +/** + * Validates the naming conventions established by the SW360 project. + *

+ * These rules enforce the naming patterns documented in + * {@code .github/instructions/sw360_backend.instructions.md}: + *

    + *
  • Controllers: {@code *Controller.java}
  • + *
  • Services: {@code Sw360*Service.java} or {@code SW360*Service.java}
  • + *
  • Configuration: {@code *Configuration.java} or {@code *Customizations.java}
  • + *
  • Resource Processors: {@code *ResourceProcessor.java}
  • + *
+ */ +@DisplayName("Naming Convention Rules") +class NamingConventionRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("Classes annotated with @RestController should have name ending with 'Controller'") + void restControllersShouldBeNamedController() { + ArchRule rule = classes() + .that().areAnnotatedWith(RestController.class) + .should().haveSimpleNameEndingWith("Controller") + .as("All @RestController classes should be named *Controller"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Classes annotated with @BasePathAwareController should have name ending with 'Controller'") + void basePathAwareControllersShouldBeNamedController() { + ArchRule rule = classes() + .that().areAnnotatedWith(BasePathAwareController.class) + .should().haveSimpleNameEndingWith("Controller") + .as("All @BasePathAwareController classes should be named *Controller"); + + + rule.check(restClasses); + } + + @Test + @DisplayName("Service classes in domain packages should have name ending with 'Service'") + void serviceClassesShouldBeNamedWithService() { + ArchRule rule = classes() + .that().areAnnotatedWith(org.springframework.stereotype.Service.class) + .and().resideOutsideOfPackage("..resourceserver.core..") + .and().resideOutsideOfPackage("..resourceserver.security..") + .should().haveSimpleNameEndingWith("Service") + .orShould().haveSimpleNameEndingWith("Services") + .as("@Service classes in domain packages should have a name ending with 'Service' or 'Services'"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Configuration classes should have name ending with 'Configuration' or 'Customizations'") + void configurationClassesShouldBeNamedProperly() { + ArchRule rule = classes() + .that().areAnnotatedWith(org.springframework.context.annotation.Configuration.class) + .and().resideOutsideOfPackage("..resourceserver") + .should().haveSimpleNameEndingWith("Configuration") + .orShould().haveSimpleNameEndingWith("Customizations") + .as("@Configuration classes should be named *Configuration or *Customizations"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Resource Processors should have name ending with 'ResourceProcessor'") + void resourceProcessorsShouldBeNamedProperly() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("ResourceProcessor") + .should().beAnnotatedWith(org.springframework.stereotype.Component.class) + .as("ResourceProcessor classes should be Spring @Component beans"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/OpenApiDocumentationRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/OpenApiDocumentationRulesTest.java new file mode 100644 index 0000000000..fb1fb00304 --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/OpenApiDocumentationRulesTest.java @@ -0,0 +1,103 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.*; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +/** + * Validates OpenAPI documentation standards in the SW360 REST module. + *

+ * SW360 uses SpringDoc OpenAPI for API documentation. Every public REST + * endpoint should be annotated with {@code @Operation} to ensure + * comprehensive, auto-generated API documentation. + */ +@DisplayName("OpenAPI Documentation Rules") +class OpenApiDocumentationRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("REST endpoint methods should be annotated with @Operation") + void endpointMethodsShouldHaveOperationAnnotation() { + ArchCondition haveOperationAnnotationOnEndpoints = + new ArchCondition<>("have @Operation annotation on all REST endpoint methods") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + javaClass.getMethods().stream() + .filter(method -> + method.isAnnotatedWith(GetMapping.class) + || method.isAnnotatedWith(PostMapping.class) + || method.isAnnotatedWith(PatchMapping.class) + || method.isAnnotatedWith(DeleteMapping.class) + || method.isAnnotatedWith(PutMapping.class)) + .filter(method -> !method.isAnnotatedWith(Operation.class)) + .forEach(method -> events.add(SimpleConditionEvent.violated( + javaClass, + String.format("%s.%s() is a REST endpoint but missing " + + "@Operation annotation for OpenAPI documentation", + javaClass.getSimpleName(), method.getName())))); + } + }; + + ArchRule rule = classes() + .that().areAnnotatedWith(RestController.class) + .should(haveOperationAnnotationOnEndpoints) + .as("All REST endpoint methods should have @Operation annotation for OpenAPI docs"); + + rule.check(restClasses); + } + + @Test + @DisplayName("REST controllers should declare @SecurityRequirement for Swagger UI auth") + void controllersShouldDeclareSecurityRequirementForSwagger() { + ArchCondition declareSecurityRequirements = + new ArchCondition<>("declare @SecurityRequirement at class or method level") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + boolean hasClassLevel = javaClass.isAnnotatedWith( + io.swagger.v3.oas.annotations.security.SecurityRequirement.class) + || javaClass.isAnnotatedWith( + io.swagger.v3.oas.annotations.security.SecurityRequirements.class); + + if (!hasClassLevel) { + boolean anyMethodLevel = javaClass.getMethods().stream() + .anyMatch(m -> m.isAnnotatedWith( + io.swagger.v3.oas.annotations.security.SecurityRequirement.class)); + + if (!anyMethodLevel) { + events.add(SimpleConditionEvent.violated(javaClass, + String.format("%s has no @SecurityRequirement — " + + "OpenAPI docs should show authentication requirements", + javaClass.getSimpleName()))); + } + } + } + }; + + ArchRule rule = classes() + .that().areAnnotatedWith(RestController.class) + .and().areAnnotatedWith( + org.springframework.data.rest.webmvc.BasePathAwareController.class) + .and().doNotHaveSimpleName("VersionController") + .and().doNotHaveSimpleName("AttachmentCleanUpController") + .should(declareSecurityRequirements) + .as("REST controllers should declare @SecurityRequirement for OpenAPI authentication docs"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/PackageStructureRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/PackageStructureRulesTest.java new file mode 100644 index 0000000000..075236a715 --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/PackageStructureRulesTest.java @@ -0,0 +1,93 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * Validates the package structure conventions of the SW360 REST module. + *

+ * Each domain entity (project, component, release, etc.) has its own + * sub-package under {@code ..rest.resourceserver.}. This test + * ensures structural consistency across domain packages. + *

+ * Note: SW360 domain packages have intentional cross-dependencies + * (e.g., a project references releases, components reference projects, etc.) + * so cyclic dependency checks are not applicable at the REST module level. + */ +@DisplayName("Package Structure Rules") +class PackageStructureRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("Controller classes should reside in their domain sub-package, not in core") + void controllersShouldResideInDomainPackages() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Controller") + .should().resideOutsideOfPackage("..resourceserver.core..") + .as("Controller classes should reside in domain packages " + + "(e.g., ..project.ProjectController), not in the core package"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Serializer classes should reside in the core.serializer package") + void serializersShouldResideInCoreSerializerPackage() { + ArchRule rule = classes() + .that().haveSimpleNameEndingWith("Serializer") + .should().resideInAPackage("..resourceserver.core.serializer..") + .as("Custom JSON serializer classes should reside in the core.serializer package"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Exception classes should reside in the core package") + void exceptionClassesShouldResideInCore() { + ArchRule rule = classes() + .that().areAssignableTo(Exception.class) + .and().resideInAPackage("..rest.resourceserver..") + .and().doNotHaveFullyQualifiedName(Exception.class.getName()) + .should().resideInAPackage("..resourceserver.core..") + .as("Custom exception classes should reside in the core package"); + + rule.check(restClasses); + } + + @Test + @DisplayName("Security-related classes should reside in the security package") + void securityClassesShouldResideInSecurityPackage() { + ArchRule rule = classes() + .that().haveSimpleNameContaining("Authentication") + .and().resideInAPackage("..rest.resourceserver..") + .should().resideInAPackage("..resourceserver.security..") + .orShould().resideInAPackage("..resourceserver.core..") + .as("Authentication-related classes should reside in the security or core package"); + + rule.check(restClasses); + } + + @Test + @DisplayName("No class should depend on internal JDK sun.* packages") + void noClassesShouldDependOnInternalJdkPackages() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .should().dependOnClassesThat() + .resideInAPackage("sun..") + .as("No class should depend on internal JDK (sun..) packages"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/SW360ArchitectureTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/SW360ArchitectureTest.java new file mode 100644 index 0000000000..895ccce46d --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/SW360ArchitectureTest.java @@ -0,0 +1,34 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.BeforeAll; + +/** + * Base configuration for SW360 ArchUnit architecture tests. + *

+ * Provides a shared {@link JavaClasses} instance that imports the production + * classes of the REST resource-server module. All concrete architecture test + * classes should reference {@link #restClasses} for their rule checks. + */ +abstract class SW360ArchitectureTest { + + static JavaClasses restClasses; + + @BeforeAll + static void importClasses() { + restClasses = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("org.eclipse.sw360.rest.resourceserver"); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/SecurityAnnotationRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/SecurityAnnotationRulesTest.java new file mode 100644 index 0000000000..504725e151 --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/SecurityAnnotationRulesTest.java @@ -0,0 +1,134 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * Validates security annotation patterns in the SW360 REST module. + *

+ * SW360 enforces authorization at two levels: + *

    + *
  • Spring Security filter chain: HTTP-method-based access control in + * {@code ResourceServerConfiguration} — GET requires {@code READ}, while + * POST/PUT/DELETE/PATCH require {@code WRITE}
  • + *
  • Thrift service level: Permission checks inside backend handlers + * (e.g., {@code PermissionUtils.makePermission(doc, user).isActionAllowed(RequestedAction.WRITE)})
  • + *
+ *

+ * Important: Class-level {@code @PreAuthorize} should be avoided on + * most controllers because it overrides the filter chain's per-HTTP-method rules and + * would block READ-only users from accessing GET endpoints. + *

+ * These rules focus on: + *

    + *
  • Consistent use of {@code @PreAuthorize} (not deprecated alternatives)
  • + *
  • Valid authority values ({@code ADMIN}, {@code WRITE}, {@code READ})
  • + *
  • Proper security configuration annotations
  • + *
+ */ +@DisplayName("Security Annotation Rules") +class SecurityAnnotationRulesTest extends SW360ArchitectureTest { + + + @Test + @DisplayName("No class should use deprecated @Secured — use @PreAuthorize instead") + void noClassShouldUseSecuredAnnotation() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .should().beAnnotatedWith( + org.springframework.security.access.annotation.Secured.class) + .as("Use @PreAuthorize (not @Secured) — SW360 standardizes on @PreAuthorize " + + "for method-level security"); + + rule.check(restClasses); + } + + @Test + @DisplayName("No class should use @RolesAllowed — use @PreAuthorize instead") + void noClassShouldUseRolesAllowedAnnotation() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .should().dependOnClassesThat() + .haveFullyQualifiedName("jakarta.annotation.security.RolesAllowed") + .as("Use @PreAuthorize (not @RolesAllowed) — SW360 standardizes on @PreAuthorize " + + "for method-level security"); + + rule.check(restClasses); + } + + @Test + @DisplayName("No class should use deprecated @EnableGlobalMethodSecurity — use @EnableMethodSecurity") + void noClassShouldUseDeprecatedMethodSecurityAnnotation() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .should().dependOnClassesThat() + .haveFullyQualifiedName( + "org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity") + .as("Use @EnableMethodSecurity (not deprecated @EnableGlobalMethodSecurity) — " + + "SW360 uses Spring Security 6.x"); + + rule.check(restClasses); + } + + @Test + @DisplayName("@PreAuthorize values should only use known SW360 authorities") + void preAuthorizeValuesShouldUseKnownAuthorities() { + ArchCondition useOnlyKnownAuthorities = + new ArchCondition<>("use only known SW360 authorities (ADMIN, WRITE, READ) in @PreAuthorize") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + // Check class-level @PreAuthorize + if (javaClass.isAnnotatedWith(PreAuthorize.class)) { + String value = javaClass.getAnnotationOfType(PreAuthorize.class).value(); + validatePreAuthorizeValue(javaClass, value, "class-level", events); + } + + // Check method-level @PreAuthorize + javaClass.getMethods().stream() + .filter(method -> method.isAnnotatedWith(PreAuthorize.class)) + .forEach(method -> { + String value = method.getAnnotationOfType(PreAuthorize.class).value(); + validatePreAuthorizeValue(javaClass, value, + method.getName() + "()", events); + }); + } + + private void validatePreAuthorizeValue(JavaClass javaClass, String value, + String location, ConditionEvents events) { + if (!value.contains("ADMIN") && !value.contains("WRITE") + && !value.contains("READ")) { + events.add(SimpleConditionEvent.violated(javaClass, + String.format("%s has @PreAuthorize(\"%s\") at %s — " + + "only 'ADMIN', 'WRITE', or 'READ' authorities are valid in SW360", + javaClass.getSimpleName(), value, location))); + } + } + }; + + ArchRule rule = classes() + .that().resideInAPackage("..rest.resourceserver..") + .should(useOnlyKnownAuthorities) + .as("@PreAuthorize annotations should only reference known SW360 authorities: " + + "ADMIN, WRITE, or READ"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/SpringFrameworkRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/SpringFrameworkRulesTest.java new file mode 100644 index 0000000000..62a66084f3 --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/SpringFrameworkRulesTest.java @@ -0,0 +1,132 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.RestController; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * Validates Spring Framework best practices in the SW360 REST module. + *

+ * These rules ensure proper use of Spring stereotypes, configuration, + * web-layer annotations, and bean definition patterns. + */ +@DisplayName("Spring Framework Best Practice Rules") +class SpringFrameworkRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("No class should use @Controller — use @RestController instead") + void noClassShouldUseControllerAnnotation() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .should().beAnnotatedWith(org.springframework.stereotype.Controller.class) + .as("Use @RestController (not @Controller) for REST API endpoints"); + + rule.check(restClasses); + } + + @Test + @DisplayName("@SpringBootApplication should only exist in the root package") + void springBootApplicationShouldBeInRootPackage() { + ArchRule rule = classes() + .that().areAnnotatedWith(SpringBootApplication.class) + .should().resideInAPackage("..rest.resourceserver") + .as("@SpringBootApplication class should reside in the root resourceserver package"); + + rule.check(restClasses); + } + + @Test + @DisplayName("@Configuration classes should not implement business logic interfaces") + void configurationClassesShouldNotBeServices() { + ArchRule rule = noClasses() + .that().areAnnotatedWith(Configuration.class) + .should().beAnnotatedWith(Service.class) + .as("@Configuration classes should not also be @Service — separate concerns"); + + rule.check(restClasses); + } + + @Test + @DisplayName("@Service classes should not be annotated with @RestController") + void servicesShouldNotBeControllers() { + ArchRule rule = noClasses() + .that().areAnnotatedWith(Service.class) + .should().beAnnotatedWith(RestController.class) + .as("@Service classes should not also be @RestController — keep layers separate"); + + rule.check(restClasses); + } + + @Test + @DisplayName("@Component classes should not be annotated with @Service or @RestController") + void componentsShouldNotBeServicesOrControllers() { + ArchRule rule = noClasses() + .that().areAnnotatedWith(Component.class) + .should().beAnnotatedWith(Service.class) + .orShould().beAnnotatedWith(RestController.class) + .as("@Component should not also be @Service or @RestController — " + + "use the most specific stereotype"); + + rule.check(restClasses); + } + + @Test + @DisplayName("@RestController classes should not use @ResponseBody — it is implied") + void restControllersShouldNotUseResponseBody() { + ArchRule rule = noClasses() + .that().areAnnotatedWith(RestController.class) + .should().beAnnotatedWith( + org.springframework.web.bind.annotation.ResponseBody.class) + .as("@RestController already implies @ResponseBody — do not add it explicitly"); + + rule.check(restClasses); + } + + @Test + @DisplayName("@RestController classes should not define @Bean methods — use @Configuration instead") + void controllersShouldNotDefineBeans() { + ArchCondition notDeclareBeanMethods = + new ArchCondition<>("not declare @Bean methods") { + @Override + public void check(JavaClass javaClass, ConditionEvents events) { + javaClass.getMethods().stream() + .filter(method -> method.isAnnotatedWith( + org.springframework.context.annotation.Bean.class)) + .forEach(method -> events.add(SimpleConditionEvent.violated( + javaClass, + String.format("%s.%s() is annotated with @Bean — " + + "define beans in @Configuration classes, not controllers", + javaClass.getSimpleName(), method.getName())))); + } + }; + + ArchRule rule = classes() + .that().areAnnotatedWith(RestController.class) + .should(notDeclareBeanMethods) + .as("@RestController classes should not define @Bean methods — " + + "use @Configuration classes for bean definitions"); + + rule.check(restClasses); + } +} diff --git a/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ThriftServiceBoundaryRulesTest.java b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ThriftServiceBoundaryRulesTest.java new file mode 100644 index 0000000000..7f74932ecb --- /dev/null +++ b/rest/resource-server/src/test/java/org/eclipse/sw360/rest/resourceserver/architecture/ThriftServiceBoundaryRulesTest.java @@ -0,0 +1,71 @@ +/* + * Copyright Siemens AG, 2025. Part of the SW360 Portal Project. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.sw360.rest.resourceserver.architecture; + +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * Validates that REST module classes do not bypass the Thrift service layer. + *

+ * In the SW360 architecture, the REST module communicates with backend services + * exclusively through Thrift clients (via {@code ThriftClients}). + * Direct access to: + *

    + *
  • Backend handler classes ({@code *DatabaseHandler})
  • + *
  • Repository classes ({@code *Repository})
  • + *
  • CouchDB/Cloudant client classes (except for the health indicator)
  • + *
+ * is prohibited from the REST layer. + */ +@DisplayName("Thrift Service Boundary Rules") +class ThriftServiceBoundaryRulesTest extends SW360ArchitectureTest { + + @Test + @DisplayName("REST module should not directly access database handlers") + void restModuleShouldNotAccessDatabaseHandlers() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .should().dependOnClassesThat() + .haveSimpleNameEndingWith("DatabaseHandler") + .as("REST module must not bypass Thrift services to access database handlers directly"); + + rule.check(restClasses); + } + + @Test + @DisplayName("REST module should not directly access repository classes") + void restModuleShouldNotAccessRepositories() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .should().dependOnClassesThat() + .resideInAPackage("..datahandler.db..") + .as("REST module must not access backend repository classes in the datahandler.db package"); + + rule.check(restClasses); + } + + @Test + @DisplayName("REST module (except health indicator) should not directly use Cloudant/CouchDB client classes") + void restModuleShouldNotAccessCouchDbDirectly() { + ArchRule rule = noClasses() + .that().resideInAPackage("..rest.resourceserver..") + .and().doNotHaveSimpleName("SW360RestHealthIndicator") + .should().dependOnClassesThat() + .resideInAPackage("..datahandler.cloudantclient..") + .as("REST module (except health indicator) must not access CouchDB/Cloudant client classes directly — " + + "use Thrift services via ThriftClients"); + + rule.check(restClasses); + } +}