330 lines
11 KiB
Dart
330 lines
11 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
|
|
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 {
|
|
final keys = <String>[];
|
|
var failed = true;
|
|
final service = BackendAuthServices(
|
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
|
clientId: 'web',
|
|
client: MockClient((request) async {
|
|
keys.add(jsonDecode(request.body)['idempotencyKey'] as String);
|
|
return failed
|
|
? response({'error': 'signup_attention_required'}, 503)
|
|
: response({'userId': 'user'});
|
|
}),
|
|
);
|
|
addTearDown(service.close);
|
|
const input = SignupRequest(
|
|
name: 'Ada',
|
|
email: 'ada@example.com',
|
|
password: 'password preserved',
|
|
locale: 'en',
|
|
);
|
|
await expectLater(
|
|
service.signup(input),
|
|
throwsA(
|
|
isA<AuthException>().having(
|
|
(error) => error.code,
|
|
'code',
|
|
AuthError.signupNeedsAssistance,
|
|
),
|
|
),
|
|
);
|
|
failed = false;
|
|
expect((await service.signup(input)).isSimulated, isFalse);
|
|
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('login confirms backend session only after factor and consumes local grant once', () async {
|
|
final now = DateTime.now();
|
|
var verified = false;
|
|
final service = BackendAuthServices(
|
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
|
clientId: 'web',
|
|
client: MockClient((request) async {
|
|
final body = jsonDecode(request.body);
|
|
if (request.url.path.endsWith('/login')) {
|
|
expect(body['login'], {
|
|
'login': 'user@example.com',
|
|
'password': ' password ',
|
|
});
|
|
expect(body['clientId'], 'web');
|
|
return response({
|
|
'userId': 'user',
|
|
'pendingToken': {'token': 'signed-pending'},
|
|
'challengeId': 'server-challenge',
|
|
'channel': 'email',
|
|
'target': 'u***@example.com',
|
|
'expiresAt': now.add(const Duration(minutes: 10)).toIso8601String(),
|
|
'resendAt': now.add(const Duration(minutes: 1)).toIso8601String(),
|
|
});
|
|
}
|
|
expect(request.url.path, '/api/v1/accounts/login/verify');
|
|
expect(body['pendingToken'], 'signed-pending');
|
|
expect(body['challengeId'], 'server-challenge');
|
|
if (body['code'] != '123456')
|
|
return response({'error': 'invalid'}, 400);
|
|
verified = true;
|
|
return response({
|
|
'account': {'id': 'user'},
|
|
'accessToken': {'token': 'access'},
|
|
'refreshToken': {'token': 'refresh'},
|
|
}, 200);
|
|
}),
|
|
);
|
|
addTearDown(service.close);
|
|
final result = await service.login(
|
|
const LoginRequest(
|
|
email: 'user@example.com',
|
|
password: ' password ',
|
|
locale: 'en',
|
|
),
|
|
);
|
|
expect(result.challenge, isNotNull);
|
|
expect(service.session, isNull);
|
|
final challenge = result.challenge!;
|
|
expect(challenge.id, isNot('signed-pending'));
|
|
await expectLater(
|
|
service.verify(challenge.id, '000000'),
|
|
throwsA(isA<FactorException>()),
|
|
);
|
|
expect(verified, isFalse);
|
|
expect(service.session, isNull);
|
|
final grant = await service.verify(challenge.id, '123456');
|
|
final accepted = await service.completeLogin(challenge, grant);
|
|
expect(accepted.userId, 'user');
|
|
expect(accepted.isSimulated, isFalse);
|
|
expect(service.session!.accessToken, 'access');
|
|
await expectLater(
|
|
service.completeLogin(challenge, grant),
|
|
throwsA(isA<AuthException>()),
|
|
);
|
|
await expectLater(
|
|
service.authorize(FactorPurpose.payout, 'payment', grant.token),
|
|
throwsA(isA<FactorException>()),
|
|
);
|
|
});
|
|
test(
|
|
'failed delivery preserves resend idempotency and never claims success',
|
|
() async {
|
|
final keys = <String>[];
|
|
final service = BackendAuthServices(
|
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
|
clientId: 'web',
|
|
client: MockClient((request) async {
|
|
keys.add(jsonDecode(request.body)['idempotencyKey'] as String);
|
|
return response({'error': 'unavailable'}, 503);
|
|
}),
|
|
);
|
|
addTearDown(service.close);
|
|
for (var i = 0; i < 2; i++) {
|
|
await expectLater(
|
|
service.send(email: 'user@example.com', locale: 'en'),
|
|
throwsA(isA<VerificationException>()),
|
|
);
|
|
}
|
|
expect(keys[0], keys[1]);
|
|
},
|
|
);
|
|
test('expired activation maps to recovery state', () async {
|
|
final service = BackendAuthServices(
|
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
|
clientId: 'web',
|
|
client: MockClient((_) async => response({'error': 'expired'}, 400)),
|
|
);
|
|
addTearDown(service.close);
|
|
await expectLater(
|
|
service.confirm(token: 'expired'),
|
|
throwsA(
|
|
isA<VerificationException>().having(
|
|
(e) => e.code,
|
|
'code',
|
|
VerificationError.expired,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
test(
|
|
'verification recovery preserves assistance instead of requesting retry',
|
|
() async {
|
|
final service = BackendAuthServices(
|
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
|
clientId: 'web',
|
|
client: MockClient(
|
|
(_) async => response({'error': 'signup_attention_required'}, 503),
|
|
),
|
|
);
|
|
addTearDown(service.close);
|
|
final provider = VerificationProvider(service);
|
|
addTearDown(provider.dispose);
|
|
await provider.send(email: 'ada@example.com', locale: 'en');
|
|
expect(provider.state.status, VerificationStatus.error);
|
|
expect(provider.state.error, VerificationError.needsAssistance);
|
|
await expectLater(
|
|
service.confirm(token: 'old-token'),
|
|
throwsA(
|
|
isA<VerificationException>().having(
|
|
(e) => e.code,
|
|
'code',
|
|
VerificationError.needsAssistance,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
test(
|
|
'resend preserves server challenge and retries the same delivery key',
|
|
() async {
|
|
final now = DateTime.now();
|
|
final keys = <String>[];
|
|
Map<String, Object?> challenge() => {
|
|
'userId': 'user',
|
|
'pendingToken': {'token': 'pending'},
|
|
'challengeId': 'server-challenge',
|
|
'channel': 'email',
|
|
'target': 's***@example.com',
|
|
'expiresAt': now.add(const Duration(minutes: 10)).toIso8601String(),
|
|
'resendAt': now.add(const Duration(minutes: 1)).toIso8601String(),
|
|
};
|
|
final service = BackendAuthServices(
|
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
|
clientId: 'web',
|
|
client: MockClient((request) async {
|
|
if (request.url.path.endsWith('/login')) return response(challenge());
|
|
final body = jsonDecode(request.body);
|
|
expect(body['challengeId'], 'server-challenge');
|
|
expect(body['pendingToken'], 'pending');
|
|
keys.add(body['idempotencyKey'] as String);
|
|
if (keys.length == 1) return response({'error': 'unavailable'}, 503);
|
|
return response(challenge());
|
|
}),
|
|
);
|
|
addTearDown(service.close);
|
|
final result = await service.login(
|
|
const LoginRequest(
|
|
email: 'user@example.com',
|
|
password: 'password',
|
|
locale: 'en',
|
|
),
|
|
);
|
|
final id = result.challenge!.id;
|
|
await expectLater(service.resend(id), throwsA(isA<FactorException>()));
|
|
final resent = await service.resend(id);
|
|
expect(resent.id, id);
|
|
expect(resent.target, 's***@example.com');
|
|
expect(keys[0], keys[1]);
|
|
expect(service.session, isNull);
|
|
},
|
|
);
|
|
for (final channel in ['sms', '']) {
|
|
test(
|
|
'unsupported or missing channel $channel cannot create a login challenge',
|
|
() async {
|
|
final service = BackendAuthServices(
|
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
|
clientId: 'web',
|
|
client: MockClient(
|
|
(_) async =>
|
|
response({'challengeId': 'server', 'channel': channel}),
|
|
),
|
|
);
|
|
addTearDown(service.close);
|
|
await expectLater(
|
|
service.login(
|
|
const LoginRequest(
|
|
email: 'user@example.com',
|
|
password: 'password',
|
|
locale: 'en',
|
|
),
|
|
),
|
|
throwsA(isA<FactorException>()),
|
|
);
|
|
expect(service.session, isNull);
|
|
},
|
|
);
|
|
}
|
|
}
|