added guardian login with views for their assigned childs
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/demo/demo_mode.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/auth_login/auth_login_response.dart';
|
||||
import 'package:marianum_mobile/auth_link/device_binding.dart';
|
||||
import 'package:marianum_mobile/auth_link/guardian_login_link.dart';
|
||||
import 'package:marianum_mobile/auth_link/pending_guardian_request.dart';
|
||||
import 'package:marianum_mobile/session/session.dart';
|
||||
import 'package:marianum_mobile/view/login/guardian_login_controller.dart';
|
||||
|
||||
final _now = DateTime.utc(2026, 9, 19, 12);
|
||||
|
||||
class _FakeStore implements PendingGuardianRequestStore {
|
||||
PendingGuardianRequest? stored;
|
||||
|
||||
@override
|
||||
Future<PendingGuardianRequest?> read() async => stored;
|
||||
|
||||
@override
|
||||
Future<void> write(PendingGuardianRequest request) async => stored = request;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => stored = null;
|
||||
}
|
||||
|
||||
class _FakeRequest implements AuthGuardianRequest {
|
||||
String? lastChallenge;
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<AuthGuardianRequestResponse> run({
|
||||
required String email,
|
||||
required String deviceChallenge,
|
||||
required String tokenName,
|
||||
}) async {
|
||||
calls++;
|
||||
lastChallenge = deviceChallenge;
|
||||
return AuthGuardianRequestResponse(
|
||||
requestId: 'req-$calls',
|
||||
expiresAt: _now.add(const Duration(minutes: 15)),
|
||||
resendAvailableAt: _now.add(const Duration(seconds: 60)),
|
||||
codeLength: 6,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeVerify implements AuthGuardianVerify {
|
||||
Object? failWith;
|
||||
Map<String, String?>? lastCall;
|
||||
|
||||
@override
|
||||
Future<AuthLoginResponse> run({
|
||||
required String requestId,
|
||||
required String deviceVerifier,
|
||||
required String tokenName,
|
||||
String? code,
|
||||
String? linkToken,
|
||||
}) async {
|
||||
lastCall = {
|
||||
'requestId': requestId,
|
||||
'deviceVerifier': deviceVerifier,
|
||||
'code': code,
|
||||
'linkToken': linkToken,
|
||||
};
|
||||
final failure = failWith;
|
||||
if (failure != null) throw failure;
|
||||
return AuthLoginResponse(
|
||||
token: 't',
|
||||
tokenId: 'id',
|
||||
expiresAt: null,
|
||||
user: AuthLoginUser(
|
||||
id: 'u',
|
||||
username: 'e@x.de',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
userType: 'PARENT',
|
||||
className: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
GuardianLoginException _error(GuardianLoginError error) =>
|
||||
GuardianLoginException(error, userMessage: error.name);
|
||||
|
||||
void main() {
|
||||
late _FakeStore store;
|
||||
late _FakeRequest request;
|
||||
late _FakeVerify verify;
|
||||
late List<Session> signedIn;
|
||||
late DateTime now;
|
||||
|
||||
GuardianLoginController controller() => GuardianLoginController(
|
||||
request: request,
|
||||
verify: verify,
|
||||
store: store,
|
||||
signIn: (s) async => signedIn.add(s),
|
||||
tokenName: () async => 'test',
|
||||
now: () => now,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
store = _FakeStore();
|
||||
request = _FakeRequest();
|
||||
verify = _FakeVerify();
|
||||
signedIn = [];
|
||||
now = _now;
|
||||
});
|
||||
|
||||
test(
|
||||
'requesting a code persists the request and moves to the code step',
|
||||
() async {
|
||||
final c = controller();
|
||||
expect(await c.requestCode(' E@X.de '), isFalse);
|
||||
expect(c.step, GuardianLoginStep.enterCode);
|
||||
expect(store.stored?.email, 'e@x.de');
|
||||
expect(
|
||||
request.lastChallenge,
|
||||
DeviceBinding.challengeFor(store.stored!.deviceSecret),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a valid code signs in with the device secret and clears the request',
|
||||
() async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
final secret = store.stored!.deviceSecret;
|
||||
expect(await c.submitCode('123 456'), isTrue);
|
||||
expect(verify.lastCall?['code'], '123456');
|
||||
expect(verify.lastCall?['deviceVerifier'], secret);
|
||||
expect(store.stored, isNull);
|
||||
expect(signedIn.single, isA<GuardianSession>());
|
||||
expect((signedIn.single as GuardianSession).isDemo, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('a wrong code keeps the request for another try', () async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
verify.failWith = _error(GuardianLoginError.invalidCode);
|
||||
expect(await c.submitCode('000000'), isFalse);
|
||||
expect(c.step, GuardianLoginStep.enterCode);
|
||||
expect(c.errorMessage, isNotNull);
|
||||
expect(store.stored, isNotNull);
|
||||
});
|
||||
|
||||
test('an expired request falls back to the e-mail step', () async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
verify.failWith = _error(GuardianLoginError.requestExpired);
|
||||
await c.submitCode('123456');
|
||||
expect(c.step, GuardianLoginStep.enterEmail);
|
||||
expect(store.stored, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'a link for another request is rejected without a server call',
|
||||
() async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
final ok = await c.submitLink(
|
||||
const GuardianLoginLink(requestId: 'other', linkToken: 'lt'),
|
||||
);
|
||||
expect(ok, isFalse);
|
||||
expect(verify.lastCall, isNull);
|
||||
expect(c.errorMessage, contains('anderen Gerät'));
|
||||
},
|
||||
);
|
||||
|
||||
test('a matching link signs in', () async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
final ok = await c.submitLink(
|
||||
GuardianLoginLink(requestId: store.stored!.requestId, linkToken: 'lt'),
|
||||
);
|
||||
expect(ok, isTrue);
|
||||
expect(verify.lastCall?['linkToken'], 'lt');
|
||||
});
|
||||
|
||||
test('restore resumes an open request and drops an expired one', () async {
|
||||
await controller().requestCode('e@x.de');
|
||||
final resumed = controller();
|
||||
await resumed.restore();
|
||||
expect(resumed.step, GuardianLoginStep.enterCode);
|
||||
|
||||
now = _now.add(const Duration(hours: 1));
|
||||
final late = controller();
|
||||
await late.restore();
|
||||
expect(late.step, GuardianLoginStep.enterEmail);
|
||||
expect(store.stored, isNull);
|
||||
});
|
||||
|
||||
test('resend is only possible after the cooldown', () async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
expect(c.canResend(), isFalse);
|
||||
await c.resend();
|
||||
expect(request.calls, 1);
|
||||
now = _now.add(const Duration(seconds: 61));
|
||||
expect(c.canResend(), isTrue);
|
||||
await c.resend();
|
||||
expect(request.calls, 2);
|
||||
});
|
||||
|
||||
test('the demo address signs in locally', () async {
|
||||
final c = controller();
|
||||
expect(await c.requestCode(DemoMode.guardianEmail), isTrue);
|
||||
expect(request.calls, 0);
|
||||
expect(signedIn.single.isDemo, isTrue);
|
||||
});
|
||||
|
||||
group('GuardianLoginException.fromDio', () {
|
||||
DioException failure(int status, Object? body) => DioException(
|
||||
requestOptions: RequestOptions(),
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(),
|
||||
statusCode: status,
|
||||
data: body,
|
||||
),
|
||||
);
|
||||
|
||||
test('reads the JSON error body', () {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(
|
||||
failure(401, {'error': 'invalid_code', 'attemptsLeft': 2}),
|
||||
)
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.invalidCode);
|
||||
expect(e.attemptsLeft, 2);
|
||||
expect(e.userMessage, contains('Noch 2 Versuche'));
|
||||
});
|
||||
|
||||
test('reads the plain-text error of the generic server handler', () {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(
|
||||
failure(403, 'Fehler: device_mismatch'),
|
||||
)
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.deviceMismatch);
|
||||
});
|
||||
|
||||
test('falls back to the status code', () {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(failure(429, null))
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.rateLimited);
|
||||
});
|
||||
|
||||
test('names an address the school has no guardian access for', () {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(
|
||||
failure(404, {'error': 'email_not_registered'}),
|
||||
)
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.emailNotRegistered);
|
||||
expect(e.userMessage, contains('Sekretariat'));
|
||||
});
|
||||
|
||||
test('a bare 404 means the server lacks guardian login, not an '
|
||||
'unknown address', () {
|
||||
for (final body in <Object?>[null, 'Not Found', 'Endpoint not found']) {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(failure(404, body))
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.unsupportedServer, reason: '$body');
|
||||
}
|
||||
final methodMissing =
|
||||
GuardianLoginException.fromDio(failure(405, null))
|
||||
as GuardianLoginException;
|
||||
expect(methodMissing.error, GuardianLoginError.unsupportedServer);
|
||||
});
|
||||
|
||||
test('names a disabled guardian account', () {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(
|
||||
failure(403, {'error': 'account_disabled'}),
|
||||
)
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.accountDisabled);
|
||||
});
|
||||
|
||||
test('server errors keep the generic mapping', () {
|
||||
expect(
|
||||
GuardianLoginException.fromDio(failure(500, 'boom')),
|
||||
isNot(isA<GuardianLoginException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user