첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
12
baron-sso/userfront/test/app_theme_default_font_test.dart
Normal file
12
baron-sso/userfront/test/app_theme_default_font_test.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
test('themes use the platform default font family', () {
|
||||
final lightTheme = buildLightTheme();
|
||||
final darkTheme = buildDarkTheme();
|
||||
|
||||
expect(lightTheme.textTheme.bodyMedium?.fontFamily, isNot('NotoSansKR'));
|
||||
expect(darkTheme.textTheme.bodyMedium?.fontFamily, isNot('NotoSansKR'));
|
||||
});
|
||||
}
|
||||
59
baron-sso/userfront/test/audit_device_utils_test.dart
Normal file
59
baron-sso/userfront/test/audit_device_utils_test.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/dashboard/domain/models.dart';
|
||||
import 'package:userfront/features/dashboard/presentation/audit_device_utils.dart';
|
||||
|
||||
AuditLogEntry _log({
|
||||
required String eventType,
|
||||
String userAgent = '',
|
||||
Map<String, dynamic>? details,
|
||||
}) {
|
||||
return AuditLogEntry.fromJson({
|
||||
'event_id': 'audit-1',
|
||||
'timestamp': '2026-04-14T00:00:00Z',
|
||||
'user_id': 'user-123',
|
||||
'event_type': eventType,
|
||||
'status': 'success',
|
||||
'user_agent': userAgent,
|
||||
'details': details == null ? '' : details.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('headless link login maps internal client user agent to sentinel', () {
|
||||
final log = AuditLogEntry.fromJson({
|
||||
'event_id': 'audit-1',
|
||||
'timestamp': '2026-04-14T00:00:00Z',
|
||||
'user_id': 'user-123',
|
||||
'event_type': 'POST /api/v1/auth/login/code/verify',
|
||||
'status': 'success',
|
||||
'user_agent': 'undici',
|
||||
'details':
|
||||
'{"approved_user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36"}',
|
||||
});
|
||||
|
||||
expect(preferredAuditLogUserAgent(log), headlessServerUserAgentSentinel);
|
||||
});
|
||||
|
||||
test(
|
||||
'headless password login maps internal client user agent to sentinel',
|
||||
() {
|
||||
final log = _log(
|
||||
eventType: 'POST /api/v1/auth/headless/password/login',
|
||||
userAgent: 'undici',
|
||||
);
|
||||
|
||||
expect(preferredAuditLogUserAgent(log), headlessServerUserAgentSentinel);
|
||||
},
|
||||
);
|
||||
|
||||
test('non-headless login preserves original browser user agent', () {
|
||||
const browserUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36';
|
||||
final log = _log(
|
||||
eventType: 'POST /api/v1/auth/password/login',
|
||||
userAgent: browserUa,
|
||||
);
|
||||
|
||||
expect(preferredAuditLogUserAgent(log), browserUa);
|
||||
});
|
||||
}
|
||||
620
baron-sso/userfront/test/auth_proxy_service_test.dart
Normal file
620
baron-sso/userfront/test/auth_proxy_service_test.dart
Normal file
@@ -0,0 +1,620 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
150
baron-sso/userfront/test/auth_token_store_backend_test.dart
Normal file
150
baron-sso/userfront/test/auth_token_store_backend_test.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/auth_token_store_backend.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthTokenStoreBackend', () {
|
||||
test('local 저장소가 실패하면 session 저장소에서 토큰을 읽는다', () {
|
||||
final local = _FakeTarget(throwsOnRead: true);
|
||||
final session = _FakeTarget(
|
||||
readSeed: {'baron_auth_token': 'session-jwt'},
|
||||
);
|
||||
final store = AuthTokenStoreBackend(
|
||||
localTarget: local,
|
||||
sessionTarget: session,
|
||||
);
|
||||
|
||||
expect(store.getToken(), 'session-jwt');
|
||||
});
|
||||
|
||||
test('local 저장소가 실패하면 session 저장소에 토큰을 저장한다', () {
|
||||
final local = _FakeTarget(throwsOnWrite: true);
|
||||
final session = _FakeTarget();
|
||||
final store = AuthTokenStoreBackend(
|
||||
localTarget: local,
|
||||
sessionTarget: session,
|
||||
);
|
||||
|
||||
store.setToken('new-token', provider: 'ory');
|
||||
|
||||
expect(session.read('baron_auth_token'), 'new-token');
|
||||
expect(session.read('baron_auth_provider'), 'ory');
|
||||
});
|
||||
|
||||
test('cookie mode는 token을 제거하고 provider를 유지한다', () {
|
||||
final local = _FakeTarget();
|
||||
final session = _FakeTarget();
|
||||
final store = AuthTokenStoreBackend(
|
||||
localTarget: local,
|
||||
sessionTarget: session,
|
||||
);
|
||||
|
||||
store.setToken('jwt-token', provider: 'ory');
|
||||
store.setCookieMode(provider: 'cookie-provider');
|
||||
|
||||
expect(store.getToken(), isNull);
|
||||
expect(store.usesCookie(), isTrue);
|
||||
expect(store.getProvider(), 'cookie-provider');
|
||||
expect(local.read('baron_auth_cookie_mode'), '1');
|
||||
expect(session.read('baron_auth_token'), isNull);
|
||||
});
|
||||
|
||||
test('pending provider는 빈 값이면 제거하고 저장소 오류는 건너뛴다', () {
|
||||
final local = _FakeTarget(throwsOnWrite: true, throwsOnRemove: true);
|
||||
final session = _FakeTarget();
|
||||
final store = AuthTokenStoreBackend(
|
||||
localTarget: local,
|
||||
sessionTarget: session,
|
||||
);
|
||||
|
||||
store.setPendingProvider('ory');
|
||||
expect(store.getPendingProvider(), 'ory');
|
||||
expect(session.read('baron_auth_pending_provider'), 'ory');
|
||||
|
||||
store.setPendingProvider('');
|
||||
expect(store.getPendingProvider(), isNull);
|
||||
expect(session.read('baron_auth_pending_provider'), isNull);
|
||||
});
|
||||
|
||||
test('local/session 저장이 모두 실패해도 memory fallback으로 읽을 수 있다', () {
|
||||
final store = AuthTokenStoreBackend(
|
||||
localTarget: _FakeTarget(throwsOnWrite: true, throwsOnRead: true),
|
||||
sessionTarget: _FakeTarget(throwsOnWrite: true, throwsOnRead: true),
|
||||
);
|
||||
|
||||
store.setToken('memory-token');
|
||||
store.setPendingProvider('memory-provider');
|
||||
|
||||
expect(store.getToken(), 'memory-token');
|
||||
expect(store.getPendingProvider(), 'memory-provider');
|
||||
});
|
||||
|
||||
test('clear 호출 시 local/session/memory 모두 정리된다', () {
|
||||
final local = _FakeTarget(
|
||||
readSeed: {
|
||||
'baron_auth_token': 'local-token',
|
||||
'baron_auth_provider': 'ory',
|
||||
},
|
||||
);
|
||||
final session = _FakeTarget(
|
||||
readSeed: {
|
||||
'baron_auth_token': 'session-token',
|
||||
'baron_auth_provider': 'ory',
|
||||
'baron_auth_cookie_mode': '1',
|
||||
},
|
||||
);
|
||||
final store = AuthTokenStoreBackend(
|
||||
localTarget: local,
|
||||
sessionTarget: session,
|
||||
);
|
||||
|
||||
store.clear();
|
||||
|
||||
expect(local.read('baron_auth_token'), isNull);
|
||||
expect(local.read('baron_auth_provider'), isNull);
|
||||
expect(session.read('baron_auth_token'), isNull);
|
||||
expect(session.read('baron_auth_provider'), isNull);
|
||||
expect(session.read('baron_auth_cookie_mode'), isNull);
|
||||
expect(store.getToken(), isNull);
|
||||
expect(store.getProvider(), isNull);
|
||||
expect(store.usesCookie(), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeTarget implements AuthTokenStorageTarget {
|
||||
_FakeTarget({
|
||||
this.throwsOnRead = false,
|
||||
this.throwsOnWrite = false,
|
||||
this.throwsOnRemove = false,
|
||||
Map<String, String>? readSeed,
|
||||
}) : _data = {...?readSeed};
|
||||
|
||||
final bool throwsOnRead;
|
||||
final bool throwsOnWrite;
|
||||
final bool throwsOnRemove;
|
||||
final Map<String, String> _data;
|
||||
|
||||
@override
|
||||
String? read(String key) {
|
||||
if (throwsOnRead) {
|
||||
throw Exception('read failed');
|
||||
}
|
||||
return _data[key];
|
||||
}
|
||||
|
||||
@override
|
||||
void remove(String key) {
|
||||
if (throwsOnRemove) {
|
||||
throw Exception('remove failed');
|
||||
}
|
||||
_data.remove(key);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(String key, String value) {
|
||||
if (throwsOnWrite) {
|
||||
throw Exception('write failed');
|
||||
}
|
||||
_data[key] = value;
|
||||
}
|
||||
}
|
||||
35
baron-sso/userfront/test/auth_token_store_test.dart
Normal file
35
baron-sso/userfront/test/auth_token_store_test.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/auth_token_store.dart';
|
||||
|
||||
void main() {
|
||||
group('AuthTokenStore facade', () {
|
||||
setUp(AuthTokenStore.clear);
|
||||
tearDown(AuthTokenStore.clear);
|
||||
|
||||
test('token, provider, cookie mode, pending provider 상태를 위임한다', () {
|
||||
expect(AuthTokenStore.hasToken(), isFalse);
|
||||
|
||||
AuthTokenStore.setToken('jwt-token', provider: 'ory');
|
||||
|
||||
expect(AuthTokenStore.hasToken(), isTrue);
|
||||
expect(AuthTokenStore.getToken(), 'jwt-token');
|
||||
expect(AuthTokenStore.getProvider(), 'ory');
|
||||
expect(AuthTokenStore.usesCookie(), isFalse);
|
||||
|
||||
AuthTokenStore.setPendingProvider('pending-ory');
|
||||
expect(AuthTokenStore.getPendingProvider(), 'pending-ory');
|
||||
AuthTokenStore.clearPendingProvider();
|
||||
expect(AuthTokenStore.getPendingProvider(), isNull);
|
||||
|
||||
AuthTokenStore.setCookieMode(provider: 'cookie-ory');
|
||||
expect(AuthTokenStore.hasToken(), isFalse);
|
||||
expect(AuthTokenStore.getToken(), isNull);
|
||||
expect(AuthTokenStore.getProvider(), 'cookie-ory');
|
||||
expect(AuthTokenStore.usesCookie(), isTrue);
|
||||
|
||||
AuthTokenStore.clear();
|
||||
expect(AuthTokenStore.getProvider(), isNull);
|
||||
expect(AuthTokenStore.usesCookie(), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
52
baron-sso/userfront/test/consent_error_routing_test.dart
Normal file
52
baron-sso/userfront/test/consent_error_routing_test.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/features/auth/domain/consent_error_routing.dart';
|
||||
|
||||
void main() {
|
||||
test('tenant_not_allowed consent error routes to dedicated error screen', () {
|
||||
const error = AuthProxyException(
|
||||
errorCode: 'tenant_not_allowed',
|
||||
message: '허용되지 않은 테넌트입니다.',
|
||||
);
|
||||
|
||||
expect(shouldRouteConsentErrorToErrorScreen(error), isTrue);
|
||||
});
|
||||
|
||||
test('generic consent error stays on consent screen', () {
|
||||
const error = AuthProxyException(
|
||||
errorCode: 'forbidden',
|
||||
message: '동의 정보를 가져오지 못했습니다.',
|
||||
);
|
||||
|
||||
expect(shouldRouteConsentErrorToErrorScreen(error), isFalse);
|
||||
});
|
||||
|
||||
test('tenant_not_allowed auth error also routes to error screen', () {
|
||||
const error = AuthProxyException(
|
||||
errorCode: 'tenant_not_allowed',
|
||||
message: '허용되지 않은 테넌트입니다.',
|
||||
);
|
||||
|
||||
expect(shouldRouteTenantAccessErrorToErrorScreen(error), isTrue);
|
||||
});
|
||||
|
||||
test('buildTenantAccessErrorPath builds userfront error route', () {
|
||||
const error = AuthProxyException(
|
||||
errorCode: 'tenant_not_allowed',
|
||||
message: '허용되지 않은 테넌트입니다.',
|
||||
details: {
|
||||
'allowed_tenants': ['tenant-a'],
|
||||
},
|
||||
);
|
||||
|
||||
final target = buildTenantAccessErrorPath(
|
||||
error,
|
||||
Uri.parse('https://sso-test.hmac.kr/ko?login_challenge=abc'),
|
||||
);
|
||||
|
||||
expect(target, contains('/error?'));
|
||||
expect(target, contains('error=tenant_not_allowed'));
|
||||
expect(target, contains('error_description='));
|
||||
expect(target, contains('details='));
|
||||
});
|
||||
}
|
||||
40
baron-sso/userfront/test/cookie_session_policy_test.dart
Normal file
40
baron-sso/userfront/test/cookie_session_policy_test.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/auth/domain/cookie_session_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('cookie_session_policy', () {
|
||||
test('토큰이 없고 login_challenge도 없으면 cookie 승격 허용', () {
|
||||
expect(
|
||||
shouldPromoteCookieSession(currentToken: null, loginChallenge: null),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('토큰이 이미 있으면 일반 로그인에서 cookie 승격 차단', () {
|
||||
expect(
|
||||
shouldPromoteCookieSession(
|
||||
currentToken: 'existing-token',
|
||||
loginChallenge: null,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('OIDC login_challenge가 있으면 token 존재 시에도 cookie 승격 허용', () {
|
||||
expect(
|
||||
shouldPromoteCookieSession(
|
||||
currentToken: 'existing-token',
|
||||
loginChallenge: 'lc_123',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('공백 토큰은 유효 토큰으로 간주하지 않음', () {
|
||||
expect(
|
||||
shouldPromoteCookieSession(currentToken: ' ', loginChallenge: null),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
126
baron-sso/userfront/test/dashboard_providers_test.dart
Normal file
126
baron-sso/userfront/test/dashboard_providers_test.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:userfront/features/dashboard/domain/dashboard_providers.dart';
|
||||
import 'package:userfront/features/dashboard/domain/models.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
AuditLogEntry _log(String id) {
|
||||
return AuditLogEntry.fromJson({
|
||||
'event_id': id,
|
||||
'timestamp': '2026-02-06T00:00:00Z',
|
||||
'status': 'success',
|
||||
'session_id': 's-$id',
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _drainMicrotasks() async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher;
|
||||
dispatcher.localeTestValue = const Locale('ko');
|
||||
dispatcher.localesTestValue = const [Locale('ko')];
|
||||
|
||||
tearDownAll(() {
|
||||
dispatcher.clearLocaleTestValue();
|
||||
dispatcher.clearLocalesTestValue();
|
||||
});
|
||||
|
||||
test('AuthTimelineNotifier는 초기 페이지를 로드한다', () async {
|
||||
final cursors = <String?>[];
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
authTimelineFetcherProvider.overrideWithValue(({String? cursor}) async {
|
||||
cursors.add(cursor);
|
||||
return AuditPage(items: [_log('1')], nextCursor: 'next');
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
container.read(authTimelineProvider.notifier);
|
||||
|
||||
await _drainMicrotasks();
|
||||
|
||||
final state = container.read(authTimelineProvider);
|
||||
expect(state.items.length, 1);
|
||||
expect(state.nextCursor, 'next');
|
||||
expect(cursors, [null]);
|
||||
container.dispose();
|
||||
});
|
||||
|
||||
test('AuthTimelineNotifier는 다음 커서를 사용해 추가 로드한다', () async {
|
||||
final cursors = <String?>[];
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
authTimelineFetcherProvider.overrideWithValue(({String? cursor}) async {
|
||||
cursors.add(cursor);
|
||||
if (cursor == null) {
|
||||
return AuditPage(items: [_log('1')], nextCursor: 'next');
|
||||
}
|
||||
return AuditPage(items: [_log('2')], nextCursor: '');
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
final notifier = container.read(authTimelineProvider.notifier);
|
||||
await _drainMicrotasks();
|
||||
await notifier.loadMore();
|
||||
|
||||
final state = container.read(authTimelineProvider);
|
||||
expect(state.items.map((e) => e.eventId).toList(), ['1', '2']);
|
||||
expect(cursors, [null, 'next']);
|
||||
container.dispose();
|
||||
});
|
||||
|
||||
test('AuthTimelineNotifier는 커서가 없으면 추가 로드를 하지 않는다', () async {
|
||||
var callCount = 0;
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
authTimelineFetcherProvider.overrideWithValue(({String? cursor}) async {
|
||||
callCount += 1;
|
||||
return AuditPage(items: [_log('1')], nextCursor: '');
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
final notifier = container.read(authTimelineProvider.notifier);
|
||||
await _drainMicrotasks();
|
||||
await notifier.loadMore();
|
||||
|
||||
expect(callCount, 1);
|
||||
expect(container.read(authTimelineProvider).items.length, 1);
|
||||
container.dispose();
|
||||
});
|
||||
|
||||
test('AuthTimelineNotifier는 실패 시 오류 메시지를 보관한다', () async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
authTimelineFetcherProvider.overrideWithValue(({String? cursor}) async {
|
||||
throw Exception('fail');
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
container.read(authTimelineProvider.notifier);
|
||||
|
||||
await _drainMicrotasks();
|
||||
|
||||
final state = container.read(authTimelineProvider);
|
||||
expect(state.items.isEmpty, true);
|
||||
expect(
|
||||
state.error,
|
||||
tr(
|
||||
'msg.userfront.dashboard.timeline.load_error',
|
||||
fallback: '접속이력을 불러오지 못했습니다.',
|
||||
),
|
||||
);
|
||||
container.dispose();
|
||||
});
|
||||
}
|
||||
50
baron-sso/userfront/test/dashboard_screen_smoke_test.dart
Normal file
50
baron-sso/userfront/test/dashboard_screen_smoke_test.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/auth_token_store.dart';
|
||||
import 'package:userfront/features/dashboard/presentation/dashboard_screen.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
AuthTokenStore.clear();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
AuthTokenStore.clear();
|
||||
});
|
||||
|
||||
testWidgets('대시보드는 로그인 토큰이 있으면 크래시 없이 기본 프레임을 렌더링한다', (tester) async {
|
||||
final recordedErrors = <FlutterErrorDetails>[];
|
||||
final previousOnError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
final text = details.exceptionAsString();
|
||||
if (text.contains('A RenderFlex overflowed')) {
|
||||
return;
|
||||
}
|
||||
recordedErrors.add(details);
|
||||
};
|
||||
addTearDown(() {
|
||||
FlutterError.onError = previousOnError;
|
||||
});
|
||||
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
tester.view.physicalSize = const Size(1920, 1080);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
AuthTokenStore.setToken('smoke-token', provider: 'ory');
|
||||
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(child: MaterialApp(home: DashboardScreen())),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(Scaffold), findsOneWidget);
|
||||
final hasNullCheckCrash = recordedErrors.any(
|
||||
(error) => error.exceptionAsString().contains(
|
||||
'Null check operator used on a null value',
|
||||
),
|
||||
);
|
||||
expect(hasNullCheckCrash, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/dashboard/domain/session_time_resolver.dart';
|
||||
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
|
||||
|
||||
void main() {
|
||||
test('JWT에 iat가 있으면 세션 시각으로 사용한다', () {
|
||||
const token = 'eyJhbGciOiJub25lIn0.eyJpYXQiOjE3MTEyMDc4MDB9.signature';
|
||||
|
||||
final issuedAt = resolveDashboardSessionIssuedAt(token: token);
|
||||
|
||||
expect(issuedAt, isNotNull);
|
||||
expect(issuedAt!.toUtc().toIso8601String(), '2024-03-23T15:30:00.000Z');
|
||||
});
|
||||
|
||||
test('cookie mode에서는 profile의 sessionAuthenticatedAt으로 복원한다', () {
|
||||
final profile = UserProfile(
|
||||
id: 'user-1',
|
||||
email: 'qa@example.com',
|
||||
name: 'QA User',
|
||||
phone: '01012345678',
|
||||
department: 'Platform',
|
||||
affiliationType: 'GENERAL',
|
||||
companyCode: '',
|
||||
sessionAuthenticatedAt: '2026-03-23T15:30:00Z',
|
||||
);
|
||||
|
||||
final issuedAt = resolveDashboardSessionIssuedAt(
|
||||
token: null,
|
||||
profile: profile,
|
||||
);
|
||||
|
||||
expect(issuedAt, isNotNull);
|
||||
expect(issuedAt!.toUtc().toIso8601String(), '2026-03-23T15:30:00.000Z');
|
||||
});
|
||||
}
|
||||
35
baron-sso/userfront/test/dashboard_timeline_dedup_test.dart
Normal file
35
baron-sso/userfront/test/dashboard_timeline_dedup_test.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('대시보드 화면은 auth timeline fetch 구현을 직접 가지지 않는다', () async {
|
||||
final screenFile = File(
|
||||
'lib/features/dashboard/presentation/dashboard_screen.dart',
|
||||
);
|
||||
final source = await screenFile.readAsString();
|
||||
|
||||
expect(source.contains('_fetchAuditLogs('), isFalse);
|
||||
expect(source.contains('_loadAuditLogs('), isFalse);
|
||||
expect(source.contains('/api/v1/audit/auth/timeline'), isFalse);
|
||||
});
|
||||
|
||||
test('나의 App 현황 연동해지 버튼은 단일 라인 텍스트로 렌더링한다', () async {
|
||||
final screenFile = File(
|
||||
'lib/features/dashboard/presentation/dashboard_screen.dart',
|
||||
);
|
||||
final source = await screenFile.readAsString();
|
||||
final revokeLabelIndex = source.lastIndexOf(
|
||||
"tr('ui.userfront.dashboard.revoke.title')",
|
||||
);
|
||||
|
||||
expect(revokeLabelIndex, isNonNegative);
|
||||
final snippetStart = revokeLabelIndex > 500 ? revokeLabelIndex - 500 : 0;
|
||||
final snippetEnd = revokeLabelIndex + 300 < source.length
|
||||
? revokeLabelIndex + 300
|
||||
: source.length;
|
||||
final revokeButtonSource = source.substring(snippetStart, snippetEnd);
|
||||
|
||||
expect(revokeButtonSource, contains('_singleLineText('));
|
||||
});
|
||||
}
|
||||
106
baron-sso/userfront/test/english_locale_placeholder_test.dart
Normal file
106
baron-sso/userfront/test/english_locale_placeholder_test.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:toml/toml.dart';
|
||||
|
||||
const Set<String> _placeholderValues = {
|
||||
'Action',
|
||||
'Action Label',
|
||||
'Approve Error',
|
||||
'Approve Success',
|
||||
'Body',
|
||||
'Code Hint',
|
||||
'Code Label',
|
||||
'Confirm',
|
||||
'Confirm Button',
|
||||
'Description',
|
||||
'Error',
|
||||
'Heading',
|
||||
'Input Label',
|
||||
'Invalid',
|
||||
'Label',
|
||||
'Load Failed',
|
||||
'Page Title',
|
||||
'Request Code',
|
||||
'Result Failure',
|
||||
'Sent',
|
||||
'Subtitle',
|
||||
'Title',
|
||||
'Update Success',
|
||||
};
|
||||
|
||||
String? _readTomlValue(Map<String, dynamic> root, String key) {
|
||||
dynamic cursor = root;
|
||||
for (final part in key.split('.')) {
|
||||
if (cursor is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
cursor = cursor[part];
|
||||
}
|
||||
return cursor is String ? cursor : null;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('critical english copy does not expose placeholder values', () {
|
||||
final file = File('assets/translations/en.toml');
|
||||
final document = TomlDocument.parse(file.readAsStringSync());
|
||||
final translations = document.toMap();
|
||||
|
||||
const criticalKeys = <String>[
|
||||
'ui.userfront.forgot.heading',
|
||||
'ui.userfront.forgot.input_label',
|
||||
'ui.userfront.forgot.title',
|
||||
'msg.userfront.forgot.description',
|
||||
'msg.userfront.forgot.sent',
|
||||
'ui.userfront.login.link.action_label',
|
||||
'ui.userfront.login.link.page_title',
|
||||
'ui.userfront.login.link.title',
|
||||
'ui.userfront.login.unregistered.action',
|
||||
'ui.userfront.login.unregistered.title',
|
||||
'ui.userfront.login.verification.action_label',
|
||||
'ui.userfront.login.verification.page_title',
|
||||
'ui.userfront.login.verification.title',
|
||||
'msg.userfront.login.qr.load_failed',
|
||||
'msg.userfront.login.short_code.invalid',
|
||||
'msg.userfront.login.unregistered.body',
|
||||
'ui.userfront.login_success.title',
|
||||
'msg.userfront.login_success.subtitle',
|
||||
'msg.userfront.profile.load_failed',
|
||||
'msg.userfront.profile.update_success',
|
||||
'ui.userfront.profile.password.title',
|
||||
'ui.userfront.profile.phone.code_hint',
|
||||
'msg.userfront.reset.invalid_body',
|
||||
'msg.userfront.reset.invalid_title',
|
||||
'ui.userfront.reset.subtitle',
|
||||
'ui.userfront.reset.title',
|
||||
'ui.userfront.signup.title',
|
||||
'msg.userfront.signup.agreement.title',
|
||||
'msg.userfront.signup.auth.title',
|
||||
'ui.userfront.signup.auth.email.label',
|
||||
'ui.userfront.signup.auth.email.title',
|
||||
'msg.userfront.signup.password.title',
|
||||
'msg.userfront.signup.profile.title',
|
||||
'msg.userfront.signup.success.body',
|
||||
'msg.userfront.signup.success.title',
|
||||
'ui.userfront.signup.success.action',
|
||||
];
|
||||
|
||||
final failures = <String>[];
|
||||
for (final key in criticalKeys) {
|
||||
final value = _readTomlValue(translations, key);
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
failures.add('$key is missing');
|
||||
continue;
|
||||
}
|
||||
if (_placeholderValues.contains(value.trim())) {
|
||||
failures.add('$key uses placeholder "$value"');
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
failures,
|
||||
isEmpty,
|
||||
reason: failures.isEmpty ? null : failures.join('\n'),
|
||||
);
|
||||
});
|
||||
}
|
||||
260
baron-sso/userfront/test/error_screen_test.dart
Normal file
260
baron-sso/userfront/test/error_screen_test.dart
Normal file
@@ -0,0 +1,260 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/constants/error_whitelist.dart';
|
||||
import 'package:userfront/features/auth/presentation/error_screen.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
|
||||
Future<void> _pumpErrorScreen(
|
||||
WidgetTester tester, {
|
||||
String? errorCode,
|
||||
String? description,
|
||||
bool? isProdOverride,
|
||||
Future<Map<String, dynamic>> Function()? sessionProfileLoader,
|
||||
Map<String, dynamic>? tenantAccessDetails,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ErrorScreen(
|
||||
errorCode: errorCode,
|
||||
description: description,
|
||||
isProdOverride: isProdOverride,
|
||||
sessionProfileLoader: sessionProfileLoader,
|
||||
tenantAccessDetails: tenantAccessDetails,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher;
|
||||
dispatcher.localeTestValue = const Locale('ko');
|
||||
dispatcher.localesTestValue = const [Locale('ko')];
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
final dispatcher = TestWidgetsFlutterBinding.instance.platformDispatcher;
|
||||
dispatcher.clearLocaleTestValue();
|
||||
dispatcher.clearLocalesTestValue();
|
||||
});
|
||||
|
||||
testWidgets('개발환경은 원문 메시지를 노출한다', (WidgetTester tester) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'custom_error',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: false,
|
||||
);
|
||||
|
||||
final title = tr(
|
||||
'msg.userfront.error.title_with_code',
|
||||
fallback: '오류: {{code}}',
|
||||
params: {'code': 'custom_error'},
|
||||
);
|
||||
final type = tr(
|
||||
'msg.userfront.error.type',
|
||||
fallback: '오류 종류: {{type}}',
|
||||
params: {'type': 'custom_error'},
|
||||
);
|
||||
|
||||
expect(find.text(title), findsOneWidget);
|
||||
expect(find.text('원문 메시지'), findsOneWidget);
|
||||
expect(find.text(type), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('프로덕션은 whitelist 메시지를 노출한다', (WidgetTester tester) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'settings_disabled',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: true,
|
||||
);
|
||||
|
||||
final title = tr(
|
||||
'msg.userfront.error.title',
|
||||
fallback: '인증 과정에서 오류가 발생했습니다',
|
||||
);
|
||||
final detail = tr(
|
||||
'msg.userfront.error.whitelist.settings_disabled',
|
||||
fallback: tr(internalErrorWhitelistMessageKeys['settings_disabled']!),
|
||||
);
|
||||
final type = tr(
|
||||
'msg.userfront.error.type',
|
||||
fallback: '오류 종류: {{type}}',
|
||||
params: {'type': 'settings_disabled'},
|
||||
);
|
||||
|
||||
expect(find.text(title), findsOneWidget);
|
||||
expect(find.text(detail), findsOneWidget);
|
||||
expect(find.text('원문 메시지'), findsNothing);
|
||||
expect(find.text(type), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('프로덕션은 ORY 코드를 bypass 처리한다', (WidgetTester tester) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'access_denied',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: true,
|
||||
);
|
||||
|
||||
final title = tr(
|
||||
'msg.userfront.error.title',
|
||||
fallback: '인증 과정에서 오류가 발생했습니다',
|
||||
);
|
||||
final detail = tr(
|
||||
'msg.userfront.error.ory.access_denied',
|
||||
fallback: '사용자가 동의를 거부했습니다.',
|
||||
);
|
||||
final type = tr(
|
||||
'msg.userfront.error.type',
|
||||
fallback: '오류 종류: {{type}}',
|
||||
params: {'type': 'access_denied'},
|
||||
);
|
||||
|
||||
expect(find.text(title), findsOneWidget);
|
||||
expect(find.text(detail), findsOneWidget);
|
||||
expect(find.text('원문 메시지'), findsNothing);
|
||||
expect(find.text(type), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('프로덕션은 비허용 에러를 unknown_error로 처리한다', (WidgetTester tester) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'weird_error',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: true,
|
||||
);
|
||||
|
||||
final title = tr(
|
||||
'msg.userfront.error.title',
|
||||
fallback: '인증 과정에서 오류가 발생했습니다',
|
||||
);
|
||||
final detail = tr(
|
||||
'msg.userfront.error.detail_contact',
|
||||
fallback: '에러가 계속되면 관리자에게 문의해주세요',
|
||||
);
|
||||
final type = tr(
|
||||
'msg.userfront.error.type',
|
||||
fallback: '오류 종류: {{type}}',
|
||||
params: {'type': 'unknown_error'},
|
||||
);
|
||||
|
||||
expect(find.text(title), findsOneWidget);
|
||||
expect(find.text(detail), findsOneWidget);
|
||||
expect(find.text('원문 메시지'), findsNothing);
|
||||
expect(find.text(type), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('프로덕션은 not_found 코드를 whitelist 메시지로 노출한다 (404 매핑)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'not_found',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: true,
|
||||
);
|
||||
|
||||
final detail = tr(
|
||||
'msg.userfront.error.whitelist.not_found',
|
||||
fallback: tr(internalErrorWhitelistMessageKeys['not_found']!),
|
||||
);
|
||||
final type = tr(
|
||||
'msg.userfront.error.type',
|
||||
fallback: '오류 종류: {{type}}',
|
||||
params: {'type': 'not_found'},
|
||||
);
|
||||
|
||||
expect(find.text(detail), findsOneWidget);
|
||||
expect(find.text(type), findsOneWidget);
|
||||
expect(find.text('원문 메시지'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('프로덕션은 rate_limited 코드를 whitelist 메시지로 노출한다 (429 매핑)', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'rate_limited',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: true,
|
||||
);
|
||||
|
||||
final detail = tr(
|
||||
'msg.userfront.error.whitelist.rate_limited',
|
||||
fallback: tr(internalErrorWhitelistMessageKeys['rate_limited']!),
|
||||
);
|
||||
final type = tr(
|
||||
'msg.userfront.error.type',
|
||||
fallback: '오류 종류: {{type}}',
|
||||
params: {'type': 'rate_limited'},
|
||||
);
|
||||
|
||||
expect(find.text(detail), findsOneWidget);
|
||||
expect(find.text(type), findsOneWidget);
|
||||
expect(find.text('원문 메시지'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tenant_not_allowed는 전용 차단 정보를 노출한다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'tenant_not_allowed',
|
||||
description: '원문 메시지',
|
||||
isProdOverride: true,
|
||||
sessionProfileLoader: () async {
|
||||
return {
|
||||
'email': 'employee@example.com',
|
||||
'tenant': {'name': 'Baron HQ', 'slug': 'baron-hq'},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const title = 'Application access is restricted';
|
||||
const detail =
|
||||
'The current signed-in account cannot access this application.';
|
||||
const account = 'Account';
|
||||
const primaryTenant = 'Primary affiliated tenant';
|
||||
const affiliatedTenants = 'All affiliated tenants';
|
||||
expect(find.text(title), findsOneWidget);
|
||||
expect(find.text(detail), findsOneWidget);
|
||||
expect(find.text(account), findsOneWidget);
|
||||
expect(find.text('employee@example.com'), findsOneWidget);
|
||||
expect(find.text(primaryTenant), findsOneWidget);
|
||||
expect(find.text(affiliatedTenants), findsOneWidget);
|
||||
expect(find.text('Baron HQ'), findsNWidgets(2));
|
||||
expect(find.byType(ElevatedButton), findsOneWidget);
|
||||
expect(find.byType(OutlinedButton), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tenant_not_allowed는 details를 우선 사용해 계정과 테넌트 정보를 노출한다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await _pumpErrorScreen(
|
||||
tester,
|
||||
errorCode: 'tenant_not_allowed',
|
||||
isProdOverride: true,
|
||||
tenantAccessDetails: {
|
||||
'account': {'email': 'dyddus1210@gmail.com'},
|
||||
'current_tenant': {'name': 'test1 company', 'slug': 'test1-company'},
|
||||
'affiliated_tenants': [
|
||||
{'name': 'test1 company', 'slug': 'test1-company'},
|
||||
{'name': 'test2 company', 'slug': 'test-company'},
|
||||
],
|
||||
'allowed_tenants': [
|
||||
{'name': 'test4', 'slug': 'test4'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(find.text('dyddus1210@gmail.com'), findsOneWidget);
|
||||
expect(find.text('test1 company'), findsOneWidget);
|
||||
expect(find.text('test1 company, test2 company'), findsOneWidget);
|
||||
expect(find.text('test4'), findsOneWidget);
|
||||
expect(find.text('알 수 없음'), findsNothing);
|
||||
});
|
||||
}
|
||||
105
baron-sso/userfront/test/linked_rp_launch_test.dart
Normal file
105
baron-sso/userfront/test/linked_rp_launch_test.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart';
|
||||
import 'package:userfront/features/dashboard/domain/providers/linked_rps_provider.dart';
|
||||
|
||||
LinkedRp _linkedRp({
|
||||
required String status,
|
||||
String url = '',
|
||||
String initUrl = '',
|
||||
bool autoLoginSupported = false,
|
||||
String autoLoginUrl = '',
|
||||
}) {
|
||||
return LinkedRp(
|
||||
id: 'client-1',
|
||||
name: 'Example App',
|
||||
logo: '',
|
||||
url: url,
|
||||
initUrl: initUrl,
|
||||
autoLoginSupported: autoLoginSupported,
|
||||
autoLoginUrl: autoLoginUrl,
|
||||
status: status,
|
||||
scopes: const ['openid', 'profile'],
|
||||
lastAuthenticatedAt: null,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('LinkedRp.fromJson은 init_url을 읽는다', () {
|
||||
final rp = LinkedRp.fromJson({
|
||||
'id': 'client-1',
|
||||
'name': 'Example App',
|
||||
'status': 'active',
|
||||
'url': 'https://example.com',
|
||||
'init_url': 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||
'auto_login_supported': true,
|
||||
'auto_login_url': 'https://example.com/login?auto=1',
|
||||
});
|
||||
|
||||
expect(
|
||||
rp.initUrl,
|
||||
'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||
);
|
||||
expect(rp.autoLoginSupported, isTrue);
|
||||
expect(rp.autoLoginUrl, 'https://example.com/login?auto=1');
|
||||
});
|
||||
|
||||
test('자동 로그인 지원 앱은 autoLoginUrl을 우선 진입 URL로 사용한다', () {
|
||||
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||
_linkedRp(
|
||||
status: 'active',
|
||||
url: 'https://example.com',
|
||||
initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||
autoLoginSupported: true,
|
||||
autoLoginUrl: 'https://example.com/login?auto=1',
|
||||
),
|
||||
);
|
||||
|
||||
expect(launchUrl, 'https://example.com/login?auto=1');
|
||||
});
|
||||
|
||||
test('자동 로그인 지원 앱은 autoLoginUrl이 없으면 initUrl로 폴백한다', () {
|
||||
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||
_linkedRp(
|
||||
status: 'active',
|
||||
url: 'https://example.com',
|
||||
initUrl: 'https://example.com/login?auto=1',
|
||||
autoLoginSupported: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(launchUrl, 'https://example.com/login?auto=1');
|
||||
});
|
||||
|
||||
test('자동 로그인 미지원 앱은 initUrl이 있어도 기존 url로 폴백한다', () {
|
||||
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||
_linkedRp(
|
||||
status: 'active',
|
||||
url: 'https://example.com',
|
||||
initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||
autoLoginSupported: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(launchUrl, 'https://example.com');
|
||||
});
|
||||
|
||||
test('활성 앱은 자동 로그인 URL이 없으면 기존 url로 폴백한다', () {
|
||||
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||
_linkedRp(status: 'active', url: 'https://example.com'),
|
||||
);
|
||||
|
||||
expect(launchUrl, 'https://example.com');
|
||||
});
|
||||
|
||||
test('비활성 앱은 진입 URL을 만들지 않는다', () {
|
||||
final launchUrl = resolveLinkedRpLaunchUrl(
|
||||
_linkedRp(
|
||||
status: 'inactive',
|
||||
url: 'https://example.com',
|
||||
initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
|
||||
),
|
||||
);
|
||||
|
||||
expect(launchUrl, isNull);
|
||||
});
|
||||
}
|
||||
36
baron-sso/userfront/test/locale_registry_test.dart
Normal file
36
baron-sso/userfront/test/locale_registry_test.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/i18n/locale_registry.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
LocaleRegistry.resetForTest();
|
||||
});
|
||||
|
||||
group('locale_registry', () {
|
||||
test(
|
||||
'extractSupportedLocaleCodesFromAssets excludes template and invalid',
|
||||
() {
|
||||
final locales = extractSupportedLocaleCodesFromAssets([
|
||||
'assets/translations/template.toml',
|
||||
'assets/translations/en.toml',
|
||||
'assets/translations/ko.toml',
|
||||
'assets/translations/pt_BR.toml',
|
||||
'assets/translations/readme.txt',
|
||||
'assets/translations/nested/ja.toml',
|
||||
]);
|
||||
|
||||
expect(locales, ['en', 'ko']);
|
||||
},
|
||||
);
|
||||
|
||||
test('fallback locale prefers en when available', () {
|
||||
LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'en']);
|
||||
expect(LocaleRegistry.fallbackLocaleCode, 'en');
|
||||
});
|
||||
|
||||
test('fallback locale uses first sorted code when en is absent', () {
|
||||
LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'ja']);
|
||||
expect(LocaleRegistry.fallbackLocaleCode, 'ja');
|
||||
});
|
||||
});
|
||||
}
|
||||
138
baron-sso/userfront/test/locale_storage_platform_test.dart
Normal file
138
baron-sso/userfront/test/locale_storage_platform_test.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/i18n/locale_storage_backend.dart';
|
||||
import 'package:userfront/core/i18n/locale_storage_engine.dart';
|
||||
import 'package:userfront/core/i18n/locale_storage_policy.dart';
|
||||
|
||||
class _FakeTarget implements LocaleStorageTarget {
|
||||
_FakeTarget();
|
||||
|
||||
final Map<String, String> store = {};
|
||||
bool throwOnRead = false;
|
||||
bool throwOnWrite = false;
|
||||
bool throwOnRemove = false;
|
||||
bool throwOnClear = false;
|
||||
|
||||
@override
|
||||
String? read(String key) {
|
||||
if (throwOnRead) {
|
||||
throw StateError('read blocked');
|
||||
}
|
||||
return store[key];
|
||||
}
|
||||
|
||||
@override
|
||||
bool write(String key, String value) {
|
||||
if (throwOnWrite) {
|
||||
throw StateError('write blocked');
|
||||
}
|
||||
store[key] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
bool remove(String key) {
|
||||
if (throwOnRemove) {
|
||||
throw StateError('remove blocked');
|
||||
}
|
||||
store.remove(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
if (throwOnClear) {
|
||||
throw StateError('clear blocked');
|
||||
}
|
||||
store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _FakeTarget localTarget;
|
||||
late _FakeTarget sessionTarget;
|
||||
late LocaleStorageEngine engine;
|
||||
|
||||
setUp(() {
|
||||
localTarget = _FakeTarget();
|
||||
sessionTarget = _FakeTarget();
|
||||
engine = LocaleStorageEngine(
|
||||
localTarget: localTarget,
|
||||
sessionTarget: sessionTarget,
|
||||
);
|
||||
engine.clearForTests();
|
||||
});
|
||||
|
||||
test('기본 모드에서는 local 우선으로 저장/조회한다', () {
|
||||
engine.write('ko');
|
||||
expect(engine.read(), 'ko');
|
||||
|
||||
final state = engine.debugStateForTests();
|
||||
expect(state.localCurrent, 'ko');
|
||||
expect(state.sessionCurrent, isNull);
|
||||
expect(state.memoryCurrent, isNull);
|
||||
});
|
||||
|
||||
test('legacy key를 읽으면 current key로 마이그레이션한다', () {
|
||||
localTarget.store[LocaleStoragePolicy.legacyKey] = 'en';
|
||||
|
||||
expect(engine.read(), 'en');
|
||||
|
||||
final state = engine.debugStateForTests();
|
||||
expect(state.localCurrent, 'en');
|
||||
expect(state.localLegacy, isNull);
|
||||
});
|
||||
|
||||
test('localStorage가 차단되면 sessionStorage로 fallback 한다', () {
|
||||
localTarget
|
||||
..throwOnRead = true
|
||||
..throwOnWrite = true
|
||||
..throwOnRemove = true;
|
||||
|
||||
engine.write('ko');
|
||||
expect(engine.read(), 'ko');
|
||||
|
||||
final state = engine.debugStateForTests();
|
||||
expect(state.localCurrent, isNull);
|
||||
expect(state.sessionCurrent, 'ko');
|
||||
expect(state.memoryCurrent, isNull);
|
||||
});
|
||||
|
||||
test('local/session 모두 차단되면 memory fallback 한다', () {
|
||||
localTarget
|
||||
..throwOnRead = true
|
||||
..throwOnWrite = true
|
||||
..throwOnRemove = true;
|
||||
sessionTarget
|
||||
..throwOnRead = true
|
||||
..throwOnWrite = true
|
||||
..throwOnRemove = true;
|
||||
|
||||
engine.write('en');
|
||||
expect(engine.read(), 'en');
|
||||
|
||||
final state = engine.debugStateForTests();
|
||||
expect(state.localCurrent, isNull);
|
||||
expect(state.sessionCurrent, isNull);
|
||||
expect(state.memoryCurrent, 'en');
|
||||
});
|
||||
|
||||
test('sessionOnly 모드에서는 session + memory만 사용한다', () {
|
||||
engine.setTestMode(LocaleStorageTestMode.sessionOnly);
|
||||
engine.write('ko');
|
||||
|
||||
final state = engine.debugStateForTests();
|
||||
expect(state.localCurrent, isNull);
|
||||
expect(state.sessionCurrent, 'ko');
|
||||
expect(state.memoryCurrent, isNull);
|
||||
});
|
||||
|
||||
test('memoryOnly 모드에서는 memory만 사용한다', () {
|
||||
engine.setTestMode(LocaleStorageTestMode.memoryOnly);
|
||||
engine.write('en');
|
||||
|
||||
final state = engine.debugStateForTests();
|
||||
expect(state.localCurrent, isNull);
|
||||
expect(state.sessionCurrent, isNull);
|
||||
expect(state.memoryCurrent, 'en');
|
||||
});
|
||||
}
|
||||
158
baron-sso/userfront/test/locale_utils_test.dart
Normal file
158
baron-sso/userfront/test/locale_utils_test.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/i18n/locale_registry.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
LocaleRegistry.resetForTest();
|
||||
});
|
||||
|
||||
group('locale_utils', () {
|
||||
test('normalizeLocaleCode handles supported locales', () {
|
||||
expect(normalizeLocaleCode('ko'), 'ko');
|
||||
expect(normalizeLocaleCode('ko-KR'), 'ko');
|
||||
expect(normalizeLocaleCode('en'), 'en');
|
||||
expect(normalizeLocaleCode('en-US'), 'en');
|
||||
});
|
||||
|
||||
test('normalizeLocaleCode falls back to default', () {
|
||||
expect(normalizeLocaleCode('ja'), defaultLocaleCode);
|
||||
expect(normalizeLocaleCode(null), defaultLocaleCode);
|
||||
expect(normalizeLocaleCode(''), defaultLocaleCode);
|
||||
});
|
||||
|
||||
test('extractLocaleFromPath picks locale when present', () {
|
||||
expect(extractLocaleFromPath(Uri.parse('/ko/signin')), 'ko');
|
||||
expect(extractLocaleFromPath(Uri.parse('/en/profile')), 'en');
|
||||
expect(extractLocaleFromPath(Uri.parse('/ko')), 'ko');
|
||||
});
|
||||
|
||||
test('extractLocaleFromPath returns null when missing', () {
|
||||
expect(extractLocaleFromPath(Uri.parse('/signin')), isNull);
|
||||
expect(extractLocaleFromPath(Uri.parse('/zz/signin')), isNull);
|
||||
});
|
||||
|
||||
test('stripLocalePath removes locale segment', () {
|
||||
expect(stripLocalePath(Uri.parse('/ko/signin')), '/signin');
|
||||
expect(stripLocalePath(Uri.parse('/en/profile')), '/profile');
|
||||
expect(stripLocalePath(Uri.parse('/ko')), '/');
|
||||
expect(stripLocalePath(Uri.parse('/en/')), '/');
|
||||
});
|
||||
|
||||
test('stripLocalePath keeps path without locale', () {
|
||||
expect(stripLocalePath(Uri.parse('/signin')), '/signin');
|
||||
expect(stripLocalePath(Uri.parse('/auth/callback')), '/auth/callback');
|
||||
});
|
||||
|
||||
test('buildLocalizedPath applies locale', () {
|
||||
expect(buildLocalizedPath('ko', Uri.parse('/signin')), '/ko/signin');
|
||||
expect(buildLocalizedPath('en', Uri.parse('/signin')), '/en/signin');
|
||||
expect(buildLocalizedPath('ko', Uri.parse('/')), '/ko');
|
||||
expect(buildLocalizedPath('en', Uri.parse('/')), '/en');
|
||||
});
|
||||
|
||||
test('buildLocalizedPath preserves query parameters', () {
|
||||
final uri = Uri.parse('/signin?redirect_uri=https://example.com');
|
||||
expect(
|
||||
buildLocalizedPath('ko', uri),
|
||||
'/ko/signin?redirect_uri=https://example.com',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildLocalizedPath preserves redirect_url parameter', () {
|
||||
final uri = Uri.parse('/signin?redirect_url=https://example.com/after');
|
||||
expect(
|
||||
buildLocalizedPath('ko', uri),
|
||||
'/ko/signin?redirect_url=https://example.com/after',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildLocalizedPath preserves raw query order and duplicates', () {
|
||||
final uri = Uri.parse(
|
||||
'/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2',
|
||||
);
|
||||
expect(
|
||||
buildLocalizedPath('ko', uri),
|
||||
'/ko/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildLocalizedPath preserves fragment', () {
|
||||
final uri = Uri.parse('/signin?notice=qr_login_required#auth');
|
||||
expect(
|
||||
buildLocalizedPath('ko', uri),
|
||||
'/ko/signin?notice=qr_login_required#auth',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildLocalizedPath replaces existing locale', () {
|
||||
expect(buildLocalizedPath('en', Uri.parse('/ko/signin')), '/en/signin');
|
||||
expect(buildLocalizedPath('ko', Uri.parse('/en/profile')), '/ko/profile');
|
||||
});
|
||||
|
||||
test('buildLocalizedPath keeps unknown 2-letter prefix as path', () {
|
||||
expect(
|
||||
buildLocalizedPath('ko', Uri.parse('/zz/signin')),
|
||||
'/ko/zz/signin',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildSigninRedirectPath keeps path without query', () {
|
||||
expect(
|
||||
buildSigninRedirectPath('ko', Uri.parse('/ko/profile')),
|
||||
'/ko/signin',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildSigninRedirectPath preserves full raw query', () {
|
||||
final uri = Uri.parse(
|
||||
'/ko/profile?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2¬ice=qr_login_required',
|
||||
);
|
||||
expect(
|
||||
buildSigninRedirectPath('ko', uri),
|
||||
'/ko/signin?a=1&a=2&redirect_uri=https%3A%2F%2Fexample.com%2Fcb%3Fx%3D1%26y%3D2¬ice=qr_login_required',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildSigninRedirectPath preserves redirect_url and redirect_uri', () {
|
||||
final uri = Uri.parse(
|
||||
'/ko/profile?redirect_url=https%3A%2F%2Fa.example.com%2Fcb&redirect_uri=https%3A%2F%2Fb.example.com%2Fcb',
|
||||
);
|
||||
expect(
|
||||
buildSigninRedirectPath('ko', uri),
|
||||
'/ko/signin?redirect_url=https%3A%2F%2Fa.example.com%2Fcb&redirect_uri=https%3A%2F%2Fb.example.com%2Fcb',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildLocalizedHomePath keeps locale from uri', () {
|
||||
expect(buildLocalizedHomePath(Uri.parse('/ko/signin')), '/ko/dashboard');
|
||||
expect(buildLocalizedHomePath(Uri.parse('/en/profile')), '/en/dashboard');
|
||||
});
|
||||
|
||||
test('buildLocalizedHomePath falls back to preferred locale', () {
|
||||
expect(
|
||||
buildLocalizedHomePath(Uri.parse('/signin'), preferredLocaleCode: 'ko'),
|
||||
'/ko/dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildLocalizedSigninPath keeps locale from uri', () {
|
||||
expect(buildLocalizedSigninPath(Uri.parse('/ko')), '/ko/signin');
|
||||
expect(buildLocalizedSigninPath(Uri.parse('/en/profile')), '/en/signin');
|
||||
});
|
||||
|
||||
test('buildLocalizedSigninPath falls back to preferred locale', () {
|
||||
expect(
|
||||
buildLocalizedSigninPath(
|
||||
Uri.parse('/profile'),
|
||||
preferredLocaleCode: 'ko',
|
||||
),
|
||||
'/ko/signin',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
178
baron-sso/userfront/test/log_policy_test.dart
Normal file
178
baron-sso/userfront/test/log_policy_test.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/log_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('LogPolicy.debugEnabled', () {
|
||||
test('development-like environment enables debug by default', () {
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'dev', productionDebugFlag: null),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'development', productionDebugFlag: ''),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit true enables debug in development-like environment', () {
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'dev', productionDebugFlag: 'true'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'development', productionDebugFlag: '1'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit false does not suppress local debug in development', () {
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'dev', productionDebugFlag: 'false'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'development', productionDebugFlag: '0'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('production disables debug unless explicitly enabled', () {
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'production', productionDebugFlag: ''),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'stage', productionDebugFlag: ''),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.debugEnabled(
|
||||
appEnv: 'production',
|
||||
productionDebugFlag: 'true',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.debugEnabled(appEnv: 'prod', productionDebugFlag: '1'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('LogPolicy.shouldRelayClientLog', () {
|
||||
test('production default forwards only warning or higher', () {
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'INFO',
|
||||
appEnv: 'production',
|
||||
productionDebugFlag: '',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'INFO',
|
||||
appEnv: 'stage',
|
||||
productionDebugFlag: '',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'WARNING',
|
||||
appEnv: 'production',
|
||||
productionDebugFlag: '',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'WARNING',
|
||||
appEnv: 'stage',
|
||||
productionDebugFlag: '',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'ERROR',
|
||||
appEnv: 'production',
|
||||
productionDebugFlag: '',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('production debug option forwards info logs', () {
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'INFO',
|
||||
appEnv: 'production',
|
||||
productionDebugFlag: 'true',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'explicit development debug false forwards only warning or higher',
|
||||
() {
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'INFO',
|
||||
appEnv: 'dev',
|
||||
productionDebugFlag: 'false',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
LogPolicy.shouldRelayClientLog(
|
||||
level: 'WARN',
|
||||
appEnv: 'dev',
|
||||
productionDebugFlag: 'false',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('LogPolicy.sanitize', () {
|
||||
test('sanitizes sensitive message patterns', () {
|
||||
const message =
|
||||
'token=abc123 payload={"password":"hello","safe":"ok"} authorization:BearerXYZ';
|
||||
final sanitized = LogPolicy.sanitizeMessage(message);
|
||||
expect(sanitized, isNot(contains('abc123')));
|
||||
expect(sanitized, contains('token=*****'));
|
||||
expect(sanitized, contains('"password":"*****"'));
|
||||
expect(sanitized, contains('authorization=*****'));
|
||||
});
|
||||
|
||||
test('sanitizes nested sensitive keys', () {
|
||||
final data = <String, dynamic>{
|
||||
'token': 'tok',
|
||||
'ok': 'value',
|
||||
'nested': {'new_password': 'pw', 'safe': 'x'},
|
||||
'arr': [
|
||||
{'authorization': 'Bearer secret'},
|
||||
'cookie=session=raw',
|
||||
],
|
||||
};
|
||||
|
||||
final sanitized = LogPolicy.sanitizeData(data);
|
||||
expect(sanitized['token'], '*****');
|
||||
expect(sanitized['ok'], 'value');
|
||||
expect(
|
||||
(sanitized['nested'] as Map<String, dynamic>)['new_password'],
|
||||
'*****',
|
||||
);
|
||||
expect((sanitized['nested'] as Map<String, dynamic>)['safe'], 'x');
|
||||
expect(
|
||||
((sanitized['arr'] as List).first
|
||||
as Map<String, dynamic>)['authorization'],
|
||||
'*****',
|
||||
);
|
||||
expect((sanitized['arr'] as List)[1], 'cookie=*****');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/login_challenge_loop_guard.dart';
|
||||
|
||||
void main() {
|
||||
group('login_challenge_loop_guard', () {
|
||||
test('mark 이후 cooldown 내 재시도는 차단되고 clear 후 허용된다', () {
|
||||
const challenge = 'loop-guard-test-challenge';
|
||||
loginChallengeLoopGuard.clear(challenge);
|
||||
|
||||
expect(loginChallengeLoopGuard.shouldAllowAutoAccept(challenge), isTrue);
|
||||
|
||||
loginChallengeLoopGuard.markAutoAcceptAttempt(challenge);
|
||||
|
||||
expect(
|
||||
loginChallengeLoopGuard.shouldAllowAutoAccept(
|
||||
challenge,
|
||||
cooldownMs: 60000,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
loginChallengeLoopGuard.clear(challenge);
|
||||
|
||||
expect(loginChallengeLoopGuard.shouldAllowAutoAccept(challenge), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
100
baron-sso/userfront/test/login_challenge_resolver_test.dart
Normal file
100
baron-sso/userfront/test/login_challenge_resolver_test.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/auth/domain/login_challenge_resolver.dart';
|
||||
|
||||
void main() {
|
||||
group('login_challenge_resolver', () {
|
||||
test('widget 값이 있으면 최우선으로 사용', () {
|
||||
final resolved = resolveLoginChallenge(
|
||||
widgetLoginChallenge: 'widget-challenge',
|
||||
uri: Uri.parse('/ko/login'),
|
||||
rawSearch: '?login_challenge=raw-search',
|
||||
rawHref: 'https://sso.example.test/ko/login?login_challenge=raw-href',
|
||||
);
|
||||
|
||||
expect(resolved.value, 'widget-challenge');
|
||||
expect(resolved.source, LoginChallengeSource.widget);
|
||||
});
|
||||
|
||||
test('widget 값이 없으면 URI query에서 복구', () {
|
||||
final resolved = resolveLoginChallenge(
|
||||
widgetLoginChallenge: null,
|
||||
uri: Uri.parse('/ko/login?login_challenge=uri-query'),
|
||||
rawSearch: '',
|
||||
rawHref: '',
|
||||
);
|
||||
|
||||
expect(resolved.value, 'uri-query');
|
||||
expect(resolved.source, LoginChallengeSource.uriQuery);
|
||||
});
|
||||
|
||||
test('URI query가 비어 있으면 raw search에서 복구', () {
|
||||
final resolved = resolveLoginChallenge(
|
||||
widgetLoginChallenge: null,
|
||||
uri: Uri.parse('/ko/login'),
|
||||
rawSearch: '?login_challenge=raw-search-value&x=1',
|
||||
rawHref: '',
|
||||
);
|
||||
|
||||
expect(resolved.value, 'raw-search-value');
|
||||
expect(resolved.source, LoginChallengeSource.rawSearch);
|
||||
expect(resolved.rawSearchHasLoginChallenge, isTrue);
|
||||
});
|
||||
|
||||
test('raw search도 비어 있으면 raw href에서 복구', () {
|
||||
final resolved = resolveLoginChallenge(
|
||||
widgetLoginChallenge: null,
|
||||
uri: Uri.parse('/ko/login'),
|
||||
rawSearch: '',
|
||||
rawHref:
|
||||
'https://sso.example.test/ko/login?a=1&login_challenge=raw-href-value#fragment',
|
||||
);
|
||||
|
||||
expect(resolved.value, 'raw-href-value');
|
||||
expect(resolved.source, LoginChallengeSource.rawHref);
|
||||
expect(resolved.rawHrefHasLoginChallenge, isTrue);
|
||||
});
|
||||
|
||||
test('raw query 파싱 실패 시 수동 파싱으로 복구하고 diagnostics를 남긴다', () {
|
||||
final resolved = resolveLoginChallenge(
|
||||
widgetLoginChallenge: ' ',
|
||||
uri: Uri.parse('/ko/login'),
|
||||
rawSearch: '?x=%E0%A4%A&login_challenge=manual%20value',
|
||||
rawHref: '',
|
||||
);
|
||||
|
||||
expect(resolved.value, 'manual value');
|
||||
expect(resolved.source, LoginChallengeSource.rawSearch);
|
||||
expect(resolved.toDiagnostics(), {
|
||||
'resolved_value_len': 12,
|
||||
'resolved_source': 'rawSearch',
|
||||
'uri_has_login_challenge': false,
|
||||
'raw_search_has_login_challenge': true,
|
||||
'raw_href_has_login_challenge': false,
|
||||
});
|
||||
});
|
||||
|
||||
test('raw href가 일반 URI로 파싱되지 않아도 query 조각에서 복구한다', () {
|
||||
final resolved = resolveLoginChallenge(
|
||||
widgetLoginChallenge: null,
|
||||
uri: Uri.parse('/ko/login'),
|
||||
rawSearch: '?login_challenge',
|
||||
rawHref: 'not a url ?login_challenge=href%20fallback#fragment',
|
||||
);
|
||||
|
||||
expect(resolved.value, 'href fallback');
|
||||
expect(resolved.source, LoginChallengeSource.rawHref);
|
||||
});
|
||||
|
||||
test('값이 전부 없으면 missing', () {
|
||||
final resolved = resolveLoginChallenge(
|
||||
widgetLoginChallenge: null,
|
||||
uri: Uri.parse('/ko/login'),
|
||||
rawSearch: '',
|
||||
rawHref: 'https://sso.example.test/ko/login?x=1',
|
||||
);
|
||||
|
||||
expect(resolved.value, isNull);
|
||||
expect(resolved.source, LoginChallengeSource.missing);
|
||||
});
|
||||
});
|
||||
}
|
||||
75
baron-sso/userfront/test/login_link_route_policy_test.dart
Normal file
75
baron-sso/userfront/test/login_link_route_policy_test.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/i18n/locale_registry.dart';
|
||||
import 'package:userfront/features/auth/domain/login_link_route_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('login_link_route_policy', () {
|
||||
setUp(() {
|
||||
LocaleRegistry.setSupportedLocaleCodesForTest(['ko', 'en']);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
LocaleRegistry.resetForTest();
|
||||
});
|
||||
|
||||
test('extracts short code from plain short-code route', () {
|
||||
final shortCode = extractLoginShortCode(Uri.parse('/l/AB123456'));
|
||||
expect(shortCode, 'AB123456');
|
||||
});
|
||||
|
||||
test('extracts short code from localized short-code route', () {
|
||||
final shortCode = extractLoginShortCode(Uri.parse('/ko/l/AB123456'));
|
||||
expect(shortCode, 'AB123456');
|
||||
});
|
||||
|
||||
test('treats localized short-code route as public path', () {
|
||||
final isPublic = isPublicAuthPath(
|
||||
'/l/AB123456',
|
||||
Uri.parse('/ko/l/AB123456'),
|
||||
);
|
||||
expect(isPublic, isTrue);
|
||||
});
|
||||
|
||||
test('public auth path 목록을 허용하고 일반 보호 경로는 제외한다', () {
|
||||
final publicPaths = [
|
||||
'/signin',
|
||||
'/signup',
|
||||
'/login',
|
||||
'/registration',
|
||||
'/verify',
|
||||
'/verification',
|
||||
'/verify-complete',
|
||||
'/verify/token',
|
||||
'/l/AB123456',
|
||||
'/approve',
|
||||
'/ql/AB123456',
|
||||
'/forgot-password',
|
||||
'/recovery',
|
||||
'/reset-password',
|
||||
'/error',
|
||||
'/settings',
|
||||
'/consent',
|
||||
'/consent/challenge',
|
||||
];
|
||||
|
||||
for (final path in publicPaths) {
|
||||
expect(isPublicAuthPath(path, Uri.parse(path)), isTrue, reason: path);
|
||||
}
|
||||
|
||||
expect(
|
||||
isPublicAuthPath('/dashboard', Uri.parse('/ko/dashboard')),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
isPublicAuthPath('/dashboard', Uri.parse('/ko/auth/consent/callback')),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('short code route가 아니면 null을 반환한다', () {
|
||||
expect(extractLoginShortCode(Uri.parse('/login')), isNull);
|
||||
expect(extractLoginShortCode(Uri.parse('/l')), isNull);
|
||||
expect(extractLoginShortCode(Uri.parse('/x/AB123456')), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
94
baron-sso/userfront/test/login_navigation_race_test.dart
Normal file
94
baron-sso/userfront/test/login_navigation_race_test.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/core/i18n/locale_registry.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/core/services/auth_token_store.dart';
|
||||
|
||||
class _AuthRefreshNotifier extends ChangeNotifier {
|
||||
void refresh() => notifyListeners();
|
||||
}
|
||||
|
||||
Widget _buildRaceTestApp(_AuthRefreshNotifier notifier) {
|
||||
final router = GoRouter(
|
||||
initialLocation: '/ko/signin',
|
||||
refreshListenable: notifier,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/:locale',
|
||||
builder: (context, state) => const Scaffold(body: Text('locale-root')),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'dashboard',
|
||||
builder: (context, state) => const Scaffold(body: Text('home')),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'signin',
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
AuthTokenStore.setToken('race-token', provider: 'ory');
|
||||
notifier.refresh();
|
||||
context.go('/ko/dashboard');
|
||||
},
|
||||
child: const Text('login'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
redirect: (context, state) {
|
||||
final requestedLocale = extractLocaleFromPath(state.uri);
|
||||
if (requestedLocale == null) {
|
||||
return buildLocalizedPath(resolvePreferredLocaleCode(), state.uri);
|
||||
}
|
||||
|
||||
final token = AuthTokenStore.getToken();
|
||||
final isLoggedIn =
|
||||
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||
final path = stripLocalePath(state.uri);
|
||||
if (path == '/signin') {
|
||||
return null;
|
||||
}
|
||||
if (!isLoggedIn) {
|
||||
return buildSigninRedirectPath(requestedLocale, state.uri);
|
||||
}
|
||||
if (path == '/') {
|
||||
return '/$requestedLocale/dashboard';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
return MaterialApp.router(routerConfig: router);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']);
|
||||
AuthTokenStore.clear();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
AuthTokenStore.clear();
|
||||
LocaleRegistry.resetForTest();
|
||||
});
|
||||
|
||||
testWidgets('로그인 성공 이벤트(notify + go) 동시 호출 시 홈으로 안정적으로 이동', (tester) async {
|
||||
final notifier = _AuthRefreshNotifier();
|
||||
await tester.pumpWidget(_buildRaceTestApp(notifier));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('login'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('login'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('home'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
120
baron-sso/userfront/test/logout_service_test.dart
Normal file
120
baron-sso/userfront/test/logout_service_test.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/logout_service.dart';
|
||||
|
||||
void main() {
|
||||
test('현재 세션이 있으면 서버 세션 종료 후 로컬 로그아웃을 진행한다', () async {
|
||||
final events = <String>[];
|
||||
final service = LogoutService(
|
||||
loadCurrentSessionId: () async {
|
||||
events.add('load');
|
||||
return 'current-sid';
|
||||
},
|
||||
revokeSession: (sessionId) async {
|
||||
events.add('revoke:$sessionId');
|
||||
},
|
||||
clearAuth: () {
|
||||
events.add('clear');
|
||||
},
|
||||
notifyAuthChanged: () {
|
||||
events.add('notify');
|
||||
},
|
||||
);
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']);
|
||||
});
|
||||
|
||||
test('현재 세션이 없으면 서버 세션 종료 없이 로컬 로그아웃만 진행한다', () async {
|
||||
final events = <String>[];
|
||||
final service = LogoutService(
|
||||
loadCurrentSessionId: () async {
|
||||
events.add('load');
|
||||
return null;
|
||||
},
|
||||
revokeSession: (sessionId) async {
|
||||
events.add('revoke:$sessionId');
|
||||
},
|
||||
clearAuth: () {
|
||||
events.add('clear');
|
||||
},
|
||||
notifyAuthChanged: () {
|
||||
events.add('notify');
|
||||
},
|
||||
);
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(events, ['load', 'clear', 'notify']);
|
||||
});
|
||||
|
||||
test('현재 세션 ID가 빈 문자열이면 서버 세션 종료 없이 로컬 로그아웃만 진행한다', () async {
|
||||
final events = <String>[];
|
||||
final service = LogoutService(
|
||||
loadCurrentSessionId: () async {
|
||||
events.add('load');
|
||||
return '';
|
||||
},
|
||||
revokeSession: (sessionId) async {
|
||||
events.add('revoke:$sessionId');
|
||||
},
|
||||
clearAuth: () {
|
||||
events.add('clear');
|
||||
},
|
||||
notifyAuthChanged: () {
|
||||
events.add('notify');
|
||||
},
|
||||
);
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(events, ['load', 'clear', 'notify']);
|
||||
});
|
||||
|
||||
test('현재 세션 조회가 실패해도 로컬 로그아웃은 계속 진행한다', () async {
|
||||
final events = <String>[];
|
||||
final service = LogoutService(
|
||||
loadCurrentSessionId: () async {
|
||||
events.add('load');
|
||||
throw Exception('load failed');
|
||||
},
|
||||
revokeSession: (sessionId) async {
|
||||
events.add('revoke:$sessionId');
|
||||
},
|
||||
clearAuth: () {
|
||||
events.add('clear');
|
||||
},
|
||||
notifyAuthChanged: () {
|
||||
events.add('notify');
|
||||
},
|
||||
);
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(events, ['load', 'clear', 'notify']);
|
||||
});
|
||||
|
||||
test('서버 세션 종료가 실패해도 로컬 로그아웃은 계속 진행한다', () async {
|
||||
final events = <String>[];
|
||||
final service = LogoutService(
|
||||
loadCurrentSessionId: () async {
|
||||
events.add('load');
|
||||
return 'current-sid';
|
||||
},
|
||||
revokeSession: (sessionId) async {
|
||||
events.add('revoke:$sessionId');
|
||||
throw Exception('revoke failed');
|
||||
},
|
||||
clearAuth: () {
|
||||
events.add('clear');
|
||||
},
|
||||
notifyAuthChanged: () {
|
||||
events.add('notify');
|
||||
},
|
||||
);
|
||||
|
||||
await service.logout();
|
||||
|
||||
expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']);
|
||||
});
|
||||
}
|
||||
63
baron-sso/userfront/test/null_check_recovery_test.dart
Normal file
63
baron-sso/userfront/test/null_check_recovery_test.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/i18n/locale_registry.dart';
|
||||
import 'package:userfront/core/services/null_check_recovery.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
LocaleRegistry.resetForTest();
|
||||
});
|
||||
|
||||
test('Null check 오류 + 루트(/)면 선호 로케일 signin으로 복구', () {
|
||||
final target = computeNullCheckRecoveryTarget(
|
||||
exception: Exception('Null check operator used on a null value'),
|
||||
uri: Uri.parse('https://sss.hmac.kr/'),
|
||||
preferredLocaleCode: 'ko',
|
||||
);
|
||||
|
||||
expect(target, '/ko/signin');
|
||||
});
|
||||
|
||||
test('Null check 오류 + /ko면 /ko/signin으로 복구', () {
|
||||
final target = computeNullCheckRecoveryTarget(
|
||||
exception: Exception('Null check operator used on a null value'),
|
||||
uri: Uri.parse('https://sss.hmac.kr/ko'),
|
||||
preferredLocaleCode: 'en',
|
||||
);
|
||||
|
||||
expect(target, '/ko/signin');
|
||||
});
|
||||
|
||||
test('이미 /ko/signin이면 복구 이동하지 않음', () {
|
||||
final target = computeNullCheckRecoveryTarget(
|
||||
exception: Exception('Null check operator used on a null value'),
|
||||
uri: Uri.parse('https://sss.hmac.kr/ko/signin'),
|
||||
preferredLocaleCode: 'ko',
|
||||
);
|
||||
|
||||
expect(target, isNull);
|
||||
});
|
||||
|
||||
test('Null check 오류여도 /ko/profile에서는 복구 이동하지 않음', () {
|
||||
final target = computeNullCheckRecoveryTarget(
|
||||
exception: Exception('Null check operator used on a null value'),
|
||||
uri: Uri.parse('https://sss.hmac.kr/ko/profile'),
|
||||
preferredLocaleCode: 'ko',
|
||||
);
|
||||
|
||||
expect(target, isNull);
|
||||
});
|
||||
|
||||
test('다른 오류 메시지면 복구 이동하지 않음', () {
|
||||
final target = computeNullCheckRecoveryTarget(
|
||||
exception: Exception('Some other error'),
|
||||
uri: Uri.parse('https://sss.hmac.kr/ko'),
|
||||
preferredLocaleCode: 'ko',
|
||||
);
|
||||
|
||||
expect(target, isNull);
|
||||
});
|
||||
}
|
||||
62
baron-sso/userfront/test/oidc_redirect_guard_test.dart
Normal file
62
baron-sso/userfront/test/oidc_redirect_guard_test.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/oidc_redirect_guard.dart';
|
||||
|
||||
void main() {
|
||||
group('oidc_redirect_guard', () {
|
||||
test('http/https 절대 URL만 허용', () {
|
||||
final ok = validateOidcRedirectTarget(
|
||||
'https://sso.example.test/oidc/oauth2/auth?client_id=devfront&login_verifier=abc&state=xyz&code_challenge=ccc&code_challenge_method=S256&response_type=code&scope=openid%20profile&redirect_uri=http%3A%2F%2Flocalhost%3A5174%2Fcallback',
|
||||
);
|
||||
expect(ok.isValid, isTrue);
|
||||
expect(ok.reason, 'ok');
|
||||
expect(ok.scheme, 'https');
|
||||
expect(ok.host, 'sso.example.test');
|
||||
expect(ok.path, '/oidc/oauth2/auth');
|
||||
expect(ok.isOidcAuthPath, isTrue);
|
||||
expect(ok.queryParamCount, 8);
|
||||
expect(
|
||||
ok.queryKeys,
|
||||
containsAll(['client_id', 'login_verifier', 'state']),
|
||||
);
|
||||
expect(ok.hasLoginVerifier, isTrue);
|
||||
expect(ok.loginVerifierLength, 3);
|
||||
expect(ok.hasState, isTrue);
|
||||
expect(ok.stateLength, 3);
|
||||
expect(ok.hasClientId, isTrue);
|
||||
expect(ok.clientId, 'devfront');
|
||||
expect(ok.hasCodeChallenge, isTrue);
|
||||
expect(ok.codeChallengeLength, 3);
|
||||
expect(ok.codeChallengeMethod, 'S256');
|
||||
expect(ok.hasRedirectUri, isTrue);
|
||||
expect(ok.redirectUriScheme, 'http');
|
||||
expect(ok.redirectUriHost, 'localhost');
|
||||
expect(ok.redirectUriPort, 5174);
|
||||
expect(ok.redirectUriPath, '/callback');
|
||||
expect(ok.responseType, 'code');
|
||||
expect(ok.scopeCount, 2);
|
||||
expect(ok.toDiagnostics()['client_id'], 'devfront');
|
||||
|
||||
final relative = validateOidcRedirectTarget('/oidc/oauth2/auth');
|
||||
expect(relative.isValid, isFalse);
|
||||
expect(relative.reason, 'not_absolute');
|
||||
|
||||
final js = validateOidcRedirectTarget('javascript:alert(1)');
|
||||
expect(js.isValid, isFalse);
|
||||
expect(js.reason, 'not_absolute');
|
||||
});
|
||||
|
||||
test('빈 문자열과 파싱 실패를 차단', () {
|
||||
final empty = validateOidcRedirectTarget(' ');
|
||||
expect(empty.isValid, isFalse);
|
||||
expect(empty.reason, 'empty');
|
||||
expect(empty.length, 0);
|
||||
expect(empty.queryParamCount, 0);
|
||||
expect(empty.hasRedirectUri, isFalse);
|
||||
|
||||
final malformed = validateOidcRedirectTarget('https://[broken');
|
||||
expect(malformed.isValid, isFalse);
|
||||
expect(malformed.reason, 'parse_error');
|
||||
expect(malformed.queryParamCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/auth/domain/password_login_flow_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('password_login_flow_policy', () {
|
||||
test('redirectTo가 있으면 OIDC redirect를 우선한다', () {
|
||||
final action = decidePasswordLoginNextAction(
|
||||
hasLoginChallenge: true,
|
||||
redirectTo:
|
||||
'https://sso.example.test/oidc/oauth2/auth?login_verifier=a',
|
||||
jwt: 'jwt-token',
|
||||
);
|
||||
|
||||
expect(action, PasswordLoginNextAction.redirectToOidc);
|
||||
});
|
||||
|
||||
test('OIDC challenge가 있고 redirectTo가 없으면 accept를 시도한다', () {
|
||||
final action = decidePasswordLoginNextAction(
|
||||
hasLoginChallenge: true,
|
||||
redirectTo: null,
|
||||
jwt: 'jwt-token',
|
||||
);
|
||||
|
||||
expect(action, PasswordLoginNextAction.acceptOidc);
|
||||
});
|
||||
|
||||
test('OIDC challenge가 없고 jwt가 있으면 로컬 로그인 완료로 진행한다', () {
|
||||
final action = decidePasswordLoginNextAction(
|
||||
hasLoginChallenge: false,
|
||||
redirectTo: null,
|
||||
jwt: 'jwt-token',
|
||||
);
|
||||
|
||||
expect(action, PasswordLoginNextAction.localLogin);
|
||||
});
|
||||
|
||||
test('redirectTo/jwt 모두 없으면 invalid로 처리한다', () {
|
||||
final action = decidePasswordLoginNextAction(
|
||||
hasLoginChallenge: false,
|
||||
redirectTo: null,
|
||||
jwt: null,
|
||||
);
|
||||
|
||||
expect(action, PasswordLoginNextAction.invalid);
|
||||
});
|
||||
});
|
||||
}
|
||||
112
baron-sso/userfront/test/profile_notifier_persistence_test.dart
Normal file
112
baron-sso/userfront/test/profile_notifier_persistence_test.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
|
||||
import 'package:userfront/features/profile/data/repositories/profile_repository.dart';
|
||||
import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart';
|
||||
|
||||
class _FakeProfileRepository extends ProfileRepository {
|
||||
_FakeProfileRepository({
|
||||
required this.initialProfile,
|
||||
required this.persistUpdate,
|
||||
}) : _profile = initialProfile;
|
||||
|
||||
final UserProfile initialProfile;
|
||||
final bool persistUpdate;
|
||||
UserProfile _profile;
|
||||
|
||||
int updateCount = 0;
|
||||
String? lastRequestedDepartment;
|
||||
|
||||
@override
|
||||
Future<UserProfile> getMyProfile() async {
|
||||
return _profile;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateMyProfile({
|
||||
required String name,
|
||||
required String phone,
|
||||
required String department,
|
||||
}) async {
|
||||
updateCount += 1;
|
||||
lastRequestedDepartment = department;
|
||||
if (!persistUpdate) {
|
||||
return;
|
||||
}
|
||||
_profile = _profile.copyWith(
|
||||
name: name,
|
||||
phone: phone,
|
||||
department: department,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UserProfile _seedProfile({required String department}) {
|
||||
return UserProfile(
|
||||
id: 'user-1',
|
||||
email: 'qa@example.com',
|
||||
name: 'QA User',
|
||||
phone: '01012345678',
|
||||
department: department,
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'BARON',
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('재현: 저장소가 소속 변경을 반영하지 않으면 loadProfile 이후 이전 값으로 보인다', () async {
|
||||
final repository = _FakeProfileRepository(
|
||||
initialProfile: _seedProfile(department: 'Old Dept'),
|
||||
persistUpdate: false,
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [profileRepositoryProvider.overrideWithValue(repository)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final initial = await container.read(profileProvider.future);
|
||||
expect(initial?.department, 'Old Dept');
|
||||
|
||||
await container
|
||||
.read(profileProvider.notifier)
|
||||
.updateProfile(
|
||||
name: 'QA User',
|
||||
phone: '01012345678',
|
||||
department: 'New Dept',
|
||||
);
|
||||
|
||||
expect(repository.updateCount, 1);
|
||||
expect(repository.lastRequestedDepartment, 'New Dept');
|
||||
|
||||
await container.read(profileProvider.notifier).loadProfile();
|
||||
expect(container.read(profileProvider).value?.department, 'Old Dept');
|
||||
});
|
||||
|
||||
test('소속 변경이 저장소에 반영되면 loadProfile 이후에도 변경값이 유지된다', () async {
|
||||
final repository = _FakeProfileRepository(
|
||||
initialProfile: _seedProfile(department: 'Old Dept'),
|
||||
persistUpdate: true,
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [profileRepositoryProvider.overrideWithValue(repository)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final initial = await container.read(profileProvider.future);
|
||||
expect(initial?.department, 'Old Dept');
|
||||
|
||||
await container
|
||||
.read(profileProvider.notifier)
|
||||
.updateProfile(
|
||||
name: 'QA User',
|
||||
phone: '01012345678',
|
||||
department: 'New Dept',
|
||||
);
|
||||
|
||||
expect(repository.updateCount, 1);
|
||||
expect(repository.lastRequestedDepartment, 'New Dept');
|
||||
|
||||
await container.read(profileProvider.notifier).loadProfile();
|
||||
expect(container.read(profileProvider).value?.department, 'New Dept');
|
||||
});
|
||||
}
|
||||
131
baron-sso/userfront/test/profile_page_edit_flow_test.dart
Normal file
131
baron-sso/userfront/test/profile_page_edit_flow_test.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/profile/data/models/user_profile_model.dart';
|
||||
import 'package:userfront/features/profile/domain/notifiers/profile_notifier.dart';
|
||||
import 'package:userfront/features/profile/presentation/pages/profile_page.dart';
|
||||
|
||||
// Mocking the profile notifier
|
||||
class MockProfileNotifier extends ProfileNotifier {
|
||||
UserProfile? _profile;
|
||||
bool updateCalled = false;
|
||||
String? updatedName;
|
||||
|
||||
@override
|
||||
Future<UserProfile?> build() async {
|
||||
_profile = UserProfile(
|
||||
id: 'test-id',
|
||||
email: 'test@example.com',
|
||||
name: 'Original Name',
|
||||
phone: '01012345678',
|
||||
department: 'Dev',
|
||||
affiliationType: 'employee',
|
||||
companyCode: 'C100',
|
||||
);
|
||||
return _profile;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserProfile?> loadProfile() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = AsyncValue.data(_profile);
|
||||
return _profile;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateProfile({
|
||||
String? name,
|
||||
String? phone,
|
||||
String? department,
|
||||
}) async {
|
||||
updateCalled = true;
|
||||
updatedName = name;
|
||||
_profile = _profile!.copyWith(
|
||||
name: name ?? _profile!.name,
|
||||
phone: phone ?? _profile!.phone,
|
||||
department: department ?? _profile!.department,
|
||||
);
|
||||
state = AsyncValue.data(_profile);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'ProfilePage explicit save button UX flow (Edit -> Cancel -> Edit -> Save)',
|
||||
(tester) async {
|
||||
final recordedErrors = <FlutterErrorDetails>[];
|
||||
final previousOnError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
final text = details.exceptionAsString();
|
||||
if (text.contains('A RenderFlex overflowed')) {
|
||||
return;
|
||||
}
|
||||
recordedErrors.add(details);
|
||||
};
|
||||
addTearDown(() {
|
||||
FlutterError.onError = previousOnError;
|
||||
});
|
||||
|
||||
tester.view.physicalSize = const Size(1920, 1080);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final mockNotifier = MockProfileNotifier();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [profileProvider.overrideWith(() => mockNotifier)],
|
||||
child: const MaterialApp(home: Scaffold(body: ProfilePage())),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 1. Entering edit mode
|
||||
final editButton = find.byKey(const Key('profile-name-edit-button'));
|
||||
expect(editButton, findsOneWidget);
|
||||
await tester.tap(editButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final inputField = find.byKey(const Key('profile-name-input'));
|
||||
expect(inputField, findsOneWidget);
|
||||
|
||||
// 2. Testing cancel flow
|
||||
await tester.enterText(inputField, 'Changed Name');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final cancelButton = find.byKey(const Key('profile-name-cancel-button'));
|
||||
await tester.tap(cancelButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// After cancellation, the field should be read-only again.
|
||||
expect(find.byKey(const Key('profile-name-input')), findsNothing);
|
||||
// Find text could be part of ListTile
|
||||
expect(find.text('Original Name'), findsWidgets);
|
||||
|
||||
// 3. Re-enter edit mode and explicitly save
|
||||
await tester.tap(find.byKey(const Key('profile-name-edit-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('profile-name-input')),
|
||||
'Saved Name',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final saveButton = find.byKey(const Key('profile-name-save-button'));
|
||||
await tester.tap(saveButton);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
FlutterError.onError = previousOnError;
|
||||
|
||||
// Verify the mock received the update
|
||||
expect(mockNotifier.updateCalled, isTrue);
|
||||
expect(mockNotifier.updatedName, 'Saved Name');
|
||||
expect(recordedErrors, isEmpty);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/auth/presentation/qr_camera_bootstrap_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('bootstrapQrCamera', () {
|
||||
test('권한 허용 후 카메라 실행 성공 시 ready 상태를 반환한다', () async {
|
||||
var stopCalled = false;
|
||||
|
||||
final result = await bootstrapQrCamera(
|
||||
hasBarcodeDetector: true,
|
||||
openCameraAndPlay: () async {},
|
||||
stopCamera: () async {
|
||||
stopCalled = true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, QrCameraBootstrapStatus.ready);
|
||||
expect(stopCalled, isFalse);
|
||||
});
|
||||
|
||||
test('권한 허용 후 play 단계 오류는 cameraError로 분류한다', () async {
|
||||
var stopCalled = false;
|
||||
|
||||
final result = await bootstrapQrCamera(
|
||||
hasBarcodeDetector: true,
|
||||
openCameraAndPlay: () async {
|
||||
throw Exception('NotReadableError: Could not start video source');
|
||||
},
|
||||
stopCamera: () async {
|
||||
stopCalled = true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, QrCameraBootstrapStatus.cameraError);
|
||||
expect(result.errorDetail, contains('NotReadableError'));
|
||||
expect(stopCalled, isFalse);
|
||||
});
|
||||
|
||||
test('권한 거부 오류는 permissionError로 분류한다', () async {
|
||||
final result = await bootstrapQrCamera(
|
||||
hasBarcodeDetector: true,
|
||||
openCameraAndPlay: () async {
|
||||
throw Exception('NotAllowedError: Permission denied');
|
||||
},
|
||||
stopCamera: () async {},
|
||||
);
|
||||
|
||||
expect(result.status, QrCameraBootstrapStatus.permissionError);
|
||||
expect(result.errorDetail, contains('NotAllowedError'));
|
||||
});
|
||||
|
||||
test('detector 미지원이면 카메라를 정리하고 detectorUnsupported를 반환한다', () async {
|
||||
var stopCalled = false;
|
||||
|
||||
final result = await bootstrapQrCamera(
|
||||
hasBarcodeDetector: false,
|
||||
openCameraAndPlay: () async {},
|
||||
stopCamera: () async {
|
||||
stopCalled = true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, QrCameraBootstrapStatus.detectorUnsupported);
|
||||
expect(stopCalled, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
27
baron-sso/userfront/test/qr_scan_route_test.dart
Normal file
27
baron-sso/userfront/test/qr_scan_route_test.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/auth/presentation/qr_scan_route.dart';
|
||||
|
||||
void main() {
|
||||
group('buildQrApprovePath', () {
|
||||
test('스캔 값을 trim/encode 해서 approve 경로를 만든다', () {
|
||||
final result = buildQrApprovePath(
|
||||
' https://sss.hmac.kr/ql/abc-123?x=1&y=2 ',
|
||||
localeCode: 'ko',
|
||||
);
|
||||
|
||||
expect(
|
||||
result,
|
||||
'/ko/approve?ref=https%3A%2F%2Fsss.hmac.kr%2Fql%2Fabc-123%3Fx%3D1%26y%3D2',
|
||||
);
|
||||
});
|
||||
|
||||
test('현재 URI에서 locale을 추출한다', () {
|
||||
final result = buildQrApprovePath(
|
||||
'abc123',
|
||||
currentUri: Uri.parse('https://sss.hmac.kr/en/dashboard'),
|
||||
);
|
||||
|
||||
expect(result, '/en/approve?ref=abc123');
|
||||
});
|
||||
});
|
||||
}
|
||||
16
baron-sso/userfront/test/qr_scan_screen_test.dart
Normal file
16
baron-sso/userfront/test/qr_scan_screen_test.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/features/auth/presentation/qr_scan_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('QR 스캔 화면은 비활성 문구 대신 입력/이동 UI를 노출한다', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: QRScanScreen()));
|
||||
|
||||
expect(
|
||||
find.text('QR Scanner is temporarily disabled for WASM build stability.'),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('qr_scan_manual_input')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('qr_scan_submit_button')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
225
baron-sso/userfront/test/router_redirect_widget_test.dart
Normal file
225
baron-sso/userfront/test/router_redirect_widget_test.dart
Normal file
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/core/i18n/locale_registry.dart';
|
||||
import 'package:userfront/core/i18n/locale_utils.dart';
|
||||
import 'package:userfront/core/services/auth_token_store.dart';
|
||||
|
||||
Widget _buildTestApp(String initialLocation) {
|
||||
final router = GoRouter(
|
||||
initialLocation: initialLocation,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/:locale',
|
||||
redirect: (context, state) {
|
||||
if (state.uri.pathSegments.length != 1) {
|
||||
return null;
|
||||
}
|
||||
final localeCode = normalizeLocaleCode(
|
||||
state.pathParameters['locale'],
|
||||
);
|
||||
final token = AuthTokenStore.getToken();
|
||||
final isLoggedIn =
|
||||
(token != null && token.isNotEmpty) ||
|
||||
AuthTokenStore.usesCookie();
|
||||
if (!isLoggedIn) {
|
||||
return buildSigninRedirectPath(localeCode, state.uri);
|
||||
}
|
||||
return '/$localeCode/dashboard';
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'dashboard',
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Text('dashboard-page')),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'signin',
|
||||
builder: (context, state) {
|
||||
final challenge = state.uri.queryParameters['login_challenge'];
|
||||
final redirect =
|
||||
state.uri.queryParameters['redirect_uri'] ??
|
||||
state.uri.queryParameters['redirect_url'] ??
|
||||
'';
|
||||
return Scaffold(
|
||||
body: Text(
|
||||
'signin|challenge=${challenge ?? ''}|redirect=$redirect',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'login',
|
||||
builder: (context, state) {
|
||||
final challenge = state.uri.queryParameters['login_challenge'];
|
||||
final redirect =
|
||||
state.uri.queryParameters['redirect_uri'] ??
|
||||
state.uri.queryParameters['redirect_url'] ??
|
||||
'';
|
||||
return Scaffold(
|
||||
body: Text(
|
||||
'login|challenge=${challenge ?? ''}|redirect=$redirect',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'profile',
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Text('profile-page')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
redirect: (context, state) {
|
||||
final requestedLocale = extractLocaleFromPath(state.uri);
|
||||
if (requestedLocale == null) {
|
||||
return buildLocalizedPath(resolvePreferredLocaleCode(), state.uri);
|
||||
}
|
||||
|
||||
final token = AuthTokenStore.getToken();
|
||||
final isLoggedIn =
|
||||
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||
final path = stripLocalePath(state.uri);
|
||||
final isPublicPath = path == '/signin' || path == '/login';
|
||||
if (isPublicPath) {
|
||||
return null;
|
||||
}
|
||||
if (!isLoggedIn) {
|
||||
return buildSigninRedirectPath(requestedLocale, state.uri);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
return MaterialApp.router(routerConfig: router);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
LocaleRegistry.setSupportedLocaleCodesForTest(['en', 'ko']);
|
||||
AuthTokenStore.clear();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
AuthTokenStore.clear();
|
||||
LocaleRegistry.resetForTest();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'루트 경로: /{locale} 로 접근 시 /{locale}/signin 으로 리다이렉트되어야 한다 (버그: 화면 렌더링 안됨)',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_buildTestApp('/ko'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('signin|'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('로그인 상태에서 /{locale} 접근 시 dashboard로 이동', (tester) async {
|
||||
AuthTokenStore.setToken('root-token', provider: 'ory');
|
||||
await tester.pumpWidget(_buildTestApp('/ko'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('dashboard-page'), findsOneWidget);
|
||||
expect(find.textContaining('signin|'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('/login: login_challenge와 redirect_uri를 전달', (tester) async {
|
||||
final encodedRedirectUri = Uri.encodeComponent(
|
||||
'https://rp.example.com/callback?x=1',
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
'/en/login?login_challenge=lc_999&redirect_uri=$encodedRedirectUri',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'login|challenge=lc_999|redirect=https://rp.example.com/callback?x=1',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('비로그인: redirect_uri/login_challenge가 signin으로 전달', (
|
||||
tester,
|
||||
) async {
|
||||
final encodedRedirectUri = Uri.encodeComponent(
|
||||
'https://rp.example.com/cb?x=1',
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
'/en/profile?login_challenge=lc_123&redirect_uri=$encodedRedirectUri',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'signin|challenge=lc_123|redirect=https://rp.example.com/cb?x=1',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('비로그인: redirect_uri가 없으면 redirect_url을 전달', (tester) async {
|
||||
final encodedRedirectUrl = Uri.encodeComponent(
|
||||
'https://legacy.example.com/cb',
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp('/en/profile?redirect_url=$encodedRedirectUrl'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('signin|challenge=|redirect=https://legacy.example.com/cb'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('로그인 상태: profile 접근 시 signin으로 리다이렉트하지 않음', (tester) async {
|
||||
AuthTokenStore.setToken('test-token', provider: 'ory');
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
'/en/profile?redirect_uri=https%3A%2F%2Frp.example.com%2Fcb',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('profile-page'), findsOneWidget);
|
||||
expect(find.textContaining('signin|'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('빈 토큰은 로그인으로 간주하지 않고 signin으로 리다이렉트', (tester) async {
|
||||
AuthTokenStore.setToken('', provider: 'ory');
|
||||
await tester.pumpWidget(_buildTestApp('/ko/profile'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('signin|'), findsOneWidget);
|
||||
expect(find.text('profile-page'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('로그인 후 같은 브라우저 새 창/팝업에서도 세션이 유지된다', (tester) async {
|
||||
await tester.pumpWidget(_buildTestApp('/en/signin'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('signin|'), findsOneWidget);
|
||||
|
||||
AuthTokenStore.setToken('persisted-token', provider: 'ory');
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
'/en/profile?redirect_uri=https%3A%2F%2Frp.example.com%2Fcb',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('profile-page'), findsOneWidget);
|
||||
expect(find.textContaining('signin|'), findsNothing);
|
||||
});
|
||||
}
|
||||
56
baron-sso/userfront/test/runtime_env_compile_time_test.dart
Normal file
56
baron-sso/userfront/test/runtime_env_compile_time_test.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/services/runtime_env.dart';
|
||||
|
||||
const _expectedBackendUrl = String.fromEnvironment('BACKEND_URL');
|
||||
const _expectedUserfrontUrl = String.fromEnvironment('USERFRONT_URL');
|
||||
|
||||
void main() {
|
||||
group('runtime env compile-time defines', () {
|
||||
test('runtime fallback is empty outside a browser origin', () {
|
||||
expect(runtimeOriginFallback(), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'BACKEND_URL dart-define overrides runtime origin fallback when set',
|
||||
() {
|
||||
if (_expectedBackendUrl.isEmpty) {
|
||||
expect(runtimeBackendUrl(), runtimeOriginFallback());
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runtimeBackendUrl(), sanitizedUrl(_expectedBackendUrl));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'USERFRONT_URL dart-define overrides runtime origin fallback when set',
|
||||
() {
|
||||
if (_expectedUserfrontUrl.isEmpty) {
|
||||
expect(runtimeUserfrontUrl(), runtimeOriginFallback());
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runtimeUserfrontUrl(), sanitizedUrl(_expectedUserfrontUrl));
|
||||
},
|
||||
);
|
||||
|
||||
test('dart-define URLs are sanitized', () {
|
||||
if (_expectedBackendUrl.isEmpty || _expectedUserfrontUrl.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runtimeBackendUrl(), isNot(endsWith('/')));
|
||||
expect(runtimeUserfrontUrl(), isNot(endsWith('/')));
|
||||
});
|
||||
|
||||
test(
|
||||
'sanitizedUrl removes dollar signs, whitespace, and trailing slash',
|
||||
() {
|
||||
expect(
|
||||
sanitizedUrl(' https://example.test/path/\$ '),
|
||||
'https://example.test/path',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
32
baron-sso/userfront/test/theme_controller_test.dart
Normal file
32
baron-sso/userfront/test/theme_controller_test.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:userfront/core/theme/theme_controller.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await ThemeController.app.setThemeMode(ThemeMode.light);
|
||||
});
|
||||
|
||||
test('저장된 dark 값을 복원한다', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
ThemeController.appStorageKey: 'dark',
|
||||
});
|
||||
|
||||
await ThemeController.app.restore();
|
||||
|
||||
expect(ThemeController.app.value, ThemeMode.dark);
|
||||
});
|
||||
|
||||
test('toggle 결과를 저장한다', () async {
|
||||
await ThemeController.app.restore();
|
||||
await ThemeController.app.toggle();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(ThemeController.app.value, ThemeMode.dark);
|
||||
expect(prefs.getString(ThemeController.appStorageKey), 'dark');
|
||||
});
|
||||
}
|
||||
58
baron-sso/userfront/test/toml_asset_loader_test.dart
Normal file
58
baron-sso/userfront/test/toml_asset_loader_test.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/i18n/toml_asset_loader.dart';
|
||||
import 'package:userfront/i18n_data.dart';
|
||||
|
||||
void main() {
|
||||
test('TomlAssetLoader keeps flat keys for fast startup lookup', () async {
|
||||
const loader = TomlAssetLoader();
|
||||
|
||||
final translations = await loader.load(
|
||||
'assets/translations',
|
||||
const Locale('en'),
|
||||
);
|
||||
|
||||
expect(translations['domain.company.baron'], 'Baron');
|
||||
expect(translations['domain'], isNull);
|
||||
});
|
||||
|
||||
test('English signup policy copy stays small enough for first render', () {
|
||||
const sensitiveKeys = [
|
||||
'msg.userfront.signup.privacy_full',
|
||||
'msg.userfront.signup.tos_full',
|
||||
];
|
||||
|
||||
for (final key in sensitiveKeys) {
|
||||
final value = enStrings[key];
|
||||
expect(value, isNotNull, reason: key);
|
||||
expect(value!.length, lessThan(1024), reason: key);
|
||||
expect(value.contains(r'\\\\'), isFalse, reason: key);
|
||||
}
|
||||
});
|
||||
|
||||
test('Korean linked app revoke action label stays unbreakable', () {
|
||||
expect(koStrings['ui.userfront.dashboard.revoke.title'], '연동해지');
|
||||
});
|
||||
|
||||
test(
|
||||
'TomlAssetLoader excludes non-userfront dictionaries at startup',
|
||||
() async {
|
||||
const loader = TomlAssetLoader();
|
||||
|
||||
final translations = await loader.load(
|
||||
'assets/translations',
|
||||
const Locale('en'),
|
||||
);
|
||||
|
||||
expect(translations['ui.admin.nav.api_keys'], isNull);
|
||||
expect(translations['ui.dev.console_title'], isNull);
|
||||
expect(
|
||||
translations['err.userfront.auth_proxy.login_failed'],
|
||||
'Login failed.',
|
||||
);
|
||||
expect(translations['ui.userfront.login.action.submit'], 'Sign in');
|
||||
expect(translations['ui.common.theme_light'], 'Light');
|
||||
},
|
||||
);
|
||||
}
|
||||
85
baron-sso/userfront/test/verification_route_policy_test.dart
Normal file
85
baron-sso/userfront/test/verification_route_policy_test.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:userfront/core/i18n/locale_registry.dart';
|
||||
import 'package:userfront/features/auth/domain/verification_completion_route.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(LocaleRegistry.primeWithDefaults);
|
||||
|
||||
group('verification route policy', () {
|
||||
test('루트 인증 payload는 전용 verify 라우트로 정리한다', () {
|
||||
final redirect = buildDedicatedVerificationRedirect(
|
||||
Uri.parse(
|
||||
'/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop',
|
||||
),
|
||||
localeCode: 'ko',
|
||||
);
|
||||
|
||||
expect(
|
||||
redirect,
|
||||
'/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-root',
|
||||
);
|
||||
});
|
||||
|
||||
test('signin 인증 payload도 로그인 화면에서 직접 소비하지 않는다', () {
|
||||
final redirect = buildDedicatedVerificationRedirect(
|
||||
Uri.parse('/ko/signin?t=magic-token&utm=drop'),
|
||||
localeCode: 'ko',
|
||||
);
|
||||
|
||||
expect(redirect, '/ko/verify?t=magic-token');
|
||||
});
|
||||
|
||||
test('인증 payload 여부를 식별한다', () {
|
||||
expect(hasVerificationPayload(Uri.parse('/?t=magic-token')), isTrue);
|
||||
expect(
|
||||
hasVerificationPayload(
|
||||
Uri.parse('/ko/signin?loginId=e2e%40example.com&code=654321'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
hasVerificationPayload(Uri.parse('/ko/signin?code=654321')),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('전용 verify 계열 라우트와 완료 라우트 경로를 식별한다', () {
|
||||
expect(
|
||||
buildLocalizedVerificationCompletePath('ko'),
|
||||
'/ko/verify-complete',
|
||||
);
|
||||
expect(isDedicatedVerificationRoute(Uri.parse('/verification')), isTrue);
|
||||
expect(isDedicatedVerificationRoute(Uri.parse('/ko/verify/abc')), isTrue);
|
||||
expect(isDedicatedVerificationRoute(Uri.parse('/en/l/SHORT123')), isTrue);
|
||||
expect(isDedicatedVerificationRoute(Uri.parse('/ko/signin')), isFalse);
|
||||
});
|
||||
|
||||
test('빈 pendingRef와 불완전한 payload는 리다이렉트 query에서 제외한다', () {
|
||||
expect(
|
||||
buildDedicatedVerificationRedirect(
|
||||
Uri.parse(
|
||||
'/signin?loginId=user%40example.com&code=123456&pendingRef=',
|
||||
),
|
||||
localeCode: 'en',
|
||||
),
|
||||
'/en/verify?loginId=user%40example.com&code=123456',
|
||||
);
|
||||
expect(
|
||||
buildDedicatedVerificationRedirect(
|
||||
Uri.parse('/signin?loginId=user%40example.com'),
|
||||
localeCode: 'en',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('이미 전용 verify 라우트면 다시 리다이렉트하지 않는다', () {
|
||||
final redirect = buildDedicatedVerificationRedirect(
|
||||
Uri.parse('/ko/verify?loginId=e2e%40example.com&code=654321'),
|
||||
localeCode: 'ko',
|
||||
);
|
||||
|
||||
expect(redirect, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
10
baron-sso/userfront/test/widget_test.dart
Normal file
10
baron-sso/userfront/test/widget_test.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
// THIS FILE IS INTENTIONALLY LEFT BLANK TO AVOID WEB-RELATED TEST FAILURES IN CI
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('smoke test', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
|
||||
expect(find.byType(SizedBox), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user