fix(mobile): redact sensitive diagnostic logs (#2199)

* fix(mobile): redact sensitive diagnostic logs

* fix(mobile): tighten diagnostic log redaction

* fix(mobile): harden sanitized diagnostics

* fix(mobile): clarify offline fallback diagnostics

* fix(mobile): refine diagnostic log redaction

* fix(mobile): tighten diagnostic log redaction

* fix(mobile): harden auth diagnostic failures
This commit is contained in:
ghost
2026-06-06 08:47:30 -06:00
committed by GitHub
parent cf60e2c1d6
commit f7be206c55
13 changed files with 809 additions and 334 deletions

View File

@@ -58,6 +58,13 @@ class AuthProvider with ChangeNotifier {
_loadStoredAuth();
}
void _logAuthException(String operation, Object error) {
LogService.instance.error(
'AuthProvider',
'$operation failed with ${error.runtimeType}',
);
}
Future<void> _loadStoredAuth() async {
_isLoading = true;
_isInitializing = true;
@@ -127,7 +134,10 @@ class AuthProvider with ChangeNotifier {
otpCode: otpCode,
);
LogService.instance.debug('AuthProvider', 'Login result: $result');
LogService.instance.debug(
'AuthProvider',
'Login result received: success=${result['success'] == true}, mfa_required=${result['mfa_required'] == true}',
);
if (result['success'] == true) {
_tokens = result['tokens'] as AuthTokens?;
@@ -141,7 +151,8 @@ class AuthProvider with ChangeNotifier {
if (result['mfa_required'] == true) {
_mfaRequired = true;
_showMfaInput = true; // Show MFA input field
LogService.instance.debug('AuthProvider', 'MFA required! Setting _showMfaInput to true');
LogService.instance.debug(
'AuthProvider', 'MFA required! Setting _showMfaInput to true');
// If user already submitted an OTP code, this is likely an invalid OTP error
// Show the error message so user knows the code was wrong
@@ -165,7 +176,9 @@ class AuthProvider with ChangeNotifier {
return false;
}
} catch (e) {
_errorMessage = 'Connection error: ${e.toString()}';
_logAuthException('Login', e);
_errorMessage =
'Unable to connect. Please check your network and try again.';
_isLoading = false;
notifyListeners();
return false;
@@ -182,7 +195,10 @@ class AuthProvider with ChangeNotifier {
try {
final result = await _authService.loginWithApiKey(apiKey: apiKey);
LogService.instance.debug('AuthProvider', 'API key login result: $result');
LogService.instance.debug(
'AuthProvider',
'API key login result received: success=${result['success'] == true}',
);
if (result['success'] == true) {
_apiKey = apiKey;
@@ -197,9 +213,10 @@ class AuthProvider with ChangeNotifier {
notifyListeners();
return false;
}
} catch (e, stackTrace) {
LogService.instance.error('AuthProvider', 'API key login error: $e\n$stackTrace');
_errorMessage = 'Unable to connect. Please check your network and try again.';
} catch (e) {
_logAuthException('API key login', e);
_errorMessage =
'Unable to connect. Please check your network and try again.';
_isLoading = false;
notifyListeners();
return false;
@@ -241,7 +258,9 @@ class AuthProvider with ChangeNotifier {
return false;
}
} catch (e) {
_errorMessage = 'Connection error: ${e.toString()}';
_logAuthException('Signup', e);
_errorMessage =
'Unable to connect. Please check your network and try again.';
_isLoading = false;
notifyListeners();
return false;
@@ -260,12 +279,13 @@ class AuthProvider with ChangeNotifier {
deviceInfo: deviceInfo,
);
final launched = await launchUrl(Uri.parse(ssoUrl), mode: LaunchMode.externalApplication);
final launched = await launchUrl(Uri.parse(ssoUrl),
mode: LaunchMode.externalApplication);
if (!launched) {
_errorMessage = 'Unable to open browser for sign-in.';
}
} catch (e, stackTrace) {
LogService.instance.error('AuthProvider', 'SSO launch error: $e\n$stackTrace');
} catch (e) {
_logAuthException('SSO launch', e);
_errorMessage = 'Unable to start sign-in. Please try again.';
} finally {
_isLoading = false;
@@ -306,8 +326,8 @@ class AuthProvider with ChangeNotifier {
notifyListeners();
return false;
}
} catch (e, stackTrace) {
LogService.instance.error('AuthProvider', 'SSO callback error: $e\n$stackTrace');
} catch (e) {
_logAuthException('SSO callback', e);
_errorMessage = 'Sign-in failed. Please try again.';
_isLoading = false;
notifyListeners();
@@ -349,8 +369,8 @@ class AuthProvider with ChangeNotifier {
notifyListeners();
return false;
}
} catch (e, stackTrace) {
LogService.instance.error('AuthProvider', 'SSO link error: $e\n$stackTrace');
} catch (e) {
_logAuthException('SSO link', e);
_errorMessage = 'Failed to link account. Please try again.';
_isLoading = false;
notifyListeners();
@@ -392,8 +412,8 @@ class AuthProvider with ChangeNotifier {
notifyListeners();
return false;
}
} catch (e, stackTrace) {
LogService.instance.error('AuthProvider', 'SSO create account error: $e\n$stackTrace');
} catch (e) {
_logAuthException('SSO create account', e);
_errorMessage = 'Failed to create account. Please try again.';
_isLoading = false;
notifyListeners();

View File

@@ -3,9 +3,11 @@ import 'package:flutter/foundation.dart';
import '../models/chat.dart';
import '../models/message.dart';
import '../services/chat_service.dart';
import '../services/log_service.dart';
class ChatProvider with ChangeNotifier {
final ChatService _chatService = ChatService();
final LogService _log = LogService.instance;
List<Chat> _chats = [];
Chat? _currentChat;
@@ -60,7 +62,7 @@ class ChatProvider with ChangeNotifier {
_errorMessage = result['error'] ?? 'Failed to fetch chats';
}
} catch (e) {
debugPrint('fetchChats error: $e');
_log.warning('ChatProvider', 'fetchChats failed: ${e.runtimeType}');
_errorMessage = 'Something went wrong. Please try again.';
} finally {
_isLoading = false;
@@ -94,7 +96,7 @@ class ChatProvider with ChangeNotifier {
_errorMessage = result['error'] ?? 'Failed to fetch chat';
}
} catch (e) {
debugPrint('fetchChat error: $e');
_log.warning('ChatProvider', 'fetchChat failed: ${e.runtimeType}');
_errorMessage = 'Something went wrong. Please try again.';
} finally {
_isLoading = false;
@@ -153,7 +155,7 @@ class ChatProvider with ChangeNotifier {
return null;
}
} catch (e) {
debugPrint('createChat error: $e');
_log.warning('ChatProvider', 'createChat failed: ${e.runtimeType}');
_errorMessage = 'Something went wrong. Please try again.';
_isLoading = false;
notifyListeners();
@@ -164,9 +166,8 @@ class ChatProvider with ChangeNotifier {
void _rollbackOptimisticMessage(String optimisticId, String chatId) {
if (_currentChat != null && _currentChat!.id == chatId) {
_currentChat = _currentChat!.copyWith(
messages: _currentChat!.messages
.where((m) => m.id != optimisticId)
.toList(),
messages:
_currentChat!.messages.where((m) => m.id != optimisticId).toList(),
);
}
_isWaitingForResponse = false;
@@ -236,7 +237,7 @@ class ChatProvider with ChangeNotifier {
} catch (e) {
// Roll back the optimistic message on error.
_rollbackOptimisticMessage(optimisticId, chatId);
debugPrint('sendMessage error: $e');
_log.warning('ChatProvider', 'sendMessage failed: ${e.runtimeType}');
_errorMessage = 'Something went wrong. Please try again.';
return false;
} finally {
@@ -282,7 +283,7 @@ class ChatProvider with ChangeNotifier {
notifyListeners();
}
} catch (e) {
debugPrint('updateChatTitle error: $e');
_log.warning('ChatProvider', 'updateChatTitle failed: ${e.runtimeType}');
_errorMessage = 'Something went wrong. Please try again.';
notifyListeners();
}
@@ -314,7 +315,7 @@ class ChatProvider with ChangeNotifier {
return false;
}
} catch (e) {
debugPrint('deleteChat error: $e');
_log.warning('ChatProvider', 'deleteChat failed: ${e.runtimeType}');
_errorMessage = 'Something went wrong. Please try again.';
notifyListeners();
return false;
@@ -334,7 +335,8 @@ class ChatProvider with ChangeNotifier {
final deletedCount = (result['deletedCount'] as int?) ?? 0;
if (result['success'] == true || deletedCount > 0) {
final failedIds = ((result['failedIds'] as List?) ?? []).cast<String>().toSet();
final failedIds =
((result['failedIds'] as List?) ?? []).cast<String>().toSet();
final deleted = chatIds.toSet().difference(failedIds);
_chats.removeWhere((c) => deleted.contains(c.id));
@@ -350,7 +352,10 @@ class ChatProvider with ChangeNotifier {
notifyListeners();
return false;
} catch (e) {
debugPrint('deleteMultipleChats error: $e');
_log.warning(
'ChatProvider',
'deleteMultipleChats failed: ${e.runtimeType}',
);
_errorMessage = 'Something went wrong. Please try again.';
notifyListeners();
return false;
@@ -477,14 +482,15 @@ class ChatProvider with ChangeNotifier {
} catch (e) {
// Network error — allow polling to continue; timeout check below will
// stop it if the deadline has passed.
debugPrint('Polling error: ${e.toString()}');
_log.warning('ChatProvider', 'Polling failed: ${e.runtimeType}');
}
// Evaluate timeout only after the attempt, and only when no progress was made.
if (_pollingStartTime != null &&
DateTime.now().difference(_pollingStartTime!) >= _pollingTimeout) {
_stopPolling();
_errorMessage = 'The assistant took too long to respond. Please try again.';
_errorMessage =
'The assistant took too long to respond. Please try again.';
notifyListeners();
}
}

View File

@@ -102,8 +102,10 @@ class TransactionsProvider with ChangeNotifier {
accountId: accountId,
);
_log.debug('TransactionsProvider',
'Loaded ${localTransactions.length} transactions from local storage (accountId: $accountId)');
_log.debug(
'TransactionsProvider',
'Loaded ${localTransactions.length} transactions from local storage${accountId != null ? " with account filter" : ""}',
);
_transactions = localTransactions;
notifyListeners();
@@ -114,8 +116,10 @@ class TransactionsProvider with ChangeNotifier {
'Online: $isOnline, ForceSync: $forceSync, LocalEmpty: ${localTransactions.isEmpty}');
if (isOnline && (forceSync || localTransactions.isEmpty)) {
_log.debug('TransactionsProvider',
'Syncing from server for accountId: $accountId');
_log.debug(
'TransactionsProvider',
'Syncing transactions from server${accountId != null ? " with account filter" : ""}',
);
final result = await _syncService.syncFromServer(
accessToken: accessToken,
accountId: accountId,
@@ -168,8 +172,10 @@ class TransactionsProvider with ChangeNotifier {
try {
final isOnline = _connectivityService?.isOnline ?? false;
_log.info('TransactionsProvider',
'Creating transaction: $name, amount: $amount, online: $isOnline');
_log.info(
'TransactionsProvider',
'Creating transaction locally, online: $isOnline',
);
// ALWAYS save locally first (offline-first strategy)
final localTransaction = await _offlineStorage.saveTransaction(
@@ -189,8 +195,7 @@ class TransactionsProvider with ChangeNotifier {
syncStatus: SyncStatus.pending, // Start as pending
);
_log.info('TransactionsProvider',
'Transaction saved locally with ID: ${localTransaction.localId}');
_log.info('TransactionsProvider', 'Transaction saved locally');
// Reload transactions to show the new one immediately
await fetchTransactions(accessToken: accessToken, accountId: accountId);
@@ -446,8 +451,10 @@ class TransactionsProvider with ChangeNotifier {
required String localId,
required SyncStatus syncStatus,
}) async {
_log.info('TransactionsProvider',
'Undoing transaction $localId with status $syncStatus');
_log.info(
'TransactionsProvider',
'Undoing transaction with status $syncStatus',
);
try {
final success =

View File

@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import '../models/custom_proxy_header.dart';
import '../services/api_config.dart';
import '../services/custom_proxy_headers_service.dart';
import '../services/log_service.dart';
import '../widgets/custom_proxy_headers_editor.dart';
class BackendConfigScreen extends StatefulWidget {
@@ -47,10 +48,13 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
if (savedUrl != null && savedUrl.isNotEmpty) {
urlToShow = savedUrl;
}
} catch (e, stack) {
} catch (e) {
// Swallow storage failures so the screen still becomes interactive with
// sensible defaults; the user can re-enter and re-save.
debugPrint('BackendConfigScreen: failed to load saved config: $e\n$stack');
LogService.instance.warning(
'BackendConfigScreen',
'Failed to load saved backend config: $e',
);
} finally {
if (mounted) {
setState(() {
@@ -75,9 +79,9 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
try {
// Normalize base URL by removing trailing slashes
final normalizedUrl = _urlController.text.trim().replaceAll(
RegExp(r'/+$'),
'',
);
RegExp(r'/+$'),
'',
);
// Apply the unsaved edits only for the duration of this probe so the
// test reflects what the user is about to save. Restored in `finally`.
@@ -85,23 +89,21 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
// Check /sessions/new page to verify it's a Sure backend
final sessionsUrl = Uri.parse('$normalizedUrl/sessions/new');
final sessionsResponse = await http
.get(sessionsUrl, headers: ApiConfig.htmlHeaders())
.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw Exception(
'Connection timeout. Please check the URL and try again.',
);
},
final sessionsResponse =
await http.get(sessionsUrl, headers: ApiConfig.htmlHeaders()).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw Exception(
'Connection timeout. Please check the URL and try again.',
);
},
);
if (sessionsResponse.statusCode >= 200 &&
sessionsResponse.statusCode < 400) {
if (mounted) {
setState(() {
_successMessage =
'Connection successful!';
_successMessage = 'Connection successful!';
});
}
} else {
@@ -139,9 +141,9 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
try {
// Normalize base URL by removing trailing slashes
final normalizedUrl = _urlController.text.trim().replaceAll(
RegExp(r'/+$'),
'',
);
RegExp(r'/+$'),
'',
);
// Save URL to SharedPreferences
final prefs = await SharedPreferences.getInstance();
@@ -223,17 +225,17 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
Text(
'Configuration',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Update your Sure server URL',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
@@ -430,8 +432,8 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
Text(
'You can change this later in the settings.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],

View File

@@ -51,7 +51,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
// Select first account of the selected type
final filteredAccounts = _getFilteredAccounts(accountsProvider.accounts);
setState(() {
_selectedAccount = filteredAccounts.isNotEmpty ? filteredAccounts.first : null;
_selectedAccount =
filteredAccounts.isNotEmpty ? filteredAccounts.first : null;
});
if (_selectedAccount != null) {
await _loadTransactionsForAccount();
@@ -87,13 +88,17 @@ class _CalendarScreenState extends State<CalendarScreen> {
);
final transactions = transactionsProvider.transactions;
_log.info('CalendarScreen', 'Loaded ${transactions.length} transactions for account ${_selectedAccount!.name}');
_log.info(
'CalendarScreen',
'Loaded ${transactions.length} transactions for selected account',
);
// Store transactions for date filtering
_transactions = List.from(transactions);
_calculateDailyChanges(transactions);
_log.info('CalendarScreen', 'Calculated ${_dailyChanges.length} days with changes');
_log.info('CalendarScreen',
'Calculated ${_dailyChanges.length} days with changes');
}
setState(() {
@@ -104,7 +109,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
void _calculateDailyChanges(List<Transaction> transactions) {
final changes = <String, double>{};
_log.debug('CalendarScreen', 'Starting to calculate daily changes for ${transactions.length} transactions');
_log.debug('CalendarScreen',
'Starting to calculate daily changes for ${transactions.length} transactions');
for (var transaction in transactions) {
try {
@@ -115,23 +121,24 @@ class _CalendarScreenState extends State<CalendarScreen> {
// For asset accounts, flip the sign to match accounting conventions
// For liability accounts, also flip the sign
if (_selectedAccount?.isAsset == true || _selectedAccount?.isLiability == true) {
if (_selectedAccount?.isAsset == true ||
_selectedAccount?.isLiability == true) {
amount = -amount;
}
_log.debug('CalendarScreen', 'Processing transaction date: $dateKey, parsed amount sign adjusted');
changes[dateKey] = (changes[dateKey] ?? 0.0) + amount;
_log.debug('CalendarScreen', 'Date $dateKey now has total: ${changes[dateKey]}');
} catch (_) {
_log.error('CalendarScreen', 'Failed to process transaction for calendar');
} catch (e) {
final sanitizedError = LogService.sanitize(e.toString());
final errorSummary = sanitizedError.length > 120
? '${sanitizedError.substring(0, 120)}...'
: sanitizedError;
_log.error('CalendarScreen',
'Failed to process transaction for calendar: $errorSummary');
}
}
_log.info('CalendarScreen', 'Final changes map has ${changes.length} entries');
changes.forEach((date, amount) {
_log.debug('CalendarScreen', '$date -> $amount');
});
_log.info(
'CalendarScreen', 'Final changes map has ${changes.length} entries');
setState(() {
_dailyChanges = changes;
@@ -172,7 +179,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
return _transactions.where((transaction) {
try {
final transactionDate = DateTime.parse(transaction.date);
final transactionDateKey = DateFormat('yyyy-MM-dd').format(transactionDate);
final transactionDateKey =
DateFormat('yyyy-MM-dd').format(transactionDate);
return transactionDateKey == dateKey;
} catch (e) {
return false;
@@ -237,7 +245,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
}
// For asset accounts, flip the sign interpretation
if (_selectedAccount?.isAsset == true || _selectedAccount?.isLiability == true) {
if (_selectedAccount?.isAsset == true ||
_selectedAccount?.isLiability == true) {
isNegative = !isNegative;
}
@@ -321,8 +330,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
Text(
'Account Type',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
SegmentedButton<String>(
@@ -343,11 +352,15 @@ class _CalendarScreenState extends State<CalendarScreen> {
setState(() {
_accountType = newSelection.first;
// Switch to first account of new type
final filteredAccounts = _getFilteredAccounts(accountsProvider.accounts);
_selectedAccount = filteredAccounts.isNotEmpty ? filteredAccounts.first : null;
final filteredAccounts =
_getFilteredAccounts(accountsProvider.accounts);
_selectedAccount = filteredAccounts.isNotEmpty
? filteredAccounts.first
: null;
_dailyChanges = {};
_transactions = [];
_selectedDate = null; // Clear selection when changing account type
_selectedDate =
null; // Clear selection when changing account type
});
if (_selectedAccount != null) {
_loadTransactionsForAccount();
@@ -382,7 +395,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
vertical: 12,
),
),
items: _getFilteredAccounts(accountsProvider.accounts).map((account) {
items: _getFilteredAccounts(accountsProvider.accounts)
.map((account) {
return DropdownMenuItem(
value: account,
child: Text('${account.name} (${account.currency})'),
@@ -453,11 +467,11 @@ class _CalendarScreenState extends State<CalendarScreen> {
Text(
_formatCurrency(_getTotalForMonth()),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: _getTotalForMonth() >= 0
? Colors.green
: Colors.red,
fontWeight: FontWeight.bold,
),
color: _getTotalForMonth() >= 0
? Colors.green
: Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
@@ -475,8 +489,10 @@ class _CalendarScreenState extends State<CalendarScreen> {
}
Widget _buildCalendar(ColorScheme colorScheme) {
final firstDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month, 1);
final lastDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
final firstDayOfMonth =
DateTime(_currentMonth.year, _currentMonth.month, 1);
final lastDayOfMonth =
DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
final daysInMonth = lastDayOfMonth.day;
final startWeekday = firstDayOfMonth.weekday % 7; // 0 = Sunday
@@ -506,18 +522,21 @@ class _CalendarScreenState extends State<CalendarScreen> {
),
// Calendar grid
...List.generate((daysInMonth + startWeekday + 6) ~/ 7, (weekIndex) {
...List.generate((daysInMonth + startWeekday + 6) ~/ 7,
(weekIndex) {
return SizedBox(
height: 70,
child: Row(
children: List.generate(7, (dayIndex) {
final dayNumber = weekIndex * 7 + dayIndex - startWeekday + 1;
final dayNumber =
weekIndex * 7 + dayIndex - startWeekday + 1;
if (dayNumber < 1 || dayNumber > daysInMonth) {
return const Expanded(child: SizedBox.shrink());
}
final date = DateTime(_currentMonth.year, _currentMonth.month, dayNumber);
final date = DateTime(
_currentMonth.year, _currentMonth.month, dayNumber);
final dateKey = DateFormat('yyyy-MM-dd').format(date);
final change = _dailyChanges[dateKey] ?? 0.0;
final hasChange = _dailyChanges.containsKey(dateKey);
@@ -541,7 +560,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
);
}
Widget _buildDayCell(DateTime date, int day, double change, bool hasChange, ColorScheme colorScheme) {
Widget _buildDayCell(DateTime date, int day, double change, bool hasChange,
ColorScheme colorScheme) {
Color? backgroundColor;
Color? textColor;
@@ -569,7 +589,9 @@ class _CalendarScreenState extends State<CalendarScreen> {
color: backgroundColor ?? colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? Theme.of(context).primaryColor : colorScheme.outlineVariant,
color: isSelected
? Theme.of(context).primaryColor
: colorScheme.outlineVariant,
width: isSelected ? 3 : 1,
),
),

View File

@@ -35,6 +35,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _isTogglingBiometric = false;
List<CustomProxyHeader> _customHeaders = [];
String _displayInitial(String? displayName) {
final trimmed = displayName?.trim() ?? '';
return trimmed.isEmpty ? 'U' : trimmed[0].toUpperCase();
}
@override
void initState() {
super.initState();
@@ -86,7 +91,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) {
final build = packageInfo.buildNumber;
final display = build.isNotEmpty
? '${packageInfo.version} (${build})'
? '${packageInfo.version} ($build)'
: packageInfo.version;
setState(() => _appVersion = display);
}
@@ -107,8 +112,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) {
setState(() => _customHeaders = headers);
}
} catch (e, stack) {
debugPrint('SettingsScreen: failed to load custom headers: $e\n$stack');
} catch (e) {
LogService.instance.warning(
'SettingsScreen',
'Failed to load custom headers: $e',
);
// Keep the existing _customHeaders state so the screen remains usable.
}
}
@@ -119,10 +127,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context) => AlertDialog(
title: const Text('Clear Local Data'),
content: const Text(
'This will delete all locally cached transactions and accounts. '
'Your data on the server will not be affected. '
'Are you sure you want to continue?'
),
'This will delete all locally cached transactions and accounts. '
'Your data on the server will not be affected. '
'Are you sure you want to continue?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -156,7 +163,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Local data cleared successfully. Pull to refresh to sync from server.'),
content: Text(
'Local data cleared successfully. Pull to refresh to sync from server.'),
backgroundColor: Colors.green,
duration: Duration(seconds: 3),
),
@@ -242,7 +250,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Account reset has been initiated. This may take a moment.'),
content: Text(
'Account reset has been initiated. This may take a moment.'),
backgroundColor: Colors.green,
),
);
@@ -298,7 +307,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return;
}
final result = await UserService().deleteAccount(accessToken: accessToken);
final result =
await UserService().deleteAccount(accessToken: accessToken);
if (!context.mounted) return;
@@ -344,7 +354,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _showCustomHeadersDialog() async {
final formKey = GlobalKey<FormState>();
final latestHeaders = await CustomProxyHeadersService.instance.loadHeaders();
final latestHeaders =
await CustomProxyHeadersService.instance.loadHeaders();
if (!mounted) return;
setState(() => _customHeaders = latestHeaders);
@@ -438,7 +449,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
radius: 30,
backgroundColor: colorScheme.primary,
child: Text(
authProvider.user?.displayName[0].toUpperCase() ?? 'U',
_displayInitial(authProvider.user?.displayName),
style: TextStyle(
fontSize: 24,
color: colorScheme.onPrimary,
@@ -453,9 +464,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
Text(
authProvider.user?.displayName ?? 'User',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
@@ -512,7 +526,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const LogViewerScreen()),
MaterialPageRoute(
builder: (context) => const LogViewerScreen()),
);
},
),
@@ -570,7 +585,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
selected: {themeProvider.themeMode},
onSelectionChanged: (modes) => themeProvider.setThemeMode(modes.first),
onSelectionChanged: (modes) =>
themeProvider.setThemeMode(modes.first),
showSelectedIcon: false,
),
);
@@ -627,7 +643,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (_biometricSupported) ...[
const Divider(),
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
@@ -639,11 +654,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
),
SwitchListTile(
secondary: const Icon(Icons.fingerprint),
title: const Text('Biometric Lock'),
subtitle: const Text('Require biometric authentication when resuming the app'),
subtitle: const Text(
'Require biometric authentication when resuming the app'),
value: _biometricEnabled,
onChanged: _isTogglingBiometric ? null : _toggleBiometric,
),
@@ -671,10 +686,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
'Delete all accounts, categories, merchants, and tags but keep your user account',
),
trailing: _isResettingAccount
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: null,
enabled: !_isResettingAccount && !_isDeletingAccount,
onTap: _isResettingAccount || _isDeletingAccount ? null : () => _handleResetAccount(context),
onTap: _isResettingAccount || _isDeletingAccount
? null
: () => _handleResetAccount(context),
),
ListTile(
@@ -684,10 +704,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
'Permanently remove all your data. This cannot be undone.',
),
trailing: _isDeletingAccount
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: null,
enabled: !_isDeletingAccount && !_isResettingAccount,
onTap: _isDeletingAccount || _isResettingAccount ? null : () => _handleDeleteAccount(context),
onTap: _isDeletingAccount || _isResettingAccount
? null
: () => _handleDeleteAccount(context),
),
const Divider(),

View File

@@ -15,6 +15,28 @@ class AuthService {
static const String _apiKeyKey = 'api_key';
static const String _authModeKey = 'auth_mode';
void _logAuthException(String operation, Object error) {
LogService.instance.error(
'AuthService',
'$operation failed with ${error.runtimeType}',
);
}
String _responseError(Map<String, dynamic> responseData, String fallback) {
final error = responseData['error'];
if (error is String && error.isNotEmpty) return error;
final errors = responseData['errors'];
if (errors is List) {
final joined = errors.whereType<Object>().join(', ');
if (joined.isNotEmpty) return joined;
} else if (errors is String && errors.isNotEmpty) {
return errors;
}
return fallback;
}
Future<Map<String, dynamic>> login({
required String email,
required String password,
@@ -34,14 +56,18 @@ class AuthService {
body['otp_code'] = otpCode;
}
final response = await http.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode(body),
).timeout(const Duration(seconds: 30));
final response = await http
.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 30));
LogService.instance.debug('AuthService', 'Login response status: ${response.statusCode}');
LogService.instance.debug('AuthService', 'Login response body: ${response.body}');
LogService.instance.debug(
'AuthService',
'Login response received with status ${response.statusCode}',
);
final responseData = jsonDecode(response.body);
@@ -54,7 +80,7 @@ class AuthService {
User? user;
if (responseData['user'] != null) {
final rawUser = responseData['user'];
_logRawUserPayload('login', rawUser);
_logUserPayloadShape('login', rawUser);
user = User.fromJson(rawUser);
await _saveUser(user);
}
@@ -64,7 +90,8 @@ class AuthService {
'tokens': tokens,
'user': user,
};
} else if (response.statusCode == 401 && responseData['mfa_required'] == true) {
} else if (response.statusCode == 401 &&
responseData['mfa_required'] == true) {
return {
'success': false,
'mfa_required': true,
@@ -73,41 +100,41 @@ class AuthService {
} else {
return {
'success': false,
'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Login failed',
'error': _responseError(responseData, 'Login failed'),
};
}
} on SocketException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Login SocketException: $e\n$stackTrace');
} on SocketException catch (e) {
_logAuthException('Login', e);
return {
'success': false,
'error': 'Network unavailable',
};
} on TimeoutException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Login TimeoutException: $e\n$stackTrace');
} on TimeoutException catch (e) {
_logAuthException('Login', e);
return {
'success': false,
'error': 'Request timed out',
};
} on HttpException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Login HttpException: $e\n$stackTrace');
} on HttpException catch (e) {
_logAuthException('Login', e);
return {
'success': false,
'error': 'Invalid response from server',
};
} on FormatException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Login FormatException: $e\n$stackTrace');
} on FormatException catch (e) {
_logAuthException('Login', e);
return {
'success': false,
'error': 'Invalid response from server',
};
} on TypeError catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Login TypeError: $e\n$stackTrace');
} on TypeError catch (e) {
_logAuthException('Login', e);
return {
'success': false,
'error': 'Invalid response from server',
};
} catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Login unexpected error: $e\n$stackTrace');
} catch (e) {
_logAuthException('Login', e);
return {
'success': false,
'error': 'An unexpected error occurred',
@@ -140,11 +167,13 @@ class AuthService {
body['invite_code'] = inviteCode;
}
final response = await http.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode(body),
).timeout(const Duration(seconds: 30));
final response = await http
.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 30));
final responseData = jsonDecode(response.body);
@@ -157,7 +186,7 @@ class AuthService {
User? user;
if (responseData['user'] != null) {
final rawUser = responseData['user'];
_logRawUserPayload('signup', rawUser);
_logUserPayloadShape('signup', rawUser);
user = User.fromJson(rawUser);
await _saveUser(user);
}
@@ -170,41 +199,41 @@ class AuthService {
} else {
return {
'success': false,
'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Signup failed',
'error': _responseError(responseData, 'Signup failed'),
};
}
} on SocketException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Signup SocketException: $e\n$stackTrace');
} on SocketException catch (e) {
_logAuthException('Signup', e);
return {
'success': false,
'error': 'Network unavailable',
};
} on TimeoutException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Signup TimeoutException: $e\n$stackTrace');
} on TimeoutException catch (e) {
_logAuthException('Signup', e);
return {
'success': false,
'error': 'Request timed out',
};
} on HttpException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Signup HttpException: $e\n$stackTrace');
} on HttpException catch (e) {
_logAuthException('Signup', e);
return {
'success': false,
'error': 'Invalid response from server',
};
} on FormatException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Signup FormatException: $e\n$stackTrace');
} on FormatException catch (e) {
_logAuthException('Signup', e);
return {
'success': false,
'error': 'Invalid response from server',
};
} on TypeError catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Signup TypeError: $e\n$stackTrace');
} on TypeError catch (e) {
_logAuthException('Signup', e);
return {
'success': false,
'error': 'Invalid response from server',
};
} catch (e, stackTrace) {
LogService.instance.error('AuthService', 'Signup unexpected error: $e\n$stackTrace');
} catch (e) {
_logAuthException('Signup', e);
return {
'success': false,
'error': 'An unexpected error occurred',
@@ -219,14 +248,16 @@ class AuthService {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/refresh');
final response = await http.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode({
'refresh_token': refreshToken,
'device': deviceInfo,
}),
).timeout(const Duration(seconds: 30));
final response = await http
.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode({
'refresh_token': refreshToken,
'device': deviceInfo,
}),
)
.timeout(const Duration(seconds: 30));
final responseData = jsonDecode(response.body);
@@ -244,38 +275,38 @@ class AuthService {
'error': responseData['error'] ?? 'Token refresh failed',
};
}
} on SocketException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'RefreshToken SocketException: $e\n$stackTrace');
} on SocketException catch (e) {
_logAuthException('RefreshToken', e);
return {
'success': false,
'error': 'Network unavailable',
};
} on TimeoutException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'RefreshToken TimeoutException: $e\n$stackTrace');
} on TimeoutException catch (e) {
_logAuthException('RefreshToken', e);
return {
'success': false,
'error': 'Request timed out',
};
} on HttpException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'RefreshToken HttpException: $e\n$stackTrace');
} on HttpException catch (e) {
_logAuthException('RefreshToken', e);
return {
'success': false,
'error': 'Invalid response from server',
};
} on FormatException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'RefreshToken FormatException: $e\n$stackTrace');
} on FormatException catch (e) {
_logAuthException('RefreshToken', e);
return {
'success': false,
'error': 'Invalid response from server',
};
} on TypeError catch (e, stackTrace) {
LogService.instance.error('AuthService', 'RefreshToken TypeError: $e\n$stackTrace');
} on TypeError catch (e) {
_logAuthException('RefreshToken', e);
return {
'success': false,
'error': 'Invalid response from server',
};
} catch (e, stackTrace) {
LogService.instance.error('AuthService', 'RefreshToken unexpected error: $e\n$stackTrace');
} catch (e) {
_logAuthException('RefreshToken', e);
return {
'success': false,
'error': 'An unexpected error occurred',
@@ -298,7 +329,8 @@ class AuthService {
},
).timeout(const Duration(seconds: 30));
LogService.instance.debug('AuthService', 'API key login response status: ${response.statusCode}');
LogService.instance.debug('AuthService',
'API key login response status: ${response.statusCode}');
if (response.statusCode == 200) {
await _saveApiKey(apiKey);
@@ -316,20 +348,20 @@ class AuthService {
'error': 'Login failed (status ${response.statusCode})',
};
}
} on SocketException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'API key login SocketException: $e\n$stackTrace');
} on SocketException catch (e) {
_logAuthException('API key login', e);
return {
'success': false,
'error': 'Network unavailable',
};
} on TimeoutException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'API key login TimeoutException: $e\n$stackTrace');
} on TimeoutException catch (e) {
_logAuthException('API key login', e);
return {
'success': false,
'error': 'Request timed out',
};
} catch (e, stackTrace) {
LogService.instance.error('AuthService', 'API key login unexpected error: $e\n$stackTrace');
} catch (e) {
_logAuthException('API key login', e);
return {
'success': false,
'error': 'An unexpected error occurred',
@@ -388,11 +420,13 @@ class AuthService {
// Exchange authorization code for tokens via secure POST
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_exchange');
final response = await http.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode({'code': code}),
).timeout(const Duration(seconds: 30));
final response = await http
.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode({'code': code}),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode != 200) {
final errorData = jsonDecode(response.body);
@@ -413,7 +447,7 @@ class AuthService {
});
await _saveTokens(tokens);
_logRawUserPayload('sso_exchange', data['user']);
_logUserPayloadShape('sso_exchange', data['user']);
final user = User.fromJson(data['user']);
await _saveUser(user);
@@ -422,20 +456,20 @@ class AuthService {
'tokens': tokens,
'user': user,
};
} on SocketException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'SSO exchange SocketException: $e\n$stackTrace');
} on SocketException catch (e) {
_logAuthException('SSO exchange', e);
return {
'success': false,
'error': 'Network unavailable',
};
} on TimeoutException catch (e, stackTrace) {
LogService.instance.error('AuthService', 'SSO exchange TimeoutException: $e\n$stackTrace');
} on TimeoutException catch (e) {
_logAuthException('SSO exchange', e);
return {
'success': false,
'error': 'Request timed out',
};
} catch (e, stackTrace) {
LogService.instance.error('AuthService', 'SSO exchange unexpected error: $e\n$stackTrace');
} catch (e) {
_logAuthException('SSO exchange', e);
return {
'success': false,
'error': 'Failed to exchange authorization code',
@@ -450,15 +484,17 @@ class AuthService {
}) async {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_link');
final response = await http.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode({
'linking_code': linkingCode,
'email': email,
'password': password,
}),
).timeout(const Duration(seconds: 30));
final response = await http
.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode({
'linking_code': linkingCode,
'email': email,
'password': password,
}),
)
.timeout(const Duration(seconds: 30));
final responseData = jsonDecode(response.body);
@@ -468,7 +504,7 @@ class AuthService {
User? user;
if (responseData['user'] != null) {
_logRawUserPayload('sso_link', responseData['user']);
_logUserPayloadShape('sso_link', responseData['user']);
user = User.fromJson(responseData['user']);
await _saveUser(user);
}
@@ -481,15 +517,17 @@ class AuthService {
} else {
return {
'success': false,
'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account linking failed',
'error': _responseError(responseData, 'Account linking failed'),
};
}
} on SocketException {
} on SocketException catch (e) {
_logAuthException('SSO link', e);
return {'success': false, 'error': 'Network unavailable'};
} on TimeoutException {
} on TimeoutException catch (e) {
_logAuthException('SSO link', e);
return {'success': false, 'error': 'Request timed out'};
} catch (e, stackTrace) {
LogService.instance.error('AuthService', 'SSO link error: $e\n$stackTrace');
} catch (e) {
_logAuthException('SSO link', e);
return {'success': false, 'error': 'Failed to link account'};
}
}
@@ -500,18 +538,21 @@ class AuthService {
String? lastName,
}) async {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_create_account');
final url =
Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_create_account');
final body = <String, dynamic>{
'linking_code': linkingCode,
};
if (firstName != null) body['first_name'] = firstName;
if (lastName != null) body['last_name'] = lastName;
final response = await http.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode(body),
).timeout(const Duration(seconds: 30));
final response = await http
.post(
url,
headers: ApiConfig.jsonHeaders(),
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 30));
final responseData = jsonDecode(response.body);
@@ -521,7 +562,7 @@ class AuthService {
User? user;
if (responseData['user'] != null) {
_logRawUserPayload('sso_create_account', responseData['user']);
_logUserPayloadShape('sso_create_account', responseData['user']);
user = User.fromJson(responseData['user']);
await _saveUser(user);
}
@@ -534,15 +575,17 @@ class AuthService {
} else {
return {
'success': false,
'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Account creation failed',
'error': _responseError(responseData, 'Account creation failed'),
};
}
} on SocketException {
} on SocketException catch (e) {
_logAuthException('SSO create account', e);
return {'success': false, 'error': 'Network unavailable'};
} on TimeoutException {
} on TimeoutException catch (e) {
_logAuthException('SSO create account', e);
return {'success': false, 'error': 'Request timed out'};
} catch (e, stackTrace) {
LogService.instance.error('AuthService', 'SSO create account error: $e\n$stackTrace');
} catch (e) {
_logAuthException('SSO create account', e);
return {'success': false, 'error': 'Failed to create account'};
}
}
@@ -563,7 +606,7 @@ class AuthService {
final responseData = jsonDecode(response.body);
if (response.statusCode == 200) {
_logRawUserPayload('enable_ai', responseData['user']);
_logUserPayloadShape('enable_ai', responseData['user']);
final user = User.fromJson(responseData['user']);
await _saveUser(user);
return {
@@ -574,12 +617,13 @@ class AuthService {
return {
'success': false,
'error': responseData['error'] ?? responseData['errors']?.join(', ') ?? 'Failed to enable AI',
'error': _responseError(responseData, 'Failed to enable AI'),
};
} catch (e) {
_logAuthException('Enable AI', e);
return {
'success': false,
'error': 'Network error: ${e.toString()}',
'error': 'Network error',
};
}
}
@@ -594,7 +638,7 @@ class AuthService {
Future<AuthTokens?> getStoredTokens() async {
final tokensJson = await _storage.read(key: _tokenKey);
if (tokensJson == null) return null;
try {
return AuthTokens.fromJson(jsonDecode(tokensJson));
} catch (e) {
@@ -605,7 +649,7 @@ class AuthService {
Future<User?> getStoredUser() async {
final userJson = await _storage.read(key: _userKey);
if (userJson == null) return null;
try {
return User.fromJson(jsonDecode(userJson));
} catch (e) {
@@ -627,20 +671,25 @@ class AuthService {
);
}
void _logRawUserPayload(String source, dynamic userPayload) {
void _logUserPayloadShape(String source, dynamic userPayload) {
if (userPayload == null) {
LogService.instance.debug('AuthService', '$source user payload: <missing>');
LogService.instance.debug(
'AuthService',
'$source user payload missing',
);
return;
}
if (userPayload is Map<String, dynamic>) {
try {
LogService.instance.debug('AuthService', '$source user payload: ${jsonEncode(userPayload)}');
} catch (_) {
LogService.instance.debug('AuthService', '$source user payload: $userPayload');
}
LogService.instance.debug(
'AuthService',
'$source user payload received with ${userPayload.length} fields',
);
} else {
LogService.instance.debug('AuthService', '$source user payload type: ${userPayload.runtimeType}');
LogService.instance.debug(
'AuthService',
'$source user payload type: ${userPayload.runtimeType}',
);
}
}

View File

@@ -201,8 +201,7 @@ class DatabaseHelper {
return localId;
}
final db = await database;
_log.debug('DatabaseHelper',
'Inserting transaction: local_id=${transaction['local_id']}, account_id="${transaction['account_id']}", server_id=${transaction['server_id']}');
_log.debug('DatabaseHelper', 'Inserting transaction into local database');
await db.insert(
'transactions',
transaction,
@@ -235,8 +234,7 @@ class DatabaseHelper {
final db = await database;
if (accountId != null) {
_log.debug('DatabaseHelper',
'Querying transactions WHERE account_id = "$accountId"');
_log.debug('DatabaseHelper', 'Querying scoped transactions');
final results = await db.query(
'transactions',
where: 'account_id = ?',

View File

@@ -32,17 +32,88 @@ class LogService with ChangeNotifier {
List<LogEntry> get logs => List.unmodifiable(_logs);
static final List<RegExp> _authPatterns = [
RegExp(
r'\b(authorization|x-api-key|api[-_]?key|access[-_]?token|refresh[-_]?token|auth[-_]?token|bearer|password|otp[-_]?code|linking[-_]?code|secret|custom[-_]?proxy[-_]?headers?)\b\s*[:=]\s*(Bearer\s+[A-Za-z0-9._~+/=-]+|"[^"]*"|[^\s,}]+)',
caseSensitive: false,
),
RegExp(
r'"(authorization|x-api-key|api[-_]?key|access[-_]?token|refresh[-_]?token|auth[-_]?token|password|otp[-_]?code|linking[-_]?code|secret)"\s*:\s*("[^"]*"|[0-9.]+|true|false|null)',
caseSensitive: false,
),
];
static final List<RegExp> _businessDataPatterns = [
RegExp(
r'\b(local[-_]?id|account[-_]?id|server[-_]?id|transaction[-_]?id|merchant[-_]?id|category[-_]?id|tag[-_]?ids?|user[-_]?id|backend[-_]?url|base[-_]?url|amount|account[-_]?name|merchant[-_]?name|category[-_]?name|display[-_]?name|transaction[-_]?name|email|first[-_]?name|last[-_]?name)\b\s*[:=]\s*("[^"]*"|[^\s,}]+)',
caseSensitive: false,
),
RegExp(
r'"(local[-_]?id|account[-_]?id|server[-_]?id|transaction[-_]?id|merchant[-_]?id|category[-_]?id|tag[-_]?ids?|user[-_]?id|backend[-_]?url|base[-_]?url|amount|account[-_]?name|merchant[-_]?name|category[-_]?name|display[-_]?name|transaction[-_]?name|email|first[-_]?name|last[-_]?name)"\s*:\s*("[^"]*"|[0-9.]+|true|false|null)',
caseSensitive: false,
),
];
static final List<RegExp> _sensitiveKeyPatterns = [
..._authPatterns,
..._businessDataPatterns,
];
static final RegExp _bearerTokenPattern =
RegExp(r'\bBearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false);
static final RegExp _urlPattern = RegExp(r'https?://[^\s,}]+');
static final RegExp _hostLookupPattern = RegExp(
r'''(Failed host lookup:\s*)['"]?[^'"\s)]+['"]?''',
caseSensitive: false,
);
static final RegExp _socketAddressPattern = RegExp(
r'\b(address|host)\s*=\s*([^\s,}]+)',
caseSensitive: false,
);
static final RegExp _emailPattern = RegExp(
r'\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b',
caseSensitive: false);
static final RegExp _uuidPattern = RegExp(
r'\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b',
caseSensitive: false,
);
static final RegExp _longNumericIdPattern = RegExp(r'\b\d{14,}\b');
static String sanitize(String message) {
var sanitized = message;
for (final pattern in _sensitiveKeyPatterns) {
sanitized = sanitized.replaceAllMapped(pattern, (match) {
final key = match.group(1) ?? 'value';
return '$key=[redacted]';
});
}
sanitized = sanitized
.replaceAll(_bearerTokenPattern, 'Bearer [redacted]')
.replaceAll(_urlPattern, '[url]')
.replaceAllMapped(
_hostLookupPattern, (match) => '${match.group(1)}[host]')
.replaceAllMapped(
_socketAddressPattern, (match) => '${match.group(1)}=[host]')
.replaceAll(_emailPattern, '[email]')
.replaceAll(_uuidPattern, '[id]')
.replaceAll(_longNumericIdPattern, '[id]');
return sanitized;
}
/// Call this when log viewer screen becomes active
void setLogViewerActive(bool active) {
_isLogViewerActive = active;
}
void log(String tag, String message, {String level = 'INFO'}) {
final sanitizedMessage = sanitize(message);
final entry = LogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
message: sanitizedMessage,
);
_logs.add(entry);
@@ -53,7 +124,7 @@ class LogService with ChangeNotifier {
}
// Also print to console for development
debugPrint('[$level][$tag] $message');
debugPrint('[$level][$tag] $sanitizedMessage');
// Only notify listeners if log viewer is active to avoid unnecessary rebuilds
if (_isLogViewerActive) {
@@ -63,7 +134,8 @@ class LogService with ChangeNotifier {
void debug(String tag, String message) => log(tag, message, level: 'DEBUG');
void info(String tag, String message) => log(tag, message, level: 'INFO');
void warning(String tag, String message) => log(tag, message, level: 'WARNING');
void warning(String tag, String message) =>
log(tag, message, level: 'WARNING');
void error(String tag, String message) => log(tag, message, level: 'ERROR');
void clear() {
@@ -73,8 +145,10 @@ class LogService with ChangeNotifier {
String exportLogs() {
final buffer = StringBuffer();
// Log messages are sanitized before storage; export should preserve them.
for (final log in _logs) {
buffer.writeln('${log.formattedTime} [${log.level}][${log.tag}] ${log.message}');
buffer.writeln(
'${log.formattedTime} [${log.level}][${log.tag}] ${log.message}');
}
return buffer.toString();
}

View File

@@ -30,8 +30,10 @@ class OfflineStorageService {
}) async {
final localId = _uuid.v4();
_log.info('OfflineStorage',
'saveTransaction called: localId=$localId, accountId=$accountId, syncStatus=$syncStatus');
_log.info(
'OfflineStorage',
'saveTransaction called with syncStatus=$syncStatus',
);
final transaction = OfflineTransaction(
id: serverId,
@@ -54,8 +56,7 @@ class OfflineStorageService {
try {
await _dbHelper.insertTransaction(transaction.toDatabaseMap());
_log.info('OfflineStorage',
'Transaction saved successfully with localId: $localId');
_log.info('OfflineStorage', 'Transaction saved successfully');
return transaction;
} catch (e) {
_log.error('OfflineStorage', 'Failed to save transaction: $e');
@@ -65,21 +66,14 @@ class OfflineStorageService {
Future<List<OfflineTransaction>> getTransactions({String? accountId}) async {
_log.debug(
'OfflineStorage', 'getTransactions called with accountId: $accountId');
'OfflineStorage',
'getTransactions called${accountId != null ? " with account filter" : ""}',
);
final transactionMaps =
await _dbHelper.getTransactions(accountId: accountId);
_log.debug('OfflineStorage',
'Retrieved ${transactionMaps.length} transaction maps from database');
if (transactionMaps.isNotEmpty && accountId != null) {
_log.debug('OfflineStorage', 'Sample transaction account_ids:');
for (int i = 0; i < transactionMaps.take(3).length; i++) {
final map = transactionMaps[i];
_log.debug('OfflineStorage',
' - Transaction ${map['server_id']}: account_id="${map['account_id']}"');
}
}
final transactions = transactionMaps
.map((map) => OfflineTransaction.fromDatabaseMap(map))
.toList();
@@ -139,14 +133,15 @@ class OfflineStorageService {
/// Mark a transaction for pending deletion (offline delete)
Future<void> markTransactionForDeletion(String serverId) async {
_log.info(
'OfflineStorage', 'Marking transaction $serverId for pending deletion');
_log.info('OfflineStorage', 'Marking transaction for pending deletion');
// Find the transaction by server ID
final existing = await getTransactionByServerId(serverId);
if (existing == null) {
_log.warning('OfflineStorage',
'Transaction $serverId not found, cannot mark for deletion');
_log.warning(
'OfflineStorage',
'Transaction not found, cannot mark for deletion',
);
return;
}
@@ -158,33 +153,34 @@ class OfflineStorageService {
await _dbHelper.updateTransaction(
existing.localId, updated.toDatabaseMap());
_log.info('OfflineStorage',
'Transaction ${existing.localId} marked as pending_delete');
_log.info('OfflineStorage', 'Transaction marked as pending_delete');
}
/// Undo a pending transaction operation (either pending create or pending delete)
Future<bool> undoPendingTransaction(
String localId, SyncStatus currentStatus) async {
_log.info('OfflineStorage',
'Undoing pending transaction $localId with status $currentStatus');
_log.info(
'OfflineStorage',
'Undoing pending transaction with status $currentStatus',
);
final existing = await getTransactionByLocalId(localId);
if (existing == null) {
_log.warning(
'OfflineStorage', 'Transaction $localId not found, cannot undo');
_log.warning('OfflineStorage', 'Transaction not found, cannot undo');
return false;
}
if (currentStatus == SyncStatus.pending) {
// For pending creates: delete the transaction completely
_log.info(
'OfflineStorage', 'Deleting pending create transaction $localId');
_log.info('OfflineStorage', 'Deleting pending create transaction');
await deleteTransaction(localId);
return true;
} else if (currentStatus == SyncStatus.pendingDelete) {
// For pending deletes: restore to synced status
_log.info('OfflineStorage',
'Restoring pending delete transaction $localId to synced');
_log.info(
'OfflineStorage',
'Restoring pending delete transaction to synced',
);
final updated = existing.copyWith(
syncStatus: SyncStatus.synced,
updatedAt: DateTime.now(),
@@ -203,13 +199,6 @@ class OfflineStorageService {
_log.info('OfflineStorage',
'syncTransactionsFromServer called with ${serverTransactions.length} transactions from server');
// Log first transaction's accountId for debugging
if (serverTransactions.isNotEmpty) {
final firstTx = serverTransactions.first;
_log.info('OfflineStorage',
'First transaction: id=${firstTx.id}, accountId="${firstTx.accountId}", name="${firstTx.name}"');
}
// Use upsert logic instead of clear + insert to preserve recently uploaded transactions
_log.info('OfflineStorage',
'Upserting all transactions from server (preserving pending/failed)');
@@ -251,8 +240,11 @@ class OfflineStorageService {
// Log if transaction has empty accountId
if (transaction.accountId.isEmpty) {
_log.warning('OfflineStorage',
'Transaction ${transaction.id} has empty accountId from server! Provided accountId: $accountId, effective: $effectiveAccountId');
final fallbackApplied = accountId != null && accountId.isNotEmpty;
_log.warning(
'OfflineStorage',
'Transaction has empty accountId from server; fallbackApplied=$fallbackApplied',
);
}
// Check if we already have this transaction
@@ -264,8 +256,10 @@ class OfflineStorageService {
effectiveAccountId.isEmpty ? existing.accountId : effectiveAccountId;
if (finalAccountId.isEmpty) {
_log.error('OfflineStorage',
'CRITICAL: Updating transaction ${transaction.id} with EMPTY accountId!');
_log.error(
'OfflineStorage',
'CRITICAL: Updating transaction with EMPTY accountId; operation=update fallbackApplied=false existingAccountIdPresent=false',
);
}
final updated = existing.mergeServerTransaction(
@@ -277,8 +271,10 @@ class OfflineStorageService {
} else {
// Insert new transaction
if (effectiveAccountId.isEmpty) {
_log.error('OfflineStorage',
'CRITICAL: Inserting transaction ${transaction.id} with EMPTY accountId!');
_log.error(
'OfflineStorage',
'CRITICAL: Inserting transaction with EMPTY accountId; operation=insert fallbackApplied=false',
);
}
final offlineTransaction = OfflineTransaction(

View File

@@ -30,7 +30,8 @@ class SyncService with ChangeNotifier {
try {
final pendingDeletes = await _offlineStorage.getPendingDeletes();
_log.info('SyncService', 'Found ${pendingDeletes.length} pending deletes to process');
_log.info('SyncService',
'Found ${pendingDeletes.length} pending deletes to process');
if (pendingDeletes.isEmpty) {
return SyncResult(success: true, syncedCount: 0);
@@ -40,14 +41,16 @@ class SyncService with ChangeNotifier {
try {
// Only attempt to delete on server if the transaction has a server ID
if (transaction.id != null && transaction.id!.isNotEmpty) {
_log.info('SyncService', 'Deleting transaction ${transaction.id} from server');
_log.info(
'SyncService', 'Deleting pending transaction from server');
final result = await _transactionsService.deleteTransaction(
accessToken: accessToken,
transactionId: transaction.id!,
);
if (result['success'] == true) {
_log.info('SyncService', 'Delete success! Removing from local storage');
_log.info(
'SyncService', 'Delete success! Removing from local storage');
// Delete from local storage completely
await _offlineStorage.deleteTransaction(transaction.localId);
successCount++;
@@ -63,7 +66,10 @@ class SyncService with ChangeNotifier {
}
} else {
// No server ID means it was never synced to server, just delete locally
_log.info('SyncService', 'Transaction ${transaction.localId} has no server ID, deleting locally only');
_log.info(
'SyncService',
'Pending delete has no server ID, deleting locally only',
);
await _offlineStorage.deleteTransaction(transaction.localId);
successCount++;
}
@@ -79,7 +85,8 @@ class SyncService with ChangeNotifier {
}
}
_log.info('SyncService', 'Delete complete: $successCount success, $failureCount failed');
_log.info('SyncService',
'Delete complete: $successCount success, $failureCount failed');
return SyncResult(
success: failureCount == 0,
@@ -99,14 +106,17 @@ class SyncService with ChangeNotifier {
}
/// Sync pending transactions to server (internal method without sync lock check)
Future<SyncResult> _syncPendingTransactionsInternal(String accessToken) async {
Future<SyncResult> _syncPendingTransactionsInternal(
String accessToken) async {
int successCount = 0;
int failureCount = 0;
String? lastError;
try {
final pendingTransactions = await _offlineStorage.getPendingTransactions();
_log.info('SyncService', 'Found ${pendingTransactions.length} pending transactions to upload');
final pendingTransactions =
await _offlineStorage.getPendingTransactions();
_log.info('SyncService',
'Found ${pendingTransactions.length} pending transactions to upload');
if (pendingTransactions.isEmpty) {
return SyncResult(success: true, syncedCount: 0);
@@ -114,7 +124,7 @@ class SyncService with ChangeNotifier {
for (final transaction in pendingTransactions) {
try {
_log.info('SyncService', 'Uploading transaction ${transaction.localId} (${transaction.name})');
_log.info('SyncService', 'Uploading pending transaction');
// Upload transaction to server
final result = await _transactionsService.createTransaction(
accessToken: accessToken,
@@ -133,7 +143,8 @@ class SyncService with ChangeNotifier {
if (result['success'] == true) {
// Update local transaction with server ID and mark as synced
final serverTransaction = result['transaction'] as Transaction;
_log.info('SyncService', 'Upload success! Server ID: ${serverTransaction.id}');
_log.info('SyncService',
'Upload success; local transaction marked synced');
await _offlineStorage.updateTransactionSyncStatus(
localId: transaction.localId,
syncStatus: SyncStatus.synced,
@@ -162,7 +173,8 @@ class SyncService with ChangeNotifier {
}
}
_log.info('SyncService', 'Upload complete: $successCount success, $failureCount failed');
_log.info('SyncService',
'Upload complete: $successCount success, $failureCount failed');
return SyncResult(
success: failureCount == 0,
@@ -221,7 +233,12 @@ class SyncService with ChangeNotifier {
}) async {
try {
_log.info('SyncService', '========== SYNC FROM SERVER START ==========');
_log.info('SyncService', 'Fetching transactions from server (accountId: ${accountId ?? "ALL"})');
_log.info(
'SyncService',
accountId == null
? 'Fetching transactions for all accounts'
: 'Fetching transactions for scoped account',
);
List<Transaction> allTransactions = [];
int currentPage = 1;
@@ -230,7 +247,8 @@ class SyncService with ChangeNotifier {
// Fetch all pages
while (currentPage <= totalPages) {
_log.info('SyncService', '>>> Fetching page $currentPage of $totalPages (perPage: $perPage)');
_log.info('SyncService',
'>>> Fetching page $currentPage of $totalPages (perPage: $perPage)');
final result = await _transactionsService.getTransactions(
accessToken: accessToken,
@@ -239,15 +257,19 @@ class SyncService with ChangeNotifier {
perPage: perPage,
);
_log.debug('SyncService', 'API call completed for page $currentPage, success: ${result['success']}');
_log.debug('SyncService',
'API call completed for page $currentPage, success: ${result['success']}');
if (result['success'] == true) {
final pageTransactions = (result['transactions'] as List<dynamic>?)
?.cast<Transaction>() ?? [];
final pageTransactions =
(result['transactions'] as List<dynamic>?)?.cast<Transaction>() ??
[];
_log.info('SyncService', 'Page $currentPage returned ${pageTransactions.length} transactions');
_log.info('SyncService',
'Page $currentPage returned ${pageTransactions.length} transactions');
allTransactions.addAll(pageTransactions);
_log.info('SyncService', 'Total transactions accumulated: ${allTransactions.length}');
_log.info('SyncService',
'Total transactions accumulated: ${allTransactions.length}');
// Extract pagination info if available
final pagination = result['pagination'] as Map<String, dynamic>?;
@@ -255,24 +277,35 @@ class SyncService with ChangeNotifier {
final prevTotalPages = totalPages;
totalPages = pagination['total_pages'] as int? ?? 1;
final totalCount = pagination['total_count'] as int? ?? 0;
final currentPageFromApi = pagination['page'] as int? ?? currentPage;
final currentPageFromApi =
pagination['page'] as int? ?? currentPage;
final perPageFromApi = pagination['per_page'] as int? ?? perPage;
_log.info('SyncService', 'Pagination info: page=$currentPageFromApi/$totalPages, per_page=$perPageFromApi, total_count=$totalCount');
if (currentPageFromApi != currentPage) {
_log.warning('SyncService',
'Pagination page mismatch: expected $currentPage, received $currentPageFromApi of $totalPages');
}
_log.info('SyncService',
'Pagination info: page=$currentPageFromApi/$totalPages, per_page=$perPageFromApi, total_count=$totalCount');
if (prevTotalPages != totalPages) {
_log.info('SyncService', 'Total pages updated from $prevTotalPages to $totalPages');
_log.info('SyncService',
'Total pages updated from $prevTotalPages to $totalPages');
}
} else {
// No pagination info means this is the only page
_log.warning('SyncService', 'No pagination info in response - assuming single page');
_log.warning('SyncService',
'No pagination info in response - assuming single page');
totalPages = currentPage;
}
_log.info('SyncService', 'Moving to next page (current: $currentPage, total: $totalPages)');
_log.info('SyncService',
'Moving to next page (current: $currentPage, total: $totalPages)');
currentPage++;
} else {
_log.error('SyncService', 'Server returned error on page $currentPage: ${result['error']}');
_log.error('SyncService',
'Server returned error on page $currentPage: ${result['error']}');
return SyncResult(
success: false,
error: result['error'] as String? ?? 'Failed to sync from server',
@@ -280,17 +313,23 @@ class SyncService with ChangeNotifier {
}
}
_log.info('SyncService', '>>> Pagination loop completed. Fetched ${currentPage - 1} pages');
_log.info('SyncService', '>>> Received total of ${allTransactions.length} transactions from server');
_log.info('SyncService',
'>>> Pagination loop completed. Fetched ${currentPage - 1} pages');
_log.info('SyncService',
'>>> Received total of ${allTransactions.length} transactions from server');
// Update local cache with server data
_log.info('SyncService', '========== UPDATING LOCAL CACHE ==========');
if (accountId == null) {
_log.info('SyncService', 'Full sync - clearing and replacing all transactions');
_log.info('SyncService',
'Full sync - clearing and replacing all transactions');
// Full sync - replace all transactions
await _offlineStorage.syncTransactionsFromServer(allTransactions);
} else {
_log.info('SyncService', 'Partial sync - upserting ${allTransactions.length} transactions for account $accountId');
_log.info(
'SyncService',
'Partial sync - upserting ${allTransactions.length} transactions',
);
// Partial sync - upsert transactions
int upsertCount = 0;
for (final transaction in allTransactions) {
@@ -300,13 +339,16 @@ class SyncService with ChangeNotifier {
);
upsertCount++;
if (upsertCount % 50 == 0) {
_log.info('SyncService', 'Upserted $upsertCount/${allTransactions.length} transactions');
_log.info('SyncService',
'Upserted $upsertCount/${allTransactions.length} transactions');
}
}
_log.info('SyncService', 'Completed upserting $upsertCount transactions');
_log.info(
'SyncService', 'Completed upserting $upsertCount transactions');
}
_log.info('SyncService', '========== SYNC FROM SERVER COMPLETE ==========');
_log.info(
'SyncService', '========== SYNC FROM SERVER COMPLETE ==========');
_lastSyncTime = DateTime.now();
notifyListeners();
@@ -326,7 +368,8 @@ class SyncService with ChangeNotifier {
/// Sync accounts from server and update local cache
Future<SyncResult> syncAccounts(String accessToken) async {
try {
final result = await _accountsService.getAccounts(accessToken: accessToken);
final result =
await _accountsService.getAccounts(accessToken: accessToken);
if (result['success'] == true) {
final accountsList = result['accounts'] as List<dynamic>? ?? [];
@@ -374,17 +417,20 @@ class SyncService with ChangeNotifier {
// Step 1: Process pending deletes (do this first to free up resources)
_log.info('SyncService', 'Step 1: Processing pending deletes');
final deleteResult = await _syncPendingDeletesInternal(accessToken);
_log.info('SyncService', 'Step 1 complete: ${deleteResult.syncedCount ?? 0} deleted, ${deleteResult.failedCount ?? 0} failed');
_log.info('SyncService',
'Step 1 complete: ${deleteResult.syncedCount ?? 0} deleted, ${deleteResult.failedCount ?? 0} failed');
// Step 2: Upload pending transactions
_log.info('SyncService', 'Step 2: Uploading pending transactions');
final uploadResult = await _syncPendingTransactionsInternal(accessToken);
_log.info('SyncService', 'Step 2 complete: ${uploadResult.syncedCount ?? 0} uploaded, ${uploadResult.failedCount ?? 0} failed');
_log.info('SyncService',
'Step 2 complete: ${uploadResult.syncedCount ?? 0} uploaded, ${uploadResult.failedCount ?? 0} failed');
// Step 3: Download transactions from server
_log.info('SyncService', 'Step 3: Downloading transactions from server');
final downloadResult = await syncFromServer(accessToken: accessToken);
_log.info('SyncService', 'Step 3 complete: ${downloadResult.syncedCount ?? 0} downloaded');
_log.info('SyncService',
'Step 3 complete: ${downloadResult.syncedCount ?? 0} downloaded');
// Step 4: Sync accounts
_log.info('SyncService', 'Step 4: Syncing accounts');
@@ -394,17 +440,29 @@ class SyncService with ChangeNotifier {
_isSyncing = false;
_lastSyncTime = DateTime.now();
final allSuccess = deleteResult.success && uploadResult.success && downloadResult.success && accountsResult.success;
_syncError = allSuccess ? null : (deleteResult.error ?? uploadResult.error ?? downloadResult.error ?? accountsResult.error);
final allSuccess = deleteResult.success &&
uploadResult.success &&
downloadResult.success &&
accountsResult.success;
_syncError = allSuccess
? null
: (deleteResult.error ??
uploadResult.error ??
downloadResult.error ??
accountsResult.error);
_log.info('SyncService', '==== Full Sync Complete: ${allSuccess ? "SUCCESS" : "PARTIAL/FAILED"} ====');
_log.info('SyncService',
'==== Full Sync Complete: ${allSuccess ? "SUCCESS" : "PARTIAL/FAILED"} ====');
notifyListeners();
return SyncResult(
success: allSuccess,
syncedCount: (deleteResult.syncedCount ?? 0) + (uploadResult.syncedCount ?? 0) + (downloadResult.syncedCount ?? 0),
failedCount: (deleteResult.failedCount ?? 0) + (uploadResult.failedCount ?? 0),
syncedCount: (deleteResult.syncedCount ?? 0) +
(uploadResult.syncedCount ?? 0) +
(downloadResult.syncedCount ?? 0),
failedCount:
(deleteResult.failedCount ?? 0) + (uploadResult.failedCount ?? 0),
error: _syncError,
);
} catch (e) {
@@ -421,7 +479,8 @@ class SyncService with ChangeNotifier {
}
/// Auto sync if online - to be called when app regains connectivity
Future<void> autoSync(String accessToken, ConnectivityService connectivityService) async {
Future<void> autoSync(
String accessToken, ConnectivityService connectivityService) async {
if (connectivityService.isOnline && !_isSyncing) {
await performFullSync(accessToken);
}

View File

@@ -0,0 +1,51 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sure_mobile/services/api_config.dart';
import 'package:sure_mobile/services/auth_service.dart';
void main() {
group('AuthService', () {
late HttpServer server;
setUp(() async {
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
ApiConfig.clearApiKeyAuth();
ApiConfig.setCustomProxyHeaders([]);
ApiConfig.setBaseUrl('http://${server.address.host}:${server.port}');
});
tearDown(() async {
ApiConfig.setBaseUrl(ApiConfig.defaultBaseUrl);
await server.close(force: true);
});
test('login handles string errors payloads without throwing', () async {
final subscription = server.listen((request) {
if (request.method != 'POST' ||
request.uri.path != '/api/v1/auth/login') {
request.response.statusCode = 404;
request.response.close();
return;
}
request.response
..statusCode = 422
..headers.contentType = ContentType.json
..write(jsonEncode({'errors': 'Invalid login payload'}))
..close();
});
addTearDown(subscription.cancel);
final result = await AuthService().login(
email: 'user@example.test',
password: 'password',
deviceInfo: const {'platform': 'test'},
);
expect(result['success'], false);
expect(result['error'], 'Invalid login payload');
});
});
}

View File

@@ -0,0 +1,166 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sure_mobile/services/log_service.dart';
void main() {
setUp(() {
LogService.instance.clear();
});
test('sanitize redacts authentication and identity values', () {
final sanitized = LogService.sanitize(
'Authorization: Bearer secret-token '
'X-Api-Key=mobile-secret '
'email=user@example.com '
'accountId=123e4567-e89b-12d3-a456-426614174000 '
'backendUrl=https://sure.example.test/api',
);
expect(sanitized, isNot(contains('secret-token')));
expect(sanitized, isNot(contains('mobile-secret')));
expect(sanitized, isNot(contains('user@example.com')));
expect(sanitized, isNot(contains('123e4567-e89b-12d3-a456-426614174000')));
expect(sanitized, isNot(contains('sure.example.test')));
expect(sanitized, contains('[redacted]'));
});
test('sanitize redacts host-only socket error backends', () {
final sanitized = LogService.sanitize(
"SocketException: Failed host lookup: 'private.internal' "
'(OS Error: nodename nor servname provided, errno = 8)',
);
expect(sanitized, contains('Failed host lookup: [host]'));
expect(sanitized, isNot(contains('private.internal')));
final unquoted = LogService.sanitize(
'SocketException: Failed host lookup: private.internal '
'(OS Error: lookup failed)',
);
expect(unquoted, contains('Failed host lookup: [host]'));
expect(unquoted, isNot(contains('private.internal')));
});
test('sanitize redacts financial and merchant values', () {
final sanitized = LogService.sanitize(
'transactionName=Coffee amount=123.45 merchantName="Corner Store" '
'"transaction_id":"txn_123","local_id":"local_123"',
);
expect(sanitized, contains('[redacted]'));
expect(sanitized, isNot(contains('Coffee')));
expect(sanitized, isNot(contains('123.45')));
expect(sanitized, isNot(contains('Corner Store')));
expect(sanitized, isNot(contains('txn_123')));
expect(sanitized, isNot(contains('local_123')));
});
test('sanitize redacts long numeric ids but preserves shorter counts', () {
final longNumericId = LogService.sanitize('orderId=12345678901234');
final shorterCount = LogService.sanitize('count=123456789012');
final millisecondTimestamp = LogService.sanitize('timestamp=1717605296123');
expect(longNumericId, contains('orderId=[id]'));
expect(longNumericId, isNot(contains('12345678901234')));
expect(shorterCount, contains('123456789012'));
expect(millisecondTimestamp, contains('timestamp=1717605296123'));
});
test('sanitize handles empty and safe messages', () {
expect(LogService.sanitize(''), '');
const message = 'Fetched 25 transactions on page 2 with syncStatus=pending '
'filename=main.dart pageName=transactions hostname=localhost '
'animationName=fadeIn';
expect(LogService.sanitize(message), message);
});
test('sanitize redacts repeated sensitive values', () {
final sanitized = LogService.sanitize(
'email=one@example.com email=two@example.com '
'Authorization: Bearer first-token Authorization: Bearer second-token',
);
expect(sanitized, isNot(contains('one@example.com')));
expect(sanitized, isNot(contains('two@example.com')));
expect(sanitized, isNot(contains('first-token')));
expect(sanitized, isNot(contains('second-token')));
expect(
'[redacted]'.allMatches(sanitized), hasLength(greaterThanOrEqualTo(4)));
});
test('auth service style errors redact credentials and backend values', () {
LogService.instance.error(
'AuthService',
'Login SocketException: Failed host lookup: '
"'private.internal' password=secret otp_code=123456 "
'Authorization: Bearer token-123 email=user@example.com '
'https://sure.example.test/login',
);
final exported = LogService.instance.exportLogs();
expect(exported, isNot(contains('private.internal')));
expect(exported, isNot(contains('secret')));
expect(exported, isNot(contains('123456')));
expect(exported, isNot(contains('token-123')));
expect(exported, isNot(contains('user@example.com')));
expect(exported, isNot(contains('sure.example.test')));
});
test('log storage and export use sanitized messages', () {
LogService.instance.info(
'Test',
'Saved transaction transactionName=Coffee amount=9.99 email=user@example.com',
);
expect(LogService.instance.logs, hasLength(1));
expect(LogService.instance.logs.single.message, isNot(contains('Coffee')));
expect(LogService.instance.logs.single.message, isNot(contains('9.99')));
expect(LogService.instance.logs.single.message,
isNot(contains('user@example.com')));
final exported = LogService.instance.exportLogs();
expect(exported, isNot(contains('Coffee')));
expect(exported, isNot(contains('9.99')));
expect(exported, isNot(contains('user@example.com')));
});
test('all log levels store sanitized messages', () {
LogService.instance.debug('Test', 'accessToken=debug-secret');
LogService.instance.warning('Test', 'merchantName=Warning Store');
LogService.instance.error('Test', 'backendUrl=https://sure.example.test');
final exported = LogService.instance.exportLogs();
expect(exported, isNot(contains('debug-secret')));
expect(exported, isNot(contains('Warning Store')));
expect(exported, isNot(contains('sure.example.test')));
});
test('export redacts long interleaved diagnostic messages', () {
final safeChunks = List.filled(50, 'sync page ok').join(' ');
LogService.instance.info(
'Test',
'$safeChunks accountId=123e4567-e89b-12d3-a456-426614174000 '
'amount=123456.78 merchantName="Big Store" $safeChunks',
);
final exported = LogService.instance.exportLogs();
expect(exported, contains('sync page ok'));
expect(exported, isNot(contains('123e4567-e89b-12d3-a456-426614174000')));
expect(exported, isNot(contains('123456.78')));
expect(exported, isNot(contains('Big Store')));
});
test('sanitize preserves safe operational diagnostics', () {
final sanitized = LogService.sanitize(
'Fetched 25 transactions on page 2 with syncStatus=pending '
'filename=main.dart pageName=transactions hostname=localhost',
);
expect(sanitized, contains('Fetched 25 transactions'));
expect(sanitized, contains('page 2'));
expect(sanitized, contains('syncStatus=pending'));
expect(sanitized, contains('filename=main.dart'));
expect(sanitized, contains('pageName=transactions'));
expect(sanitized, contains('hostname=localhost'));
});
}