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 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/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 a59c481..49c9633 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,49 +1,43 @@ -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 + image: tea.zendric.de/cedric/xpensely-server:latest + 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 - - /mnt/user/appdata/xpensely_backups:/backups - 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 diff --git a/dockerfile b/dockerfile index a3702e8..6fad8f9 100644 --- a/dockerfile +++ b/dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:17-jdk-slim +FROM eclipse-temurin:17-jdk COPY ./target/*.jar app.jar diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..68fe07c --- /dev/null +++ b/docs/API.md @@ -0,0 +1,704 @@ +# 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 + +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 + +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 + +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 + +### 4.1 Home + +#### `GET /` + +Health check. No authentication required. + +**Response:** `200 OK` +``` +Welcome +``` + +--- + +### 4.2 Users + +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 + +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 + +### 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 + +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` + +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. diff --git a/pom.xml b/pom.xml index df740a9..357a67e 100644 --- a/pom.xml +++ b/pom.xml @@ -1,110 +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 - - - - - - - - - - - - - - - 17 - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-security - - - 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.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 597d733..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 @@ -1,35 +1,34 @@ 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,36 +37,30 @@ 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) { - 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); - } - + public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { + AppUser convertedUser = userRequest.convertToAppUser(); + AppUser nUser = userService.createUser(convertedUser); + return new ResponseEntity<>(nUser, HttpStatus.CREATED); } @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..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 @@ -1,196 +1,149 @@ 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.slf4j.Logger; +import org.slf4j.LoggerFactory; 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 static final Logger log = LoggerFactory.getLogger(ExpenseListController.class); + + 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) { - 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."); - } - - expenseList.setSharedWith(null); - - ExpenseList savedItem = expenseListService.createList(expenseList); - return new ResponseEntity<>(savedItem, HttpStatus.CREATED); - } catch (Exception e) { - e.printStackTrace(); - return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); - } + 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); + 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); } @DeleteMapping("{id}") - public ResponseEntity delete(@PathVariable("id") Long id) { - try { - expenseListService.deleteById(id); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } catch (Exception e) { - return new ResponseEntity<>(HttpStatus.EXPECTATION_FAILED); - } + 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()); + expenseListService.deleteById(id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); } @PostMapping("/{id}/add") public ResponseEntity addExpenseToList( @PathVariable("id") Long expenseListId, - @RequestBody ExpenseInput expenseInput) { - 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); - } + @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()); + 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") public ResponseEntity updateExpenseInList( @PathVariable("id") Long expenseListId, - @RequestBody ExpenseChangeRequest expenseChangeRequest) { - 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); - - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); - } + @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest, + Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional expenseListOpt = expenseListService.findById(expenseListId); + if (expenseListOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, expenseListOpt.get()); + 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") public ResponseEntity deleteExpenseFromList( @PathVariable("id") Long expenseListId, - @RequestParam("expenseId") Long expenseId) { - try { - expenseListService.deleteExpenseFromList(expenseListId, expenseId); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } catch (Exception e) { - return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); - } + @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()); + expenseListService.deleteExpenseFromList(expenseListId, expenseId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); } @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 +153,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..5a0c270 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java @@ -0,0 +1,69 @@ +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; + +@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<>(); + 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())); + } + + @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/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/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/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..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,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.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; @Configuration @@ -17,7 +18,7 @@ public class SecurityConfig { http .authorizeHttpRequests(auth -> auth .anyRequest().permitAll()) - .csrf().disable(); + .csrf(csrf -> csrf.disable()); return http.build(); } @@ -31,7 +32,8 @@ public class SecurityConfig { .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults())) .oauth2Login(Customizer.withDefaults()) - .csrf().disable(); + .addFilterAfter(new RateLimitFilter(), BearerTokenAuthenticationFilter.class) + .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 0ee1be6..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 @@ -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; @@ -39,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); } @@ -59,76 +43,29 @@ public class ExpenseListService { return repository.findById(id); } - public Iterable findAll() { - return repository.findAll(); - } - public ExpenseList save(ExpenseList expenseList) { return repository.save(expenseList); } 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 +76,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 +105,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 +113,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 +128,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 +137,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..97ddbc5 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 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 org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; + +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..b99093c --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/AppUserControllerTest.java @@ -0,0 +1,102 @@ +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.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; +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(value = AppUserController.class, excludeAutoConfiguration = { + OAuth2ClientAutoConfiguration.class, + OAuth2ClientWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.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..5c50950 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -0,0 +1,181 @@ +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.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; +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(value = ExpenseListController.class, excludeAutoConfiguration = { + OAuth2ClientAutoConfiguration.class, + OAuth2ClientWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.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()); + } + + @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()); + } + + @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()) + .andExpect(jsonPath("$.name").exists()); + } + + @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()) + .andExpect(jsonPath("$.name").exists()); + } +} 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"); + } +}