diff --git a/docs/README.md b/docs/README.md index 3981ff6..2648bc1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ | [Token Lifecycle](token-lifecycle.md) | Token resolution algorithm, refresh, exchange, recovery, session monitoring | | [Platform Adapters](platform-adapters.md) | StorageProvider (via plugins) and NetworkDelegate interfaces | | [Architecture](architecture.md) | Internal design: module structure, HTTP pipeline, dependency graph | -| [Dart parity](dart-parity.md) | Dart/Flutter SDK roadmap, `packages/dart/morph_core` scaffold ([issue #1](https://github.com/burgan-tech/morph-api-client/issues/1)) | +| [Dart parity](dart-parity.md) | Dart/Flutter SDK status vs TypeScript ([issue #1](https://github.com/burgan-tech/morph-api-client/issues/1), [issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)), Flutter PoC parity, backlog | | Document | Description | |----------|-------------| diff --git a/docs/dart-parity.md b/docs/dart-parity.md index 1e49368..8d91d23 100644 --- a/docs/dart-parity.md +++ b/docs/dart-parity.md @@ -35,12 +35,16 @@ Base branch for work is **`f/plugin`** unless release policy changes. | Memory storage | **Done:** `morph_storage` module + plugin for tests/tools. | | OAuth return / redirect | **Done:** `oauthRedirectBase` on `MorphOptions`; `completeOAuthReturn` with conditional `dart:html` + optional `Uri? currentUri`. | | Typed `MorphConfig` DTOs | **Backlog:** hand-written or codegen from JSON boundary. | -| Persistent / browser storage | **Done:** `morph_core_storage` adapter in `morph-data-store` bridges `StorageProvider` → `IContextStore` (Keychain/KeyStore); `poc/flutter-poc` switched from in-memory to persistent storage. | -| Sample app | **Done:** `poc/flutter-poc` Flutter app — token status, OAuth login, mock-API call, HTTP trace log (closes #24). | +| Persistent storage (adapter) | **Done:** `morph_core_storage` in **morph-data-store** bridges `morph_core.StorageProvider` → `IContextStore` (Device / platform secure storage). Suitable for **mobile/desktop** PoC and production apps that set **ContextStore identity** before user-scoped writes. | +| Flutter PoC (`poc/flutter-poc`) | **Done:** Feature parity with **`poc/ts-vue`** (closes [#27](https://github.com/burgan-tech/morph-api-client/issues/27), PR [#28](https://github.com/burgan-tech/morph-api-client/pull/28)): provider-grouped status, dynamic actions, mock-API sheet + HTTP trace, provider config sheet, JSON-driven simulation (`assets/poc-simulation.json` copy of `docs/poc/poc-simulation.json`). **Web:** uses **in-memory** `StorageProvider` — full-page OAuth reload cannot satisfy ContextStore **user** boundary before the first token write; **non-web** uses **ContextStore** when init succeeds. **`run_web.sh`** defaults to **`--profile`** (fast single bundle; debug reload is slow and can race Keycloak code expiry). Keycloak **`webOrigins`** must include `http://localhost:4200` for **morph-device** and **morph-session** as well as morph-login (browser `POST /token`). **Unit tests:** `poc/flutter-poc/test/poc_simulation_test.dart` (+ `flutter test`); `parsePocSimulationJson`, `isPocSessionDeadStop`, mocked fetch steps. | +| Sample app (minimal) | **Done (earlier milestone):** baseline Flutter app — token status, OAuth, mock call, trace log ([#24](https://github.com/burgan-tech/morph-api-client/issues/24)). | ## CI -**.github/workflows/dart.yml** runs `dart analyze --fatal-infos` and `dart test` for `morph_core`, `morph_oauth2`, and `morph_logger`. **`morph_storage`** is intentionally omitted from the matrix while it stays test-only transitively; add when it has standalone coverage. +**.github/workflows/dart.yml** runs `dart analyze --fatal-infos` and `dart test` for **`morph_core`**, **`morph_oauth2`**, and **`morph_logger`**. + +- **`morph_storage`** is intentionally omitted from the matrix while it stays test-only transitively; tracked in backlog [#21](https://github.com/burgan-tech/morph-api-client/issues/21). +- **`poc/flutter-poc`** is **not** in CI yet: `pubspec` uses **`path:`** dependencies on **morph-data-store** checked out beside this repo locally. Adding CI would require a second checkout or publishing those packages. ## Design intent (aligned with TS) @@ -54,4 +58,4 @@ Per [architecture.md](architecture.md): Acceptance criteria and epic tracking: **[issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)**. -When starting work on a backlog row (persistent / browser storage, typed `MorphConfig`, `poc/` sample, adding `morph_storage` to CI), open a **dedicated GitHub issue** and drive it with the [morph-api-client-issue-pr-merge](../.cursor/skills/morph-api-client-issue-pr-merge/SKILL.md) workflow so each slice stays reviewable. +When starting work on a backlog row (typed `MorphConfig`, **`morph_storage` in CI**, façade polish, OAuth hardening, `package:web` migration), use the open issues **[#18](https://github.com/burgan-tech/morph-api-client/issues/18)**–**[#22](https://github.com/burgan-tech/morph-api-client/issues/22)** and epic **[issue #3](https://github.com/burgan-tech/morph-api-client/issues/3)**. Drive each slice with the [morph-api-client-issue-pr-merge](../.cursor/skills/morph-api-client-issue-pr-merge/SKILL.md) workflow so it stays reviewable. diff --git a/docs/poc-guide.md b/docs/poc-guide.md index b17e849..40b5cef 100644 --- a/docs/poc-guide.md +++ b/docs/poc-guide.md @@ -32,6 +32,24 @@ Test users in Keycloak: --- +## Flutter PoC (`poc/flutter-poc`) + +The Flutter sample mirrors **`poc/ts-vue`** against the **same** Keycloak (8080) and Mock API (3000). Issue [#27](https://github.com/burgan-tech/morph-api-client/issues/27) / PR [#28](https://github.com/burgan-tech/morph-api-client/pull/28): grouped token status, dynamic actions from `grantHint`, mock-API bottom sheet + HTTP trace, provider metadata sheet, and **`docs/poc/poc-simulation.json`** bundled as **`assets/poc-simulation.json`**. + +| Topic | Flutter notes | +|--------|----------------| +| Run (Chrome + backends) | From `poc/flutter-poc`: `./run_web.sh` starts Keycloak, mock-api, and Flutter web on **localhost:4200** (defaults to **`--profile`**). Flags: `--no-keycloak`, `--no-mock-api`, `--debug`. From repo root Keycloak compose path is **`poc/keycloak`** (not `flutter-poc/poc/keycloak`). | +| OAuth on web | Redirect **`http://localhost:4200/`** is registered in **`poc/keycloak/morph-realm.json`**. Use **`webOnlyWindowName: '_self'`** so login stays in one tab. | +| CORS | Keycloak clients **`morph-login`**, **`morph-device`**, **`morph-session`** need **`webOrigins`** including **`http://localhost:4200`** so browser token `POST` succeeds (not only the login client). | +| Storage | **Web:** in-memory tokens for the PoC (session-scoped Keycloak tokens vs ContextStore **user** identity — see [dart-parity.md](dart-parity.md)). **iOS/Android/desktop:** **ContextStore** via **`morph_core_storage`** when initialization succeeds. | +| Simulation UI | **Run simulation** runs the auto-step list on demand (Vue uses a timed tick by default). Session-dead stopping uses the same JSON `sessionDeadCheck` idea; see [poc/simulation.md](poc/simulation.md). | +| Tests | `cd poc/flutter-poc && flutter test` — parser, session-dead helper, mocked HTTP fetch, real asset load. | +| Deeper detail | [dart-parity.md](dart-parity.md); app-specific **`poc/flutter-poc/README.md`** (Android emulator host, secrets, web troubleshooting). | + +Then continue with Vue flow below, or use Flutter for the same conceptual steps (acquire device token, Keycloak login, exchange, mock API, simulation). + +--- + ## The Home Page Open **http://127.0.0.1:5173/** in your browser. The Home page has four main sections: diff --git a/docs/poc/simulation.md b/docs/poc/simulation.md index d6a8390..0e17db1 100644 --- a/docs/poc/simulation.md +++ b/docs/poc/simulation.md @@ -20,6 +20,16 @@ The table shows **status**, **total ms**, and a short **detail** line. **Verbose If **one tick** yields **AUTH** for **every** auth id listed in **`sessionDeadCheck.authIds`** in `docs/poc/poc-simulation.json` (default: **1fa** and **2fa**), the loop **stops** and the banner shows **`sessionDeadCheck.message`**. Adjust the list or message in JSON if your scenario differs. +## Flutter PoC executor + +The Flutter app at **`poc/flutter-poc`** includes a copy of this document’s JSON as **`assets/poc-simulation.json`**. Execution is implemented in **`lib/poc_simulation.dart`**: + +- **`fetch`** — `GET` via `package:http` to `mockApi.baseUrl` (with timeout); same step shape as Vue. +- **`host`** — `MorphRuntime.http.hostFetch` with `method`, `path`, `auth`, optional `body` / `headers`. +- **`logout_provider`** — `MorphClient.auth(providerKey).logout()`. + +Parsing is synchronous via **`parsePocSimulationJson`** (tests) and **`loadPocSimulation`** (loads the asset). The **Simulation** panel runs steps **when the user taps Run** (not on a fixed interval like the Vue dev server’s default tick). **Session dead:** after an **AUTH** result, **`isPocSessionDeadStop`** requires the step’s host **`auth`** to be listed in **`sessionDeadCheck.authIds`** *and* `invalid_grant` or `Token is not active` in the error detail (parentheses matter — see unit tests). **Unit tests:** `poc/flutter-poc/test/poc_simulation_test.dart`. + ## Short token lifetimes (Keycloak) **Fresh** imports use the short defaults below (`morph-realm.json`). If Keycloak was created from an older export or you ran **`restore-simulation-lifetimes.sh`** (long-lived PoC), re-apply the short profile (Keycloak must be up): diff --git a/poc/flutter-poc/lib/poc_simulation.dart b/poc/flutter-poc/lib/poc_simulation.dart index 372f40d..e3a9d48 100644 --- a/poc/flutter-poc/lib/poc_simulation.dart +++ b/poc/flutter-poc/lib/poc_simulation.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; import 'package:morph_core/morph_core.dart'; // --------------------------------------------------------------------------- @@ -73,6 +74,22 @@ final class PocSimStepResult { } } +/// When an auto-simulation step returns [PocSimStepResult.status] `AUTH`, decide +/// whether to stop the loop (stale Keycloak session). Kept pure for unit tests. +bool isPocSessionDeadStop({ + required PocSimStepResult result, + required PocSimStep step, + required List sessionDeadAuthIds, +}) { + if (result.status != 'AUTH') return false; + final isSessionDead = + step is PocSimHostStep && sessionDeadAuthIds.contains(step.auth); + final detail = result.detail ?? ''; + return isSessionDead && + (detail.contains('invalid_grant') || + detail.contains('Token is not active')); +} + // --------------------------------------------------------------------------- // Config loader // --------------------------------------------------------------------------- @@ -93,10 +110,16 @@ class PocSimulationConfig { Future loadPocSimulation(String mockApiBase) async { final raw = await rootBundle.loadString('assets/poc-simulation.json'); + return parsePocSimulationJson(raw, mockApiBase); +} + +/// Parses the PoC simulation document (same shape as [assets/poc-simulation.json]). +/// [mockApiFallback] is used when the document omits `mockApi.baseUrl`. +PocSimulationConfig parsePocSimulationJson(String raw, String mockApiFallback) { final json = jsonDecode(raw) as Map; final mockApi = (json['mockApi'] as Map?) ?? {}; - final baseUrl = (mockApi['baseUrl'] as String?) ?? mockApiBase; + final baseUrl = (mockApi['baseUrl'] as String?) ?? mockApiFallback; final sessionDeadCheck = (json['sessionDeadCheck'] as Map?) ?? {}; @@ -175,20 +198,26 @@ PocSimStep? _parseStep(Map m) { Future runPocSimStep( MorphClient morph, PocSimulationConfig cfg, - PocSimStep step, -) async { + PocSimStep step, [ + http.Client? httpClient, +]) async { return switch (step) { - PocSimFetchStep s => _runFetch(s, cfg.mockApiBaseUrl), + PocSimFetchStep s => _runFetch(s, cfg.mockApiBaseUrl, httpClient), PocSimHostStep s => _runHost(morph, s), PocSimLogoutStep s => _runLogout(morph, s), }; } Future _runFetch( - PocSimFetchStep step, String mockApiBase) async { + PocSimFetchStep step, + String mockApiBase, [ + http.Client? clientOverride, +]) async { final url = '$mockApiBase${step.path}'; + final client = clientOverride ?? http.Client(); + final ownsClient = clientOverride == null; try { - final res = await http + final res = await client .get(Uri.parse(url)) .timeout(const Duration(seconds: 10)); final expectedStatus = step.expectStatus; @@ -210,9 +239,20 @@ Future _runFetch( } catch (e) { return PocSimStepResult( label: step.label, status: 'NET', detail: e.toString()); + } finally { + if (ownsClient) client.close(); } } +/// Calls [_runFetch] with an injected HTTP client for unit tests. +@visibleForTesting +Future runPocSimFetchForTesting( + PocSimFetchStep step, + String mockApiBase, + http.Client httpClient, +) => + _runFetch(step, mockApiBase, httpClient); + Future _runHost( MorphClient morph, PocSimHostStep step) async { try { diff --git a/poc/flutter-poc/lib/widgets/simulation_panel.dart b/poc/flutter-poc/lib/widgets/simulation_panel.dart index fc33e0a..448d277 100644 --- a/poc/flutter-poc/lib/widgets/simulation_panel.dart +++ b/poc/flutter-poc/lib/widgets/simulation_panel.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; import 'package:morph_core/morph_core.dart'; import '../poc_simulation.dart'; @@ -24,6 +25,13 @@ class _SimulationPanelState extends State { bool _probe404Enabled = false; final List _results = []; String _sessionDeadMessage = ''; + final http.Client _httpClient = http.Client(); + + @override + void dispose() { + _httpClient.close(); + super.dispose(); + } List get _autoSteps => widget.cfg.steps .where((s) => @@ -47,23 +55,19 @@ class _SimulationPanelState extends State { for (final step in _autoSteps) { if (!mounted) break; - final result = await runPocSimStep(widget.morph, widget.cfg, step); + final result = await runPocSimStep(widget.morph, widget.cfg, step, _httpClient); if (!mounted) break; setState(() => _results.add(result)); // Session dead check - if (result.status == 'AUTH') { - final detail = result.detail ?? ''; - final isSessionDead = widget.cfg.sessionDeadAuthIds.any( - (id) => step is PocSimHostStep && step.auth == id, - ); - if (isSessionDead && - (detail.contains('invalid_grant') || - detail.contains('Token is not active'))) { - setState(() => _sessionDeadMessage = widget.cfg.sessionDeadMessage); - break; - } + if (isPocSessionDeadStop( + result: result, + step: step, + sessionDeadAuthIds: widget.cfg.sessionDeadAuthIds, + )) { + setState(() => _sessionDeadMessage = widget.cfg.sessionDeadMessage); + break; } } diff --git a/poc/flutter-poc/test/poc_simulation_test.dart b/poc/flutter-poc/test/poc_simulation_test.dart new file mode 100644 index 0000000..fb2b2a7 --- /dev/null +++ b/poc/flutter-poc/test/poc_simulation_test.dart @@ -0,0 +1,296 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:morph_flutter_poc/poc_simulation.dart'; + +void main() { + group('PocSimStepResult.isError', () { + test('int status >= 400 is error', () { + expect( + const PocSimStepResult(label: 'x', status: 200).isError, + false, + ); + expect( + const PocSimStepResult(label: 'x', status: 399).isError, + false, + ); + expect( + const PocSimStepResult(label: 'x', status: 400).isError, + true, + ); + }); + + test('string sentinel statuses', () { + expect( + const PocSimStepResult(label: 'x', status: 'OK').isError, + false, + ); + expect( + const PocSimStepResult(label: 'x', status: 'ERR').isError, + true, + ); + expect( + const PocSimStepResult(label: 'x', status: 'NET').isError, + true, + ); + expect( + const PocSimStepResult(label: 'x', status: 'AUTH').isError, + true, + ); + }); + }); + + group('parsePocSimulationJson', () { + test('fills mock URL from fallback when baseUrl omitted', () { + final cfg = parsePocSimulationJson( + jsonEncode({ + 'steps': [ + {'type': 'fetch', 'label': 'ping', 'path': '/health'}, + ], + }), + 'http://fallback/', + ); + expect(cfg.mockApiBaseUrl, 'http://fallback/'); + expect(cfg.steps, hasLength(1)); + expect(cfg.steps.first, isA()); + expect((cfg.steps.first as PocSimFetchStep).path, '/health'); + }); + + test('uses mockApi.baseUrl when present', () { + final cfg = parsePocSimulationJson( + jsonEncode({ + 'mockApi': {'baseUrl': 'http://mock/'}, + 'steps': [], + }), + 'http://ignored/', + ); + expect(cfg.mockApiBaseUrl, 'http://mock/'); + }); + + test('parses fetch, host, logout and skips unknown types', () { + final cfg = parsePocSimulationJson( + jsonEncode({ + 'steps': [ + {'type': 'fetch', 'label': 'f', 'path': '/a', 'expectStatus': 201}, + { + 'type': 'host', + 'label': 'h', + 'hostKey': 'main-api', + 'method': 'post', + 'path': '/p', + 'auth': 'morph-auth/device', + 'headers': {'X': '1'}, + 'skipInAutoSim': true, + }, + {'type': 'logout_provider', 'label': 'l', 'providerKey': 'morph-auth'}, + {'type': 'unknown_future_type', 'label': 'skip'}, + ], + }), + 'http://m/', + ); + expect(cfg.steps, hasLength(3)); + + final host = cfg.steps[1] as PocSimHostStep; + expect(host.method, 'POST'); + expect(host.headers, {'X': '1'}); + expect(host.skipInAutoSim, true); + }); + + test('probe_404 conditional block merges steps', () { + final cfg = parsePocSimulationJson( + jsonEncode({ + 'steps': [ + {'type': 'fetch', 'label': 'a', 'path': '/a'}, + ], + 'conditionalBlocks': [ + { + 'id': 'probe_404', + 'steps': [ + {'type': 'fetch', 'label': 'probe', 'path': '/missing'}, + ], + }, + ], + }), + 'http://m/', + ); + expect(cfg.steps, hasLength(2)); + expect(cfg.steps.last.label, 'probe'); + }); + + test('sessionDeadCheck defaults', () { + final empty = parsePocSimulationJson('{}', 'http://m/'); + expect(empty.sessionDeadAuthIds, isEmpty); + expect(empty.sessionDeadMessage, 'Session expired.'); + + final full = parsePocSimulationJson( + jsonEncode({ + 'sessionDeadCheck': { + 'authIds': ['morph-auth/1fa'], + 'message': 'custom', + }, + }), + 'http://m/', + ); + expect(full.sessionDeadAuthIds, ['morph-auth/1fa']); + expect(full.sessionDeadMessage, 'custom'); + }); + }); + + group('isPocSessionDeadStop', () { + late PocSimHostStep sessionStep; + + setUp(() { + sessionStep = const PocSimHostStep( + label: 'x', + hostKey: 'main-api', + method: 'GET', + path: '/p', + auth: 'morph-auth/2fa', + ); + }); + + test('requires AUTH status', () { + expect( + isPocSessionDeadStop( + result: const PocSimStepResult(label: 'x', status: 'ERR'), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/2fa'], + ), + false, + ); + }); + + test('requires auth context in sessionDeadAuthIds', () { + expect( + isPocSessionDeadStop( + result: const PocSimStepResult( + label: 'x', + status: 'AUTH', + detail: 'invalid_grant foo', + ), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/device'], + ), + false, + ); + }); + + test('requires invalid_grant OR Token is not active in detail', () { + expect( + isPocSessionDeadStop( + result: + const PocSimStepResult(label: 'x', status: 'AUTH', detail: 'other'), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/2fa'], + ), + false, + ); + + expect( + isPocSessionDeadStop( + result: const PocSimStepResult( + label: 'x', + status: 'AUTH', + detail: 'something invalid_grant', + ), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/2fa'], + ), + true, + ); + + expect( + isPocSessionDeadStop( + result: const PocSimStepResult( + label: 'x', + status: 'AUTH', + detail: 'Token is not active (session)', + ), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/2fa'], + ), + true, + ); + }); + + test('does not treat Token is not active as session-dead for non-listed auth ' + '(operator-precedence regression)', () { + expect( + isPocSessionDeadStop( + result: const PocSimStepResult( + label: 'x', + status: 'AUTH', + detail: 'Token is not active', + ), + step: sessionStep, + sessionDeadAuthIds: const ['morph-auth/1fa'], // 2fa step not listed + ), + false, + ); + }); + + test('logout step never matches session host auth', () { + expect( + isPocSessionDeadStop( + result: const PocSimStepResult( + label: 'x', + status: 'AUTH', + detail: 'invalid_grant', + ), + step: const PocSimLogoutStep(label: 'out', providerKey: 'morph-auth'), + sessionDeadAuthIds: const ['morph-auth'], + ), + false, + ); + }); + }); + + group('fetch step (mocked HTTP)', () { + test('parses JSON body on 200', () async { + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.toString(), 'http://m/ping'); + return http.Response(jsonEncode({'ok': true}), 200); + }); + final step = const PocSimFetchStep(label: 'p', path: '/ping'); + final r = + await runPocSimFetchForTesting(step, 'http://m', client); + expect(r.status, 200); + expect(r.isError, false); + expect(r.body, {'ok': true}); + client.close(); + }); + + test('unexpected explicit expectStatus sets detail', () async { + final client = MockClient( + (_) async => http.Response('{}', 500), + ); + final step = + const PocSimFetchStep(label: 'e', path: '/', expectStatus: 200); + final r = + await runPocSimFetchForTesting(step, 'http://m', client); + expect(r.status, 500); + expect(r.isError, true); + expect(r.detail, contains('Unexpected status')); + client.close(); + }); + }); + + group('loadPocSimulation asset', () { + test('parses committed poc-simulation.json', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + final cfg = await loadPocSimulation('http://should-not-use-if-json-has-base'); + expect(cfg.steps, isNotEmpty); + expect( + cfg.mockApiBaseUrl, + startsWith('http://'), + ); + expect( + cfg.sessionDeadAuthIds, + containsAll(['morph-auth/1fa', 'morph-auth/2fa']), + ); + }); + }); +} diff --git a/poc/flutter-poc/test/widget_test.dart b/poc/flutter-poc/test/widget_test.dart index afe544c..db27406 100644 --- a/poc/flutter-poc/test/widget_test.dart +++ b/poc/flutter-poc/test/widget_test.dart @@ -1,3 +1,8 @@ -// Placeholder — integration tests requiring a running Keycloak + mock-api -// backend are not wired in this PoC. Add unit tests for individual widgets here. -void main() {} +// Placeholder retained for template discovery; real coverage is in poc_simulation_test.dart. +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('smoke: test harness runs', () { + expect(true, isTrue); + }); +}