첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View 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'));
});
}

View 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);
});
}

View 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();
}
}

View 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;
}
}

View 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);
});
});
}

View 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='));
});
}

View 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,
);
});
});
}

View 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();
});
}

View 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);
});
}

View File

@@ -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');
});
}

View 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('));
});
}

View 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'),
);
});
}

View 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);
});
}

View 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);
});
}

View 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');
});
});
}

View 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');
});
}

View 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&notice=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&notice=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',
);
});
});
}

View 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=*****');
});
});
}

View File

@@ -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);
});
});
}

View 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);
});
});
}

View 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);
});
});
}

View 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);
});
}

View 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']);
});
}

View 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);
});
}

View 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);
});
});
}

View File

@@ -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);
});
});
}

View 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');
});
}

View 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);
},
);
}

View File

@@ -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);
});
});
}

View 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');
});
});
}

View 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);
});
}

View 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);
});
}

View 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',
);
},
);
});
}

View 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');
});
}

View 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');
},
);
}

View 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);
});
});
}

View 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);
});
}