From 000a984d046efbcf1ef952b225ff23eab8330f8e Mon Sep 17 00:00:00 2001 From: oskar Date: Mon, 12 Jan 2026 22:23:38 +0100 Subject: [PATCH] partially work. issues: 1. no frontend logout when not authorized 2026-01-12T22:21:03.531+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-1] [b137cd03c5fed357d857aa5201957552-608b35eaef78b8f9] o.s.security.web.FilterChainProxy : Securing POST /api/telemetry/test 2026-01-12T22:21:03.546+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-1] [b137cd03c5fed357d857aa5201957552-f9d4701c12880487] o.s.s.oauth2.jwt.JwtTimestampValidator : Jwt expired at 2026-01-12T21:18:04Z 2026-01-12T22:21:03.546+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-1] [b137cd03c5fed357d857aa5201957552-f9d4701c12880487] o.s.s.o.s.r.a.JwtAuthenticationProvider : Failed to authenticate since the JWT was invalid 2. 500 2026-01-12T22:21:59.224+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.security.web.FilterChainProxy : Secured POST /api/telemetry/test 2026-01-12T22:21:59.225+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.web.servlet.DispatcherServlet : POST "/api/telemetry/test", parameters={} 2026-01-12T22:21:59.227+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.mosenioring.app.api.TelemetryController#recordTestTelemetry(TelemetryRequest, Authentication) 2026-01-12T22:21:59.308+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.web.method.HandlerMethod : Could not resolve parameter [0] in public org.springframework.http.ResponseEntity com.mosenioring.app.api.TelemetryController.recordTestTelemetry(com.mosenioring.app.telemetry.TelemetryRequest,org.springframework.security.core.Authentication): JSON parse error: Cannot deserialize value of type `java.time.Instant` from String "2026-01-12T22:21:58.583050": Failed to deserialize java.time.Instant: (java.time.format.DateTimeParseException) Text '2026-01-12T22:21:58.583050' could not be parsed at index 26 2026-01-12T22:21:59.308+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] .m.m.a.ExceptionHandlerExceptionResolver : Using @ExceptionHandler com.mosenioring.common.web.ProblemDetailsAdvice#handleUnexpected(Exception, HttpServletRequest) 2026-01-12T22:21:59.337+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.w.s.m.m.a.HttpEntityMethodProcessor : Using 'application/problem+json', given [*/*] and supported [application/problem+json] 2026-01-12T22:21:59.339+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.w.s.m.m.a.HttpEntityMethodProcessor : Writing [ProblemDetail[type='https://httpstatuses.io/500', title='Unexpected error', status=500, detail='JSON (truncated)...] 2026-01-12T22:21:59.344+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.time.Instant` from String "2026-01-12T22:21:58.583050": Failed to deserialize java.time.Instant: (java.time.format.DateTimeParseException) Text '2026-01-12T22:21:58.583050' could not be parsed at index 26] 2026-01-12T22:21:59.344+01:00 DEBUG 76514 --- [mosenioring-backend] [nio-8080-exec-3] [94043d787087b50f996c7e87b550ff71-a32a7387b3d26ba2] o.s.web.servlet.DispatcherServlet : Completed 500 INTERNAL_SERVER_ERROR --- back001/app/build.gradle.kts | 1 + .../app/api/TelemetryController.kt | 56 +++++++++ .../app/config/JwtDecoderConfig.kt | 44 +++++++ .../app/telemetry/TelemetryModels.kt | 55 +++++++++ .../app/telemetry/TelemetryService.kt | 35 ++++++ .../app/src/main/resources/application.yml | 4 + .../app/api/TelemetryControllerTest.kt | 116 ++++++++++++++++++ .../app/telemetry/TelemetryServiceTest.kt | 37 ++++++ .../lib/src/core/network/http_client.dart | 82 +++++++++++++ .../mosenioring/lib/src/di/providers.dart | 46 +++++++ .../features/home/presentation/home_page.dart | 85 ++++++++++++- .../telemetry/data/auth_token_provider.dart | 14 +++ .../data/telemetry_remote_data_source.dart | 36 ++++++ .../data/telemetry_service_impl.dart | 90 ++++++++++++++ .../telemetry/domain/platform_info.dart | 20 +++ .../telemetry/domain/telemetry_failure.dart | 25 ++++ .../telemetry/domain/telemetry_payload.dart | 22 ++++ .../domain/telemetry_payload_builder.dart | 22 ++++ .../telemetry/domain/telemetry_service.dart | 3 + .../telemetry/domain/token_provider.dart | 3 + .../presentation/telemetry_controller.dart | 58 +++++++++ .../presentation/telemetry_state.dart | 28 +++++ .../telemetry/telemetry_controller_test.dart | 100 +++++++++++++++ .../telemetry/telemetry_service_test.dart | 93 ++++++++++++++ 24 files changed, 1071 insertions(+), 4 deletions(-) create mode 100644 back001/app/src/main/kotlin/com/mosenioring/app/api/TelemetryController.kt create mode 100644 back001/app/src/main/kotlin/com/mosenioring/app/config/JwtDecoderConfig.kt create mode 100644 back001/app/src/main/kotlin/com/mosenioring/app/telemetry/TelemetryModels.kt create mode 100644 back001/app/src/main/kotlin/com/mosenioring/app/telemetry/TelemetryService.kt create mode 100644 back001/app/src/test/kotlin/com/mosenioring/app/api/TelemetryControllerTest.kt create mode 100644 back001/app/src/test/kotlin/com/mosenioring/app/telemetry/TelemetryServiceTest.kt create mode 100644 front001/mosenioring/lib/src/core/network/http_client.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/data/auth_token_provider.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/data/telemetry_remote_data_source.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/data/telemetry_service_impl.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/domain/platform_info.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/domain/telemetry_failure.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/domain/telemetry_payload.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/domain/telemetry_payload_builder.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/domain/telemetry_service.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/domain/token_provider.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/presentation/telemetry_controller.dart create mode 100644 front001/mosenioring/lib/src/features/telemetry/presentation/telemetry_state.dart create mode 100644 front001/mosenioring/test/features/telemetry/telemetry_controller_test.dart create mode 100644 front001/mosenioring/test/features/telemetry/telemetry_service_test.dart diff --git a/back001/app/build.gradle.kts b/back001/app/build.gradle.kts index 87ed9c3..1321845 100644 --- a/back001/app/build.gradle.kts +++ b/back001/app/build.gradle.kts @@ -33,4 +33,5 @@ dependencies { runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") } diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/api/TelemetryController.kt b/back001/app/src/main/kotlin/com/mosenioring/app/api/TelemetryController.kt new file mode 100644 index 0000000..590a8e2 --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/api/TelemetryController.kt @@ -0,0 +1,56 @@ +package com.mosenioring.app.api + +import com.mosenioring.app.telemetry.AuthenticatedUser +import com.mosenioring.app.telemetry.TelemetryRequest +import com.mosenioring.app.telemetry.TelemetryResponse +import com.mosenioring.app.telemetry.TelemetryService +import com.mosenioring.common.security.LocalUserPrincipal +import com.mosenioring.common.security.SecurityUtils +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.security.core.Authentication +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken +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 + +@RestController +@RequestMapping("/api/telemetry") +class TelemetryController( + private val telemetryService: TelemetryService +) { + @PostMapping("/test") + fun recordTestTelemetry( + @Valid @RequestBody request: TelemetryRequest, + authentication: Authentication + ): ResponseEntity { + val user = resolveUser(authentication) + val response = telemetryService.recordTestTelemetry(request, user) + return ResponseEntity.ok(response) + } + + private fun resolveUser(authentication: Authentication): AuthenticatedUser { + return when (authentication) { + is JwtAuthenticationToken -> { + val token = authentication.token + AuthenticatedUser( + userId = token.subject ?: "unknown", + username = token.getClaimAsString("preferred_username"), + email = token.getClaimAsString("email") + ) + } + else -> { + val principal = authentication.principal + val userId = (principal as? LocalUserPrincipal)?.userId + ?: SecurityUtils.currentUserId() + ?: "unknown" + AuthenticatedUser( + userId = userId, + username = null, + email = null + ) + } + } + } +} diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/config/JwtDecoderConfig.kt b/back001/app/src/main/kotlin/com/mosenioring/app/config/JwtDecoderConfig.kt new file mode 100644 index 0000000..bd00bfa --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/config/JwtDecoderConfig.kt @@ -0,0 +1,44 @@ +package com.mosenioring.app.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtClaimValidator +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.JwtValidators +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder + +@Configuration +@EnableConfigurationProperties(SecurityProperties::class) +class JwtDecoderConfig( + private val securityProperties: SecurityProperties, + @Value("\${spring.security.oauth2.resourceserver.jwt.issuer-uri}") private val issuerUri: String +) { + @Bean + fun jwtDecoder(): JwtDecoder { + val decoder = NimbusJwtDecoder.withIssuerLocation(issuerUri).build() + val allowedIssuers = if (securityProperties.acceptedIssuers.isNotEmpty()) { + securityProperties.acceptedIssuers + } else { + listOf(issuerUri) + } + val issuerValidator = JwtClaimValidator("iss") { issuer -> + issuer != null && allowedIssuers.contains(issuer) + } + val validator = DelegatingOAuth2TokenValidator( + JwtValidators.createDefault(), + issuerValidator + ) + decoder.setJwtValidator(validator) + return decoder + } +} + +@ConfigurationProperties(prefix = "app.security") +data class SecurityProperties( + val acceptedIssuers: List = emptyList() +) diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/telemetry/TelemetryModels.kt b/back001/app/src/main/kotlin/com/mosenioring/app/telemetry/TelemetryModels.kt new file mode 100644 index 0000000..21ee74f --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/telemetry/TelemetryModels.kt @@ -0,0 +1,55 @@ +package com.mosenioring.app.telemetry + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonValue +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import java.time.Instant + +data class TelemetryRequest( + @field:NotNull val timestamp: Instant?, + @field:NotNull val platform: TelemetryPlatform?, + @field:NotBlank val appVersion: String, + @field:Size(max = 200) val note: String? = null +) + +data class TelemetryResponse( + val status: String, + val receivedAt: Instant, + val userId: String, + val echo: TelemetryEcho +) + +data class TelemetryEcho( + val timestamp: Instant, + val platform: TelemetryPlatform, + val appVersion: String, + val note: String? +) + +data class AuthenticatedUser( + val userId: String, + val username: String?, + val email: String? +) + +enum class TelemetryPlatform { + ANDROID, + IOS; + + @JsonValue + fun toJson(): String = name.lowercase() + + companion object { + @JsonCreator + @JvmStatic + fun fromJson(value: String): TelemetryPlatform { + return when (value.trim().lowercase()) { + "android" -> ANDROID + "ios" -> IOS + else -> throw IllegalArgumentException("Unsupported platform: $value") + } + } + } +} diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/telemetry/TelemetryService.kt b/back001/app/src/main/kotlin/com/mosenioring/app/telemetry/TelemetryService.kt new file mode 100644 index 0000000..9b69f1e --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/telemetry/TelemetryService.kt @@ -0,0 +1,35 @@ +package com.mosenioring.app.telemetry + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class TelemetryService { + private val logger = LoggerFactory.getLogger(TelemetryService::class.java) + + fun recordTestTelemetry(request: TelemetryRequest, user: AuthenticatedUser): TelemetryResponse { + val timestamp = requireNotNull(request.timestamp) { "Missing timestamp" } + val platform = requireNotNull(request.platform) { "Missing platform" } + val echo = TelemetryEcho( + timestamp = timestamp, + platform = platform, + appVersion = request.appVersion, + note = request.note + ) + + logger.info( + "Test telemetry received userId={} platform={} timestamp={}", + user.userId, + platform, + timestamp + ) + + return TelemetryResponse( + status = "ok", + receivedAt = Instant.now(), + userId = user.userId, + echo = echo + ) + } +} diff --git a/back001/app/src/main/resources/application.yml b/back001/app/src/main/resources/application.yml index 0a5b594..13c9fa3 100644 --- a/back001/app/src/main/resources/application.yml +++ b/back001/app/src/main/resources/application.yml @@ -39,6 +39,10 @@ storage: bucket: mosenioring app: + security: + accepted-issuers: + - http://localhost:8081/realms/mosenioring + - http://10.0.2.2:8081/realms/mosenioring outbox: publish-delay-ms: 2000 batch-size: 50 diff --git a/back001/app/src/test/kotlin/com/mosenioring/app/api/TelemetryControllerTest.kt b/back001/app/src/test/kotlin/com/mosenioring/app/api/TelemetryControllerTest.kt new file mode 100644 index 0000000..bb327f9 --- /dev/null +++ b/back001/app/src/test/kotlin/com/mosenioring/app/api/TelemetryControllerTest.kt @@ -0,0 +1,116 @@ +package com.mosenioring.app.api + +import com.mosenioring.app.config.SecurityConfig +import com.mosenioring.app.telemetry.AuthenticatedUser +import com.mosenioring.app.telemetry.TelemetryEcho +import com.mosenioring.app.telemetry.TelemetryPlatform +import com.mosenioring.app.telemetry.TelemetryRequest +import com.mosenioring.app.telemetry.TelemetryResponse +import com.mosenioring.app.telemetry.TelemetryService +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.ImportAutoConfiguration +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.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration +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.test.context.ContextConfiguration +import org.springframework.http.MediaType +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +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 = [TelemetryController::class]) +@ContextConfiguration(classes = [TelemetryController::class, SecurityConfig::class]) +@ImportAutoConfiguration( + exclude = [ + DataSourceAutoConfiguration::class, + DataSourceTransactionManagerAutoConfiguration::class, + HibernateJpaAutoConfiguration::class, + JpaRepositoriesAutoConfiguration::class + ] +) +@Import(SecurityConfig::class) +@AutoConfigureMockMvc +class TelemetryControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @MockBean + private lateinit var telemetryService: TelemetryService + + @MockBean + private lateinit var jwtDecoder: JwtDecoder + + @Test + fun `returns ok when authenticated with valid payload`() { + val response = TelemetryResponse( + status = "ok", + receivedAt = Instant.parse("2024-01-01T00:00:00Z"), + userId = "user-1", + echo = TelemetryEcho( + timestamp = Instant.parse("2024-01-01T00:00:00Z"), + platform = TelemetryPlatform.ANDROID, + appVersion = "dev", + note = "test call from mobile" + ) + ) + `when`( + telemetryService.recordTestTelemetry( + anyNonNull(), + anyNonNull() + ) + ).thenReturn(response) + + val requestBody = """ + { + "timestamp": "2024-01-01T00:00:00Z", + "platform": "android", + "appVersion": "dev", + "note": "test call from mobile" + } + """.trimIndent() + + mockMvc.perform( + post("/api/telemetry/test") + .with(jwt().jwt { it.subject("user-1") }) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.status").value("ok")) + .andExpect(jsonPath("$.userId").value("user-1")) + .andExpect(jsonPath("$.echo.platform").value("android")) + } + + @Test + fun `returns 400 when payload invalid`() { + val requestBody = """ + { + "platform": "android" + } + """.trimIndent() + + mockMvc.perform( + post("/api/telemetry/test") + .with(jwt().jwt { it.subject("user-1") }) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + ) + .andExpect(status().isBadRequest) + } +} + +@Suppress("UNCHECKED_CAST") +private fun anyNonNull(): T = ArgumentMatchers.any() ?: null as T diff --git a/back001/app/src/test/kotlin/com/mosenioring/app/telemetry/TelemetryServiceTest.kt b/back001/app/src/test/kotlin/com/mosenioring/app/telemetry/TelemetryServiceTest.kt new file mode 100644 index 0000000..ad95c85 --- /dev/null +++ b/back001/app/src/test/kotlin/com/mosenioring/app/telemetry/TelemetryServiceTest.kt @@ -0,0 +1,37 @@ +package com.mosenioring.app.telemetry + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant + +class TelemetryServiceTest { + + @Test + fun `records telemetry and returns response`() { + val service = TelemetryService() + val request = TelemetryRequest( + timestamp = Instant.parse("2024-01-02T12:00:00Z"), + platform = TelemetryPlatform.IOS, + appVersion = "dev", + note = "test call from mobile" + ) + val user = AuthenticatedUser( + userId = "user-42", + username = "tester", + email = "tester@example.com" + ) + + val before = Instant.now() + val response = service.recordTestTelemetry(request, user) + val after = Instant.now() + + assertEquals("ok", response.status) + assertEquals("user-42", response.userId) + assertEquals(request.timestamp, response.echo.timestamp) + assertEquals(request.platform, response.echo.platform) + assertEquals(request.appVersion, response.echo.appVersion) + assertEquals(request.note, response.echo.note) + assertTrue(!response.receivedAt.isBefore(before) && !response.receivedAt.isAfter(after)) + } +} diff --git a/front001/mosenioring/lib/src/core/network/http_client.dart b/front001/mosenioring/lib/src/core/network/http_client.dart new file mode 100644 index 0000000..4899c29 --- /dev/null +++ b/front001/mosenioring/lib/src/core/network/http_client.dart @@ -0,0 +1,82 @@ +import 'package:dio/dio.dart'; + +import 'api_client.dart'; + +class HttpResponse { + const HttpResponse({ + required this.statusCode, + this.data, + }); + + final int statusCode; + final T? data; +} + +class HttpClientException implements Exception { + HttpClientException({ + required this.message, + this.statusCode, + this.isNetworkError = false, + }); + + final String message; + final int? statusCode; + final bool isNetworkError; + + @override + String toString() { + return 'HttpClientException(message: $message, statusCode: $statusCode, isNetworkError: $isNetworkError)'; + } + + factory HttpClientException.fromDio(DioException error) { + final statusCode = error.response?.statusCode; + final isNetworkError = error.type == DioExceptionType.connectionError || + error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout; + return HttpClientException( + message: error.message ?? 'HTTP request failed', + statusCode: statusCode, + isNetworkError: isNetworkError, + ); + } +} + +abstract class HttpClient { + Future> post( + String path, { + Object? data, + Map? headers, + }); +} + +class ApiHttpClient implements HttpClient { + ApiHttpClient(this._apiClient); + + final ApiClient _apiClient; + + @override + Future> post( + String path, { + Object? data, + Map? headers, + }) async { + try { + final response = await _apiClient.request( + path, + method: 'POST', + data: data, + options: Options( + headers: headers, + validateStatus: (status) => status != null, + ), + ); + return HttpResponse( + statusCode: response.statusCode ?? 0, + data: response.data, + ); + } on DioException catch (error) { + throw HttpClientException.fromDio(error); + } + } +} diff --git a/front001/mosenioring/lib/src/di/providers.dart b/front001/mosenioring/lib/src/di/providers.dart index 10f2b89..428a919 100644 --- a/front001/mosenioring/lib/src/di/providers.dart +++ b/front001/mosenioring/lib/src/di/providers.dart @@ -5,12 +5,22 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../core/config/app_config.dart'; import '../core/network/api_client.dart'; +import '../core/network/http_client.dart'; import '../features/auth/data/auth_local_data_source.dart'; import '../features/auth/data/auth_remote_data_source.dart'; import '../features/auth/data/auth_repository_impl.dart'; import '../features/auth/domain/auth_repository.dart'; import '../features/auth/presentation/auth_controller.dart'; import '../features/auth/presentation/auth_state.dart'; +import '../features/telemetry/data/auth_token_provider.dart'; +import '../features/telemetry/data/telemetry_remote_data_source.dart'; +import '../features/telemetry/data/telemetry_service_impl.dart'; +import '../features/telemetry/domain/platform_info.dart'; +import '../features/telemetry/domain/telemetry_payload_builder.dart'; +import '../features/telemetry/domain/telemetry_service.dart'; +import '../features/telemetry/domain/token_provider.dart'; +import '../features/telemetry/presentation/telemetry_controller.dart'; +import '../features/telemetry/presentation/telemetry_state.dart'; final appConfigProvider = Provider((ref) { const apiBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: ''); @@ -142,3 +152,39 @@ final authRepositoryProvider = Provider((ref) { final authControllerProvider = NotifierProvider(() { return AuthController(); }); + +final httpClientProvider = Provider((ref) { + return ApiHttpClient(ref.watch(apiClientProvider)); +}); + +final platformInfoProvider = Provider((ref) { + return const DevicePlatformInfo(); +}); + +final tokenProvider = Provider((ref) { + return AuthTokenProvider(ref.watch(authRepositoryProvider)); +}); + +final telemetryPayloadBuilderProvider = Provider((ref) { + return TelemetryPayloadBuilder( + platformInfo: ref.watch(platformInfoProvider), + now: DateTime.now, + ); +}); + +final telemetryRemoteDataSourceProvider = Provider((ref) { + return TelemetryRemoteDataSource(ref.watch(httpClientProvider)); +}); + +final telemetryServiceProvider = Provider((ref) { + return TelemetryServiceImpl( + ref.watch(telemetryRemoteDataSourceProvider), + ref.watch(tokenProvider), + ref.watch(telemetryPayloadBuilderProvider), + ); +}); + +final telemetryControllerProvider = + NotifierProvider(() { + return TelemetryController(); +}); diff --git a/front001/mosenioring/lib/src/features/home/presentation/home_page.dart b/front001/mosenioring/lib/src/features/home/presentation/home_page.dart index c7cedf6..ff3da87 100644 --- a/front001/mosenioring/lib/src/features/home/presentation/home_page.dart +++ b/front001/mosenioring/lib/src/features/home/presentation/home_page.dart @@ -3,13 +3,52 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mosenioring/l10n/app_localizations.dart'; import '../../../di/providers.dart'; +import '../../telemetry/presentation/telemetry_state.dart'; -class HomePage extends ConsumerWidget { +class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _HomePageState(); +} + +class _HomePageState extends ConsumerState { + late final ProviderSubscription _telemetrySubscription; + + @override + void initState() { + super.initState(); + _telemetrySubscription = + ref.listenManual(telemetryControllerProvider, (previous, next) { + if (previous?.lastOutcome == next.lastOutcome || + next.lastOutcome == null || + !mounted) { + return; + } + final messenger = ScaffoldMessenger.of(context); + if (next.lastOutcome == TelemetryOutcome.success) { + messenger.showSnackBar( + const SnackBar(content: Text('Telemetry sent')), + ); + } else if (next.lastOutcome == TelemetryOutcome.failure) { + final message = next.errorMessage ?? 'Telemetry request failed'; + messenger.showSnackBar( + SnackBar(content: Text(message)), + ); + } + }); + } + + @override + void dispose() { + _telemetrySubscription.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final telemetryState = ref.watch(telemetryControllerProvider); return Scaffold( appBar: AppBar( @@ -24,8 +63,46 @@ class HomePage extends ConsumerWidget { ), ], ), - body: Center( - child: Text(l10n.signedInMessage), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + l10n.signedInMessage, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 24), + Text( + 'Developer tools', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: telemetryState.isLoading + ? null + : () { + ref + .read(telemetryControllerProvider.notifier) + .sendTestTelemetry(); + }, + child: telemetryState.isLoading + ? const Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 8), + Text('Sending...'), + ], + ) + : const Text('Send test telemetry'), + ), + ), + ], ), ); } diff --git a/front001/mosenioring/lib/src/features/telemetry/data/auth_token_provider.dart b/front001/mosenioring/lib/src/features/telemetry/data/auth_token_provider.dart new file mode 100644 index 0000000..d9fcb6b --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/data/auth_token_provider.dart @@ -0,0 +1,14 @@ +import '../../auth/domain/auth_repository.dart'; +import '../domain/token_provider.dart'; + +class AuthTokenProvider implements TokenProvider { + AuthTokenProvider(this._authRepository); + + final AuthRepository _authRepository; + + @override + Future getAccessToken() async { + final token = await _authRepository.getSavedToken(); + return token?.accessToken; + } +} diff --git a/front001/mosenioring/lib/src/features/telemetry/data/telemetry_remote_data_source.dart b/front001/mosenioring/lib/src/features/telemetry/data/telemetry_remote_data_source.dart new file mode 100644 index 0000000..1058f20 --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/data/telemetry_remote_data_source.dart @@ -0,0 +1,36 @@ +import '../../../core/network/http_client.dart'; +import '../domain/telemetry_payload.dart'; + +class TelemetryApiResponse { + const TelemetryApiResponse({ + required this.statusCode, + this.data, + }); + + final int statusCode; + final Object? data; +} + +class TelemetryRemoteDataSource { + TelemetryRemoteDataSource(this._httpClient); + + final HttpClient _httpClient; + + Future sendTestTelemetry({ + required String accessToken, + required TelemetryPayload payload, + }) async { + final response = await _httpClient.post( + '/api/telemetry/test', + data: payload.toJson(), + headers: { + 'Authorization': 'Bearer $accessToken', + 'Content-Type': 'application/json', + }, + ); + return TelemetryApiResponse( + statusCode: response.statusCode, + data: response.data, + ); + } +} diff --git a/front001/mosenioring/lib/src/features/telemetry/data/telemetry_service_impl.dart b/front001/mosenioring/lib/src/features/telemetry/data/telemetry_service_impl.dart new file mode 100644 index 0000000..829e094 --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/data/telemetry_service_impl.dart @@ -0,0 +1,90 @@ +import '../../../core/network/http_client.dart'; +import '../domain/telemetry_failure.dart'; +import '../domain/telemetry_payload_builder.dart'; +import '../domain/telemetry_service.dart'; +import '../domain/token_provider.dart'; +import 'telemetry_remote_data_source.dart'; + +class TelemetryServiceImpl implements TelemetryService { + TelemetryServiceImpl( + this._remoteDataSource, + this._tokenProvider, + this._payloadBuilder, + ); + + final TelemetryRemoteDataSource _remoteDataSource; + final TokenProvider _tokenProvider; + final TelemetryPayloadBuilder _payloadBuilder; + + @override + Future sendTestTelemetry() async { + final accessToken = await _tokenProvider.getAccessToken(); + if (accessToken == null || accessToken.isEmpty) { + throw TelemetryFailure( + 'Missing access token', + type: TelemetryFailureType.unauthorized, + debugMessage: 'Access token missing when sending telemetry', + ); + } + + try { + final payload = _payloadBuilder.build(); + final response = await _remoteDataSource.sendTestTelemetry( + accessToken: accessToken, + payload: payload, + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return; + } + + throw _failureFromStatus(response.statusCode); + } on HttpClientException catch (error) { + throw _failureFromHttp(error); + } on TelemetryFailure { + rethrow; + } catch (error) { + throw TelemetryFailure( + 'Unable to send telemetry', + type: TelemetryFailureType.unknown, + debugMessage: 'Unexpected telemetry error: ${error.runtimeType}', + ); + } + } + + TelemetryFailure _failureFromStatus(int statusCode) { + if (statusCode == 401 || statusCode == 403) { + return TelemetryFailure( + 'Not authorized', + type: TelemetryFailureType.unauthorized, + statusCode: statusCode, + debugMessage: 'Telemetry request unauthorized ($statusCode)', + ); + } + return TelemetryFailure( + 'Telemetry request failed', + type: TelemetryFailureType.server, + statusCode: statusCode, + debugMessage: 'Telemetry request failed with status $statusCode', + ); + } + + TelemetryFailure _failureFromHttp(HttpClientException error) { + if (error.isNetworkError) { + return TelemetryFailure( + 'Network error. Try again.', + type: TelemetryFailureType.network, + statusCode: error.statusCode, + debugMessage: 'Network error: ${error.message}', + ); + } + if (error.statusCode != null) { + return _failureFromStatus(error.statusCode!); + } + return TelemetryFailure( + 'Telemetry request failed', + type: TelemetryFailureType.unknown, + debugMessage: 'HTTP error: ${error.message}', + ); + } +} diff --git a/front001/mosenioring/lib/src/features/telemetry/domain/platform_info.dart b/front001/mosenioring/lib/src/features/telemetry/domain/platform_info.dart new file mode 100644 index 0000000..679e505 --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/domain/platform_info.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; + +abstract class PlatformInfo { + String get platform; +} + +class DevicePlatformInfo implements PlatformInfo { + const DevicePlatformInfo(); + + @override + String get platform { + if (defaultTargetPlatform == TargetPlatform.android) { + return 'android'; + } + if (defaultTargetPlatform == TargetPlatform.iOS) { + return 'ios'; + } + return 'unknown'; + } +} diff --git a/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_failure.dart b/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_failure.dart new file mode 100644 index 0000000..62ab011 --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_failure.dart @@ -0,0 +1,25 @@ +enum TelemetryFailureType { + unauthorized, + network, + server, + unknown, +} + +class TelemetryFailure implements Exception { + TelemetryFailure( + this.message, { + this.type = TelemetryFailureType.unknown, + this.statusCode, + this.debugMessage, + }); + + final String message; + final TelemetryFailureType type; + final int? statusCode; + final String? debugMessage; + + @override + String toString() { + return 'TelemetryFailure(type: $type, statusCode: $statusCode, message: $message)'; + } +} diff --git a/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_payload.dart b/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_payload.dart new file mode 100644 index 0000000..ecd7fdc --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_payload.dart @@ -0,0 +1,22 @@ +class TelemetryPayload { + const TelemetryPayload({ + required this.timestamp, + required this.platform, + required this.appVersion, + required this.note, + }); + + final String timestamp; + final String platform; + final String appVersion; + final String note; + + Map toJson() { + return { + 'timestamp': timestamp, + 'platform': platform, + 'appVersion': appVersion, + 'note': note, + }; + } +} diff --git a/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_payload_builder.dart b/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_payload_builder.dart new file mode 100644 index 0000000..0c55b60 --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_payload_builder.dart @@ -0,0 +1,22 @@ +import 'platform_info.dart'; +import 'telemetry_payload.dart'; + +class TelemetryPayloadBuilder { + TelemetryPayloadBuilder({ + required PlatformInfo platformInfo, + DateTime Function()? now, + }) : _platformInfo = platformInfo, + _now = now ?? DateTime.now; + + final PlatformInfo _platformInfo; + final DateTime Function() _now; + + TelemetryPayload build() { + return TelemetryPayload( + timestamp: _now().toIso8601String(), + platform: _platformInfo.platform, + appVersion: 'dev', + note: 'test call from mobile', + ); + } +} diff --git a/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_service.dart b/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_service.dart new file mode 100644 index 0000000..c75f370 --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/domain/telemetry_service.dart @@ -0,0 +1,3 @@ +abstract class TelemetryService { + Future sendTestTelemetry(); +} diff --git a/front001/mosenioring/lib/src/features/telemetry/domain/token_provider.dart b/front001/mosenioring/lib/src/features/telemetry/domain/token_provider.dart new file mode 100644 index 0000000..66ab918 --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/domain/token_provider.dart @@ -0,0 +1,3 @@ +abstract class TokenProvider { + Future getAccessToken(); +} diff --git a/front001/mosenioring/lib/src/features/telemetry/presentation/telemetry_controller.dart b/front001/mosenioring/lib/src/features/telemetry/presentation/telemetry_controller.dart new file mode 100644 index 0000000..d011db4 --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/presentation/telemetry_controller.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../di/providers.dart'; +import '../domain/telemetry_failure.dart'; +import '../domain/telemetry_service.dart'; +import 'telemetry_state.dart'; + +class TelemetryController extends Notifier { + @override + TelemetryState build() { + return const TelemetryState(); + } + + TelemetryService get _service => ref.watch(telemetryServiceProvider); + + Future sendTestTelemetry() async { + if (state.isLoading) { + return; + } + state = state.copyWith( + isLoading: true, + lastOutcome: null, + errorMessage: null, + ); + + try { + await _service.sendTestTelemetry(); + state = state.copyWith( + isLoading: false, + lastOutcome: TelemetryOutcome.success, + errorMessage: null, + ); + } on TelemetryFailure catch (failure) { + _logFailure(failure); + state = state.copyWith( + isLoading: false, + lastOutcome: TelemetryOutcome.failure, + errorMessage: failure.message, + ); + } catch (error) { + _logFailure(error); + state = state.copyWith( + isLoading: false, + lastOutcome: TelemetryOutcome.failure, + errorMessage: 'Telemetry request failed', + ); + } + } + + void _logFailure(Object error) { + if (error is TelemetryFailure) { + debugPrint('Telemetry failed: ${error.debugMessage ?? error.message}'); + return; + } + debugPrint('Telemetry failed: ${error.runtimeType}'); + } +} diff --git a/front001/mosenioring/lib/src/features/telemetry/presentation/telemetry_state.dart b/front001/mosenioring/lib/src/features/telemetry/presentation/telemetry_state.dart new file mode 100644 index 0000000..30db456 --- /dev/null +++ b/front001/mosenioring/lib/src/features/telemetry/presentation/telemetry_state.dart @@ -0,0 +1,28 @@ +enum TelemetryOutcome { + success, + failure, +} + +class TelemetryState { + const TelemetryState({ + this.isLoading = false, + this.lastOutcome, + this.errorMessage, + }); + + final bool isLoading; + final TelemetryOutcome? lastOutcome; + final String? errorMessage; + + TelemetryState copyWith({ + bool? isLoading, + TelemetryOutcome? lastOutcome, + String? errorMessage, + }) { + return TelemetryState( + isLoading: isLoading ?? this.isLoading, + lastOutcome: lastOutcome, + errorMessage: errorMessage, + ); + } +} diff --git a/front001/mosenioring/test/features/telemetry/telemetry_controller_test.dart b/front001/mosenioring/test/features/telemetry/telemetry_controller_test.dart new file mode 100644 index 0000000..51c1824 --- /dev/null +++ b/front001/mosenioring/test/features/telemetry/telemetry_controller_test.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +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/telemetry/domain/telemetry_failure.dart'; +import 'package:mosenioring/src/features/telemetry/domain/telemetry_service.dart'; +import 'package:mosenioring/src/features/telemetry/presentation/telemetry_state.dart'; + +class FakeTelemetryService implements TelemetryService { + FakeTelemetryService.success() + : _behavior = _Behavior.success, + failure = null, + completer = null; + FakeTelemetryService.failure(this.failure) + : _behavior = _Behavior.failure, + completer = null; + FakeTelemetryService.delayed(this.completer) + : _behavior = _Behavior.delayed, + failure = null; + + final _Behavior _behavior; + final TelemetryFailure? failure; + final Completer? completer; + + @override + Future sendTestTelemetry() { + switch (_behavior) { + case _Behavior.success: + return Future.value(); + case _Behavior.failure: + return Future.error(failure!); + case _Behavior.delayed: + return completer!.future; + } + } +} + +enum _Behavior { + success, + failure, + delayed, +} + +void main() { + test('TelemetryController toggles loading during call', () async { + final completer = Completer(); + final service = FakeTelemetryService.delayed(completer); + final container = ProviderContainer( + overrides: [ + telemetryServiceProvider.overrideWithValue(service), + ], + ); + addTearDown(container.dispose); + + final notifier = container.read(telemetryControllerProvider.notifier); + final future = notifier.sendTestTelemetry(); + + expect(container.read(telemetryControllerProvider).isLoading, isTrue); + + completer.complete(); + await future; + + expect(container.read(telemetryControllerProvider).isLoading, isFalse); + }); + + test('TelemetryController exposes success outcome', () async { + final service = FakeTelemetryService.success(); + final container = ProviderContainer( + overrides: [ + telemetryServiceProvider.overrideWithValue(service), + ], + ); + addTearDown(container.dispose); + + await container.read(telemetryControllerProvider.notifier).sendTestTelemetry(); + + final state = container.read(telemetryControllerProvider); + expect(state.lastOutcome, TelemetryOutcome.success); + expect(state.errorMessage, isNull); + }); + + test('TelemetryController exposes failure outcome', () async { + final service = FakeTelemetryService.failure( + TelemetryFailure('Boom'), + ); + final container = ProviderContainer( + overrides: [ + telemetryServiceProvider.overrideWithValue(service), + ], + ); + addTearDown(container.dispose); + + await container.read(telemetryControllerProvider.notifier).sendTestTelemetry(); + + final state = container.read(telemetryControllerProvider); + expect(state.lastOutcome, TelemetryOutcome.failure); + expect(state.errorMessage, 'Boom'); + }); +} diff --git a/front001/mosenioring/test/features/telemetry/telemetry_service_test.dart b/front001/mosenioring/test/features/telemetry/telemetry_service_test.dart new file mode 100644 index 0000000..b1bff7b --- /dev/null +++ b/front001/mosenioring/test/features/telemetry/telemetry_service_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mosenioring/src/core/network/http_client.dart'; +import 'package:mosenioring/src/features/telemetry/data/telemetry_remote_data_source.dart'; +import 'package:mosenioring/src/features/telemetry/data/telemetry_service_impl.dart'; +import 'package:mosenioring/src/features/telemetry/domain/platform_info.dart'; +import 'package:mosenioring/src/features/telemetry/domain/telemetry_failure.dart'; +import 'package:mosenioring/src/features/telemetry/domain/telemetry_payload_builder.dart'; +import 'package:mosenioring/src/features/telemetry/domain/token_provider.dart'; + +class FakeHttpClient implements HttpClient { + FakeHttpClient({required this.statusCode}); + + final int statusCode; + String? lastPath; + Object? lastBody; + Map? lastHeaders; + + @override + Future> post( + String path, { + Object? data, + Map? headers, + }) async { + lastPath = path; + lastBody = data; + lastHeaders = headers; + return HttpResponse( + statusCode: statusCode, + data: null, + ); + } +} + +class FakeTokenProvider implements TokenProvider { + FakeTokenProvider(this.token); + + final String? token; + + @override + Future getAccessToken() async => token; +} + +class FakePlatformInfo implements PlatformInfo { + FakePlatformInfo(this.value); + + final String value; + + @override + String get platform => value; +} + +void main() { + test('TelemetryService success returns without error', () async { + final httpClient = FakeHttpClient(statusCode: 200); + final payloadBuilder = TelemetryPayloadBuilder( + platformInfo: FakePlatformInfo('android'), + now: () => DateTime(2024, 1, 1), + ); + final service = TelemetryServiceImpl( + TelemetryRemoteDataSource(httpClient), + FakeTokenProvider('token'), + payloadBuilder, + ); + + await service.sendTestTelemetry(); + + expect(httpClient.lastPath, '/api/telemetry/test'); + }); + + test('TelemetryService failure maps non-2xx response', () async { + final httpClient = FakeHttpClient(statusCode: 500); + final payloadBuilder = TelemetryPayloadBuilder( + platformInfo: FakePlatformInfo('ios'), + now: () => DateTime(2024, 1, 1), + ); + final service = TelemetryServiceImpl( + TelemetryRemoteDataSource(httpClient), + FakeTokenProvider('token'), + payloadBuilder, + ); + + expect( + () async => service.sendTestTelemetry(), + throwsA( + isA().having( + (failure) => failure.type, + 'type', + TelemetryFailureType.server, + ), + ), + ); + }); +}