diff --git a/backend/pom.xml b/backend/pom.xml index 6c415956..e4e2eb2b 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -108,6 +108,12 @@ org.awaitility awaitility test + + + com.tngtech.archunit + archunit-junit5 + 1.3.0 + test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java b/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java new file mode 100644 index 00000000..809da1fc --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java @@ -0,0 +1,147 @@ +package org.raddatz.familienarchiv.shared; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import jakarta.persistence.Entity; +import org.raddatz.familienarchiv.FamilienarchivApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.JpaRepository; +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; + +@AnalyzeClasses(packagesOf = FamilienarchivApplication.class) +class ArchitectureTest { + + // Rule 1: Controllers must never inject repositories directly. + // Security rationale: bypassing the service layer skips @RequirePermission + // AOP checks that are enforced on service methods. + @ArchTest + static final ArchRule no_controller_injects_repository_directly = + noClasses() + .that().areAnnotatedWith(RestController.class) + .should().dependOnClassesThat().areAssignableTo(JpaRepository.class); + + // Rule 2: Services access only their own domain's repositories, never a foreign domain's. + // Prevents hidden coupling between domains that should communicate via service APIs. + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_document = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..document..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("document")); + + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_person = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..person..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("person")); + + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_tag = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..tag..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("tag")); + + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_user = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..user..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("user")); + + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_dashboard = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..dashboard..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("dashboard")); + + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_geschichte = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..geschichte..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("geschichte")); + + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_notification = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..notification..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("notification")); + + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_ocr = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..ocr..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("ocr")); + + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_importing = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..importing..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("importing")); + + @ArchTest + static final ArchRule services_only_access_own_domain_repositories_audit = + noClasses() + .that().areAnnotatedWith(Service.class) + .and().resideInAPackage("..audit..") + .should().dependOnClassesThat(foreignJpaRepositoryFor("audit")); + + // Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages. + // Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages + // where it can be audited and reasoned about independently. + @ArchTest + static final ArchRule no_configuration_class_in_domain_packages = + noClasses() + .that().areAnnotatedWith(Configuration.class) + .and().areNotAnnotatedWith(SpringBootApplication.class) + .should().resideInAnyPackage( + "..document..", "..person..", "..tag..", + "..geschichte..", "..notification..", "..ocr..", + "..filestorage..", "..importing..", "..dashboard..", "..audit.." + ); + + // Rule 4: Entities belong to their domain packages, not to a shared model layer. + // Prevents regression to a flat, layer-based package layout. + @ArchTest + static final ArchRule entities_reside_in_domain_packages = + classes() + .that().areAnnotatedWith(Entity.class) + .should().resideInAnyPackage( + "..document..", "..person..", "..tag..", "..user..", + "..geschichte..", "..notification..", "..ocr..", "..audit.." + ); + + // TODO Rule 5: Controllers expose endpoints under their domain prefix + // (e.g., classes in ..document.. are annotated with @RequestMapping("/api/documents")). + // Implementing this requires a custom ArchUnit DescribedPredicate inspecting the + // @RequestMapping annotation value — deferred due to brittleness concerns. + // Tracked in: http://heim-nas:3005/marcel/familienarchiv/issues/427 + + private static DescribedPredicate foreignJpaRepositoryFor(String ownDomain) { + // Exact-segment match: prevents a domain name that is a substring of another + // (e.g. "tag" inside "tagging") from silently escaping the predicate. + // The pattern matches the domain as a complete path segment, with an optional sub-package. + String ownPackagePattern = ".*\\.familienarchiv\\." + ownDomain + "(\\..+)?$"; + return new DescribedPredicate("be a JPA repository from a domain other than " + ownDomain) { + @Override + public boolean test(JavaClass clazz) { + return clazz.isAssignableTo(JpaRepository.class) + && !clazz.getPackageName().matches(ownPackagePattern); + } + }; + } +}