diff --git a/CHANGELOG.md b/CHANGELOG.md index 82cc9f99..5e944c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,17 @@ All notable changes to this project will be documented in this file. ### BREAKING +- **`Auth.fake()`'s login/logout now dispatch `AuthLogin`/`AuthLogout` through the real `Event` facade, same as the real guard.** A test that registered an `Event.listen` listener and drove it through `Auth.fake()` used to see nothing; it now observes the same events a real session fires. A test asserting a listener's absence of a call across a faked login/logout now needs to account for it. (`lib/src/testing/fake_auth_manager.dart`, `doc/testing/facades.md`, `doc/security/authentication.md`) - **`state()` and `count()` return a copy, and every factory implements `Factory newFactory()`.** They used to mutate the factory and return it, so `final f = UserFactory(); f.state({...}); f.make()` carried the state, and two branches off one base leaked into each other. Dart cannot construct "the same subclass" on its own, so the new abstract hook answers with the subclass constructor (`Factory newFactory() => UserFactory();`). Named states move to an extension on `Factory`, since a method on the subclass is out of reach after the first `state()` or `count()`; the old `state({...}) as UserFactory` cast would now throw. No factory subclass exists in this package's `lib/`, `test/` or `example/`; the `make:factory` stub and `doc/database/seeding.md` show the new shape. (`lib/src/database/seeding/factory.dart`, `assets/stubs/factory.stub`, `doc/database/seeding.md`) - **`MagicTest.init()` resets the Gate, the Translator and the DateManager between tests.** It now calls `Gate.flush()` in `setUp` and `tearDown`, and `DateManager.reset()` plus `Translator.reset()` in `tearDown`, so an ability, a loaded catalogue or a locale no longer leaks into the next test. A suite under `MagicTest.init()` that defines abilities or loads translations in `setUpAll` loses them after the first test and must move that work to `setUp`. (`lib/src/testing/magic_test.dart`, `doc/testing/getting-started.md`) ### Added +- **`BaseGuard` dispatches `AuthLogin`/`AuthLogout` through the `Event` facade.** `startSession` dispatches `AuthLogin(user)` last and awaits its listeners, skipped when the session ended or was replaced while the user was being cached; `logout()` dispatches `AuthLogout(previous)` after the `stateNotifier` bump and before a rethrown Vault failure, for a guest (`null` user) too, meaning "the in-memory session ended" rather than "the credentials are gone" (gate server-side release on `Auth.hasToken()`). `AuthRestored` is unchanged: only an API-confirmed sync fires it. (`lib/src/auth/guards/base_guard.dart`, `doc/security/authentication.md`, `doc/digging-deeper/events.md`, `skills/magic-framework/`) +- **`Event.listen(MagicListener Function() factory)`, a registration shortcut on the `Event` facade.** Equivalent to `EventDispatcher.instance.register(T, [factory])`, so a listener can be registered without adding a mapping to `AppEventServiceProvider.listen`. `T` must be named explicitly; call it from a provider's `register()`, not `boot()`, since a guard can dispatch `AuthLogin` during `AuthServiceProvider.boot`. (`lib/src/facades/event.dart`, `doc/digging-deeper/events.md`, `skills/magic-framework/`) +- **`AuthChannelSubscription`, a reconciler for a private broadcast channel whose name depends on auth state.** `sync()` re-reads a caller-supplied `channelName()` on every call, serialised against overlapping calls, and is a no-op when the name has not changed, whatever the connection is doing (the Reverb driver recovers a drop on its own; resubscribing here would open a second socket). A name change leaves the old channel by its prefixed name, connects only when not already connected, then subscribes and wires `listeners`. `onReconnect` fires on both an `Echo.onReconnect` signal and a `connectionState` transition to `connected`. `dispose()` cancels only the reconnect-listening subscriptions. (`lib/src/broadcasting/auth_channel_subscription.dart`, `doc/digging-deeper/broadcasting.md`, `skills/magic-framework/`) +- **`Str.unwrap(value, before, [after])`, Laravel's `Str::unwrap` ported.** Strips `before` from the start and `after` (default `before`) from the end, each checked and stripped independently, so a prefix-only match (`'"x'`) loses the leading quote and stays unbalanced rather than being left alone. (`lib/src/support/str.dart`, `doc/digging-deeper/helpers.md`, `skills/magic-framework/`) +- **`CollapsesIndexedErrorKeys.collapse(wireKey)`, a static entry point for the mixin's own collapse.** For a controller that cannot mix in `CollapsesIndexedErrorKeys` (it already extends a different base) but still needs to collapse an indexed wire validation key (`items.0.name`) onto its field name (`name`). Same collapse `errorFieldFor` runs when the mixin is in place. (`lib/src/concerns/validates_requests.dart`, `doc/digging-deeper/validation.md`, `skills/magic-framework/`) - **`Model.unguarded()`, `Model.unguard()`, `Model.reguard()` and `Model.isUnguarded`, Laravel's mass-assignment switch.** Inside `Model.unguarded(() => ...)` every `fill()` keeps every key, whatever `fillable` and `guarded` say; the guard comes back when the callback returns or throws, and a nested call leaves the outer scope unguarded. The callback must be synchronous, since an async one would run its later `fill()` calls guarded, and an assertion says so, now checked whether or not the call nests inside an outer `unguard()`/`unguarded()` scope. Outside it, `fill()` behaves exactly as before, `strict: true` included; `fromMap()` and `setRawAttributes()` are untouched. (`lib/src/database/eloquent/model.dart`) - **`Factory.raw()`**, the merged definition and states without a model: always a `List>`, one map per model, a single map when no `count()` was set. (`lib/src/database/seeding/factory.dart`) - **`Carbon.setTestNow([testNow])` and `Carbon.hasTestNow()`, Laravel's frozen-clock testing helper.** `Carbon.now([timezone])` returns the frozen instant while one is set (timezone conversion still applies on top of it), `isToday()`, `isYesterday()`, `isTomorrow()`, `isFuture()`, `isPast()` and argument-less `diffForHumans()` measure against it, and `Carbon.setTestNow()` with no argument (or `null`) clears the freeze. A test that seeds an app's clock now has a Laravel-parity seam instead of threading a fake `DateTime` through every call site. (`lib/src/support/carbon.dart`, `doc/digging-deeper/carbon.md`, `skills/magic-framework/`) @@ -28,6 +34,8 @@ All notable changes to this project will be documented in this file. ### Fixed +- **`ReverbBroadcastDriver.connect()` is idempotent: a second call, or a call while a reconnect is armed or in flight, no longer opens a second socket.** A second `connect()` used to assign a fresh socket over the live one and leak it, and a retry timer armed by a drop, a failed retry or a connection timeout stayed armed beside it. `connect()` now returns when connected, joins the attempt already in flight (single-flight shared with the reconnect timer), and supersedes an armed retry: it cancels the timer and runs the same reconnect work now, resubscribing every channel and firing `onReconnect`; a failed superseding attempt re-arms the retry before it throws. `AuthChannelSubscription` goes back to a plain `isConnected` gate before `Echo.connect()` instead of inferring a pending reconnect from `connectionState`. (`lib/src/broadcasting/drivers/reverb_broadcast_driver.dart`, `lib/src/broadcasting/auth_channel_subscription.dart`, `doc/digging-deeper/broadcasting.md`, `skills/magic-framework/`) +- **`ReverbBroadcastDriver` reported `reconnecting` after a socket drop even with `reconnect: false`, so a consumer of `connectionState` was told a reconnect was coming when `_scheduleReconnect` had already returned without arming one.** `_onDone` and `_onError` now report `disconnected` instead when the `reconnect` config key is off, and still report `reconnecting` when it is on. (`lib/src/broadcasting/drivers/reverb_broadcast_driver.dart`) - **The `AbilityCallback` doc listed `bool callback(Model user)` as valid.** The gate always calls `callback(user, arguments)`, so that shape throws and the ability is denied; the doc now shows `(Model user, [dynamic arg])`. (`lib/src/auth/gate_manager.dart`) - **`env('KEY', fallback)` returned the literal two-character string `'""'`/`"''"` for `KEY=""`/`KEY=''`, not the default and not an empty string.** `flutter_dotenv`'s own parser needs at least one character inside the quotes to strip them, so a present-but-quote-only value passed through unquoted. `Env.get` now trims a value that is exactly `'""'` or `"''"` down to `''`, matching Laravel's `Env::get` (`laravel-framework/src/Illuminate/Support/Env.php:271`): an explicit empty value is `''`, never treated as absent. (`lib/src/foundation/env.dart`, `doc/getting-started/configuration.md`, `skills/magic-framework/`) diff --git a/doc/digging-deeper/broadcasting.md b/doc/digging-deeper/broadcasting.md index c853f656..0c22250a 100644 --- a/doc/digging-deeper/broadcasting.md +++ b/doc/digging-deeper/broadcasting.md @@ -27,6 +27,7 @@ Magic provides a Laravel Echo-equivalent broadcasting system for real-time WebSo - [Activity Monitor and Heartbeat](#activity-monitor-and-heartbeat) - [Connection Timeout](#connection-timeout) - [Deduplication](#deduplication) +- [Auth-Scoped Subscriptions with AuthChannelSubscription](#auth-scoped-subscriptions) - [Testing Broadcasting](#testing-broadcasting) @@ -137,7 +138,7 @@ The `Echo` facade provides static access to the broadcasting system, proxying al | `Echo.join(name)` | `BroadcastPresenceChannel` | Join a presence channel (auth + member tracking) | | `Echo.listen(channel, event, callback)` | `BroadcastChannel` | Shorthand: subscribe + listen in one call | | `Echo.leave(name)` | `void` | Unsubscribe from a channel | -| `Echo.connect()` | `Future` | Establish the WebSocket connection | +| `Echo.connect()` | `Future` | Establish the WebSocket connection; idempotent, never opens a second socket | | `Echo.disconnect()` | `Future` | Close the connection and release resources | | `Echo.connection` | `BroadcastDriver` | The resolved default driver instance | | `Echo.socketId` | `String?` | Server-assigned socket identifier, or `null` when disconnected | @@ -553,6 +554,8 @@ The `connection_timeout` config key (default: **15 seconds**) controls how long - A reconnect is scheduled (subject to backoff and the `reconnect` config flag). - A `TimeoutException` is thrown from `Echo.connect()` so callers can surface an error state. +`ReverbBroadcastDriver.connect()` is idempotent, so calling it again is always safe: it returns at once when already connected, joins an attempt already in flight (another `connect()` or a timer-driven retry), and when a reconnect is armed (after a drop, a failed retry, or this timeout) it cancels the armed retry and reconnects now, resubscribing every channel and firing `onReconnect` as the timer would. A second call never opens a second socket. + ```dart 'connections': { 'reverb': { @@ -570,6 +573,43 @@ The Reverb driver maintains a ring buffer of recently seen event fingerprints (c Configure the buffer size with `dedup_buffer_size` (default: `100`). A larger buffer consumes more memory but reduces false duplicate detection during high-throughput scenarios. + +## Auth-Scoped Subscriptions with AuthChannelSubscription + +`AuthChannelSubscription` keeps a single private channel subscription in sync with a caller-supplied name, re-read on every `sync()` call. It is the seam behind a channel whose name depends on the signed-in user or team: which channel is currently subscribed, leaving the old one and standing up the replacement when the name changes, and re-firing `onReconnect` after a connection drop so a caller can refetch whatever the socket missed while it was down (Reverb does not replay). + +```dart +late final subscription = AuthChannelSubscription( + channelName: () { + final teamId = Auth.user()?.teamId; + return teamId == null ? null : 'teams.$teamId'; + }, + listeners: { + 'incident.opened': (event) => refetchIncidents(), + }, + onReconnect: refetchIncidents, +); + +// Wire it to whatever changes the channel name, typically the guard's +// own state notifier: +Auth.stateNotifier.addListener(subscription.sync); + +// Reconcile once at startup too, since the listener only fires on a change. +subscription.sync(); +``` + +`sync()` is serialised: a call arriving while another is in flight defers and re-runs once more after the current one settles, rather than risking two live subscriptions across an await. It is a no-op when `channelName()` still answers the name it is already subscribed to, whatever the connection is doing at that moment: the Reverb driver recovers a drop and re-subscribes on its own. A name change leaves the old channel by its fully-qualified (prefixed) name, calls `Echo.connect()` when the connection is not live, then subscribes to the new channel and wires every entry of `listeners`. That connect is safe during a pending reconnect because the Reverb driver's `connect()` is idempotent (see [Connection Timeout](#connection-timeout)). + +`onReconnect` fires on both an `Echo.onReconnect` signal and a `connectionState` transition to `connected`, covering a driver that announces its own recovery as well as the same recovery observed independently. `dispose()` cancels only the reconnect-listening subscriptions; it does not leave the channel or disconnect, which stay live until the next `sync()` resolves a `null` channel name. A `null` channel name disconnects the whole default connection via `Echo.disconnect()`, not just this channel, dropping any other channel the app subscribed elsewhere through `Echo`: deliberate, since a signed-out app has no business staying on the socket. + +```dart +@override +void onClose() { + Auth.stateNotifier.removeListener(subscription.sync); + subscription.dispose(); +} +``` + ## Testing Broadcasting diff --git a/doc/digging-deeper/events.md b/doc/digging-deeper/events.md index c5cd544e..e3d5b60d 100644 --- a/doc/digging-deeper/events.md +++ b/doc/digging-deeper/events.md @@ -177,18 +177,23 @@ class OrderController extends MagicController { ## Inline Listeners -For simple event handling, you can register listeners inline using a closure instead of creating a dedicated listener class: +`Event.listen(factory)` registers a listener without adding a mapping to `AppEventServiceProvider.listen`. It takes a factory, `MagicListener Function()`, the same shape `listen` registers under the hood, not a bare closure: ```dart -Event.listen((event) { - Log.info('Order shipped: ${event.order.id}'); -}); +class LogOrderShipped extends MagicListener { + @override + Future handle(OrderShipped event) async { + Log.info('Order shipped: ${event.order.id}'); + } +} + +Event.listen(() => LogOrderShipped()); ``` -Inline listeners are useful for quick logging, metrics, or simple side effects. For more complex logic, use dedicated listener classes. +`T` is the registration key and must be named explicitly: leave it off and Dart infers `MagicEvent`, which no dispatched event matches exactly. > [!TIP] -> Register inline listeners in your `EventServiceProvider`'s `boot()` method to keep them organized alongside class-based listener registrations. +> Register `Event.listen` calls in your `EventServiceProvider`'s `register()` method, not `boot()`: a guard can dispatch `AuthLogin` during `AuthServiceProvider.boot`, before a later provider's `boot()` runs. Registrations last until `MagicApp.flush()`. ## Framework Events @@ -199,14 +204,22 @@ Magic fires several system events automatically. | Event | Fired When | |-------|------------| -| `AuthLogin` | User successfully logs in | -| `AuthLogout` | User logs out | -| `AuthFailed` | Authentication attempt fails | +| `AuthLogin` | A guard's `startSession` finishes: the token (when given) is persisted, the user is set and cached. Not fired on a restore. | +| `AuthLogout` | A guard's `logout()` ends the in-memory session, guest logout included. Not a promise the credentials are gone: it fires even when a Vault delete failed, so a listener releasing server-side state must gate on `Auth.hasToken()`. | +| `AuthRestored` | An API-confirmed sync (`BaseGuard`'s background `/user` fetch) sets the user; not fired for the cache-only step of `Auth.restore()`. | + +> [!NOTE] +> `AuthFailed` is defined, not dispatched by the guards: no code path in `lib/` fires it automatically. Dispatch it yourself from a failed login flow, e.g. `Event.dispatch(AuthFailed(credentials, guard: 'web'));` in the `catch` branch around your `Auth.login()` call. ```dart -Event.listen((event) { - Log.info('User logged in: ${event.user.email}'); -}); +class LogAuthLogin extends MagicListener { + @override + Future handle(AuthLogin event) async { + Log.info('User logged in: ${event.user.authIdentifier}'); + } +} + +Event.listen(() => LogAuthLogin()); ``` ### Model Lifecycle Events @@ -222,12 +235,17 @@ Event.listen((event) { | `ModelDeleted` | After model is deleted | ```dart -Event.listen((event) { - if (event.model is User) { - final user = event.model as User; - Log.info('New user registered: ${user.email}'); +class LogNewUser extends MagicListener { + @override + Future handle(ModelCreated event) async { + if (event.model is User) { + final user = event.model as User; + Log.info('New user registered: ${user.email}'); + } } -}); +} + +Event.listen(() => LogNewUser()); ``` ### Gate Events @@ -240,9 +258,14 @@ Event.listen((event) { ```dart // Log denied access attempts -Event.listen((event) { - Log.warning('Access denied: ${event.ability} for user ${event.user?.id}'); -}); +class LogDeniedAccess extends MagicListener { + @override + Future handle(GateAccessDenied event) async { + Log.warning('Access denied: ${event.ability} for user ${event.user?.id}'); + } +} + +Event.listen(() => LogDeniedAccess()); ``` ### Database Events diff --git a/doc/digging-deeper/helpers.md b/doc/digging-deeper/helpers.md index 1797ddee..23b3c9c5 100644 --- a/doc/digging-deeper/helpers.md +++ b/doc/digging-deeper/helpers.md @@ -27,6 +27,14 @@ Str.lower('IŞIK', locale: 'tr'); // 'ışık' Str.initials('ismail kaya', limit: 2, capitalize: true, locale: 'tr'); // 'İK' ``` +`Str.unwrap(value, before, [after])` strips `before` from the start and `after` (default `before`) from the end, each checked and stripped independently, mirroring Laravel's `Str::unwrap`. A prefix-only match (`'"x'`) loses the leading quote and is left unbalanced rather than untouched. + +```dart +Str.unwrap('"quoted"', '"'); // 'quoted' +Str.unwrap('"x', '"'); // 'x', prefix-only match still strips +Str.unwrap('[value]', '[', ']'); // 'value' +``` + ## Number diff --git a/doc/digging-deeper/validation.md b/doc/digging-deeper/validation.md index c92bc9af..8c933461 100644 --- a/doc/digging-deeper/validation.md +++ b/doc/digging-deeper/validation.md @@ -328,6 +328,12 @@ class ItemsController extends MagicController Two wire keys that collapse onto the same field (two failing elements of the same list) keep the FIRST message; the later one is dropped rather than overwriting it. A key addressing a distinct sub-key rather than a list element (`credentials.token`) is left whole, since a form with a separate error slot per sub-key needs each one kept. +A controller that already extends a different base and cannot mix in `CollapsesIndexedErrorKeys` still reaches the same collapse through the mixin's static helper, `CollapsesIndexedErrorKeys.collapse(wireKey)`: + +```dart +final field = CollapsesIndexedErrorKeys.collapse('items.0.name'); // 'name' +``` + ## Server-Side Validation diff --git a/doc/getting-started/service-providers.md b/doc/getting-started/service-providers.md index cb54a8d8..a23c0526 100644 --- a/doc/getting-started/service-providers.md +++ b/doc/getting-started/service-providers.md @@ -105,12 +105,10 @@ Future boot() async { // ✅ Safe to access any registered service final config = Config.get('payment'); final auth = Auth.instance; - - // Register event listeners - Event.listen((event) { - Log.info('User logged in: ${event.user.email}'); - }); - + + // Event listeners belong in register(), not here: the auth guard can + // dispatch AuthLogin during AuthServiceProvider.boot, before this runs. + // Perform async initialization await initializePaymentGateway(); } diff --git a/doc/security/authentication.md b/doc/security/authentication.md index ca47b21e..d15e8931 100644 --- a/doc/security/authentication.md +++ b/doc/security/authentication.md @@ -13,6 +13,7 @@ Magic provides a frontend-focused authentication system with secure token storag - [Auto Token Refresh](#auto-token-refresh) - [Protecting Routes](#protecting-routes) - [Login & Logout](#login--logout) +- [Auth Events](#auth-events) - [Testing](#testing) @@ -423,10 +424,30 @@ A sign-in or a sign-out that completes while the sync is in the air wins: a late If `userFactory` is not set on the guard, the cache load and API sync steps are skipped gracefully (no error is thrown). Set `userFactory` via `Auth.manager.setUserFactory()` (or pass it to `BaseGuard`'s constructor) during the boot phase to enable full session restore. + +## Auth Events + +`BaseGuard` (and `FakeAuthManager`'s fake guard) dispatch `MagicEvent`s through the `Event` facade; see [Events](../digging-deeper/events.md#framework-events) for `Event.listen`'s registration shape. + +- **`AuthLogin`** fires at the end of `startSession`, once the token (when given) is persisted and the user is set and cached. Not fired on a restore; a restored user announces itself through `AuthRestored` instead. Skipped when the session ended or was replaced while the user was being cached, so a sign-out racing a sign-in never gets a stray `AuthLogin` for the session it just ended. +- **`AuthLogout`** fires from `logout()`, after the `stateNotifier` bump and before a rethrown Vault failure. It carries the user held when the logout began (`null` for a guest), and a guest logout still dispatches. It means "the in-memory session ended", not "the credentials are gone": it fires even when a Vault delete failed, so a listener releasing server-side state must still gate on `Auth.hasToken()`. +- **`AuthRestored`** fires only after an API-confirmed sync (`BaseGuard`'s background `/user` fetch inside `restore()`), never for the cache-only step that runs first. A sign-in or sign-out racing that sync suppresses it the same way `AuthLogin` is suppressed. + +```dart +class LogAuthEvents extends MagicListener { + @override + Future handle(AuthLogout event) async { + Log.info('Session ended for ${event.user?.authIdentifier ?? 'guest'}'); + } +} + +Event.listen(() => LogAuthEvents()); +``` + ## Testing -Replace the real auth manager with a `FakeAuthManager` using `Auth.fake()`. The fake routes all guard operations through an in-memory guard so tests run without platform channels or a real backend. +Replace the real auth manager with a `FakeAuthManager` using `Auth.fake()`. The fake routes all guard operations through an in-memory guard so tests run without platform channels or a real backend. Its `login()` and `logout()` dispatch `AuthLogin`/`AuthLogout` through the real `Event` facade too, same as the real guard, so a listener under test observes a faked session exactly as it would a real one. ```dart import 'package:magic/testing.dart'; diff --git a/doc/testing/facades.md b/doc/testing/facades.md index 0efa6df3..0410a468 100644 --- a/doc/testing/facades.md +++ b/doc/testing/facades.md @@ -59,7 +59,7 @@ All fake types are exported from `package:magic/testing.dart`. Import them along ## Auth.fake() -`Auth.fake()` replaces the real `AuthManager` with a `FakeAuthManager` that routes all guard operations through an in-memory `_FakeGuard`. No platform channels, no secure storage, no token refresh calls. +`Auth.fake()` replaces the real `AuthManager` with a `FakeAuthManager` that routes all guard operations through an in-memory `_FakeGuard`. No platform channels, no secure storage, no token refresh calls. `login()` and `logout()` still dispatch `AuthLogin`/`AuthLogout` through the real `Event` facade, so a listener registered with `Event.listen` observes a faked session the same way it would a real one. **Signature:** diff --git a/lib/magic.dart b/lib/magic.dart index d1b4987a..a987a010 100644 --- a/lib/magic.dart +++ b/lib/magic.dart @@ -249,6 +249,7 @@ export 'src/cli/tinker_integration.dart'; // Broadcasting export 'config/broadcasting.dart'; +export 'src/broadcasting/auth_channel_subscription.dart'; export 'src/broadcasting/broadcast_connection_state.dart'; export 'src/broadcasting/broadcast_event.dart'; export 'src/broadcasting/broadcast_manager.dart'; diff --git a/lib/src/auth/events/auth_events.dart b/lib/src/auth/events/auth_events.dart index 5217bbfc..9a1379bc 100644 --- a/lib/src/auth/events/auth_events.dart +++ b/lib/src/auth/events/auth_events.dart @@ -2,6 +2,10 @@ import '../../events/magic_event.dart'; import '../authenticatable.dart'; /// Fired when a user successfully logs in. +/// +/// Dispatched at the end of `BaseGuard.startSession`, once the user is set and +/// cached. It does not fire on a restore; listen to `Auth.stateNotifier` for +/// "a user became known" on a cold boot. class AuthLogin extends MagicEvent { /// The user who logged in. final Authenticatable user; @@ -12,9 +16,13 @@ class AuthLogin extends MagicEvent { AuthLogin(this.user, {this.guard = 'web'}); } -/// Fired when a user logs out. +/// Fired when the in-memory session ended. +/// +/// Not a promise that the credentials are gone: it fires even when a vault +/// delete failed, so a listener releasing server-side state has to gate on +/// `Auth.hasToken()`. class AuthLogout extends MagicEvent { - /// The user who logged out. + /// The user held when the logout began, or null for a guest. final Authenticatable? user; /// The guard name used. diff --git a/lib/src/auth/guards/base_guard.dart b/lib/src/auth/guards/base_guard.dart index 7aa3954a..b3c0e54f 100644 --- a/lib/src/auth/guards/base_guard.dart +++ b/lib/src/auth/guards/base_guard.dart @@ -141,18 +141,45 @@ abstract class BaseGuard implements Guard { /// in-memory token, a late 401 logged out and deleted the token the sign-in /// had just written (0.0.18). Use this from [login] rather than /// [storeToken] followed by [setUser]. + /// + /// Dispatches [AuthLogin] last, and awaits its listeners (a throwing one is + /// logged by the dispatcher, never rethrown here), so a listener doing slow + /// work should not await it. Not dispatched when the session ended or was + /// replaced while the user was being cached, nor when a logout that was + /// already mid-[clearTokens] when this login began finishes and clears + /// [_user] before this check runs. @protected Future startSession( Authenticatable user, { String? token, String? refreshToken, }) async { - _sessionEpoch++; + final epoch = ++_sessionEpoch; if (token != null) await _holdThenPersist(token, refreshToken); setUser(user); await cacheUser(user); + + // The cache write is an await, and a sign-out or another sign-in can begin + // inside it. Neither should hear `AuthLogin` for this session after it has + // ended, and a sign-out that cleared the cache before this write landed + // would find the user back on disk. The same guard as the boot sync's. + // + // The epoch alone misses one case: a [logout] already inside its own + // Vault deletes when this sign-in began. It bumped the epoch before this + // login did, so this login's epoch is still the newest, but its + // `_user = null` (see [logout]) can still land between `setUser` above + // and this check, after the cached-user write yields. Comparing identity + // catches that: a session this login no longer owns, even with a + // matching epoch, no longer holds the user it just set. + if (_sessionEpoch != epoch || !identical(_user, user)) { + if (cachedToken == null) await clearUserCache(); + + return; + } + + await Event.dispatch(AuthLogin(user)); } /// Move the in-memory token to [token], then persist it and [refreshToken]. @@ -333,9 +360,18 @@ abstract class BaseGuard implements Guard { /// /// [_sessionEpoch] moves first, before any await, so a boot sync answering /// while the deletes run is not applied to a session that is ending. + /// + /// [AuthLogout] means "the in-memory session ended", not "the credentials + /// are gone": it follows the notifier bump even when a delete failed, so + /// listeners never disagree with [stateNotifier], and it goes out before the + /// rethrow. A listener releasing server-side state still has to gate on + /// [hasToken]. It carries the user held when the logout began (Laravel's + /// `SessionGuard::logout` takes it at entry the same way), which is null for + /// a guest, and a guest logout still dispatches. @override Future logout() async { _sessionEpoch++; + final previous = _user; Object? failure; StackTrace? failureStack; @@ -357,6 +393,8 @@ abstract class BaseGuard implements Guard { _user = null; stateNotifier.value++; + await Event.dispatch(AuthLogout(previous)); + if (failure != null) Error.throwWithStackTrace(failure, failureStack!); } diff --git a/lib/src/broadcasting/auth_channel_subscription.dart b/lib/src/broadcasting/auth_channel_subscription.dart new file mode 100644 index 00000000..2e31b9a6 --- /dev/null +++ b/lib/src/broadcasting/auth_channel_subscription.dart @@ -0,0 +1,257 @@ +import 'dart:async'; + +import '../facades/echo.dart'; +import '../facades/log.dart'; +import 'broadcast_connection_state.dart'; +import 'broadcast_event.dart'; +import 'contracts/broadcast_channel.dart'; + +/// Keeps a single private broadcast channel subscription in sync with a +/// caller-supplied channel name, re-read on every [sync] call. +/// +/// This is the seam behind an auth-scoped realtime channel: which channel is +/// currently subscribed, leaving the old one and standing up the replacement +/// when the name changes, and re-firing [onReconnect] after a connection +/// drop so a caller can refetch whatever the socket missed while it was down +/// (Reverb does not replay). Event routing, reload targets, and any +/// coalescing/debounce stay with the caller: this class only ever forwards a +/// raw [BroadcastEvent] to the listener registered for its event name. +/// +/// The caller decides when to call [sync], typically wired to an auth state +/// notifier: +/// +/// ```dart +/// final subscription = AuthChannelSubscription( +/// channelName: () => currentTeamId == null ? null : 'teams.$currentTeamId', +/// listeners: {'incident.opened': onIncidentEvent}, +/// onReconnect: refetchAll, +/// ); +/// Auth.stateNotifier.addListener(subscription.sync); +/// ``` +/// +/// This class does not read `Auth` (or any other auth source) itself: the +/// caller supplies both the current name and the trigger to re-check it. +/// +/// One Reverb reconnect can surface as both an `Echo.onReconnect` signal and +/// a `connectionState` transition to `connected`, since the driver announces +/// its own recovery and the connection stream reports the same recovery +/// independently. A single reconnect can therefore call [onReconnect] twice; +/// a caller doing a refetch on it should coalesce (e.g. drop a call already +/// in flight) rather than assume one call per drop. +/// +/// A `null` [channelName] tears the subscription down by calling +/// `Echo.disconnect()` on the default connection, which drops every channel +/// the app subscribed elsewhere through `Echo`, not just this one. That is +/// deliberate (a signed-out app has no business staying on the socket) but +/// worth knowing before wiring a second, unrelated `AuthChannelSubscription` +/// or a raw `Echo` channel onto the same connection. +class AuthChannelSubscription { + /// Creates a subscription reconciler. + /// + /// [channelName] is re-invoked on every [sync]; returning `null` means "no + /// channel right now" and tears down any live subscription. [listeners] + /// maps an event name to the callback [sync] registers on every + /// (re)subscribe. [onReconnect], when given, fires after a connection drop + /// and after an explicit `Echo.onReconnect` signal, so a caller can refetch + /// whatever a replay-less socket missed while it was down. + AuthChannelSubscription({ + required this.channelName, + required this.listeners, + this.onReconnect, + }); + + /// Re-read on every [sync]; `null` means no channel should be subscribed. + final String? Function() channelName; + + /// The event handlers registered on the channel on every (re)subscribe. + final Map listeners; + + /// Invoked after a reconnect (either an `Echo.onReconnect` signal or a + /// `connectionState` transition to `connected`), so a caller can refetch + /// what the socket missed while it was down. + final void Function()? onReconnect; + + /// The channel name last reconciled onto, or `null` when not subscribed. + /// + /// Compared against a fresh [channelName] call on each [sync] to detect a + /// change; also doubles as the subscribed marker for [_teardown]'s no-op + /// guard. + String? _subscribedName; + + /// The subscribed [BroadcastChannel], retained so it can be left by its own + /// fully-qualified (prefixed) name. + BroadcastChannel? _channel; + + /// Whether a [sync] is currently running; serializes overlapping calls. + bool _syncing = false; + + /// Set when [sync] is called while another is in flight, so the running + /// call re-runs once more afterwards and settles on the latest + /// [channelName]. + bool _resyncRequested = false; + + /// The `Echo.onReconnect` subscription, or `null` when not subscribed. + StreamSubscription? _reconnectSubscription; + + /// The `Echo.connectionState` subscription, or `null` when not subscribed. + StreamSubscription? _connectionSubscription; + + // --------------------------------------------------------------------------- + // Sync + // --------------------------------------------------------------------------- + + /// Reconciles the subscription against the current [channelName]. + /// + /// Serialised: a call arriving while another is in flight defers and runs + /// once more after the current one finishes, rather than running + /// concurrently and risking two live subscriptions across the same await + /// point. Safe to call repeatedly, e.g. wired directly as a `ChangeNotifier` + /// listener. + Future sync() async { + if (_syncing) { + _resyncRequested = true; + return; + } + _syncing = true; + try { + do { + _resyncRequested = false; + try { + await _reconcile(); + } catch (error, stackTrace) { + // A caller typically wires `sync` straight to a `ChangeNotifier` + // listener (e.g. `Auth.stateNotifier`), so a rethrow here would + // escape into the notifier's dispatch rather than the caller's own + // error handling. Logging keeps the listener alive for the next + // state change. The catch sits inside the loop, not around it, so + // a `sync` requested while this pass was failing still gets its + // one re-run below, the same as a request during a successful pass. + Log.error( + '[AuthChannelSubscription] sync failed: $error\n$stackTrace', + ); + } + } while (_resyncRequested); + } finally { + _syncing = false; + } + } + + /// The single-flight body of [sync]; assumes [sync]'s latch serialises it, + /// so it never runs concurrently with itself. + Future _reconcile() async { + final String? name = channelName(); + + // 1. No channel to subscribe to: tear down any live subscription. + if (name == null) { + await _teardown(); + return; + } + + // 2. Already on this channel: nothing to do, whatever the connection is + // doing. The marker is only set after a subscribe succeeded, and a drop + // after that is the driver's to recover (Reverb reconnects and + // re-subscribes every channel it knows on its own), so a resubscribe + // here would only repeat that work. + if (_subscribedName == name) { + return; + } + + // 3. The name changed (or first subscribe): leave the old channel and + // clear the marker BEFORE the first await, so a failed `Echo.connect()` + // leaves a clean state the next sync retries rather than a stale marker + // pointing at a channel that is not actually subscribed. + _leaveCurrentChannel(); + _subscribedName = null; + // Whether a reconnect is already pending is the driver's to know, not + // something to infer here from its state stream: the Reverb driver's + // `connect()` is idempotent (it returns when connected, joins an attempt + // in flight, and supersedes an armed retry), so this call never opens a + // second socket. The `isConnected` check only saves the round trip. + if (!Echo.connection.isConnected) { + await Echo.connect(); + } + + final BroadcastChannel channel = Echo.private(name); + for (final MapEntry entry + in listeners.entries) { + channel.listen(entry.key, entry.value); + } + _channel = channel; + _subscribedName = name; + + // 4. Wire the connect-time refetch so a reconnect closes the replay gap. + _listenForReconnect(); + } + + /// Tears down the live subscription, connection, and reconnect listeners. + /// + /// Disconnects the whole default connection (`Echo.disconnect()`), not + /// just this channel: any other channel the app subscribed through `Echo` + /// on the same connection is dropped too. Deliberate, since a signed-out + /// app has no business staying on the socket. + /// + /// A no-op when never subscribed, so a repeated `null` [channelName] stays + /// idempotent. + Future _teardown() async { + if (_subscribedName == null) { + return; + } + _leaveCurrentChannel(); + _cancelReconnectListeners(); + _subscribedName = null; + await Echo.disconnect(); + } + + /// Leaves the current channel by its fully-qualified (prefixed) name. + void _leaveCurrentChannel() { + final BroadcastChannel? channel = _channel; + if (channel == null) { + return; + } + Echo.leave(channel.name); + _channel = null; + } + + // --------------------------------------------------------------------------- + // Reconnect refetch + // --------------------------------------------------------------------------- + + /// Subscribes to both reconnect signals, firing [onReconnect] on each. + /// + /// Two signals rather than one: `Echo.onReconnect` covers a driver that + /// announces its own recovery, and the `connectionState` transition to + /// `connected` covers the same recovery observed independently, since + /// Reverb re-subscribes channels on reconnect silently and replays nothing. + void _listenForReconnect() { + _cancelReconnectListeners(); + _reconnectSubscription = Echo.onReconnect.listen( + (_) => onReconnect?.call(), + ); + _connectionSubscription = Echo.connectionState + .where( + (BroadcastConnectionState state) => + state == BroadcastConnectionState.connected, + ) + .listen((_) => onReconnect?.call()); + } + + /// Cancels the reconnect + connection-state subscriptions, if any. + void _cancelReconnectListeners() { + _reconnectSubscription?.cancel(); + _reconnectSubscription = null; + _connectionSubscription?.cancel(); + _connectionSubscription = null; + } + + // --------------------------------------------------------------------------- + // Disposal + // --------------------------------------------------------------------------- + + /// Releases the reconnect stream subscriptions. + /// + /// Idempotent. Does not touch the channel or connection, which stay live + /// until the next [sync] resolves a `null` [channelName]. + void dispose() { + _cancelReconnectListeners(); + } +} diff --git a/lib/src/broadcasting/drivers/reverb_broadcast_driver.dart b/lib/src/broadcasting/drivers/reverb_broadcast_driver.dart index 615533be..a1ad2989 100644 --- a/lib/src/broadcasting/drivers/reverb_broadcast_driver.dart +++ b/lib/src/broadcasting/drivers/reverb_broadcast_driver.dart @@ -172,6 +172,17 @@ class ReverbBroadcastDriver implements BroadcastDriver { Timer? _reconnectTimer; int _attempt = 0; + /// The socket-opening attempt currently in flight, or `null` when none is. + /// + /// Shared by the public [connect] and the reconnect timer, so any caller + /// arriving while an attempt runs joins it instead of opening a second + /// socket beside it. + Future? _inFlight; + + /// Bumped by [disconnect] so an attempt still suspended across an await + /// can tell it was cancelled and stop before touching a socket. + int _epoch = 0; + // --------------------------------------------------------------------------- // Activity monitor // --------------------------------------------------------------------------- @@ -206,10 +217,86 @@ class ReverbBroadcastDriver implements BroadcastDriver { // BroadcastDriver — connection lifecycle // --------------------------------------------------------------------------- + /// Connects to the Reverb server. Idempotent: the driver never holds more + /// than one socket, however many callers ask. + /// + /// Returns at once when already connected, and joins the attempt already + /// in flight (a concurrent [connect] or a timer-driven retry) instead of + /// starting another. The connection state stream alone cannot tell a + /// caller whether a retry is pending (a failed timer retry re-arms without + /// emitting anything), so the guard has to live here, where the retry + /// state is. + /// + /// When a reconnect timer is armed, the call supersedes it: the timer is + /// cancelled and the same reconnect work runs now, so every known channel + /// is resubscribed and [onReconnect] fires as it would for a timer retry. + /// Superseding rather than joining is deliberate: joining would park the + /// caller for the rest of the backoff (up to `max_reconnect_delay`), while + /// an explicit connect asks for a connection now. A superseding attempt + /// that fails re-arms the retry before it throws, so it never ends the + /// reconnect loop it replaced. + /// + /// @throws TimeoutException when the handshake does not arrive within + /// `connection_timeout` seconds; a retry is armed before the throw. + /// @throws StateError when the server closes the socket before the + /// handshake. @override - Future connect() async { + Future connect() { + if (_isConnected) return Future.value(); + + final inFlight = _inFlight; + if (inFlight != null) return inFlight; + + final retryTimer = _reconnectTimer; + if (retryTimer != null && retryTimer.isActive) { + retryTimer.cancel(); + _reconnectTimer = null; + return _singleFlight(_reconnect); + } + + return _singleFlight(_openSocket); + } + + /// Runs [attempt] as the one socket attempt in flight, published through + /// [_inFlight] so every other [connect] and the reconnect timer join it. + /// + /// [_inFlight] is cleared before the returned future completes, so a + /// caller reacting to the outcome with another [connect] starts a fresh + /// attempt instead of joining the finished one. + Future _singleFlight(Future Function() attempt) { + final completer = Completer(); + final flight = completer.future; + _inFlight = flight; + + attempt().then( + (_) { + if (identical(_inFlight, flight)) _inFlight = null; + completer.complete(); + }, + onError: (Object error, StackTrace stackTrace) { + if (identical(_inFlight, flight)) _inFlight = null; + completer.completeError(error, stackTrace); + }, + ); + + return flight; + } + + /// Opens a fresh socket and waits for the Pusher handshake. + /// + /// The only place a socket is created. Both [connect] and the reconnect + /// timer reach it through [_singleFlight], which is what bounds the driver + /// to one socket. Any previous socket (left by a drop or a failed + /// handshake) is released first, so it is never leaked beside the new one. + /// Returns without opening anything when [disconnect] runs while this is + /// suspended. + Future _openSocket() async { + final epoch = _epoch; _connectionStateController.add(BroadcastConnectionState.connecting); + await _releaseSocket(); + if (epoch != _epoch) return; + final host = _config['host'] as String; final port = _config['port'] as int; final scheme = _config['scheme'] as String; @@ -220,14 +307,18 @@ class ReverbBroadcastDriver implements BroadcastDriver { '?protocol=7&client=dart&version=1.0.0', ); - _channel = _channelFactory(uri); - await _channel!.ready; + final channel = _channelFactory(uri); + _channel = channel; + await channel.ready; + // disconnect() already closed this socket; a later connect() may own + // `_channel` by now, so leave every field alone. + if (epoch != _epoch) return; _connectionCompleter = Completer(); // Wrap the single-subscription stream as a broadcast stream. _broadcastStreamController = StreamController.broadcast(); - _streamSubscription = _channel!.stream.listen( + _streamSubscription = channel.stream.listen( _broadcastStreamController!.add, onDone: () { _broadcastStreamController?.close(); @@ -265,7 +356,7 @@ class ReverbBroadcastDriver implements BroadcastDriver { return _connectionCompleter!.future.timeout( Duration(seconds: timeout), onTimeout: () { - _channel?.sink.close(); + channel.sink.close(); _connectionCompleter = null; _scheduleReconnect(); throw TimeoutException( @@ -277,6 +368,11 @@ class ReverbBroadcastDriver implements BroadcastDriver { @override Future disconnect() async { + // Cancel any attempt still in flight: it sees the new epoch at its next + // await and stops, and a connect() after this starts fresh instead of + // joining it. + _epoch++; + _inFlight = null; _reconnectTimer?.cancel(); _reconnectTimer = null; _cancelActivityTimers(); @@ -692,7 +788,7 @@ class ReverbBroadcastDriver implements BroadcastDriver { if (!_isConnected) return; _isConnected = false; _socketId = null; - _connectionStateController.add(BroadcastConnectionState.reconnecting); + _connectionStateController.add(_dropConnectionState()); _scheduleReconnect(); } @@ -711,10 +807,23 @@ class ReverbBroadcastDriver implements BroadcastDriver { if (!_isConnected) return; _isConnected = false; _socketId = null; - _connectionStateController.add(BroadcastConnectionState.reconnecting); + _connectionStateController.add(_dropConnectionState()); _scheduleReconnect(); } + /// The state to report after a socket drop. + /// + /// `reconnecting` when [_scheduleReconnect] will actually attempt one, + /// `disconnected` when the `reconnect` config key disables it, so a + /// consumer of [connectionState] is never told a reconnect is coming when + /// none is armed. Reads the same key [_scheduleReconnect] reads. + BroadcastConnectionState _dropConnectionState() { + final shouldReconnect = _config['reconnect'] as bool? ?? true; + return shouldReconnect + ? BroadcastConnectionState.reconnecting + : BroadcastConnectionState.disconnected; + } + void _scheduleReconnect({bool immediate = false}) { final shouldReconnect = _config['reconnect'] as bool? ?? true; if (!shouldReconnect) return; @@ -725,52 +834,89 @@ class ReverbBroadcastDriver implements BroadcastDriver { final delay = immediate ? Duration.zero : backoffDelay(_attempt); _attempt++; - _reconnectTimer = Timer(delay, () async { - try { - _streamSubscription?.cancel(); - _streamSubscription = null; - _broadcastStreamController?.close(); - _broadcastStreamController = null; - try { - await _channel?.sink.close(); - } catch (_) {} - _channel = null; - _isConnected = false; - - await connect(); - - // Resubscribe all channels. Snapshot keys to avoid concurrent - // modification if a handler modifies _channels during iteration. - for (final name in _channels.keys.toList()) { - // Skip channels that were left after the snapshot was taken. - if (!_channels.containsKey(name)) continue; - - if (name.startsWith('presence-') || name.startsWith('private-')) { - try { - await _authenticateAndSubscribe(name); - } catch (_) { - // Per-channel failure — continue to next channel. - // Auth errors are already logged in _authenticateAndSubscribe. - } - } else { - _send({ - 'event': 'pusher:subscribe', - 'data': {'channel': name}, - }); + _reconnectTimer = Timer(delay, () { + _reconnectTimer = null; + + // An attempt already in flight (a public connect()) owns the socket; + // opening another beside it is exactly what the single-flight forbids. + if (_inFlight != null) return; + + // _reconnect already routed the failure through the interceptors, + // logged it and re-armed this timer; nobody awaits a timer retry. + _singleFlight(_reconnect).ignore(); + }); + } + + /// One reconnect: reopens the socket, resubscribes every known channel, + /// then emits [onReconnect]. + /// + /// Run by the reconnect timer and by a [connect] that supersedes it, so a + /// caller-driven reconnect behaves exactly like a timer-driven one. A + /// failure is routed through the interceptors, logged, and re-arms the + /// retry before it is rethrown to whoever started the attempt. + Future _reconnect() async { + final epoch = _epoch; + try { + await _openSocket(); + if (epoch != _epoch) return; + + // Resubscribe all channels. Snapshot keys to avoid concurrent + // modification if a handler modifies _channels during iteration. + for (final name in _channels.keys.toList()) { + // Skip channels that were left after the snapshot was taken. + if (!_channels.containsKey(name)) continue; + + if (name.startsWith('presence-') || name.startsWith('private-')) { + try { + await _authenticateAndSubscribe(name); + } catch (_) { + // Per-channel failure: continue to next channel. + // Auth errors are already logged in _authenticateAndSubscribe. } + } else { + _send({ + 'event': 'pusher:subscribe', + 'data': {'channel': name}, + }); } + } - _onReconnectController.add(null); - } catch (error) { - // Route through interceptor chain before scheduling retry. - dynamic processed = error; - for (final interceptor in _interceptors) { - processed = interceptor.onError(processed); - } - Log.error('Reconnect failed', error); - _scheduleReconnect(); + _onReconnectController.add(null); + } catch (error) { + // Route through interceptor chain before scheduling retry. + dynamic processed = error; + for (final interceptor in _interceptors) { + processed = interceptor.onError(processed); } - }); + Log.error('Reconnect failed', error); + // A disconnect() while this attempt was suspended ended the loop. + if (epoch == _epoch) _scheduleReconnect(); + rethrow; + } + } + + /// Drops the current socket and its stream plumbing, if any. + /// + /// The stream subscription is cancelled before the sink closes, so the + /// released socket's own close never reaches [_onDone] and schedules a + /// reconnect of its own. + Future _releaseSocket() async { + _streamSubscription?.cancel(); + _streamSubscription = null; + _broadcastStreamController?.close(); + _broadcastStreamController = null; + + final channel = _channel; + _channel = null; + _isConnected = false; + if (channel == null) return; + + try { + await channel.sink.close(); + } catch (_) { + // The socket is being discarded either way; a sink that is already + // dead may throw on close, and that says nothing about the next one. + } } // --------------------------------------------------------------------------- diff --git a/lib/src/concerns/validates_requests.dart b/lib/src/concerns/validates_requests.dart index 61d45bfe..431ebd09 100644 --- a/lib/src/concerns/validates_requests.dart +++ b/lib/src/concerns/validates_requests.dart @@ -392,6 +392,11 @@ mixin ValidatesRequests on MagicController implements HasValidationErrors { mixin CollapsesIndexedErrorKeys on ValidatesRequests { @override String errorFieldFor(String wireKey) => _collapseIndexedKey(wireKey); + + /// Collapses [wireKey] the same way [errorFieldFor] does, for a controller + /// that cannot mix in [CollapsesIndexedErrorKeys] (e.g. it already extends + /// a different base) but still needs the collapse. + static String collapse(String wireKey) => _collapseIndexedKey(wireKey); } /// Collapses a wire validation key onto the form field it should report on. diff --git a/lib/src/facades/event.dart b/lib/src/facades/event.dart index 232bdbf7..7596e989 100644 --- a/lib/src/facades/event.dart +++ b/lib/src/facades/event.dart @@ -1,5 +1,6 @@ import '../events/magic_event.dart'; import '../events/event_dispatcher.dart'; +import '../events/magic_listener.dart'; /// The Event Facade. /// @@ -26,4 +27,19 @@ class Event { // However, EventServiceProvider registers it as 'events'. return EventDispatcher.instance.dispatch(event); } + + /// Register a listener for events of type [T], as Laravel's `Event::listen`. + /// + /// [T] is the registration key, so it has to be named: without it Dart + /// infers [MagicEvent], and no dispatched event matches that exactly. + /// Register from a provider's `register()`, not `boot()`: the auth guard + /// can dispatch during `AuthServiceProvider.boot`, before a later provider + /// boots. Registrations last until `MagicApp.flush()`. + /// + /// ```dart + /// Event.listen(() => TrackSignIn()); + /// ``` + static void listen(MagicListener Function() factory) { + EventDispatcher.instance.register(T, [factory]); + } } diff --git a/lib/src/support/str.dart b/lib/src/support/str.dart index cc8e24c2..e4295b6c 100644 --- a/lib/src/support/str.dart +++ b/lib/src/support/str.dart @@ -78,4 +78,26 @@ abstract final class Str { return capitalize ? upper(result, locale: locale) : result; } + + /// Strips [before] from the start of [value] and [after] (default + /// [before]) from its end, each independently. + /// + /// Mirrors Laravel's `Str::unwrap`: a prefix match and a suffix match are + /// each checked and stripped on their own, so `'"x'` (prefix only) loses + /// the leading quote and keeps the string unbalanced rather than being + /// left alone. + static String unwrap(String value, String before, [String? after]) { + final String suffix = after ?? before; + String result = value; + + if (result.startsWith(before)) { + result = result.substring(before.length); + } + + if (result.endsWith(suffix)) { + result = result.substring(0, result.length - suffix.length); + } + + return result; + } } diff --git a/lib/src/testing/fake_auth_manager.dart b/lib/src/testing/fake_auth_manager.dart index 35b3c60d..a87adde8 100644 --- a/lib/src/testing/fake_auth_manager.dart +++ b/lib/src/testing/fake_auth_manager.dart @@ -3,7 +3,9 @@ import 'package:flutter/foundation.dart'; import '../auth/auth_manager.dart'; import '../auth/authenticatable.dart'; import '../auth/contracts/guard.dart'; +import '../auth/events/auth_events.dart'; import '../database/eloquent/model.dart'; +import '../facades/event.dart'; /// A fake [AuthManager] for testing. /// @@ -134,13 +136,18 @@ class _FakeGuard implements Guard { _token = data['token'] as String?; _loginAttempts.add(data); stateNotifier.value++; + + await Event.dispatch(AuthLogin(user)); } @override Future logout() async { + final previous = _user; _user = null; _token = null; stateNotifier.value++; + + await Event.dispatch(AuthLogout(previous)); } @override diff --git a/skills/magic-framework/SKILL.md b/skills/magic-framework/SKILL.md index 4f1dae21..f1bfd239 100644 --- a/skills/magic-framework/SKILL.md +++ b/skills/magic-framework/SKILL.md @@ -2,10 +2,10 @@ name: magic-framework description: "Write correct, idiomatic code in a Flutter app that depends on the `magic` framework (Laravel-inspired: IoC container, 18 facades, Eloquent-style ORM, service providers, reactive controllers, GoRouter routing, validation, auth, broadcasting). Use whenever code imports `package:magic/magic.dart` or `package:magic/testing.dart`, or the work touches Magic.init, MagicApp, a facade (Auth/Http/Cache/DB/Echo/Event/Gate/Config/Lang/Launch/Log/Pick/MagicRoute/Schema/Session/Storage/Vault/Crypt), a Model, MagicController, a MagicView, MagicFormData, FormRequest, a ServiceProvider, a migration, or the artisan make:* CLI. UI styling is Wind (separate wind-ui skill). Do NOT use for plain Flutter or Wind-only work with no magic import." when_to_use: "Use proactively when editing or scaffolding a magic app: Magic.init / a facade / a Model / a MagicController or MagicView / a form (MagicFormData, FormRequest, Validator) / a ServiceProvider / a route or MagicMiddleware / a migration / MagicStateMixin + RxStatus + fetchList / Session flash + old() + trans() / testing with MagicTest + Http.fake/Auth.fake / the artisan make:* CLI / the magic_deeplink, magic_notifications, magic_social_auth, magic_starter, magic_payments, or magic_devtools plugins. Trigger even when the user does not say the word 'magic'. Do NOT trigger for plain Flutter or Wind-only UI with no package:magic import." -version: 0.1.47 +version: 0.1.48 --- - + # Magic Framework @@ -102,7 +102,7 @@ The five assumptions a Laravel developer gets wrong most: (1) the container auto | `DB` | (lazy) | `table(name)` (query builder), `select/statement/insert/update/delete` (raw SQL), `transaction(cb)`, `beginTransaction/commit/rollback` | | `Schema` | (manager) | `create(table, (b){})`, `table`, `drop`, `dropIfExists`, `hasTable`, `hasColumn`, `getColumns`, `rename` | | `Log` | `log` | `info/error/warning/debug/notice/critical/alert/emergency`, `log(level, msg)`, `channel(name)`, `fake()` | -| `Event` | (dispatcher) | `dispatch(MagicEvent)`; register listeners with `EventDispatcher.register(Type, [() => Listener()])` | +| `Event` | (dispatcher) | `dispatch(MagicEvent)`; `listen(() => Listener())` (named `T`, register in `register()` not `boot()`); or `EventDispatcher.register(Type, [() => Listener()])` directly | | `Echo` | `broadcasting` | `channel/private/join`, `listen`, `leave`, `connect/disconnect`, `socketId`, `connectionState`, `onReconnect`, `addInterceptor`, `manager`, `fake()` | | `MagicRoute` | (router) | `page`, `group`, `layout`, `resource(name, ctrl, {only, except})`, `to`, `toNamed`, `push`, `back({fallback})`, `replace`, `setTitle`, `currentTitle`, `config` | | `Gate` | (manager) | `define`, `before`, `allows`, `denies`, `allowsAny(list)`, `allowsAll(list)`, `has`, `abilities`, `flush` | @@ -251,7 +251,9 @@ A controller with `ValidatesRequests` should call `validateRequest`/`validateReq final payload = validateRequest(StoreUserRequest(), form.data); // throws Authorization/ValidationException, populates validationErrors ``` -Mix `CollapsesIndexedErrorKeys` on top of `ValidatesRequests` to collapse a backend's indexed list-validation key (`items.0.name`) onto its field name (`name`) for a form with one error slot per field, not per element. +Mix `CollapsesIndexedErrorKeys` on top of `ValidatesRequests` to collapse a backend's indexed list-validation key (`items.0.name`) onto its field name (`name`) for a form with one error slot per field, not per element; `CollapsesIndexedErrorKeys.collapse(wireKey)` (static) runs the same collapse for a controller that cannot mix it in. + +`BaseGuard.startSession`/`logout()` (and `Auth.fake()`'s fake guard) dispatch `AuthLogin`/`AuthLogout` through `Event`: `AuthLogin` at the end of a successful `startSession`, `AuthLogout` after the state bump on every `logout()` including a guest's, never on a restore (`AuthRestored` covers that, API-confirmed sync only). `AuthChannelSubscription(channelName:, listeners:, onReconnect:)` reconciles a private broadcast channel whose name tracks auth state (wire `sync` to `Auth.stateNotifier`); see `doc/digging-deeper/broadcasting.md#auth-scoped-subscriptions`. ### Support helpers @@ -260,6 +262,7 @@ Mix `CollapsesIndexedErrorKeys` on top of `ValidatesRequests` to collapse a back ```dart Number.currency(1234.5, code: 'TRY', locale: 'tr'); // '₺1.234,50' Str.upper('istanbul', locale: 'tr'); // 'İSTANBUL', dotted-i aware +Str.unwrap('"x', '"'); // 'x', prefix-only match still strips Cast.intOr(Arr.get(payload, 'meta.priority'), 0); // Arr does no type check; compose with Cast ``` diff --git a/skills/magic-framework/references/auth-system.md b/skills/magic-framework/references/auth-system.md index b53aa3f4..9a6546b8 100644 --- a/skills/magic-framework/references/auth-system.md +++ b/skills/magic-framework/references/auth-system.md @@ -577,29 +577,33 @@ class User extends Model with Authenticatable { ## Auth Events -The auth system dispatches events on state changes: +`BaseGuard` (and `Auth.fake()`'s fake guard) dispatch these through `Event`: | Event | Fired | Data | |:------|:------|:-----| -| `AuthRestored` | After successful session restore | `user` | -| `AuthLoginAttempted` | When login is attempted | (none) | -| `AuthLogoutAttempted` | When logout is requested | (none) | +| `AuthLogin` | End of a successful `startSession` (token persisted, user set + cached). Not fired on a restore. Skipped when the session ended or was replaced while the user was being cached. | `user`, `guard` | +| `AuthLogout` | Every `logout()`, guest included, after the `stateNotifier` bump and before a rethrown Vault failure. Means "the in-memory session ended", not "the credentials are gone"; gate server-side release on `Auth.hasToken()`. | `user` (nullable for a guest), `guard` | +| `AuthRestored` | API-confirmed sync only (`BaseGuard`'s background `/user` fetch inside `restore()`), never the cache-only step. | `user`, `guard` | | `GateAbilityDefined` | When ability is registered | `ability` | | `GateAccessChecked` | After every Gate check | `ability`, `arguments`, `allowed`, `user` | | `GateAccessDenied` | When access is denied | `ability`, `arguments`, `user` | -Register listeners in a ServiceProvider: +`AuthFailed` is defined (`credentials`, `guard`), not dispatched by the guards above: no code path in `lib/` fires it. Dispatch it yourself from a failed login flow, e.g. `Event.dispatch(AuthFailed(credentials, guard: 'web'));` in the `catch` around your `Auth.login()` call. + +Register with `Event.listen`, from a provider's `register()` (not `boot()`, since a guard can dispatch `AuthLogin` during `AuthServiceProvider.boot`): ```dart -@override -Future boot() async { - EventDispatcher.instance.register(AuthRestored, [ - () => MyAuthRestoredListener(), - ]); +class LogAuthLogout extends MagicListener { + @override + Future handle(AuthLogout event) async { + Log.info('Session ended for ${event.user?.authIdentifier ?? 'guest'}'); + } +} - EventDispatcher.instance.register(GateAccessDenied, [ - () => MyAccessDeniedListener(), - ]); +@override +void register() { + Event.listen(() => LogAuthLogout()); + Event.listen(() => MyAccessDeniedListener()); } ``` diff --git a/skills/magic-framework/references/facades-api.md b/skills/magic-framework/references/facades-api.md index 04d38a09..7716ab20 100644 --- a/skills/magic-framework/references/facades-api.md +++ b/skills/magic-framework/references/facades-api.md @@ -241,7 +241,8 @@ Dispatches via `EventDispatcher.instance`. The `Event` facade only exposes `disp | Signature | Return Type | Notes | |-----------|-------------|-------| | `Event.dispatch(MagicEvent event)` | `Future` | Trigger an event to all registered listeners. | -| `EventDispatcher.instance.register(Type eventType, List factories)` | `void` | Register listener factories for an event type. Call in `ServiceProvider.boot()`. | +| `Event.listen(MagicListener Function() factory)` | `void` | Register a listener without an `AppEventServiceProvider.listen` mapping; `T` must be named explicitly. Call in `ServiceProvider.register()`, not `boot()` (a guard can dispatch `AuthLogin` during `AuthServiceProvider.boot`). | +| `EventDispatcher.instance.register(Type eventType, List factories)` | `void` | Register listener factories for an event type directly (what `Event.listen` calls under the hood). | ```dart import 'package:magic/magic.dart'; diff --git a/skills/magic-framework/references/forms-validation.md b/skills/magic-framework/references/forms-validation.md index bb04f22d..7bb5e7f2 100644 --- a/skills/magic-framework/references/forms-validation.md +++ b/skills/magic-framework/references/forms-validation.md @@ -246,6 +246,21 @@ if (response.isValidationError) { } ``` +### CollapsesIndexedErrorKeys + +Opt-in `ValidatesRequests.errorFieldFor` override: mix on top of `ValidatesRequests` to collapse a backend's indexed list-validation key (`items.0.name`) onto its field name (`name`), keeping the FIRST message when two indexed keys collapse onto the same field. A key addressing a distinct sub-key rather than a list element (`credentials.token`) is left whole. + +```dart +class ItemsController extends MagicController + with ValidatesRequests, CollapsesIndexedErrorKeys {} +``` + +`CollapsesIndexedErrorKeys.collapse(wireKey)` (static) runs the same collapse for a controller that cannot mix in the class (it already extends a different base): + +```dart +final field = CollapsesIndexedErrorKeys.collapse('items.0.name'); // 'name' +``` + ## Built-in Rules diff --git a/skills/magic-framework/references/secondary-systems.md b/skills/magic-framework/references/secondary-systems.md index 8355996e..306b4440 100644 --- a/skills/magic-framework/references/secondary-systems.md +++ b/skills/magic-framework/references/secondary-systems.md @@ -44,6 +44,7 @@ Locale-aware casing. `String.toUpperCase()`/`toLowerCase()` get Turkish/Azerbaij |:-------|:------------| | `Str.upper(value, {locale})` / `Str.lower(value, {locale})` | Dotted-i aware casing. `İ` maps to a plain `i` in EVERY locale (not only tr/az) to avoid the web's combining-dot lowercase. | | `Str.initials(value, {limit, capitalize, locale})` | First letter of each whitespace-separated word; `limit` keeps only the first N words. | +| `Str.unwrap(value, before, [after])` | Strips `before` from the start and `after` (default `before`) from the end, each checked/stripped independently (Laravel's `Str::unwrap`); a prefix-only match (`'"x'`) still loses the leading quote. | ### Arr @@ -144,6 +145,7 @@ A pub/sub system for decoupling business logic from side-effects. Dispatchers pu | Method | Parameters | Return Type | Description | |:-------|:-----------|:------------|:------------| | `Event.dispatch(event)` | `MagicEvent event` | `Future` | Dispatch an event to all registered listeners. | +| `Event.listen(factory)` | `MagicListener Function() factory` | `void` | Register a listener without adding it to `AppEventServiceProvider.listen`; `T` must be named explicitly. Equivalent to `EventDispatcher.instance.register(T, [factory])`. Call from a provider's `register()`, not `boot()`. | ### EventDispatcher (Direct Access) @@ -152,6 +154,10 @@ A pub/sub system for decoupling business logic from side-effects. Dispatchers pu | `EventDispatcher.instance.register(eventType, listeners)` | `Type eventType`, `List listeners` | `void` | Register listener factories for an event type. | | `EventDispatcher.instance.clear()` | — | `void` | Clear all registered listeners (testing only). | +### Framework Auth Events + +`BaseGuard` (and `Auth.fake()`'s fake guard) dispatch `AuthLogin`/`AuthLogout` through `Event`: `AuthLogin` at the end of a successful `startSession` (not on a restore), `AuthLogout` on every `logout()` including a guest's (means "the in-memory session ended", not "the credentials are gone"; gate server-side release on `Auth.hasToken()`). `AuthRestored` fires only on an API-confirmed sync. Full firing conditions: `references/auth-system.md#auth-events`. + ### Usage ```dart @@ -1027,7 +1033,7 @@ Laravel Echo-equivalent real-time channel system over WebSockets. Accessed via t | `Echo.join(name)` | `BroadcastPresenceChannel` | Join a presence channel (auth + member tracking) | | `Echo.listen(channel, event, callback)` | `BroadcastChannel` | Shorthand: subscribe + listen in one call | | `Echo.leave(name)` | `void` | Unsubscribe from a channel | -| `Echo.connect()` | `Future` | Establish the WebSocket connection | +| `Echo.connect()` | `Future` | Establish the WebSocket connection; idempotent, never opens a second socket | | `Echo.disconnect()` | `Future` | Close the connection | | `Echo.connection` | `BroadcastDriver` | The resolved default driver instance | | `Echo.socketId` | `String?` | Server-assigned socket ID, or `null` when disconnected | @@ -1125,6 +1131,26 @@ Auth failures in `_authenticateAndSubscribe()` are logged via `Log.error()` and Silently drops all broadcast operations. Used for local development or when `broadcasting.default` is `'null'`. `BroadcastServiceProvider` skips `connect()` when the default connection is `null`. +### AuthChannelSubscription + +Reconciles a single private channel subscription against a caller-supplied, re-read-on-every-call channel name: the seam behind a channel whose name depends on auth state (a team id, a user id). + +```dart +late final subscription = AuthChannelSubscription( + channelName: () { + final teamId = Auth.user()?.teamId; + return teamId == null ? null : 'teams.$teamId'; + }, + listeners: {'incident.opened': (event) => refetchIncidents()}, + onReconnect: refetchIncidents, +); + +Auth.stateNotifier.addListener(subscription.sync); +subscription.sync(); // reconcile once at startup too +``` + +`sync()` is serialised (a call arriving mid-flight defers and re-runs once more) and a no-op when `channelName()` still answers the subscribed name, whatever the connection is doing (the Reverb driver recovers a drop on its own). A name change leaves the old channel by its prefixed name, calls `Echo.connect()` when the connection is not live, then subscribes and wires every `listeners` entry. That connect is safe mid-reconnect: `ReverbBroadcastDriver.connect()` is idempotent (returns when connected, joins an attempt in flight, supersedes an armed retry), so it never opens a second socket. `onReconnect` fires on both an `Echo.onReconnect` signal and a `connectionState` transition to `connected`. `dispose()` cancels only the reconnect-listening subscriptions, not the channel or connection. A `null` channel name disconnects the whole default connection, dropping any other channel the app subscribed elsewhere through `Echo`: deliberate, a signed-out app has no business staying on the socket. Full reference: `doc/digging-deeper/broadcasting.md#auth-scoped-subscriptions`. + ### FakeBroadcastManager (Testing) ```dart diff --git a/skills/magic-framework/references/testing-patterns.md b/skills/magic-framework/references/testing-patterns.md index 2d54b48b..dfbab273 100644 --- a/skills/magic-framework/references/testing-patterns.md +++ b/skills/magic-framework/references/testing-patterns.md @@ -670,6 +670,8 @@ fake.assertLoginAttempted(); // At least one login() call fake.assertLoginCount(2); // Exactly 2 login() calls ``` +`login()`/`logout()` on the fake guard dispatch `AuthLogin`/`AuthLogout` through the real `Event` facade, same as `BaseGuard`, so an `Event.listen` listener under test observes a faked session exactly as it would a real one. + ### Cache.fake() ```dart diff --git a/test/auth/auth_events_dispatch_test.dart b/test/auth/auth_events_dispatch_test.dart new file mode 100644 index 00000000..deb206b7 --- /dev/null +++ b/test/auth/auth_events_dispatch_test.dart @@ -0,0 +1,292 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart' hide EventDispatcher; +import 'package:magic/magic.dart'; + +class _User extends Model with Authenticatable { + @override + String get table => 'users'; + + @override + String get resource => 'users'; +} + +_User _user(int id) => _User()..setRawAttributes({'id': id}, sync: true); + +/// Records every event of type [T] it is handed, and what [probe] read at the +/// moment it was handed, so a test can pin where in a sequence it fired. +class _RecordingListener extends MagicListener { + _RecordingListener([this.probe]); + + final Object? Function()? probe; + final List received = []; + final List probed = []; + + @override + Future handle(T event) async { + received.add(event); + probed.add(probe?.call()); + } +} + +class _ThrowingListener extends MagicListener { + @override + Future handle(T event) async => throw StateError('listener broke'); +} + +/// A vault whose first write of the cached user waits for [gate], so a test +/// can act while a sign-in is caching the user it just set. +class _HeldUserWriteVault extends FakeVaultService { + _HeldUserWriteVault(this.gate); + + final Completer gate; + + /// Completes when the first cached-user write has started and is waiting. + final Completer userWriteStarted = Completer(); + + @override + Future put(String key, String value) async { + if (key == 'auth_user' && !userWriteStarted.isCompleted) { + userWriteStarted.complete(); + await gate.future; + } + + return super.put(key, value); + } +} + +/// A vault that refuses to delete the access token, the way a locked keychain +/// refuses a `Vault.delete`. +class _RefusingTokenDeleteVault extends FakeVaultService { + _RefusingTokenDeleteVault(super.initialValues); + + @override + Future remove(String key) async { + if (key == 'auth_token') { + throw MagicVaultException('Failed to remove from vault', null); + } + + return super.remove(key); + } +} + +/// A vault whose first delete of the access token waits for [logoutGate], and +/// whose first write of the cached user waits for [loginGate], so a test can +/// pin a sign-in's `setUser` to land, then release, exactly between an +/// in-flight logout's Vault delete and that logout's own `_user = null`. +class _HeldLogoutLoginVault extends FakeVaultService { + _HeldLogoutLoginVault(super.initialValues, this.logoutGate, this.loginGate); + + final Completer logoutGate; + final Completer loginGate; + + /// Completes once the logout's token delete has started and is waiting. + final Completer tokenDeleteStarted = Completer(); + + /// Completes once the login's cached-user write has started and is waiting. + final Completer userWriteStarted = Completer(); + + @override + Future remove(String key) async { + if (key == 'auth_token' && !tokenDeleteStarted.isCompleted) { + tokenDeleteStarted.complete(); + await logoutGate.future; + } + + return super.remove(key); + } + + @override + Future put(String key, String value) async { + if (key == 'auth_user' && !userWriteStarted.isCompleted) { + userWriteStarted.complete(); + await loginGate.future; + } + + return super.put(key, value); + } +} + +void main() { + late _RecordingListener logins; + late _RecordingListener logouts; + + void listen({Object? Function()? probe}) { + logins = _RecordingListener(probe); + logouts = _RecordingListener(probe); + EventDispatcher.instance.register(AuthLogin, [() => logins]); + EventDispatcher.instance.register(AuthLogout, [() => logouts]); + } + + setUp(() { + MagicApp.reset(); + Magic.flush(); + Log.fake(); + }); + + tearDown(() { + Auth.unfake(); + Vault.unfake(); + Log.unfake(); + MagicApp.reset(); + Magic.flush(); + }); + + group('BaseGuard', () { + test('Auth.login on a bearer guard delivers one AuthLogin', () async { + Vault.fake(); + Magic.singleton('auth', AuthManager.new); + Auth.manager.forgetGuards(); + listen(probe: Auth.check); + final user = _user(1); + + await Auth.login({'token': 'a-token'}, user); + + expect(logins.received, hasLength(1)); + expect(logins.received.single.user, same(user)); + expect(logins.probed, [true], reason: 'fired once the user is set'); + expect(logouts.received, isEmpty); + Auth.manager.forgetGuards(); + }); + + test( + 'a sign-out inside the sign-in\'s cache write gets no AuthLogin', + () async { + // `startSession` awaits the cached-user write after setting the user, + // and a sign-out can begin inside it. Hearing `AuthLogin` after + // `AuthLogout` would attribute a session that no longer exists. + final cacheWrite = Completer(); + final vault = _HeldUserWriteVault(cacheWrite); + Magic.app.setInstance('vault', vault); + listen(); + final guard = BearerTokenGuard(); + final user = _user(1); + + final signingIn = guard.login({'token': 'a-token'}, user); + await vault.userWriteStarted.future; + await guard.logout(); + cacheWrite.complete(); + await signingIn; + + expect(logouts.received, hasLength(1)); + expect(logouts.received.single.user, same(user)); + expect(logins.received, isEmpty); + expect(guard.check(), isFalse); + expect(await Vault.get('auth_user'), isNull); + }, + ); + + test('a logout already inside its vault deletes when a sign-in begins gets ' + 'no AuthLogin once that logout clears the user', () async { + // The logout starts first and gets stuck deleting the access token, + // exactly as a slow keychain would. The sign-in runs `setUser` while + // that delete is still pending, then this vault holds the sign-in's + // own cached-user write open too, so the test can release the + // logout (letting it clear `_user`) before letting the sign-in + // resume and reach its pre-dispatch check. The epoch alone cannot + // catch this: the sign-in's epoch is the newest either way. + final logoutGate = Completer(); + final loginGate = Completer(); + final vault = _HeldLogoutLoginVault( + {'auth_token': 'a-token'}, + logoutGate, + loginGate, + ); + Magic.app.setInstance('vault', vault); + final guard = BearerTokenGuard(); + final userA = _user(1); + guard.setUser(userA); + listen(); + + final loggingOut = guard.logout(); + await vault.tokenDeleteStarted.future; + + final userB = _user(2); + final signingIn = guard.login({'token': 'b-token'}, userB); + await vault.userWriteStarted.future; + + // Let the logout finish clearing `_user` while the sign-in is still + // paused on its own cached-user write. + logoutGate.complete(); + await loggingOut; + + // Now let the sign-in resume and reach its pre-dispatch check. + loginGate.complete(); + await signingIn; + + expect(logins.received, isEmpty, reason: 'the logout already won'); + expect(logouts.received, hasLength(1)); + expect(logouts.received.single.user, same(userA)); + expect(guard.check(), isFalse); + }); + + test( + 'a logout whose token delete throws still delivers AuthLogout', + () async { + // The in-memory clear and the notifier bump are unconditional, so the + // event is too: skipping it would leave listeners holding a user the + // screen no longer shows. + Magic.app.setInstance( + 'vault', + _RefusingTokenDeleteVault({'auth_token': 'a-token'}), + ); + final guard = BearerTokenGuard(); + listen(probe: guard.check); + final user = _user(1); + guard.setUser(user); + + await expectLater(guard.logout(), throwsA(isA())); + + expect(logouts.received, hasLength(1)); + expect(logouts.received.single.user, same(user)); + expect(logouts.probed, [false], reason: 'fired after the clear'); + }, + ); + + test('a guest logout delivers AuthLogout with no user', () async { + Vault.fake(); + listen(); + + await BearerTokenGuard().logout(); + + expect(logouts.received, hasLength(1)); + expect(logouts.received.single.user, isNull); + }); + + test('a throwing listener fails neither login nor logout', () async { + Vault.fake(); + EventDispatcher.instance.register(AuthLogin, [ + () => _ThrowingListener(), + ]); + EventDispatcher.instance.register(AuthLogout, [ + () => _ThrowingListener(), + ]); + final guard = BearerTokenGuard(); + + await guard.login({'token': 'a-token'}, _user(1)); + expect(guard.check(), isTrue); + + await guard.logout(); + expect(guard.check(), isFalse); + }); + }); + + group('Auth.fake', () { + test('login and logout deliver AuthLogin then AuthLogout', () async { + Auth.fake(); + listen(probe: () => Auth.stateNotifier.value); + final user = _user(1); + + await Auth.login({'token': 'a-token'}, user); + await Auth.logout(); + + expect(logins.received.single.user, same(user)); + expect(logouts.received.single.user, same(user)); + expect( + [...logins.probed, ...logouts.probed], + [1, 2], + reason: 'each fires after its own bump, as on the real guard', + ); + }); + }); +} diff --git a/test/broadcasting/auth_channel_subscription_test.dart b/test/broadcasting/auth_channel_subscription_test.dart new file mode 100644 index 00000000..4bcaa475 --- /dev/null +++ b/test/broadcasting/auth_channel_subscription_test.dart @@ -0,0 +1,363 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +/// A [FakeBroadcastDriver] with controllable `onReconnect` and +/// `connectionState` streams, plus a `connect()` counter. +/// +/// The shipped [FakeBroadcastDriver] cannot emit a synthetic reconnect or +/// connection-state signal: both streams are `Stream.empty()`. It also cannot +/// distinguish one `connect()` call from two, since `connect()` just flips a +/// bool; counting them is what shows when the subscription asks the driver +/// to connect and when it reuses the live connection. +class _CountingBroadcastDriver extends FakeBroadcastDriver { + int connectCount = 0; + + /// When set, the next [connect] emits `connecting` on [connectionState] + /// and then throws, instead of succeeding. Simulates the real Reverb + /// driver's `connect()` failing after its own `connecting` announcement + /// (e.g. the server unreachable at boot). + bool failNextConnect = false; + + final StreamController _reconnectController = + StreamController.broadcast(); + final StreamController _connectionStateController = + StreamController.broadcast(); + + @override + Future connect() async { + connectCount++; + if (failNextConnect) { + failNextConnect = false; + _connectionStateController.add(BroadcastConnectionState.connecting); + throw StateError('connect failed'); + } + return super.connect(); + } + + @override + Stream get onReconnect => _reconnectController.stream; + + @override + Stream get connectionState => + _connectionStateController.stream; + + /// Emits a synthetic `Echo.onReconnect` signal. + void emitReconnect() => _reconnectController.add(null); + + /// Emits a synthetic `connectionState` transition. + void emitConnectionState(BroadcastConnectionState state) => + _connectionStateController.add(state); +} + +/// A [FakeBroadcastManager] handing out a [_CountingBroadcastDriver]. +/// +/// Overrides `connection()` rather than replacing the parent's driver, which +/// is private and final, so the inherited assertion helpers (`assertSubscribed` +/// etc.) still speak for the parent's unused driver. Read state off [spy] +/// directly in tests that use this manager. +class _CountingBroadcastManager extends FakeBroadcastManager { + final _CountingBroadcastDriver spy = _CountingBroadcastDriver(); + + @override + BroadcastDriver connection([String? name]) => spy; +} + +void main() { + late FakeBroadcastManager echo; + + setUp(() { + MagicApp.reset(); + Magic.flush(); + // Bind LogManager so the documented degradation path (a caught `sync` + // failure logs rather than throwing) resolves the `log` service. + Magic.singleton('log', () => LogManager()); + echo = Echo.fake(); + }); + + tearDown(() { + Echo.unfake(); + MagicApp.reset(); + Magic.flush(); + }); + + /// Lets a stream event queued on a [StreamController] reach its listener + /// before assertions run. + Future flushMicrotasks() => Future.delayed(Duration.zero); + + test( + 'the first sync subscribes and a dispatched event reaches its listener', + () async { + final List received = []; + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () => 'teams.1', + listeners: { + 'incident.opened': received.add, + }, + ); + + await subscription.sync(); + + echo.assertConnected(); + echo.assertSubscribed('private-teams.1'); + echo.assertListening('private-teams.1', 'incident.opened'); + + echo.dispatch( + 'private-teams.1', + 'incident.opened', + const {'id': 'i1'}, + ); + + expect(received, hasLength(1)); + expect(received.single.data['id'], 'i1'); + }, + ); + + test( + 'a name change leaves the old channel and subscribes to the new one with ' + 'exactly one connect', + () async { + final _CountingBroadcastManager counting = _CountingBroadcastManager(); + Magic.app.setInstance('broadcasting', counting); + + String channelName = 'teams.1'; + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () => channelName, + listeners: {}, + ); + + await subscription.sync(); + expect( + counting.spy.connectCount, + 1, + reason: 'the first sync must connect', + ); + expect(counting.spy.subscribedChannels, contains('private-teams.1')); + + channelName = 'teams.2'; + await subscription.sync(); + + expect( + counting.spy.connectCount, + 1, + reason: 'an already-open socket is reused', + ); + expect(counting.spy.subscribedChannels, contains('private-teams.2')); + expect( + counting.spy.subscribedChannels, + isNot(contains('private-teams.1')), + ); + }, + ); + + test('an unchanged name does not reconnect while the socket is down, the ' + 'driver recovers it', () async { + final _CountingBroadcastManager counting = _CountingBroadcastManager(); + Magic.app.setInstance('broadcasting', counting); + + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () => 'teams.1', + listeners: {}, + ); + + await subscription.sync(); + await counting.spy.disconnect(); + await subscription.sync(); + + expect( + counting.spy.connectCount, + 1, + reason: + 'an unchanged name is the driver\'s to recover; it resubscribes ' + 'on its own', + ); + }); + + test('a name change while the socket is down asks the driver to connect, ' + 'whatever state it last reported, and subscribes', () async { + final _CountingBroadcastManager counting = _CountingBroadcastManager(); + Magic.app.setInstance('broadcasting', counting); + + String channelName = 'teams.1'; + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () => channelName, + listeners: {}, + ); + + await subscription.sync(); + expect(counting.spy.connectCount, 1); + + // The driver dropped its socket and reports a pending reconnect. The + // subscription no longer second-guesses that: the driver's connect() + // is idempotent and supersedes its own armed retry. + counting.spy.emitConnectionState(BroadcastConnectionState.reconnecting); + await flushMicrotasks(); + await counting.spy.disconnect(); + + channelName = 'teams.2'; + await subscription.sync(); + + expect(counting.spy.connectCount, 2); + expect(counting.spy.subscribedChannels, contains('private-teams.2')); + }); + + test('a failed own connect does not strand the subscription: the next sync ' + 'connects again and subscribes', () async { + final _CountingBroadcastManager counting = _CountingBroadcastManager(); + Magic.app.setInstance('broadcasting', counting); + counting.spy.failNextConnect = true; + + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () => 'teams.1', + listeners: {}, + ); + + await subscription.sync(); + expect( + counting.spy.connectCount, + 1, + reason: 'the first sync attempts to connect', + ); + expect( + counting.spy.subscribedChannels, + isEmpty, + reason: 'a failed connect must not subscribe on a dead driver', + ); + + await subscription.sync(); + + expect( + counting.spy.connectCount, + 2, + reason: 'the next sync retries the connect', + ); + expect(counting.spy.subscribedChannels, contains('private-teams.1')); + }); + + test('overlapping syncs serialize and settle on the latest name', () async { + String channelName = 'teams.1'; + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () => channelName, + listeners: {}, + ); + + // Start the first sync (teams.1) without awaiting; it suspends at + // `await Echo.connect()`. + final Future first = subscription.sync(); + // The name changes before the first sync completes. Without the latch + // this would run concurrently and could leave both channels subscribed. + channelName = 'teams.2'; + final Future second = subscription.sync(); + + await Future.wait(>[first, second]); + + echo.assertSubscribed('private-teams.2'); + echo.assertNotSubscribed('private-teams.1'); + expect( + echo.driver.subscribedChannels + .where((String c) => c == 'private-teams.2') + .length, + 1, + reason: 'the deferred re-run must not double-subscribe the settled name', + ); + }); + + test('a null channel name leaves the channel and disconnects', () async { + String? channelName = 'teams.1'; + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () => channelName, + listeners: {}, + ); + + await subscription.sync(); + echo.assertConnected(); + + channelName = null; + await subscription.sync(); + + echo.assertDisconnected(); + echo.assertNotSubscribed('private-teams.1'); + }); + + test('a null channel name before any subscribe is a safe no-op', () async { + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () => null, + listeners: {}, + ); + + await subscription.sync(); + + echo.assertDisconnected(); + }); + + test('a reconnect signal and a connected transition after a drop each fire ' + 'onReconnect exactly once', () async { + final _CountingBroadcastManager counting = _CountingBroadcastManager(); + Magic.app.setInstance('broadcasting', counting); + + int reconnectCount = 0; + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () => 'teams.1', + listeners: {}, + onReconnect: () => reconnectCount++, + ); + await subscription.sync(); + + counting.spy.emitReconnect(); + await flushMicrotasks(); + expect(reconnectCount, 1); + + // Simulate a drop and Reverb's silent re-subscribe: the driver reports + // `connected` again with no `onReconnect` signal of its own. + counting.spy.emitConnectionState(BroadcastConnectionState.disconnected); + counting.spy.emitConnectionState(BroadcastConnectionState.connected); + await flushMicrotasks(); + + expect(reconnectCount, 2); + }); + + test('a resync requested while a pass is throwing still runs and settles on ' + 'the latest name', () async { + // `channelName` throws on its very first call, the way a caller's + // getter might while its own state is mid-update. A second `sync` + // arrives before that failing pass is caught, so it only sets the + // resync flag; the fix is that the flag is still honored after the + // catch, not just after a clean pass. + int calls = 0; + String channelName = 'teams.1'; + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () { + calls++; + if (calls == 1) { + throw StateError('channel name unavailable'); + } + return channelName; + }, + listeners: {}, + ); + + final Future first = subscription.sync(); + channelName = 'teams.2'; + final Future second = subscription.sync(); + + await Future.wait(>[first, second]); + + echo.assertSubscribed('private-teams.2'); + }); + + test('dispose is idempotent', () async { + final AuthChannelSubscription subscription = AuthChannelSubscription( + channelName: () => 'teams.1', + listeners: {}, + ); + await subscription.sync(); + + subscription.dispose(); + subscription.dispose(); + }); +} diff --git a/test/broadcasting/drivers/reverb_broadcast_driver_test.dart b/test/broadcasting/drivers/reverb_broadcast_driver_test.dart index 1c0a8764..2cf76019 100644 --- a/test/broadcasting/drivers/reverb_broadcast_driver_test.dart +++ b/test/broadcasting/drivers/reverb_broadcast_driver_test.dart @@ -773,6 +773,179 @@ void main() { }); }); + group('ReverbBroadcastDriver: idempotent connect', () { + /// Creates a driver whose channel factory records every socket it opens, + /// so a test can count them. Each socket completes the Pusher handshake + /// after a microtask, except that the server closes it before the + /// handshake when [refuse] answers `true` for its 1-based index, and + /// never answers it at all when [silent] does. + (ReverbBroadcastDriver, List<_MockWebSocketChannel>) createCountingDriver({ + bool Function(int index)? refuse, + bool Function(int index)? silent, + Map? configOverrides, + }) { + final sockets = <_MockWebSocketChannel>[]; + final driver = ReverbBroadcastDriver( + _defaultConfig( + overrides: { + 'reconnect': true, + 'connection_timeout': 1, + ...?configOverrides, + }, + ), + channelFactory: (_) { + final socket = _MockWebSocketChannel(); + sockets.add(socket); + final index = sockets.length; + if (refuse?.call(index) ?? false) { + Future.delayed(Duration.zero, socket.simulateClose); + } else if (!(silent?.call(index) ?? false)) { + _simulateConnectionEstablished(socket, socketId: 'socket-$index'); + } + return socket; + }, + random: Random(42), + ); + return (driver, sockets); + } + + Set subscribedOn(_MockWebSocketChannel socket) => socket.sentFrames + .where((f) => f['event'] == 'pusher:subscribe') + .map((f) => (f['data'] as Map)['channel'] as String) + .toSet(); + + test('two concurrent connect() calls open one socket', () async { + final (driver, sockets) = createCountingDriver(); + + await Future.wait(>[driver.connect(), driver.connect()]); + + expect(sockets, hasLength(1)); + expect(driver.isConnected, isTrue); + + await driver.disconnect(); + }); + + test('connect() while connected opens nothing', () async { + final (driver, sockets) = createCountingDriver(); + await driver.connect(); + + final states = []; + driver.connectionState.listen(states.add); + + await driver.connect(); + + expect(sockets, hasLength(1)); + expect(states, isEmpty); + expect(driver.socketId, 'socket-1'); + + await driver.disconnect(); + }); + + test('connect() after a drop supersedes the armed retry: one socket in ' + 'total, channels resubscribed, onReconnect once', () async { + final (driver, sockets) = createCountingDriver(); + await driver.connect(); + driver.channel('orders'); + await Future.delayed(Duration.zero); + + var reconnects = 0; + driver.onReconnect.listen((_) => reconnects++); + + // The server drops the socket; the driver arms its retry timer. + sockets.first.simulateClose(); + await Future.delayed(Duration.zero); + expect(driver.isConnected, isFalse); + + await driver.connect(); + + expect(sockets, hasLength(2)); + expect(driver.isConnected, isTrue); + expect(subscribedOn(sockets[1]), contains('orders')); + + // Past the attempt-0 backoff (at most 650ms): the cancelled retry must + // not open a third socket. + await Future.delayed(const Duration(milliseconds: 800)); + + expect(sockets, hasLength(2)); + expect(reconnects, 1); + + await driver.disconnect(); + }); + + test('connect() during the backoff after a failed timer-driven retry ' + 'opens one socket, not two', () async { + final (driver, sockets) = createCountingDriver( + refuse: (index) => index == 2, + ); + await driver.connect(); + + // Drop, then let the timer-driven retry (socket 2) fail and re-arm. + sockets.first.simulateClose(); + await Future.delayed(const Duration(milliseconds: 800)); + expect(sockets, hasLength(2)); + expect(driver.isConnected, isFalse); + + await driver.connect(); + + expect(sockets, hasLength(3)); + expect(driver.isConnected, isTrue); + + // Past the attempt-1 backoff (at most 1300ms). + await Future.delayed(const Duration(milliseconds: 1500)); + + expect(sockets, hasLength(3)); + + await driver.disconnect(); + }); + + test('connect() during the retry armed by a connection timeout opens one ' + 'socket, not two', () async { + final (driver, sockets) = createCountingDriver( + silent: (index) => index == 1, + ); + + // The server never answers socket 1: connect() times out after 1s and + // arms a retry before throwing. + await expectLater(driver.connect(), throwsA(isA())); + expect(sockets, hasLength(1)); + + await driver.connect(); + + expect(sockets, hasLength(2)); + expect(driver.isConnected, isTrue); + + // Past the backoff the timeout armed (at most 650ms). + await Future.delayed(const Duration(milliseconds: 800)); + + expect(sockets, hasLength(2)); + + await driver.disconnect(); + }); + + test('a failed superseding connect() throws to its caller and re-arms ' + 'the retry', () async { + final (driver, sockets) = createCountingDriver( + refuse: (index) => index == 2, + ); + await driver.connect(); + + sockets.first.simulateClose(); + await Future.delayed(Duration.zero); + + await expectLater(driver.connect(), throwsA(isA())); + expect(sockets, hasLength(2)); + + // The retry loop survives the failed public attempt: the re-armed + // timer (attempt-1 backoff, at most 1300ms) opens socket 3. + await Future.delayed(const Duration(milliseconds: 1500)); + + expect(sockets, hasLength(3)); + expect(driver.isConnected, isTrue); + + await driver.disconnect(); + }); + }); + group('ReverbBroadcastDriver — Pusher error codes', () { test('4000-4099 are fatal (no reconnect)', () { final driver = ReverbBroadcastDriver(_defaultConfig()); @@ -847,6 +1020,26 @@ void main() { await driver.disconnect(); }); + test('onDone emits disconnected, not reconnecting, when reconnect is ' + 'disabled', () async { + final (driver, mock) = await _createConnectedDriver( + configOverrides: {'reconnect': false}, + ); + + final states = []; + driver.connectionState.listen(states.add); + + // Simulate server closing the connection. + mock.simulateClose(); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(states, contains(BroadcastConnectionState.disconnected)); + expect(states, isNot(contains(BroadcastConnectionState.reconnecting))); + + await driver.disconnect(); + }); + test('onError routes through interceptor chain', () async { final (driver, mock) = await _createConnectedDriver(); diff --git a/test/concerns/validates_requests_form_request_test.dart b/test/concerns/validates_requests_form_request_test.dart index c9d7a1e4..daa2a9c0 100644 --- a/test/concerns/validates_requests_form_request_test.dart +++ b/test/concerns/validates_requests_form_request_test.dart @@ -248,4 +248,13 @@ void main() { }, ); }); + + group('CollapsesIndexedErrorKeys.collapse', () { + test( + 'exposes the collapse result to a controller that cannot mix it in', + () { + expect(CollapsesIndexedErrorKeys.collapse('items.0.name'), 'name'); + }, + ); + }); } diff --git a/test/facades/event_listen_test.dart b/test/facades/event_listen_test.dart new file mode 100644 index 00000000..3a07ac1d --- /dev/null +++ b/test/facades/event_listen_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart' hide EventDispatcher; +import 'package:magic/magic.dart'; + +class _User extends Model with Authenticatable { + @override + String get table => 'users'; + + @override + String get resource => 'users'; +} + +class _RecordingListener extends MagicListener { + final List received = []; + + @override + Future handle(T event) async => received.add(event); +} + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + test('Event.listen registers a listener for the event type', () async { + final logins = _RecordingListener(); + final logouts = _RecordingListener(); + Event.listen(() => logins); + Event.listen(() => logouts); + final event = AuthLogin(_User()..setRawAttributes({'id': 1}, sync: true)); + + await Event.dispatch(event); + + expect(logins.received, [same(event)]); + expect(logouts.received, isEmpty); + }); +} diff --git a/test/support/exports_test.dart b/test/support/exports_test.dart index d90e2aee..db3b459e 100644 --- a/test/support/exports_test.dart +++ b/test/support/exports_test.dart @@ -27,6 +27,15 @@ void main() { expect(CollapsesIndexedErrorKeys, isNotNull); }); + test('AuthChannelSubscription resolves through the public barrel', () { + final subscription = AuthChannelSubscription( + channelName: () => null, + listeners: const {}, + ); + + expect(subscription, isNotNull); + }); + test('Env.filled resolves an absent key to the fallback', () { expect(Env.filled('MISSING_EXPORTS_TEST_KEY', 'fallback'), 'fallback'); }); diff --git a/test/support/str_test.dart b/test/support/str_test.dart index 70add8b3..fb27da1e 100644 --- a/test/support/str_test.dart +++ b/test/support/str_test.dart @@ -55,4 +55,22 @@ void main() { expect(Str.upper('istanbul', locale: 'tr_TR'), 'İSTANBUL'); expect(Str.lower('IŞIK', locale: 'tr-TR'), 'ışık'); }); + + group('Str.unwrap', () { + test('strips a matching before/after pair', () { + expect(Str.unwrap('"x"', '"'), 'x'); + expect(Str.unwrap('[x]', '[', ']'), 'x'); + }); + + test( + 'strips the prefix alone when the suffix does not match (Laravel parity)', + () { + expect(Str.unwrap('"x', '"'), 'x'); + }, + ); + + test('leaves a value with neither the prefix nor the suffix unchanged', () { + expect(Str.unwrap('x', '"'), 'x'); + }); + }); }