- 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
217 lines
7.9 KiB
Dart
217 lines
7.9 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|