diff --git a/.github/workflows/api_client.yaml b/.github/workflows/api_client.yaml new file mode 100644 index 000000000..d81c2e032 --- /dev/null +++ b/.github/workflows/api_client.yaml @@ -0,0 +1,20 @@ +name: api_client + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - "packages/api_client/**" + - ".github/workflows/api_client.yaml" + branches: + - main + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1 + with: + dart_sdk: stable + working_directory: packages/api_client diff --git a/packages/api_client/.gitignore b/packages/api_client/.gitignore new file mode 100644 index 000000000..526da1584 --- /dev/null +++ b/packages/api_client/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/api_client/README.md b/packages/api_client/README.md new file mode 100644 index 000000000..a4b36aab5 --- /dev/null +++ b/packages/api_client/README.md @@ -0,0 +1,62 @@ +# Api Client + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason) +[![License: MIT][license_badge]][license_link] + +Client to access the api. + +## Installation ๐Ÿ’ป + +**โ— In order to start using Api Client you must have the [Dart SDK][dart_install_link] installed on your machine.** + +Install via `dart pub add`: + +```sh +dart pub add api_client +``` + +--- + +## Continuous Integration ๐Ÿค– + +Api Client comes with a built-in [GitHub Actions workflow][github_actions_link] powered by [Very Good Workflows][very_good_workflows_link] but you can also add your preferred CI/CD solution. + +Out of the box, on each pull request and push, the CI `formats`, `lints`, and `tests` the code. This ensures the code remains consistent and behaves correctly as you add functionality or make changes. The project uses [Very Good Analysis][very_good_analysis_link] for a strict set of analysis options used by our team. Code coverage is enforced using the [Very Good Workflows][very_good_coverage_link]. + +--- + +## Running Tests ๐Ÿงช + +To run all unit tests: + +```sh +dart pub global activate coverage 1.2.0 +dart test --coverage=coverage +dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info +``` + +To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov). + +```sh +# Generate Coverage Report +genhtml coverage/lcov.info -o coverage/ + +# Open Coverage Report +open coverage/index.html +``` + +[dart_install_link]: https://dart.dev/get-dart +[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[mason_link]: https://github.com/felangel/mason +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage +[very_good_ventures_link]: https://verygood.ventures +[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only +[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only +[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows diff --git a/packages/api_client/analysis_options.yaml b/packages/api_client/analysis_options.yaml new file mode 100644 index 000000000..799268d3e --- /dev/null +++ b/packages/api_client/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/packages/api_client/coverage_badge.svg b/packages/api_client/coverage_badge.svg new file mode 100644 index 000000000..499e98ce2 --- /dev/null +++ b/packages/api_client/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/packages/api_client/lib/api_client.dart b/packages/api_client/lib/api_client.dart new file mode 100644 index 000000000..fd06ea1f4 --- /dev/null +++ b/packages/api_client/lib/api_client.dart @@ -0,0 +1,7 @@ +/// Client to access the api. +library api_client; + +export 'src/api_client.dart'; +export 'src/errors/api_client_error.dart'; +export 'src/extensions/extensions.dart'; +export 'src/http_calls/http_calls.dart'; diff --git a/packages/api_client/lib/src/api_client.dart b/packages/api_client/lib/src/api_client.dart new file mode 100644 index 000000000..f783cf986 --- /dev/null +++ b/packages/api_client/lib/src/api_client.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:api_client/api_client.dart'; +import 'package:http/http.dart' as http; + +/// {@template api_client} +/// Client to access the api. +/// {@endtemplate} +class ApiClient { + /// {@macro api_client} + ApiClient({ + required String baseUrl, + required Stream idTokenStream, + required Future Function() refreshIdToken, + required Stream appCheckTokenStream, + String? appCheckToken, + PostCall postCall = http.post, + PutCall putCall = http.put, + PatchCall patchCall = http.patch, + GetCall getCall = http.get, + }) : _base = Uri.parse(baseUrl), + _post = postCall, + _put = putCall, + _patch = patchCall, + _get = getCall, + _appCheckToken = appCheckToken, + _refreshIdToken = refreshIdToken { + _idTokenSubscription = idTokenStream.listen((idToken) { + _idToken = idToken; + }); + _appCheckTokenSubscription = appCheckTokenStream.listen((appCheckToken) { + _appCheckToken = appCheckToken; + }); + } + + final Uri _base; + final PostCall _post; + final PostCall _put; + final PatchCall _patch; + final GetCall _get; + final Future Function() _refreshIdToken; + + late final StreamSubscription _idTokenSubscription; + late final StreamSubscription _appCheckTokenSubscription; + String? _idToken; + String? _appCheckToken; + + Map get _headers => { + if (_idToken != null) 'Authorization': 'Bearer $_idToken', + if (_appCheckToken != null) 'X-Firebase-AppCheck': _appCheckToken!, + }; + + Future _handleUnauthorized( + Future Function() sendRequest, + ) async { + final response = await sendRequest(); + + if (response.statusCode == HttpStatus.unauthorized) { + _idToken = await _refreshIdToken(); + return sendRequest(); + } + return response; + } + + /// Dispose of resources used by this client. + Future dispose() async { + await _idTokenSubscription.cancel(); + await _appCheckTokenSubscription.cancel(); + } + + /// Sends a POST request to the specified [path] with the given [body]. + Future post( + String path, { + Object? body, + Map? queryParameters, + }) async { + return _handleUnauthorized(() async { + final response = await _post( + _base.replace( + path: path, + queryParameters: queryParameters, + ), + body: body, + headers: _headers..addContentTypeJson(), + ); + + return response.decrypted; + }); + } + + /// Sends a PATCH request to the specified [path] with the given [body]. + Future patch( + String path, { + Object? body, + Map? queryParameters, + }) async { + return _handleUnauthorized(() async { + final response = await _patch( + _base.replace( + path: path, + queryParameters: queryParameters, + ), + body: body, + headers: _headers..addContentTypeJson(), + ); + + return response.decrypted; + }); + } + + /// Sends a PUT request to the specified [path] with the given [body]. + Future put( + String path, { + Object? body, + }) async { + return _handleUnauthorized(() async { + final response = await _put( + _base.replace(path: path), + body: body, + headers: _headers..addContentTypeJson(), + ); + + return response.decrypted; + }); + } + + /// Sends a GET request to the specified [path]. + Future get( + String path, { + Map? queryParameters, + }) async { + return _handleUnauthorized(() async { + final response = await _get( + _base.replace( + path: path, + queryParameters: queryParameters, + ), + headers: _headers, + ); + + return response.decrypted; + }); + } + + /// Sends a GET request to the specified public [path]. + Future getPublic( + String path, { + Map? queryParameters, + }) async { + return _handleUnauthorized(() async { + final response = await _get( + _base.replace( + path: path, + queryParameters: queryParameters, + ), + headers: _headers, + ); + + return response; + }); + } +} diff --git a/packages/api_client/lib/src/errors/api_client_error.dart b/packages/api_client/lib/src/errors/api_client_error.dart new file mode 100644 index 000000000..901acc60e --- /dev/null +++ b/packages/api_client/lib/src/errors/api_client_error.dart @@ -0,0 +1,23 @@ +/// {@template api_client_error} +/// Error throw when accessing api failed. +/// +/// Check [cause] and [stackTrace] for specific details. +/// {@endtemplate} +class ApiClientError implements Exception { + /// {@macro api_client_error} + ApiClientError(this.cause, this.stackTrace); + + /// Error cause. + final dynamic cause; + + /// The stack trace of the error. + final StackTrace stackTrace; + + @override + String toString() { + return ''' +cause: $cause +stackTrace: $stackTrace +'''; + } +} diff --git a/packages/api_client/lib/src/extensions/extensions.dart b/packages/api_client/lib/src/extensions/extensions.dart new file mode 100644 index 000000000..537b1ed03 --- /dev/null +++ b/packages/api_client/lib/src/extensions/extensions.dart @@ -0,0 +1,2 @@ +export 'json_content_type.dart'; +export 'response_decryption.dart'; diff --git a/packages/api_client/lib/src/extensions/json_content_type.dart b/packages/api_client/lib/src/extensions/json_content_type.dart new file mode 100644 index 000000000..103186710 --- /dev/null +++ b/packages/api_client/lib/src/extensions/json_content_type.dart @@ -0,0 +1,11 @@ +import 'dart:io'; + +/// {@template json_content_type} +/// Extension on [Map] to add content-type value +/// {@endtemplate} +extension JsonContentType on Map { + /// Adds content-type to map + void addContentTypeJson() { + addAll({HttpHeaders.contentTypeHeader: ContentType.json.value}); + } +} diff --git a/packages/api_client/lib/src/extensions/response_decryption.dart b/packages/api_client/lib/src/extensions/response_decryption.dart new file mode 100644 index 000000000..0513e59c5 --- /dev/null +++ b/packages/api_client/lib/src/extensions/response_decryption.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:encrypt/encrypt.dart'; +import 'package:http/http.dart' as http; + +/// {@template response_encryption} +/// Extension on [http.Response] to decrypt body +/// {@endtemplate} +extension ResponseDecryption on http.Response { + /// Get [http.Response] with decrypted body + http.Response get decrypted { + if (body.isEmpty) return this; + + final key = Key.fromUtf8(_encryptionKey); + final iv = IV.fromUtf8(_encryptionIV); + + final encrypter = Encrypter(AES(key)); + + final decrypted = encrypter.decrypt64(body, iv: iv); + + return http.Response( + jsonDecode(decrypted).toString(), + statusCode, + headers: headers, + isRedirect: isRedirect, + persistentConnection: persistentConnection, + reasonPhrase: reasonPhrase, + request: request, + ); + } + + String get _encryptionKey { + const value = String.fromEnvironment( + 'ENCRYPTION_KEY', + // Default value is set at 32 characters to match required length of + // AES key. The default value can then be used for testing purposes. + defaultValue: 'encryption_key_not_set_123456789', + ); + return value; + } + + String get _encryptionIV { + const value = String.fromEnvironment( + 'ENCRYPTION_IV', + // Default value is set at 116 characters to match required length of + // IV key. The default value can then be used for testing purposes. + defaultValue: 'iv_not_set_12345', + ); + return value; + } +} diff --git a/packages/api_client/lib/src/http_calls/http_calls.dart b/packages/api_client/lib/src/http_calls/http_calls.dart new file mode 100644 index 000000000..d7f9423eb --- /dev/null +++ b/packages/api_client/lib/src/http_calls/http_calls.dart @@ -0,0 +1,28 @@ +import 'package:http/http.dart' as http; + +/// Definition of a post call used by this client. +typedef PostCall = Future Function( + Uri, { + Object? body, + Map? headers, +}); + +/// Definition of a patch call used by this client. +typedef PatchCall = Future Function( + Uri, { + Object? body, + Map? headers, +}); + +/// Definition of a put call used by this client. +typedef PutCall = Future Function( + Uri, { + Object? body, + Map? headers, +}); + +/// Definition of a get call used by this client. +typedef GetCall = Future Function( + Uri, { + Map? headers, +}); diff --git a/packages/api_client/pubspec.yaml b/packages/api_client/pubspec.yaml new file mode 100644 index 000000000..fdc809f23 --- /dev/null +++ b/packages/api_client/pubspec.yaml @@ -0,0 +1,17 @@ +name: api_client +description: Client to access the api. +version: 0.1.0+1 +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + +dev_dependencies: + mocktail: ^1.0.0 + test: ^1.19.2 + very_good_analysis: ^5.1.0 + +dependencies: + encrypt: ^5.0.3 + equatable: ^2.0.5 + http: ^1.2.1 \ No newline at end of file diff --git a/packages/api_client/test/src/api_client_test.dart b/packages/api_client/test/src/api_client_test.dart new file mode 100644 index 000000000..462b117a5 --- /dev/null +++ b/packages/api_client/test/src/api_client_test.dart @@ -0,0 +1,539 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:api_client/api_client.dart'; +import 'package:encrypt/encrypt.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockHttpClient extends Mock { + Future get(Uri uri, {Map? headers}); + Future post( + Uri uri, { + Object? body, + Map? headers, + }); + Future patch( + Uri uri, { + Object? body, + Map? headers, + }); + Future put( + Uri uri, { + Object? body, + Map? headers, + }); +} + +void main() { + setUpAll(() { + registerFallbackValue(Uri.parse('http://localhost')); + }); + + group('ApiClient', () { + const baseUrl = 'http://baseurl.com'; + const mockIdToken = 'mockIdToken'; + const mockNewIdToken = 'mockNewIdToken'; + const mockAppCheckToken = 'mockAppCheckToken'; + + // Since the key and iv are set from the environment variables, we can + // reference the default values here. + final key = Key.fromUtf8('encryption_key_not_set_123456789'); + final iv = IV.fromUtf8('iv_not_set_12345'); + final encrypter = Encrypter(AES(key)); + + final testJson = {'data': 'test'}; + + final encrypted = encrypter.encrypt(jsonEncode(testJson), iv: iv).base64; + + final encryptedResponse = http.Response(encrypted, 200); + final expectedResponse = http.Response(testJson.toString(), 200); + + late ApiClient subject; + late _MockHttpClient httpClient; + late StreamController idTokenStreamController; + late StreamController appCheckTokenStreamController; + + Future Function() refreshIdToken = () async => null; + + setUp(() { + httpClient = _MockHttpClient(); + + when( + () => httpClient.get( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => encryptedResponse); + + when( + () => httpClient.post( + any(), + body: any(named: 'body'), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => encryptedResponse); + + when( + () => httpClient.patch( + any(), + body: any(named: 'body'), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => encryptedResponse); + + when( + () => httpClient.put( + any(), + body: any(named: 'body'), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => encryptedResponse); + + idTokenStreamController = StreamController.broadcast(); + appCheckTokenStreamController = StreamController.broadcast(); + + subject = ApiClient( + baseUrl: baseUrl, + getCall: httpClient.get, + postCall: httpClient.post, + patchCall: httpClient.patch, + putCall: httpClient.put, + idTokenStream: idTokenStreamController.stream, + refreshIdToken: () => refreshIdToken(), + appCheckTokenStream: appCheckTokenStreamController.stream, + ); + }); + + test('can be instantiated', () { + expect( + ApiClient( + baseUrl: 'http://localhost', + idTokenStream: Stream.empty(), + refreshIdToken: () async => null, + appCheckTokenStream: Stream.empty(), + ), + isNotNull, + ); + }); + + group('dispose', () { + test('cancels id token stream subscription', () async { + expect(idTokenStreamController.hasListener, isTrue); + expect(appCheckTokenStreamController.hasListener, isTrue); + + await subject.dispose(); + + expect(idTokenStreamController.hasListener, isFalse); + expect(appCheckTokenStreamController.hasListener, isFalse); + }); + }); + + group('get', () { + test('returns the response', () async { + final response = await subject.get('/'); + + expect(response.statusCode, equals(expectedResponse.statusCode)); + expect(response.body, equals(expectedResponse.body)); + }); + + test('sends the request correctly', () async { + await subject.get( + '/path/to/endpoint', + queryParameters: { + 'param1': 'value1', + 'param2': 'value2', + }, + ); + + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'), + headers: {}, + ), + ).called(1); + }); + + test('sends the authentication and app check token', () async { + idTokenStreamController.add(mockIdToken); + appCheckTokenStreamController.add(mockAppCheckToken); + await Future.microtask(() {}); + await subject.get('/path/to/endpoint'); + + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + }, + ), + ).called(1); + }); + + test('refreshes the authentication token when needed', () async { + when( + () => httpClient.get( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => http.Response('', 401)); + + refreshIdToken = () async => mockNewIdToken; + + idTokenStreamController.add(mockIdToken); + appCheckTokenStreamController.add(mockAppCheckToken); + await Future.microtask(() {}); + await subject.get('/path/to/endpoint'); + + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + }, + ), + ).called(1); + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockNewIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + }, + ), + ).called(1); + }); + }); + + group('getPublic', () { + setUp(() { + when( + () => httpClient.get( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => expectedResponse); + }); + test('returns the response', () async { + final response = await subject.getPublic('/'); + + expect(response.statusCode, equals(expectedResponse.statusCode)); + expect(response.body, equals(expectedResponse.body)); + }); + + test('sends the request correctly', () async { + await subject.getPublic( + '/path/to/endpoint', + queryParameters: { + 'param1': 'value1', + 'param2': 'value2', + }, + ); + + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'), + headers: {}, + ), + ).called(1); + }); + + test('sends the authentication and app check token', () async { + idTokenStreamController.add(mockIdToken); + appCheckTokenStreamController.add(mockAppCheckToken); + await Future.microtask(() {}); + await subject.getPublic('/path/to/endpoint'); + + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + }, + ), + ).called(1); + }); + + test('refreshes the authentication token when needed', () async { + when( + () => httpClient.get( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => http.Response('', 401)); + + refreshIdToken = () async => mockNewIdToken; + + idTokenStreamController.add(mockIdToken); + appCheckTokenStreamController.add(mockAppCheckToken); + await Future.microtask(() {}); + await subject.getPublic('/path/to/endpoint'); + + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + }, + ), + ).called(1); + verify( + () => httpClient.get( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockNewIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + }, + ), + ).called(1); + }); + }); + + group('post', () { + test('returns the response', () async { + final response = await subject.post('/'); + + expect(response.statusCode, equals(expectedResponse.statusCode)); + expect(response.body, equals(expectedResponse.body)); + }); + + test('sends the request correctly', () async { + await subject.post( + '/path/to/endpoint', + queryParameters: {'param1': 'value1', 'param2': 'value2'}, + body: 'BODY_CONTENT', + ); + + verify( + () => httpClient.post( + Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'), + body: 'BODY_CONTENT', + headers: {HttpHeaders.contentTypeHeader: ContentType.json.value}, + ), + ).called(1); + }); + + test('sends the authentication and app check token', () async { + idTokenStreamController.add(mockIdToken); + appCheckTokenStreamController.add(mockAppCheckToken); + await Future.microtask(() {}); + await subject.post('/path/to/endpoint'); + + verify( + () => httpClient.post( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + + test('refreshes the authentication token when needed', () async { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => http.Response('', 401)); + + refreshIdToken = () async => mockNewIdToken; + + idTokenStreamController.add(mockIdToken); + appCheckTokenStreamController.add(mockAppCheckToken); + await Future.microtask(() {}); + await subject.post('/path/to/endpoint'); + + verify( + () => httpClient.post( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + verify( + () => httpClient.post( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockNewIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + }); + + group('patch', () { + test('returns the response', () async { + final response = await subject.patch('/'); + + expect(response.statusCode, equals(expectedResponse.statusCode)); + expect(response.body, equals(expectedResponse.body)); + }); + + test('sends the request correctly', () async { + await subject.patch( + '/path/to/endpoint', + queryParameters: {'param1': 'value1', 'param2': 'value2'}, + body: 'BODY_CONTENT', + ); + + verify( + () => httpClient.patch( + Uri.parse('$baseUrl/path/to/endpoint?param1=value1¶m2=value2'), + body: 'BODY_CONTENT', + headers: {HttpHeaders.contentTypeHeader: ContentType.json.value}, + ), + ).called(1); + }); + + test('sends the authentication and app check token', () async { + idTokenStreamController.add(mockIdToken); + appCheckTokenStreamController.add(mockAppCheckToken); + await Future.microtask(() {}); + await subject.patch('/path/to/endpoint'); + + verify( + () => httpClient.patch( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + + test('refreshes the authentication token when needed', () async { + when( + () => httpClient.patch( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => http.Response('', 401)); + + refreshIdToken = () async => mockNewIdToken; + + idTokenStreamController.add(mockIdToken); + appCheckTokenStreamController.add(mockAppCheckToken); + await Future.microtask(() {}); + await subject.patch('/path/to/endpoint'); + + verify( + () => httpClient.patch( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + verify( + () => httpClient.patch( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockNewIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + }); + + group('put', () { + test('returns the response', () async { + final response = await subject.put('/'); + + expect(response.statusCode, equals(expectedResponse.statusCode)); + expect(response.body, equals(expectedResponse.body)); + }); + + test('sends the request correctly', () async { + await subject.put( + '/path/to/endpoint', + body: 'BODY_CONTENT', + ); + + verify( + () => httpClient.put( + Uri.parse('$baseUrl/path/to/endpoint'), + body: 'BODY_CONTENT', + headers: {HttpHeaders.contentTypeHeader: ContentType.json.value}, + ), + ).called(1); + }); + + test('sends the authentication and app check token', () async { + idTokenStreamController.add(mockIdToken); + appCheckTokenStreamController.add(mockAppCheckToken); + await Future.microtask(() {}); + await subject.put('/path/to/endpoint'); + + verify( + () => httpClient.put( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + + test('refreshes the authentication token when needed', () async { + when( + () => httpClient.put( + any(), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => http.Response('', 401)); + + refreshIdToken = () async => mockNewIdToken; + + idTokenStreamController.add(mockIdToken); + appCheckTokenStreamController.add(mockAppCheckToken); + await Future.microtask(() {}); + await subject.put('/path/to/endpoint'); + + verify( + () => httpClient.put( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + verify( + () => httpClient.put( + Uri.parse('$baseUrl/path/to/endpoint'), + headers: { + 'Authorization': 'Bearer $mockNewIdToken', + 'X-Firebase-AppCheck': mockAppCheckToken, + HttpHeaders.contentTypeHeader: ContentType.json.value, + }, + ), + ).called(1); + }); + }); + }); +} diff --git a/packages/api_client/test/src/errors/api_client_error_test.dart b/packages/api_client/test/src/errors/api_client_error_test.dart new file mode 100644 index 000000000..a753d2377 --- /dev/null +++ b/packages/api_client/test/src/errors/api_client_error_test.dart @@ -0,0 +1,16 @@ +import 'package:api_client/src/errors/api_client_error.dart'; +import 'package:test/test.dart'; + +void main() { + group('ApiClientError', () { + test('toString returns the cause', () { + expect( + ApiClientError('Ops', StackTrace.fromString('stackTrace')).toString(), + equals(''' +cause: Ops +stackTrace: stackTrace +'''), + ); + }); + }); +} diff --git a/packages/api_client/test/src/extensions/response_decryption_test.dart b/packages/api_client/test/src/extensions/response_decryption_test.dart new file mode 100644 index 000000000..8c2bb0890 --- /dev/null +++ b/packages/api_client/test/src/extensions/response_decryption_test.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; + +import 'package:api_client/src/extensions/response_decryption.dart'; +import 'package:encrypt/encrypt.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +void main() { + group('ResponseDecryption', () { + test('decrypts correctly', () { + final key = Key.fromUtf8('encryption_key_not_set_123456789'); + final iv = IV.fromUtf8('iv_not_set_12345'); + final encrypter = Encrypter(AES(key)); + final testJson = {'data': 'test'}; + final encrypted = encrypter.encrypt(jsonEncode(testJson), iv: iv).base64; + final encryptedResponse = http.Response(encrypted, 200); + final expectedResponse = http.Response(testJson.toString(), 200); + + expect(encryptedResponse.decrypted.body, equals(expectedResponse.body)); + }); + }); +}