Fixed Auth and Connection issues
Build and Deploy Spring Boot Server / build (push) Successful in 3m41s
Build and Deploy Spring Boot Server / build (push) Successful in 3m41s
This commit is contained in:
@@ -6,6 +6,11 @@ target/
|
|||||||
!**/src/test/**/target/
|
!**/src/test/**/target/
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Local debugging (not for CI/prod)
|
||||||
|
.env.local
|
||||||
|
Dockerfile.local
|
||||||
|
compose.local.yml
|
||||||
|
|
||||||
### STS ###
|
### STS ###
|
||||||
.apt_generated
|
.apt_generated
|
||||||
.classpath
|
.classpath
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -27,7 +27,7 @@ public class ExpenseInput {
|
|||||||
private String owner;
|
private String owner;
|
||||||
|
|
||||||
@NotNull(message = "Amount is required")
|
@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 amount;
|
||||||
|
|
||||||
private Double personalUseAmount;
|
private Double personalUseAmount;
|
||||||
@@ -36,7 +36,7 @@ public class ExpenseInput {
|
|||||||
@NotNull(message = "Date is required")
|
@NotNull(message = "Date is required")
|
||||||
private LocalDate date;
|
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 String category;
|
||||||
|
|
||||||
private ExpenseList expenseList;
|
private ExpenseList expenseList;
|
||||||
|
|||||||
+3
-6
@@ -1,6 +1,7 @@
|
|||||||
package de.zendric.app.xpensely_server.security;
|
package de.zendric.app.xpensely_server.security;
|
||||||
|
|
||||||
import de.zendric.app.xpensely_server.model.AppUser;
|
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 de.zendric.app.xpensely_server.services.UserService;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
@@ -24,12 +25,8 @@ public class AuthenticatedUserResolver {
|
|||||||
Jwt jwt = (Jwt) authentication.getPrincipal();
|
Jwt jwt = (Jwt) authentication.getPrincipal();
|
||||||
String googleId = jwt.getSubject();
|
String googleId = jwt.getSubject();
|
||||||
try {
|
try {
|
||||||
AppUser user = userService.getUserByGoogleId(googleId);
|
return userService.getUserByGoogleId(googleId);
|
||||||
if (user == null) {
|
} catch (IllegalArgumentException | ResourceNotFoundException e) {
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered");
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -1,6 +1,7 @@
|
|||||||
package de.zendric.app.xpensely_Server.security;
|
package de.zendric.app.xpensely_Server.security;
|
||||||
|
|
||||||
import de.zendric.app.xpensely_server.model.AppUser;
|
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.security.AuthenticatedUserResolver;
|
||||||
import de.zendric.app.xpensely_server.services.UserService;
|
import de.zendric.app.xpensely_server.services.UserService;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -48,7 +49,8 @@ class AuthenticatedUserResolverTest {
|
|||||||
.subject("unknown-id")
|
.subject("unknown-id")
|
||||||
.build();
|
.build();
|
||||||
JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt);
|
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,
|
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
|
||||||
() -> resolver.resolveCurrentUser(auth));
|
() -> resolver.resolveCurrentUser(auth));
|
||||||
|
|||||||
+153
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user