Compare commits
10 commits
ec670e90ae
...
8ced1f7925
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ced1f7925 | ||
|
|
5628aa5675 | ||
|
|
1315b95a72 | ||
|
|
d096fc479d | ||
|
|
e5f7c3ee19 | ||
|
|
553ae2bd69 | ||
|
|
bbd7a371dd | ||
|
|
73597b9bca | ||
|
|
d8f91f42e5 | ||
|
|
8cfb81c99d |
|
|
@ -2,65 +2,72 @@
|
|||
|
||||
Production-ready Kotlin/Spring Boot 3 modular monolith skeleton for patient-caregiver-doctor coordination.
|
||||
|
||||
## Requirements
|
||||
## 🏛 Architecture
|
||||
This project follows a **Modular Monolith** architecture:
|
||||
- `app`: Main entry point, configuration, and shared controllers.
|
||||
- `common`: Cross-cutting concerns (security, tenant handling, outbox pattern).
|
||||
- `modules/*`: Independent business modules (Clinical, Identity, Messaging, etc.).
|
||||
- `workers/*`: Background event consumers/processors.
|
||||
|
||||
## 🛠 Requirements
|
||||
- Java 21
|
||||
- Docker + Docker Compose
|
||||
|
||||
## Local run
|
||||
1) Start dependencies:
|
||||
## 🚀 Local Run
|
||||
|
||||
### 1. Start Dependencies
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2) Run the API (local profile, local auth enabled):
|
||||
### 2. Run the API
|
||||
Choose a profile:
|
||||
|
||||
**Local Development (with mock auth):**
|
||||
```bash
|
||||
SPRING_PROFILES_ACTIVE=local \
|
||||
ALLOW_LOCAL_AUTH=true \
|
||||
./gradlew :app:bootRun
|
||||
SPRING_PROFILES_ACTIVE=local ALLOW_LOCAL_AUTH=true ./gradlew :app:bootRun
|
||||
```
|
||||
*Allows bypassing Keycloak using `X-Local-*` headers.*
|
||||
|
||||
**Dev Mode (with Keycloak):**
|
||||
```bash
|
||||
SPRING_PROFILES_ACTIVE=dev ./gradlew :app:bootRun
|
||||
```
|
||||
|
||||
```bash
|
||||
SPRING_PROFILES_ACTIVE=dev \
|
||||
ALLOW_LOCAL_AUTH=false \
|
||||
./gradlew :app:bootRun
|
||||
```
|
||||
|
||||
3) (Optional) Run the worker:
|
||||
|
||||
### 3. Run the Worker (Optional)
|
||||
```bash
|
||||
./gradlew :workers:notification-worker:bootRun
|
||||
```
|
||||
|
||||
Required env flags (local/dev):
|
||||
- `SPRING_PROFILES_ACTIVE=local`
|
||||
- `ALLOW_LOCAL_AUTH=true` (enables local auth headers)
|
||||
- `KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/mosenioring` (if using Keycloak)
|
||||
- Frontend should set `USE_LOCAL_AUTH=true` when using local auth headers.
|
||||
## 🧪 Testing
|
||||
Run all tests:
|
||||
```bash
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
## Auth
|
||||
- The backend is a JWT resource server and does not handle user passwords.
|
||||
- Local auth shortcut is available only when `SPRING_PROFILES_ACTIVE=local`
|
||||
and `ALLOW_LOCAL_AUTH=true`.
|
||||
## 🔐 Auth & Multi-tenancy
|
||||
- **JWT Resource Server**: Uses Keycloak by default.
|
||||
- **Multi-tenancy**: Enforced via `X-Tenant-Id` header (local) or `tenant_id` JWT claim.
|
||||
- **Local Auth Headers** (only when `ALLOW_LOCAL_AUTH=true`):
|
||||
- `X-Local-Email`: User identity.
|
||||
- `X-Local-Roles`: e.g., `ADMIN, DOCTOR, CAREGIVER`.
|
||||
- `X-Tenant-Id`: Target tenant.
|
||||
|
||||
Local headers (dev only):
|
||||
- `X-Local-Email`: user id/email
|
||||
- `X-Local-Roles`: comma-separated roles (ADMIN, DOCTOR, CAREGIVER)
|
||||
- `X-Tenant-Id`: tenant id
|
||||
## 📨 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.
|
||||
|
||||
## OpenAPI
|
||||
- http://localhost:8080/swagger-ui/index.html
|
||||
## Health
|
||||
- http://localhost:8080/health
|
||||
## 🔗 Key Services & Links
|
||||
- **OpenAPI**: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html)
|
||||
- **Health**: [http://localhost:8080/health](http://localhost:8080/health)
|
||||
- **Postgres**: `localhost:5432` (mosenioring/mosenioring)
|
||||
- **Keycloak**: [http://localhost:8081](http://localhost:8081) (admin/admin)
|
||||
- **RabbitMQ**: [http://localhost:15672](http://localhost:15672) (guest/guest)
|
||||
- **MinIO**: [http://localhost:9001](http://localhost:9001) (minio/minio123)
|
||||
|
||||
## Key services
|
||||
- Postgres: localhost:5432 (mosenioring/mosenioring)
|
||||
- Keycloak: http://localhost:8081 (admin/admin)
|
||||
- RabbitMQ: http://localhost:15672 (guest/guest)
|
||||
- MinIO: http://localhost:9001 (minio/minio123)
|
||||
|
||||
## Notes
|
||||
- Tenant ID is enforced via `TenantFilter` using JWT claim `tenant_id`, or `X-Tenant-Id` header (local).
|
||||
- Medication plan creation publishes a `MedicationPlanCreated` outbox event.
|
||||
- Worker consumes and emits `NotificationRequested` events with idempotency via Redis.
|
||||
## 📝 Notes
|
||||
- **Outbox Pattern**: Medication plans publish events to an outbox table for reliable messaging.
|
||||
- **Idempotency**: Workers use Redis to ensure events are processed only once.
|
||||
|
|
|
|||
|
|
@ -24,14 +24,16 @@ dependencies {
|
|||
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
||||
implementation("org.flywaydb:flyway-core")
|
||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0")
|
||||
implementation("org.flywaydb:flyway-database-postgresql")
|
||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
|
||||
implementation("io.micrometer:micrometer-registry-prometheus")
|
||||
implementation("io.micrometer:micrometer-tracing-bridge-otel")
|
||||
implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.38.0")
|
||||
implementation("software.amazon.awssdk:s3:2.25.63")
|
||||
implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.46.0")
|
||||
implementation("software.amazon.awssdk:s3:2.29.52")
|
||||
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
testImplementation("com.h2database:h2")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class SecurityConfig {
|
|||
.authorizeHttpRequests {
|
||||
it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").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.anyRequest().authenticated()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ app:
|
|||
notification-exchange: notification.events
|
||||
medication-queue: medication.plan.created
|
||||
notification-queue: notification.requested
|
||||
invites:
|
||||
token-pepper: change-me-in-prod
|
||||
|
||||
management:
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,11 @@ import org.gradle.api.plugins.JavaPluginExtension
|
|||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
kotlin("jvm") version "1.9.23" apply false
|
||||
kotlin("plugin.spring") version "1.9.23" apply false
|
||||
kotlin("plugin.jpa") version "1.9.23" apply false
|
||||
id("org.springframework.boot") version "3.2.5" apply false
|
||||
id("io.spring.dependency-management") version "1.1.4" apply false
|
||||
kotlin("jvm") version "1.9.25" apply false
|
||||
kotlin("plugin.spring") version "1.9.25" apply false
|
||||
kotlin("plugin.jpa") version "1.9.25" apply false
|
||||
id("org.springframework.boot") version "3.4.1" apply false
|
||||
id("io.spring.dependency-management") version "1.1.7" apply false
|
||||
}
|
||||
|
||||
allprojects {
|
||||
|
|
@ -23,14 +23,14 @@ subprojects {
|
|||
plugins.withId("io.spring.dependency-management") {
|
||||
the<DependencyManagementExtension>().apply {
|
||||
imports {
|
||||
mavenBom("org.springframework.boot:spring-boot-dependencies:3.2.5")
|
||||
mavenBom("org.springframework.boot:spring-boot-dependencies:3.4.1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile> {
|
||||
kotlinOptions {
|
||||
jvmTarget = "21"
|
||||
jvmTarget = "17"
|
||||
freeCompilerArgs = listOf("-Xjsr305=strict")
|
||||
}
|
||||
}
|
||||
|
|
@ -38,14 +38,14 @@ subprojects {
|
|||
plugins.withId("java") {
|
||||
extensions.configure<JavaPluginExtension> {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
languageVersion.set(JavaLanguageVersion.of(17))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plugins.withId("org.jetbrains.kotlin.jvm") {
|
||||
extensions.configure<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension> {
|
||||
jvmToolchain(21)
|
||||
jvmToolchain(17)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,4 +21,5 @@ dependencies {
|
|||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.mockito:mockito-core:5.12.0")
|
||||
testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
package com.mosenioring.common.outbox
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.mosenioring.common.security.SecurityUtils
|
||||
import com.mosenioring.common.tenant.TenantContext
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
|
@ -14,15 +12,11 @@ class OutboxService(
|
|||
private val objectMapper: ObjectMapper
|
||||
) {
|
||||
fun enqueue(eventType: String, payload: Any): OutboxEvent {
|
||||
val tenantId = TenantContext.getTenantId() ?: "unknown"
|
||||
val event = OutboxEvent(
|
||||
id = UUID.randomUUID().toString(),
|
||||
eventType = eventType,
|
||||
payload = objectMapper.writeValueAsString(payload)
|
||||
)
|
||||
event.tenantId = tenantId
|
||||
event.createdBy = SecurityUtils.currentUserId()
|
||||
event.updatedBy = SecurityUtils.currentUserId()
|
||||
return repository.save(event)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import org.springframework.stereotype.Component
|
|||
interface PatientRelationshipRepository {
|
||||
fun isCaregiverOf(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
|
||||
|
|
@ -20,7 +21,8 @@ class PatientAccessChecker(
|
|||
return true
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
package com.mosenioring.common
|
||||
|
||||
import com.mosenioring.common.tenant.TenantContext
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
|
||||
class BaseEntityTest {
|
||||
|
||||
class TestEntity : BaseEntity()
|
||||
|
||||
@Test
|
||||
fun `onCreate populates metadata`() {
|
||||
TenantContext.setTenantId("tenant-123")
|
||||
val entity = TestEntity()
|
||||
|
||||
entity.onCreate()
|
||||
|
||||
assertEquals("tenant-123", entity.tenantId)
|
||||
assertNotNull(entity.createdAt)
|
||||
assertNotNull(entity.updatedAt)
|
||||
assertEquals(entity.createdAt, entity.updatedAt)
|
||||
TenantContext.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onUpdate updates updatedAt`() {
|
||||
val entity = TestEntity()
|
||||
entity.onCreate()
|
||||
val originalUpdatedAt = entity.updatedAt
|
||||
|
||||
Thread.sleep(10) // Ensure some time passes
|
||||
entity.onUpdate()
|
||||
|
||||
assertTrue(entity.updatedAt.isAfter(originalUpdatedAt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not overwrite existing tenantId`() {
|
||||
val entity = TestEntity()
|
||||
entity.tenantId = "manual-tenant"
|
||||
|
||||
entity.onCreate()
|
||||
|
||||
assertEquals("manual-tenant", entity.tenantId)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ class PatientAccessCheckerTest {
|
|||
SecurityContextHolder.getContext().authentication = auth
|
||||
Mockito.`when`(relationships.isCaregiverOf("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"))
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +50,18 @@ class PatientAccessCheckerTest {
|
|||
SecurityContextHolder.getContext().authentication = auth
|
||||
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user2")).thenReturn(true)
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_DB: mosenioring
|
||||
POSTGRES_USER: mosenioring
|
||||
|
|
@ -11,7 +11,7 @@ services:
|
|||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:22.0.5
|
||||
image: quay.io/keycloak/keycloak:26.0.8
|
||||
command: start-dev --import-realm
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
|
|
@ -22,18 +22,18 @@ services:
|
|||
- ./docker/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.12-management
|
||||
image: rabbitmq:3.13-management
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
image: redis:7.4
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2024-04-06T05-26-02Z
|
||||
image: minio/minio:latest
|
||||
environment:
|
||||
MINIO_ROOT_USER: minio
|
||||
MINIO_ROOT_PASSWORD: minio123
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"realm": [
|
||||
{ "name": "ADMIN" },
|
||||
{ "name": "DOCTOR" },
|
||||
{ "name": "CAREGIVER" }
|
||||
{ "name": "CAREGIVER" },
|
||||
{ "name": "PATIENT" }
|
||||
]
|
||||
},
|
||||
"clients": [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
|
|
|||
|
|
@ -9,4 +9,7 @@ plugins {
|
|||
dependencies {
|
||||
api(project(":common"))
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
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,38 @@
|
|||
package com.mosenioring.audit.service
|
||||
|
||||
import com.mosenioring.audit.AuditEvent
|
||||
import com.mosenioring.audit.AuditRepository
|
||||
import com.mosenioring.common.tenant.TenantContext
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito.*
|
||||
|
||||
class AuditServiceTest {
|
||||
|
||||
private lateinit var repository: AuditRepository
|
||||
private lateinit var service: AuditService
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repository = mock(AuditRepository::class.java)
|
||||
service = AuditService(repository)
|
||||
TenantContext.setTenantId("t-test")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records audit event correctly`() {
|
||||
service.record("ACTION_X", "resource_y", "id-1", "p-1")
|
||||
|
||||
val captor = ArgumentCaptor.forClass(AuditEvent::class.java)
|
||||
verify(repository).save(captor.capture())
|
||||
|
||||
val event = captor.value
|
||||
assertEquals("ACTION_X", event.action)
|
||||
assertEquals("resource_y", event.resource)
|
||||
assertEquals("id-1", event.resourceId)
|
||||
assertEquals("p-1", event.patientId)
|
||||
assertEquals("t-test", event.tenantId)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,4 +9,7 @@ plugins {
|
|||
dependencies {
|
||||
api(project(":common"))
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import com.mosenioring.clinical.repo.TestOrderRepository
|
|||
import com.mosenioring.common.Events
|
||||
import com.mosenioring.common.outbox.OutboxService
|
||||
import com.mosenioring.common.security.PatientAccess
|
||||
import com.mosenioring.common.security.SecurityUtils
|
||||
import com.mosenioring.common.tenant.TenantContext
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.stereotype.Service
|
||||
|
|
@ -26,9 +25,6 @@ class MedicationPlanService(
|
|||
fun createMedicationPlan(patientId: String, description: String): MedicationPlan {
|
||||
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
|
||||
val plan = MedicationPlan(UUID.randomUUID().toString(), patientId, description)
|
||||
plan.tenantId = tenantId
|
||||
plan.createdBy = SecurityUtils.currentUserId()
|
||||
plan.updatedBy = SecurityUtils.currentUserId()
|
||||
val saved = repository.save(plan)
|
||||
outboxService.enqueue(
|
||||
Events.MEDICATION_PLAN_CREATED,
|
||||
|
|
@ -52,11 +48,8 @@ class TestOrderService(
|
|||
@Transactional
|
||||
@PatientAccess
|
||||
fun createTestOrder(patientId: String, testName: String): TestOrder {
|
||||
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
|
||||
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" }
|
||||
val order = TestOrder(UUID.randomUUID().toString(), patientId, testName, "ORDERED")
|
||||
order.tenantId = tenantId
|
||||
order.createdBy = SecurityUtils.currentUserId()
|
||||
order.updatedBy = SecurityUtils.currentUserId()
|
||||
val saved = repository.save(order)
|
||||
auditService.record("TEST_ORDER_CREATED", "test", saved.id, patientId)
|
||||
return saved
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.mosenioring.clinical.service
|
||||
|
||||
import com.mosenioring.audit.service.AuditService
|
||||
import com.mosenioring.clinical.MedicationPlan
|
||||
import com.mosenioring.clinical.repo.MedicationPlanRepository
|
||||
import com.mosenioring.common.Events
|
||||
import com.mosenioring.common.outbox.OutboxService
|
||||
import com.mosenioring.common.tenant.TenantContext
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.kotlin.*
|
||||
|
||||
class MedicationPlanServiceTest {
|
||||
|
||||
private val repository: MedicationPlanRepository = mock()
|
||||
private val outboxService: OutboxService = mock()
|
||||
private val auditService: AuditService = mock()
|
||||
private val service: MedicationPlanService = MedicationPlanService(repository, outboxService, auditService)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
TenantContext.setTenantId("t1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creates medication plan and records events`() {
|
||||
val planToReturn = MedicationPlan("id-1", "p1", "Take vitamin D")
|
||||
doReturn(planToReturn).whenever(repository).save(any())
|
||||
doReturn(mock<com.mosenioring.common.outbox.OutboxEvent>()).whenever(outboxService).enqueue(any(), any())
|
||||
|
||||
val plan = service.createMedicationPlan("p1", "Take vitamin D")
|
||||
|
||||
assertNotNull(plan)
|
||||
assertEquals("p1", plan.patientId)
|
||||
assertEquals("Take vitamin D", plan.description)
|
||||
|
||||
verify(repository).save(any<MedicationPlan>())
|
||||
verify(outboxService).enqueue(eq(Events.MEDICATION_PLAN_CREATED), any())
|
||||
verify(auditService).record(eq("MEDICATION_PLAN_CREATED"), eq("medication_plan"), any(), eq("p1"))
|
||||
}
|
||||
}
|
||||
|
|
@ -9,4 +9,7 @@ plugins {
|
|||
dependencies {
|
||||
api(project(":common"))
|
||||
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> {
|
||||
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
|
||||
class PatientRelationshipRepositoryImpl(
|
||||
private val caregiverRepository: PatientCaregiverRepository,
|
||||
private val doctorRepository: PatientDoctorRepository
|
||||
private val doctorRepository: PatientDoctorRepository,
|
||||
private val subjectRepository: PatientSubjectRepository
|
||||
) : PatientRelationshipRepository {
|
||||
override fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean =
|
||||
caregiverRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
|
||||
|
||||
override fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean =
|
||||
doctorRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
|
||||
|
||||
override fun isSubjectOf(tenantId: String, patientId: String, userId: String): Boolean =
|
||||
subjectRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.mosenioring.identity.service
|
||||
|
||||
import com.mosenioring.audit.service.AuditService
|
||||
import com.mosenioring.common.security.SecurityUtils
|
||||
import com.mosenioring.common.tenant.TenantContext
|
||||
import com.mosenioring.identity.*
|
||||
import com.mosenioring.identity.repo.*
|
||||
|
|
@ -20,8 +19,6 @@ class TenantService(
|
|||
fun createTenant(name: String): Tenant {
|
||||
val tenant = Tenant(UUID.randomUUID().toString(), name)
|
||||
tenant.tenantId = tenant.id
|
||||
tenant.createdBy = SecurityUtils.currentUserId()
|
||||
tenant.updatedBy = SecurityUtils.currentUserId()
|
||||
val saved = tenantRepository.save(tenant)
|
||||
auditService.record("TENANT_CREATED", "tenant", saved.id, null)
|
||||
return saved
|
||||
|
|
@ -36,11 +33,8 @@ class UserService(
|
|||
@Transactional
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
fun inviteUser(email: String, role: String): User {
|
||||
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
|
||||
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" }
|
||||
val user = User(UUID.randomUUID().toString(), email, role, "INVITED")
|
||||
user.tenantId = tenantId
|
||||
user.createdBy = SecurityUtils.currentUserId()
|
||||
user.updatedBy = SecurityUtils.currentUserId()
|
||||
val saved = userRepository.save(user)
|
||||
auditService.record("USER_INVITED", "user", saved.id, null)
|
||||
return saved
|
||||
|
|
@ -57,11 +51,8 @@ class PatientService(
|
|||
@Transactional
|
||||
@PreAuthorize("hasAnyRole('ADMIN','DOCTOR','CAREGIVER')")
|
||||
fun createPatient(fullName: String): Patient {
|
||||
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
|
||||
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" }
|
||||
val patient = Patient(UUID.randomUUID().toString(), fullName)
|
||||
patient.tenantId = tenantId
|
||||
patient.createdBy = SecurityUtils.currentUserId()
|
||||
patient.updatedBy = SecurityUtils.currentUserId()
|
||||
val saved = patientRepository.save(patient)
|
||||
auditService.record("PATIENT_CREATED", "patient", saved.id, saved.id)
|
||||
return saved
|
||||
|
|
@ -73,9 +64,6 @@ class PatientService(
|
|||
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
|
||||
patientRepository.findByIdAndTenantId(patientId, tenantId) ?: throw IllegalArgumentException("Patient not found")
|
||||
val link = PatientCaregiver(UUID.randomUUID().toString(), patientId, userId)
|
||||
link.tenantId = tenantId
|
||||
link.createdBy = SecurityUtils.currentUserId()
|
||||
link.updatedBy = SecurityUtils.currentUserId()
|
||||
val saved = caregiverRepository.save(link)
|
||||
auditService.record("CARE_GIVER_LINKED", "patient_caregiver", saved.id, patientId)
|
||||
return saved
|
||||
|
|
@ -87,9 +75,6 @@ class PatientService(
|
|||
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
|
||||
patientRepository.findByIdAndTenantId(patientId, tenantId) ?: throw IllegalArgumentException("Patient not found")
|
||||
val link = PatientDoctor(UUID.randomUUID().toString(), patientId, userId)
|
||||
link.tenantId = tenantId
|
||||
link.createdBy = SecurityUtils.currentUserId()
|
||||
link.updatedBy = SecurityUtils.currentUserId()
|
||||
val saved = doctorRepository.save(link)
|
||||
auditService.record("DOCTOR_LINKED", "patient_doctor", saved.id, patientId)
|
||||
return saved
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -43,9 +43,6 @@ class FileService(
|
|||
val fileId = UUID.randomUUID().toString()
|
||||
val storageKey = "$tenantId/$fileId/$fileName"
|
||||
val metadata = FileMetadata(fileId, patientId, fileName, contentType, storageKey)
|
||||
metadata.tenantId = tenantId
|
||||
metadata.createdBy = SecurityUtils.currentUserId()
|
||||
metadata.updatedBy = SecurityUtils.currentUserId()
|
||||
repository.save(metadata)
|
||||
|
||||
val request = PutObjectRequest.builder()
|
||||
|
|
|
|||
|
|
@ -9,4 +9,7 @@ plugins {
|
|||
dependencies {
|
||||
api(project(":common"))
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,9 @@ class MessageService(
|
|||
@Transactional
|
||||
@PatientAccess
|
||||
fun addMessage(patientId: String, body: String): Message {
|
||||
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
|
||||
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" }
|
||||
val senderId = SecurityUtils.currentUserId() ?: throw IllegalStateException("Missing user")
|
||||
val message = Message(UUID.randomUUID().toString(), patientId, senderId, body)
|
||||
message.tenantId = tenantId
|
||||
message.createdBy = senderId
|
||||
message.updatedBy = senderId
|
||||
val saved = repository.save(message)
|
||||
auditService.record("MESSAGE_ADDED", "message", saved.id, patientId)
|
||||
return saved
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package com.mosenioring.messaging.service
|
||||
|
||||
import com.mosenioring.audit.service.AuditService
|
||||
import com.mosenioring.common.tenant.TenantContext
|
||||
import com.mosenioring.common.security.SecurityUtils
|
||||
import com.mosenioring.messaging.Message
|
||||
import com.mosenioring.messaging.repo.MessageRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.kotlin.*
|
||||
import org.mockito.MockedStatic
|
||||
import org.mockito.Mockito
|
||||
|
||||
class MessageServiceTest {
|
||||
|
||||
private val repository: MessageRepository = mock()
|
||||
private val auditService: AuditService = mock()
|
||||
private val service: MessageService = MessageService(repository, auditService)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
TenantContext.setTenantId("t1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adds message and records audit`() {
|
||||
val userId = "u1"
|
||||
val patientId = "p1"
|
||||
val body = "Hello"
|
||||
|
||||
val principal = com.mosenioring.common.security.LocalUserPrincipal(userId, "t1", emptySet())
|
||||
val auth = org.springframework.security.authentication.UsernamePasswordAuthenticationToken(principal, "n/a", emptyList())
|
||||
org.springframework.security.core.context.SecurityContextHolder.getContext().authentication = auth
|
||||
|
||||
try {
|
||||
whenever(repository.save(any<Message>())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val result = service.addMessage(patientId, body)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(patientId, result.patientId)
|
||||
assertEquals(userId, result.senderId)
|
||||
assertEquals(body, result.body)
|
||||
|
||||
verify(repository).save(any<Message>())
|
||||
verify(auditService).record(eq("MESSAGE_ADDED"), eq("message"), any(), eq(patientId))
|
||||
} finally {
|
||||
org.springframework.security.core.context.SecurityContextHolder.clearContext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,11 @@ management, and a login flow ready to integrate with a Swagger/OpenAPI backend.
|
|||
|
||||
- `lib/src/app`: app shell + router (GoRouter)
|
||||
- `lib/src/di`: dependency providers
|
||||
- `lib/src/core`: config + networking
|
||||
- `lib/src/core`: config, networking, and core utilities
|
||||
- `lib/src/features/auth`: auth domain/data/presentation
|
||||
- `lib/src/features/home`: home screen
|
||||
- `lib/src/features/telemetry`: telemetry domain/data/presentation
|
||||
- `lib/l10n`: localization (arb files)
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -39,7 +41,19 @@ When the spec is ready, generate a client and replace `ApiClient` usage:
|
|||
|
||||
1. Save your spec (e.g., `openapi.yaml`) or point to its URL.
|
||||
2. Generate a Dart client (OpenAPI Generator or Swagger Codegen).
|
||||
3. Swap `AuthRemoteDataSource` to call the generated client.
|
||||
3. Swap `AuthRemoteDataSource` or `TelemetryRemoteDataSource` to call the generated client.
|
||||
|
||||
## Clean and test
|
||||
```sh
|
||||
flutter clean
|
||||
flutter test
|
||||
```
|
||||
|
||||
## Clean and build
|
||||
```sh
|
||||
flutter clean
|
||||
flutter build apk
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
|
|
@ -85,8 +99,9 @@ flutter run -d <device_id> \
|
|||
Redirect configuration:
|
||||
- Android: `android/app/build.gradle.kts` sets `appAuthRedirectScheme`/`appAuthRedirectHost` for AppAuth, and `android/app/src/main/AndroidManifest.xml` registers the `RedirectUriReceiverActivity`.
|
||||
- iOS: `ios/Runner/Info.plist` registers `com.mosenioring.app` under `CFBundleURLTypes`.
|
||||
- Localization: `l10n.yaml` and `lib/l10n/*.arb` files.
|
||||
|
||||
Launcher icons:
|
||||
```sh
|
||||
flutter pub run flutter_launcher_icons
|
||||
dart run flutter_launcher_icons
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="mosenioring"
|
||||
android:name="${applicationName}"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import 'package:go_router/go_router.dart';
|
|||
import '../di/providers.dart';
|
||||
import '../features/auth/presentation/login_page.dart';
|
||||
import '../features/home/presentation/home_page.dart';
|
||||
import '../features/offline_lock/presentation/unlock_screen.dart';
|
||||
import '../features/app_gate/presentation/app_gate_state.dart';
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
final authState = ref.watch(authControllerProvider);
|
||||
final appGateState = ref.watch(appGateControllerProvider);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/login',
|
||||
|
|
@ -19,15 +21,26 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
path: '/',
|
||||
builder: (context, state) => const HomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/unlock',
|
||||
builder: (context, state) => const UnlockScreen(),
|
||||
),
|
||||
],
|
||||
redirect: (context, state) {
|
||||
final isLoggedIn = authState.isAuthenticated;
|
||||
final isLoggingIn = state.matchedLocation == '/login';
|
||||
if (appGateState.isLoading) {
|
||||
return null;
|
||||
}
|
||||
final destination = appGateState.destination;
|
||||
final location = state.matchedLocation;
|
||||
|
||||
if (!isLoggedIn && !isLoggingIn) {
|
||||
if (destination == AppGateDestination.login && location != '/login') {
|
||||
return '/login';
|
||||
}
|
||||
if (isLoggedIn && isLoggingIn) {
|
||||
if (destination == AppGateDestination.unlock && location != '/unlock') {
|
||||
return '/unlock';
|
||||
}
|
||||
if (destination == AppGateDestination.home &&
|
||||
(location == '/login' || location == '/unlock')) {
|
||||
return '/';
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,40 @@ class AppConfig {
|
|||
required this.keycloakRedirectUrl,
|
||||
});
|
||||
|
||||
factory AppConfig.fromEnvironment() {
|
||||
const apiBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: '');
|
||||
const useLocalAuth =
|
||||
bool.fromEnvironment('USE_LOCAL_AUTH', defaultValue: false);
|
||||
const localTenantId = String.fromEnvironment(
|
||||
'LOCAL_TENANT_ID',
|
||||
defaultValue: '11111111-1111-1111-1111-111111111111',
|
||||
);
|
||||
const localRoles =
|
||||
String.fromEnvironment('LOCAL_ROLES', defaultValue: 'CAREGIVER');
|
||||
const keycloakIssuer =
|
||||
String.fromEnvironment('KEYCLOAK_ISSUER', defaultValue: '');
|
||||
const keycloakIssuerUri =
|
||||
String.fromEnvironment('KEYCLOAK_ISSUER_URI', defaultValue: '');
|
||||
final resolvedIssuer =
|
||||
keycloakIssuer.isNotEmpty ? keycloakIssuer : keycloakIssuerUri;
|
||||
const keycloakClientId =
|
||||
String.fromEnvironment('KEYCLOAK_CLIENT_ID', defaultValue: '');
|
||||
const keycloakRedirectUrl = String.fromEnvironment(
|
||||
'KEYCLOAK_REDIRECT_URL',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
return AppConfig(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
useLocalAuth: useLocalAuth,
|
||||
localTenantId: localTenantId,
|
||||
localRoles: localRoles,
|
||||
keycloakIssuer: resolvedIssuer,
|
||||
keycloakClientId: keycloakClientId,
|
||||
keycloakRedirectUrl: keycloakRedirectUrl,
|
||||
);
|
||||
}
|
||||
|
||||
final String apiBaseUrl;
|
||||
final bool useLocalAuth;
|
||||
final String localTenantId;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
import 'connectivity_service.dart';
|
||||
|
||||
class ConnectivityPlusService implements ConnectivityService {
|
||||
ConnectivityPlusService(this._connectivity);
|
||||
|
||||
final Connectivity _connectivity;
|
||||
|
||||
@override
|
||||
Future<bool> isOnline() async {
|
||||
final result = await _connectivity.checkConnectivity();
|
||||
if (result.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return !result.contains(ConnectivityResult.none);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
abstract class ConnectivityService {
|
||||
Future<bool> isOnline();
|
||||
}
|
||||
23
front001/mosenioring/lib/src/core/security/jwt_utils.dart
Normal file
23
front001/mosenioring/lib/src/core/security/jwt_utils.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import 'dart:convert';
|
||||
|
||||
String? extractSubjectFromJwt(String token) {
|
||||
final parts = token.split('.');
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
final payload = parts[1];
|
||||
try {
|
||||
final normalized = base64Url.normalize(payload);
|
||||
final decoded = utf8.decode(base64Url.decode(normalized));
|
||||
final data = jsonDecode(decoded);
|
||||
if (data is Map<String, dynamic>) {
|
||||
final sub = data['sub'];
|
||||
if (sub is String && sub.isNotEmpty) {
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,17 +1,34 @@
|
|||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
import '../core/config/app_config.dart';
|
||||
import '../core/network/api_client.dart';
|
||||
import '../core/network/connectivity_plus_service.dart';
|
||||
import '../core/network/connectivity_service.dart';
|
||||
import '../core/network/http_client.dart';
|
||||
import '../features/app_gate/presentation/app_gate_controller.dart';
|
||||
import '../features/app_gate/presentation/app_gate_state.dart';
|
||||
import '../features/auth/data/auth_local_data_source.dart';
|
||||
import '../features/auth/data/auth_remote_data_source.dart';
|
||||
import '../features/auth/data/auth_repository_impl.dart';
|
||||
import '../features/auth/domain/auth_repository.dart';
|
||||
import '../features/auth/presentation/auth_controller.dart';
|
||||
import '../features/auth/presentation/auth_state.dart';
|
||||
import '../features/offline_lock/data/biometric_authenticator.dart';
|
||||
import '../features/offline_lock/data/flutter_secure_storage_adapter.dart';
|
||||
import '../features/offline_lock/data/local_auth_biometric_authenticator.dart';
|
||||
import '../features/offline_lock/data/offline_lock_repository_impl.dart';
|
||||
import '../features/offline_lock/data/pbkdf2_pin_hasher.dart';
|
||||
import '../features/offline_lock/domain/offline_lock_repository.dart';
|
||||
import '../features/offline_lock/domain/pin_hasher.dart';
|
||||
import '../features/offline_lock/presentation/unlock_controller.dart';
|
||||
import '../features/offline_lock/presentation/unlock_state.dart';
|
||||
import '../features/session/data/session_repository_impl.dart';
|
||||
import '../features/session/domain/session_repository.dart';
|
||||
import '../features/telemetry/data/auth_token_provider.dart';
|
||||
import '../features/telemetry/data/telemetry_remote_data_source.dart';
|
||||
import '../features/telemetry/data/telemetry_service_impl.dart';
|
||||
|
|
@ -22,42 +39,24 @@ import '../features/telemetry/domain/token_provider.dart';
|
|||
import '../features/telemetry/presentation/telemetry_controller.dart';
|
||||
import '../features/telemetry/presentation/telemetry_state.dart';
|
||||
|
||||
final appConfigProvider = Provider<AppConfig>((ref) {
|
||||
const apiBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: '');
|
||||
const useLocalAuth =
|
||||
bool.fromEnvironment('USE_LOCAL_AUTH', defaultValue: false);
|
||||
const localTenantId = String.fromEnvironment(
|
||||
'LOCAL_TENANT_ID',
|
||||
defaultValue: '11111111-1111-1111-1111-111111111111',
|
||||
);
|
||||
const localRoles =
|
||||
String.fromEnvironment('LOCAL_ROLES', defaultValue: 'CAREGIVER');
|
||||
const keycloakIssuer = String.fromEnvironment('KEYCLOAK_ISSUER', defaultValue: '');
|
||||
const keycloakIssuerUri =
|
||||
String.fromEnvironment('KEYCLOAK_ISSUER_URI', defaultValue: '');
|
||||
final resolvedIssuer =
|
||||
keycloakIssuer.isNotEmpty ? keycloakIssuer : keycloakIssuerUri;
|
||||
const keycloakClientId =
|
||||
String.fromEnvironment('KEYCLOAK_CLIENT_ID', defaultValue: '');
|
||||
const keycloakRedirectUrl = String.fromEnvironment(
|
||||
'KEYCLOAK_REDIRECT_URL',
|
||||
defaultValue: '',
|
||||
);
|
||||
return AppConfig(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
useLocalAuth: useLocalAuth,
|
||||
localTenantId: localTenantId,
|
||||
localRoles: localRoles,
|
||||
keycloakIssuer: resolvedIssuer,
|
||||
keycloakClientId: keycloakClientId,
|
||||
keycloakRedirectUrl: keycloakRedirectUrl,
|
||||
);
|
||||
});
|
||||
final appConfigProvider = Provider<AppConfig>((ref) => AppConfig.fromEnvironment());
|
||||
|
||||
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
||||
return const FlutterSecureStorage();
|
||||
});
|
||||
|
||||
final connectivityProvider = Provider<Connectivity>((ref) {
|
||||
return Connectivity();
|
||||
});
|
||||
|
||||
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
||||
return ConnectivityPlusService(ref.watch(connectivityProvider));
|
||||
});
|
||||
|
||||
final localAuthProvider = Provider<LocalAuthentication>((ref) {
|
||||
return LocalAuthentication();
|
||||
});
|
||||
|
||||
final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) {
|
||||
return AuthLocalDataSource(ref.watch(secureStorageProvider));
|
||||
});
|
||||
|
|
@ -85,9 +84,11 @@ final dioProvider = Provider<Dio>((ref) {
|
|||
if (config.useLocalAuth) {
|
||||
final session = await localDataSource.readLocalSession();
|
||||
if (session != null) {
|
||||
options.headers['X-Local-Email'] = session.email;
|
||||
options.headers['X-Local-Roles'] = session.roles;
|
||||
options.headers['X-Tenant-Id'] = session.tenantId;
|
||||
options.headers.addAll({
|
||||
'X-Local-Email': session.email,
|
||||
'X-Local-Roles': session.roles,
|
||||
'X-Tenant-Id': session.tenantId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
final token = await localDataSource.readToken();
|
||||
|
|
@ -98,21 +99,18 @@ final dioProvider = Provider<Dio>((ref) {
|
|||
handler.next(options);
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
if (config.useLocalAuth) {
|
||||
handler.next(error);
|
||||
return;
|
||||
}
|
||||
final response = error.response;
|
||||
final requestOptions = error.requestOptions;
|
||||
if (response?.statusCode != 401 || requestOptions.extra['retried'] == true) {
|
||||
handler.next(error);
|
||||
return;
|
||||
}
|
||||
final refreshToken = (await localDataSource.readToken())?.refreshToken;
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
handler.next(error);
|
||||
return;
|
||||
|
||||
if (config.useLocalAuth ||
|
||||
response?.statusCode != 401 ||
|
||||
requestOptions.extra['retried'] == true ||
|
||||
refreshToken == null ||
|
||||
refreshToken.isEmpty) {
|
||||
return handler.next(error);
|
||||
}
|
||||
|
||||
try {
|
||||
final newToken =
|
||||
await authRemoteDataSource.refreshToken(refreshToken: refreshToken);
|
||||
|
|
@ -149,10 +147,42 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
|||
);
|
||||
});
|
||||
|
||||
final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
|
||||
return SessionRepositoryImpl(ref.watch(authLocalDataSourceProvider));
|
||||
});
|
||||
|
||||
final authControllerProvider = NotifierProvider<AuthController, AuthState>(() {
|
||||
return AuthController();
|
||||
});
|
||||
|
||||
final appGateControllerProvider = NotifierProvider<AppGateController, AppGateState>(() {
|
||||
return AppGateController();
|
||||
});
|
||||
|
||||
final offlineLockStorageProvider = Provider<FlutterSecureStorageAdapter>((ref) {
|
||||
return FlutterSecureStorageAdapter(ref.watch(secureStorageProvider));
|
||||
});
|
||||
|
||||
final pinHasherProvider = Provider<PinHasher>((ref) {
|
||||
return Pbkdf2PinHasher();
|
||||
});
|
||||
|
||||
final biometricAuthenticatorProvider = Provider<BiometricAuthenticator>((ref) {
|
||||
return LocalAuthBiometricAuthenticator(ref.watch(localAuthProvider));
|
||||
});
|
||||
|
||||
final offlineLockRepositoryProvider = Provider<OfflineLockRepository>((ref) {
|
||||
return OfflineLockRepositoryImpl(
|
||||
storage: ref.watch(offlineLockStorageProvider),
|
||||
hasher: ref.watch(pinHasherProvider),
|
||||
biometricAuthenticator: ref.watch(biometricAuthenticatorProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final unlockControllerProvider = NotifierProvider<UnlockController, UnlockState>(() {
|
||||
return UnlockController();
|
||||
});
|
||||
|
||||
final httpClientProvider = Provider<HttpClient>((ref) {
|
||||
return ApiHttpClient(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../di/providers.dart';
|
||||
import '../../../core/security/jwt_utils.dart';
|
||||
import '../../session/domain/models/session_marker.dart';
|
||||
import 'app_gate_state.dart';
|
||||
|
||||
class AppGateController extends Notifier<AppGateState> {
|
||||
@override
|
||||
AppGateState build() {
|
||||
Future.microtask(_bootstrap);
|
||||
return const AppGateState(isLoading: true);
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
final sessionRepository = ref.read(sessionRepositoryProvider);
|
||||
final connectivityService = ref.read(connectivityServiceProvider);
|
||||
final authRepository = ref.read(authRepositoryProvider);
|
||||
|
||||
final token = await sessionRepository.readToken();
|
||||
var marker = await sessionRepository.readSessionMarker();
|
||||
final hasSession = marker != null || token != null;
|
||||
|
||||
if (!hasSession) {
|
||||
state = const AppGateState(isLoading: false, destination: AppGateDestination.login);
|
||||
return;
|
||||
}
|
||||
|
||||
if (marker == null && token != null) {
|
||||
final userId = extractSubjectFromJwt(token.accessToken) ?? 'unknown';
|
||||
marker = SessionMarker(userId: userId, lastOnlineAt: DateTime.now());
|
||||
await sessionRepository.saveSessionMarker(marker);
|
||||
}
|
||||
|
||||
final isOnline = await connectivityService.isOnline();
|
||||
if (!isOnline) {
|
||||
state = const AppGateState(
|
||||
isLoading: false,
|
||||
destination: AppGateDestination.unlock,
|
||||
isOffline: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final refreshToken = token?.refreshToken;
|
||||
if (refreshToken != null && refreshToken.isNotEmpty) {
|
||||
try {
|
||||
final refreshed = await authRepository.refreshToken(refreshToken: refreshToken);
|
||||
final userId =
|
||||
extractSubjectFromJwt(refreshed.accessToken) ?? marker?.userId ?? 'unknown';
|
||||
await sessionRepository.saveSessionMarker(
|
||||
SessionMarker(userId: userId, lastOnlineAt: DateTime.now()),
|
||||
);
|
||||
} catch (error) {
|
||||
state = AppGateState(
|
||||
isLoading: false,
|
||||
destination: AppGateDestination.login,
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state = const AppGateState(
|
||||
isLoading: false,
|
||||
destination: AppGateDestination.home,
|
||||
isOffline: false,
|
||||
);
|
||||
}
|
||||
|
||||
void setOfflineUnlocked() {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
destination: AppGateDestination.home,
|
||||
isOffline: true,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
|
||||
void setOnlineAuthenticated() {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
destination: AppGateDestination.home,
|
||||
isOffline: false,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
|
||||
void resetToLogin() {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
destination: AppGateDestination.login,
|
||||
isOffline: false,
|
||||
errorMessage: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
enum AppGateDestination { login, unlock, home }
|
||||
|
||||
class AppGateState {
|
||||
const AppGateState({
|
||||
this.isLoading = false,
|
||||
this.destination = AppGateDestination.login,
|
||||
this.isOffline = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final bool isLoading;
|
||||
final AppGateDestination destination;
|
||||
final bool isOffline;
|
||||
final String? errorMessage;
|
||||
|
||||
AppGateState copyWith({
|
||||
bool? isLoading,
|
||||
AppGateDestination? destination,
|
||||
bool? isOffline,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return AppGateState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
destination: destination ?? this.destination,
|
||||
isOffline: isOffline ?? this.isOffline,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../domain/models/auth_token.dart';
|
||||
import '../../session/domain/models/session_marker.dart';
|
||||
|
||||
class AuthLocalDataSource {
|
||||
AuthLocalDataSource(this._storage);
|
||||
|
|
@ -12,6 +13,8 @@ class AuthLocalDataSource {
|
|||
static const _localEmailKey = 'local_email';
|
||||
static const _localRolesKey = 'local_roles';
|
||||
static const _localTenantKey = 'local_tenant';
|
||||
static const _sessionUserIdKey = 'session_user_id';
|
||||
static const _sessionLastOnlineKey = 'session_last_online_at';
|
||||
|
||||
Future<AuthToken?> readToken() async {
|
||||
final accessToken = await _storage.read(key: _accessTokenKey);
|
||||
|
|
@ -24,8 +27,11 @@ class AuthLocalDataSource {
|
|||
|
||||
Future<void> saveToken(AuthToken token) async {
|
||||
await _storage.write(key: _accessTokenKey, value: token.accessToken);
|
||||
if (token.refreshToken != null) {
|
||||
final refreshToken = token.refreshToken;
|
||||
if (refreshToken != null && refreshToken.isNotEmpty) {
|
||||
await _storage.write(key: _refreshTokenKey, value: token.refreshToken);
|
||||
} else {
|
||||
await _storage.delete(key: _refreshTokenKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -40,9 +46,15 @@ class AuthLocalDataSource {
|
|||
}
|
||||
|
||||
Future<LocalAuthSession?> readLocalSession() async {
|
||||
final email = await _storage.read(key: _localEmailKey);
|
||||
final roles = await _storage.read(key: _localRolesKey);
|
||||
final tenantId = await _storage.read(key: _localTenantKey);
|
||||
final results = await Future.wait([
|
||||
_storage.read(key: _localEmailKey),
|
||||
_storage.read(key: _localRolesKey),
|
||||
_storage.read(key: _localTenantKey),
|
||||
]);
|
||||
final email = results[0];
|
||||
final roles = results[1];
|
||||
final tenantId = results[2];
|
||||
|
||||
if (email == null || tenantId == null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -54,11 +66,40 @@ class AuthLocalDataSource {
|
|||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _storage.delete(key: _accessTokenKey);
|
||||
await _storage.delete(key: _refreshTokenKey);
|
||||
await _storage.delete(key: _localEmailKey);
|
||||
await _storage.delete(key: _localRolesKey);
|
||||
await _storage.delete(key: _localTenantKey);
|
||||
await Future.wait([
|
||||
_storage.delete(key: _accessTokenKey),
|
||||
_storage.delete(key: _refreshTokenKey),
|
||||
_storage.delete(key: _localEmailKey),
|
||||
_storage.delete(key: _localRolesKey),
|
||||
_storage.delete(key: _localTenantKey),
|
||||
_storage.delete(key: _sessionUserIdKey),
|
||||
_storage.delete(key: _sessionLastOnlineKey),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> saveSessionMarker(SessionMarker marker) async {
|
||||
await _storage.write(key: _sessionUserIdKey, value: marker.userId);
|
||||
await _storage.write(
|
||||
key: _sessionLastOnlineKey,
|
||||
value: marker.lastOnlineAt.toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<SessionMarker?> readSessionMarker() async {
|
||||
final results = await Future.wait([
|
||||
_storage.read(key: _sessionUserIdKey),
|
||||
_storage.read(key: _sessionLastOnlineKey),
|
||||
]);
|
||||
final userId = results[0];
|
||||
final lastOnlineRaw = results[1];
|
||||
if (userId == null || userId.isEmpty || lastOnlineRaw == null) {
|
||||
return null;
|
||||
}
|
||||
final parsed = DateTime.tryParse(lastOnlineRaw);
|
||||
if (parsed == null) {
|
||||
return null;
|
||||
}
|
||||
return SessionMarker(userId: userId, lastOnlineAt: parsed);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||
return token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthToken> refreshToken({required String refreshToken}) async {
|
||||
final token = await _remote.refreshToken(refreshToken: refreshToken);
|
||||
await _local.saveToken(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthToken?> getSavedToken() {
|
||||
return _local.readToken();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ abstract class AuthRepository {
|
|||
required String password,
|
||||
});
|
||||
|
||||
Future<AuthToken> refreshToken({required String refreshToken});
|
||||
|
||||
Future<AuthToken?> getSavedToken();
|
||||
|
||||
Future<void> persistToken(AuthToken token);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../di/providers.dart';
|
||||
import '../../../core/security/jwt_utils.dart';
|
||||
import '../data/auth_local_data_source.dart';
|
||||
import '../domain/auth_repository.dart';
|
||||
import '../domain/models/auth_token.dart';
|
||||
import '../../session/domain/models/session_marker.dart';
|
||||
import '../../session/domain/session_repository.dart';
|
||||
import '../../app_gate/presentation/app_gate_controller.dart';
|
||||
import 'auth_state.dart';
|
||||
|
||||
class AuthController extends Notifier<AuthState> {
|
||||
|
|
@ -15,17 +19,22 @@ class AuthController extends Notifier<AuthState> {
|
|||
|
||||
AuthRepository get _repository => ref.watch(authRepositoryProvider);
|
||||
AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider);
|
||||
SessionRepository get _sessionRepository => ref.watch(sessionRepositoryProvider);
|
||||
AppGateController get _appGateController =>
|
||||
ref.read(appGateControllerProvider.notifier);
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
final config = ref.read(appConfigProvider);
|
||||
final configError = config.validationError;
|
||||
if (configError != null) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(isLoading: false, errorMessage: configError);
|
||||
return;
|
||||
}
|
||||
if (config.useLocalAuth) {
|
||||
final localSession = await _localDataSource.readLocalSession();
|
||||
if (!ref.mounted) return;
|
||||
state = AuthState(
|
||||
isLoading: false,
|
||||
token: localSession != null ? const AuthToken(accessToken: 'local') : null,
|
||||
|
|
@ -33,6 +42,7 @@ class AuthController extends Notifier<AuthState> {
|
|||
return;
|
||||
}
|
||||
final token = await _repository.getSavedToken();
|
||||
if (!ref.mounted) return;
|
||||
state = AuthState(isLoading: false, token: token);
|
||||
}
|
||||
|
||||
|
|
@ -56,11 +66,22 @@ class AuthController extends Notifier<AuthState> {
|
|||
);
|
||||
const token = AuthToken(accessToken: 'local');
|
||||
await _repository.persistToken(token);
|
||||
await _sessionRepository.saveSessionMarker(
|
||||
SessionMarker(userId: email, lastOnlineAt: DateTime.now()),
|
||||
);
|
||||
state = const AuthState(isLoading: false, token: token);
|
||||
_appGateController.setOnlineAuthenticated();
|
||||
return;
|
||||
}
|
||||
final token = await _repository.login(email: email, password: password);
|
||||
await _sessionRepository.saveSessionMarker(
|
||||
SessionMarker(
|
||||
userId: extractSubjectFromJwt(token.accessToken) ?? email,
|
||||
lastOnlineAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
state = AuthState(isLoading: false, token: token);
|
||||
_appGateController.setOnlineAuthenticated();
|
||||
} catch (error) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
|
|
@ -72,13 +93,13 @@ class AuthController extends Notifier<AuthState> {
|
|||
Future<void> logout() async {
|
||||
await _repository.logout();
|
||||
state = const AuthState();
|
||||
_appGateController.resetToLogin();
|
||||
}
|
||||
|
||||
String _friendlyError(Object error) {
|
||||
final message = error.toString();
|
||||
if (message.startsWith('Exception: ')) {
|
||||
return message.substring('Exception: '.length);
|
||||
}
|
||||
return message;
|
||||
return message.startsWith('Exception: ')
|
||||
? message.substring('Exception: '.length)
|
||||
: message;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,9 @@ class HomePage extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
late final ProviderSubscription<TelemetryState> _telemetrySubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_telemetrySubscription =
|
||||
ref.listenManual<TelemetryState>(telemetryControllerProvider, (previous, next) {
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<TelemetryState>(telemetryControllerProvider, (previous, next) {
|
||||
if (previous?.lastOutcome == next.lastOutcome ||
|
||||
next.lastOutcome == null ||
|
||||
!mounted) {
|
||||
|
|
@ -37,18 +33,10 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_telemetrySubscription.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final telemetryState = ref.watch(telemetryControllerProvider);
|
||||
final appGateState = ref.watch(appGateControllerProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -70,6 +58,46 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||
l10n.signedInMessage,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
if (appGateState.isOffline) ...[
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Offline mode',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'You can continue working. Sync when you are back online.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final isOnline =
|
||||
await ref.read(connectivityServiceProvider).isOnline();
|
||||
if (!mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
isOnline
|
||||
? 'Sync is not implemented yet.'
|
||||
: 'Still offline. Try again later.',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Sync now'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Developer tools',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
abstract class BiometricAuthenticator {
|
||||
Future<bool> isAvailable();
|
||||
Future<bool> authenticate({required String reason});
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'key_value_storage.dart';
|
||||
|
||||
class FlutterSecureStorageAdapter implements KeyValueStorage {
|
||||
FlutterSecureStorageAdapter(this._storage);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) => _storage.delete(key: key);
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) => _storage.read(key: key);
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) => _storage.write(key: key, value: value);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
abstract class KeyValueStorage {
|
||||
Future<String?> read(String key);
|
||||
Future<void> write(String key, String value);
|
||||
Future<void> delete(String key);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
import 'biometric_authenticator.dart';
|
||||
|
||||
class LocalAuthBiometricAuthenticator implements BiometricAuthenticator {
|
||||
LocalAuthBiometricAuthenticator(this._localAuth);
|
||||
|
||||
final LocalAuthentication _localAuth;
|
||||
|
||||
@override
|
||||
Future<bool> authenticate({required String reason}) async {
|
||||
try {
|
||||
return _localAuth.authenticate(
|
||||
localizedReason: reason,
|
||||
biometricOnly: true,
|
||||
persistAcrossBackgrounding: true,
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isAvailable() async {
|
||||
try {
|
||||
final supported = await _localAuth.isDeviceSupported();
|
||||
if (!supported) return false;
|
||||
return _localAuth.canCheckBiometrics;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import '../domain/offline_lock_repository.dart';
|
||||
import '../domain/pin_hash.dart';
|
||||
import '../domain/pin_hasher.dart';
|
||||
import 'biometric_authenticator.dart';
|
||||
import 'key_value_storage.dart';
|
||||
|
||||
class OfflineLockRepositoryImpl implements OfflineLockRepository {
|
||||
OfflineLockRepositoryImpl({
|
||||
required KeyValueStorage storage,
|
||||
required PinHasher hasher,
|
||||
required BiometricAuthenticator biometricAuthenticator,
|
||||
}) : _storage = storage,
|
||||
_hasher = hasher,
|
||||
_biometricAuthenticator = biometricAuthenticator;
|
||||
|
||||
final KeyValueStorage _storage;
|
||||
final PinHasher _hasher;
|
||||
final BiometricAuthenticator _biometricAuthenticator;
|
||||
|
||||
static const _pinHashKey = 'offline_pin_hash';
|
||||
static const _pinSaltKey = 'offline_pin_salt';
|
||||
static const _pinIterationsKey = 'offline_pin_iterations';
|
||||
|
||||
@override
|
||||
Future<bool> canUseBiometrics() => _biometricAuthenticator.isAvailable();
|
||||
|
||||
@override
|
||||
Future<bool> biometricUnlock() {
|
||||
return _biometricAuthenticator.authenticate(reason: 'Unlock to continue');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasPin() async {
|
||||
final hash = await _storage.read(_pinHashKey);
|
||||
final salt = await _storage.read(_pinSaltKey);
|
||||
return hash != null && salt != null && hash.isNotEmpty && salt.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setPin(String pin) async {
|
||||
final hashed = await _hasher.hash(pin);
|
||||
await _storage.write(_pinHashKey, hashed.hashBase64);
|
||||
await _storage.write(_pinSaltKey, hashed.saltBase64);
|
||||
await _storage.write(_pinIterationsKey, hashed.iterations.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> verifyPin(String pin) async {
|
||||
final stored = await _readPinHash();
|
||||
if (stored == null) {
|
||||
return false;
|
||||
}
|
||||
return _hasher.verify(pin: pin, stored: stored);
|
||||
}
|
||||
|
||||
Future<PinHash?> _readPinHash() async {
|
||||
final hash = await _storage.read(_pinHashKey);
|
||||
final salt = await _storage.read(_pinSaltKey);
|
||||
final iterationsRaw = await _storage.read(_pinIterationsKey);
|
||||
if (hash == null || salt == null || iterationsRaw == null) {
|
||||
return null;
|
||||
}
|
||||
final iterations = int.tryParse(iterationsRaw);
|
||||
if (iterations == null) {
|
||||
return null;
|
||||
}
|
||||
return PinHash(
|
||||
saltBase64: salt,
|
||||
hashBase64: hash,
|
||||
iterations: iterations,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
|
||||
import '../domain/pin_hash.dart';
|
||||
import '../domain/pin_hasher.dart';
|
||||
|
||||
class Pbkdf2PinHasher implements PinHasher {
|
||||
Pbkdf2PinHasher({
|
||||
this.iterations = 100000,
|
||||
this.saltLength = 16,
|
||||
});
|
||||
|
||||
final int iterations;
|
||||
final int saltLength;
|
||||
final Random _random = Random.secure();
|
||||
|
||||
@override
|
||||
Future<PinHash> hash(String pin) async {
|
||||
final salt = List<int>.generate(saltLength, (_) => _random.nextInt(256));
|
||||
final hash = await _deriveHash(pin, salt, iterations);
|
||||
return PinHash(
|
||||
saltBase64: base64UrlEncode(salt),
|
||||
hashBase64: base64UrlEncode(hash),
|
||||
iterations: iterations,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> verify({
|
||||
required String pin,
|
||||
required PinHash stored,
|
||||
}) async {
|
||||
final salt = base64Url.decode(stored.saltBase64);
|
||||
final derived = await _deriveHash(pin, salt, stored.iterations);
|
||||
final storedHash = base64Url.decode(stored.hashBase64);
|
||||
return _constantTimeEquals(derived, storedHash);
|
||||
}
|
||||
|
||||
Future<List<int>> _deriveHash(String pin, List<int> salt, int iterations) async {
|
||||
final pbkdf2 = Pbkdf2(
|
||||
macAlgorithm: Hmac.sha256(),
|
||||
iterations: iterations,
|
||||
bits: 256,
|
||||
);
|
||||
final key = await pbkdf2.deriveKey(
|
||||
secretKey: SecretKey(utf8.encode(pin)),
|
||||
nonce: salt,
|
||||
);
|
||||
return (await key.extractBytes());
|
||||
}
|
||||
|
||||
bool _constantTimeEquals(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
var diff = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
diff |= a[i] ^ b[i];
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
abstract class OfflineLockRepository {
|
||||
Future<bool> canUseBiometrics();
|
||||
Future<bool> biometricUnlock();
|
||||
|
||||
Future<bool> hasPin();
|
||||
Future<void> setPin(String pin);
|
||||
Future<bool> verifyPin(String pin);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
class PinHash {
|
||||
const PinHash({
|
||||
required this.saltBase64,
|
||||
required this.hashBase64,
|
||||
required this.iterations,
|
||||
});
|
||||
|
||||
final String saltBase64;
|
||||
final String hashBase64;
|
||||
final int iterations;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import 'pin_hash.dart';
|
||||
|
||||
abstract class PinHasher {
|
||||
Future<PinHash> hash(String pin);
|
||||
Future<bool> verify({
|
||||
required String pin,
|
||||
required PinHash stored,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../di/providers.dart';
|
||||
import '../../app_gate/presentation/app_gate_controller.dart';
|
||||
import '../domain/offline_lock_repository.dart';
|
||||
import 'unlock_state.dart';
|
||||
|
||||
class UnlockController extends Notifier<UnlockState> {
|
||||
@override
|
||||
UnlockState build() {
|
||||
Future.microtask(_bootstrap);
|
||||
return const UnlockState(isLoading: true);
|
||||
}
|
||||
|
||||
OfflineLockRepository get _repository => ref.read(offlineLockRepositoryProvider);
|
||||
AppGateController get _appGateController =>
|
||||
ref.read(appGateControllerProvider.notifier);
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final biometricsAvailable = await _repository.canUseBiometrics();
|
||||
final hasPin = await _repository.hasPin();
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
biometricsAvailable: biometricsAvailable,
|
||||
hasPin: hasPin,
|
||||
showPinEntry: !biometricsAvailable,
|
||||
);
|
||||
}
|
||||
|
||||
void showPinEntry() {
|
||||
state = state.copyWith(showPinEntry: true, errorMessage: null);
|
||||
}
|
||||
|
||||
Future<void> unlockWithBiometrics() async {
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
final success = await _repository.biometricUnlock();
|
||||
if (!ref.mounted) return;
|
||||
if (success) {
|
||||
state = state.copyWith(isLoading: false, isUnlocked: true);
|
||||
_appGateController.setOfflineUnlocked();
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isLoading: false, errorMessage: 'Biometric unlock failed');
|
||||
}
|
||||
|
||||
Future<void> submitPin(String pin) async {
|
||||
final normalized = pin.trim();
|
||||
if (normalized.length < 4 || normalized.length > 6) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: 'PIN must be 4-6 digits',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
final hasPin = await _repository.hasPin();
|
||||
if (!ref.mounted) return;
|
||||
|
||||
if (!hasPin) {
|
||||
await _repository.setPin(normalized);
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(isLoading: false, hasPin: true, isUnlocked: true);
|
||||
_appGateController.setOfflineUnlocked();
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await _repository.verifyPin(normalized);
|
||||
if (!ref.mounted) return;
|
||||
if (success) {
|
||||
state = state.copyWith(isLoading: false, isUnlocked: true);
|
||||
_appGateController.setOfflineUnlocked();
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isLoading: false, errorMessage: 'Invalid PIN');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../di/providers.dart';
|
||||
|
||||
class UnlockScreen extends ConsumerStatefulWidget {
|
||||
const UnlockScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<UnlockScreen> createState() => _UnlockScreenState();
|
||||
}
|
||||
|
||||
class _UnlockScreenState extends ConsumerState<UnlockScreen> {
|
||||
final TextEditingController _pinController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(unlockControllerProvider);
|
||||
final controller = ref.read(unlockControllerProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Unlock')),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.lock, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Unlock to continue',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (state.biometricsAvailable && !state.showPinEntry) ...[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: state.isLoading ? null : controller.unlockWithBiometrics,
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
label: state.isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Use biometrics'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: state.isLoading ? null : controller.showPinEntry,
|
||||
child: const Text('Use PIN instead'),
|
||||
),
|
||||
],
|
||||
if (state.showPinEntry || !state.biometricsAvailable) ...[
|
||||
TextField(
|
||||
controller: _pinController,
|
||||
keyboardType: TextInputType.number,
|
||||
obscureText: true,
|
||||
maxLength: 6,
|
||||
decoration: InputDecoration(
|
||||
labelText: state.hasPin ? 'Enter PIN' : 'Create a PIN',
|
||||
counterText: '',
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: state.isLoading
|
||||
? null
|
||||
: () {
|
||||
controller.submitPin(_pinController.text);
|
||||
},
|
||||
child: state.isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(state.hasPin ? 'Unlock' : 'Set PIN'),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
state.errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
class UnlockState {
|
||||
const UnlockState({
|
||||
this.isLoading = false,
|
||||
this.isUnlocked = false,
|
||||
this.errorMessage,
|
||||
this.biometricsAvailable = false,
|
||||
this.showPinEntry = false,
|
||||
this.hasPin = false,
|
||||
});
|
||||
|
||||
final bool isLoading;
|
||||
final bool isUnlocked;
|
||||
final String? errorMessage;
|
||||
final bool biometricsAvailable;
|
||||
final bool showPinEntry;
|
||||
final bool hasPin;
|
||||
|
||||
UnlockState copyWith({
|
||||
bool? isLoading,
|
||||
bool? isUnlocked,
|
||||
String? errorMessage,
|
||||
bool? biometricsAvailable,
|
||||
bool? showPinEntry,
|
||||
bool? hasPin,
|
||||
}) {
|
||||
return UnlockState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isUnlocked: isUnlocked ?? this.isUnlocked,
|
||||
errorMessage: errorMessage,
|
||||
biometricsAvailable: biometricsAvailable ?? this.biometricsAvailable,
|
||||
showPinEntry: showPinEntry ?? this.showPinEntry,
|
||||
hasPin: hasPin ?? this.hasPin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import '../../auth/data/auth_local_data_source.dart';
|
||||
import '../../auth/domain/models/auth_token.dart';
|
||||
import '../domain/models/session_marker.dart';
|
||||
import '../domain/session_repository.dart';
|
||||
|
||||
class SessionRepositoryImpl implements SessionRepository {
|
||||
SessionRepositoryImpl(this._localDataSource);
|
||||
|
||||
final AuthLocalDataSource _localDataSource;
|
||||
|
||||
@override
|
||||
Future<void> clear() => _localDataSource.clear();
|
||||
|
||||
@override
|
||||
Future<SessionMarker?> readSessionMarker() => _localDataSource.readSessionMarker();
|
||||
|
||||
@override
|
||||
Future<AuthToken?> readToken() => _localDataSource.readToken();
|
||||
|
||||
@override
|
||||
Future<void> saveSessionMarker(SessionMarker marker) {
|
||||
return _localDataSource.saveSessionMarker(marker);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveToken(AuthToken token) => _localDataSource.saveToken(token);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
class SessionMarker {
|
||||
const SessionMarker({
|
||||
required this.userId,
|
||||
required this.lastOnlineAt,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final DateTime lastOnlineAt;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import '../../auth/domain/models/auth_token.dart';
|
||||
import 'models/session_marker.dart';
|
||||
|
||||
abstract class SessionRepository {
|
||||
Future<AuthToken?> readToken();
|
||||
Future<void> saveToken(AuthToken token);
|
||||
Future<void> clear();
|
||||
|
||||
Future<SessionMarker?> readSessionMarker();
|
||||
Future<void> saveSessionMarker(SessionMarker marker);
|
||||
}
|
||||
|
|
@ -28,17 +28,14 @@ class TelemetryServiceImpl implements TelemetryService {
|
|||
}
|
||||
|
||||
try {
|
||||
final payload = _payloadBuilder.build();
|
||||
final response = await _remoteDataSource.sendTestTelemetry(
|
||||
accessToken: accessToken,
|
||||
payload: payload,
|
||||
payload: _payloadBuilder.build(),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
return;
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw _failureFromStatus(response.statusCode);
|
||||
}
|
||||
|
||||
throw _failureFromStatus(response.statusCode);
|
||||
} on HttpClientException catch (error) {
|
||||
throw _failureFromHttp(error);
|
||||
} on TelemetryFailure {
|
||||
|
|
@ -53,19 +50,16 @@ class TelemetryServiceImpl implements TelemetryService {
|
|||
}
|
||||
|
||||
TelemetryFailure _failureFromStatus(int statusCode) {
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
return TelemetryFailure(
|
||||
'Not authorized',
|
||||
type: TelemetryFailureType.unauthorized,
|
||||
statusCode: statusCode,
|
||||
debugMessage: 'Telemetry request unauthorized ($statusCode)',
|
||||
);
|
||||
}
|
||||
final isUnauthorized = statusCode == 401 || statusCode == 403;
|
||||
return TelemetryFailure(
|
||||
'Telemetry request failed',
|
||||
type: TelemetryFailureType.server,
|
||||
isUnauthorized ? 'Not authorized' : 'Telemetry request failed',
|
||||
type: isUnauthorized
|
||||
? TelemetryFailureType.unauthorized
|
||||
: TelemetryFailureType.server,
|
||||
statusCode: statusCode,
|
||||
debugMessage: 'Telemetry request failed with status $statusCode',
|
||||
debugMessage: isUnauthorized
|
||||
? 'Telemetry request unauthorized ($statusCode)'
|
||||
: 'Telemetry request failed with status $statusCode',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@
|
|||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import connectivity_plus
|
||||
import flutter_appauth
|
||||
import flutter_secure_storage_darwin
|
||||
import local_auth_darwin
|
||||
import path_provider_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,12 +36,15 @@ dependencies:
|
|||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
connectivity_plus: ^7.0.0
|
||||
cryptography: ^2.7.0
|
||||
dio: ^5.7.0
|
||||
flutter_appauth: ^11.0.0
|
||||
flutter_riverpod: ^3.1.0
|
||||
flutter_secure_storage: ^10.0.0
|
||||
go_router: ^17.0.1
|
||||
intl: ^0.20.2
|
||||
local_auth: ^3.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
@ -53,7 +56,7 @@ dev_dependencies:
|
|||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
|
|
|||
96
front001/mosenioring/test/core/config/app_config_test.dart
Normal file
96
front001/mosenioring/test/core/config/app_config_test.dart
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mosenioring/src/core/config/app_config.dart';
|
||||
|
||||
void main() {
|
||||
group('AppConfig', () {
|
||||
test('validationError returns null when API_BASE_URL and useLocalAuth are set', () {
|
||||
const config = AppConfig(
|
||||
apiBaseUrl: 'http://localhost:8080',
|
||||
useLocalAuth: true,
|
||||
localTenantId: '123',
|
||||
localRoles: 'USER',
|
||||
keycloakIssuer: '',
|
||||
keycloakClientId: '',
|
||||
keycloakRedirectUrl: '',
|
||||
);
|
||||
|
||||
expect(config.validationError, isNull);
|
||||
});
|
||||
|
||||
test('validationError returns error when API_BASE_URL is missing', () {
|
||||
const config = AppConfig(
|
||||
apiBaseUrl: ' ',
|
||||
useLocalAuth: true,
|
||||
localTenantId: '123',
|
||||
localRoles: 'USER',
|
||||
keycloakIssuer: '',
|
||||
keycloakClientId: '',
|
||||
keycloakRedirectUrl: '',
|
||||
);
|
||||
|
||||
expect(config.validationError, contains('Missing API_BASE_URL'));
|
||||
});
|
||||
|
||||
test('validationError returns error when Keycloak config is missing and not using local auth', () {
|
||||
// Assuming we are on a platform that supports AppAuth in tests (usually not, so it might hit the platform check first)
|
||||
// Actually, in terminal tests, defaultTargetPlatform might be android or ios depending on environment, but often it's not.
|
||||
// Let's test the logic regardless of platform by checking the specific requirements.
|
||||
|
||||
const config = AppConfig(
|
||||
apiBaseUrl: 'http://localhost:8080',
|
||||
useLocalAuth: false,
|
||||
localTenantId: '123',
|
||||
localRoles: 'USER',
|
||||
keycloakIssuer: '',
|
||||
keycloakClientId: '',
|
||||
keycloakRedirectUrl: '',
|
||||
);
|
||||
|
||||
final error = config.validationError;
|
||||
expect(error, isNotNull);
|
||||
// It will either fail on platform support or on missing issuer.
|
||||
});
|
||||
|
||||
test('keycloakDiscoveryUrl is correctly formed', () {
|
||||
const config = AppConfig(
|
||||
apiBaseUrl: 'http://localhost:8080',
|
||||
useLocalAuth: false,
|
||||
localTenantId: '123',
|
||||
localRoles: 'USER',
|
||||
keycloakIssuer: 'https://auth.example.com/realms/myrealm',
|
||||
keycloakClientId: 'client',
|
||||
keycloakRedirectUrl: 'app://callback',
|
||||
);
|
||||
|
||||
expect(config.keycloakDiscoveryUrl, 'https://auth.example.com/realms/myrealm/.well-known/openid-configuration');
|
||||
});
|
||||
|
||||
test('allowInsecureConnections is true for http issuer', () {
|
||||
const config = AppConfig(
|
||||
apiBaseUrl: 'http://localhost:8080',
|
||||
useLocalAuth: false,
|
||||
localTenantId: '123',
|
||||
localRoles: 'USER',
|
||||
keycloakIssuer: 'http://localhost:8081',
|
||||
keycloakClientId: 'client',
|
||||
keycloakRedirectUrl: 'app://callback',
|
||||
);
|
||||
|
||||
expect(config.allowInsecureConnections, isTrue);
|
||||
});
|
||||
|
||||
test('allowInsecureConnections is false for https issuer', () {
|
||||
const config = AppConfig(
|
||||
apiBaseUrl: 'http://localhost:8080',
|
||||
useLocalAuth: false,
|
||||
localTenantId: '123',
|
||||
localRoles: 'USER',
|
||||
keycloakIssuer: 'https://auth.example.com',
|
||||
keycloakClientId: 'client',
|
||||
keycloakRedirectUrl: 'app://callback',
|
||||
);
|
||||
|
||||
expect(config.allowInsecureConnections, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mosenioring/src/core/network/connectivity_service.dart';
|
||||
import 'package:mosenioring/src/di/providers.dart';
|
||||
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart';
|
||||
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart';
|
||||
import 'package:mosenioring/src/features/auth/domain/auth_repository.dart';
|
||||
import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart';
|
||||
import 'package:mosenioring/src/features/session/domain/models/session_marker.dart';
|
||||
import 'package:mosenioring/src/features/session/domain/session_repository.dart';
|
||||
|
||||
class FakeSessionRepository implements SessionRepository {
|
||||
AuthToken? token;
|
||||
SessionMarker? marker;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
token = null;
|
||||
marker = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SessionMarker?> readSessionMarker() async => marker;
|
||||
|
||||
@override
|
||||
Future<AuthToken?> readToken() async => token;
|
||||
|
||||
@override
|
||||
Future<void> saveSessionMarker(SessionMarker marker) async {
|
||||
this.marker = marker;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveToken(AuthToken token) async {
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeConnectivityService implements ConnectivityService {
|
||||
FakeConnectivityService(this._online);
|
||||
|
||||
bool _online;
|
||||
|
||||
set online(bool value) => _online = value;
|
||||
|
||||
@override
|
||||
Future<bool> isOnline() async => _online;
|
||||
}
|
||||
|
||||
class FakeAuthRepository implements AuthRepository {
|
||||
AuthToken? token;
|
||||
|
||||
@override
|
||||
Future<AuthToken?> getSavedToken() async => token;
|
||||
|
||||
@override
|
||||
Future<AuthToken> login({required String email, required String password}) async {
|
||||
return const AuthToken(accessToken: 'token');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {}
|
||||
|
||||
@override
|
||||
Future<void> persistToken(AuthToken token) async {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthToken> refreshToken({required String refreshToken}) async {
|
||||
return const AuthToken(accessToken: 'new_token', refreshToken: 'refresh');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('no session routes to login', () async {
|
||||
final sessionRepository = FakeSessionRepository();
|
||||
final connectivityService = FakeConnectivityService(true);
|
||||
final authRepository = FakeAuthRepository();
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
sessionRepositoryProvider.overrideWithValue(sessionRepository),
|
||||
connectivityServiceProvider.overrideWithValue(connectivityService),
|
||||
authRepositoryProvider.overrideWithValue(authRepository),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future.delayed(Duration.zero);
|
||||
if (!container.read(appGateControllerProvider).isLoading) break;
|
||||
}
|
||||
final state = container.read(appGateControllerProvider);
|
||||
|
||||
expect(state.destination, AppGateDestination.login);
|
||||
});
|
||||
|
||||
test('session + offline routes to unlock', () async {
|
||||
final sessionRepository = FakeSessionRepository()
|
||||
..marker = SessionMarker(userId: 'user', lastOnlineAt: DateTime.now());
|
||||
final connectivityService = FakeConnectivityService(false);
|
||||
final authRepository = FakeAuthRepository();
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
sessionRepositoryProvider.overrideWithValue(sessionRepository),
|
||||
connectivityServiceProvider.overrideWithValue(connectivityService),
|
||||
authRepositoryProvider.overrideWithValue(authRepository),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future.delayed(Duration.zero);
|
||||
if (!container.read(appGateControllerProvider).isLoading) break;
|
||||
}
|
||||
final state = container.read(appGateControllerProvider);
|
||||
|
||||
expect(state.destination, AppGateDestination.unlock);
|
||||
expect(state.isOffline, isTrue);
|
||||
});
|
||||
|
||||
test('session + online routes to home', () async {
|
||||
final sessionRepository = FakeSessionRepository()
|
||||
..marker = SessionMarker(userId: 'user', lastOnlineAt: DateTime.now())
|
||||
..token = const AuthToken(accessToken: 'token', refreshToken: 'refresh');
|
||||
final connectivityService = FakeConnectivityService(true);
|
||||
final authRepository = FakeAuthRepository();
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
sessionRepositoryProvider.overrideWithValue(sessionRepository),
|
||||
connectivityServiceProvider.overrideWithValue(connectivityService),
|
||||
authRepositoryProvider.overrideWithValue(authRepository),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future.delayed(Duration.zero);
|
||||
if (!container.read(appGateControllerProvider).isLoading) break;
|
||||
}
|
||||
final state = container.read(appGateControllerProvider);
|
||||
|
||||
expect(state.destination, AppGateDestination.home);
|
||||
expect(state.isOffline, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mosenioring/src/di/providers.dart';
|
||||
import 'package:mosenioring/src/features/auth/domain/auth_repository.dart';
|
||||
import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart';
|
||||
import 'package:mosenioring/src/features/auth/data/auth_local_data_source.dart';
|
||||
import 'package:mosenioring/src/core/config/app_config.dart';
|
||||
import 'package:mosenioring/src/features/session/domain/models/session_marker.dart';
|
||||
import 'package:mosenioring/src/features/session/domain/session_repository.dart';
|
||||
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart';
|
||||
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart';
|
||||
|
||||
class MockAuthRepository implements AuthRepository {
|
||||
AuthToken? token;
|
||||
Object? loginError;
|
||||
bool logoutCalled = false;
|
||||
|
||||
@override
|
||||
Future<AuthToken?> getSavedToken() async => token;
|
||||
|
||||
@override
|
||||
Future<AuthToken> login({required String email, required String password}) async {
|
||||
if (loginError != null) throw loginError!;
|
||||
return const AuthToken(accessToken: 'new_token');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthToken> refreshToken({required String refreshToken}) async {
|
||||
return const AuthToken(accessToken: 'refreshed_token');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {
|
||||
logoutCalled = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> persistToken(AuthToken token) async {
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
|
||||
class MockAuthLocalDataSource implements AuthLocalDataSource {
|
||||
LocalAuthSession? session;
|
||||
AuthToken? token;
|
||||
SessionMarker? marker;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
session = null;
|
||||
token = null;
|
||||
marker = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LocalAuthSession?> readLocalSession() async => session;
|
||||
|
||||
@override
|
||||
Future<AuthToken?> readToken() async => token;
|
||||
|
||||
@override
|
||||
Future<void> saveLocalSession({required String email, required String roles, required String tenantId}) async {
|
||||
session = LocalAuthSession(email: email, roles: roles, tenantId: tenantId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveToken(AuthToken token) async {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SessionMarker?> readSessionMarker() async => marker;
|
||||
|
||||
@override
|
||||
Future<void> saveSessionMarker(SessionMarker marker) async {
|
||||
this.marker = marker;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class FakeSessionRepository implements SessionRepository {
|
||||
AuthToken? token;
|
||||
SessionMarker? marker;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
token = null;
|
||||
marker = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SessionMarker?> readSessionMarker() async => marker;
|
||||
|
||||
@override
|
||||
Future<AuthToken?> readToken() async => token;
|
||||
|
||||
@override
|
||||
Future<void> saveSessionMarker(SessionMarker marker) async {
|
||||
this.marker = marker;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveToken(AuthToken token) async {
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAppGateController extends AppGateController {
|
||||
@override
|
||||
AppGateState build() => const AppGateState();
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('AuthController', () {
|
||||
late MockAuthRepository mockRepository;
|
||||
late FakeSessionRepository fakeSessionRepository;
|
||||
late FakeAppGateController fakeAppGateController;
|
||||
late AppConfig testConfig;
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockAuthRepository();
|
||||
fakeSessionRepository = FakeSessionRepository();
|
||||
fakeAppGateController = FakeAppGateController();
|
||||
testConfig = const AppConfig(
|
||||
apiBaseUrl: 'https://api.example.com',
|
||||
useLocalAuth: false,
|
||||
localTenantId: '123',
|
||||
localRoles: 'USER',
|
||||
keycloakIssuer: 'https://keycloak.com',
|
||||
keycloakClientId: 'client',
|
||||
keycloakRedirectUrl: 'app://callback',
|
||||
);
|
||||
});
|
||||
|
||||
ProviderContainer createContainer({AppConfig? config}) {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
authRepositoryProvider.overrideWithValue(mockRepository),
|
||||
appConfigProvider.overrideWithValue(config ?? testConfig),
|
||||
sessionRepositoryProvider.overrideWithValue(fakeSessionRepository),
|
||||
appGateControllerProvider.overrideWith(
|
||||
() => fakeAppGateController,
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
return container;
|
||||
}
|
||||
|
||||
test('initial state is loading then unauthenticated when no token', () async {
|
||||
final container = createContainer();
|
||||
|
||||
// Wait for bootstrap microtask
|
||||
await Future.microtask(() {});
|
||||
|
||||
final state = container.read(authControllerProvider);
|
||||
expect(state.isLoading, isFalse);
|
||||
expect(state.isAuthenticated, isFalse);
|
||||
});
|
||||
|
||||
test('bootstrap loads saved token', () async {
|
||||
mockRepository.token = const AuthToken(accessToken: 'saved_token');
|
||||
final container = createContainer();
|
||||
|
||||
// Keep checking until state is updated or timeout
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await Future.delayed(Duration.zero);
|
||||
if (container.read(authControllerProvider).isAuthenticated) break;
|
||||
}
|
||||
|
||||
final state = container.read(authControllerProvider);
|
||||
expect(state.isAuthenticated, isTrue);
|
||||
expect(state.token?.accessToken, 'saved_token');
|
||||
});
|
||||
|
||||
test('login success updates state', () async {
|
||||
final container = createContainer();
|
||||
await Future.microtask(() {});
|
||||
|
||||
await container.read(authControllerProvider.notifier).login(
|
||||
email: 'test@example.com',
|
||||
password: 'password',
|
||||
);
|
||||
|
||||
final state = container.read(authControllerProvider);
|
||||
expect(state.isAuthenticated, isTrue);
|
||||
expect(state.token?.accessToken, 'new_token');
|
||||
expect(state.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('login failure updates error message', () async {
|
||||
final container = createContainer();
|
||||
await Future.microtask(() {});
|
||||
mockRepository.loginError = Exception('Invalid credentials');
|
||||
|
||||
await container.read(authControllerProvider.notifier).login(
|
||||
email: 'test@example.com',
|
||||
password: 'password',
|
||||
);
|
||||
|
||||
final state = container.read(authControllerProvider);
|
||||
expect(state.isAuthenticated, isFalse);
|
||||
expect(state.errorMessage, 'Invalid credentials');
|
||||
});
|
||||
|
||||
test('logout clears state', () async {
|
||||
mockRepository.token = const AuthToken(accessToken: 'saved_token');
|
||||
final container = createContainer();
|
||||
await Future.microtask(() {});
|
||||
|
||||
await container.read(authControllerProvider.notifier).logout();
|
||||
|
||||
final state = container.read(authControllerProvider);
|
||||
expect(state.isAuthenticated, isFalse);
|
||||
expect(mockRepository.logoutCalled, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mosenioring/src/features/offline_lock/data/biometric_authenticator.dart';
|
||||
import 'package:mosenioring/src/features/offline_lock/data/offline_lock_repository_impl.dart';
|
||||
import 'package:mosenioring/src/features/offline_lock/data/key_value_storage.dart';
|
||||
import 'package:mosenioring/src/features/offline_lock/domain/pin_hash.dart';
|
||||
import 'package:mosenioring/src/features/offline_lock/domain/pin_hasher.dart';
|
||||
|
||||
class InMemoryStorage implements KeyValueStorage {
|
||||
final Map<String, String> _data = {};
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async {
|
||||
_data.remove(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async => _data[key];
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) async {
|
||||
_data[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeBiometricAuthenticator implements BiometricAuthenticator {
|
||||
@override
|
||||
Future<bool> authenticate({required String reason}) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> isAvailable() async => false;
|
||||
}
|
||||
|
||||
class DeterministicPinHasher implements PinHasher {
|
||||
@override
|
||||
Future<PinHash> hash(String pin) async {
|
||||
return PinHash(
|
||||
saltBase64: base64UrlEncode(utf8.encode('salt')),
|
||||
hashBase64: base64UrlEncode(utf8.encode('hash:$pin')),
|
||||
iterations: 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> verify({required String pin, required PinHash stored}) async {
|
||||
final expected = base64UrlEncode(utf8.encode('hash:$pin'));
|
||||
return stored.hashBase64 == expected;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('verifyPin returns true for correct pin and false for incorrect pin', () async {
|
||||
final storage = InMemoryStorage();
|
||||
final repository = OfflineLockRepositoryImpl(
|
||||
storage: storage,
|
||||
hasher: DeterministicPinHasher(),
|
||||
biometricAuthenticator: FakeBiometricAuthenticator(),
|
||||
);
|
||||
|
||||
await repository.setPin('1234');
|
||||
|
||||
expect(await repository.verifyPin('1234'), isTrue);
|
||||
expect(await repository.verifyPin('9876'), isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mosenioring/src/di/providers.dart';
|
||||
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart';
|
||||
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart';
|
||||
import 'package:mosenioring/src/features/offline_lock/domain/offline_lock_repository.dart';
|
||||
import 'package:mosenioring/src/features/offline_lock/presentation/unlock_state.dart';
|
||||
|
||||
class FakeOfflineLockRepository implements OfflineLockRepository {
|
||||
bool biometricsAvailable = false;
|
||||
bool pinSet = true;
|
||||
bool biometricResult = true;
|
||||
bool verifyResult = true;
|
||||
|
||||
@override
|
||||
Future<bool> biometricUnlock() async => biometricResult;
|
||||
|
||||
@override
|
||||
Future<bool> canUseBiometrics() async => biometricsAvailable;
|
||||
|
||||
@override
|
||||
Future<bool> hasPin() async => pinSet;
|
||||
|
||||
@override
|
||||
Future<void> setPin(String pin) async {
|
||||
pinSet = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> verifyPin(String pin) async => verifyResult;
|
||||
}
|
||||
|
||||
class FakeAppGateController extends AppGateController {
|
||||
@override
|
||||
AppGateState build() => const AppGateState();
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('submitPin transitions idle -> loading -> success', () async {
|
||||
final repository = FakeOfflineLockRepository()
|
||||
..biometricsAvailable = false
|
||||
..pinSet = true
|
||||
..verifyResult = true;
|
||||
final appGateController = FakeAppGateController();
|
||||
final states = <UnlockState>[];
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
offlineLockRepositoryProvider.overrideWithValue(repository),
|
||||
appGateControllerProvider.overrideWith(() => appGateController),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.listen(unlockControllerProvider, (_, next) => states.add(next));
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
await container.read(unlockControllerProvider.notifier).submitPin('1234');
|
||||
|
||||
expect(states.any((state) => state.isLoading), isTrue);
|
||||
expect(states.last.isUnlocked, isTrue);
|
||||
expect(states.last.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('submitPin transitions idle -> loading -> failure', () async {
|
||||
final repository = FakeOfflineLockRepository()
|
||||
..biometricsAvailable = false
|
||||
..pinSet = true
|
||||
..verifyResult = false;
|
||||
final appGateController = FakeAppGateController();
|
||||
final states = <UnlockState>[];
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
offlineLockRepositoryProvider.overrideWithValue(repository),
|
||||
appGateControllerProvider.overrideWith(() => appGateController),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.listen(unlockControllerProvider, (_, next) => states.add(next));
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
await container.read(unlockControllerProvider.notifier).submitPin('1234');
|
||||
|
||||
expect(states.any((state) => state.isLoading), isTrue);
|
||||
expect(states.last.isUnlocked, isFalse);
|
||||
expect(states.last.errorMessage, 'Invalid PIN');
|
||||
});
|
||||
}
|
||||
|
|
@ -9,11 +9,85 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mosenioring/src/app/app.dart';
|
||||
import 'package:mosenioring/src/core/config/app_config.dart';
|
||||
import 'package:mosenioring/src/di/providers.dart';
|
||||
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart';
|
||||
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart';
|
||||
import 'package:mosenioring/src/features/auth/domain/auth_repository.dart';
|
||||
import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart';
|
||||
import 'package:mosenioring/src/features/session/domain/session_repository.dart';
|
||||
import 'package:mosenioring/src/features/session/domain/models/session_marker.dart';
|
||||
|
||||
class FakeAuthRepository implements AuthRepository {
|
||||
@override
|
||||
Future<AuthToken?> getSavedToken() async => null;
|
||||
|
||||
@override
|
||||
Future<AuthToken> login({required String email, required String password}) async {
|
||||
return const AuthToken(accessToken: 'token');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {}
|
||||
|
||||
@override
|
||||
Future<void> persistToken(AuthToken token) async {}
|
||||
|
||||
@override
|
||||
Future<AuthToken> refreshToken({required String refreshToken}) async {
|
||||
return const AuthToken(accessToken: 'token');
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSessionRepository implements SessionRepository {
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
|
||||
@override
|
||||
Future<SessionMarker?> readSessionMarker() async => null;
|
||||
|
||||
@override
|
||||
Future<AuthToken?> readToken() async => null;
|
||||
|
||||
@override
|
||||
Future<void> saveSessionMarker(SessionMarker marker) async {}
|
||||
|
||||
@override
|
||||
Future<void> saveToken(AuthToken token) async {}
|
||||
}
|
||||
|
||||
class FakeAppGateController extends AppGateController {
|
||||
@override
|
||||
AppGateState build() => const AppGateState(
|
||||
isLoading: false,
|
||||
destination: AppGateDestination.login,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('App smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const ProviderScope(child: App()));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authRepositoryProvider.overrideWithValue(FakeAuthRepository()),
|
||||
sessionRepositoryProvider.overrideWithValue(FakeSessionRepository()),
|
||||
appConfigProvider.overrideWithValue(
|
||||
const AppConfig(
|
||||
apiBaseUrl: 'https://api.example.com',
|
||||
useLocalAuth: false,
|
||||
localTenantId: '123',
|
||||
localRoles: 'USER',
|
||||
keycloakIssuer: 'https://keycloak.com',
|
||||
keycloakClientId: 'client',
|
||||
keycloakRedirectUrl: 'app://callback',
|
||||
),
|
||||
),
|
||||
appGateControllerProvider.overrideWith(() => FakeAppGateController()),
|
||||
],
|
||||
child: const App(),
|
||||
),
|
||||
);
|
||||
await tester.pump(); // Start _bootstrap
|
||||
await tester.pump(const Duration(milliseconds: 100)); // Allow some time
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <local_auth_windows/local_auth_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
LocalAuthPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
flutter_secure_storage_windows
|
||||
local_auth_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
Loading…
Reference in a new issue