diff --git a/CHANGELOG.md b/CHANGELOG.md index d259f53..6161a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,14 @@ All notable changes to this project will be documented in this file. - [ ] `example/` updated when the change touches the canonical consumer scaffold - [ ] `flutter test` green; `dart analyze` clean; `dart format` no diff; `dart pub publish --dry-run` no blocking errors +### Added + +- **`MagicMiddleware.redirectTarget(String location)` for pre-build redirect guards.** Redirect-style guards (auth / guest) can now return a redirect target synchronously, evaluated inside the router's `redirect` callback BEFORE any page builds. Previously the only way to redirect was an imperative `MagicRoute.to()` inside `handle()`, which runs post-mount and remounts the destination view, recreating its form state on every mount (the login-double-mount bug). `_handleRedirect` now evaluates every matched route's global + route middleware `redirectTarget` and returns the first non-null target. The default returns `null`, and `handle()` now defaults to `next()`, so a redirect-only guard overrides just `redirectTarget`. Fully backward compatible: existing `handle()`-based guards keep working. Touches `lib/src/http/middleware/magic_middleware.dart`, `lib/src/routing/magic_router.dart`; adds `test/routing/redirect_guard_mount_test.dart` (asserts the destination mounts exactly once, including through a layout ShellRoute). + ### Fixed +- **`Crypt` now accepts the `base64:` app key that `key:generate` produces.** `key:generate` writes `APP_KEY=base64:`, but `EncryptionServiceProvider` required `app.key` to be a raw 32-character string and threw `App Key must be 32 characters for AES-256` on the generated key, so `Crypt.encrypt`/`decrypt` were unusable out of the box. Added `MagicEncrypter.fromAppKey(appKey)` which base64-decodes a `base64:`-prefixed key to its 32 bytes (and still accepts a raw 32-character key); `EncryptionServiceProvider` now binds through it. Touches `lib/src/encryption/magic_encrypter.dart`, `lib/src/encryption/encryption_service_provider.dart`; adds three `fromAppKey` cases to `test/encryption/magic_encrypter_test.dart`. +- **`MagicStatefulView` now calls the controller's `onInit()` lifecycle hook.** `MagicStatefulViewState.initState` listened to the controller and called the VIEW's own `onInit()` hook, but never invoked the CONTROLLER's `onInit()`, despite the documented contract. A controller that bootstraps in `onInit` (initial data load, table creation, subscriptions) silently never ran it when backed by a `MagicStatefulView`, so the screen rendered against uninitialized state (e.g. a query against a table the controller's `onInit` was supposed to create). It now calls `_controller.onInit()` guarded by `MagicController.initialized`, so a `SimpleMagicController` that already initialized in its constructor is not double-initialized and a singleton controller reused across re-mounts initializes exactly once per lifetime. Touches `lib/src/ui/magic_view.dart`; adds `test/ui/magic_view_controller_oninit_test.dart`. - **Auth no longer warns on every boot of a fresh app.** `AuthServiceProvider.boot()` logged a `userFactory not registered` warning (blaming provider order) whenever no userFactory was set, even for apps with no stored session to restore. It now only warns when a stored session actually exists (`Auth.hasToken()`) but cannot be rebuilt; a fresh app or a logged-out user stays quiet (debug-level). The stored-session check is guarded so a misconfigured Auth (for example, no Vault registered) cannot crash boot from this warning-verbosity path. Touches `lib/src/auth/auth_service_provider.dart`; adds three cases to `test/auth/auth_test.dart`. ### Changed diff --git a/doc/basics/middleware.md b/doc/basics/middleware.md index deaa0bf..d272665 100644 --- a/doc/basics/middleware.md +++ b/doc/basics/middleware.md @@ -39,20 +39,24 @@ import 'package:magic/magic.dart'; class EnsureAuthenticated extends MagicMiddleware { @override - Future handle(void Function() next) async { - if (Auth.check()) { - next(); // Allow navigation to proceed - } else { - MagicRoute.to('/login'); // Redirect to login - } + String? redirectTarget(String location) { + if (!Auth.check() && location != '/login') return '/login'; + return null; // Authenticated, or already on /login: allow. } } ``` -As you can see, if the user is not authenticated, the middleware will redirect the user to the login screen. If the user is authenticated, the request is passed further into the application by calling `next()`. +As you can see, if the user is not authenticated, the middleware returns the login path and the user is redirected to the login screen. If the user is authenticated, it returns `null` and the request proceeds. + +### Redirect guards vs blocking guards + +Middleware has two hooks, and you pick the one that matches the intent: + +- **`redirectTarget(String location)`** for redirect-style guards (auth, guest, role gates). It is evaluated synchronously in the router's `redirect` callback BEFORE the route builds, so the destination view mounts exactly once. Return a path to redirect to, or `null` to allow. Always return `null` when `location` already equals the target, otherwise the redirect loops. +- **`handle(void Function() next)`** for async, non-redirecting concerns (logging, a blocked-state screen). Call `next()` to allow, skip it to block. Both hooks default to allow, so override only the one you need. > [!IMPORTANT] -> If you do NOT call `next()`, navigation will be blocked. Always ensure you either call `next()` or redirect the user. +> Prefer `redirectTarget` for redirects. Redirecting with an imperative `MagicRoute.to()` from inside `handle()` runs after the route has already mounted, so it remounts the destination view and recreates its state. ### Async Operations diff --git a/doc/security/authentication.md b/doc/security/authentication.md index c18ac6c..deb745d 100644 --- a/doc/security/authentication.md +++ b/doc/security/authentication.md @@ -329,12 +329,9 @@ Create a `guest` middleware to redirect authenticated users: ```dart class RedirectIfAuthenticated extends MagicMiddleware { @override - Future handle(void Function() next) async { - if (Auth.check()) { - MagicRoute.to('/dashboard'); - } else { - next(); - } + String? redirectTarget(String location) { + if (Auth.check() && location != '/dashboard') return '/dashboard'; + return null; } } ``` diff --git a/lib/src/encryption/encryption_service_provider.dart b/lib/src/encryption/encryption_service_provider.dart index a0ab311..37cde0c 100644 --- a/lib/src/encryption/encryption_service_provider.dart +++ b/lib/src/encryption/encryption_service_provider.dart @@ -12,11 +12,14 @@ class EncryptionServiceProvider extends ServiceProvider { /// Bootstrap the encryption services. /// - /// This method checks for the presence of a valid 32-character `app.key` - /// in the configuration. If the key is missing or invalid, it registers - /// a factory that throws a clear exception when the [Crypt] facade is accessed. + /// This method checks for the presence of an `app.key` in the configuration. + /// If the key is missing, it registers a factory that throws a clear exception + /// when the [Crypt] facade is accessed. /// - /// If the key is valid, it binds the [MagicEncrypter] as a singleton. + /// Otherwise it binds a lazy [MagicEncrypter] built via + /// [MagicEncrypter.fromAppKey], which accepts both a `base64:`-prefixed key + /// (as produced by `magic key:generate`) and a raw 32-character key, and + /// throws a clear error on access if the resolved key is not 32 bytes. @override Future boot() async { final key = Config.get('app.key'); @@ -32,13 +35,8 @@ class EncryptionServiceProvider extends ServiceProvider { return; } - if (key.length != 32) { - app.singleton('encrypter', () { - throw Exception('App Key must be 32 characters for AES-256.'); - }); - return; - } - - app.singleton('encrypter', () => MagicEncrypter(key)); + // Lazy: an invalid key surfaces a clear error when Crypt is first used, + // not during boot. fromAppKey handles both base64: and raw 32-char keys. + app.singleton('encrypter', () => MagicEncrypter.fromAppKey(key)); } } diff --git a/lib/src/encryption/magic_encrypter.dart b/lib/src/encryption/magic_encrypter.dart index 0c5bad5..7528be7 100644 --- a/lib/src/encryption/magic_encrypter.dart +++ b/lib/src/encryption/magic_encrypter.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:encrypt/encrypt.dart'; import 'exceptions.dart'; @@ -25,6 +28,39 @@ class MagicEncrypter { } } + /// Build an encrypter from an already-resolved [Key]. + MagicEncrypter._fromKey(Key key) + : _encrypter = Encrypter(AES(key, mode: AESMode.cbc)); + + /// Build an encrypter from a Laravel-style `app.key`. + /// + /// Accepts either a `base64:`-prefixed key (as produced by + /// `magic key:generate`, which base64-encodes 32 random bytes) or a raw + /// 32-character string. Throws when the resolved key is not 32 bytes, so the + /// generator output and the encrypter stay compatible. + factory MagicEncrypter.fromAppKey(String appKey) { + if (appKey.startsWith('base64:')) { + final Uint8List bytes; + try { + bytes = base64.decode(appKey.substring('base64:'.length)); + } on FormatException catch (e) { + // base64.decode throws a terse low-level FormatException; rethrow with + // an actionable message since every app key now flows through here. + throw Exception( + 'App Key has a "base64:" prefix but the value is not valid base64 ' + '(${e.message}). Re-run `magic key:generate`.', + ); + } + if (bytes.length != 32) { + throw Exception( + 'App Key must decode to 32 bytes for AES-256 (got ${bytes.length}).', + ); + } + return MagicEncrypter._fromKey(Key(bytes)); + } + return MagicEncrypter(appKey); + } + /// Encrypt the given value. /// /// This method generates a fresh, secure random 16-byte IV (Initialization Vector) diff --git a/lib/src/http/middleware/authorize_middleware.dart b/lib/src/http/middleware/authorize_middleware.dart index 85af0ee..e5ec8d2 100644 --- a/lib/src/http/middleware/authorize_middleware.dart +++ b/lib/src/http/middleware/authorize_middleware.dart @@ -1,6 +1,5 @@ import 'package:magic/src/http/middleware/magic_middleware.dart'; import 'package:magic/src/facades/gate.dart'; -import 'package:magic/src/facades/route.dart'; /// Authorization Middleware. /// @@ -21,26 +20,9 @@ import 'package:magic/src/facades/route.dart'; /// .middleware(['auth', 'can:edit-post']); /// ``` /// -/// ## With Route Arguments -/// -/// When you need to check authorization against a model, pass the model -/// from the route parameters: -/// -/// ```dart -/// class EditPostMiddleware extends MagicMiddleware { -/// @override -/// Future handle(void Function() next) async { -/// final postId = MagicRoute.param('id'); -/// final post = await Post.find(postId); -/// -/// if (Gate.allows('edit-post', post)) { -/// next(); -/// } else { -/// MagicRoute.to('/unauthorized'); -/// } -/// } -/// } -/// ``` +/// Gating resolves in the router's `redirect` callback (pre-build) via +/// [redirectTarget], so a denied route never builds and the unauthorized +/// destination mounts exactly once. class AuthorizeMiddleware extends MagicMiddleware { /// The ability to check. final String ability; @@ -63,11 +45,10 @@ class AuthorizeMiddleware extends MagicMiddleware { }); @override - Future handle(void Function() next) async { - if (Gate.allows(ability, arguments)) { - next(); - } else { - MagicRoute.to(unauthorizedRoute); + String? redirectTarget(String location) { + if (!Gate.allows(ability, arguments) && location != unauthorizedRoute) { + return unauthorizedRoute; } + return null; } } diff --git a/lib/src/http/middleware/magic_middleware.dart b/lib/src/http/middleware/magic_middleware.dart index 6b71da4..50ab09e 100644 --- a/lib/src/http/middleware/magic_middleware.dart +++ b/lib/src/http/middleware/magic_middleware.dart @@ -3,17 +3,32 @@ /// Middleware intercepts navigation and can allow, block, or redirect. /// This mimics Laravel's `$next($request)` pattern. /// -/// ## Usage +/// ## Redirect-style guards +/// +/// Override [redirectTarget] so the redirect resolves BEFORE the route builds. +/// The destination view then mounts exactly once. /// /// ```dart /// class EnsureAuthenticated extends MagicMiddleware { /// @override +/// String? redirectTarget(String location) { +/// if (!Auth.check() && location != '/login') return '/login'; +/// return null; +/// } +/// } +/// ``` +/// +/// ## Blocking / async guards +/// +/// Override [handle] for async, non-redirecting concerns. Call `next()` to +/// allow navigation; skip it to block. +/// +/// ```dart +/// class LogNavigation extends MagicMiddleware { +/// @override /// Future handle(void Function() next) async { -/// if (AuthService.isLoggedIn) { -/// next(); // Allow navigation -/// } else { -/// MagicRoute.to('/login'); // Redirect -/// } +/// Log.info('navigating'); +/// next(); /// } /// } /// ``` @@ -29,22 +44,53 @@ /// .middleware(['auth']); /// ``` abstract class MagicMiddleware { + /// Resolve a redirect target for [location] BEFORE the route builds. + /// + /// This is the redirect-guard hook. Return a path to redirect to, or `null` + /// to allow the navigation. It is evaluated synchronously inside the router's + /// `redirect` callback, so a redirect-style guard resolves before any page + /// is built and the destination view mounts exactly once. Prefer this over + /// an imperative `MagicRoute.to()` inside [handle]: redirecting from [handle] + /// runs post-mount and remounts the destination. + /// + /// Always return `null` when [location] already equals the target, otherwise + /// the redirect loops. + /// + /// ```dart + /// class EnsureAuthenticated extends MagicMiddleware { + /// @override + /// String? redirectTarget(String location) { + /// if (!Auth.check() && location != '/login') return '/login'; + /// return null; + /// } + /// } + /// ``` + /// + /// The default returns `null` (no redirect), so non-redirecting middleware + /// only need to implement [handle]. + String? redirectTarget(String location) => null; + /// Handle the navigation request. /// /// Call [next] to allow navigation to proceed. - /// If [next] is NOT called, navigation stops (redirect can happen inside). + /// If [next] is NOT called, navigation stops. + /// + /// Use this for async, non-redirecting concerns (logging, feature gating + /// that shows a blocked state). For redirect-style guards, override + /// [redirectTarget] instead so the redirect resolves pre-build. + /// + /// The default allows navigation, so a redirect-only guard can override + /// just [redirectTarget] and leave this alone. /// /// ```dart /// @override /// Future handle(void Function() next) async { /// if (canProceed) { /// next(); - /// } else { - /// MagicRoute.to('/login'); /// } /// } /// ``` - Future handle(void Function() next); + Future handle(void Function() next) async => next(); } /// A simple middleware that always allows navigation. diff --git a/lib/src/routing/magic_router.dart b/lib/src/routing/magic_router.dart index c33c604..9c896a3 100644 --- a/lib/src/routing/magic_router.dart +++ b/lib/src/routing/magic_router.dart @@ -84,9 +84,6 @@ class MagicRouter { /// Whether the router has been built. bool _isBuilt = false; - /// Pending redirect from async middleware. - String? _pendingRedirect; - /// Saved intended URL for redirect-after-login pattern. String? _intendedUrl; @@ -391,12 +388,44 @@ class MagicRouter { } /// Handle global redirects (sync only). + /// + /// Runs before any page builds. Redirect-style guards drive it: every + /// global + route middleware's [MagicMiddleware.redirectTarget] is + /// evaluated for the matched location and the first non-null target wins. + /// Resolving redirects here (pre-build) instead of imperatively from a + /// guard widget keeps the destination view mounting exactly once. String? _handleRedirect(BuildContext context, GoRouterState state) { - // Check for pending redirect from middleware - if (_pendingRedirect != null) { - final redirect = _pendingRedirect; - _pendingRedirect = null; - return redirect; + // Evaluate redirect-style guards synchronously, pre-build. + final location = state.matchedLocation; + final route = _resolveRoute(state); + final middlewares = [ + ...Kernel.globalMiddleware, + if (route != null) ...Kernel.resolveAll(route.middlewares), + ]; + for (final middleware in middlewares) { + final target = middleware.redirectTarget(location); + if (target != null && target != location) { + return target; + } + } + return null; + } + + /// Resolve the [RouteDefinition] whose pattern matches [state]. + /// + /// Searches top-level routes and every layout's children. Matches on the + /// configured full path pattern (`state.fullPath`), so it works for static + /// and parameterized routes alike. Returns `null` when no route matches. + RouteDefinition? _resolveRoute(GoRouterState state) { + final fullPath = state.fullPath; + if (fullPath == null) return null; + for (final route in _routes) { + if (route.fullPath == fullPath) return route; + } + for (final layout in _layouts) { + for (final child in layout.children) { + if (child.fullPath == fullPath) return child; + } } return null; } diff --git a/lib/src/ui/magic_view.dart b/lib/src/ui/magic_view.dart index d3c3835..e302388 100644 --- a/lib/src/ui/magic_view.dart +++ b/lib/src/ui/magic_view.dart @@ -125,6 +125,14 @@ abstract class MagicStatefulViewState< _controller.addListener(_onControllerChanged); // Auto-clear validation errors when new view initializes (Laravel-like) _clearValidationErrors(); + // Run the controller's onInit lifecycle hook the first time it backs a + // view (data bootstrap, table creation, initial load live here). Guarded by + // `initialized` so a SimpleMagicController that already ran onInit in its + // constructor is not initialized twice, and a singleton controller reused + // across re-mounts runs onInit exactly once per lifetime. + if (!_controller.initialized) { + _controller.onInit(); + } onInit(); } diff --git a/skills/magic-framework/references/routing-navigation.md b/skills/magic-framework/references/routing-navigation.md index 3992d8d..8734817 100644 --- a/skills/magic-framework/references/routing-navigation.md +++ b/skills/magic-framework/references/routing-navigation.md @@ -347,19 +347,19 @@ import 'package:magic/magic.dart'; class EnsureAuthenticated extends MagicMiddleware { @override - void handle(void Function() next) { - if (Auth.check()) { - next(); // Proceed to next middleware or route - } else { - // Halt pipeline (do not call next()) - MagicRouter.instance.setIntendedUrl(MagicRouter.instance.currentLocation ?? '/'); - MagicRoute.replace('/login'); + String? redirectTarget(String location) { + // Evaluated pre-build in the router redirect; the destination view + // mounts exactly once. Return null to allow navigation. + if (!Auth.check() && location != '/login') { + MagicRouter.instance.setIntendedUrl(location); + return '/login'; } + return null; } } ``` -Middleware must call `next()` to proceed. If it doesn't, the pipeline halts and the route is blocked. +The `handle()` hook must call `next()` to proceed; if it doesn't, the pipeline halts and the route is blocked. Redirect-style guards (the example above) instead override `redirectTarget`, which resolves before the route builds and never interacts with `next()`. ## RouteServiceProvider Pattern @@ -434,13 +434,12 @@ void main() async { // Middleware class EnsureAuthenticated extends MagicMiddleware { @override - void handle(void Function() next) { - if (!Auth.check()) { - MagicRouter.instance.setIntendedUrl(MagicRouter.instance.currentLocation ?? '/'); - MagicRoute.replace('/login'); - } else { - next(); + String? redirectTarget(String location) { + if (!Auth.check() && location != '/login') { + MagicRouter.instance.setIntendedUrl(location); + return '/login'; } + return null; } } diff --git a/skills/magic-framework/references/templates.md b/skills/magic-framework/references/templates.md index 2cf76db..50513f5 100644 --- a/skills/magic-framework/references/templates.md +++ b/skills/magic-framework/references/templates.md @@ -333,16 +333,14 @@ import 'package:magic/magic.dart'; class EnsureAuthenticated extends MagicMiddleware { @override - Future handle(void Function() next) async { - if (Auth.check()) { - next(); // Proceed to next middleware or route - } else { - MagicRouter.instance.setIntendedUrl( - MagicRouter.instance.currentLocation ?? '/', - ); - MagicRoute.replace('/login'); - // Do NOT call next() -- halts pipeline + String? redirectTarget(String location) { + // Evaluated pre-build in the router redirect; the destination + // view mounts exactly once. Return null to allow navigation. + if (!Auth.check() && location != '/login') { + MagicRouter.instance.setIntendedUrl(location); + return '/login'; } + return null; } } ``` diff --git a/test/encryption/magic_encrypter_test.dart b/test/encryption/magic_encrypter_test.dart index 500cc9a..2883507 100644 --- a/test/encryption/magic_encrypter_test.dart +++ b/test/encryption/magic_encrypter_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:magic/src/encryption/magic_encrypter.dart'; import 'package:magic/src/encryption/exceptions.dart'; @@ -10,6 +12,46 @@ void main() { expect(() => MagicEncrypter('short'), throwsException); }); + group('fromAppKey', () { + test('accepts a raw 32-character key and round-trips', () { + final encrypter = MagicEncrypter.fromAppKey(validKey); + final cipher = encrypter.encrypt('hello'); + expect(encrypter.decrypt(cipher), 'hello'); + }); + + test( + 'accepts a base64: key (magic key:generate format) and round-trips', + () { + // key:generate writes `base64:`. + final appKey = 'base64:${base64.encode(validKey.codeUnits)}'; + final encrypter = MagicEncrypter.fromAppKey(appKey); + final cipher = encrypter.encrypt('hello'); + expect(encrypter.decrypt(cipher), 'hello'); + }, + ); + + test('throws when a base64: key does not decode to 32 bytes', () { + final shortKey = 'base64:${base64.encode([1, 2, 3])}'; + expect(() => MagicEncrypter.fromAppKey(shortKey), throwsException); + }); + + test( + 'throws an actionable error when the base64: value is malformed', + () { + expect( + () => MagicEncrypter.fromAppKey('base64:###not-base64###'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('not valid base64'), + ), + ), + ); + }, + ); + }); + test('encrypts and decrypts values', () { final encrypter = MagicEncrypter(validKey); final original = 'Hello World'; diff --git a/test/routing/redirect_guard_mount_test.dart b/test/routing/redirect_guard_mount_test.dart new file mode 100644 index 0000000..6bd44a3 --- /dev/null +++ b/test/routing/redirect_guard_mount_test.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +/// Regression tests for the login double-mount bug. +/// +/// Background: redirect-style guards (auth / guest) used to redirect from +/// inside the post-mount `_MiddlewareGuard` via an imperative `MagicRoute.to`. +/// That made the destination view (e.g. the login screen) mount more than +/// once and recreate its form state on every mount. +/// +/// The fix moves redirect gating into go_router's synchronous `redirect` +/// callback (pre-build) via [MagicMiddleware.redirectTarget]. A guarded route +/// now resolves its redirect BEFORE any page builds, so the destination view +/// mounts exactly once. +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + setUp(() { + MagicApp.reset(); + Magic.flush(); + TitleManager.reset(); + MagicRouter.reset(); + Kernel.flush(); + Gate.manager.flush(); + Log.fake(); + _LoginProbe.mountCount = 0; + _DashboardProbe.mountCount = 0; + }); + + group('redirect-style guard pre-build gating', () { + testWidgets( + 'unauthenticated boot redirects to /login and mounts it exactly once', + (tester) async { + // 'auth' guard redirects unauthenticated users to /login pre-build. + Kernel.register('auth', () => _AuthGuard(authenticated: false)); + // 'guest' guard lets unauthenticated users stay on /login. + Kernel.register('guest', () => _GuestGuard(authenticated: false)); + + MagicRoute.page( + '/', + () => const _DashboardProbe(), + ).middleware(['auth']); + MagicRoute.page( + '/login', + () => const _LoginProbe(), + ).middleware(['guest']); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + // (a) The redirect actually fired: we are on /login, not the dashboard. + expect(MagicRouter.instance.currentPath, '/login'); + expect( + _DashboardProbe.mountCount, + 0, + reason: 'dashboard must never build for an unauthenticated user', + ); + + // (b) The login view mounted exactly once (no double-mount). + expect( + _LoginProbe.mountCount, + 1, + reason: 'login must mount exactly once across the boot redirect', + ); + }, + ); + + testWidgets('authenticated boot stays on / and never mounts /login', ( + tester, + ) async { + Kernel.register('auth', () => _AuthGuard(authenticated: true)); + Kernel.register('guest', () => _GuestGuard(authenticated: true)); + + MagicRoute.page('/', () => const _DashboardProbe()).middleware(['auth']); + MagicRoute.page( + '/login', + () => const _LoginProbe(), + ).middleware(['guest']); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/'); + expect(_DashboardProbe.mountCount, 1); + expect(_LoginProbe.mountCount, 0); + }); + + testWidgets('redirect gating resolves through a layout (ShellRoute) too', ( + tester, + ) async { + Kernel.register('auth', () => _AuthGuard(authenticated: false)); + Kernel.register('guest', () => _GuestGuard(authenticated: false)); + + MagicRoute.group( + middleware: ['auth'], + layoutId: 'app', + layout: (child) => _LayoutShell(child: child), + routes: () { + MagicRoute.page('/', () => const _DashboardProbe()); + }, + ); + MagicRoute.group( + middleware: ['guest'], + layoutId: 'guest', + layout: (child) => _LayoutShell(child: child), + routes: () { + MagicRoute.page('/login', () => const _LoginProbe()); + }, + ); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/login'); + expect(_LoginProbe.mountCount, 1); + expect(_DashboardProbe.mountCount, 0); + }); + }); + + group('AuthorizeMiddleware (can:) pre-build gating', () { + testWidgets('denied ability redirects to /unauthorized before build', ( + tester, + ) async { + Auth.fake(user: _fakeUser()); + Gate.define('edit-post', (user, _) => false); + Kernel.register('can:edit-post', () => AuthorizeMiddleware('edit-post')); + + // _LoginProbe stands in for the protected page; _DashboardProbe for + // the unauthorized destination. + MagicRoute.page('/', () => const Text('home')); + MagicRoute.page( + '/edit', + () => const _LoginProbe(), + ).middleware(['can:edit-post']); + MagicRoute.page('/unauthorized', () => const _DashboardProbe()); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + MagicRouter.instance.to('/edit'); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/unauthorized'); + expect( + _LoginProbe.mountCount, + 0, + reason: 'a denied route must never build', + ); + expect(_DashboardProbe.mountCount, 1); + }); + + testWidgets('allowed ability builds the protected route once', ( + tester, + ) async { + Auth.fake(user: _fakeUser()); + Gate.define('edit-post', (user, _) => true); + Kernel.register('can:edit-post', () => AuthorizeMiddleware('edit-post')); + + MagicRoute.page('/', () => const Text('home')); + MagicRoute.page( + '/edit', + () => const _LoginProbe(), + ).middleware(['can:edit-post']); + MagicRoute.page('/unauthorized', () => const _DashboardProbe()); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + MagicRouter.instance.to('/edit'); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/edit'); + expect(_LoginProbe.mountCount, 1); + expect(_DashboardProbe.mountCount, 0); + }); + }); +} + +/// Auth guard: redirects to /login pre-build when not authenticated. +class _AuthGuard extends MagicMiddleware { + _AuthGuard({required this.authenticated}); + + final bool authenticated; + + @override + String? redirectTarget(String location) { + if (!authenticated && location != '/login') return '/login'; + return null; + } + + @override + Future handle(void Function() next) async => next(); +} + +/// Guest guard: redirects authenticated users away from guest routes. +class _GuestGuard extends MagicMiddleware { + _GuestGuard({required this.authenticated}); + + final bool authenticated; + + @override + String? redirectTarget(String location) { + if (authenticated && location != '/') return '/'; + return null; + } + + @override + Future handle(void Function() next) async => next(); +} + +class _LoginProbe extends StatefulWidget { + const _LoginProbe(); + + static int mountCount = 0; + + @override + State<_LoginProbe> createState() => _LoginProbeState(); +} + +class _LoginProbeState extends State<_LoginProbe> { + @override + void initState() { + super.initState(); + _LoginProbe.mountCount++; + } + + @override + Widget build(BuildContext context) => const Text('login'); +} + +class _DashboardProbe extends StatefulWidget { + const _DashboardProbe(); + + static int mountCount = 0; + + @override + State<_DashboardProbe> createState() => _DashboardProbeState(); +} + +class _DashboardProbeState extends State<_DashboardProbe> { + @override + void initState() { + super.initState(); + _DashboardProbe.mountCount++; + } + + @override + Widget build(BuildContext context) => const Text('dashboard'); +} + +class _LayoutShell extends StatelessWidget { + const _LayoutShell({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => child; +} + +/// Minimal authenticated user for the AuthorizeMiddleware tests. +class _FakeUser extends Model with Authenticatable { + @override + String get table => 'users'; + @override + String get resource => 'users'; + @override + List get fillable => ['id', 'name']; +} + +_FakeUser _fakeUser() { + final user = _FakeUser(); + user.fill({'id': 1, 'name': 'Alice'}); + user.exists = true; + return user; +} diff --git a/test/ui/magic_view_controller_oninit_test.dart b/test/ui/magic_view_controller_oninit_test.dart new file mode 100644 index 0000000..905308b --- /dev/null +++ b/test/ui/magic_view_controller_oninit_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +/// A plain controller whose [onInit] is NOT called from its constructor (the +/// common case: data bootstrap lives in onInit and relies on the view calling +/// it). Mirrors the magic_example showcase controllers. +class _LazyController extends MagicController with MagicStateMixin { + int initCount = 0; + + @override + void onInit() { + super.onInit(); + initCount++; + } +} + +/// A controller that initializes itself in its constructor (the +/// [SimpleMagicController] shape). Mounting a view must NOT call onInit again. +class _EagerController extends MagicController with MagicStateMixin { + _EagerController() { + onInit(); + } + + int initCount = 0; + + @override + void onInit() { + super.onInit(); + initCount++; + } +} + +class _LazyView extends MagicStatefulView<_LazyController> { + const _LazyView(); + + @override + State<_LazyView> createState() => _LazyViewState(); +} + +class _LazyViewState + extends MagicStatefulViewState<_LazyController, _LazyView> { + @override + Widget build(BuildContext context) => const SizedBox(); +} + +class _EagerView extends MagicStatefulView<_EagerController> { + const _EagerView(); + + @override + State<_EagerView> createState() => _EagerViewState(); +} + +class _EagerViewState + extends MagicStatefulViewState<_EagerController, _EagerView> { + @override + Widget build(BuildContext context) => const SizedBox(); +} + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + }); + tearDown(Magic.flush); + + group('MagicStatefulViewState controller.onInit', () { + testWidgets('calls controller.onInit() once when not yet initialized', ( + tester, + ) async { + final controller = _LazyController(); + Magic.put<_LazyController>(controller); + + expect(controller.initialized, isFalse); + expect(controller.initCount, 0); + + await tester.pumpWidget(const MaterialApp(home: _LazyView())); + + expect( + controller.initialized, + isTrue, + reason: 'mounting the view must initialize its controller', + ); + expect(controller.initCount, 1); + }); + + testWidgets( + 'does not double-call onInit for a self-initializing controller', + (tester) async { + final controller = _EagerController(); + Magic.put<_EagerController>(controller); + + // Already initialized by its own constructor. + expect(controller.initialized, isTrue); + expect(controller.initCount, 1); + + await tester.pumpWidget(const MaterialApp(home: _EagerView())); + + // The view must NOT call onInit a second time. + expect( + controller.initCount, + 1, + reason: 'an already-initialized controller is not re-initialized', + ); + }, + ); + + testWidgets('remounting the view does not re-run controller.onInit', ( + tester, + ) async { + final controller = _LazyController(); + Magic.put<_LazyController>(controller); + + await tester.pumpWidget(const MaterialApp(home: _LazyView())); + expect(controller.initCount, 1); + + // Replace then restore the view to force a fresh State + initState. + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + await tester.pumpWidget(const MaterialApp(home: _LazyView())); + + expect( + controller.initCount, + 1, + reason: 'onInit runs once per controller lifetime, not per mount', + ); + }); + }); +}