Implement invitation management for patients and add supporting APIs, services, and tests
oka: not tested yet - Introduced `Invitation` entity, table schema, and repository. - Added `InvitationController` with APIs for creating, resolving, and accepting invitations. - Implemented `InvitationService` to handle invitation workflows, including token generation and validation. - Created unit tests for controller, service, and repository layers. - Added support for patient-subject relationships with `PatientSubject` entity and repository. - Updated `PatientAccessChecker` to handle subject-based access control. - Extended Keycloak provisioning service for invitation role management. - Updated database migrations to include invitations and patient-subject tables. - Enhanced security configuration to permit invitation resolution API. - Updated build scripts and dependencies for testing support.
This commit is contained in:
parent
5628aa5675
commit
8ced1f7925
|
|
@ -53,6 +53,13 @@ Run all tests:
|
||||||
- `X-Local-Roles`: e.g., `ADMIN, DOCTOR, CAREGIVER`.
|
- `X-Local-Roles`: e.g., `ADMIN, DOCTOR, CAREGIVER`.
|
||||||
- `X-Tenant-Id`: Target tenant.
|
- `X-Tenant-Id`: Target tenant.
|
||||||
|
|
||||||
|
## 📨 Invitation Onboarding
|
||||||
|
- **Invite-only registration**: Admins create invites; users accept with a token.
|
||||||
|
- **Resolve endpoint**: `POST /api/v1/invites/resolve` returns only masked email + expiry.
|
||||||
|
- **Accept endpoint**: `POST /api/v1/invites/accept` links the authenticated user to a patient.
|
||||||
|
- **Token hashing**: Invitation tokens are stored as HMAC-SHA256 with a server-side pepper.
|
||||||
|
- **Config**: Set `app.invites.token-pepper` in `back001/app/src/main/resources/application.yml` for non-dev environments.
|
||||||
|
|
||||||
## 🔗 Key Services & Links
|
## 🔗 Key Services & Links
|
||||||
- **OpenAPI**: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html)
|
- **OpenAPI**: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html)
|
||||||
- **Health**: [http://localhost:8080/health](http://localhost:8080/health)
|
- **Health**: [http://localhost:8080/health](http://localhost:8080/health)
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,5 @@ dependencies {
|
||||||
|
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
testImplementation("org.springframework.security:spring-security-test")
|
testImplementation("org.springframework.security:spring-security-test")
|
||||||
|
testImplementation("com.h2database:h2")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class SecurityConfig {
|
||||||
.authorizeHttpRequests {
|
.authorizeHttpRequests {
|
||||||
it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||||
it.requestMatchers("/api/v1/demo").permitAll()
|
it.requestMatchers("/api/v1/demo").permitAll()
|
||||||
|
it.requestMatchers(HttpMethod.POST, "/api/v1/invites/resolve").permitAll()
|
||||||
it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN")
|
it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN")
|
||||||
it.anyRequest().authenticated()
|
it.anyRequest().authenticated()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ app:
|
||||||
notification-exchange: notification.events
|
notification-exchange: notification.events
|
||||||
medication-queue: medication.plan.created
|
medication-queue: medication.plan.created
|
||||||
notification-queue: notification.requested
|
notification-queue: notification.requested
|
||||||
|
invites:
|
||||||
|
token-pepper: change-me-in-prod
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
create table invitations (
|
||||||
|
id varchar(64) primary key,
|
||||||
|
token_hash varchar(255) not null,
|
||||||
|
email varchar(255) not null,
|
||||||
|
role varchar(64) not null,
|
||||||
|
patient_id varchar(64) not null,
|
||||||
|
status varchar(32) not null,
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
accepted_at timestamptz,
|
||||||
|
accepted_by_user_id varchar(64),
|
||||||
|
created_by_admin varchar(128),
|
||||||
|
tenant_id varchar(64) not null,
|
||||||
|
created_at timestamptz not null,
|
||||||
|
updated_at timestamptz not null,
|
||||||
|
created_by varchar(128),
|
||||||
|
updated_by varchar(128)
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index idx_invitations_token_hash on invitations(token_hash);
|
||||||
|
create index idx_invitations_status_expires_at on invitations(status, expires_at);
|
||||||
|
create index idx_invitations_tenant on invitations(tenant_id);
|
||||||
|
|
||||||
|
alter table invitations add constraint fk_invitations_patient
|
||||||
|
foreign key (patient_id) references patients(id) on delete cascade;
|
||||||
|
|
||||||
|
alter table invitations add constraint fk_invitations_accepted_by_user
|
||||||
|
foreign key (accepted_by_user_id) references users(id) on delete set null;
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
create table patient_subjects (
|
||||||
|
id varchar(64) primary key,
|
||||||
|
patient_id varchar(64) not null,
|
||||||
|
user_id varchar(64) not null,
|
||||||
|
tenant_id varchar(64) not null,
|
||||||
|
created_at timestamptz not null,
|
||||||
|
updated_at timestamptz not null,
|
||||||
|
created_by varchar(128),
|
||||||
|
updated_by varchar(128)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index idx_patient_subjects_tenant on patient_subjects(tenant_id);
|
||||||
|
create unique index idx_patient_subjects_unique on patient_subjects(tenant_id, patient_id, user_id);
|
||||||
|
|
||||||
|
alter table patient_subjects add constraint fk_patient_subjects_patient
|
||||||
|
foreign key (patient_id) references patients(id) on delete cascade;
|
||||||
|
|
||||||
|
alter table patient_subjects add constraint fk_patient_subjects_user
|
||||||
|
foreign key (user_id) references users(id) on delete cascade;
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
package com.mosenioring.identity.api
|
||||||
|
|
||||||
|
import com.mosenioring.app.config.SecurityConfig
|
||||||
|
import com.mosenioring.common.web.ProblemDetailsAdvice
|
||||||
|
import com.mosenioring.identity.service.InvitationService
|
||||||
|
import com.mosenioring.identity.service.InviteResolveResult
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito.`when`
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.autoconfigure.ImportAutoConfiguration
|
||||||
|
import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
|
||||||
|
import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@WebMvcTest(controllers = [InvitationController::class])
|
||||||
|
@ContextConfiguration(classes = [InvitationController::class, SecurityConfig::class])
|
||||||
|
@ImportAutoConfiguration(
|
||||||
|
exclude = [
|
||||||
|
DataSourceAutoConfiguration::class,
|
||||||
|
DataSourceTransactionManagerAutoConfiguration::class,
|
||||||
|
HibernateJpaAutoConfiguration::class,
|
||||||
|
JpaRepositoriesAutoConfiguration::class
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(SecurityConfig::class, ProblemDetailsAdvice::class)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class InvitationControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var mockMvc: MockMvc
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var invitationService: InvitationService
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var jwtDecoder: JwtDecoder
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolves invite when valid`() {
|
||||||
|
val expiresAt = Instant.parse("2024-01-01T12:00:00Z")
|
||||||
|
`when`(invitationService.resolveInvite("token-123"))
|
||||||
|
.thenReturn(InviteResolveResult("PATIENT", "i***@example.com", expiresAt))
|
||||||
|
|
||||||
|
val body = """{ "token": "token-123" }"""
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/api/v1/invites/resolve")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body)
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.role").value("PATIENT"))
|
||||||
|
.andExpect(jsonPath("$.emailMasked").value("i***@example.com"))
|
||||||
|
.andExpect(jsonPath("$.expiresAt").value(expiresAt.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects expired invite`() {
|
||||||
|
`when`(invitationService.resolveInvite("expired-token"))
|
||||||
|
.thenThrow(IllegalArgumentException("Invalid invitation"))
|
||||||
|
|
||||||
|
val body = """{ "token": "expired-token" }"""
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/api/v1/invites/resolve")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body)
|
||||||
|
)
|
||||||
|
.andExpect(status().isBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package com.mosenioring.identity.repo
|
||||||
|
|
||||||
|
import com.mosenioring.app.Application
|
||||||
|
import com.mosenioring.common.tenant.TenantContext
|
||||||
|
import com.mosenioring.identity.Invitation
|
||||||
|
import com.mosenioring.identity.Patient
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.context.TestPropertySource
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@ContextConfiguration(classes = [Application::class])
|
||||||
|
@TestPropertySource(
|
||||||
|
properties = [
|
||||||
|
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||||
|
"spring.flyway.enabled=false"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class InvitationRepositoryTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var invitationRepository: InvitationRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var patientRepository: PatientRepository
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
TenantContext.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enforces unique token hash`() {
|
||||||
|
TenantContext.setTenantId("t1")
|
||||||
|
val patient = patientRepository.save(Patient(UUID.randomUUID().toString(), "invited@example.com"))
|
||||||
|
val invitation1 = Invitation(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
tokenHash = "hash-1",
|
||||||
|
email = "invited@example.com",
|
||||||
|
role = Invitation.ROLE_PATIENT,
|
||||||
|
patient = patient,
|
||||||
|
status = Invitation.STATUS_PENDING,
|
||||||
|
expiresAt = Instant.now().plusSeconds(3600)
|
||||||
|
)
|
||||||
|
val invitation2 = Invitation(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
tokenHash = "hash-1",
|
||||||
|
email = "invited@example.com",
|
||||||
|
role = Invitation.ROLE_PATIENT,
|
||||||
|
patient = patient,
|
||||||
|
status = Invitation.STATUS_PENDING,
|
||||||
|
expiresAt = Instant.now().plusSeconds(3600)
|
||||||
|
)
|
||||||
|
|
||||||
|
invitationRepository.save(invitation1)
|
||||||
|
invitationRepository.flush()
|
||||||
|
|
||||||
|
assertThrows(DataIntegrityViolationException::class.java) {
|
||||||
|
invitationRepository.saveAndFlush(invitation2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import org.springframework.stereotype.Component
|
||||||
interface PatientRelationshipRepository {
|
interface PatientRelationshipRepository {
|
||||||
fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean
|
fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean
|
||||||
fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean
|
fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean
|
||||||
|
fun isSubjectOf(tenantId: String, patientId: String, userId: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
|
|
@ -20,7 +21,8 @@ class PatientAccessChecker(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
val userId = SecurityUtils.currentUserId() ?: return false
|
val userId = SecurityUtils.currentUserId() ?: return false
|
||||||
return relationships.isCaregiverOf(tenantId, patientId, userId) ||
|
return relationships.isSubjectOf(tenantId, patientId, userId) ||
|
||||||
|
relationships.isCaregiverOf(tenantId, patientId, userId) ||
|
||||||
relationships.isDoctorOf(tenantId, patientId, userId)
|
relationships.isDoctorOf(tenantId, patientId, userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class PatientAccessCheckerTest {
|
||||||
SecurityContextHolder.getContext().authentication = auth
|
SecurityContextHolder.getContext().authentication = auth
|
||||||
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user1")).thenReturn(false)
|
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user1")).thenReturn(false)
|
||||||
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user1")).thenReturn(false)
|
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user1")).thenReturn(false)
|
||||||
|
Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user1")).thenReturn(false)
|
||||||
assertFalse(checker.hasAccess("p1"))
|
assertFalse(checker.hasAccess("p1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,6 +50,18 @@ class PatientAccessCheckerTest {
|
||||||
SecurityContextHolder.getContext().authentication = auth
|
SecurityContextHolder.getContext().authentication = auth
|
||||||
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user2")).thenReturn(true)
|
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user2")).thenReturn(true)
|
||||||
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user2")).thenReturn(false)
|
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user2")).thenReturn(false)
|
||||||
|
Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user2")).thenReturn(false)
|
||||||
|
assertTrue(checker.hasAccess("p1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `allows patient subject relationship`() {
|
||||||
|
val principal = LocalUserPrincipal("user3", "t1", emptySet())
|
||||||
|
val auth = UsernamePasswordAuthenticationToken(principal, "n/a", emptyList())
|
||||||
|
SecurityContextHolder.getContext().authentication = auth
|
||||||
|
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user3")).thenReturn(false)
|
||||||
|
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user3")).thenReturn(false)
|
||||||
|
Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user3")).thenReturn(true)
|
||||||
assertTrue(checker.hasAccess("p1"))
|
assertTrue(checker.hasAccess("p1"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,7 @@ plugins {
|
||||||
dependencies {
|
dependencies {
|
||||||
api(project(":common"))
|
api(project(":common"))
|
||||||
implementation(project(":modules:audit"))
|
implementation(project(":modules:audit"))
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
|
testImplementation("org.mockito:mockito-core")
|
||||||
|
testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
package com.mosenioring.identity
|
||||||
|
|
||||||
|
import com.mosenioring.common.BaseEntity
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.FetchType
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.Index
|
||||||
|
import jakarta.persistence.JoinColumn
|
||||||
|
import jakarta.persistence.ManyToOne
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import jakarta.persistence.UniqueConstraint
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "invitations",
|
||||||
|
uniqueConstraints = [UniqueConstraint(name = "uq_invitations_token_hash", columnNames = ["token_hash"])],
|
||||||
|
indexes = [Index(name = "idx_invitations_status_expires_at", columnList = "status,expires_at")]
|
||||||
|
)
|
||||||
|
class Invitation(
|
||||||
|
@Id
|
||||||
|
@Column(name = "id")
|
||||||
|
val id: String,
|
||||||
|
|
||||||
|
@Column(name = "token_hash", nullable = false, unique = true)
|
||||||
|
val tokenHash: String,
|
||||||
|
|
||||||
|
@Column(name = "email", nullable = false)
|
||||||
|
val email: String,
|
||||||
|
|
||||||
|
@Column(name = "role", nullable = false)
|
||||||
|
val role: String,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "patient_id", nullable = false)
|
||||||
|
val patient: Patient,
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false)
|
||||||
|
var status: String,
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
var expiresAt: Instant,
|
||||||
|
|
||||||
|
@Column(name = "accepted_at")
|
||||||
|
var acceptedAt: Instant? = null,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "accepted_by_user_id")
|
||||||
|
var acceptedBy: User? = null,
|
||||||
|
|
||||||
|
@Column(name = "created_by_admin")
|
||||||
|
val createdByAdmin: String? = null
|
||||||
|
) : BaseEntity() {
|
||||||
|
companion object {
|
||||||
|
const val STATUS_PENDING = "PENDING"
|
||||||
|
const val STATUS_ACCEPTED = "ACCEPTED"
|
||||||
|
const val STATUS_EXPIRED = "EXPIRED"
|
||||||
|
|
||||||
|
const val ROLE_PATIENT = "PATIENT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.mosenioring.identity
|
||||||
|
|
||||||
|
import com.mosenioring.common.BaseEntity
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "patient_subjects")
|
||||||
|
class PatientSubject(
|
||||||
|
@Id
|
||||||
|
@Column(name = "id")
|
||||||
|
val id: String,
|
||||||
|
|
||||||
|
@Column(name = "patient_id", nullable = false)
|
||||||
|
val patientId: String,
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
val userId: String
|
||||||
|
) : BaseEntity()
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
package com.mosenioring.identity.api
|
||||||
|
|
||||||
|
import com.mosenioring.common.security.SecurityUtils
|
||||||
|
import com.mosenioring.identity.service.InvitationService
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import jakarta.validation.constraints.Email
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
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 org.springframework.web.servlet.support.ServletUriComponentsBuilder
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1")
|
||||||
|
class InvitationController(
|
||||||
|
private val invitationService: InvitationService
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping("/admin/invites")
|
||||||
|
fun createInvite(
|
||||||
|
@Valid @RequestBody request: CreateInviteRequest,
|
||||||
|
servletRequest: HttpServletRequest
|
||||||
|
): ResponseEntity<CreateInviteResponse> {
|
||||||
|
val result = invitationService.createPatientInvite(request.email, SecurityUtils.currentUserId())
|
||||||
|
val inviteLink = buildInviteLink(servletRequest, result.token)
|
||||||
|
return ResponseEntity.ok(CreateInviteResponse(inviteLink, result.expiresAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/invites/resolve")
|
||||||
|
fun resolveInvite(@Valid @RequestBody request: ResolveInviteRequest): ResponseEntity<ResolveInviteResponse> {
|
||||||
|
val result = invitationService.resolveInvite(request.token)
|
||||||
|
return ResponseEntity.ok(ResolveInviteResponse(result.role, result.emailMasked, result.expiresAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/invites/accept")
|
||||||
|
fun acceptInvite(@Valid @RequestBody request: AcceptInviteRequest): ResponseEntity<AcceptInviteResponse> {
|
||||||
|
val userId = SecurityUtils.currentUserId() ?: throw IllegalArgumentException("Missing user")
|
||||||
|
val result = invitationService.acceptInvite(request.token, userId)
|
||||||
|
return ResponseEntity.ok(AcceptInviteResponse("accepted", result.patientId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildInviteLink(request: HttpServletRequest, token: String): String {
|
||||||
|
val base = ServletUriComponentsBuilder.fromRequestUri(request)
|
||||||
|
.replacePath("/invite")
|
||||||
|
.replaceQuery(null)
|
||||||
|
.build()
|
||||||
|
.toUriString()
|
||||||
|
return "$base?token=$token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreateInviteRequest(
|
||||||
|
@field:Email val email: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateInviteResponse(val inviteLink: String, val expiresAt: java.time.Instant)
|
||||||
|
|
||||||
|
data class ResolveInviteRequest(
|
||||||
|
@field:NotBlank val token: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ResolveInviteResponse(val role: String, val emailMasked: String, val expiresAt: java.time.Instant)
|
||||||
|
|
||||||
|
data class AcceptInviteRequest(
|
||||||
|
@field:NotBlank val token: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AcceptInviteResponse(val status: String, val patientId: String)
|
||||||
|
|
@ -20,3 +20,11 @@ interface PatientCaregiverRepository : JpaRepository<PatientCaregiver, String> {
|
||||||
interface PatientDoctorRepository : JpaRepository<PatientDoctor, String> {
|
interface PatientDoctorRepository : JpaRepository<PatientDoctor, String> {
|
||||||
fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean
|
fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PatientSubjectRepository : JpaRepository<PatientSubject, String> {
|
||||||
|
fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvitationRepository : JpaRepository<Invitation, String> {
|
||||||
|
fun findByTokenHash(tokenHash: String): Invitation?
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,15 @@ import org.springframework.stereotype.Repository
|
||||||
@Repository
|
@Repository
|
||||||
class PatientRelationshipRepositoryImpl(
|
class PatientRelationshipRepositoryImpl(
|
||||||
private val caregiverRepository: PatientCaregiverRepository,
|
private val caregiverRepository: PatientCaregiverRepository,
|
||||||
private val doctorRepository: PatientDoctorRepository
|
private val doctorRepository: PatientDoctorRepository,
|
||||||
|
private val subjectRepository: PatientSubjectRepository
|
||||||
) : PatientRelationshipRepository {
|
) : PatientRelationshipRepository {
|
||||||
override fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean =
|
override fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean =
|
||||||
caregiverRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
|
caregiverRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
|
||||||
|
|
||||||
override fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean =
|
override fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean =
|
||||||
doctorRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
|
doctorRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
|
||||||
|
|
||||||
|
override fun isSubjectOf(tenantId: String, patientId: String, userId: String): Boolean =
|
||||||
|
subjectRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
package com.mosenioring.identity.service
|
||||||
|
|
||||||
|
import com.mosenioring.common.security.LocalUserPrincipal
|
||||||
|
import com.mosenioring.common.tenant.TenantContext
|
||||||
|
import com.mosenioring.identity.Invitation
|
||||||
|
import com.mosenioring.identity.Patient
|
||||||
|
import com.mosenioring.identity.PatientSubject
|
||||||
|
import com.mosenioring.identity.User
|
||||||
|
import com.mosenioring.identity.repo.InvitationRepository
|
||||||
|
import com.mosenioring.identity.repo.PatientRepository
|
||||||
|
import com.mosenioring.identity.repo.PatientSubjectRepository
|
||||||
|
import com.mosenioring.identity.repo.UserRepository
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
data class InviteCreationResult(val token: String, val expiresAt: Instant)
|
||||||
|
data class InviteResolveResult(val role: String, val emailMasked: String, val expiresAt: Instant)
|
||||||
|
data class InviteAcceptResult(val patientId: String, val role: String)
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class InvitationService(
|
||||||
|
private val invitationRepository: InvitationRepository,
|
||||||
|
private val patientRepository: PatientRepository,
|
||||||
|
private val userRepository: UserRepository,
|
||||||
|
private val subjectRepository: PatientSubjectRepository,
|
||||||
|
private val keycloakProvisioningService: KeycloakProvisioningService,
|
||||||
|
@Value("\${app.invites.token-pepper:change-me}") private val tokenPepper: String
|
||||||
|
) {
|
||||||
|
@Transactional
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
fun createPatientInvite(email: String, createdByAdmin: String?): InviteCreationResult {
|
||||||
|
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" }
|
||||||
|
|
||||||
|
val patient = Patient(UUID.randomUUID().toString(), generatePatientPlaceholderName())
|
||||||
|
val savedPatient = patientRepository.save(patient)
|
||||||
|
|
||||||
|
keycloakProvisioningService.provisionUser(email, Invitation.ROLE_PATIENT)?.let { userId ->
|
||||||
|
val user = User(userId, email, Invitation.ROLE_PATIENT, "INVITED")
|
||||||
|
userRepository.save(user)
|
||||||
|
}
|
||||||
|
keycloakProvisioningService.sendSetPasswordEmail(email)
|
||||||
|
|
||||||
|
val token = generateToken()
|
||||||
|
val invitation = Invitation(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
tokenHash = hashToken(token),
|
||||||
|
email = email,
|
||||||
|
role = Invitation.ROLE_PATIENT,
|
||||||
|
patient = savedPatient,
|
||||||
|
status = Invitation.STATUS_PENDING,
|
||||||
|
expiresAt = Instant.now().plus(INVITE_TTL),
|
||||||
|
acceptedAt = null,
|
||||||
|
acceptedBy = null,
|
||||||
|
createdByAdmin = createdByAdmin
|
||||||
|
)
|
||||||
|
invitationRepository.save(invitation)
|
||||||
|
return InviteCreationResult(token, invitation.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun resolveInvite(token: String): InviteResolveResult {
|
||||||
|
val invitation = invitationRepository.findByTokenHash(hashToken(token))
|
||||||
|
?: throw IllegalArgumentException("Invalid invitation")
|
||||||
|
if (invitation.status != Invitation.STATUS_PENDING || isExpired(invitation)) {
|
||||||
|
if (isExpired(invitation)) {
|
||||||
|
markExpired(invitation)
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Invalid invitation")
|
||||||
|
}
|
||||||
|
return InviteResolveResult(invitation.role, maskEmail(invitation.email), invitation.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun acceptInvite(token: String, authenticatedUserId: String): InviteAcceptResult {
|
||||||
|
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" }
|
||||||
|
|
||||||
|
val invitation = loadInvitation(token)
|
||||||
|
if (invitation.status != Invitation.STATUS_PENDING) {
|
||||||
|
throw IllegalArgumentException("Invitation not available")
|
||||||
|
}
|
||||||
|
if (isExpired(invitation)) {
|
||||||
|
markExpired(invitation)
|
||||||
|
throw IllegalArgumentException("Invitation expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
val authenticatedEmail = resolveAuthenticatedEmail()
|
||||||
|
val user = userRepository.findById(authenticatedUserId).orElseGet {
|
||||||
|
if (authenticatedEmail.isNullOrBlank()) {
|
||||||
|
throw IllegalArgumentException("User not found")
|
||||||
|
}
|
||||||
|
if (!invitation.email.equals(authenticatedEmail, ignoreCase = true)) {
|
||||||
|
throw IllegalArgumentException("Invitation email mismatch")
|
||||||
|
}
|
||||||
|
val newUser = User(authenticatedUserId, authenticatedEmail, invitation.role, "ACTIVE")
|
||||||
|
userRepository.save(newUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invitation.email.equals(user.email, ignoreCase = true)) {
|
||||||
|
throw IllegalArgumentException("Invitation email mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.role == Invitation.ROLE_PATIENT) {
|
||||||
|
linkPatientToUser(invitation.patient.id, user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
invitation.status = Invitation.STATUS_ACCEPTED
|
||||||
|
invitation.acceptedAt = Instant.now()
|
||||||
|
invitation.acceptedBy = user
|
||||||
|
invitationRepository.save(invitation)
|
||||||
|
|
||||||
|
return InviteAcceptResult(invitation.patient.id, invitation.role)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInvitation(token: String): Invitation =
|
||||||
|
invitationRepository.findByTokenHash(hashToken(token))
|
||||||
|
?: throw IllegalArgumentException("Invitation not found")
|
||||||
|
|
||||||
|
private fun isExpired(invitation: Invitation): Boolean =
|
||||||
|
Instant.now().isAfter(invitation.expiresAt)
|
||||||
|
|
||||||
|
private fun markExpired(invitation: Invitation) {
|
||||||
|
if (invitation.status == Invitation.STATUS_PENDING) {
|
||||||
|
invitation.status = Invitation.STATUS_EXPIRED
|
||||||
|
invitationRepository.save(invitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linkPatientToUser(patientId: String, userId: String) {
|
||||||
|
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
|
||||||
|
if (!subjectRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)) {
|
||||||
|
val link = PatientSubject(UUID.randomUUID().toString(), patientId, userId)
|
||||||
|
subjectRepository.save(link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveAuthenticatedEmail(): String? {
|
||||||
|
val authentication = SecurityContextHolder.getContext().authentication ?: return null
|
||||||
|
return when (authentication) {
|
||||||
|
is JwtAuthenticationToken -> authentication.token.getClaimAsString("email")
|
||||||
|
?: authentication.token.getClaimAsString("preferred_username")
|
||||||
|
else -> (authentication.principal as? LocalUserPrincipal)?.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateToken(): String {
|
||||||
|
val bytes = ByteArray(TOKEN_BYTES)
|
||||||
|
secureRandom.nextBytes(bytes)
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hashToken(token: String): String {
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
val key = SecretKeySpec(tokenPepper.toByteArray(Charsets.UTF_8), "HmacSHA256")
|
||||||
|
mac.init(key)
|
||||||
|
val hash = mac.doFinal(token.toByteArray(Charsets.UTF_8))
|
||||||
|
return hash.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maskEmail(email: String): String {
|
||||||
|
val atIndex = email.indexOf('@')
|
||||||
|
if (atIndex <= 0) {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
val name = email.substring(0, atIndex)
|
||||||
|
val domain = email.substring(atIndex + 1)
|
||||||
|
val masked = if (name.length == 1) "*" else "${name.first()}***"
|
||||||
|
return "$masked@$domain"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generatePatientPlaceholderName(): String {
|
||||||
|
val suffix = UUID.randomUUID().toString().replace("-", "").take(6)
|
||||||
|
return "Patient-$suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val INVITE_TTL = Duration.ofHours(24)
|
||||||
|
private const val TOKEN_BYTES = 32
|
||||||
|
private val secureRandom = SecureRandom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.mosenioring.identity.service
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
interface KeycloakProvisioningService {
|
||||||
|
fun provisionUser(email: String, role: String): String?
|
||||||
|
fun sendSetPasswordEmail(email: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class NoopKeycloakProvisioningService : KeycloakProvisioningService {
|
||||||
|
override fun provisionUser(email: String, role: String): String? = null
|
||||||
|
override fun sendSetPasswordEmail(email: String) = Unit
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
package com.mosenioring.identity.service
|
||||||
|
|
||||||
|
import com.mosenioring.common.tenant.TenantContext
|
||||||
|
import com.mosenioring.identity.Invitation
|
||||||
|
import com.mosenioring.identity.Patient
|
||||||
|
import com.mosenioring.identity.PatientSubject
|
||||||
|
import com.mosenioring.identity.User
|
||||||
|
import com.mosenioring.identity.repo.InvitationRepository
|
||||||
|
import com.mosenioring.identity.repo.PatientRepository
|
||||||
|
import com.mosenioring.identity.repo.PatientSubjectRepository
|
||||||
|
import com.mosenioring.identity.repo.UserRepository
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.kotlin.any
|
||||||
|
import org.mockito.kotlin.mock
|
||||||
|
import org.mockito.kotlin.whenever
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Optional
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class InvitationServiceTest {
|
||||||
|
|
||||||
|
private val invitationRepository: InvitationRepository = mock()
|
||||||
|
private val patientRepository: PatientRepository = mock()
|
||||||
|
private val userRepository: UserRepository = mock()
|
||||||
|
private val subjectRepository: PatientSubjectRepository = mock()
|
||||||
|
private val keycloakProvisioningService: KeycloakProvisioningService = mock()
|
||||||
|
private val service = InvitationService(
|
||||||
|
invitationRepository,
|
||||||
|
patientRepository,
|
||||||
|
userRepository,
|
||||||
|
subjectRepository,
|
||||||
|
keycloakProvisioningService,
|
||||||
|
"test-pepper"
|
||||||
|
)
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
TenantContext.setTenantId("t1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
TenantContext.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accepts valid invite`() {
|
||||||
|
val token = "token-123"
|
||||||
|
val patient = Patient("patient-1", "invite@example.com")
|
||||||
|
val invitation = Invitation(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
tokenHash = hashToken(token),
|
||||||
|
email = "invite@example.com",
|
||||||
|
role = Invitation.ROLE_PATIENT,
|
||||||
|
patient = patient,
|
||||||
|
status = Invitation.STATUS_PENDING,
|
||||||
|
expiresAt = Instant.now().plusSeconds(3600)
|
||||||
|
)
|
||||||
|
val user = User("user-1", "invite@example.com", Invitation.ROLE_PATIENT, "ACTIVE")
|
||||||
|
|
||||||
|
whenever(invitationRepository.findByTokenHash(invitation.tokenHash)).thenReturn(invitation)
|
||||||
|
whenever(userRepository.findById("user-1")).thenReturn(Optional.of(user))
|
||||||
|
whenever(subjectRepository.existsByTenantIdAndPatientIdAndUserId("t1", patient.id, user.id))
|
||||||
|
.thenReturn(false)
|
||||||
|
whenever(subjectRepository.save(any<PatientSubject>())).thenAnswer { it.arguments[0] as PatientSubject }
|
||||||
|
whenever(invitationRepository.save(any<Invitation>())).thenAnswer { it.arguments[0] as Invitation }
|
||||||
|
|
||||||
|
val result = service.acceptInvite(token, "user-1")
|
||||||
|
|
||||||
|
assertEquals(patient.id, result.patientId)
|
||||||
|
assertEquals(Invitation.ROLE_PATIENT, result.role)
|
||||||
|
assertEquals(Invitation.STATUS_ACCEPTED, invitation.status)
|
||||||
|
assertNotNull(invitation.acceptedAt)
|
||||||
|
assertEquals(user, invitation.acceptedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects expired invite`() {
|
||||||
|
val token = "expired-token"
|
||||||
|
val patient = Patient("patient-1", "invite@example.com")
|
||||||
|
val invitation = Invitation(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
tokenHash = hashToken(token),
|
||||||
|
email = "invite@example.com",
|
||||||
|
role = Invitation.ROLE_PATIENT,
|
||||||
|
patient = patient,
|
||||||
|
status = Invitation.STATUS_PENDING,
|
||||||
|
expiresAt = Instant.now().minusSeconds(60)
|
||||||
|
)
|
||||||
|
whenever(invitationRepository.findByTokenHash(invitation.tokenHash)).thenReturn(invitation)
|
||||||
|
whenever(invitationRepository.save(any<Invitation>())).thenAnswer { it.arguments[0] as Invitation }
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
service.acceptInvite(token, "user-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(Invitation.STATUS_EXPIRED, invitation.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects invite when email mismatches`() {
|
||||||
|
val token = "mismatch-token"
|
||||||
|
val patient = Patient("patient-1", "invite@example.com")
|
||||||
|
val invitation = Invitation(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
tokenHash = hashToken(token),
|
||||||
|
email = "invite@example.com",
|
||||||
|
role = Invitation.ROLE_PATIENT,
|
||||||
|
patient = patient,
|
||||||
|
status = Invitation.STATUS_PENDING,
|
||||||
|
expiresAt = Instant.now().plusSeconds(3600)
|
||||||
|
)
|
||||||
|
val user = User("user-1", "other@example.com", Invitation.ROLE_PATIENT, "ACTIVE")
|
||||||
|
|
||||||
|
whenever(invitationRepository.findByTokenHash(invitation.tokenHash)).thenReturn(invitation)
|
||||||
|
whenever(userRepository.findById("user-1")).thenReturn(Optional.of(user))
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
service.acceptInvite(token, "user-1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hashToken(token: String): String {
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
val key = SecretKeySpec("test-pepper".toByteArray(Charsets.UTF_8), "HmacSHA256")
|
||||||
|
mac.init(key)
|
||||||
|
val hash = mac.doFinal(token.toByteArray(Charsets.UTF_8))
|
||||||
|
return hash.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue