Compare commits
4
Commits
auth-56
...
fa7b75d1b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa7b75d1b8 | ||
|
|
a587cbbc24 | ||
|
|
5f3b443e7b | ||
|
|
30f06c2f8f |
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
### 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.
|
||||
@@ -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<AuthForm> createState() => _AuthFormState();
|
||||
@@ -33,7 +34,7 @@ class _AuthFormState extends State<AuthForm> {
|
||||
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<AuthForm> {
|
||||
key: const ValueKey('password'),
|
||||
enabled: !state.isLoading,
|
||||
controller: _password,
|
||||
autofocus: widget.email?.isNotEmpty ?? false,
|
||||
obscureText: !_showPassword,
|
||||
autofillHints: [
|
||||
_signUp
|
||||
|
||||
@@ -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<AuthPage> createState() => _AuthPageState();
|
||||
@@ -56,7 +57,7 @@ class _AuthPageState extends State<AuthPage> {
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: AuthForm(key: _formKey),
|
||||
child: AuthForm(key: _formKey, email: widget.email),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"verificationRequest": "Отправить новое письмо",
|
||||
"verificationRetry": "Повторить подтверждение",
|
||||
"verificationBack": "Вернуться ко входу",
|
||||
"verificationContinue": "Перейти ко входу",
|
||||
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
||||
"verificationDemoLink": "Перейти к подтверждению",
|
||||
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
||||
|
||||
@@ -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<void> main() async {
|
||||
usePathUrlStrategy();
|
||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
|
||||
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -318,15 +318,21 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -42,6 +42,67 @@ Future<GoRouter> 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<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', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -6,6 +6,8 @@ class BackendDTO {
|
||||
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
|
||||
);
|
||||
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 userId =>
|
||||
data['userId'] as String? ??
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -172,8 +172,10 @@ class BackendAuthServices
|
||||
|
||||
@override
|
||||
Future<VerificationResult> 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
|
||||
|
||||
@@ -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<VerificationResult> 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);
|
||||
|
||||
@@ -10,6 +10,48 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
void main() {
|
||||
http.Response response(Map<String, Object?> 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<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(
|
||||
'signup distinguishes support recovery and keeps the retry key',
|
||||
() async {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user