diff --git a/.agent/workflows/git.md b/.agent/workflows/git.md new file mode 100644 index 00000000..2f63d0af --- /dev/null +++ b/.agent/workflows/git.md @@ -0,0 +1,11 @@ +--- +description: Automated Git workflow to analyze message patterns, format code, stage, commit, and push changes. +--- + +// turbo-all +1. Run `git status` to identify current changes and untracked files. +2. Run `git log -n 15 --pretty=format:"%s"` to analyze the recent commit message style, prefixes, and patterns used in the repository. +3. Apply code formatting in the backend by running `./gradlew spotlessApply` in the `backend` directory. +4. Stage all changes in the repository using `git add .`. +5. Based on the analysis in step 2 and the changes identified in step 1, generate a commit message that follows the project's style and run `git commit -m ""`. +6. Push the committed changes to the current branch using `git push`. diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index 8b2fe07e..369cd303 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -1,58 +1,78 @@ -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle - -name: Java CI with Gradle +name: PR CI Pipeline on: push: branches: - "development" + - "main" pull_request: - branches: [ "development" ] + branches: + - "development" + - "main" + - "merge/**" + - "feature/**" + +permissions: + contents: read + pull-requests: read jobs: test: + name: test runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v4 + - name: Checkout Repository + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + - name: Set up JDK 21 - uses: actions/setup-java@v4.7.1 + uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' + - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4.4.2 - - name: Run tests + uses: gradle/actions/setup-gradle@v4 + + - name: Check Code Formatting (Spotless) + if: github.event_name == 'pull_request' + run: | + cd backend + ./gradlew spotlessCheck || { + echo "❌ Code formatting check failed!" + echo "" + echo "Please run the following command locally to fix formatting:" + echo " cd backend && ./gradlew spotlessApply" + echo "" + echo "Then commit and push the changes." + exit 1 + } + + - name: Run Checkstyle + run: cd backend && ./gradlew checkstyleMain checkstyleTest + + - name: Run Tests run: cd backend && ./gradlew :test build: + name: build needs: test runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v4 + - name: Checkout Repository + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + - name: Set up JDK 21 - uses: actions/setup-java@v4.7.1 + uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4.4.2 - - - name: Setup Node - uses: actions/setup-node@v4 + uses: gradle/actions/setup-gradle@v4 - - name: Build with Gradle Wrapper + - name: Build with Gradle run: cd backend && ./gradlew :bootJar - - # - name: Upload built jar - # uses: actions/upload-artifact@v4 - # with: - # name: Application - # path: backend/build/libs/*.jar diff --git a/README.md b/README.md index 9cd67295..66ebfe11 100644 --- a/README.md +++ b/README.md @@ -10,59 +10,115 @@ This repository contains the source code for the Quantum Kit (QuaK) Web IDE. ## Docker Workflows -### Development Workflow (Recommended) -This is the **preferred way** to develop locally. It ensures a consistent environment and supports hot-reloading. +### Development Workflow (Docker-only) +This ensures a consistent environment and supports hot-reloading. + +1. **Start Backend & Database:** + * **Linux/macOS:** -1. **Start Backend & Database:** - * **Linux/macOS:** ```bash - sudo docker-compose -f docker-compose.dev.yaml up --build + docker compose -f docker-compose.dev.yaml up --build ``` - * **Windows:** + + * **Windows:** + ```powershell docker-compose -f docker-compose.dev.yaml up --build ``` - * Runs the Spring Boot backend on port `8080`. - * Runs MariaDB on port `3306`. - * *Note:* The backend does **not** serve frontend files in this mode. -2. **Start Frontend:** + * Runs the Spring Boot backend on port `8080`. + * Runs MariaDB on port `3306`. + * *Note:* The backend does **not** serve frontend files in this mode. + +2. **Start Frontend:** In a new terminal: + ```bash cd frontend npm run dev ``` - * Runs the Vite dev server on port `5173`. - * Proxies API requests to `localhost:8080`. - * Access the app at `http://localhost:5173`. + +### Hybrid Workflow (Debugger-Friendly) + +**Recommended for backend development.** This allows you to run the backend in your IDE or via terminal with a debugger while using Docker for the database. + +1. **Start Database:** + + ```bash + docker-compose -f docker-compose.dev.yaml up -d database + ``` + +2. **Start Backend (with Debugging):** + You can run the backend in debug mode via terminal. It will listen on port **5005** for a debugger while the API remains on **8080**. + + ```bash + cd backend + ./gradlew bootRun -PjvmArgs="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005" + ``` + + * **Port 8080:** Standard Web/API (Frontend stays connected). + * **Port 5005:** Debugging (Connect your IDE here via "Remote JVM Debug"). + +3. **Start Frontend:** + + ```bash + cd frontend + npm run dev + ``` + +Access the app at `http://localhost:5173`. ### Production Workflow + To run the full application (Backend + Frontend served statically): -1. **Start Application:** +1. **Start Application:** + ```bash - sudo docker-compose -f docker-compose.prod.yaml up --build + docker compose -f docker-compose.prod.yaml up --build ``` - * Builds the frontend and serves it via the backend (or Nginx if configured). - * Runs the Spring Boot backend and MariaDB. - * Access the app at `http://localhost:8080` (or the configured production port). + + * Builds the frontend and serves it via the backend (or Nginx if configured). + * Runs the Spring Boot backend and MariaDB. + * Access the app at `http://localhost:8080` (or the configured production port). ## Developing Please have a look at the [developer guidelines](/docs/DEVELOPMENT.md). +## Testing + +### Backend Tests + +To run the backend tests, execute the following command in the `backend` directory: + +```bash +./gradlew test +``` + +### Frontend Tests + +To run the frontend tests, execute the following command in the `frontend` directory: + +```bash +npm run test +``` + ## Deployment -The project will automatically be deployed when a change to the _development_-branch happens. +The project will automatically be deployed when a change to the *development*-branch happens. ### Setting up the Deployment Server + On a server of your choice, set up the following: + * [Docker](https://docs.docker.com/engine/install/) * [Dokku](https://dokku.com/docs/getting-started/installation/) * Make sure to enable `vhost` during the installation-dialog (this is the default) Then, run the following commands: + ```bash dokku apps:create quak dokku builder:set quak build-dir backend @@ -74,14 +130,17 @@ cat github # Save the content of the private for later # You may also want to move the ssh-keys somewhere else ``` + We now want to set the GitHub-Secrets inside this repository: -* _DEPLOYMENT_SERVER_ADDRESS_ -* _DEPLOYMENT_SERVER_SSH_KEY_ + +* *DEPLOYMENT_SERVER_ADDRESS* +* *DEPLOYMENT_SERVER_SSH_KEY* * This has the content of the private-key generated above Lastly, make sure that all relevant ports (e.g. 8080) are exposed to the outside world. ## Legacy Execution (Not Recommended) + *Note: This method is deprecated. Please use the Docker Development Workflow above.* To run the QuaK editor manually without Docker, run: @@ -89,10 +148,13 @@ To run the QuaK editor manually without Docker, run: inside the `backend` directory ### Dependencies + The project requires the following dependencies to be installed on the system: + * Java >= Version 21 ### Automatic installation of nodejs + Through the use of the [gradle-node-plugin], the project can automatically install `npm`. If you want to use this feature, run any gradle-command with the flag `-PdownloadNode` (i.e. `gradlew :bootRun -PdownloadNode`). diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..d8f3e0fb --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,31 @@ +# Build artifacts +build/ +.gradle/ +bin/ +/out/ + +# IDE +.idea/ +.vscode/ +*.iml +*.ipr +*.iws + +# Environment +.env +.env.local +.env.*.local + +# Git +.git/ +.gitignore + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db + +# Node (in case backend references frontend) +node_modules/ diff --git a/backend/README.md b/backend/README.md index 5aaa9d68..cb13213a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,5 +1,34 @@ # QuaK Backend +## Code Formatting and Style + +This project uses **Spotless** for code formatting and **Checkstyle** for style enforcement. + +### Commands + +| Command | Description | +|:-------------------------------------|:-----------------------------------------------| +| `./gradlew spotlessApply` | Auto-format all Java code | +| `./gradlew spotlessCheck` | Check if code is properly formatted | +| `./gradlew checkstyleMain checkstyleTest` | Run Checkstyle on all code | + +### Before Creating a Pull Request + +**Spotless formatting checks only run on pull requests** (not on regular pushes to your branch). + +Before creating a PR, ensure your code is properly formatted: + +```bash +cd backend +./gradlew spotlessApply +./gradlew spotlessCheck +./gradlew checkstyleMain checkstyleTest +``` + +If the PR pipeline fails due to formatting, run `./gradlew spotlessApply` locally, commit, and push again. + +--- + ## Testing Workflows ### 1. Test-Kategorien diff --git a/backend/build.gradle b/backend/build.gradle index ea84dd68..8dfadd15 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -1,8 +1,10 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.4.5' - id 'io.spring.dependency-management' version '1.1.7' - id "com.github.node-gradle.node" version "7.1.0" + id 'java' + id 'org.springframework.boot' version '3.4.5' + id 'io.spring.dependency-management' version '1.1.7' + id "com.github.node-gradle.node" version "7.1.0" + id 'com.diffplug.spotless' version '6.25.0' + id 'checkstyle' } apply from: 'node.gradle' @@ -19,13 +21,41 @@ repositories { mavenCentral() } +spotless { + java { + target 'src/*/java/**/*.java' + googleJavaFormat().aosp().reflowLongStrings() + removeUnusedImports() + formatAnnotations() + trimTrailingWhitespace() + endWithNewline() + } +} + +checkstyle { + toolVersion = '10.12.5' + configFile = file("${project.projectDir}/config/checkstyle/checkstyle.xml") + ignoreFailures = false + maxWarnings = 0 +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Lombok - must be declared before MapStruct for proper annotation processor ordering + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' + testCompileOnly 'org.projectlombok:lombok:1.18.30' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.30' + + // MapStruct with Lombok binding implementation 'org.mapstruct:mapstruct:1.5.5.Final' + annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.session:spring-session-core' @@ -54,18 +84,82 @@ tasks.register('getFrontend', Copy) { into(layout.projectDirectory.dir('src/main/resources/static')) } +tasks.register('prepareDevStatic') { + group = 'QuaK' + description = 'Ensures 8080 serves a placeholder instead of stale frontend files during development' + + doLast { + def staticDir = file("src/main/resources/static") + if (!staticDir.exists()) staticDir.mkdirs() + + // Delete stale files + staticDir.listFiles().each { f -> + if (f.name != ".gitkeep") { + if (f.isDirectory()) f.deleteDir() + else f.delete() + } + } + + // Create placeholder + def placeholder = file("src/main/resources/static/index.html") + placeholder.text = """ + + + + QuaK - Backend Dev Mode + + + +
+

Backend is Ready!

+

You are accessing the API Port (8080). The full frontend is not served here during development.

+

Please start the frontend in another terminal:

+
cd frontend && npm run dev
+ Open App on Port 5173 +
+ + +""".trim() + } +} + // Only build frontend when explicitly requested via -PbuildFrontend=true def shouldBuildFrontend = project.hasProperty('buildFrontend') && project.property('buildFrontend') == 'true' tasks.named("processResources") { if (shouldBuildFrontend) { dependsOn getFrontend + } else { + dependsOn prepareDevStatic } } tasks.named("bootRun") { - group = 'QuaK' - mainClass = 'edu.kit.quak.QuaKApplication' + group = 'QuaK' + mainClass = 'edu.kit.quak.QuaKApplication' + + // Load environment variables from .env file for local development + if (file(".env").exists()) { + file(".env").readLines().each { line -> + if (line.trim() && !line.startsWith("#") && line.contains("=")) { + def parts = line.split("=", 2) + environment parts[0].trim(), parts[1].trim() + } + } + } + + // Allow passing custom JVM arguments via -PjvmArgs="..." + if (project.hasProperty('jvmArgs')) { + jvmArgs(project.property('jvmArgs').toString().split('\\s+')) + } } tasks.named('test') { @@ -108,4 +202,74 @@ tasks.register('integrationTest', Test) { } // More storage for Integration Tests maxHeapSize = '1G' -} \ No newline at end of file +} + +// Production build task - always includes frontend +tasks.register('buildProduction') { + group = 'QuaK' + description = 'Build backend with frontend for production deployment' + dependsOn getFrontend + dependsOn build + doLast { + println "βœ… Production build complete with frontend included!" + println "πŸ“¦ JAR location: build/libs/" + } +} + +// Frontend test task +tasks.register('testFrontend', Exec) { + group = 'QuaK' + description = 'Run frontend tests using Vitest' + workingDir = layout.projectDirectory.file("../frontend") + + // Check if npm is available + def npmCmd = System.getProperty('os.name').toLowerCase().contains('windows') ? 'npm.cmd' : 'npm' + commandLine npmCmd, 'test' + + // Make it fail gracefully if npm is not installed + ignoreExitValue = false + + doFirst { + println "πŸ§ͺ Running frontend tests..." + } + + doLast { + println "βœ… Frontend tests completed!" + } +} + +// Run all tests (backend + frontend) in parallel +tasks.register('testAll') { + group = 'QuaK' + description = 'Run both backend and frontend tests in parallel' + + dependsOn test + dependsOn testFrontend + + doLast { + println "" + println "╔═══════════════════════════════════════╗" + println "β•‘ βœ… All tests completed successfully! β•‘" + println "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" + } +} + +// Run all tests sequentially (useful for CI) +tasks.register('testAllSequential') { + group = 'QuaK' + description = 'Run backend tests, then frontend tests (sequential)' + + doFirst { + println "πŸ§ͺ Running tests sequentially..." + } + + finalizedBy test + + doLast { + tasks.testFrontend.execute() + println "" + println "╔═══════════════════════════════════════╗" + println "β•‘ βœ… All tests completed successfully! β•‘" + println "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" + } +} diff --git a/backend/config/checkstyle/checkstyle.xml b/backend/config/checkstyle/checkstyle.xml new file mode 100644 index 00000000..0b9361d6 --- /dev/null +++ b/backend/config/checkstyle/checkstyle.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/java/edu/kit/quak/QuaKApplication.java b/backend/src/main/java/edu/kit/quak/QuaKApplication.java index 4f4908c5..60e543f5 100644 --- a/backend/src/main/java/edu/kit/quak/QuaKApplication.java +++ b/backend/src/main/java/edu/kit/quak/QuaKApplication.java @@ -3,11 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +/** Main entry point for the QuaK application. */ @SpringBootApplication public class QuaKApplication { - public static void main(String[] args) { - SpringApplication.run(QuaKApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(QuaKApplication.class, args); + } } diff --git a/backend/src/main/java/edu/kit/quak/application/circuit/ports/in/CircuitServicePort.java b/backend/src/main/java/edu/kit/quak/application/circuit/ports/in/CircuitServicePort.java index 0bf76955..c49df509 100644 --- a/backend/src/main/java/edu/kit/quak/application/circuit/ports/in/CircuitServicePort.java +++ b/backend/src/main/java/edu/kit/quak/application/circuit/ports/in/CircuitServicePort.java @@ -5,14 +5,24 @@ public interface CircuitServicePort { QuantumCircuit init(); + QuantumCircuit get(String circuitId); + void delete(String circuitId); QuantumCircuit addQubit(String circuitId); + QuantumCircuit changeQubitName(String circuitId, String qubitId, String name); + QuantumCircuit deleteQubit(String circuitId, String qubitId); - QuantumCircuit addGate(String circuitId, ElementaryQuantumGateDefinitionIdentifier definitionId, int qubitIdx, int positionIdx); + QuantumCircuit addGate( + String circuitId, + ElementaryQuantumGateDefinitionIdentifier definitionId, + int qubitIdx, + int positionIdx); + QuantumCircuit moveGate(String circuitId, String id, int qubitIdx, int positionIdx); + QuantumCircuit deleteGate(String circuitId, String gateId); } diff --git a/backend/src/main/java/edu/kit/quak/application/circuit/ports/out/CircuitRepositoryPort.java b/backend/src/main/java/edu/kit/quak/application/circuit/ports/out/CircuitRepositoryPort.java index 58a3d894..60d7b5f0 100644 --- a/backend/src/main/java/edu/kit/quak/application/circuit/ports/out/CircuitRepositoryPort.java +++ b/backend/src/main/java/edu/kit/quak/application/circuit/ports/out/CircuitRepositoryPort.java @@ -1,11 +1,12 @@ package edu.kit.quak.application.circuit.ports.out; import edu.kit.quak.core.circuit.model.QuantumCircuit; - import java.util.Optional; public interface CircuitRepositoryPort { Optional findById(String id); + QuantumCircuit save(QuantumCircuit circuit); + void delete(String circuitId); } diff --git a/backend/src/main/java/edu/kit/quak/application/circuit/services/CircuitService.java b/backend/src/main/java/edu/kit/quak/application/circuit/services/CircuitService.java index 6f910416..d6b9031a 100644 --- a/backend/src/main/java/edu/kit/quak/application/circuit/services/CircuitService.java +++ b/backend/src/main/java/edu/kit/quak/application/circuit/services/CircuitService.java @@ -5,9 +5,8 @@ import edu.kit.quak.core.circuit.model.QuantumCircuit; import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGateDefinitionIdentifier; import jakarta.persistence.EntityNotFoundException; -import org.springframework.stereotype.Service; - import java.util.function.Consumer; +import org.springframework.stereotype.Service; @Service public class CircuitService implements CircuitServicePort { @@ -50,13 +49,22 @@ public QuantumCircuit deleteQubit(String circuitId, String qubitId) { } @Override - public QuantumCircuit addGate(String circuitId, ElementaryQuantumGateDefinitionIdentifier definitionId, int qubitIdx, int positionIdx) { - return updateCircuit(circuitId, circuit -> circuit.addElementaryQuantumGate(definitionId, qubitIdx, positionIdx)); + public QuantumCircuit addGate( + String circuitId, + ElementaryQuantumGateDefinitionIdentifier definitionId, + int qubitIdx, + int positionIdx) { + return updateCircuit( + circuitId, + circuit -> circuit.addElementaryQuantumGate(definitionId, qubitIdx, positionIdx)); } @Override - public QuantumCircuit moveGate(String circuitId, String gateId, int targetQubitIdx, int positionIdx) { - return updateCircuit(circuitId, circuit -> circuit.moveQuantumOperation(gateId, targetQubitIdx, positionIdx)); + public QuantumCircuit moveGate( + String circuitId, String gateId, int targetQubitIdx, int positionIdx) { + return updateCircuit( + circuitId, + circuit -> circuit.moveQuantumOperation(gateId, targetQubitIdx, positionIdx)); } @Override @@ -65,11 +73,16 @@ public QuantumCircuit deleteGate(String circuitId, String gateId) { } private QuantumCircuit updateCircuit(String circuitId, Consumer action) { - QuantumCircuit circuit = repository.findById(circuitId) - .orElseThrow(() -> new EntityNotFoundException("Circuit not found: " + circuitId)); + QuantumCircuit circuit = + repository + .findById(circuitId) + .orElseThrow( + () -> + new EntityNotFoundException( + "Circuit not found: " + circuitId)); action.accept(circuit); return repository.save(circuit); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryDelegator.java b/backend/src/main/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryDelegator.java index 92d36c71..bc64eaec 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryDelegator.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryDelegator.java @@ -1,18 +1,17 @@ package edu.kit.quak.application.filesystem.delegator; import edu.kit.quak.core.filesystem.model.FileElementContainer; +import java.util.Optional; +import java.util.UUID; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import java.util.Optional; - /** * Routes repository operations for polymorphic {@link FileElementContainer} types. - *

- * Resolves the appropriate repository via the {@link FileElementContainerRepositoryRegistry} - * based on the ID prefix. This centralizes persistence orchestration and shields - * application services from routing logic. - *

+ * + *

Resolves the appropriate repository via the {@link FileElementContainerRepositoryRegistry} + * based on the ID prefix. This centralizes persistence orchestration and shields application + * services from routing logic. */ @Component public class FileElementContainerRepositoryDelegator { @@ -20,7 +19,8 @@ public class FileElementContainerRepositoryDelegator { private final FileElementContainerRepositoryRegistry registry; @Autowired - public FileElementContainerRepositoryDelegator(FileElementContainerRepositoryRegistry registry) { + public FileElementContainerRepositoryDelegator( + FileElementContainerRepositoryRegistry registry) { this.registry = registry; } @@ -44,4 +44,22 @@ public Optional> findContainerById(String id) { return registry.getRepository(prefix) .flatMap(repo -> repo.findById(id).map(c -> (FileElementContainer) c)); } -} \ No newline at end of file + + /** + * Efficiently finds the owner ID of the root project containing the given element. Uses a + * single database query with recursive CTE to traverse the hierarchy, avoiding N+1 queries. + * + * @param elementId The ID of any file element (file, directory, or project) + * @return The UUID of the user who owns the root project + */ + public Optional findProjectOwnerIdByElementId(String elementId) { + if (elementId == null || elementId.isBlank()) return Optional.empty(); + + char prefix = elementId.charAt(0); + + // Use any repository that supports this query (they all delegate to the same + // native query) + return registry.getRepository(prefix) + .flatMap(repo -> repo.findProjectOwnerIdByElementId(elementId)); + } +} diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryRegistry.java b/backend/src/main/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryRegistry.java index 34fa1803..7d1b724a 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryRegistry.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryRegistry.java @@ -2,29 +2,29 @@ import edu.kit.quak.application.filesystem.ports.out.FileElementContainerRepositoryPort; import edu.kit.quak.core.filesystem.model.FileElementContainer; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; /** * Application-layer registry for {@link FileElementContainerRepositoryPort} implementations. - *

- * Maps repositories to their unique ID prefixes to enable dynamic lookup. - * Ensures prefix uniqueness during application startup to guarantee unambiguous - * routing for polymorphic domain objects. - *

+ * + *

Maps repositories to their unique ID prefixes to enable dynamic lookup. Ensures prefix + * uniqueness during application startup to guarantee unambiguous routing for polymorphic domain + * objects. */ @Component public class FileElementContainerRepositoryRegistry { - private final Map> repoByPrefix = new HashMap<>(); + private final Map> repoByPrefix = + new HashMap<>(); @Autowired - public FileElementContainerRepositoryRegistry(List> repositories) { + public FileElementContainerRepositoryRegistry( + List> repositories) { for (FileElementContainerRepositoryPort repo : repositories) { char prefix = repo.idPrefix(); @@ -36,7 +36,9 @@ public FileElementContainerRepositoryRegistry(List> Optional> getRepository(char prefix) { - return Optional.ofNullable((FileElementContainerRepositoryPort) repoByPrefix.get(prefix)); + public > + Optional> getRepository(char prefix) { + return Optional.ofNullable( + (FileElementContainerRepositoryPort) repoByPrefix.get(prefix)); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/exceptions/AccessDeniedException.java b/backend/src/main/java/edu/kit/quak/application/filesystem/exceptions/AccessDeniedException.java new file mode 100644 index 00000000..8ae63f18 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/exceptions/AccessDeniedException.java @@ -0,0 +1,21 @@ +package edu.kit.quak.application.filesystem.exceptions; + +/** + * Thrown when a user attempts to access a resource they do not own. This is a domain exception that + * should be mapped to HTTP 403 Forbidden by the infrastructure layer. + */ +public class AccessDeniedException extends RuntimeException { + + public AccessDeniedException(String message) { + super(message); + } + + public AccessDeniedException(String resourceType, String resourceId) { + super( + "Access denied: You do not have permission to access " + + resourceType + + " with ID '" + + resourceId + + "'"); + } +} diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/DirectoryServicePort.java b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/DirectoryServicePort.java index 1428cdcf..a19f2731 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/DirectoryServicePort.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/DirectoryServicePort.java @@ -1,13 +1,14 @@ package edu.kit.quak.application.filesystem.ports.in; import edu.kit.quak.core.filesystem.model.Directory; +import edu.kit.quak.core.user.model.User; public interface DirectoryServicePort { - Directory createDirectory(Directory container, String parentId); + Directory createDirectory(Directory container, String parentId, User user); - Directory renameDirectory(String dId, String newName); + Directory renameDirectory(String dId, String newName, User user); - void removeDirectory(String id); + void removeDirectory(String id, User user); - Directory retrieveDirectory(String id); + Directory retrieveDirectory(String id, User user); } diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/FileServicePort.java b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/FileServicePort.java index c3e088b6..76f3eb73 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/FileServicePort.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/FileServicePort.java @@ -1,18 +1,19 @@ package edu.kit.quak.application.filesystem.ports.in; import edu.kit.quak.core.filesystem.model.File; +import edu.kit.quak.core.user.model.User; public interface FileServicePort { - File createFile(File element, String parentId); + File createFile(File element, String parentId, User user); - File renameFile(String fId, String newName); + File renameFile(String fId, String newName, User user); - void removeFile(String id); + void removeFile(String id, User user); - File retrieveFile(String id); + File retrieveFile(String id, User user); - void setFileContent(String fileId, byte[] content, String contentType); + void setFileContent(String fileId, byte[] content, String contentType, User user); - byte[] getFileContent(String fileId); -} \ No newline at end of file + byte[] getFileContent(String fileId, User user); +} diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/ProjectServicePort.java b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/ProjectServicePort.java index b9708f00..4af4b739 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/ProjectServicePort.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/in/ProjectServicePort.java @@ -1,18 +1,26 @@ package edu.kit.quak.application.filesystem.ports.in; import edu.kit.quak.core.filesystem.model.Project; - +import edu.kit.quak.core.user.model.User; import java.util.List; +/** + * Input port for project-related use cases. Uses only domain concepts, no framework dependencies. + */ public interface ProjectServicePort { - Project createProject(Project container); + /** Creates a new project owned by the user. */ + Project createProject(Project container, User user); - Project renameProject(String dId, String newName); + /** Renames a project if the user owns it. */ + Project renameProject(String pId, String newName, User user); - void removeProject(String id); + /** Removes a project if the user owns it. */ + void removeProject(String id, User user); - Project retrieveProject(String id); + /** Retrieves a project if the user owns it. */ + Project retrieveProject(String id, User user); - List listProjects(); + /** Lists all projects owned by the user. */ + List listProjects(User user); } diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileContentRepositoryPort.java b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileContentRepositoryPort.java index 0eefc82f..0f9f7171 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileContentRepositoryPort.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileContentRepositoryPort.java @@ -13,6 +13,7 @@ public interface FileContentRepositoryPort { /** * Loads the content of a file. + * * @param fId ID of the file * @return byte array of content */ @@ -20,6 +21,7 @@ public interface FileContentRepositoryPort { /** * Deletes the content of a file. + * * @param fId ID of the file */ void deleteContent(String fId); diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileElementContainerRepositoryPort.java b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileElementContainerRepositoryPort.java index 15ab39f2..3806806b 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileElementContainerRepositoryPort.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileElementContainerRepositoryPort.java @@ -1,19 +1,19 @@ package edu.kit.quak.application.filesystem.ports.out; import edu.kit.quak.core.filesystem.model.FileElementContainer; - import java.util.Optional; +import java.util.UUID; /** - * Repository port for {@link FileElementContainer} aggregates. - *

- * Each implementation is responsible for exactly one container definitionId and - * must declare the ID prefix it manages. The prefix is used to route - * persistence operations to the correct repository. + * Repository port for {@link FileElementContainer} aggregates. <<<<<<< HEAD + * + *

Each implementation is responsible for exactly one container type and must declare the ID + * prefix it manages. The prefix is used to route persistence operations to the correct repository. * - * @param the concrete container aggregate definitionId + * @param the concrete container aggregate type */ -public interface FileElementContainerRepositoryPort> extends FileElementRepositoryPort { +public interface FileElementContainerRepositoryPort> + extends FileElementRepositoryPort { /** * Returns the unique ID prefix handled by this repository. @@ -24,6 +24,7 @@ public interface FileElementContainerRepositoryPort findProjectOwnerIdByElementId(String elementId); } diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileElementRepositoryPort.java b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileElementRepositoryPort.java index eafc5925..f7fac097 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileElementRepositoryPort.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileElementRepositoryPort.java @@ -1,13 +1,14 @@ package edu.kit.quak.application.filesystem.ports.out; /** - * Port to manage FileElements (including File, Directory, Project) persistence. - * This interface is used by Application Services and implemented by a JPA Adapter. + * Port to manage FileElements (including File, Directory, Project) persistence. This interface is + * used by Application Services and implemented by a JPA Adapter. */ public interface FileElementRepositoryPort { /** * Search a FileElement by its ID. + * * @param id The ID of the element to delete. * @return Whether the element exists. */ diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileRepositoryPort.java b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileRepositoryPort.java index 345eb610..701d45d1 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileRepositoryPort.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/FileRepositoryPort.java @@ -1,12 +1,12 @@ package edu.kit.quak.application.filesystem.ports.out; import edu.kit.quak.core.filesystem.model.File; - import java.util.Optional; public interface FileRepositoryPort extends FileElementRepositoryPort { /** * Finds a File by its ID. + * * @param fId The ID of the file. * @return The domain object, if found. */ diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/ProjectRepositoryPort.java b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/ProjectRepositoryPort.java index 08d210d7..ff0eace9 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/ProjectRepositoryPort.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/ports/out/ProjectRepositoryPort.java @@ -1,19 +1,22 @@ package edu.kit.quak.application.filesystem.ports.out; import edu.kit.quak.core.filesystem.model.Project; - import java.util.List; +import java.util.UUID; public interface ProjectRepositoryPort extends FileElementContainerRepositoryPort { /** - * Lists all projects - * @return All projects. + * Lists all projects owned by a specific user. + * + * @param ownerId The UUID of the owner + * @return All projects belonging to the specified user. */ - List getAllProjects(); + List getProjectsByOwnerId(UUID ownerId); /** * Deletes a Project by its ID. + * * @param id The ID of the Project to delete. */ void deleteById(String id); diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/services/AbstractFileElementService.java b/backend/src/main/java/edu/kit/quak/application/filesystem/services/AbstractFileElementService.java new file mode 100644 index 00000000..9e756f1d --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/services/AbstractFileElementService.java @@ -0,0 +1,169 @@ +package edu.kit.quak.application.filesystem.services; + +import edu.kit.quak.application.filesystem.delegator.FileElementContainerRepositoryDelegator; +import edu.kit.quak.application.filesystem.exceptions.AccessDeniedException; +import edu.kit.quak.core.filesystem.model.FileElement; +import edu.kit.quak.core.filesystem.model.FileElementContainer; +import edu.kit.quak.core.user.model.User; +import java.util.UUID; +import java.util.function.Consumer; +import lombok.extern.slf4j.Slf4j; + +/** + * Abstract base class for file element services that provides common functionality for ownership + * verification, parent retrieval, and element modification operations. + * + *

This class eliminates code duplication across DirectoryService and FileService by extracting + * shared logic for: + * + *

    + *
  • Ownership verification using efficient recursive CTE queries + *
  • Parent container retrieval + *
  • Finding elements within parent containers + *
  • Modifying elements within their parent context + *
+ * + * @param the type of FileElement this service manages (File, Directory, etc.) + * @author Generated by refactoring + */ +@Slf4j +public abstract class AbstractFileElementService> { + + protected final FileElementContainerRepositoryDelegator delegator; + + protected AbstractFileElementService(FileElementContainerRepositoryDelegator delegator) { + this.delegator = delegator; + } + + /** + * Verifies that the given user owns the project containing the file/directory. Uses a single + * efficient database query with recursive CTE to find the root project's owner, avoiding N+1 + * queries when traversing deep hierarchies. + * + * @param parentId the ID of the parent container + * @param user the user to verify ownership for + * @throws AccessDeniedException if user doesn't own the project + */ + protected void verifyOwnershipByParentId(String parentId, User user) { + if (parentId == null) { + throw new IllegalStateException("Cannot verify ownership: element has no parent"); + } + + // Use efficient single-query ownership lookup + UUID projectOwnerId = + delegator + .findProjectOwnerIdByElementId(parentId) + .orElseThrow( + () -> + new IllegalStateException( + "Could not find root project for element with" + + " parent ID: " + + parentId)); + + if (!projectOwnerId.equals(user.getId())) { + log.warn( + "Access denied: User '{}' is not owner of project '{}' ({} parent: '{}')", + user.getId(), + projectOwnerId, + getElementTypeName(), + parentId); + throw new AccessDeniedException(getElementTypeName(), parentId); + } + } + + /** + * Retrieves the parent container by ID. + * + * @param parentId the ID of the parent container + * @return the parent container + * @throws IllegalStateException if parent is null or not found + */ + protected FileElementContainer getParentById(String parentId) { + if (parentId == null) { + throw new IllegalStateException( + getElementTypeName() + " has no parent - corrupt state"); + } + return delegator + .findContainerById(parentId) + .orElseThrow( + () -> new IllegalStateException("Parent not found with ID: " + parentId)); + } + + /** + * Finds a specific element within its parent container. + * + * @param parent the parent container + * @param elementId the ID of the element to find + * @return the found element + * @throws IllegalStateException if element is not found in parent + */ + protected T findElementInParent(FileElementContainer parent, String elementId) { + return parent.getContents().stream() + .filter(c -> c.getId().equals(elementId)) + .filter(this::isCorrectType) + .map(this::castToType) + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + getElementTypeName() + + " not found in parent container (ID: " + + elementId + + ")")); + } + + /** + * Modifies an element within its parent context and persists the changes. + * + * @param elementId the ID of the element to modify + * @param modifier the modification function to apply + * @return the modified element + */ + protected T modifyElementInParent(String elementId, Consumer modifier) { + T tempElement = retrieveWithoutAuth(elementId); + FileElementContainer parent = getParentById(tempElement.getParentId()); + + // Finding the "real" child in the context of the parent + T childInParent = findElementInParent(parent, elementId); + + // Apply changes + modifier.accept(childInParent); + + // Save changes through parent + FileElementContainer savedParent = delegator.save(parent); + + // Return updated child + return findElementInParent(savedParent, elementId); + } + + /** + * Retrieves an element by ID without authentication check. Should only be used internally. + * + * @param id the ID of the element + * @return the element + */ + protected abstract T retrieveWithoutAuth(String id); + + /** + * Returns the name of the element type for logging and error messages. + * + * @return the element type name (e.g., "file", "directory") + */ + protected abstract String getElementTypeName(); + + /** + * Checks if a FileElement is of the correct type for this service. + * + * @param element the element to check + * @return true if the element is of the correct type + */ + protected abstract boolean isCorrectType(FileElement element); + + /** + * Casts a FileElement to the specific type managed by this service. + * + * @param element the element to cast + * @return the casted element + */ + protected abstract T castToType(FileElement element); +} diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/services/DirectoryService.java b/backend/src/main/java/edu/kit/quak/application/filesystem/services/DirectoryService.java index 0fec2a0e..123739f5 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/services/DirectoryService.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/services/DirectoryService.java @@ -4,90 +4,105 @@ import edu.kit.quak.application.filesystem.ports.in.DirectoryServicePort; import edu.kit.quak.application.filesystem.ports.out.DirectoryRepositoryPort; import edu.kit.quak.core.filesystem.model.Directory; +import edu.kit.quak.core.filesystem.model.FileElement; import edu.kit.quak.core.filesystem.model.FileElementContainer; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.stereotype.Service; - +import edu.kit.quak.core.user.model.User; import java.util.NoSuchElementException; -import java.util.function.Consumer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service -public class DirectoryService implements DirectoryServicePort { +@Slf4j +public class DirectoryService extends AbstractFileElementService + implements DirectoryServicePort { private final DirectoryRepositoryPort repository; - private final FileElementContainerRepositoryDelegator delegator; - public DirectoryService(DirectoryRepositoryPort repository, FileElementContainerRepositoryDelegator delegator) { + + public DirectoryService( + DirectoryRepositoryPort repository, FileElementContainerRepositoryDelegator delegator) { + super(delegator); this.repository = repository; - this.delegator = delegator; } // region Create @Override @Transactional - public Directory createDirectory(Directory container, String parentId) { + public Directory createDirectory(Directory container, String parentId, User user) { + log.info( + "Creating directory '{}' in parent '{}' for user '{}'", + container.getName(), + parentId, + user.getId()); + verifyOwnershipByParentId(parentId, user); + FileElementContainer parent = getParentById(parentId); parent.addChild(container); FileElementContainer savedParent = delegator.save(parent); - return findDirectoryInParent(savedParent, container.getId()); + return findElementInParent(savedParent, container.getId()); } + // endregion Create // region Read @Override - public Directory retrieveDirectory(String id) { - return repository.findById(id).orElseThrow(NoSuchElementException::new); + public Directory retrieveDirectory(String id, User user) { + log.debug("Retrieving directory '{}' for user '{}'", id, user.getId()); + Directory directory = repository.findById(id).orElseThrow(NoSuchElementException::new); + verifyOwnershipByParentId(directory.getParentId(), user); + return directory; } + // endregion Read // region Update @Override @Transactional - public Directory renameDirectory(String dId, String newName) { - return modifyDirectoryInParent(dId, directory -> directory.rename(newName)); + public Directory renameDirectory(String dId, String newName, User user) { + log.info("Renaming directory '{}' to '{}' for user '{}'", dId, newName, user.getId()); + Directory directory = repository.findById(dId).orElseThrow(NoSuchElementException::new); + verifyOwnershipByParentId(directory.getParentId(), user); + return modifyElementInParent(dId, d -> d.rename(newName)); } + // endregion Update // region Delete @Override @Transactional - public void removeDirectory(String dId) { - Directory directory = retrieveDirectory(dId); + public void removeDirectory(String dId, User user) { + log.info("Removing directory '{}' for user '{}'", dId, user.getId()); + Directory directory = retrieveWithoutAuth(dId); + verifyOwnershipByParentId(directory.getParentId(), user); + FileElementContainer parent = getParentById(directory.getParentId()); parent.removeChild(directory); delegator.save(parent); } + // endregion Delete - // Get the fresh parent (important due to shallow copies of mappers) - private FileElementContainer getParentById(String parentId) { - if (parentId == null) throw new IllegalStateException("Directory has no parent corrupt state"); - return delegator.findContainerById(parentId) - .orElseThrow(() -> new IllegalStateException("Parent not found with ID" + parentId)); - } + // region AbstractFileElementService Implementation - private Directory findDirectoryInParent(FileElementContainer parent, String dId) { - return parent.getContents().stream() - .filter(c -> c.getId().equals(dId)) - .filter(c -> c instanceof Directory) - .map(c -> (Directory) c) - .findFirst() - .orElseThrow(() -> new IllegalStateException("File not found in parent container (ID: " + dId + ")")); + @Override + protected Directory retrieveWithoutAuth(String id) { + return repository.findById(id).orElseThrow(NoSuchElementException::new); } - private Directory modifyDirectoryInParent(String dId, Consumer modifier) { - Directory tempDir = retrieveDirectory(dId); - FileElementContainer parent = getParentById(tempDir.getParentId()); - - // Finding the β€œreal” child in the context of the parents - Directory childInParent = findDirectoryInParent(parent, dId); - - // Apply changes - modifier.accept(childInParent); + @Override + protected String getElementTypeName() { + return "directory"; + } - // Save changes through parent - FileElementContainer savedParent = delegator.save(parent); + @Override + protected boolean isCorrectType(FileElement element) { + return element instanceof Directory; + } - // Return updated child - return findDirectoryInParent(savedParent, dId); + @Override + protected Directory castToType(FileElement element) { + return (Directory) element; } + + // endregion } diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/services/FileService.java b/backend/src/main/java/edu/kit/quak/application/filesystem/services/FileService.java index b03f53a9..201181f4 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/services/FileService.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/services/FileService.java @@ -5,117 +5,141 @@ import edu.kit.quak.application.filesystem.ports.out.FileContentRepositoryPort; import edu.kit.quak.application.filesystem.ports.out.FileRepositoryPort; import edu.kit.quak.core.filesystem.model.File; +import edu.kit.quak.core.filesystem.model.FileElement; import edu.kit.quak.core.filesystem.model.FileElementContainer; +import edu.kit.quak.core.user.model.User; +import java.util.NoSuchElementException; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.NoSuchElementException; -import java.util.function.Consumer; - @Service -public class FileService implements FileServicePort { +@Slf4j +public class FileService extends AbstractFileElementService implements FileServicePort { private final FileRepositoryPort repository; private final FileContentRepositoryPort contentRepository; - private final FileElementContainerRepositoryDelegator delegator; - public FileService(FileRepositoryPort repository, FileContentRepositoryPort contentRepository, FileElementContainerRepositoryDelegator delegator) { + public FileService( + FileRepositoryPort repository, + FileContentRepositoryPort contentRepository, + FileElementContainerRepositoryDelegator delegator) { + super(delegator); this.repository = repository; this.contentRepository = contentRepository; - this.delegator = delegator; } // region Create @Override @Transactional - public File createFile(File element, String parentId) { + public File createFile(File element, String parentId, User user) { + log.info( + "Creating file '{}' in parent '{}' for user '{}'", + element.getName(), + parentId, + user.getId()); + verifyOwnershipByParentId(parentId, user); + FileElementContainer parent = getParentById(parentId); parent.addChild(element); FileElementContainer savedParent = delegator.save(parent); // Create initially empty content entry - File createdFile = findFileInParent(savedParent, element.getId()); + File createdFile = findElementInParent(savedParent, element.getId()); contentRepository.saveContent(createdFile.getId(), new byte[0]); return createdFile; } + // endregion Create // region Read @Override - public File retrieveFile(String id) { - return repository.findById(id).orElseThrow(NoSuchElementException::new); + public File retrieveFile(String id, User user) { + log.debug("Retrieving file '{}' for user '{}'", id, user.getId()); + File file = repository.findById(id).orElseThrow(NoSuchElementException::new); + verifyOwnershipByParentId(file.getParentId(), user); + return file; } @Override @Transactional - public byte[] getFileContent(String fId) { - if (!repository.existsById(fId)) { - throw new NoSuchElementException("File not found: " + fId); - } + public byte[] getFileContent(String fId, User user) { + log.debug("Retrieving content for file '{}'", fId); + File file = + repository + .findById(fId) + .orElseThrow(() -> new NoSuchElementException("File not found: " + fId)); + verifyOwnershipByParentId(file.getParentId(), user); return contentRepository.loadContent(fId).orElseThrow(NoSuchElementException::new); } + // endregion Retrieve // region Update @Override @Transactional - public File renameFile(String fId, String newName) { - return modifyFileInParent(fId, file -> file.rename(newName)); + public File renameFile(String fId, String newName, User user) { + log.info("Renaming file '{}' to '{}' for user '{}'", fId, newName, user.getId()); + File file = repository.findById(fId).orElseThrow(NoSuchElementException::new); + verifyOwnershipByParentId(file.getParentId(), user); + return modifyElementInParent(fId, f -> f.rename(newName)); } @Override @Transactional - public void setFileContent(String fId, byte[] content, String contentType) { - modifyFileInParent(fId, file -> { - file.setLastAccessNow(); - file.setContentType(contentType); - }); + public void setFileContent(String fId, byte[] content, String contentType, User user) { + log.info("Updating content for file '{}'", fId); + File file = repository.findById(fId).orElseThrow(NoSuchElementException::new); + verifyOwnershipByParentId(file.getParentId(), user); + + modifyElementInParent( + fId, + f -> { + f.setLastAccessNow(); + f.setContentType(contentType); + }); // Store content blob seperate contentRepository.saveContent(fId, content); } + // endregion Update // region Delete @Override @Transactional - public void removeFile(String fId) { - File file = retrieveFile(fId); + public void removeFile(String fId, User user) { + log.info("Removing file '{}' for user '{}'", fId, user.getId()); + File file = retrieveWithoutAuth(fId); + verifyOwnershipByParentId(file.getParentId(), user); + FileElementContainer parent = getParentById(file.getParentId()); parent.removeChild(file); delegator.save(parent); contentRepository.deleteContent(fId); } + // endregion Delete - // Get the fresh parent (important due to shallow copies of mappers) - private FileElementContainer getParentById(String parentId) { - if (parentId == null) throw new IllegalStateException("File has no parent corrupt state"); - return delegator.findContainerById(parentId) - .orElseThrow(() -> new IllegalStateException("Parent not found with ID" + parentId)); - } + // region AbstractFileElementService Implementation - private File findFileInParent(FileElementContainer parent, String fileId) { - return parent.getContents().stream() - .filter(c -> c.getId().equals(fileId)) - .filter(c -> c instanceof File) - .map(c -> (File) c) - .findFirst() - .orElseThrow(() -> new IllegalStateException("File not found in parent container (ID: " + fileId + ")")); + @Override + protected File retrieveWithoutAuth(String id) { + return repository.findById(id).orElseThrow(NoSuchElementException::new); } - private File modifyFileInParent(String fileId, Consumer modifier) { - File tempFile = retrieveFile(fileId); - FileElementContainer parent = getParentById(tempFile.getParentId()); - - // Finding the β€œreal” child in the context of the parents - File childInParent = findFileInParent(parent, fileId); - - // Apply changes - modifier.accept(childInParent); + @Override + protected String getElementTypeName() { + return "file"; + } - // Save changes through parent - FileElementContainer savedParent = delegator.save(parent); + @Override + protected boolean isCorrectType(FileElement element) { + return element instanceof File; + } - // Return updated child - return findFileInParent(savedParent, fileId); + @Override + protected File castToType(FileElement element) { + return (File) element; } + + // endregion } diff --git a/backend/src/main/java/edu/kit/quak/application/filesystem/services/ProjectService.java b/backend/src/main/java/edu/kit/quak/application/filesystem/services/ProjectService.java index df28beb8..d4009da6 100644 --- a/backend/src/main/java/edu/kit/quak/application/filesystem/services/ProjectService.java +++ b/backend/src/main/java/edu/kit/quak/application/filesystem/services/ProjectService.java @@ -1,14 +1,19 @@ package edu.kit.quak.application.filesystem.services; +import edu.kit.quak.application.filesystem.exceptions.AccessDeniedException; import edu.kit.quak.application.filesystem.ports.in.ProjectServicePort; import edu.kit.quak.application.filesystem.ports.out.ProjectRepositoryPort; import edu.kit.quak.core.filesystem.model.Project; -import org.springframework.stereotype.Service; - +import edu.kit.quak.core.user.model.User; import java.util.List; import java.util.NoSuchElementException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service +@Transactional +@Slf4j public class ProjectService implements ProjectServicePort { private final ProjectRepositoryPort repository; @@ -18,30 +23,61 @@ public ProjectService(ProjectRepositoryPort repository) { } @Override - public Project createProject(Project container) { - return repository.save(container); + public Project createProject(Project project, User user) { + log.info("Creating project '{}' for user '{}'", project.getName(), user.getId()); + project.setOwnerId(user.getId()); + return repository.save(project); } @Override - public Project renameProject(String pId, String newName ) { - Project project = repository.findById(pId) - .orElseThrow(NoSuchElementException::new); + public Project renameProject(String pId, String newName, User user) { + log.info("Renaming project '{}' to '{}' for user '{}'", pId, newName, user.getId()); + Project project = repository.findById(pId).orElseThrow(NoSuchElementException::new); + + verifyOwnership(project, user); + project.rename(newName); return repository.save(project); } @Override - public void removeProject(String id) { + public void removeProject(String id, User user) { + log.info("Removing project '{}' for user '{}'", id, user.getId()); + Project project = repository.findById(id).orElseThrow(NoSuchElementException::new); + + verifyOwnership(project, user); + repository.deleteById(id); } @Override - public Project retrieveProject(String id) { - return repository.findById(id).orElseThrow(NoSuchElementException::new); + public Project retrieveProject(String id, User user) { + log.debug("Retrieving project '{}' for user '{}'", id, user.getId()); + Project project = repository.findById(id).orElseThrow(NoSuchElementException::new); + + verifyOwnership(project, user); + + return project; } @Override - public List listProjects() { - return repository.getAllProjects(); + public List listProjects(User user) { + log.debug("Listing projects for user '{}'", user.getId()); + return repository.getProjectsByOwnerId(user.getId()); + } + + /** + * Verifies that the given user owns the given project. + * + * @throws AccessDeniedException if user doesn't own the project + */ + private void verifyOwnership(Project project, User user) { + if (project.getOwnerId() == null || !project.getOwnerId().equals(user.getId())) { + log.warn( + "Access denied: User '{}' does not own project '{}'", + user.getId(), + project.getId()); + throw new AccessDeniedException("project", project.getId()); + } } } diff --git a/backend/src/main/java/edu/kit/quak/application/library/exceptions/GateDefinitionNotFoundException.java b/backend/src/main/java/edu/kit/quak/application/library/exceptions/GateDefinitionNotFoundException.java index 4a9da40f..02441bf4 100644 --- a/backend/src/main/java/edu/kit/quak/application/library/exceptions/GateDefinitionNotFoundException.java +++ b/backend/src/main/java/edu/kit/quak/application/library/exceptions/GateDefinitionNotFoundException.java @@ -4,4 +4,4 @@ public class GateDefinitionNotFoundException extends RuntimeException { public GateDefinitionNotFoundException(String name) { super("Gate with name '" + name + "' not found."); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/application/library/ports/in/GateDefinitionServicePort.java b/backend/src/main/java/edu/kit/quak/application/library/ports/in/GateDefinitionServicePort.java index e64175e8..bafb28fe 100644 --- a/backend/src/main/java/edu/kit/quak/application/library/ports/in/GateDefinitionServicePort.java +++ b/backend/src/main/java/edu/kit/quak/application/library/ports/in/GateDefinitionServicePort.java @@ -1,11 +1,11 @@ package edu.kit.quak.application.library.ports.in; import edu.kit.quak.core.library.model.GateDefinition; - import java.util.List; import java.util.Optional; public interface GateDefinitionServicePort { List getAllGateDefinitions(); + Optional getGateDefinitionById(String id); -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/application/library/ports/out/GateDefinitionRepositoryPort.java b/backend/src/main/java/edu/kit/quak/application/library/ports/out/GateDefinitionRepositoryPort.java index 09e766ac..db92d02b 100644 --- a/backend/src/main/java/edu/kit/quak/application/library/ports/out/GateDefinitionRepositoryPort.java +++ b/backend/src/main/java/edu/kit/quak/application/library/ports/out/GateDefinitionRepositoryPort.java @@ -1,11 +1,11 @@ package edu.kit.quak.application.library.ports.out; import edu.kit.quak.core.library.model.GateDefinition; - import java.util.List; import java.util.Optional; public interface GateDefinitionRepositoryPort { List findAllGateDefinitions(); + Optional findGateDefinitionById(String id); } diff --git a/backend/src/main/java/edu/kit/quak/application/library/services/GateDefinitionService.java b/backend/src/main/java/edu/kit/quak/application/library/services/GateDefinitionService.java index 7e313731..4411196e 100644 --- a/backend/src/main/java/edu/kit/quak/application/library/services/GateDefinitionService.java +++ b/backend/src/main/java/edu/kit/quak/application/library/services/GateDefinitionService.java @@ -3,10 +3,9 @@ import edu.kit.quak.application.library.ports.in.GateDefinitionServicePort; import edu.kit.quak.application.library.ports.out.GateDefinitionRepositoryPort; import edu.kit.quak.core.library.model.GateDefinition; -import org.springframework.stereotype.Service; - import java.util.List; import java.util.Optional; +import org.springframework.stereotype.Service; @Service public class GateDefinitionService implements GateDefinitionServicePort { @@ -26,4 +25,4 @@ public List getAllGateDefinitions() { public Optional getGateDefinitionById(String id) { return gateRepository.findGateDefinitionById(id); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/application/user/dto/AuthStatusResponse.java b/backend/src/main/java/edu/kit/quak/application/user/dto/AuthStatusResponse.java new file mode 100644 index 00000000..5039d0a2 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/dto/AuthStatusResponse.java @@ -0,0 +1,11 @@ +package edu.kit.quak.application.user.dto; + +import edu.kit.quak.core.user.model.User; + +/** + * DTO for authentication status response. + * + * @param authenticated Whether the user is authenticated + * @param user User information if authenticated, null otherwise + */ +public record AuthStatusResponse(boolean authenticated, User user) {} diff --git a/backend/src/main/java/edu/kit/quak/application/user/dto/LogoutResponse.java b/backend/src/main/java/edu/kit/quak/application/user/dto/LogoutResponse.java new file mode 100644 index 00000000..cabc62be --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/dto/LogoutResponse.java @@ -0,0 +1,4 @@ +package edu.kit.quak.application.user.dto; + +/** DTO for logout response. */ +public record LogoutResponse(String message) {} diff --git a/backend/src/main/java/edu/kit/quak/application/user/exceptions/UserNotFoundException.java b/backend/src/main/java/edu/kit/quak/application/user/exceptions/UserNotFoundException.java new file mode 100644 index 00000000..9ef41585 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/exceptions/UserNotFoundException.java @@ -0,0 +1,16 @@ +package edu.kit.quak.application.user.exceptions; + +/** + * Thrown when a user cannot be found in the database. This is a domain exception that should be + * mapped to HTTP 401 Unauthorized by the infrastructure layer. + */ +public class UserNotFoundException extends RuntimeException { + + public UserNotFoundException(String issuer, String subject) { + super("User not found for issuer '" + issuer + "' and subject '" + subject + "'"); + } + + public UserNotFoundException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/edu/kit/quak/application/user/ports/in/AuthServicePort.java b/backend/src/main/java/edu/kit/quak/application/user/ports/in/AuthServicePort.java new file mode 100644 index 00000000..33561590 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/ports/in/AuthServicePort.java @@ -0,0 +1,29 @@ +package edu.kit.quak.application.user.ports.in; + +import edu.kit.quak.application.user.dto.AuthStatusResponse; +import edu.kit.quak.application.user.dto.LogoutResponse; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import java.util.Optional; + +/** + * Input port for authentication operations. This port is framework-agnostic and uses only domain + * concepts. + */ +public interface AuthServicePort { + + /** + * Builds authentication status response from the provided authenticated user. + * + * @param authenticatedUser Optional authenticated user, empty if not authenticated + * @return AuthStatusResponse containing authentication status and user info if authenticated + */ + AuthStatusResponse getAuthenticationStatus(Optional authenticatedUser); + + /** + * Handles logout operation. + * + * @param sessionId The session ID to logout + * @return LogoutResponse containing logout status message + */ + LogoutResponse logout(String sessionId); +} diff --git a/backend/src/main/java/edu/kit/quak/application/user/ports/in/OidcSyncServicePort.java b/backend/src/main/java/edu/kit/quak/application/user/ports/in/OidcSyncServicePort.java new file mode 100644 index 00000000..71989bc1 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/ports/in/OidcSyncServicePort.java @@ -0,0 +1,8 @@ +package edu.kit.quak.application.user.ports.in; + +import edu.kit.quak.core.user.model.User; + +/** Input port for OIDC user synchronization. */ +public interface OidcSyncServicePort { + User syncUser(String issuer, OidcUserInfo userInfo); +} diff --git a/backend/src/main/java/edu/kit/quak/application/user/ports/in/OidcUserInfo.java b/backend/src/main/java/edu/kit/quak/application/user/ports/in/OidcUserInfo.java new file mode 100644 index 00000000..3f4b66f7 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/ports/in/OidcUserInfo.java @@ -0,0 +1,11 @@ +package edu.kit.quak.application.user.ports.in; + +/** DTO for OIDC user information to decouple the application layer from Spring Security. */ +public record OidcUserInfo( + String sub, + String email, + Boolean emailVerified, + String fullName, + String givenName, + String familyName, + String picture) {} diff --git a/backend/src/main/java/edu/kit/quak/application/user/ports/in/UserServicePort.java b/backend/src/main/java/edu/kit/quak/application/user/ports/in/UserServicePort.java new file mode 100644 index 00000000..53c63ec5 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/ports/in/UserServicePort.java @@ -0,0 +1,27 @@ +package edu.kit.quak.application.user.ports.in; + +import edu.kit.quak.core.user.model.AuthenticatedUser; +import edu.kit.quak.core.user.model.User; +import java.util.Optional; +import java.util.UUID; + +/** + * Input port defining user-related use cases. Uses only domain concepts, no framework dependencies. + */ +public interface UserServicePort { + User getAuthenticatedUser(AuthenticatedUser authenticatedUser); + + Optional findById(UUID id); + + Optional findByIssuerAndSub(String issuer, String sub); + + /** + * Efficiently retrieves only the authenticated user's UUID without loading the full entity. Use + * this when only the user ID is needed (e.g., for ownership verification). + * + * @param authenticatedUser The authenticated user's claims + * @return The user's UUID + * @throws UserNotFoundException if user doesn't exist + */ + UUID getAuthenticatedUserId(AuthenticatedUser authenticatedUser); +} diff --git a/backend/src/main/java/edu/kit/quak/application/user/ports/out/UserRepositoryPort.java b/backend/src/main/java/edu/kit/quak/application/user/ports/out/UserRepositoryPort.java new file mode 100644 index 00000000..e442cfe6 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/ports/out/UserRepositoryPort.java @@ -0,0 +1,26 @@ +package edu.kit.quak.application.user.ports.out; + +import edu.kit.quak.core.user.model.User; +import java.util.Optional; +import java.util.UUID; + +/** Output port for user persistence operations. */ +public interface UserRepositoryPort { + User save(User user); + + Optional findById(UUID id); + + Optional findByIssuerAndSub(String issuer, String sub); + + /** + * Efficiently retrieves only the user's UUID without loading the full entity. Use this when + * only the user ID is needed (e.g., for ownership verification). + * + * @param issuer The OAuth2/OIDC issuer + * @param sub The subject claim from the OIDC token + * @return The user's UUID if found + */ + Optional findIdByIssuerAndSub(String issuer, String sub); + + void deleteById(UUID id); +} diff --git a/backend/src/main/java/edu/kit/quak/application/user/services/AuthService.java b/backend/src/main/java/edu/kit/quak/application/user/services/AuthService.java new file mode 100644 index 00000000..a89d0981 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/services/AuthService.java @@ -0,0 +1,61 @@ +package edu.kit.quak.application.user.services; + +import edu.kit.quak.application.user.dto.AuthStatusResponse; +import edu.kit.quak.application.user.dto.LogoutResponse; +import edu.kit.quak.application.user.ports.in.AuthServicePort; +import edu.kit.quak.application.user.ports.out.UserRepositoryPort; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * Service for authentication-related business logic. Handles auth status checks, user info + * retrieval, and logout operations. + * + *

This service is framework-agnostic and works only with domain concepts. The infrastructure + * layer (AuthRestAdapter) is responsible for extracting authentication information from the + * framework and passing it here. + */ +@Service +@Slf4j +public class AuthService implements AuthServicePort { + + private final UserRepositoryPort userRepository; + + public AuthService(UserRepositoryPort userRepository) { + this.userRepository = userRepository; + } + + @Override + public AuthStatusResponse getAuthenticationStatus( + Optional authenticatedUser) { + log.debug("Checking auth status. Authenticated: {}", authenticatedUser.isPresent()); + + if (authenticatedUser.isEmpty()) { + return new AuthStatusResponse(false, null); + } + + AuthenticatedUser domainUser = authenticatedUser.get(); + + // Fetch the full User object from the database + log.debug( + "Fetching full user details for issuer: {}, sub: {}", + domainUser.issuer(), + domainUser.subject()); + + return userRepository + .findByIssuerAndSub(domainUser.issuer(), domainUser.subject()) + .map(user -> new AuthStatusResponse(true, user)) + .orElse(new AuthStatusResponse(false, null)); + } + + @Override + public LogoutResponse logout(String sessionId) { + log.info("Processing logout for session: {}", sessionId); + // Business logic for logout can be added here if needed + // (e.g., audit logging, cleanup operations) + + return new LogoutResponse("Logged out successfully"); + } +} diff --git a/backend/src/main/java/edu/kit/quak/application/user/services/OidcUserSyncService.java b/backend/src/main/java/edu/kit/quak/application/user/services/OidcUserSyncService.java new file mode 100644 index 00000000..45305d76 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/services/OidcUserSyncService.java @@ -0,0 +1,68 @@ +package edu.kit.quak.application.user.services; + +import edu.kit.quak.application.user.ports.in.OidcSyncServicePort; +import edu.kit.quak.application.user.ports.in.OidcUserInfo; +import edu.kit.quak.application.user.ports.out.UserRepositoryPort; +import edu.kit.quak.core.user.model.User; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Service for synchronizing OIDC user information with the database. This service is called after + * successful OAuth2 login to ensure user data is up-to-date. + */ +@Service +@Slf4j +public class OidcUserSyncService implements OidcSyncServicePort { + + private final UserRepositoryPort userRepository; + + public OidcUserSyncService(UserRepositoryPort userRepository) { + this.userRepository = userRepository; + } + + @Override + @Transactional + public User syncUser(String issuer, OidcUserInfo userInfo) { + String sub = userInfo.sub(); + if (sub == null) { + log.error("Subject (sub) claim is missing in OIDC user data for issuer '{}'", issuer); + throw new IllegalArgumentException("Subject (sub) claim is missing"); + } + + log.debug("Syncing user for issuer='{}' sub='{}'", issuer, sub); + + return userRepository + .findByIssuerAndSub(issuer, sub) + .map(existingUser -> updateUser(existingUser, userInfo)) + .orElseGet(() -> createUser(issuer, sub, userInfo)); + } + + private User updateUser(User user, OidcUserInfo userInfo) { + log.info("Updating existing user '{}' from OIDC data", user.getId()); + user.updateFromOidc( + userInfo.email(), + userInfo.emailVerified(), + userInfo.fullName(), + userInfo.givenName(), + userInfo.familyName(), + userInfo.picture()); + return userRepository.save(user); + } + + private User createUser(String issuer, String sub, OidcUserInfo userInfo) { + log.info("Creating new user for issuer='{}' sub='{}'", issuer, sub); + User user = + User.createFromOidc( + issuer, + sub, + userInfo.email(), + userInfo.emailVerified(), + userInfo.fullName(), + userInfo.givenName(), + userInfo.familyName(), + userInfo.picture()); + return userRepository.save(user); + } +} diff --git a/backend/src/main/java/edu/kit/quak/application/user/services/UserService.java b/backend/src/main/java/edu/kit/quak/application/user/services/UserService.java new file mode 100644 index 00000000..1c46506a --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/application/user/services/UserService.java @@ -0,0 +1,79 @@ +package edu.kit.quak.application.user.services; + +import edu.kit.quak.application.user.exceptions.UserNotFoundException; +import edu.kit.quak.application.user.ports.in.UserServicePort; +import edu.kit.quak.application.user.ports.out.UserRepositoryPort; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import edu.kit.quak.core.user.model.User; +import java.util.Optional; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * Service for user-related business logic. Handles user authentication, authorization, and user + * data operations. + */ +@Service +@Slf4j +public class UserService implements UserServicePort { + + private final UserRepositoryPort userRepository; + + public UserService(UserRepositoryPort userRepository) { + this.userRepository = userRepository; + } + + @Override + public User getAuthenticatedUser(AuthenticatedUser authenticatedUser) { + log.debug( + "Fetching authenticated user details for issuer={} sub={}", + authenticatedUser.issuer(), + authenticatedUser.subject()); + // Look up the full user from the repository using the authenticated user's + // issuer and subject + return userRepository + .findByIssuerAndSub(authenticatedUser.issuer(), authenticatedUser.subject()) + .orElseThrow( + () -> { + log.warn( + "User not found for issuer={} sub={}", + authenticatedUser.issuer(), + authenticatedUser.subject()); + return new UserNotFoundException( + authenticatedUser.issuer(), authenticatedUser.subject()); + }); + } + + @Override + public Optional findById(UUID id) { + log.debug("Finding user by ID: {}", id); + return userRepository.findById(id); + } + + @Override + public Optional findByIssuerAndSub(String issuer, String sub) { + log.debug("Finding user by issuer={} sub={}", issuer, sub); + return userRepository.findByIssuerAndSub(issuer, sub); + } + + @Override + public UUID getAuthenticatedUserId(AuthenticatedUser authenticatedUser) { + log.debug( + "Fetching authenticated user ID for issuer={} sub={}", + authenticatedUser.issuer(), + authenticatedUser.subject()); + // Use the efficient query that only fetches the UUID + return userRepository + .findIdByIssuerAndSub(authenticatedUser.issuer(), authenticatedUser.subject()) + .orElseThrow( + () -> { + log.warn( + "User ID not found for issuer={} sub={}", + authenticatedUser.issuer(), + authenticatedUser.subject()); + return new UserNotFoundException( + authenticatedUser.issuer(), authenticatedUser.subject()); + }); + } +} diff --git a/backend/src/main/java/edu/kit/quak/core/circuit/model/QuantumCircuit.java b/backend/src/main/java/edu/kit/quak/core/circuit/model/QuantumCircuit.java index 43ef4320..1b444b45 100644 --- a/backend/src/main/java/edu/kit/quak/core/circuit/model/QuantumCircuit.java +++ b/backend/src/main/java/edu/kit/quak/core/circuit/model/QuantumCircuit.java @@ -5,7 +5,6 @@ import edu.kit.quak.core.circuit.model.register.QuantumRegister; import edu.kit.quak.core.circuit.model.register.Qubit; import edu.kit.quak.core.circuit.model.register.Register; - import java.util.*; public class QuantumCircuit extends ElementWithId { @@ -26,24 +25,32 @@ public void setRegisters(List registers) { } public QuantumRegister addQuantumRegister() { - int nextIndex = registers.stream() - .map(Register::getName) - .filter(name -> name.startsWith(REGISTER_PREFIX)) - .map(name -> name.substring(REGISTER_PREFIX.length())) - .mapToInt(Integer::parseInt) - .max() - .orElse(-1) + 1; + int nextIndex = + registers.stream() + .map(Register::getName) + .filter(name -> name.startsWith(REGISTER_PREFIX)) + .map(name -> name.substring(REGISTER_PREFIX.length())) + .mapToInt(Integer::parseInt) + .max() + .orElse(-1) + + 1; QuantumRegister register = new QuantumRegister(REGISTER_PREFIX + nextIndex); registers.add(register); return register; } public void deleteQuantumRegister(String qubitId) { - registers.removeIf(register -> - register.asQuantum() - .map(qReg -> !qReg.getQubits().isEmpty() && qReg.getQubits().getFirst().getId().equals(qubitId)) - .orElse(false) - ); + registers.removeIf( + register -> + register.asQuantum() + .map( + qReg -> + !qReg.getQubits().isEmpty() + && qReg.getQubits() + .getFirst() + .getId() + .equals(qubitId)) + .orElse(false)); } public void addQubit() { @@ -53,36 +60,52 @@ public void addQubit() { public void changeQubitName(String qubitId, String name) { for (Register register : registers) { - register.asQuantum().ifPresent(qReg -> { - boolean qubitFound = qReg.getQubits().stream() - .anyMatch(qubit -> qubit.getId().equals(qubitId)); - - if (qubitFound) { - qReg.setName(name); - } - }); + register.asQuantum() + .ifPresent( + qReg -> { + boolean qubitFound = + qReg.getQubits().stream() + .anyMatch(qubit -> qubit.getId().equals(qubitId)); + + if (qubitFound) { + qReg.setName(name); + } + }); } } - public void addElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier definitionId, int registerIdx, int positionIdx) { + public void addElementaryQuantumGate( + ElementaryQuantumGateDefinitionIdentifier definitionId, + int registerIdx, + int positionIdx) { if (registerIdx < 0 || registerIdx >= registers.size()) { throw new IllegalArgumentException("Register index out of bounds: " + registerIdx); } - registers.get(registerIdx).asQuantum().ifPresentOrElse( - qReg -> qReg.addElementaryQuantumGate(definitionId, positionIdx), - () -> { - throw new IllegalArgumentException( - String.format("Register at index %d is not a quantum register (cannot add gate).", registerIdx) - ); - } - ); + registers + .get(registerIdx) + .asQuantum() + .ifPresentOrElse( + qReg -> qReg.addElementaryQuantumGate(definitionId, positionIdx), + () -> { + throw new IllegalArgumentException( + String.format( + "Register at index %d is not a quantum register (cannot" + + " add gate).", + registerIdx)); + }); } public void moveQuantumOperation(String operationId, int targetRegisterIdx, int positionIdx) { // search for tuple (Qubit, Operation) - var location = findOperationLocation(operationId) - .orElseThrow(() -> new IllegalArgumentException(String.format("Operation %s not found within circuit %s.", operationId, id))); + var location = + findOperationLocation(operationId) + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Operation %s not found within circuit %s.", + operationId, id))); Qubit sourceQubit = location.qubit(); QuantumOperation operationToMove = location.operation(); @@ -91,30 +114,40 @@ public void moveQuantumOperation(String operationId, int targetRegisterIdx, int throw new IllegalArgumentException("Target register index out of bounds."); } - QuantumRegister targetReg = registers.get(targetRegisterIdx).asQuantum() - .orElseThrow(() -> new IllegalArgumentException("Target register is not a quantum register.")); + QuantumRegister targetReg = + registers + .get(targetRegisterIdx) + .asQuantum() + .orElseThrow( + () -> + new IllegalArgumentException( + "Target register is not a quantum register.")); if (targetReg.getQubits().isEmpty()) { throw new IllegalStateException("Target register has no qubits."); } - + Qubit targetQubit = targetReg.getQubits().getFirst(); sourceQubit.removeOperation(operationToMove); - operationToMove.generateNewId(); //Generate new id because of orphan removal problems with Hibernate. + operationToMove.generateNewId(); // Generate new id because of orphan removal problems with + // Hibernate. targetQubit.addOperation(positionIdx, operationToMove); } public void deleteQuantumOperation(String operationId) { - boolean removed = findOperationLocation(operationId) - .map(loc -> { - loc.qubit().removeOperation(loc.operation()); - return true; - }) - .orElse(false); + boolean removed = + findOperationLocation(operationId) + .map( + loc -> { + loc.qubit().removeOperation(loc.operation()); + return true; + }) + .orElse(false); if (!removed) { - throw new IllegalArgumentException(String.format("Operation %s not found within circuit %s.", operationId, id)); + throw new IllegalArgumentException( + String.format("Operation %s not found within circuit %s.", operationId, id)); } } @@ -122,7 +155,8 @@ public void deleteQuantumOperation(String operationId) { public String toString() { StringBuilder sb = new StringBuilder(); sb.append("QuantumCircuit(id=").append(id).append(")\n"); - registers.forEach(reg -> sb.append(" ").append(reg.toString().replace("\n", "\n ")).append("\n")); + registers.forEach( + reg -> sb.append(" ").append(reg.toString().replace("\n", "\n ")).append("\n")); return sb.toString().trim(); } @@ -132,9 +166,11 @@ private Optional findOperationLocation(String operationId) { return registers.stream() .flatMap(reg -> reg.asQuantum().stream()) .flatMap(qReg -> qReg.getQubits().stream()) - .flatMap(qubit -> qubit.getOperations().stream() - .filter(op -> op.getId().equals(operationId)) - .map(op -> new OperationLocation(qubit, op))) + .flatMap( + qubit -> + qubit.getOperations().stream() + .filter(op -> op.getId().equals(operationId)) + .map(op -> new OperationLocation(qubit, op))) .findFirst(); } } diff --git a/backend/src/main/java/edu/kit/quak/core/circuit/model/operation/ElementaryQuantumGate.java b/backend/src/main/java/edu/kit/quak/core/circuit/model/operation/ElementaryQuantumGate.java index 378c7d4a..76388035 100644 --- a/backend/src/main/java/edu/kit/quak/core/circuit/model/operation/ElementaryQuantumGate.java +++ b/backend/src/main/java/edu/kit/quak/core/circuit/model/operation/ElementaryQuantumGate.java @@ -16,17 +16,32 @@ public ElementaryQuantumGateDefinitionIdentifier getDefinitionId() { return definitionId; } - public double getTheta() { return theta; } - public void setTheta(double theta) { this.theta = theta; } + public double getTheta() { + return theta; + } - public double getPhi() { return phi; } - public void setPhi(double phi) { this.phi = phi; } + public void setTheta(double theta) { + this.theta = theta; + } - public double getLambda() { return lambda; } - public void setLambda(double lambda) { this.lambda = lambda; } + public double getPhi() { + return phi; + } + + public void setPhi(double phi) { + this.phi = phi; + } + + public double getLambda() { + return lambda; + } + + public void setLambda(double lambda) { + this.lambda = lambda; + } @Override public String toString() { return String.format("[Gate: %s (id=%s)]", getDefinitionId(), getId()); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/core/circuit/model/operation/QuantumOperation.java b/backend/src/main/java/edu/kit/quak/core/circuit/model/operation/QuantumOperation.java index 2948ba2f..d7a8be1d 100644 --- a/backend/src/main/java/edu/kit/quak/core/circuit/model/operation/QuantumOperation.java +++ b/backend/src/main/java/edu/kit/quak/core/circuit/model/operation/QuantumOperation.java @@ -6,4 +6,4 @@ public abstract class QuantumOperation extends ElementWithId { protected QuantumOperation() { super(); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/core/circuit/model/register/ClassicRegister.java b/backend/src/main/java/edu/kit/quak/core/circuit/model/register/ClassicRegister.java index 2dbbb443..77aacef8 100644 --- a/backend/src/main/java/edu/kit/quak/core/circuit/model/register/ClassicRegister.java +++ b/backend/src/main/java/edu/kit/quak/core/circuit/model/register/ClassicRegister.java @@ -27,4 +27,4 @@ public void setBits(List bits) { public void addBit(Boolean bit) { bits.add(bit); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/core/circuit/model/register/QuantumRegister.java b/backend/src/main/java/edu/kit/quak/core/circuit/model/register/QuantumRegister.java index 9df1e056..abcb3547 100644 --- a/backend/src/main/java/edu/kit/quak/core/circuit/model/register/QuantumRegister.java +++ b/backend/src/main/java/edu/kit/quak/core/circuit/model/register/QuantumRegister.java @@ -2,7 +2,6 @@ import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGate; import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGateDefinitionIdentifier; - import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -34,7 +33,8 @@ public Qubit addQubit() { return qubit; } - public void addElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier definitionId, int positionIdx) { + public void addElementaryQuantumGate( + ElementaryQuantumGateDefinitionIdentifier definitionId, int positionIdx) { Qubit qubit = qubits.getFirst(); ElementaryQuantumGate gate = new ElementaryQuantumGate(definitionId); qubit.addOperation(positionIdx, gate); diff --git a/backend/src/main/java/edu/kit/quak/core/circuit/model/register/Qubit.java b/backend/src/main/java/edu/kit/quak/core/circuit/model/register/Qubit.java index e2b587c4..3be2a2a5 100644 --- a/backend/src/main/java/edu/kit/quak/core/circuit/model/register/Qubit.java +++ b/backend/src/main/java/edu/kit/quak/core/circuit/model/register/Qubit.java @@ -2,7 +2,6 @@ import edu.kit.quak.core.circuit.model.ElementWithId; import edu.kit.quak.core.circuit.model.operation.QuantumOperation; - import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -46,4 +45,4 @@ public String toString() { } return sb.toString(); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/core/circuit/model/register/Register.java b/backend/src/main/java/edu/kit/quak/core/circuit/model/register/Register.java index 7c9414d6..bab00ab1 100644 --- a/backend/src/main/java/edu/kit/quak/core/circuit/model/register/Register.java +++ b/backend/src/main/java/edu/kit/quak/core/circuit/model/register/Register.java @@ -1,7 +1,6 @@ package edu.kit.quak.core.circuit.model.register; import edu.kit.quak.core.circuit.model.ElementWithId; - import java.util.Optional; public abstract class Register extends ElementWithId { @@ -22,6 +21,7 @@ public void setName(String name) { /** * Capability Query: Is this a Quantum Register? + * * @return Optional containing this if it is a QuantumRegister, empty otherwise. */ public Optional asQuantum() { @@ -30,6 +30,7 @@ public Optional asQuantum() { /** * Capability Query: Is this a Classic Register? + * * @return Optional containing this if it is a ClassicRegister, empty otherwise. */ public Optional asClassic() { diff --git a/backend/src/main/java/edu/kit/quak/core/circuit/package-info.java b/backend/src/main/java/edu/kit/quak/core/circuit/package-info.java index c6aee03a..d95f76fc 100644 --- a/backend/src/main/java/edu/kit/quak/core/circuit/package-info.java +++ b/backend/src/main/java/edu/kit/quak/core/circuit/package-info.java @@ -1,19 +1,20 @@ /** - * This package implements the quantum circuit metamodel proposed by Gemeinhardt et al. (2024), - * "A Model-Driven Framework for Composition-Based Quantum Circuit Design". - *

- * The architecture is based on the metamodel but does not adopt every aspect of it. - * Aspects, that have been adopted, include: + * This package implements the quantum circuit metamodel proposed by Gemeinhardt et al. (2024), "A + * Model-Driven Framework for Composition-Based Quantum Circuit Design". + * + *

The architecture is based on the metamodel but does not adopt every aspect of it. Aspects, + * that have been adopted, include: + * *

    - *
  • Quantum circuits
  • - *
  • Classical and quantum registers
  • - *
  • Elementary and composite quantum operations
  • + *
  • Quantum circuits + *
  • Classical and quantum registers + *
  • Elementary and composite quantum operations *
* - * Reference: - * F. Gemeinhardt, A. Garmendia, M. Wimmer, R. Wille, "A Model-Driven Framework for Composition-Based Quantum Circuit Design", - * Johannes Kepler University Linz, 2024. + *

Reference: F. Gemeinhardt, A. Garmendia, M. Wimmer, R. Wille, "A Model-Driven Framework for + * Composition-Based Quantum Circuit Design", Johannes Kepler University Linz, 2024. * - * @see ACM Digital Library: A Model-Driven Framework for Composition-Based Quantum Circuit Design + * @see ACM Digital Library: A Model-Driven + * Framework for Composition-Based Quantum Circuit Design */ -package edu.kit.quak.core.circuit; \ No newline at end of file +package edu.kit.quak.core.circuit; diff --git a/backend/src/main/java/edu/kit/quak/core/filesystem/model/Directory.java b/backend/src/main/java/edu/kit/quak/core/filesystem/model/Directory.java index 17b73de0..b1932c9f 100644 --- a/backend/src/main/java/edu/kit/quak/core/filesystem/model/Directory.java +++ b/backend/src/main/java/edu/kit/quak/core/filesystem/model/Directory.java @@ -1,8 +1,7 @@ package edu.kit.quak.core.filesystem.model; /** - * Domain POJO for Directory - * A Directory is a container of FileElements inside a project. + * Domain POJO for Directory A Directory is a container of FileElements inside a project. * * @author Henrik K */ @@ -25,5 +24,7 @@ public String getTypeIdentifier() { } @Override - public char getIdPrefix() { return ID_PREFIX; } + public char getIdPrefix() { + return ID_PREFIX; + } } diff --git a/backend/src/main/java/edu/kit/quak/core/filesystem/model/File.java b/backend/src/main/java/edu/kit/quak/core/filesystem/model/File.java index 5c9b995e..9aff338d 100644 --- a/backend/src/main/java/edu/kit/quak/core/filesystem/model/File.java +++ b/backend/src/main/java/edu/kit/quak/core/filesystem/model/File.java @@ -1,16 +1,18 @@ package edu.kit.quak.core.filesystem.model; -import org.springframework.http.MediaType; - -/** - * Domain POJO for FileElement - */ +/** Domain POJO for FileElement */ public class File extends FileElement { public static final String TYPE_IDENTIFIER = "file"; public static final char ID_PREFIX = 'f'; - private String contentType = MediaType.ALL_VALUE; + /** + * Default content type for files when no specific type is set. Represents "accept all" or + * "unknown" media type. + */ + public static final String DEFAULT_CONTENT_TYPE = "*/*"; + + private String contentType = DEFAULT_CONTENT_TYPE; public File(String name, String parentId) { super(name, parentId); @@ -20,17 +22,24 @@ protected File() { super(); } - //region getter and setter + // region getter and setter @Override public String getTypeIdentifier() { return TYPE_IDENTIFIER; } + @Override - public char getIdPrefix() { return ID_PREFIX; } + public char getIdPrefix() { + return ID_PREFIX; + } - public String getContentType() { return contentType; } - public void setContentType(String contentType) { this.contentType = contentType; } + public String getContentType() { + return contentType; + } + public void setContentType(String contentType) { + this.contentType = contentType; + } - //endregion + // endregion } diff --git a/backend/src/main/java/edu/kit/quak/core/filesystem/model/FileElement.java b/backend/src/main/java/edu/kit/quak/core/filesystem/model/FileElement.java index b2299ad1..2f8456f2 100644 --- a/backend/src/main/java/edu/kit/quak/core/filesystem/model/FileElement.java +++ b/backend/src/main/java/edu/kit/quak/core/filesystem/model/FileElement.java @@ -2,15 +2,19 @@ import java.time.Instant; import java.util.UUID; +import lombok.Getter; +import lombok.Setter; /** - * Domain POJO for FileElement - * A FileElement is an element inside a {@link FileElementContainer container} or the container itself. - * The idea behind this class is the concept of files and directories as they are found inside a POSIX filesystem. + * Domain POJO for FileElement A FileElement is an element inside a {@link FileElementContainer + * container} or the container itself. The idea behind this class is the concept of files and + * directories as they are found inside a POSIX filesystem. * * @param The definitionId used by the implementing classes in the method * @author Henrik K */ +@Getter +@Setter public abstract class FileElement> { private String id; @@ -20,7 +24,11 @@ public abstract class FileElement> { private String parentId; // Frameworks only - protected FileElement() { } + protected FileElement() { + this.id = getIdPrefix() + "-" + UUID.randomUUID(); + this.createdOn = Instant.now(); + this.lastAccess = Instant.now(); + } public FileElement(String name, String parentId) { this.id = getIdPrefix() + "-" + UUID.randomUUID(); // ID generated in Domain (Best Practice) @@ -30,11 +38,13 @@ public FileElement(String name, String parentId) { this.lastAccess = Instant.now(); } - //region getter and setter - public String getId() { return id; } - public void setId(String id) { this.id = id; } - - public String getName() { return name; } + /** + * Renames this element and updates the lastAccess timestamp. For business logic, prefer this + * method over {@link #setName(String)}. + * + * @param newName the new name for this element + * @throws IllegalArgumentException if the name is null or blank + */ public void rename(String newName) { if (newName == null || newName.isBlank()) { throw new IllegalArgumentException("Name cannot be empty"); @@ -43,29 +53,13 @@ public void rename(String newName) { this.lastAccess = Instant.now(); } - public String getParentId() { - return parentId; - } - public void setParentId(String parentId) { - this.parentId = parentId; - } - - protected void setCreatedOn(Instant createdOn) { this.createdOn = createdOn; } - public Instant getCreatedOn() { - return createdOn; - } - - public Instant getLastAccess() { - return lastAccess; - } - public void setLastAccess(Instant lastAccess) { this.lastAccess = lastAccess; } public void setLastAccessNow() { this.lastAccess = Instant.now(); } public abstract String getTypeIdentifier(); + public abstract char getIdPrefix(); - //endregion @Override public final boolean equals(Object o) { @@ -76,6 +70,6 @@ public final boolean equals(Object o) { @Override public final int hashCode() { - return FileElement.class.hashCode(); + return getId() != null ? getId().hashCode() : 0; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/core/filesystem/model/FileElementContainer.java b/backend/src/main/java/edu/kit/quak/core/filesystem/model/FileElementContainer.java index dc45b27e..ff93ff92 100644 --- a/backend/src/main/java/edu/kit/quak/core/filesystem/model/FileElementContainer.java +++ b/backend/src/main/java/edu/kit/quak/core/filesystem/model/FileElementContainer.java @@ -5,12 +5,13 @@ import java.util.Set; /** - * Domain POJO for FileElementContainer - * A FileElementContainer is a container that holds {@link FileElement FileElements}. + * Domain POJO for FileElementContainer A FileElementContainer is a container that holds {@link + * FileElement FileElements}. * * @author Henrik K */ -public abstract class FileElementContainer> extends FileElement { +public abstract class FileElementContainer> + extends FileElement { protected Set> contents = new HashSet<>(); @@ -25,14 +26,20 @@ protected FileElementContainer() { public Set> getContents() { return Collections.unmodifiableSet(contents); } - public void setContents(Set> contents) { this.contents = contents; } + + public void setContents(Set> contents) { + this.contents = contents; + } public void addChild(FileElement child) { // No duplicate names within one parent if (hasChildWithName(child.getName())) { throw new IllegalArgumentException( - "An element with the name '" + child.getName() + "' already exists in '" + this.getName() + "'" - ); + "An element with the name '" + + child.getName() + + "' already exists in '" + + this.getName() + + "'"); } this.contents.add(child); @@ -53,4 +60,4 @@ private boolean hasChildWithName(String name) { // Child will find itself make sure it is not added yet .anyMatch(existing -> existing.getName().equalsIgnoreCase(name)); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/core/filesystem/model/Project.java b/backend/src/main/java/edu/kit/quak/core/filesystem/model/Project.java index 65df1618..c9d9d988 100644 --- a/backend/src/main/java/edu/kit/quak/core/filesystem/model/Project.java +++ b/backend/src/main/java/edu/kit/quak/core/filesystem/model/Project.java @@ -1,29 +1,49 @@ package edu.kit.quak.core.filesystem.model; +import java.util.UUID; +import lombok.Getter; +import lombok.Setter; + /** - * Domain POJO for Project - * A project is a top level container of {@link FileElement}. + * Domain POJO for Project A project is a top level container of {@link FileElement}. * - * @implNote Projects are in their core similar to a directory. - * They are implemented in an own class to allow for different functionalities - * later in development. + * @implNote Projects are in their core similar to a directory. They are implemented in an own class + * to allow for different functionalities later in development. * @author Henrik K */ +@Getter +@Setter public class Project extends FileElementContainer { public static final String TYPE_IDENTIFIER = "project"; public static final char ID_PREFIX = 'p'; - protected Project() { super(); } + /** + * The UUID of the user who owns this project. This is used for user isolation - each user can + * only see their own projects. + */ + private UUID ownerId; + + public Project() { + super(); + } public Project(String name) { super(name, null); } + public Project(String name, UUID ownerId) { + super(name, null); + this.ownerId = ownerId; + } + @Override public String getTypeIdentifier() { return TYPE_IDENTIFIER; } + @Override - public char getIdPrefix() { return ID_PREFIX; } + public char getIdPrefix() { + return ID_PREFIX; + } } diff --git a/backend/src/main/java/edu/kit/quak/core/library/model/GateDefinition.java b/backend/src/main/java/edu/kit/quak/core/library/model/GateDefinition.java index bde23422..6dc97ba3 100644 --- a/backend/src/main/java/edu/kit/quak/core/library/model/GateDefinition.java +++ b/backend/src/main/java/edu/kit/quak/core/library/model/GateDefinition.java @@ -11,27 +11,27 @@ public record GateDefinition( int qubitCount, String symbol, List parameters, - InspectorInfo inspectorInfo -) { - // Compact constructor to ensure non-null lists and handle optional InspectorInfo + InspectorInfo inspectorInfo) { + // Compact constructor to ensure non-null lists and handle optional + // InspectorInfo public GateDefinition { parameters = parameters != null ? Collections.unmodifiableList(parameters) : List.of(); - // InspectorInfo can be null - it's optional for gates that don't need inspector details + // InspectorInfo can be null - it's optional for gates that don't need inspector + // details } /** - * Contains detailed information about a gate for display in the Inspector view. - * All string fields containing mathematical notation should use LaTeX format. + * Contains detailed information about a gate for display in the Inspector view. All string + * fields containing mathematical notation should use LaTeX format. * - * @param operatorDefinition LaTeX string representing the gate's operator definition (e.g., "H = |0\rangle\langle0| + |1\rangle\langle1|") + * @param operatorDefinition LaTeX string representing the gate's operator definition (e.g., "H + * = |0\rangle\langle0| + |1\rangle\langle1|") * @param truthTable List of input/output state mappings for the gate - * @param matrix Matrix representation of the gate with both LaTeX display format and computable values + * @param matrix Matrix representation of the gate with both LaTeX display format and computable + * values */ public record InspectorInfo( - String operatorDefinition, - List truthTable, - MatrixInfo matrix - ) { + String operatorDefinition, List truthTable, MatrixInfo matrix) { // ensures non-null public InspectorInfo { truthTable = truthTable != null ? Collections.unmodifiableList(truthTable) : List.of(); @@ -39,46 +39,33 @@ public record InspectorInfo( } } - /** - * Represents a single row in the truth table logic. - */ - public record TruthTableEntry( - String input, // e.g., "|0>" - String output // e.g., "|1>" - ) {} + /** Represents a single row in the truth table logic. */ + public record TruthTableEntry(String input, String output) {} /** - * Dual representation of the gate matrix: - * 1. Display (LaTeX) for reading. - * 2. Computable (Math strings) for calculating numeric values in the frontend. + * Dual representation of the gate matrix: 1. Display (LaTeX) for reading. 2. Computable (Math + * strings) for calculating numeric values in the frontend. */ - public record MatrixInfo( - String display, // LaTeX string - int rows, - int cols, - List> computable // 2D grid of math strings (e.g. "cos(theta/2)") - ) { + public record MatrixInfo(String display, int rows, int cols, List> computable) { // ensures non-null and validates dimensions public MatrixInfo { display = display != null ? display : ""; computable = computable != null ? Collections.unmodifiableList(computable) : List.of(); - + // Validate dimensions match actual computable matrix size if (!computable.isEmpty()) { if (computable.size() != rows) { throw new IllegalArgumentException( - "MatrixInfo dimension mismatch: expected %d rows but computable matrix has %d rows" - .formatted(rows, computable.size()) - ); + "MatrixInfo dimension mismatch: expected %d rows but computable matrix has %d rows" + .formatted(rows, computable.size())); } // Validate all rows have the same number of columns for (int i = 0; i < computable.size(); i++) { List row = computable.get(i); if (row.size() != cols) { throw new IllegalArgumentException( - "MatrixInfo dimension mismatch: expected %d cols but row %d has %d elements" - .formatted(cols, i, row.size()) - ); + "MatrixInfo dimension mismatch: expected %d cols but row %d has %d elements" + .formatted(cols, i, row.size())); } } } diff --git a/backend/src/main/java/edu/kit/quak/core/user/model/AuthenticatedUser.java b/backend/src/main/java/edu/kit/quak/core/user/model/AuthenticatedUser.java new file mode 100644 index 00000000..334ae94d --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/core/user/model/AuthenticatedUser.java @@ -0,0 +1,18 @@ +package edu.kit.quak.core.user.model; + +import java.util.UUID; + +/** + * Domain model representing an authenticated user in the system. This is a framework-agnostic + * representation used throughout the application layer. + * + * @param userId The unique identifier of the authenticated user + * @param issuer The OAuth2/OIDC issuer (e.g., "github", "google") + * @param subject The subject claim from the OIDC token (unique per issuer) + */ +public record AuthenticatedUser(UUID userId, String issuer, String subject) { + /** Creates an AuthenticatedUser from a User domain model. */ + public static AuthenticatedUser from(User user) { + return new AuthenticatedUser(user.getId(), user.getIssuer(), user.getSub()); + } +} diff --git a/backend/src/main/java/edu/kit/quak/core/user/model/User.java b/backend/src/main/java/edu/kit/quak/core/user/model/User.java new file mode 100644 index 00000000..0fdc4059 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/core/user/model/User.java @@ -0,0 +1,71 @@ +package edu.kit.quak.core.user.model; + +import java.time.Instant; +import java.util.UUID; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** Domain model representing a User. This is a pure POJO with no infrastructure dependencies. */ +@Getter +@Setter +@NoArgsConstructor +public class User { + private UUID id; + private String issuer; + private String sub; + private String email; + private Boolean emailVerified; + private String name; + private String givenName; + private String familyName; + private String avatarUrl; + private Instant createdAt; + private Instant updatedAt; + private Instant lastLoginAt; + + public User(UUID id, String issuer, String sub) { + this.id = id; + this.issuer = issuer; + this.sub = sub; + } + + // Business Methods + public void updateFromOidc( + String email, + Boolean emailVerified, + String name, + String givenName, + String familyName, + String avatarUrl) { + this.email = email; + this.emailVerified = emailVerified; + this.name = name; + this.givenName = givenName; + this.familyName = familyName; + this.avatarUrl = avatarUrl; + this.lastLoginAt = Instant.now(); + } + + public static User createFromOidc( + String issuer, + String sub, + String email, + Boolean emailVerified, + String name, + String givenName, + String familyName, + String avatarUrl) { + User user = new User(); + user.setIssuer(issuer); + user.setSub(sub); + user.setEmail(email); + user.setEmailVerified(emailVerified); + user.setName(name); + user.setGivenName(givenName); + user.setFamilyName(familyName); + user.setAvatarUrl(avatarUrl); + user.setLastLoginAt(Instant.now()); + return user; + } +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/GlobalExceptionHandler.java b/backend/src/main/java/edu/kit/quak/infrastructure/GlobalExceptionHandler.java index d802a25f..81fbc077 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/GlobalExceptionHandler.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/GlobalExceptionHandler.java @@ -1,6 +1,10 @@ package edu.kit.quak.infrastructure; +import edu.kit.quak.application.filesystem.exceptions.AccessDeniedException; import edu.kit.quak.application.library.exceptions.GateDefinitionNotFoundException; +import edu.kit.quak.application.user.exceptions.UserNotFoundException; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -8,22 +12,23 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.NoSuchElementException; -import java.util.stream.Collectors; - -// TODO: Seperate between global Exceptions (500 or Validation errors) and package specific errors (filesystem, circuit...) -// RFC-7807 +/** + * Global exception handler that translates domain exceptions to HTTP responses. Follows RFC-7807 + * Problem Details for HTTP APIs. + */ @RestControllerAdvice public class GlobalExceptionHandler { // Catches Validation errors -> 400 Bad Request @ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex) { - String errors = ex.getBindingResult().getFieldErrors().stream() - .map(error -> error.getField() + ": " + error.getDefaultMessage()) - .collect(Collectors.joining(", ")); + String errors = + ex.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); - ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed"); + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed"); problem.setTitle("Invalid Request Content"); problem.setProperty("errors", errors); // Custom Property hinzufΓΌgen return problem; @@ -34,7 +39,8 @@ public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex) // Strictly speaking, IllegalArgumentException is a 400 (client error). @ExceptionHandler(IllegalArgumentException.class) public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { - ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); problem.setTitle("Bad Request"); return problem; } @@ -42,7 +48,8 @@ public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { // Catches "Corrupt State" and Configuration errors -> 500 Internal Server Error @ExceptionHandler(IllegalStateException.class) public ProblemDetail handleIllegalState(IllegalStateException ex) { - ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); problem.setTitle("Internal Server Error"); return problem; } @@ -50,16 +57,38 @@ public ProblemDetail handleIllegalState(IllegalStateException ex) { // Catches standard Optional.orElseThrow() -> 404 Not Found @ExceptionHandler(NoSuchElementException.class) public ProblemDetail handleNotFound(NoSuchElementException ex) { - ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); problem.setTitle("Resource Not Found"); return problem; } + // Catches user authentication failures -> 401 Unauthorized + @ExceptionHandler(UserNotFoundException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public ProblemDetail handleUserNotFound(UserNotFoundException ex) { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); + problem.setTitle("Unauthorized"); + return problem; + } + + // Catches authorization/ownership failures -> 403 Forbidden + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ProblemDetail handleAccessDenied(AccessDeniedException ex) { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage()); + problem.setTitle("Access Denied"); + return problem; + } + // TODO: Seperate library related and filesystem related exceptions @ExceptionHandler(GateDefinitionNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) // 404 public ProblemDetail handleGateNotFound(GateDefinitionNotFoundException ex) { - ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); problem.setTitle("Gate Not Found"); return problem; } @@ -67,8 +96,10 @@ public ProblemDetail handleGateNotFound(GateDefinitionNotFoundException ex) { // Fallback -> 500 Internal Server Error @ExceptionHandler(Exception.class) public ProblemDetail handleGeneralError(Exception ex) { - // TODO: Introduce Logging! (Log.error(ex)) - ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred."); + ex.printStackTrace(); // Simple fallback logging + ProblemDetail problem = + ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred."); problem.setTitle("Internal Error"); return problem; } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/CircuitRestAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/CircuitRestAdapter.java index ac274e65..164bf3c5 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/CircuitRestAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/CircuitRestAdapter.java @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/circuit") +@RequestMapping("/api/circuit") public class CircuitRestAdapter { private final CircuitServicePort service; private final CircuitDtoMapper mapper; @@ -49,39 +49,43 @@ public CircuitResponse addQubit(@PathVariable String circuitId) { @PatchMapping("/{circuitId}/qubit") @ResponseStatus(HttpStatus.CREATED) - public CircuitResponse changeQubitName(@PathVariable String circuitId, - @RequestBody ChangeQubitNameRequest request) { + public CircuitResponse changeQubitName( + @PathVariable String circuitId, @RequestBody ChangeQubitNameRequest request) { QuantumCircuit circuit = service.changeQubitName(circuitId, request.id(), request.name()); return mapper.toResponse(circuit); } @DeleteMapping("/{circuitId}/qubit/{qubitId}") - public CircuitResponse deleteQubit(@PathVariable String circuitId, - @PathVariable String qubitId) { + public CircuitResponse deleteQubit( + @PathVariable String circuitId, @PathVariable String qubitId) { QuantumCircuit circuit = service.deleteQubit(circuitId, qubitId); return mapper.toResponse(circuit); } @PostMapping("/{circuitId}/gate") @ResponseStatus(HttpStatus.CREATED) - public CircuitResponse addGate(@PathVariable String circuitId, - @RequestBody AddGateRequest request) { - ElementaryQuantumGateDefinitionIdentifier definitionId = ElementaryQuantumGateDefinitionIdentifier.fromString(request.definitionId()); - QuantumCircuit circuit = service.addGate(circuitId, definitionId, request.toQubitIdx(), request.toPositionIdx()); + public CircuitResponse addGate( + @PathVariable String circuitId, @RequestBody AddGateRequest request) { + ElementaryQuantumGateDefinitionIdentifier definitionId = + ElementaryQuantumGateDefinitionIdentifier.fromString(request.definitionId()); + QuantumCircuit circuit = + service.addGate( + circuitId, definitionId, request.toQubitIdx(), request.toPositionIdx()); return mapper.toResponse(circuit); } @PatchMapping("/{circuitId}/gate") - public CircuitResponse moveGate(@PathVariable String circuitId, - @RequestBody MoveGateRequest request) { - QuantumCircuit circuit = service.moveGate(circuitId, request.id(), request.toQubitIdx(), request.toPositionIdx()); + public CircuitResponse moveGate( + @PathVariable String circuitId, @RequestBody MoveGateRequest request) { + QuantumCircuit circuit = + service.moveGate( + circuitId, request.id(), request.toQubitIdx(), request.toPositionIdx()); return mapper.toResponse(circuit); } @DeleteMapping("/{circuitId}/gate/{gateId}") - public CircuitResponse deleteGate(@PathVariable String circuitId, - @PathVariable String gateId) { + public CircuitResponse deleteGate(@PathVariable String circuitId, @PathVariable String gateId) { QuantumCircuit circuit = service.deleteGate(circuitId, gateId); return mapper.toResponse(circuit); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/AddGateRequest.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/AddGateRequest.java index 1c2169bf..ba069bdf 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/AddGateRequest.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/AddGateRequest.java @@ -1,8 +1,3 @@ package edu.kit.quak.infrastructure.circuit.in.web.rest.dto; -public record AddGateRequest( - String definitionId, - int toQubitIdx, - int toPositionIdx -) { -} \ No newline at end of file +public record AddGateRequest(String definitionId, int toQubitIdx, int toPositionIdx) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/ChangeQubitNameRequest.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/ChangeQubitNameRequest.java index 94dcc9b9..9486a210 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/ChangeQubitNameRequest.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/ChangeQubitNameRequest.java @@ -1,7 +1,3 @@ package edu.kit.quak.infrastructure.circuit.in.web.rest.dto; -public record ChangeQubitNameRequest( - String id, - String name -) { -} +public record ChangeQubitNameRequest(String id, String name) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/CircuitResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/CircuitResponse.java index fcee1e6a..81c36c74 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/CircuitResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/CircuitResponse.java @@ -2,8 +2,4 @@ import java.util.List; -public record CircuitResponse( - String id, - List registers -) { -} \ No newline at end of file +public record CircuitResponse(String id, List registers) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/GateResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/GateResponse.java index 9db0a8d0..85bc6ef7 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/GateResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/GateResponse.java @@ -2,8 +2,4 @@ import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGateDefinitionIdentifier; -public record GateResponse( - String id, - ElementaryQuantumGateDefinitionIdentifier definitionId -) { -} \ No newline at end of file +public record GateResponse(String id, ElementaryQuantumGateDefinitionIdentifier definitionId) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/MoveGateRequest.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/MoveGateRequest.java index 367022f4..dddef00d 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/MoveGateRequest.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/MoveGateRequest.java @@ -1,8 +1,3 @@ package edu.kit.quak.infrastructure.circuit.in.web.rest.dto; -public record MoveGateRequest( - String id, - int toQubitIdx, - int toPositionIdx -) { -} \ No newline at end of file +public record MoveGateRequest(String id, int toQubitIdx, int toPositionIdx) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/QubitResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/QubitResponse.java index 0db0befb..c642d00e 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/QubitResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/QubitResponse.java @@ -2,14 +2,11 @@ import java.util.List; -public record QubitResponse( - String id, - List gates -) { +public record QubitResponse(String id, List gates) { // Ensure gates list is not null public QubitResponse { if (gates == null) { gates = List.of(); } } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/RegisterResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/RegisterResponse.java index 9d6cc8f9..6e23450e 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/RegisterResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/dto/RegisterResponse.java @@ -2,9 +2,4 @@ import java.util.List; -public record RegisterResponse( - String id, - String name, - List qubits -) { -} +public record RegisterResponse(String id, String name, List qubits) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitDtoMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitDtoMapper.java index 8c4b0d9f..8879068b 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitDtoMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitDtoMapper.java @@ -5,8 +5,9 @@ import org.mapstruct.Mapper; import org.mapstruct.MappingConstants; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, - uses = { RegisterDtoMapper.class }) +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, + uses = {RegisterDtoMapper.class}) public interface CircuitDtoMapper { CircuitResponse toResponse(QuantumCircuit circuit); -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/GateDtoMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/GateDtoMapper.java index 59ea055d..5ac08010 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/GateDtoMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/GateDtoMapper.java @@ -5,7 +5,9 @@ import edu.kit.quak.infrastructure.circuit.in.web.rest.dto.GateResponse; import org.mapstruct.*; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, + unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface GateDtoMapper { @BeanMapping(subclassExhaustiveStrategy = SubclassExhaustiveStrategy.RUNTIME_EXCEPTION) @SubclassMapping(source = ElementaryQuantumGate.class, target = GateResponse.class) diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/QubitDtoMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/QubitDtoMapper.java index f7791b5c..952241ad 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/QubitDtoMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/QubitDtoMapper.java @@ -4,8 +4,9 @@ import edu.kit.quak.infrastructure.circuit.in.web.rest.dto.QubitResponse; import org.mapstruct.*; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, - uses = { GateDtoMapper.class }) +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, + uses = {GateDtoMapper.class}) public interface QubitDtoMapper { @Mapping(target = "gates", source = "operations") QubitResponse toResponse(Qubit qubit); diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/RegisterDtoMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/RegisterDtoMapper.java index dc13f9c8..4e6f2a75 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/RegisterDtoMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/RegisterDtoMapper.java @@ -6,11 +6,13 @@ import edu.kit.quak.infrastructure.circuit.in.web.rest.dto.RegisterResponse; import org.mapstruct.*; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, - uses = { QubitDtoMapper.class }) +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, + unmappedTargetPolicy = ReportingPolicy.IGNORE, + uses = {QubitDtoMapper.class}) public interface RegisterDtoMapper { @BeanMapping(subclassExhaustiveStrategy = SubclassExhaustiveStrategy.RUNTIME_EXCEPTION) @SubclassMapping(source = QuantumRegister.class, target = RegisterResponse.class) @SubclassMapping(source = ClassicRegister.class, target = RegisterResponse.class) RegisterResponse toResponse(Register register); -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/CircuitJpaAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/CircuitJpaAdapter.java index b98eca6e..21687656 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/CircuitJpaAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/CircuitJpaAdapter.java @@ -5,9 +5,8 @@ import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.JpaQuantumCircuit; import edu.kit.quak.infrastructure.circuit.out.db.jpa.mapper.CircuitJpaMapper; import edu.kit.quak.infrastructure.circuit.out.db.jpa.repository.SpringDataJpaCircuitRepository; -import org.springframework.stereotype.Repository; - import java.util.Optional; +import org.springframework.stereotype.Repository; @Repository public class CircuitJpaAdapter implements CircuitRepositoryPort { diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/JpaElementWithId.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/JpaElementWithId.java index 9816481e..a898ebb6 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/JpaElementWithId.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/JpaElementWithId.java @@ -5,8 +5,7 @@ @MappedSuperclass public abstract class JpaElementWithId { - @Id - protected String id; + @Id protected String id; public String getId() { return id; diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/JpaQuantumCircuit.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/JpaQuantumCircuit.java index 3171250f..489f3aa8 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/JpaQuantumCircuit.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/JpaQuantumCircuit.java @@ -18,4 +18,4 @@ public List getRegisters() { public void setRegisters(List registers) { this.registers = registers; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/operation/JpaElementaryQuantumGate.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/operation/JpaElementaryQuantumGate.java index cb678b04..bd0ea6dc 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/operation/JpaElementaryQuantumGate.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/operation/JpaElementaryQuantumGate.java @@ -44,4 +44,4 @@ public double getLambda() { public void setLambda(double lambda) { this.lambda = lambda; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/register/JpaClassicRegister.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/register/JpaClassicRegister.java index 2a313b20..8c26fc0e 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/register/JpaClassicRegister.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/register/JpaClassicRegister.java @@ -4,16 +4,13 @@ import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; - import java.util.ArrayList; import java.util.List; @Entity @DiscriminatorValue("CLASSIC") public class JpaClassicRegister extends JpaRegister { - @ElementCollection - @CollectionTable - private List bits = new ArrayList<>(); + @ElementCollection @CollectionTable private List bits = new ArrayList<>(); public void setBits(List bits) { this.bits = bits; diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/register/JpaQubit.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/register/JpaQubit.java index 8d9df7c1..6064a5b8 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/register/JpaQubit.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/entity/register/JpaQubit.java @@ -3,7 +3,6 @@ import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.JpaElementWithId; import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.operation.JpaQuantumOperation; import jakarta.persistence.*; - import java.util.ArrayList; import java.util.List; diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/CircuitJpaMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/CircuitJpaMapper.java index 00f86ecd..fb5864af 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/CircuitJpaMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/CircuitJpaMapper.java @@ -4,13 +4,13 @@ import edu.kit.quak.core.circuit.model.register.Register; import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.JpaQuantumCircuit; import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.register.JpaRegister; -import org.mapstruct.*; - import java.util.List; +import org.mapstruct.*; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, collectionMappingStrategy = CollectionMappingStrategy.TARGET_IMMUTABLE, - uses = { RegisterJpaMapper.class }) + uses = {RegisterJpaMapper.class}) public interface CircuitJpaMapper { @Mapping(target = "id", source = "id") JpaQuantumCircuit toEntity(QuantumCircuit domain); diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/QubitJpaMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/QubitJpaMapper.java index d326e3e4..e420f2cc 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/QubitJpaMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/QubitJpaMapper.java @@ -4,7 +4,8 @@ import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.register.JpaQubit; import org.mapstruct.*; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, collectionMappingStrategy = CollectionMappingStrategy.TARGET_IMMUTABLE, uses = {OperationJpaMapper.class}) public interface QubitJpaMapper { diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/RegisterJpaMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/RegisterJpaMapper.java index 909f2503..5dc491a7 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/RegisterJpaMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/RegisterJpaMapper.java @@ -8,7 +8,8 @@ import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.register.JpaRegister; import org.mapstruct.*; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, collectionMappingStrategy = CollectionMappingStrategy.TARGET_IMMUTABLE, uses = {QubitJpaMapper.class}) public interface RegisterJpaMapper { @@ -27,7 +28,8 @@ public interface RegisterJpaMapper { @AfterMapping default void linkQubits(@MappingTarget JpaRegister entity) { - if (entity instanceof JpaQuantumRegister quantumRegister && quantumRegister.getQubits() != null) { + if (entity instanceof JpaQuantumRegister quantumRegister + && quantumRegister.getQubits() != null) { quantumRegister.getQubits().forEach(qubit -> qubit.setRegister(quantumRegister)); } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/repository/SpringDataJpaCircuitRepository.java b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/repository/SpringDataJpaCircuitRepository.java index e6fbaec4..78c6a41a 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/repository/SpringDataJpaCircuitRepository.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/repository/SpringDataJpaCircuitRepository.java @@ -3,5 +3,4 @@ import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.JpaQuantumCircuit; import org.springframework.data.jpa.repository.JpaRepository; -public interface SpringDataJpaCircuitRepository extends JpaRepository { -} +public interface SpringDataJpaCircuitRepository extends JpaRepository {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/config/SecurityConfig.java b/backend/src/main/java/edu/kit/quak/infrastructure/config/SecurityConfig.java new file mode 100644 index 00000000..2036a367 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/config/SecurityConfig.java @@ -0,0 +1,259 @@ +package edu.kit.quak.infrastructure.config; + +import edu.kit.quak.application.user.ports.in.OidcSyncServicePort; +import edu.kit.quak.application.user.ports.in.OidcUserInfo; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.List; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Security configuration for the application. Configures OAuth2/OIDC authentication, CORS, CSRF, + * and authorization rules. + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Value("${app.frontend.url}") + private String frontendUrl; + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + OAuth2AuthorizationRequestResolver authorizationRequestResolver, + AuthenticationSuccessHandler authenticationSuccessHandler) + throws Exception { + http.cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf( + csrf -> + csrf.csrfTokenRepository( + CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler( + new CsrfTokenRequestAttributeHandler()) + .ignoringRequestMatchers( + "/api/auth/**", "/login/**", "/oauth2/**")) + .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class) + .authorizeHttpRequests( + auth -> + auth.requestMatchers( + "/", + "/login/**", + "/oauth2/**", + "/api/auth/user", + "/error", + "/*.js", + "/*.css", + "/*.html", + "/*.ico", + "/*.png", + "/*.jpg", + "/assets/**") + .permitAll() + .anyRequest() + .authenticated()) + .oauth2Login( + oauth2 -> + oauth2.authorizationEndpoint( + authorization -> + authorization.authorizationRequestResolver( + authorizationRequestResolver)) + .successHandler(authenticationSuccessHandler)) + .logout( + logout -> + logout.logoutUrl("/api/auth/logout") + .logoutSuccessHandler( + (request, response, authentication) -> { + response.setStatus( + jakarta.servlet.http.HttpServletResponse + .SC_OK); + }) + .invalidateHttpSession(true) + .deleteCookies("JSESSIONID") + .permitAll()) + .exceptionHandling( + exception -> + exception + .authenticationEntryPoint( + new org.springframework.security.web.authentication + .HttpStatusEntryPoint( + org.springframework.http.HttpStatus + .UNAUTHORIZED)) + .accessDeniedHandler( + (request, response, accessDeniedException) -> { + response.setStatus( + org.springframework.http.HttpStatus + .FORBIDDEN + .value()); + response.setContentType("application/json"); + response.getWriter() + .write( + "{\"error\":\"Access" + + " Denied\",\"message\":\"" + + accessDeniedException + .getMessage() + + "\"}"); + })); + + return http.build(); + } + + @Bean + public OAuth2AuthorizationRequestResolver authorizationRequestResolver( + ClientRegistrationRepository clientRegistrationRepository) { + + DefaultOAuth2AuthorizationRequestResolver defaultResolver = + new DefaultOAuth2AuthorizationRequestResolver( + clientRegistrationRepository, "/oauth2/authorization"); + + return new OAuth2AuthorizationRequestResolver() { + @Override + public OAuth2AuthorizationRequest resolve( + jakarta.servlet.http.HttpServletRequest request) { + OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request); + return authorizationRequest != null + ? customizeAuthorizationRequest(authorizationRequest) + : null; + } + + @Override + public OAuth2AuthorizationRequest resolve( + jakarta.servlet.http.HttpServletRequest request, String clientRegistrationId) { + OAuth2AuthorizationRequest authorizationRequest = + defaultResolver.resolve(request, clientRegistrationId); + return authorizationRequest != null + ? customizeAuthorizationRequest(authorizationRequest) + : null; + } + }; + } + + private OAuth2AuthorizationRequest customizeAuthorizationRequest( + OAuth2AuthorizationRequest authorizationRequest) { + + // Generate PKCE code verifier and challenge + String codeVerifier = generateCodeVerifier(); + String codeChallenge = generateCodeChallenge(codeVerifier); + + return OAuth2AuthorizationRequest.from(authorizationRequest) + .additionalParameters( + params -> { + params.put("code_challenge", codeChallenge); + params.put("code_challenge_method", "S256"); + params.put("prompt", "select_account"); + }) + .attributes( + attrs -> { + attrs.put("code_verifier", codeVerifier); + }) + .build(); + } + + private String generateCodeVerifier() { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(UUID.randomUUID().toString().getBytes()); + } + + private String generateCodeChallenge(String codeVerifier) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(codeVerifier.getBytes()); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to generate code challenge", e); + } + } + + @Bean + public AuthenticationSuccessHandler authenticationSuccessHandler( + OidcSyncServicePort oidcUserSyncService) { + SimpleUrlAuthenticationSuccessHandler delegate = + new SimpleUrlAuthenticationSuccessHandler(); + delegate.setDefaultTargetUrl(frontendUrl + "/"); + delegate.setAlwaysUseDefaultTargetUrl(true); + + return (request, response, authentication) -> { + if (authentication.getPrincipal() + instanceof + org.springframework.security.oauth2.core.oidc.user.OidcUser oidcUser) { + if (authentication + instanceof + org.springframework.security.oauth2.client.authentication + .OAuth2AuthenticationToken + oauthToken) { + String registrationId = oauthToken.getAuthorizedClientRegistrationId(); + OidcUserInfo userInfo = + new OidcUserInfo( + oidcUser.getSubject(), + oidcUser.getEmail(), + oidcUser.getEmailVerified(), + oidcUser.getFullName(), + oidcUser.getGivenName(), + oidcUser.getFamilyName(), + oidcUser.getPicture()); + edu.kit.quak.core.user.model.User user = + oidcUserSyncService.syncUser(registrationId, userInfo); + request.getSession().setAttribute("userId", user.getId()); + } + } + delegate.onAuthenticationSuccess(request, response, authentication); + }; + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of(frontendUrl)); + configuration.setAllowedMethods( + List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + private static class CsrfCookieFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrfToken != null) { + csrfToken.getToken(); + } + filterChain.doFilter(request, response); + } + } +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ApiConstants.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ApiConstants.java index 64e9d4cf..a0bfd116 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ApiConstants.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ApiConstants.java @@ -2,5 +2,6 @@ public final class ApiConstants { private ApiConstants() {} + public static final String HEADER_PARENT_ID = "parent-id"; } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/DirectoryRestAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/DirectoryRestAdapter.java index 1be664ea..a8e2c6fd 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/DirectoryRestAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/DirectoryRestAdapter.java @@ -1,48 +1,75 @@ package edu.kit.quak.infrastructure.filesystem.in.web.rest; import edu.kit.quak.application.filesystem.ports.in.DirectoryServicePort; +import edu.kit.quak.application.user.ports.in.UserServicePort; import edu.kit.quak.core.filesystem.model.Directory; +import edu.kit.quak.core.user.model.User; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.DirectoryContentsResponse; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.DirectoryDetailsResponse; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.DirectoryRequest; import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.DirectoryDtoMapper; +import edu.kit.quak.infrastructure.user.in.web.rest.mapper.AuthenticationMapper; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/directory") +@RequestMapping("/api/directory") public class DirectoryRestAdapter { private final DirectoryServicePort service; + private final UserServicePort userService; private final DirectoryDtoMapper mapper; + private final AuthenticationMapper authMapper; - public DirectoryRestAdapter(DirectoryServicePort service, DirectoryDtoMapper mapper) { + public DirectoryRestAdapter( + DirectoryServicePort service, + UserServicePort userService, + DirectoryDtoMapper mapper, + AuthenticationMapper authMapper) { this.service = service; + this.userService = userService; this.mapper = mapper; + this.authMapper = authMapper; } @PostMapping("/") @ResponseStatus(HttpStatus.CREATED) - public DirectoryDetailsResponse createDirectory(@RequestBody DirectoryRequest request, @RequestHeader(name = ApiConstants.HEADER_PARENT_ID) String parentId) { + @PreAuthorize("isAuthenticated()") + public DirectoryDetailsResponse createDirectory( + @RequestBody DirectoryRequest request, + @RequestHeader(name = ApiConstants.HEADER_PARENT_ID) String parentId, + Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); Directory directoryToCreate = mapper.toDomain(request); - Directory createdDirectory = service.createDirectory(directoryToCreate, parentId); + Directory createdDirectory = service.createDirectory(directoryToCreate, parentId, user); return mapper.toDetailsResponse(createdDirectory); } @GetMapping("/{dId}") - public DirectoryContentsResponse retrieveDirectory(@PathVariable String dId) { - Directory dir = service.retrieveDirectory(dId); - + @PreAuthorize("isAuthenticated()") + public DirectoryContentsResponse retrieveDirectory( + @PathVariable String dId, Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + Directory dir = service.retrieveDirectory(dId, user); return mapper.toContentsResponse(dir); } @DeleteMapping("/{dId}") - public void deleteDirectory(@PathVariable String dId) { - service.removeDirectory(dId); + @PreAuthorize("isAuthenticated()") + public void deleteDirectory(@PathVariable String dId, Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + service.removeDirectory(dId, user); } @PatchMapping("/{dId}") - public DirectoryDetailsResponse renameDirectory(@PathVariable String dId, @RequestBody DirectoryRequest request) { - Directory updatedDirectory = service.renameDirectory(dId, request.name()); + @PreAuthorize("isAuthenticated()") + public DirectoryDetailsResponse renameDirectory( + @PathVariable String dId, + @RequestBody DirectoryRequest request, + Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + Directory updatedDirectory = service.renameDirectory(dId, request.name(), user); return mapper.toDetailsResponse(updatedDirectory); } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/FileRestAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/FileRestAdapter.java index 941dbbc5..16e47b62 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/FileRestAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/FileRestAdapter.java @@ -1,64 +1,94 @@ package edu.kit.quak.infrastructure.filesystem.in.web.rest; import edu.kit.quak.application.filesystem.ports.in.FileServicePort; +import edu.kit.quak.application.user.ports.in.UserServicePort; import edu.kit.quak.core.filesystem.model.File; +import edu.kit.quak.core.user.model.User; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.*; import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.FileDtoMapper; +import edu.kit.quak.infrastructure.user.in.web.rest.mapper.AuthenticationMapper; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/file") +@RequestMapping("/api/file") public class FileRestAdapter { private final FileServicePort service; + private final UserServicePort userService; private final FileDtoMapper mapper; + private final AuthenticationMapper authMapper; - public FileRestAdapter(FileServicePort service, FileDtoMapper mapper) { + public FileRestAdapter( + FileServicePort service, + UserServicePort userService, + FileDtoMapper mapper, + AuthenticationMapper authMapper) { this.service = service; + this.userService = userService; this.mapper = mapper; + this.authMapper = authMapper; } @PostMapping("/") @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("isAuthenticated()") public FileDetailsResponse createFile( @RequestBody @Valid CreateFileRequest request, // Triggers the Spring Validation - @RequestHeader(name = ApiConstants.HEADER_PARENT_ID) String parentId - ) { + @RequestHeader(name = ApiConstants.HEADER_PARENT_ID) String parentId, + Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); File fileToCreate = mapper.toDomain(request); - File createdFile = service.createFile(fileToCreate, parentId); + File createdFile = service.createFile(fileToCreate, parentId, user); return mapper.toDetailsResponse(createdFile); } @GetMapping("/{fId}") - public FileDetailsResponse retrieveFile(@PathVariable String fId) { - File domainFile = service.retrieveFile(fId); + @PreAuthorize("isAuthenticated()") + public FileDetailsResponse retrieveFile( + @PathVariable String fId, Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + File domainFile = service.retrieveFile(fId, user); return mapper.toDetailsResponse(domainFile); } @DeleteMapping("/{fId}") - public void deleteFile(@PathVariable String fId) { - service.removeFile(fId); + @PreAuthorize("isAuthenticated()") + public void deleteFile(@PathVariable String fId, Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + service.removeFile(fId, user); } @PatchMapping("/{fId}") + @PreAuthorize("isAuthenticated()") public FileDetailsResponse renameFile( @PathVariable String fId, - @RequestBody RenameFileRequest request) { - File updatedFile = service.renameFile(fId, request.name()); + @RequestBody RenameFileRequest request, + Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + File updatedFile = service.renameFile(fId, request.name(), user); return mapper.toDetailsResponse(updatedFile); } @GetMapping("/{fId}/content") - public FileContentResponse getFileContent(@PathVariable String fId) { - byte[] content = service.getFileContent(fId); + @PreAuthorize("isAuthenticated()") + public FileContentResponse getFileContent( + @PathVariable String fId, Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + byte[] content = service.getFileContent(fId, user); return mapper.toContentResponse(content); } @PutMapping("/{fId}/content") - public void setFileContent(@PathVariable String fId, - @RequestBody FileContentRequest fileContent) { - service.setFileContent(fId, fileContent.content(), fileContent.contentType()); + @PreAuthorize("isAuthenticated()") + public void setFileContent( + @PathVariable String fId, + @RequestBody FileContentRequest fileContent, + Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + service.setFileContent(fId, fileContent.content(), fileContent.contentType(), user); } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ProjectRestAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ProjectRestAdapter.java index c12033af..f51c14d5 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ProjectRestAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ProjectRestAdapter.java @@ -1,56 +1,87 @@ package edu.kit.quak.infrastructure.filesystem.in.web.rest; import edu.kit.quak.application.filesystem.ports.in.ProjectServicePort; +import edu.kit.quak.application.user.ports.in.UserServicePort; import edu.kit.quak.core.filesystem.model.Project; +import edu.kit.quak.core.user.model.User; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.ProjectContentsResponse; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.ProjectDetailsResponse; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.ProjectRequest; import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.ProjectDtoMapper; +import edu.kit.quak.infrastructure.user.in.web.rest.mapper.AuthenticationMapper; +import java.util.List; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -import java.util.List; - +/** + * REST adapter for project-related endpoints. Handles HTTP-specific concerns and converts framework + * types to domain types. + */ @RestController -@RequestMapping("/project") +@RequestMapping("/api/project") public class ProjectRestAdapter { private final ProjectServicePort service; + private final UserServicePort userService; private final ProjectDtoMapper mapper; + private final AuthenticationMapper authMapper; - public ProjectRestAdapter(ProjectServicePort service, ProjectDtoMapper mapper) { + public ProjectRestAdapter( + ProjectServicePort service, + UserServicePort userService, + ProjectDtoMapper mapper, + AuthenticationMapper authMapper) { this.service = service; + this.userService = userService; this.mapper = mapper; + this.authMapper = authMapper; } @GetMapping({"", "/"}) - public List getProjects() { - List projects = service.listProjects(); + @PreAuthorize("isAuthenticated()") + public List getProjects(Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + List projects = service.listProjects(user); return mapper.toDetailsResponseList(projects); } @PostMapping({"", "/"}) @ResponseStatus(HttpStatus.CREATED) - public ProjectDetailsResponse createProject(@RequestBody ProjectRequest request) { + @PreAuthorize("isAuthenticated()") + public ProjectDetailsResponse createProject( + @RequestBody ProjectRequest request, Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); Project projectToCreate = mapper.toDomain(request); - Project createdProject = service.createProject(projectToCreate); // project has no parent + Project createdProject = service.createProject(projectToCreate, user); return mapper.toDetailsResponse(createdProject); } @GetMapping("/{pId}") - public ProjectContentsResponse retrieveProject(@PathVariable String pId) { - Project project = service.retrieveProject(pId); + @PreAuthorize("isAuthenticated()") + public ProjectContentsResponse retrieveProject( + @PathVariable String pId, Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + Project project = service.retrieveProject(pId, user); return mapper.toContentsResponse(project); } @DeleteMapping("/{pId}") - public void deleteProject(@PathVariable String pId) { - service.removeProject(pId); + @PreAuthorize("isAuthenticated()") + public void deleteProject(@PathVariable String pId, Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + service.removeProject(pId, user); } @PatchMapping("/{pId}") - public ProjectDetailsResponse renameProject(@PathVariable String pId, @RequestBody ProjectRequest request) { - Project updatedProject = service.renameProject(pId, request.name()); + @PreAuthorize("isAuthenticated()") + public ProjectDetailsResponse renameProject( + @PathVariable String pId, + @RequestBody ProjectRequest request, + Authentication authentication) { + User user = userService.getAuthenticatedUser(authMapper.toDomain(authentication)); + Project updatedProject = service.renameProject(pId, request.name(), user); return mapper.toDetailsResponse(updatedProject); } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/CreateFileRequest.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/CreateFileRequest.java index 51e97770..1a06fbd3 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/CreateFileRequest.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/CreateFileRequest.java @@ -5,10 +5,6 @@ import jakarta.validation.constraints.Pattern; public record CreateFileRequest( - @NotBlank(message = "Filename must not be blank") - String name, - - @NotNull(message = "Content-Type must be specified") - @Pattern(regexp = "^[a-z]+/[-a-z0-9]+$", message = "Invalid Content-Type format") - String contentType -) {} \ No newline at end of file + @NotBlank(message = "Filename must not be blank") String name, + @NotNull(message = "Content-Type must be specified") @Pattern(regexp = "^[a-z]+/[-a-z0-9]+$", message = "Invalid Content-Type format") + String contentType) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryContentsResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryContentsResponse.java index 0e8d912d..5300f7ec 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryContentsResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryContentsResponse.java @@ -9,5 +9,4 @@ public record DirectoryContentsResponse( String type, List contents, Instant createdOn, - Instant lastAccess -) { } + Instant lastAccess) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryDetailsResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryDetailsResponse.java index 764e7eeb..8d2841d5 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryDetailsResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryDetailsResponse.java @@ -4,7 +4,8 @@ public class DirectoryDetailsResponse extends FileElementDto { - public DirectoryDetailsResponse(String id, String name, String type, Instant createdOn, Instant lastAccess) { + public DirectoryDetailsResponse( + String id, String name, String type, Instant createdOn, Instant lastAccess) { super(id, name, type, createdOn, lastAccess); } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryRequest.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryRequest.java index ea060053..2c755057 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryRequest.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/DirectoryRequest.java @@ -3,7 +3,4 @@ import jakarta.validation.constraints.NotBlank; public record DirectoryRequest( - @NotBlank(message = "Directory name must not be blank") - String name -) { -} + @NotBlank(message = "Directory name must not be blank") String name) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileContentRequest.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileContentRequest.java index 9c449816..9723771f 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileContentRequest.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileContentRequest.java @@ -3,10 +3,4 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -public record FileContentRequest( - @NotNull - byte[] content, - - @NotBlank - String contentType -) {} +public record FileContentRequest(@NotNull byte[] content, @NotBlank String contentType) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileContentResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileContentResponse.java index 5392e9a8..84f66678 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileContentResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileContentResponse.java @@ -1,4 +1,3 @@ package edu.kit.quak.infrastructure.filesystem.in.web.rest.dto; -public record FileContentResponse(byte[] content) { -} +public record FileContentResponse(byte[] content) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileDetailsResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileDetailsResponse.java index 0b186102..f17df415 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileDetailsResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileDetailsResponse.java @@ -6,7 +6,13 @@ public class FileDetailsResponse extends FileElementDto { private final String contentType; - public FileDetailsResponse(String id, String name, String type, String contentType, Instant createdOn, Instant lastAccess) { + public FileDetailsResponse( + String id, + String name, + String type, + String contentType, + Instant createdOn, + Instant lastAccess) { super(id, name, type, createdOn, lastAccess); this.contentType = contentType; } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileElementDto.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileElementDto.java index 5309e1dd..31cd700f 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileElementDto.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/FileElementDto.java @@ -10,7 +10,8 @@ public abstract class FileElementDto { private final Instant createdOn; private final Instant lastAccess; - public FileElementDto(String id, String name, String type, Instant createdOn, Instant lastAccess) { + public FileElementDto( + String id, String name, String type, Instant createdOn, Instant lastAccess) { this.id = id; this.name = name; this.type = type; @@ -21,12 +22,20 @@ public FileElementDto(String id, String name, String type, Instant createdOn, In public String getId() { return id; } + public String getName() { return name; } + public String getType() { return type; } - public Instant getCreatedOn() { return createdOn; } - public Instant getLastAccess() { return lastAccess; } + + public Instant getCreatedOn() { + return createdOn; + } + + public Instant getLastAccess() { + return lastAccess; + } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectContentsResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectContentsResponse.java index 45054687..28bebd19 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectContentsResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectContentsResponse.java @@ -9,5 +9,4 @@ public record ProjectContentsResponse( String type, List contents, Instant createdOn, - Instant lastAccess -) { } \ No newline at end of file + Instant lastAccess) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectDetailsResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectDetailsResponse.java index e4bb8694..5e00ca67 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectDetailsResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectDetailsResponse.java @@ -3,9 +3,4 @@ import java.time.Instant; public record ProjectDetailsResponse( - String id, - String name, - String type, - Instant createdOn, - Instant lastAccess -) { } + String id, String name, String type, Instant createdOn, Instant lastAccess) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectRequest.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectRequest.java index 0d2b56c1..0197b79f 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectRequest.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/ProjectRequest.java @@ -2,7 +2,4 @@ import jakarta.validation.constraints.NotBlank; -public record ProjectRequest( - @NotBlank(message = "Project name must not be blank") - String name -) { } +public record ProjectRequest(@NotBlank(message = "Project name must not be blank") String name) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/RenameFileRequest.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/RenameFileRequest.java index 1e69f792..e154eff5 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/RenameFileRequest.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/dto/RenameFileRequest.java @@ -2,7 +2,4 @@ import jakarta.validation.constraints.NotBlank; -public record RenameFileRequest( - @NotBlank - String name -) {} \ No newline at end of file +public record RenameFileRequest(@NotBlank String name) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/DirectoryDtoMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/DirectoryDtoMapper.java index c65b5b02..2295a794 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/DirectoryDtoMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/DirectoryDtoMapper.java @@ -8,19 +8,22 @@ import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = {FileElementDtoMapper.class}) +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, + uses = {FileElementDtoMapper.class}) public interface DirectoryDtoMapper { @Mapping(target = "parentId", ignore = true) @Mapping(target = "id", ignore = true) @Mapping(target = "contents", ignore = true) + @Mapping(target = "createdOn", ignore = true) @Mapping(target = "lastAccess", ignore = true) - Directory toDomain(DirectoryRequest domain); + Directory toDomain(DirectoryRequest request); - @Mapping(target = "type", source = "domain.typeIdentifier") - DirectoryDetailsResponse toDetailsResponse(Directory domain); + @Mapping(target = "type", source = "typeIdentifier") + DirectoryDetailsResponse toDetailsResponse(Directory directory); - @Mapping(target = "type", source = "domain.typeIdentifier") - @Mapping(target = "contents", source = "domain.contents") - DirectoryContentsResponse toContentsResponse(Directory domain); + @Mapping(target = "type", source = "typeIdentifier") + @Mapping(target = "contents", source = "contents") + DirectoryContentsResponse toContentsResponse(Directory directory); } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileDtoMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileDtoMapper.java index a2d7111c..da4fcf87 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileDtoMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileDtoMapper.java @@ -8,17 +8,19 @@ import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = {FileElementDtoMapper.class}) +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, + uses = {FileElementDtoMapper.class}) public interface FileDtoMapper { @Mapping(target = "id", ignore = true) // ID created in domain @Mapping(target = "parentId", ignore = true) // ParentId set in application @Mapping(target = "createdOn", ignore = true) - @Mapping(target = "lastAccess",ignore = true) + @Mapping(target = "lastAccess", ignore = true) File toDomain(CreateFileRequest request); - @Mapping(target = "type", source = "domain.typeIdentifier") - FileDetailsResponse toDetailsResponse(File domain); + @Mapping(target = "type", source = "typeIdentifier") + FileDetailsResponse toDetailsResponse(File file); FileContentResponse toContentResponse(byte[] content); } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileElementDtoMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileElementDtoMapper.java index 39324f74..f79973ea 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileElementDtoMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileElementDtoMapper.java @@ -4,25 +4,28 @@ import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.core.filesystem.model.FileElement; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.FileElementDto; -import org.mapstruct.Mapper; -import org.mapstruct.MappingConstants; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Lazy; - import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = {FileDtoMapper.class, DirectoryDtoMapper.class}) -public abstract class FileElementDtoMapper { +/** + * Mapper for polymorphic FileElement types (File and Directory). + * + *

Note: This is a @Component instead of a MapStruct @Mapper because it performs runtime + * polymorphic mapping. It inspects the actual type of FileElement at runtime and delegates to the + * appropriate specialized mapper (FileDtoMapper or DirectoryDtoMapper). This dynamic dispatch logic + * cannot be expressed in MapStruct's declarative mapping syntax, which is why we use a manual + * component-based approach here. + */ +@Component +public class FileElementDtoMapper { - @Autowired - @Lazy - protected FileDtoMapper fileMapper; + @Autowired @Lazy protected FileDtoMapper fileMapper; - @Autowired - @Lazy - protected DirectoryDtoMapper directoryMapper; + @Autowired @Lazy protected DirectoryDtoMapper directoryMapper; public FileElementDto toDto(FileElement element) { if (element instanceof File file) { @@ -30,17 +33,15 @@ public FileElementDto toDto(FileElement element) { } else if (element instanceof Directory dir) { return directoryMapper.toDetailsResponse(dir); } else { - throw new IllegalArgumentException("Unknown FileElement definitionId: " + element.getClass()); + throw new IllegalArgumentException( + "Unknown FileElement definitionId: " + element.getClass()); } } - public List mapSetToList(Set> set) { if (set == null) { return null; } - return set.stream() - .map(this::toDto) - .collect(Collectors.toList()); + return set.stream().map(this::toDto).collect(Collectors.toList()); } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/ProjectDtoMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/ProjectDtoMapper.java index 508eaf8b..22e50876 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/ProjectDtoMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/ProjectDtoMapper.java @@ -4,27 +4,30 @@ import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.ProjectContentsResponse; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.ProjectDetailsResponse; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.ProjectRequest; +import java.util.List; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = {FileElementDtoMapper.class}) +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, + uses = {FileElementDtoMapper.class}) public interface ProjectDtoMapper { @Mapping(target = "id", ignore = true) @Mapping(target = "parentId", ignore = true) @Mapping(target = "contents", ignore = true) + @Mapping(target = "createdOn", ignore = true) @Mapping(target = "lastAccess", ignore = true) - Project toDomain(ProjectRequest domain); + @Mapping(target = "ownerId", ignore = true) // Set by service layer from auth context + Project toDomain(ProjectRequest request); - @Mapping(target = "type", source = "domain.typeIdentifier") - ProjectDetailsResponse toDetailsResponse(Project domain); + @Mapping(target = "type", source = "typeIdentifier") + ProjectDetailsResponse toDetailsResponse(Project project); - @Mapping(target = "type", source = "domain.typeIdentifier") - @Mapping(target = "contents", source = "domain.contents") - ProjectContentsResponse toContentsResponse(Project domain); + @Mapping(target = "type", source = "typeIdentifier") + @Mapping(target = "contents", source = "contents") + ProjectContentsResponse toContentsResponse(Project project); - List toDetailsResponseList(List domains); + List toDetailsResponseList(List projects); } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/DirectoryJpaAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/DirectoryJpaAdapter.java index 0823abd2..bcab75fd 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/DirectoryJpaAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/DirectoryJpaAdapter.java @@ -7,9 +7,9 @@ import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.DirectoryJpaMapper; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.repository.SpringDataDirectoryRepository; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.repository.SpringDataFileElementContainerRepository; -import org.springframework.stereotype.Repository; - import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; @Repository public class DirectoryJpaAdapter implements DirectoryRepositoryPort { @@ -18,7 +18,10 @@ public class DirectoryJpaAdapter implements DirectoryRepositoryPort { SpringDataFileElementContainerRepository parentRepository; DirectoryJpaMapper directoryMapper; - public DirectoryJpaAdapter(SpringDataDirectoryRepository directoryRepository, DirectoryJpaMapper directoryMapper, SpringDataFileElementContainerRepository parentRepository) { + public DirectoryJpaAdapter( + SpringDataDirectoryRepository directoryRepository, + DirectoryJpaMapper directoryMapper, + SpringDataFileElementContainerRepository parentRepository) { this.directoryRepository = directoryRepository; this.directoryMapper = directoryMapper; this.parentRepository = parentRepository; @@ -31,19 +34,24 @@ public char idPrefix() { @Override public Optional findById(String dId) { - return directoryRepository.findById(dId) - .map(directoryMapper::toDomainEntity); + return directoryRepository.findById(dId).map(directoryMapper::toDomainEntity); } - @Override public Directory save(Directory container) { JpaDirectory jpaDirectory = directoryMapper.toJpaEntity(container); - // We need to set the parent of container manually because it is ignored by the mapping + // We need to set the parent of container manually because it is ignored by the + // mapping // else we would lose the bidirectional behavior in the db if (container.getParentId() != null) { - JpaFileElementContainer parent = parentRepository.findById(container.getParentId()) - .orElseThrow(() -> new IllegalArgumentException("Parent not found: " + container.getParentId())); + JpaFileElementContainer parent = + parentRepository + .findById(container.getParentId()) + .orElseThrow( + () -> + new IllegalArgumentException( + "Parent not found: " + + container.getParentId())); jpaDirectory.setParent(parent); } return directoryMapper.toDomainEntity(directoryRepository.save(jpaDirectory)); @@ -53,4 +61,11 @@ public Directory save(Directory container) { public boolean existsById(String dId) { return directoryRepository.existsById(dId); } + + @Override + public Optional findProjectOwnerIdByElementId(String elementId) { + return parentRepository + .findProjectOwnerIdByElementId(elementId) + .map(JpaUtils::convertToUuid); + } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileContentJpaAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileContentJpaAdapter.java index e7e66e64..88ec0415 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileContentJpaAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileContentJpaAdapter.java @@ -4,9 +4,8 @@ import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFileContent; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.repository.SpringDataFileContentRepository; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.repository.SpringDataJpaFileRepository; -import org.springframework.stereotype.Repository; - import java.util.Optional; +import org.springframework.stereotype.Repository; @Repository public class FileContentJpaAdapter implements FileContentRepositoryPort { @@ -14,8 +13,9 @@ public class FileContentJpaAdapter implements FileContentRepositoryPort { private final SpringDataFileContentRepository contentRepository; private final SpringDataJpaFileRepository fileRepository; - public FileContentJpaAdapter(SpringDataFileContentRepository contentRepository, - SpringDataJpaFileRepository fileRepository) { + public FileContentJpaAdapter( + SpringDataFileContentRepository contentRepository, + SpringDataJpaFileRepository fileRepository) { this.contentRepository = contentRepository; this.fileRepository = fileRepository; } @@ -23,19 +23,24 @@ public FileContentJpaAdapter(SpringDataFileContentRepository contentRepository, @Override public void saveContent(String fileId, byte[] content) { if (!fileRepository.existsById(fileId)) { - throw new IllegalArgumentException("Cannot save content. File Metadata not found for ID: " + fileId); + throw new IllegalArgumentException( + "Cannot save content. File Metadata not found for ID: " + fileId); } - JpaFileContent entity = contentRepository.findById(fileId) - .map(existing -> { - // Update - existing.setContent(content); - return existing; - }) - .orElseGet(() -> { - // Create - return new JpaFileContent(fileId, content); - }); + JpaFileContent entity = + contentRepository + .findById(fileId) + .map( + existing -> { + // Update + existing.setContent(content); + return existing; + }) + .orElseGet( + () -> { + // Create + return new JpaFileContent(fileId, content); + }); // store contentRepository.save(entity); @@ -43,12 +48,11 @@ public void saveContent(String fileId, byte[] content) { @Override public Optional loadContent(String fileId) { - return contentRepository.findById(fileId) - .map(JpaFileContent::getContent); + return contentRepository.findById(fileId).map(JpaFileContent::getContent); } @Override public void deleteContent(String fileId) { contentRepository.deleteById(fileId); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileJpaAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileJpaAdapter.java index 615cff0a..3e5b8464 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileJpaAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileJpaAdapter.java @@ -4,13 +4,13 @@ import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.FileJpaMapper; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.repository.SpringDataJpaFileRepository; -import org.springframework.stereotype.Repository; - import java.util.Optional; +import org.springframework.stereotype.Repository; /** - * Adapter that connects the FileElementRepositoryPort (Application) to Spring Data JPA (Infrastructure). - * The file adapter only supports read access. Save, Update and Delete operations are handled by root aggregate. + * Adapter that connects the FileElementRepositoryPort (Application) to Spring Data JPA + * (Infrastructure). The file adapter only supports read access. Save, Update and Delete operations + * are handled by root aggregate. */ @Repository public class FileJpaAdapter implements FileRepositoryPort { @@ -24,8 +24,7 @@ public FileJpaAdapter(SpringDataJpaFileRepository fileRepository, FileJpaMapper @Override public Optional findById(String fId) { - return fileRepository.findById(fId) - .map(fileMapper::toDomainEntity); + return fileRepository.findById(fId).map(fileMapper::toDomainEntity); } @Override diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/JpaUtils.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/JpaUtils.java new file mode 100644 index 00000000..f11c60e1 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/JpaUtils.java @@ -0,0 +1,39 @@ +package edu.kit.quak.infrastructure.filesystem.out.db.jpa; + +import java.nio.ByteBuffer; +import java.util.UUID; + +/** Utility class for JPA specific operations. */ +public final class JpaUtils { + + private JpaUtils() { + // Private constructor to prevent instantiation + } + + /** + * Converts the raw database value to UUID. Different databases may return different types for + * UUIDs when using native queries: - H2 returns byte[] - MariaDB may return UUID or String + * + * @param value the raw value from the database + * @return the converted UUID or null if the value is null + * @throws IllegalArgumentException if the value cannot be converted + */ + public static UUID convertToUuid(Object value) { + if (value == null) { + return null; + } + if (value instanceof UUID uuid) { + return uuid; + } else if (value instanceof byte[] bytes) { + // H2 returns UUID as byte array + if (bytes.length != 16) { + throw new IllegalArgumentException("Byte array for UUID must be 16 bytes long"); + } + ByteBuffer bb = ByteBuffer.wrap(bytes); + return new UUID(bb.getLong(), bb.getLong()); + } else if (value instanceof String str) { + return UUID.fromString(str); + } + throw new IllegalArgumentException("Cannot convert value to UUID: " + value.getClass()); + } +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/ProjectJpaAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/ProjectJpaAdapter.java index 03cf075a..c20e9f72 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/ProjectJpaAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/ProjectJpaAdapter.java @@ -4,20 +4,26 @@ import edu.kit.quak.core.filesystem.model.Project; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaProject; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.ProjectJpaMapper; +import edu.kit.quak.infrastructure.filesystem.out.db.jpa.repository.SpringDataFileElementContainerRepository; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.repository.SpringDataProjectRepository; -import org.springframework.stereotype.Repository; - import java.util.List; import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; @Repository public class ProjectJpaAdapter implements ProjectRepositoryPort { - public final SpringDataProjectRepository repository; + private final SpringDataProjectRepository repository; + private final SpringDataFileElementContainerRepository containerRepository; private final ProjectJpaMapper projectMapper; - ProjectJpaAdapter(SpringDataProjectRepository repository, ProjectJpaMapper projectMapper) { + ProjectJpaAdapter( + SpringDataProjectRepository repository, + SpringDataFileElementContainerRepository containerRepository, + ProjectJpaMapper projectMapper) { this.repository = repository; + this.containerRepository = containerRepository; this.projectMapper = projectMapper; } @@ -28,8 +34,7 @@ public char idPrefix() { @Override public Optional findById(String pId) { - return repository.findById(pId) - .map(projectMapper::toDomainEntity); + return repository.findById(pId).map(projectMapper::toDomainEntity); } @Override @@ -40,9 +45,9 @@ public Project save(Project project) { } @Override - public List getAllProjects() { - return repository.findAll() - .stream().map(projectMapper::toDomainEntity) + public List getProjectsByOwnerId(UUID ownerId) { + return repository.findAllByOwnerId(ownerId).stream() + .map(projectMapper::toDomainEntity) .toList(); } @@ -55,4 +60,11 @@ public void deleteById(String pId) { public boolean existsById(String pId) { return repository.existsById(pId); } + + @Override + public Optional findProjectOwnerIdByElementId(String elementId) { + return containerRepository + .findProjectOwnerIdByElementId(elementId) + .map(JpaUtils::convertToUuid); + } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaDirectory.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaDirectory.java index fdf2ada8..d297a0bb 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaDirectory.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaDirectory.java @@ -14,4 +14,4 @@ protected JpaDirectory() { public JpaDirectory(String name, JpaFileElementContainer parent) { super(name, parent); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFile.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFile.java index dd25e68d..8cc9a767 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFile.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFile.java @@ -3,13 +3,17 @@ import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; /** - * JPA entity for persisting File domain objects. - * This lives in the infrastructure layer and maps directly to the database schema. + * JPA entity for persisting File domain objects. This lives in the infrastructure layer and maps + * directly to the database schema. */ @Entity @DiscriminatorValue("file") +@Getter +@Setter public class JpaFile extends JpaFileElement { @Column(name = "content_type") @@ -22,7 +26,4 @@ protected JpaFile() { public JpaFile(String name, JpaFileElementContainer parent) { super(name, parent); } - - public String getContentType() { return contentType; } - public void setContentType(String contentType) { this.contentType = contentType; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileContent.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileContent.java index 515bdd2b..adc1c52f 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileContent.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileContent.java @@ -4,8 +4,11 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Lob; +import lombok.Getter; +import lombok.Setter; @Entity +@Getter public class JpaFileContent { // One to One foreign key to File @@ -13,8 +16,7 @@ public class JpaFileContent { @Column(name = "file_id", nullable = false, updatable = false) private String fileId; - @Lob - private byte[] content; + @Lob @Setter private byte[] content; protected JpaFileContent() {} @@ -22,8 +24,4 @@ public JpaFileContent(String fileId, byte[] content) { this.fileId = fileId; this.content = content; } - - public byte[] getContent() { return content; } - public void setContent(byte[] content) { this.content = content; } - public String getFileId() { return fileId; } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileElement.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileElement.java index 98b40e8f..d6636b6e 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileElement.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileElement.java @@ -1,17 +1,17 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity; import jakarta.persistence.*; - import java.time.Instant; +import lombok.Getter; +import lombok.Setter; -/** - * JPA Entity for Storage - * Contains persistence annotations and id configurations - */ +/** JPA Entity for Storage Contains persistence annotations and id configurations */ @Entity @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @DiscriminatorColumn(name = "dtype", discriminatorType = DiscriminatorType.STRING) @Table(name = "file_element") +@Getter +@Setter public abstract class JpaFileElement> { @Id @@ -36,18 +36,7 @@ public JpaFileElement(String name, JpaFileElementContainer parent) { this.parent = parent; } - protected JpaFileElement() { } - - public String getId() { return id; } - public void setId(String id) { this.id = id; } - public String getName() { return name; } - public void setName(String name) { this.name = name; } - public Instant getCreatedOn() { return createdOn; } - public void setCreatedOn(Instant createdOn) { this.createdOn = createdOn; } - public Instant getLastAccess() { return lastAccess; } - public void setLastAccess(Instant lastAccess) { this.lastAccess = lastAccess; } - public JpaFileElementContainer getParent() { return parent; } - public void setParent(JpaFileElementContainer parent) { this.parent = parent; } + protected JpaFileElement() {} @Override public final boolean equals(Object o) { @@ -58,6 +47,6 @@ public final boolean equals(Object o) { @Override public final int hashCode() { - return JpaFileElement.class.hashCode(); + return getId() != null ? getId().hashCode() : 0; } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileElementContainer.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileElementContainer.java index 512adb1a..f17fd063 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileElementContainer.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaFileElementContainer.java @@ -4,14 +4,20 @@ import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.OneToMany; - import java.util.HashSet; import java.util.Set; +import lombok.Getter; @Entity -public abstract class JpaFileElementContainer> extends JpaFileElement { - - @OneToMany(mappedBy = "parent", orphanRemoval = true, cascade = CascadeType.ALL, fetch = FetchType.LAZY) +@Getter +public abstract class JpaFileElementContainer> + extends JpaFileElement { + + @OneToMany( + mappedBy = "parent", + orphanRemoval = true, + cascade = CascadeType.ALL, + fetch = FetchType.LAZY) protected Set> contents = new HashSet<>(); protected JpaFileElementContainer() { @@ -22,14 +28,10 @@ public JpaFileElementContainer(String name, JpaFileElementContainer parent) { super(name, parent); } - public Set> getContents() { - return contents; - } - public void setContents(Set> contents) { this.contents = contents; for (JpaFileElement element : contents) { element.setParent(this); } } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaProject.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaProject.java index 9dea056f..d7441de4 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaProject.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/entity/JpaProject.java @@ -1,14 +1,35 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity; +import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; +import java.util.UUID; +import lombok.Getter; +import lombok.Setter; @Entity @DiscriminatorValue("project") +@Getter +@Setter public class JpaProject extends JpaFileElementContainer { - public JpaProject() { super(); } + + /** + * The UUID of the user who owns this project. We store only the ID to avoid coupling filesystem + * entities with user entities. + */ + @Column(name = "owner_id") + private UUID ownerId; + + public JpaProject() { + super(); + } public JpaProject(String name) { super(name, null); } -} \ No newline at end of file + + public JpaProject(String name, UUID ownerId) { + super(name, null); + this.ownerId = ownerId; + } +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/DirectoryJpaMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/DirectoryJpaMapper.java index 50aca306..e107da5f 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/DirectoryJpaMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/DirectoryJpaMapper.java @@ -6,7 +6,9 @@ import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = {FileElementJpaMapper.class}) +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, + uses = {FileElementJpaMapper.class}) public abstract class DirectoryJpaMapper { @Mapping(target = "id", source = "id") diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileElementJpaMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileElementJpaMapper.java index 2b3c3bea..b36b8a46 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileElementJpaMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileElementJpaMapper.java @@ -1,6 +1,5 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper; - import edu.kit.quak.core.filesystem.model.Directory; import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.core.filesystem.model.FileElement; @@ -9,27 +8,20 @@ import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFile; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFileElement; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaProject; +import java.util.Set; +import java.util.stream.Collectors; import org.mapstruct.Mapper; import org.mapstruct.MappingConstants; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; -import java.util.Set; -import java.util.stream.Collectors; - @Mapper(componentModel = MappingConstants.ComponentModel.SPRING) public abstract class FileElementJpaMapper { - @Autowired - @Lazy - protected FileJpaMapper fileMapper; + @Autowired @Lazy protected FileJpaMapper fileMapper; - @Autowired - @Lazy - protected DirectoryJpaMapper directoryMapper; + @Autowired @Lazy protected DirectoryJpaMapper directoryMapper; - @Autowired - @Lazy - protected ProjectJpaMapper projectMapper; + @Autowired @Lazy protected ProjectJpaMapper projectMapper; // Map polymorph FileElement public JpaFileElement toJpaEntity(FileElement domain) { @@ -43,9 +35,7 @@ public JpaFileElement toJpaEntity(FileElement domain) { return projectMapper.toJpaEntity(p); } - throw new IllegalArgumentException( - "Unknown FileElement subtype: " + domain.getClass() - ); + throw new IllegalArgumentException("Unknown FileElement subtype: " + domain.getClass()); } public FileElement toDomainEntity(JpaFileElement jpa) { @@ -59,23 +49,17 @@ public FileElement toDomainEntity(JpaFileElement jpa) { return projectMapper.toDomainEntity(p); } - throw new IllegalArgumentException( - "Unknown JpaFileElement subtype: " + jpa.getClass() - ); + throw new IllegalArgumentException("Unknown JpaFileElement subtype: " + jpa.getClass()); } // Map Set required for contents public Set> toJpaSet(Set> domainSet) { if (domainSet == null) return null; - return domainSet.stream() - .map(this::toJpaEntity) - .collect(Collectors.toSet()); + return domainSet.stream().map(this::toJpaEntity).collect(Collectors.toSet()); } public Set> toDomainSet(Set> jpaSet) { if (jpaSet == null) return null; - return jpaSet.stream() - .map(this::toDomainEntity) - .collect(Collectors.toSet()); + return jpaSet.stream().map(this::toDomainEntity).collect(Collectors.toSet()); } } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileJpaMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileJpaMapper.java index 6249903c..5c6cefa5 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileJpaMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileJpaMapper.java @@ -6,12 +6,15 @@ import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, uses = {FileElementJpaMapper.class}) public abstract class FileJpaMapper { @Mapping(target = "id", source = "id") - @Mapping(target = "parent", ignore = true) // Is set automatically - we never store Files directly + @Mapping( + target = "parent", + ignore = true) // Is set automatically - we never store Files directly public abstract JpaFile toJpaEntity(File domain); @Mapping(target = "id", source = "id") diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/ProjectJpaMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/ProjectJpaMapper.java index 7c0c88ea..18eb9013 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/ProjectJpaMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/ProjectJpaMapper.java @@ -6,15 +6,19 @@ import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = {FileElementJpaMapper.class}) +@Mapper( + componentModel = MappingConstants.ComponentModel.SPRING, + uses = {FileElementJpaMapper.class}) public abstract class ProjectJpaMapper { @Mapping(target = "id", source = "id") @Mapping(target = "parent", ignore = true) // We don't have a parent @Mapping(target = "contents", source = "contents") + @Mapping(target = "ownerId", source = "ownerId") public abstract JpaProject toJpaEntity(Project domain); @Mapping(target = "id", source = "id") - @Mapping(target = "parentId", source = "parent.id") + @Mapping(target = "parentId", ignore = true) // Projects are top-level and have no parent + @Mapping(target = "ownerId", source = "ownerId") public abstract Project toDomainEntity(JpaProject jpaEntity); } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataDirectoryRepository.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataDirectoryRepository.java index 803892c1..1179ac42 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataDirectoryRepository.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataDirectoryRepository.java @@ -3,5 +3,4 @@ import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaDirectory; import org.springframework.data.jpa.repository.JpaRepository; -public interface SpringDataDirectoryRepository extends JpaRepository { -} +public interface SpringDataDirectoryRepository extends JpaRepository {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataFileContentRepository.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataFileContentRepository.java index b06af9bd..cdff4ce4 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataFileContentRepository.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataFileContentRepository.java @@ -3,5 +3,4 @@ import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFileContent; import org.springframework.data.jpa.repository.JpaRepository; -public interface SpringDataFileContentRepository extends JpaRepository { -} +public interface SpringDataFileContentRepository extends JpaRepository {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataFileElementContainerRepository.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataFileElementContainerRepository.java index 33585486..4de760fe 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataFileElementContainerRepository.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataFileElementContainerRepository.java @@ -1,7 +1,35 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa.repository; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFileElementContainer; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -public interface SpringDataFileElementContainerRepository extends JpaRepository, String> { +public interface SpringDataFileElementContainerRepository + extends JpaRepository, String> { + + /** + * Finds the owner ID of the root project containing the given element. Uses a recursive CTE to + * traverse the parent hierarchy in a single query, avoiding the N+1 query problem. + * + * @param elementId The ID of any file element (file, directory, or project) + * @return The UUID of the user who owns the root project + */ + @Query( + value = + """ + WITH RECURSIVE hierarchy(id, parent_id, owner_id, dtype) AS ( + SELECT id, parent_id, owner_id, dtype + FROM file_element + WHERE id = :elementId + UNION ALL + SELECT fe.id, fe.parent_id, fe.owner_id, fe.dtype + FROM file_element fe + INNER JOIN hierarchy h ON fe.id = h.parent_id + ) + SELECT owner_id FROM hierarchy WHERE dtype = 'project' + """, + nativeQuery = true) + Optional findProjectOwnerIdByElementId(@Param("elementId") String elementId); } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataJpaFileRepository.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataJpaFileRepository.java index 2ce979cd..de2b7e2a 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataJpaFileRepository.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataJpaFileRepository.java @@ -3,5 +3,4 @@ import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFile; import org.springframework.data.jpa.repository.JpaRepository; -public interface SpringDataJpaFileRepository extends JpaRepository { -} +public interface SpringDataJpaFileRepository extends JpaRepository {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataProjectRepository.java b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataProjectRepository.java index f518ff28..19d7166f 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataProjectRepository.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/repository/SpringDataProjectRepository.java @@ -1,7 +1,17 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa.repository; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaProject; +import java.util.List; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; public interface SpringDataProjectRepository extends JpaRepository { + + /** + * Find all projects owned by a specific user. + * + * @param ownerId The UUID of the owner + * @return List of projects owned by the user + */ + List findAllByOwnerId(UUID ownerId); } diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/GateDefinitionRestAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/GateDefinitionRestAdapter.java index 788ec079..ba3d8a00 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/GateDefinitionRestAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/GateDefinitionRestAdapter.java @@ -5,22 +5,22 @@ import edu.kit.quak.core.library.model.GateDefinition; import edu.kit.quak.infrastructure.library.in.web.rest.dto.GateDefinitionResponse; import edu.kit.quak.infrastructure.library.in.web.rest.mapper.GateDefinitionDtoMapper; +import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - @RestController -@RequestMapping("/gates") +@RequestMapping("/api/gates") public class GateDefinitionRestAdapter { private final GateDefinitionServicePort gateService; private final GateDefinitionDtoMapper mapper; - public GateDefinitionRestAdapter(GateDefinitionServicePort gateService, GateDefinitionDtoMapper mapper) { + public GateDefinitionRestAdapter( + GateDefinitionServicePort gateService, GateDefinitionDtoMapper mapper) { this.gateService = gateService; this.mapper = mapper; } @@ -33,9 +33,10 @@ public List getAllGates() { @GetMapping("/{id}") public ResponseEntity getGateById(@PathVariable String id) { - return gateService.getGateDefinitionById(id) + return gateService + .getGateDefinitionById(id) .map(mapper::toResponse) // Mapping Domain -> DTO .map(ResponseEntity::ok) .orElseThrow(() -> new GateDefinitionNotFoundException(id)); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/dto/GateDefinitionResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/dto/GateDefinitionResponse.java index 90775bf6..fa47b7f4 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/dto/GateDefinitionResponse.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/dto/GateDefinitionResponse.java @@ -10,8 +10,7 @@ public record GateDefinitionResponse( int qubitCount, String symbol, List parameters, - InspectorInfoResponse inspectorInfo -) { + InspectorInfoResponse inspectorInfo) { // Ensure parameters list is not null public GateDefinitionResponse { if (parameters == null) { @@ -22,18 +21,10 @@ public record GateDefinitionResponse( public record InspectorInfoResponse( String operatorDefinition, List truthTable, - MatrixInfoResponse matrix - ) {} + MatrixInfoResponse matrix) {} - public record TruthTableEntryResponse( - String input, - String output - ) {} + public record TruthTableEntryResponse(String input, String output) {} public record MatrixInfoResponse( - String display, - int rows, - int cols, - List> computable - ) {} -} \ No newline at end of file + String display, int rows, int cols, List> computable) {} +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/mapper/GateDefinitionDtoMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/mapper/GateDefinitionDtoMapper.java index f9f3b668..e74f705a 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/mapper/GateDefinitionDtoMapper.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/library/in/web/rest/mapper/GateDefinitionDtoMapper.java @@ -2,15 +2,14 @@ import edu.kit.quak.core.library.model.GateDefinition; import edu.kit.quak.infrastructure.library.in.web.rest.dto.GateDefinitionResponse; +import java.util.List; import org.mapstruct.Mapper; import org.mapstruct.MappingConstants; -import java.util.List; - @Mapper(componentModel = MappingConstants.ComponentModel.SPRING) public interface GateDefinitionDtoMapper { GateDefinitionResponse toResponse(GateDefinition gateDefinition); List toResponseList(List gateDefinitions); -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/library/out/json/JsonGateDefinitionDefinitionRepositoryAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/library/out/json/JsonGateDefinitionDefinitionRepositoryAdapter.java index 59223ea0..ffe8ce07 100644 --- a/backend/src/main/java/edu/kit/quak/infrastructure/library/out/json/JsonGateDefinitionDefinitionRepositoryAdapter.java +++ b/backend/src/main/java/edu/kit/quak/infrastructure/library/out/json/JsonGateDefinitionDefinitionRepositoryAdapter.java @@ -4,10 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import edu.kit.quak.application.library.ports.out.GateDefinitionRepositoryPort; import edu.kit.quak.core.library.model.GateDefinition; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.ClassPathResource; -import org.springframework.stereotype.Repository; - import jakarta.annotation.PostConstruct; import java.io.IOException; import java.io.InputStream; @@ -16,6 +12,9 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Repository; @Repository public class JsonGateDefinitionDefinitionRepositoryAdapter implements GateDefinitionRepositoryPort { @@ -25,8 +24,10 @@ public class JsonGateDefinitionDefinitionRepositoryAdapter implements GateDefini private List cachedGateDefinitions = Collections.emptyList(); // Constructor Injection with property - public JsonGateDefinitionDefinitionRepositoryAdapter(ObjectMapper objectMapper, - @Value("${quak.library.gates-file:gatedefinitions.json}") String gateDefinitionsFilePath) { + public JsonGateDefinitionDefinitionRepositoryAdapter( + ObjectMapper objectMapper, + @Value("${quak.library.gates-file:gatedefinitions.json}") + String gateDefinitionsFilePath) { this.objectMapper = objectMapper; this.gateDefinitionsFilePath = gateDefinitionsFilePath; } @@ -43,26 +44,27 @@ public List findAllGateDefinitions() { @Override public Optional findGateDefinitionById(String id) { - return cachedGateDefinitions.stream() - .filter(g -> g.id().equals(id)) - .findFirst(); + return cachedGateDefinitions.stream().filter(g -> g.id().equals(id)).findFirst(); } private List loadGateDefinitionsFromJson() { try { ClassPathResource resource = new ClassPathResource(gateDefinitionsFilePath); if (!resource.exists()) { - throw new IllegalStateException("Gate library file not found: " + gateDefinitionsFilePath); + throw new IllegalStateException( + "Gate library file not found: " + gateDefinitionsFilePath); } try (InputStream is = resource.getInputStream()) { - JsonGateDefinitionDto[] dtos = objectMapper.readValue(is, JsonGateDefinitionDto[].class); + JsonGateDefinitionDto[] dtos = + objectMapper.readValue(is, JsonGateDefinitionDto[].class); return Arrays.stream(dtos) .map(JsonGateDefinitionDto::toDomain) .collect(Collectors.toList()); } } catch (IOException e) { - throw new IllegalStateException("Failed to parse gate library from " + gateDefinitionsFilePath, e); + throw new IllegalStateException( + "Failed to parse gate library from " + gateDefinitionsFilePath, e); } } @@ -74,8 +76,7 @@ private record JsonGateDefinitionDto( @JsonProperty("description") String description, @JsonProperty("qubitCount") int qubitCount, @JsonProperty("parameters") List parameters, - @JsonProperty("inspectorInfo") JsonInspectorInfoDto inspectorInfo - ) { + @JsonProperty("inspectorInfo") JsonInspectorInfoDto inspectorInfo) { GateDefinition toDomain() { return new GateDefinition( id, @@ -85,31 +86,26 @@ GateDefinition toDomain() { qubitCount, symbol, parameters != null ? parameters : Collections.emptyList(), - inspectorInfo != null ? inspectorInfo.toDomain() : null - ); + inspectorInfo != null ? inspectorInfo.toDomain() : null); } } private record JsonInspectorInfoDto( @JsonProperty("operatorDefinition") String operatorDefinition, @JsonProperty("truthTable") List truthTable, - @JsonProperty("matrix") JsonMatrixDto matrix - ) { + @JsonProperty("matrix") JsonMatrixDto matrix) { GateDefinition.InspectorInfo toDomain() { return new GateDefinition.InspectorInfo( operatorDefinition, - truthTable != null ? truthTable.stream() - .map(JsonTruthTableEntryDto::toDomain) - .toList() : Collections.emptyList(), - matrix != null ? matrix.toDomain() : null - ); + truthTable != null + ? truthTable.stream().map(JsonTruthTableEntryDto::toDomain).toList() + : Collections.emptyList(), + matrix != null ? matrix.toDomain() : null); } } private record JsonTruthTableEntryDto( - @JsonProperty("input") String input, - @JsonProperty("output") String output - ) { + @JsonProperty("input") String input, @JsonProperty("output") String output) { GateDefinition.TruthTableEntry toDomain() { return new GateDefinition.TruthTableEntry(input, output); } @@ -119,10 +115,9 @@ private record JsonMatrixDto( @JsonProperty("display") String display, @JsonProperty("rows") int rows, @JsonProperty("cols") int cols, - @JsonProperty("computable") List> computable - ) { + @JsonProperty("computable") List> computable) { GateDefinition.MatrixInfo toDomain() { return new GateDefinition.MatrixInfo(display, rows, cols, computable); } } -} \ No newline at end of file +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/AuthRestAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/AuthRestAdapter.java new file mode 100644 index 00000000..70a5c115 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/AuthRestAdapter.java @@ -0,0 +1,76 @@ +package edu.kit.quak.infrastructure.user.in.web.rest; + +import edu.kit.quak.application.user.dto.AuthStatusResponse; +import edu.kit.quak.application.user.dto.LogoutResponse; +import edu.kit.quak.application.user.exceptions.UserNotFoundException; +import edu.kit.quak.application.user.ports.in.AuthServicePort; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import edu.kit.quak.infrastructure.user.in.web.rest.dto.RestAuthStatusResponse; +import edu.kit.quak.infrastructure.user.in.web.rest.dto.RestLogoutResponse; +import edu.kit.quak.infrastructure.user.in.web.rest.mapper.AuthenticationMapper; +import jakarta.servlet.http.HttpSession; +import java.util.Optional; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +/** + * REST adapter for authentication-related endpoints. Handles HTTP-specific concerns and delegates + * business logic to the application layer. + */ +@RestController +@RequestMapping("/api/auth") +public class AuthRestAdapter { + + private final AuthServicePort authService; + private final AuthenticationMapper authenticationMapper; + + public AuthRestAdapter(AuthServicePort authService, AuthenticationMapper authenticationMapper) { + this.authService = authService; + this.authenticationMapper = authenticationMapper; + } + + @GetMapping("/user") + public RestAuthStatusResponse getUser() { + Optional authenticatedUser = extractAuthenticatedUser(); + + AuthStatusResponse response = authService.getAuthenticationStatus(authenticatedUser); + return authenticationMapper.toRestResponse(response); + } + + @PostMapping("/logout") + public RestLogoutResponse logout(HttpSession session) { + // Extract sessionId for business logic + String sessionId = session.getId(); + + // Call application service + LogoutResponse response = authService.logout(sessionId); + + // Handle infrastructure concerns: clear security context and invalidate session + SecurityContextHolder.clearContext(); + session.invalidate(); + + return authenticationMapper.toRestResponse(response); + } + + /** + * Extracts AuthenticatedUser from Spring Security context. + * + * @return Optional containing the authenticated user, empty if not authenticated + */ + private Optional extractAuthenticatedUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null + || !authentication.isAuthenticated() + || "anonymousUser".equals(authentication.getPrincipal())) { + return Optional.empty(); + } + + try { + return Optional.of(authenticationMapper.toDomain(authentication)); + } catch (UserNotFoundException e) { + return Optional.empty(); + } + } +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/FaviconController.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/FaviconController.java new file mode 100644 index 00000000..3938bc87 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/FaviconController.java @@ -0,0 +1,20 @@ +package edu.kit.quak.infrastructure.user.in.web.rest; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controller to handle favicon requests and prevent noisy 404 logs. Since this is a REST-only + * backend, we don't need an actual icon. + */ +@RestController +public class FaviconController { + + @GetMapping("favicon.ico") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void returnNoFavicon() { + // Just return 204 No Content + } +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/UserRestAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/UserRestAdapter.java new file mode 100644 index 00000000..6638dc39 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/UserRestAdapter.java @@ -0,0 +1,43 @@ +package edu.kit.quak.infrastructure.user.in.web.rest; + +import edu.kit.quak.application.user.ports.in.UserServicePort; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import edu.kit.quak.core.user.model.User; +import edu.kit.quak.infrastructure.user.in.web.rest.dto.UserResponse; +import edu.kit.quak.infrastructure.user.in.web.rest.mapper.AuthenticationMapper; +import edu.kit.quak.infrastructure.user.in.web.rest.mapper.UserDtoMapper; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST adapter for user-related endpoints. Handles HTTP-specific concerns and converts framework + * types to domain types. + */ +@RestController +@RequestMapping("/api") +public class UserRestAdapter { + + private final UserServicePort userService; + private final UserDtoMapper userDtoMapper; + private final AuthenticationMapper authMapper; + + public UserRestAdapter( + UserServicePort userService, + UserDtoMapper userDtoMapper, + AuthenticationMapper authMapper) { + this.userService = userService; + this.userDtoMapper = userDtoMapper; + this.authMapper = authMapper; + } + + @GetMapping("/me") + @PreAuthorize("isAuthenticated()") + public UserResponse getCurrentUser(Authentication authentication) { + AuthenticatedUser authUser = authMapper.toDomain(authentication); + User user = userService.getAuthenticatedUser(authUser); + return userDtoMapper.toResponse(user); + } +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/AuthUserResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/AuthUserResponse.java new file mode 100644 index 00000000..b37513f2 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/AuthUserResponse.java @@ -0,0 +1,13 @@ +package edu.kit.quak.infrastructure.user.in.web.rest.dto; + +import java.util.UUID; + +/** + * Infrastructure-specific DTO for authenticated user information in the REST response. + * + * @param userId The unique identifier of the user + * @param email The user's email address + * @param name The user's display name + * @param picture The URL to the user's profile picture/avatar + */ +public record AuthUserResponse(UUID userId, String email, String name, String picture) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/RestAuthStatusResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/RestAuthStatusResponse.java new file mode 100644 index 00000000..ffa5250e --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/RestAuthStatusResponse.java @@ -0,0 +1,11 @@ +package edu.kit.quak.infrastructure.user.in.web.rest.dto; + +import java.util.UUID; + +/** + * Infrastructure-specific DTO for the authentication status REST response. + * + * @param authenticated Whether the user is authenticated + * @param userId Unique identifier of the authenticated user, or null if not authenticated + */ +public record RestAuthStatusResponse(boolean authenticated, UUID userId) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/RestLogoutResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/RestLogoutResponse.java new file mode 100644 index 00000000..831ee2fa --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/RestLogoutResponse.java @@ -0,0 +1,8 @@ +package edu.kit.quak.infrastructure.user.in.web.rest.dto; + +/** + * Infrastructure-specific DTO for the logout REST response. + * + * @param message Success or status message + */ +public record RestLogoutResponse(String message) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/UserResponse.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/UserResponse.java new file mode 100644 index 00000000..e298e3ad --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/dto/UserResponse.java @@ -0,0 +1,7 @@ +package edu.kit.quak.infrastructure.user.in.web.rest.dto; + +import java.util.UUID; + +/** DTO for user response data. */ +public record UserResponse( + UUID userId, String email, String name, String avatarUrl, Boolean emailVerified) {} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/mapper/AuthenticationMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/mapper/AuthenticationMapper.java new file mode 100644 index 00000000..86e75117 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/mapper/AuthenticationMapper.java @@ -0,0 +1,75 @@ +package edu.kit.quak.infrastructure.user.in.web.rest.mapper; + +import edu.kit.quak.application.user.dto.AuthStatusResponse; +import edu.kit.quak.application.user.dto.LogoutResponse; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import edu.kit.quak.infrastructure.user.in.web.rest.dto.RestAuthStatusResponse; +import edu.kit.quak.infrastructure.user.in.web.rest.dto.RestLogoutResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Component; + +/** + * Utility to convert between Spring Security, domain models, and REST DTOs. This adapter handles + * the framework-to-domain and domain-to-infrastructure translations for authentication. + */ +@Component +public class AuthenticationMapper { + + /** + * Extracts domain AuthenticatedUser from Spring Security Authentication. + * + * @param authentication Spring Security authentication object + * @return Domain model representing the authenticated user + * @throws edu.kit.quak.application.user.exceptions.UserNotFoundException if authentication is + * not OAuth2/OIDC based + */ + public AuthenticatedUser toDomain(Authentication authentication) { + if (authentication == null) { + throw new edu.kit.quak.application.user.exceptions.UserNotFoundException( + "No authentication found", "User is not authenticated"); + } + + if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { + throw new edu.kit.quak.application.user.exceptions.UserNotFoundException( + "Invalid authentication type", "Expected OAuth2 authentication"); + } + + if (!(authentication.getPrincipal() instanceof OidcUser oidcUser)) { + throw new edu.kit.quak.application.user.exceptions.UserNotFoundException( + "Invalid principal type", "Expected OIDC user"); + } + + String issuer = oauthToken.getAuthorizedClientRegistrationId(); + String subject = oidcUser.getSubject(); + + // Note: userId will be null here - it needs to be looked up from the database + // by the AuthService + return new AuthenticatedUser(null, issuer, subject); + } + + /** + * Converts application AuthStatusResponse to infrastructure RestAuthStatusResponse. + * + * @param response The application-layer response + * @return The infrastructure-layer REST response + */ + public RestAuthStatusResponse toRestResponse(AuthStatusResponse response) { + java.util.UUID userId = null; + if (response.user() != null) { + userId = response.user().getId(); + } + return new RestAuthStatusResponse(response.authenticated(), userId); + } + + /** + * Converts application LogoutResponse to infrastructure RestLogoutResponse. + * + * @param response The application-layer response + * @return The infrastructure-layer REST response + */ + public RestLogoutResponse toRestResponse(LogoutResponse response) { + return new RestLogoutResponse(response.message()); + } +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/mapper/UserDtoMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/mapper/UserDtoMapper.java new file mode 100644 index 00000000..96e1f622 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/in/web/rest/mapper/UserDtoMapper.java @@ -0,0 +1,15 @@ +package edu.kit.quak.infrastructure.user.in.web.rest.mapper; + +import edu.kit.quak.core.user.model.User; +import edu.kit.quak.infrastructure.user.in.web.rest.dto.UserResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; + +/** Mapper for converting User domain model to DTOs. */ +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public interface UserDtoMapper { + + @Mapping(source = "id", target = "userId") + UserResponse toResponse(User user); +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/UserJpaAdapter.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/UserJpaAdapter.java new file mode 100644 index 00000000..18841347 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/UserJpaAdapter.java @@ -0,0 +1,53 @@ +package edu.kit.quak.infrastructure.user.out.db.jpa; + +import edu.kit.quak.application.user.ports.out.UserRepositoryPort; +import edu.kit.quak.core.user.model.User; +import edu.kit.quak.infrastructure.user.out.db.jpa.entity.JpaUser; +import edu.kit.quak.infrastructure.user.out.db.jpa.mapper.UserJpaMapper; +import edu.kit.quak.infrastructure.user.out.db.jpa.repository.SpringDataUserRepository; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +/** + * JPA adapter implementing UserRepositoryPort. This adapter translates domain operations to JPA + * operations. + */ +@Repository +public class UserJpaAdapter implements UserRepositoryPort { + + private final SpringDataUserRepository repository; + private final UserJpaMapper mapper; + + public UserJpaAdapter(SpringDataUserRepository repository, UserJpaMapper mapper) { + this.repository = repository; + this.mapper = mapper; + } + + @Override + public User save(User user) { + JpaUser jpaUser = mapper.toJpa(user); + JpaUser saved = repository.save(jpaUser); + return mapper.toDomain(saved); + } + + @Override + public Optional findById(UUID id) { + return repository.findById(id).map(mapper::toDomain); + } + + @Override + public Optional findByIssuerAndSub(String issuer, String sub) { + return repository.findByIssuerAndSub(issuer, sub).map(mapper::toDomain); + } + + @Override + public void deleteById(UUID id) { + repository.deleteById(id); + } + + @Override + public Optional findIdByIssuerAndSub(String issuer, String sub) { + return repository.findIdByIssuerAndSub(issuer, sub); + } +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/entity/JpaUser.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/entity/JpaUser.java new file mode 100644 index 00000000..b60ab2fe --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/entity/JpaUser.java @@ -0,0 +1,58 @@ +package edu.kit.quak.infrastructure.user.out.db.jpa.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +/** JPA entity for User persistence. */ +@Entity +@Table( + name = "users", + uniqueConstraints = {@UniqueConstraint(columnNames = {"issuer", "sub"})}) +@Getter +@Setter +@NoArgsConstructor +public class JpaUser { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String issuer; + + @Column(nullable = false) + private String sub; + + private String email; + + @Column(name = "email_verified") + private Boolean emailVerified; + + private String name; + + @Column(name = "given_name") + private String givenName; + + @Column(name = "family_name") + private String familyName; + + @Column(name = "avatar_url") + private String avatarUrl; + + @CreationTimestamp + @Column(name = "created_at", updatable = false, columnDefinition = "TIMESTAMP(6)") + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", columnDefinition = "TIMESTAMP(6)") + private Instant updatedAt; + + @Column(name = "last_login_at", columnDefinition = "TIMESTAMP(6)") + private Instant lastLoginAt; +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/mapper/UserJpaMapper.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/mapper/UserJpaMapper.java new file mode 100644 index 00000000..312ecb75 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/mapper/UserJpaMapper.java @@ -0,0 +1,18 @@ +package edu.kit.quak.infrastructure.user.out.db.jpa.mapper; + +import edu.kit.quak.core.user.model.User; +import edu.kit.quak.infrastructure.user.out.db.jpa.entity.JpaUser; +import org.mapstruct.Mapper; +import org.mapstruct.MappingConstants; + +/** + * MapStruct mapper for converting between User domain model and JpaUser entity. All field names + * match between domain and JPA entity, so no explicit mappings are needed. + */ +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public interface UserJpaMapper { + + User toDomain(JpaUser jpaUser); + + JpaUser toJpa(User user); +} diff --git a/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/repository/SpringDataUserRepository.java b/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/repository/SpringDataUserRepository.java new file mode 100644 index 00000000..2fdf62d0 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/infrastructure/user/out/db/jpa/repository/SpringDataUserRepository.java @@ -0,0 +1,22 @@ +package edu.kit.quak.infrastructure.user.out.db.jpa.repository; + +import edu.kit.quak.infrastructure.user.out.db.jpa.entity.JpaUser; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** Spring Data JPA repository for JpaUser entity. */ +@Repository +public interface SpringDataUserRepository extends JpaRepository { + Optional findByIssuerAndSub(String issuer, String sub); + + /** + * Efficiently retrieves only the user's UUID without loading the full entity. This is ideal for + * ownership verification where only the ID is needed. + */ + @Query("SELECT u.id FROM JpaUser u WHERE u.issuer = :issuer AND u.sub = :sub") + Optional findIdByIssuerAndSub(@Param("issuer") String issuer, @Param("sub") String sub); +} diff --git a/backend/src/main/java/edu/kit/quak/security/AuthController.java b/backend/src/main/java/edu/kit/quak/security/AuthController.java deleted file mode 100644 index 685e94d9..00000000 --- a/backend/src/main/java/edu/kit/quak/security/AuthController.java +++ /dev/null @@ -1,67 +0,0 @@ -package edu.kit.quak.security; - -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.web.bind.annotation.*; - -import jakarta.servlet.http.HttpSession; -import java.util.HashMap; -import java.util.Map; - -@RestController -@RequestMapping("/api/auth") -public class AuthController { - - @GetMapping("/status") - public Map getAuthStatus(HttpSession session) { - Map response = new HashMap<>(); - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication != null && authentication.isAuthenticated() - && !"anonymousUser".equals(authentication.getPrincipal())) { - - response.put("authenticated", true); - - if (authentication.getPrincipal() instanceof OAuth2User) { - OAuth2User user = (OAuth2User) authentication.getPrincipal(); - Map userInfo = new HashMap<>(); - userInfo.put("email", user.getAttribute("email")); - userInfo.put("name", user.getAttribute("name")); - userInfo.put("picture", user.getAttribute("picture")); - response.put("user", userInfo); - } - } else { - response.put("authenticated", false); - } - - return response; - } - - @GetMapping("/user") - public Map getUser() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication != null && authentication.getPrincipal() instanceof OAuth2User) { - OAuth2User user = (OAuth2User) authentication.getPrincipal(); - Map userInfo = new HashMap<>(); - userInfo.put("email", user.getAttribute("email")); - userInfo.put("name", user.getAttribute("name")); - userInfo.put("picture", user.getAttribute("picture")); - userInfo.put("sub", user.getAttribute("sub")); - return userInfo; - } - - throw new RuntimeException("User not authenticated"); - } - - @PostMapping("/logout") - public Map logout(HttpSession session) { - session.invalidate(); - SecurityContextHolder.clearContext(); - - Map response = new HashMap<>(); - response.put("message", "Logged out successfully"); - return response; - } -} diff --git a/backend/src/main/java/edu/kit/quak/security/SecurityConfig.java b/backend/src/main/java/edu/kit/quak/security/SecurityConfig.java deleted file mode 100644 index b4704434..00000000 --- a/backend/src/main/java/edu/kit/quak/security/SecurityConfig.java +++ /dev/null @@ -1,187 +0,0 @@ -package edu.kit.quak.security; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; -import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.csrf.CsrfToken; -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; -import java.util.List; -import java.util.UUID; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - @Value("${app.frontend.url}") - private String frontendUrl; - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, - OAuth2AuthorizationRequestResolver authorizationRequestResolver) throws Exception { - http - .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .csrf(csrf -> csrf - .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) - .ignoringRequestMatchers("/api/auth/**", "/login/**", "/oauth2/**") - ) - .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/", - "/login/**", - "/oauth2/**", - "/api/auth/status", - "/error", - "/*.js", - "/*.css", - "/*.html", - "/*.ico", - "/*.png", - "/*.jpg", - "/assets/**" - ).permitAll() - .anyRequest().authenticated() - ) - .oauth2Login(oauth2 -> oauth2 - .authorizationEndpoint(authorization -> authorization - .authorizationRequestResolver(authorizationRequestResolver) - ) - .successHandler(authenticationSuccessHandler()) - ) - .logout(logout -> logout - .logoutUrl("/api/auth/logout") - .logoutSuccessHandler((request, response, authentication) -> { - response.setStatus(jakarta.servlet.http.HttpServletResponse.SC_OK); - }) - .invalidateHttpSession(true) - .deleteCookies("JSESSIONID") - .permitAll() - ) - .exceptionHandling(exception -> exception - .authenticationEntryPoint(new org.springframework.security.web.authentication.HttpStatusEntryPoint(org.springframework.http.HttpStatus.UNAUTHORIZED)) - ); - - return http.build(); - } - - @Bean - public OAuth2AuthorizationRequestResolver authorizationRequestResolver( - ClientRegistrationRepository clientRegistrationRepository) { - - DefaultOAuth2AuthorizationRequestResolver defaultResolver = - new DefaultOAuth2AuthorizationRequestResolver( - clientRegistrationRepository, "/oauth2/authorization"); - - return new OAuth2AuthorizationRequestResolver() { - @Override - public OAuth2AuthorizationRequest resolve(jakarta.servlet.http.HttpServletRequest request) { - OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request); - return authorizationRequest != null ? - customizeAuthorizationRequest(authorizationRequest) : null; - } - - @Override - public OAuth2AuthorizationRequest resolve(jakarta.servlet.http.HttpServletRequest request, String clientRegistrationId) { - OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request, clientRegistrationId); - return authorizationRequest != null ? - customizeAuthorizationRequest(authorizationRequest) : null; - } - }; - } - - private OAuth2AuthorizationRequest customizeAuthorizationRequest( - OAuth2AuthorizationRequest authorizationRequest) { - - // Generate PKCE code verifier and challenge - String codeVerifier = generateCodeVerifier(); - String codeChallenge = generateCodeChallenge(codeVerifier); - - return OAuth2AuthorizationRequest - .from(authorizationRequest) - .additionalParameters(params -> { - params.put("code_challenge", codeChallenge); - params.put("code_challenge_method", "S256"); - }) - .attributes(attrs -> { - attrs.put("code_verifier", codeVerifier); - }) - .build(); - } - - private String generateCodeVerifier() { - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(UUID.randomUUID().toString().getBytes()); - } - - private String generateCodeChallenge(String codeVerifier) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(codeVerifier.getBytes()); - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(hash); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("Failed to generate code challenge", e); - } - } - - @Bean - public AuthenticationSuccessHandler authenticationSuccessHandler() { - SimpleUrlAuthenticationSuccessHandler handler = new SimpleUrlAuthenticationSuccessHandler(); - handler.setDefaultTargetUrl(frontendUrl + "/"); - handler.setAlwaysUseDefaultTargetUrl(true); - return handler; - } - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of(frontendUrl)); - configuration.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(true); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } - - private static class CsrfCookieFilter extends OncePerRequestFilter { - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); - if (csrfToken != null) { - csrfToken.getToken(); - } - filterChain.doFilter(request, response); - } - } -} diff --git a/backend/src/main/java/edu/kit/quak/security/model/UserInfo.java b/backend/src/main/java/edu/kit/quak/security/model/UserInfo.java deleted file mode 100644 index adbfe2d4..00000000 --- a/backend/src/main/java/edu/kit/quak/security/model/UserInfo.java +++ /dev/null @@ -1,50 +0,0 @@ -package edu.kit.quak.security.model; - -public class UserInfo { - private String email; - private String name; - private String picture; - private String sub; - - public UserInfo() { - } - - public UserInfo(String email, String name, String picture, String sub) { - this.email = email; - this.name = name; - this.picture = picture; - this.sub = sub; - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getPicture() { - return picture; - } - - public void setPicture(String picture) { - this.picture = picture; - } - - public String getSub() { - return sub; - } - - public void setSub(String sub) { - this.sub = sub; - } -} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index ca8d4719..007c62a0 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,6 +1,13 @@ +# Import .env file if it exists (Spring Boot native support) +spring.config.import=optional:file:.env[.properties] + spring.application.name=QuaK -spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.url=jdbc:h2:mem:localhost +spring.datasource.driver-class-name=org.mariadb.jdbc.Driver +spring.datasource.url=${DB_URL:jdbc:mariadb://localhost:3306/quak} +spring.datasource.username=${DB_USERNAME:root} +spring.datasource.password=${DB_PASSWORD:hello} +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect # OAuth2 / OIDC Configuration spring.security.oauth2.client.registration.google.client-id=${OIDC_CLIENT_ID:your-client-id-here} @@ -21,7 +28,8 @@ spring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis server.servlet.session.cookie.http-only=true server.servlet.session.cookie.secure=${COOKIE_SECURE:false} server.servlet.session.cookie.same-site=lax -server.servlet.session.timeout=30m +server.servlet.session.timeout=1h +server.servlet.session.cookie.max-age=7d # Frontend URL (for redirects after login) app.frontend.url=${FRONTEND_URL:http://localhost:5173} diff --git a/backend/src/test/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryDelegatorTest.java b/backend/src/test/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryDelegatorTest.java index 494d12a1..c95ef67f 100644 --- a/backend/src/test/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryDelegatorTest.java +++ b/backend/src/test/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryDelegatorTest.java @@ -1,9 +1,13 @@ package edu.kit.quak.application.filesystem.delegator; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + import edu.kit.quak.application.filesystem.ports.out.FileElementContainerRepositoryPort; import edu.kit.quak.core.filesystem.model.FileElementContainer; import edu.kit.quak.core.filesystem.model.Project; import edu.kit.quak.shared.tags.UnitTest; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,20 +15,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - @UnitTest @ExtendWith(MockitoExtension.class) class FileElementContainerRepositoryDelegatorTest { - @Mock - FileElementContainerRepositoryRegistry registry; + @Mock FileElementContainerRepositoryRegistry registry; - @Mock - FileElementContainerRepositoryPort projectRepo; + @Mock FileElementContainerRepositoryPort projectRepo; FileElementContainerRepositoryDelegator delegator; @@ -85,4 +82,4 @@ void findById_returnsEmptyOnUnknownPrefix() { assertTrue(result.isEmpty()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryRegistryTest.java b/backend/src/test/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryRegistryTest.java index 78b2018e..1fb072f6 100644 --- a/backend/src/test/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryRegistryTest.java +++ b/backend/src/test/java/edu/kit/quak/application/filesystem/delegator/FileElementContainerRepositoryRegistryTest.java @@ -1,27 +1,24 @@ package edu.kit.quak.application.filesystem.delegator; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + import edu.kit.quak.application.filesystem.ports.out.FileElementContainerRepositoryPort; import edu.kit.quak.shared.tags.UnitTest; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.when; - @UnitTest @ExtendWith(MockitoExtension.class) class FileElementContainerRepositoryRegistryTest { - @Mock - FileElementContainerRepositoryPort repoP; + @Mock FileElementContainerRepositoryPort repoP; - @Mock - FileElementContainerRepositoryPort repoD; + @Mock FileElementContainerRepositoryPort repoD; @Test @DisplayName("Registry maps repositories correctly by their prefix") @@ -47,8 +44,8 @@ void registryThrowsOnDuplicatePrefix() { List> repos = List.of(repoP, repoD); - assertThrows(IllegalStateException.class, () -> - new FileElementContainerRepositoryRegistry(repos) - ); + assertThrows( + IllegalStateException.class, + () -> new FileElementContainerRepositoryRegistry(repos)); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/application/filesystem/services/DirectoryServiceTest.java b/backend/src/test/java/edu/kit/quak/application/filesystem/services/DirectoryServiceTest.java index 1a10c2e5..0cdaabbf 100644 --- a/backend/src/test/java/edu/kit/quak/application/filesystem/services/DirectoryServiceTest.java +++ b/backend/src/test/java/edu/kit/quak/application/filesystem/services/DirectoryServiceTest.java @@ -1,49 +1,62 @@ package edu.kit.quak.application.filesystem.services; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import edu.kit.quak.application.filesystem.delegator.FileElementContainerRepositoryDelegator; import edu.kit.quak.application.filesystem.ports.out.DirectoryRepositoryPort; import edu.kit.quak.core.filesystem.model.Directory; import edu.kit.quak.core.filesystem.model.Project; +import edu.kit.quak.core.user.model.User; import edu.kit.quak.shared.tags.UnitTest; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - @UnitTest @ExtendWith(MockitoExtension.class) class DirectoryServiceTest { - @Mock - private DirectoryRepositoryPort repository; - @Mock - private FileElementContainerRepositoryDelegator delegator; + @Mock private DirectoryRepositoryPort repository; + @Mock private FileElementContainerRepositoryDelegator delegator; + + @InjectMocks private DirectoryService service; - @InjectMocks - private DirectoryService service; + private User testUser; + private UUID testUserId; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUser = new User(); + testUser.setId(testUserId); + testUser.setIssuer("github"); + testUser.setSub("testuser"); + } @Test void createDirectory_savesParent() { // Arrange String parentId = "p-1"; - Project parent = new Project("RootProject"); + Project parent = new Project("RootProject", testUserId); parent.setId(parentId); Directory newDir = new Directory("NewDir", parentId); newDir.setId("d-new"); + // Mock the efficient ownership check + when(delegator.findProjectOwnerIdByElementId(parentId)).thenReturn(Optional.of(testUserId)); when(delegator.findContainerById(parentId)).thenReturn(Optional.of(parent)); when(delegator.save(parent)).thenReturn(parent); // Act - service.createDirectory(newDir, parentId); + service.createDirectory(newDir, parentId, testUser); // Assert assertTrue(parent.getContents().contains(newDir)); @@ -54,10 +67,13 @@ void createDirectory_savesParent() { @Test void createDirectory_throws_whenParentNotFound() { Directory newDir = new Directory("NewDir", "missing"); - when(delegator.findContainerById("missing")).thenReturn(Optional.empty()); - assertThrows(IllegalStateException.class, - () -> service.createDirectory(newDir, "missing")); + // Mock the ownership check to succeed, but container lookup fails + when(delegator.findProjectOwnerIdByElementId("missing")).thenReturn(Optional.empty()); + + assertThrows( + IllegalStateException.class, + () -> service.createDirectory(newDir, "missing", testUser)); } @Test @@ -66,7 +82,7 @@ void renameDirectory_renamesAndSavesParent() { String dirId = "dir-1"; String parentId = "p-1"; - Project parent = new Project("Root"); + Project parent = new Project("Root", testUserId); parent.setId(parentId); Directory dir = new Directory("OldName", parentId); @@ -75,12 +91,14 @@ void renameDirectory_renamesAndSavesParent() { parent.addChild(dir); when(repository.findById(dirId)).thenReturn(Optional.of(dir)); + // Mock the efficient ownership check + when(delegator.findProjectOwnerIdByElementId(parentId)).thenReturn(Optional.of(testUserId)); when(delegator.findContainerById(parentId)).thenReturn(Optional.of(parent)); when(delegator.save(parent)).thenAnswer(invocation -> invocation.getArgument(0)); // Act - service.renameDirectory(dirId, "NewName"); + service.renameDirectory(dirId, "NewName", testUser); // Assert assertEquals("NewName", dir.getName()); @@ -93,7 +111,7 @@ void removeDirectory_removesFromParentAndSaves() { String dirId = "dir-1"; String parentId = "p-1"; - Project parent = new Project("Root"); + Project parent = new Project("Root", testUserId); parent.setId(parentId); Directory dir = new Directory("ToDel", parentId); @@ -102,13 +120,15 @@ void removeDirectory_removesFromParentAndSaves() { parent.addChild(dir); when(repository.findById(dirId)).thenReturn(Optional.of(dir)); + // Mock the efficient ownership check + when(delegator.findProjectOwnerIdByElementId(parentId)).thenReturn(Optional.of(testUserId)); when(delegator.findContainerById(parentId)).thenReturn(Optional.of(parent)); // Act - service.removeDirectory(dirId); + service.removeDirectory(dirId, testUser); // Assert assertFalse(parent.getContents().contains(dir)); verify(delegator).save(parent); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/application/filesystem/services/FileServiceTest.java b/backend/src/test/java/edu/kit/quak/application/filesystem/services/FileServiceTest.java index 14d8900a..a859a66d 100644 --- a/backend/src/test/java/edu/kit/quak/application/filesystem/services/FileServiceTest.java +++ b/backend/src/test/java/edu/kit/quak/application/filesystem/services/FileServiceTest.java @@ -1,52 +1,64 @@ package edu.kit.quak.application.filesystem.services; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import edu.kit.quak.application.filesystem.delegator.FileElementContainerRepositoryDelegator; import edu.kit.quak.application.filesystem.ports.out.FileContentRepositoryPort; import edu.kit.quak.application.filesystem.ports.out.FileRepositoryPort; import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.core.filesystem.model.Project; +import edu.kit.quak.core.user.model.User; import edu.kit.quak.shared.tags.UnitTest; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - @UnitTest @ExtendWith(MockitoExtension.class) class FileServiceTest { - @Mock - private FileRepositoryPort repository; - @Mock - private FileContentRepositoryPort contentRepository; - @Mock - private FileElementContainerRepositoryDelegator delegator; + @Mock private FileRepositoryPort repository; + @Mock private FileContentRepositoryPort contentRepository; + @Mock private FileElementContainerRepositoryDelegator delegator; - @InjectMocks - private FileService service; + @InjectMocks private FileService service; + + private User testUser; + private UUID testUserId; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUser = new User(); + testUser.setId(testUserId); + testUser.setIssuer("github"); + testUser.setSub("testuser"); + } @Test void createFile_linksToParentAndSaves() { // Arrange String parentId = "p-1"; - Project parent = new Project("P"); + Project parent = new Project("P", testUserId); parent.setId(parentId); File file = new File("test.txt", parentId); file.setId("f-new"); + // Mock the efficient ownership check + when(delegator.findProjectOwnerIdByElementId(parentId)).thenReturn(Optional.of(testUserId)); when(delegator.findContainerById(parentId)).thenReturn(Optional.of(parent)); when(delegator.save(parent)).thenReturn(parent); // Act - File result = service.createFile(file, parentId); + File result = service.createFile(file, parentId, testUser); // Assert assertTrue(parent.getContents().contains(result)); @@ -60,7 +72,7 @@ void setFileContent_updatesMetadataAndSavesContent() { String fileId = "f-1"; String parentId = "p-1"; - Project parent = new Project("P"); + Project parent = new Project("P", testUserId); parent.setId(parentId); File file = new File("test.txt", parentId); @@ -69,11 +81,13 @@ void setFileContent_updatesMetadataAndSavesContent() { parent.addChild(file); when(repository.findById(fileId)).thenReturn(Optional.of(file)); + // Mock the efficient ownership check + when(delegator.findProjectOwnerIdByElementId(parentId)).thenReturn(Optional.of(testUserId)); when(delegator.findContainerById(parentId)).thenReturn(Optional.of(parent)); when(delegator.save(parent)).thenAnswer(invocation -> invocation.getArgument(0)); // Act - service.setFileContent(fileId, "Hello".getBytes(), "text/plain"); + service.setFileContent(fileId, "Hello".getBytes(), "text/plain", testUser); // Assert assertEquals("text/plain", file.getContentType()); @@ -87,7 +101,7 @@ void removeFile_deletesMetadataAndContent() { String fileId = "f-1"; String parentId = "p-1"; - Project parent = new Project("P"); + Project parent = new Project("P", testUserId); parent.setId(parentId); File file = new File("del.txt", parentId); @@ -96,10 +110,12 @@ void removeFile_deletesMetadataAndContent() { parent.addChild(file); when(repository.findById(fileId)).thenReturn(Optional.of(file)); + // Mock the efficient ownership check + when(delegator.findProjectOwnerIdByElementId(parentId)).thenReturn(Optional.of(testUserId)); when(delegator.findContainerById(parentId)).thenReturn(Optional.of(parent)); // Act - service.removeFile(fileId); + service.removeFile(fileId, testUser); // Assert assertFalse(parent.getContents().contains(file)); @@ -114,7 +130,7 @@ void renameFile_throws_whenCorruptState() { when(repository.findById("f-1")).thenReturn(Optional.of(orphanFile)); - assertThrows(IllegalStateException.class, - () -> service.renameFile("f-1", "NewName")); + assertThrows( + IllegalStateException.class, () -> service.renameFile("f-1", "NewName", testUser)); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/application/filesystem/services/ProjectServiceTest.java b/backend/src/test/java/edu/kit/quak/application/filesystem/services/ProjectServiceTest.java index 97b16582..5287ccd9 100644 --- a/backend/src/test/java/edu/kit/quak/application/filesystem/services/ProjectServiceTest.java +++ b/backend/src/test/java/edu/kit/quak/application/filesystem/services/ProjectServiceTest.java @@ -1,48 +1,63 @@ package edu.kit.quak.application.filesystem.services; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import edu.kit.quak.application.filesystem.ports.out.ProjectRepositoryPort; import edu.kit.quak.core.filesystem.model.Project; +import edu.kit.quak.core.user.model.User; import edu.kit.quak.shared.tags.UnitTest; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.NoSuchElementException; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - @UnitTest @ExtendWith(MockitoExtension.class) class ProjectServiceTest { - @Mock - private ProjectRepositoryPort repository; + @Mock private ProjectRepositoryPort repository; - @InjectMocks private ProjectService service; + private User testUser; + + @BeforeEach + void setUp() { + service = new ProjectService(repository); + testUser = new User(); + testUser.setId(UUID.randomUUID()); + } @Test void createProject_delegatesToRepo() { + when(repository.save(any(Project.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + Project p = new Project("P1"); - service.createProject(p); + Project result = service.createProject(p, testUser); + + assertEquals(testUser.getId(), result.getOwnerId()); verify(repository).save(p); } @Test void renameProject_updatesNameAndSaves() { // Arrange - Project p = new Project("Old"); + Project p = new Project("Old", testUser.getId()); when(repository.findById("1")).thenReturn(Optional.of(p)); + when(repository.save(any(Project.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); // Act - service.renameProject("1", "New"); + service.renameProject("1", "New", testUser); // Assert assertEquals("New", p.getName()); @@ -53,7 +68,7 @@ void renameProject_updatesNameAndSaves() { void renameProject_throws_whenNotFound() { when(repository.findById(anyString())).thenReturn(Optional.empty()); - assertThrows(NoSuchElementException.class, - () -> service.renameProject("99", "New")); + assertThrows( + NoSuchElementException.class, () -> service.renameProject("99", "New", testUser)); } } diff --git a/backend/src/test/java/edu/kit/quak/application/library/services/GateDefinitionServiceTest.java b/backend/src/test/java/edu/kit/quak/application/library/services/GateDefinitionServiceTest.java index 2041431f..2cfde2b7 100644 --- a/backend/src/test/java/edu/kit/quak/application/library/services/GateDefinitionServiceTest.java +++ b/backend/src/test/java/edu/kit/quak/application/library/services/GateDefinitionServiceTest.java @@ -1,5 +1,7 @@ package edu.kit.quak.application.library.services; +import static org.mockito.Mockito.verify; + import edu.kit.quak.application.library.ports.out.GateDefinitionRepositoryPort; import edu.kit.quak.shared.tags.UnitTest; import org.junit.jupiter.api.Test; @@ -8,20 +10,16 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import static org.mockito.Mockito.verify; - @UnitTest @ExtendWith(MockitoExtension.class) class GateDefinitionServiceTest { - @Mock - GateDefinitionRepositoryPort repo; - @InjectMocks - GateDefinitionService service; + @Mock GateDefinitionRepositoryPort repo; + @InjectMocks GateDefinitionService service; @Test void getAllGateDefinitions_delegatesToRepo() { service.getAllGateDefinitions(); verify(repo).findAllGateDefinitions(); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/application/user/services/AuthServiceTest.java b/backend/src/test/java/edu/kit/quak/application/user/services/AuthServiceTest.java new file mode 100644 index 00000000..6c8edfd5 --- /dev/null +++ b/backend/src/test/java/edu/kit/quak/application/user/services/AuthServiceTest.java @@ -0,0 +1,110 @@ +package edu.kit.quak.application.user.services; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import edu.kit.quak.application.user.dto.AuthStatusResponse; +import edu.kit.quak.application.user.dto.LogoutResponse; +import edu.kit.quak.application.user.ports.out.UserRepositoryPort; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for AuthService. Tests authentication status and logout functionality. + * + *

Note: These tests are framework-agnostic since the refactored AuthService works only with + * domain concepts (AuthenticatedUser) and not Spring Security. + */ +class AuthServiceTest { + + private AuthService authService; + private UserRepositoryPort userRepository; + + private static final String TEST_SESSION_ID = "test-session-123"; + private static final UUID TEST_USER_ID = UUID.randomUUID(); + private static final String TEST_ISSUER = "google"; + private static final String TEST_SUBJECT = "sub-123"; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepositoryPort.class); + authService = new AuthService(userRepository); + } + + @Nested + @DisplayName("getAuthenticationStatus Tests") + class GetAuthenticationStatusTests { + + @Test + @DisplayName("Should return authenticated=false when no authenticated user") + void getAuthenticationStatus_noAuth_returnsFalse() { + AuthStatusResponse result = authService.getAuthenticationStatus(Optional.empty()); + + assertFalse(result.authenticated()); + assertNull(result.user()); + } + + @Test + @DisplayName( + "Should return authenticated=true with user domain model for authenticated user") + void getAuthenticationStatus_authenticatedUser_returnsTrueWithUser() { + AuthenticatedUser authenticatedUser = + new AuthenticatedUser(TEST_USER_ID, TEST_ISSUER, TEST_SUBJECT); + + // Create a mock User object + edu.kit.quak.core.user.model.User mockUser = new edu.kit.quak.core.user.model.User(); + mockUser.setId(TEST_USER_ID); + mockUser.setIssuer(TEST_ISSUER); + mockUser.setSub(TEST_SUBJECT); + mockUser.setEmail("test@example.com"); + mockUser.setName("Test User"); + mockUser.setAvatarUrl("https://example.com/avatar.jpg"); + + // Mock the repository to return the user + org.mockito.Mockito.when(userRepository.findByIssuerAndSub(TEST_ISSUER, TEST_SUBJECT)) + .thenReturn(Optional.of(mockUser)); + + AuthStatusResponse result = + authService.getAuthenticationStatus(Optional.of(authenticatedUser)); + + assertTrue(result.authenticated()); + assertNotNull(result.user()); + + edu.kit.quak.core.user.model.User returnedUser = result.user(); + assertEquals(TEST_USER_ID, returnedUser.getId()); + assertEquals(TEST_ISSUER, returnedUser.getIssuer()); + assertEquals(TEST_SUBJECT, returnedUser.getSub()); + assertEquals("test@example.com", returnedUser.getEmail()); + assertEquals("Test User", returnedUser.getName()); + assertEquals("https://example.com/avatar.jpg", returnedUser.getAvatarUrl()); + } + } + + @Nested + @DisplayName("logout Tests") + class LogoutTests { + + @Test + @DisplayName("Should return success message on logout") + void logout_returnsSuccessMessage() { + LogoutResponse result = authService.logout(TEST_SESSION_ID); + + assertNotNull(result); + assertEquals("Logged out successfully", result.message()); + } + + @Test + @DisplayName("Should handle null session ID gracefully") + void logout_nullSessionId_returnsSuccessMessage() { + LogoutResponse result = authService.logout(null); + + assertNotNull(result); + assertEquals("Logged out successfully", result.message()); + } + } +} diff --git a/backend/src/test/java/edu/kit/quak/application/user/services/OidcUserSyncServiceTest.java b/backend/src/test/java/edu/kit/quak/application/user/services/OidcUserSyncServiceTest.java new file mode 100644 index 00000000..a5b2a55a --- /dev/null +++ b/backend/src/test/java/edu/kit/quak/application/user/services/OidcUserSyncServiceTest.java @@ -0,0 +1,210 @@ +package edu.kit.quak.application.user.services; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import edu.kit.quak.application.user.ports.in.OidcUserInfo; +import edu.kit.quak.application.user.ports.out.UserRepositoryPort; +import edu.kit.quak.core.user.model.User; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Unit tests for OidcUserSyncService. Tests user synchronization logic during OIDC login. */ +@ExtendWith(MockitoExtension.class) +class OidcUserSyncServiceTest { + + @Mock private UserRepositoryPort userRepository; + + @InjectMocks private OidcUserSyncService oidcUserSyncService; + + private OidcUserInfo createUserInfo() { + return new OidcUserInfo( + "test-sub-123", + "test@example.com", + true, + "Test User", + "Test", + "User", + "https://example.com/avatar.jpg"); + } + + @Nested + @DisplayName("syncUser - New User Creation") + class NewUserCreationTests { + + @Test + @DisplayName("Should create new user when user does not exist") + void syncUser_newUser_createsUser() { + OidcUserInfo userInfo = createUserInfo(); + // Arrange + when(userRepository.findByIssuerAndSub("google", "test-sub-123")) + .thenReturn(Optional.empty()); + + User savedUser = new User(UUID.randomUUID(), "google", "test-sub-123"); + savedUser.setEmail("test@example.com"); + when(userRepository.save(any(User.class))).thenReturn(savedUser); + + // Act + User result = oidcUserSyncService.syncUser("google", userInfo); + + // Assert + assertNotNull(result); + + // Verify save was called with correct data + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + + User capturedUser = userCaptor.getValue(); + assertEquals("google", capturedUser.getIssuer()); + assertEquals("test-sub-123", capturedUser.getSub()); + assertEquals("test@example.com", capturedUser.getEmail()); + assertEquals(true, capturedUser.getEmailVerified()); + assertEquals("Test User", capturedUser.getName()); + assertEquals("Test", capturedUser.getGivenName()); + assertEquals("User", capturedUser.getFamilyName()); + assertEquals("https://example.com/avatar.jpg", capturedUser.getAvatarUrl()); + assertNotNull(capturedUser.getLastLoginAt()); + } + + @Test + @DisplayName("Should handle user with minimal OIDC claims") + void syncUser_minimalClaims_createsUser() { + // Arrange + OidcUserInfo minimalUserInfo = + new OidcUserInfo("minimal-sub", null, null, null, null, null, null); + + when(userRepository.findByIssuerAndSub("github", "minimal-sub")) + .thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // Act + User result = oidcUserSyncService.syncUser("github", minimalUserInfo); + + // Assert + assertNotNull(result); + assertEquals("github", result.getIssuer()); + assertEquals("minimal-sub", result.getSub()); + assertNull(result.getEmail()); + } + } + + @Nested + @DisplayName("syncUser - Existing User Update") + class ExistingUserUpdateTests { + + @Test + @DisplayName("Should update existing user with new OIDC data") + void syncUser_existingUser_updatesUser() { + OidcUserInfo userInfo = createUserInfo(); + // Arrange + UUID existingUserId = UUID.randomUUID(); + User existingUser = new User(existingUserId, "google", "test-sub-123"); + existingUser.setEmail("old@example.com"); + existingUser.setName("Old Name"); + + when(userRepository.findByIssuerAndSub("google", "test-sub-123")) + .thenReturn(Optional.of(existingUser)); + when(userRepository.save(any(User.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // Act + User result = oidcUserSyncService.syncUser("google", userInfo); + + // Assert + assertNotNull(result); + assertEquals(existingUserId, result.getId()); + assertEquals("test@example.com", result.getEmail()); // Updated + assertEquals("Test User", result.getName()); // Updated + assertEquals(true, result.getEmailVerified()); + assertNotNull(result.getLastLoginAt()); + + verify(userRepository).save(existingUser); + } + + @Test + @DisplayName("Should preserve user ID when updating") + void syncUser_existingUser_preservesId() { + OidcUserInfo userInfo = createUserInfo(); + // Arrange + UUID existingUserId = UUID.randomUUID(); + User existingUser = new User(existingUserId, "google", "test-sub-123"); + + when(userRepository.findByIssuerAndSub("google", "test-sub-123")) + .thenReturn(Optional.of(existingUser)); + when(userRepository.save(any(User.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // Act + User result = oidcUserSyncService.syncUser("google", userInfo); + + // Assert + assertEquals(existingUserId, result.getId()); + assertEquals("google", result.getIssuer()); + assertEquals("test-sub-123", result.getSub()); + } + } + + @Nested + @DisplayName("syncUser - Error Handling") + class ErrorHandlingTests { + + @Test + @DisplayName("Should throw IllegalArgumentException when subject is null") + void syncUser_nullSubject_throwsException() { + // Arrange + OidcUserInfo nullSubUserInfo = + new OidcUserInfo(null, null, null, null, null, null, null); + + // Act & Assert + assertThrows( + IllegalArgumentException.class, + () -> oidcUserSyncService.syncUser("google", nullSubUserInfo)); + + verify(userRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("syncUser - Different Issuers") + class DifferentIssuersTests { + + @Test + @DisplayName("Should create separate users for different issuers with same sub") + void syncUser_differentIssuers_createsSeparateUsers() { + OidcUserInfo userInfo = createUserInfo(); + // Arrange + when(userRepository.findByIssuerAndSub("google", "test-sub-123")) + .thenReturn(Optional.empty()); + when(userRepository.findByIssuerAndSub("github", "test-sub-123")) + .thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))) + .thenAnswer( + invocation -> { + User u = invocation.getArgument(0); + u.setId(UUID.randomUUID()); + return u; + }); + + // Act + User googleUser = oidcUserSyncService.syncUser("google", userInfo); + User githubUser = oidcUserSyncService.syncUser("github", userInfo); + + // Assert + assertNotEquals(googleUser.getId(), githubUser.getId()); + assertEquals("google", googleUser.getIssuer()); + assertEquals("github", githubUser.getIssuer()); + + verify(userRepository, times(2)).save(any(User.class)); + } + } +} diff --git a/backend/src/test/java/edu/kit/quak/application/user/services/UserServiceTest.java b/backend/src/test/java/edu/kit/quak/application/user/services/UserServiceTest.java new file mode 100644 index 00000000..15cbb035 --- /dev/null +++ b/backend/src/test/java/edu/kit/quak/application/user/services/UserServiceTest.java @@ -0,0 +1,142 @@ +package edu.kit.quak.application.user.services; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import edu.kit.quak.application.user.exceptions.UserNotFoundException; +import edu.kit.quak.application.user.ports.out.UserRepositoryPort; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import edu.kit.quak.core.user.model.User; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Unit tests for UserService. Tests user retrieval logic. */ +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock private UserRepositoryPort userRepository; + + @InjectMocks private UserService userService; + + private User testUser; + private UUID testUserId; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUser = new User(testUserId, "google", "test-sub-123"); + testUser.setEmail("test@example.com"); + testUser.setName("Test User"); + } + + @Nested + @DisplayName("findById Tests") + class FindByIdTests { + + @Test + @DisplayName("Should return user when user exists") + void findById_existingUser_returnsUser() { + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + + Optional result = userService.findById(testUserId); + + assertTrue(result.isPresent()); + assertEquals(testUserId, result.get().getId()); + assertEquals("google", result.get().getIssuer()); + assertEquals("test-sub-123", result.get().getSub()); + verify(userRepository).findById(testUserId); + } + + @Test + @DisplayName("Should return empty when user does not exist") + void findById_nonExistingUser_returnsEmpty() { + UUID nonExistentId = UUID.randomUUID(); + when(userRepository.findById(nonExistentId)).thenReturn(Optional.empty()); + + Optional result = userService.findById(nonExistentId); + + assertFalse(result.isPresent()); + verify(userRepository).findById(nonExistentId); + } + } + + @Nested + @DisplayName("findByIssuerAndSub Tests") + class FindByIssuerAndSubTests { + + @Test + @DisplayName("Should return user when issuer and sub match") + void findByIssuerAndSub_existingUser_returnsUser() { + when(userRepository.findByIssuerAndSub("google", "test-sub-123")) + .thenReturn(Optional.of(testUser)); + + Optional result = userService.findByIssuerAndSub("google", "test-sub-123"); + + assertTrue(result.isPresent()); + assertEquals("google", result.get().getIssuer()); + assertEquals("test-sub-123", result.get().getSub()); + verify(userRepository).findByIssuerAndSub("google", "test-sub-123"); + } + + @Test + @DisplayName("Should return empty when issuer and sub do not match") + void findByIssuerAndSub_nonExistingUser_returnsEmpty() { + when(userRepository.findByIssuerAndSub("github", "unknown-sub")) + .thenReturn(Optional.empty()); + + Optional result = userService.findByIssuerAndSub("github", "unknown-sub"); + + assertFalse(result.isPresent()); + verify(userRepository).findByIssuerAndSub("github", "unknown-sub"); + } + } + + @Nested + @DisplayName("getAuthenticatedUser Tests") + class GetAuthenticatedUserTests { + + @Test + @DisplayName("Should return user when authenticated user exists in database") + void getAuthenticatedUser_existingUser_returnsUser() { + // Arrange + AuthenticatedUser authenticatedUser = + new AuthenticatedUser(null, "google", "test-sub-123"); + when(userRepository.findByIssuerAndSub("google", "test-sub-123")) + .thenReturn(Optional.of(testUser)); + + // Act + User result = userService.getAuthenticatedUser(authenticatedUser); + + // Assert + assertNotNull(result); + assertEquals(testUserId, result.getId()); + assertEquals("google", result.getIssuer()); + } + + @Test + @DisplayName("Should throw UserNotFoundException when user not found") + void getAuthenticatedUser_userNotFound_throwsException() { + // Arrange + AuthenticatedUser authenticatedUser = + new AuthenticatedUser(null, "google", "unknown-sub"); + when(userRepository.findByIssuerAndSub("google", "unknown-sub")) + .thenReturn(Optional.empty()); + + // Act & Assert + UserNotFoundException exception = + assertThrows( + UserNotFoundException.class, + () -> userService.getAuthenticatedUser(authenticatedUser)); + assertTrue(exception.getMessage().contains("google")); + assertTrue(exception.getMessage().contains("unknown-sub")); + } + } +} diff --git a/backend/src/test/java/edu/kit/quak/core/filesystem/model/FileElementDomainTest.java b/backend/src/test/java/edu/kit/quak/core/filesystem/model/FileElementDomainTest.java index dd8c3ed5..0f7da621 100644 --- a/backend/src/test/java/edu/kit/quak/core/filesystem/model/FileElementDomainTest.java +++ b/backend/src/test/java/edu/kit/quak/core/filesystem/model/FileElementDomainTest.java @@ -1,12 +1,11 @@ package edu.kit.quak.core.filesystem.model; -import edu.kit.quak.shared.tags.UnitTest; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import edu.kit.quak.shared.tags.UnitTest; import java.time.Instant; import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; @UnitTest class FileElementDomainTest { @@ -75,7 +74,8 @@ void fileRenameUpdatesLastAccess() throws InterruptedException { file.rename("NewName"); assertEquals("NewName", file.getName()); - assertTrue(file.getLastAccess().isAfter(beforeRename), "LastAccess should update on rename"); + assertTrue( + file.getLastAccess().isAfter(beforeRename), "LastAccess should update on rename"); } @Test @@ -126,7 +126,8 @@ void movingElementBetweenContainersWorks() { // Assert assertFalse(sourceDir.getContents().contains(file)); assertTrue(targetDir.getContents().contains(file)); - assertEquals(targetDir.getId(), file.getParentId(), "ParentID must be updated to new container"); + assertEquals( + targetDir.getId(), file.getParentId(), "ParentID must be updated to new container"); } @Test @@ -137,8 +138,9 @@ void cannotAddDuplicateNameToContainer() { File f2 = new File("Config.txt", dir.getId()); - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> dir.addChild(f2)); + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> dir.addChild(f2)); assertTrue(ex.getMessage().toLowerCase().contains("exists")); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/CircuitRestAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/CircuitRestAdapterTest.java index 24ca6a9c..eeab6160 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/CircuitRestAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/CircuitRestAdapterTest.java @@ -1,5 +1,11 @@ package edu.kit.quak.infrastructure.circuit.in.web.rest; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import edu.kit.quak.application.circuit.ports.in.CircuitServicePort; import edu.kit.quak.core.circuit.model.QuantumCircuit; import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGate; @@ -19,21 +25,18 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import static org.mockito.BDDMockito.given; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(CircuitRestAdapter.class) -@Import({CircuitDtoMapperImpl.class, RegisterDtoMapperImpl.class,QubitDtoMapperImpl.class, GateDtoMapperImpl.class}) +@Import({ + CircuitDtoMapperImpl.class, + RegisterDtoMapperImpl.class, + QubitDtoMapperImpl.class, + GateDtoMapperImpl.class +}) @WithMockUser(username = "tester", roles = "USER") class CircuitRestAdapterTest { - @Autowired - private MockMvc mockMvc; + @Autowired private MockMvc mockMvc; - @MockitoBean - private CircuitServicePort circuitServicePort; + @MockitoBean private CircuitServicePort circuitServicePort; @Test void initCircuit_ShouldReturnCreated() throws Exception { @@ -42,9 +45,7 @@ void initCircuit_ShouldReturnCreated() throws Exception { given(circuitServicePort.init()).willReturn(circuit); // Act & Assert - mockMvc.perform(post("/circuit") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform(post("/api/circuit").with(csrf()).contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value(circuit.getId())) .andExpect(jsonPath("$.registers").exists()) @@ -60,7 +61,7 @@ void getCircuit_ShouldReturnCircuit() throws Exception { given(circuitServicePort.get(circuitId)).willReturn(circuit); // Act & Assert - mockMvc.perform(get("/circuit/{circuitId}", circuitId)) + mockMvc.perform(get("/api/circuit/{circuitId}", circuitId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").exists()); } @@ -75,9 +76,10 @@ void addQubit_ShouldReturnCreated() throws Exception { given(circuitServicePort.addQubit(circuitId)).willReturn(circuit); // Act & Assert - mockMvc.perform(post("/circuit/{circuitId}/qubit", circuitId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform( + post("/api/circuit/{circuitId}/qubit", circuitId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.registers").exists()) .andExpect(jsonPath("$.registers").isArray()) @@ -97,9 +99,10 @@ void deleteQubit_ShouldReturnUpdatedCircuit() throws Exception { given(circuitServicePort.deleteQubit(circuitId, qubitId)).willReturn(updatedCircuit); // Act & Assert - mockMvc.perform(delete("/circuit/{circuitId}/qubit/{qubitId}", circuitId, qubitId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform( + delete("/api/circuit/{circuitId}/qubit/{qubitId}", circuitId, qubitId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(updatedCircuit.getId())); } @@ -111,9 +114,15 @@ void addGate_ShouldReturnCreated() throws Exception { QuantumCircuit circuit = new QuantumCircuit(); QuantumRegister register = circuit.addQuantumRegister(); Qubit qubit = register.addQubit(); - qubit.addOperation(qubit.getOperations().size(), new ElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier.CX)); - given(circuitServicePort.addGate(circuitId, ElementaryQuantumGateDefinitionIdentifier.CX, 0, 0)).willReturn(circuit); - String payload = """ + qubit.addOperation( + qubit.getOperations().size(), + new ElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier.CX)); + given( + circuitServicePort.addGate( + circuitId, ElementaryQuantumGateDefinitionIdentifier.CX, 0, 0)) + .willReturn(circuit); + String payload = + """ { "definitionId": "cx", "toQubitIdx": 0, @@ -122,10 +131,11 @@ void addGate_ShouldReturnCreated() throws Exception { """; // Act & Assert - mockMvc.perform(post("/circuit/{circuitId}/gate", circuitId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(payload)) + mockMvc.perform( + post("/api/circuit/{circuitId}/gate", circuitId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.registers").exists()) .andExpect(jsonPath("$.registers").isArray()) @@ -136,4 +146,4 @@ void addGate_ShouldReturnCreated() throws Exception { .andExpect(jsonPath("$.registers[0].qubits[0].gates[0]").exists()) .andExpect(jsonPath("$.registers[0].qubits[0].gates[0].definitionId").value("CX")); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitDtoMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitDtoMapperTest.java index 770ad060..530abd94 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitDtoMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitDtoMapperTest.java @@ -1,5 +1,8 @@ package edu.kit.quak.infrastructure.circuit.in.web.rest.mapper; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + import edu.kit.quak.core.circuit.model.QuantumCircuit; import edu.kit.quak.infrastructure.circuit.in.web.rest.dto.CircuitResponse; import org.junit.jupiter.api.Test; @@ -8,16 +11,11 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - @ExtendWith(MockitoExtension.class) class CircuitDtoMapperTest { - @Spy - private RegisterDtoMapperImpl registerDtoMapper; + @Spy private RegisterDtoMapperImpl registerDtoMapper; - @InjectMocks - private CircuitDtoMapperImpl mapper; + @InjectMocks private CircuitDtoMapperImpl mapper; @Test void toResponse() { diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitGateDefinitionDtoMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitGateDefinitionDtoMapperTest.java index 22580b03..784739ba 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitGateDefinitionDtoMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/CircuitGateDefinitionDtoMapperTest.java @@ -1,5 +1,8 @@ package edu.kit.quak.infrastructure.circuit.in.web.rest.mapper; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGate; import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGateDefinitionIdentifier; import edu.kit.quak.infrastructure.circuit.in.web.rest.dto.GateResponse; @@ -8,18 +11,15 @@ import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - @ExtendWith(MockitoExtension.class) class CircuitGateDefinitionDtoMapperTest { - @InjectMocks - private GateDtoMapperImpl mapper; + @InjectMocks private GateDtoMapperImpl mapper; @Test void toResponse() { // Arrange - ElementaryQuantumGate gate = new ElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier.X); + ElementaryQuantumGate gate = + new ElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier.X); // Act GateResponse response = mapper.toResponse(gate); @@ -29,4 +29,4 @@ void toResponse() { assertEquals(gate.getId(), response.id()); assertEquals(ElementaryQuantumGateDefinitionIdentifier.X, response.definitionId()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/QubitDtoMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/QubitDtoMapperTest.java index 5a6b36f2..a941ef3f 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/QubitDtoMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/QubitDtoMapperTest.java @@ -1,5 +1,8 @@ package edu.kit.quak.infrastructure.circuit.in.web.rest.mapper; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGate; import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGateDefinitionIdentifier; import edu.kit.quak.core.circuit.model.register.Qubit; @@ -10,22 +13,19 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - @ExtendWith(MockitoExtension.class) class QubitDtoMapperTest { - @Spy - private GateDtoMapperImpl gateDtoMapper; + @Spy private GateDtoMapperImpl gateDtoMapper; - @InjectMocks - private QubitDtoMapperImpl mapper; + @InjectMocks private QubitDtoMapperImpl mapper; @Test void toResponse() { // Arrange Qubit qubit = new Qubit(); - qubit.addOperation(qubit.getOperations().size(), new ElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier.H)); + qubit.addOperation( + qubit.getOperations().size(), + new ElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier.H)); // Act QubitResponse response = mapper.toResponse(qubit); @@ -33,6 +33,8 @@ void toResponse() { // Assert assertNotNull(response); assertEquals(1, response.gates().size()); - assertEquals(ElementaryQuantumGateDefinitionIdentifier.H, response.gates().getFirst().definitionId()); + assertEquals( + ElementaryQuantumGateDefinitionIdentifier.H, + response.gates().getFirst().definitionId()); } } diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/RegisterDtoMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/RegisterDtoMapperTest.java index 443ac86c..bb3da2b2 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/RegisterDtoMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/in/web/rest/mapper/RegisterDtoMapperTest.java @@ -1,5 +1,8 @@ package edu.kit.quak.infrastructure.circuit.in.web.rest.mapper; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + import edu.kit.quak.core.circuit.model.register.QuantumRegister; import edu.kit.quak.infrastructure.circuit.in.web.rest.dto.RegisterResponse; import org.junit.jupiter.api.Test; @@ -8,16 +11,11 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - @ExtendWith(MockitoExtension.class) class RegisterDtoMapperTest { - @Spy - private QubitDtoMapperImpl qubitDtoMapper; + @Spy private QubitDtoMapperImpl qubitDtoMapper; - @InjectMocks - private RegisterDtoMapperImpl mapper; + @InjectMocks private RegisterDtoMapperImpl mapper; @Test void toResponse() { diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/CircuitJpaAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/CircuitJpaAdapterTest.java index fccb213b..cf905737 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/CircuitJpaAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/CircuitJpaAdapterTest.java @@ -1,5 +1,7 @@ package edu.kit.quak.infrastructure.circuit.out.db.jpa; +import static org.assertj.core.api.Assertions.assertThat; + import edu.kit.quak.core.circuit.model.QuantumCircuit; import edu.kit.quak.core.circuit.model.register.QuantumRegister; import edu.kit.quak.core.circuit.model.register.Qubit; @@ -9,27 +11,24 @@ import edu.kit.quak.infrastructure.circuit.out.db.jpa.mapper.QubitJpaMapperImpl; import edu.kit.quak.infrastructure.circuit.out.db.jpa.mapper.RegisterJpaMapperImpl; import edu.kit.quak.infrastructure.circuit.out.db.jpa.repository.SpringDataJpaCircuitRepository; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - @DataJpaTest -@Import({CircuitJpaAdapter.class, - CircuitJpaMapperImpl.class, - RegisterJpaMapperImpl.class, - QubitJpaMapperImpl.class, - OperationJpaMapperImpl.class}) +@Import({ + CircuitJpaAdapter.class, + CircuitJpaMapperImpl.class, + RegisterJpaMapperImpl.class, + QubitJpaMapperImpl.class, + OperationJpaMapperImpl.class +}) class CircuitJpaAdapterTest { - @Autowired - private CircuitJpaAdapter jpaAdapter; + @Autowired private CircuitJpaAdapter jpaAdapter; - @Autowired - private SpringDataJpaCircuitRepository springRepository; + @Autowired private SpringDataJpaCircuitRepository springRepository; @Test void saveAndFindCircuit_ShouldPersistData() { @@ -73,4 +72,4 @@ void findCircuitById_ShouldReturnEmpty_WhenNotFound() { // Assert assertThat(found).isEmpty(); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/CircuitJpaMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/CircuitJpaMapperTest.java index b1158d3f..4ad02775 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/CircuitJpaMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/CircuitJpaMapperTest.java @@ -1,27 +1,24 @@ package edu.kit.quak.infrastructure.circuit.out.db.jpa.mapper; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + import edu.kit.quak.core.circuit.model.QuantumCircuit; import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.JpaQuantumCircuit; import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.register.JpaQuantumRegister; import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.register.JpaRegister; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - @ExtendWith(MockitoExtension.class) class CircuitJpaMapperTest { - @Spy - private RegisterJpaMapperImpl registerJpaMapper; + @Spy private RegisterJpaMapperImpl registerJpaMapper; - @InjectMocks - private CircuitJpaMapperImpl mapper; + @InjectMocks private CircuitJpaMapperImpl mapper; @Test void domainToEntity() { @@ -36,9 +33,9 @@ void domainToEntity() { // Assert assertNotNull(entity); assertEquals(2, entity.getRegisters().size()); - for (int idx = 0; idx < entity.getRegisters().size(); idx++) { + for (int idx = 0; idx < entity.getRegisters().size(); idx++) { assertEquals(String.format("q%d", idx), entity.getRegisters().get(idx).getName()); - assertEquals(entity, entity.getRegisters().get(idx).getCircuit()); //AfterMapping + assertEquals(entity, entity.getRegisters().get(idx).getCircuit()); // AfterMapping } } @@ -60,8 +57,8 @@ void entityToDomain() { // Assert assertNotNull(domain); assertEquals(2, domain.getRegisters().size()); - for (int idx = 0; idx < domain.getRegisters().size(); idx++) { + for (int idx = 0; idx < domain.getRegisters().size(); idx++) { assertEquals(String.format("q%d", idx), domain.getRegisters().get(idx).getName()); } } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/OperationJpaMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/OperationJpaMapperTest.java index c7296a4f..68e7e958 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/OperationJpaMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/OperationJpaMapperTest.java @@ -1,5 +1,7 @@ package edu.kit.quak.infrastructure.circuit.out.db.jpa.mapper; +import static org.junit.jupiter.api.Assertions.*; + import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGate; import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGateDefinitionIdentifier; import edu.kit.quak.core.circuit.model.operation.QuantumOperation; @@ -10,17 +12,15 @@ import org.mockito.InjectMocks; import org.mockito.junit.jupiter.MockitoExtension; -import static org.junit.jupiter.api.Assertions.*; - @ExtendWith(MockitoExtension.class) class OperationJpaMapperTest { - @InjectMocks - private OperationJpaMapperImpl mapper; + @InjectMocks private OperationJpaMapperImpl mapper; @Test void domainToEntity() { // Arrange - ElementaryQuantumGate domain = new ElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier.CX); + ElementaryQuantumGate domain = + new ElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier.CX); // Act JpaQuantumOperation entity = mapper.toEntity(domain); @@ -47,4 +47,4 @@ void entityToDomain() { ElementaryQuantumGate gate = (ElementaryQuantumGate) domain; assertEquals(ElementaryQuantumGateDefinitionIdentifier.H, gate.getDefinitionId()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/QubitJpaMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/QubitJpaMapperTest.java index 44950850..a3928984 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/QubitJpaMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/QubitJpaMapperTest.java @@ -1,35 +1,34 @@ package edu.kit.quak.infrastructure.circuit.out.db.jpa.mapper; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGate; import edu.kit.quak.core.circuit.model.operation.ElementaryQuantumGateDefinitionIdentifier; import edu.kit.quak.core.circuit.model.register.Qubit; import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.operation.JpaElementaryQuantumGate; import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.register.JpaQubit; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - @ExtendWith(MockitoExtension.class) class QubitJpaMapperTest { - @Spy - private OperationJpaMapperImpl operationJpaMapper; + @Spy private OperationJpaMapperImpl operationJpaMapper; - @InjectMocks - private QubitJpaMapperImpl mapper; + @InjectMocks private QubitJpaMapperImpl mapper; @Test void domainToEntity() { // Arrange Qubit domain = new Qubit(); - domain.addOperation(domain.getOperations().size(), new ElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier.CX)); + domain.addOperation( + domain.getOperations().size(), + new ElementaryQuantumGate(ElementaryQuantumGateDefinitionIdentifier.CX)); // Act JpaQubit entity = mapper.toEntity(domain); @@ -37,7 +36,7 @@ void domainToEntity() { // Assert assertNotNull(entity); assertEquals(1, entity.getOperations().size()); - assertEquals(entity, entity.getOperations().getFirst().getQubit()); //AfterMapping + assertEquals(entity, entity.getOperations().getFirst().getQubit()); // AfterMapping } @Test @@ -55,4 +54,4 @@ void entityToDomain() { assertNotNull(domain); assertEquals(1, domain.getOperations().size()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/RegisterJpaMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/RegisterJpaMapperTest.java index ed15a1f5..663e0b8e 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/RegisterJpaMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/circuit/out/db/jpa/mapper/RegisterJpaMapperTest.java @@ -1,26 +1,23 @@ package edu.kit.quak.infrastructure.circuit.out.db.jpa.mapper; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + import edu.kit.quak.core.circuit.model.register.QuantumRegister; import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.register.JpaQuantumRegister; import edu.kit.quak.infrastructure.circuit.out.db.jpa.entity.register.JpaQubit; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - @ExtendWith(MockitoExtension.class) class RegisterJpaMapperTest { - @Spy - private QubitJpaMapperImpl qubitJpaMapper; + @Spy private QubitJpaMapperImpl qubitJpaMapper; - @InjectMocks - private RegisterJpaMapperImpl mapper; + @InjectMocks private RegisterJpaMapperImpl mapper; @Test void domainToEntity() { @@ -36,7 +33,7 @@ void domainToEntity() { assertEquals("name", entity.getName()); assertNotNull(entity.getQubits()); assertEquals(1, entity.getQubits().size()); - assertEquals(entity, entity.getQubits().getFirst().getRegister()); //AfterMapping + assertEquals(entity, entity.getQubits().getFirst().getRegister()); // AfterMapping } @Test @@ -56,4 +53,4 @@ void entityToDomain() { assertNotNull(domain.getQubits()); assertEquals(1, domain.getQubits().size()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/DirectoryRestAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/DirectoryRestAdapterTest.java index 86789a22..268ff72a 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/DirectoryRestAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/DirectoryRestAdapterTest.java @@ -1,42 +1,59 @@ package edu.kit.quak.infrastructure.filesystem.in.web.rest; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import edu.kit.quak.application.filesystem.ports.in.DirectoryServicePort; +import edu.kit.quak.application.user.ports.in.UserServicePort; import edu.kit.quak.core.filesystem.model.Directory; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.DirectoryDtoMapperImpl; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.FileDtoMapperImpl; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.FileElementDtoMapperImpl; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.ProjectDtoMapperImpl; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import edu.kit.quak.core.user.model.User; +import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.DirectoryDtoMapper; +import edu.kit.quak.infrastructure.user.in.web.rest.mapper.AuthenticationMapper; import edu.kit.quak.shared.tags.IntegrationTest; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @IntegrationTest @WebMvcTest(DirectoryRestAdapter.class) -@Import({DirectoryDtoMapperImpl.class, FileElementDtoMapperImpl.class, FileDtoMapperImpl.class, ProjectDtoMapperImpl.class}) +@org.springframework.context.annotation.ComponentScan( + basePackageClasses = {DirectoryDtoMapper.class}) @WithMockUser(username = "tester", roles = "USER") // <--- Simulates logged-in user class DirectoryRestAdapterTest { - @Autowired - MockMvc mockMvc; + @Autowired MockMvc mockMvc; + + @MockitoBean DirectoryServicePort directoryService; - @MockitoBean - DirectoryServicePort directoryService; + @MockitoBean UserServicePort userService; + + @MockitoBean AuthenticationMapper authenticationMapper; + + private User testUser; + + @BeforeEach + void setUp() { + AuthenticatedUser testAuthUser = + new AuthenticatedUser(UUID.randomUUID(), "github", "tester"); + testUser = new User(testAuthUser.userId(), testAuthUser.issuer(), testAuthUser.subject()); + + when(authenticationMapper.toDomain(any())).thenReturn(testAuthUser); + when(userService.getAuthenticatedUser(any(AuthenticatedUser.class))).thenReturn(testUser); + } @Test @DisplayName("POST /directory/ creates directory and returns 201") @@ -44,18 +61,20 @@ void createDirectory() throws Exception { Directory createdDir = new Directory("NewDir", null); createdDir.setId("d-123"); - when(directoryService.createDirectory(any(Directory.class), eq("p-1"))) + when(directoryService.createDirectory(any(Directory.class), eq("p-1"), any(User.class))) .thenReturn(createdDir); - String json = """ - { "name": "NewDir" } - """; - - mockMvc.perform(post("/directory/") - .with(csrf()) - .header(ApiConstants.HEADER_PARENT_ID, "p-1") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) + String json = + """ + { "name": "NewDir" } + """; + + mockMvc.perform( + post("/api/directory/") + .with(csrf()) + .header(ApiConstants.HEADER_PARENT_ID, "p-1") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value("d-123")); } @@ -66,9 +85,9 @@ void retrieveDirectory() throws Exception { Directory dir = new Directory("MyDir", null); dir.setId("d-123"); - when(directoryService.retrieveDirectory("d-123")).thenReturn(dir); + when(directoryService.retrieveDirectory(eq("d-123"), any(User.class))).thenReturn(dir); - mockMvc.perform(get("/directory/d-123")) + mockMvc.perform(get("/api/directory/d-123")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("d-123")); } @@ -76,11 +95,9 @@ void retrieveDirectory() throws Exception { @Test @DisplayName("DELETE /directory/{id} calls service") void deleteDirectory() throws Exception { - mockMvc.perform(delete("/directory/d-123") - .with(csrf())) - .andExpect(status().isOk()); + mockMvc.perform(delete("/api/directory/d-123").with(csrf())).andExpect(status().isOk()); - verify(directoryService).removeDirectory("d-123"); + verify(directoryService).removeDirectory(eq("d-123"), any(User.class)); } @Test @@ -89,18 +106,20 @@ void renameDirectory() throws Exception { Directory updated = new Directory("Renamed", null); updated.setId("d-123"); - when(directoryService.renameDirectory("d-123", "Renamed")) + when(directoryService.renameDirectory(eq("d-123"), eq("Renamed"), any(User.class))) .thenReturn(updated); - String json = """ - { "name": "Renamed" } - """; + String json = + """ + { "name": "Renamed" } + """; - mockMvc.perform(patch("/directory/d-123") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) + mockMvc.perform( + patch("/api/directory/d-123") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("Renamed")); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/FileRestAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/FileRestAdapterTest.java index 3a58d89f..de5669fc 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/FileRestAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/FileRestAdapterTest.java @@ -1,47 +1,58 @@ package edu.kit.quak.infrastructure.filesystem.in.web.rest; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import edu.kit.quak.application.filesystem.ports.in.FileServicePort; +import edu.kit.quak.application.user.ports.in.UserServicePort; import edu.kit.quak.core.filesystem.model.File; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.DirectoryDtoMapperImpl; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.FileDtoMapperImpl; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.FileElementDtoMapperImpl; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.ProjectDtoMapperImpl; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import edu.kit.quak.core.user.model.User; +import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.FileDtoMapper; +import edu.kit.quak.infrastructure.user.in.web.rest.mapper.AuthenticationMapper; import edu.kit.quak.shared.tags.IntegrationTest; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @IntegrationTest @WebMvcTest(FileRestAdapter.class) -@Import({ - FileDtoMapperImpl.class, - FileElementDtoMapperImpl.class, - DirectoryDtoMapperImpl.class, - ProjectDtoMapperImpl.class -}) +@org.springframework.context.annotation.ComponentScan(basePackageClasses = {FileDtoMapper.class}) @WithMockUser(username = "tester", roles = "USER") // Simulates logged-in user class FileRestAdapterTest { - @Autowired - MockMvc mockMvc; + @Autowired MockMvc mockMvc; + + @MockitoBean FileServicePort fileService; + + @MockitoBean UserServicePort userService; + + @MockitoBean AuthenticationMapper authenticationMapper; + + private User testUser; - @MockitoBean - FileServicePort fileService; + @BeforeEach + void setUp() { + AuthenticatedUser testAuthUser = + new AuthenticatedUser(UUID.randomUUID(), "github", "tester"); + testUser = new User(testAuthUser.userId(), testAuthUser.issuer(), testAuthUser.subject()); + + when(authenticationMapper.toDomain(any())).thenReturn(testAuthUser); + when(userService.getAuthenticatedUser(any(AuthenticatedUser.class))).thenReturn(testUser); + } @Test @DisplayName("POST /file/ creates file successfully (check validation & CSRF)") @@ -49,21 +60,23 @@ void createFile_success() throws Exception { File createdFile = new File("test.txt", null); createdFile.setId("f-123"); - when(fileService.createFile(any(File.class), eq("d-1"))) + when(fileService.createFile(any(File.class), eq("d-1"), any(User.class))) .thenReturn(createdFile); - String jsonRequest = """ - { - "name": "test.txt", - "contentType": "text/plain" - } - """; - - mockMvc.perform(post("/file/") - .with(csrf()) - .header(ApiConstants.HEADER_PARENT_ID, "d-1") - .contentType(MediaType.APPLICATION_JSON) - .content(jsonRequest)) + String jsonRequest = + """ + { + "name": "test.txt", + "contentType": "text/plain" + } + """; + + mockMvc.perform( + post("/api/file/") + .with(csrf()) + .header(ApiConstants.HEADER_PARENT_ID, "d-1") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value("f-123")) .andExpect(jsonPath("$.name").value("test.txt")) @@ -73,19 +86,21 @@ void createFile_success() throws Exception { @Test @DisplayName("POST /file/ returns 400 on invalid content-definitionId format") void createFile_validationError() throws Exception { - // β€œinvalid-definitionId” does not match the regex in the DTO - String jsonRequest = """ - { - "name": "test.txt", - "contentType": "invalid-definitionId" - } - """; - - mockMvc.perform(post("/file/") - .with(csrf()) - .header(ApiConstants.HEADER_PARENT_ID, "d-1") - .contentType(MediaType.APPLICATION_JSON) - .content(jsonRequest)) + // "invalid-type" does not match the regex in the DTO + String jsonRequest = + """ + { + "name": "test.txt", + "contentType": "invalid-type" + } + """; + + mockMvc.perform( + post("/api/file/") + .with(csrf()) + .header(ApiConstants.HEADER_PARENT_ID, "d-1") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.title").exists()) .andExpect(jsonPath("$.status").value(400)); @@ -96,11 +111,10 @@ void createFile_validationError() throws Exception { void retrieveFile_success() throws Exception { File file = new File("image.png", null); file.setId("f-555"); - // file.setContentType("image/png"); // Falls dein Mock das braucht - when(fileService.retrieveFile("f-555")).thenReturn(file); + when(fileService.retrieveFile(eq("f-555"), any(User.class))).thenReturn(file); - mockMvc.perform(get("/file/f-555")) + mockMvc.perform(get("/api/file/f-555")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("f-555")) .andExpect(jsonPath("$.name").value("image.png")); @@ -109,11 +123,9 @@ void retrieveFile_success() throws Exception { @Test @DisplayName("DELETE /file/{id} removes file") void deleteFile_success() throws Exception { - mockMvc.perform(delete("/file/f-123") - .with(csrf())) - .andExpect(status().isOk()); + mockMvc.perform(delete("/api/file/f-123").with(csrf())).andExpect(status().isOk()); - verify(fileService).removeFile("f-123"); + verify(fileService).removeFile(eq("f-123"), any(User.class)); } @Test @@ -122,17 +134,18 @@ void renameFile_success() throws Exception { File updatedFile = new File("renamed.txt", null); updatedFile.setId("f-123"); - when(fileService.renameFile("f-123", "renamed.txt")) + when(fileService.renameFile(eq("f-123"), eq("renamed.txt"), any(User.class))) .thenReturn(updatedFile); String jsonRequest = """ - { "name": "renamed.txt" } - """; - - mockMvc.perform(patch("/file/f-123") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(jsonRequest)) + { "name": "renamed.txt" } + """; + + mockMvc.perform( + patch("/api/file/f-123") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("renamed.txt")); } @@ -141,9 +154,9 @@ void renameFile_success() throws Exception { @DisplayName("GET /file/{id}/content returns byte array (Base64 in JSON)") void getFileContent_success() throws Exception { byte[] content = "Hello World".getBytes(); - when(fileService.getFileContent("f-123")).thenReturn(content); + when(fileService.getFileContent(eq("f-123"), any(User.class))).thenReturn(content); - mockMvc.perform(get("/file/f-123/content")) + mockMvc.perform(get("/api/file/f-123/content")) .andExpect(status().isOk()) // Jackson automatically serializes byte[] as a Base64 string .andExpect(jsonPath("$.content").isNotEmpty()); @@ -153,19 +166,22 @@ void getFileContent_success() throws Exception { @DisplayName("PUT /file/{id}/content updates content") void setFileContent_success() throws Exception { // "SGVsbG8=" ist Base64 fΓΌr "Hello" - String jsonRequest = """ - { - "content": "SGVsbG8=",\s - "contentType": "text/plain" - } - \s"""; - - mockMvc.perform(put("/file/f-123/content") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(jsonRequest)) + String jsonRequest = + """ + { + "content": "SGVsbG8=", + "contentType": "text/plain" + } + """; + + mockMvc.perform( + put("/api/file/f-123/content") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) .andExpect(status().isOk()); - verify(fileService).setFileContent(eq("f-123"), any(byte[].class), eq("text/plain")); + verify(fileService) + .setFileContent(eq("f-123"), any(byte[].class), eq("text/plain"), any(User.class)); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ProjectRestAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ProjectRestAdapterTest.java index 93926156..8a3f9a00 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ProjectRestAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/ProjectRestAdapterTest.java @@ -1,48 +1,59 @@ package edu.kit.quak.infrastructure.filesystem.in.web.rest; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import edu.kit.quak.application.filesystem.ports.in.ProjectServicePort; +import edu.kit.quak.application.user.ports.in.UserServicePort; import edu.kit.quak.core.filesystem.model.Project; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.DirectoryDtoMapperImpl; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.FileDtoMapperImpl; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.FileElementDtoMapperImpl; -import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.ProjectDtoMapperImpl; +import edu.kit.quak.core.user.model.AuthenticatedUser; +import edu.kit.quak.core.user.model.User; +import edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper.ProjectDtoMapper; +import edu.kit.quak.infrastructure.user.in.web.rest.mapper.AuthenticationMapper; import edu.kit.quak.shared.tags.IntegrationTest; +import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @IntegrationTest @WebMvcTest(ProjectRestAdapter.class) -@Import({ - ProjectDtoMapperImpl.class, - FileElementDtoMapperImpl.class, - DirectoryDtoMapperImpl.class, - FileDtoMapperImpl.class -}) +@org.springframework.context.annotation.ComponentScan(basePackageClasses = {ProjectDtoMapper.class}) @WithMockUser(username = "tester", roles = "USER") // simulates logged-in user class ProjectRestAdapterTest { - @Autowired - MockMvc mockMvc; + @Autowired MockMvc mockMvc; + + @MockitoBean ProjectServicePort projectService; + + @MockitoBean UserServicePort userService; - @MockitoBean - ProjectServicePort projectService; + @MockitoBean AuthenticationMapper authMapper; + + @org.junit.jupiter.api.BeforeEach + void setUp() { + // Mock the authMapper to return a test AuthenticatedUser for any Authentication + AuthenticatedUser testAuthUser = + new AuthenticatedUser(UUID.randomUUID(), "test", "test-sub"); + User testUser = + new User(testAuthUser.userId(), testAuthUser.issuer(), testAuthUser.subject()); + + when(authMapper.toDomain(any(org.springframework.security.core.Authentication.class))) + .thenReturn(testAuthUser); + when(userService.getAuthenticatedUser(any(AuthenticatedUser.class))).thenReturn(testUser); + } @Test @DisplayName("GET /project returns list of projects") @@ -52,9 +63,9 @@ void getProjects_success() throws Exception { Project p2 = new Project("Beta"); p2.setId("p-2"); - when(projectService.listProjects()).thenReturn(List.of(p1, p2)); + when(projectService.listProjects(any(User.class))).thenReturn(List.of(p1, p2)); - mockMvc.perform(get("/project")) + mockMvc.perform(get("/api/project")) .andExpect(status().isOk()) .andExpect(jsonPath("$.length()").value(2)) .andExpect(jsonPath("$[0].name").value("Alpha")) @@ -67,16 +78,19 @@ void createProject_success() throws Exception { Project createdProject = new Project("New Project"); createdProject.setId("p-100"); - when(projectService.createProject(any(Project.class))).thenReturn(createdProject); + when(projectService.createProject(any(Project.class), any(User.class))) + .thenReturn(createdProject); - String jsonRequest = """ - { "name": "New Project" } - """; + String jsonRequest = + """ + { "name": "New Project" } + """; - mockMvc.perform(post("/project") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(jsonRequest)) + mockMvc.perform( + post("/api/project") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value("p-100")) .andExpect(jsonPath("$.name").value("New Project")); @@ -88,10 +102,9 @@ void retrieveProject_success() throws Exception { Project project = new Project("MyProject"); project.setId("p-1"); - // Service returns Object directly (no Optional), based on your Exception Handling Refactoring - when(projectService.retrieveProject("p-1")).thenReturn(project); + when(projectService.retrieveProject(eq("p-1"), any(User.class))).thenReturn(project); - mockMvc.perform(get("/project/p-1")) + mockMvc.perform(get("/api/project/p-1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("p-1")) .andExpect(jsonPath("$.name").value("MyProject")); @@ -100,11 +113,10 @@ void retrieveProject_success() throws Exception { @Test @DisplayName("DELETE /project/{id} removes project") void deleteProject_success() throws Exception { - mockMvc.perform(delete("/project/p-1") - .with(csrf())) // WICHTIG: CSRF Token + mockMvc.perform(delete("/api/project/p-1").with(csrf())) // WICHTIG: CSRF Token .andExpect(status().isOk()); - verify(projectService).removeProject("p-1"); + verify(projectService).removeProject(eq("p-1"), any(User.class)); } @Test @@ -113,18 +125,20 @@ void renameProject_success() throws Exception { Project updatedProject = new Project("Renamed Project"); updatedProject.setId("p-1"); - when(projectService.renameProject("p-1", "Renamed Project")) + when(projectService.renameProject(eq("p-1"), eq("Renamed Project"), any(User.class))) .thenReturn(updatedProject); - String jsonRequest = """ - { "name": "Renamed Project" } - """; + String jsonRequest = + """ + { "name": "Renamed Project" } + """; - mockMvc.perform(patch("/project/p-1") - .with(csrf()) // WICHTIG: CSRF Token - .contentType(MediaType.APPLICATION_JSON) - .content(jsonRequest)) + mockMvc.perform( + patch("/api/project/p-1") + .with(csrf()) // WICHTIG: CSRF Token + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("Renamed Project")); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileElementDtoMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileElementDtoMapperTest.java index bcfe8962..dcbb9784 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileElementDtoMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/in/web/rest/mapper/FileElementDtoMapperTest.java @@ -1,38 +1,46 @@ package edu.kit.quak.infrastructure.filesystem.in.web.rest.mapper; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + import edu.kit.quak.core.filesystem.model.Directory; import edu.kit.quak.core.filesystem.model.File; +import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.DirectoryDetailsResponse; +import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.FileDetailsResponse; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.FileElementDto; import edu.kit.quak.shared.tags.UnitTest; +import java.time.Instant; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; -import org.mockito.Spy; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import static org.assertj.core.api.Assertions.assertThat; - @UnitTest @ExtendWith(MockitoExtension.class) class FileElementDtoMapperTest { - @Spy - FileDtoMapperImpl fileMapper; + @Mock private FileDtoMapper fileMapper; - @Spy - DirectoryDtoMapperImpl directoryMapper; + @Mock private DirectoryDtoMapper directoryMapper; - @InjectMocks - FileElementDtoMapperImpl mapper; + @InjectMocks private FileElementDtoMapper mapper; @Test @DisplayName("Should map File entity to FileDetailsResponse") void testMapFile() { File file = new File("test.txt", null); + FileDetailsResponse expectedResponse = + new FileDetailsResponse( + file.getId(), "test.txt", "file", null, Instant.now(), Instant.now()); + + when(fileMapper.toDetailsResponse(any(File.class))).thenReturn(expectedResponse); FileElementDto result = mapper.toDto(file); + assertThat(result).isNotNull(); assertThat(result.getType()).isEqualTo("file"); assertThat(result.getName()).isEqualTo("test.txt"); } @@ -41,9 +49,15 @@ void testMapFile() { @DisplayName("Should map Directory entity to DirectoryDetailsResponse") void testMapDirectory() { Directory dir = new Directory("docs", null); + DirectoryDetailsResponse expectedResponse = + new DirectoryDetailsResponse( + dir.getId(), "docs", "directory", Instant.now(), Instant.now()); + + when(directoryMapper.toDetailsResponse(any(Directory.class))).thenReturn(expectedResponse); FileElementDto result = mapper.toDto(dir); + assertThat(result).isNotNull(); assertThat(result.getType()).isEqualTo("directory"); assertThat(result.getName()).isEqualTo("docs"); } diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/DirectoryJpaAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/DirectoryJpaAdapterTest.java index af31fbe7..6964579f 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/DirectoryJpaAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/DirectoryJpaAdapterTest.java @@ -1,42 +1,30 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa; +import static org.junit.jupiter.api.Assertions.*; + import edu.kit.quak.core.filesystem.model.Directory; import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.core.filesystem.model.FileElement; import edu.kit.quak.core.filesystem.model.Project; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.DirectoryJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.FileElementJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.FileJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.ProjectJpaMapperImpl; import edu.kit.quak.shared.tags.IntegrationTest; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; - @IntegrationTest @DataJpaTest -@Import({ - DirectoryJpaAdapter.class, - ProjectJpaAdapter.class, - DirectoryJpaMapperImpl.class, - FileJpaMapperImpl.class, - ProjectJpaMapperImpl.class, - FileElementJpaMapperImpl.class -}) +@org.springframework.context.annotation.ComponentScan( + basePackages = "edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper") +@Import({DirectoryJpaAdapter.class, ProjectJpaAdapter.class}) @Transactional class DirectoryJpaAdapterTest { - @Autowired - private DirectoryJpaAdapter adapter; + @Autowired private DirectoryJpaAdapter adapter; - @Autowired - private ProjectJpaAdapter projectAdapter; + @Autowired private ProjectJpaAdapter projectAdapter; @Test void saveAndFindDirectory_withFiles() { @@ -48,7 +36,6 @@ void saveAndFindDirectory_withFiles() { dir.addChild(file); - Directory saved = adapter.save(dir); assertNotNull(saved.getId()); @@ -95,4 +82,4 @@ void updateDirectory_removesFile_whenRemovedFromList() { Directory reloaded = adapter.findById(saved.getId()).orElseThrow(); assertTrue(reloaded.getContents().isEmpty()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileContentJpaAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileContentJpaAdapterTest.java index d9598921..0a7d00f8 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileContentJpaAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileContentJpaAdapterTest.java @@ -1,44 +1,30 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa; +import static org.junit.jupiter.api.Assertions.*; + import edu.kit.quak.core.filesystem.model.Directory; import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.core.filesystem.model.Project; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.DirectoryJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.FileElementJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.FileJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.ProjectJpaMapperImpl; import edu.kit.quak.shared.tags.IntegrationTest; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; - @IntegrationTest @DataJpaTest -@Import({ - FileContentJpaAdapter.class, - ProjectJpaAdapter.class, - DirectoryJpaAdapter.class, - ProjectJpaMapperImpl.class, - DirectoryJpaMapperImpl.class, - FileJpaMapperImpl.class, - FileElementJpaMapperImpl.class -}) +@org.springframework.context.annotation.ComponentScan( + basePackages = "edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper") +@Import({FileContentJpaAdapter.class, ProjectJpaAdapter.class, DirectoryJpaAdapter.class}) class FileContentJpaAdapterTest { - @Autowired - private FileContentJpaAdapter contentAdapter; + @Autowired private FileContentJpaAdapter contentAdapter; - @Autowired - private ProjectJpaAdapter projectAdapter; + @Autowired private ProjectJpaAdapter projectAdapter; - @Autowired - private DirectoryJpaAdapter directoryAdapter; + @Autowired private DirectoryJpaAdapter directoryAdapter; private String validFileId; @@ -70,9 +56,9 @@ void saveAndLoadContent_success() { @Test void saveContent_throws_whenMetadataMissing() { - assertThrows(IllegalArgumentException.class, () -> - contentAdapter.saveContent("invalid-id", new byte[]{1, 2, 3}) - ); + assertThrows( + IllegalArgumentException.class, + () -> contentAdapter.saveContent("invalid-id", new byte[] {1, 2, 3})); } @Test diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileJpaAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileJpaAdapterTest.java index 5f856e04..19c59514 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileJpaAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/FileJpaAdapterTest.java @@ -1,43 +1,29 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa; +import static org.junit.jupiter.api.Assertions.*; + import edu.kit.quak.core.filesystem.model.Directory; import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.core.filesystem.model.Project; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.DirectoryJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.FileElementJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.FileJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.ProjectJpaMapperImpl; import edu.kit.quak.shared.tags.IntegrationTest; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; - @IntegrationTest @DataJpaTest -@Import({ - FileJpaAdapter.class, - DirectoryJpaAdapter.class, - ProjectJpaAdapter.class, - FileJpaMapperImpl.class, - DirectoryJpaMapperImpl.class, - ProjectJpaMapperImpl.class, - FileElementJpaMapperImpl.class -}) +@org.springframework.context.annotation.ComponentScan( + basePackages = "edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper") +@Import({FileJpaAdapter.class, DirectoryJpaAdapter.class, ProjectJpaAdapter.class}) class FileJpaAdapterTest { - @Autowired - private FileJpaAdapter fileAdapter; + @Autowired private FileJpaAdapter fileAdapter; - @Autowired - private DirectoryJpaAdapter directoryAdapter; + @Autowired private DirectoryJpaAdapter directoryAdapter; - @Autowired - private ProjectJpaAdapter projectAdapter; + @Autowired private ProjectJpaAdapter projectAdapter; @Test void findById_returnsFile_whenExists() { @@ -49,11 +35,12 @@ void findById_returnsFile_whenExists() { Directory savedDir = directoryAdapter.save(dir); - String fileId = savedDir.getContents().stream() - .filter(e -> e.getName().equals("TestFile.txt")) - .findFirst() - .orElseThrow() - .getId(); + String fileId = + savedDir.getContents().stream() + .filter(e -> e.getName().equals("TestFile.txt")) + .findFirst() + .orElseThrow() + .getId(); // 2. Act Optional loaded = fileAdapter.findById(fileId); diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/ProjectJpaAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/ProjectJpaAdapterTest.java index a36f8a8b..2cc04a71 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/ProjectJpaAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/ProjectJpaAdapterTest.java @@ -1,39 +1,30 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa; +import static org.junit.jupiter.api.Assertions.*; + import edu.kit.quak.core.filesystem.model.Directory; import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.core.filesystem.model.FileElement; import edu.kit.quak.core.filesystem.model.Project; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.DirectoryJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.FileElementJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.FileJpaMapperImpl; -import edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper.ProjectJpaMapperImpl; import edu.kit.quak.shared.tags.IntegrationTest; +import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; - @IntegrationTest @DataJpaTest -@Import({ - ProjectJpaAdapter.class, - ProjectJpaMapperImpl.class, - DirectoryJpaMapperImpl.class, - FileJpaMapperImpl.class, - FileElementJpaMapperImpl.class -}) +@org.springframework.context.annotation.ComponentScan( + basePackages = "edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper") +@Import({ProjectJpaAdapter.class}) @Transactional public class ProjectJpaAdapterTest { - @Autowired - private ProjectJpaAdapter adapter; + @Autowired private ProjectJpaAdapter adapter; @Test void saveAndFindById_withContents() { @@ -69,15 +60,31 @@ void saveAndFindById_withContents() { } @Test - void getAllProjects_returnsAllPersistedProjects() { - adapter.save(new Project("P1")); - adapter.save(new Project("P2")); - - List projects = adapter.getAllProjects(); - - assertEquals(2, projects.size()); - assertTrue(projects.stream().anyMatch(p -> p.getName().equals("P1"))); - assertTrue(projects.stream().anyMatch(p -> p.getName().equals("P2"))); + void getProjectsByOwnerId_returnsOnlyOwnedProjects() { + UUID user1Id = UUID.randomUUID(); + UUID user2Id = UUID.randomUUID(); + + Project p1 = new Project("User1-Project1", user1Id); + Project p2 = new Project("User1-Project2", user1Id); + Project p3 = new Project("User2-Project1", user2Id); + + adapter.save(p1); + adapter.save(p2); + adapter.save(p3); + + // User 1 should only see their 2 projects + List user1Projects = adapter.getProjectsByOwnerId(user1Id); + assertEquals(2, user1Projects.size()); + assertTrue(user1Projects.stream().allMatch(p -> p.getOwnerId().equals(user1Id))); + + // User 2 should only see their 1 project + List user2Projects = adapter.getProjectsByOwnerId(user2Id); + assertEquals(1, user2Projects.size()); + assertEquals(user2Id, user2Projects.get(0).getOwnerId()); + + // Non-existent user should see no projects + List noProjects = adapter.getProjectsByOwnerId(UUID.randomUUID()); + assertTrue(noProjects.isEmpty()); } @Test @@ -95,7 +102,6 @@ void deleteById_removesProjectFromDatabase() { adapter.deleteById(saved.getId()); assertFalse(adapter.existsById(saved.getId())); - assertTrue(adapter.getAllProjects().isEmpty()); } @Test diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/DirectoryJpaMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/DirectoryJpaMapperTest.java index a1b5de55..3733c725 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/DirectoryJpaMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/DirectoryJpaMapperTest.java @@ -1,5 +1,9 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.*; + import edu.kit.quak.core.filesystem.model.Directory; import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.core.filesystem.model.FileElement; @@ -8,29 +12,27 @@ import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFileElement; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaProject; import edu.kit.quak.shared.tags.UnitTest; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Spy; +import org.mapstruct.factory.Mappers; import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.anySet; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import org.springframework.test.util.ReflectionTestUtils; @UnitTest @ExtendWith(MockitoExtension.class) class DirectoryJpaMapperTest { - @InjectMocks - private DirectoryJpaMapperImpl mapper; - - @Spy - private FileElementJpaMapperImpl elementMapper; + private DirectoryJpaMapper mapper; + private FileElementJpaMapper elementMapper; + @BeforeEach + void setUp() { + mapper = Mappers.getMapper(DirectoryJpaMapper.class); + elementMapper = spy(Mappers.getMapper(FileElementJpaMapper.class)); + ReflectionTestUtils.setField(mapper, "fileElementJpaMapper", elementMapper); + } @Test void domainToJpaEntity_ShouldIgnoreParent_AndMapContents() { @@ -81,4 +83,4 @@ void jpaToDomainEntity_ShouldMapParentId() { assertEquals(1, dir.getContents().size()); verify(elementMapper).toDomainSet(jpa.getContents()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileElementJpaMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileElementJpaMapperTest.java index 8f1ab64e..c3faefd7 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileElementJpaMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileElementJpaMapperTest.java @@ -1,5 +1,8 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + import edu.kit.quak.core.filesystem.model.Directory; import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.core.filesystem.model.FileElement; @@ -7,28 +10,33 @@ import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFile; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFileElement; import edu.kit.quak.shared.tags.UnitTest; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Spy; +import org.mapstruct.factory.Mappers; import org.mockito.junit.jupiter.MockitoExtension; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import org.springframework.test.util.ReflectionTestUtils; @UnitTest @ExtendWith(MockitoExtension.class) class FileElementJpaMapperTest { - @InjectMocks - private FileElementJpaMapperImpl mapper; - - @Spy - private FileJpaMapperImpl fileMapper; - @Spy - private DirectoryJpaMapperImpl directoryMapper; - @Spy - private ProjectJpaMapperImpl projectMapper; + private FileElementJpaMapper mapper; + private FileJpaMapper fileMapper; + private DirectoryJpaMapper directoryMapper; + private ProjectJpaMapper projectMapper; + + @BeforeEach + void setUp() { + mapper = Mappers.getMapper(FileElementJpaMapper.class); + fileMapper = spy(Mappers.getMapper(FileJpaMapper.class)); + directoryMapper = spy(Mappers.getMapper(DirectoryJpaMapper.class)); + projectMapper = spy(Mappers.getMapper(ProjectJpaMapper.class)); + + ReflectionTestUtils.setField(mapper, "fileMapper", fileMapper); + ReflectionTestUtils.setField(mapper, "directoryMapper", directoryMapper); + ReflectionTestUtils.setField(mapper, "projectMapper", projectMapper); + } @Test void mapFileToJpa() { @@ -63,25 +71,33 @@ void mapDirectoryToJpa() { verify(directoryMapper).toJpaEntity(dir); } - @Test void mapUnknownTypeThrows() { // Arrange - // Use a local class to satisfy the recursive generic definitionId T extends FileElement + // Use a local class to satisfy the recursive generic type T extends + // FileElement class UnknownFileElement extends FileElement { public UnknownFileElement(String name, String parentId) { super(name, parentId); } - @Override public String getTypeIdentifier() { return "x"; } - @Override public char getIdPrefix() { return 'x'; } + + @Override + public String getTypeIdentifier() { + return "x"; + } + + @Override + public char getIdPrefix() { + return 'x'; + } } UnknownFileElement unknown = new UnknownFileElement("X", null); // Act & Assert - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> mapper.toJpaEntity(unknown)); + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> mapper.toJpaEntity(unknown)); assertTrue(exception.getMessage().contains("Unknown FileElement subtype")); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileJpaMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileJpaMapperTest.java index 0ab143b4..04519c57 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileJpaMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/FileJpaMapperTest.java @@ -1,27 +1,28 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaDirectory; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFile; import edu.kit.quak.shared.tags.UnitTest; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Spy; +import org.mapstruct.factory.Mappers; import org.mockito.junit.jupiter.MockitoExtension; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - @UnitTest @ExtendWith(MockitoExtension.class) class FileJpaMapperTest { - @InjectMocks - private FileJpaMapperImpl mapper; + private FileJpaMapper mapper; - @Spy - private FileElementJpaMapperImpl fileElementJpaMapper; + @BeforeEach + void setUp() { + mapper = Mappers.getMapper(FileJpaMapper.class); + } @Test void domainToJpaEntity_ShouldIgnoreParent() { @@ -57,4 +58,4 @@ void jpaToDomainEntity_ShouldMapParentId() { assertEquals("text/plain", domain.getContentType()); assertEquals("d-parent-1", domain.getParentId()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/ProjectJpaMapperTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/ProjectJpaMapperTest.java index c3427727..ea81739a 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/ProjectJpaMapperTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/filesystem/out/db/jpa/mapper/ProjectJpaMapperTest.java @@ -1,5 +1,10 @@ package edu.kit.quak.infrastructure.filesystem.out.db.jpa.mapper; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.*; + import edu.kit.quak.core.filesystem.model.Directory; import edu.kit.quak.core.filesystem.model.File; import edu.kit.quak.core.filesystem.model.FileElement; @@ -9,29 +14,27 @@ import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaFileElement; import edu.kit.quak.infrastructure.filesystem.out.db.jpa.entity.JpaProject; import edu.kit.quak.shared.tags.UnitTest; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Spy; +import org.mapstruct.factory.Mappers; import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.ArgumentMatchers.anySet; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import org.springframework.test.util.ReflectionTestUtils; @UnitTest @ExtendWith(MockitoExtension.class) class ProjectJpaMapperTest { - @InjectMocks - private ProjectJpaMapperImpl mapper; + private ProjectJpaMapper mapper; + private FileElementJpaMapper elementMapper; - @Spy - private FileElementJpaMapperImpl elementMapper; + @BeforeEach + void setUp() { + mapper = Mappers.getMapper(ProjectJpaMapper.class); + elementMapper = spy(Mappers.getMapper(FileElementJpaMapper.class)); + ReflectionTestUtils.setField(mapper, "fileElementJpaMapper", elementMapper); + } @Test void domainToJpaEntity() { @@ -83,4 +86,4 @@ void jpaToDomainEntity() { verify(elementMapper).toDomainSet(jpa.getContents()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/library/in/web/rest/GateDefinitionDefinitionRestAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/library/in/web/rest/GateDefinitionDefinitionRestAdapterTest.java index 60ce4104..b9a408de 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/library/in/web/rest/GateDefinitionDefinitionRestAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/library/in/web/rest/GateDefinitionDefinitionRestAdapterTest.java @@ -1,10 +1,17 @@ package edu.kit.quak.infrastructure.library.in.web.rest; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import edu.kit.quak.application.library.ports.in.GateDefinitionServicePort; import edu.kit.quak.core.library.model.GateDefinition; import edu.kit.quak.infrastructure.GlobalExceptionHandler; import edu.kit.quak.infrastructure.library.in.web.rest.mapper.GateDefinitionDtoMapperImpl; import edu.kit.quak.shared.tags.IntegrationTest; +import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -13,44 +20,35 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import java.util.List; -import java.util.Optional; - -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @IntegrationTest @WebMvcTest(GateDefinitionRestAdapter.class) @Import({GateDefinitionDtoMapperImpl.class, GlobalExceptionHandler.class}) @WithMockUser(username = "tester", roles = "USER") class GateDefinitionDefinitionRestAdapterTest { - @Autowired - MockMvc mockMvc; + @Autowired MockMvc mockMvc; - @MockitoBean - GateDefinitionServicePort gateService; + @MockitoBean GateDefinitionServicePort gateService; @Test void getGate_returns200AndDto() throws Exception { // Arrange - GateDefinition gateDefinition = new GateDefinition( - "x", // id - "X", // name - "Pauli", // category - "Bit-Flip", // description - 1, // qubitCount - "X", // symbol - List.of(), // parameters - null // inspectorInfo - ); + GateDefinition gateDefinition = + new GateDefinition( + "x", // id + "X", // name + "Pauli", // category + "Bit-Flip", // description + 1, // qubitCount + "X", // symbol + List.of(), // parameters + null // inspectorInfo + ); when(gateService.getGateDefinitionById("x")).thenReturn(Optional.of(gateDefinition)); // Act & Assert - mockMvc.perform(get("/gates/x")) + mockMvc.perform(get("/api/gates/x")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("x")) .andExpect(jsonPath("$.name").value("X")) @@ -60,44 +58,40 @@ void getGate_returns200AndDto() throws Exception { @Test void getGate_returns200AndDtoWithInspectorInfo() throws Exception { // Arrange - GateDefinition.TruthTableEntry entry1 = new GateDefinition.TruthTableEntry("|0\\rangle", "|1\\rangle"); - GateDefinition.TruthTableEntry entry2 = new GateDefinition.TruthTableEntry("|1\\rangle", "|0\\rangle"); - - GateDefinition.MatrixInfo matrixInfo = new GateDefinition.MatrixInfo( - "\\begin{pmatrix} 0 & 1 \\\\ 1 & 0 \\end{pmatrix}", - 2, - 2, - List.of(List.of("0", "1"), List.of("1", "0")) - ); - - GateDefinition.InspectorInfo inspectorInfo = new GateDefinition.InspectorInfo( - "X = |0\\rangle\\langle1| + |1\\rangle\\langle0|", - List.of(entry1, entry2), - matrixInfo - ); - - GateDefinition gateDefinition = new GateDefinition( - "x", - "X", - "Pauli", - "Bit-Flip", - 1, - "X", - List.of(), - inspectorInfo - ); + GateDefinition.TruthTableEntry entry1 = + new GateDefinition.TruthTableEntry("|0\\rangle", "|1\\rangle"); + GateDefinition.TruthTableEntry entry2 = + new GateDefinition.TruthTableEntry("|1\\rangle", "|0\\rangle"); + + GateDefinition.MatrixInfo matrixInfo = + new GateDefinition.MatrixInfo( + "\\begin{pmatrix} 0 & 1 \\\\ 1 & 0 \\end{pmatrix}", + 2, + 2, + List.of(List.of("0", "1"), List.of("1", "0"))); + + GateDefinition.InspectorInfo inspectorInfo = + new GateDefinition.InspectorInfo( + "X = |0\\rangle\\langle1| + |1\\rangle\\langle0|", + List.of(entry1, entry2), + matrixInfo); + + GateDefinition gateDefinition = + new GateDefinition("x", "X", "Pauli", "Bit-Flip", 1, "X", List.of(), inspectorInfo); when(gateService.getGateDefinitionById("x")).thenReturn(Optional.of(gateDefinition)); // Act & Assert - mockMvc.perform(get("/gates/x")) + mockMvc.perform(get("/api/gates/x")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("x")) .andExpect(jsonPath("$.name").value("X")) .andExpect(jsonPath("$.symbol").value("X")) // Verify InspectorInfo structure .andExpect(jsonPath("$.inspectorInfo").exists()) - .andExpect(jsonPath("$.inspectorInfo.operatorDefinition").value("X = |0\\rangle\\langle1| + |1\\rangle\\langle0|")) + .andExpect( + jsonPath("$.inspectorInfo.operatorDefinition") + .value("X = |0\\rangle\\langle1| + |1\\rangle\\langle0|")) // Verify TruthTable .andExpect(jsonPath("$.inspectorInfo.truthTable").isArray()) .andExpect(jsonPath("$.inspectorInfo.truthTable[0].input").value("|0\\rangle")) @@ -106,7 +100,9 @@ void getGate_returns200AndDtoWithInspectorInfo() throws Exception { .andExpect(jsonPath("$.inspectorInfo.truthTable[1].output").value("|0\\rangle")) // Verify MatrixInfo .andExpect(jsonPath("$.inspectorInfo.matrix").exists()) - .andExpect(jsonPath("$.inspectorInfo.matrix.display").value("\\begin{pmatrix} 0 & 1 \\\\ 1 & 0 \\end{pmatrix}")) + .andExpect( + jsonPath("$.inspectorInfo.matrix.display") + .value("\\begin{pmatrix} 0 & 1 \\\\ 1 & 0 \\end{pmatrix}")) .andExpect(jsonPath("$.inspectorInfo.matrix.rows").value(2)) .andExpect(jsonPath("$.inspectorInfo.matrix.cols").value(2)) .andExpect(jsonPath("$.inspectorInfo.matrix.computable").isArray()) @@ -122,8 +118,8 @@ void getGate_returns404_whenNotFound() throws Exception { when(gateService.getGateDefinitionById("GibtsNicht")).thenReturn(Optional.empty()); // Act & Assert - mockMvc.perform(get("/gates/GibtsNicht")) + mockMvc.perform(get("/api/gates/GibtsNicht")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.title").value("Gate Not Found")); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/infrastructure/library/out/json/JsonGateDefinitionDefinitionRepositoryAdapterTest.java b/backend/src/test/java/edu/kit/quak/infrastructure/library/out/json/JsonGateDefinitionDefinitionRepositoryAdapterTest.java index 115f3802..dd92f2f9 100644 --- a/backend/src/test/java/edu/kit/quak/infrastructure/library/out/json/JsonGateDefinitionDefinitionRepositoryAdapterTest.java +++ b/backend/src/test/java/edu/kit/quak/infrastructure/library/out/json/JsonGateDefinitionDefinitionRepositoryAdapterTest.java @@ -1,15 +1,14 @@ package edu.kit.quak.infrastructure.library.out.json; +import static org.junit.jupiter.api.Assertions.*; + import com.fasterxml.jackson.databind.ObjectMapper; import edu.kit.quak.core.library.model.GateDefinition; import edu.kit.quak.shared.tags.UnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - import java.util.List; import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; @UnitTest class JsonGateDefinitionDefinitionRepositoryAdapterTest { @@ -42,7 +41,9 @@ void loadGates_parsesComplexStructureCorrectly() { // Deep checks for InspectorInfo assertNotNull(hGateDefinition.inspectorInfo(), "InspectorInfo should be mapped"); - assertFalse(hGateDefinition.inspectorInfo().truthTable().isEmpty(), "TruthTable should contain entries"); + assertFalse( + hGateDefinition.inspectorInfo().truthTable().isEmpty(), + "TruthTable should contain entries"); assertEquals("|0⟩", hGateDefinition.inspectorInfo().truthTable().getFirst().input()); } @@ -69,4 +70,4 @@ void findGateDefinitionById() { assertTrue(result.isPresent()); assertEquals("Hadamard", result.get().name()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/edu/kit/quak/integration/filesystem/ProjectLifecycleIntegrationTest.java b/backend/src/test/java/edu/kit/quak/integration/filesystem/ProjectLifecycleIntegrationTest.java index e21c2e36..315f37b4 100644 --- a/backend/src/test/java/edu/kit/quak/integration/filesystem/ProjectLifecycleIntegrationTest.java +++ b/backend/src/test/java/edu/kit/quak/integration/filesystem/ProjectLifecycleIntegrationTest.java @@ -1,10 +1,18 @@ package edu.kit.quak.integration.filesystem; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.fasterxml.jackson.databind.ObjectMapper; import edu.kit.quak.infrastructure.filesystem.in.web.rest.ApiConstants; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.DirectoryDetailsResponse; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.FileDetailsResponse; import edu.kit.quak.infrastructure.filesystem.in.web.rest.dto.ProjectDetailsResponse; +import edu.kit.quak.infrastructure.user.out.db.jpa.entity.JpaUser; +import edu.kit.quak.infrastructure.user.out.db.jpa.repository.SpringDataUserRepository; import edu.kit.quak.shared.tags.IntegrationTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -14,92 +22,137 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.OidcLoginRequestPostProcessor; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.transaction.annotation.Transactional; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @IntegrationTest @SpringBootTest @AutoConfigureMockMvc @Transactional -@WithMockUser(username = "integration-user", roles = "USER") class ProjectLifecycleIntegrationTest { - @Autowired - MockMvc mockMvc; + @Autowired MockMvc mockMvc; + + @Autowired ObjectMapper objectMapper; - @Autowired - ObjectMapper objectMapper; + @Autowired private SpringDataUserRepository userRepository; + + @Autowired private jakarta.persistence.EntityManager entityManager; private String projectId; private String dirId; private String fileId; + private OidcLoginRequestPostProcessor authenticatedUser() { + return oidcLogin() + .idToken( + token -> + token.claim("sub", "test-sub") + .claim("email", "test@example.com") + .claim("name", "Test User")) + .clientRegistration( + ClientRegistration.withRegistrationId("test") + .clientId("test-client-id") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("http://localhost/callback") + .authorizationUri("http://localhost/authorize") + .tokenUri("http://localhost/token") + .build()); + } + @BeforeEach void setUp() throws Exception { + // Ensure user exists + if (userRepository.findByIssuerAndSub("test", "test-sub").isEmpty()) { + JpaUser user = new JpaUser(); + user.setIssuer("test"); + user.setSub("test-sub"); + user.setEmail("test@example.com"); + user.setName("Test User"); + userRepository.save(user); + } + + // Flush user to DB so that constraints/foreign keys work if needed + entityManager.flush(); + // --- 1. Create Project --- - String projectJson = """ - { "name": "Integration Project" } - """; - - MvcResult projectResult = mockMvc.perform(post("/project") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(projectJson)) - .andExpect(status().isCreated()) - .andReturn(); - - ProjectDetailsResponse project = objectMapper.readValue( - projectResult.getResponse().getContentAsString(), - ProjectDetailsResponse.class - ); + String projectJson = + """ + { "name": "Integration Project" } + """; + + MvcResult projectResult = + mockMvc.perform( + post("/api/project") + .with(authenticatedUser()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(projectJson)) + .andExpect(status().isCreated()) + .andReturn(); + + ProjectDetailsResponse project = + objectMapper.readValue( + projectResult.getResponse().getContentAsString(), + ProjectDetailsResponse.class); this.projectId = project.id(); + // Flush to ensure project is visible to native queries + entityManager.flush(); + // --- 2. Create Directory --- - String dirJson = """ - { "name": "Docs" } - """; - - MvcResult dirResult = mockMvc.perform(post("/directory/") - .with(csrf()) - .header(ApiConstants.HEADER_PARENT_ID, this.projectId) - .contentType(MediaType.APPLICATION_JSON) - .content(dirJson)) - .andExpect(status().isCreated()) - .andReturn(); - - DirectoryDetailsResponse directory = objectMapper.readValue( - dirResult.getResponse().getContentAsString(), - DirectoryDetailsResponse.class - ); + String dirJson = + """ + { "name": "Docs" } + """; + + MvcResult dirResult = + mockMvc.perform( + post("/api/directory/") + .with(authenticatedUser()) + .with(csrf()) + .header(ApiConstants.HEADER_PARENT_ID, this.projectId) + .contentType(MediaType.APPLICATION_JSON) + .content(dirJson)) + .andExpect(status().isCreated()) + .andReturn(); + + DirectoryDetailsResponse directory = + objectMapper.readValue( + dirResult.getResponse().getContentAsString(), + DirectoryDetailsResponse.class); this.dirId = directory.getId(); + // Flush to ensure directory is visible to native queries + entityManager.flush(); + // --- 3. Create File --- - String fileJson = """ - { - "name": "specs.pdf", - "contentType": "application/pdf" - } - """; - - MvcResult fileResult = mockMvc.perform(post("/file/") - .with(csrf()) - .header(ApiConstants.HEADER_PARENT_ID, this.dirId) - .contentType(MediaType.APPLICATION_JSON) - .content(fileJson)) - .andExpect(status().isCreated()) - .andReturn(); - - FileDetailsResponse file = objectMapper.readValue( - fileResult.getResponse().getContentAsString(), - FileDetailsResponse.class - ); + String fileJson = + """ + { + "name": "specs.pdf", + "contentType": "application/pdf" + } + """; + + MvcResult fileResult = + mockMvc.perform( + post("/api/file/") + .with(authenticatedUser()) + .with(csrf()) + .header(ApiConstants.HEADER_PARENT_ID, this.dirId) + .contentType(MediaType.APPLICATION_JSON) + .content(fileJson)) + .andExpect(status().isCreated()) + .andReturn(); + + FileDetailsResponse file = + objectMapper.readValue( + fileResult.getResponse().getContentAsString(), FileDetailsResponse.class); this.fileId = file.getId(); } @@ -108,18 +161,17 @@ void setUp() throws Exception { void testFullLifecycle() throws Exception { // Check whether the project now has content (GET) - mockMvc.perform(get("/project/" + projectId)) + mockMvc.perform(get("/api/project/" + projectId).with(authenticatedUser())) .andExpect(status().isOk()) // Should the directory contain .andExpect(jsonPath("$.contents[0].id").value(dirId)); // Delete Project (Cascading Delete Test) - mockMvc.perform(delete("/project/" + projectId) - .with(csrf())) + mockMvc.perform(delete("/api/project/" + projectId).with(authenticatedUser()).with(csrf())) .andExpect(status().isOk()); // Verify that the file is also gone (Accessing file should return 404) - mockMvc.perform(get("/file/" + fileId)) + mockMvc.perform(get("/api/file/" + fileId).with(authenticatedUser())) .andExpect(status().isNotFound()); } @@ -128,21 +180,25 @@ void testFullLifecycle() throws Exception { void testFileContentOperations() throws Exception { // Upload Content (PUT) String contentBase64 = "SGVsbG8gV29ybGQ="; // "Hello World" in Base64 - String updateJson = """ - {\s - "content": "%s",\s - "contentType": "text/plain"\s - } - \s""".formatted(contentBase64); - - mockMvc.perform(put("/file/" + fileId + "/content") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(updateJson)) + String updateJson = + """ + {\s + "content": "%s",\s + "contentType": "text/plain"\s + } + \s""" + .formatted(contentBase64); + + mockMvc.perform( + put("/api/file/" + fileId + "/content") + .with(authenticatedUser()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(updateJson)) .andExpect(status().isOk()); // 3. Download Content (GET) - mockMvc.perform(get("/file/" + fileId + "/content")) + mockMvc.perform(get("/api/file/" + fileId + "/content").with(authenticatedUser())) .andExpect(status().isOk()) .andExpect(jsonPath("$.content").value(contentBase64)); } @@ -150,46 +206,57 @@ void testFileContentOperations() throws Exception { @Test @DisplayName("E2E: Prevent Duplicate Filenames (Domain Logic Check)") void testDuplicateFilenamePrevention() throws Exception { - String duplicateFileJson = """ - { - "name": "specs.pdf", - "contentType": "application/pdf" - } - """; - - mockMvc.perform(post("/file/") - .with(csrf()) - .header(ApiConstants.HEADER_PARENT_ID, this.dirId) - .contentType(MediaType.APPLICATION_JSON) - .content(duplicateFileJson)) + String duplicateFileJson = + """ + { + "name": "specs.pdf", + "contentType": "application/pdf" + } + """; + + mockMvc.perform( + post("/api/file/") + .with(authenticatedUser()) + .with(csrf()) + .header(ApiConstants.HEADER_PARENT_ID, this.dirId) + .contentType(MediaType.APPLICATION_JSON) + .content(duplicateFileJson)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.detail").value(org.hamcrest.Matchers.containsString("already exists"))); + .andExpect( + jsonPath("$.detail") + .value(org.hamcrest.Matchers.containsString("already exists"))); } @Test @DisplayName("E2E: Rename File and Directory") void testRenameEntities() throws Exception { // 1. Rename File "specs.pdf" -> "architecture.pdf" - String renameFileJson = """ - { "name": "architecture.pdf" } - """; - - mockMvc.perform(patch("/file/" + this.fileId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(renameFileJson)) + String renameFileJson = + """ + { "name": "architecture.pdf" } + """; + + mockMvc.perform( + patch("/api/file/" + this.fileId) + .with(authenticatedUser()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(renameFileJson)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("architecture.pdf")); // 2. Rename Directory "Docs" -> "References" - String renameDirJson = """ - { "name": "References" } - """; - - mockMvc.perform(patch("/directory/" + this.dirId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(renameDirJson)) + String renameDirJson = + """ + { "name": "References" } + """; + + mockMvc.perform( + patch("/api/directory/" + this.dirId) + .with(authenticatedUser()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(renameDirJson)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("References")); } @@ -197,45 +264,50 @@ void testRenameEntities() throws Exception { @Test @DisplayName("E2E: Nested Directories (Deep Hierarchy)") void testNestedStructure() throws Exception { - String subDirJson = """ - { "name": "SubFolder" } - """; - - MvcResult result = mockMvc.perform(post("/directory/") - .with(csrf()) - .header(ApiConstants.HEADER_PARENT_ID, this.dirId) - .contentType(MediaType.APPLICATION_JSON) - .content(subDirJson)) - .andExpect(status().isCreated()) - .andReturn(); - - DirectoryDetailsResponse subDir = objectMapper.readValue( - result.getResponse().getContentAsString(), - DirectoryDetailsResponse.class - ); - - String deepFileJson = """ - { "name": "deep.txt", "contentType": "text/plain" } - """; - - mockMvc.perform(post("/file/") - .with(csrf()) - .header(ApiConstants.HEADER_PARENT_ID, subDir.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(deepFileJson)) + String subDirJson = + """ + { "name": "SubFolder" } + """; + + MvcResult result = + mockMvc.perform( + post("/api/directory/") + .with(authenticatedUser()) + .with(csrf()) + .header(ApiConstants.HEADER_PARENT_ID, this.dirId) + .contentType(MediaType.APPLICATION_JSON) + .content(subDirJson)) + .andExpect(status().isCreated()) + .andReturn(); + + DirectoryDetailsResponse subDir = + objectMapper.readValue( + result.getResponse().getContentAsString(), DirectoryDetailsResponse.class); + + String deepFileJson = + """ + { "name": "deep.txt", "contentType": "text/plain" } + """; + + mockMvc.perform( + post("/api/file/") + .with(authenticatedUser()) + .with(csrf()) + .header(ApiConstants.HEADER_PARENT_ID, subDir.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(deepFileJson)) .andExpect(status().isCreated()); } @Test @DisplayName("E2E: Accessing Non-Existent Resource returns 404") @Disabled - // TODO: Fix Exception Handling + // TODO: Fix Exception Handling void testNotFoundHandling() throws Exception { - mockMvc.perform(get("/file/f-999999999-non-existent")) + mockMvc.perform(get("/api/file/f-999999999-non-existent").with(authenticatedUser())) .andExpect(status().isNotFound()); // Expects 404 - mockMvc.perform(delete("/directory/d-999999999") - .with(csrf())) + mockMvc.perform(delete("/api/directory/d-999999999").with(authenticatedUser()).with(csrf())) .andExpect(status().isNotFound()); // Expects 404 } } diff --git a/backend/src/test/java/edu/kit/quak/integration/library/GateDefinitionIntegrationTest.java b/backend/src/test/java/edu/kit/quak/integration/library/GateDefinitionIntegrationTest.java index c7acd380..756e3579 100644 --- a/backend/src/test/java/edu/kit/quak/integration/library/GateDefinitionIntegrationTest.java +++ b/backend/src/test/java/edu/kit/quak/integration/library/GateDefinitionIntegrationTest.java @@ -1,5 +1,9 @@ package edu.kit.quak.integration.library; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import edu.kit.quak.shared.tags.IntegrationTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -9,23 +13,17 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @IntegrationTest @SpringBootTest @AutoConfigureMockMvc @WithMockUser(username = "tester", roles = "USER") class GateDefinitionIntegrationTest { - @Autowired - MockMvc mockMvc; + @Autowired MockMvc mockMvc; @Test void getAllGates_endToEnd() throws Exception { - mockMvc.perform(get("/gates") - .contentType(MediaType.APPLICATION_JSON)) + mockMvc.perform(get("/api/gates").contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$").isArray()) .andExpect(jsonPath("$[0].name").exists()); diff --git a/backend/src/test/java/edu/kit/quak/integration/user/UserIntegrationTest.java b/backend/src/test/java/edu/kit/quak/integration/user/UserIntegrationTest.java new file mode 100644 index 00000000..08af2df5 --- /dev/null +++ b/backend/src/test/java/edu/kit/quak/integration/user/UserIntegrationTest.java @@ -0,0 +1,161 @@ +package edu.kit.quak.integration.user; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import edu.kit.quak.infrastructure.user.out.db.jpa.entity.JpaUser; +import edu.kit.quak.infrastructure.user.out.db.jpa.repository.SpringDataUserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Integration tests for User-related endpoints. Tests the full request/response cycle including + * security. + */ +@SpringBootTest +@AutoConfigureMockMvc +class UserIntegrationTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private SpringDataUserRepository userRepository; + + private JpaUser testUser; + + @BeforeEach + void setUp() { + // Create a test user if not exists + // The oidcLogin() mock uses "test" as the registration ID by default in our + // tests + testUser = + userRepository + .findByIssuerAndSub("test", "test-sub") + .orElseGet( + () -> { + JpaUser user = new JpaUser(); + user.setIssuer( + "test"); // Match the test's OIDC mock registration ID + user.setSub("test-sub"); + user.setEmail("test@example.com"); + user.setName("Test User"); + user.setEmailVerified(true); + return userRepository.save(user); + }); + } + + private org.springframework.security.test.web.servlet.request + .SecurityMockMvcRequestPostProcessors.OidcLoginRequestPostProcessor + authenticatedUser() { + return oidcLogin() + .idToken( + token -> + token.claim("sub", "test-sub") + .claim("email", "test@example.com") + .claim("name", "Test User") + .claim("picture", "https://example.com/avatar.jpg")) + .clientRegistration( + org.springframework.security.oauth2.client.registration.ClientRegistration + .withRegistrationId("test") + .clientId("test-client-id") + .authorizationGrantType( + org.springframework.security.oauth2.core + .AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("http://localhost/callback") + .authorizationUri("http://localhost/authorize") + .tokenUri("http://localhost/token") + .build()); + } + + @Nested + @DisplayName("GET /api/me Endpoint") + class GetMeEndpointTests { + + @Test + @DisplayName("Should return 401 when not authenticated") + void getMeEndpoint_unauthenticated_returns401() throws Exception { + mockMvc.perform(get("/api/me")).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Should return user data when authenticated") + void getMeEndpoint_authenticated_returnsUserData() throws Exception { + mockMvc.perform(get("/api/me").with(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json")) + .andExpect(jsonPath("$.userId").exists()) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.name").value("Test User")); + } + } + + @Nested + @DisplayName("GET /api/auth/user Endpoint") + class AuthStatusEndpointTests { + + @Test + @DisplayName("Should return authenticated=false when not logged in") + void authStatus_notLoggedIn_returnsFalse() throws Exception { + mockMvc.perform(get("/api/auth/user")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)); + } + + @Test + @DisplayName("Should return authenticated=true when logged in") + void authStatus_loggedIn_returnsTrue() throws Exception { + mockMvc.perform(get("/api/auth/user").with(authenticatedUser())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) + .andExpect(jsonPath("$.userId").value(testUser.getId().toString())); + } + } + + @Nested + @DisplayName("POST /api/auth/logout Endpoint") + class LogoutEndpointTests { + + @Test + @DisplayName("Should successfully logout authenticated user") + void logout_authenticated_success() throws Exception { + mockMvc.perform(post("/api/auth/logout").with(csrf()).with(authenticatedUser())) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("Should allow logout even when not authenticated") + void logout_notAuthenticated_success() throws Exception { + mockMvc.perform(post("/api/auth/logout").with(csrf())).andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("Security Tests") + class SecurityTests { + + @Test + @DisplayName("Protected endpoints should require authentication") + void protectedEndpoints_requireAuth() throws Exception { + // /api/me requires authentication + mockMvc.perform(get("/api/me")).andExpect(status().isUnauthorized()); + + // /api/projects requires authentication + mockMvc.perform(get("/api/projects")).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Public endpoints should be accessible without authentication") + void publicEndpoints_noAuthRequired() throws Exception { + // /api/auth/user is public (returns authenticated=false) + mockMvc.perform(get("/api/auth/user")).andExpect(status().isOk()); + } + } +} diff --git a/backend/src/test/java/edu/kit/quak/shared/tags/IntegrationTest.java b/backend/src/test/java/edu/kit/quak/shared/tags/IntegrationTest.java index 5f25f657..bbd7cc4b 100644 --- a/backend/src/test/java/edu/kit/quak/shared/tags/IntegrationTest.java +++ b/backend/src/test/java/edu/kit/quak/shared/tags/IntegrationTest.java @@ -1,20 +1,18 @@ package edu.kit.quak.shared.tags; -import org.junit.jupiter.api.Tag; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.junit.jupiter.api.Tag; /** * Marker annotation for integration tests. - *

- * Can be applied to classes or methods. Adds the JUnit 5 tag "integration", - * allowing selective execution or filtering of integration tests. - *

+ * + *

Can be applied to classes or methods. Adds the JUnit 5 tag "integration", allowing selective + * execution or filtering of integration tests. */ -@Target({ ElementType.TYPE, ElementType.METHOD }) +@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Tag("integration") public @interface IntegrationTest {} diff --git a/backend/src/test/java/edu/kit/quak/shared/tags/UnitTest.java b/backend/src/test/java/edu/kit/quak/shared/tags/UnitTest.java index dca91a88..218510d8 100644 --- a/backend/src/test/java/edu/kit/quak/shared/tags/UnitTest.java +++ b/backend/src/test/java/edu/kit/quak/shared/tags/UnitTest.java @@ -1,20 +1,18 @@ package edu.kit.quak.shared.tags; -import org.junit.jupiter.api.Tag; - import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.junit.jupiter.api.Tag; /** * Marker annotation for unit tests. - *

- * Can be applied to classes or methods. Adds the JUnit 5 tag "unit", - * allowing selective execution or filtering of unit tests. - *

+ * + *

Can be applied to classes or methods. Adds the JUnit 5 tag "unit", allowing selective + * execution or filtering of unit tests. */ -@Target({ ElementType.TYPE, ElementType.METHOD }) +@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Tag("unit") public @interface UnitTest {} diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties new file mode 100644 index 00000000..01c3347f --- /dev/null +++ b/backend/src/test/resources/application.properties @@ -0,0 +1,14 @@ +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MariaDB +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password=password +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop + +app.frontend.url=http://localhost:5173 + +# OIDC placeholders to avoid resolution errors +spring.security.oauth2.client.registration.google.client-id=test-client-id +spring.security.oauth2.client.registration.google.client-secret=test-client-secret +spring.security.oauth2.client.provider.google.issuer-uri=https://accounts.google.com + diff --git a/frontend/README.md b/frontend/README.md index eaed5582..6cdad19c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -14,5 +14,28 @@ The goal is to bridge textual and visual quantum programming, making development ## Development +### Linting For linting use this command. -`npm run lint` +```bash +npm run lint +``` + +### Testing +This project uses [Vitest](https://vitest.dev/) for testing. + +* **Run all tests:** + ```bash + npm test + ``` +* **Watch mode (automatic re-run on changes):** + ```bash + npm run test:watch + ``` +* **Interactive UI:** + ```bash + npm run test:ui + ``` +* **Coverage report:** + ```bash + npm run test:coverage + ``` diff --git a/frontend/package.json b/frontend/package.json index d835a5ca..75f6dadc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,11 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@chakra-ui/react": "^3.16.1", @@ -59,13 +63,17 @@ "@types/react-dom": "^19.0.4", "@types/react-katex": "^3.0.4", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.0.5", + "@vitest/ui": "^3.0.5", "eslint": "^9.22.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "jsdom": "^25.0.1", "tw-animate-css": "^1.2.8", "typescript": "~5.7.2", "typescript-eslint": "^8.26.1", - "vite": "^6.3.1" + "vite": "^6.3.1", + "vitest": "^3.0.5" } } diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 8e094e51..f655b8a6 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,122 +1,125 @@ -/** - * API utility for making authenticated requests to the backend - * All requests automatically include session cookies - */ - -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080'; - -interface FetchOptions extends RequestInit { - headers?: HeadersInit; -} - -/** - * Make an authenticated API request - * Automatically includes credentials (session cookie) - */ -export async function apiRequest( - endpoint: string, - options: FetchOptions = {} -): Promise { - const url = `${API_BASE_URL}${endpoint}`; - - // Get CSRF token from cookie - const csrfToken = document.cookie - .split('; ') - .find(row => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1]; - - const defaultOptions: FetchOptions = { - credentials: 'include', // Always include session cookie - ...options, // Spread options first so headers can be merged correctly below - headers: { - 'Content-Type': 'application/json', - ...(csrfToken ? { 'X-XSRF-TOKEN': csrfToken } : {}), - ...options.headers, - }, - }; - - try { - const response = await fetch(url, defaultOptions); - - // Handle authentication errors - if (response.status === 401) { - // Redirect to login if not authenticated - window.location.href = '/login'; - throw new Error('Unauthorized'); - } - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `API error: ${response.statusText}`); - } - - // Return parsed JSON - // Return parsed JSON or text based on content type - const contentType = response.headers.get("content-type"); - if (contentType && contentType.indexOf("application/json") !== -1) { - return await response.json(); - } else { - return await response.text() as unknown as T; - } - } catch (error) { - console.error('API request failed:', error); - throw error; - } -} - -/** - * Convenience methods for common HTTP verbs - */ -export const api = { - get: (endpoint: string, options?: FetchOptions) => - apiRequest(endpoint, { ...options, method: 'GET' }), - - post: (endpoint: string, data?: unknown, options?: FetchOptions) => - apiRequest(endpoint, { - ...options, - method: 'POST', - body: JSON.stringify(data), - }), - - put: (endpoint: string, data?: unknown, options?: FetchOptions) => { - const isString = typeof data === 'string'; - const headers = { ...options?.headers } as Record; - - if (isString && !headers['Content-Type']) { - headers['Content-Type'] = 'text/plain'; - } - - return apiRequest(endpoint, { - ...options, - headers, - method: 'PUT', - body: isString ? data as string : JSON.stringify(data), - }); - }, - - delete: (endpoint: string, options?: FetchOptions) => - apiRequest(endpoint, { ...options, method: 'DELETE' }), - - patch: (endpoint: string, data?: unknown, options?: FetchOptions) => - apiRequest(endpoint, { - ...options, - method: 'PATCH', - body: JSON.stringify(data), - }), -}; - -/** - * Example usage: - * - * // GET request - * const projects = await api.get('/api/projects'); - * - * // POST request - * const newProject = await api.post('/api/projects', { name: 'My Project' }); - * - * // PUT request - * const updated = await api.put('/api/projects/123', { name: 'Updated Name' }); - * - * // DELETE request - * await api.delete('/api/projects/123'); - */ \ No newline at end of file +/** + * API utility for making authenticated requests to the backend + * All requests automatically include session cookies + */ + +/** + * Example usage: + * + * // GET request + * const projects = await api.get('/api/projects'); + * + * // POST request + * const newProject = await api.post('/api/projects', { name: 'My Project' }); + * + * // PUT request + * const updated = await api.put('/api/projects/123', { name: 'Updated Name' }); + * + * // DELETE request + * await api.delete('/api/projects/123'); + */ + + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080'; + +interface FetchOptions extends RequestInit { + headers?: HeadersInit; + skipRedirect?: boolean; +} + +/** + * Make an authenticated API request + * Automatically includes credentials (session cookie) + */ +export async function apiRequest( + endpoint: string, + options: FetchOptions = {} +): Promise { + const url = `${API_BASE_URL}${endpoint}`; + + // Get CSRF token from cookie + const csrfToken = document.cookie + .split('; ') + .find(row => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1]; + + const defaultOptions: FetchOptions = { + credentials: 'include', // Always include session cookie + ...options, + headers: { + 'Content-Type': 'application/json', + ...(csrfToken ? { 'X-XSRF-TOKEN': csrfToken } : {}), + ...options.headers, + }, + }; + + try { + const response = await fetch(url, defaultOptions); + + // Handle authentication errors + if (response.status === 401) { + if (!options.skipRedirect) { + // Redirect to login if not authenticated + window.location.href = '/login'; + } + throw new Error('Unauthorized'); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `API error: ${response.statusText}`); + } + + // Return parsed JSON or text based on content type + const contentType = response.headers.get("content-type"); + if (contentType && contentType.indexOf("application/json") !== -1) { + return await response.json(); + } else { + return await response.text() as unknown as T; + } + } catch (error) { + console.error('API request failed:', error); + throw error; + } +} + +/** + * Convenience methods for common HTTP verbs + */ +export const api = { + get: (endpoint: string, options?: FetchOptions) => + apiRequest(endpoint, { ...options, method: 'GET' }), + + post: (endpoint: string, data?: unknown, options?: FetchOptions) => + apiRequest(endpoint, { + ...options, + method: 'POST', + body: JSON.stringify(data), + }), + + put: (endpoint: string, data?: unknown, options?: FetchOptions) => { + const isString = typeof data === 'string'; + const headers = { ...options?.headers } as Record; + + if (isString && !headers['Content-Type']) { + headers['Content-Type'] = 'text/plain'; + } + + return apiRequest(endpoint, { + ...options, + headers, + method: 'PUT', + body: isString ? data as string : JSON.stringify(data), + }); + }, + + delete: (endpoint: string, options?: FetchOptions) => + apiRequest(endpoint, { ...options, method: 'DELETE' }), + + patch: (endpoint: string, data?: unknown, options?: FetchOptions) => + apiRequest(endpoint, { + ...options, + method: 'PATCH', + body: JSON.stringify(data), + }), +}; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index ab33c1dc..9be568d2 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -3,11 +3,13 @@ import { Link, useLocation } from 'react-router-dom'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Home, User, Settings, LogOut } from 'lucide-react'; import { useAuth } from '@/contexts/AuthContext'; +import { useCurrentUser } from '@/hooks/useUser'; import ThemeSwitch from "@/components/ThemeSwitch"; export const Navbar: React.FC = () => { const location = useLocation(); - const { user, logout } = useAuth(); + const { logout } = useAuth(); + const { user } = useCurrentUser(); // Determine active tab based on current path const getActiveTab = () => { @@ -58,11 +60,11 @@ export const Navbar: React.FC = () => { {user && (

- +
- {user.picture && ( + {user.avatarUrl && ( {user.name} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 2b2c14fe..b3a81f9f 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,9 +1,9 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { api } from '@/api/api'; +// Minimal user identity for AuthContext interface User { - email: string; - name: string; - picture: string; + userId: string; } interface AuthContextType { @@ -37,14 +37,15 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const checkAuthStatus = async () => { try { - const response = await fetch(`${API_BASE_URL}/api/auth/status`, { + const response = await fetch(`${API_BASE_URL}/api/auth/user`, { credentials: 'include', // Important: include cookies }); if (response.ok) { const data = await response.json(); - if (data.authenticated && data.user) { - setUser(data.user); + // Expecting { authenticated: boolean, userId: string } + if (data.authenticated && data.userId) { + setUser({ userId: data.userId }); } else { setUser(null); } @@ -70,16 +71,13 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { const logout = async () => { try { - await fetch(`${API_BASE_URL}/api/auth/logout`, { - method: 'POST', - credentials: 'include', - }); + await api.post('/api/auth/logout'); setUser(null); } catch (error) { console.error('Logout failed:', error); } finally { - // Reload the page to ensure all states are cleared - window.location.reload(); + // Redirect to home page to ensure all states are cleared + window.location.href = '/'; } }; diff --git a/frontend/src/hooks/useUser.ts b/frontend/src/hooks/useUser.ts new file mode 100644 index 00000000..f6a8b4a5 --- /dev/null +++ b/frontend/src/hooks/useUser.ts @@ -0,0 +1,51 @@ +import { useState, useEffect } from 'react'; +import { useAuth } from '@/contexts/AuthContext'; +import { api } from '@/api/api'; + +export interface UserDto { + userId: string; + email: string; + name: string; + avatarUrl: string | null; + emailVerified: boolean; +} + +export function useCurrentUser() { + const { isAuthenticated, isLoading: isAuthLoading } = useAuth(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchUser = async () => { + // Wait for AuthContext to finish initialization + if (isAuthLoading) { + return; + } + + if (!isAuthenticated) { + setUser(null); + setLoading(false); + return; + } + + try { + // Ensure loading state is set while fetching + setLoading(true); + const data = await api.get('/api/me', { skipRedirect: true }); + setUser(data); + setError(null); + } catch (err) { + console.error('Failed to fetch user:', err); + setError('Failed to load user data'); + setUser(null); + } finally { + setLoading(false); + } + }; + + fetchUser(); + }, [isAuthenticated, isAuthLoading]); + + return { user, loading: loading || isAuthLoading, error }; +} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 28ff0a47..ad1ef076 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,24 +1,54 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { User } from 'lucide-react'; +import { User as UserIcon, Loader2 } from 'lucide-react'; +import { api } from '@/api/api'; + +interface UserDto { + userId: string; + email: string; + name: string; + avatarUrl: string | null; + emailVerified: boolean; +} export const Profile: React.FC = () => { - // Mock profile data - const [profileData] = useState({ - username: 'quantum_researcher', - email: 'alice.quantum@example.com', - fullName: 'Dr. Alice Quantum', - bio: 'Quantum computing researcher specializing in quantum algorithms and error correction. Passionate about making quantum computing accessible to everyone.', - institution: 'Quantum Research Institute', - role: 'Senior Researcher', - joinDate: 'January 2024', - projectsCount: 12, - collaborations: 5 - }); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchProfile = async () => { + try { + const data = await api.get('/api/me'); + setUser(data); + } catch (err) { + console.error('Failed to fetch profile:', err); + setError('Failed to load profile data'); + } finally { + setLoading(false); + } + }; + + fetchProfile(); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !user) { + return ( +
+

Error

+

{error || 'User not found'}

+
+ ); + } return (
@@ -29,97 +59,33 @@ export const Profile: React.FC = () => {
-
- +
+ {user.avatarUrl ? ( + {user.name} + ) : ( + + )}
- {profileData.fullName} + {user.name} - @{profileData.username} β€’ {profileData.email} + {user.email}
- {profileData.role} - {profileData.institution} - Joined {profileData.joinDate} + {user.emailVerified && ( + + Verified Email + + )} + User ID: {user.userId}
-

{profileData.bio}

-
- - - {/* Statistics Card */} - - - Activity Statistics - - -
-
-
{profileData.projectsCount}
-
Projects
-
-
-
{profileData.collaborations}
-
Collaborations
-
-
-
24
-
Circuits Created
-
-
-
-
- - {/* Edit Profile Card */} - - - Edit Profile - Update your profile information - - -
-
- - -
-
- - -
-
- -
- - -
- -
-
- - -
-
- - -
-
- -
- -