forked from baron/baron-sso
621 lines
20 KiB
Dart
621 lines
20 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:userfront/core/services/auth_proxy_service.dart';
|
|
import 'package:userfront/core/services/auth_token_store.dart';
|
|
|
|
void main() {
|
|
late _RecordingClient client;
|
|
|
|
setUp(() {
|
|
client = _RecordingClient();
|
|
AuthTokenStore.clear();
|
|
AuthProxyService.debugSetHttpClientFactoryForTesting(({
|
|
bool withCredentials = false,
|
|
}) {
|
|
client.withCredentialsCalls.add(withCredentials);
|
|
return client;
|
|
});
|
|
});
|
|
|
|
tearDown(() {
|
|
AuthProxyService.debugSetHttpClientFactoryForTesting(null);
|
|
AuthTokenStore.clear();
|
|
});
|
|
|
|
group('AuthProxyService HTTP contract', () {
|
|
test('getMe는 bearer token과 cookie mode를 구분한다', () async {
|
|
client.enqueueJson({'id': 'user-1'});
|
|
|
|
final result = await AuthProxyService.getMe(
|
|
token: 'jwt-token',
|
|
useCookie: false,
|
|
);
|
|
|
|
expect(result['id'], 'user-1');
|
|
expect(client.withCredentialsCalls, [false]);
|
|
expect(
|
|
client.requests.single.headers['Authorization'],
|
|
'Bearer jwt-token',
|
|
);
|
|
expect(client.closedCount, 1);
|
|
});
|
|
|
|
test('checkCookieSession은 credential client로 profile을 조회한다', () async {
|
|
client.enqueueJson({'email': 'user@example.com'});
|
|
|
|
final result = await AuthProxyService.checkCookieSession();
|
|
|
|
expect(result['email'], 'user@example.com');
|
|
expect(client.withCredentialsCalls, [true]);
|
|
expect(client.requests.single.url.path, '/api/v1/user/me');
|
|
});
|
|
|
|
test('initEnchantedLink는 개발 환경에서 drySend와 codeOnly를 전송한다', () async {
|
|
client.enqueueJson({'pendingRef': 'pending-1'});
|
|
|
|
await AuthProxyService.initEnchantedLink(
|
|
'user@example.com',
|
|
method: 'email',
|
|
codeOnly: true,
|
|
drySend: true,
|
|
);
|
|
|
|
final body = client.lastJsonBody;
|
|
expect(body['loginId'], 'user@example.com');
|
|
expect(body['method'], 'email');
|
|
expect(body['codeOnly'], isTrue);
|
|
expect(body['drySend'], isTrue);
|
|
expect(body['uri'], isA<String>());
|
|
});
|
|
|
|
test('poll 계열은 pending 상태 400 응답 body를 정상 결과로 반환한다', () async {
|
|
client.enqueueJson({'status': 'pending'}, statusCode: 400);
|
|
|
|
final result = await AuthProxyService.pollEnchantedLink('pending-1');
|
|
|
|
expect(result['status'], 'pending');
|
|
expect(client.lastJsonBody['pendingRef'], 'pending-1');
|
|
});
|
|
|
|
test('verifyLoginCode는 pendingRef가 있을 때만 payload에 포함한다', () async {
|
|
client.enqueueJson({'ok': true});
|
|
|
|
await AuthProxyService.verifyLoginCode(
|
|
'user@example.com',
|
|
'123456',
|
|
pendingRef: 'pending-1',
|
|
verifyOnly: true,
|
|
);
|
|
|
|
expect(client.lastJsonBody, {
|
|
'loginId': 'user@example.com',
|
|
'code': '123456',
|
|
'verifyOnly': true,
|
|
'pendingRef': 'pending-1',
|
|
});
|
|
});
|
|
|
|
test('fetchCurrentSessionId는 현재 세션만 반환하고 없으면 null을 반환한다', () async {
|
|
AuthTokenStore.setToken('jwt-token');
|
|
client.enqueueJson({
|
|
'items': [
|
|
{'session_id': 'old-session', 'is_current': false},
|
|
{'session_id': 'current-session', 'is_current': true},
|
|
],
|
|
});
|
|
client.enqueueJson({
|
|
'items': [
|
|
{'session_id': 'old-session', 'is_current': false},
|
|
],
|
|
});
|
|
|
|
final current = await AuthProxyService.fetchCurrentSessionId();
|
|
final missing = await AuthProxyService.fetchCurrentSessionId();
|
|
|
|
expect(current, 'current-session');
|
|
expect(missing, isNull);
|
|
expect(
|
|
client.requests.first.headers['Authorization'],
|
|
'Bearer jwt-token',
|
|
);
|
|
});
|
|
|
|
test(
|
|
'consent error는 code/message/details를 AuthProxyException으로 보존한다',
|
|
() async {
|
|
client.enqueueJson({
|
|
'code': 'tenant_not_allowed',
|
|
'error': 'tenant blocked',
|
|
'details': {
|
|
'allowed_tenants': ['gp'],
|
|
},
|
|
}, statusCode: 403);
|
|
|
|
await expectLater(
|
|
AuthProxyService.getConsentInfo('consent-1'),
|
|
throwsA(
|
|
isA<AuthProxyException>()
|
|
.having(
|
|
(error) => error.errorCode,
|
|
'code',
|
|
'tenant_not_allowed',
|
|
)
|
|
.having((error) => error.message, 'message', 'tenant blocked')
|
|
.having(
|
|
(error) => error.details?['allowed_tenants'],
|
|
'details',
|
|
['gp'],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'acceptOidcLogin error는 code/message/details를 AuthProxyException으로 보존한다',
|
|
() async {
|
|
client.enqueueJson({
|
|
'code': 'tenant_not_allowed',
|
|
'error': 'tenant blocked',
|
|
'details': {
|
|
'allowed_tenants': ['gp'],
|
|
},
|
|
}, statusCode: 403);
|
|
|
|
await expectLater(
|
|
AuthProxyService.acceptOidcLogin('login-challenge', token: 'jwt'),
|
|
throwsA(
|
|
isA<AuthProxyException>()
|
|
.having(
|
|
(error) => error.errorCode,
|
|
'code',
|
|
'tenant_not_allowed',
|
|
)
|
|
.having((error) => error.message, 'message', 'tenant blocked')
|
|
.having(
|
|
(error) => error.details?['allowed_tenants'],
|
|
'details',
|
|
['gp'],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'approveQrLogin은 credential mode와 bearer token payload를 지원한다',
|
|
() async {
|
|
client.enqueueJson({'ok': true});
|
|
|
|
await AuthProxyService.approveQrLogin(
|
|
'pending-qr',
|
|
token: 'jwt-token',
|
|
withCredentials: true,
|
|
);
|
|
|
|
expect(client.withCredentialsCalls, [true]);
|
|
expect(client.lastJsonBody, {
|
|
'pendingRef': 'pending-qr',
|
|
'token': 'jwt-token',
|
|
});
|
|
},
|
|
);
|
|
|
|
test('sendLog는 민감 정보를 제거한 client log를 전송한다', () async {
|
|
client.enqueueJson({'ok': true});
|
|
|
|
await AuthProxyService.sendLog(
|
|
'warn',
|
|
'token=secret password=hidden',
|
|
data: {'authorization': 'Bearer secret', 'safe': 'value'},
|
|
);
|
|
|
|
expect(client.requests.single.url.path, '/api/v1/client-log');
|
|
final body = client.lastJsonBody;
|
|
expect(body['level'], 'warn');
|
|
expect(body['message'], isNot(contains('secret')));
|
|
expect((body['data'] as Map<String, dynamic>)['safe'], 'value');
|
|
expect(
|
|
(body['data'] as Map<String, dynamic>)['authorization'],
|
|
isNot(contains('secret')),
|
|
);
|
|
});
|
|
|
|
test('주요 성공 API는 method, path, payload 계약을 유지한다', () async {
|
|
client
|
|
..enqueueJson({'minLength': 12})
|
|
..enqueueJson({'id': 'user-1'})
|
|
..enqueueJson({'slug': 'tenant'})
|
|
..enqueueJson({'verified': true})
|
|
..enqueueJson({'verified': true})
|
|
..enqueueJson({'redirect_to': '/callback'})
|
|
..enqueueJson({'redirect_to': '/consent'})
|
|
..enqueueJson({'redirect_to': '/rejected'})
|
|
..enqueueJson({'redirect_to': '/oidc'})
|
|
..enqueueJson({'pendingRef': 'reset-pending'})
|
|
..enqueueJson({'ok': true})
|
|
..enqueueJson({'ok': true})
|
|
..enqueueJson({'verified': true})
|
|
..enqueueJson({'pendingRef': 'qr'})
|
|
..enqueueJson({'status': 'pending'})
|
|
..enqueueJson({'ok': true})
|
|
..enqueueJson({'ok': true})
|
|
..enqueueJson({
|
|
'users': [
|
|
{'loginId': 'user@example.com'},
|
|
],
|
|
})
|
|
..enqueueJson({'ok': true})
|
|
..enqueueJson({'ok': true})
|
|
..enqueueJson({'ok': true})
|
|
..enqueueJson({
|
|
'items': [
|
|
{'client_id': 'rp-1'},
|
|
],
|
|
})
|
|
..enqueueJson({'ok': true})
|
|
..enqueueJson({'available': true})
|
|
..enqueueJson({'available': true, 'message': 'ok'})
|
|
..enqueueJson({'message': 'duplicated'}, statusCode: 409)
|
|
..enqueueAny([
|
|
{'slug': 'gp'},
|
|
])
|
|
..enqueueJson({'ok': true})
|
|
..enqueueJson({'ok': true})
|
|
..enqueueJson({'verified': true})
|
|
..enqueueJson({'ok': true});
|
|
|
|
AuthTokenStore.setToken('jwt-token');
|
|
|
|
expect((await AuthProxyService.fetchPasswordPolicy())['minLength'], 12);
|
|
expect(
|
|
await AuthProxyService.getSessionStatus(token: 'session-token'),
|
|
200,
|
|
);
|
|
expect((await AuthProxyService.getTenantInfo())['slug'], 'tenant');
|
|
expect(
|
|
(await AuthProxyService.verifyMagicLink(
|
|
'magic-token',
|
|
verifyOnly: true,
|
|
))['verified'],
|
|
isTrue,
|
|
);
|
|
expect(
|
|
(await AuthProxyService.verifyLoginShortCode(
|
|
'SHORT123',
|
|
verifyOnly: true,
|
|
))['verified'],
|
|
isTrue,
|
|
);
|
|
expect(
|
|
(await AuthProxyService.loginWithPassword(
|
|
'user@example.com',
|
|
'password',
|
|
loginChallenge: 'challenge-1',
|
|
))['redirect_to'],
|
|
'/callback',
|
|
);
|
|
expect(
|
|
(await AuthProxyService.acceptConsent(
|
|
'consent-1',
|
|
grantScope: ['openid'],
|
|
))['redirect_to'],
|
|
'/consent',
|
|
);
|
|
expect(
|
|
(await AuthProxyService.rejectConsent('consent-1'))['redirect_to'],
|
|
'/rejected',
|
|
);
|
|
expect(
|
|
(await AuthProxyService.acceptOidcLogin(
|
|
'login-challenge',
|
|
token: 'jwt-token',
|
|
))['redirect_to'],
|
|
'/oidc',
|
|
);
|
|
expect(
|
|
(await AuthProxyService.initiatePasswordReset(
|
|
'user@example.com',
|
|
drySend: true,
|
|
))['pendingRef'],
|
|
'reset-pending',
|
|
);
|
|
expect(
|
|
(await AuthProxyService.completePasswordReset(
|
|
loginId: 'user@example.com',
|
|
token: 'reset-token',
|
|
newPassword: 'new-password',
|
|
))['ok'],
|
|
isTrue,
|
|
);
|
|
await AuthProxyService.sendSms('01012345678');
|
|
expect(
|
|
(await AuthProxyService.verifySmsCode(
|
|
'01012345678',
|
|
'123456',
|
|
))['verified'],
|
|
isTrue,
|
|
);
|
|
expect((await AuthProxyService.initQrLogin())['pendingRef'], 'qr');
|
|
expect((await AuthProxyService.pollQrStatus('qr'))['status'], 'pending');
|
|
expect(await AuthProxyService.checkAdminAuth('admin-pass'), isTrue);
|
|
await AuthProxyService.createUser(
|
|
loginId: 'user@example.com',
|
|
adminPassword: 'admin-pass',
|
|
email: 'user@example.com',
|
|
phone: '01012345678',
|
|
displayName: 'User',
|
|
);
|
|
expect(await AuthProxyService.listUsers('admin-pass', query: 'user'), [
|
|
{'loginId': 'user@example.com'},
|
|
]);
|
|
await AuthProxyService.deleteUser('admin-pass', 'user@example.com');
|
|
await AuthProxyService.updateUserStatus(
|
|
'admin-pass',
|
|
'user@example.com',
|
|
'disabled',
|
|
);
|
|
await AuthProxyService.updateUserDetails(
|
|
adminPassword: 'admin-pass',
|
|
loginId: 'user@example.com',
|
|
email: 'user2@example.com',
|
|
phone: '01099998888',
|
|
displayName: 'User 2',
|
|
);
|
|
expect(await AuthProxyService.fetchLinkedRps(), [
|
|
{'client_id': 'rp-1'},
|
|
]);
|
|
await AuthProxyService.revokeLinkedRp('rp-1');
|
|
expect(
|
|
await AuthProxyService.checkEmailAvailability('new@example.com'),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
await AuthProxyService.checkLoginIDAvailability(
|
|
'new@example.com',
|
|
tenantSlug: 'gp',
|
|
),
|
|
{'available': true, 'message': 'ok'},
|
|
);
|
|
expect(
|
|
await AuthProxyService.checkLoginIDAvailability('dup@example.com'),
|
|
{'available': false, 'message': 'duplicated'},
|
|
);
|
|
expect(
|
|
await AuthProxyService.getActiveTenants(email: 'user@example.com'),
|
|
[
|
|
{'slug': 'gp'},
|
|
],
|
|
);
|
|
await AuthProxyService.sendSignupCode('user@example.com', 'email');
|
|
await AuthProxyService.sendSignupCode('01012345678', 'sms');
|
|
expect(
|
|
(await AuthProxyService.verifySignupCode(
|
|
'user@example.com',
|
|
'email',
|
|
'123456',
|
|
))['verified'],
|
|
isTrue,
|
|
);
|
|
await AuthProxyService.signup(
|
|
email: 'user@example.com',
|
|
password: 'password',
|
|
name: 'User',
|
|
phone: '01012345678',
|
|
affiliationType: 'tenant',
|
|
tenantSlug: 'gp',
|
|
department: 'R&D',
|
|
termsAccepted: true,
|
|
);
|
|
|
|
expect(
|
|
client.requests.map((request) => request.method),
|
|
containsAll(['GET', 'POST', 'PATCH', 'DELETE']),
|
|
);
|
|
expect(
|
|
client.requests.map((request) => request.url.path),
|
|
containsAll([
|
|
'/api/v1/auth/password/policy',
|
|
'/api/v1/auth/magic-link/verify',
|
|
'/api/v1/auth/login/code/verify-short',
|
|
'/api/v1/auth/password/login',
|
|
'/api/v1/auth/consent/accept',
|
|
'/api/v1/auth/consent/reject',
|
|
'/api/v1/auth/oidc/login/accept',
|
|
'/api/v1/auth/password/reset/initiate',
|
|
'/api/v1/auth/password/reset/complete',
|
|
'/api/v1/auth/sms',
|
|
'/api/v1/auth/verify-sms',
|
|
'/api/v1/auth/qr/init',
|
|
'/api/v1/auth/qr/poll',
|
|
'/api/v1/admin/check',
|
|
'/api/v1/admin/users',
|
|
'/api/v1/user/rp/linked',
|
|
'/api/v1/auth/signup/check-email',
|
|
'/api/v1/auth/signup/check-login-id',
|
|
'/api/v1/auth/signup/tenants',
|
|
'/api/v1/auth/signup/send-email-code',
|
|
'/api/v1/auth/signup/send-sms-code',
|
|
'/api/v1/auth/signup/verify-code',
|
|
'/api/v1/auth/signup',
|
|
]),
|
|
);
|
|
});
|
|
|
|
test('대표 실패 API는 예외와 fallback 값을 반환한다', () async {
|
|
client
|
|
..enqueueJson({'error': 'bad policy'}, statusCode: 500)
|
|
..enqueueJson({'error': 'profile failed'}, statusCode: 401)
|
|
..enqueueJson({'error': 'tenant failed'}, statusCode: 500)
|
|
..enqueueJson({'error': 'init failed'}, statusCode: 500)
|
|
..enqueueJson({'error': 'poll failed'}, statusCode: 500)
|
|
..enqueueJson({'error': 'magic failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'code failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'short failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'password failed'}, statusCode: 401)
|
|
..enqueueJson({'error': 'accept failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'reject failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'oidc failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'reset init failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'reset complete failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'sms failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'verify sms failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'qr init failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'qr poll failed'}, statusCode: 500)
|
|
..enqueueJson({'error': 'qr approve failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'create failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'list failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'delete failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'status failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'update failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'linked failed'}, statusCode: 500)
|
|
..enqueueJson({'error': 'revoke linked failed'}, statusCode: 400)
|
|
..enqueueJson({'available': false}, statusCode: 500)
|
|
..enqueueAny([], statusCode: 500)
|
|
..enqueueJson({'error': 'signup code failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'verify failed'}, statusCode: 400)
|
|
..enqueueJson({'error': 'signup failed'}, statusCode: 400);
|
|
|
|
Future<void> expectThrows(Future<void> Function() action) async {
|
|
await expectLater(action(), throwsA(isA<Object>()));
|
|
}
|
|
|
|
await expectThrows(() async => AuthProxyService.fetchPasswordPolicy());
|
|
await expectThrows(() async => AuthProxyService.getMe(token: 'bad'));
|
|
await expectThrows(() async => AuthProxyService.getTenantInfo());
|
|
await expectThrows(
|
|
() async => AuthProxyService.initEnchantedLink('user'),
|
|
);
|
|
await expectThrows(
|
|
() async => AuthProxyService.pollEnchantedLink('pending'),
|
|
);
|
|
await expectThrows(() async => AuthProxyService.verifyMagicLink('magic'));
|
|
await expectThrows(
|
|
() async => AuthProxyService.verifyLoginCode('user', '123456'),
|
|
);
|
|
await expectThrows(
|
|
() async => AuthProxyService.verifyLoginShortCode('SHORT'),
|
|
);
|
|
await expectThrows(
|
|
() async => AuthProxyService.loginWithPassword('user', 'password'),
|
|
);
|
|
await expectThrows(() async => AuthProxyService.acceptConsent('consent'));
|
|
await expectThrows(() async => AuthProxyService.rejectConsent('consent'));
|
|
await expectThrows(() async => AuthProxyService.acceptOidcLogin('login'));
|
|
await expectThrows(
|
|
() async => AuthProxyService.initiatePasswordReset('user'),
|
|
);
|
|
await expectThrows(
|
|
() async => AuthProxyService.completePasswordReset(newPassword: 'new'),
|
|
);
|
|
await expectThrows(() async => AuthProxyService.sendSms('01012345678'));
|
|
await expectThrows(
|
|
() async => AuthProxyService.verifySmsCode('01012345678', '123456'),
|
|
);
|
|
await expectThrows(() async => AuthProxyService.initQrLogin());
|
|
await expectThrows(() async => AuthProxyService.pollQrStatus('pending'));
|
|
await expectThrows(
|
|
() async => AuthProxyService.approveQrLogin('pending'),
|
|
);
|
|
await expectThrows(
|
|
() async => AuthProxyService.createUser(
|
|
loginId: 'user',
|
|
adminPassword: 'admin',
|
|
),
|
|
);
|
|
await expectThrows(() async => AuthProxyService.listUsers('admin'));
|
|
await expectThrows(
|
|
() async => AuthProxyService.deleteUser('admin', 'user'),
|
|
);
|
|
await expectThrows(
|
|
() async =>
|
|
AuthProxyService.updateUserStatus('admin', 'user', 'disabled'),
|
|
);
|
|
await expectThrows(
|
|
() async => AuthProxyService.updateUserDetails(
|
|
adminPassword: 'admin',
|
|
loginId: 'user',
|
|
),
|
|
);
|
|
await expectThrows(() async => AuthProxyService.fetchLinkedRps());
|
|
await expectThrows(() async => AuthProxyService.revokeLinkedRp('rp'));
|
|
expect(
|
|
await AuthProxyService.checkEmailAvailability('used@example.com'),
|
|
isFalse,
|
|
);
|
|
expect(await AuthProxyService.getActiveTenants(), isEmpty);
|
|
await expectThrows(
|
|
() async =>
|
|
AuthProxyService.sendSignupCode('user@example.com', 'email'),
|
|
);
|
|
await expectThrows(
|
|
() async => AuthProxyService.verifySignupCode(
|
|
'user@example.com',
|
|
'email',
|
|
'123456',
|
|
),
|
|
);
|
|
await expectThrows(
|
|
() async => AuthProxyService.signup(
|
|
email: 'user@example.com',
|
|
password: 'password',
|
|
name: 'User',
|
|
phone: '01012345678',
|
|
affiliationType: 'tenant',
|
|
department: 'R&D',
|
|
termsAccepted: true,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
class _QueuedResponse {
|
|
const _QueuedResponse(this.statusCode, this.body);
|
|
|
|
final int statusCode;
|
|
final String body;
|
|
}
|
|
|
|
class _RecordingClient extends http.BaseClient {
|
|
final requests = <http.BaseRequest>[];
|
|
final withCredentialsCalls = <bool>[];
|
|
final _responses = <_QueuedResponse>[];
|
|
int closedCount = 0;
|
|
|
|
void enqueueJson(Map<String, dynamic> body, {int statusCode = 200}) {
|
|
enqueueAny(body, statusCode: statusCode);
|
|
}
|
|
|
|
void enqueueAny(Object? body, {int statusCode = 200}) {
|
|
_responses.add(_QueuedResponse(statusCode, jsonEncode(body)));
|
|
}
|
|
|
|
Map<String, dynamic> get lastJsonBody {
|
|
final request = requests.last as http.Request;
|
|
return jsonDecode(request.body) as Map<String, dynamic>;
|
|
}
|
|
|
|
@override
|
|
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
|
requests.add(request);
|
|
final response = _responses.isEmpty
|
|
? const _QueuedResponse(200, '{}')
|
|
: _responses.removeAt(0);
|
|
return http.StreamedResponse(
|
|
Stream.value(utf8.encode(response.body)),
|
|
response.statusCode,
|
|
request: request,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
|
|
@override
|
|
void close() {
|
|
closedCount += 1;
|
|
super.close();
|
|
}
|
|
}
|