Show accepted signup delivery without a second send step (#62)
This commit is contained in:
1 parent
e84d2d1ad3
commit
3af60f38b1
19 files changed
+421
-69
No files matched your search
@@ -12,9 +12,13 @@ abstract final class BackendMapper {
|
||||
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');
|
||||
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) {
|
||||
|
||||
@@ -5,6 +5,12 @@ import '../models/verification.dart';
|
||||
abstract final class AuthMapper {
|
||||
static AuthResult fromDTO(AuthDTO dto) =>
|
||||
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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'two_factor.dart';
|
||||
import 'verification.dart';
|
||||
|
||||
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
||||
class LoginRequest {
|
||||
@@ -31,9 +32,11 @@ class AuthResult {
|
||||
required this.userId,
|
||||
required this.isSimulated,
|
||||
this.challenge,
|
||||
this.delivery,
|
||||
});
|
||||
final String userId;
|
||||
final FactorChallenge? challenge;
|
||||
final VerificationDelivery? delivery;
|
||||
final bool isSimulated;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ enum VerificationStatus {
|
||||
|
||||
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 {
|
||||
const VerificationException(this.code);
|
||||
final VerificationError code;
|
||||
|
||||
@@ -4,13 +4,22 @@ import '../models/verification.dart';
|
||||
import '../services/verification.dart';
|
||||
|
||||
class VerificationProvider extends ChangeNotifier {
|
||||
VerificationProvider(this.service);
|
||||
VerificationProvider(this.service, {VerificationDelivery? delivery})
|
||||
: _state = _initialState(delivery);
|
||||
final VerificationService service;
|
||||
VerificationState _state = const VerificationState(VerificationStatus.idle);
|
||||
VerificationState _state;
|
||||
VerificationState get state => _state;
|
||||
bool _disposed = false;
|
||||
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(
|
||||
() => service.send(email: email.trim(), locale: locale),
|
||||
VerificationStatus.sending,
|
||||
@@ -61,10 +70,10 @@ class VerificationProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
void reset({VerificationDelivery? delivery}) {
|
||||
if (_disposed) return;
|
||||
++_generation;
|
||||
_state = const VerificationState(VerificationStatus.idle);
|
||||
_state = _initialState(delivery);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ class BackendAuthServices
|
||||
final _pending = <String, ({String token, String challengeId})>{};
|
||||
final _grants = <String, AuthSession>{};
|
||||
final _retryKeys = <String, String>{};
|
||||
String? _queuedSignup;
|
||||
AuthSession? _session;
|
||||
AuthSession? get session => _session;
|
||||
@override
|
||||
@@ -113,9 +112,8 @@ class BackendAuthServices
|
||||
'locale': r.locale,
|
||||
'idempotencyKey': key,
|
||||
});
|
||||
final result = BackendMapper.signup(dto);
|
||||
final result = BackendMapper.signup(dto, email: email);
|
||||
_retryKeys.remove('signup:$email');
|
||||
_queuedSignup = email;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -141,14 +139,6 @@ class BackendAuthServices
|
||||
required String locale,
|
||||
}) async {
|
||||
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);
|
||||
try {
|
||||
await _post('verification/send', {
|
||||
|
||||
@@ -40,8 +40,9 @@ class DummySignupService implements SignupService, SimulationInfo {
|
||||
Future<AuthResult> signup(SignupRequest request) async {
|
||||
await Future<void>.delayed(delay);
|
||||
if (failure case final error?) throw AuthException(error);
|
||||
return AuthMapper.fromDTO(
|
||||
return AuthMapper.signup(
|
||||
const AuthDTO(userId: 'demo-user', simulated: true),
|
||||
email: request.email.trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,13 +111,27 @@ class InstrumentedAuthServices
|
||||
simulation: _simulation(loginService),
|
||||
);
|
||||
@override
|
||||
Future<AuthResult> signup(SignupRequest request) => _run(
|
||||
JourneyKind.signup,
|
||||
JourneyStep.signup,
|
||||
() => signupService.signup(request),
|
||||
(r) => r.isSimulated,
|
||||
simulation: _simulation(signupService),
|
||||
);
|
||||
Future<AuthResult> signup(SignupRequest request) async {
|
||||
final result = await _run(
|
||||
JourneyKind.signup,
|
||||
JourneyStep.signup,
|
||||
() => signupService.signup(request),
|
||||
(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
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
|
||||
@@ -142,11 +142,11 @@ void main() {
|
||||
.isSimulated,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
(await const DummySignupService(delay: Duration.zero).signup(signup))
|
||||
.isSimulated,
|
||||
isTrue,
|
||||
);
|
||||
final accepted = await const DummySignupService(delay: Duration.zero)
|
||||
.signup(signup);
|
||||
expect(accepted.isSimulated, isTrue);
|
||||
expect(accepted.delivery?.email, 'a@example.com');
|
||||
expect(accepted.delivery?.isSimulated, isTrue);
|
||||
await expectLater(
|
||||
const DummyLoginService(
|
||||
delay: Duration.zero,
|
||||
|
||||
@@ -89,35 +89,48 @@ void main() {
|
||||
expect(keys[0], keys[1]);
|
||||
},
|
||||
);
|
||||
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
||||
final paths = <String>[];
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((request) async {
|
||||
paths.add(request.url.path);
|
||||
if (request.url.path.endsWith('/signup')) {
|
||||
expect(jsonDecode(request.body)['password'], ' password preserved ');
|
||||
return response({'userId': 'user'});
|
||||
}
|
||||
return response({'accepted': true});
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.signup(
|
||||
const SignupRequest(
|
||||
name: 'User',
|
||||
email: 'USER@example.com',
|
||||
password: ' password preserved ',
|
||||
locale: 'en',
|
||||
),
|
||||
);
|
||||
expect(result.isSimulated, isFalse);
|
||||
await service.send(email: 'user@example.com', locale: 'en');
|
||||
expect(paths, ['/api/v1/accounts/signup']);
|
||||
await service.send(email: 'user@example.com', locale: 'en');
|
||||
expect(paths.last, '/api/v1/accounts/verification/send');
|
||||
});
|
||||
test(
|
||||
'signup returns its receipt and every explicit resend reaches API',
|
||||
() async {
|
||||
final paths = <String>[];
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((request) async {
|
||||
paths.add(request.url.path);
|
||||
if (request.url.path.endsWith('/signup')) {
|
||||
expect(
|
||||
jsonDecode(request.body)['password'],
|
||||
' password preserved ',
|
||||
);
|
||||
return response({'userId': 'user'});
|
||||
}
|
||||
return response({'accepted': true});
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.signup(
|
||||
const SignupRequest(
|
||||
name: 'User',
|
||||
email: 'USER@example.com',
|
||||
password: ' password preserved ',
|
||||
locale: 'en',
|
||||
),
|
||||
);
|
||||
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 {
|
||||
final now = DateTime.now();
|
||||
var verified = false;
|
||||
|
||||
@@ -28,6 +28,36 @@ class PendingService implements VerificationService {
|
||||
}
|
||||
|
||||
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 {
|
||||
'demo-valid': VerificationStatus.verified,
|
||||
'demo-expired': VerificationStatus.expired,
|
||||
|
||||
Reference in new issue
Block a user