Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8ef9c3d14 | ||
|
|
3af60f38b1 |
No files matched your search
@@ -296,3 +296,66 @@ 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
|
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
|
not been merged into remote main; this local deployment does not imply a main
|
||||||
branch merge.
|
branch merge.
|
||||||
|
|
||||||
|
### Accepted signup delivery (#62)
|
||||||
|
|
||||||
|
Task [#62](https://git.sendico.io/tech/sxchnge/issues/62), branch
|
||||||
|
`email-verification-62`, delivered in
|
||||||
|
[PR #63](https://git.sendico.io/tech/sxchnge/pulls/63). On October 23, signup
|
||||||
|
returned HTTP 202 and the local
|
||||||
|
SMTP provider accepted its message with status 250, while the user reported an
|
||||||
|
internal-error screen immediately after Create account. No subsequent
|
||||||
|
verification request from that attempt reached the BFF. The precise original
|
||||||
|
client exception was not captured; an ordinary isolated release reproduction
|
||||||
|
succeeded. The regression below deliberately injects an unavailable follow-up
|
||||||
|
send service to cover the post-acceptance failure path without claiming that
|
||||||
|
injected exception was the original cause.
|
||||||
|
|
||||||
|
Backend and preview signup mappers now return a `VerificationDelivery` domain
|
||||||
|
receipt with the recipient and simulation status. It means that signup accepted
|
||||||
|
the email-queue operation, not inbox delivery, account activation or a session.
|
||||||
|
The signup provider exposes it through its operation result. The form captures
|
||||||
|
the receipt before clearing password controllers, whose change listeners reset
|
||||||
|
auth state, and passes it as transient router extra. The verification provider
|
||||||
|
initializes directly in the sent state; mounting the page performs no send.
|
||||||
|
This replaces the backend adapter's hidden one-use signup cache. Every explicit
|
||||||
|
resend now invokes its service, including the first one after signup.
|
||||||
|
|
||||||
|
Routes without a receipt retain their existing behavior: unverified login may
|
||||||
|
request an email, a fresh verification page asks for an address, and emailed
|
||||||
|
links run confirmation. Recipient changes invalidate the displayed claim until
|
||||||
|
another send succeeds. New receipts replace stale recipients. The route codec
|
||||||
|
discards the receipt when serializing history, so reloads do not invent a queued
|
||||||
|
result or retain the address. No token or password is included in the receipt.
|
||||||
|
|
||||||
|
The existing email-delivery requested/succeeded signals now follow the accepted
|
||||||
|
signup receipt inside the instrumented service instead of a synthetic send on
|
||||||
|
page mount. They stay in the signup journey, occur once for the accepted
|
||||||
|
operation, and remain intermediate queue-acceptance signals. Resends retain
|
||||||
|
separate attempts and failures; confirmation remains the terminal outcome.
|
||||||
|
Simulation and privacy rules are unchanged, including the in-memory analytics
|
||||||
|
and cross-device correlation limitations above.
|
||||||
|
|
||||||
|
Frontend review covered the complete app/auth contribution and preserved the
|
||||||
|
DTO, mapper, service, domain model, provider and UI boundaries. Regressions cover
|
||||||
|
accepted signup followed by unavailable resend, explicit retry, normalized
|
||||||
|
receipt mapping, provider initialization/reset, recipient replacement, transient
|
||||||
|
history, event correlation and exclusion of form values from analytics. EN/RU
|
||||||
|
flow tests run at 390px with 1.5x text scaling; existing keyboard, autofill and
|
||||||
|
accessibility suites also pass. Changed-file Dart format checks, analysis in
|
||||||
|
`frontend/app` and `frontend/packages/sxchnge_auth`, and `make test-frontend`
|
||||||
|
(221 tests) pass. The new injected-failure regression failed on the previous
|
||||||
|
implementation and passes with the receipt handoff. A release-browser fixture
|
||||||
|
with the same backend adapter and a failing resend service shows Check your
|
||||||
|
inbox after signup with no send call, then the expected error only after an
|
||||||
|
explicit resend. It sends no email. Native Safari was not automated.
|
||||||
|
|
||||||
|
The frontend image was rebuilt with freshly resolved stable digest pins and
|
||||||
|
recreated without restarting BFF. The served JavaScript matches the new image
|
||||||
|
at SHA-256 `0a45ce6f0b4658b1f03d8e39b9e39fd3fbfcf6f1626cb3f17705dce2da130a6c`.
|
||||||
|
Normal browser-trust HTTPS serves the frontend and API with HTTP 200.
|
||||||
|
`sh infra/local/tools.sh verify` passes all seven services, Vault/database
|
||||||
|
checks and authenticated Sendico currency/ledger reads. No new customer email,
|
||||||
|
activation or payment was submitted for this change, and no security scan was
|
||||||
|
requested or run. The user's pre-existing browser tab must reload to use the
|
||||||
|
new bundle; their original Safari exception remains unobserved.
|
||||||
@@ -99,10 +99,11 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final email = _email.text.trim();
|
final email = _email.text.trim();
|
||||||
|
final delivery = signup.state.result!.delivery;
|
||||||
TextInput.finishAutofillContext();
|
TextInput.finishAutofillContext();
|
||||||
_password.clear();
|
_password.clear();
|
||||||
_confirmation.clear();
|
_confirmation.clear();
|
||||||
context.goNamed(AppRoutes.verificationSent, extra: email);
|
context.goNamed(AppRoutes.verificationSent, extra: delivery ?? email);
|
||||||
} else {
|
} else {
|
||||||
final login = context.read<LoginProvider>();
|
final login = context.read<LoginProvider>();
|
||||||
await login.login(
|
await login.login(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
|
|||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
import '../auth/page.dart';
|
import '../auth/page.dart';
|
||||||
@@ -144,6 +146,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
|||||||
name: AppRoutes.verificationSent,
|
name: AppRoutes.verificationSent,
|
||||||
builder: (context, state) => VerificationPage(
|
builder: (context, state) => VerificationPage(
|
||||||
email: state.extra is String ? state.extra as String : null,
|
email: state.extra is String ? state.extra as String : null,
|
||||||
|
delivery: state.extra is VerificationDelivery
|
||||||
|
? state.extra as VerificationDelivery
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import '../routing/routes.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class VerificationPage extends StatefulWidget {
|
class VerificationPage extends StatefulWidget {
|
||||||
const VerificationPage({super.key, this.email, this.token});
|
const VerificationPage({super.key, this.email, this.token, this.delivery});
|
||||||
final String? email;
|
final String? email;
|
||||||
final String? token;
|
final String? token;
|
||||||
|
final VerificationDelivery? delivery;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VerificationPage> createState() => _VerificationPageState();
|
State<VerificationPage> createState() => _VerificationPageState();
|
||||||
@@ -25,9 +26,12 @@ class VerificationPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _VerificationPageState extends State<VerificationPage> {
|
class _VerificationPageState extends State<VerificationPage> {
|
||||||
final _form = GlobalKey<FormState>();
|
final _form = GlobalKey<FormState>();
|
||||||
late final _email = TextEditingController(text: widget.email);
|
late final _email = TextEditingController(
|
||||||
|
text: widget.delivery?.email ?? widget.email,
|
||||||
|
);
|
||||||
late final _provider = VerificationProvider(
|
late final _provider = VerificationProvider(
|
||||||
context.read<VerificationService>(),
|
context.read<VerificationService>(),
|
||||||
|
delivery: widget.delivery,
|
||||||
);
|
);
|
||||||
final _emailFocus = FocusNode();
|
final _emailFocus = FocusNode();
|
||||||
String? _recipient;
|
String? _recipient;
|
||||||
@@ -44,7 +48,7 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (widget.token != null) {
|
if (widget.token != null) {
|
||||||
_provider.confirm(widget.token!);
|
_provider.confirm(widget.token!);
|
||||||
} else if (_email.text.isNotEmpty) {
|
} else if (widget.delivery == null && _email.text.isNotEmpty) {
|
||||||
_send();
|
_send();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -58,14 +62,26 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant VerificationPage oldWidget) {
|
void didUpdateWidget(covariant VerificationPage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.token == widget.token) return;
|
if (oldWidget.token == widget.token &&
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
oldWidget.email == widget.email &&
|
||||||
_provider.reset();
|
identical(oldWidget.delivery, widget.delivery)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldWidget.token != widget.token) {
|
||||||
|
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||||
|
}
|
||||||
|
_provider.reset(delivery: widget.delivery);
|
||||||
_lastWasSend = false;
|
_lastWasSend = false;
|
||||||
_validated = false;
|
_validated = false;
|
||||||
_email.clear();
|
_recipient = null;
|
||||||
|
_email.text = widget.delivery?.email ?? widget.email ?? '';
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && widget.token != null) _provider.confirm(widget.token!);
|
if (!mounted) return;
|
||||||
|
if (widget.token != null) {
|
||||||
|
_provider.confirm(widget.token!);
|
||||||
|
} else if (widget.delivery == null && _email.text.isNotEmpty) {
|
||||||
|
_send();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "18.0.1"
|
version: "18.0.1"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ dev_dependencies:
|
|||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
http: ^1.6.0
|
||||||
flutter:
|
flutter:
|
||||||
generate: true
|
generate: true
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
@@ -42,6 +42,44 @@ Future<GoRouter> openRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
testWidgets('queued signup receipt is transient and updates its recipient', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final router = await openRoute(tester, '/');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
router.goNamed(
|
||||||
|
AppRoutes.verificationSent,
|
||||||
|
extra: const VerificationDelivery(
|
||||||
|
email: 'first@example.com',
|
||||||
|
isSimulated: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(find.text('first@example.com'), findsOneWidget);
|
||||||
|
router.goNamed(
|
||||||
|
AppRoutes.verificationSent,
|
||||||
|
extra: const VerificationDelivery(
|
||||||
|
email: 'second@example.com',
|
||||||
|
isSimulated: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('first@example.com'), findsNothing);
|
||||||
|
expect(find.text('second@example.com'), findsOneWidget);
|
||||||
|
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||||
|
router.routerDelegate.currentConfiguration,
|
||||||
|
)!;
|
||||||
|
expect(restored.state.toString(), isNot(contains('second@example.com')));
|
||||||
|
expect(restored.uri.toString(), '/verification-sent');
|
||||||
|
router.goNamed(AppRoutes.login);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
router.goNamed(AppRoutes.verificationSent);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget);
|
||||||
|
expect(find.text('second@example.com'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
for (final language in ['en', 'ru']) {
|
for (final language in ['en', 'ru']) {
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'verified link keeps success visible and prefills login ($language)',
|
'verified link keeps success visible and prefills login ($language)',
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
class DeliveryBackend extends BackendAuthServices {
|
||||||
|
DeliveryBackend(List<String> requests)
|
||||||
|
: super(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
requests.add(request.url.path);
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'data': request.url.path.endsWith('/signup')
|
||||||
|
? {'userId': 'customer'}
|
||||||
|
: {'accepted': true},
|
||||||
|
}),
|
||||||
|
202,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
int sends = 0;
|
||||||
|
bool failResend = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<VerificationResult> send({
|
||||||
|
required String email,
|
||||||
|
required String locale,
|
||||||
|
}) async {
|
||||||
|
sends++;
|
||||||
|
if (failResend) {
|
||||||
|
throw const VerificationException(VerificationError.internal);
|
||||||
|
}
|
||||||
|
return super.send(email: email, locale: locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final language in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'accepted signup survives an unavailable resend service ($language)',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
final requests = <String>[];
|
||||||
|
final backend = DeliveryBackend(requests);
|
||||||
|
final analytics = DummyAnalyticsService();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
backend: backend,
|
||||||
|
analyticsService: analytics,
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(language)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final signup = find.text(language == 'ru' ? 'Регистрация' : 'Sign up');
|
||||||
|
await tester.ensureVisible(signup);
|
||||||
|
await tester.tap(signup);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final entry in {
|
||||||
|
'name': 'Ada Lovelace',
|
||||||
|
'email': 'ada@example.com',
|
||||||
|
'password': 'password preserved',
|
||||||
|
'confirmation': 'password preserved',
|
||||||
|
}.entries) {
|
||||||
|
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
|
||||||
|
}
|
||||||
|
final submit = find.byKey(const ValueKey('submit'));
|
||||||
|
await tester.ensureVisible(submit);
|
||||||
|
await tester.tap(submit);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(find.text('ada@example.com'), findsOneWidget);
|
||||||
|
expect(backend.sends, 0);
|
||||||
|
expect(requests, ['/api/v1/accounts/signup']);
|
||||||
|
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
|
||||||
|
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||||
|
router.routerDelegate.currentConfiguration,
|
||||||
|
)!;
|
||||||
|
expect(restored.uri.toString(), '/verification-sent');
|
||||||
|
expect(restored.state.toString(), isNot(contains('ada@example.com')));
|
||||||
|
expect(
|
||||||
|
analytics.events.where(
|
||||||
|
(event) => event.name == 'auth_signup_succeeded',
|
||||||
|
),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.where(
|
||||||
|
(event) => event.name == 'auth_email_delivery_succeeded',
|
||||||
|
),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
final resend = find.byKey(const ValueKey('verification-resend'));
|
||||||
|
await tester.ensureVisible(resend);
|
||||||
|
await tester.tap(resend);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('verification-error')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(backend.sends, 1);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<TextFormField>(
|
||||||
|
find.byKey(const ValueKey('verification-email')),
|
||||||
|
)
|
||||||
|
.controller!
|
||||||
|
.text,
|
||||||
|
'ada@example.com',
|
||||||
|
);
|
||||||
|
backend.failResend = false;
|
||||||
|
await tester.ensureVisible(resend);
|
||||||
|
await tester.tap(resend);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(backend.sends, 2);
|
||||||
|
expect(requests.last, '/api/v1/accounts/verification/send');
|
||||||
|
expect(requests, hasLength(2));
|
||||||
|
expect(
|
||||||
|
analytics.events.map((event) => event.journeyId).toSet(),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.map((event) => event.properties).toString(),
|
||||||
|
isNot(contains('ada@example.com')),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,13 @@ abstract final class BackendMapper {
|
|||||||
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
|
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
static AuthResult signup(BackendDTO dto) {
|
static AuthResult signup(BackendDTO dto, {required String email}) {
|
||||||
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,
|
||||||
|
delivery: VerificationDelivery(email: email, isSimulated: false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static FactorChallenge challenge(BackendDTO dto, String id) {
|
static FactorChallenge challenge(BackendDTO dto, String id) {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import '../models/verification.dart';
|
|||||||
abstract final class AuthMapper {
|
abstract final class AuthMapper {
|
||||||
static AuthResult fromDTO(AuthDTO dto) =>
|
static AuthResult fromDTO(AuthDTO dto) =>
|
||||||
AuthResult(userId: dto.userId, isSimulated: dto.simulated);
|
AuthResult(userId: dto.userId, isSimulated: dto.simulated);
|
||||||
|
|
||||||
|
static AuthResult signup(AuthDTO dto, {required String email}) => AuthResult(
|
||||||
|
userId: dto.userId,
|
||||||
|
isSimulated: dto.simulated,
|
||||||
|
delivery: VerificationDelivery(email: email, isSimulated: dto.simulated),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract final class VerificationMapper {
|
abstract final class VerificationMapper {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'two_factor.dart';
|
import 'two_factor.dart';
|
||||||
|
import 'verification.dart';
|
||||||
|
|
||||||
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
||||||
class LoginRequest {
|
class LoginRequest {
|
||||||
@@ -31,9 +32,11 @@ class AuthResult {
|
|||||||
required this.userId,
|
required this.userId,
|
||||||
required this.isSimulated,
|
required this.isSimulated,
|
||||||
this.challenge,
|
this.challenge,
|
||||||
|
this.delivery,
|
||||||
});
|
});
|
||||||
final String userId;
|
final String userId;
|
||||||
final FactorChallenge? challenge;
|
final FactorChallenge? challenge;
|
||||||
|
final VerificationDelivery? delivery;
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ enum VerificationStatus {
|
|||||||
|
|
||||||
enum VerificationError { expired, invalid, internal, needsAssistance }
|
enum VerificationError { expired, invalid, internal, needsAssistance }
|
||||||
|
|
||||||
|
/// An accepted email-queue operation, not proof of inbox delivery or activation.
|
||||||
|
class VerificationDelivery {
|
||||||
|
const VerificationDelivery({required this.email, required this.isSimulated});
|
||||||
|
final String email;
|
||||||
|
final bool isSimulated;
|
||||||
|
}
|
||||||
|
|
||||||
class VerificationException implements Exception {
|
class VerificationException implements Exception {
|
||||||
const VerificationException(this.code);
|
const VerificationException(this.code);
|
||||||
final VerificationError code;
|
final VerificationError code;
|
||||||
|
|||||||
@@ -4,13 +4,22 @@ import '../models/verification.dart';
|
|||||||
import '../services/verification.dart';
|
import '../services/verification.dart';
|
||||||
|
|
||||||
class VerificationProvider extends ChangeNotifier {
|
class VerificationProvider extends ChangeNotifier {
|
||||||
VerificationProvider(this.service);
|
VerificationProvider(this.service, {VerificationDelivery? delivery})
|
||||||
|
: _state = _initialState(delivery);
|
||||||
final VerificationService service;
|
final VerificationService service;
|
||||||
VerificationState _state = const VerificationState(VerificationStatus.idle);
|
VerificationState _state;
|
||||||
VerificationState get state => _state;
|
VerificationState get state => _state;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
int _generation = 0;
|
int _generation = 0;
|
||||||
|
|
||||||
|
static VerificationState _initialState(VerificationDelivery? delivery) =>
|
||||||
|
delivery == null
|
||||||
|
? const VerificationState(VerificationStatus.idle)
|
||||||
|
: VerificationState(
|
||||||
|
VerificationStatus.sent,
|
||||||
|
isSimulated: delivery.isSimulated,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> send({required String email, required String locale}) => _run(
|
Future<void> send({required String email, required String locale}) => _run(
|
||||||
() => service.send(email: email.trim(), locale: locale),
|
() => service.send(email: email.trim(), locale: locale),
|
||||||
VerificationStatus.sending,
|
VerificationStatus.sending,
|
||||||
@@ -61,10 +70,10 @@ class VerificationProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset({VerificationDelivery? delivery}) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
++_generation;
|
++_generation;
|
||||||
_state = const VerificationState(VerificationStatus.idle);
|
_state = _initialState(delivery);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class BackendAuthServices
|
|||||||
final _pending = <String, ({String token, String challengeId})>{};
|
final _pending = <String, ({String token, String challengeId})>{};
|
||||||
final _grants = <String, AuthSession>{};
|
final _grants = <String, AuthSession>{};
|
||||||
final _retryKeys = <String, String>{};
|
final _retryKeys = <String, String>{};
|
||||||
String? _queuedSignup;
|
|
||||||
AuthSession? _session;
|
AuthSession? _session;
|
||||||
AuthSession? get session => _session;
|
AuthSession? get session => _session;
|
||||||
@override
|
@override
|
||||||
@@ -113,9 +112,8 @@ class BackendAuthServices
|
|||||||
'locale': r.locale,
|
'locale': r.locale,
|
||||||
'idempotencyKey': key,
|
'idempotencyKey': key,
|
||||||
});
|
});
|
||||||
final result = BackendMapper.signup(dto);
|
final result = BackendMapper.signup(dto, email: email);
|
||||||
_retryKeys.remove('signup:$email');
|
_retryKeys.remove('signup:$email');
|
||||||
_queuedSignup = email;
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,14 +139,6 @@ class BackendAuthServices
|
|||||||
required String locale,
|
required String locale,
|
||||||
}) async {
|
}) async {
|
||||||
final normalized = email.trim().toLowerCase();
|
final normalized = email.trim().toLowerCase();
|
||||||
// Signup already queued this email. The existing confirmation page requests
|
|
||||||
// its initial delivery on mount; consume that receipt once.
|
|
||||||
if (_queuedSignup == normalized) {
|
|
||||||
_queuedSignup = null;
|
|
||||||
return VerificationMapper.fromDTO(
|
|
||||||
const VerificationDTO(simulated: false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
||||||
try {
|
try {
|
||||||
await _post('verification/send', {
|
await _post('verification/send', {
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ class DummySignupService implements SignupService, SimulationInfo {
|
|||||||
Future<AuthResult> signup(SignupRequest request) async {
|
Future<AuthResult> signup(SignupRequest request) async {
|
||||||
await Future<void>.delayed(delay);
|
await Future<void>.delayed(delay);
|
||||||
if (failure case final error?) throw AuthException(error);
|
if (failure case final error?) throw AuthException(error);
|
||||||
return AuthMapper.fromDTO(
|
return AuthMapper.signup(
|
||||||
const AuthDTO(userId: 'demo-user', simulated: true),
|
const AuthDTO(userId: 'demo-user', simulated: true),
|
||||||
|
email: request.email.trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,13 +111,27 @@ class InstrumentedAuthServices
|
|||||||
simulation: _simulation(loginService),
|
simulation: _simulation(loginService),
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
Future<AuthResult> signup(SignupRequest request) => _run(
|
Future<AuthResult> signup(SignupRequest request) async {
|
||||||
JourneyKind.signup,
|
final result = await _run(
|
||||||
JourneyStep.signup,
|
JourneyKind.signup,
|
||||||
() => signupService.signup(request),
|
JourneyStep.signup,
|
||||||
(r) => r.isSimulated,
|
() => signupService.signup(request),
|
||||||
simulation: _simulation(signupService),
|
(r) => r.isSimulated,
|
||||||
);
|
simulation: _simulation(signupService),
|
||||||
|
);
|
||||||
|
final delivery = result.delivery;
|
||||||
|
if (delivery != null) {
|
||||||
|
final operation = recorder.begin(
|
||||||
|
JourneyKind.verification,
|
||||||
|
JourneyStep.emailDelivery,
|
||||||
|
onboarding: true,
|
||||||
|
simulated: delivery.isSimulated,
|
||||||
|
);
|
||||||
|
recorder.finish(operation, simulated: delivery.isSimulated);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> send({
|
Future<VerificationResult> send({
|
||||||
required String email,
|
required String email,
|
||||||
|
|||||||
@@ -142,11 +142,11 @@ void main() {
|
|||||||
.isSimulated,
|
.isSimulated,
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(
|
final accepted = await const DummySignupService(delay: Duration.zero)
|
||||||
(await const DummySignupService(delay: Duration.zero).signup(signup))
|
.signup(signup);
|
||||||
.isSimulated,
|
expect(accepted.isSimulated, isTrue);
|
||||||
isTrue,
|
expect(accepted.delivery?.email, 'a@example.com');
|
||||||
);
|
expect(accepted.delivery?.isSimulated, isTrue);
|
||||||
await expectLater(
|
await expectLater(
|
||||||
const DummyLoginService(
|
const DummyLoginService(
|
||||||
delay: Duration.zero,
|
delay: Duration.zero,
|
||||||
|
|||||||
@@ -89,35 +89,48 @@ void main() {
|
|||||||
expect(keys[0], keys[1]);
|
expect(keys[0], keys[1]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
test(
|
||||||
final paths = <String>[];
|
'signup returns its receipt and every explicit resend reaches API',
|
||||||
final service = BackendAuthServices(
|
() async {
|
||||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
final paths = <String>[];
|
||||||
clientId: 'web',
|
final service = BackendAuthServices(
|
||||||
client: MockClient((request) async {
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
paths.add(request.url.path);
|
clientId: 'web',
|
||||||
if (request.url.path.endsWith('/signup')) {
|
client: MockClient((request) async {
|
||||||
expect(jsonDecode(request.body)['password'], ' password preserved ');
|
paths.add(request.url.path);
|
||||||
return response({'userId': 'user'});
|
if (request.url.path.endsWith('/signup')) {
|
||||||
}
|
expect(
|
||||||
return response({'accepted': true});
|
jsonDecode(request.body)['password'],
|
||||||
}),
|
' password preserved ',
|
||||||
);
|
);
|
||||||
addTearDown(service.close);
|
return response({'userId': 'user'});
|
||||||
final result = await service.signup(
|
}
|
||||||
const SignupRequest(
|
return response({'accepted': true});
|
||||||
name: 'User',
|
}),
|
||||||
email: 'USER@example.com',
|
);
|
||||||
password: ' password preserved ',
|
addTearDown(service.close);
|
||||||
locale: 'en',
|
final result = await service.signup(
|
||||||
),
|
const SignupRequest(
|
||||||
);
|
name: 'User',
|
||||||
expect(result.isSimulated, isFalse);
|
email: 'USER@example.com',
|
||||||
await service.send(email: 'user@example.com', locale: 'en');
|
password: ' password preserved ',
|
||||||
expect(paths, ['/api/v1/accounts/signup']);
|
locale: 'en',
|
||||||
await service.send(email: 'user@example.com', locale: 'en');
|
),
|
||||||
expect(paths.last, '/api/v1/accounts/verification/send');
|
);
|
||||||
});
|
expect(result.isSimulated, isFalse);
|
||||||
|
expect(result.delivery?.email, 'user@example.com');
|
||||||
|
expect(result.delivery?.isSimulated, isFalse);
|
||||||
|
expect(paths, ['/api/v1/accounts/signup']);
|
||||||
|
await service.send(email: 'user@example.com', locale: 'en');
|
||||||
|
expect(paths, [
|
||||||
|
'/api/v1/accounts/signup',
|
||||||
|
'/api/v1/accounts/verification/send',
|
||||||
|
]);
|
||||||
|
await service.send(email: 'user@example.com', locale: 'en');
|
||||||
|
expect(paths.last, '/api/v1/accounts/verification/send');
|
||||||
|
expect(paths, hasLength(3));
|
||||||
|
},
|
||||||
|
);
|
||||||
test('login confirms backend session only after factor and consumes local grant once', () async {
|
test('login confirms backend session only after factor and consumes local grant once', () async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
var verified = false;
|
var verified = false;
|
||||||
|
|||||||
@@ -28,6 +28,36 @@ class PendingService implements VerificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
test(
|
||||||
|
'accepted delivery initializes sent state without calling the service',
|
||||||
|
() async {
|
||||||
|
final service = PendingService();
|
||||||
|
final provider = VerificationProvider(
|
||||||
|
service,
|
||||||
|
delivery: const VerificationDelivery(
|
||||||
|
email: 'ada@example.com',
|
||||||
|
isSimulated: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
expect(provider.state.status, VerificationStatus.sent);
|
||||||
|
expect(provider.state.isSimulated, isFalse);
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
expect(service.calls, 0);
|
||||||
|
final resend = provider.send(email: 'ada@example.com', locale: 'en');
|
||||||
|
expect(provider.state.status, VerificationStatus.sending);
|
||||||
|
expect(service.calls, 1);
|
||||||
|
service.pending.completeError(
|
||||||
|
const VerificationException(VerificationError.internal),
|
||||||
|
);
|
||||||
|
await resend;
|
||||||
|
expect(provider.state.status, VerificationStatus.error);
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
provider.reset();
|
||||||
|
expect(provider.state.status, VerificationStatus.idle);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (final scenario in {
|
for (final scenario in {
|
||||||
'demo-valid': VerificationStatus.verified,
|
'demo-valid': VerificationStatus.verified,
|
||||||
'demo-expired': VerificationStatus.expired,
|
'demo-expired': VerificationStatus.expired,
|
||||||
|
|||||||
Reference in new issue
Block a user