1
0
forked from baron/baron-sso
Files
baron-sso/userfront/test/auth_proxy_service_test.dart

590 lines
19 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(
'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();
}
}