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()); }); 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() .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() .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('createUser는 내부 도메인 personal 정책 오류를 한국어 안내로 표시한다', () async { client.enqueueJson({ 'error': 'internal email domain cannot be assigned to personal tenant: user@hanmaceng.co.kr', }, statusCode: 400); await expectLater( AuthProxyService.createUser( loginId: 'user@hanmaceng.co.kr', adminPassword: 'admin-pass', email: 'user@hanmaceng.co.kr', ), throwsA( isA().having( (error) => error.toString(), 'message', contains('대표소속을 회사 또는 조직 소속으로 지정해 주세요'), ), ), ); }); test('updateUserDetails는 내부 도메인 personal 정책 오류를 한국어 안내로 표시한다', () async { client.enqueueJson({ 'error': '내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다: user@brsw.kr', }, statusCode: 400); await expectLater( AuthProxyService.updateUserDetails( adminPassword: 'admin-pass', loginId: 'user@brsw.kr', email: 'user@brsw.kr', ), throwsA( isA().having( (error) => error.toString(), 'message', contains('대표소속을 회사 또는 조직 소속으로 지정해 주세요'), ), ), ); }); 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)['safe'], 'value'); expect( (body['data'] as Map)['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 expectThrows(Future Function() action) async { await expectLater(action(), throwsA(isA())); } 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 = []; final withCredentialsCalls = []; final _responses = <_QueuedResponse>[]; int closedCount = 0; void enqueueJson(Map body, {int statusCode = 200}) { enqueueAny(body, statusCode: statusCode); } void enqueueAny(Object? body, {int statusCode = 200}) { _responses.add(_QueuedResponse(statusCode, jsonEncode(body))); } Map get lastJsonBody { final request = requests.last as http.Request; return jsonDecode(request.body) as Map; } @override Future 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(); } }