1
0
forked from baron/baron-sso

userfront&backend test coverage 추가

This commit is contained in:
2026-05-29 18:04:04 +09:00
parent 23cd316c23
commit 4c56c28481
26 changed files with 2405 additions and 260 deletions

View File

@@ -6,7 +6,18 @@ import 'auth_token_store.dart';
import 'log_policy.dart';
import 'runtime_env.dart';
typedef AuthProxyHttpClientFactory =
http.Client Function({bool withCredentials});
class AuthProxyService {
static AuthProxyHttpClientFactory? _httpClientFactoryForTesting;
static void debugSetHttpClientFactoryForTesting(
AuthProxyHttpClientFactory? factory,
) {
_httpClientFactoryForTesting = factory;
}
static String get _baseUrl => runtimeBackendUrl();
static bool get _isProd {
@@ -32,9 +43,71 @@ class AuthProxyService {
);
}
static http.Client _createClient({bool withCredentials = false}) {
final factory = _httpClientFactoryForTesting;
if (factory != null) {
return factory(withCredentials: withCredentials);
}
return createHttpClient(withCredentials: withCredentials);
}
static Future<http.Response> _get(
Uri url, {
Map<String, String>? headers,
bool withCredentials = false,
}) async {
final client = _createClient(withCredentials: withCredentials);
try {
return await client.get(url, headers: headers);
} finally {
client.close();
}
}
static Future<http.Response> _post(
Uri url, {
Map<String, String>? headers,
Object? body,
bool withCredentials = false,
}) async {
final client = _createClient(withCredentials: withCredentials);
try {
return await client.post(url, headers: headers, body: body);
} finally {
client.close();
}
}
static Future<http.Response> _patch(
Uri url, {
Map<String, String>? headers,
Object? body,
bool withCredentials = false,
}) async {
final client = _createClient(withCredentials: withCredentials);
try {
return await client.patch(url, headers: headers, body: body);
} finally {
client.close();
}
}
static Future<http.Response> _delete(
Uri url, {
Map<String, String>? headers,
bool withCredentials = false,
}) async {
final client = _createClient(withCredentials: withCredentials);
try {
return await client.delete(url, headers: headers);
} finally {
client.close();
}
}
static Future<Map<String, dynamic>> fetchPasswordPolicy() async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy');
final response = await http.get(url);
final response = await _get(url);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
@@ -47,24 +120,20 @@ class AuthProxyService {
static Future<Map<String, dynamic>> checkCookieSession() async {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: true);
try {
final response = await client.get(
url,
headers: {'Content-Type': 'application/json'},
);
final response = await _get(
url,
headers: {'Content-Type': 'application/json'},
withCredentials: true,
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
}
throw _error(
'err.userfront.auth_proxy.profile_load',
'Failed to load the profile: {{error}}',
detail: response.body,
);
} finally {
client.close();
if (response.statusCode == 200) {
return jsonDecode(response.body);
}
throw _error(
'err.userfront.auth_proxy.profile_load',
'Failed to load the profile: {{error}}',
detail: response.body,
);
}
static Future<Map<String, dynamic>> getMe({
@@ -72,25 +141,24 @@ class AuthProxyService {
bool useCookie = true,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await client.get(url, headers: headers);
if (response.statusCode == 200) {
return jsonDecode(response.body);
}
throw _error(
'err.userfront.auth_proxy.profile_load',
'Failed to load the profile: {{error}}',
detail: response.body,
);
} finally {
client.close();
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await _get(
url,
headers: headers,
withCredentials: useCookie,
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
}
throw _error(
'err.userfront.auth_proxy.profile_load',
'Failed to load the profile: {{error}}',
detail: response.body,
);
}
static Future<int> getSessionStatus({
@@ -98,22 +166,21 @@ class AuthProxyService {
bool useCookie = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await client.get(url, headers: headers);
return response.statusCode;
} finally {
client.close();
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await _get(
url,
headers: headers,
withCredentials: useCookie,
);
return response.statusCode;
}
static Future<Map<String, dynamic>> getTenantInfo() async {
final url = Uri.parse('$_baseUrl/api/v1/auth/tenant-info');
final response = await http.get(url);
final response = await _get(url);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
@@ -144,7 +211,7 @@ class AuthProxyService {
body['codeOnly'] = true;
}
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
@@ -166,7 +233,7 @@ class AuthProxyService {
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'pendingRef': pendingRef}),
@@ -191,7 +258,7 @@ class AuthProxyService {
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'token': token, 'verifyOnly': verifyOnly}),
@@ -225,7 +292,7 @@ class AuthProxyService {
payload['pendingRef'] = pendingRef;
}
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
@@ -246,22 +313,21 @@ class AuthProxyService {
final url = Uri.parse('$_baseUrl/api/v1/user/sessions/$sessionId');
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await client.delete(url, headers: headers);
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.revoke',
'Failed to revoke the session: {{error}}',
detail: response.body,
);
}
} finally {
client.close();
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await _delete(
url,
headers: headers,
withCredentials: useCookie,
);
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.revoke',
'Failed to revoke the session: {{error}}',
detail: response.body,
);
}
}
@@ -269,35 +335,34 @@ class AuthProxyService {
final url = Uri.parse('$_baseUrl/api/v1/user/sessions');
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await client.get(url, headers: headers);
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.load',
'Failed to load the active sessions: {{error}}',
detail: response.body,
);
}
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await _get(
url,
headers: headers,
withCredentials: useCookie,
);
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.load',
'Failed to load the active sessions: {{error}}',
detail: response.body,
);
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? const [];
for (final item in items.whereType<Map<String, dynamic>>()) {
if (item['is_current'] == true) {
final sessionId = item['session_id']?.toString().trim() ?? '';
if (sessionId.isNotEmpty) {
return sessionId;
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? const [];
for (final item in items.whereType<Map<String, dynamic>>()) {
if (item['is_current'] == true) {
final sessionId = item['session_id']?.toString().trim() ?? '';
if (sessionId.isNotEmpty) {
return sessionId;
}
}
return null;
} finally {
client.close();
}
return null;
}
static Future<Map<String, dynamic>> verifyLoginShortCode(
@@ -306,7 +371,7 @@ class AuthProxyService {
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify-short');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'shortCode': shortCode, 'verifyOnly': verifyOnly}),
@@ -337,7 +402,7 @@ class AuthProxyService {
'login_challenge': loginChallenge,
};
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
@@ -360,29 +425,24 @@ class AuthProxyService {
final url = Uri.parse(
'$_baseUrl/api/v1/auth/consent',
).replace(queryParameters: {'consent_challenge': consentChallenge});
final client = createHttpClient(withCredentials: true);
try {
final response = await client.get(
url,
headers: {'Content-Type': 'application/json'},
);
final response = await _get(
url,
headers: {'Content-Type': 'application/json'},
withCredentials: true,
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
final rawDetails = errorBody['details'];
throw AuthProxyException(
errorCode: (errorBody['code'] ?? '').toString(),
message:
(errorBody['error'] ??
tr('err.userfront.auth_proxy.consent_fetch'))
.toString(),
details: rawDetails is Map<String, dynamic> ? rawDetails : null,
);
}
} finally {
client.close();
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
final rawDetails = errorBody['details'];
throw AuthProxyException(
errorCode: (errorBody['code'] ?? '').toString(),
message:
(errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch'))
.toString(),
details: rawDetails is Map<String, dynamic> ? rawDetails : null,
);
}
}
@@ -396,24 +456,20 @@ class AuthProxyService {
body['grant_scope'] = grantScope;
}
final client = createHttpClient(withCredentials: true);
try {
final response = await client.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
);
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
withCredentials: true,
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_accept'),
);
}
} finally {
client.close();
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_accept'),
);
}
}
@@ -423,24 +479,20 @@ class AuthProxyService {
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject');
final body = <String, dynamic>{'consent_challenge': consentChallenge};
final client = createHttpClient(withCredentials: true);
try {
final response = await client.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
);
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
withCredentials: true,
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_reject'),
);
}
} finally {
client.close();
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_reject'),
);
}
}
@@ -453,24 +505,20 @@ class AuthProxyService {
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final client = createHttpClient(withCredentials: true);
try {
final response = await client.post(
url,
headers: headers,
body: jsonEncode({'login_challenge': loginChallenge}),
);
final response = await _post(
url,
headers: headers,
body: jsonEncode({'login_challenge': loginChallenge}),
withCredentials: true,
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ?? tr('err.userfront.auth_proxy.oidc_accept'),
);
}
} finally {
client.close();
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ?? tr('err.userfront.auth_proxy.oidc_accept'),
);
}
}
@@ -479,7 +527,7 @@ class AuthProxyService {
bool? drySend,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
@@ -514,7 +562,7 @@ class AuthProxyService {
final url = Uri.parse(
'$_baseUrl/api/v1/auth/password/reset/complete',
).replace(queryParameters: query);
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'newPassword': newPassword}),
@@ -534,7 +582,7 @@ class AuthProxyService {
static Future<void> sendSms(String phoneNumber) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'phoneNumber': phoneNumber}),
@@ -555,7 +603,7 @@ class AuthProxyService {
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'phoneNumber': phoneNumber, 'code': code}),
@@ -574,7 +622,7 @@ class AuthProxyService {
static Future<Map<String, dynamic>> initQrLogin() async {
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/init');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
);
@@ -592,7 +640,7 @@ class AuthProxyService {
static Future<Map<String, dynamic>> pollQrStatus(String pendingRef) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/poll');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'pendingRef': pendingRef}),
@@ -624,39 +672,26 @@ class AuthProxyService {
payload['token'] = token;
}
http.Client? client;
try {
if (withCredentials) {
client = createHttpClient(withCredentials: true);
}
final response = await (client != null
? client.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
)
: http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
));
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
withCredentials: withCredentials,
);
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.qr_approve',
'Failed to approve QR login: {{error}}',
detail: response.body,
);
}
} finally {
client?.close();
if (response.statusCode != 200) {
throw _error(
'err.userfront.auth_proxy.qr_approve',
'Failed to approve QR login: {{error}}',
detail: response.body,
);
}
}
static Future<bool> checkAdminAuth(String adminPassword) async {
final url = Uri.parse('$_baseUrl/api/v1/admin/check');
try {
final response = await http.get(
final response = await _get(
url,
headers: {
'Content-Type': 'application/json',
@@ -678,7 +713,7 @@ class AuthProxyService {
}) async {
final url = Uri.parse('$_baseUrl/api/v1/admin/users');
final response = await http.post(
final response = await _post(
url,
headers: {
'Content-Type': 'application/json',
@@ -710,7 +745,7 @@ class AuthProxyService {
uri = uri.replace(queryParameters: {'text': query});
}
final response = await http.get(
final response = await _get(
uri,
headers: {
'Content-Type': 'application/json',
@@ -734,7 +769,7 @@ class AuthProxyService {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
final response = await http.delete(
final response = await _delete(
url,
headers: {
'Content-Type': 'application/json',
@@ -759,7 +794,7 @@ class AuthProxyService {
final encodedId = Uri.encodeComponent(loginId);
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
final response = await http.patch(
final response = await _patch(
url,
headers: {
'Content-Type': 'application/json',
@@ -792,7 +827,7 @@ class AuthProxyService {
if (phone != null) body['phone'] = phone;
if (displayName != null) body['displayName'] = displayName;
final response = await http.patch(
final response = await _patch(
url,
headers: {
'Content-Type': 'application/json',
@@ -815,26 +850,25 @@ class AuthProxyService {
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
try {
final response = await client.get(url, headers: headers);
final response = await _get(
url,
headers: headers,
withCredentials: useCookie,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['items'] ?? [];
} else {
throw _error(
'err.userfront.auth_proxy.linked_apps_load',
'Failed to load linked applications.',
);
}
} finally {
client.close();
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['items'] ?? [];
} else {
throw _error(
'err.userfront.auth_proxy.linked_apps_load',
'Failed to load linked applications.',
);
}
}
@@ -843,24 +877,22 @@ class AuthProxyService {
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
try {
final response = await client.delete(url, headers: headers);
final response = await _delete(
url,
headers: headers,
withCredentials: useCookie,
);
if (response.statusCode != 200) {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ??
tr('err.userfront.auth_proxy.linked_app_revoke'),
);
}
} finally {
client.close();
if (response.statusCode != 200) {
final errorBody = jsonDecode(response.body);
throw Exception(
errorBody['error'] ?? tr('err.userfront.auth_proxy.linked_app_revoke'),
);
}
}
@@ -888,7 +920,7 @@ class AuthProxyService {
final sanitizedMessage = LogPolicy.sanitizeMessage(message);
final sanitizedData = data == null ? null : LogPolicy.sanitizeData(data);
try {
await http.post(
await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
@@ -945,7 +977,7 @@ class AuthProxyService {
static Future<bool> checkEmailAvailability(String email) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/check-email');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email}),
@@ -967,7 +999,7 @@ class AuthProxyService {
if (tenantSlug != null && tenantSlug.isNotEmpty) {
bodyData['tenantSlug'] = tenantSlug;
}
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(bodyData),
@@ -997,7 +1029,7 @@ class AuthProxyService {
}
final url = Uri.parse(uriString);
final response = await http.get(url);
final response = await _get(url);
if (response.statusCode == 200) {
final List<dynamic> list = jsonDecode(response.body);
return list.cast<Map<String, dynamic>>();
@@ -1009,7 +1041,7 @@ class AuthProxyService {
final path = type == 'email' ? 'send-email-code' : 'send-sms-code';
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'target': target}),
@@ -1031,7 +1063,7 @@ class AuthProxyService {
) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup/verify-code');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'target': target, 'type': type, 'code': code}),
@@ -1055,7 +1087,7 @@ class AuthProxyService {
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/signup');
final response = await http.post(
final response = await _post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({

View File

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

View File

@@ -30,6 +30,54 @@ void main() {
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: {
@@ -67,11 +115,13 @@ 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
@@ -84,6 +134,9 @@ class _FakeTarget implements AuthTokenStorageTarget {
@override
void remove(String key) {
if (throwsOnRemove) {
throw Exception('remove failed');
}
_data.remove(key);
}

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

@@ -54,6 +54,37 @@ void main() {
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,

View File

@@ -29,5 +29,47 @@ void main() {
);
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

@@ -48,6 +48,52 @@ void main() {
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(

View File

@@ -42,5 +42,15 @@ void main() {
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

@@ -43,6 +43,36 @@ void main() {
);
});
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'),