From 3c683394edc39c3b7a29ecf5631ceddda87badc0 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 17 Jul 2024 23:07:56 +0400 Subject: [PATCH] Fix JWT and add test --- lib/src/model/jwt.dart | 55 +++++++++++++++++++++++++++++++------ pubspec.yaml | 1 - test/unit/jwt_test.dart | 55 +++++++++++++++++++++++++++++++++++++ test/unit/spinify_test.dart | 10 ++++++- test/unit_test.dart | 2 ++ 5 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 test/unit/jwt_test.dart diff --git a/lib/src/model/jwt.dart b/lib/src/model/jwt.dart index 7af1bda..e80b577 100644 --- a/lib/src/model/jwt.dart +++ b/lib/src/model/jwt.dart @@ -236,6 +236,9 @@ sealed class SpinifyJWT { /// Creates JWT from [secret] (with HMAC-SHA256 algorithm) /// and current payload. String encode(String secret); + + /// Creates a JSON representation of payload. + Map toJson(); } final class _SpinifyJWTImpl extends SpinifyJWT { @@ -256,28 +259,30 @@ final class _SpinifyJWTImpl extends SpinifyJWT { }) : super._(); factory _SpinifyJWTImpl.decode(String jwt, [String? secret]) { - // Разделение токена на составляющие части + // Split token into parts var parts = jwt.split('.'); if (parts.length != 3) { + // coverage:ignore-line throw const FormatException( 'Invalid token format, expected 3 parts separated by "."'); } final [encodedHeader, encodedPayload, encodedSignature] = parts; if (secret != null) { - // Вычисление подписи + // Compute signature final key = utf8.encode(secret); // Your 256 bit secret key final bytes = utf8.encode('$encodedHeader.$encodedPayload'); - var hmacSha256 = Hmac(sha256, key); // HMAC-SHA256 - var digest = hmacSha256.convert(bytes); - - // Кодирование подписи - var computedSignature = base64Url.encode(digest.bytes); + final hmacSha256 = Hmac(sha256, key); // HMAC-SHA256 + final digest = hmacSha256.convert(bytes); + final computedSignature = const _UnpaddedBase64Converter() + .convert(base64Url.encode(digest.bytes)); - // Сравнение подписи в токене с вычисленной подписью + // Check signature equality + // coverage:ignore-start if (computedSignature != encodedSignature) { throw const FormatException('Invalid token signature'); } + // coverage:ignore-end } Map payload; @@ -286,10 +291,12 @@ final class _SpinifyJWTImpl extends SpinifyJWT { .fuse(const Utf8Decoder()) .fuse>( const JsonDecoder().cast>()) - .convert(encodedPayload); + .convert(const _NormilizeBase64Converter().convert(encodedPayload)); } on Object catch (_, stackTrace) { + // coverage:ignore-start Error.throwWithStackTrace( const FormatException('Can\'t decode token payload'), stackTrace); + // coverage:ignore-end } try { return _SpinifyJWTImpl( @@ -310,8 +317,10 @@ final class _SpinifyJWTImpl extends SpinifyJWT { expireAt: payload['expire_at'] as int?, ); } on Object catch (_, stackTrace) { + // coverage:ignore-start Error.throwWithStackTrace( const FormatException('Invalid token payload data'), stackTrace); + // coverage:ignore-end } } @@ -402,6 +411,23 @@ final class _SpinifyJWTImpl extends SpinifyJWT { return '$encodedHeader.$encodedPayload.$encodedSignature'; } + @override + Map toJson() => { + 'sub': sub, + if (channel != null) 'channel': channel, + if (exp != null) 'exp': exp, + if (iat != null) 'iat': iat, + if (jti != null) 'jti': jti, + if (aud != null) 'aud': aud, + if (iss != null) 'iss': iss, + if (info != null) 'info': info, + if (b64info != null) 'b64info': b64info, + if (channels != null) 'channels': channels, + if (subs != null) 'subs': subs, + if (meta != null) 'meta': meta, + if (expireAt != null) 'expireAt': expireAt, + }; + @override String toString() => 'SpinifyJWT{sub: $sub}'; } @@ -418,3 +444,14 @@ class _UnpaddedBase64Converter extends Converter { return input; } } + +/// A converter thats normalizes Base64-encoded strings +class _NormilizeBase64Converter extends Converter { + const _NormilizeBase64Converter(); + + @override + String convert(String input) { + final padding = (4 - input.length % 4) % 4; + return input + '=' * padding; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1e2c29e..301a423 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,7 +56,6 @@ dependencies: fixnum: ^1.1.0 stack_trace: ^1.11.0 - dev_dependencies: build_runner: ^2.4.6 pubspec_generator: ^4.0.0 diff --git a/test/unit/jwt_test.dart b/test/unit/jwt_test.dart new file mode 100644 index 0000000..c7a3313 --- /dev/null +++ b/test/unit/jwt_test.dart @@ -0,0 +1,55 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:spinify/spinify.dart'; +import 'package:test/test.dart'; + +void main() { + group('JWT', () { + test('Create', () { + expect(() => SpinifyJWT(sub: 'sub'), returnsNormally); + }); + + test('Encode_and_decode', () { + final jwt = SpinifyJWT( + sub: 'sub', + channel: 'channel', + iat: 1234567890, + exp: 1234567890, + iss: 'iss', + aud: 'aud', + jti: 'jti', + expireAt: 1234567890, + b64info: 'b64info', + channels: const [ + 'channel1', + 'channel2', + ], + subs: const {}, + info: const {}, + meta: const {}, + ); + expect(jwt, isA()); + expect(jwt.toString(), equals('SpinifyJWT{sub: ${jwt.sub}}')); + final encoded = jwt.encode('secret'); + expect(encoded, isA()); + final decoded = SpinifyJWT.decode(encoded, 'secret'); + expect( + decoded, + isA() + .having((e) => e.sub, 'sub', jwt.sub) + .having((e) => e.channel, 'channel', jwt.channel) + .having((e) => e.iat, 'iat', jwt.iat) + .having((e) => e.exp, 'exp', jwt.exp) + .having((e) => e.iss, 'iss', jwt.iss) + .having((e) => e.aud, 'aud', jwt.aud) + .having((e) => e.jti, 'jti', jwt.jti) + .having((e) => e.expireAt, 'expireAt', jwt.expireAt) + .having((e) => e.b64info, 'b64info', jwt.b64info) + .having((e) => e.channels, 'channels', jwt.channels) + .having((e) => e.subs, 'subs', jwt.subs), + ); + expect(decoded.toJson(), equals(jwt.toJson())); + expect(decoded.toString(), equals(jwt.toString())); + }); + }); +} diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index d776121..1acb051 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -234,7 +234,9 @@ void main() { greaterThan(Int64.ZERO), ), ])); - client.close(); + client + ..newSubscription('channel') + ..close(); async.elapse(client.config.timeout); expect( client.metrics, @@ -261,8 +263,14 @@ void main() { ), ])); expect(() => client.metrics.toString(), returnsNormally); + expect(client.metrics.toString(), equals('SpinifyMetrics{}')); expect(() => client.metrics.toJson(), returnsNormally); expect(client.metrics.toJson(), isA>()); + expect(client.metrics.channels, hasLength(2)); + expect( + client.metrics.channels['channel'], + isA().having((c) => c.toString(), + 'subscriptions', equals(r'SpinifyMetrics$Channel{}'))); })); }); } diff --git a/test/unit_test.dart b/test/unit_test.dart index 64fb108..8e1ddd3 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -1,6 +1,7 @@ import 'package:test/test.dart'; import 'unit/config_test.dart' as config_test; +import 'unit/jwt_test.dart' as jwt_test; import 'unit/logs_test.dart' as logs_test; import 'unit/server_subscription_test.dart' as server_subscription_test; import 'unit/spinify_test.dart' as spinify_test; @@ -11,5 +12,6 @@ void main() { config_test.main(); spinify_test.main(); server_subscription_test.main(); + jwt_test.main(); }); }