From f958fb785320e07a6d95dc896ae89f159e3d6782 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Sat, 4 Jul 2026 17:31:21 +0200 Subject: [PATCH] Fixed Auth and Connection issues --- .gitignore | 5 + docs/xpensely-api.http | 148 +++++++++++++++++ .../xpensely_server/model/ExpenseInput.java | 4 +- .../security/AuthenticatedUserResolver.java | 9 +- .../config/TestJwtDecoderConfig.java | 24 +++ .../AuthenticatedUserResolverTest.java | 4 +- .../SecurityFilterChainIntegrationTest.java | 153 ++++++++++++++++++ .../resources/META-INF/build-info.properties | 5 + .../application-integration.properties | 10 ++ 9 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 docs/xpensely-api.http create mode 100644 src/test/java/de/zendric/app/xpensely_Server/config/TestJwtDecoderConfig.java create mode 100644 src/test/java/de/zendric/app/xpensely_Server/security/SecurityFilterChainIntegrationTest.java create mode 100644 src/test/resources/META-INF/build-info.properties create mode 100644 src/test/resources/application-integration.properties diff --git a/.gitignore b/.gitignore index 8c660d3..27fbf8f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ target/ !**/src/test/**/target/ .env +# Local debugging (not for CI/prod) +.env.local +Dockerfile.local +compose.local.yml + ### STS ### .apt_generated .classpath diff --git a/docs/xpensely-api.http b/docs/xpensely-api.http new file mode 100644 index 0000000..a5f6835 --- /dev/null +++ b/docs/xpensely-api.http @@ -0,0 +1,148 @@ +# Xpensely API — Manual test file (IntelliJ HTTP Client / VS Code REST Client) +# +# HOW TO GET A TOKEN: +# 1. Run the Flutter dev app and sign in with Google +# 2. Add a breakpoint or log in auth_interceptor.dart after _storage.read(key: 'id_token') +# 3. Paste the token value below (valid for ~1 hour) +# +# Switch between dev and prod by changing @baseUrl. + +@devBaseUrl = https://dev-xpensely.zendric.de/api +@prodBaseUrl = https://xpensely.zendric.de/api +@baseUrl = {{devBaseUrl}} + +# Paste your Google ID token here: +@token = PASTE_ID_TOKEN_HERE + +# Your Google sub (from the JWT payload — decode at jwt.io): +@googleId = PASTE_GOOGLE_SUB_HERE + + +### ─── Health ──────────────────────────────────────────────────────────────── + +### Version (no auth required — should always return 200) +GET {{baseUrl}}/version + +### ─── Auth probe ───────────────────────────────────────────────────────────── + +### Any protected endpoint without a token — expect 401 +GET {{baseUrl}}/users/byGoogleId?id={{googleId}} + +### ─── Users ────────────────────────────────────────────────────────────────── + +### Look up your account by Google ID — expect 200 if account exists, 403 if not +GET {{baseUrl}}/users/byGoogleId?id={{googleId}} +Authorization: Bearer {{token}} + +### + +### Get your account by DB id — replace {id} with your numeric user id +GET {{baseUrl}}/users?id=1 +Authorization: Bearer {{token}} + +### + +### Get any user by username (no auth required) +GET {{baseUrl}}/users/byName?username=testuser + +### + +### Create a new account (requires valid JWT — account must not exist yet) +POST {{baseUrl}}/users/createUser +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "username": "myusername", + "googleId": "{{googleId}}" +} + +### + +### Delete your account — replace {id} with your numeric user id +DELETE {{baseUrl}}/users?id=1 +Authorization: Bearer {{token}} + +### ─── Expense Lists ─────────────────────────────────────────────────────────── + +### Get all your expense lists — expect 200 (with data) or 204 (empty) +GET {{baseUrl}}/expenselist/mine +Authorization: Bearer {{token}} + +### + +### Get a specific expense list by id +GET {{baseUrl}}/expenselist/byId?id=1 +Authorization: Bearer {{token}} + +### + +### Create a new expense list +POST {{baseUrl}}/expenselist/create +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "name": "Groceries" +} + +### + +### Add an expense to a list — replace {listId} in the URL +POST {{baseUrl}}/expenselist/1/add +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "title": "Coffee", + "owner": "myusername", + "amount": 4.50, + "date": "2026-05-15", + "category": "Food" +} + +### + +### Update an expense in a list +PUT {{baseUrl}}/expenselist/1/update +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "id": 1, + "title": "Coffee (updated)", + "ownerName": "myusername", + "amount": 5.00, + "date": "2026-05-15", + "category": "Food", + "expenseListId": 1 +} + +### + +### Delete an expense from a list +DELETE {{baseUrl}}/expenselist/1/delete?expenseId=1 +Authorization: Bearer {{token}} + +### + +### Generate an invite code for a list (you must be the owner) +POST {{baseUrl}}/expenselist/1/invite +Authorization: Bearer {{token}} + +### + +### Accept an invite +POST {{baseUrl}}/expenselist/accept-invite +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "inviteCode": "PASTE_6_CHAR_CODE_HERE" +} + +### + +### Delete an expense list (you must be the owner) +DELETE {{baseUrl}}/expenselist/1 +Authorization: Bearer {{token}} diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java index 1a31fd2..ee11b4b 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java @@ -27,7 +27,7 @@ public class ExpenseInput { private String owner; @NotNull(message = "Amount is required") - @DecimalMin(value = "0.01", message = "Amount must be greater than zero") + @DecimalMin(value = "0.00", message = "Amount must not be negative") private Double amount; private Double personalUseAmount; @@ -36,7 +36,7 @@ public class ExpenseInput { @NotNull(message = "Date is required") private LocalDate date; - @NotBlank(message = "Category is required") + // Optional: Quick Add creates stub expenses without a category, to be filled in later. private String category; private ExpenseList expenseList; diff --git a/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java b/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java index 4d5686c..43e0b7b 100644 --- a/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java +++ b/src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java @@ -1,6 +1,7 @@ package de.zendric.app.xpensely_server.security; import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.services.UserService; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; @@ -24,12 +25,8 @@ public class AuthenticatedUserResolver { Jwt jwt = (Jwt) authentication.getPrincipal(); String googleId = jwt.getSubject(); try { - AppUser user = userService.getUserByGoogleId(googleId); - if (user == null) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); - } - return user; - } catch (IllegalArgumentException e) { + return userService.getUserByGoogleId(googleId); + } catch (IllegalArgumentException | ResourceNotFoundException e) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); } } diff --git a/src/test/java/de/zendric/app/xpensely_Server/config/TestJwtDecoderConfig.java b/src/test/java/de/zendric/app/xpensely_Server/config/TestJwtDecoderConfig.java new file mode 100644 index 0000000..ec25b0e --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/config/TestJwtDecoderConfig.java @@ -0,0 +1,24 @@ +package de.zendric.app.xpensely_Server.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; + +/** + * Provides a no-op JwtDecoder so the application context starts without + * fetching Google's JWKS endpoint. Tests use SecurityMockMvcRequestPostProcessors.jwt() + * which populates the SecurityContext directly and never calls this decoder. + */ +@TestConfiguration +public class TestJwtDecoderConfig { + + @Bean + @Primary + public JwtDecoder jwtDecoder() { + return token -> { + throw new JwtException("Real JWT validation is disabled in integration tests — use jwt() post-processor"); + }; + } +} diff --git a/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java b/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java index e854437..614fafb 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/security/AuthenticatedUserResolverTest.java @@ -1,6 +1,7 @@ package de.zendric.app.xpensely_Server.security; import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; import org.junit.jupiter.api.BeforeEach; @@ -48,7 +49,8 @@ class AuthenticatedUserResolverTest { .subject("unknown-id") .build(); JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); - when(userService.getUserByGoogleId("unknown-id")).thenReturn(null); + when(userService.getUserByGoogleId("unknown-id")) + .thenThrow(new ResourceNotFoundException("User not found with Google ID: unknown-id")); ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> resolver.resolveCurrentUser(auth)); diff --git a/src/test/java/de/zendric/app/xpensely_Server/security/SecurityFilterChainIntegrationTest.java b/src/test/java/de/zendric/app/xpensely_Server/security/SecurityFilterChainIntegrationTest.java new file mode 100644 index 0000000..adaa5d6 --- /dev/null +++ b/src/test/java/de/zendric/app/xpensely_Server/security/SecurityFilterChainIntegrationTest.java @@ -0,0 +1,153 @@ +package de.zendric.app.xpensely_Server.security; + +import de.zendric.app.xpensely_Server.config.TestJwtDecoderConfig; +import de.zendric.app.xpensely_server.model.AppUser; +import de.zendric.app.xpensely_server.repo.ExpenseListRepository; +import de.zendric.app.xpensely_server.repo.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Full-stack integration tests for the security filter chain. + * Runs with the real securityFilterChain (not the test permit-all chain). + * Uses an H2 in-memory database (see application-integration.properties). + * JWT validation is bypassed via jwt() post-processor — no real Google token needed. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +@ActiveProfiles("integration") +@Import(TestJwtDecoderConfig.class) +class SecurityFilterChainIntegrationTest { + + @Autowired MockMvc mockMvc; + @Autowired UserRepository userRepository; + @Autowired ExpenseListRepository expenseListRepository; + + private static final String KNOWN_GOOGLE_ID = "google-integration-test-123"; + private static final String UNKNOWN_GOOGLE_ID = "google-unknown-999"; + + @BeforeEach + void setUp() { + expenseListRepository.deleteAll(); + userRepository.deleteAll(); + + AppUser user = new AppUser(); + user.setGoogleId(KNOWN_GOOGLE_ID); + user.setUsername("integrationtestuser"); + userRepository.save(user); + } + + @AfterEach + void tearDown() { + expenseListRepository.deleteAll(); + userRepository.deleteAll(); + } + + // --- Public endpoints --- + + @Test + void versionEndpoint_noAuth_returns200() throws Exception { + mockMvc.perform(get("/api/version")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.version").exists()); + } + + // --- Unauthenticated access (no JWT) → 401 --- + + @Test + void byGoogleId_noJwt_returns401() throws Exception { + mockMvc.perform(get("/api/users/byGoogleId").param("id", KNOWN_GOOGLE_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + void createUser_noJwt_returns401() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"newuser\",\"googleId\":\"new-google-id\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void expenseListMine_noJwt_returns401() throws Exception { + mockMvc.perform(get("/api/expenselist/mine")) + .andExpect(status().isUnauthorized()); + } + + // --- Authenticated, user exists in DB → success --- + + @Test + void byGoogleId_withJwt_userInDb_returns200() throws Exception { + mockMvc.perform(get("/api/users/byGoogleId") + .param("id", KNOWN_GOOGLE_ID) + .with(jwt().jwt(j -> j.subject(KNOWN_GOOGLE_ID)))) + .andExpect(status().isOk()); + } + + @Test + void expenseListMine_withJwt_userInDb_emptyList_returns204() throws Exception { + mockMvc.perform(get("/api/expenselist/mine") + .with(jwt().jwt(j -> j.subject(KNOWN_GOOGLE_ID)))) + .andExpect(status().isNoContent()); + } + + @Test + void createUser_withJwt_validBody_returns201() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"newuser123\",\"googleId\":\"brand-new-google-id\"}") + .with(jwt().jwt(j -> j.subject("brand-new-google-id")))) + .andExpect(status().isCreated()); + } + + // --- Authenticated, user NOT in DB → 403 --- + + @Test + void byGoogleId_withJwt_userNotInDb_returns403() throws Exception { + mockMvc.perform(get("/api/users/byGoogleId") + .param("id", UNKNOWN_GOOGLE_ID) + .with(jwt().jwt(j -> j.subject(UNKNOWN_GOOGLE_ID)))) + .andExpect(status().isForbidden()); + } + + @Test + void expenseListMine_withJwt_userNotInDb_returns403() throws Exception { + mockMvc.perform(get("/api/expenselist/mine") + .with(jwt().jwt(j -> j.subject(UNKNOWN_GOOGLE_ID)))) + .andExpect(status().isForbidden()); + } + + // --- Authenticated, accessing another user's resource → 403 --- + + @Test + void byGoogleId_withJwt_differentId_returns403() throws Exception { + mockMvc.perform(get("/api/users/byGoogleId") + .param("id", UNKNOWN_GOOGLE_ID) + .with(jwt().jwt(j -> j.subject(KNOWN_GOOGLE_ID)))) + .andExpect(status().isForbidden()); + } + + // --- Validation errors still return 400 with JWT --- + + @Test + void createUser_withJwt_blankUsername_returns400() throws Exception { + mockMvc.perform(post("/api/users/createUser") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"\",\"googleId\":\"some-id\"}") + .with(jwt().jwt(j -> j.subject("some-id")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.username").exists()); + } +} diff --git a/src/test/resources/META-INF/build-info.properties b/src/test/resources/META-INF/build-info.properties new file mode 100644 index 0000000..ee75f51 --- /dev/null +++ b/src/test/resources/META-INF/build-info.properties @@ -0,0 +1,5 @@ +build.artifact=xpensely-server +build.group=de.zendric.app +build.name=XpenselyServer +build.time=2026-01-01T00\:00\:00.000Z +build.version=test diff --git a/src/test/resources/application-integration.properties b/src/test/resources/application-integration.properties new file mode 100644 index 0000000..82e30da --- /dev/null +++ b/src/test/resources/application-integration.properties @@ -0,0 +1,10 @@ +# H2 in-memory database replaces PostgreSQL for integration tests +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop + +# Placeholder URI — TestJwtDecoderConfig overrides the decoder bean so this URI is never fetched +spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://placeholder.invalid/jwks