Compare commits
2 Commits
0993164062
...
84d7647481
| Author | SHA1 | Date | |
|---|---|---|---|
| 84d7647481 | |||
| 89315e01dd |
178
Readme.md
Normal file
178
Readme.md
Normal file
@ -0,0 +1,178 @@
|
||||
# 🏡 HemHub – A Home & Household Management API
|
||||
|
||||
HemHub is a Spring Boot 3 application showcasing secure, role-based APIs using **Spring Security**, **OAuth2 JWT**, and **Keycloak**.
|
||||
The system models households, members, and tasks (future iterations), providing a realistic multi-tenant backend suitable for professional portfolio or consulting demonstrations.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### Iteration 1 – Security Foundation
|
||||
- OAuth2 / JWT authentication via **Keycloak 25**.
|
||||
- `/me` endpoint exposing user claims and roles.
|
||||
- Dockerized Keycloak + API stack.
|
||||
- Unit and integration tests for security validation.
|
||||
|
||||
### Iteration 2 – Household & Membership Domain
|
||||
- PostgreSQL persistence with **Flyway** migrations.
|
||||
- Entities: `Household`, `HouseholdMember`.
|
||||
- Role-based access (`OWNER` / `MEMBER`).
|
||||
- Guarded endpoints with method-level security.
|
||||
- Integration tests for domain logic and security.
|
||||
- IntelliJ HTTP Client script for easy manual testing.
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Architecture Overview
|
||||
```bash
|
||||
+-------------+
|
||||
| Keycloak | <-- OAuth2 provider (JWT tokens)
|
||||
+-------------+
|
||||
|
|
||||
v
|
||||
+-------------------+ +-------------------------+
|
||||
| hemhub-api | --> | PostgreSQL 16 database |
|
||||
| Spring Boot app | +-------------------------+
|
||||
| ├─ SecurityConfig |
|
||||
| ├─ Controllers |
|
||||
| ├─ Services |
|
||||
| └─ JPA Entities |
|
||||
+-------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker / Docker Compose
|
||||
- JDK 21+
|
||||
- Gradle 8+
|
||||
|
||||
### Run
|
||||
```bash
|
||||
make run # build + start API + Keycloak + Postgres
|
||||
```
|
||||
After startup:
|
||||
* API available at http://localhost:8080
|
||||
* Keycloak Admin Console: http://localhost:8081
|
||||
* Username: admin
|
||||
* Password: admin
|
||||
|
||||
### Obtain Token
|
||||
```bash
|
||||
curl -s -X POST \
|
||||
-d "client_id=hemhub-public" \
|
||||
-d "grant_type=password" \
|
||||
-d "username=maria" \
|
||||
-d "password=Passw0rd" \
|
||||
http://localhost:8081/realms/hemhub/protocol/openid-connect/token \
|
||||
| jq -r .access_token
|
||||
```
|
||||
---
|
||||
## 🧪 Example API Usage
|
||||
|
||||
### Use the included IntelliJ HTTP Client script:
|
||||
```bash
|
||||
src/test/http/hemhub-api.http
|
||||
```
|
||||
This file automates:
|
||||
1. Fetching an access token.
|
||||
2. Creating a household.
|
||||
3. Listing households and members.
|
||||
4. Adding new members.
|
||||
|
||||
Each request stores variables (`{{token}}`, `{{householdId}}`) automatically for later steps.
|
||||
|
||||
---
|
||||
|
||||
## 🧰 API Endpoints
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|-----|------------|---------------|
|
||||
| GET | /me | Current user info from JWT | 🔒Authenticated |
|
||||
| POST | /api/v1/households | Create a new household | 🔒Authenticated |
|
||||
| GET | /api/v1/households | List caller’s households | 🔒Authenticated |
|
||||
| GET | /api/v1/households/{id}/members | List members in household | 🔒Member only |
|
||||
| POST | /api/v1/households/{id}/members | Add new member | 🔒OWNER only |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
Run full test suite:
|
||||
```bash
|
||||
./gradlew clean test
|
||||
```
|
||||
|
||||
* Uses in-memory H2 database.
|
||||
* Applies Flyway migrations automatically.
|
||||
* Covers controllers, services, and access control.
|
||||
* All tests must pass before CI build succeeds.
|
||||
|
||||
Integration tests cover endpoints, security, and service logic.
|
||||
|
||||
## 🐳 Docker Services
|
||||
| Service | Port | Description |
|
||||
| ------------ | ---- | ------------------------ |
|
||||
| `hemhub-api` | 8080 | Spring Boot REST API |
|
||||
| `keycloak` | 8081 | OAuth2 Identity Provider |
|
||||
| `postgres` | 5432 | PostgreSQL 16 database |
|
||||
|
||||
### Common commands
|
||||
```bash
|
||||
make run # Build + start stack
|
||||
make stop # Stop and remove containers
|
||||
make logs # Tail logs from all services
|
||||
make image # Build Docker image for API
|
||||
```
|
||||
---
|
||||
## ⚙️ Configuration Overview
|
||||
| Component | Tool | Version |
|
||||
| --------------------------- | -------------------------------------- | ------- |
|
||||
| **Language** | Java | 21 |
|
||||
| **Framework** | Spring Boot | 3.3.x |
|
||||
| **Security** | Spring Security OAuth2 Resource Server | |
|
||||
| **Database** | PostgreSQL | 16.10 |
|
||||
| **Migrations** | Flyway | 10.17.3 |
|
||||
| **Container Orchestration** | Docker Compose | |
|
||||
| **Build System** | Gradle | 8.x |
|
||||
| **Identity Provider** | Keycloak | 25.x |
|
||||
| **Testing** | JUnit 5, MockMvc | |
|
||||
|
||||
## 🧩 Folder Structure
|
||||
```bash
|
||||
hemhub/
|
||||
├─ src/
|
||||
│ ├─ main/java/se/urmo/hemhub/
|
||||
│ │ ├─ controller/ # REST endpoints
|
||||
│ │ ├─ domain/ # JPA entities
|
||||
│ │ ├─ repo/ # Repositories
|
||||
│ │ ├─ service/ # Business logic
|
||||
│ │ ├─ security/ # Security config & guards
|
||||
│ │ └─ HemhubApplication # Main entry point
|
||||
│ └─ resources/
|
||||
│ ├─ db/migration/ # Flyway migrations (V1, V2, …)
|
||||
│ └─ application.yml
|
||||
│
|
||||
├─ src/test/
|
||||
│ ├─ java/… # Unit & integration tests
|
||||
│ └─ http/hemhub-api.http # IntelliJ HTTP test file
|
||||
│
|
||||
├─ docker-compose.yml
|
||||
├─ Makefile
|
||||
├─ build.gradle
|
||||
└─ README.md
|
||||
```
|
||||
---
|
||||
## 🧰 Development Notes
|
||||
|
||||
* IDE: IntelliJ IDEA recommended.
|
||||
* Run IntelliJ HTTP scripts directly (src/test/http/hemhub-api.http).
|
||||
* For local debugging, set env SPRING_PROFILES_ACTIVE=dev.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
|
||||
|
||||
26
build.gradle
26
build.gradle
@ -11,22 +11,38 @@ java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
ext['flyway.version'] = '10.17.3' // managed override
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
implementation 'org.springframework.security:spring-security-oauth2-jose'
|
||||
// --- Spring starters ---
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
// Core Flyway + Postgres driver plugin
|
||||
implementation 'org.flywaydb:flyway-core'
|
||||
implementation 'org.flywaydb:flyway-database-postgresql'
|
||||
|
||||
// DB driver
|
||||
runtimeOnly 'org.postgresql:postgresql:42.7.4'
|
||||
|
||||
// --- Lombok (used in services/guards) ---
|
||||
compileOnly 'org.projectlombok:lombok:1.18.32'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.32'
|
||||
testCompileOnly 'org.projectlombok:lombok:1.18.32'
|
||||
testAnnotationProcessor 'org.projectlombok:lombok:1.18.32'
|
||||
|
||||
// --- Tests ---
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
testImplementation 'com.jayway.jsonpath:json-path:2.9.0'
|
||||
testRuntimeOnly 'com.h2database:h2:2.2.224' // for application-test.yml profile
|
||||
}
|
||||
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
finalizedBy jacocoTestReport
|
||||
|
||||
32
src/main/java/se/urmo/hemhub/config/HouseholdGuard.java
Normal file
32
src/main/java/se/urmo/hemhub/config/HouseholdGuard.java
Normal file
@ -0,0 +1,32 @@
|
||||
package se.urmo.hemhub.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.stereotype.Component;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
import se.urmo.hemhub.repo.HouseholdMemberRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HouseholdGuard {
|
||||
private final HouseholdMemberRepository members;
|
||||
|
||||
public boolean isMember(UUID householdId, Authentication auth) {
|
||||
var sub = jwtSub(auth);
|
||||
return members.existsByHouseholdIdAndUserSub(householdId, sub);
|
||||
}
|
||||
|
||||
public boolean isOwner(UUID householdId, Authentication auth) {
|
||||
var sub = jwtSub(auth);
|
||||
return members.findByHouseholdIdAndUserSub(householdId, sub)
|
||||
.map(m -> m.getRole() == HouseholdMember.Role.OWNER).orElse(false);
|
||||
}
|
||||
|
||||
private static String jwtSub(Authentication auth) {
|
||||
if (auth == null || !(auth.getPrincipal() instanceof Jwt jwt)) throw new IllegalStateException("No JWT");
|
||||
return jwt.getSubject();
|
||||
}
|
||||
}
|
||||
@ -4,16 +4,9 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
@ -22,7 +15,7 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/public/**",
|
||||
|
||||
20
src/main/java/se/urmo/hemhub/domain/Household.java
Normal file
20
src/main/java/se/urmo/hemhub/domain/Household.java
Normal file
@ -0,0 +1,20 @@
|
||||
package se.urmo.hemhub.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "households")
|
||||
public class Household {
|
||||
@Id private UUID id;
|
||||
@Column(nullable=false, length=120) private String name;
|
||||
@Column(name="created_at", nullable=false) private Instant createdAt = Instant.now();
|
||||
|
||||
protected Household() {}
|
||||
public Household(UUID id, String name) { this.id=id; this.name=name; }
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public String getName() { return name; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
39
src/main/java/se/urmo/hemhub/domain/HouseholdMember.java
Normal file
39
src/main/java/se/urmo/hemhub/domain/HouseholdMember.java
Normal file
@ -0,0 +1,39 @@
|
||||
package se.urmo.hemhub.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter
|
||||
@Entity
|
||||
@Table(name = "household_members",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames={"household_id","user_sub"}))
|
||||
public class HouseholdMember {
|
||||
public enum Role { OWNER, MEMBER }
|
||||
|
||||
@Id private UUID id;
|
||||
|
||||
@ManyToOne(optional=false, fetch=FetchType.LAZY)
|
||||
@JoinColumn(name="household_id")
|
||||
private Household household;
|
||||
|
||||
@Column(name="user_sub", nullable=false, length=64) private String userSub;
|
||||
@Column(nullable=true, length=255) private String email;
|
||||
@Column(name="display_name", length=120) private String displayName;
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
private Role role;
|
||||
@Column(name="created_at", nullable=false) private Instant createdAt = Instant.now();
|
||||
|
||||
protected HouseholdMember() {}
|
||||
public HouseholdMember(UUID id, Household h, String userSub, String email, String displayName, Role role) {
|
||||
this.id=id; this.household=h; this.userSub=userSub; this.email=email; this.displayName=displayName; this.role=role;
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public String getUserSub() { return userSub; }
|
||||
public Role getRole() { return role; }
|
||||
}
|
||||
12
src/main/java/se/urmo/hemhub/dto/HouseholdDtos.java
Normal file
12
src/main/java/se/urmo/hemhub/dto/HouseholdDtos.java
Normal file
@ -0,0 +1,12 @@
|
||||
package se.urmo.hemhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.*;
|
||||
|
||||
public class HouseholdDtos {
|
||||
public record CreateHouseholdRequest(@NotBlank String name) {}
|
||||
public record HouseholdResponse(UUID id, String name) {}
|
||||
public record MemberResponse(UUID id, String userSub, String email, String displayName, String role) {}
|
||||
public record AddMemberRequest(@NotNull String userSub, String email, String displayName, @NotNull String role) {}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package se.urmo.hemhub.repo;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
||||
List<HouseholdMember> findByUserSub(String userSub);
|
||||
|
||||
// ✅ Use property path "householdId"
|
||||
boolean existsByHouseholdIdAndUserSub(UUID householdId, String userSub);
|
||||
Optional<HouseholdMember> findByHouseholdIdAndUserSub(UUID householdId, String userSub);
|
||||
List<HouseholdMember> findByHouseholdId(UUID householdId);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package se.urmo.hemhub.repo;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import se.urmo.hemhub.domain.Household;
|
||||
import java.util.*;
|
||||
|
||||
public interface HouseholdRepository extends JpaRepository<Household, UUID> {}
|
||||
58
src/main/java/se/urmo/hemhub/service/HouseholdService.java
Normal file
58
src/main/java/se/urmo/hemhub/service/HouseholdService.java
Normal file
@ -0,0 +1,58 @@
|
||||
package se.urmo.hemhub.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import se.urmo.hemhub.domain.Household;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
import se.urmo.hemhub.repo.HouseholdMemberRepository;
|
||||
import se.urmo.hemhub.repo.HouseholdRepository;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HouseholdService {
|
||||
private final HouseholdRepository households;
|
||||
private final HouseholdMemberRepository members;
|
||||
|
||||
@Transactional
|
||||
public Household createHousehold(String name, String creatorSub, String email, String displayName) {
|
||||
var h = new Household(UUID.randomUUID(), name);
|
||||
households.save(h);
|
||||
var m = new HouseholdMember(UUID.randomUUID(), h, creatorSub, email, displayName, HouseholdMember.Role.OWNER);
|
||||
members.save(m);
|
||||
return h;
|
||||
}
|
||||
|
||||
@Transactional(readOnly=true)
|
||||
public List<Household> listForUser(String userSub) {
|
||||
return members.findByUserSub(userSub).stream().map(HouseholdMember::getHousehold).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public HouseholdMember addMember(UUID householdId, String requesterSub, String userSub, String email, String displayName, HouseholdMember.Role role) {
|
||||
var req = members.findByHouseholdIdAndUserSub(householdId, requesterSub)
|
||||
.orElseThrow(() -> new NoSuchElementException("Requester not member of household"));
|
||||
if (req.getRole() != HouseholdMember.Role.OWNER) throw new SecurityException("Only OWNER may add members");
|
||||
|
||||
var h = households.findById(householdId).orElseThrow();
|
||||
if (members.existsByHouseholdIdAndUserSub(householdId, userSub)) {
|
||||
throw new IllegalStateException("User already member");
|
||||
}
|
||||
var m = new HouseholdMember(UUID.randomUUID(), h, userSub, email, displayName, role);
|
||||
return members.save(m);
|
||||
}
|
||||
|
||||
|
||||
@Transactional(readOnly=true)
|
||||
public List<HouseholdMember> listMembers(UUID householdId, String requesterSub) {
|
||||
ensureMember(householdId, requesterSub);
|
||||
return members.findByHouseholdId(householdId);
|
||||
}
|
||||
|
||||
private void ensureMember(UUID householdId, String sub) {
|
||||
if (!members.existsByHouseholdIdAndUserSub(householdId, sub))
|
||||
throw new SecurityException("Forbidden for non-members");
|
||||
}
|
||||
}
|
||||
69
src/main/java/se/urmo/hemhub/web/HouseholdController.java
Normal file
69
src/main/java/se/urmo/hemhub/web/HouseholdController.java
Normal file
@ -0,0 +1,69 @@
|
||||
package se.urmo.hemhub.web;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.urmo.hemhub.domain.HouseholdMember;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.AddMemberRequest;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.CreateHouseholdRequest;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.HouseholdResponse;
|
||||
import se.urmo.hemhub.dto.HouseholdDtos.MemberResponse;
|
||||
import se.urmo.hemhub.service.HouseholdService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@RequiredArgsConstructor
|
||||
public class HouseholdController {
|
||||
private final HouseholdService service;
|
||||
|
||||
@PostMapping("/households")
|
||||
public HouseholdResponse create(@Valid @RequestBody CreateHouseholdRequest req, Authentication auth) {
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
var h = service.createHousehold(req.name(), jwt.getSubject(), jwt.getClaimAsString("email"), jwt.getClaimAsString("preferred_username"));
|
||||
return new HouseholdResponse(h.getId(), h.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/households")
|
||||
public List<HouseholdResponse> myHouseholds(Authentication auth) {
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
return service.listForUser(jwt.getSubject()).stream()
|
||||
.map(h -> new HouseholdResponse(h.getId(), h.getName()))
|
||||
.collect(toList());
|
||||
}
|
||||
|
||||
@PreAuthorize("@householdGuard.isOwner(#householdId, authentication)")
|
||||
@PostMapping("/households/{householdId}/members")
|
||||
public List<MemberResponse> addMember(@PathVariable UUID householdId, @Valid @RequestBody AddMemberRequest req, Authentication auth) {
|
||||
var role = HouseholdMember.Role.valueOf(req.role());
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
service.addMember(householdId, jwt.getSubject(), req.userSub(), req.email(), req.displayName(), role);
|
||||
return listMembersInternal(householdId, auth);
|
||||
}
|
||||
|
||||
@PreAuthorize("@householdGuard.isMember(#householdId, authentication)")
|
||||
@GetMapping("/households/{householdId}/members")
|
||||
public List<MemberResponse> members(@PathVariable UUID householdId, Authentication auth) {
|
||||
return listMembersInternal(householdId, auth);
|
||||
}
|
||||
|
||||
private List<MemberResponse> listMembersInternal(UUID householdId, Authentication auth) {
|
||||
var jwt = (Jwt) auth.getPrincipal();
|
||||
return service.listMembers(householdId, jwt.getSubject()).stream()
|
||||
.map(m -> new MemberResponse(m.getId(), m.getUserSub(), m.getUserSub().equals(jwt.getSubject()) ? jwt.getClaimAsString("email") : m.getEmail(),
|
||||
m.getUserSub().equals(jwt.getSubject()) ? jwt.getClaimAsString("preferred_username") : m.getUserSub(), m.getRole().name()))
|
||||
.collect(toList());
|
||||
}
|
||||
}
|
||||
26
src/main/resources/db/migration/V2__household.sql
Normal file
26
src/main/resources/db/migration/V2__household.sql
Normal file
@ -0,0 +1,26 @@
|
||||
-- src/main/resources/db/migration/V2__households.sql
|
||||
|
||||
-- Households & Members (H2/PG compatible)
|
||||
CREATE TABLE households (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE household_members (
|
||||
id UUID PRIMARY KEY,
|
||||
household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||
user_sub VARCHAR(64) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
display_name VARCHAR(120),
|
||||
role VARCHAR(16) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (household_id, user_sub)
|
||||
);
|
||||
|
||||
-- Optional: enforce role values (H2 accepts the CHECK too)
|
||||
ALTER TABLE household_members
|
||||
ADD CONSTRAINT chk_household_role CHECK (role IN ('OWNER','MEMBER'));
|
||||
|
||||
CREATE INDEX idx_household_members_user_sub ON household_members(user_sub);
|
||||
CREATE INDEX idx_household_members_household ON household_members(household_id);
|
||||
55
src/test/http/hemhub-api.http
Normal file
55
src/test/http/hemhub-api.http
Normal file
@ -0,0 +1,55 @@
|
||||
### 1. Get access token for user "maria"
|
||||
POST http://localhost:8081/realms/hemhub/protocol/openid-connect/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
client_id=hemhub-public&
|
||||
grant_type=password&
|
||||
username=maria&
|
||||
password=Passw0rd
|
||||
|
||||
> {%
|
||||
client.global.set("token", response.body.access_token);
|
||||
client.global.set("refresh_token", response.body.refresh_token);
|
||||
%}
|
||||
|
||||
### 2. Decode token (optional, debugging)
|
||||
GET https://jwt.io/?access_token={{token}}
|
||||
|
||||
### 3. Get current user info
|
||||
GET http://localhost:8080/me
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
### 4. Create a household
|
||||
POST http://localhost:8080/api/v1/households
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"name": "Familjen Andersson"
|
||||
}
|
||||
|
||||
> {% client.global.set("householdId", JSON.parse(response.body).id); %}
|
||||
|
||||
### 5. List my households
|
||||
GET http://localhost:8080/api/v1/households
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
### 6. List household members
|
||||
GET http://localhost:8080/api/v1/households/{{householdId}}/members
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
### 7. Add a member (requires OWNER role)
|
||||
POST http://localhost:8080/api/v1/households/{{householdId}}/members
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"userSub": "ulf-sub",
|
||||
"email": "ulf@example.com",
|
||||
"displayName": "Ulf",
|
||||
"role": "MEMBER"
|
||||
}
|
||||
|
||||
### 8. List members again (should now include Ulf)
|
||||
GET http://localhost:8080/api/v1/households/{{householdId}}/members
|
||||
Authorization: Bearer {{token}}
|
||||
@ -0,0 +1,63 @@
|
||||
package se.urmo.hemhub.integration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
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.*;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class HouseholdControllerIT {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
|
||||
@Test
|
||||
void create_household_and_list_for_user() throws Exception {
|
||||
var jwtUser = jwt().jwt(j -> {
|
||||
j.subject("sub-user-1");
|
||||
j.claim("email","u1@example.com");
|
||||
j.claim("preferred_username","u1");
|
||||
j.claim("realm_access", java.util.Map.of("roles", java.util.List.of("OWNER","MEMBER")));
|
||||
});
|
||||
|
||||
// create
|
||||
mvc.perform(post("/api/v1/households")
|
||||
.with(jwtUser)
|
||||
.contentType("application/json")
|
||||
.content("{\"name\":\"Familjen U1\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").exists());
|
||||
|
||||
// list
|
||||
mvc.perform(get("/api/v1/households").with(jwtUser))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].name").value("Familjen U1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void add_member_requires_owner() throws Exception {
|
||||
var owner = jwt().jwt(j -> { j.subject("owner-sub"); j.claim("email","o@ex.com"); j.claim("preferred_username","owner"); });
|
||||
var other = jwt().jwt(j -> { j.subject("other-sub"); j.claim("email","x@ex.com"); j.claim("preferred_username","x"); });
|
||||
|
||||
// create household as owner
|
||||
var res = mvc.perform(post("/api/v1/households").with(owner)
|
||||
.contentType("application/json").content("{\"name\":\"H1\"}"))
|
||||
.andExpect(status().isOk()).andReturn();
|
||||
var id = com.jayway.jsonpath.JsonPath.read(res.getResponse().getContentAsString(), "$.id");
|
||||
|
||||
// cannot add as non-owner
|
||||
mvc.perform(post("/api/v1/households/"+id+"/members").with(other)
|
||||
.contentType("application/json")
|
||||
.content("{\"userSub\":\"u2\",\"role\":\"MEMBER\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package se.urmo.hemhub.web;
|
||||
package se.urmo.hemhub.integration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
@ -1,4 +1,4 @@
|
||||
package se.urmo.hemhub.web;
|
||||
package se.urmo.hemhub.integration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
@ -6,6 +6,12 @@ spring:
|
||||
password:
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
ddl-auto: none # 👈 turn off Hibernate's validate/ddl in tests
|
||||
properties:
|
||||
hibernate.hbm2ddl.auto: none
|
||||
flyway:
|
||||
enabled: false
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
logging:
|
||||
level:
|
||||
org.flywaydb.core: INFO
|
||||
Reference in New Issue
Block a user