web: migrate health notifications to js_interop; add browser hook
- Replace dart:js with package:js in health_check_service_web.dart\n- Implement showHealthCheckNotification in web/index.html\n- Pin js dependency to ^0.6.7 for flutter_secure_storage_web compatibility auth: harden AuthInterceptor + tests - Allow overrideAuthRepository injection for testing\n- Normalize imports to package: paths\n- Add unit test covering token attach, 401→refresh→retry, and failure path\n- Add integration test skeleton gated by env vars ui/data: map User.companyName to list column - Add companyName to domain User\n- Map UserDto.company?.name\n- Render companyName in user_list cleanup: remove legacy equipment table + unused code; minor warnings - Remove _buildFlexibleTable and unused helpers\n- Remove unused zipcode details and cache retry constant\n- Fix null-aware and non-null assertions\n- Address child-last warnings in administrator dialog docs: update AGENTS.md session context
This commit is contained in:
41
test/integration/auth_flow_integration_test.dart
Normal file
41
test/integration/auth_flow_integration_test.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final baseUrl = Platform.environment['INTEGRATION_API_BASE_URL'];
|
||||
final username = Platform.environment['INTEGRATION_LOGIN_USERNAME'];
|
||||
final password = Platform.environment['INTEGRATION_LOGIN_PASSWORD'];
|
||||
|
||||
group('Auth Integration (real API)', () {
|
||||
test('health endpoint responds', () async {
|
||||
if (baseUrl == null || baseUrl.isEmpty) {
|
||||
return; // silently succeed when not configured
|
||||
}
|
||||
final dio = Dio(BaseOptions(baseUrl: baseUrl));
|
||||
final res = await dio.get('/health');
|
||||
expect(res.statusCode, inInclusiveRange(200, 204));
|
||||
}, tags: ['integration']);
|
||||
|
||||
test('login and get users (requires credentials)', () async {
|
||||
if (baseUrl == null || username == null || password == null) {
|
||||
return; // silently succeed when not configured
|
||||
}
|
||||
final dio = Dio(BaseOptions(baseUrl: baseUrl));
|
||||
final loginRes = await dio.post('/auth/login', data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
});
|
||||
expect(loginRes.statusCode, inInclusiveRange(200, 204));
|
||||
|
||||
final accessToken = loginRes.data['access_token'] as String?;
|
||||
expect(accessToken, isNotNull);
|
||||
|
||||
dio.options.headers['Authorization'] = 'Bearer $accessToken';
|
||||
final usersRes = await dio.get('/users');
|
||||
expect(usersRes.statusCode, inInclusiveRange(200, 204));
|
||||
}, tags: ['integration']);
|
||||
});
|
||||
}
|
||||
|
||||
216
test/unit/auth_interceptor_test.dart
Normal file
216
test/unit/auth_interceptor_test.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import 'package:superport/core/constants/api_endpoints.dart';
|
||||
import 'package:superport/core/errors/failures.dart';
|
||||
import 'package:superport/data/datasources/remote/interceptors/auth_interceptor.dart';
|
||||
import 'package:superport/data/models/auth/refresh_token_request.dart';
|
||||
import 'package:superport/data/models/auth/login_request.dart';
|
||||
import 'package:superport/data/models/auth/login_response.dart';
|
||||
import 'package:superport/data/models/auth/auth_user.dart';
|
||||
import 'package:superport/data/models/auth/token_response.dart';
|
||||
import 'package:superport/domain/repositories/auth_repository.dart';
|
||||
|
||||
class _InMemoryAuthRepository implements AuthRepository {
|
||||
String? _accessToken;
|
||||
String? _refreshToken;
|
||||
bool cleared = false;
|
||||
|
||||
_InMemoryAuthRepository({String? accessToken, String? refreshToken})
|
||||
: _accessToken = accessToken,
|
||||
_refreshToken = refreshToken;
|
||||
|
||||
@override
|
||||
Future<Either<Failure, String?>> getStoredAccessToken() async => Right(_accessToken);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, String?>> getStoredRefreshToken() async => Right(_refreshToken);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, TokenResponse>> refreshToken(RefreshTokenRequest refreshRequest) async {
|
||||
if (refreshRequest.refreshToken != _refreshToken || _refreshToken == null) {
|
||||
return Left(ServerFailure(message: 'Invalid refresh token'));
|
||||
}
|
||||
// Issue new tokens
|
||||
_accessToken = 'NEW_TOKEN';
|
||||
_refreshToken = 'NEW_REFRESH';
|
||||
return Right(TokenResponse(
|
||||
accessToken: _accessToken!,
|
||||
refreshToken: _refreshToken!,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 3600,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> clearLocalSession() async {
|
||||
cleared = true;
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
return const Right(null);
|
||||
}
|
||||
|
||||
// Unused in these tests
|
||||
@override
|
||||
Future<Either<Failure, void>> changePassword(String currentPassword, String newPassword) async =>
|
||||
const Right(null);
|
||||
@override
|
||||
Future<Either<Failure, void>> requestPasswordReset(String email) async => const Right(null);
|
||||
@override
|
||||
Future<Either<Failure, bool>> isAuthenticated() async => const Right(true);
|
||||
@override
|
||||
Future<Either<Failure, void>> logout() async => const Right(null);
|
||||
@override
|
||||
Future<Either<Failure, bool>> validateSession() async => const Right(true);
|
||||
@override
|
||||
Future<Either<Failure, LoginResponse>> login(LoginRequest loginRequest) async =>
|
||||
Left(ServerFailure(message: 'not used in test'));
|
||||
@override
|
||||
Future<Either<Failure, AuthUser>> getCurrentUser() async =>
|
||||
Left(ServerFailure(message: 'not used in test'));
|
||||
}
|
||||
|
||||
/// Interceptor that terminates requests without hitting network.
|
||||
class _TerminatorInterceptor extends Interceptor {
|
||||
RequestOptions? lastOptions;
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
lastOptions = options;
|
||||
// Default success response (200)
|
||||
handler.resolve(Response(requestOptions: options, statusCode: 200, data: {'ok': true}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Interceptor that rejects with 401 unless it sees the expected token.
|
||||
class _UnauthorizedThenOkInterceptor extends Interceptor {
|
||||
final String requiredBearer;
|
||||
_UnauthorizedThenOkInterceptor(this.requiredBearer);
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final auth = options.headers['Authorization'];
|
||||
// Debug: record current auth header on the test interceptor
|
||||
// ignore: avoid_print
|
||||
print('Test UnauthorizedThenOkInterceptor saw Authorization: ' + (auth?.toString() ?? 'NULL'));
|
||||
if (auth == 'Bearer $requiredBearer') {
|
||||
handler.resolve(Response(requestOptions: options, statusCode: 200, data: {'ok': true}));
|
||||
} else {
|
||||
handler.reject(DioException(
|
||||
requestOptions: options,
|
||||
response: Response(requestOptions: options, statusCode: 401),
|
||||
type: DioExceptionType.badResponse,
|
||||
message: 'Unauthorized',
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple HTTP adapter that returns 200 when Authorization matches,
|
||||
/// otherwise returns 401. This is used to exercise `dio.fetch(...)` retry path.
|
||||
class _AuthTestAdapter implements HttpClientAdapter {
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<List<int>>? requestStream,
|
||||
Future? cancelFuture,
|
||||
) async {
|
||||
final auth = options.headers['Authorization'];
|
||||
// Login endpoint always returns 200
|
||||
if (options.path == ApiEndpoints.login) {
|
||||
return ResponseBody.fromString('{"ok":true}', 200, headers: {});
|
||||
}
|
||||
if (auth == 'Bearer NEW_TOKEN') {
|
||||
return ResponseBody.fromString('{"ok":true}', 200, headers: {});
|
||||
}
|
||||
return ResponseBody.fromString('', 401, headers: {});
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
group('AuthInterceptor', () {
|
||||
late GetIt sl;
|
||||
|
||||
setUp(() {
|
||||
sl = GetIt.instance;
|
||||
sl.reset(dispose: true);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
sl.reset(dispose: true);
|
||||
});
|
||||
|
||||
test('attaches Authorization header for protected endpoints', () async {
|
||||
final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: 'REFRESH');
|
||||
sl.registerLazySingleton<AuthRepository>(() => repo);
|
||||
|
||||
final dio = Dio(BaseOptions(baseUrl: 'https://example.com'));
|
||||
final terminator = _TerminatorInterceptor();
|
||||
dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo));
|
||||
dio.interceptors.add(terminator);
|
||||
|
||||
final res = await dio.get(ApiEndpoints.users);
|
||||
expect(res.statusCode, 200);
|
||||
expect(terminator.lastOptions, isNotNull);
|
||||
expect(terminator.lastOptions!.headers['Authorization'], 'Bearer OLD_TOKEN');
|
||||
});
|
||||
|
||||
test('skips Authorization header for auth endpoints', () async {
|
||||
final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: 'REFRESH');
|
||||
sl.registerLazySingleton<AuthRepository>(() => repo);
|
||||
|
||||
final dio = Dio(BaseOptions(baseUrl: 'https://example.com'));
|
||||
final terminator = _TerminatorInterceptor();
|
||||
dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo));
|
||||
dio.interceptors.add(terminator);
|
||||
|
||||
final res = await dio.get(ApiEndpoints.login);
|
||||
expect(res.statusCode, 200);
|
||||
expect(terminator.lastOptions, isNotNull);
|
||||
expect(terminator.lastOptions!.headers.containsKey('Authorization'), isFalse);
|
||||
});
|
||||
|
||||
test('on 401, refresh token and retry succeeds', () async {
|
||||
final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: 'REFRESH');
|
||||
sl.registerLazySingleton<AuthRepository>(() => repo);
|
||||
|
||||
final dio = Dio(BaseOptions(baseUrl: 'https://example.com'));
|
||||
dio.httpClientAdapter = _AuthTestAdapter();
|
||||
dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo));
|
||||
|
||||
final res = await dio.get(ApiEndpoints.users);
|
||||
expect(res.statusCode, 200);
|
||||
// ensure repo now holds new token
|
||||
final tokenEither = await repo.getStoredAccessToken();
|
||||
expect(tokenEither.getOrElse(() => null), 'NEW_TOKEN');
|
||||
});
|
||||
|
||||
test('on 401 and refresh fails, session cleared and error bubbles', () async {
|
||||
// Repo with no refresh token (will fail refresh)
|
||||
final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: null);
|
||||
sl.registerLazySingleton<AuthRepository>(() => repo);
|
||||
// Verify registration exists
|
||||
expect(GetIt.instance.isRegistered<AuthRepository>(), true);
|
||||
|
||||
final dio = Dio(BaseOptions(baseUrl: 'https://example.com'));
|
||||
dio.httpClientAdapter = _AuthTestAdapter();
|
||||
dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo));
|
||||
|
||||
DioException? caught;
|
||||
try {
|
||||
await dio.get(ApiEndpoints.users);
|
||||
} on DioException catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught, isNotNull);
|
||||
expect(caught!.response?.statusCode, 401);
|
||||
expect(repo.cleared, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user