Compare commits

..

No commits in common. "8ced1f79257e1c20a8537a9dae3c6e610e875ce9" and "ec670e90ae483280a4a3718428cfdaac960f11b1" have entirely different histories.

77 changed files with 202 additions and 2662 deletions

View file

@ -2,72 +2,65 @@
Production-ready Kotlin/Spring Boot 3 modular monolith skeleton for patient-caregiver-doctor coordination. Production-ready Kotlin/Spring Boot 3 modular monolith skeleton for patient-caregiver-doctor coordination.
## 🏛 Architecture ## Requirements
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 - Java 21
- Docker + Docker Compose - Docker + Docker Compose
## 🚀 Local Run ## Local run
1) Start dependencies:
### 1. Start Dependencies
```bash ```bash
docker compose up -d docker compose up -d
``` ```
### 2. Run the API 2) Run the API (local profile, local auth enabled):
Choose a profile:
**Local Development (with mock auth):**
```bash ```bash
SPRING_PROFILES_ACTIVE=local ALLOW_LOCAL_AUTH=true ./gradlew :app:bootRun SPRING_PROFILES_ACTIVE=local \
``` ALLOW_LOCAL_AUTH=true \
*Allows bypassing Keycloak using `X-Local-*` headers.* ./gradlew :app:bootRun
**Dev Mode (with Keycloak):**
```bash
SPRING_PROFILES_ACTIVE=dev ./gradlew :app:bootRun
``` ```
### 3. Run the Worker (Optional) ```bash
SPRING_PROFILES_ACTIVE=dev \
ALLOW_LOCAL_AUTH=false \
./gradlew :app:bootRun
```
3) (Optional) Run the worker:
```bash ```bash
./gradlew :workers:notification-worker:bootRun ./gradlew :workers:notification-worker:bootRun
``` ```
## 🧪 Testing Required env flags (local/dev):
Run all tests: - `SPRING_PROFILES_ACTIVE=local`
```bash - `ALLOW_LOCAL_AUTH=true` (enables local auth headers)
./gradlew test - `KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/mosenioring` (if using Keycloak)
``` - Frontend should set `USE_LOCAL_AUTH=true` when using local auth headers.
## 🔐 Auth & Multi-tenancy ## Auth
- **JWT Resource Server**: Uses Keycloak by default. - The backend is a JWT resource server and does not handle user passwords.
- **Multi-tenancy**: Enforced via `X-Tenant-Id` header (local) or `tenant_id` JWT claim. - Local auth shortcut is available only when `SPRING_PROFILES_ACTIVE=local`
- **Local Auth Headers** (only when `ALLOW_LOCAL_AUTH=true`): and `ALLOW_LOCAL_AUTH=true`.
- `X-Local-Email`: User identity.
- `X-Local-Roles`: e.g., `ADMIN, DOCTOR, CAREGIVER`.
- `X-Tenant-Id`: Target tenant.
## 📨 Invitation Onboarding Local headers (dev only):
- **Invite-only registration**: Admins create invites; users accept with a token. - `X-Local-Email`: user id/email
- **Resolve endpoint**: `POST /api/v1/invites/resolve` returns only masked email + expiry. - `X-Local-Roles`: comma-separated roles (ADMIN, DOCTOR, CAREGIVER)
- **Accept endpoint**: `POST /api/v1/invites/accept` links the authenticated user to a patient. - `X-Tenant-Id`: tenant id
- **Token hashing**: Invitation tokens are stored as HMAC-SHA256 with a server-side pepper.
- **Config**: Set `app.invites.token-pepper` in `back001/app/src/main/resources/application.yml` for non-dev environments.
## 🔗 Key Services & Links ## OpenAPI
- **OpenAPI**: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html) - http://localhost:8080/swagger-ui/index.html
- **Health**: [http://localhost:8080/health](http://localhost:8080/health) ## Health
- **Postgres**: `localhost:5432` (mosenioring/mosenioring) - http://localhost:8080/health
- **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)
## 📝 Notes ## Key services
- **Outbox Pattern**: Medication plans publish events to an outbox table for reliable messaging. - Postgres: localhost:5432 (mosenioring/mosenioring)
- **Idempotency**: Workers use Redis to ensure events are processed only once. - 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.

View file

@ -24,16 +24,14 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-database-postgresql") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
implementation("io.micrometer:micrometer-registry-prometheus") implementation("io.micrometer:micrometer-registry-prometheus")
implementation("io.micrometer:micrometer-tracing-bridge-otel") implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.46.0") implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.38.0")
implementation("software.amazon.awssdk:s3:2.29.52") implementation("software.amazon.awssdk:s3:2.25.63")
runtimeOnly("org.postgresql:postgresql") runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test") testImplementation("org.springframework.security:spring-security-test")
testImplementation("com.h2database:h2")
} }

View file

@ -25,7 +25,6 @@ class SecurityConfig {
.authorizeHttpRequests { .authorizeHttpRequests {
it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll() it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
it.requestMatchers("/api/v1/demo").permitAll() it.requestMatchers("/api/v1/demo").permitAll()
it.requestMatchers(HttpMethod.POST, "/api/v1/invites/resolve").permitAll()
it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN") it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN")
it.anyRequest().authenticated() it.anyRequest().authenticated()
} }

View file

@ -55,8 +55,6 @@ app:
notification-exchange: notification.events notification-exchange: notification.events
medication-queue: medication.plan.created medication-queue: medication.plan.created
notification-queue: notification.requested notification-queue: notification.requested
invites:
token-pepper: change-me-in-prod
management: management:
endpoints: endpoints:

View file

@ -1,27 +0,0 @@
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

@ -1,19 +0,0 @@
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

@ -1,84 +0,0 @@
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

@ -1,69 +0,0 @@
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 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
kotlin("jvm") version "1.9.25" apply false kotlin("jvm") version "1.9.23" apply false
kotlin("plugin.spring") version "1.9.25" apply false kotlin("plugin.spring") version "1.9.23" apply false
kotlin("plugin.jpa") version "1.9.25" apply false kotlin("plugin.jpa") version "1.9.23" apply false
id("org.springframework.boot") version "3.4.1" apply false id("org.springframework.boot") version "3.2.5" apply false
id("io.spring.dependency-management") version "1.1.7" apply false id("io.spring.dependency-management") version "1.1.4" apply false
} }
allprojects { allprojects {
@ -23,14 +23,14 @@ subprojects {
plugins.withId("io.spring.dependency-management") { plugins.withId("io.spring.dependency-management") {
the<DependencyManagementExtension>().apply { the<DependencyManagementExtension>().apply {
imports { imports {
mavenBom("org.springframework.boot:spring-boot-dependencies:3.4.1") mavenBom("org.springframework.boot:spring-boot-dependencies:3.2.5")
} }
} }
} }
tasks.withType<KotlinCompile> { tasks.withType<KotlinCompile> {
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "21"
freeCompilerArgs = listOf("-Xjsr305=strict") freeCompilerArgs = listOf("-Xjsr305=strict")
} }
} }
@ -38,14 +38,14 @@ subprojects {
plugins.withId("java") { plugins.withId("java") {
extensions.configure<JavaPluginExtension> { extensions.configure<JavaPluginExtension> {
toolchain { toolchain {
languageVersion.set(JavaLanguageVersion.of(17)) languageVersion.set(JavaLanguageVersion.of(21))
} }
} }
} }
plugins.withId("org.jetbrains.kotlin.jvm") { plugins.withId("org.jetbrains.kotlin.jvm") {
extensions.configure<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension> { extensions.configure<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension> {
jvmToolchain(17) jvmToolchain(21)
} }
} }

View file

@ -21,5 +21,4 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.mockito:mockito-core:5.12.0") testImplementation("org.mockito:mockito-core:5.12.0")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1")
} }

View file

@ -1,6 +1,8 @@
package com.mosenioring.common.outbox package com.mosenioring.common.outbox
import com.fasterxml.jackson.databind.ObjectMapper 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.context.annotation.Profile
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.UUID import java.util.UUID
@ -12,11 +14,15 @@ class OutboxService(
private val objectMapper: ObjectMapper private val objectMapper: ObjectMapper
) { ) {
fun enqueue(eventType: String, payload: Any): OutboxEvent { fun enqueue(eventType: String, payload: Any): OutboxEvent {
val tenantId = TenantContext.getTenantId() ?: "unknown"
val event = OutboxEvent( val event = OutboxEvent(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
eventType = eventType, eventType = eventType,
payload = objectMapper.writeValueAsString(payload) payload = objectMapper.writeValueAsString(payload)
) )
event.tenantId = tenantId
event.createdBy = SecurityUtils.currentUserId()
event.updatedBy = SecurityUtils.currentUserId()
return repository.save(event) return repository.save(event)
} }
} }

View file

@ -7,7 +7,6 @@ import org.springframework.stereotype.Component
interface PatientRelationshipRepository { interface PatientRelationshipRepository {
fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean
fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean
fun isSubjectOf(tenantId: String, patientId: String, userId: String): Boolean
} }
@Component @Component
@ -21,8 +20,7 @@ class PatientAccessChecker(
return true return true
} }
val userId = SecurityUtils.currentUserId() ?: return false val userId = SecurityUtils.currentUserId() ?: return false
return relationships.isSubjectOf(tenantId, patientId, userId) || return relationships.isCaregiverOf(tenantId, patientId, userId) ||
relationships.isCaregiverOf(tenantId, patientId, userId) ||
relationships.isDoctorOf(tenantId, patientId, userId) relationships.isDoctorOf(tenantId, patientId, userId)
} }
} }

View file

@ -1,47 +0,0 @@
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,7 +39,6 @@ class PatientAccessCheckerTest {
SecurityContextHolder.getContext().authentication = auth SecurityContextHolder.getContext().authentication = auth
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user1")).thenReturn(false) Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user1")).thenReturn(false)
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user1")).thenReturn(false) Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user1")).thenReturn(false)
Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user1")).thenReturn(false)
assertFalse(checker.hasAccess("p1")) assertFalse(checker.hasAccess("p1"))
} }
@ -50,18 +49,6 @@ class PatientAccessCheckerTest {
SecurityContextHolder.getContext().authentication = auth SecurityContextHolder.getContext().authentication = auth
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user2")).thenReturn(true) Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user2")).thenReturn(true)
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user2")).thenReturn(false) Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user2")).thenReturn(false)
Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user2")).thenReturn(false)
assertTrue(checker.hasAccess("p1"))
}
@Test
fun `allows patient subject relationship`() {
val principal = LocalUserPrincipal("user3", "t1", emptySet())
val auth = UsernamePasswordAuthenticationToken(principal, "n/a", emptyList())
SecurityContextHolder.getContext().authentication = auth
Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user3")).thenReturn(false)
Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user3")).thenReturn(false)
Mockito.`when`(relationships.isSubjectOf("t1", "p1", "user3")).thenReturn(true)
assertTrue(checker.hasAccess("p1")) assertTrue(checker.hasAccess("p1"))
} }
} }

View file

@ -1,6 +1,6 @@
services: services:
postgres: postgres:
image: postgres:17 image: postgres:16
environment: environment:
POSTGRES_DB: mosenioring POSTGRES_DB: mosenioring
POSTGRES_USER: mosenioring POSTGRES_USER: mosenioring
@ -11,7 +11,7 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
keycloak: keycloak:
image: quay.io/keycloak/keycloak:26.0.8 image: quay.io/keycloak/keycloak:22.0.5
command: start-dev --import-realm command: start-dev --import-realm
environment: environment:
KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN: admin
@ -22,18 +22,18 @@ services:
- ./docker/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro - ./docker/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
rabbitmq: rabbitmq:
image: rabbitmq:3.13-management image: rabbitmq:3.12-management
ports: ports:
- "5672:5672" - "5672:5672"
- "15672:15672" - "15672:15672"
redis: redis:
image: redis:7.4 image: redis:7
ports: ports:
- "6379:6379" - "6379:6379"
minio: minio:
image: minio/minio:latest image: minio/minio:RELEASE.2024-04-06T05-26-02Z
environment: environment:
MINIO_ROOT_USER: minio MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123 MINIO_ROOT_PASSWORD: minio123

View file

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

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View file

@ -9,7 +9,4 @@ plugins {
dependencies { dependencies {
api(project(":common")) api(project(":common"))
implementation("org.springframework.boot:spring-boot-starter-data-jpa") 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

@ -1,38 +0,0 @@
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,7 +9,4 @@ plugins {
dependencies { dependencies {
api(project(":common")) api(project(":common"))
implementation(project(":modules:audit")) implementation(project(":modules:audit"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.mockito:mockito-core")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1")
} }

View file

@ -8,6 +8,7 @@ import com.mosenioring.clinical.repo.TestOrderRepository
import com.mosenioring.common.Events import com.mosenioring.common.Events
import com.mosenioring.common.outbox.OutboxService import com.mosenioring.common.outbox.OutboxService
import com.mosenioring.common.security.PatientAccess import com.mosenioring.common.security.PatientAccess
import com.mosenioring.common.security.SecurityUtils
import com.mosenioring.common.tenant.TenantContext import com.mosenioring.common.tenant.TenantContext
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -25,6 +26,9 @@ class MedicationPlanService(
fun createMedicationPlan(patientId: String, description: String): MedicationPlan { fun createMedicationPlan(patientId: String, description: String): MedicationPlan {
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
val plan = MedicationPlan(UUID.randomUUID().toString(), patientId, description) val plan = MedicationPlan(UUID.randomUUID().toString(), patientId, description)
plan.tenantId = tenantId
plan.createdBy = SecurityUtils.currentUserId()
plan.updatedBy = SecurityUtils.currentUserId()
val saved = repository.save(plan) val saved = repository.save(plan)
outboxService.enqueue( outboxService.enqueue(
Events.MEDICATION_PLAN_CREATED, Events.MEDICATION_PLAN_CREATED,
@ -48,8 +52,11 @@ class TestOrderService(
@Transactional @Transactional
@PatientAccess @PatientAccess
fun createTestOrder(patientId: String, testName: String): TestOrder { fun createTestOrder(patientId: String, testName: String): TestOrder {
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" } val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
val order = TestOrder(UUID.randomUUID().toString(), patientId, testName, "ORDERED") 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) val saved = repository.save(order)
auditService.record("TEST_ORDER_CREATED", "test", saved.id, patientId) auditService.record("TEST_ORDER_CREATED", "test", saved.id, patientId)
return saved return saved

View file

@ -1,43 +0,0 @@
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,7 +9,4 @@ plugins {
dependencies { dependencies {
api(project(":common")) api(project(":common"))
implementation(project(":modules:audit")) implementation(project(":modules:audit"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.mockito:mockito-core")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1")
} }

View file

@ -1,62 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,71 +0,0 @@
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,11 +20,3 @@ interface PatientCaregiverRepository : JpaRepository<PatientCaregiver, String> {
interface PatientDoctorRepository : JpaRepository<PatientDoctor, String> { interface PatientDoctorRepository : JpaRepository<PatientDoctor, String> {
fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean
} }
interface PatientSubjectRepository : JpaRepository<PatientSubject, String> {
fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean
}
interface InvitationRepository : JpaRepository<Invitation, String> {
fun findByTokenHash(tokenHash: String): Invitation?
}

View file

@ -6,15 +6,11 @@ import org.springframework.stereotype.Repository
@Repository @Repository
class PatientRelationshipRepositoryImpl( class PatientRelationshipRepositoryImpl(
private val caregiverRepository: PatientCaregiverRepository, private val caregiverRepository: PatientCaregiverRepository,
private val doctorRepository: PatientDoctorRepository, private val doctorRepository: PatientDoctorRepository
private val subjectRepository: PatientSubjectRepository
) : PatientRelationshipRepository { ) : PatientRelationshipRepository {
override fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean = override fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean =
caregiverRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId) caregiverRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
override fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean = override fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean =
doctorRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId) doctorRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
override fun isSubjectOf(tenantId: String, patientId: String, userId: String): Boolean =
subjectRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId)
} }

View file

@ -1,6 +1,7 @@
package com.mosenioring.identity.service package com.mosenioring.identity.service
import com.mosenioring.audit.service.AuditService import com.mosenioring.audit.service.AuditService
import com.mosenioring.common.security.SecurityUtils
import com.mosenioring.common.tenant.TenantContext import com.mosenioring.common.tenant.TenantContext
import com.mosenioring.identity.* import com.mosenioring.identity.*
import com.mosenioring.identity.repo.* import com.mosenioring.identity.repo.*
@ -19,6 +20,8 @@ class TenantService(
fun createTenant(name: String): Tenant { fun createTenant(name: String): Tenant {
val tenant = Tenant(UUID.randomUUID().toString(), name) val tenant = Tenant(UUID.randomUUID().toString(), name)
tenant.tenantId = tenant.id tenant.tenantId = tenant.id
tenant.createdBy = SecurityUtils.currentUserId()
tenant.updatedBy = SecurityUtils.currentUserId()
val saved = tenantRepository.save(tenant) val saved = tenantRepository.save(tenant)
auditService.record("TENANT_CREATED", "tenant", saved.id, null) auditService.record("TENANT_CREATED", "tenant", saved.id, null)
return saved return saved
@ -33,8 +36,11 @@ class UserService(
@Transactional @Transactional
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
fun inviteUser(email: String, role: String): User { fun inviteUser(email: String, role: String): User {
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" } val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
val user = User(UUID.randomUUID().toString(), email, role, "INVITED") 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) val saved = userRepository.save(user)
auditService.record("USER_INVITED", "user", saved.id, null) auditService.record("USER_INVITED", "user", saved.id, null)
return saved return saved
@ -51,8 +57,11 @@ class PatientService(
@Transactional @Transactional
@PreAuthorize("hasAnyRole('ADMIN','DOCTOR','CAREGIVER')") @PreAuthorize("hasAnyRole('ADMIN','DOCTOR','CAREGIVER')")
fun createPatient(fullName: String): Patient { fun createPatient(fullName: String): Patient {
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" } val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
val patient = Patient(UUID.randomUUID().toString(), fullName) val patient = Patient(UUID.randomUUID().toString(), fullName)
patient.tenantId = tenantId
patient.createdBy = SecurityUtils.currentUserId()
patient.updatedBy = SecurityUtils.currentUserId()
val saved = patientRepository.save(patient) val saved = patientRepository.save(patient)
auditService.record("PATIENT_CREATED", "patient", saved.id, saved.id) auditService.record("PATIENT_CREATED", "patient", saved.id, saved.id)
return saved return saved
@ -64,6 +73,9 @@ class PatientService(
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
patientRepository.findByIdAndTenantId(patientId, tenantId) ?: throw IllegalArgumentException("Patient not found") patientRepository.findByIdAndTenantId(patientId, tenantId) ?: throw IllegalArgumentException("Patient not found")
val link = PatientCaregiver(UUID.randomUUID().toString(), patientId, userId) val link = PatientCaregiver(UUID.randomUUID().toString(), patientId, userId)
link.tenantId = tenantId
link.createdBy = SecurityUtils.currentUserId()
link.updatedBy = SecurityUtils.currentUserId()
val saved = caregiverRepository.save(link) val saved = caregiverRepository.save(link)
auditService.record("CARE_GIVER_LINKED", "patient_caregiver", saved.id, patientId) auditService.record("CARE_GIVER_LINKED", "patient_caregiver", saved.id, patientId)
return saved return saved
@ -75,6 +87,9 @@ class PatientService(
val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
patientRepository.findByIdAndTenantId(patientId, tenantId) ?: throw IllegalArgumentException("Patient not found") patientRepository.findByIdAndTenantId(patientId, tenantId) ?: throw IllegalArgumentException("Patient not found")
val link = PatientDoctor(UUID.randomUUID().toString(), patientId, userId) val link = PatientDoctor(UUID.randomUUID().toString(), patientId, userId)
link.tenantId = tenantId
link.createdBy = SecurityUtils.currentUserId()
link.updatedBy = SecurityUtils.currentUserId()
val saved = doctorRepository.save(link) val saved = doctorRepository.save(link)
auditService.record("DOCTOR_LINKED", "patient_doctor", saved.id, patientId) auditService.record("DOCTOR_LINKED", "patient_doctor", saved.id, patientId)
return saved return saved

View file

@ -1,191 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,137 +0,0 @@
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,6 +43,9 @@ class FileService(
val fileId = UUID.randomUUID().toString() val fileId = UUID.randomUUID().toString()
val storageKey = "$tenantId/$fileId/$fileName" val storageKey = "$tenantId/$fileId/$fileName"
val metadata = FileMetadata(fileId, patientId, fileName, contentType, storageKey) val metadata = FileMetadata(fileId, patientId, fileName, contentType, storageKey)
metadata.tenantId = tenantId
metadata.createdBy = SecurityUtils.currentUserId()
metadata.updatedBy = SecurityUtils.currentUserId()
repository.save(metadata) repository.save(metadata)
val request = PutObjectRequest.builder() val request = PutObjectRequest.builder()

View file

@ -9,7 +9,4 @@ plugins {
dependencies { dependencies {
api(project(":common")) api(project(":common"))
implementation(project(":modules:audit")) implementation(project(":modules:audit"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.mockito:mockito-core")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1")
} }

View file

@ -18,9 +18,12 @@ class MessageService(
@Transactional @Transactional
@PatientAccess @PatientAccess
fun addMessage(patientId: String, body: String): Message { fun addMessage(patientId: String, body: String): Message {
requireNotNull(TenantContext.getTenantId()) { "Missing tenant" } val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant")
val senderId = SecurityUtils.currentUserId() ?: throw IllegalStateException("Missing user") val senderId = SecurityUtils.currentUserId() ?: throw IllegalStateException("Missing user")
val message = Message(UUID.randomUUID().toString(), patientId, senderId, body) val message = Message(UUID.randomUUID().toString(), patientId, senderId, body)
message.tenantId = tenantId
message.createdBy = senderId
message.updatedBy = senderId
val saved = repository.save(message) val saved = repository.save(message)
auditService.record("MESSAGE_ADDED", "message", saved.id, patientId) auditService.record("MESSAGE_ADDED", "message", saved.id, patientId)
return saved return saved

View file

@ -1,53 +0,0 @@
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,11 +7,9 @@ management, and a login flow ready to integrate with a Swagger/OpenAPI backend.
- `lib/src/app`: app shell + router (GoRouter) - `lib/src/app`: app shell + router (GoRouter)
- `lib/src/di`: dependency providers - `lib/src/di`: dependency providers
- `lib/src/core`: config, networking, and core utilities - `lib/src/core`: config + networking
- `lib/src/features/auth`: auth domain/data/presentation - `lib/src/features/auth`: auth domain/data/presentation
- `lib/src/features/home`: home screen - `lib/src/features/home`: home screen
- `lib/src/features/telemetry`: telemetry domain/data/presentation
- `lib/l10n`: localization (arb files)
## Configuration ## Configuration
@ -41,19 +39,7 @@ 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. 1. Save your spec (e.g., `openapi.yaml`) or point to its URL.
2. Generate a Dart client (OpenAPI Generator or Swagger Codegen). 2. Generate a Dart client (OpenAPI Generator or Swagger Codegen).
3. Swap `AuthRemoteDataSource` or `TelemetryRemoteDataSource` to call the generated client. 3. Swap `AuthRemoteDataSource` to call the generated client.
## Clean and test
```sh
flutter clean
flutter test
```
## Clean and build
```sh
flutter clean
flutter build apk
```
## Running ## Running
@ -99,9 +85,8 @@ flutter run -d <device_id> \
Redirect configuration: Redirect configuration:
- Android: `android/app/build.gradle.kts` sets `appAuthRedirectScheme`/`appAuthRedirectHost` for AppAuth, and `android/app/src/main/AndroidManifest.xml` registers the `RedirectUriReceiverActivity`. - 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`. - iOS: `ios/Runner/Info.plist` registers `com.mosenioring.app` under `CFBundleURLTypes`.
- Localization: `l10n.yaml` and `lib/l10n/*.arb` files.
Launcher icons: Launcher icons:
```sh ```sh
dart run flutter_launcher_icons flutter pub run flutter_launcher_icons
``` ```

View file

@ -1,6 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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 <application
android:label="mosenioring" android:label="mosenioring"
android:name="${applicationName}" android:name="${applicationName}"

View file

@ -4,11 +4,9 @@ import 'package:go_router/go_router.dart';
import '../di/providers.dart'; import '../di/providers.dart';
import '../features/auth/presentation/login_page.dart'; import '../features/auth/presentation/login_page.dart';
import '../features/home/presentation/home_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 appRouterProvider = Provider<GoRouter>((ref) {
final appGateState = ref.watch(appGateControllerProvider); final authState = ref.watch(authControllerProvider);
return GoRouter( return GoRouter(
initialLocation: '/login', initialLocation: '/login',
@ -21,26 +19,15 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/', path: '/',
builder: (context, state) => const HomePage(), builder: (context, state) => const HomePage(),
), ),
GoRoute(
path: '/unlock',
builder: (context, state) => const UnlockScreen(),
),
], ],
redirect: (context, state) { redirect: (context, state) {
if (appGateState.isLoading) { final isLoggedIn = authState.isAuthenticated;
return null; final isLoggingIn = state.matchedLocation == '/login';
}
final destination = appGateState.destination;
final location = state.matchedLocation;
if (destination == AppGateDestination.login && location != '/login') { if (!isLoggedIn && !isLoggingIn) {
return '/login'; return '/login';
} }
if (destination == AppGateDestination.unlock && location != '/unlock') { if (isLoggedIn && isLoggingIn) {
return '/unlock';
}
if (destination == AppGateDestination.home &&
(location == '/login' || location == '/unlock')) {
return '/'; return '/';
} }
return null; return null;

View file

@ -11,40 +11,6 @@ class AppConfig {
required this.keycloakRedirectUrl, 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 String apiBaseUrl;
final bool useLocalAuth; final bool useLocalAuth;
final String localTenantId; final String localTenantId;

View file

@ -1,18 +0,0 @@
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

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

View file

@ -1,23 +0,0 @@
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,34 +1,17 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.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/config/app_config.dart';
import '../core/network/api_client.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 '../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_local_data_source.dart';
import '../features/auth/data/auth_remote_data_source.dart'; import '../features/auth/data/auth_remote_data_source.dart';
import '../features/auth/data/auth_repository_impl.dart'; import '../features/auth/data/auth_repository_impl.dart';
import '../features/auth/domain/auth_repository.dart'; import '../features/auth/domain/auth_repository.dart';
import '../features/auth/presentation/auth_controller.dart'; import '../features/auth/presentation/auth_controller.dart';
import '../features/auth/presentation/auth_state.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/auth_token_provider.dart';
import '../features/telemetry/data/telemetry_remote_data_source.dart'; import '../features/telemetry/data/telemetry_remote_data_source.dart';
import '../features/telemetry/data/telemetry_service_impl.dart'; import '../features/telemetry/data/telemetry_service_impl.dart';
@ -39,24 +22,42 @@ import '../features/telemetry/domain/token_provider.dart';
import '../features/telemetry/presentation/telemetry_controller.dart'; import '../features/telemetry/presentation/telemetry_controller.dart';
import '../features/telemetry/presentation/telemetry_state.dart'; import '../features/telemetry/presentation/telemetry_state.dart';
final appConfigProvider = Provider<AppConfig>((ref) => AppConfig.fromEnvironment()); 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 secureStorageProvider = Provider<FlutterSecureStorage>((ref) { final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
return const FlutterSecureStorage(); 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) { final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) {
return AuthLocalDataSource(ref.watch(secureStorageProvider)); return AuthLocalDataSource(ref.watch(secureStorageProvider));
}); });
@ -84,11 +85,9 @@ final dioProvider = Provider<Dio>((ref) {
if (config.useLocalAuth) { if (config.useLocalAuth) {
final session = await localDataSource.readLocalSession(); final session = await localDataSource.readLocalSession();
if (session != null) { if (session != null) {
options.headers.addAll({ options.headers['X-Local-Email'] = session.email;
'X-Local-Email': session.email, options.headers['X-Local-Roles'] = session.roles;
'X-Local-Roles': session.roles, options.headers['X-Tenant-Id'] = session.tenantId;
'X-Tenant-Id': session.tenantId,
});
} }
} else { } else {
final token = await localDataSource.readToken(); final token = await localDataSource.readToken();
@ -99,18 +98,21 @@ final dioProvider = Provider<Dio>((ref) {
handler.next(options); handler.next(options);
}, },
onError: (error, handler) async { onError: (error, handler) async {
if (config.useLocalAuth) {
handler.next(error);
return;
}
final response = error.response; final response = error.response;
final requestOptions = error.requestOptions; final requestOptions = error.requestOptions;
final refreshToken = (await localDataSource.readToken())?.refreshToken; if (response?.statusCode != 401 || requestOptions.extra['retried'] == true) {
handler.next(error);
if (config.useLocalAuth || return;
response?.statusCode != 401 || }
requestOptions.extra['retried'] == true || final refreshToken = (await localDataSource.readToken())?.refreshToken;
refreshToken == null || if (refreshToken == null || refreshToken.isEmpty) {
refreshToken.isEmpty) { handler.next(error);
return handler.next(error); return;
} }
try { try {
final newToken = final newToken =
await authRemoteDataSource.refreshToken(refreshToken: refreshToken); await authRemoteDataSource.refreshToken(refreshToken: refreshToken);
@ -147,42 +149,10 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
); );
}); });
final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
return SessionRepositoryImpl(ref.watch(authLocalDataSourceProvider));
});
final authControllerProvider = NotifierProvider<AuthController, AuthState>(() { final authControllerProvider = NotifierProvider<AuthController, AuthState>(() {
return AuthController(); 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) { final httpClientProvider = Provider<HttpClient>((ref) {
return ApiHttpClient(ref.watch(apiClientProvider)); return ApiHttpClient(ref.watch(apiClientProvider));
}); });

View file

@ -1,98 +0,0 @@
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

@ -1,29 +0,0 @@
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,7 +1,6 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../domain/models/auth_token.dart'; import '../domain/models/auth_token.dart';
import '../../session/domain/models/session_marker.dart';
class AuthLocalDataSource { class AuthLocalDataSource {
AuthLocalDataSource(this._storage); AuthLocalDataSource(this._storage);
@ -13,8 +12,6 @@ class AuthLocalDataSource {
static const _localEmailKey = 'local_email'; static const _localEmailKey = 'local_email';
static const _localRolesKey = 'local_roles'; static const _localRolesKey = 'local_roles';
static const _localTenantKey = 'local_tenant'; static const _localTenantKey = 'local_tenant';
static const _sessionUserIdKey = 'session_user_id';
static const _sessionLastOnlineKey = 'session_last_online_at';
Future<AuthToken?> readToken() async { Future<AuthToken?> readToken() async {
final accessToken = await _storage.read(key: _accessTokenKey); final accessToken = await _storage.read(key: _accessTokenKey);
@ -27,11 +24,8 @@ class AuthLocalDataSource {
Future<void> saveToken(AuthToken token) async { Future<void> saveToken(AuthToken token) async {
await _storage.write(key: _accessTokenKey, value: token.accessToken); await _storage.write(key: _accessTokenKey, value: token.accessToken);
final refreshToken = token.refreshToken; if (token.refreshToken != null) {
if (refreshToken != null && refreshToken.isNotEmpty) {
await _storage.write(key: _refreshTokenKey, value: token.refreshToken); await _storage.write(key: _refreshTokenKey, value: token.refreshToken);
} else {
await _storage.delete(key: _refreshTokenKey);
} }
} }
@ -46,15 +40,9 @@ class AuthLocalDataSource {
} }
Future<LocalAuthSession?> readLocalSession() async { Future<LocalAuthSession?> readLocalSession() async {
final results = await Future.wait([ final email = await _storage.read(key: _localEmailKey);
_storage.read(key: _localEmailKey), final roles = await _storage.read(key: _localRolesKey);
_storage.read(key: _localRolesKey), final tenantId = await _storage.read(key: _localTenantKey);
_storage.read(key: _localTenantKey),
]);
final email = results[0];
final roles = results[1];
final tenantId = results[2];
if (email == null || tenantId == null) { if (email == null || tenantId == null) {
return null; return null;
} }
@ -66,40 +54,11 @@ class AuthLocalDataSource {
} }
Future<void> clear() async { Future<void> clear() async {
await Future.wait([ await _storage.delete(key: _accessTokenKey);
_storage.delete(key: _accessTokenKey), await _storage.delete(key: _refreshTokenKey);
_storage.delete(key: _refreshTokenKey), await _storage.delete(key: _localEmailKey);
_storage.delete(key: _localEmailKey), await _storage.delete(key: _localRolesKey);
_storage.delete(key: _localRolesKey), await _storage.delete(key: _localTenantKey);
_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,13 +19,6 @@ class AuthRepositoryImpl implements AuthRepository {
return token; return token;
} }
@override
Future<AuthToken> refreshToken({required String refreshToken}) async {
final token = await _remote.refreshToken(refreshToken: refreshToken);
await _local.saveToken(token);
return token;
}
@override @override
Future<AuthToken?> getSavedToken() { Future<AuthToken?> getSavedToken() {
return _local.readToken(); return _local.readToken();

View file

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

View file

@ -1,13 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../di/providers.dart'; import '../../../di/providers.dart';
import '../../../core/security/jwt_utils.dart';
import '../data/auth_local_data_source.dart'; import '../data/auth_local_data_source.dart';
import '../domain/auth_repository.dart'; import '../domain/auth_repository.dart';
import '../domain/models/auth_token.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'; import 'auth_state.dart';
class AuthController extends Notifier<AuthState> { class AuthController extends Notifier<AuthState> {
@ -19,22 +15,17 @@ class AuthController extends Notifier<AuthState> {
AuthRepository get _repository => ref.watch(authRepositoryProvider); AuthRepository get _repository => ref.watch(authRepositoryProvider);
AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider); AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider);
SessionRepository get _sessionRepository => ref.watch(sessionRepositoryProvider);
AppGateController get _appGateController =>
ref.read(appGateControllerProvider.notifier);
Future<void> _bootstrap() async { Future<void> _bootstrap() async {
state = state.copyWith(isLoading: true, errorMessage: null); state = state.copyWith(isLoading: true, errorMessage: null);
final config = ref.read(appConfigProvider); final config = ref.read(appConfigProvider);
final configError = config.validationError; final configError = config.validationError;
if (configError != null) { if (configError != null) {
if (!ref.mounted) return;
state = state.copyWith(isLoading: false, errorMessage: configError); state = state.copyWith(isLoading: false, errorMessage: configError);
return; return;
} }
if (config.useLocalAuth) { if (config.useLocalAuth) {
final localSession = await _localDataSource.readLocalSession(); final localSession = await _localDataSource.readLocalSession();
if (!ref.mounted) return;
state = AuthState( state = AuthState(
isLoading: false, isLoading: false,
token: localSession != null ? const AuthToken(accessToken: 'local') : null, token: localSession != null ? const AuthToken(accessToken: 'local') : null,
@ -42,7 +33,6 @@ class AuthController extends Notifier<AuthState> {
return; return;
} }
final token = await _repository.getSavedToken(); final token = await _repository.getSavedToken();
if (!ref.mounted) return;
state = AuthState(isLoading: false, token: token); state = AuthState(isLoading: false, token: token);
} }
@ -66,22 +56,11 @@ class AuthController extends Notifier<AuthState> {
); );
const token = AuthToken(accessToken: 'local'); const token = AuthToken(accessToken: 'local');
await _repository.persistToken(token); await _repository.persistToken(token);
await _sessionRepository.saveSessionMarker(
SessionMarker(userId: email, lastOnlineAt: DateTime.now()),
);
state = const AuthState(isLoading: false, token: token); state = const AuthState(isLoading: false, token: token);
_appGateController.setOnlineAuthenticated();
return; return;
} }
final token = await _repository.login(email: email, password: password); 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); state = AuthState(isLoading: false, token: token);
_appGateController.setOnlineAuthenticated();
} catch (error) { } catch (error) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
@ -93,13 +72,13 @@ class AuthController extends Notifier<AuthState> {
Future<void> logout() async { Future<void> logout() async {
await _repository.logout(); await _repository.logout();
state = const AuthState(); state = const AuthState();
_appGateController.resetToLogin();
} }
String _friendlyError(Object error) { String _friendlyError(Object error) {
final message = error.toString(); final message = error.toString();
return message.startsWith('Exception: ') if (message.startsWith('Exception: ')) {
? message.substring('Exception: '.length) return message.substring('Exception: '.length);
: message; }
return message;
} }
} }

View file

@ -13,9 +13,13 @@ class HomePage extends ConsumerStatefulWidget {
} }
class _HomePageState extends ConsumerState<HomePage> { class _HomePageState extends ConsumerState<HomePage> {
late final ProviderSubscription<TelemetryState> _telemetrySubscription;
@override @override
Widget build(BuildContext context) { void initState() {
ref.listen<TelemetryState>(telemetryControllerProvider, (previous, next) { super.initState();
_telemetrySubscription =
ref.listenManual<TelemetryState>(telemetryControllerProvider, (previous, next) {
if (previous?.lastOutcome == next.lastOutcome || if (previous?.lastOutcome == next.lastOutcome ||
next.lastOutcome == null || next.lastOutcome == null ||
!mounted) { !mounted) {
@ -33,10 +37,18 @@ class _HomePageState extends ConsumerState<HomePage> {
); );
} }
}); });
}
@override
void dispose() {
_telemetrySubscription.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final telemetryState = ref.watch(telemetryControllerProvider); final telemetryState = ref.watch(telemetryControllerProvider);
final appGateState = ref.watch(appGateControllerProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -58,46 +70,6 @@ class _HomePageState extends ConsumerState<HomePage> {
l10n.signedInMessage, l10n.signedInMessage,
style: Theme.of(context).textTheme.titleMedium, 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), const SizedBox(height: 24),
Text( Text(
'Developer tools', 'Developer tools',

View file

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

View file

@ -1,18 +0,0 @@
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

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

View file

@ -1,33 +0,0 @@
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

@ -1,73 +0,0 @@
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

@ -1,62 +0,0 @@
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

@ -1,8 +0,0 @@
abstract class OfflineLockRepository {
Future<bool> canUseBiometrics();
Future<bool> biometricUnlock();
Future<bool> hasPin();
Future<void> setPin(String pin);
Future<bool> verifyPin(String pin);
}

View file

@ -1,11 +0,0 @@
class PinHash {
const PinHash({
required this.saltBase64,
required this.hashBase64,
required this.iterations,
});
final String saltBase64;
final String hashBase64;
final int iterations;
}

View file

@ -1,9 +0,0 @@
import 'pin_hash.dart';
abstract class PinHasher {
Future<PinHash> hash(String pin);
Future<bool> verify({
required String pin,
required PinHash stored,
});
}

View file

@ -1,78 +0,0 @@
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

@ -1,114 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,27 +0,0 @@
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

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

View file

@ -1,11 +0,0 @@
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,14 +28,17 @@ class TelemetryServiceImpl implements TelemetryService {
} }
try { try {
final payload = _payloadBuilder.build();
final response = await _remoteDataSource.sendTestTelemetry( final response = await _remoteDataSource.sendTestTelemetry(
accessToken: accessToken, accessToken: accessToken,
payload: _payloadBuilder.build(), payload: payload,
); );
if (response.statusCode != 200 && response.statusCode != 201) { if (response.statusCode == 200 || response.statusCode == 201) {
throw _failureFromStatus(response.statusCode); return;
} }
throw _failureFromStatus(response.statusCode);
} on HttpClientException catch (error) { } on HttpClientException catch (error) {
throw _failureFromHttp(error); throw _failureFromHttp(error);
} on TelemetryFailure { } on TelemetryFailure {
@ -50,16 +53,19 @@ class TelemetryServiceImpl implements TelemetryService {
} }
TelemetryFailure _failureFromStatus(int statusCode) { TelemetryFailure _failureFromStatus(int statusCode) {
final isUnauthorized = statusCode == 401 || statusCode == 403; if (statusCode == 401 || statusCode == 403) {
return TelemetryFailure(
'Not authorized',
type: TelemetryFailureType.unauthorized,
statusCode: statusCode,
debugMessage: 'Telemetry request unauthorized ($statusCode)',
);
}
return TelemetryFailure( return TelemetryFailure(
isUnauthorized ? 'Not authorized' : 'Telemetry request failed', 'Telemetry request failed',
type: isUnauthorized type: TelemetryFailureType.server,
? TelemetryFailureType.unauthorized
: TelemetryFailureType.server,
statusCode: statusCode, statusCode: statusCode,
debugMessage: isUnauthorized debugMessage: 'Telemetry request failed with status $statusCode',
? 'Telemetry request unauthorized ($statusCode)'
: 'Telemetry request failed with status $statusCode',
); );
} }

View file

@ -5,16 +5,12 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import connectivity_plus
import flutter_appauth import flutter_appauth
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import local_auth_darwin
import path_provider_foundation import path_provider_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin")) FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
} }

View file

@ -36,15 +36,12 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
connectivity_plus: ^7.0.0
cryptography: ^2.7.0
dio: ^5.7.0 dio: ^5.7.0
flutter_appauth: ^11.0.0 flutter_appauth: ^11.0.0
flutter_riverpod: ^3.1.0 flutter_riverpod: ^3.1.0
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
go_router: ^17.0.1 go_router: ^17.0.1
intl: ^0.20.2 intl: ^0.20.2
local_auth: ^3.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -56,7 +53,7 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.13.1
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

View file

@ -1,96 +0,0 @@
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

@ -1,149 +0,0 @@
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

@ -1,220 +0,0 @@
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

@ -1,66 +0,0 @@
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

@ -1,90 +0,0 @@
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,85 +9,11 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mosenioring/src/app/app.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() { void main() {
testWidgets('App smoke test', (WidgetTester tester) async { testWidgets('App smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame. // Build our app and trigger a frame.
await tester.pumpWidget( await tester.pumpWidget(const ProviderScope(child: App()));
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(); // Start _bootstrap
await tester.pump(const Duration(milliseconds: 100)); // Allow some time await tester.pump(const Duration(milliseconds: 100)); // Allow some time

View file

@ -6,15 +6,9 @@
#include "generated_plugin_registrant.h" #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 <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
} }

View file

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