diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..0a9af7a --- /dev/null +++ b/Readme.md @@ -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 + + + diff --git a/build.gradle b/build.gradle index 2574667..430fa86 100644 --- a/build.gradle +++ b/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 diff --git a/src/main/java/se/urmo/hemhub/config/HouseholdGuard.java b/src/main/java/se/urmo/hemhub/config/HouseholdGuard.java new file mode 100644 index 0000000..fae0fd5 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/config/HouseholdGuard.java @@ -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(); + } +} diff --git a/src/main/java/se/urmo/hemhub/domain/Household.java b/src/main/java/se/urmo/hemhub/domain/Household.java new file mode 100644 index 0000000..8dc5e7e --- /dev/null +++ b/src/main/java/se/urmo/hemhub/domain/Household.java @@ -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; } +} diff --git a/src/main/java/se/urmo/hemhub/domain/HouseholdMember.java b/src/main/java/se/urmo/hemhub/domain/HouseholdMember.java new file mode 100644 index 0000000..d4098f5 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/domain/HouseholdMember.java @@ -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; } +} diff --git a/src/main/java/se/urmo/hemhub/dto/HouseholdDtos.java b/src/main/java/se/urmo/hemhub/dto/HouseholdDtos.java new file mode 100644 index 0000000..b39253e --- /dev/null +++ b/src/main/java/se/urmo/hemhub/dto/HouseholdDtos.java @@ -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) {} +} diff --git a/src/main/java/se/urmo/hemhub/repo/HouseholdMemberRepository.java b/src/main/java/se/urmo/hemhub/repo/HouseholdMemberRepository.java new file mode 100644 index 0000000..3d2553a --- /dev/null +++ b/src/main/java/se/urmo/hemhub/repo/HouseholdMemberRepository.java @@ -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 { + List findByUserSub(String userSub); + + // βœ… Use property path "householdId" + boolean existsByHouseholdIdAndUserSub(UUID householdId, String userSub); + Optional findByHouseholdIdAndUserSub(UUID householdId, String userSub); + List findByHouseholdId(UUID householdId); +} diff --git a/src/main/java/se/urmo/hemhub/repo/HouseholdRepository.java b/src/main/java/se/urmo/hemhub/repo/HouseholdRepository.java new file mode 100644 index 0000000..c36acd6 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/repo/HouseholdRepository.java @@ -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 {} diff --git a/src/main/java/se/urmo/hemhub/service/HouseholdService.java b/src/main/java/se/urmo/hemhub/service/HouseholdService.java new file mode 100644 index 0000000..bdc93a2 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/service/HouseholdService.java @@ -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 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 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"); + } +} diff --git a/src/main/java/se/urmo/hemhub/web/HouseholdController.java b/src/main/java/se/urmo/hemhub/web/HouseholdController.java new file mode 100644 index 0000000..26e3cb6 --- /dev/null +++ b/src/main/java/se/urmo/hemhub/web/HouseholdController.java @@ -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 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 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 members(@PathVariable UUID householdId, Authentication auth) { + return listMembersInternal(householdId, auth); + } + + private List 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()); + } +} diff --git a/src/main/resources/db/migration/V2__household.sql b/src/main/resources/db/migration/V2__household.sql new file mode 100644 index 0000000..c4a12fd --- /dev/null +++ b/src/main/resources/db/migration/V2__household.sql @@ -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); diff --git a/src/test/http/hemhub-api.http b/src/test/http/hemhub-api.http new file mode 100644 index 0000000..6121dd6 --- /dev/null +++ b/src/test/http/hemhub-api.http @@ -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}} diff --git a/src/test/java/se/urmo/hemhub/integration/HouseholdControllerIT.java b/src/test/java/se/urmo/hemhub/integration/HouseholdControllerIT.java new file mode 100644 index 0000000..f288377 --- /dev/null +++ b/src/test/java/se/urmo/hemhub/integration/HouseholdControllerIT.java @@ -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()); + } +} diff --git a/src/test/java/se/urmo/hemhub/web/MeControllerBranchesIT.java b/src/test/java/se/urmo/hemhub/integration/MeControllerBranchesIT.java similarity index 98% rename from src/test/java/se/urmo/hemhub/web/MeControllerBranchesIT.java rename to src/test/java/se/urmo/hemhub/integration/MeControllerBranchesIT.java index fcfc38a..aba1dbe 100644 --- a/src/test/java/se/urmo/hemhub/web/MeControllerBranchesIT.java +++ b/src/test/java/se/urmo/hemhub/integration/MeControllerBranchesIT.java @@ -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; diff --git a/src/test/java/se/urmo/hemhub/web/MeControllerIT.java b/src/test/java/se/urmo/hemhub/integration/MeControllerIT.java similarity index 100% rename from src/test/java/se/urmo/hemhub/web/MeControllerIT.java rename to src/test/java/se/urmo/hemhub/integration/MeControllerIT.java diff --git a/src/test/java/se/urmo/hemhub/web/PublicControllerIT.java b/src/test/java/se/urmo/hemhub/integration/PublicControllerIT.java similarity index 96% rename from src/test/java/se/urmo/hemhub/web/PublicControllerIT.java rename to src/test/java/se/urmo/hemhub/integration/PublicControllerIT.java index 741a835..3025e8b 100644 --- a/src/test/java/se/urmo/hemhub/web/PublicControllerIT.java +++ b/src/test/java/se/urmo/hemhub/integration/PublicControllerIT.java @@ -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; diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index c03e8ed..c1ec63c 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -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 \ No newline at end of file