diff --git a/backend/product/auth/flow/service.go b/backend/product/auth/flow/service.go index 07b1827..4334bf7 100644 --- a/backend/product/auth/flow/service.go +++ b/backend/product/auth/flow/service.go @@ -179,10 +179,11 @@ func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key s err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}}) return result.ExpiresAt, err } -func (s *Service) Activate(ctx context.Context, raw string) error { +func (s *Service) Activate(ctx context.Context, raw string) (string, error) { if len(raw) != 43 { - return verification.ErorrTokenNotFound() + return "", verification.ErorrTokenNotFound() } + var email string _, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) { token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw) if err != nil { @@ -200,7 +201,14 @@ func (s *Service) Activate(ctx context.Context, raw string) error { return nil, err } } - return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)) + if err := s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)); err != nil { + return nil, err + } + email = a.Login + return nil, nil }) - return err + if err != nil { + return "", err + } + return email, nil } diff --git a/backend/product/auth/http/v1/public/verification.go b/backend/product/auth/http/v1/public/verification.go index 9bfbf3a..1f99015 100644 --- a/backend/product/auth/http/v1/public/verification.go +++ b/backend/product/auth/http/v1/public/verification.go @@ -139,11 +139,15 @@ func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc { if err := decodeFlow(r, &req); err != nil { return pr.flowError(err) } - if err := pr.flow.Activate(r.Context(), req.Token); err != nil { + email, err := pr.flow.Activate(r.Context(), req.Token) + if err != nil { return pr.flowError(err) } pr.logger.Info("Account activation completed") - return response.Accepted(pr.logger, map[string]bool{"verified": true}) + return response.Accepted(pr.logger, struct { + Verified bool `json:"verified"` + Email string `json:"email"` + }{Verified: true, Email: email}) } func challengeScope(raw string) string { h := sha256.Sum256([]byte(raw)) diff --git a/backend/product/auth/http/v1/public/verification_integration_test.go b/backend/product/auth/http/v1/public/verification_integration_test.go index 0bd19fa..31fe5da 100644 --- a/backend/product/auth/http/v1/public/verification_integration_test.go +++ b/backend/product/auth/http/v1/public/verification_integration_test.go @@ -152,17 +152,33 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) { require.NoError(t, service.Facts.Ready(ctx, a.ID)) login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"} require.Equal(t, 403, post("login", login).Code) - require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code) + invalid := post("verification/confirm", map[string]string{"token": "invalid"}) + require.Equal(t, 400, invalid.Code) + require.NotContains(t, invalid.Body.String(), a.Login) guard := &activationGuard{SignupFacts: service.Facts, block: true} service.Facts = guard - require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code) + blocked := post("verification/confirm", map[string]string{"token": activation}) + require.Equal(t, 503, blocked.Code) + require.NotContains(t, blocked.Body.String(), a.Login) a, err = stores.Accounts.GetByEmail(ctx, a.Login) require.NoError(t, err) require.False(t, a.IsActive()) guard.block = false w = post("verification/confirm", map[string]string{"token": activation}) require.Equal(t, 202, w.Code, w.Body.String()) - require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code) + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) + var confirmed struct { + Data struct { + Verified bool `json:"verified"` + Email string `json:"email"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &confirmed)) + require.True(t, confirmed.Data.Verified) + require.Equal(t, a.Login, confirmed.Data.Email) + used := post("verification/confirm", map[string]string{"token": activation}) + require.Equal(t, 400, used.Code) + require.NotContains(t, used.Body.String(), a.Login) a, err = stores.Accounts.GetByEmail(ctx, a.Login) require.NoError(t, err) require.Equal(t, customer.AccountActive, a.Status) diff --git a/docs/ux/auth-journeys.md b/docs/ux/auth-journeys.md index aa0e4f6..bcac572 100644 --- a/docs/ux/auth-journeys.md +++ b/docs/ux/auth-journeys.md @@ -214,3 +214,45 @@ This verifies the browser metadata, not Safari's credential popup. With Flutter web accessibility semantics enabled, unfocused semantic inputs initially use framework-default autocomplete values until focused; password-manager behavior with assistive technology still requires a native browser audit. + +## Verification links and login handoff + +Task: [#60](https://git.sendico.io/tech/sxchnge/issues/60), branch +`email-verification-60`. Email links use `/verify-email?token=…`; the web entry +point installs Flutter's path URL strategy before initializing the application. +This matches Caddy's existing SPA fallback and preserves the path and query on a +fresh page load. Previously the default hash strategy could start at login and +never submit the activation token. Unknown routes retain the canonical login +fallback; invalid, expired and failed confirmation attempts remain on the +verification page with the existing recovery actions. + +Activation consumes the token, checks the pending account and accepted signup +facts, and activates the account in the existing database transaction. Only +after a successful commit does the API return `{verified: true, email: ...}`. +Failure responses do not disclose the account email. Responses retain the +shared no-store policy. The frontend backend DTO and mapper produce a +`VerificationResult`; the verification provider exposes that result only in the +verified state and clears it on reset, loading and failure. A verified response +from an older backend without the optional email can still show success. + +Success stays visible until the user chooses **Continue to login**. This replaces +the misleading workspace action that could redirect an unauthenticated visitor +back to login. The verified email travels through transient router extras into +the editable login controller; the password starts empty and receives focus. +Normal password and second-factor checks remain required. The email is not +added to URLs, serialized history, persistent storage or analytics. A link opened +in another tab or device can receive the verified address from the API without +depending on the originating signup form's memory. + +Existing requested/succeeded/failed email-confirmation journey signals remain +the measurement source. Success continues to mean that the confirmation service +returned a verified result, with simulated outcomes kept separate. No field or +prefill event is needed. Existing limits around pre-submit abandonment, +cross-device journey correlation and the in-memory analytics sink still apply. + +Regression coverage includes transactional activation and rollback, returned +email and failure privacy, used-token rejection, backend response mapping, +provider identity clearing, EN/RU success-to-login navigation, editable prefill, +empty focused password, and exclusion of email from serialized navigation state. +The supplied private diagnostic token is not a test fixture or documentation +input; it is kept out of repository sources and tracker records. diff --git a/frontend/app/lib/auth/form.dart b/frontend/app/lib/auth/form.dart index 315300f..90124c8 100644 --- a/frontend/app/lib/auth/form.dart +++ b/frontend/app/lib/auth/form.dart @@ -19,7 +19,8 @@ import 'validation.dart'; import '../generated/i18n/app_localizations.dart'; class AuthForm extends StatefulWidget { - const AuthForm({super.key}); + const AuthForm({super.key, this.email}); + final String? email; @override State createState() => _AuthFormState(); @@ -33,7 +34,7 @@ class _AuthFormState extends State { bool _showPassword = false; bool _showConfirmation = false; bool _submitted = false; - final _email = TextEditingController(); + late final _email = TextEditingController(text: widget.email); final _name = TextEditingController(); AuthProvider get _provider => @@ -264,6 +265,7 @@ class _AuthFormState extends State { key: const ValueKey('password'), enabled: !state.isLoading, controller: _password, + autofocus: widget.email?.isNotEmpty ?? false, obscureText: !_showPassword, autofillHints: [ _signUp diff --git a/frontend/app/lib/auth/page.dart b/frontend/app/lib/auth/page.dart index 7a5d386..bdad200 100644 --- a/frontend/app/lib/auth/page.dart +++ b/frontend/app/lib/auth/page.dart @@ -10,7 +10,8 @@ import 'form.dart'; import '../generated/i18n/app_localizations.dart'; class AuthPage extends StatefulWidget { - const AuthPage({super.key}); + const AuthPage({super.key, this.email}); + final String? email; @override State createState() => _AuthPageState(); @@ -56,7 +57,7 @@ class _AuthPageState extends State { Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440), - child: AuthForm(key: _formKey), + child: AuthForm(key: _formKey, email: widget.email), ), ), ], diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index 3a93507..11fac1d 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -63,6 +63,7 @@ "verificationRequest": "Send a new verification email", "verificationRetry": "Retry verification", "verificationBack": "Back to login", + "verificationContinue": "Continue to login", "verificationHint": "No email? Check your spam folder or request another below.", "verificationDemoLink": "Continue to confirmation", "verificationStep": "CONFIRM YOUR EMAIL", diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 49a5117..2535b62 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -63,6 +63,7 @@ "verificationRequest": "Отправить новое письмо", "verificationRetry": "Повторить подтверждение", "verificationBack": "Вернуться ко входу", + "verificationContinue": "Перейти ко входу", "verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.", "verificationDemoLink": "Перейти к подтверждению", "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ", diff --git a/frontend/app/lib/main.dart b/frontend/app/lib/main.dart index 0be4eec..517d50f 100644 --- a/frontend/app/lib/main.dart +++ b/frontend/app/lib/main.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; + import 'package:provider/provider.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart'; @@ -18,6 +20,7 @@ import 'routing/router.dart'; import 'generated/i18n/app_localizations.dart'; Future main() async { + usePathUrlStrategy(); final binding = WidgetsFlutterBinding.ensureInitialized(); final locale = await LocaleProvider.load(binding.platformDispatcher.locales); const apiUrl = String.fromEnvironment('SXCHNGE_API_URL'); diff --git a/frontend/app/lib/routing/router.dart b/frontend/app/lib/routing/router.dart index cd728c9..e244bc8 100644 --- a/frontend/app/lib/routing/router.dart +++ b/frontend/app/lib/routing/router.dart @@ -135,7 +135,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) { GoRoute( path: '/', name: AppRoutes.login, - builder: (context, state) => const AuthPage(), + builder: (context, state) => AuthPage( + email: state.extra is String ? state.extra as String : null, + ), ), GoRoute( path: '/verification-sent', diff --git a/frontend/app/lib/verification/page.dart b/frontend/app/lib/verification/page.dart index c6d8602..f27e9b5 100644 --- a/frontend/app/lib/verification/page.dart +++ b/frontend/app/lib/verification/page.dart @@ -318,15 +318,21 @@ class _VerificationPageState extends State { if (verified) ...[ const SizedBox(height: 16), FilledButton( - onPressed: () => context.go('/accounts'), - child: Text(l.wEnter), + key: const ValueKey('verification-continue'), + onPressed: () => context.goNamed( + AppRoutes.login, + extra: state.result?.verifiedEmail, + ), + child: Text(l.verificationContinue), + ), + ], + if (!verified) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: () => context.goNamed(AppRoutes.login), + child: Text(l.verificationBack), ), ], - const SizedBox(height: 16), - TextButton( - onPressed: () => context.goNamed(AppRoutes.login), - child: Text(l.verificationBack), - ), ], ), ), diff --git a/frontend/app/pubspec.lock b/frontend/app/pubspec.lock index 7b13355..302e955 100644 --- a/frontend/app/pubspec.lock +++ b/frontend/app/pubspec.lock @@ -185,7 +185,7 @@ packages: source: sdk version: "0.0.0" flutter_web_plugins: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" diff --git a/frontend/app/pubspec.yaml b/frontend/app/pubspec.yaml index f659714..9a0ccba 100644 --- a/frontend/app/pubspec.yaml +++ b/frontend/app/pubspec.yaml @@ -14,6 +14,8 @@ dependencies: path: ../packages/sxchnge_analytics flutter: sdk: flutter + flutter_web_plugins: + sdk: flutter sxchnge_auth: path: ../packages/sxchnge_auth sxchnge_theme: diff --git a/frontend/app/test/auth_test.dart b/frontend/app/test/auth_test.dart index 31a5784..ed39922 100644 --- a/frontend/app/test/auth_test.dart +++ b/frontend/app/test/auth_test.dart @@ -145,8 +145,8 @@ void main() { find.byKey(const ValueKey('verification-verified')), findsOneWidget, ); - await tester.ensureVisible(find.text('Back to login')); - await tester.tap(find.text('Back to login')); + await tester.ensureVisible(find.text('Continue to login')); + await tester.tap(find.text('Continue to login')); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('confirmation')), findsNothing); expect(find.text('example-password'), findsNothing); diff --git a/frontend/app/test/routing_test.dart b/frontend/app/test/routing_test.dart index 6d8d645..2c6bb65 100644 --- a/frontend/app/test/routing_test.dart +++ b/frontend/app/test/routing_test.dart @@ -42,6 +42,67 @@ Future openRoute( } void main() { + for (final language in ['en', 'ru']) { + testWidgets( + 'verified link keeps success visible and prefills login ($language)', + (tester) async { + tester.platformDispatcher.defaultRouteNameTestValue = + '/verify-email?token=demo-valid'; + addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue); + await tester.pumpWidget( + SxchngeApp( + localeProvider: LocaleProvider(initialLocale: Locale(language)), + verificationService: const DummyVerificationService( + delay: Duration.zero, + verifiedEmail: 'private-alias@example.com', + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('verification-verified')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('email')), findsNothing); + final router = GoRouter.of( + tester.element(find.byType(VerificationPage)), + ); + expect(router.routeInformationProvider.value.uri.path, '/verify-email'); + await tester.ensureVisible( + find.byKey(const ValueKey('verification-continue')), + ); + await tester.tap(find.byKey(const ValueKey('verification-continue'))); + await tester.pumpAndSettle(); + final email = tester.widget( + find.byKey(const ValueKey('email')), + ); + expect(email.controller!.text, 'private-alias@example.com'); + final password = tester.state( + find.descendant( + of: find.byKey(const ValueKey('password')), + matching: find.byType(EditableText), + ), + ); + expect(password.currentTextEditingValue.text, isEmpty); + expect(password.widget.focusNode.hasFocus, isTrue); + expect(router.routeInformationProvider.value.uri.toString(), '/'); + final restored = router.routeInformationParser.restoreRouteInformation( + router.routerDelegate.currentConfiguration, + )!; + expect( + restored.state.toString(), + isNot(contains('private-alias@example.com')), + ); + expect(restored.state.toString(), isNot(contains('demo-valid'))); + await tester.enterText( + find.byKey(const ValueKey('email')), + 'changed@example.com', + ); + expect(email.controller!.text, 'changed@example.com'); + expect(find.byKey(const ValueKey('confirmation')), findsNothing); + }, + ); + } testWidgets('unknown deep link redirects to canonical login URL', ( tester, ) async { diff --git a/frontend/packages/sxchnge_auth/lib/src/dto/backend.dart b/frontend/packages/sxchnge_auth/lib/src/dto/backend.dart index 4d511e7..afda8f7 100644 --- a/frontend/packages/sxchnge_auth/lib/src/dto/backend.dart +++ b/frontend/packages/sxchnge_auth/lib/src/dto/backend.dart @@ -6,6 +6,8 @@ class BackendDTO { (jsonDecode(body) as Map)['data'] as Map, ); final Map data; + bool get verified => data['verified'] == true; + String? get email => data['email'] as String?; String get error => data['error'] as String? ?? 'unavailable'; String get userId => data['userId'] as String? ?? diff --git a/frontend/packages/sxchnge_auth/lib/src/dto/results.dart b/frontend/packages/sxchnge_auth/lib/src/dto/results.dart index 79b6dd0..99ff64c 100644 --- a/frontend/packages/sxchnge_auth/lib/src/dto/results.dart +++ b/frontend/packages/sxchnge_auth/lib/src/dto/results.dart @@ -6,6 +6,7 @@ class AuthDTO { } class VerificationDTO { - const VerificationDTO({required this.simulated}); + const VerificationDTO({required this.simulated, this.email}); final bool simulated; + final String? email; } diff --git a/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart b/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart index a586abb..14be575 100644 --- a/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart +++ b/frontend/packages/sxchnge_auth/lib/src/mappers/backend.dart @@ -5,6 +5,13 @@ import '../models/two_factor.dart'; import '../models/verification.dart'; abstract final class BackendMapper { + static VerificationResult activation(BackendDTO dto) { + if (!dto.verified) { + throw const VerificationException(VerificationError.internal); + } + return VerificationResult(isSimulated: false, verifiedEmail: dto.email); + } + static AuthResult signup(BackendDTO dto) { if (dto.userId.isEmpty) throw const FormatException('Missing user'); return AuthResult(userId: dto.userId, isSimulated: false); diff --git a/frontend/packages/sxchnge_auth/lib/src/mappers/results.dart b/frontend/packages/sxchnge_auth/lib/src/mappers/results.dart index b1456b1..e3ba202 100644 --- a/frontend/packages/sxchnge_auth/lib/src/mappers/results.dart +++ b/frontend/packages/sxchnge_auth/lib/src/mappers/results.dart @@ -9,5 +9,5 @@ abstract final class AuthMapper { abstract final class VerificationMapper { static VerificationResult fromDTO(VerificationDTO dto) => - VerificationResult(isSimulated: dto.simulated); + VerificationResult(isSimulated: dto.simulated, verifiedEmail: dto.email); } diff --git a/frontend/packages/sxchnge_auth/lib/src/models/verification.dart b/frontend/packages/sxchnge_auth/lib/src/models/verification.dart index 777755e..ed6dbe9 100644 --- a/frontend/packages/sxchnge_auth/lib/src/models/verification.dart +++ b/frontend/packages/sxchnge_auth/lib/src/models/verification.dart @@ -17,15 +17,22 @@ class VerificationException implements Exception { } class VerificationResult { - const VerificationResult({required this.isSimulated}); + const VerificationResult({required this.isSimulated, this.verifiedEmail}); final bool isSimulated; + final String? verifiedEmail; } class VerificationState { - const VerificationState(this.status, {this.isSimulated = false, this.error}); + const VerificationState( + this.status, { + this.isSimulated = false, + this.error, + this.result, + }); final VerificationStatus status; final VerificationError? error; final bool isSimulated; + final VerificationResult? result; bool get isLoading => status == VerificationStatus.sending || status == VerificationStatus.checking; diff --git a/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart b/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart index 3484ee8..2d0963d 100644 --- a/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart +++ b/frontend/packages/sxchnge_auth/lib/src/providers/verification.dart @@ -35,7 +35,11 @@ class VerificationProvider extends ChangeNotifier { try { final result = await operation(); if (_disposed || generation != _generation) return; - _state = VerificationState(success, isSimulated: result.isSimulated); + _state = VerificationState( + success, + isSimulated: result.isSimulated, + result: success == VerificationStatus.verified ? result : null, + ); } catch (error) { if (_disposed || generation != _generation) return; final code = error is VerificationException diff --git a/frontend/packages/sxchnge_auth/lib/src/services/backend.dart b/frontend/packages/sxchnge_auth/lib/src/services/backend.dart index d9a49f4..42a8388 100644 --- a/frontend/packages/sxchnge_auth/lib/src/services/backend.dart +++ b/frontend/packages/sxchnge_auth/lib/src/services/backend.dart @@ -172,8 +172,10 @@ class BackendAuthServices @override Future confirm({required String token}) async { - await _post('verification/confirm', {'token': token}, verification: true); - return VerificationMapper.fromDTO(const VerificationDTO(simulated: false)); + final dto = await _post('verification/confirm', { + 'token': token, + }, verification: true); + return BackendMapper.activation(dto); } @override diff --git a/frontend/packages/sxchnge_auth/lib/src/services/verification.dart b/frontend/packages/sxchnge_auth/lib/src/services/verification.dart index 9ce505e..4ddd414 100644 --- a/frontend/packages/sxchnge_auth/lib/src/services/verification.dart +++ b/frontend/packages/sxchnge_auth/lib/src/services/verification.dart @@ -19,11 +19,13 @@ class DummyVerificationService implements VerificationService, SimulationInfo { const DummyVerificationService({ this.delay = const Duration(milliseconds: 600), this.sendFailure, + this.verifiedEmail = 'alex@example.com', }); @override bool get isSimulated => true; final Duration delay; final VerificationError? sendFailure; + final String verifiedEmail; @override Future send({ @@ -41,7 +43,7 @@ class DummyVerificationService implements VerificationService, SimulationInfo { switch (token) { case 'demo-valid': return VerificationMapper.fromDTO( - const VerificationDTO(simulated: true), + VerificationDTO(simulated: true, email: verifiedEmail), ); case 'demo-expired': throw const VerificationException(VerificationError.expired); diff --git a/frontend/packages/sxchnge_auth/test/backend_test.dart b/frontend/packages/sxchnge_auth/test/backend_test.dart index 1212276..91b5ee3 100644 --- a/frontend/packages/sxchnge_auth/test/backend_test.dart +++ b/frontend/packages/sxchnge_auth/test/backend_test.dart @@ -10,6 +10,48 @@ import 'package:sxchnge_auth/sxchnge_auth.dart'; void main() { http.Response response(Map data, [int status = 202]) => http.Response(jsonEncode({'data': data}), status); + test( + 'activation maps only a confirmed response to the verified email', + () async { + var confirmed = true; + final service = BackendAuthServices( + baseUri: Uri.parse('https://api.example/api/v1'), + clientId: 'web', + client: MockClient((request) async { + expect(request.url.path, '/api/v1/accounts/verification/confirm'); + expect(jsonDecode(request.body), {'token': 'opaque-link'}); + return response({ + 'verified': confirmed, + 'email': 'alias@example.com', + }); + }), + ); + addTearDown(service.close); + final accepted = await service.confirm(token: 'opaque-link'); + expect(accepted.verifiedEmail, 'alias@example.com'); + expect(accepted.isSimulated, isFalse); + confirmed = false; + await expectLater( + service.confirm(token: 'opaque-link'), + throwsA(isA()), + ); + }, + ); + test( + 'activation remains compatible with a verified response without email', + () async { + final service = BackendAuthServices( + baseUri: Uri.parse('https://api.example/api/v1'), + clientId: 'web', + client: MockClient((_) async => response({'verified': true})), + ); + addTearDown(service.close); + expect( + (await service.confirm(token: 'opaque-link')).verifiedEmail, + isNull, + ); + }, + ); test( 'signup distinguishes support recovery and keeps the retry key', () async { diff --git a/frontend/packages/sxchnge_auth/test/verification_test.dart b/frontend/packages/sxchnge_auth/test/verification_test.dart index 354a639..fb5743c 100644 --- a/frontend/packages/sxchnge_auth/test/verification_test.dart +++ b/frontend/packages/sxchnge_auth/test/verification_test.dart @@ -42,6 +42,12 @@ void main() { addTearDown(provider.dispose); await provider.confirm(scenario.key); expect(provider.state.status, scenario.value); + expect( + provider.state.result?.verifiedEmail, + scenario.value == VerificationStatus.verified + ? 'alex@example.com' + : null, + ); expect( provider.state.isSimulated, scenario.value == VerificationStatus.verified, @@ -75,7 +81,23 @@ void main() { service.pending.complete(const VerificationResult(isSimulated: true)); await operation; expect(provider.state.status, VerificationStatus.idle); + expect(provider.state.result, isNull); }); + test( + 'a later failed confirmation clears the previous verified identity', + () async { + final provider = VerificationProvider( + const DummyVerificationService(delay: Duration.zero), + ); + addTearDown(provider.dispose); + await provider.confirm('demo-valid'); + expect(provider.state.result?.verifiedEmail, 'alex@example.com'); + final operation = provider.confirm('bad'); + expect(provider.state.result, isNull); + await operation; + expect(provider.state.result, isNull); + }, + ); test('dispose discards late exception', () async { final service = PendingService(); final provider = VerificationProvider(service);