Merge pull request 'Complete verification links and prefill login with the verified email' (#61) from email-verification-60 into main

Reviewed-on: #61
This commit was merged in pull request #61.
This commit is contained in:
tech committed 2026-10-20 18:00:05 +00:00
commit fa7b75d1b8
25 files changed
+310 -32

No files matched your search

+12 -4
View File
@@ -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()}}) 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 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 { 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) { _, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw) token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
if err != nil { if err != nil {
@@ -200,7 +201,14 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
return nil, err 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
} }
@@ -139,11 +139,15 @@ func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
if err := decodeFlow(r, &req); err != nil { if err := decodeFlow(r, &req); err != nil {
return pr.flowError(err) 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) return pr.flowError(err)
} }
pr.logger.Info("Account activation completed") 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 { func challengeScope(raw string) string {
h := sha256.Sum256([]byte(raw)) h := sha256.Sum256([]byte(raw))
@@ -152,17 +152,33 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
require.NoError(t, service.Facts.Ready(ctx, a.ID)) 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"} 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, 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} guard := &activationGuard{SignupFacts: service.Facts, block: true}
service.Facts = guard 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) a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err) require.NoError(t, err)
require.False(t, a.IsActive()) require.False(t, a.IsActive())
guard.block = false guard.block = false
w = post("verification/confirm", map[string]string{"token": activation}) w = post("verification/confirm", map[string]string{"token": activation})
require.Equal(t, 202, w.Code, w.Body.String()) 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) a, err = stores.Accounts.GetByEmail(ctx, a.Login)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, customer.AccountActive, a.Status) require.Equal(t, customer.AccountActive, a.Status)
+82
View File
@@ -214,3 +214,85 @@ This verifies the browser metadata, not Safari's credential popup. With Flutter
web accessibility semantics enabled, unfocused semantic inputs initially use web accessibility semantics enabled, unfocused semantic inputs initially use
framework-default autocomplete values until focused; password-manager behavior framework-default autocomplete values until focused; password-manager behavior
with assistive technology still requires a native browser audit. 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.
### Validation and local release (#60)
Delivery: [PR #61](https://git.sendico.io/tech/sxchnge/pulls/61), implementation
commit `5f3b443`. Reviewed the complete backend and frontend contribution against
their module guidelines and the UX/observability checklists. No task-caused
review findings remain. Existing recovery, loading and accessible status states
are retained; EN/RU widget tests and existing small-screen/text-scale coverage
pass. Browser startup was checked against a separately built demo with synthetic
tokens: direct success remains visible, Continue to login shows the verified
email with an empty focused password, and an expired link offers resend/back.
Native Safari was unavailable to automation, so browser-specific helper behavior
is not claimed as verified by this check.
On 2026-10-19, changed-file Dart formatting, `flutter analyze` in both affected
packages (`frontend/app`, `frontend/packages/sxchnge_auth`), `make test-frontend`
(217 tests), `GOFLAGS=-p=1 make test`, and `make lint-backend` passed. The focused
transactional activation integration test also passed independently. Initial
test-container startup and image-resolution attempts failed while Docker was
stalled. After the user-authorized Docker Desktop restart, the unchanged checks
passed; no assertions, timeouts or gates were weakened. No security scan was
requested or run.
The task branch was integrated locally into the existing `identity-facts-57`
stand checkout, preserving its unrelated configuration. Fresh stable image
versions/digests were resolved successfully with `sh infra/local/tools.sh images`.
Compose rebuilt BFF and frontend with `build --pull bff frontend`, then recreated
them and the Sendico session sidecar (which shares BFF's network namespace).
Docker restart recovery also unsealed Sendico's existing Vault with its saved
local key and restarted affected existing services in dependency order.
The updated BFF, frontend and Sendico sidecar are healthy. The authenticated
Sendico currency/ledger check passes. HTTPS serves the direct verification path;
the served JavaScript matches the new frontend image with SHA-256
`b194c0cf5dcc62634c13c7fd3d2f7e482272cb7abc1e470c62629400bf2d5d21`.
A live synthetic invalid token returns HTTP 400 without an email. The supplied
real token was checked read-only and remains unused and unexpired, with its
account pending verification, ready for the user to reopen the link. PR #61 has
not been merged into remote main; this local deployment does not imply a main
branch merge.
+4 -2
View File
@@ -19,7 +19,8 @@ import 'validation.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class AuthForm extends StatefulWidget { class AuthForm extends StatefulWidget {
const AuthForm({super.key}); const AuthForm({super.key, this.email});
final String? email;
@override @override
State<AuthForm> createState() => _AuthFormState(); State<AuthForm> createState() => _AuthFormState();
@@ -33,7 +34,7 @@ class _AuthFormState extends State<AuthForm> {
bool _showPassword = false; bool _showPassword = false;
bool _showConfirmation = false; bool _showConfirmation = false;
bool _submitted = false; bool _submitted = false;
final _email = TextEditingController(); late final _email = TextEditingController(text: widget.email);
final _name = TextEditingController(); final _name = TextEditingController();
AuthProvider get _provider => AuthProvider get _provider =>
@@ -264,6 +265,7 @@ class _AuthFormState extends State<AuthForm> {
key: const ValueKey('password'), key: const ValueKey('password'),
enabled: !state.isLoading, enabled: !state.isLoading,
controller: _password, controller: _password,
autofocus: widget.email?.isNotEmpty ?? false,
obscureText: !_showPassword, obscureText: !_showPassword,
autofillHints: [ autofillHints: [
_signUp _signUp
+3 -2
View File
@@ -10,7 +10,8 @@ import 'form.dart';
import '../generated/i18n/app_localizations.dart'; import '../generated/i18n/app_localizations.dart';
class AuthPage extends StatefulWidget { class AuthPage extends StatefulWidget {
const AuthPage({super.key}); const AuthPage({super.key, this.email});
final String? email;
@override @override
State<AuthPage> createState() => _AuthPageState(); State<AuthPage> createState() => _AuthPageState();
@@ -56,7 +57,7 @@ class _AuthPageState extends State<AuthPage> {
Center( Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440), constraints: const BoxConstraints(maxWidth: 440),
child: AuthForm(key: _formKey), child: AuthForm(key: _formKey, email: widget.email),
), ),
), ),
], ],
+1
View File
@@ -63,6 +63,7 @@
"verificationRequest": "Send a new verification email", "verificationRequest": "Send a new verification email",
"verificationRetry": "Retry verification", "verificationRetry": "Retry verification",
"verificationBack": "Back to login", "verificationBack": "Back to login",
"verificationContinue": "Continue to login",
"verificationHint": "No email? Check your spam folder or request another below.", "verificationHint": "No email? Check your spam folder or request another below.",
"verificationDemoLink": "Continue to confirmation", "verificationDemoLink": "Continue to confirmation",
"verificationStep": "CONFIRM YOUR EMAIL", "verificationStep": "CONFIRM YOUR EMAIL",
+1
View File
@@ -63,6 +63,7 @@
"verificationRequest": "Отправить новое письмо", "verificationRequest": "Отправить новое письмо",
"verificationRetry": "Повторить подтверждение", "verificationRetry": "Повторить подтверждение",
"verificationBack": "Вернуться ко входу", "verificationBack": "Вернуться ко входу",
"verificationContinue": "Перейти ко входу",
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.", "verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
"verificationDemoLink": "Перейти к подтверждению", "verificationDemoLink": "Перейти к подтверждению",
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ", "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
+3
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart';
@@ -18,6 +20,7 @@ import 'routing/router.dart';
import 'generated/i18n/app_localizations.dart'; import 'generated/i18n/app_localizations.dart';
Future<void> main() async { Future<void> main() async {
usePathUrlStrategy();
final binding = WidgetsFlutterBinding.ensureInitialized(); final binding = WidgetsFlutterBinding.ensureInitialized();
final locale = await LocaleProvider.load(binding.platformDispatcher.locales); final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL'); const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
+3 -1
View File
@@ -135,7 +135,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
GoRoute( GoRoute(
path: '/', path: '/',
name: AppRoutes.login, name: AppRoutes.login,
builder: (context, state) => const AuthPage(), builder: (context, state) => AuthPage(
email: state.extra is String ? state.extra as String : null,
),
), ),
GoRoute( GoRoute(
path: '/verification-sent', path: '/verification-sent',
+13 -7
View File
@@ -318,15 +318,21 @@ class _VerificationPageState extends State<VerificationPage> {
if (verified) ...[ if (verified) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton( FilledButton(
onPressed: () => context.go('/accounts'), key: const ValueKey('verification-continue'),
child: Text(l.wEnter), 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),
),
], ],
), ),
), ),
+1 -1
View File
@@ -185,7 +185,7 @@ packages:
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
+2
View File
@@ -14,6 +14,8 @@ dependencies:
path: ../packages/sxchnge_analytics path: ../packages/sxchnge_analytics
flutter: flutter:
sdk: flutter sdk: flutter
flutter_web_plugins:
sdk: flutter
sxchnge_auth: sxchnge_auth:
path: ../packages/sxchnge_auth path: ../packages/sxchnge_auth
sxchnge_theme: sxchnge_theme:
+2 -2
View File
@@ -145,8 +145,8 @@ void main() {
find.byKey(const ValueKey('verification-verified')), find.byKey(const ValueKey('verification-verified')),
findsOneWidget, findsOneWidget,
); );
await tester.ensureVisible(find.text('Back to login')); await tester.ensureVisible(find.text('Continue to login'));
await tester.tap(find.text('Back to login')); await tester.tap(find.text('Continue to login'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsNothing); expect(find.byKey(const ValueKey('confirmation')), findsNothing);
expect(find.text('example-password'), findsNothing); expect(find.text('example-password'), findsNothing);
+61
View File
@@ -42,6 +42,67 @@ Future<GoRouter> openRoute(
} }
void main() { 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<TextFormField>(
find.byKey(const ValueKey('email')),
);
expect(email.controller!.text, 'private-alias@example.com');
final password = tester.state<EditableTextState>(
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', ( testWidgets('unknown deep link redirects to canonical login URL', (
tester, tester,
) async { ) async {
@@ -6,6 +6,8 @@ class BackendDTO {
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>, (jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
); );
final Map<String, dynamic> data; final Map<String, dynamic> data;
bool get verified => data['verified'] == true;
String? get email => data['email'] as String?;
String get error => data['error'] as String? ?? 'unavailable'; String get error => data['error'] as String? ?? 'unavailable';
String get userId => String get userId =>
data['userId'] as String? ?? data['userId'] as String? ??
@@ -6,6 +6,7 @@ class AuthDTO {
} }
class VerificationDTO { class VerificationDTO {
const VerificationDTO({required this.simulated}); const VerificationDTO({required this.simulated, this.email});
final bool simulated; final bool simulated;
final String? email;
} }
@@ -5,6 +5,13 @@ import '../models/two_factor.dart';
import '../models/verification.dart'; import '../models/verification.dart';
abstract final class BackendMapper { 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) { static AuthResult signup(BackendDTO dto) {
if (dto.userId.isEmpty) throw const FormatException('Missing user'); if (dto.userId.isEmpty) throw const FormatException('Missing user');
return AuthResult(userId: dto.userId, isSimulated: false); return AuthResult(userId: dto.userId, isSimulated: false);
@@ -9,5 +9,5 @@ abstract final class AuthMapper {
abstract final class VerificationMapper { abstract final class VerificationMapper {
static VerificationResult fromDTO(VerificationDTO dto) => static VerificationResult fromDTO(VerificationDTO dto) =>
VerificationResult(isSimulated: dto.simulated); VerificationResult(isSimulated: dto.simulated, verifiedEmail: dto.email);
} }
@@ -17,15 +17,22 @@ class VerificationException implements Exception {
} }
class VerificationResult { class VerificationResult {
const VerificationResult({required this.isSimulated}); const VerificationResult({required this.isSimulated, this.verifiedEmail});
final bool isSimulated; final bool isSimulated;
final String? verifiedEmail;
} }
class VerificationState { 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 VerificationStatus status;
final VerificationError? error; final VerificationError? error;
final bool isSimulated; final bool isSimulated;
final VerificationResult? result;
bool get isLoading => bool get isLoading =>
status == VerificationStatus.sending || status == VerificationStatus.sending ||
status == VerificationStatus.checking; status == VerificationStatus.checking;
@@ -35,7 +35,11 @@ class VerificationProvider extends ChangeNotifier {
try { try {
final result = await operation(); final result = await operation();
if (_disposed || generation != _generation) return; 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) { } catch (error) {
if (_disposed || generation != _generation) return; if (_disposed || generation != _generation) return;
final code = error is VerificationException final code = error is VerificationException
@@ -172,8 +172,10 @@ class BackendAuthServices
@override @override
Future<VerificationResult> confirm({required String token}) async { Future<VerificationResult> confirm({required String token}) async {
await _post('verification/confirm', {'token': token}, verification: true); final dto = await _post('verification/confirm', {
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false)); 'token': token,
}, verification: true);
return BackendMapper.activation(dto);
} }
@override @override
@@ -19,11 +19,13 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
const DummyVerificationService({ const DummyVerificationService({
this.delay = const Duration(milliseconds: 600), this.delay = const Duration(milliseconds: 600),
this.sendFailure, this.sendFailure,
this.verifiedEmail = 'alex@example.com',
}); });
@override @override
bool get isSimulated => true; bool get isSimulated => true;
final Duration delay; final Duration delay;
final VerificationError? sendFailure; final VerificationError? sendFailure;
final String verifiedEmail;
@override @override
Future<VerificationResult> send({ Future<VerificationResult> send({
@@ -41,7 +43,7 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
switch (token) { switch (token) {
case 'demo-valid': case 'demo-valid':
return VerificationMapper.fromDTO( return VerificationMapper.fromDTO(
const VerificationDTO(simulated: true), VerificationDTO(simulated: true, email: verifiedEmail),
); );
case 'demo-expired': case 'demo-expired':
throw const VerificationException(VerificationError.expired); throw const VerificationException(VerificationError.expired);
@@ -10,6 +10,48 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
void main() { void main() {
http.Response response(Map<String, Object?> data, [int status = 202]) => http.Response response(Map<String, Object?> data, [int status = 202]) =>
http.Response(jsonEncode({'data': data}), status); 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<VerificationException>()),
);
},
);
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( test(
'signup distinguishes support recovery and keeps the retry key', 'signup distinguishes support recovery and keeps the retry key',
() async { () async {
@@ -42,6 +42,12 @@ void main() {
addTearDown(provider.dispose); addTearDown(provider.dispose);
await provider.confirm(scenario.key); await provider.confirm(scenario.key);
expect(provider.state.status, scenario.value); expect(provider.state.status, scenario.value);
expect(
provider.state.result?.verifiedEmail,
scenario.value == VerificationStatus.verified
? 'alex@example.com'
: null,
);
expect( expect(
provider.state.isSimulated, provider.state.isSimulated,
scenario.value == VerificationStatus.verified, scenario.value == VerificationStatus.verified,
@@ -75,7 +81,23 @@ void main() {
service.pending.complete(const VerificationResult(isSimulated: true)); service.pending.complete(const VerificationResult(isSimulated: true));
await operation; await operation;
expect(provider.state.status, VerificationStatus.idle); 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 { test('dispose discards late exception', () async {
final service = PendingService(); final service = PendingService();
final provider = VerificationProvider(service); final provider = VerificationProvider(service);