From 936140e76fcd8e50ea97058feff87327659092bd Mon Sep 17 00:00:00 2001 From: Cedric Date: Sun, 11 May 2025 10:15:15 +0200 Subject: [PATCH 01/20] dockerCompose backup --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index dad15a4..a59c481 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,7 @@ services: - xpensely-network volumes: - db_data:/var/lib/postgresql/data + - /mnt/user/appdata/xpensely_backups:/backups restart: unless-stopped healthcheck: test: -- 2.52.0 From 726be3f61336e9f8ac33fbd63b57fba723ca0b99 Mon Sep 17 00:00:00 2001 From: Cedric Date: Fri, 11 Jul 2025 14:16:05 +0200 Subject: [PATCH 02/20] bu commit --- README.md | 7 ++++ apiDesign.drawio | 86 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9c1204 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Run it locally: + +1. build the current state: + mvn clean install or mvn clean install -DskipTests + +2. docker it up and run it + docker-compose -f dev-docker-compose.yml up --build diff --git a/apiDesign.drawio b/apiDesign.drawio index e276b67..f82c248 100644 --- a/apiDesign.drawio +++ b/apiDesign.drawio @@ -1,6 +1,6 @@ - + @@ -193,6 +193,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- 2.52.0 From 4a04c85fe80d0ab49c4038406cb5a8aa47f2ca82 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 21:40:25 +0200 Subject: [PATCH 03/20] ci: use Maven wrapper instead of mvn to fix command not found on Gitea runner --- .gitea/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index e2dfadd..36feade 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - # 2. Set up Java and Maven (includes Maven, no separate install needed) + # 2. Set up Java (Maven wrapper handles Maven version automatically) - name: Set up JDK (Eclipse Temurin) uses: actions/setup-java@v4 with: @@ -22,9 +22,9 @@ jobs: java-version: "17" cache: maven - # 3. Build the Spring Boot application + # 3. Build the Spring Boot application using the Maven wrapper - name: Build Spring Boot Application - run: mvn clean package -DskipTests + run: ./mvnw clean package -DskipTests # 4. Set up Docker Buildx (enables layer caching) - name: Set up Docker Buildx -- 2.52.0 From 0e63b6e4e6e29c223762aec5fa894b1974be88c6 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 22:07:30 +0200 Subject: [PATCH 04/20] ci: fix mvnw executable permission --- mvnw | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 mvnw diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 -- 2.52.0 From b221d07b487b2e76cb572fefcf90729ac8aca177 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 22:08:23 +0200 Subject: [PATCH 05/20] ci: use ubuntu-java17 runner image, remove setup-java step --- .gitea/workflows/build.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 36feade..4f62b27 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -7,22 +7,14 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-java17 steps: # 1. Checkout the code - name: Checkout code uses: actions/checkout@v4 - # 2. Set up Java (Maven wrapper handles Maven version automatically) - - name: Set up JDK (Eclipse Temurin) - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "17" - cache: maven - - # 3. Build the Spring Boot application using the Maven wrapper + # 2. Build the Spring Boot application using the Maven wrapper (Java 17 pre-installed in runner image) - name: Build Spring Boot Application run: ./mvnw clean package -DskipTests -- 2.52.0 From 40b8f45de80219478a22782788805cea3d597075 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 22:15:16 +0200 Subject: [PATCH 06/20] ci: test with Java 21 runner image -- 2.52.0 From ed5543ce61c2f14f26ead4aa7fb0c35821c38ca6 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 22:22:06 +0200 Subject: [PATCH 07/20] ci: fix Java 21 in dockerfile, add Docker Hub login to avoid rate limits --- .gitea/workflows/build.yml | 13 ++++++++++--- dockerfile | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 4f62b27..b21ae88 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -22,7 +22,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # 5. Docker login + # 5. Login to Docker Hub to avoid pull rate limits + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # 6. Docker login to Gitea registry - name: Login to Docker Registry uses: docker/login-action@v3 with: @@ -30,7 +37,7 @@ jobs: username: ${{ secrets.TEAUSER }} password: ${{ secrets.TEAPASSWORD }} - # 6. Build and push Docker image with layer caching + # 7. Build and push Docker image with layer caching - name: Build and Push Docker Image uses: docker/build-push-action@v5 with: @@ -40,7 +47,7 @@ jobs: 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 + # 8. Trigger Dokploy to redeploy the dev server automatically - name: Trigger Dokploy Redeploy run: | curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}" \ diff --git a/dockerfile b/dockerfile index 6fad8f9..e2ff1bb 100644 --- a/dockerfile +++ b/dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:17-jdk +FROM eclipse-temurin:21-jdk COPY ./target/*.jar app.jar -- 2.52.0 From 417eef7042666b43d302998c535d842be711bbbc Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 22:29:40 +0200 Subject: [PATCH 08/20] ci: fix Dokploy webhook, token is in URL not Authorization header --- .gitea/workflows/build.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index b21ae88..66d9651 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -50,6 +50,4 @@ jobs: # 8. Trigger Dokploy to redeploy the dev server automatically - name: Trigger Dokploy Redeploy run: | - curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}" \ - -H "Authorization: Bearer ${{ secrets.DOKPLOY_TOKEN }}" \ - --fail + curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}" --fail -- 2.52.0 From 5cab2fed3b2c04bad60b84ced81c60931841a7f4 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 22:36:44 +0200 Subject: [PATCH 09/20] ci: test Dokploy webhook -- 2.52.0 From 8381cdbffa4618f56ce4fd152258e348d4d5aa3f Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 22:59:53 +0200 Subject: [PATCH 10/20] ci: test full pipeline -- 2.52.0 From b42980200dd886fc2b9cbbde21c5dda8330b1fa0 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 23:38:07 +0200 Subject: [PATCH 11/20] ci: trigger Dokploy redeploy via API after image push --- .gitea/workflows/build.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 66d9651..151f3f5 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -47,7 +47,11 @@ jobs: 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 - # 8. Trigger Dokploy to redeploy the dev server automatically + # 8. Trigger Dokploy to redeploy the dev server automatically via API - name: Trigger Dokploy Redeploy run: | - curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}" --fail + curl -X POST "https://dokploy.zendric.de/api/compose.redeploy" \ + -H "Content-Type: application/json" \ + -H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \ + -d "{\"composeId\": \"${{ secrets.DOKPLOY_COMPOSE_ID }}\"}" \ + --fail -- 2.52.0 From 28809346441d20528ed1451d6b46b41999fcc61b Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sun, 10 May 2026 23:43:13 +0200 Subject: [PATCH 12/20] ci: test full pipeline end-to-end -- 2.52.0 From a3fa59f34794d6d35d820adfd7dc2477eef0a6ff Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 12 May 2026 20:34:42 +0200 Subject: [PATCH 13/20] ci: test full pipeline end-to-end -- 2.52.0 From 29f26a8a1838ab5e83e87f75728ea246e4e55f99 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 12 May 2026 23:08:37 +0200 Subject: [PATCH 14/20] feat: add /api/version endpoint returning build time and version --- pom.xml | 7 +++++++ .../controller/HomeController.java | 17 +++++++++++++++++ .../security/SecurityConfig.java | 1 + 3 files changed, 25 insertions(+) diff --git a/pom.xml b/pom.xml index 357a67e..45528c4 100644 --- a/pom.xml +++ b/pom.xml @@ -128,6 +128,13 @@ + + + + build-info + + + diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/HomeController.java b/src/main/java/de/zendric/app/xpensely_server/controller/HomeController.java index 12a8948..842d575 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/HomeController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/HomeController.java @@ -1,13 +1,30 @@ package de.zendric.app.xpensely_server.controller; +import org.springframework.boot.info.BuildProperties; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; + @RestController class HomeController { + private final BuildProperties buildProperties; + + HomeController(BuildProperties buildProperties) { + this.buildProperties = buildProperties; + } + @GetMapping("/") public String getAll() { return "Welcome"; } + + @GetMapping("/api/version") + public Map version() { + return Map.of( + "version", buildProperties.getVersion(), + "builtAt", buildProperties.getTime().toString() + ); + } } \ No newline at end of file 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 19a8a19..2b85f99 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 @@ -28,6 +28,7 @@ public class SecurityConfig { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/version").permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults())) -- 2.52.0 From 28df2a66ca0c5d9c39df84b4da1c94925f8b45df Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 12 May 2026 23:25:07 +0200 Subject: [PATCH 15/20] fix: always pull latest image on redeploy --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 49c9633..c319a7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: xpensely-server: image: tea.zendric.de/cedric/xpensely-server:latest + pull_policy: always restart: always environment: GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} -- 2.52.0 From f7f2bf5768552b91451364887644d532a23e77c7 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 12 May 2026 23:57:45 +0200 Subject: [PATCH 16/20] ci: switch to compose.deploy to force fresh image pull --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 151f3f5..f658ef1 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -50,7 +50,7 @@ jobs: # 8. Trigger Dokploy to redeploy the dev server automatically via API - name: Trigger Dokploy Redeploy run: | - curl -X POST "https://dokploy.zendric.de/api/compose.redeploy" \ + curl -X POST "https://dokploy.zendric.de/api/compose.deploy" \ -H "Content-Type: application/json" \ -H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \ -d "{\"composeId\": \"${{ secrets.DOKPLOY_COMPOSE_ID }}\"}" \ -- 2.52.0 From 7ad72119a8598286f963d38be9b1333ade77c9ee Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Thu, 14 May 2026 23:46:40 +0200 Subject: [PATCH 17/20] fix secrets no longer needed --- .claude/settings.local.json | 8 ++++ docker-compose.yml | 3 -- docs/API.md | 46 +++++++++++++++---- .../security/SecurityConfig.java | 1 - src/main/resources/application.properties | 2 - 5 files changed, 45 insertions(+), 15 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4bb51fb --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git add *)", + "Bash(git commit *)" + ] + } +} diff --git a/docker-compose.yml b/docker-compose.yml index c319a7d..64b6e0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,6 @@ services: pull_policy: always restart: always environment: - GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} - DB_PORT: 5432 DB_P_NAME: ${POSTGRES_DB} DB_USERNAME: ${POSTGRES_USER} diff --git a/docs/API.md b/docs/API.md index 68fe07c..c4ffb65 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # Xpensely Server — API Reference -> Last updated: 2026-05-09 · Branch: `feature/security-hardening` +> Last updated: 2026-05-14 · Branch: `dev` ## Table of Contents 1. [Overview](#1-overview) @@ -28,6 +28,7 @@ Xpensely Server is a Spring Boot REST API that manages shared expense lists for | Method | Path | Description | |--------|------|-------------| | GET | `/` | Health check — returns `"Welcome"` | +| GET | `/api/version` | Returns build version and timestamp | | POST | `/api/users/createUser` | Register a new user | | GET | `/api/users/byName` | Look up a user by username | @@ -98,6 +99,25 @@ Welcome --- +#### `GET /api/version` + +Returns the application version and build timestamp. No authentication required. + +**Response:** `200 OK` +```json +{ + "version": "0.0.1-SNAPSHOT", + "builtAt": "2026-05-09T10:00:00Z" +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `version` | String | Maven project version | +| `builtAt` | String (ISO-8601) | UTC timestamp of the build | + +--- + ### 4.2 Users Base path: `/api/users` @@ -121,7 +141,7 @@ Base path: `/api/users` | `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. +**Success response:** `201 Created` — returns the created [AppUser](#appuser) object. **Error responses:** | Status | Condition | @@ -145,6 +165,7 @@ Base path: `/api/users` **Error responses:** | Status | Condition | |--------|-----------| +| 403 | Authenticated user's ID does not match the requested `id` | | 404 | No user found for `id` | --- @@ -181,6 +202,7 @@ Base path: `/api/users` **Error responses:** | Status | Condition | |--------|-----------| +| 403 | Requested Google ID does not match the authenticated user's Google ID | | 404 | No user found for that Google ID | --- @@ -194,11 +216,12 @@ Base path: `/api/users` |-------|------|----------|-------------| | `id` | Long | Yes | Database ID of the user to delete | -**Success response:** `200 OK` — returns the deleted [AppUser](#appuser). +**Success response:** `200 OK` — returns a plain string: `"User deleted: "`. **Error responses:** | Status | Condition | |--------|-----------| +| 403 | Authenticated user's ID does not match the requested `id` | | 404 | No user found for `id` | --- @@ -219,7 +242,11 @@ Returns all expense lists where the caller is the owner **or** has been shared t **Request body:** None -**Success response:** `200 OK` — array of [ExpenseList](#expenselist). +**Success responses:** +| Status | Condition | +|--------|-----------| +| 200 OK | Returns array of [ExpenseList](#expenselist) | +| 204 No Content | Caller has no expense lists | ```json [ @@ -337,7 +364,7 @@ Only the **owner** may delete a list. Deleting a list cascades to all its expens | `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). +**Success response:** `201 Created` — returns the created [Expense](#expense). **Error responses:** | Status | Condition | @@ -411,7 +438,7 @@ Caller must be a member of the list. Expense must belong to the specified list. Caller must be a member of the list. -**Success response:** `200 OK` — returns the deleted [Expense](#expense). +**Success response:** `204 No Content` **Error responses:** | Status | Condition | @@ -465,13 +492,14 @@ Joins the caller to a shared expense list using an invite code. |-------|------|-------------| | `inviteCode` | String | Required. Exactly 6 characters. | -**Success response:** `200 OK` — returns the [ExpenseList](#expenselist) the caller joined. +**Success response:** `200 OK` — returns a plain string: `"User added to the list"`. **Error responses:** | Status | Condition | |--------|-----------| -| 400 | Validation failure or invite code not found / expired | -| 403 | Caller is already the owner of this list | +| 400 | Validation failure or caller is already the owner of the list | +| 404 | Invite code not found or expired | +| 226 IM Used | List already has a second member (`sharedWith` is not null) | --- 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 2b85f99..97ef8b8 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 @@ -32,7 +32,6 @@ public class SecurityConfig { .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults())) - .oauth2Login(Customizer.withDefaults()) .addFilterAfter(new RateLimitFilter(), BearerTokenAuthenticationFilter.class) .csrf(csrf -> csrf.disable()); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 61116e5..ad7db8d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,8 +3,6 @@ spring.application.name=XpenselyServer #Security spring.security.enabled=false -spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} -spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} spring.security.oauth2.resourceserver.jwt.issuer-uri=https://accounts.google.com # PostgreSQL Configuration -- 2.52.0 From f958fb785320e07a6d95dc896ae89f159e3d6782 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sat, 4 Jul 2026 17:31:21 +0200 Subject: [PATCH 18/20] Fixed Auth and Connection issues --- .gitignore | 5 + docs/xpensely-api.http | 148 +++++++++++++++++ .../xpensely_server/model/ExpenseInput.java | 4 +- .../security/AuthenticatedUserResolver.java | 9 +- .../config/TestJwtDecoderConfig.java | 24 +++ .../AuthenticatedUserResolverTest.java | 4 +- .../SecurityFilterChainIntegrationTest.java | 153 ++++++++++++++++++ .../resources/META-INF/build-info.properties | 5 + .../application-integration.properties | 10 ++ 9 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 docs/xpensely-api.http create mode 100644 src/test/java/de/zendric/app/xpensely_Server/config/TestJwtDecoderConfig.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/security/SecurityFilterChainIntegrationTest.java create mode 100644 src/test/resources/META-INF/build-info.properties create mode 100644 src/test/resources/application-integration.properties diff --git a/.gitignore b/.gitignore index 8c660d3..27fbf8f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ target/ !**/src/test/**/target/ .env +# Local debugging (not for CI/prod) +.env.local +Dockerfile.local +compose.local.yml + ### STS ### .apt_generated .classpath diff --git a/docs/xpensely-api.http b/docs/xpensely-api.http new file mode 100644 index 0000000..a5f6835 --- /dev/null +++ b/docs/xpensely-api.http @@ -0,0 +1,148 @@ +# Xpensely API — Manual test file (IntelliJ HTTP Client / VS Code REST Client) +# +# HOW TO GET A TOKEN: +# 1. Run the Flutter dev app and sign in with Google +# 2. Add a breakpoint or log in auth_interceptor.dart after _storage.read(key: 'id_token') +# 3. Paste the token value below (valid for ~1 hour) +# +# Switch between dev and prod by changing @baseUrl. + +@devBaseUrl = https://dev-xpensely.zendric.de/api +@prodBaseUrl = https://xpensely.zendric.de/api +@baseUrl = {{devBaseUrl}} + +# Paste your Google ID token here: +@token = PASTE_ID_TOKEN_HERE + +# Your Google sub (from the JWT payload — decode at jwt.io): +@googleId = PASTE_GOOGLE_SUB_HERE + + +### ─── Health ──────────────────────────────────────────────────────────────── + +### Version (no auth required — should always return 200) +GET {{baseUrl}}/version + +### ─── Auth probe ───────────────────────────────────────────────────────────── + +### Any protected endpoint without a token — expect 401 +GET {{baseUrl}}/users/byGoogleId?id={{googleId}} + +### ─── Users ────────────────────────────────────────────────────────────────── + +### Look up your account by Google ID — expect 200 if account exists, 403 if not +GET {{baseUrl}}/users/byGoogleId?id={{googleId}} +Authorization: Bearer {{token}} + +### + +### Get your account by DB id — replace {id} with your numeric user id +GET {{baseUrl}}/users?id=1 +Authorization: Bearer {{token}} + +### + +### Get any user by username (no auth required) +GET {{baseUrl}}/users/byName?username=testuser + +### + +### Create a new account (requires valid JWT — account must not exist yet) +POST {{baseUrl}}/users/createUser +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "username": "myusername", + "googleId": "{{googleId}}" +} + +### + +### Delete your account — replace {id} with your numeric user id +DELETE {{baseUrl}}/users?id=1 +Authorization: Bearer {{token}} + +### ─── Expense Lists ─────────────────────────────────────────────────────────── + +### Get all your expense lists — expect 200 (with data) or 204 (empty) +GET {{baseUrl}}/expenselist/mine +Authorization: Bearer {{token}} + +### + +### Get a specific expense list by id +GET {{baseUrl}}/expenselist/byId?id=1 +Authorization: Bearer {{token}} + +### + +### Create a new expense list +POST {{baseUrl}}/expenselist/create +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "name": "Groceries" +} + +### + +### Add an expense to a list — replace {listId} in the URL +POST {{baseUrl}}/expenselist/1/add +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "title": "Coffee", + "owner": "myusername", + "amount": 4.50, + "date": "2026-05-15", + "category": "Food" +} + +### + +### Update an expense in a list +PUT {{baseUrl}}/expenselist/1/update +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "id": 1, + "title": "Coffee (updated)", + "ownerName": "myusername", + "amount": 5.00, + "date": "2026-05-15", + "category": "Food", + "expenseListId": 1 +} + +### + +### Delete an expense from a list +DELETE {{baseUrl}}/expenselist/1/delete?expenseId=1 +Authorization: Bearer {{token}} + +### + +### Generate an invite code for a list (you must be the owner) +POST {{baseUrl}}/expenselist/1/invite +Authorization: Bearer {{token}} + +### + +### Accept an invite +POST {{baseUrl}}/expenselist/accept-invite +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "inviteCode": "PASTE_6_CHAR_CODE_HERE" +} + +### + +### Delete an expense list (you must be the owner) +DELETE {{baseUrl}}/expenselist/1 +Authorization: Bearer {{token}} 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 1a31fd2..ee11b4b 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 @@ -27,7 +27,7 @@ public class ExpenseInput { private String owner; @NotNull(message = "Amount is required") - @DecimalMin(value = "0.01", message = "Amount must be greater than zero") + @DecimalMin(value = "0.00", message = "Amount must not be negative") private Double amount; private Double personalUseAmount; @@ -36,7 +36,7 @@ public class ExpenseInput { @NotNull(message = "Date is required") private LocalDate date; - @NotBlank(message = "Category is required") + // Optional: Quick Add creates stub expenses without a category, to be filled in later. private String category; private ExpenseList expenseList; 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 index 4d5686c..43e0b7b 100644 --- a/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java +++ b/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java @@ -1,6 +1,7 @@ package de.zendric.app.xpensely_server.security; import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.services.UserService; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; @@ -24,12 +25,8 @@ public class AuthenticatedUserResolver { 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) { + return userService.getUserByGoogleId(googleId); + } catch (IllegalArgumentException | ResourceNotFoundException e) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); } } diff --git a/src/test/java/de/zendric/app/xpensely_Server/config/TestJwtDecoderConfig.java b/src/test/java/de/zendric/app/xpensely_Server/config/TestJwtDecoderConfig.java new file mode 100644 index 0000000..ec25b0e --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/config/TestJwtDecoderConfig.java @@ -0,0 +1,24 @@ +package de.zendric.app.xpensely_Server.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; + +/** + * Provides a no-op JwtDecoder so the application context starts without + * fetching Google's JWKS endpoint. Tests use SecurityMockMvcRequestPostProcessors.jwt() + * which populates the SecurityContext directly and never calls this decoder. + */ +@TestConfiguration +public class TestJwtDecoderConfig { + + @Bean + @Primary + public JwtDecoder jwtDecoder() { + return token -> { + throw new JwtException("Real JWT validation is disabled in integration tests — use jwt() post-processor"); + }; + } +} 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 index e854437..614fafb 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java @@ -1,6 +1,7 @@ package de.zendric.app.xpensely_Server.security; import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; import org.junit.jupiter.api.BeforeEach; @@ -48,7 +49,8 @@ class AuthenticatedUserResolverTest { .subject("unknown-id") .build(); JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); - when(userService.getUserByGoogleId("unknown-id")).thenReturn(null); + when(userService.getUserByGoogleId("unknown-id")) + .thenThrow(new ResourceNotFoundException("User not found with Google ID: unknown-id")); ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> resolver.resolveCurrentUser(auth)); diff --git a/src/test/java/de/zendric/app/xpensely_Server/security/SecurityFilterChainIntegrationTest.java b/src/test/java/de/zendric/app/xpensely_Server/security/SecurityFilterChainIntegrationTest.java new file mode 100644 index 0000000..adaa5d6 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/security/SecurityFilterChainIntegrationTest.java @@ -0,0 +1,153 @@ +package de.zendric.app.xpensely_Server.security; + +import de.zendric.app.xpensely_Server.config.TestJwtDecoderConfig; +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.repo.ExpenseListRepository; +import de.zendric.app.xpensely_server.repo.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Full-stack integration tests for the security filter chain. + * Runs with the real securityFilterChain (not the test permit-all chain). + * Uses an H2 in-memory database (see application-integration.properties). + * JWT validation is bypassed via jwt() post-processor — no real Google token needed. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +@ActiveProfiles("integration") +@Import(TestJwtDecoderConfig.class) +class SecurityFilterChainIntegrationTest { + + @Autowired MockMvc mockMvc; + @Autowired UserRepository userRepository; + @Autowired ExpenseListRepository expenseListRepository; + + private static final String KNOWN_GOOGLE_ID = "google-integration-test-123"; + private static final String UNKNOWN_GOOGLE_ID = "google-unknown-999"; + + @BeforeEach + void setUp() { + expenseListRepository.deleteAll(); + userRepository.deleteAll(); + + AppUser user = new AppUser(); + user.setGoogleId(KNOWN_GOOGLE_ID); + user.setUsername("integrationtestuser"); + userRepository.save(user); + } + + @AfterEach + void tearDown() { + expenseListRepository.deleteAll(); + userRepository.deleteAll(); + } + + // --- Public endpoints --- + + @Test + void versionEndpoint_noAuth_returns200() throws Exception { + mockMvc.perform(get("/api/version")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.version").exists()); + } + + // --- Unauthenticated access (no JWT) → 401 --- + + @Test + void byGoogleId_noJwt_returns401() throws Exception { + mockMvc.perform(get("/api/users/byGoogleId").param("id", KNOWN_GOOGLE_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + void createUser_noJwt_returns401() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"newuser\",\"googleId\":\"new-google-id\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void expenseListMine_noJwt_returns401() throws Exception { + mockMvc.perform(get("/api/expenselist/mine")) + .andExpect(status().isUnauthorized()); + } + + // --- Authenticated, user exists in DB → success --- + + @Test + void byGoogleId_withJwt_userInDb_returns200() throws Exception { + mockMvc.perform(get("/api/users/byGoogleId") + .param("id", KNOWN_GOOGLE_ID) + .with(jwt().jwt(j -> j.subject(KNOWN_GOOGLE_ID)))) + .andExpect(status().isOk()); + } + + @Test + void expenseListMine_withJwt_userInDb_emptyList_returns204() throws Exception { + mockMvc.perform(get("/api/expenselist/mine") + .with(jwt().jwt(j -> j.subject(KNOWN_GOOGLE_ID)))) + .andExpect(status().isNoContent()); + } + + @Test + void createUser_withJwt_validBody_returns201() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"newuser123\",\"googleId\":\"brand-new-google-id\"}") + .with(jwt().jwt(j -> j.subject("brand-new-google-id")))) + .andExpect(status().isCreated()); + } + + // --- Authenticated, user NOT in DB → 403 --- + + @Test + void byGoogleId_withJwt_userNotInDb_returns403() throws Exception { + mockMvc.perform(get("/api/users/byGoogleId") + .param("id", UNKNOWN_GOOGLE_ID) + .with(jwt().jwt(j -> j.subject(UNKNOWN_GOOGLE_ID)))) + .andExpect(status().isForbidden()); + } + + @Test + void expenseListMine_withJwt_userNotInDb_returns403() throws Exception { + mockMvc.perform(get("/api/expenselist/mine") + .with(jwt().jwt(j -> j.subject(UNKNOWN_GOOGLE_ID)))) + .andExpect(status().isForbidden()); + } + + // --- Authenticated, accessing another user's resource → 403 --- + + @Test + void byGoogleId_withJwt_differentId_returns403() throws Exception { + mockMvc.perform(get("/api/users/byGoogleId") + .param("id", UNKNOWN_GOOGLE_ID) + .with(jwt().jwt(j -> j.subject(KNOWN_GOOGLE_ID)))) + .andExpect(status().isForbidden()); + } + + // --- Validation errors still return 400 with JWT --- + + @Test + void createUser_withJwt_blankUsername_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"\",\"googleId\":\"some-id\"}") + .with(jwt().jwt(j -> j.subject("some-id")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } +} diff --git a/src/test/resources/META-INF/build-info.properties b/src/test/resources/META-INF/build-info.properties new file mode 100644 index 0000000..ee75f51 --- /dev/null +++ b/src/test/resources/META-INF/build-info.properties @@ -0,0 +1,5 @@ +build.artifact=xpensely-server +build.group=de.zendric.app +build.name=XpenselyServer +build.time=2026-01-01T00\:00\:00.000Z +build.version=test diff --git a/src/test/resources/application-integration.properties b/src/test/resources/application-integration.properties new file mode 100644 index 0000000..82e30da --- /dev/null +++ b/src/test/resources/application-integration.properties @@ -0,0 +1,10 @@ +# H2 in-memory database replaces PostgreSQL for integration tests +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop + +# Placeholder URI — TestJwtDecoderConfig overrides the decoder bean so this URI is never fetched +spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://placeholder.invalid/jwks -- 2.52.0 From d73d349a58d76d6b7af2b5ee18c536c4812aa03c Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sat, 4 Jul 2026 22:14:58 +0200 Subject: [PATCH 19/20] Release 1.1.0 Final Security Hardening --- pom.xml | 8 +-- .../controller/AppUserController.java | 14 +++-- .../controller/ExpenseListController.java | 30 ++++++++-- .../controller/GlobalExceptionHandler.java | 8 +++ .../app/xpensely_server/model/AppUser.java | 3 +- ...ogleAccountAlreadyRegisteredException.java | 11 ++++ .../model/UsernameUpdateRequest.java | 15 +++++ .../xpensely_server/repo/UserRepository.java | 2 + .../xpensely_server/services/UserService.java | 15 ++++- .../controller/AppUserControllerTest.java | 38 ++++++++++-- .../controller/ExpenseListControllerTest.java | 60 +++++++++++++++++-- 11 files changed, 173 insertions(+), 31 deletions(-) create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/Exception/GoogleAccountAlreadyRegisteredException.java create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/UsernameUpdateRequest.java diff --git a/pom.xml b/pom.xml index 45528c4..9d421c4 100644 --- a/pom.xml +++ b/pom.xml @@ -6,11 +6,11 @@ org.springframework.boot spring-boot-starter-parent 4.0.6 - + de.zendric.app XpenselyServer - 1.0.0 + 1.1.0 XpenselyServer XpenselyServer used to handle the Xpensely App @@ -52,10 +52,6 @@ org.springframework.boot spring-boot-starter-oauth2-resource-server - - org.springframework.boot - spring-boot-starter-oauth2-client - org.springframework.boot spring-boot-starter-web 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 597824b..a3a483b 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,6 +9,7 @@ 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.UsernameUpdateRequest; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; @@ -31,11 +32,6 @@ public class AppUserController { 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); @@ -51,6 +47,14 @@ public class AppUserController { return new ResponseEntity<>(nUser, HttpStatus.CREATED); } + @PutMapping("/username") + public ResponseEntity updateUsername(@RequestBody @Valid UsernameUpdateRequest request, + Authentication authentication) { + AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); + AppUser updated = userService.updateUsername(self.getId(), request.getUsername()); + return ResponseEntity.ok(updated); + } + @DeleteMapping public ResponseEntity deleteUser(@RequestParam Long id, Authentication authentication) { AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); 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 a2227e3..ae4bb58 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 @@ -17,7 +17,6 @@ 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") @@ -26,14 +25,12 @@ public class ExpenseListController { private static final Logger log = LoggerFactory.getLogger(ExpenseListController.class); private final ExpenseListService expenseListService; - private final UserService userService; private final CategoryService categoryService; private final AuthenticatedUserResolver authenticatedUserResolver; - public ExpenseListController(ExpenseListService expenseListService, UserService userService, + public ExpenseListController(ExpenseListService expenseListService, CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) { this.expenseListService = expenseListService; - this.userService = userService; this.categoryService = categoryService; this.authenticatedUserResolver = authenticatedUserResolver; } @@ -93,7 +90,7 @@ public class ExpenseListController { if (listOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, listOpt.get()); - AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); + AppUser expenseOwner = resolveListMember(listOpt.get(), expenseInput.getOwner()); Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); @@ -109,7 +106,7 @@ public class ExpenseListController { if (expenseListOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, expenseListOpt.get()); - AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); + AppUser expenseOwner = resolveListMember(expenseListOpt.get(), expenseChangeRequest.getOwnerName()); Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); return new ResponseEntity<>(updatedExpense, HttpStatus.OK); @@ -156,6 +153,11 @@ public class ExpenseListController { if (list.getOwner().getId().equals(authenticatedUser.getId())) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list"); } + if (list.getOwner().getUsername().equals(authenticatedUser.getUsername())) { + return ResponseEntity.status(HttpStatus.CONFLICT).body( + "You and the list owner both use the username \"" + authenticatedUser.getUsername() + + "\". Please change your username before joining this list."); + } list.setSharedWith(authenticatedUser); expenseListService.save(list); return ResponseEntity.ok("User added to the list"); @@ -173,4 +175,20 @@ public class ExpenseListController { if (!isOwner && !isShared) throw new ResponseStatusException(HttpStatus.FORBIDDEN); } + + /** + * Resolves an expense owner by username among the list's members. Usernames are + * not globally unique, so the owner must be one of this list's (at most two) + * members — never a global lookup. + */ + private AppUser resolveListMember(ExpenseList list, String username) { + if (list.getOwner() != null && list.getOwner().getUsername().equals(username)) { + return list.getOwner(); + } + if (list.getSharedWith() != null && list.getSharedWith().getUsername().equals(username)) { + return list.getSharedWith(); + } + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Expense owner must be a member of this list"); + } } 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 5a0c270..2a923f8 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,5 +1,6 @@ package de.zendric.app.xpensely_server.controller; +import de.zendric.app.xpensely_server.model.Exception.GoogleAccountAlreadyRegisteredException; import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; import org.slf4j.Logger; @@ -47,6 +48,13 @@ public class GlobalExceptionHandler { .body(Map.of("error", ex.getMessage())); } + @ExceptionHandler(GoogleAccountAlreadyRegisteredException.class) + public ResponseEntity> handleGoogleAccountConflict( + GoogleAccountAlreadyRegisteredException 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()) diff --git a/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java b/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java index 90a0cd9..4a98266 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/AppUser.java @@ -26,9 +26,10 @@ public class AppUser { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "username", nullable = false, unique = true) + @Column(name = "username", nullable = false) private String username; + @Column(name = "google_id", unique = true) @JsonIgnore private String googleId; diff --git a/src/main/java/de/zendric/app/xpensely_server/model/Exception/GoogleAccountAlreadyRegisteredException.java b/src/main/java/de/zendric/app/xpensely_server/model/Exception/GoogleAccountAlreadyRegisteredException.java new file mode 100644 index 0000000..5e34eb7 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/Exception/GoogleAccountAlreadyRegisteredException.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.CONFLICT) +public class GoogleAccountAlreadyRegisteredException extends RuntimeException { + public GoogleAccountAlreadyRegisteredException(String message) { + super(message); + } +} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/UsernameUpdateRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/UsernameUpdateRequest.java new file mode 100644 index 0000000..7be09b8 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/UsernameUpdateRequest.java @@ -0,0 +1,15 @@ +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 UsernameUpdateRequest { + + @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; +} diff --git a/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java b/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java index a35ba4f..00945c2 100644 --- a/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java +++ b/src/main/java/de/zendric/app/xpensely_server/repo/UserRepository.java @@ -14,4 +14,6 @@ public interface UserRepository extends JpaRepository { Optional findByGoogleId(String id); Boolean existsByUsername(String username); + + Boolean existsByGoogleId(String googleId); } 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 a86d665..cf9bee8 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 @@ -5,8 +5,8 @@ import java.util.List; import org.springframework.stereotype.Service; import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.GoogleAccountAlreadyRegisteredException; 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; @Service @@ -22,9 +22,18 @@ public class UserService { } public AppUser createUser(AppUser user) { - if (Boolean.TRUE.equals(userRepository.existsByUsername(user.getUsername()))) { - throw new UsernameAlreadyExistsException("Username already exists"); + if (Boolean.TRUE.equals(userRepository.existsByGoogleId(user.getGoogleId()))) { + throw new GoogleAccountAlreadyRegisteredException( + "An account already exists for this Google account"); } + // Usernames are not unique — identity is the Google account (googleId). + return userRepository.save(user); + } + + public AppUser updateUsername(Long userId, String newUsername) { + AppUser user = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId)); + user.setUsername(newUsername); return userRepository.save(user); } 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 b99093c..f23efe9 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,6 @@ 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.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; @@ -22,8 +20,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(value = AppUserController.class, excludeAutoConfiguration = { - OAuth2ClientAutoConfiguration.class, - OAuth2ClientWebSecurityAutoConfiguration.class, OAuth2ResourceServerAutoConfiguration.class }) @AutoConfigureMockMvc(addFilters = false) @@ -99,4 +95,38 @@ class AppUserControllerTest { mockMvc.perform(delete("/api/users").param("id", "99")) .andExpect(status().isForbidden()); } + + // --- Rename username --- + + @Test + void updateUsername_valid_returns200WithUpdatedUser() throws Exception { + AppUser self = new AppUser(); self.setId(1L); self.setUsername("old"); + AppUser updated = new AppUser(); updated.setId(1L); updated.setUsername("newname"); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self); + when(userService.updateUsername(1L, "newname")).thenReturn(updated); + + mockMvc.perform(put("/api/users/username") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"newname\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("newname")); + } + + @Test + void updateUsername_invalidPattern_returns400() throws Exception { + mockMvc.perform(put("/api/users/username") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"bad name!\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } + + @Test + void updateUsername_tooShort_returns400() throws Exception { + mockMvc.perform(put("/api/users/username") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"ab\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } } 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 5c50950..50913ea 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 @@ -2,15 +2,13 @@ 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.Expense; 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.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; @@ -19,17 +17,17 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; 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(value = ExpenseListController.class, excludeAutoConfiguration = { - OAuth2ClientAutoConfiguration.class, - OAuth2ClientWebSecurityAutoConfiguration.class, OAuth2ResourceServerAutoConfiguration.class }) @AutoConfigureMockMvc(addFilters = false) @@ -38,7 +36,6 @@ class ExpenseListControllerTest { @Autowired MockMvc mockMvc; @MockitoBean ExpenseListService expenseListService; - @MockitoBean UserService userService; @MockitoBean CategoryService categoryService; @MockitoBean AuthenticatedUserResolver authenticatedUserResolver; @@ -178,4 +175,55 @@ class ExpenseListControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.name").exists()); } + + // --- Owner resolution within list members --- + + @Test + void addExpense_ownerNotAListMember_returns400() throws Exception { + AppUser member = new AppUser(); member.setId(1L); member.setUsername("alice"); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(member); + + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member); + + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"ghost\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void addExpense_ownerIsAListMember_returns201() throws Exception { + AppUser member = new AppUser(); member.setId(1L); member.setUsername("alice"); + ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(member); + + when(expenseListService.findById(1L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member); + when(expenseListService.addExpenseToList(eq(1L), any())).thenReturn(new Expense()); + + mockMvc.perform(post("/api/expenselist/1/add") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) + .andExpect(status().isCreated()); + } + + // --- Duplicate-username invite guard --- + + @Test + void acceptInvite_sameUsernameAsOwner_returns409() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("cedric"); + AppUser joiner = new AppUser(); joiner.setId(2L); joiner.setUsername("cedric"); + ExpenseList list = new ExpenseList(); + list.setId(1L); + list.setOwner(owner); + list.setInviteCodeExpiration(LocalDateTime.now().plusHours(1)); + + when(expenseListService.findByInviteCode("ABC123")).thenReturn(list); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(joiner); + + mockMvc.perform(post("/api/expenselist/accept-invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inviteCode\":\"ABC123\"}")) + .andExpect(status().isConflict()); + } } -- 2.52.0 From 41e32efd7ed35b9b121d9bd80701d68dda3e13c2 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sat, 4 Jul 2026 22:50:54 +0200 Subject: [PATCH 20/20] Added Rename User and Lists --- .../controller/ExpenseListController.java | 12 ++++ .../services/ExpenseListService.java | 7 +++ .../controller/ExpenseListControllerTest.java | 55 +++++++++++++++++++ 3 files changed, 74 insertions(+) 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 ae4bb58..03ce5a9 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 @@ -80,6 +80,18 @@ public class ExpenseListController { return new ResponseEntity<>(HttpStatus.NO_CONTENT); } + @PutMapping("/{id}/rename") + public ResponseEntity rename(@PathVariable("id") Long id, + @RequestBody @Valid CreateExpenseListRequest request, 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()); + ExpenseList renamed = expenseListService.renameList(id, request.getName()); + return new ResponseEntity<>(renamed, HttpStatus.OK); + } + @PostMapping("/{id}/add") public ResponseEntity addExpenseToList( @PathVariable("id") Long expenseListId, 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 20d8177..7ab97f7 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 @@ -39,6 +39,13 @@ public class ExpenseListService { repository.deleteById(id); } + public ExpenseList renameList(Long id, String newName) { + ExpenseList list = repository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + id)); + list.setName(newName); + return repository.save(list); + } + public Optional findById(Long id) { return repository.findById(id); } 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 50913ea..e203b64 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 @@ -207,6 +207,61 @@ class ExpenseListControllerTest { .andExpect(status().isCreated()); } + // --- Rename list --- + + @Test + void renameList_owner_returns200WithRenamedList() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + ExpenseList list = new ExpenseList(); list.setId(5L); list.setOwner(owner); + ExpenseList renamed = new ExpenseList(); renamed.setId(5L); renamed.setName("Groceries"); + + when(expenseListService.findById(5L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner); + when(expenseListService.renameList(5L, "Groceries")).thenReturn(renamed); + + mockMvc.perform(put("/api/expenselist/5/rename") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Groceries\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Groceries")); + } + + @Test + void renameList_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(put("/api/expenselist/5/rename") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Groceries\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void renameList_blankName_returns400() throws Exception { + mockMvc.perform(put("/api/expenselist/5/rename") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.name").exists()); + } + + @Test + void renameList_notFound_returns404() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + when(expenseListService.findById(9L)).thenReturn(Optional.empty()); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner); + + mockMvc.perform(put("/api/expenselist/9/rename") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Groceries\"}")) + .andExpect(status().isNotFound()); + } + // --- Duplicate-username invite guard --- @Test -- 2.52.0