Skip to content

Commit adf83a6

Browse files
committed
test(auth): add token invalidation
1 parent c98d9cb commit adf83a6

File tree

3 files changed

+174
-7
lines changed

3 files changed

+174
-7
lines changed

lib/src/services/jwt_auth_token_service.dart

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ class JwtAuthTokenService implements AuthTokenService {
103103
final jti = jwt.payload['jti'] as String?;
104104
if (jti == null || jti.isEmpty) {
105105
print(
106-
'[validateToken] Token validation failed: Missing or empty "jti" claim.',);
106+
'[validateToken] Token validation failed: Missing or empty "jti" claim.',
107+
);
107108
// Throw specific exception for malformed token
108109
throw const BadRequestException(
109110
'Malformed token: Missing or empty JWT ID (jti) claim.',
@@ -114,7 +115,8 @@ class JwtAuthTokenService implements AuthTokenService {
114115
final isBlacklisted = await _blacklistService.isBlacklisted(jti);
115116
if (isBlacklisted) {
116117
print(
117-
'[validateToken] Token validation failed: Token is blacklisted (jti: $jti).',);
118+
'[validateToken] Token validation failed: Token is blacklisted (jti: $jti).',
119+
);
118120
// Throw specific exception for blacklisted token
119121
throw const UnauthorizedException('Token has been invalidated.');
120122
}
@@ -166,8 +168,8 @@ class JwtAuthTokenService implements AuthTokenService {
166168
return user;
167169
} on JWTExpiredException catch (e, s) {
168170
print('[validateToken] CATCH JWTExpiredException: Token expired. $e\n$s');
169-
// Let the specific UnauthorizedException for expiry propagate
170-
rethrow;
171+
// Throw the standardized exception instead of rethrowing the specific one
172+
throw const UnauthorizedException('Token expired.');
171173
} on JWTInvalidException catch (e, s) {
172174
print(
173175
'[validateToken] CATCH JWTInvalidException: Invalid token. '
@@ -214,7 +216,6 @@ class JwtAuthTokenService implements AuthTokenService {
214216
SecretKey(_secretKey),
215217
checkExpiresIn: false, // IMPORTANT: Don't fail if expired here
216218
checkHeaderType: true, // Keep other standard checks
217-
// checkIssuedAt: true, // This parameter doesn't exist
218219
);
219220
print('[invalidateToken] Token signature verified.');
220221

test/helpers/mock_classes.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:dart_frog/dart_frog.dart';
22
import 'package:ht_api/src/services/auth_service.dart';
33
import 'package:ht_api/src/services/auth_token_service.dart';
4+
import 'package:ht_api/src/services/token_blacklist_service.dart'; // Added import
45
import 'package:ht_api/src/services/verification_code_storage_service.dart';
56
import 'package:ht_app_settings_repository/ht_app_settings_repository.dart';
67
import 'package:ht_data_repository/ht_data_repository.dart';
@@ -26,6 +27,8 @@ class MockAuthTokenService extends Mock implements AuthTokenService {}
2627
class MockVerificationCodeStorageService extends Mock
2728
implements VerificationCodeStorageService {}
2829

30+
class MockTokenBlacklistService extends Mock implements TokenBlacklistService {}
31+
2932
// Repository Mocks
3033
class MockHtDataRepository<T> extends Mock implements HtDataRepository<T> {}
3134

test/src/services/jwt_auth_token_service_test.dart

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
22
import 'package:ht_api/src/services/jwt_auth_token_service.dart';
3+
// Import blacklist service
34
import 'package:ht_shared/ht_shared.dart';
45
import 'package:mocktail/mocktail.dart';
56
import 'package:test/test.dart';
@@ -13,6 +14,8 @@ void main() {
1314
group('JwtAuthTokenService', () {
1415
late JwtAuthTokenService service;
1516
late MockUserRepository mockUserRepository;
17+
late MockTokenBlacklistService
18+
mockBlacklistService; // Add mock blacklist service
1619
late MockUuid mockUuid;
1720

1821
const testUser = User(
@@ -25,13 +28,17 @@ void main() {
2528
setUpAll(() {
2629
// Register fallback values for argument matchers
2730
registerFallbackValue(const User(id: 'fallback', isAnonymous: true));
31+
// Register fallback for DateTime if needed for blacklist mock
32+
registerFallbackValue(DateTime(2024));
2833
});
2934

3035
setUp(() {
3136
mockUserRepository = MockUserRepository();
37+
mockBlacklistService = MockTokenBlacklistService(); // Instantiate mock
3238
mockUuid = MockUuid();
3339
service = JwtAuthTokenService(
3440
userRepository: mockUserRepository,
41+
blacklistService: mockBlacklistService, // Provide mock
3542
uuidGenerator: mockUuid,
3643
);
3744

@@ -110,6 +117,9 @@ void main() {
110117
// Stub user repository to return the user when read is called
111118
when(() => mockUserRepository.read(testUser.id))
112119
.thenAnswer((_) async => testUser);
120+
// Stub blacklist service to return false (not blacklisted) by default
121+
when(() => mockBlacklistService.isBlacklisted(any()))
122+
.thenAnswer((_) async => false);
113123
});
114124

115125
test('successfully validates a correct token and returns user', () async {
@@ -257,7 +267,160 @@ void main() {
257267
verify(() => mockUserRepository.read(testUser.id)).called(1);
258268
});
259269
});
270+
271+
group('invalidateToken', () {
272+
late String validToken;
273+
late String validJti;
274+
late DateTime validExpiry;
275+
276+
setUp(() async {
277+
// Generate a valid token and extract its details for tests
278+
validToken = await service.generateToken(testUser);
279+
final jwt = JWT.verify(
280+
validToken,
281+
SecretKey(
282+
'your-very-hardcoded-super-secret-key-replace-this-in-prod',
283+
),
284+
);
285+
validJti = jwt.payload['jti'] as String;
286+
final expClaim = jwt.payload['exp'] as int;
287+
validExpiry =
288+
DateTime.fromMillisecondsSinceEpoch(expClaim * 1000, isUtc: true);
289+
290+
// Default stub for blacklist success
291+
when(
292+
() => mockBlacklistService.blacklist(any(), any()),
293+
).thenAnswer((_) async => Future.value());
294+
});
295+
296+
test('successfully invalidates a valid token', () async {
297+
// Act
298+
await service.invalidateToken(validToken);
299+
300+
// Assert
301+
verify(
302+
() => mockBlacklistService.blacklist(validJti, validExpiry),
303+
).called(1);
304+
});
305+
306+
test('throws InvalidInputException for invalid token signature',
307+
() async {
308+
// Arrange: Sign with a different key
309+
final jwt = JWT({'sub': testUser.id}, subject: testUser.id);
310+
final invalidSignatureToken = jwt.sign(SecretKey(_invalidSecretKey));
311+
312+
// Act & Assert
313+
await expectLater(
314+
() => service.invalidateToken(invalidSignatureToken),
315+
throwsA(
316+
isA<InvalidInputException>().having(
317+
(e) => e.message,
318+
'message',
319+
contains('Invalid token format'),
320+
),
321+
),
322+
);
323+
verifyNever(() => mockBlacklistService.blacklist(any(), any()));
324+
});
325+
326+
test('throws InvalidInputException for token missing "jti" claim',
327+
() async {
328+
// Arrange: Create token without jti
329+
final jwt = JWT(
330+
{
331+
'sub': testUser.id,
332+
'exp': validExpiry.millisecondsSinceEpoch ~/ 1000,
333+
},
334+
subject: testUser.id,
335+
// No jti
336+
);
337+
final noJtiToken = jwt.sign(
338+
SecretKey(
339+
'your-very-hardcoded-super-secret-key-replace-this-in-prod',
340+
),
341+
);
342+
343+
// Act & Assert
344+
await expectLater(
345+
() => service.invalidateToken(noJtiToken),
346+
throwsA(
347+
isA<InvalidInputException>().having(
348+
(e) => e.message,
349+
'message',
350+
'Cannot invalidate token: Missing or empty JWT ID (jti) claim.',
351+
),
352+
),
353+
);
354+
verifyNever(() => mockBlacklistService.blacklist(any(), any()));
355+
});
356+
357+
test('throws InvalidInputException for token missing "exp" claim',
358+
() async {
359+
// Arrange: Create token without exp
360+
final jwt = JWT(
361+
{'sub': testUser.id, 'jti': testUuidValue},
362+
subject: testUser.id,
363+
jwtId: testUuidValue,
364+
// No exp
365+
);
366+
final noExpToken = jwt.sign(
367+
SecretKey(
368+
'your-very-hardcoded-super-secret-key-replace-this-in-prod',
369+
),
370+
);
371+
372+
// Act & Assert
373+
await expectLater(
374+
() => service.invalidateToken(noExpToken),
375+
throwsA(
376+
isA<InvalidInputException>().having(
377+
(e) => e.message,
378+
'message',
379+
'Cannot invalidate token: Missing or invalid expiry (exp) claim.',
380+
),
381+
),
382+
);
383+
verifyNever(() => mockBlacklistService.blacklist(any(), any()));
384+
});
385+
386+
test('rethrows HtHttpException from blacklist service', () async {
387+
// Arrange
388+
const exception = ServerException('Blacklist database error');
389+
when(() => mockBlacklistService.blacklist(validJti, validExpiry))
390+
.thenThrow(exception);
391+
392+
// Act & Assert
393+
await expectLater(
394+
() => service.invalidateToken(validToken),
395+
throwsA(isA<ServerException>()),
396+
);
397+
verify(
398+
() => mockBlacklistService.blacklist(validJti, validExpiry),
399+
).called(1);
400+
});
401+
402+
test('throws OperationFailedException for unexpected blacklist error',
403+
() async {
404+
// Arrange
405+
final exception = Exception('Unexpected blacklist failure');
406+
when(() => mockBlacklistService.blacklist(validJti, validExpiry))
407+
.thenThrow(exception);
408+
409+
// Act & Assert
410+
await expectLater(
411+
() => service.invalidateToken(validToken),
412+
throwsA(
413+
isA<OperationFailedException>().having(
414+
(e) => e.message,
415+
'message',
416+
contains('Token invalidation failed unexpectedly'),
417+
),
418+
),
419+
);
420+
verify(
421+
() => mockBlacklistService.blacklist(validJti, validExpiry),
422+
).called(1);
423+
});
424+
});
260425
});
261426
}
262-
263-
// Removed the extension trying to access the private secret key.

0 commit comments

Comments
 (0)