Compare commits

...

10 commits

Author SHA1 Message Date
oskar 8ced1f7925 Implement invitation management for patients and add supporting APIs, services, and tests
oka: not tested yet

- Introduced `Invitation` entity, table schema, and repository.
- Added `InvitationController` with APIs for creating, resolving, and accepting invitations.
- Implemented `InvitationService` to handle invitation workflows, including token generation and validation.
- Created unit tests for controller, service, and repository layers.
- Added support for patient-subject relationships with `PatientSubject` entity and repository.
- Updated `PatientAccessChecker` to handle subject-based access control.
- Extended Keycloak provisioning service for invitation role management.
- Updated database migrations to include invitations and patient-subject tables.
- Enhanced security configuration to permit invitation resolution API.
- Updated build scripts and dependencies for testing support.
2026-01-14 15:12:10 +01:00
oskar 5628aa5675 Update Docker images, Kotlin, Spring Boot, Gradle, and dependencies
- Upgraded Docker image versions for `postgres`, `keycloak`, `rabbitmq`, `redis`, and `minio`.
- Updated Kotlin to `1.9.25` and aligned related plugins.
- Upgraded Spring Boot to `3.4.1` and adjusted dependencies accordingly.
- Downgraded JVM target and toolchain to `17` for compatibility.
- Updated Gradle to `8.12` along with dependency version improvements (`flyway-core`, `opentelemetry`, `springdoc`, AWS SDK).
2026-01-13 15:37:29 +01:00
oskar 1315b95a72 implement offline lock with PIN and biometric authentication
- Introduced `AppGateController` to manage initial routing (login, unlock, or home) based on session and connectivity state.
- Added `OfflineLockRepository` and `UnlockController` with support for PIN (PBKDF2 hashing) and biometric authentication.
- Created `UnlockScreen` for PIN entry and biometric prompts.
- Added `ConnectivityService` to detect online/offline status using `connectivity_plus`.
- Enhanced `AuthLocalDataSource` and `SessionRepository` to manage session markers and user IDs.
- Updated `AuthController` to handle session markers and navigation transitions.
- Modified `GoRouter` to use `AppGateState` for app-wide redirection logic.
- Integrated `local_auth` and `cryptography` packages.
- Added comprehensive unit tests for `AppGateController` and `UnlockController`.
2026-01-13 14:35:57 +01:00
oskar d096fc479d Add "PATIENT" role to Keycloak realm configuration 2026-01-13 14:31:09 +01:00
oskar e5f7c3ee19 Add unit tests for core service modules and update build dependencies for testing
- Added unit tests for `AuditService`, `BaseEntity`, `MedicationPlanService`, and `MessageService`.
- Updated Gradle test dependencies across modules to include `mockito-core` and `mockito-kotlin`.
2026-01-12 23:22:00 +01:00
oskar 553ae2bd69 Remove redundant tenant and user metadata assignments across services. 2026-01-12 23:13:22 +01:00
oskar bbd7a371dd Add unit tests for AppConfig and AuthController, and include mount checks in AuthController. 2026-01-12 23:12:02 +01:00
oskar 73597b9bca Refactored error handling, configuration, and telemetry logic for improved readability, efficiency, and maintainability. 2026-01-12 22:58:19 +01:00
oskar d8f91f42e5 Improved README.md with restructured sections, better formatting, and added examples for local development, testing, and architecture overview. 2026-01-12 22:50:15 +01:00
oskar 8cfb81c99d front docs improvement 2026-01-12 22:43:29 +01:00
77 changed files with 2661 additions and 201 deletions

View file

@ -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.

View file

@ -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")
}

View file

@ -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()
}

View file

@ -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:

View file

@ -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;

View file

@ -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;

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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")
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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"))
}
}

View file

@ -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

View file

@ -6,7 +6,8 @@
"realm": [
{ "name": "ADMIN" },
{ "name": "DOCTOR" },
{ "name": "CAREGIVER" }
{ "name": "CAREGIVER" },
{ "name": "PATIENT" }
]
},
"clients": [

View file

@ -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

View file

@ -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")
}

View file

@ -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)
}
}

View file

@ -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")
}

View file

@ -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

View file

@ -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"))
}
}

View file

@ -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")
}

View file

@ -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"
}
}

View file

@ -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()

View file

@ -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)

View file

@ -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?
}

View file

@ -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)
}

View file

@ -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

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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) }
}
}

View file

@ -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()

View file

@ -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")
}

View file

@ -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

View file

@ -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()
}
}
}

View file

@ -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
```

View file

@ -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}"

View file

@ -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;

View file

@ -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;

View file

@ -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);
}
}

View file

@ -0,0 +1,3 @@
abstract class ConnectivityService {
Future<bool> isOnline();
}

View 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;
}

View file

@ -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));
});

View file

@ -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,
);
}
}

View file

@ -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,
);
}
}

View file

@ -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);
}
}

View file

@ -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();

View file

@ -6,6 +6,8 @@ abstract class AuthRepository {
required String password,
});
Future<AuthToken> refreshToken({required String refreshToken});
Future<AuthToken?> getSavedToken();
Future<void> persistToken(AuthToken token);

View file

@ -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;
}
}

View file

@ -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',

View file

@ -0,0 +1,4 @@
abstract class BiometricAuthenticator {
Future<bool> isAvailable();
Future<bool> authenticate({required String reason});
}

View file

@ -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);
}

View file

@ -0,0 +1,5 @@
abstract class KeyValueStorage {
Future<String?> read(String key);
Future<void> write(String key, String value);
Future<void> delete(String key);
}

View file

@ -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;
}
}
}

View file

@ -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,
);
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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,
});
}

View file

@ -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');
}
}

View file

@ -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),
),
],
],
),
),
),
),
);
}
}

View file

@ -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,
);
}
}

View file

@ -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);
}

View file

@ -0,0 +1,9 @@
class SessionMarker {
const SessionMarker({
required this.userId,
required this.lastOnlineAt,
});
final String userId;
final DateTime lastOnlineAt;
}

View file

@ -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);
}

View file

@ -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);
}
} 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) {
final isUnauthorized = statusCode == 401 || statusCode == 403;
return TelemetryFailure(
'Not authorized',
type: TelemetryFailureType.unauthorized,
isUnauthorized ? 'Not authorized' : 'Telemetry request failed',
type: isUnauthorized
? TelemetryFailureType.unauthorized
: TelemetryFailureType.server,
statusCode: statusCode,
debugMessage: 'Telemetry request unauthorized ($statusCode)',
);
}
return TelemetryFailure(
'Telemetry request failed',
type: TelemetryFailureType.server,
statusCode: statusCode,
debugMessage: 'Telemetry request failed with status $statusCode',
debugMessage: isUnauthorized
? 'Telemetry request unauthorized ($statusCode)'
: 'Telemetry request failed with status $statusCode',
);
}

View file

@ -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"))
}

View file

@ -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

View 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);
});
});
}

View file

@ -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);
});
}

View file

@ -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);
});
});
}

View file

@ -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);
});
}

View file

@ -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');
});
}

View file

@ -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

View file

@ -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"));
}

View file

@ -3,7 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
flutter_secure_storage_windows
local_auth_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST