From 2ba7f8d5da9fe1235b753e3cd75f70fc10a139bd Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Feb 2026 14:55:25 +0100 Subject: [PATCH 01/43] Vps update --- dev-docker-compose.yml | 52 ------------------------------------------ docker-compose.yml | 50 ++++++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 77 deletions(-) delete mode 100644 dev-docker-compose.yml diff --git a/dev-docker-compose.yml b/dev-docker-compose.yml deleted file mode 100644 index 972d12c..0000000 --- a/dev-docker-compose.yml +++ /dev/null @@ -1,52 +0,0 @@ -version: "3.8" -services: - xpensely-server: - build: - context: . - dockerfile: Dockerfile - image: xpensely-server:local - labels: - net.unraid.docker.icon: https://tea.zendric.de/Cedric/XpenselyServer/raw/branch/main/src/main/resources/static/xpensely_icon_white.png - container_name: xpensely-server - ports: - - 3636:8080 - environment: - GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} - DB_PORT: 5432 - DB_P_NAME: xpensely - DB_USERNAME: ${DB_USERNAME} - DB_PASSWORD: ${DB_PASSWORD} - SPRING_PROFILES_ACTIVE: test - depends_on: - postgresdb: - condition: service_healthy - networks: - - xpensely-network - postgresdb: - labels: - net.unraid.docker.icon: https://raw.githubusercontent.com/docker-library/docs/01c12653951b2fe592c1f93a13b4e289ada0e3a1/postgres/logo.png - image: postgres:14 - container_name: postgresdb - ports: - - 5435:5432 - environment: - POSTGRES_DB: xpensely - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_PASSWORD: ${DB_PASSWORD} - networks: - - xpensely-network - volumes: - - db_data:/var/lib/postgresql/data - restart: unless-stopped - healthcheck: - test: - - CMD-SHELL - - pg_isready -U ${DB_USERNAME} -d xpensely - interval: 10s - timeout: 5s - retries: 5 -volumes: - db_data: null -networks: - xpensely-network: null diff --git a/docker-compose.yml b/docker-compose.yml index dad15a4..c33d5c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,48 +1,48 @@ version: "3.8" + services: xpensely-server: - image: tea.zendric.de/cedric/xpensely-server:0 - labels: - net.unraid.docker.icon: https://tea.zendric.de/Cedric/XpenselyServer/raw/branch/main/src/main/resources/static/xpensely_icon_white.png - container_name: xpensely-server - ports: - - 3636:8080 + build: + context: . + dockerfile: Dockerfile + image: xpensely-server + restart: always environment: GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + DB_PORT: 5432 - DB_P_NAME: xpensely - DB_USERNAME: ${DB_USERNAME} - DB_PASSWORD: ${DB_PASSWORD} + DB_P_NAME: ${POSTGRES_DB} + DB_USERNAME: ${POSTGRES_USER} + DB_PASSWORD: ${POSTGRES_PASSWORD} + + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} depends_on: postgresdb: condition: service_healthy networks: - xpensely-network + postgresdb: - labels: - net.unraid.docker.icon: https://raw.githubusercontent.com/docker-library/docs/01c12653951b2fe592c1f93a13b4e289ada0e3a1/postgres/logo.png image: postgres:14 - container_name: postgresdb - ports: - - 5435:5432 + restart: unless-stopped environment: - POSTGRES_DB: xpensely - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data networks: - xpensely-network - volumes: - - db_data:/var/lib/postgresql/data - restart: unless-stopped healthcheck: - test: - - CMD-SHELL - - pg_isready -U ${DB_USERNAME} -d xpensely + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 + volumes: - db_data: null + postgres_data: + networks: - xpensely-network: null + xpensely-network: + driver: bridge From e9851ffea46b22016ab0635cebeed38603e88fef Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Feb 2026 15:05:44 +0100 Subject: [PATCH 02/43] dockerfile update --- dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dockerfile b/dockerfile index a3702e8..7f41371 100644 --- a/dockerfile +++ b/dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:17-jdk-slim +FROM openjdk:17-jdk-slim-bullseye COPY ./target/*.jar app.jar From 3656ccc9412b637bcb351a4f3535d8ed97a0a97d Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Feb 2026 15:07:41 +0100 Subject: [PATCH 03/43] dockerfile aktualisiert --- dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dockerfile b/dockerfile index 7f41371..6fad8f9 100644 --- a/dockerfile +++ b/dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:17-jdk-slim-bullseye +FROM eclipse-temurin:17-jdk COPY ./target/*.jar app.jar From 8c8eccb35e4fc249f09ba4b6992e59f567412de6 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Feb 2026 15:13:23 +0100 Subject: [PATCH 04/43] dockerfile aktualisiert --- dockerfile | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/dockerfile b/dockerfile index 6fad8f9..48ce5db 100644 --- a/dockerfile +++ b/dockerfile @@ -1,7 +1,23 @@ -FROM eclipse-temurin:17-jdk +# Stage 1: Build the JAR +FROM eclipse-temurin:17-jdk AS build -COPY ./target/*.jar app.jar +WORKDIR /app + +# Copy Maven files and source code +COPY pom.xml . +COPY src ./src + +# Build the Spring Boot app +RUN ./mvnw clean package -DskipTests + +# Stage 2: Runtime +FROM eclipse-temurin:17-jdk-slim + +WORKDIR /app + +# Copy built JAR from build stage +COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java","-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java","-jar","app.jar"] From 8f17e8d8a83289011bddd82e2b6b5569b4605bfb Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Feb 2026 15:15:53 +0100 Subject: [PATCH 05/43] dockerfile aktualisiert --- dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dockerfile b/dockerfile index 48ce5db..7f8f1a6 100644 --- a/dockerfile +++ b/dockerfile @@ -11,7 +11,7 @@ COPY src ./src RUN ./mvnw clean package -DskipTests # Stage 2: Runtime -FROM eclipse-temurin:17-jdk-slim +FROM eclipse-temurin:17-jdk WORKDIR /app From 374d91f0c95d7a6d27b2c14d3b0507b5eeb4363e Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Feb 2026 15:18:40 +0100 Subject: [PATCH 06/43] dockerfile aktualisiert --- dockerfile | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/dockerfile b/dockerfile index 7f8f1a6..fe5cb88 100644 --- a/dockerfile +++ b/dockerfile @@ -3,12 +3,17 @@ FROM eclipse-temurin:17-jdk AS build WORKDIR /app -# Copy Maven files and source code +# Install Maven +RUN apt-get update && \ + apt-get install -y maven && \ + rm -rf /var/lib/apt/lists/* + +# Copy project files COPY pom.xml . COPY src ./src -# Build the Spring Boot app -RUN ./mvnw clean package -DskipTests +# Build the app +RUN mvn clean package -DskipTests # Stage 2: Runtime FROM eclipse-temurin:17-jdk From f8407db3acd018c2a381608c7ab7a43e0d86ee4b Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Feb 2026 15:27:51 +0100 Subject: [PATCH 07/43] docker-compose.yml aktualisiert --- docker-compose.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c33d5c7..1292106 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,6 @@ -version: "3.8" - services: xpensely-server: - build: - context: . - dockerfile: Dockerfile - image: xpensely-server + image: tea.zendric.de/cedric/xpensely-server:1.0.0 # replace with the tag you want to deploy restart: always environment: GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} From 7bac694357e8aca6f29d5412e3555d69ed4f70f3 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Feb 2026 15:29:55 +0100 Subject: [PATCH 08/43] docker-compose.yml aktualisiert --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1292106..49c9633 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: xpensely-server: - image: tea.zendric.de/cedric/xpensely-server:1.0.0 # replace with the tag you want to deploy + image: tea.zendric.de/cedric/xpensely-server:latest restart: always environment: GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} From 477a5c3c70f0144746af3e95999de0c922781adc Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Feb 2026 15:31:16 +0100 Subject: [PATCH 09/43] dockerfile aktualisiert --- dockerfile | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/dockerfile b/dockerfile index fe5cb88..6fad8f9 100644 --- a/dockerfile +++ b/dockerfile @@ -1,28 +1,7 @@ -# Stage 1: Build the JAR -FROM eclipse-temurin:17-jdk AS build - -WORKDIR /app - -# Install Maven -RUN apt-get update && \ - apt-get install -y maven && \ - rm -rf /var/lib/apt/lists/* - -# Copy project files -COPY pom.xml . -COPY src ./src - -# Build the app -RUN mvn clean package -DskipTests - -# Stage 2: Runtime FROM eclipse-temurin:17-jdk -WORKDIR /app - -# Copy built JAR from build stage -COPY --from=build /app/target/*.jar app.jar +COPY ./target/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java","-jar","app.jar"] +ENTRYPOINT ["java","-jar", "app.jar"] \ No newline at end of file From 76e878ff5cb22acb40434d1ef09c16c193d9f884 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Feb 2026 20:25:38 +0100 Subject: [PATCH 10/43] docker-compose.yml aktualisiert --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 49c9633..5f191c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: postgresdb: image: postgres:14 restart: unless-stopped + ports: + - "5432:5432" environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} From 229a6a8a4361fe6d0be5c4de279508be1741dcf4 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 4 Mar 2026 15:16:11 +0100 Subject: [PATCH 11/43] docker-compose.yml aktualisiert --- docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5f191c4..49c9633 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,8 +21,6 @@ services: postgresdb: image: postgres:14 restart: unless-stopped - ports: - - "5432:5432" environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} From e3b8917bfcd62ae088dcd18f586118d4587181c6 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 4 May 2026 21:58:20 +0200 Subject: [PATCH 12/43] docs: add security hardening design spec Covers input validation, JWT-based authorization enforcement, and per-user rate limiting via Bucket4j. Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-04-security-hardening-design.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-04-security-hardening-design.md diff --git a/docs/superpowers/specs/2026-05-04-security-hardening-design.md b/docs/superpowers/specs/2026-05-04-security-hardening-design.md new file mode 100644 index 0000000..b2f3f04 --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-security-hardening-design.md @@ -0,0 +1,166 @@ +# Security Hardening Design — XpenselyServer +**Date:** 2026-05-04 +**Scope:** Input validation, authorization enforcement, rate limiting + +--- + +## Problem Statement + +The XpenselyServer has three security gaps: + +1. **No input validation** — request bodies are accepted without any field constraints, allowing null fields, negative amounts, oversized strings, and malformed data to reach the database layer. +2. **Authorization bypass** — every endpoint trusts caller-supplied user IDs from query params or request bodies rather than the authenticated JWT. Any authenticated user can read or destroy another user's expense lists. +3. **No rate limiting** — no protection against brute-force or abuse of sensitive endpoints (invite generation, account creation, invite acceptance). + +--- + +## Section 1 — Input Validation + +### Dependency +Add `spring-boot-starter-validation` to `pom.xml`. + +### Request Model Constraints + +**`AppUserCreateRequest`** +- `username`: `@NotBlank @Size(min=3, max=30) @Pattern(regexp="^[a-zA-Z0-9_.-]+$")` +- `googleId`: `@NotBlank` + +**`ExpenseInput`** +- `title`: `@NotBlank @Size(max=100)` +- `owner`: `@NotBlank` +- `amount`: `@NotNull @DecimalMin("0.01")` +- `date`: `@NotNull` +- `category`: `@NotBlank` + +**`ExpenseChangeRequest`** — same constraints as `ExpenseInput` for the corresponding fields. + +**`InviteRequest`** +- `inviteCode`: `@NotBlank @Size(min=6, max=6)` +- `userId` field removed (derived from JWT — see Section 2) + +### Controller Changes +Add `@Valid` to every `@RequestBody` parameter in `AppUserController` and `ExpenseListController`. + +### Error Handling +Add a `@ControllerAdvice` class `GlobalExceptionHandler` that: +- Catches `MethodArgumentNotValidException` → returns `400 Bad Request` with a map of `{ field: errorMessage }` pairs +- Catches `IllegalArgumentException` → returns `400 Bad Request` with the exception message + +This replaces the current pattern of returning `500 INTERNAL_SERVER_ERROR` or `417 EXPECTATION_FAILED` for validation failures. + +### Cleanup +Remove the stray `@Id` and `@GeneratedValue` JPA annotations from `ExpenseInput` — it is a DTO, not an entity. + +--- + +## Section 2 — Authorization Model + +### Core Principle +Stop trusting caller-supplied user IDs. Derive the authenticated user from the JWT on every request. + +### New Component: `AuthenticatedUserResolver` +A `@Component` with a single method: +```java +AppUser resolveCurrentUser(Authentication auth) +``` +- Extracts the `sub` claim (Google ID) from the JWT +- Calls `UserService.getUserByGoogleId(sub)` to return the `AppUser` +- Throws `ResponseStatusException(403)` if no user is found for the JWT subject + +### Endpoint Changes + +| Endpoint | Change | +|---|---| +| `GET /api/expenselist/all` | **Removed** — no legitimate non-admin use case | +| `GET /api/expenselist/byUser?userId=X` | **Replaced** by `GET /api/expenselist/mine` — returns lists for the JWT user, no param | +| `GET /api/expenselist/byUsername?username=X` | **Removed** — redundant with `/mine` | +| `GET /api/expenselist/byId?id=X` | **Guard added** — 403 if authenticated user is neither owner nor sharedWith | +| `DELETE /api/expenselist/{id}` | **Guard added** — 403 if authenticated user is not the owner | +| `POST /api/expenselist/{id}/add` | **Guard added** — 403 if authenticated user is not owner or sharedWith | +| `PUT /api/expenselist/{id}/update` | **Guard added** — 403 if authenticated user is not owner or sharedWith | +| `DELETE /api/expenselist/{id}/delete` | **Guard added** — 403 if authenticated user is not owner or sharedWith | +| `POST /api/expenselist/{listId}/invite` | **Guard added** — 403 if authenticated user is not the owner | +| `POST /api/expenselist/accept-invite` | **`userId` removed from body** — derived from JWT instead | +| `GET /api/users?id=X` | **Guard added** — 403 if id doesn't match JWT user's id | +| `GET /api/users/byGoogleId?id=X` | **Guard added** — 403 if id doesn't match JWT sub | +| `DELETE /api/users?id=X` | **Guard added** — 403 if id doesn't match JWT user's id | + +### Ownership Check Helper +Each guard is implemented as a private method in its controller (2–3 lines): +```java +private void assertOwner(AppUser authenticated, ExpenseList list) { + if (!list.getOwner().getId().equals(authenticated.getId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); +} + +private void assertMember(AppUser authenticated, ExpenseList list) { + boolean isOwner = list.getOwner().getId().equals(authenticated.getId()); + boolean isShared = list.getSharedWith() != null && list.getSharedWith().getId().equals(authenticated.getId()); + if (!isOwner && !isShared) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); +} +``` + +### Test Profile +The `@Profile("test")` security chain in `SecurityConfig` is untouched. Existing tests continue to work without authentication. + +--- + +## Section 3 — Rate Limiting + +### Dependency +Add `bucket4j-core` to `pom.xml`. In-memory storage — no external cache needed for single-instance deployment. + +### Implementation +A `RateLimitFilter` extending `OncePerRequestFilter`, registered as a `@Component` with `@Profile("!test")`: + +- **Key for authenticated requests:** JWT `sub` claim (per-user bucket) +- **Key for unauthenticated requests:** remote IP address (pre-auth fallback) +- Buckets stored in a `ConcurrentHashMap` + +### Limits + +| Endpoint pattern | Limit | +|---|---| +| All endpoints (default) | 60 requests / minute | +| `POST /api/expenselist/*/invite` | 5 requests / minute | +| `POST /api/expenselist/accept-invite` | 10 requests / minute | +| `POST /api/users/createUser` | 3 requests / minute | + +Sensitive endpoints get their own per-user bucket independent of the general bucket. + +### Response +When a bucket is exhausted: `429 Too Many Requests` with a `Retry-After: ` header indicating time until refill. + +--- + +## Architecture Summary + +``` +Request + └── RateLimitFilter (per-user/IP buckets) + └── SecurityFilterChain (JWT validation) + └── Controller + ├── @Valid on @RequestBody → GlobalExceptionHandler on failure + ├── AuthenticatedUserResolver → AppUser from JWT sub + └── assertOwner / assertMember → 403 on violation +``` + +No new service layer is introduced. The authorization checks are lightweight and local to each controller method. + +--- + +## Files Affected + +| File | Change | +|---|---| +| `pom.xml` | Add `spring-boot-starter-validation`, `bucket4j-core` | +| `model/AppUserCreateRequest.java` | Add validation annotations | +| `model/ExpenseInput.java` | Add validation annotations, remove JPA annotations | +| `model/ExpenseChangeRequest.java` | Add validation annotations | +| `model/InviteRequest.java` | Add validation annotations, remove `userId` field | +| `controller/AppUserController.java` | Add `@Valid`, ownership guards, use `AuthenticatedUserResolver` | +| `controller/ExpenseListController.java` | Add `@Valid`, ownership guards, remove/rename endpoints, use `AuthenticatedUserResolver` | +| `security/AuthenticatedUserResolver.java` | **New** — resolves JWT sub to `AppUser` | +| `security/RateLimitFilter.java` | **New** — per-user/IP rate limiting | +| `controller/GlobalExceptionHandler.java` | **New** — structured 400/403 error responses | From efe84942ff90dbd7a96e6f37f9a9aaa01907c70e Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 4 May 2026 22:21:56 +0200 Subject: [PATCH 13/43] docs: add security hardening implementation plan 8-task TDD plan covering input validation, JWT-based authorization enforcement, and Bucket4j rate limiting. Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-04-security-hardening.md | 1462 +++++++++++++++++ 1 file changed, 1462 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-04-security-hardening.md diff --git a/docs/superpowers/plans/2026-05-04-security-hardening.md b/docs/superpowers/plans/2026-05-04-security-hardening.md new file mode 100644 index 0000000..e1f13eb --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-security-hardening.md @@ -0,0 +1,1462 @@ +# Security Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add input validation, JWT-based authorization enforcement, and per-user rate limiting to XpenselyServer. + +**Architecture:** Bean Validation annotations on all request models with a `@ControllerAdvice` exception handler for structured 400 responses; an `AuthenticatedUserResolver` component extracts the authenticated `AppUser` from the JWT `sub` claim and guards every endpoint via `assertOwner`/`assertMember` helpers; a `RateLimitFilter` registered inside the Spring Security chain uses Bucket4j in-memory buckets keyed by JWT subject (or IP fallback) to enforce per-endpoint limits. + +**Tech Stack:** Spring Boot 3.4.1, Jakarta Bean Validation (spring-boot-starter-validation), Bucket4j 8.10.1 (bucket4j-core), Spring Security Test (jwt() post processor), JUnit 5, Mockito + +--- + +> **Before starting:** Check out the feature branch. +> ``` +> git checkout feature/security-hardening +> ``` + +> **Client compatibility note:** `GET /api/expenselist/byUser` and `GET /api/expenselist/byUsername` are removed; the Flutter client must be updated to call `GET /api/expenselist/mine` instead. This is out of scope for this plan — coordinate with the client update separately. + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `pom.xml` | Modify | Add validation + Bucket4j dependencies | +| `model/AppUserCreateRequest.java` | Modify | Add Bean Validation annotations | +| `model/ExpenseInput.java` | Modify | Add validation; remove stray JPA annotations | +| `model/ExpenseChangeRequest.java` | Modify | Add Bean Validation annotations | +| `model/InviteRequest.java` | Modify | Add validation; remove `userId` field | +| `controller/GlobalExceptionHandler.java` | Create | Structured 400/403 error responses | +| `controller/AppUserController.java` | Modify | Add `@Valid`, ownership guards, JWT user resolution | +| `controller/ExpenseListController.java` | Modify | Add `@Valid`, ownership guards, rename/remove endpoints | +| `security/AuthenticatedUserResolver.java` | Create | Resolve JWT sub claim to `AppUser` entity | +| `security/RateLimitFilter.java` | Create | Per-user/IP Bucket4j rate limiting | +| `test/.../controller/AppUserControllerTest.java` | Create | Validation + authorization tests for user controller | +| `test/.../controller/ExpenseListControllerTest.java` | Create | Validation + authorization tests for expense list controller | +| `test/.../security/AuthenticatedUserResolverTest.java` | Create | Unit tests for JWT resolution | +| `test/.../security/RateLimitFilterTest.java` | Create | Unit tests for rate limiting | + +--- + +## Task 1: Add Maven Dependencies + +**Files:** +- Modify: `pom.xml` + +- [ ] **Step 1: Add dependencies** + +In `pom.xml`, inside ``, add after the existing `spring-boot-starter-security` dependency: + +```xml + + org.springframework.boot + spring-boot-starter-validation + + + com.bucket4j + bucket4j-core + 8.10.1 + +``` + +- [ ] **Step 2: Verify build compiles** + +Run: `mvn compile -q` + +Expected: `BUILD SUCCESS` with no errors. + +- [ ] **Step 3: Commit** + +``` +git add pom.xml +git commit -m "build: add spring-boot-starter-validation and bucket4j-core" +``` + +--- + +## Task 2: Annotate Request Models + +**Files:** +- Modify: `src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java` +- Modify: `src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java` +- Modify: `src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java` +- Modify: `src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java` + +- [ ] **Step 1: Update AppUserCreateRequest.java** + +Replace the entire file content with: + +```java +package de.zendric.app.xpensely_server.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class AppUserCreateRequest { + + @NotBlank(message = "Username is required") + @Size(min = 3, max = 30, message = "Username must be between 3 and 30 characters") + @Pattern(regexp = "^[a-zA-Z0-9_.\\-]+$", message = "Username may only contain letters, digits, underscores, dots, and hyphens") + private String username; + + @NotBlank(message = "Google ID is required") + private String googleId; + + public AppUser convertToAppUser() { + AppUser appUser = new AppUser(); + appUser.setGoogleId(googleId); + appUser.setUsername(username); + return appUser; + } +} +``` + +- [ ] **Step 2: Update ExpenseInput.java** + +Replace the entire file content with (removes stray `@Id`/`@GeneratedValue`, adds validation): + +```java +package de.zendric.app.xpensely_server.model; + +import java.time.LocalDate; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class ExpenseInput { + + private Long id; + + @NotBlank(message = "Title is required") + @Size(max = 100, message = "Title must not exceed 100 characters") + private String title; + + @NotBlank(message = "Owner is required") + private String owner; + + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") + private Double amount; + + private Double personalUseAmount; + private Double otherPersonAmount; + + @NotNull(message = "Date is required") + private LocalDate date; + + @NotBlank(message = "Category is required") + private String category; + + private ExpenseList expenseList; + + public Expense convertToExpense(Long userId) { + AppUser appUser = new AppUser(); + appUser.setId(userId); + appUser.setUsername(owner); + + Expense expense = new Expense(); + expense.setAmount(amount); + expense.setDate(date); + expense.setPersonalUseAmount(personalUseAmount); + expense.setOtherPersonAmount(otherPersonAmount); + expense.setExpenseList(expenseList); + expense.setId(id); + expense.setOwner(appUser); + expense.setTitle(title); + expense.setCategory(category); + + return expense; + } +} +``` + +- [ ] **Step 3: Update ExpenseChangeRequest.java** + +Replace the entire file content with: + +```java +package de.zendric.app.xpensely_server.model; + +import java.time.LocalDate; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ExpenseChangeRequest { + + private Long id; + + @NotBlank(message = "Title is required") + @Size(max = 100, message = "Title must not exceed 100 characters") + private String title; + + @NotBlank(message = "Owner name is required") + private String ownerName; + + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") + private Double amount; + + private Double personalUseAmount; + private Double otherPersonAmount; + + @NotNull(message = "Date is required") + private LocalDate date; + + @NotBlank(message = "Category is required") + private String category; + + public Expense convertToExpense(Long userId, ExpenseList expenseList) { + AppUser appUser = new AppUser(); + appUser.setId(userId); + appUser.setUsername(ownerName); + + Expense expense = new Expense(); + expense.setAmount(amount); + expense.setDate(date); + expense.setPersonalUseAmount(personalUseAmount); + expense.setOtherPersonAmount(otherPersonAmount); + expense.setExpenseList(expenseList); + expense.setId(id); + expense.setOwner(appUser); + expense.setTitle(title); + expense.setCategory(category); + + return expense; + } +} +``` + +- [ ] **Step 4: Update InviteRequest.java** + +Replace the entire file content with (`userId` removed — will be derived from JWT): + +```java +package de.zendric.app.xpensely_server.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class InviteRequest { + + @NotBlank(message = "Invite code is required") + @Size(min = 6, max = 6, message = "Invite code must be exactly 6 characters") + private String inviteCode; +} +``` + +- [ ] **Step 5: Verify build compiles** + +Run: `mvn compile -q` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 6: Commit** + +``` +git add src/main/java/de/zendric/app/xpensely_server/model/ +git commit -m "feat: add Bean Validation annotations to request models" +``` + +--- + +## Task 3: Add GlobalExceptionHandler + +**Files:** +- Create: `src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java` +- Create: `src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java` + +- [ ] **Step 1: Write the failing test** + +Create `src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java`: + +```java +package de.zendric.app.xpensely_server.controller; + +import de.zendric.app.xpensely_server.services.UserService; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(AppUserController.class) +@ActiveProfiles("test") +class AppUserControllerTest { + + @Autowired MockMvc mockMvc; + @MockBean UserService userService; + + @Test + void createUser_blankUsername_returns400WithFieldError() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_invalidUsernamePattern_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"hello world!\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_usernameTooShort_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"ab\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_blankGoogleId_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"validuser\",\"googleId\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.googleId").exists()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` + +Expected: FAIL — validation is not wired up yet; the controller returns 417 or 500 instead of 400. + +- [ ] **Step 3: Create GlobalExceptionHandler** + +Create `src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java`: + +```java +package de.zendric.app.xpensely_server.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + errors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", ex.getMessage())); + } +} +``` + +- [ ] **Step 4: Add @Valid to AppUserController.createUser** + +In `src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java`, add `import jakarta.validation.Valid;` and update the `createUser` method signature: + +```java +import jakarta.validation.Valid; + +// Change: +public ResponseEntity createUser(@RequestBody AppUserCreateRequest userRequest) { +// To: +public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` + +Expected: `Tests run: 4, Failures: 0, Errors: 0` + +- [ ] **Step 6: Commit** + +``` +git add src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java +git add src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java +git add src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java +git commit -m "feat: add GlobalExceptionHandler and @Valid to user creation endpoint" +``` + +--- + +## Task 4: Add @Valid to ExpenseList Endpoints + +**Files:** +- Modify: `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java` +- Create: `src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java` + +- [ ] **Step 1: Write the failing tests** + +Create `src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java`: + +```java +package de.zendric.app.xpensely_server.controller; + +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; +import de.zendric.app.xpensely_server.services.CategoryService; +import de.zendric.app.xpensely_server.services.ExpenseListService; +import de.zendric.app.xpensely_server.services.UserService; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(ExpenseListController.class) +@ActiveProfiles("test") +class ExpenseListControllerTest { + + @Autowired MockMvc mockMvc; + @MockBean ExpenseListService expenseListService; + @MockBean UserService userService; + @MockBean CategoryService categoryService; + @MockBean AuthenticatedUserResolver authenticatedUserResolver; + + @Test + void addExpense_blankTitle_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"\",\"owner\":\"alice\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").exists()); + } + + @Test + void addExpense_negativeAmount_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":-5.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.amount").exists()); + } + + @Test + void addExpense_nullDate_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":10.0,\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.date").exists()); + } + + @Test + void acceptInvite_blankCode_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/accept-invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inviteCode\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.inviteCode").exists()); + } + + @Test + void acceptInvite_wrongCodeLength_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/accept-invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inviteCode\":\"ABC\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.inviteCode").exists()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` + +Expected: FAIL — `@Valid` not yet added and `AuthenticatedUserResolver` bean does not exist yet. + +- [ ] **Step 3: Create stub AuthenticatedUserResolver** + +Create `src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java` (full implementation comes in Task 5; create the stub now so the app compiles): + +```java +package de.zendric.app.xpensely_server.security; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.services.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class AuthenticatedUserResolver { + + private final UserService userService; + + public AuthenticatedUserResolver(UserService userService) { + this.userService = userService; + } + + public AppUser resolveCurrentUser(Authentication authentication) { + if (authentication == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not authenticated"); + } + Jwt jwt = (Jwt) authentication.getPrincipal(); + String googleId = jwt.getSubject(); + try { + AppUser user = userService.getUserByGoogleId(googleId); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); + } + return user; + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); + } + } +} +``` + +- [ ] **Step 4: Add @Valid to ExpenseListController endpoints** + +In `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java`, add `import jakarta.validation.Valid;` and update the three `@RequestBody` parameters: + +```java +import jakarta.validation.Valid; + +// Change addExpenseToList signature: +public ResponseEntity addExpenseToList( + @PathVariable("id") Long expenseListId, + @RequestBody @Valid ExpenseInput expenseInput) { + +// Change updateExpenseInList signature: +public ResponseEntity updateExpenseInList( + @PathVariable("id") Long expenseListId, + @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest) { + +// Change acceptInvite signature: +public ResponseEntity acceptInvite(@RequestBody @Valid InviteRequest inviteRequest) { +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` + +Expected: `Tests run: 5, Failures: 0, Errors: 0` + +- [ ] **Step 6: Run all tests** + +Run: `mvn test -q` + +Expected: All existing tests still pass. + +- [ ] **Step 7: Commit** + +``` +git add src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +git add src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java +git add src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java +git commit -m "feat: add @Valid to expense list endpoints and stub AuthenticatedUserResolver" +``` + +--- + +## Task 5: Test and Complete AuthenticatedUserResolver + +**Files:** +- Modify: `src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java` (already complete from Task 4) +- Create: `src/test/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolverTest.java` + +- [ ] **Step 1: Write the failing tests** + +Create `src/test/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolverTest.java`: + +```java +package de.zendric.app.xpensely_server.security; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.services.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AuthenticatedUserResolverTest { + + UserService userService; + AuthenticatedUserResolver resolver; + + @BeforeEach + void setUp() { + userService = mock(UserService.class); + resolver = new AuthenticatedUserResolver(userService); + } + + @Test + void resolveCurrentUser_validJwt_returnsAppUser() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("google-id-123") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + + AppUser user = new AppUser(); + user.setId(1L); + user.setGoogleId("google-id-123"); + when(userService.getUserByGoogleId("google-id-123")).thenReturn(user); + + AppUser result = resolver.resolveCurrentUser(auth); + assertEquals(user, result); + } + + @Test + void resolveCurrentUser_userNotFound_throws403() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("unknown-id") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + when(userService.getUserByGoogleId("unknown-id")).thenReturn(null); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(auth)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + void resolveCurrentUser_userServiceThrows_throws403() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("gone-id") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + when(userService.getUserByGoogleId("gone-id")).thenThrow(new IllegalArgumentException("not found")); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(auth)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + void resolveCurrentUser_nullAuthentication_throws403() { + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(null)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `mvn test -Dtest=AuthenticatedUserResolverTest -pl . -q` + +Expected: `Tests run: 4, Failures: 0, Errors: 0` + +(The implementation was already written in Task 4. If tests fail, revisit the resolver logic.) + +- [ ] **Step 3: Commit** + +``` +git add src/test/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolverTest.java +git commit -m "test: add unit tests for AuthenticatedUserResolver" +``` + +--- + +## Task 6: Add Authorization to ExpenseListController + +**Files:** +- Modify: `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java` +- Modify: `src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java` + +- [ ] **Step 1: Write failing authorization tests** + +Add these tests to the existing `ExpenseListControllerTest.java` (append inside the class body, before the closing brace): + +```java + // --- Authorization tests --- + // These run with the test profile (security disabled) but mock the resolver + // to simulate different users, testing the ownership guard logic directly. + + @Test + void getById_authenticatedUserNotMember_returns403() throws Exception { + de.zendric.app.xpensely_server.model.AppUser owner = new de.zendric.app.xpensely_server.model.AppUser(); + owner.setId(1L); + de.zendric.app.xpensely_server.model.AppUser requester = new de.zendric.app.xpensely_server.model.AppUser(); + requester.setId(2L); + + de.zendric.app.xpensely_server.model.ExpenseList list = new de.zendric.app.xpensely_server.model.ExpenseList(); + list.setId(1L); + list.setOwner(owner); + + org.mockito.Mockito.when(expenseListService.findById(1L)).thenReturn(java.util.Optional.of(list)); + org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) + .thenReturn(requester); + + mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) + .andExpect(status().isForbidden()); + } + + @Test + void getById_authenticatedUserIsOwner_returns200() throws Exception { + de.zendric.app.xpensely_server.model.AppUser owner = new de.zendric.app.xpensely_server.model.AppUser(); + owner.setId(1L); + + de.zendric.app.xpensely_server.model.ExpenseList list = new de.zendric.app.xpensely_server.model.ExpenseList(); + list.setId(1L); + list.setOwner(owner); + + org.mockito.Mockito.when(expenseListService.findById(1L)).thenReturn(java.util.Optional.of(list)); + org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) + .thenReturn(owner); + + mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) + .andExpect(status().isOk()); + } + + @Test + void deleteList_nonOwner_returns403() throws Exception { + de.zendric.app.xpensely_server.model.AppUser owner = new de.zendric.app.xpensely_server.model.AppUser(); + owner.setId(1L); + de.zendric.app.xpensely_server.model.AppUser nonOwner = new de.zendric.app.xpensely_server.model.AppUser(); + nonOwner.setId(2L); + + de.zendric.app.xpensely_server.model.ExpenseList list = new de.zendric.app.xpensely_server.model.ExpenseList(); + list.setId(5L); + list.setOwner(owner); + + org.mockito.Mockito.when(expenseListService.findById(5L)).thenReturn(java.util.Optional.of(list)); + org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) + .thenReturn(nonOwner); + + mockMvc.perform(delete("/api/expenselist/5")) + .andExpect(status().isForbidden()); + } + + @Test + void getMine_returnsCurrentUserLists() throws Exception { + de.zendric.app.xpensely_server.model.AppUser user = new de.zendric.app.xpensely_server.model.AppUser(); + user.setId(3L); + + org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) + .thenReturn(user); + org.mockito.Mockito.when(expenseListService.findByUserId(3L)) + .thenReturn(java.util.List.of(new de.zendric.app.xpensely_server.model.ExpenseList())); + + mockMvc.perform(get("/api/expenselist/mine")) + .andExpect(status().isOk()); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` + +Expected: FAIL — `getMine` endpoint doesn't exist, authorization guards not yet implemented. + +- [ ] **Step 3: Rewrite ExpenseListController with authorization** + +Replace the entire content of `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java`: + +```java +package de.zendric.app.xpensely_server.controller; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import de.zendric.app.xpensely_server.model.*; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; +import de.zendric.app.xpensely_server.services.CategoryService; +import de.zendric.app.xpensely_server.services.ExpenseListService; +import de.zendric.app.xpensely_server.services.UserService; + +@RestController +@RequestMapping("/api/expenselist") +class ExpenseListController { + + private final ExpenseListService expenseListService; + private final UserService userService; + private final CategoryService categoryService; + private final AuthenticatedUserResolver authenticatedUserResolver; + + public ExpenseListController(ExpenseListService expenseListService, UserService userService, + CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) { + this.expenseListService = expenseListService; + this.userService = userService; + this.categoryService = categoryService; + this.authenticatedUserResolver = authenticatedUserResolver; + } + + @GetMapping("/mine") + public ResponseEntity> getMine(Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + List items = expenseListService.findByUserId(user.getId()); + if (items.isEmpty()) + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + return new ResponseEntity<>(items, HttpStatus.OK); + } + + @GetMapping("/byId") + public ResponseEntity getById(@RequestParam Long id, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional existingItemOptional = expenseListService.findById(id); + if (existingItemOptional.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, existingItemOptional.get()); + return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK); + } + + @PostMapping("/create") + public ResponseEntity create(@RequestBody ExpenseList expenseList, + Authentication authentication) { + try { + AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); + expenseList.setOwner(authenticatedUser); + XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); + expenseList.setXpenselyStandardCategories(standardCategories); + expenseList.setSharedWith(null); + ExpenseList savedItem = expenseListService.createList(expenseList); + return new ResponseEntity<>(savedItem, HttpStatus.CREATED); + } catch (ResponseStatusException e) { + throw e; + } catch (Exception e) { + e.printStackTrace(); + return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); + } + } + + @DeleteMapping("{id}") + public ResponseEntity delete(@PathVariable("id") Long id, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(id); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertOwner(user, listOpt.get()); + try { + expenseListService.deleteById(id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.EXPECTATION_FAILED); + } + } + + @PostMapping("/{id}/add") + public ResponseEntity addExpenseToList( + @PathVariable("id") Long expenseListId, + @RequestBody @Valid ExpenseInput expenseInput, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(expenseListId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, listOpt.get()); + try { + AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); + Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); + Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); + return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @PutMapping("/{id}/update") + public ResponseEntity updateExpenseInList( + @PathVariable("id") Long expenseListId, + @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional expenseListOpt = expenseListService.findById(expenseListId); + if (expenseListOpt.isEmpty()) + return new ResponseEntity<>(null, HttpStatus.NOT_FOUND); + assertMember(user, expenseListOpt.get()); + try { + AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); + Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); + Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); + return new ResponseEntity<>(updatedExpense, HttpStatus.OK); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); + } + } + + @DeleteMapping("/{id}/delete") + public ResponseEntity deleteExpenseFromList( + @PathVariable("id") Long expenseListId, + @RequestParam("expenseId") Long expenseId, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(expenseListId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, listOpt.get()); + try { + expenseListService.deleteExpenseFromList(expenseListId, expenseId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); + } + } + + @PostMapping("/{listId}/invite") + public ResponseEntity generateInvite(@PathVariable Long listId, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(listId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertOwner(user, listOpt.get()); + String inviteCode = expenseListService.generateInviteCode(listId); + return ResponseEntity.ok(inviteCode); + } + + @PostMapping("/accept-invite") + public ResponseEntity acceptInvite(@RequestBody @Valid InviteRequest inviteRequest, + Authentication authentication) { + AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); + ExpenseList list = expenseListService.findByInviteCode(inviteRequest.getInviteCode()); + + if (list == null || list.getInviteCodeExpiration() == null || + list.getInviteCodeExpiration().isBefore(LocalDateTime.now())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Invalid or expired invite code"); + } + if (list.getSharedWith() != null) { + return ResponseEntity.status(HttpStatus.IM_USED).body("List has already been shared"); + } + if (list.getOwner().getId().equals(authenticatedUser.getId())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list"); + } + list.setSharedWith(authenticatedUser); + expenseListService.save(list); + return ResponseEntity.ok("User added to the list"); + } + + // --- Authorization helpers --- + + private void assertOwner(AppUser authenticated, ExpenseList list) { + if (!list.getOwner().getId().equals(authenticated.getId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + private void assertMember(AppUser authenticated, ExpenseList list) { + boolean isOwner = list.getOwner().getId().equals(authenticated.getId()); + boolean isShared = list.getSharedWith() != null + && list.getSharedWith().getId().equals(authenticated.getId()); + if (!isOwner && !isShared) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` + +Expected: `Tests run: 9, Failures: 0, Errors: 0` + +- [ ] **Step 5: Run all tests** + +Run: `mvn test -q` + +Expected: All tests pass. + +- [ ] **Step 6: Commit** + +``` +git add src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +git add src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java +git commit -m "feat: add ownership guards and JWT-based authorization to expense list endpoints" +``` + +--- + +## Task 7: Add Authorization to AppUserController + +**Files:** +- Modify: `src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java` +- Modify: `src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java` + +- [ ] **Step 1: Write failing authorization tests** + +Add these tests inside `AppUserControllerTest.java` (before the closing brace). Add `@MockBean AuthenticatedUserResolver authenticatedUserResolver;` to the class fields first: + +```java + @MockBean de.zendric.app.xpensely_server.security.AuthenticatedUserResolver authenticatedUserResolver; + + @Test + void getUser_differentUser_returns403() throws Exception { + de.zendric.app.xpensely_server.model.AppUser self = new de.zendric.app.xpensely_server.model.AppUser(); + self.setId(1L); + org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) + .thenReturn(self); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .get("/api/users").param("id", "99")) + .andExpect(status().isForbidden()); + } + + @Test + void getUser_sameUser_returns200() throws Exception { + de.zendric.app.xpensely_server.model.AppUser self = new de.zendric.app.xpensely_server.model.AppUser(); + self.setId(1L); + org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) + .thenReturn(self); + org.mockito.Mockito.when(userService.getUser(1L)).thenReturn(self); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .get("/api/users").param("id", "1")) + .andExpect(status().isOk()); + } + + @Test + void deleteUser_differentUser_returns403() throws Exception { + de.zendric.app.xpensely_server.model.AppUser self = new de.zendric.app.xpensely_server.model.AppUser(); + self.setId(1L); + org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) + .thenReturn(self); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .delete("/api/users").param("id", "99")) + .andExpect(status().isForbidden()); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` + +Expected: FAIL — authorization guards not yet added. + +- [ ] **Step 3: Rewrite AppUserController with authorization** + +Replace the entire content of `src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java`: + +```java +package de.zendric.app.xpensely_server.controller; + +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.AppUserCreateRequest; +import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; +import de.zendric.app.xpensely_server.services.UserService; + +@RestController +@RequestMapping("/api/users") +public class AppUserController { + + private final UserService userService; + private final AuthenticatedUserResolver authenticatedUserResolver; + + public AppUserController(UserService userService, AuthenticatedUserResolver authenticatedUserResolver) { + this.userService = userService; + this.authenticatedUserResolver = authenticatedUserResolver; + } + + @GetMapping + public ResponseEntity getUser(@RequestParam Long id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + assertSelf(self, id); + return ResponseEntity.ok(userService.getUser(id)); + } + + @GetMapping("/byName") + public AppUser getUserByName(@RequestParam String username) { + return userService.getUserByName(username); + } + + @GetMapping("/byGoogleId") + public ResponseEntity getUserByGoogleId(@RequestParam String id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + if (!self.getGoogleId().equals(id)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return ResponseEntity.ok(self); + } + + @PostMapping("/createUser") + public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { + try { + AppUser convertedUser = userRequest.convertToAppUser(); + AppUser nUser = userService.createUser(convertedUser); + return new ResponseEntity<>(nUser, HttpStatus.CREATED); + } catch (UsernameAlreadyExistsException e) { + return new ResponseEntity<>(null, HttpStatus.CONFLICT); + } catch (Exception e) { + return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); + } + } + + @DeleteMapping + public ResponseEntity deleteUser(@RequestParam Long id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + assertSelf(self, id); + AppUser user = userService.deleteUserById(id); + return ResponseEntity.ok("User deleted: " + user.getUsername()); + } + + private void assertSelf(AppUser authenticated, Long requestedId) { + if (!authenticated.getId().equals(requestedId)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` + +Expected: `Tests run: 7, Failures: 0, Errors: 0` + +- [ ] **Step 5: Run all tests** + +Run: `mvn test -q` + +Expected: All tests pass. + +- [ ] **Step 6: Commit** + +``` +git add src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java +git add src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java +git commit -m "feat: add ownership guards to user endpoints" +``` + +--- + +## Task 8: Add RateLimitFilter + +**Files:** +- Create: `src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java` +- Modify: `src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java` +- Create: `src/test/java/de/zendric/app/xpensely_server/security/RateLimitFilterTest.java` + +- [ ] **Step 1: Write the failing tests** + +Create `src/test/java/de/zendric/app/xpensely_server/security/RateLimitFilterTest.java`: + +```java +package de.zendric.app.xpensely_server.security; + +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RateLimitFilterTest { + + RateLimitFilter filter; + + @BeforeEach + void setUp() { + filter = new RateLimitFilter(); + } + + @Test + void generalEndpoint_underLimit_allowsRequest() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/expenselist/mine"); + request.setRemoteAddr("10.0.0.1"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + assertNotEquals(429, response.getStatus()); + } + + @Test + void createUser_exceedsLimit_returns429() throws Exception { + FilterChain chain = mock(FilterChain.class); + + // Exhaust the 3-request limit + for (int i = 0; i < 3; i++) { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); + req.setRemoteAddr("10.0.0.2"); + MockHttpServletResponse res = new MockHttpServletResponse(); + filter.doFilter(req, res, chain); + assertNotEquals(429, res.getStatus(), "Request " + (i + 1) + " should be allowed"); + } + + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); + req.setRemoteAddr("10.0.0.2"); + MockHttpServletResponse res = new MockHttpServletResponse(); + filter.doFilter(req, res, chain); + + assertEquals(429, res.getStatus()); + assertNotNull(res.getHeader("Retry-After")); + } + + @Test + void createUser_differentIps_separateBuckets() throws Exception { + FilterChain chain = mock(FilterChain.class); + + // Exhaust limit for IP A + for (int i = 0; i < 3; i++) { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); + req.setRemoteAddr("10.0.0.3"); + filter.doFilter(req, new MockHttpServletResponse(), chain); + } + + // IP B should still be allowed + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); + req.setRemoteAddr("10.0.0.4"); + MockHttpServletResponse res = new MockHttpServletResponse(); + filter.doFilter(req, res, chain); + + assertNotEquals(429, res.getStatus()); + } + + @Test + void exceedLimit_responseHasRetryAfterHeader() throws Exception { + FilterChain chain = mock(FilterChain.class); + for (int i = 0; i < 3; i++) { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); + req.setRemoteAddr("10.0.0.5"); + filter.doFilter(req, new MockHttpServletResponse(), chain); + } + + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); + req.setRemoteAddr("10.0.0.5"); + MockHttpServletResponse res = new MockHttpServletResponse(); + filter.doFilter(req, res, chain); + + assertNotNull(res.getHeader("Retry-After")); + assertTrue(Integer.parseInt(res.getHeader("Retry-After")) > 0); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=RateLimitFilterTest -pl . -q` + +Expected: FAIL — `RateLimitFilter` does not exist yet. + +- [ ] **Step 3: Create RateLimitFilter** + +Create `src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java`: + +```java +package de.zendric.app.xpensely_server.security; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Refill; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +public class RateLimitFilter extends OncePerRequestFilter { + + private final Map generalBuckets = new ConcurrentHashMap<>(); + private final Map inviteBuckets = new ConcurrentHashMap<>(); + private final Map acceptInviteBuckets = new ConcurrentHashMap<>(); + private final Map createUserBuckets = new ConcurrentHashMap<>(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + String key = resolveKey(request); + String uri = request.getRequestURI(); + + Bucket bucket = selectBucket(key, uri); + var probe = bucket.tryConsumeAndReturnRemaining(1); + + if (!probe.isConsumed()) { + long retryAfterSeconds = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()); + response.setStatus(429); + response.setHeader("Retry-After", String.valueOf(Math.max(1, retryAfterSeconds))); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Too many requests\"}"); + return; + } + + chain.doFilter(request, response); + } + + private String resolveKey(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof Jwt jwt) { + return "user:" + jwt.getSubject(); + } + return "ip:" + request.getRemoteAddr(); + } + + private Bucket selectBucket(String key, String uri) { + if (uri.matches(".*/[0-9]+/invite$")) { + return inviteBuckets.computeIfAbsent(key, k -> createBucket(5, Duration.ofMinutes(1))); + } + if (uri.endsWith("/accept-invite")) { + return acceptInviteBuckets.computeIfAbsent(key, k -> createBucket(10, Duration.ofMinutes(1))); + } + if (uri.endsWith("/createUser")) { + return createUserBuckets.computeIfAbsent(key, k -> createBucket(3, Duration.ofMinutes(1))); + } + return generalBuckets.computeIfAbsent(key, k -> createBucket(60, Duration.ofMinutes(1))); + } + + private Bucket createBucket(int capacity, Duration refillPeriod) { + Bandwidth limit = Bandwidth.classic(capacity, Refill.intervally(capacity, refillPeriod)); + return Bucket.builder().addLimit(limit).build(); + } +} +``` + +- [ ] **Step 4: Register RateLimitFilter in SecurityConfig** + +Replace the entire content of `src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java`: + +```java +package de.zendric.app.xpensely_server.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + @Profile("test") + public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .csrf(csrf -> csrf.disable()); + return http.build(); + } + + @Bean + @Profile("!test") + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) + .oauth2Login(Customizer.withDefaults()) + .csrf(csrf -> csrf.disable()) + .addFilterAfter(new RateLimitFilter(), BearerTokenAuthenticationFilter.class); + return http.build(); + } +} +``` + +- [ ] **Step 5: Run RateLimitFilter tests** + +Run: `mvn test -Dtest=RateLimitFilterTest -pl . -q` + +Expected: `Tests run: 4, Failures: 0, Errors: 0` + +- [ ] **Step 6: Run all tests** + +Run: `mvn test -q` + +Expected: All tests pass, `BUILD SUCCESS`. + +- [ ] **Step 7: Commit** + +``` +git add src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java +git add src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java +git add src/test/java/de/zendric/app/xpensely_server/security/RateLimitFilterTest.java +git commit -m "feat: add RateLimitFilter with per-user Bucket4j rate limiting" +``` + +--- + +## Done + +All security hardening is complete. Summary of what was implemented: + +| Area | What changed | +|---|---| +| Input validation | Bean Validation on all request models; `@Valid` on all `@RequestBody`; `GlobalExceptionHandler` returns structured 400s | +| Authorization | `AuthenticatedUserResolver` resolves JWT sub to `AppUser`; all endpoints enforce ownership/membership; dangerous endpoints (`/all`, `/byUser`, `/byUsername`) removed or replaced with `/mine` | +| Rate limiting | `RateLimitFilter` inside security chain; per-user buckets (JWT sub) with IP fallback; 60 req/min general, 5/min invite generation, 10/min invite acceptance, 3/min account creation | +| Client compat | Flutter client must be updated: replace calls to `/byUser?userId=X` with `GET /api/expenselist/mine` (no param needed) | From b7db35defe8036cbad8801eab84a31c511bbee1e Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 4 May 2026 22:34:10 +0200 Subject: [PATCH 14/43] build: add spring-boot-starter-validation and bucket4j-core --- pom.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pom.xml b/pom.xml index df740a9..d3a59cc 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,15 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-validation + + + com.bucket4j + bucket4j-core + 8.10.1 + org.springframework.boot spring-boot-starter-oauth2-resource-server From 3bea06fead21cf240be89f3ecff7eb5ce85c381f Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 4 May 2026 22:36:20 +0200 Subject: [PATCH 15/43] feat: add Bean Validation annotations to request models --- .../model/AppUserCreateRequest.java | 10 +++++++--- .../model/ExpenseChangeRequest.java | 19 ++++++++++++++++++- .../xpensely_server/model/ExpenseInput.java | 18 +++++++++++++----- .../xpensely_server/model/InviteRequest.java | 6 +++++- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java index 3ae678d..c2f05ed 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java @@ -1,21 +1,25 @@ package de.zendric.app.xpensely_server.model; -import jakarta.persistence.Column; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Data; @Data public class AppUserCreateRequest { - @Column(name = "username", nullable = false, unique = true) + @NotBlank(message = "Username is required") + @Size(min = 3, max = 30, message = "Username must be between 3 and 30 characters") + @Pattern(regexp = "^[a-zA-Z0-9_.\\-]+$", message = "Username may only contain letters, digits, underscores, dots, and hyphens") private String username; + @NotBlank(message = "Google ID is required") private String googleId; public AppUser convertToAppUser() { AppUser appUser = new AppUser(); appUser.setGoogleId(googleId); appUser.setUsername(username); - return appUser; } } diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java index 65f8edd..20c161b 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java @@ -2,6 +2,10 @@ package de.zendric.app.xpensely_server.model; import java.time.LocalDate; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -12,12 +16,25 @@ import lombok.NoArgsConstructor; public class ExpenseChangeRequest { private Long id; + + @NotBlank(message = "Title is required") + @Size(max = 100, message = "Title must not exceed 100 characters") private String title; + + @NotBlank(message = "Owner name is required") private String ownerName; + + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") private Double amount; + private Double personalUseAmount; private Double otherPersonAmount; + + @NotNull(message = "Date is required") private LocalDate date; + + @NotBlank(message = "Category is required") private String category; public Expense convertToExpense(Long userId, ExpenseList expenseList) { @@ -38,4 +55,4 @@ public class ExpenseChangeRequest { return expense; } -} \ No newline at end of file +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java index 3fb17ad..1a31fd2 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java @@ -2,9 +2,10 @@ package de.zendric.app.xpensely_server.model; import java.time.LocalDate; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,19 +17,26 @@ import lombok.Setter; @NoArgsConstructor public class ExpenseInput { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @NotBlank(message = "Title is required") + @Size(max = 100, message = "Title must not exceed 100 characters") private String title; + @NotBlank(message = "Owner is required") private String owner; + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") private Double amount; + private Double personalUseAmount; private Double otherPersonAmount; + @NotNull(message = "Date is required") private LocalDate date; + + @NotBlank(message = "Category is required") private String category; private ExpenseList expenseList; diff --git a/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java index 81ac35a..e572c44 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java @@ -1,5 +1,7 @@ package de.zendric.app.xpensely_server.model; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -8,6 +10,8 @@ import lombok.NoArgsConstructor; @AllArgsConstructor @NoArgsConstructor public class InviteRequest { + + @NotBlank(message = "Invite code is required") + @Size(min = 6, max = 6, message = "Invite code must be exactly 6 characters") private String inviteCode; - private Long userId; } From a948bca2fc79a940386c9febd01029daa39e0c8e Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 4 May 2026 22:44:37 +0200 Subject: [PATCH 16/43] feat: add GlobalExceptionHandler, @Valid to user creation, AuthenticatedUserResolver stub, and rewrite ExpenseListController with authorization --- .../controller/AppUserController.java | 3 +- .../controller/ExpenseListController.java | 204 ++++++++---------- .../controller/GlobalExceptionHandler.java | 30 +++ .../security/AuthenticatedUserResolver.java | 36 ++++ .../controller/AppUserControllerTest.java | 60 ++++++ 5 files changed, 216 insertions(+), 117 deletions(-) create mode 100644 src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java index 597d733..16e6673 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java @@ -1,5 +1,6 @@ package de.zendric.app.xpensely_server.controller; +import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -51,7 +52,7 @@ public class AppUserController { } @PostMapping("/createUser") - public ResponseEntity createUser(@RequestBody AppUserCreateRequest userRequest) { + public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { try { AppUser convertedUser = userRequest.convertToAppUser(); diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java index efaa067..cb5fb13 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java @@ -1,30 +1,18 @@ package de.zendric.app.xpensely_server.controller; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; +import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; -import de.zendric.app.xpensely_server.model.AppUser; -import de.zendric.app.xpensely_server.model.Expense; -import de.zendric.app.xpensely_server.model.ExpenseChangeRequest; -import de.zendric.app.xpensely_server.model.ExpenseInput; -import de.zendric.app.xpensely_server.model.ExpenseList; -import de.zendric.app.xpensely_server.model.InviteRequest; -import de.zendric.app.xpensely_server.model.XpenselyStandardCategories; +import de.zendric.app.xpensely_server.model.*; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.CategoryService; import de.zendric.app.xpensely_server.services.ExpenseListService; import de.zendric.app.xpensely_server.services.UserService; @@ -33,93 +21,51 @@ import de.zendric.app.xpensely_server.services.UserService; @RequestMapping("/api/expenselist") class ExpenseListController { - private ExpenseListService expenseListService; - private UserService userService; - private CategoryService categoryService; + private final ExpenseListService expenseListService; + private final UserService userService; + private final CategoryService categoryService; + private final AuthenticatedUserResolver authenticatedUserResolver; - @Autowired public ExpenseListController(ExpenseListService expenseListService, UserService userService, - CategoryService categoryService) { + CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) { this.expenseListService = expenseListService; this.userService = userService; this.categoryService = categoryService; + this.authenticatedUserResolver = authenticatedUserResolver; } - @GetMapping("/all") - public ResponseEntity> getAll() { - try { - List items = new ArrayList<>(); - - expenseListService.findAll().forEach(items::add); - - if (items.isEmpty()) - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - - return new ResponseEntity<>(items, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @GetMapping("/byUser") - public ResponseEntity> getByUser(@RequestParam Long userId) { - try { - List items = expenseListService.findByUserId(userId); - - if (items.isEmpty()) - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - - return new ResponseEntity<>(items, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @GetMapping("/byUsername") - public ResponseEntity> getByUser(@RequestParam String username) { - try { - List items = expenseListService.findByUsername(username); - - if (items.isEmpty()) - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - - return new ResponseEntity<>(items, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } + @GetMapping("/mine") + public ResponseEntity> getMine(Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + List items = expenseListService.findByUserId(user.getId()); + if (items.isEmpty()) + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + return new ResponseEntity<>(items, HttpStatus.OK); } @GetMapping("/byId") - public ResponseEntity getById(@RequestParam Long id) { + public ResponseEntity getById(@RequestParam Long id, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); Optional existingItemOptional = expenseListService.findById(id); - - if (existingItemOptional.isPresent()) { - return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK); - } else { + if (existingItemOptional.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); - } + assertMember(user, existingItemOptional.get()); + return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK); } @PostMapping("/create") - // TODO add handling of categories by using DTO - public ResponseEntity create(@RequestBody ExpenseList expenseList) { + public ResponseEntity create(@RequestBody ExpenseList expenseList, + Authentication authentication) { try { - if (expenseList.getOwner() != null) { - AppUser existingOwner = userService.getUser(expenseList.getOwner().getId()); - if (existingOwner == null) { - throw new IllegalArgumentException("Owner does not exist."); - } - expenseList.setOwner(existingOwner); - XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); - expenseList.setXpenselyStandardCategories(standardCategories); - } else { - throw new IllegalArgumentException("Owner is required."); - } - + AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); + expenseList.setOwner(authenticatedUser); + XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); + expenseList.setXpenselyStandardCategories(standardCategories); expenseList.setSharedWith(null); - ExpenseList savedItem = expenseListService.createList(expenseList); return new ResponseEntity<>(savedItem, HttpStatus.CREATED); + } catch (ResponseStatusException e) { + throw e; } catch (Exception e) { e.printStackTrace(); return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); @@ -127,7 +73,12 @@ class ExpenseListController { } @DeleteMapping("{id}") - public ResponseEntity delete(@PathVariable("id") Long id) { + public ResponseEntity delete(@PathVariable("id") Long id, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(id); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertOwner(user, listOpt.get()); try { expenseListService.deleteById(id); return new ResponseEntity<>(HttpStatus.NO_CONTENT); @@ -139,11 +90,16 @@ class ExpenseListController { @PostMapping("/{id}/add") public ResponseEntity addExpenseToList( @PathVariable("id") Long expenseListId, - @RequestBody ExpenseInput expenseInput) { + @RequestBody @Valid ExpenseInput expenseInput, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(expenseListId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, listOpt.get()); try { AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); - Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); } catch (Exception e) { @@ -154,18 +110,18 @@ class ExpenseListController { @PutMapping("/{id}/update") public ResponseEntity updateExpenseInList( @PathVariable("id") Long expenseListId, - @RequestBody ExpenseChangeRequest expenseChangeRequest) { + @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional expenseListOpt = expenseListService.findById(expenseListId); + if (expenseListOpt.isEmpty()) + return new ResponseEntity<>(null, HttpStatus.NOT_FOUND); + assertMember(user, expenseListOpt.get()); try { AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); - Optional expenseList = expenseListService.findById(expenseListId); - if (expenseList.isPresent()) { - Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseList.get()); - - Expense addedExpense = expenseListService.updateExpense(expenseListId, expense); - return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); - } - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - + Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); + Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); + return new ResponseEntity<>(updatedExpense, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); } @@ -174,7 +130,13 @@ class ExpenseListController { @DeleteMapping("/{id}/delete") public ResponseEntity deleteExpenseFromList( @PathVariable("id") Long expenseListId, - @RequestParam("expenseId") Long expenseId) { + @RequestParam("expenseId") Long expenseId, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(expenseListId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, listOpt.get()); try { expenseListService.deleteExpenseFromList(expenseListId, expenseId); return new ResponseEntity<>(HttpStatus.NO_CONTENT); @@ -184,13 +146,20 @@ class ExpenseListController { } @PostMapping("/{listId}/invite") - public ResponseEntity generateInvite(@PathVariable Long listId) { + public ResponseEntity generateInvite(@PathVariable Long listId, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(listId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertOwner(user, listOpt.get()); String inviteCode = expenseListService.generateInviteCode(listId); return ResponseEntity.ok(inviteCode); } @PostMapping("/accept-invite") - public ResponseEntity acceptInvite(@RequestBody InviteRequest inviteRequest) { + public ResponseEntity acceptInvite(@RequestBody @Valid InviteRequest inviteRequest, + Authentication authentication) { + AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); ExpenseList list = expenseListService.findByInviteCode(inviteRequest.getInviteCode()); if (list == null || list.getInviteCodeExpiration() == null || @@ -200,21 +169,24 @@ class ExpenseListController { if (list.getSharedWith() != null) { return ResponseEntity.status(HttpStatus.IM_USED).body("List has already been shared"); } - if (list.getOwner().getId() == inviteRequest.getUserId()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cant join your own List"); - } - AppUser user = null; - try { - user = userService.getUser(inviteRequest.getUserId()); - } catch (Exception e) { - throw new RuntimeException("User not found"); - } - if (user != null) { - list.setSharedWith(user); - expenseListService.save(list); - } else { - throw new RuntimeException("User not found"); + if (list.getOwner().getId().equals(authenticatedUser.getId())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list"); } + list.setSharedWith(authenticatedUser); + expenseListService.save(list); return ResponseEntity.ok("User added to the list"); } + + private void assertOwner(AppUser authenticated, ExpenseList list) { + if (!list.getOwner().getId().equals(authenticated.getId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + private void assertMember(AppUser authenticated, ExpenseList list) { + boolean isOwner = list.getOwner().getId().equals(authenticated.getId()); + boolean isShared = list.getSharedWith() != null + && list.getSharedWith().getId().equals(authenticated.getId()); + if (!isOwner && !isShared) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } } diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..efbc72d --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java @@ -0,0 +1,30 @@ +package de.zendric.app.xpensely_server.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + errors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", ex.getMessage())); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java b/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java new file mode 100644 index 0000000..4d5686c --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java @@ -0,0 +1,36 @@ +package de.zendric.app.xpensely_server.security; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.services.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class AuthenticatedUserResolver { + + private final UserService userService; + + public AuthenticatedUserResolver(UserService userService) { + this.userService = userService; + } + + public AppUser resolveCurrentUser(Authentication authentication) { + if (authentication == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not authenticated"); + } + Jwt jwt = (Jwt) authentication.getPrincipal(); + String googleId = jwt.getSubject(); + try { + AppUser user = userService.getUserByGoogleId(googleId); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); + } + return user; + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); + } + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java new file mode 100644 index 0000000..7e2167a --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java @@ -0,0 +1,60 @@ +package de.zendric.app.xpensely_Server.controller; + +import de.zendric.app.xpensely_server.controller.AppUserController; +import de.zendric.app.xpensely_server.services.UserService; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(AppUserController.class) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class AppUserControllerTest { + + @Autowired MockMvc mockMvc; + @MockitoBean UserService userService; + + @Test + void createUser_blankUsername_returns400WithFieldError() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_invalidUsernamePattern_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"hello world!\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_usernameTooShort_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"ab\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_blankGoogleId_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"validuser\",\"googleId\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.googleId").exists()); + } +} From bb2a4d70b200a021f4adfa62fc836fdee3dad24f Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Mon, 4 May 2026 22:46:29 +0200 Subject: [PATCH 17/43] feat: add ExpenseListController validation and authorization tests --- .../controller/ExpenseListController.java | 2 +- .../controller/ExpenseListControllerTest.java | 135 ++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java index cb5fb13..7d86cf7 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java @@ -19,7 +19,7 @@ import de.zendric.app.xpensely_server.services.UserService; @RestController @RequestMapping("/api/expenselist") -class ExpenseListController { +public class ExpenseListController { private final ExpenseListService expenseListService; private final UserService userService; diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java new file mode 100644 index 0000000..2cba572 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -0,0 +1,135 @@ +package de.zendric.app.xpensely_Server.controller; + +import de.zendric.app.xpensely_server.controller.ExpenseListController; +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; +import de.zendric.app.xpensely_server.services.CategoryService; +import de.zendric.app.xpensely_server.services.ExpenseListService; +import de.zendric.app.xpensely_server.services.UserService; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +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.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(ExpenseListController.class) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class ExpenseListControllerTest { + + @Autowired MockMvc mockMvc; + @MockitoBean ExpenseListService expenseListService; + @MockitoBean UserService userService; + @MockitoBean CategoryService categoryService; + @MockitoBean AuthenticatedUserResolver authenticatedUserResolver; + + // --- Validation tests --- + + @Test + void addExpense_blankTitle_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"\",\"owner\":\"alice\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").exists()); + } + + @Test + void addExpense_negativeAmount_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":-5.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.amount").exists()); + } + + @Test + void addExpense_nullDate_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":10.0,\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.date").exists()); + } + + @Test + void acceptInvite_blankCode_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/accept-invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inviteCode\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.inviteCode").exists()); + } + + @Test + void acceptInvite_wrongCodeLength_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/accept-invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inviteCode\":\"ABC\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.inviteCode").exists()); + } + + // --- Authorization tests --- + + @Test + void getById_authenticatedUserNotMember_returns403() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + AppUser requester = new AppUser(); requester.setId(2L); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner); + + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(requester); + + mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) + .andExpect(status().isForbidden()); + } + + @Test + void getById_authenticatedUserIsOwner_returns200() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner); + + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner); + + mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) + .andExpect(status().isOk()); + } + + @Test + void deleteList_nonOwner_returns403() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + AppUser nonOwner = new AppUser(); nonOwner.setId(2L); + ExpenseList list = new ExpenseList(); list.setId(5L); list.setOwner(owner); + + when(expenseListService.findById(5L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(nonOwner); + + mockMvc.perform(delete("/api/expenselist/5")) + .andExpect(status().isForbidden()); + } + + @Test + void getMine_returnsCurrentUserLists() throws Exception { + AppUser user = new AppUser(); user.setId(3L); + + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(user); + when(expenseListService.findByUserId(3L)).thenReturn(List.of(new ExpenseList())); + + mockMvc.perform(get("/api/expenselist/mine")) + .andExpect(status().isOk()); + } +} From 95688e5111d9f5ee304364118fabb6f264eeff04 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 10:03:35 +0200 Subject: [PATCH 18/43] test: add unit tests for AuthenticatedUserResolver --- .../AuthenticatedUserResolverTest.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java diff --git a/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java b/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java new file mode 100644 index 0000000..e854437 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java @@ -0,0 +1,78 @@ +package de.zendric.app.xpensely_Server.security; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; +import de.zendric.app.xpensely_server.services.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AuthenticatedUserResolverTest { + + UserService userService; + AuthenticatedUserResolver resolver; + + @BeforeEach + void setUp() { + userService = mock(UserService.class); + resolver = new AuthenticatedUserResolver(userService); + } + + @Test + void resolveCurrentUser_validJwt_returnsAppUser() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("google-id-123") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + + AppUser user = new AppUser(); + user.setId(1L); + user.setGoogleId("google-id-123"); + when(userService.getUserByGoogleId("google-id-123")).thenReturn(user); + + AppUser result = resolver.resolveCurrentUser(auth); + assertEquals(user, result); + } + + @Test + void resolveCurrentUser_userNotFound_throws403() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("unknown-id") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + when(userService.getUserByGoogleId("unknown-id")).thenReturn(null); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(auth)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + void resolveCurrentUser_userServiceThrows_throws403() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("gone-id") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + when(userService.getUserByGoogleId("gone-id")).thenThrow(new IllegalArgumentException("not found")); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(auth)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + void resolveCurrentUser_nullAuthentication_throws403() { + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(null)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } +} From 457efab45272c195d17ab07d9967f883e36c41c5 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 11:13:05 +0200 Subject: [PATCH 19/43] security: enforce JWT-based authorization on AppUserController Added AuthenticatedUserResolver injection and assertSelf guard to getUser, getUserByGoogleId, and deleteUser endpoints. createUser remains open for registration. Added 7 controller tests covering validation failures and 403 enforcement. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/AppUserController.java | 53 +++++++++---------- .../controller/AppUserControllerTest.java | 37 ++++++++++++- 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java index 16e6673..7bb5e8a 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java @@ -1,36 +1,35 @@ package de.zendric.app.xpensely_server.controller; import jakarta.validation.Valid; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.AppUserCreateRequest; import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; @RestController @RequestMapping("/api/users") public class AppUserController { - private UserService userService; + private final UserService userService; + private final AuthenticatedUserResolver authenticatedUserResolver; - @Autowired - public AppUserController(UserService userService) { + public AppUserController(UserService userService, AuthenticatedUserResolver authenticatedUserResolver) { this.userService = userService; + this.authenticatedUserResolver = authenticatedUserResolver; } @GetMapping - public AppUser getUser(@RequestParam Long id) { - return userService.getUser(id); + public ResponseEntity getUser(@RequestParam Long id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + assertSelf(self, id); + return ResponseEntity.ok(userService.getUser(id)); } @GetMapping("/byName") @@ -39,23 +38,17 @@ public class AppUserController { } @GetMapping("/byGoogleId") - public ResponseEntity getUserByGoogleId(@RequestParam String id) { - try { - AppUser userByGoogleId = userService.getUserByGoogleId(id); - return new ResponseEntity<>(userByGoogleId, HttpStatus.OK); - - } catch (IllegalArgumentException e) { - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } + public ResponseEntity getUserByGoogleId(@RequestParam String id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + if (!self.getGoogleId().equals(id)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return ResponseEntity.ok(self); } @PostMapping("/createUser") public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { try { AppUser convertedUser = userRequest.convertToAppUser(); - AppUser nUser = userService.createUser(convertedUser); return new ResponseEntity<>(nUser, HttpStatus.CREATED); } catch (UsernameAlreadyExistsException e) { @@ -63,12 +56,18 @@ public class AppUserController { } catch (Exception e) { return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); } - } @DeleteMapping - public String deleteUser(@RequestParam Long id) { + public ResponseEntity deleteUser(@RequestParam Long id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + assertSelf(self, id); AppUser user = userService.deleteUserById(id); - return "User deleted : " + user.getUsername(); + return ResponseEntity.ok("User deleted: " + user.getUsername()); + } + + private void assertSelf(AppUser authenticated, Long requestedId) { + if (!authenticated.getId().equals(requestedId)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); } } diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java index 7e2167a..94dbd6d 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java @@ -1,6 +1,8 @@ package de.zendric.app.xpensely_Server.controller; import de.zendric.app.xpensely_server.controller.AppUserController; +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -11,7 +13,9 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(AppUserController.class) @@ -21,6 +25,7 @@ class AppUserControllerTest { @Autowired MockMvc mockMvc; @MockitoBean UserService userService; + @MockitoBean AuthenticatedUserResolver authenticatedUserResolver; @Test void createUser_blankUsername_returns400WithFieldError() throws Exception { @@ -57,4 +62,34 @@ class AppUserControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.googleId").exists()); } + + // --- Authorization tests --- + + @Test + void getUser_differentUser_returns403() throws Exception { + AppUser self = new AppUser(); self.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + + mockMvc.perform(get("/api/users").param("id", "99")) + .andExpect(status().isForbidden()); + } + + @Test + void getUser_sameUser_returns200() throws Exception { + AppUser self = new AppUser(); self.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + when(userService.getUser(1L)).thenReturn(self); + + mockMvc.perform(get("/api/users").param("id", "1")) + .andExpect(status().isOk()); + } + + @Test + void deleteUser_differentUser_returns403() throws Exception { + AppUser self = new AppUser(); self.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + + mockMvc.perform(delete("/api/users").param("id", "99")) + .andExpect(status().isForbidden()); + } } From 024b3880e7de9c6ed6ab9b1d30817aae322d1afa Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 11:19:42 +0200 Subject: [PATCH 20/43] security: add per-user/IP rate limiting via Bucket4j RateLimitFilter (OncePerRequestFilter) enforces 60 req/min per authenticated Google ID or client IP, using Bucket4j in-memory token buckets. Filter is registered after BearerTokenAuthenticationFilter in the production security chain. Added 4 unit tests covering allow, block, per-IP isolation, and X-Forwarded-For preference. Co-Authored-By: Claude Sonnet 4.6 --- .../security/RateLimitFilter.java | 61 +++++++++++++ .../security/SecurityConfig.java | 2 + .../security/RateLimitFilterTest.java | 89 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java diff --git a/src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java b/src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java new file mode 100644 index 0000000..f04341e --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java @@ -0,0 +1,61 @@ +package de.zendric.app.xpensely_server.security; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class RateLimitFilter extends OncePerRequestFilter { + + private static final int REQUESTS_PER_MINUTE = 60; + + private final Map buckets = new ConcurrentHashMap<>(); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String key = resolveKey(request); + Bucket bucket = buckets.computeIfAbsent(key, k -> newBucket()); + + if (bucket.tryConsume(1)) { + filterChain.doFilter(request, response); + } else { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.getWriter().write("Rate limit exceeded"); + } + } + + private String resolveKey(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof Jwt jwt) { + return "user:" + jwt.getSubject(); + } + String ip = request.getHeader("X-Forwarded-For"); + if (ip != null && !ip.isBlank()) { + return "ip:" + ip.split(",")[0].trim(); + } + return "ip:" + request.getRemoteAddr(); + } + + private Bucket newBucket() { + return Bucket.builder() + .addLimit(Bandwidth.builder() + .capacity(REQUESTS_PER_MINUTE) + .refillGreedy(REQUESTS_PER_MINUTE, Duration.ofMinutes(1)) + .build()) + .build(); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java b/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java index d0d2465..1ae17fc 100644 --- a/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java +++ b/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; @Configuration @@ -31,6 +32,7 @@ public class SecurityConfig { .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults())) .oauth2Login(Customizer.withDefaults()) + .addFilterAfter(new RateLimitFilter(), BearerTokenAuthenticationFilter.class) .csrf().disable(); return http.build(); diff --git a/src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java b/src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java new file mode 100644 index 0000000..825832b --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java @@ -0,0 +1,89 @@ +package de.zendric.app.xpensely_Server.security; + +import de.zendric.app.xpensely_server.security.RateLimitFilter; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class RateLimitFilterTest { + + RateLimitFilter filter; + FilterChain chain; + + @BeforeEach + void setUp() { + filter = new RateLimitFilter(); + chain = mock(FilterChain.class); + } + + @Test + void allowsRequestUnderLimit() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("1.2.3.4"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + verify(chain, times(1)).doFilter(request, response); + assertEquals(200, response.getStatus()); + } + + @Test + void blocksRequestOverLimit() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("5.6.7.8"); + + for (int i = 0; i < 60; i++) { + filter.doFilter(request, new MockHttpServletResponse(), chain); + } + + MockHttpServletResponse blockedResponse = new MockHttpServletResponse(); + filter.doFilter(request, blockedResponse, chain); + + assertEquals(429, blockedResponse.getStatus()); + verify(chain, times(60)).doFilter(eq(request), any()); + } + + @Test + void differentIpsBucketedSeparately() throws Exception { + MockHttpServletRequest req1 = new MockHttpServletRequest(); + req1.setRemoteAddr("10.0.0.1"); + MockHttpServletRequest req2 = new MockHttpServletRequest(); + req2.setRemoteAddr("10.0.0.2"); + + for (int i = 0; i < 60; i++) { + filter.doFilter(req1, new MockHttpServletResponse(), chain); + } + + MockHttpServletResponse response2 = new MockHttpServletResponse(); + filter.doFilter(req2, response2, chain); + + assertEquals(200, response2.getStatus()); + } + + @Test + void prefersXForwardedForHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("192.168.1.1"); + request.addHeader("X-Forwarded-For", "203.0.113.5, 10.0.0.1"); + + for (int i = 0; i < 60; i++) { + filter.doFilter(request, new MockHttpServletResponse(), chain); + } + + MockHttpServletResponse blocked = new MockHttpServletResponse(); + filter.doFilter(request, blocked, chain); + assertEquals(429, blocked.getStatus()); + + MockHttpServletRequest directRequest = new MockHttpServletRequest(); + directRequest.setRemoteAddr("192.168.1.1"); + MockHttpServletResponse directResponse = new MockHttpServletResponse(); + filter.doFilter(directRequest, directResponse, chain); + assertEquals(200, directResponse.getStatus()); + } +} From 9c91da9f30228ed3b519792089c0daa99b2cca4f Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 11:23:51 +0200 Subject: [PATCH 21/43] test: fix ExpenseListRepositoryTest with H2 and proper save-then-find pattern Added H2 as a test-scoped dependency so @DataJpaTest has an embedded database. Rewrote the test to save an entity and assert on the returned ID rather than assuming a record exists at ID=1. Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 7 ++- .../ExpenseListRepositoryTest.java | 47 ++++++++++++++----- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/pom.xml b/pom.xml index d3a59cc..83dc34c 100644 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,7 @@ - 17 + 21 @@ -80,6 +80,11 @@ spring-boot-starter-test test + + com.h2database + h2 + test + org.springframework.security spring-security-test diff --git a/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java b/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java index 6a4cb49..23bce57 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java @@ -1,26 +1,47 @@ package de.zendric.app.xpensely_Server; -import java.util.Optional; - +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.repo.ExpenseListRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import de.zendric.app.xpensely_server.model.ExpenseList; -import de.zendric.app.xpensely_server.repo.ExpenseListRepository; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class ExpenseListRepositoryTest { - @Autowired - private ExpenseListRepository expenseListRepository; + @Autowired + private ExpenseListRepository expenseListRepository; - @Test - void testFindExpenseListById() { - // Assuming an ExpenseList with id = 1 exists in your test DB. - Optional optionalExpenseList = expenseListRepository.findById(1L); + @Test + void saveAndFindById_returnsExpenseList() { + ExpenseList list = new ExpenseList(); + list.setName("Groceries"); + ExpenseList saved = expenseListRepository.save(list); - ExpenseList expenseList = optionalExpenseList.get(); - System.out.println("ExpenseList name: " + expenseList.getName()); - } + Optional found = expenseListRepository.findById(saved.getId()); + + assertTrue(found.isPresent()); + assertEquals("Groceries", found.get().getName()); + } + + @Test + void findById_nonExistentId_returnsEmpty() { + Optional found = expenseListRepository.findById(999L); + assertTrue(found.isEmpty()); + } + + @Test + void delete_removesFromRepository() { + ExpenseList list = new ExpenseList(); + list.setName("To Delete"); + ExpenseList saved = expenseListRepository.save(list); + + expenseListRepository.deleteById(saved.getId()); + + assertTrue(expenseListRepository.findById(saved.getId()).isEmpty()); + } } From 68783cc8921f4c3f3b1c9e7856935f4e99cc5bbf Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 15:20:46 +0200 Subject: [PATCH 22/43] fix: throw ResourceNotFoundException instead of returning null, replace full-table-scan list queries with JPQL --- .../Exception/ResourceNotFoundException.java | 11 +++ .../repo/ExpenseListRepository.java | 13 +++- .../services/ExpenseListService.java | 41 +--------- .../xpensely_server/services/UserService.java | 36 +++------ .../services/ExpenseListServiceTest.java | 57 ++++++++++++++ .../services/UserServiceTest.java | 75 +++++++++++++++++++ 6 files changed, 171 insertions(+), 62 deletions(-) create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java diff --git a/src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java b/src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java new file mode 100644 index 0000000..46e0582 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java @@ -0,0 +1,11 @@ +package de.zendric.app.xpensely_server.model.Exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java index c42b820..5a81911 100644 --- a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java +++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java @@ -3,13 +3,24 @@ package de.zendric.app.xpensely_server.repo; import java.util.List; 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; import de.zendric.app.xpensely_server.model.ExpenseList; @Repository public interface ExpenseListRepository extends JpaRepository { + List findByOwnerId(Long ownerId); ExpenseList findByInviteCode(String inviteCode); -} \ No newline at end of file + + @Query("SELECT el FROM ExpenseList el WHERE el.owner.id = :userId OR el.sharedWith.id = :sharedUserId") + List findByOwnerIdOrSharedWithId(@Param("userId") Long userId, + @Param("sharedUserId") Long sharedUserId); + + @Query("SELECT el FROM ExpenseList el WHERE el.owner.username = :username OR el.sharedWith.username = :sharedUsername") + List findByOwnerUsernameOrSharedWithUsername(@Param("username") String username, + @Param("sharedUsername") String sharedUsername); +} diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java index 0ee1be6..3ac2301 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,20 +17,15 @@ import de.zendric.app.xpensely_server.model.XpenselyCustomCategory; import de.zendric.app.xpensely_server.repo.ExpenseListRepository; import de.zendric.app.xpensely_server.repo.ExpenseRepository; import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository; -import jakarta.persistence.EntityManager; @Service @Transactional public class ExpenseListService { - private ExpenseListRepository repository; + private final ExpenseListRepository repository; private final ExpenseRepository expenseRepository; - private XpenselyCustomCategoryRepository customCategoryRepository; + private final XpenselyCustomCategoryRepository customCategoryRepository; - @Autowired - private EntityManager entityManager; - - @Autowired public ExpenseListService(ExpenseListRepository repository, ExpenseRepository expenseRepository, XpenselyCustomCategoryRepository customCategoryRepository) { this.repository = repository; @@ -68,38 +62,11 @@ public class ExpenseListService { } public List findByUserId(Long id) { - List allLists = repository.findAll(); - List userSpecificList = new ArrayList<>(); - for (ExpenseList expenseList : allLists) { - AppUser sharedWith = expenseList.getSharedWith(); - - if (expenseList.getOwner().getId().equals(id)) { - userSpecificList.add(expenseList); - } else { - if (sharedWith != null && sharedWith.getId().equals(id)) { - userSpecificList.add(expenseList); - } - } - } - return userSpecificList; + return repository.findByOwnerIdOrSharedWithId(id, id); } public List findByUsername(String username) { - List allLists = repository.findAll(); - List userSpecificList = new ArrayList<>(); - for (ExpenseList expenseList : allLists) { - AppUser sharedWith = expenseList.getSharedWith(); - - if (expenseList.getOwner().getUsername().equals(username)) { - userSpecificList.add(expenseList); - } else { - if (sharedWith != null && sharedWith.getUsername().equals(username)) { - userSpecificList.add(expenseList); - } - } - } - return userSpecificList; - + return repository.findByOwnerUsernameOrSharedWithUsername(username, username); } public Expense addExpenseToList(Long expenseListId, Expense expense) { diff --git a/src/main/java/de/zendric/app/xpensely_server/services/UserService.java b/src/main/java/de/zendric/app/xpensely_server/services/UserService.java index dc0dee7..a86d665 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/UserService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/UserService.java @@ -1,11 +1,11 @@ package de.zendric.app.xpensely_server.services; import java.util.List; -import java.util.Optional; import org.springframework.stereotype.Service; import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; import de.zendric.app.xpensely_server.repo.UserRepository; @@ -29,36 +29,24 @@ public class UserService { } public AppUser getUser(Long id) { - Optional user = userRepository.findById(id); - if (user.isPresent()) { - return user.get(); - } else - return null; + return userRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id)); } public AppUser deleteUserById(Long id) { - Optional user = userRepository.findById(id); - if (user.isPresent()) { - userRepository.deleteById(id); - return user.get(); - } else - return null; + AppUser user = userRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id)); + userRepository.deleteById(id); + return user; } public AppUser getUserByName(String username) { - Optional optUser = userRepository.findByUsername(username); - if (optUser.isPresent()) { - return optUser.get(); - } else - return null; + return userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + username)); } public AppUser getUserByGoogleId(String id) { - Optional optUser = userRepository.findByGoogleId(id); - if (optUser.isPresent()) { - return optUser.get(); - } else - return null; + return userRepository.findByGoogleId(id) + .orElseThrow(() -> new ResourceNotFoundException("User not found with Google ID: " + id)); } - -} \ No newline at end of file +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java new file mode 100644 index 0000000..ae1fc98 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java @@ -0,0 +1,57 @@ +package de.zendric.app.xpensely_Server.services; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.repo.ExpenseListRepository; +import de.zendric.app.xpensely_server.repo.ExpenseRepository; +import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository; +import de.zendric.app.xpensely_server.services.ExpenseListService; +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.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class ExpenseListServiceTest { + + @Mock ExpenseListRepository repository; + @Mock ExpenseRepository expenseRepository; + @Mock XpenselyCustomCategoryRepository customCategoryRepository; + + @InjectMocks + ExpenseListService service; + + @Test + void findByUserId_usesRepositoryQuery_notFindAll() { + AppUser owner = new AppUser(); owner.setId(1L); + ExpenseList list = new ExpenseList(); list.setId(10L); list.setOwner(owner); + when(repository.findByOwnerIdOrSharedWithId(1L, 1L)).thenReturn(List.of(list)); + + List result = service.findByUserId(1L); + + assertThat(result).hasSize(1); + verify(repository).findByOwnerIdOrSharedWithId(1L, 1L); + verify(repository, never()).findAll(); + } + + @Test + void findByUsername_usesRepositoryQuery_notFindAll() { + AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice"); + ExpenseList list = new ExpenseList(); list.setId(10L); list.setOwner(owner); + when(repository.findByOwnerUsernameOrSharedWithUsername("alice", "alice")).thenReturn(List.of(list)); + + List result = service.findByUsername("alice"); + + assertThat(result).hasSize(1); + verify(repository).findByOwnerUsernameOrSharedWithUsername("alice", "alice"); + verify(repository, never()).findAll(); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java new file mode 100644 index 0000000..9202c92 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java @@ -0,0 +1,75 @@ +package de.zendric.app.xpensely_Server.services; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; +import de.zendric.app.xpensely_server.repo.UserRepository; +import de.zendric.app.xpensely_server.services.UserService; +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.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + UserRepository userRepository; + + @InjectMocks + UserService userService; + + @Test + void getUserByName_throwsResourceNotFound_whenUserMissing() { + when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUserByName("ghost")) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("ghost"); + } + + @Test + void getUserByName_returnsUser_whenFound() { + AppUser user = new AppUser(); + user.setId(1L); + user.setUsername("alice"); + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + + AppUser result = userService.getUserByName("alice"); + + assertThat(result.getUsername()).isEqualTo("alice"); + } + + @Test + void getUser_throwsResourceNotFound_whenIdMissing() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUser(99L)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("99"); + } + + @Test + void deleteUserById_throwsResourceNotFound_whenIdMissing() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.deleteUserById(99L)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("99"); + } + + @Test + void getUserByGoogleId_throwsResourceNotFound_whenMissing() { + when(userRepository.findByGoogleId("gid-404")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUserByGoogleId("gid-404")) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("gid-404"); + } +} From 906b60d264a7761e9e895c024460689e368eeb9e Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 16:40:31 +0200 Subject: [PATCH 23/43] fix: single-param JPQL queries, ResourceNotFoundException throughout ExpenseListService, remove addExpenseToList loop Co-Authored-By: Claude Sonnet 4.6 --- .../repo/ExpenseListRepository.java | 10 ++--- .../services/ExpenseListService.java | 38 +++++-------------- .../services/ExpenseListServiceTest.java | 8 ++-- 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java index 5a81911..6c8ccfb 100644 --- a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java +++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java @@ -16,11 +16,9 @@ public interface ExpenseListRepository extends JpaRepository ExpenseList findByInviteCode(String inviteCode); - @Query("SELECT el FROM ExpenseList el WHERE el.owner.id = :userId OR el.sharedWith.id = :sharedUserId") - List findByOwnerIdOrSharedWithId(@Param("userId") Long userId, - @Param("sharedUserId") Long sharedUserId); + @Query("SELECT el FROM ExpenseList el WHERE el.owner.id = :userId OR el.sharedWith.id = :userId") + List findByOwnerIdOrSharedWithId(@Param("userId") Long userId); - @Query("SELECT el FROM ExpenseList el WHERE el.owner.username = :username OR el.sharedWith.username = :sharedUsername") - List findByOwnerUsernameOrSharedWithUsername(@Param("username") String username, - @Param("sharedUsername") String sharedUsername); + @Query("SELECT el FROM ExpenseList el WHERE el.owner.username = :username OR el.sharedWith.username = :username") + List findByOwnerUsernameOrSharedWithUsername(@Param("username") String username); } diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java index 3ac2301..284e89f 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -1,8 +1,6 @@ package de.zendric.app.xpensely_server.services; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -10,9 +8,9 @@ import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.Expense; import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.XpenselyCustomCategory; import de.zendric.app.xpensely_server.repo.ExpenseListRepository; import de.zendric.app.xpensely_server.repo.ExpenseRepository; @@ -62,40 +60,24 @@ public class ExpenseListService { } public List findByUserId(Long id) { - return repository.findByOwnerIdOrSharedWithId(id, id); + return repository.findByOwnerIdOrSharedWithId(id); } public List findByUsername(String username) { - return repository.findByOwnerUsernameOrSharedWithUsername(username, username); + return repository.findByOwnerUsernameOrSharedWithUsername(username); } public Expense addExpenseToList(Long expenseListId, Expense expense) { - // find expenseList ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new RuntimeException("ExpenseList not found with id: " + expenseListId)); - // get all added expenses - HashSet existingId = new HashSet<>(); - for (Expense e : expenseList.getExpenses()) { - existingId.add(e.getId()); - } - // add the new expense + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); expenseList.addExpense(expense); - // save repository.save(expenseList); - - Expense newExpense = new Expense(); - for (Expense e : expenseList.getExpenses()) { - if (!existingId.contains(e.getId())) { - newExpense = e; - break; - } - } - return newExpense; + return expense; } public void deleteExpenseFromList(Long expenseListId, Long expenseId) { ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new RuntimeException("ExpenseList not found with id: " + expenseListId)); + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); Expense expenseToRemove = null; for (Expense expense : expenseList.getExpenses()) { if (expense.getId().equals(expenseId)) { @@ -106,14 +88,14 @@ public class ExpenseListService { if (expenseToRemove != null) { expenseList.removeExpense(expenseToRemove); } else { - throw new RuntimeException("Expense not found with id: " + expenseId); + throw new ResourceNotFoundException("Expense not found with id: " + expenseId); } repository.save(expenseList); } public String generateInviteCode(Long listId) { ExpenseList list = repository.findById(listId) - .orElseThrow(() -> new RuntimeException("List not found")); + .orElseThrow(() -> new ResourceNotFoundException("List not found")); String inviteCode; if (list.getInviteCode() == null || list.getInviteCodeExpiration().isBefore(LocalDateTime.now())) { @@ -158,7 +140,7 @@ public class ExpenseListService { // TODO implement API for this public XpenselyCustomCategory addCustomCategory(Long expenseListId, XpenselyCustomCategory customCategory) { ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new RuntimeException("Expense List not found")); + .orElseThrow(() -> new ResourceNotFoundException("Expense List not found")); customCategory.setExpenseList(expenseList); return customCategoryRepository.save(customCategory); @@ -167,7 +149,7 @@ public class ExpenseListService { // TODO implement API for this public void deleteCustomCategory(Long expenseListId, Long categoryId) { XpenselyCustomCategory category = customCategoryRepository.findById(categoryId) - .orElseThrow(() -> new RuntimeException("Custom Category not found")); + .orElseThrow(() -> new ResourceNotFoundException("Custom Category not found")); if (!category.getExpenseList().getId().equals(expenseListId)) { throw new RuntimeException("Category does not belong to the specified Expense List"); } diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java index ae1fc98..0f832a5 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java @@ -33,12 +33,12 @@ class ExpenseListServiceTest { void findByUserId_usesRepositoryQuery_notFindAll() { AppUser owner = new AppUser(); owner.setId(1L); ExpenseList list = new ExpenseList(); list.setId(10L); list.setOwner(owner); - when(repository.findByOwnerIdOrSharedWithId(1L, 1L)).thenReturn(List.of(list)); + when(repository.findByOwnerIdOrSharedWithId(1L)).thenReturn(List.of(list)); List result = service.findByUserId(1L); assertThat(result).hasSize(1); - verify(repository).findByOwnerIdOrSharedWithId(1L, 1L); + verify(repository).findByOwnerIdOrSharedWithId(1L); verify(repository, never()).findAll(); } @@ -46,12 +46,12 @@ class ExpenseListServiceTest { void findByUsername_usesRepositoryQuery_notFindAll() { AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice"); ExpenseList list = new ExpenseList(); list.setId(10L); list.setOwner(owner); - when(repository.findByOwnerUsernameOrSharedWithUsername("alice", "alice")).thenReturn(List.of(list)); + when(repository.findByOwnerUsernameOrSharedWithUsername("alice")).thenReturn(List.of(list)); List result = service.findByUsername("alice"); assertThat(result).hasSize(1); - verify(repository).findByOwnerUsernameOrSharedWithUsername("alice", "alice"); + verify(repository).findByOwnerUsernameOrSharedWithUsername("alice"); verify(repository, never()).findAll(); } } From 797d482ebff55f88b13c4f92f59584ad7dcf3379 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 16:55:01 +0200 Subject: [PATCH 24/43] fix: use ResourceNotFoundException for not-found cases in updateExpense, IllegalArgumentException for ownership mismatch in deleteCustomCategory --- .../app/xpensely_server/services/ExpenseListService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java index 284e89f..0f65b6e 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -117,7 +117,7 @@ public class ExpenseListService { public Expense updateExpense(Long expenseListId, Expense updatedExpense) { ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new IllegalArgumentException("ExpenseList not found")); + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); if (!expenseList.getExpenses().stream() .anyMatch(expense -> expense.getId().equals(updatedExpense.getId()))) { @@ -125,7 +125,7 @@ public class ExpenseListService { } Expense existingExpense = expenseRepository.findById(updatedExpense.getId()) - .orElseThrow(() -> new IllegalArgumentException("Expense not found")); + .orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + updatedExpense.getId())); existingExpense.setTitle(updatedExpense.getTitle()); existingExpense.setAmount(updatedExpense.getAmount()); existingExpense.setPersonalUseAmount(updatedExpense.getPersonalUseAmount()); @@ -151,7 +151,7 @@ public class ExpenseListService { XpenselyCustomCategory category = customCategoryRepository.findById(categoryId) .orElseThrow(() -> new ResourceNotFoundException("Custom Category not found")); if (!category.getExpenseList().getId().equals(expenseListId)) { - throw new RuntimeException("Category does not belong to the specified Expense List"); + throw new IllegalArgumentException("Category does not belong to the specified Expense List"); } customCategoryRepository.delete(category); } From 2bd229cc5e1eb7c24964ef7af2d1f65f46912785 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 16:59:35 +0200 Subject: [PATCH 25/43] Remove docs from tracking --- .../plans/2026-05-04-security-hardening.md | 1462 ----------------- .../2026-05-04-security-hardening-design.md | 166 -- 2 files changed, 1628 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-04-security-hardening.md delete mode 100644 docs/superpowers/specs/2026-05-04-security-hardening-design.md diff --git a/docs/superpowers/plans/2026-05-04-security-hardening.md b/docs/superpowers/plans/2026-05-04-security-hardening.md deleted file mode 100644 index e1f13eb..0000000 --- a/docs/superpowers/plans/2026-05-04-security-hardening.md +++ /dev/null @@ -1,1462 +0,0 @@ -# Security Hardening Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add input validation, JWT-based authorization enforcement, and per-user rate limiting to XpenselyServer. - -**Architecture:** Bean Validation annotations on all request models with a `@ControllerAdvice` exception handler for structured 400 responses; an `AuthenticatedUserResolver` component extracts the authenticated `AppUser` from the JWT `sub` claim and guards every endpoint via `assertOwner`/`assertMember` helpers; a `RateLimitFilter` registered inside the Spring Security chain uses Bucket4j in-memory buckets keyed by JWT subject (or IP fallback) to enforce per-endpoint limits. - -**Tech Stack:** Spring Boot 3.4.1, Jakarta Bean Validation (spring-boot-starter-validation), Bucket4j 8.10.1 (bucket4j-core), Spring Security Test (jwt() post processor), JUnit 5, Mockito - ---- - -> **Before starting:** Check out the feature branch. -> ``` -> git checkout feature/security-hardening -> ``` - -> **Client compatibility note:** `GET /api/expenselist/byUser` and `GET /api/expenselist/byUsername` are removed; the Flutter client must be updated to call `GET /api/expenselist/mine` instead. This is out of scope for this plan — coordinate with the client update separately. - ---- - -## File Map - -| File | Action | Responsibility | -|---|---|---| -| `pom.xml` | Modify | Add validation + Bucket4j dependencies | -| `model/AppUserCreateRequest.java` | Modify | Add Bean Validation annotations | -| `model/ExpenseInput.java` | Modify | Add validation; remove stray JPA annotations | -| `model/ExpenseChangeRequest.java` | Modify | Add Bean Validation annotations | -| `model/InviteRequest.java` | Modify | Add validation; remove `userId` field | -| `controller/GlobalExceptionHandler.java` | Create | Structured 400/403 error responses | -| `controller/AppUserController.java` | Modify | Add `@Valid`, ownership guards, JWT user resolution | -| `controller/ExpenseListController.java` | Modify | Add `@Valid`, ownership guards, rename/remove endpoints | -| `security/AuthenticatedUserResolver.java` | Create | Resolve JWT sub claim to `AppUser` entity | -| `security/RateLimitFilter.java` | Create | Per-user/IP Bucket4j rate limiting | -| `test/.../controller/AppUserControllerTest.java` | Create | Validation + authorization tests for user controller | -| `test/.../controller/ExpenseListControllerTest.java` | Create | Validation + authorization tests for expense list controller | -| `test/.../security/AuthenticatedUserResolverTest.java` | Create | Unit tests for JWT resolution | -| `test/.../security/RateLimitFilterTest.java` | Create | Unit tests for rate limiting | - ---- - -## Task 1: Add Maven Dependencies - -**Files:** -- Modify: `pom.xml` - -- [ ] **Step 1: Add dependencies** - -In `pom.xml`, inside ``, add after the existing `spring-boot-starter-security` dependency: - -```xml - - org.springframework.boot - spring-boot-starter-validation - - - com.bucket4j - bucket4j-core - 8.10.1 - -``` - -- [ ] **Step 2: Verify build compiles** - -Run: `mvn compile -q` - -Expected: `BUILD SUCCESS` with no errors. - -- [ ] **Step 3: Commit** - -``` -git add pom.xml -git commit -m "build: add spring-boot-starter-validation and bucket4j-core" -``` - ---- - -## Task 2: Annotate Request Models - -**Files:** -- Modify: `src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java` -- Modify: `src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java` -- Modify: `src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java` -- Modify: `src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java` - -- [ ] **Step 1: Update AppUserCreateRequest.java** - -Replace the entire file content with: - -```java -package de.zendric.app.xpensely_server.model; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import lombok.Data; - -@Data -public class AppUserCreateRequest { - - @NotBlank(message = "Username is required") - @Size(min = 3, max = 30, message = "Username must be between 3 and 30 characters") - @Pattern(regexp = "^[a-zA-Z0-9_.\\-]+$", message = "Username may only contain letters, digits, underscores, dots, and hyphens") - private String username; - - @NotBlank(message = "Google ID is required") - private String googleId; - - public AppUser convertToAppUser() { - AppUser appUser = new AppUser(); - appUser.setGoogleId(googleId); - appUser.setUsername(username); - return appUser; - } -} -``` - -- [ ] **Step 2: Update ExpenseInput.java** - -Replace the entire file content with (removes stray `@Id`/`@GeneratedValue`, adds validation): - -```java -package de.zendric.app.xpensely_server.model; - -import java.time.LocalDate; - -import jakarta.validation.constraints.DecimalMin; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Getter -@Setter -@AllArgsConstructor -@NoArgsConstructor -public class ExpenseInput { - - private Long id; - - @NotBlank(message = "Title is required") - @Size(max = 100, message = "Title must not exceed 100 characters") - private String title; - - @NotBlank(message = "Owner is required") - private String owner; - - @NotNull(message = "Amount is required") - @DecimalMin(value = "0.01", message = "Amount must be greater than zero") - private Double amount; - - private Double personalUseAmount; - private Double otherPersonAmount; - - @NotNull(message = "Date is required") - private LocalDate date; - - @NotBlank(message = "Category is required") - private String category; - - private ExpenseList expenseList; - - public Expense convertToExpense(Long userId) { - AppUser appUser = new AppUser(); - appUser.setId(userId); - appUser.setUsername(owner); - - Expense expense = new Expense(); - expense.setAmount(amount); - expense.setDate(date); - expense.setPersonalUseAmount(personalUseAmount); - expense.setOtherPersonAmount(otherPersonAmount); - expense.setExpenseList(expenseList); - expense.setId(id); - expense.setOwner(appUser); - expense.setTitle(title); - expense.setCategory(category); - - return expense; - } -} -``` - -- [ ] **Step 3: Update ExpenseChangeRequest.java** - -Replace the entire file content with: - -```java -package de.zendric.app.xpensely_server.model; - -import java.time.LocalDate; - -import jakarta.validation.constraints.DecimalMin; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class ExpenseChangeRequest { - - private Long id; - - @NotBlank(message = "Title is required") - @Size(max = 100, message = "Title must not exceed 100 characters") - private String title; - - @NotBlank(message = "Owner name is required") - private String ownerName; - - @NotNull(message = "Amount is required") - @DecimalMin(value = "0.01", message = "Amount must be greater than zero") - private Double amount; - - private Double personalUseAmount; - private Double otherPersonAmount; - - @NotNull(message = "Date is required") - private LocalDate date; - - @NotBlank(message = "Category is required") - private String category; - - public Expense convertToExpense(Long userId, ExpenseList expenseList) { - AppUser appUser = new AppUser(); - appUser.setId(userId); - appUser.setUsername(ownerName); - - Expense expense = new Expense(); - expense.setAmount(amount); - expense.setDate(date); - expense.setPersonalUseAmount(personalUseAmount); - expense.setOtherPersonAmount(otherPersonAmount); - expense.setExpenseList(expenseList); - expense.setId(id); - expense.setOwner(appUser); - expense.setTitle(title); - expense.setCategory(category); - - return expense; - } -} -``` - -- [ ] **Step 4: Update InviteRequest.java** - -Replace the entire file content with (`userId` removed — will be derived from JWT): - -```java -package de.zendric.app.xpensely_server.model; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class InviteRequest { - - @NotBlank(message = "Invite code is required") - @Size(min = 6, max = 6, message = "Invite code must be exactly 6 characters") - private String inviteCode; -} -``` - -- [ ] **Step 5: Verify build compiles** - -Run: `mvn compile -q` - -Expected: `BUILD SUCCESS` - -- [ ] **Step 6: Commit** - -``` -git add src/main/java/de/zendric/app/xpensely_server/model/ -git commit -m "feat: add Bean Validation annotations to request models" -``` - ---- - -## Task 3: Add GlobalExceptionHandler - -**Files:** -- Create: `src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java` -- Create: `src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java` - -- [ ] **Step 1: Write the failing test** - -Create `src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java`: - -```java -package de.zendric.app.xpensely_server.controller; - -import de.zendric.app.xpensely_server.services.UserService; -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.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(AppUserController.class) -@ActiveProfiles("test") -class AppUserControllerTest { - - @Autowired MockMvc mockMvc; - @MockBean UserService userService; - - @Test - void createUser_blankUsername_returns400WithFieldError() throws Exception { - mockMvc.perform(post("/api/users/createUser") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"\",\"googleId\":\"gid123\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.username").exists()); - } - - @Test - void createUser_invalidUsernamePattern_returns400() throws Exception { - mockMvc.perform(post("/api/users/createUser") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"hello world!\",\"googleId\":\"gid123\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.username").exists()); - } - - @Test - void createUser_usernameTooShort_returns400() throws Exception { - mockMvc.perform(post("/api/users/createUser") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"ab\",\"googleId\":\"gid123\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.username").exists()); - } - - @Test - void createUser_blankGoogleId_returns400() throws Exception { - mockMvc.perform(post("/api/users/createUser") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"validuser\",\"googleId\":\"\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.googleId").exists()); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` - -Expected: FAIL — validation is not wired up yet; the controller returns 417 or 500 instead of 400. - -- [ ] **Step 3: Create GlobalExceptionHandler** - -Create `src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java`: - -```java -package de.zendric.app.xpensely_server.controller; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.util.HashMap; -import java.util.Map; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException ex) { - Map errors = new HashMap<>(); - for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { - errors.put(fieldError.getField(), fieldError.getDefaultMessage()); - } - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors); - } - - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", ex.getMessage())); - } -} -``` - -- [ ] **Step 4: Add @Valid to AppUserController.createUser** - -In `src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java`, add `import jakarta.validation.Valid;` and update the `createUser` method signature: - -```java -import jakarta.validation.Valid; - -// Change: -public ResponseEntity createUser(@RequestBody AppUserCreateRequest userRequest) { -// To: -public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` - -Expected: `Tests run: 4, Failures: 0, Errors: 0` - -- [ ] **Step 6: Commit** - -``` -git add src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java -git add src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java -git add src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java -git commit -m "feat: add GlobalExceptionHandler and @Valid to user creation endpoint" -``` - ---- - -## Task 4: Add @Valid to ExpenseList Endpoints - -**Files:** -- Modify: `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java` -- Create: `src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java` - -- [ ] **Step 1: Write the failing tests** - -Create `src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java`: - -```java -package de.zendric.app.xpensely_server.controller; - -import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; -import de.zendric.app.xpensely_server.services.CategoryService; -import de.zendric.app.xpensely_server.services.ExpenseListService; -import de.zendric.app.xpensely_server.services.UserService; -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.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(ExpenseListController.class) -@ActiveProfiles("test") -class ExpenseListControllerTest { - - @Autowired MockMvc mockMvc; - @MockBean ExpenseListService expenseListService; - @MockBean UserService userService; - @MockBean CategoryService categoryService; - @MockBean AuthenticatedUserResolver authenticatedUserResolver; - - @Test - void addExpense_blankTitle_returns400() throws Exception { - mockMvc.perform(post("/api/expenselist/1/add") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"\",\"owner\":\"alice\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.title").exists()); - } - - @Test - void addExpense_negativeAmount_returns400() throws Exception { - mockMvc.perform(post("/api/expenselist/1/add") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":-5.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.amount").exists()); - } - - @Test - void addExpense_nullDate_returns400() throws Exception { - mockMvc.perform(post("/api/expenselist/1/add") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":10.0,\"category\":\"Food\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.date").exists()); - } - - @Test - void acceptInvite_blankCode_returns400() throws Exception { - mockMvc.perform(post("/api/expenselist/accept-invite") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"inviteCode\":\"\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.inviteCode").exists()); - } - - @Test - void acceptInvite_wrongCodeLength_returns400() throws Exception { - mockMvc.perform(post("/api/expenselist/accept-invite") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"inviteCode\":\"ABC\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.inviteCode").exists()); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` - -Expected: FAIL — `@Valid` not yet added and `AuthenticatedUserResolver` bean does not exist yet. - -- [ ] **Step 3: Create stub AuthenticatedUserResolver** - -Create `src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java` (full implementation comes in Task 5; create the stub now so the app compiles): - -```java -package de.zendric.app.xpensely_server.security; - -import de.zendric.app.xpensely_server.model.AppUser; -import de.zendric.app.xpensely_server.services.UserService; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.stereotype.Component; -import org.springframework.web.server.ResponseStatusException; - -@Component -public class AuthenticatedUserResolver { - - private final UserService userService; - - public AuthenticatedUserResolver(UserService userService) { - this.userService = userService; - } - - public AppUser resolveCurrentUser(Authentication authentication) { - if (authentication == null) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not authenticated"); - } - Jwt jwt = (Jwt) authentication.getPrincipal(); - String googleId = jwt.getSubject(); - try { - AppUser user = userService.getUserByGoogleId(googleId); - if (user == null) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); - } - return user; - } catch (IllegalArgumentException e) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); - } - } -} -``` - -- [ ] **Step 4: Add @Valid to ExpenseListController endpoints** - -In `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java`, add `import jakarta.validation.Valid;` and update the three `@RequestBody` parameters: - -```java -import jakarta.validation.Valid; - -// Change addExpenseToList signature: -public ResponseEntity addExpenseToList( - @PathVariable("id") Long expenseListId, - @RequestBody @Valid ExpenseInput expenseInput) { - -// Change updateExpenseInList signature: -public ResponseEntity updateExpenseInList( - @PathVariable("id") Long expenseListId, - @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest) { - -// Change acceptInvite signature: -public ResponseEntity acceptInvite(@RequestBody @Valid InviteRequest inviteRequest) { -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` - -Expected: `Tests run: 5, Failures: 0, Errors: 0` - -- [ ] **Step 6: Run all tests** - -Run: `mvn test -q` - -Expected: All existing tests still pass. - -- [ ] **Step 7: Commit** - -``` -git add src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java -git add src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java -git add src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java -git commit -m "feat: add @Valid to expense list endpoints and stub AuthenticatedUserResolver" -``` - ---- - -## Task 5: Test and Complete AuthenticatedUserResolver - -**Files:** -- Modify: `src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java` (already complete from Task 4) -- Create: `src/test/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolverTest.java` - -- [ ] **Step 1: Write the failing tests** - -Create `src/test/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolverTest.java`: - -```java -package de.zendric.app.xpensely_server.security; - -import de.zendric.app.xpensely_server.model.AppUser; -import de.zendric.app.xpensely_server.services.UserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.web.server.ResponseStatusException; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class AuthenticatedUserResolverTest { - - UserService userService; - AuthenticatedUserResolver resolver; - - @BeforeEach - void setUp() { - userService = mock(UserService.class); - resolver = new AuthenticatedUserResolver(userService); - } - - @Test - void resolveCurrentUser_validJwt_returnsAppUser() { - Jwt jwt = Jwt.withTokenValue("token") - .header("alg", "RS256") - .subject("google-id-123") - .build(); - JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); - - AppUser user = new AppUser(); - user.setId(1L); - user.setGoogleId("google-id-123"); - when(userService.getUserByGoogleId("google-id-123")).thenReturn(user); - - AppUser result = resolver.resolveCurrentUser(auth); - assertEquals(user, result); - } - - @Test - void resolveCurrentUser_userNotFound_throws403() { - Jwt jwt = Jwt.withTokenValue("token") - .header("alg", "RS256") - .subject("unknown-id") - .build(); - JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); - when(userService.getUserByGoogleId("unknown-id")).thenReturn(null); - - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> resolver.resolveCurrentUser(auth)); - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - } - - @Test - void resolveCurrentUser_userServiceThrows_throws403() { - Jwt jwt = Jwt.withTokenValue("token") - .header("alg", "RS256") - .subject("gone-id") - .build(); - JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); - when(userService.getUserByGoogleId("gone-id")).thenThrow(new IllegalArgumentException("not found")); - - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> resolver.resolveCurrentUser(auth)); - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - } - - @Test - void resolveCurrentUser_nullAuthentication_throws403() { - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> resolver.resolveCurrentUser(null)); - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - } -} -``` - -- [ ] **Step 2: Run tests to verify they pass** - -Run: `mvn test -Dtest=AuthenticatedUserResolverTest -pl . -q` - -Expected: `Tests run: 4, Failures: 0, Errors: 0` - -(The implementation was already written in Task 4. If tests fail, revisit the resolver logic.) - -- [ ] **Step 3: Commit** - -``` -git add src/test/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolverTest.java -git commit -m "test: add unit tests for AuthenticatedUserResolver" -``` - ---- - -## Task 6: Add Authorization to ExpenseListController - -**Files:** -- Modify: `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java` -- Modify: `src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java` - -- [ ] **Step 1: Write failing authorization tests** - -Add these tests to the existing `ExpenseListControllerTest.java` (append inside the class body, before the closing brace): - -```java - // --- Authorization tests --- - // These run with the test profile (security disabled) but mock the resolver - // to simulate different users, testing the ownership guard logic directly. - - @Test - void getById_authenticatedUserNotMember_returns403() throws Exception { - de.zendric.app.xpensely_server.model.AppUser owner = new de.zendric.app.xpensely_server.model.AppUser(); - owner.setId(1L); - de.zendric.app.xpensely_server.model.AppUser requester = new de.zendric.app.xpensely_server.model.AppUser(); - requester.setId(2L); - - de.zendric.app.xpensely_server.model.ExpenseList list = new de.zendric.app.xpensely_server.model.ExpenseList(); - list.setId(1L); - list.setOwner(owner); - - org.mockito.Mockito.when(expenseListService.findById(1L)).thenReturn(java.util.Optional.of(list)); - org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) - .thenReturn(requester); - - mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) - .andExpect(status().isForbidden()); - } - - @Test - void getById_authenticatedUserIsOwner_returns200() throws Exception { - de.zendric.app.xpensely_server.model.AppUser owner = new de.zendric.app.xpensely_server.model.AppUser(); - owner.setId(1L); - - de.zendric.app.xpensely_server.model.ExpenseList list = new de.zendric.app.xpensely_server.model.ExpenseList(); - list.setId(1L); - list.setOwner(owner); - - org.mockito.Mockito.when(expenseListService.findById(1L)).thenReturn(java.util.Optional.of(list)); - org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) - .thenReturn(owner); - - mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) - .andExpect(status().isOk()); - } - - @Test - void deleteList_nonOwner_returns403() throws Exception { - de.zendric.app.xpensely_server.model.AppUser owner = new de.zendric.app.xpensely_server.model.AppUser(); - owner.setId(1L); - de.zendric.app.xpensely_server.model.AppUser nonOwner = new de.zendric.app.xpensely_server.model.AppUser(); - nonOwner.setId(2L); - - de.zendric.app.xpensely_server.model.ExpenseList list = new de.zendric.app.xpensely_server.model.ExpenseList(); - list.setId(5L); - list.setOwner(owner); - - org.mockito.Mockito.when(expenseListService.findById(5L)).thenReturn(java.util.Optional.of(list)); - org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) - .thenReturn(nonOwner); - - mockMvc.perform(delete("/api/expenselist/5")) - .andExpect(status().isForbidden()); - } - - @Test - void getMine_returnsCurrentUserLists() throws Exception { - de.zendric.app.xpensely_server.model.AppUser user = new de.zendric.app.xpensely_server.model.AppUser(); - user.setId(3L); - - org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) - .thenReturn(user); - org.mockito.Mockito.when(expenseListService.findByUserId(3L)) - .thenReturn(java.util.List.of(new de.zendric.app.xpensely_server.model.ExpenseList())); - - mockMvc.perform(get("/api/expenselist/mine")) - .andExpect(status().isOk()); - } -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` - -Expected: FAIL — `getMine` endpoint doesn't exist, authorization guards not yet implemented. - -- [ ] **Step 3: Rewrite ExpenseListController with authorization** - -Replace the entire content of `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java`: - -```java -package de.zendric.app.xpensely_server.controller; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; - -import de.zendric.app.xpensely_server.model.*; -import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; -import de.zendric.app.xpensely_server.services.CategoryService; -import de.zendric.app.xpensely_server.services.ExpenseListService; -import de.zendric.app.xpensely_server.services.UserService; - -@RestController -@RequestMapping("/api/expenselist") -class ExpenseListController { - - private final ExpenseListService expenseListService; - private final UserService userService; - private final CategoryService categoryService; - private final AuthenticatedUserResolver authenticatedUserResolver; - - public ExpenseListController(ExpenseListService expenseListService, UserService userService, - CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) { - this.expenseListService = expenseListService; - this.userService = userService; - this.categoryService = categoryService; - this.authenticatedUserResolver = authenticatedUserResolver; - } - - @GetMapping("/mine") - public ResponseEntity> getMine(Authentication authentication) { - AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); - List items = expenseListService.findByUserId(user.getId()); - if (items.isEmpty()) - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - return new ResponseEntity<>(items, HttpStatus.OK); - } - - @GetMapping("/byId") - public ResponseEntity getById(@RequestParam Long id, Authentication authentication) { - AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); - Optional existingItemOptional = expenseListService.findById(id); - if (existingItemOptional.isEmpty()) - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - assertMember(user, existingItemOptional.get()); - return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK); - } - - @PostMapping("/create") - public ResponseEntity create(@RequestBody ExpenseList expenseList, - Authentication authentication) { - try { - AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); - expenseList.setOwner(authenticatedUser); - XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); - expenseList.setXpenselyStandardCategories(standardCategories); - expenseList.setSharedWith(null); - ExpenseList savedItem = expenseListService.createList(expenseList); - return new ResponseEntity<>(savedItem, HttpStatus.CREATED); - } catch (ResponseStatusException e) { - throw e; - } catch (Exception e) { - e.printStackTrace(); - return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); - } - } - - @DeleteMapping("{id}") - public ResponseEntity delete(@PathVariable("id") Long id, Authentication authentication) { - AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); - Optional listOpt = expenseListService.findById(id); - if (listOpt.isEmpty()) - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - assertOwner(user, listOpt.get()); - try { - expenseListService.deleteById(id); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } catch (Exception e) { - return new ResponseEntity<>(HttpStatus.EXPECTATION_FAILED); - } - } - - @PostMapping("/{id}/add") - public ResponseEntity addExpenseToList( - @PathVariable("id") Long expenseListId, - @RequestBody @Valid ExpenseInput expenseInput, - Authentication authentication) { - AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); - Optional listOpt = expenseListService.findById(expenseListId); - if (listOpt.isEmpty()) - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - assertMember(user, listOpt.get()); - try { - AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); - Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); - Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); - return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @PutMapping("/{id}/update") - public ResponseEntity updateExpenseInList( - @PathVariable("id") Long expenseListId, - @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest, - Authentication authentication) { - AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); - Optional expenseListOpt = expenseListService.findById(expenseListId); - if (expenseListOpt.isEmpty()) - return new ResponseEntity<>(null, HttpStatus.NOT_FOUND); - assertMember(user, expenseListOpt.get()); - try { - AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); - Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); - Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); - return new ResponseEntity<>(updatedExpense, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - } - } - - @DeleteMapping("/{id}/delete") - public ResponseEntity deleteExpenseFromList( - @PathVariable("id") Long expenseListId, - @RequestParam("expenseId") Long expenseId, - Authentication authentication) { - AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); - Optional listOpt = expenseListService.findById(expenseListId); - if (listOpt.isEmpty()) - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - assertMember(user, listOpt.get()); - try { - expenseListService.deleteExpenseFromList(expenseListId, expenseId); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); - } - } - - @PostMapping("/{listId}/invite") - public ResponseEntity generateInvite(@PathVariable Long listId, Authentication authentication) { - AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); - Optional listOpt = expenseListService.findById(listId); - if (listOpt.isEmpty()) - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - assertOwner(user, listOpt.get()); - String inviteCode = expenseListService.generateInviteCode(listId); - return ResponseEntity.ok(inviteCode); - } - - @PostMapping("/accept-invite") - public ResponseEntity acceptInvite(@RequestBody @Valid InviteRequest inviteRequest, - Authentication authentication) { - AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); - ExpenseList list = expenseListService.findByInviteCode(inviteRequest.getInviteCode()); - - if (list == null || list.getInviteCodeExpiration() == null || - list.getInviteCodeExpiration().isBefore(LocalDateTime.now())) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Invalid or expired invite code"); - } - if (list.getSharedWith() != null) { - return ResponseEntity.status(HttpStatus.IM_USED).body("List has already been shared"); - } - if (list.getOwner().getId().equals(authenticatedUser.getId())) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list"); - } - list.setSharedWith(authenticatedUser); - expenseListService.save(list); - return ResponseEntity.ok("User added to the list"); - } - - // --- Authorization helpers --- - - private void assertOwner(AppUser authenticated, ExpenseList list) { - if (!list.getOwner().getId().equals(authenticated.getId())) - throw new ResponseStatusException(HttpStatus.FORBIDDEN); - } - - private void assertMember(AppUser authenticated, ExpenseList list) { - boolean isOwner = list.getOwner().getId().equals(authenticated.getId()); - boolean isShared = list.getSharedWith() != null - && list.getSharedWith().getId().equals(authenticated.getId()); - if (!isOwner && !isShared) - throw new ResponseStatusException(HttpStatus.FORBIDDEN); - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` - -Expected: `Tests run: 9, Failures: 0, Errors: 0` - -- [ ] **Step 5: Run all tests** - -Run: `mvn test -q` - -Expected: All tests pass. - -- [ ] **Step 6: Commit** - -``` -git add src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java -git add src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java -git commit -m "feat: add ownership guards and JWT-based authorization to expense list endpoints" -``` - ---- - -## Task 7: Add Authorization to AppUserController - -**Files:** -- Modify: `src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java` -- Modify: `src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java` - -- [ ] **Step 1: Write failing authorization tests** - -Add these tests inside `AppUserControllerTest.java` (before the closing brace). Add `@MockBean AuthenticatedUserResolver authenticatedUserResolver;` to the class fields first: - -```java - @MockBean de.zendric.app.xpensely_server.security.AuthenticatedUserResolver authenticatedUserResolver; - - @Test - void getUser_differentUser_returns403() throws Exception { - de.zendric.app.xpensely_server.model.AppUser self = new de.zendric.app.xpensely_server.model.AppUser(); - self.setId(1L); - org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) - .thenReturn(self); - - mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .get("/api/users").param("id", "99")) - .andExpect(status().isForbidden()); - } - - @Test - void getUser_sameUser_returns200() throws Exception { - de.zendric.app.xpensely_server.model.AppUser self = new de.zendric.app.xpensely_server.model.AppUser(); - self.setId(1L); - org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) - .thenReturn(self); - org.mockito.Mockito.when(userService.getUser(1L)).thenReturn(self); - - mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .get("/api/users").param("id", "1")) - .andExpect(status().isOk()); - } - - @Test - void deleteUser_differentUser_returns403() throws Exception { - de.zendric.app.xpensely_server.model.AppUser self = new de.zendric.app.xpensely_server.model.AppUser(); - self.setId(1L); - org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) - .thenReturn(self); - - mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders - .delete("/api/users").param("id", "99")) - .andExpect(status().isForbidden()); - } -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` - -Expected: FAIL — authorization guards not yet added. - -- [ ] **Step 3: Rewrite AppUserController with authorization** - -Replace the entire content of `src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java`: - -```java -package de.zendric.app.xpensely_server.controller; - -import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; - -import de.zendric.app.xpensely_server.model.AppUser; -import de.zendric.app.xpensely_server.model.AppUserCreateRequest; -import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; -import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; -import de.zendric.app.xpensely_server.services.UserService; - -@RestController -@RequestMapping("/api/users") -public class AppUserController { - - private final UserService userService; - private final AuthenticatedUserResolver authenticatedUserResolver; - - public AppUserController(UserService userService, AuthenticatedUserResolver authenticatedUserResolver) { - this.userService = userService; - this.authenticatedUserResolver = authenticatedUserResolver; - } - - @GetMapping - public ResponseEntity getUser(@RequestParam Long id, Authentication authentication) { - AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); - assertSelf(self, id); - return ResponseEntity.ok(userService.getUser(id)); - } - - @GetMapping("/byName") - public AppUser getUserByName(@RequestParam String username) { - return userService.getUserByName(username); - } - - @GetMapping("/byGoogleId") - public ResponseEntity getUserByGoogleId(@RequestParam String id, Authentication authentication) { - AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); - if (!self.getGoogleId().equals(id)) - throw new ResponseStatusException(HttpStatus.FORBIDDEN); - return ResponseEntity.ok(self); - } - - @PostMapping("/createUser") - public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { - try { - AppUser convertedUser = userRequest.convertToAppUser(); - AppUser nUser = userService.createUser(convertedUser); - return new ResponseEntity<>(nUser, HttpStatus.CREATED); - } catch (UsernameAlreadyExistsException e) { - return new ResponseEntity<>(null, HttpStatus.CONFLICT); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - } - } - - @DeleteMapping - public ResponseEntity deleteUser(@RequestParam Long id, Authentication authentication) { - AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); - assertSelf(self, id); - AppUser user = userService.deleteUserById(id); - return ResponseEntity.ok("User deleted: " + user.getUsername()); - } - - private void assertSelf(AppUser authenticated, Long requestedId) { - if (!authenticated.getId().equals(requestedId)) - throw new ResponseStatusException(HttpStatus.FORBIDDEN); - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` - -Expected: `Tests run: 7, Failures: 0, Errors: 0` - -- [ ] **Step 5: Run all tests** - -Run: `mvn test -q` - -Expected: All tests pass. - -- [ ] **Step 6: Commit** - -``` -git add src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java -git add src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java -git commit -m "feat: add ownership guards to user endpoints" -``` - ---- - -## Task 8: Add RateLimitFilter - -**Files:** -- Create: `src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java` -- Modify: `src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java` -- Create: `src/test/java/de/zendric/app/xpensely_server/security/RateLimitFilterTest.java` - -- [ ] **Step 1: Write the failing tests** - -Create `src/test/java/de/zendric/app/xpensely_server/security/RateLimitFilterTest.java`: - -```java -package de.zendric.app.xpensely_server.security; - -import jakarta.servlet.FilterChain; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class RateLimitFilterTest { - - RateLimitFilter filter; - - @BeforeEach - void setUp() { - filter = new RateLimitFilter(); - } - - @Test - void generalEndpoint_underLimit_allowsRequest() throws Exception { - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/expenselist/mine"); - request.setRemoteAddr("10.0.0.1"); - MockHttpServletResponse response = new MockHttpServletResponse(); - FilterChain chain = mock(FilterChain.class); - - filter.doFilter(request, response, chain); - - verify(chain).doFilter(request, response); - assertNotEquals(429, response.getStatus()); - } - - @Test - void createUser_exceedsLimit_returns429() throws Exception { - FilterChain chain = mock(FilterChain.class); - - // Exhaust the 3-request limit - for (int i = 0; i < 3; i++) { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); - req.setRemoteAddr("10.0.0.2"); - MockHttpServletResponse res = new MockHttpServletResponse(); - filter.doFilter(req, res, chain); - assertNotEquals(429, res.getStatus(), "Request " + (i + 1) + " should be allowed"); - } - - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); - req.setRemoteAddr("10.0.0.2"); - MockHttpServletResponse res = new MockHttpServletResponse(); - filter.doFilter(req, res, chain); - - assertEquals(429, res.getStatus()); - assertNotNull(res.getHeader("Retry-After")); - } - - @Test - void createUser_differentIps_separateBuckets() throws Exception { - FilterChain chain = mock(FilterChain.class); - - // Exhaust limit for IP A - for (int i = 0; i < 3; i++) { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); - req.setRemoteAddr("10.0.0.3"); - filter.doFilter(req, new MockHttpServletResponse(), chain); - } - - // IP B should still be allowed - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); - req.setRemoteAddr("10.0.0.4"); - MockHttpServletResponse res = new MockHttpServletResponse(); - filter.doFilter(req, res, chain); - - assertNotEquals(429, res.getStatus()); - } - - @Test - void exceedLimit_responseHasRetryAfterHeader() throws Exception { - FilterChain chain = mock(FilterChain.class); - for (int i = 0; i < 3; i++) { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); - req.setRemoteAddr("10.0.0.5"); - filter.doFilter(req, new MockHttpServletResponse(), chain); - } - - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); - req.setRemoteAddr("10.0.0.5"); - MockHttpServletResponse res = new MockHttpServletResponse(); - filter.doFilter(req, res, chain); - - assertNotNull(res.getHeader("Retry-After")); - assertTrue(Integer.parseInt(res.getHeader("Retry-After")) > 0); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mvn test -Dtest=RateLimitFilterTest -pl . -q` - -Expected: FAIL — `RateLimitFilter` does not exist yet. - -- [ ] **Step 3: Create RateLimitFilter** - -Create `src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java`: - -```java -package de.zendric.app.xpensely_server.security; - -import io.github.bucket4j.Bandwidth; -import io.github.bucket4j.Bucket; -import io.github.bucket4j.Refill; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; - -public class RateLimitFilter extends OncePerRequestFilter { - - private final Map generalBuckets = new ConcurrentHashMap<>(); - private final Map inviteBuckets = new ConcurrentHashMap<>(); - private final Map acceptInviteBuckets = new ConcurrentHashMap<>(); - private final Map createUserBuckets = new ConcurrentHashMap<>(); - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain chain) throws ServletException, IOException { - String key = resolveKey(request); - String uri = request.getRequestURI(); - - Bucket bucket = selectBucket(key, uri); - var probe = bucket.tryConsumeAndReturnRemaining(1); - - if (!probe.isConsumed()) { - long retryAfterSeconds = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()); - response.setStatus(429); - response.setHeader("Retry-After", String.valueOf(Math.max(1, retryAfterSeconds))); - response.setContentType("application/json"); - response.getWriter().write("{\"error\":\"Too many requests\"}"); - return; - } - - chain.doFilter(request, response); - } - - private String resolveKey(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof Jwt jwt) { - return "user:" + jwt.getSubject(); - } - return "ip:" + request.getRemoteAddr(); - } - - private Bucket selectBucket(String key, String uri) { - if (uri.matches(".*/[0-9]+/invite$")) { - return inviteBuckets.computeIfAbsent(key, k -> createBucket(5, Duration.ofMinutes(1))); - } - if (uri.endsWith("/accept-invite")) { - return acceptInviteBuckets.computeIfAbsent(key, k -> createBucket(10, Duration.ofMinutes(1))); - } - if (uri.endsWith("/createUser")) { - return createUserBuckets.computeIfAbsent(key, k -> createBucket(3, Duration.ofMinutes(1))); - } - return generalBuckets.computeIfAbsent(key, k -> createBucket(60, Duration.ofMinutes(1))); - } - - private Bucket createBucket(int capacity, Duration refillPeriod) { - Bandwidth limit = Bandwidth.classic(capacity, Refill.intervally(capacity, refillPeriod)); - return Bucket.builder().addLimit(limit).build(); - } -} -``` - -- [ ] **Step 4: Register RateLimitFilter in SecurityConfig** - -Replace the entire content of `src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java`: - -```java -package de.zendric.app.xpensely_server.security; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - @Bean - @Profile("test") - public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) - .csrf(csrf -> csrf.disable()); - return http.build(); - } - - @Bean - @Profile("!test") - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) - .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) - .oauth2Login(Customizer.withDefaults()) - .csrf(csrf -> csrf.disable()) - .addFilterAfter(new RateLimitFilter(), BearerTokenAuthenticationFilter.class); - return http.build(); - } -} -``` - -- [ ] **Step 5: Run RateLimitFilter tests** - -Run: `mvn test -Dtest=RateLimitFilterTest -pl . -q` - -Expected: `Tests run: 4, Failures: 0, Errors: 0` - -- [ ] **Step 6: Run all tests** - -Run: `mvn test -q` - -Expected: All tests pass, `BUILD SUCCESS`. - -- [ ] **Step 7: Commit** - -``` -git add src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java -git add src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java -git add src/test/java/de/zendric/app/xpensely_server/security/RateLimitFilterTest.java -git commit -m "feat: add RateLimitFilter with per-user Bucket4j rate limiting" -``` - ---- - -## Done - -All security hardening is complete. Summary of what was implemented: - -| Area | What changed | -|---|---| -| Input validation | Bean Validation on all request models; `@Valid` on all `@RequestBody`; `GlobalExceptionHandler` returns structured 400s | -| Authorization | `AuthenticatedUserResolver` resolves JWT sub to `AppUser`; all endpoints enforce ownership/membership; dangerous endpoints (`/all`, `/byUser`, `/byUsername`) removed or replaced with `/mine` | -| Rate limiting | `RateLimitFilter` inside security chain; per-user buckets (JWT sub) with IP fallback; 60 req/min general, 5/min invite generation, 10/min invite acceptance, 3/min account creation | -| Client compat | Flutter client must be updated: replace calls to `/byUser?userId=X` with `GET /api/expenselist/mine` (no param needed) | diff --git a/docs/superpowers/specs/2026-05-04-security-hardening-design.md b/docs/superpowers/specs/2026-05-04-security-hardening-design.md deleted file mode 100644 index b2f3f04..0000000 --- a/docs/superpowers/specs/2026-05-04-security-hardening-design.md +++ /dev/null @@ -1,166 +0,0 @@ -# Security Hardening Design — XpenselyServer -**Date:** 2026-05-04 -**Scope:** Input validation, authorization enforcement, rate limiting - ---- - -## Problem Statement - -The XpenselyServer has three security gaps: - -1. **No input validation** — request bodies are accepted without any field constraints, allowing null fields, negative amounts, oversized strings, and malformed data to reach the database layer. -2. **Authorization bypass** — every endpoint trusts caller-supplied user IDs from query params or request bodies rather than the authenticated JWT. Any authenticated user can read or destroy another user's expense lists. -3. **No rate limiting** — no protection against brute-force or abuse of sensitive endpoints (invite generation, account creation, invite acceptance). - ---- - -## Section 1 — Input Validation - -### Dependency -Add `spring-boot-starter-validation` to `pom.xml`. - -### Request Model Constraints - -**`AppUserCreateRequest`** -- `username`: `@NotBlank @Size(min=3, max=30) @Pattern(regexp="^[a-zA-Z0-9_.-]+$")` -- `googleId`: `@NotBlank` - -**`ExpenseInput`** -- `title`: `@NotBlank @Size(max=100)` -- `owner`: `@NotBlank` -- `amount`: `@NotNull @DecimalMin("0.01")` -- `date`: `@NotNull` -- `category`: `@NotBlank` - -**`ExpenseChangeRequest`** — same constraints as `ExpenseInput` for the corresponding fields. - -**`InviteRequest`** -- `inviteCode`: `@NotBlank @Size(min=6, max=6)` -- `userId` field removed (derived from JWT — see Section 2) - -### Controller Changes -Add `@Valid` to every `@RequestBody` parameter in `AppUserController` and `ExpenseListController`. - -### Error Handling -Add a `@ControllerAdvice` class `GlobalExceptionHandler` that: -- Catches `MethodArgumentNotValidException` → returns `400 Bad Request` with a map of `{ field: errorMessage }` pairs -- Catches `IllegalArgumentException` → returns `400 Bad Request` with the exception message - -This replaces the current pattern of returning `500 INTERNAL_SERVER_ERROR` or `417 EXPECTATION_FAILED` for validation failures. - -### Cleanup -Remove the stray `@Id` and `@GeneratedValue` JPA annotations from `ExpenseInput` — it is a DTO, not an entity. - ---- - -## Section 2 — Authorization Model - -### Core Principle -Stop trusting caller-supplied user IDs. Derive the authenticated user from the JWT on every request. - -### New Component: `AuthenticatedUserResolver` -A `@Component` with a single method: -```java -AppUser resolveCurrentUser(Authentication auth) -``` -- Extracts the `sub` claim (Google ID) from the JWT -- Calls `UserService.getUserByGoogleId(sub)` to return the `AppUser` -- Throws `ResponseStatusException(403)` if no user is found for the JWT subject - -### Endpoint Changes - -| Endpoint | Change | -|---|---| -| `GET /api/expenselist/all` | **Removed** — no legitimate non-admin use case | -| `GET /api/expenselist/byUser?userId=X` | **Replaced** by `GET /api/expenselist/mine` — returns lists for the JWT user, no param | -| `GET /api/expenselist/byUsername?username=X` | **Removed** — redundant with `/mine` | -| `GET /api/expenselist/byId?id=X` | **Guard added** — 403 if authenticated user is neither owner nor sharedWith | -| `DELETE /api/expenselist/{id}` | **Guard added** — 403 if authenticated user is not the owner | -| `POST /api/expenselist/{id}/add` | **Guard added** — 403 if authenticated user is not owner or sharedWith | -| `PUT /api/expenselist/{id}/update` | **Guard added** — 403 if authenticated user is not owner or sharedWith | -| `DELETE /api/expenselist/{id}/delete` | **Guard added** — 403 if authenticated user is not owner or sharedWith | -| `POST /api/expenselist/{listId}/invite` | **Guard added** — 403 if authenticated user is not the owner | -| `POST /api/expenselist/accept-invite` | **`userId` removed from body** — derived from JWT instead | -| `GET /api/users?id=X` | **Guard added** — 403 if id doesn't match JWT user's id | -| `GET /api/users/byGoogleId?id=X` | **Guard added** — 403 if id doesn't match JWT sub | -| `DELETE /api/users?id=X` | **Guard added** — 403 if id doesn't match JWT user's id | - -### Ownership Check Helper -Each guard is implemented as a private method in its controller (2–3 lines): -```java -private void assertOwner(AppUser authenticated, ExpenseList list) { - if (!list.getOwner().getId().equals(authenticated.getId())) - throw new ResponseStatusException(HttpStatus.FORBIDDEN); -} - -private void assertMember(AppUser authenticated, ExpenseList list) { - boolean isOwner = list.getOwner().getId().equals(authenticated.getId()); - boolean isShared = list.getSharedWith() != null && list.getSharedWith().getId().equals(authenticated.getId()); - if (!isOwner && !isShared) - throw new ResponseStatusException(HttpStatus.FORBIDDEN); -} -``` - -### Test Profile -The `@Profile("test")` security chain in `SecurityConfig` is untouched. Existing tests continue to work without authentication. - ---- - -## Section 3 — Rate Limiting - -### Dependency -Add `bucket4j-core` to `pom.xml`. In-memory storage — no external cache needed for single-instance deployment. - -### Implementation -A `RateLimitFilter` extending `OncePerRequestFilter`, registered as a `@Component` with `@Profile("!test")`: - -- **Key for authenticated requests:** JWT `sub` claim (per-user bucket) -- **Key for unauthenticated requests:** remote IP address (pre-auth fallback) -- Buckets stored in a `ConcurrentHashMap` - -### Limits - -| Endpoint pattern | Limit | -|---|---| -| All endpoints (default) | 60 requests / minute | -| `POST /api/expenselist/*/invite` | 5 requests / minute | -| `POST /api/expenselist/accept-invite` | 10 requests / minute | -| `POST /api/users/createUser` | 3 requests / minute | - -Sensitive endpoints get their own per-user bucket independent of the general bucket. - -### Response -When a bucket is exhausted: `429 Too Many Requests` with a `Retry-After: ` header indicating time until refill. - ---- - -## Architecture Summary - -``` -Request - └── RateLimitFilter (per-user/IP buckets) - └── SecurityFilterChain (JWT validation) - └── Controller - ├── @Valid on @RequestBody → GlobalExceptionHandler on failure - ├── AuthenticatedUserResolver → AppUser from JWT sub - └── assertOwner / assertMember → 403 on violation -``` - -No new service layer is introduced. The authorization checks are lightweight and local to each controller method. - ---- - -## Files Affected - -| File | Change | -|---|---| -| `pom.xml` | Add `spring-boot-starter-validation`, `bucket4j-core` | -| `model/AppUserCreateRequest.java` | Add validation annotations | -| `model/ExpenseInput.java` | Add validation annotations, remove JPA annotations | -| `model/ExpenseChangeRequest.java` | Add validation annotations | -| `model/InviteRequest.java` | Add validation annotations, remove `userId` field | -| `controller/AppUserController.java` | Add `@Valid`, ownership guards, use `AuthenticatedUserResolver` | -| `controller/ExpenseListController.java` | Add `@Valid`, ownership guards, remove/rename endpoints, use `AuthenticatedUserResolver` | -| `security/AuthenticatedUserResolver.java` | **New** — resolves JWT sub to `AppUser` | -| `security/RateLimitFilter.java` | **New** — per-user/IP rate limiting | -| `controller/GlobalExceptionHandler.java` | **New** — structured 400/403 error responses | From 9b95741292bdfe996813b1f42b5945ba7f9e75b2 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 17:00:01 +0200 Subject: [PATCH 26/43] fix: add /docs/superpowers to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 37fdfda..8c660d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md target/ +/docs/superpowers !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ From f0de751da4c1584d37e61d3c069a99587ac5b488 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 17:11:37 +0200 Subject: [PATCH 27/43] fix: centralise error handling in GlobalExceptionHandler, add SLF4J logging, remove HTTP 417 and e.printStackTrace() - Expand GlobalExceptionHandler with handlers for ResourceNotFoundException (404), UsernameAlreadyExistsException (409), ResponseStatusException (pass-through), RuntimeException (500), and generic Exception (500); add SLF4J logging - Remove all bare try/catch blocks and e.printStackTrace() calls from ExpenseListController; add SLF4J logger field - Add test: create_returns500_onUnexpectedServiceError Co-Authored-By: Claude Sonnet 4.6 --- .../controller/ExpenseListController.java | 65 +++++++------------ .../controller/GlobalExceptionHandler.java | 39 +++++++++++ .../controller/ExpenseListControllerTest.java | 13 ++++ 3 files changed, 75 insertions(+), 42 deletions(-) diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java index 7d86cf7..52ea21d 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java @@ -5,6 +5,8 @@ import java.util.List; import java.util.Optional; import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -21,6 +23,8 @@ import de.zendric.app.xpensely_server.services.UserService; @RequestMapping("/api/expenselist") public class ExpenseListController { + private static final Logger log = LoggerFactory.getLogger(ExpenseListController.class); + private final ExpenseListService expenseListService; private final UserService userService; private final CategoryService categoryService; @@ -56,20 +60,13 @@ public class ExpenseListController { @PostMapping("/create") public ResponseEntity create(@RequestBody ExpenseList expenseList, Authentication authentication) { - try { - AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); - expenseList.setOwner(authenticatedUser); - XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); - expenseList.setXpenselyStandardCategories(standardCategories); - expenseList.setSharedWith(null); - ExpenseList savedItem = expenseListService.createList(expenseList); - return new ResponseEntity<>(savedItem, HttpStatus.CREATED); - } catch (ResponseStatusException e) { - throw e; - } catch (Exception e) { - e.printStackTrace(); - return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); - } + AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); + expenseList.setOwner(authenticatedUser); + XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); + expenseList.setXpenselyStandardCategories(standardCategories); + expenseList.setSharedWith(null); + ExpenseList savedItem = expenseListService.createList(expenseList); + return new ResponseEntity<>(savedItem, HttpStatus.CREATED); } @DeleteMapping("{id}") @@ -79,12 +76,8 @@ public class ExpenseListController { if (listOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertOwner(user, listOpt.get()); - try { - expenseListService.deleteById(id); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } catch (Exception e) { - return new ResponseEntity<>(HttpStatus.EXPECTATION_FAILED); - } + expenseListService.deleteById(id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); } @PostMapping("/{id}/add") @@ -97,14 +90,10 @@ public class ExpenseListController { if (listOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, listOpt.get()); - try { - AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); - Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); - Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); - return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } + AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); + Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); + Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); + return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); } @PutMapping("/{id}/update") @@ -117,14 +106,10 @@ public class ExpenseListController { if (expenseListOpt.isEmpty()) return new ResponseEntity<>(null, HttpStatus.NOT_FOUND); assertMember(user, expenseListOpt.get()); - try { - AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); - Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); - Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); - return new ResponseEntity<>(updatedExpense, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - } + AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); + Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); + Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); + return new ResponseEntity<>(updatedExpense, HttpStatus.OK); } @DeleteMapping("/{id}/delete") @@ -137,12 +122,8 @@ public class ExpenseListController { if (listOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, listOpt.get()); - try { - expenseListService.deleteExpenseFromList(expenseListId, expenseId); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); - } + expenseListService.deleteExpenseFromList(expenseListId, expenseId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); } @PostMapping("/{listId}/invite") diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java index efbc72d..5a0c270 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java @@ -1,11 +1,16 @@ package de.zendric.app.xpensely_server.controller; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; +import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; import java.util.HashMap; import java.util.Map; @@ -13,6 +18,8 @@ import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException ex) { Map errors = new HashMap<>(); @@ -27,4 +34,36 @@ public class GlobalExceptionHandler { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", ex.getMessage())); } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleNotFound(ResourceNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", ex.getMessage())); + } + + @ExceptionHandler(UsernameAlreadyExistsException.class) + public ResponseEntity> handleUsernameConflict(UsernameAlreadyExistsException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", ex.getMessage())); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus(ResponseStatusException ex) { + return ResponseEntity.status(ex.getStatusCode()) + .body(Map.of("error", ex.getReason() != null ? ex.getReason() : ex.getMessage())); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntime(RuntimeException ex) { + log.error("Unhandled runtime exception", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "An unexpected error occurred")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneric(Exception ex) { + log.error("Unhandled exception", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "An unexpected error occurred")); + } } diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java index 2cba572..4736738 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -132,4 +132,17 @@ class ExpenseListControllerTest { mockMvc.perform(get("/api/expenselist/mine")) .andExpect(status().isOk()); } + + @Test + void create_returns500_onUnexpectedServiceError() throws Exception { + AppUser user = new AppUser(); + user.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(user); + when(categoryService.getDefaultCategories()).thenThrow(new RuntimeException("db down")); + + mockMvc.perform(post("/api/expenselist/create") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Groceries\"}")) + .andExpect(status().isInternalServerError()); + } } From 19c7e1915f5b620bfcc7986f593c856505bce6aa Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 17:13:53 +0200 Subject: [PATCH 28/43] security hardening (#12) Reviewed-on: https://tea.zendric.de/Cedric/XpenselyServer/pulls/12 Co-authored-by: Cedric Hornberger Co-committed-by: Cedric Hornberger --- .gitignore | 1 + pom.xml | 16 +- .../controller/AppUserController.java | 56 ++--- .../controller/ExpenseListController.java | 206 ++++++++---------- .../controller/GlobalExceptionHandler.java | 30 +++ .../model/AppUserCreateRequest.java | 10 +- .../Exception/ResourceNotFoundException.java | 11 + .../model/ExpenseChangeRequest.java | 19 +- .../xpensely_server/model/ExpenseInput.java | 18 +- .../xpensely_server/model/InviteRequest.java | 6 +- .../repo/ExpenseListRepository.java | 11 +- .../security/AuthenticatedUserResolver.java | 36 +++ .../security/RateLimitFilter.java | 61 ++++++ .../security/SecurityConfig.java | 2 + .../services/ExpenseListService.java | 81 ++----- .../xpensely_server/services/UserService.java | 36 +-- .../ExpenseListRepositoryTest.java | 47 ++-- .../controller/AppUserControllerTest.java | 95 ++++++++ .../controller/ExpenseListControllerTest.java | 135 ++++++++++++ .../AuthenticatedUserResolverTest.java | 78 +++++++ .../security/RateLimitFilterTest.java | 89 ++++++++ .../services/ExpenseListServiceTest.java | 57 +++++ .../services/UserServiceTest.java | 75 +++++++ 23 files changed, 916 insertions(+), 260 deletions(-) create mode 100644 src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java diff --git a/.gitignore b/.gitignore index 37fdfda..8c660d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md target/ +/docs/superpowers !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ diff --git a/pom.xml b/pom.xml index df740a9..83dc34c 100644 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,7 @@ - 17 + 21 @@ -38,6 +38,15 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-validation + + + com.bucket4j + bucket4j-core + 8.10.1 + org.springframework.boot spring-boot-starter-oauth2-resource-server @@ -71,6 +80,11 @@ spring-boot-starter-test test + + com.h2database + h2 + test + org.springframework.security spring-security-test diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java index 597d733..7bb5e8a 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java @@ -1,35 +1,35 @@ package de.zendric.app.xpensely_server.controller; -import org.springframework.beans.factory.annotation.Autowired; +import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.AppUserCreateRequest; import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; @RestController @RequestMapping("/api/users") public class AppUserController { - private UserService userService; + private final UserService userService; + private final AuthenticatedUserResolver authenticatedUserResolver; - @Autowired - public AppUserController(UserService userService) { + public AppUserController(UserService userService, AuthenticatedUserResolver authenticatedUserResolver) { this.userService = userService; + this.authenticatedUserResolver = authenticatedUserResolver; } @GetMapping - public AppUser getUser(@RequestParam Long id) { - return userService.getUser(id); + public ResponseEntity getUser(@RequestParam Long id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + assertSelf(self, id); + return ResponseEntity.ok(userService.getUser(id)); } @GetMapping("/byName") @@ -38,23 +38,17 @@ public class AppUserController { } @GetMapping("/byGoogleId") - public ResponseEntity getUserByGoogleId(@RequestParam String id) { - try { - AppUser userByGoogleId = userService.getUserByGoogleId(id); - return new ResponseEntity<>(userByGoogleId, HttpStatus.OK); - - } catch (IllegalArgumentException e) { - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } + public ResponseEntity getUserByGoogleId(@RequestParam String id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + if (!self.getGoogleId().equals(id)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return ResponseEntity.ok(self); } @PostMapping("/createUser") - public ResponseEntity createUser(@RequestBody AppUserCreateRequest userRequest) { + public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { try { AppUser convertedUser = userRequest.convertToAppUser(); - AppUser nUser = userService.createUser(convertedUser); return new ResponseEntity<>(nUser, HttpStatus.CREATED); } catch (UsernameAlreadyExistsException e) { @@ -62,12 +56,18 @@ public class AppUserController { } catch (Exception e) { return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); } - } @DeleteMapping - public String deleteUser(@RequestParam Long id) { + public ResponseEntity deleteUser(@RequestParam Long id, Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + assertSelf(self, id); AppUser user = userService.deleteUserById(id); - return "User deleted : " + user.getUsername(); + return ResponseEntity.ok("User deleted: " + user.getUsername()); + } + + private void assertSelf(AppUser authenticated, Long requestedId) { + if (!authenticated.getId().equals(requestedId)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); } } diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java index efaa067..7d86cf7 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java @@ -1,125 +1,71 @@ package de.zendric.app.xpensely_server.controller; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; +import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; -import de.zendric.app.xpensely_server.model.AppUser; -import de.zendric.app.xpensely_server.model.Expense; -import de.zendric.app.xpensely_server.model.ExpenseChangeRequest; -import de.zendric.app.xpensely_server.model.ExpenseInput; -import de.zendric.app.xpensely_server.model.ExpenseList; -import de.zendric.app.xpensely_server.model.InviteRequest; -import de.zendric.app.xpensely_server.model.XpenselyStandardCategories; +import de.zendric.app.xpensely_server.model.*; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.CategoryService; import de.zendric.app.xpensely_server.services.ExpenseListService; import de.zendric.app.xpensely_server.services.UserService; @RestController @RequestMapping("/api/expenselist") -class ExpenseListController { +public class ExpenseListController { - private ExpenseListService expenseListService; - private UserService userService; - private CategoryService categoryService; + private final ExpenseListService expenseListService; + private final UserService userService; + private final CategoryService categoryService; + private final AuthenticatedUserResolver authenticatedUserResolver; - @Autowired public ExpenseListController(ExpenseListService expenseListService, UserService userService, - CategoryService categoryService) { + CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) { this.expenseListService = expenseListService; this.userService = userService; this.categoryService = categoryService; + this.authenticatedUserResolver = authenticatedUserResolver; } - @GetMapping("/all") - public ResponseEntity> getAll() { - try { - List items = new ArrayList<>(); - - expenseListService.findAll().forEach(items::add); - - if (items.isEmpty()) - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - - return new ResponseEntity<>(items, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @GetMapping("/byUser") - public ResponseEntity> getByUser(@RequestParam Long userId) { - try { - List items = expenseListService.findByUserId(userId); - - if (items.isEmpty()) - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - - return new ResponseEntity<>(items, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @GetMapping("/byUsername") - public ResponseEntity> getByUser(@RequestParam String username) { - try { - List items = expenseListService.findByUsername(username); - - if (items.isEmpty()) - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - - return new ResponseEntity<>(items, HttpStatus.OK); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); - } + @GetMapping("/mine") + public ResponseEntity> getMine(Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + List items = expenseListService.findByUserId(user.getId()); + if (items.isEmpty()) + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + return new ResponseEntity<>(items, HttpStatus.OK); } @GetMapping("/byId") - public ResponseEntity getById(@RequestParam Long id) { + public ResponseEntity getById(@RequestParam Long id, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); Optional existingItemOptional = expenseListService.findById(id); - - if (existingItemOptional.isPresent()) { - return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK); - } else { + if (existingItemOptional.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); - } + assertMember(user, existingItemOptional.get()); + return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK); } @PostMapping("/create") - // TODO add handling of categories by using DTO - public ResponseEntity create(@RequestBody ExpenseList expenseList) { + public ResponseEntity create(@RequestBody ExpenseList expenseList, + Authentication authentication) { try { - if (expenseList.getOwner() != null) { - AppUser existingOwner = userService.getUser(expenseList.getOwner().getId()); - if (existingOwner == null) { - throw new IllegalArgumentException("Owner does not exist."); - } - expenseList.setOwner(existingOwner); - XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); - expenseList.setXpenselyStandardCategories(standardCategories); - } else { - throw new IllegalArgumentException("Owner is required."); - } - + AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); + expenseList.setOwner(authenticatedUser); + XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); + expenseList.setXpenselyStandardCategories(standardCategories); expenseList.setSharedWith(null); - ExpenseList savedItem = expenseListService.createList(expenseList); return new ResponseEntity<>(savedItem, HttpStatus.CREATED); + } catch (ResponseStatusException e) { + throw e; } catch (Exception e) { e.printStackTrace(); return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); @@ -127,7 +73,12 @@ class ExpenseListController { } @DeleteMapping("{id}") - public ResponseEntity delete(@PathVariable("id") Long id) { + public ResponseEntity delete(@PathVariable("id") Long id, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(id); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertOwner(user, listOpt.get()); try { expenseListService.deleteById(id); return new ResponseEntity<>(HttpStatus.NO_CONTENT); @@ -139,11 +90,16 @@ class ExpenseListController { @PostMapping("/{id}/add") public ResponseEntity addExpenseToList( @PathVariable("id") Long expenseListId, - @RequestBody ExpenseInput expenseInput) { + @RequestBody @Valid ExpenseInput expenseInput, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(expenseListId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, listOpt.get()); try { AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); - Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); } catch (Exception e) { @@ -154,18 +110,18 @@ class ExpenseListController { @PutMapping("/{id}/update") public ResponseEntity updateExpenseInList( @PathVariable("id") Long expenseListId, - @RequestBody ExpenseChangeRequest expenseChangeRequest) { + @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional expenseListOpt = expenseListService.findById(expenseListId); + if (expenseListOpt.isEmpty()) + return new ResponseEntity<>(null, HttpStatus.NOT_FOUND); + assertMember(user, expenseListOpt.get()); try { AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); - Optional expenseList = expenseListService.findById(expenseListId); - if (expenseList.isPresent()) { - Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseList.get()); - - Expense addedExpense = expenseListService.updateExpense(expenseListId, expense); - return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); - } - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - + Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); + Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); + return new ResponseEntity<>(updatedExpense, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); } @@ -174,7 +130,13 @@ class ExpenseListController { @DeleteMapping("/{id}/delete") public ResponseEntity deleteExpenseFromList( @PathVariable("id") Long expenseListId, - @RequestParam("expenseId") Long expenseId) { + @RequestParam("expenseId") Long expenseId, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(expenseListId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, listOpt.get()); try { expenseListService.deleteExpenseFromList(expenseListId, expenseId); return new ResponseEntity<>(HttpStatus.NO_CONTENT); @@ -184,13 +146,20 @@ class ExpenseListController { } @PostMapping("/{listId}/invite") - public ResponseEntity generateInvite(@PathVariable Long listId) { + public ResponseEntity generateInvite(@PathVariable Long listId, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(listId); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertOwner(user, listOpt.get()); String inviteCode = expenseListService.generateInviteCode(listId); return ResponseEntity.ok(inviteCode); } @PostMapping("/accept-invite") - public ResponseEntity acceptInvite(@RequestBody InviteRequest inviteRequest) { + public ResponseEntity acceptInvite(@RequestBody @Valid InviteRequest inviteRequest, + Authentication authentication) { + AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); ExpenseList list = expenseListService.findByInviteCode(inviteRequest.getInviteCode()); if (list == null || list.getInviteCodeExpiration() == null || @@ -200,21 +169,24 @@ class ExpenseListController { if (list.getSharedWith() != null) { return ResponseEntity.status(HttpStatus.IM_USED).body("List has already been shared"); } - if (list.getOwner().getId() == inviteRequest.getUserId()) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cant join your own List"); - } - AppUser user = null; - try { - user = userService.getUser(inviteRequest.getUserId()); - } catch (Exception e) { - throw new RuntimeException("User not found"); - } - if (user != null) { - list.setSharedWith(user); - expenseListService.save(list); - } else { - throw new RuntimeException("User not found"); + if (list.getOwner().getId().equals(authenticatedUser.getId())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list"); } + list.setSharedWith(authenticatedUser); + expenseListService.save(list); return ResponseEntity.ok("User added to the list"); } + + private void assertOwner(AppUser authenticated, ExpenseList list) { + if (!list.getOwner().getId().equals(authenticated.getId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + private void assertMember(AppUser authenticated, ExpenseList list) { + boolean isOwner = list.getOwner().getId().equals(authenticated.getId()); + boolean isShared = list.getSharedWith() != null + && list.getSharedWith().getId().equals(authenticated.getId()); + if (!isOwner && !isShared) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } } diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..efbc72d --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java @@ -0,0 +1,30 @@ +package de.zendric.app.xpensely_server.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + errors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", ex.getMessage())); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java index 3ae678d..c2f05ed 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java @@ -1,21 +1,25 @@ package de.zendric.app.xpensely_server.model; -import jakarta.persistence.Column; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import lombok.Data; @Data public class AppUserCreateRequest { - @Column(name = "username", nullable = false, unique = true) + @NotBlank(message = "Username is required") + @Size(min = 3, max = 30, message = "Username must be between 3 and 30 characters") + @Pattern(regexp = "^[a-zA-Z0-9_.\\-]+$", message = "Username may only contain letters, digits, underscores, dots, and hyphens") private String username; + @NotBlank(message = "Google ID is required") private String googleId; public AppUser convertToAppUser() { AppUser appUser = new AppUser(); appUser.setGoogleId(googleId); appUser.setUsername(username); - return appUser; } } diff --git a/src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java b/src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java new file mode 100644 index 0000000..46e0582 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/Exception/ResourceNotFoundException.java @@ -0,0 +1,11 @@ +package de.zendric.app.xpensely_server.model.Exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java index 65f8edd..20c161b 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java @@ -2,6 +2,10 @@ package de.zendric.app.xpensely_server.model; import java.time.LocalDate; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -12,12 +16,25 @@ import lombok.NoArgsConstructor; public class ExpenseChangeRequest { private Long id; + + @NotBlank(message = "Title is required") + @Size(max = 100, message = "Title must not exceed 100 characters") private String title; + + @NotBlank(message = "Owner name is required") private String ownerName; + + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") private Double amount; + private Double personalUseAmount; private Double otherPersonAmount; + + @NotNull(message = "Date is required") private LocalDate date; + + @NotBlank(message = "Category is required") private String category; public Expense convertToExpense(Long userId, ExpenseList expenseList) { @@ -38,4 +55,4 @@ public class ExpenseChangeRequest { return expense; } -} \ No newline at end of file +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java index 3fb17ad..1a31fd2 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java @@ -2,9 +2,10 @@ package de.zendric.app.xpensely_server.model; import java.time.LocalDate; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,19 +17,26 @@ import lombok.Setter; @NoArgsConstructor public class ExpenseInput { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @NotBlank(message = "Title is required") + @Size(max = 100, message = "Title must not exceed 100 characters") private String title; + @NotBlank(message = "Owner is required") private String owner; + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") private Double amount; + private Double personalUseAmount; private Double otherPersonAmount; + @NotNull(message = "Date is required") private LocalDate date; + + @NotBlank(message = "Category is required") private String category; private ExpenseList expenseList; diff --git a/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java index 81ac35a..e572c44 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java @@ -1,5 +1,7 @@ package de.zendric.app.xpensely_server.model; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -8,6 +10,8 @@ import lombok.NoArgsConstructor; @AllArgsConstructor @NoArgsConstructor public class InviteRequest { + + @NotBlank(message = "Invite code is required") + @Size(min = 6, max = 6, message = "Invite code must be exactly 6 characters") private String inviteCode; - private Long userId; } diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java index c42b820..6c8ccfb 100644 --- a/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java +++ b/src/main/java/de/zendric/app/xpensely_server/repo/ExpenseListRepository.java @@ -3,13 +3,22 @@ package de.zendric.app.xpensely_server.repo; import java.util.List; 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; import de.zendric.app.xpensely_server.model.ExpenseList; @Repository public interface ExpenseListRepository extends JpaRepository { + List findByOwnerId(Long ownerId); ExpenseList findByInviteCode(String inviteCode); -} \ No newline at end of file + + @Query("SELECT el FROM ExpenseList el WHERE el.owner.id = :userId OR el.sharedWith.id = :userId") + List findByOwnerIdOrSharedWithId(@Param("userId") Long userId); + + @Query("SELECT el FROM ExpenseList el WHERE el.owner.username = :username OR el.sharedWith.username = :username") + List findByOwnerUsernameOrSharedWithUsername(@Param("username") String username); +} diff --git a/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java b/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java new file mode 100644 index 0000000..4d5686c --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java @@ -0,0 +1,36 @@ +package de.zendric.app.xpensely_server.security; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.services.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class AuthenticatedUserResolver { + + private final UserService userService; + + public AuthenticatedUserResolver(UserService userService) { + this.userService = userService; + } + + public AppUser resolveCurrentUser(Authentication authentication) { + if (authentication == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not authenticated"); + } + Jwt jwt = (Jwt) authentication.getPrincipal(); + String googleId = jwt.getSubject(); + try { + AppUser user = userService.getUserByGoogleId(googleId); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); + } + return user; + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); + } + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java b/src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java new file mode 100644 index 0000000..f04341e --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java @@ -0,0 +1,61 @@ +package de.zendric.app.xpensely_server.security; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class RateLimitFilter extends OncePerRequestFilter { + + private static final int REQUESTS_PER_MINUTE = 60; + + private final Map buckets = new ConcurrentHashMap<>(); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String key = resolveKey(request); + Bucket bucket = buckets.computeIfAbsent(key, k -> newBucket()); + + if (bucket.tryConsume(1)) { + filterChain.doFilter(request, response); + } else { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.getWriter().write("Rate limit exceeded"); + } + } + + private String resolveKey(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof Jwt jwt) { + return "user:" + jwt.getSubject(); + } + String ip = request.getHeader("X-Forwarded-For"); + if (ip != null && !ip.isBlank()) { + return "ip:" + ip.split(",")[0].trim(); + } + return "ip:" + request.getRemoteAddr(); + } + + private Bucket newBucket() { + return Bucket.builder() + .addLimit(Bandwidth.builder() + .capacity(REQUESTS_PER_MINUTE) + .refillGreedy(REQUESTS_PER_MINUTE, Duration.ofMinutes(1)) + .build()) + .build(); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java b/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java index d0d2465..1ae17fc 100644 --- a/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java +++ b/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; @Configuration @@ -31,6 +32,7 @@ public class SecurityConfig { .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults())) .oauth2Login(Customizer.withDefaults()) + .addFilterAfter(new RateLimitFilter(), BearerTokenAuthenticationFilter.class) .csrf().disable(); return http.build(); diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java index 0ee1be6..0f65b6e 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -1,37 +1,29 @@ package de.zendric.app.xpensely_server.services; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.Expense; import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.XpenselyCustomCategory; import de.zendric.app.xpensely_server.repo.ExpenseListRepository; import de.zendric.app.xpensely_server.repo.ExpenseRepository; import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository; -import jakarta.persistence.EntityManager; @Service @Transactional public class ExpenseListService { - private ExpenseListRepository repository; + private final ExpenseListRepository repository; private final ExpenseRepository expenseRepository; - private XpenselyCustomCategoryRepository customCategoryRepository; + private final XpenselyCustomCategoryRepository customCategoryRepository; - @Autowired - private EntityManager entityManager; - - @Autowired public ExpenseListService(ExpenseListRepository repository, ExpenseRepository expenseRepository, XpenselyCustomCategoryRepository customCategoryRepository) { this.repository = repository; @@ -68,67 +60,24 @@ public class ExpenseListService { } public List findByUserId(Long id) { - List allLists = repository.findAll(); - List userSpecificList = new ArrayList<>(); - for (ExpenseList expenseList : allLists) { - AppUser sharedWith = expenseList.getSharedWith(); - - if (expenseList.getOwner().getId().equals(id)) { - userSpecificList.add(expenseList); - } else { - if (sharedWith != null && sharedWith.getId().equals(id)) { - userSpecificList.add(expenseList); - } - } - } - return userSpecificList; + return repository.findByOwnerIdOrSharedWithId(id); } public List findByUsername(String username) { - List allLists = repository.findAll(); - List userSpecificList = new ArrayList<>(); - for (ExpenseList expenseList : allLists) { - AppUser sharedWith = expenseList.getSharedWith(); - - if (expenseList.getOwner().getUsername().equals(username)) { - userSpecificList.add(expenseList); - } else { - if (sharedWith != null && sharedWith.getUsername().equals(username)) { - userSpecificList.add(expenseList); - } - } - } - return userSpecificList; - + return repository.findByOwnerUsernameOrSharedWithUsername(username); } public Expense addExpenseToList(Long expenseListId, Expense expense) { - // find expenseList ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new RuntimeException("ExpenseList not found with id: " + expenseListId)); - // get all added expenses - HashSet existingId = new HashSet<>(); - for (Expense e : expenseList.getExpenses()) { - existingId.add(e.getId()); - } - // add the new expense + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); expenseList.addExpense(expense); - // save repository.save(expenseList); - - Expense newExpense = new Expense(); - for (Expense e : expenseList.getExpenses()) { - if (!existingId.contains(e.getId())) { - newExpense = e; - break; - } - } - return newExpense; + return expense; } public void deleteExpenseFromList(Long expenseListId, Long expenseId) { ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new RuntimeException("ExpenseList not found with id: " + expenseListId)); + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); Expense expenseToRemove = null; for (Expense expense : expenseList.getExpenses()) { if (expense.getId().equals(expenseId)) { @@ -139,14 +88,14 @@ public class ExpenseListService { if (expenseToRemove != null) { expenseList.removeExpense(expenseToRemove); } else { - throw new RuntimeException("Expense not found with id: " + expenseId); + throw new ResourceNotFoundException("Expense not found with id: " + expenseId); } repository.save(expenseList); } public String generateInviteCode(Long listId) { ExpenseList list = repository.findById(listId) - .orElseThrow(() -> new RuntimeException("List not found")); + .orElseThrow(() -> new ResourceNotFoundException("List not found")); String inviteCode; if (list.getInviteCode() == null || list.getInviteCodeExpiration().isBefore(LocalDateTime.now())) { @@ -168,7 +117,7 @@ public class ExpenseListService { public Expense updateExpense(Long expenseListId, Expense updatedExpense) { ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new IllegalArgumentException("ExpenseList not found")); + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + expenseListId)); if (!expenseList.getExpenses().stream() .anyMatch(expense -> expense.getId().equals(updatedExpense.getId()))) { @@ -176,7 +125,7 @@ public class ExpenseListService { } Expense existingExpense = expenseRepository.findById(updatedExpense.getId()) - .orElseThrow(() -> new IllegalArgumentException("Expense not found")); + .orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + updatedExpense.getId())); existingExpense.setTitle(updatedExpense.getTitle()); existingExpense.setAmount(updatedExpense.getAmount()); existingExpense.setPersonalUseAmount(updatedExpense.getPersonalUseAmount()); @@ -191,7 +140,7 @@ public class ExpenseListService { // TODO implement API for this public XpenselyCustomCategory addCustomCategory(Long expenseListId, XpenselyCustomCategory customCategory) { ExpenseList expenseList = repository.findById(expenseListId) - .orElseThrow(() -> new RuntimeException("Expense List not found")); + .orElseThrow(() -> new ResourceNotFoundException("Expense List not found")); customCategory.setExpenseList(expenseList); return customCategoryRepository.save(customCategory); @@ -200,9 +149,9 @@ public class ExpenseListService { // TODO implement API for this public void deleteCustomCategory(Long expenseListId, Long categoryId) { XpenselyCustomCategory category = customCategoryRepository.findById(categoryId) - .orElseThrow(() -> new RuntimeException("Custom Category not found")); + .orElseThrow(() -> new ResourceNotFoundException("Custom Category not found")); if (!category.getExpenseList().getId().equals(expenseListId)) { - throw new RuntimeException("Category does not belong to the specified Expense List"); + throw new IllegalArgumentException("Category does not belong to the specified Expense List"); } customCategoryRepository.delete(category); } diff --git a/src/main/java/de/zendric/app/xpensely_server/services/UserService.java b/src/main/java/de/zendric/app/xpensely_server/services/UserService.java index dc0dee7..a86d665 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/UserService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/UserService.java @@ -1,11 +1,11 @@ package de.zendric.app.xpensely_server.services; import java.util.List; -import java.util.Optional; import org.springframework.stereotype.Service; import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; import de.zendric.app.xpensely_server.repo.UserRepository; @@ -29,36 +29,24 @@ public class UserService { } public AppUser getUser(Long id) { - Optional user = userRepository.findById(id); - if (user.isPresent()) { - return user.get(); - } else - return null; + return userRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id)); } public AppUser deleteUserById(Long id) { - Optional user = userRepository.findById(id); - if (user.isPresent()) { - userRepository.deleteById(id); - return user.get(); - } else - return null; + AppUser user = userRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id)); + userRepository.deleteById(id); + return user; } public AppUser getUserByName(String username) { - Optional optUser = userRepository.findByUsername(username); - if (optUser.isPresent()) { - return optUser.get(); - } else - return null; + return userRepository.findByUsername(username) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + username)); } public AppUser getUserByGoogleId(String id) { - Optional optUser = userRepository.findByGoogleId(id); - if (optUser.isPresent()) { - return optUser.get(); - } else - return null; + return userRepository.findByGoogleId(id) + .orElseThrow(() -> new ResourceNotFoundException("User not found with Google ID: " + id)); } - -} \ No newline at end of file +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java b/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java index 6a4cb49..23bce57 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java @@ -1,26 +1,47 @@ package de.zendric.app.xpensely_Server; -import java.util.Optional; - +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.repo.ExpenseListRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import de.zendric.app.xpensely_server.model.ExpenseList; -import de.zendric.app.xpensely_server.repo.ExpenseListRepository; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; @DataJpaTest class ExpenseListRepositoryTest { - @Autowired - private ExpenseListRepository expenseListRepository; + @Autowired + private ExpenseListRepository expenseListRepository; - @Test - void testFindExpenseListById() { - // Assuming an ExpenseList with id = 1 exists in your test DB. - Optional optionalExpenseList = expenseListRepository.findById(1L); + @Test + void saveAndFindById_returnsExpenseList() { + ExpenseList list = new ExpenseList(); + list.setName("Groceries"); + ExpenseList saved = expenseListRepository.save(list); - ExpenseList expenseList = optionalExpenseList.get(); - System.out.println("ExpenseList name: " + expenseList.getName()); - } + Optional found = expenseListRepository.findById(saved.getId()); + + assertTrue(found.isPresent()); + assertEquals("Groceries", found.get().getName()); + } + + @Test + void findById_nonExistentId_returnsEmpty() { + Optional found = expenseListRepository.findById(999L); + assertTrue(found.isEmpty()); + } + + @Test + void delete_removesFromRepository() { + ExpenseList list = new ExpenseList(); + list.setName("To Delete"); + ExpenseList saved = expenseListRepository.save(list); + + expenseListRepository.deleteById(saved.getId()); + + assertTrue(expenseListRepository.findById(saved.getId()).isEmpty()); + } } diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java new file mode 100644 index 0000000..94dbd6d --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java @@ -0,0 +1,95 @@ +package de.zendric.app.xpensely_Server.controller; + +import de.zendric.app.xpensely_server.controller.AppUserController; +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; +import de.zendric.app.xpensely_server.services.UserService; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +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.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(AppUserController.class) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class AppUserControllerTest { + + @Autowired MockMvc mockMvc; + @MockitoBean UserService userService; + @MockitoBean AuthenticatedUserResolver authenticatedUserResolver; + + @Test + void createUser_blankUsername_returns400WithFieldError() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_invalidUsernamePattern_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"hello world!\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_usernameTooShort_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"ab\",\"googleId\":\"gid123\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void createUser_blankGoogleId_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"validuser\",\"googleId\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.googleId").exists()); + } + + // --- Authorization tests --- + + @Test + void getUser_differentUser_returns403() throws Exception { + AppUser self = new AppUser(); self.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + + mockMvc.perform(get("/api/users").param("id", "99")) + .andExpect(status().isForbidden()); + } + + @Test + void getUser_sameUser_returns200() throws Exception { + AppUser self = new AppUser(); self.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + when(userService.getUser(1L)).thenReturn(self); + + mockMvc.perform(get("/api/users").param("id", "1")) + .andExpect(status().isOk()); + } + + @Test + void deleteUser_differentUser_returns403() throws Exception { + AppUser self = new AppUser(); self.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + + mockMvc.perform(delete("/api/users").param("id", "99")) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java new file mode 100644 index 0000000..2cba572 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -0,0 +1,135 @@ +package de.zendric.app.xpensely_Server.controller; + +import de.zendric.app.xpensely_server.controller.ExpenseListController; +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; +import de.zendric.app.xpensely_server.services.CategoryService; +import de.zendric.app.xpensely_server.services.ExpenseListService; +import de.zendric.app.xpensely_server.services.UserService; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +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.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(ExpenseListController.class) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class ExpenseListControllerTest { + + @Autowired MockMvc mockMvc; + @MockitoBean ExpenseListService expenseListService; + @MockitoBean UserService userService; + @MockitoBean CategoryService categoryService; + @MockitoBean AuthenticatedUserResolver authenticatedUserResolver; + + // --- Validation tests --- + + @Test + void addExpense_blankTitle_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"\",\"owner\":\"alice\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").exists()); + } + + @Test + void addExpense_negativeAmount_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":-5.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.amount").exists()); + } + + @Test + void addExpense_nullDate_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":10.0,\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.date").exists()); + } + + @Test + void acceptInvite_blankCode_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/accept-invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inviteCode\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.inviteCode").exists()); + } + + @Test + void acceptInvite_wrongCodeLength_returns400() throws Exception { + mockMvc.perform(post("/api/expenselist/accept-invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inviteCode\":\"ABC\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.inviteCode").exists()); + } + + // --- Authorization tests --- + + @Test + void getById_authenticatedUserNotMember_returns403() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + AppUser requester = new AppUser(); requester.setId(2L); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner); + + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(requester); + + mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) + .andExpect(status().isForbidden()); + } + + @Test + void getById_authenticatedUserIsOwner_returns200() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(owner); + + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner); + + mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) + .andExpect(status().isOk()); + } + + @Test + void deleteList_nonOwner_returns403() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + AppUser nonOwner = new AppUser(); nonOwner.setId(2L); + ExpenseList list = new ExpenseList(); list.setId(5L); list.setOwner(owner); + + when(expenseListService.findById(5L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(nonOwner); + + mockMvc.perform(delete("/api/expenselist/5")) + .andExpect(status().isForbidden()); + } + + @Test + void getMine_returnsCurrentUserLists() throws Exception { + AppUser user = new AppUser(); user.setId(3L); + + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(user); + when(expenseListService.findByUserId(3L)).thenReturn(List.of(new ExpenseList())); + + mockMvc.perform(get("/api/expenselist/mine")) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java b/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java new file mode 100644 index 0000000..e854437 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java @@ -0,0 +1,78 @@ +package de.zendric.app.xpensely_Server.security; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; +import de.zendric.app.xpensely_server.services.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AuthenticatedUserResolverTest { + + UserService userService; + AuthenticatedUserResolver resolver; + + @BeforeEach + void setUp() { + userService = mock(UserService.class); + resolver = new AuthenticatedUserResolver(userService); + } + + @Test + void resolveCurrentUser_validJwt_returnsAppUser() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("google-id-123") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + + AppUser user = new AppUser(); + user.setId(1L); + user.setGoogleId("google-id-123"); + when(userService.getUserByGoogleId("google-id-123")).thenReturn(user); + + AppUser result = resolver.resolveCurrentUser(auth); + assertEquals(user, result); + } + + @Test + void resolveCurrentUser_userNotFound_throws403() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("unknown-id") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + when(userService.getUserByGoogleId("unknown-id")).thenReturn(null); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(auth)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + void resolveCurrentUser_userServiceThrows_throws403() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("gone-id") + .build(); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + when(userService.getUserByGoogleId("gone-id")).thenThrow(new IllegalArgumentException("not found")); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(auth)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + void resolveCurrentUser_nullAuthentication_throws403() { + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> resolver.resolveCurrentUser(null)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java b/src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java new file mode 100644 index 0000000..825832b --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/security/RateLimitFilterTest.java @@ -0,0 +1,89 @@ +package de.zendric.app.xpensely_Server.security; + +import de.zendric.app.xpensely_server.security.RateLimitFilter; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class RateLimitFilterTest { + + RateLimitFilter filter; + FilterChain chain; + + @BeforeEach + void setUp() { + filter = new RateLimitFilter(); + chain = mock(FilterChain.class); + } + + @Test + void allowsRequestUnderLimit() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("1.2.3.4"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, chain); + + verify(chain, times(1)).doFilter(request, response); + assertEquals(200, response.getStatus()); + } + + @Test + void blocksRequestOverLimit() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("5.6.7.8"); + + for (int i = 0; i < 60; i++) { + filter.doFilter(request, new MockHttpServletResponse(), chain); + } + + MockHttpServletResponse blockedResponse = new MockHttpServletResponse(); + filter.doFilter(request, blockedResponse, chain); + + assertEquals(429, blockedResponse.getStatus()); + verify(chain, times(60)).doFilter(eq(request), any()); + } + + @Test + void differentIpsBucketedSeparately() throws Exception { + MockHttpServletRequest req1 = new MockHttpServletRequest(); + req1.setRemoteAddr("10.0.0.1"); + MockHttpServletRequest req2 = new MockHttpServletRequest(); + req2.setRemoteAddr("10.0.0.2"); + + for (int i = 0; i < 60; i++) { + filter.doFilter(req1, new MockHttpServletResponse(), chain); + } + + MockHttpServletResponse response2 = new MockHttpServletResponse(); + filter.doFilter(req2, response2, chain); + + assertEquals(200, response2.getStatus()); + } + + @Test + void prefersXForwardedForHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("192.168.1.1"); + request.addHeader("X-Forwarded-For", "203.0.113.5, 10.0.0.1"); + + for (int i = 0; i < 60; i++) { + filter.doFilter(request, new MockHttpServletResponse(), chain); + } + + MockHttpServletResponse blocked = new MockHttpServletResponse(); + filter.doFilter(request, blocked, chain); + assertEquals(429, blocked.getStatus()); + + MockHttpServletRequest directRequest = new MockHttpServletRequest(); + directRequest.setRemoteAddr("192.168.1.1"); + MockHttpServletResponse directResponse = new MockHttpServletResponse(); + filter.doFilter(directRequest, directResponse, chain); + assertEquals(200, directResponse.getStatus()); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java new file mode 100644 index 0000000..0f832a5 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java @@ -0,0 +1,57 @@ +package de.zendric.app.xpensely_Server.services; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.repo.ExpenseListRepository; +import de.zendric.app.xpensely_server.repo.ExpenseRepository; +import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository; +import de.zendric.app.xpensely_server.services.ExpenseListService; +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.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class ExpenseListServiceTest { + + @Mock ExpenseListRepository repository; + @Mock ExpenseRepository expenseRepository; + @Mock XpenselyCustomCategoryRepository customCategoryRepository; + + @InjectMocks + ExpenseListService service; + + @Test + void findByUserId_usesRepositoryQuery_notFindAll() { + AppUser owner = new AppUser(); owner.setId(1L); + ExpenseList list = new ExpenseList(); list.setId(10L); list.setOwner(owner); + when(repository.findByOwnerIdOrSharedWithId(1L)).thenReturn(List.of(list)); + + List result = service.findByUserId(1L); + + assertThat(result).hasSize(1); + verify(repository).findByOwnerIdOrSharedWithId(1L); + verify(repository, never()).findAll(); + } + + @Test + void findByUsername_usesRepositoryQuery_notFindAll() { + AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("alice"); + ExpenseList list = new ExpenseList(); list.setId(10L); list.setOwner(owner); + when(repository.findByOwnerUsernameOrSharedWithUsername("alice")).thenReturn(List.of(list)); + + List result = service.findByUsername("alice"); + + assertThat(result).hasSize(1); + verify(repository).findByOwnerUsernameOrSharedWithUsername("alice"); + verify(repository, never()).findAll(); + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java new file mode 100644 index 0000000..9202c92 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/services/UserServiceTest.java @@ -0,0 +1,75 @@ +package de.zendric.app.xpensely_Server.services; + +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; +import de.zendric.app.xpensely_server.repo.UserRepository; +import de.zendric.app.xpensely_server.services.UserService; +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.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + UserRepository userRepository; + + @InjectMocks + UserService userService; + + @Test + void getUserByName_throwsResourceNotFound_whenUserMissing() { + when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUserByName("ghost")) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("ghost"); + } + + @Test + void getUserByName_returnsUser_whenFound() { + AppUser user = new AppUser(); + user.setId(1L); + user.setUsername("alice"); + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + + AppUser result = userService.getUserByName("alice"); + + assertThat(result.getUsername()).isEqualTo("alice"); + } + + @Test + void getUser_throwsResourceNotFound_whenIdMissing() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUser(99L)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("99"); + } + + @Test + void deleteUserById_throwsResourceNotFound_whenIdMissing() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.deleteUserById(99L)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("99"); + } + + @Test + void getUserByGoogleId_throwsResourceNotFound_whenMissing() { + when(userRepository.findByGoogleId("gid-404")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getUserByGoogleId("gid-404")) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("gid-404"); + } +} From 8b96433b1ae986be9b6910716c8e21f3a4f11601 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 5 May 2026 17:28:47 +0200 Subject: [PATCH 29/43] feat: add CreateExpenseListRequest DTO with validation to POST /create endpoint Co-Authored-By: Claude Sonnet 4.6 --- .../controller/ExpenseListController.java | 4 +++- .../model/CreateExpenseListRequest.java | 17 +++++++++++++ .../controller/ExpenseListControllerTest.java | 24 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/CreateExpenseListRequest.java diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java index 52ea21d..3ef07f8 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java @@ -58,9 +58,11 @@ public class ExpenseListController { } @PostMapping("/create") - public ResponseEntity create(@RequestBody ExpenseList expenseList, + public ResponseEntity create(@RequestBody @Valid CreateExpenseListRequest request, Authentication authentication) { AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); + ExpenseList expenseList = new ExpenseList(); + expenseList.setName(request.getName()); expenseList.setOwner(authenticatedUser); XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); expenseList.setXpenselyStandardCategories(standardCategories); diff --git a/src/main/java/de/zendric/app/xpensely_server/model/CreateExpenseListRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/CreateExpenseListRequest.java new file mode 100644 index 0000000..1bf3fa6 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/CreateExpenseListRequest.java @@ -0,0 +1,17 @@ +package de.zendric.app.xpensely_server.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class CreateExpenseListRequest { + + @NotBlank(message = "List name is required") + @Size(max = 100, message = "List name must not exceed 100 characters") + private String name; +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java index 4736738..65b043d 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -145,4 +145,28 @@ class ExpenseListControllerTest { .content("{\"name\":\"Groceries\"}")) .andExpect(status().isInternalServerError()); } + + @Test + void create_returns400_whenNameIsBlank() throws Exception { + AppUser user = new AppUser(); + user.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(user); + + mockMvc.perform(post("/api/expenselist/create") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void create_returns400_whenBodyIsEmpty() throws Exception { + AppUser user = new AppUser(); + user.setId(1L); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(user); + + mockMvc.perform(post("/api/expenselist/create") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } } From b1324e304825b497135829b19fcd8814886ab18c Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 6 May 2026 14:40:11 +0200 Subject: [PATCH 30/43] test: add jsonPath field assertions to create validation tests --- .../controller/ExpenseListControllerTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java index 65b043d..22418e3 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -155,7 +155,8 @@ class ExpenseListControllerTest { mockMvc.perform(post("/api/expenselist/create") .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"\"}")) - .andExpect(status().isBadRequest()); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.name").exists()); } @Test @@ -167,6 +168,7 @@ class ExpenseListControllerTest { mockMvc.perform(post("/api/expenselist/create") .contentType(MediaType.APPLICATION_JSON) .content("{}")) - .andExpect(status().isBadRequest()); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.name").exists()); } } From 3d456f2f81e6f5c2a745011dd7f389720a8e7aff Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sat, 9 May 2026 23:04:27 +0200 Subject: [PATCH 31/43] Bugfixes --- pom.xml | 249 +++++++++--------- .../controller/AppUserController.java | 13 +- .../controller/ExpenseListController.java | 3 +- .../security/SecurityConfig.java | 6 +- .../services/ExpenseListService.java | 12 - .../ExpenseListRepositoryTest.java | 2 +- .../controller/AppUserControllerTest.java | 13 +- .../controller/ExpenseListControllerTest.java | 13 +- 8 files changed, 159 insertions(+), 152 deletions(-) diff --git a/pom.xml b/pom.xml index 83dc34c..357a67e 100644 --- a/pom.xml +++ b/pom.xml @@ -1,124 +1,135 @@ - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 3.4.1 - - - de.zendric.app - XpenselyServer - 1.0.0 - XpenselyServer - XpenselyServer used to handle the Xpensely App - - - - - - - - - - - - - - - 21 - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-security - - - org.springframework.boot - spring-boot-starter-validation - - - com.bucket4j - bucket4j-core - 8.10.1 - - - org.springframework.boot - spring-boot-starter-oauth2-resource-server - - - org.springframework.boot - spring-boot-starter-oauth2-client - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-devtools - runtime - true - - - org.postgresql - postgresql - runtime - - - org.projectlombok - lombok - true - - - org.springframework.boot - spring-boot-starter-test - test - - - com.h2database - h2 - test - - - org.springframework.security - spring-security-test - test - - + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.6 + + + de.zendric.app + XpenselyServer + 1.0.0 + XpenselyServer + XpenselyServer used to handle the Xpensely App + + + + + + + + + + + + + + + 21 + 1.18.46 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + com.bucket4j + bucket4j-core + 8.10.1 + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + + + com.h2database + h2 + test + + + org.springframework.security + spring-security-test + test + + - - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - - - - + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java index 7bb5e8a..597824b 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java @@ -9,7 +9,6 @@ import org.springframework.web.server.ResponseStatusException; import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.AppUserCreateRequest; -import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; @@ -47,15 +46,9 @@ public class AppUserController { @PostMapping("/createUser") public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { - try { - AppUser convertedUser = userRequest.convertToAppUser(); - AppUser nUser = userService.createUser(convertedUser); - return new ResponseEntity<>(nUser, HttpStatus.CREATED); - } catch (UsernameAlreadyExistsException e) { - return new ResponseEntity<>(null, HttpStatus.CONFLICT); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - } + AppUser convertedUser = userRequest.convertToAppUser(); + AppUser nUser = userService.createUser(convertedUser); + return new ResponseEntity<>(nUser, HttpStatus.CREATED); } @DeleteMapping diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java index 3ef07f8..a2227e3 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java @@ -68,6 +68,7 @@ public class ExpenseListController { expenseList.setXpenselyStandardCategories(standardCategories); expenseList.setSharedWith(null); ExpenseList savedItem = expenseListService.createList(expenseList); + log.debug("Created expense list '{}' for user {}", savedItem.getName(), authenticatedUser.getId()); return new ResponseEntity<>(savedItem, HttpStatus.CREATED); } @@ -106,7 +107,7 @@ public class ExpenseListController { AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); Optional expenseListOpt = expenseListService.findById(expenseListId); if (expenseListOpt.isEmpty()) - return new ResponseEntity<>(null, HttpStatus.NOT_FOUND); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, expenseListOpt.get()); AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); diff --git a/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java b/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java index 1ae17fc..19a8a19 100644 --- a/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java +++ b/src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java @@ -6,7 +6,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; @Configuration @@ -18,7 +18,7 @@ public class SecurityConfig { http .authorizeHttpRequests(auth -> auth .anyRequest().permitAll()) - .csrf().disable(); + .csrf(csrf -> csrf.disable()); return http.build(); } @@ -33,7 +33,7 @@ public class SecurityConfig { .jwt(Customizer.withDefaults())) .oauth2Login(Customizer.withDefaults()) .addFilterAfter(new RateLimitFilter(), BearerTokenAuthenticationFilter.class) - .csrf().disable(); + .csrf(csrf -> csrf.disable()); return http.build(); } diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java index 0f65b6e..20d8177 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -31,18 +31,10 @@ public class ExpenseListService { this.customCategoryRepository = customCategoryRepository; } - public List getAllLists() { - return repository.findAll(); - } - public ExpenseList createList(ExpenseList list) { return repository.save(list); } - public void deleteList(Long id) { - repository.deleteById(id); - } - public void deleteById(Long id) { repository.deleteById(id); } @@ -51,10 +43,6 @@ public class ExpenseListService { return repository.findById(id); } - public Iterable findAll() { - return repository.findAll(); - } - public ExpenseList save(ExpenseList expenseList) { return repository.save(expenseList); } diff --git a/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java b/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java index 23bce57..97ddbc5 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/ExpenseListRepositoryTest.java @@ -4,7 +4,7 @@ import de.zendric.app.xpensely_server.model.ExpenseList; import de.zendric.app.xpensely_server.repo.ExpenseListRepository; 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.boot.data.jpa.test.autoconfigure.DataJpaTest; import java.util.Optional; diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java index 94dbd6d..b99093c 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java @@ -6,8 +6,11 @@ import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; 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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration; +import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -18,7 +21,11 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -@WebMvcTest(AppUserController.class) +@WebMvcTest(value = AppUserController.class, excludeAutoConfiguration = { + OAuth2ClientAutoConfiguration.class, + OAuth2ClientWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class +}) @AutoConfigureMockMvc(addFilters = false) @ActiveProfiles("test") class AppUserControllerTest { diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java index 22418e3..5c50950 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -9,8 +9,11 @@ import de.zendric.app.xpensely_server.services.ExpenseListService; import de.zendric.app.xpensely_server.services.UserService; 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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration; +import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -24,7 +27,11 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -@WebMvcTest(ExpenseListController.class) +@WebMvcTest(value = ExpenseListController.class, excludeAutoConfiguration = { + OAuth2ClientAutoConfiguration.class, + OAuth2ClientWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class +}) @AutoConfigureMockMvc(addFilters = false) @ActiveProfiles("test") class ExpenseListControllerTest { From 9c35bb84351f108452945b0a51522246d146526a Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sat, 9 May 2026 23:49:56 +0200 Subject: [PATCH 32/43] docs: scaffold API.md with section headings --- docs/API.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/API.md diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..41ab27d --- /dev/null +++ b/docs/API.md @@ -0,0 +1,55 @@ +# Xpensely Server — API Reference + +> Last updated: 2026-05-09 · Branch: `feature/security-hardening` + +## Table of Contents +1. [Overview](#1-overview) +2. [Authentication](#2-authentication) +3. [Rate Limiting](#3-rate-limiting) +4. [Endpoints](#4-endpoints) + - 4.1 [Home](#41-home) + - 4.2 [Users](#42-users) + - 4.3 [Expense Lists](#43-expense-lists) +5. [Data Models](#5-data-models) +6. [Error Handling](#6-error-handling) +7. [Recent Changes — `feature/security-hardening`](#7-recent-changes) + +--- + +## 1. Overview + +_TODO_ + +## 2. Authentication + +_TODO_ + +## 3. Rate Limiting + +_TODO_ + +## 4. Endpoints + +### 4.1 Home + +_TODO_ + +### 4.2 Users + +_TODO_ + +### 4.3 Expense Lists + +_TODO_ + +## 5. Data Models + +_TODO_ + +## 6. Error Handling + +_TODO_ + +## 7. Recent Changes — `feature/security-hardening` + +_TODO_ From 8fb1820bc7c0e2d767c0eb243ff5a09c173f2f70 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sat, 9 May 2026 23:50:42 +0200 Subject: [PATCH 33/43] docs: write API overview section --- docs/API.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 41ab27d..50808e5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -18,7 +18,20 @@ ## 1. Overview -_TODO_ +Xpensely Server is a Spring Boot REST API that manages shared expense lists for pairs of users. It uses Google OAuth2 JWT tokens for authentication. All protected endpoints require a valid Bearer token in the `Authorization` header. + +**Base URL (local dev):** `http://localhost:8080` + +**Content-Type:** `application/json` for all request and response bodies. + +**Public endpoints (no auth required):** +| Method | Path | Description | +|--------|------|-------------| +| GET | `/` | Health check — returns `"Welcome"` | +| POST | `/api/users/createUser` | Register a new user | +| GET | `/api/users/byName` | Look up a user by username | + +All other endpoints require authentication (see [Section 2](#2-authentication)). ## 2. Authentication From 9b93cd97a6fba5cc4e77aaf93613302721622fe7 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sat, 9 May 2026 23:56:14 +0200 Subject: [PATCH 34/43] docs: write authentication section --- docs/API.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 50808e5..900168f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -35,7 +35,32 @@ All other endpoints require authentication (see [Section 2](#2-authentication)). ## 2. Authentication -_TODO_ +The server uses **OAuth2 Resource Server** authentication with **Google ID JWT tokens**. + +### How it works + +1. The client authenticates with Google and receives a Google ID JWT. +2. Every protected API request must include this token in the header: + +``` +Authorization: Bearer +``` + +3. The server validates the JWT signature and extracts the `sub` claim (Google User ID). +4. The `sub` value is used to look up the registered `AppUser` in the database via `AuthenticatedUserResolver`. +5. If no `AppUser` exists for that Google ID, the request is rejected with **403 Forbidden**. + +### Test profile + +When the application runs under the `test` Spring profile (`-Dspring.profiles.active=test`), **all security is disabled** — every endpoint is accessible without a token. This is used for automated tests only. + +### User registration flow + +Before a user can call any protected endpoint they must first be registered: + +1. Authenticate with Google to obtain a Google ID JWT. +2. Call `POST /api/users/createUser` with the JWT's `sub` value as `googleId` and a chosen `username`. +3. All subsequent protected calls use the same JWT — the server resolves the caller automatically. ## 3. Rate Limiting From 18e740bb73cc0458e9b393bc978e591b3f652658 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 20:13:58 +0200 Subject: [PATCH 35/43] docs: write rate limiting section --- docs/API.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 900168f..c4ee4c8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -64,7 +64,24 @@ Before a user can call any protected endpoint they must first be registered: ## 3. Rate Limiting -_TODO_ +All requests pass through a `RateLimitFilter` (implemented with **Bucket4j**). + +| Setting | Value | +|---------|-------| +| Limit | 60 requests per minute | +| Window | Rolling 1-minute bucket | +| Key (authenticated) | JWT `sub` claim (Google User ID) | +| Key (unauthenticated) | `X-Forwarded-For` header, falling back to remote IP | + +When the limit is exceeded the server responds with: + +``` +HTTP 429 Too Many Requests +``` + +No `Retry-After` header is currently returned. Clients should back off and retry after 60 seconds. + +> **Note:** Rate limiting applies in the `!test` profile only. Tests run without rate limiting. ## 4. Endpoints From 2b84ed0de87e0a65273f5ac22abfa9e5d228994e Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 20:14:22 +0200 Subject: [PATCH 36/43] docs: write home and users endpoint sections --- docs/API.md | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/docs/API.md b/docs/API.md index c4ee4c8..b9df55d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -87,11 +87,121 @@ No `Retry-After` header is currently returned. Clients should back off and retry ### 4.1 Home -_TODO_ +#### `GET /` + +Health check. No authentication required. + +**Response:** `200 OK` +``` +Welcome +``` + +--- ### 4.2 Users -_TODO_ +Base path: `/api/users` + +--- + +#### `POST /api/users/createUser` — Register a user + +**Auth required:** No + +**Request body:** +```json +{ + "username": "alice", + "googleId": "118400012345678901234" +} +``` + +| Field | Type | Constraints | +|-------|------|-------------| +| `username` | String | Required. 3–30 chars. Pattern: `^[a-zA-Z0-9_.\-]+$` | +| `googleId` | String | Required. Non-blank. Must match the JWT `sub` from Google. | + +**Success response:** `200 OK` — returns the created [AppUser](#appuser) object. + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 400 | Validation failure (field errors returned as `{"fieldName": "message"}`) | +| 409 | `username` already taken | + +--- + +#### `GET /api/users` — Get user by ID + +**Auth required:** Yes + +**Query params:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | Long | Yes | Database ID of the user | + +**Success response:** `200 OK` — returns [AppUser](#appuser). + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 404 | No user found for `id` | + +--- + +#### `GET /api/users/byName` — Get user by username + +**Auth required:** No + +**Query params:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `username` | String | Yes | Exact username (case-sensitive) | + +**Success response:** `200 OK` — returns [AppUser](#appuser). + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 404 | No user found for `username` | + +--- + +#### `GET /api/users/byGoogleId` — Get user by Google ID + +**Auth required:** Yes + +**Query params:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | String | Yes | Google `sub` claim | + +**Success response:** `200 OK` — returns [AppUser](#appuser). + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 404 | No user found for that Google ID | + +--- + +#### `DELETE /api/users` — Delete a user + +**Auth required:** Yes + +**Query params:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | Long | Yes | Database ID of the user to delete | + +**Success response:** `200 OK` — returns the deleted [AppUser](#appuser). + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 404 | No user found for `id` | + +--- ### 4.3 Expense Lists From ddf64305a5153fce9fe1cc090c4f0d20fb2fdf5b Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 20:17:22 +0200 Subject: [PATCH 37/43] docs: write expense lists endpoint section --- docs/API.md | 270 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 269 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index b9df55d..8ba848b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -205,7 +205,275 @@ Base path: `/api/users` ### 4.3 Expense Lists -_TODO_ +Base path: `/api/expenselist` + +All endpoints in this group require authentication. The authenticated caller is resolved from the JWT and used for ownership checks. + +--- + +#### `GET /api/expenselist/mine` — Get caller's expense lists + +Returns all expense lists where the caller is the owner **or** has been shared the list. + +**Auth required:** Yes + +**Request body:** None + +**Success response:** `200 OK` — array of [ExpenseList](#expenselist). + +```json +[ + { + "id": 1, + "name": "Holiday Trip", + "inviteCode": null, + "owner": { "id": 1, "username": "alice" }, + "sharedWith": null, + "expenses": [], + "customCategories": [] + } +] +``` + +--- + +#### `GET /api/expenselist/byId` — Get expense list by ID + +**Auth required:** Yes + +**Query params:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | Long | Yes | Expense list database ID | + +The caller must be the owner or the shared user of the list. + +**Success response:** `200 OK` — returns [ExpenseList](#expenselist). + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 403 | Caller is neither owner nor shared user | +| 404 | No list found for `id` | + +--- + +#### `POST /api/expenselist/create` — Create an expense list + +**Auth required:** Yes + +**Request body:** +```json +{ + "name": "Road Trip 2026" +} +``` + +| Field | Type | Constraints | +|-------|------|-------------| +| `name` | String | Required. Max 100 chars. | + +The authenticated caller becomes the `owner` of the new list. The list is initialised with the default Xpensely standard categories. + +**Success response:** `201 Created` — returns the created [ExpenseList](#expenselist). + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 400 | Validation failure | + +--- + +#### `DELETE /api/expenselist/{id}` — Delete an expense list + +**Auth required:** Yes + +**Path params:** +| Param | Type | Description | +|-------|------|-------------| +| `id` | Long | Expense list database ID | + +Only the **owner** may delete a list. Deleting a list cascades to all its expenses and custom categories. + +**Success response:** `204 No Content` + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 403 | Caller is not the owner | +| 404 | No list found for `id` | + +--- + +#### `POST /api/expenselist/{id}/add` — Add an expense to a list + +**Auth required:** Yes + +**Path params:** +| Param | Type | Description | +|-------|------|-------------| +| `id` | Long | Expense list database ID | + +**Request body:** +```json +{ + "title": "Dinner", + "owner": "alice", + "amount": 42.50, + "personalUseAmount": 21.25, + "otherPersonAmount": 21.25, + "date": "2026-05-09", + "category": "Food" +} +``` + +| Field | Type | Constraints | +|-------|------|-------------| +| `title` | String | Required. Max 100 chars. | +| `owner` | String | Required. Username of the person who paid. | +| `amount` | Double | Required. Min 0.01. | +| `personalUseAmount` | Double | Optional. Caller's share. | +| `otherPersonAmount` | Double | Optional. Other person's share. | +| `date` | String (ISO-8601) | Required. Format: `YYYY-MM-DD`. | +| `category` | String | Required. Non-blank category name. | + +**Success response:** `200 OK` — returns the created [Expense](#expense). + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 400 | Validation failure | +| 403 | Caller is not a member (owner or sharedWith) of the list | +| 404 | List not found | + +--- + +#### `PUT /api/expenselist/{id}/update` — Update an expense + +**Auth required:** Yes + +**Path params:** +| Param | Type | Description | +|-------|------|-------------| +| `id` | Long | Expense list database ID | + +**Request body:** +```json +{ + "id": 7, + "title": "Dinner (updated)", + "ownerName": "alice", + "amount": 50.00, + "personalUseAmount": 25.00, + "otherPersonAmount": 25.00, + "date": "2026-05-09", + "category": "Food" +} +``` + +| Field | Type | Constraints | +|-------|------|-------------| +| `id` | Long | Required. ID of the expense to update. | +| `title` | String | Required. Max 100 chars. | +| `ownerName` | String | Required. Username of the payer. | +| `amount` | Double | Required. Min 0.01. | +| `personalUseAmount` | Double | Optional. | +| `otherPersonAmount` | Double | Optional. | +| `date` | String (ISO-8601) | Required. `YYYY-MM-DD`. | +| `category` | String | Required. | + +Caller must be a member of the list. Expense must belong to the specified list. + +**Success response:** `200 OK` — returns the updated [Expense](#expense). + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 400 | Validation failure or expense does not belong to this list | +| 403 | Caller is not a member of the list | +| 404 | List or expense not found | + +--- + +#### `DELETE /api/expenselist/{id}/delete` — Remove an expense from a list + +**Auth required:** Yes + +**Path params:** +| Param | Type | Description | +|-------|------|-------------| +| `id` | Long | Expense list database ID | + +**Query params:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `expenseId` | Long | Yes | ID of the expense to remove | + +Caller must be a member of the list. + +**Success response:** `200 OK` — returns the deleted [Expense](#expense). + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 403 | Caller is not a member of the list | +| 404 | List or expense not found | + +--- + +#### `POST /api/expenselist/{listId}/invite` — Generate an invite code + +Generates (or refreshes) a 6-character uppercase invite code for the list, valid for **1 week**. + +**Auth required:** Yes + +**Path params:** +| Param | Type | Description | +|-------|------|-------------| +| `listId` | Long | Expense list database ID | + +Caller must be the **owner** of the list. + +**Success response:** `200 OK` — returns the invite code as a plain string. + +``` +AB3X7Q +``` + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 403 | Caller is not the owner | +| 404 | List not found | + +--- + +#### `POST /api/expenselist/accept-invite` — Accept an invite + +Joins the caller to a shared expense list using an invite code. + +**Auth required:** Yes + +**Request body:** +```json +{ + "inviteCode": "AB3X7Q" +} +``` + +| Field | Type | Constraints | +|-------|------|-------------| +| `inviteCode` | String | Required. Exactly 6 characters. | + +**Success response:** `200 OK` — returns the [ExpenseList](#expenselist) the caller joined. + +**Error responses:** +| Status | Condition | +|--------|-----------| +| 400 | Validation failure or invite code not found / expired | +| 403 | Caller is already the owner of this list | + +--- ## 5. Data Models From 2782823c3deca1efb3e20a26d7a4e5f0e77ba405 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 20:17:55 +0200 Subject: [PATCH 38/43] docs: write data models section --- docs/API.md | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 8ba848b..67d531d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -477,7 +477,124 @@ Joins the caller to a shared expense list using an invite code. ## 5. Data Models -_TODO_ +### AppUser + +Returned by all `/api/users` endpoints. Sensitive fields (`googleId`, `createdAt`) are hidden from API responses via `@JsonIgnore`. + +```json +{ + "id": 1, + "username": "alice" +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | Long | Auto-generated primary key | +| `username` | String | Unique. 3–30 chars. | + +--- + +### ExpenseList + +```json +{ + "id": 1, + "name": "Road Trip", + "inviteCode": "AB3X7Q", + "owner": { "id": 1, "username": "alice" }, + "sharedWith": { "id": 2, "username": "bob" }, + "xpenselyStandardCategories": { + "id": 1, + "categories": [ + { "id": 1, "name": "Food", "colorCode": "#FF5733" } + ] + }, + "customCategories": [], + "expenses": [] +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | Long | Auto-generated primary key | +| `name` | String | Display name of the list | +| `inviteCode` | String \| null | 6-char code; `null` if not yet generated or expired | +| `owner` | AppUser | User who created the list | +| `sharedWith` | AppUser \| null | Second member of the list; `null` until an invite is accepted | +| `xpenselyStandardCategories` | XpenselyStandardCategories | Default category set assigned at creation | +| `customCategories` | XpenselyCustomCategory[] | User-defined categories, ordered A→Z | +| `expenses` | Expense[] | All expenses, ordered by date ASC then id ASC | + +> `inviteCodeExpiration` is hidden from API responses (`@JsonIgnore`). + +--- + +### Expense + +```json +{ + "id": 7, + "title": "Dinner", + "owner": { "id": 1, "username": "alice" }, + "amount": 42.50, + "personalUseAmount": 21.25, + "otherPersonAmount": 21.25, + "category": "Food", + "date": "2026-05-09" +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | Long | Auto-generated | +| `title` | String | Description of the expense | +| `owner` | AppUser | Who paid | +| `amount` | Double | Total amount. Min 0.01. | +| `personalUseAmount` | Double \| null | Caller's share | +| `otherPersonAmount` | Double \| null | Other member's share | +| `category` | String | Category name (free text — must match a category on the list) | +| `date` | String (ISO-8601) | `YYYY-MM-DD` | + +> The `expenseList` back-reference is excluded via `@JsonBackReference` to prevent circular serialisation. + +--- + +### XpenselyStandardCategories + +A named group of standard categories assigned to every new expense list. + +```json +{ + "id": 1, + "categories": [ + { "id": 1, "name": "Food", "colorCode": "#FF5733" }, + { "id": 2, "name": "Transport", "colorCode": "#3498DB" } + ] +} +``` + +--- + +### XpenselyCustomCategory + +A user-defined category attached to a specific expense list. + +```json +{ + "id": 5, + "name": "Souvenirs", + "colorCode": "#9B59B6" +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `id` | Long | Auto-generated | +| `name` | String | Category label | +| `colorCode` | String | 7-char hex string e.g. `#RRGGBB` | + +> The `expenseList` back-reference is excluded via `@JsonBackReference`. ## 6. Error Handling From 7189e4fb08a4892c54881ee570ce89d8c1ddbd1c Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 20:18:28 +0200 Subject: [PATCH 39/43] docs: write error handling section --- docs/API.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 67d531d..95d2534 100644 --- a/docs/API.md +++ b/docs/API.md @@ -598,7 +598,37 @@ A user-defined category attached to a specific expense list. ## 6. Error Handling -_TODO_ +All errors are returned as JSON. The `GlobalExceptionHandler` (`@RestControllerAdvice`) maps exceptions to HTTP status codes consistently across every endpoint. + +### Error response format + +Most errors: +```json +{ + "error": "Human-readable message" +} +``` + +Validation errors (`400`): +```json +{ + "username": "size must be between 3 and 30", + "googleId": "must not be blank" +} +``` + +### Status code reference + +| HTTP Status | Condition | Source | +|-------------|-----------|--------| +| 400 Bad Request | Input validation failed (missing/invalid fields) | `MethodArgumentNotValidException` via `@Valid` | +| 400 Bad Request | Business rule violation (e.g. expense not in this list) | `IllegalArgumentException` | +| 403 Forbidden | User not registered in the system | `AuthenticatedUserResolver` → `ResponseStatusException(FORBIDDEN)` | +| 403 Forbidden | Ownership check failed (e.g. deleting someone else's list) | `ResponseStatusException(FORBIDDEN)` in controller | +| 404 Not Found | Entity does not exist (user, list, expense) | `ResourceNotFoundException` | +| 409 Conflict | Username already taken | `UsernameAlreadyExistsException` | +| 429 Too Many Requests | Rate limit exceeded | `RateLimitFilter` (returned directly, not via exception handler) | +| 500 Internal Server Error | Unexpected runtime or generic exception | `RuntimeException` / `Exception` — message is hidden from client | ## 7. Recent Changes — `feature/security-hardening` From a5e5824a446cca494cbf896243e21bcbce3271c9 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 20:18:58 +0200 Subject: [PATCH 40/43] docs: write recent changes section --- docs/API.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 95d2534..68fe07c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -632,4 +632,73 @@ Validation errors (`400`): ## 7. Recent Changes — `feature/security-hardening` -_TODO_ +This section summarises API-visible changes introduced on the `feature/security-hardening` branch (commits `3d456f2` → `b1324e3` → `8b96433` → `f0de751`). A developer or agent reading this document should be aware of these differences compared to the `main` branch. + +--- + +### New: Centralised error handling (`GlobalExceptionHandler`) + +**File:** `src/main/java/.../controller/GlobalExceptionHandler.java` + +All error responses now follow the consistent JSON shape described in [Section 6](#6-error-handling). Previously, individual controllers returned ad-hoc responses, including HTTP 417 and raw stack trace output. Both have been removed. SLF4J logging has been added for server-side error visibility. + +--- + +### New: `AuthenticatedUserResolver` + +**File:** `src/main/java/.../security/AuthenticatedUserResolver.java` + +A new component that extracts the Google ID from the JWT `sub` claim and resolves the corresponding `AppUser`. Injected into all protected controller methods. Returns `403 Forbidden` if the token is valid but the user has never called `POST /api/users/createUser`. + +--- + +### New: `RateLimitFilter` — 60 req/min + +**File:** `src/main/java/.../security/RateLimitFilter.java` + +Rate limiting was not present on `main`. See [Section 3](#3-rate-limiting) for full details. + +--- + +### New: `CreateExpenseListRequest` DTO with validation + +**File:** `src/main/java/.../model/CreateExpenseListRequest.java` + +`POST /api/expenselist/create` previously accepted a raw `ExpenseList` body. It now requires a `CreateExpenseListRequest` with a single validated `name` field (`@NotBlank`, `@Size(max=100)`). Clients must update their request body shape. + +--- + +### Updated: Validation constraints tightened on existing DTOs + +| DTO | Field | Change | +|-----|-------|--------| +| `AppUserCreateRequest` | `username` | Added `@Pattern(regexp="^[a-zA-Z0-9_.\-]+$")` — special characters (other than `_`, `.`, `-`) now rejected | +| `InviteRequest` | `inviteCode` | Added `@Size(min=6, max=6)` — non-6-char codes now rejected at binding | +| `ExpenseInput` | `amount` | Added `@DecimalMin("0.01")` — zero and negative amounts now rejected | +| `ExpenseChangeRequest` | `amount` | Added `@DecimalMin("0.01")` — zero and negative amounts now rejected | + +--- + +### Updated: `SecurityConfig` — test-profile isolation + +**File:** `src/main/java/.../security/SecurityConfig.java` + +Two separate `SecurityFilterChain` beans now exist: +- `@Profile("test")` — all endpoints permitted, CSRF disabled (for automated tests only). +- `@Profile("!test")` — full JWT/OAuth2 enforcement (production behaviour). + +Previously a single config was used for both, which made tests fragile. + +--- + +### Fixed: `ExpenseListService` — JPQL and exception consistency + +- Single `@Param` used in all JPQL queries (multi-param variant was broken). +- `ResourceNotFoundException` is now thrown consistently instead of returning `null` or a generic `RuntimeException`. +- `addExpenseToList` delegates directly to `ExpenseList.addExpense()` rather than looping. + +--- + +### Fixed: `UserService` — null returns replaced with exceptions + +All not-found paths in `UserService` now throw `ResourceNotFoundException` instead of returning `null`. Callers that previously needed to null-check responses will now receive a `404` response. From 46c8df45d6ef0642690a030ef7aa1a3e64f7fb78 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 21:24:10 +0200 Subject: [PATCH 41/43] ci: test pipeline From 5549691d5068922f703e64ccfc5c5afc2658c381 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 21:24:18 +0200 Subject: [PATCH 42/43] ci: test pipeline From 0876eecf50c34adb279c68fd9a680d320ed8844c Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 21:27:28 +0200 Subject: [PATCH 43/43] ci: improve dev pipeline with Dokploy webhook and Docker layer caching --- .gitea/workflows/build.yml | 58 ++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 0aa927d..e2dfadd 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -12,43 +12,45 @@ jobs: steps: # 1. Checkout the code - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - # 2. Set up Java and Maven + # 2. Set up Java and Maven (includes Maven, no separate install needed) - name: Set up JDK (Eclipse Temurin) - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: "temurin" java-version: "17" cache: maven - # 3. Verify Maven installation - - name: Install Maven - run: | - sudo apt-get update - sudo apt-get install -y maven - mvn -version - - # 4. Build the Spring Boot application + # 3. Build the Spring Boot application - name: Build Spring Boot Application - run: | - mvn clean package -DskipTests + run: mvn clean package -DskipTests - # 5. Set up Docker - - name: Set up Docker - run: | - docker --version + # 4. Set up Docker Buildx (enables layer caching) + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - # 6. Build the Docker image - - name: Build and Package Docker Image - run: | - docker build -t tea.zendric.de/cedric/xpensely-server:latest . - - # 7. Docker login + # 5. Docker login - name: Login to Docker Registry + uses: docker/login-action@v3 + with: + registry: tea.zendric.de + username: ${{ secrets.TEAUSER }} + password: ${{ secrets.TEAPASSWORD }} + + # 6. Build and push Docker image with layer caching + - name: Build and Push Docker Image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: tea.zendric.de/cedric/xpensely-server:latest + cache-from: type=registry,ref=tea.zendric.de/cedric/xpensely-server:buildcache + cache-to: type=registry,ref=tea.zendric.de/cedric/xpensely-server:buildcache,mode=max + + # 7. Trigger Dokploy to redeploy the dev server automatically + - name: Trigger Dokploy Redeploy run: | - echo "${{ secrets.TEAPASSWORD }}" | docker login tea.zendric.de -u ${{ secrets.TEAUSER }} --password-stdin - # 8. Push Docker image - - name: Push the Docker Image to registry - run: | - docker push tea.zendric.de/cedric/xpensely-server:latest + curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}" \ + -H "Authorization: Bearer ${{ secrets.DOKPLOY_TOKEN }}" \ + --fail